diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..bde27e0 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,13 @@ +name: rust + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + - run: cargo build --release + working-directory: ./rust/tsnet \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3812672..165d88c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ libtailscale_*.h /sourcepkg/libtailscale /sourcepkg/libtailscale.tar* +/rust/tsnet/target /vendor/ diff --git a/rust/tsnet/Cargo.lock b/rust/tsnet/Cargo.lock new file mode 100644 index 0000000..bcef8cb --- /dev/null +++ b/rust/tsnet/Cargo.lock @@ -0,0 +1,467 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "errno" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi", +] + +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "libc" +version = "0.2.172" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" + +[[package]] +name = "libloading" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a793df0d7afeac54f95b471d3af7f0d4fb975699f972341a4b76988d49cdf0c" +dependencies = [ + "cfg-if", + "windows-targets 0.53.0", +] + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "prettyplease" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "664ec5419c51e34154eec046ebcba56312d5a2fc3b09a06da188e1ad21afadf6" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "syn" +version = "2.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "tsnet" +version = "0.1.0" +dependencies = [ + "bindgen", + "convert_case", + "libc", + "tempfile", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] diff --git a/rust/tsnet/Cargo.toml b/rust/tsnet/Cargo.toml new file mode 100644 index 0000000..20fbb36 --- /dev/null +++ b/rust/tsnet/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "tsnet" +version = "0.1.0" +edition = "2024" +description = "Rust bindings for libtailscale" +license = "BSD-3-Clause" +repository = "https://github.com/tailscale/libtailscale" + +[dependencies] +libc = "0.2" + +[build-dependencies] +bindgen = "0.71.1" +convert_case = "0.6" + +[dev-dependencies] +tempfile = "3.20.0" diff --git a/rust/tsnet/build.rs b/rust/tsnet/build.rs new file mode 100644 index 0000000..3b36cd0 --- /dev/null +++ b/rust/tsnet/build.rs @@ -0,0 +1,73 @@ +use bindgen::callbacks::ParseCallbacks; +use std::env; +use std::path::PathBuf; +use std::process::Command; + +#[derive(Debug)] +struct RenameItems; + +impl ParseCallbacks for RenameItems { + fn item_name(&self, original_name: &str) -> Option { + // Hardcode known type names to rename to UpperCamelCase + match original_name { + "tailscale" => Some("TailscaleBinding".to_string()), + "tailscale_conn" => Some("TailscaleConnBinding".to_string()), + "tailscale_listener" => Some("TailscaleListenerBinding".to_string()), + _ => None, + } + } +} + +fn main() { + // Path to the libtailscale submodule + let project_root = "../../"; + let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()); + + // Ensure the submodule is initialized and updated + let status = Command::new("git") + .args(&["submodule", "update", "--init", "--recursive"]) + .status() + .expect("Failed to run git submodule update"); + if !status.success() { + panic!("Failed to update submodules"); + } + + // Build libtailscale.a using Makefile + let status = Command::new("make") + .arg("c-archive") + .current_dir(project_root) + .status() + .expect("Failed to execute make c-archive"); + if !status.success() { + panic!("Failed to build libtailscale"); + } + + // Tell Cargo to link the static library and macOS frameworks + println!("cargo:rustc-link-lib=static=tailscale"); + println!("cargo:rustc-link-search=native={}", project_root); + + // macos specific + #[cfg(target_os = "macos")] + { + println!("cargo:rustc-link-lib=framework=CoreFoundation"); + println!("cargo:rustc-link-lib=framework=Security"); + println!("cargo:rustc-link-lib=framework=IOKit"); + println!("cargo:rustc-link-arg=-mmacosx-version-min=15.4"); + } + + // Trigger rebuild if libtailscale.a changes + println!("cargo:rerun-if-changed={}/libtailscale.a", project_root); + + // Generate bindings using bindgen + let bindings = bindgen::Builder::default() + .header(format!("{}/tailscale.h", project_root)) + .allowlist_function("tailscale_.*") + .rustified_enum(".*") + .parse_callbacks(Box::new(RenameItems)) + .generate() + .expect("Unable to generate bindings for tailscale.h"); + + bindings + .write_to_file(out_path.join("bindings.rs")) + .expect("Couldn't write bindings to output directory"); +} diff --git a/rust/tsnet/examples/echo.rs b/rust/tsnet/examples/echo.rs new file mode 100644 index 0000000..f335a86 --- /dev/null +++ b/rust/tsnet/examples/echo.rs @@ -0,0 +1,35 @@ +use std::{ + io::{Read, Write}, + net::TcpStream, + os::fd::AsFd, +}; +use tsnet::TSNet; + +fn main() -> Result<(), String> { + let config = tsnet::ConfigBuilder::new().ephemeral(true).build()?; + + let mut ts = TSNet::new(config)?; + ts.up()?; + + let listener = ts.listen("tcp", ":1999")?; + + loop { + let conn = ts.accept(listener.as_fd()).unwrap(); + let remote_addr = ts.get_remote_addr(conn.as_fd(), listener.as_fd()).unwrap(); + let mut stream = TcpStream::from(conn); + let mut buf = [0; 1024]; + + println!("connection from: {}", remote_addr); + + while let Ok(n) = stream.read(&mut buf) { + if n == 0 { + break; + } + stream.write_all(&buf[..n]).unwrap(); + stream.flush().unwrap(); + } + + drop(stream); + return Ok(()); + } +} diff --git a/rust/tsnet/src/lib.rs b/rust/tsnet/src/lib.rs new file mode 100644 index 0000000..e76bcda --- /dev/null +++ b/rust/tsnet/src/lib.rs @@ -0,0 +1,552 @@ +use bindings::{TailscaleBinding, TailscaleConnBinding, TailscaleListenerBinding}; +use std::{ + ffi::{c_char, CStr, CString}, + os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd}, +}; + +/// Raw bindings for libtailscale +mod bindings { + include!(concat!(env!("OUT_DIR"), "/bindings.rs")); +} + +const ERANGE: i32 = 34; +const EBADF: i32 = 9; +const INET6_ADDRSTRLEN: usize = 46; + +/// A TailscaleListener is a socket on the tailnet listening for connections. +/// +/// It is much like allocating a system socket(2) and calling listen(2). +/// Accept connections with tailscale_accept and close the listener with close. +/// +/// Under the hood, a tailscale_listener is one half of a socketpair itself, +/// used to move the connection fd from Go to C. This means you can use epoll +/// or its equivalent on a tailscale_listener to know if there is a connection +/// read to accept. +// Define TailscaleListenerBinding based on platform +pub type TailscaleListener = OwnedFd; + +/// A TailscaleConnection is a connection to an address on the tailnet. +/// +/// It is a pipe(2) on which you can use read(2), write(2), and close(2). +pub type TailscaleConnection = OwnedFd; + +/// Represents a Tailscale server instance +pub struct TSNet { + server: TailscaleBinding, +} + +/// Optional parameters that, if needed, +/// must be set before any explicit or implicit call to tailscale_start. +pub struct Config { + dir: Option, + hostname: Option, + authkey: Option, + control_url: Option, + ephemeral: bool, + logging_fd: i32, +} + +/// A convenience builder to help create a new TSNet instance. +/// +/// ``` +/// let config = tailscale::ConfigBuilder::new() +/// .dir(state_dir) +/// .ephemeral(true) +/// .hostname("rust-example") +/// .build()?; +/// +/// let mut ts = TSNet::new(config)?; +/// ``` +pub struct ConfigBuilder { + dir: Option, + hostname: Option, + authkey: Option, + control_url: Option, + ephemeral: bool, + logging_fd: i32, +} + +impl ConfigBuilder { + pub fn new() -> Self { + ConfigBuilder { + dir: None, + hostname: None, + authkey: None, + control_url: None, + ephemeral: false, + logging_fd: -1, + } + } + + pub fn dir(mut self, dir: &str) -> Self { + self.dir = Some(dir.to_string()); + self + } + + pub fn hostname(mut self, hostname: &str) -> Self { + self.hostname = Some(hostname.to_string()); + self + } + + pub fn authkey(mut self, authkey: &str) -> Self { + self.authkey = Some(authkey.to_string()); + self + } + + pub fn control_url(mut self, control_url: &str) -> Self { + self.control_url = Some(control_url.to_string()); + self + } + + pub fn ephemeral(mut self, ephemeral: bool) -> Self { + self.ephemeral = ephemeral; + self + } + + pub fn logging_fd(mut self, logging_fd: i32) -> Self { + self.logging_fd = logging_fd; + self + } + + pub fn build(self) -> Result { + Ok(Config { + dir: self.dir, + hostname: self.hostname, + authkey: self.authkey, + control_url: self.control_url, + ephemeral: self.ephemeral, + logging_fd: self.logging_fd, + }) + } +} + +impl TSNet { + /// Creates a new Tailscale server instance + /// + /// No network connection is initialized until start is called. + pub fn new(config: Config) -> Result { + let server = unsafe { bindings::tailscale_new() }; + + if let Some(authkey) = config.authkey { + set_auth_key(server, &authkey)?; + } + + if let Some(dir) = config.dir { + set_dir(server, &dir)?; + } + + if let Some(hostname) = config.hostname { + set_hostname(server, &hostname)?; + } + + if let Some(control_url) = config.control_url { + set_control_url(server, &control_url)?; + } + + if config.ephemeral { + set_ephemeral(server, config.ephemeral)?; + } + + set_log_fd(server, config.logging_fd)?; + + Ok(Self { server }) + } + + /// Connects the server to the tailnet and waits for it to be usable. + /// To cancel an in-progress call to up, use `close`. + pub fn up(&mut self) -> Result<(), String> { + let result = unsafe { bindings::tailscale_up(self.server) }; + + if result != 0 { + Err(tailscale_error_msg(self.server)?) + } else { + Ok(()) + } + } + + /// Shuts down the server. + /// The server is automatically closed when the TSNet instance is dropped. + /// This method is provided for completeness. + pub fn close(&mut self) -> Result<(), String> { + let result = unsafe { bindings::tailscale_close(self.server) }; + + if result != 0 { + Err(tailscale_error_msg(self.server)?) + } else { + Ok(()) + } + } + + /// Connects the server to the tailnet. + /// Calling this function is optional as it will be called by the first use + /// of listen or dial on a server. + /// + /// See also `up` + pub fn start(&mut self) -> Result<(), String> { + let result = unsafe { bindings::tailscale_start(self.server) }; + + if result != 0 { + Err(tailscale_error_msg(self.server)?) + } else { + Ok(()) + } + } + + /// Returns the IP addresses of the the Tailscale server as + /// a comma separated list. + /// + /// The provided buffer must be of sufficient size to hold the concatenated + /// IPs as strings. This is typically , but maybe empty, or + /// contain any number of ips. The caller is responsible for parsing + /// the output. You may assume the output is a list of well-formed IPs. + pub fn get_ips(&self, ip_buffer_size: Option) -> Result { + let buffer_size = ip_buffer_size.unwrap_or(2048); + let mut buffer = vec![0u8; buffer_size]; + + let result = unsafe { + bindings::tailscale_getips(self.server, buffer.as_mut_ptr() as *mut c_char, buffer_size) + }; + + if result == EBADF || result == ERANGE { + return Err(tailscale_error_msg(self.server)?); + } + + let c_str = unsafe { CStr::from_ptr(buffer.as_ptr() as *const c_char) }; + let ip_string = c_str + .to_str() + .map_err(|e| format!("Invalid UTF-8 in IP string: {}", e))? + .to_string(); + + Ok(ip_string) + } + + /// Listens for a connection on the tailnet. + /// + /// It is the spiritual equivalent to listen(2). + /// Returns the newly allocated listener. + /// + /// network is a string of the form "tcp", "udp", etc. + /// addr is a string of an IP address or domain name. + /// + /// Calls `start` if the server has not yet been started. + /// + /// Listen on a specific interface + /// ``` + /// let mut ts = TSNet::new(config)?; + /// ts.listen("tcp", "127.0.0.1:8080")?; + /// ``` + /// Listen on all interfaces + /// ``` + /// let mut ts = TSNet::new(config)?; + /// ts.listen("tcp", ":8080")?; + /// ``` + pub fn listen(&self, network: &str, addr: &str) -> Result { + let server = self.server; + let network = CString::new(network).map_err(|e| e.to_string())?; + let addr = CString::new(addr).map_err(|e| e.to_string())?; + let mut listener_out: TailscaleListenerBinding = -1; + + let result = unsafe { + bindings::tailscale_listen(server, network.as_ptr(), addr.as_ptr(), &mut listener_out) + }; + + if result != 0 { + return Err(tailscale_error_msg(server)?); + } + + Ok(unsafe { OwnedFd::from_raw_fd(listener_out) }) + } + + /// tailscale_accept accepts a connection on a tailscale_listener. + /// + /// It is the spiritual equivalent to accept(2). + /// + /// The newly allocated connection is written to conn_out. + pub fn accept(&self, listener: BorrowedFd) -> Result { + let mut conn_out: i32 = -1; + let result = unsafe { bindings::tailscale_accept(listener.as_raw_fd(), &mut conn_out) }; + + if result != 0 { + return Err(tailscale_error_msg(self.server)?); + } + + Ok(unsafe { OwnedFd::from_raw_fd(conn_out) }) + } + + /// Connects to the address on the tailnet. + /// + /// network is a string of the form "tcp", "udp", etc. + /// addr is a string of an IP address or domain name. + /// + /// It will start the server if it has not been started yet. + pub fn dial(&self, network: &str, addr: &str) -> Result { + let network = CString::new(network).map_err(|e| e.to_string())?; + let addr = CString::new(addr).map_err(|e| e.to_string())?; + let mut conn_out: TailscaleConnBinding = -1; + let result = unsafe { + bindings::tailscale_dial(self.server, network.as_ptr(), addr.as_ptr(), &mut conn_out) + }; + if result != 0 { + return Err(tailscale_error_msg(self.server)?); + } + Ok(unsafe { OwnedFd::from_raw_fd(conn_out) }) + } + + /// Returns the remote address (either ip4 or ip6) + /// for an incoming connection for a particular listener. + /// ``` + /// let listener = ts.listen("tcp", ":1999")?; + /// let (conn, mut stream) = ts.accept(listener).unwrap(); + /// let remote_addr = ts.get_remote_addr(conn, listener).unwrap(); + /// ``` + pub fn get_remote_addr( + &self, + conn: BorrowedFd, + listener: BorrowedFd, + ) -> Result { + let server = self.server; + let mut addr_out: [c_char; INET6_ADDRSTRLEN] = [0; INET6_ADDRSTRLEN]; + let fd = conn.as_fd(); + let result = unsafe { + bindings::tailscale_getremoteaddr( + listener.as_raw_fd(), + fd.as_raw_fd(), + addr_out.as_mut_ptr(), + addr_out.len(), + ) + }; + if result != 0 { + return Err(tailscale_error_msg(server)?); + } + + let c_str = unsafe { CStr::from_ptr(addr_out.as_ptr() as *const c_char) }; + let addr_string = c_str + .to_str() + .map_err(|e| format!("Invalid UTF-8 in IP string: {}", e))? + .to_string(); + Ok(addr_string) + } + + /// Starts a loopback address server. + /// + /// The server has multiple functions. + /// + /// It can be used as a SOCKS5 proxy onto the tailnet. + /// Authentication is required with the username "tsnet" and + /// the value of proxy_cred used as the password. + /// + /// The HTTP server also serves out the "LocalAPI" on /localapi. + /// As the LocalAPI is powerful, access to endpoints requires BOTH passing a + /// "Sec-Tailscale: localapi" HTTP header and passing local_api_cred as + /// the basic auth password. + /// + /// The pointers proxy_cred_out and local_api_cred_out must be non-NIL + /// and point to arrays that can hold 33 bytes. The first 32 bytes are + /// the credential and the final byte is a NUL terminator. + /// + /// If tailscale_loopback returns, then addr_our, proxy_cred_out, + /// and local_api_cred_out are all NUL-terminated. + /// + /// Returns the address and credentials for the proxy and local API. + /// (address, proxy_cred, local_api_cred) + /// + /// ``` + /// let (proxy_cred, local_api_cred) = ts.loopback("127.0.0.1:1999")?; + /// ``` + pub fn loopback(&self) -> Result<(String, String, String), String> { + let mut address_out = [0; 33]; + let mut proxy_cred_out = [0; 33]; + let mut local_api_cred_out = [0; 33]; + + let result = unsafe { + bindings::tailscale_loopback( + self.server, + address_out.as_mut_ptr(), + address_out.len(), + proxy_cred_out.as_mut_ptr(), + local_api_cred_out.as_mut_ptr(), + ) + }; + if result != 0 { + return Err(tailscale_error_msg(self.server)?); + } + + let address_out = unsafe { CStr::from_ptr(address_out.as_ptr() as *const c_char) }; + let proxy_cred_out = unsafe { CStr::from_ptr(proxy_cred_out.as_ptr() as *const c_char) }; + let local_api_cred_out = + unsafe { CStr::from_ptr(local_api_cred_out.as_ptr() as *const c_char) }; + + Ok(( + address_out.to_string_lossy().into_owned(), + proxy_cred_out.to_string_lossy().into_owned(), + local_api_cred_out.to_string_lossy().into_owned(), + )) + } + + /// Configures the server to have Tailscale Funnel enabled, + /// routing requests from the public web + /// (without any authentication) down to this Tailscale node, requesting new + /// LetsEncrypt TLS certs as needed, terminating TLS, and proxying all incoming + /// HTTPS requests to http:///127.0.0.1:localhostPort without TLS. + /// + /// There should be a plaintext HTTP/1 server listening on 127.0.0.1:localhostPort + /// or tsnet will serve HTTP 502 errors. + /// + /// Expect junk traffic from the internet from bots watching the public CT logs. + pub fn enable_funnel_to_localhost_plaintext_http1(&self, port: i32) -> Result<(), String> { + let result = unsafe { + bindings::tailscale_enable_funnel_to_localhost_plaintext_http1(self.server, port) + }; + if result != 0 { + return Err(tailscale_error_msg(self.server)?); + } + + Ok(()) + } +} + +/// Drop the TSNet instance +impl Drop for TSNet { + fn drop(&mut self) { + unsafe { + let result = bindings::tailscale_close(self.server); + if result != 0 { + println!("Failed to close Tailscale server: {}", result); + } + } + } +} + +/// This setting lets you set an auth key so that your program will automatically authenticate +/// with the Tailscale control plane. By default it pulls from the environment variable TS_AUTHKEY, +/// but you can set your own logic like this: +/// ``` +/// let config = tailscale::ConfigBuilder::new() +/// .authkey(&key) +/// .build()?; +/// +/// let mut ts = TSNet::new(config)?; +/// ``` +/// +fn set_auth_key(server: TailscaleBinding, key: &str) -> Result<(), String> { + let key_cstr = CString::new(key).map_err(|e| e.to_string())?; + let result = unsafe { bindings::tailscale_set_authkey(server, key_cstr.as_ptr()) }; + if result != 0 { + return Err(tailscale_error_msg(server)?); + } + Ok(()) +} + +/// This setting lets you control the directory that the tsnet.Server stores data in persistently. +/// By default,tsnet will store data in your user configuration directory based on the name of the binary. +/// Note that this folder must already exist or tsnet calls will fail. +/// Here is how to override this to store data in /data/tsnet: +/// ``` +/// let config = tailscale::ConfigBuilder::new() +/// .dir("/data/tsnet") +/// .build()?; +/// +/// let mut ts = TSNet::new(config)?; +/// ``` +/// +fn set_dir(server: TailscaleBinding, dir: &str) -> Result<(), String> { + let dir_cstr = CString::new(dir).map_err(|e| e.to_string())?; + let result = unsafe { bindings::tailscale_set_dir(server, dir_cstr.as_ptr()) }; + if result != 0 { + return Err(tailscale_error_msg(server)?); + } + Ok(()) +} + +/// This setting lets you control the host name of your program in your tailnet. +/// By default, this will be the name of your program, +/// such as foo for a program stored at /usr/local/bin/foo. +/// You can also override this by setting the Hostname field: +/// ``` +/// let config = tailscale::ConfigBuilder::new() +/// .hostname("rust-example") +/// .build()?; +/// +/// let mut ts = TSNet::new(config)?; +/// ``` +/// +fn set_hostname(server: TailscaleBinding, hostname: &str) -> Result<(), String> { + let hostname_cstr = CString::new(hostname).map_err(|e| e.to_string())?; + let result = unsafe { bindings::tailscale_set_hostname(server, hostname_cstr.as_ptr()) }; + if result != 0 { + return Err(tailscale_error_msg(server)?); + } + Ok(()) +} + +/// This setting lets you control whether the node should be registered as an ephemeral node. +/// Ephemeral nodes are automatically cleaned up after they disconnect from the control plane. +/// This is useful when using tsnet in serverless environments or when facts +/// and circumstances forbid you from using persistent state. +/// ``` +/// let config = tailscale::ConfigBuilder::new() +/// .ephemeral(true) +/// .build()?; +/// +/// let mut ts = TSNet::new(config)?; +/// ``` +/// +fn set_ephemeral(server: TailscaleBinding, ephemeral: bool) -> Result<(), String> { + let result = unsafe { bindings::tailscale_set_ephemeral(server, ephemeral as i32) }; + if result != 0 { + return Err(tailscale_error_msg(server)?); + } + Ok(()) +} +/// This setting specifies the coordination server URL. +/// If empty, the Tailscale default is used. +/// ``` +/// let config = tailscale::ConfigBuilder::new() +/// .control_url("https://controlplane.tailscale.com") +/// .build()?; +/// +/// let mut ts = TSNet::new(config)?; +/// ``` +/// +fn set_control_url(server: TailscaleBinding, control_url: &str) -> Result<(), String> { + let control_url_cstr = CString::new(control_url).map_err(|e| e.to_string())?; + let result = unsafe { bindings::tailscale_set_control_url(server, control_url_cstr.as_ptr()) }; + if result != 0 { + return Err(tailscale_error_msg(server)?); + } + Ok(()) +} + +/// Instructs the tailscale instance to write logs to fd. +/// +/// An fd value of -1 means discard all logging. +/// ``` +/// let config = tailscale::ConfigBuilder::new() +/// .logging_fd(-1) +/// .build()?; +/// +/// let mut ts = TSNet::new(config)?; +/// ``` +/// +fn set_log_fd(server: TailscaleBinding, fd: i32) -> Result<(), String> { + let result = unsafe { bindings::tailscale_set_logfd(server, fd) }; + if result != 0 { + return Err(tailscale_error_msg(server)?); + } + Ok(()) +} + +/// Get the last error message from the tailscale instance +fn tailscale_error_msg(server: TailscaleBinding) -> Result { + let mut buffer = vec![0u8; 2048]; + let result = unsafe { + bindings::tailscale_errmsg(server, buffer.as_mut_ptr() as *mut c_char, buffer.len()) + }; + + if result != 0 { + return Err("Unknown error".to_string()); + } + + let message = unsafe { CStr::from_ptr(buffer.as_ptr() as *const c_char) }; + Ok(message.to_str().unwrap().to_string()) +}