diff --git a/README.md b/README.md index 0aea736..496f721 100644 --- a/README.md +++ b/README.md @@ -478,6 +478,7 @@ The original IP packet, including its DF bit, is preserved. See | `setcap cap_net_admin+p` | ordinary user, one capability | recommended: nothing to prepare, nothing left behind. Lost on every rebuild or copy of the binary. | | systemd service | `User=`, `AmbientCapabilities=CAP_NET_ADMIN` | the same, for an installed service | | `sudo tsunagi up` | root | everything works, nothing is isolated | +| macOS root LaunchDaemon | root | the native macOS deployment; see below | | `--no-tun` | ordinary user, no capabilities | tunnels run and handshake, traffic never reaches the OS | On Windows, put `wintun.dll` beside the executable and start `tsunagi up` @@ -489,9 +490,24 @@ of claiming the agent is absent or trying to edit its locked state. Creating a TUN without sufficient privileges also explains how to restart the agent with the required permissions. -**Not implemented yet.** macOS has no provisioner; `--no-tun` is the way to -run it. The control plane and tunnels are unaffected. The decision logic -that says *what* to change is shared and tested on every platform. +On macOS the agent creates a `utun` interface, configures it with `ifconfig` +and `route`, and points the system resolver at the overlay by writing +`/etc/resolver/` files (which carry a port, so the DNS server needs no +privileged socket). All of this needs **root**: macOS has no per-capability +grant like Linux, and a `utun` plus `/etc/resolver` are root-only. The kernel +assigns the interface its `utunN` name, so the name derived from the network id +is only a request and the real one is adopted once the interface is up. + +Run it as root — `sudo tsunagi up` for a quick start, or install it as a root +**LaunchDaemon** for the native always-on deployment. As a daemon the agent runs +unprivileged commands (`join`, `status`, `dns`, `network`) over its control +socket like any other platform; the socket lives at `/var/run/tsunagi/agent.sock` +and the stores at `/var/db/tsunagi`, both fixed system paths so they do not +depend on whose shell started `sudo`. A ready-to-edit plist and install notes +are in [dist/macos/](dist/macos/). The broadcast route is installed; the +inbound-UDP firewall allowance (a `pf` anchor) is not yet, so `status` reports +that half as incomplete and names what to allow by hand. The decision logic that +says *what* to change is shared and tested on every platform. ## Checks diff --git a/crates/tsunagi-cli/src/main.rs b/crates/tsunagi-cli/src/main.rs index 01856d3..88f78cb 100644 --- a/crates/tsunagi-cli/src/main.rs +++ b/crates/tsunagi-cli/src/main.rs @@ -958,7 +958,11 @@ fn dns_publisher() -> Arc { { Arc::new(tsunagi::dns::publish::NrptPublisher::new()) } - #[cfg(not(any(target_os = "linux", target_os = "windows")))] + #[cfg(target_os = "macos")] + { + Arc::new(tsunagi::dns::publish::ResolverDirPublisher::new()) + } + #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] { Arc::new(tsunagi::dns::publish::UnsupportedPublisher::new()) } @@ -4253,11 +4257,24 @@ fn system_tun_factory() -> Result, Box Result, Box> { + use tsunagi::overlay::{MacosHostRules, ManagedTunFactory, UtunProvisioner}; + let provisioner = UtunProvisioner::new()?; + Ok(Arc::new( + ManagedTunFactory::new(Arc::new(provisioner)) + .with_host_rules(Arc::new(MacosHostRules::new())), + )) +} + /// There is no provisioner for this platform yet. /// /// Refused here rather than at the first packet, and with the one thing that /// does work on every platform named. -#[cfg(not(any(target_os = "linux", target_os = "windows")))] +#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] fn system_tun_factory() -> Result, Box> { Err(format!( "managing the overlay interface is not implemented on {} yet. \ diff --git a/crates/tsunagi/src/config.rs b/crates/tsunagi/src/config.rs index 7462282..5193f87 100644 --- a/crates/tsunagi/src/config.rs +++ b/crates/tsunagi/src/config.rs @@ -12,9 +12,13 @@ use std::time::Duration; use crate::dataplane::SharedPlugin; use crate::discovery::{MainlineDiscovery, NetworkDiscovery}; -use crate::error::{Error, Result}; +use crate::error::Result; /// Qualifier/organisation/application triple used for platform directories. +/// +/// Unused on macOS, where the default paths are fixed system locations rather +/// than per-user directories derived from this. +#[cfg_attr(target_os = "macos", allow(dead_code))] const APP_NAME: &str = "tsunagi"; /// Rendezvous slots per network; this bounds a discovery sample, not membership. @@ -74,17 +78,34 @@ impl StoragePaths { } } - /// The per-user platform directories. + /// The default platform directories. /// - /// A future system service can supply its own paths instead. + /// Per user on Linux and Windows. On macOS the agent runs as a root + /// LaunchDaemon (creating a utun interface needs root, and `$HOME` under + /// `sudo` points at the wrong place), so the stores live in the system + /// locations instead — `/var/db/tsunagi` for the mandatory state and a + /// disposable cache beside it. A caller that wants somewhere else passes + /// explicit paths to [`StoragePaths::new`] or [`StoragePaths::under`]. pub fn user_default() -> Result { - let dirs = directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| { - Error::Storage("no valid home directory for platform config paths".into()) - })?; - Ok(Self { - state_dir: dirs.data_dir().to_path_buf(), - cache_dir: dirs.cache_dir().to_path_buf(), - }) + #[cfg(target_os = "macos")] + { + Ok(Self { + state_dir: PathBuf::from("/var/db/tsunagi"), + cache_dir: PathBuf::from("/var/db/tsunagi/cache"), + }) + } + #[cfg(not(target_os = "macos"))] + { + let dirs = directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| { + crate::error::Error::Storage( + "no valid home directory for platform config paths".into(), + ) + })?; + Ok(Self { + state_dir: dirs.data_dir().to_path_buf(), + cache_dir: dirs.cache_dir().to_path_buf(), + }) + } } /// Path of the mandatory state database. diff --git a/crates/tsunagi/src/dns/publish/macos.rs b/crates/tsunagi/src/dns/publish/macos.rs new file mode 100644 index 0000000..48762f7 --- /dev/null +++ b/crates/tsunagi/src/dns/publish/macos.rs @@ -0,0 +1,333 @@ +//! Telling the macOS resolver to send some questions here, through +//! `/etc/resolver`. +//! +//! macOS reads one file per domain out of `/etc/resolver`: a file named after +//! a domain, containing the `nameserver` and `port` to send that domain's +//! questions to, makes the system resolver a *scoped* resolver for exactly +//! that suffix and nothing else (see `resolver(5)`). That is the same contract +//! the systemd-resolved and NRPT publishers arrange on the other platforms: +//! route these suffixes to this server, and claim nothing else. +//! +//! # Why a port, and so no privileged socket +//! +//! A resolver file carries a `port`, so the server does not have to sit on 53 +//! and the agent needs nothing like `CAP_NET_BIND_SERVICE`. It can serve on a +//! high port and still be reached for its suffixes. +//! +//! # Tagged, reversible, and never clobbering +//! +//! Every file this publisher writes begins with a marker line, so it only ever +//! replaces or removes files it wrote itself: a pre-existing `/etc/resolver` +//! entry a user set up by hand is left untouched. The files are removed when +//! the resolver is turned off and on shutdown; a crash leaves them behind, and +//! the next apply for the same domains overwrites them. +//! +//! # Privilege +//! +//! Writing under `/etc/resolver` needs root. A permission error is reported as +//! a refusal — the kind of failure a person has to act on — rather than a +//! transient one, so the agent does not retry it at the ordinary pace. + +use std::collections::BTreeSet; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use crate::BoxFuture; + +use super::{DnsPublisher, PublishError, Published}; + +/// The directory macOS reads scoped resolvers from. +const RESOLVER_DIR: &str = "/etc/resolver"; + +/// The first line of every file this publisher writes, so it recognises its +/// own and never touches a file a user created. +const MARKER: &str = "# managed by tsunagi — do not edit"; + +/// Writes and removes `/etc/resolver` files for the overlay zones. +#[derive(Debug)] +pub struct ResolverDirPublisher { + /// The resolver directory. A field rather than a constant so the tests can + /// point it at a temporary directory and never touch the real one. + dir: PathBuf, + /// The domain files written by the last apply, so a later apply can remove + /// the ones no longer wanted and `revert` can remove them all. + written: Mutex>, +} + +impl Default for ResolverDirPublisher { + fn default() -> Self { + Self::new() + } +} + +impl ResolverDirPublisher { + /// A publisher writing to the real `/etc/resolver`. + pub fn new() -> Self { + Self::in_dir(RESOLVER_DIR) + } + + /// A publisher writing to a specific directory. For the tests. + fn in_dir(dir: impl Into) -> Self { + Self { + dir: dir.into(), + written: Mutex::new(BTreeSet::new()), + } + } + + fn apply_inner(&self, published: &Published) -> Result<(), PublishError> { + let desired = desired_files(published)?; + + // The directory may not exist yet; creating it needs root. + std::fs::create_dir_all(&self.dir).map_err(|err| classify(&self.dir, err))?; + + // Remove the files from a previous apply that are no longer wanted. + let previous = lock(&self.written).clone(); + for name in previous.difference(&keys(&desired)) { + remove_if_ours(&self.dir.join(name)); + } + + for (name, body) in &desired { + let path = self.dir.join(name); + // Only ever overwrite a file that is already ours, or a name that + // is free. A resolver a user set up by hand is left in place and + // reported, never clobbered. + if path.exists() && !is_ours(&path) { + return Err(PublishError::Failed(format!( + "{} already exists and was not created by tsunagi; \ + remove it or rename the network to publish its resolver", + path.display() + ))); + } + std::fs::write(&path, body).map_err(|err| classify(&path, err))?; + } + + *lock(&self.written) = keys(&desired); + Ok(()) + } + + fn revert_inner(&self) { + let written = std::mem::take(&mut *lock(&self.written)); + for name in &written { + remove_if_ours(&self.dir.join(name)); + } + } +} + +impl DnsPublisher for ResolverDirPublisher { + fn name(&self) -> &str { + "resolver-dir" + } + + fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> { + // A handful of small file writes, short enough to run in place rather + // than hop to the blocking pool. + Box::pin(async move { self.apply_inner(published) }) + } + + fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> { + Box::pin(async move { + self.revert_inner(); + Ok(()) + }) + } +} + +/// The resolver files a publication implies: a map of file name to contents. +fn desired_files(published: &Published) -> Result, PublishError> { + if published.servers.is_empty() { + return Err(PublishError::Failed( + "no server address to point the resolver at".to_string(), + )); + } + let body = file_contents(published); + let mut files = Vec::new(); + for domain in &published.domains { + let name = resolver_file_name(domain)?; + files.push((name, body.clone())); + } + Ok(files) +} + +/// The contents of one resolver file: the marker, every server, one port. +fn file_contents(published: &Published) -> String { + let mut out = String::from(MARKER); + out.push('\n'); + for server in &published.servers { + let ip = match server.ip() { + IpAddr::V4(v4) => v4.to_string(), + IpAddr::V6(v6) => v6.to_string(), + }; + out.push_str("nameserver "); + out.push_str(&ip); + out.push('\n'); + } + // One port for the file; every server listens on the same one. The `port` + // directive is what lets the server stay off 53. + if let Some(first) = published.servers.first() { + out.push_str("port "); + out.push_str(&first.port().to_string()); + out.push('\n'); + } + out +} + +/// The file name for a domain, rejecting anything that is not a plain label +/// path component: a resolver file name must never escape the directory. +fn resolver_file_name(domain: &str) -> Result { + let trimmed = domain.trim_end_matches('.'); + if trimmed.is_empty() + || trimmed.contains('/') + || trimmed.contains('\\') + || trimmed.contains(std::path::MAIN_SEPARATOR) + || trimmed == "." + || trimmed == ".." + || trimmed.starts_with('.') + { + return Err(PublishError::Failed(format!( + "cannot make a resolver file name for the domain {domain:?}" + ))); + } + Ok(trimmed.to_string()) +} + +/// Whether a file is one this publisher wrote, by its marker first line. +fn is_ours(path: &Path) -> bool { + match std::fs::read_to_string(path) { + Ok(text) => text.lines().next() == Some(MARKER), + Err(_) => false, + } +} + +/// Removes a file, but only if it is one of ours. Best effort. +fn remove_if_ours(path: &Path) { + if is_ours(path) + && let Err(err) = std::fs::remove_file(path) + && err.kind() != std::io::ErrorKind::NotFound + { + tracing::debug!(path = %path.display(), %err, "cannot remove the resolver file"); + } +} + +/// The set of file names in a desired-files list. +fn keys(files: &[(String, String)]) -> BTreeSet { + files.iter().map(|(name, _)| name.clone()).collect() +} + +/// Turns a filesystem error into the right kind of publish error: a permission +/// problem is a refusal a person must act on, anything else is a plain failure. +fn classify(path: &Path, err: std::io::Error) -> PublishError { + if err.kind() == std::io::ErrorKind::PermissionDenied { + PublishError::Refused(format!( + "writing {} needs root; start the agent with `sudo` or as a root LaunchDaemon", + path.display() + )) + } else { + PublishError::Failed(format!("cannot write {}: {err}", path.display())) + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + match mutex.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use std::net::SocketAddr; + + use super::*; + + fn published(domains: &[&str]) -> Published { + Published { + interface: "utun5".into(), + servers: vec![ + "10.13.37.1:5353".parse::().unwrap(), + "[fd00::1]:5353".parse::().unwrap(), + ], + domains: domains.iter().map(|d| d.to_string()).collect(), + } + } + + #[test] + fn a_file_lists_every_server_and_one_port_under_the_marker() { + let body = file_contents(&published(&["mynet."])); + let mut lines = body.lines(); + assert_eq!(lines.next(), Some(MARKER)); + assert_eq!(lines.next(), Some("nameserver 10.13.37.1")); + assert_eq!(lines.next(), Some("nameserver fd00::1")); + assert_eq!(lines.next(), Some("port 5353")); + assert_eq!(lines.next(), None); + } + + #[test] + fn a_domain_becomes_a_plain_file_name_without_its_trailing_dot() { + assert_eq!(resolver_file_name("mynet.").unwrap(), "mynet"); + assert_eq!(resolver_file_name("my.net").unwrap(), "my.net"); + } + + #[test] + fn a_domain_that_would_escape_the_directory_is_refused() { + assert!(resolver_file_name("../etc/passwd").is_err()); + assert!(resolver_file_name("a/b").is_err()); + assert!(resolver_file_name("").is_err()); + assert!(resolver_file_name(".").is_err()); + assert!(resolver_file_name(".hidden").is_err()); + } + + #[tokio::test] + async fn apply_writes_a_file_per_domain_and_revert_removes_them() { + let dir = tempfile::tempdir().unwrap(); + let publisher = ResolverDirPublisher::in_dir(dir.path()); + + publisher + .apply(&published(&["mynet.", "other."])) + .await + .unwrap(); + assert!(dir.path().join("mynet").is_file()); + assert!(dir.path().join("other").is_file()); + + publisher.revert().await.unwrap(); + assert!(!dir.path().join("mynet").exists()); + assert!(!dir.path().join("other").exists()); + } + + #[tokio::test] + async fn re_applying_removes_a_domain_that_is_no_longer_wanted() { + let dir = tempfile::tempdir().unwrap(); + let publisher = ResolverDirPublisher::in_dir(dir.path()); + + publisher.apply(&published(&["a.", "b."])).await.unwrap(); + publisher.apply(&published(&["a."])).await.unwrap(); + + assert!(dir.path().join("a").is_file()); + assert!( + !dir.path().join("b").exists(), + "the dropped domain's file is removed" + ); + } + + #[tokio::test] + async fn a_file_a_user_created_is_never_clobbered_or_removed() { + let dir = tempfile::tempdir().unwrap(); + let foreign = dir.path().join("mynet"); + std::fs::write(&foreign, "nameserver 9.9.9.9\n").unwrap(); + + let publisher = ResolverDirPublisher::in_dir(dir.path()); + let err = publisher.apply(&published(&["mynet."])).await.unwrap_err(); + assert!(matches!(err, PublishError::Failed(_)), "{err:?}"); + // Untouched. + assert_eq!( + std::fs::read_to_string(&foreign).unwrap(), + "nameserver 9.9.9.9\n" + ); + + // And revert leaves a foreign file alone even if it is in our set. + publisher.revert().await.unwrap(); + assert!(foreign.is_file()); + } +} diff --git a/crates/tsunagi/src/dns/publish/mod.rs b/crates/tsunagi/src/dns/publish/mod.rs index ca46f15..bff16f1 100644 --- a/crates/tsunagi/src/dns/publish/mod.rs +++ b/crates/tsunagi/src/dns/publish/mod.rs @@ -28,6 +28,11 @@ mod windows; #[cfg(all(feature = "dns-publish", target_os = "windows"))] pub use windows::NrptPublisher; +#[cfg(all(feature = "dns-publish", target_os = "macos"))] +mod macos; +#[cfg(all(feature = "dns-publish", target_os = "macos"))] +pub use macos::ResolverDirPublisher; + mod unsupported; pub use unsupported::UnsupportedPublisher; diff --git a/crates/tsunagi/src/ipc/mod.rs b/crates/tsunagi/src/ipc/mod.rs index af0736b..689788d 100644 --- a/crates/tsunagi/src/ipc/mod.rs +++ b/crates/tsunagi/src/ipc/mod.rs @@ -87,7 +87,10 @@ pub fn control_socket_path(state_dir: &Path) -> PathBuf { /// On Windows, the system-wide `%ProgramData%\tsunagi\agent.sock` is used so that /// any local user, tray application, or CLI tool can manage the running agent /// without needing to know which user account or service started it. -/// On Unix, this derives from the default per-user state directory. +/// On macOS the agent runs as a root LaunchDaemon, so a fixed system path under +/// `/var/run` is used for the same reason, and it stays short enough for the +/// Unix socket path limit. On other Unix it derives from the default per-user +/// state directory. pub fn default_control_socket_path() -> PathBuf { #[cfg(windows)] { @@ -99,7 +102,11 @@ pub fn default_control_socket_path() -> PathBuf { } PathBuf::from(r"C:\ProgramData\tsunagi\agent.sock") } - #[cfg(not(windows))] + #[cfg(target_os = "macos")] + { + PathBuf::from("/var/run/tsunagi/agent.sock") + } + #[cfg(all(unix, not(target_os = "macos")))] { if let Ok(paths) = crate::config::StoragePaths::user_default() { control_socket_path(&paths.state_dir) diff --git a/crates/tsunagi/src/overlay/hostrules/macos.rs b/crates/tsunagi/src/overlay/hostrules/macos.rs new file mode 100644 index 0000000..c8ddc72 --- /dev/null +++ b/crates/tsunagi/src/overlay/hostrules/macos.rs @@ -0,0 +1,189 @@ +//! Broadcast host rules on macOS: a `route` entry; the firewall is deferred. +//! +//! The limited-broadcast route is installed with `/sbin/route`, so a game's +//! LAN discovery packet leaves through the overlay interface. It belongs to +//! the interface and is removed when broadcast is turned off, the network +//! leaves, or the agent stops — and, because a utun is torn down with the +//! process, it also goes away on its own if the agent is killed. +//! +//! The inbound-UDP firewall allowance is **not** implemented here. On macOS +//! that means a `pf` anchor, which is a good deal more machinery; until it is +//! written the firewall half reports itself as missing with what to allow by +//! hand, and the route is still installed. Failure is contained: neither half +//! can stop the agent, and `status` shows exactly which is in place. +//! +//! `route` is a program, not a system call, so it is run by absolute path from +//! `/sbin` rather than off `PATH`, and its arguments are built from values +//! this agent computed — the interface name the kernel assigned, nothing a +//! peer said. + +use crate::BoxFuture; + +use super::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, RuleOutcome}; + +/// The limited-broadcast destination routed through the overlay interface. +const LIMITED_BROADCAST: &str = "255.255.255.255"; + +/// The `route` arguments that direct the limited broadcast out an interface. +fn route_add_args(interface: &str) -> Vec { + vec![ + "-q".into(), + "-n".into(), + "add".into(), + "-host".into(), + LIMITED_BROADCAST.into(), + "-interface".into(), + interface.into(), + ] +} + +/// The `route` arguments that remove it again. +fn route_del_args(interface: &str) -> Vec { + vec![ + "-q".into(), + "-n".into(), + "delete".into(), + "-host".into(), + LIMITED_BROADCAST.into(), + "-interface".into(), + interface.into(), + ] +} + +/// What to do by hand while the `pf` allowance is unimplemented. +fn firewall_todo(plan: &BroadcastRulesPlan) -> String { + format!( + "the inbound-UDP firewall allowance is not implemented on macOS yet; if the host \ + firewall drops discovery replies, allow inbound UDP from {} on `{}` with a pf rule \ + yourself", + plan.range, plan.interface + ) +} + +/// The macOS implementation: `route` for the route, no firewall yet. +#[derive(Debug, Default)] +pub struct MacosHostRules; + +impl MacosHostRules { + /// Creates the host rules. + pub fn new() -> Self { + Self + } + + /// Installs the route, replacing one this agent added earlier so the call + /// can be repeated. The delete is best effort: a route that is not there + /// is the state the add then reaches anyway. + async fn set_route(&self, interface: String) -> RuleOutcome { + let _ = run(route_del_args(&interface)).await; + match run(route_add_args(&interface)).await { + Ok(()) => RuleOutcome::Applied, + Err(reason) => RuleOutcome::Failed(reason), + } + } +} + +impl BroadcastHostRules for MacosHostRules { + fn name(&self) -> &str { + "route" + } + + fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> { + Box::pin(async move { + let route = self.set_route(plan.interface.clone()).await; + BroadcastRulesReport { + plan: plan.clone(), + route, + firewall: RuleOutcome::Failed(firewall_todo(plan)), + } + }) + } + + fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> { + Box::pin(async move { + if let Err(reason) = run(route_del_args(interface)).await { + tracing::debug!(interface = %interface, %reason, "cannot remove the broadcast route"); + } + }) + } +} + +/// Runs `/sbin/route`, turning a non-zero exit into the message it printed. +async fn run(args: Vec) -> Result<(), String> { + let display = format!("route {}", args.join(" ")); + let output = tokio::task::spawn_blocking(move || { + std::process::Command::new("/sbin/route") + .args(&args) + .output() + }) + .await + .map_err(|err| format!("could not run {display}: {err}"))? + .map_err(|err| format!("could not run {display}: {err}"))?; + + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + let text = stderr.trim(); + if text.is_empty() { + Err(format!("{display} failed with {}", output.status)) + } else { + Err(format!("{display} failed: {text}")) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use std::net::Ipv4Addr; + + use super::*; + + fn plan() -> BroadcastRulesPlan { + BroadcastRulesPlan { + interface: "utun5".into(), + source: Ipv4Addr::new(10, 13, 37, 142), + range: "10.13.37.0/24".parse().unwrap(), + } + } + + #[test] + fn the_route_is_the_limited_broadcast_out_the_interface() { + assert_eq!( + route_add_args("utun5"), + vec![ + "-q", + "-n", + "add", + "-host", + "255.255.255.255", + "-interface", + "utun5" + ] + ); + assert_eq!( + route_del_args("utun5"), + vec![ + "-q", + "-n", + "delete", + "-host", + "255.255.255.255", + "-interface", + "utun5" + ] + ); + } + + #[test] + fn the_firewall_todo_names_the_range_and_interface() { + let todo = firewall_todo(&plan()); + assert!(todo.contains("10.13.37.0/24"), "{todo}"); + assert!(todo.contains("utun5"), "{todo}"); + assert!(todo.contains("pf"), "{todo}"); + // Deferred, so the firewall half is always reported as not in place; + // the route runs for real and so is left to an on-host check. Running + // `route` here would need root and would change the host routing + // table, which the default test suite must never do. + } +} diff --git a/crates/tsunagi/src/overlay/hostrules/mod.rs b/crates/tsunagi/src/overlay/hostrules/mod.rs index 6d453b5..1eac5a4 100644 --- a/crates/tsunagi/src/overlay/hostrules/mod.rs +++ b/crates/tsunagi/src/overlay/hostrules/mod.rs @@ -56,6 +56,11 @@ mod windows; #[cfg(all(feature = "tun-device", target_os = "windows"))] pub use windows::WindowsHostRules; +#[cfg(all(feature = "tun-device", target_os = "macos"))] +mod macos; +#[cfg(all(feature = "tun-device", target_os = "macos"))] +pub use macos::MacosHostRules; + /// What the host should be told for one interface. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BroadcastRulesPlan { diff --git a/crates/tsunagi/src/overlay/interface.rs b/crates/tsunagi/src/overlay/interface.rs index 39b21a9..ed58630 100644 --- a/crates/tsunagi/src/overlay/interface.rs +++ b/crates/tsunagi/src/overlay/interface.rs @@ -172,6 +172,13 @@ impl Interface { let name = name.into(); let host_rules = factory.host_rules(); let device = factory.create(TunRequest::bare(name.clone(), mtu)).await?; + // The name the operating system actually gave the interface. On Linux + // and Windows that is the one we asked for; on macOS the kernel assigns + // the utun unit, so the real name differs and everything that + // configures the host for this interface afterwards — the broadcast + // route and firewall, the reconfigure on an address change — has to use + // the real one, not the requested `tsun…`. + let name = device.name().to_string(); let tally = Arc::new(Tally::default()); let task = { diff --git a/crates/tsunagi/src/overlay/mod.rs b/crates/tsunagi/src/overlay/mod.rs index 3282881..5f2c230 100644 --- a/crates/tsunagi/src/overlay/mod.rs +++ b/crates/tsunagi/src/overlay/mod.rs @@ -64,10 +64,15 @@ pub use tun::{MemoryTun, MemoryTunFactory, TunDevice, TunFactory, TunRequest, ad #[cfg(all(feature = "tun-device", target_os = "linux"))] pub use hostrules::LinuxHostRules; +#[cfg(all(feature = "tun-device", target_os = "macos"))] +pub use hostrules::MacosHostRules; #[cfg(all(feature = "tun-device", target_os = "windows"))] pub use hostrules::WindowsHostRules; #[cfg(all(feature = "tun-device", target_os = "linux"))] pub use provision::NetlinkProvisioner; +#[cfg(all(feature = "tun-device", target_os = "macos"))] +pub use provision::UtunProvisioner; + #[cfg(all(feature = "tun-device", target_os = "windows"))] pub use provision::WintunProvisioner; diff --git a/crates/tsunagi/src/overlay/provision/macos.rs b/crates/tsunagi/src/overlay/provision/macos.rs new file mode 100644 index 0000000..ab7e572 --- /dev/null +++ b/crates/tsunagi/src/overlay/provision/macos.rs @@ -0,0 +1,531 @@ +//! Managing the overlay interface on macOS, through utun and `ifconfig`/`route`. +//! +//! The shape is the same as the Linux and Windows provisioners: observe what +//! is there, [`plan_changes`](super::plan_changes) to decide what to do, apply +//! the difference. Only the mechanisms differ. +//! +//! # Creating the interface +//! +//! The interface is a `utun` device, created through the `tun` dependency's +//! safe wrapper — [`open_tun`](super::super::tun) — over the in-kernel +//! `com.apple.net.utun_control` control socket. No kernel extension and no +//! Network Extension entitlement is involved; what it needs is root. The +//! device is not made persistent, so closing the last file descriptor removes +//! the interface — it goes away when the agent does, however the agent ends. +//! +//! # The kernel names the interface, not us +//! +//! Unlike Linux and Windows, a utun interface cannot be given an arbitrary +//! name: it is always `utunN`, and the unit number is assigned by the kernel. +//! So the name derived from the network id (`tsun…`) is only ever a *request*; +//! the real name is read back off the device after it is opened, and +//! everything downstream — the host rules, the resolver publication — uses the +//! real one. The agent adopts it as soon as the device is created (see +//! [`Interface`](crate::overlay::Interface)). +//! +//! # Configuring it +//! +//! Addresses, MTU and routes are applied with `ifconfig` and `route`. On Linux +//! the same work is done in process over a netlink socket; here there is no +//! equivalent this crate may call, because the in-process route is raw `ioctl` +//! and `PF_ROUTE` and that is `unsafe`. The tools are invoked by absolute path +//! from `/sbin`, so nothing on `PATH` can stand in for them, and every value +//! handed to them is one this agent derived — an interface name chosen by the +//! kernel, an address it allocated — never anything a peer said. +//! +//! # Observing it +//! +//! The interface table is read through `netdev`, a safe wrapper over +//! `getifaddrs`. An interface is matched by its real `utunN` name, which only +//! matters on reconfiguration: the first create always makes a fresh device. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::sync::{Arc, Mutex}; + +use crate::BoxFuture; +use crate::overlay::OverlayError; + +use super::super::config::Cidr; +use super::super::tun::{TunDevice, TunRequest, open_tun}; +use super::privilege::{Privilege, probe_net_admin}; +use super::{ + InterfacePlan, InterfaceProvisioner, InterfaceState, LinkKind, Provisioned, plan_changes, +}; + +/// Manages the overlay interface with utun and `ifconfig`/`route`. +#[derive(Debug)] +pub struct UtunProvisioner { + /// Interfaces this process created, by their real `utunN` name, so a + /// reconfiguration adjusts them rather than trying to create them again. + ours: Mutex>, + /// Devices kept alive for as long as the interface should exist. Dropping + /// one closes the last descriptor, which removes the utin interface. + held: Mutex)>>, +} + +impl UtunProvisioner { + /// Creates the provisioner, after checking the platform supports one. + /// + /// Whether the process is actually root is not checked here — reading the + /// effective uid needs a libc call this crate forbids — so a missing + /// privilege surfaces at utun creation with a precise message, the same + /// way the Linux and Windows paths treat the open itself as the honest + /// answer. + pub fn new() -> Result { + match probe_net_admin() { + Privilege::Available => {} + Privilege::Missing(reason) => return Err(OverlayError::Unavailable(reason)), + Privilege::Unsupported => { + return Err(OverlayError::Unavailable( + "interface management is not compiled in".to_string(), + )); + } + } + Ok(Self { + ours: Mutex::new(Vec::new()), + held: Mutex::new(Vec::new()), + }) + } + + fn is_ours(&self, name: &str) -> bool { + lock(&self.ours).iter().any(|owned| owned == name) + } + + /// Opens the utun device, which is what creates it. The kernel assigns the + /// name, so the returned device reports a `utunN` that is not `plan.name`. + fn create_device(&self, plan: &InterfacePlan) -> Result, OverlayError> { + let request = TunRequest::bare(plan.name.clone(), plan.mtu); + open_tun(&request) + } +} + +impl InterfaceProvisioner for UtunProvisioner { + fn name(&self) -> &str { + "utun" + } + + fn reconcile<'a>( + &'a self, + plan: &'a InterfacePlan, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + // A reconfiguration: the plan carries the real `utunN` name the + // agent adopted after the first create, so the interface is there + // and only the addresses and MTU need bringing into line. + if self.is_ours(&plan.name) { + let current = observe(&plan.name).await; + let changes = plan_changes(¤t, plan, true)?; + configure(&plan.name, &changes).await?; + return Ok(Provisioned { + changes, + device: None, + }); + } + + // The first create. The kernel assigns the unit, so the device is + // opened first and its real name used for everything after. + let device = self.create_device(plan)?; + let real = device.name().to_string(); + + // A fresh interface starts with nothing on it, so the whole plan is + // applied. `plan_changes` against an absent interface is exactly + // that decision; its `create_link` flag is already satisfied by the + // open above, so `configure` ignores it and only applies the rest. + let changes = plan_changes(&InterfaceState::absent(), plan, false)?; + + // A failure here leaves an interface that exists but cannot carry + // traffic. Drop the device so the utun closes, and report, rather + // than leave a trap behind. + if let Err(err) = configure(&real, &changes).await { + drop(device); + return Err(err); + } + + lock(&self.ours).push(real.clone()); + lock(&self.held).push((real, Arc::clone(&device))); + Ok(Provisioned { + changes, + device: Some(device), + }) + }) + } + + fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>> { + Box::pin(async move { + // Dropping the device closes the last descriptor, which removes the + // utun interface and the routes that were bound to it. There is + // nothing to flush: a utun never outlives the process that holds it. + lock(&self.held).retain(|(held, _)| held != name); + lock(&self.ours).retain(|owned| owned != name); + Ok(()) + }) + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + match mutex.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +/// `fe80::/10` and `169.254.0.0/16`, which macOS assigns on its own. +fn is_link_local(addr: IpAddr) -> bool { + match addr { + IpAddr::V4(addr) => addr.is_link_local(), + IpAddr::V6(addr) => (addr.segments()[0] & 0xffc0) == 0xfe80, + } +} + +/// What the interface named `name` currently looks like. +/// +/// Never fails: a name nothing is using reads as [`InterfaceState::absent`], +/// which is what the plan then acts on. +async fn observe(name: &str) -> InterfaceState { + let name = name.to_string(); + let found = tokio::task::spawn_blocking(move || { + netdev::get_interfaces() + .into_iter() + .find(|iface| iface.name == name) + }) + .await + .ok() + .flatten(); + + let Some(iface) = found else { + return InterfaceState::absent(); + }; + + let mut addresses: Vec = Vec::new(); + for net in &iface.ipv4 { + if let Ok(cidr) = Cidr::new(IpAddr::V4(net.addr()), net.prefix_len()) + && !is_link_local(cidr.addr) + { + addresses.push(cidr); + } + } + for net in &iface.ipv6 { + if let Ok(cidr) = Cidr::new(IpAddr::V6(net.addr()), net.prefix_len()) + && !is_link_local(cidr.addr) + { + addresses.push(cidr); + } + } + addresses.sort(); + + InterfaceState { + // Only ever reached for an interface this agent created (see + // `reconcile`), so it is ours and a TUN by construction. + kind: LinkKind::Tun, + attached: iface.is_up(), + up: iface.is_up(), + mtu: iface.mtu.unwrap_or(0), + addresses, + } +} + +/// Applies MTU, link state, addresses and the on-link routes they imply. +async fn configure(name: &str, changes: &super::Changes) -> Result<(), OverlayError> { + if let Some(mtu) = changes.set_mtu { + set_mtu(name, mtu).await?; + } + for cidr in &changes.remove { + // Best effort: an address that is already gone is the outcome wanted. + if let Err(err) = address_del(name, *cidr).await { + tracing::debug!(interface = %name, %cidr, %err, "could not remove address"); + } + if let Err(err) = route_del(name, *cidr).await { + tracing::debug!(interface = %name, %cidr, %err, "could not remove route"); + } + } + for cidr in &changes.add { + address_add(name, *cidr).await?; + // The subnet route makes the whole overlay range reach the interface; + // a point-to-point utun does not install one from the address alone. A + // host address (/32 or /128) needs none. Best effort: a route that is + // already there is the outcome wanted. + if let Err(err) = route_add(name, *cidr).await { + tracing::debug!(interface = %name, %cidr, %err, "could not add route"); + } + } + if changes.bring_up { + // Best effort: a utun with a descriptor open is up already, and a + // failure to nudge it is not a reason to fail the whole reconcile. + if let Err(err) = set_up(name).await { + tracing::debug!(interface = %name, %err, "could not enable the interface"); + } + } + Ok(()) +} + +/// The network address of a CIDR, with the host bits cleared. +fn network_of(cidr: Cidr) -> IpAddr { + match cidr.addr { + IpAddr::V4(addr) => { + let bits = u32::from(addr); + let mask = if cidr.prefix_len == 0 { + 0 + } else { + u32::MAX << (32 - u32::from(cidr.prefix_len)) + }; + IpAddr::V4(Ipv4Addr::from(bits & mask)) + } + IpAddr::V6(addr) => { + let bits = u128::from(addr); + let mask = if cidr.prefix_len == 0 { + 0 + } else { + u128::MAX << (128 - u32::from(cidr.prefix_len)) + }; + IpAddr::V6(Ipv6Addr::from(bits & mask)) + } + } +} + +/// Whether a CIDR is a single host address, which needs no subnet route. +fn is_host_route(cidr: Cidr) -> bool { + match cidr.addr { + IpAddr::V4(_) => cidr.prefix_len >= 32, + IpAddr::V6(_) => cidr.prefix_len >= 128, + } +} + +/// The `ifconfig` arguments that add an address to a utun interface. +/// +/// A utun is point-to-point, so the IPv4 form names the address as its own +/// peer; `alias` lets several coexist on the one interface. +fn address_add_args(name: &str, cidr: Cidr) -> Vec { + match cidr.addr { + IpAddr::V4(addr) => vec![ + name.into(), + "inet".into(), + format!("{addr}/{}", cidr.prefix_len), + addr.to_string(), + "alias".into(), + ], + IpAddr::V6(addr) => vec![ + name.into(), + "inet6".into(), + format!("{addr}/{}", cidr.prefix_len), + "alias".into(), + ], + } +} + +/// The `ifconfig` arguments that remove an address from a utun interface. +fn address_del_args(name: &str, cidr: Cidr) -> Vec { + let family = match cidr.addr { + IpAddr::V4(_) => "inet", + IpAddr::V6(_) => "inet6", + }; + vec![ + name.into(), + family.into(), + cidr.addr.to_string(), + "-alias".into(), + ] +} + +/// The `route` arguments that point a CIDR's subnet at the interface. +fn route_add_args(name: &str, cidr: Cidr) -> Vec { + let family = match cidr.addr { + IpAddr::V4(_) => "-inet", + IpAddr::V6(_) => "-inet6", + }; + vec![ + "-q".into(), + "-n".into(), + "add".into(), + family.into(), + "-net".into(), + format!("{}/{}", network_of(cidr), cidr.prefix_len), + "-interface".into(), + name.into(), + ] +} + +/// The `route` arguments that remove that subnet route. +fn route_del_args(name: &str, cidr: Cidr) -> Vec { + let family = match cidr.addr { + IpAddr::V4(_) => "-inet", + IpAddr::V6(_) => "-inet6", + }; + vec![ + "-q".into(), + "-n".into(), + "delete".into(), + family.into(), + "-net".into(), + format!("{}/{}", network_of(cidr), cidr.prefix_len), + "-interface".into(), + name.into(), + ] +} + +async fn address_add(name: &str, cidr: Cidr) -> Result<(), OverlayError> { + ifconfig(address_add_args(name, cidr)).await +} + +async fn address_del(name: &str, cidr: Cidr) -> Result<(), OverlayError> { + ifconfig(address_del_args(name, cidr)).await +} + +async fn route_add(name: &str, cidr: Cidr) -> Result<(), OverlayError> { + if is_host_route(cidr) { + return Ok(()); + } + route(route_add_args(name, cidr)).await +} + +async fn route_del(name: &str, cidr: Cidr) -> Result<(), OverlayError> { + if is_host_route(cidr) { + return Ok(()); + } + route(route_del_args(name, cidr)).await +} + +async fn set_mtu(name: &str, mtu: u32) -> Result<(), OverlayError> { + ifconfig(vec![name.into(), "mtu".into(), mtu.to_string()]).await +} + +async fn set_up(name: &str) -> Result<(), OverlayError> { + ifconfig(vec![name.into(), "up".into()]).await +} + +/// Runs `/sbin/ifconfig`, turning a non-zero exit into an error with its text. +async fn ifconfig(args: Vec) -> Result<(), OverlayError> { + run("/sbin/ifconfig", args).await +} + +/// Runs `/sbin/route`, turning a non-zero exit into an error with its text. +async fn route(args: Vec) -> Result<(), OverlayError> { + run("/sbin/route", args).await +} + +/// Runs a system tool by absolute path, so nothing on `PATH` can stand in for +/// it, and turns a non-zero exit into an error carrying what it said. +async fn run(program: &'static str, args: Vec) -> Result<(), OverlayError> { + let display = format!("{program} {}", args.join(" ")); + let output = tokio::task::spawn_blocking(move || { + std::process::Command::new(program).args(&args).output() + }) + .await + .map_err(|err| OverlayError::Other(format!("could not run {display}: {err}")))? + .map_err(|err| OverlayError::Unavailable(format!("could not run {display}: {err}")))?; + + if output.status.success() { + return Ok(()); + } + let message = { + let stderr = String::from_utf8_lossy(&output.stderr); + let text = if stderr.trim().is_empty() { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } else { + stderr.trim().to_string() + }; + if text.is_empty() { + format!("exit code {}", output.status) + } else { + text + } + }; + Err(OverlayError::Unavailable(format!( + "{display} failed: {message}" + ))) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use super::*; + + fn v4(last: u8, prefix: u8) -> Cidr { + Cidr::new(IpAddr::V4(Ipv4Addr::new(10, 13, 37, last)), prefix).unwrap() + } + + fn v6(last: u16, prefix: u8) -> Cidr { + Cidr::new( + IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, last)), + prefix, + ) + .unwrap() + } + + #[test] + fn a_v4_address_is_added_point_to_point_with_an_alias() { + let args = address_add_args("utun5", v4(69, 24)); + assert_eq!( + args, + vec!["utun5", "inet", "10.13.37.69/24", "10.13.37.69", "alias"] + ); + } + + #[test] + fn a_v6_address_is_added_with_a_prefix_and_an_alias() { + let args = address_add_args("utun5", v6(1, 128)); + assert_eq!(args, vec!["utun5", "inet6", "fd00::1/128", "alias"]); + } + + #[test] + fn deleting_an_address_names_the_family_and_removes_the_alias() { + let args = address_del_args("utun5", v4(69, 24)); + assert_eq!(args, vec!["utun5", "inet", "10.13.37.69", "-alias"]); + let args = address_del_args("utun5", v6(1, 128)); + assert_eq!(args, vec!["utun5", "inet6", "fd00::1", "-alias"]); + } + + #[test] + fn a_subnet_route_points_the_masked_network_at_the_interface() { + let args = route_add_args("utun5", v4(69, 24)); + assert_eq!( + args, + vec![ + "-q", + "-n", + "add", + "-inet", + "-net", + "10.13.37.0/24", + "-interface", + "utun5" + ] + ); + } + + #[test] + fn a_host_address_installs_no_subnet_route() { + assert!(is_host_route(v4(69, 32))); + assert!(is_host_route(v6(1, 128))); + assert!(!is_host_route(v4(69, 24))); + assert!(!is_host_route(v6(1, 64))); + } + + #[test] + fn the_network_address_clears_the_host_bits() { + assert_eq!( + network_of(v4(69, 24)), + IpAddr::V4(Ipv4Addr::new(10, 13, 37, 0)) + ); + assert_eq!( + network_of(Cidr::new(IpAddr::V4(Ipv4Addr::new(10, 13, 37, 69)), 16).unwrap()), + IpAddr::V4(Ipv4Addr::new(10, 13, 0, 0)) + ); + assert_eq!( + network_of(v6(0x1234, 64)), + IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0)) + ); + } + + #[test] + fn link_local_addresses_are_not_ours_to_manage() { + assert!(is_link_local(IpAddr::V6(Ipv6Addr::new( + 0xfe80, 0, 0, 0, 0, 0, 0, 1 + )))); + assert!(is_link_local(IpAddr::V4(Ipv4Addr::new(169, 254, 1, 1)))); + assert!(!is_link_local(IpAddr::V4(Ipv4Addr::new(10, 13, 37, 1)))); + assert!(!is_link_local(IpAddr::V6(Ipv6Addr::new( + 0xfd00, 0, 0, 0, 0, 0, 0, 1 + )))); + } +} diff --git a/crates/tsunagi/src/overlay/provision/mod.rs b/crates/tsunagi/src/overlay/provision/mod.rs index e520257..c1d0506 100644 --- a/crates/tsunagi/src/overlay/provision/mod.rs +++ b/crates/tsunagi/src/overlay/provision/mod.rs @@ -20,11 +20,13 @@ //! pure and tested on every platform, and only the execution is behind //! [`InterfaceProvisioner`]. //! -//! Four implementations: +//! Five implementations: //! //! * `NetlinkProvisioner` on Linux, which needs `CAP_NET_ADMIN`. //! * `WintunProvisioner` on Windows, a Wintun adapter configured with `netsh`, //! which needs an elevated process. +//! * `UtunProvisioner` on macOS, a utun interface configured with `ifconfig` +//! and `route`, which needs root. //! * [`MockProvisioner`], an in-memory host used by the tests. //! * [`UnsupportedProvisioner`] elsewhere, which fails with an explanation //! and a pointer at the manual route rather than pretending to work. @@ -60,6 +62,11 @@ mod windows; #[cfg(all(feature = "tun-device", target_os = "windows"))] pub use windows::WintunProvisioner; +#[cfg(all(feature = "tun-device", target_os = "macos"))] +mod macos; +#[cfg(all(feature = "tun-device", target_os = "macos"))] +pub use macos::UtunProvisioner; + mod privilege; pub use privilege::{Privilege, probe_net_admin}; diff --git a/crates/tsunagi/src/overlay/provision/privilege.rs b/crates/tsunagi/src/overlay/provision/privilege.rs index 8d9c10c..a8b9ef2 100644 --- a/crates/tsunagi/src/overlay/provision/privilege.rs +++ b/crates/tsunagi/src/overlay/provision/privilege.rs @@ -63,15 +63,20 @@ pub use linux_impl::{NetAdmin, probe_net_admin}; #[cfg(all(feature = "tun-device", target_os = "windows"))] pub use windows_impl::probe_net_admin; +#[cfg(all(feature = "tun-device", target_os = "macos"))] +pub use macos_impl::probe_net_admin; + #[cfg(not(any( all(feature = "tun-device", target_os = "linux"), - all(feature = "tun-device", target_os = "windows") + all(feature = "tun-device", target_os = "windows"), + all(feature = "tun-device", target_os = "macos") )))] pub use other_impl::probe_net_admin; #[cfg(not(any( all(feature = "tun-device", target_os = "linux"), - all(feature = "tun-device", target_os = "windows") + all(feature = "tun-device", target_os = "windows"), + all(feature = "tun-device", target_os = "macos") )))] mod other_impl { use super::Privilege; @@ -101,6 +106,25 @@ mod windows_impl { } } +#[cfg(all(feature = "tun-device", target_os = "macos"))] +mod macos_impl { + use super::Privilege; + + /// Whether this process can configure interfaces. + /// + /// macOS has no capability to hold and lower the way Linux does: creating + /// a utun interface simply needs the process to be root. Reading the + /// effective uid means a libc call this crate forbids, so the probe is + /// deliberately optimistic — it reports that the platform can manage + /// interfaces — and the real check is left to the utun open, which fails + /// with a precise "run with sudo" message when the process is not root. + /// This matches how the Linux path treats the open itself as the honest + /// answer. + pub fn probe_net_admin() -> Privilege { + Privilege::Available + } +} + #[cfg(all(feature = "tun-device", target_os = "linux"))] mod linux_impl { use caps::{CapSet, Capability}; diff --git a/crates/tsunagi/src/overlay/tun.rs b/crates/tsunagi/src/overlay/tun.rs index 3e705e3..e615a8e 100644 --- a/crates/tsunagi/src/overlay/tun.rs +++ b/crates/tsunagi/src/overlay/tun.rs @@ -417,7 +417,15 @@ mod system { /// across this call, and such a guard must not span an `await` because /// Linux capabilities are per thread. pub(crate) fn open_tun(request: &TunRequest) -> Result, OverlayError> { + // macOS mutates neither field below (it neither names the interface nor + // sets a platform config), so `mut` is unused only there. + #[cfg_attr(target_os = "macos", allow(unused_mut))] let mut config = tun::Configuration::default(); + // macOS names its tunnels `utunN` and assigns the unit number itself. + // A name derived from the network id (`tsun…`) is not a legal utun + // name, so there the kernel is left to choose and the real name is read + // back below. Linux and Windows take the requested name verbatim. + #[cfg(not(target_os = "macos"))] config.tun_name(&request.name); #[cfg(target_os = "linux")] config.platform_config(|platform| { @@ -435,9 +443,20 @@ mod system { let device = tun::create_as_async(&config).map_err(|err| open_error(&request.name, err))?; + // The interface name the operating system actually gave us: the + // requested one everywhere except macOS, where the kernel chose the + // utun unit and so the real name has to be read back off the device. + #[cfg(target_os = "macos")] + let name = { + use tun::AbstractDevice; + device.tun_name().unwrap_or_else(|_| request.name.clone()) + }; + #[cfg(not(target_os = "macos"))] + let name = request.name.clone(); + let (reader, writer) = tokio::io::split(device); Ok(Arc::new(SystemTun { - name: request.name.clone(), + name, mtu: request.mtu, reader: Mutex::new(reader), writer: Mutex::new(writer), @@ -468,6 +487,9 @@ mod system { } else if cfg!(target_os = "linux") { "Start `tsunagi up` as root or grant CAP_NET_ADMIN with \ `sudo setcap cap_net_admin,cap_net_bind_service+p /path/to/tsunagi`." + } else if cfg!(target_os = "macos") { + "Creating a utun interface needs root on macOS; start `tsunagi up` \ + with `sudo`, or install it as a root LaunchDaemon." } else { "Start `tsunagi up` with the privileges required to create a TUN interface." }; @@ -496,7 +518,12 @@ mod system { run as Administrator with the DLL beside the executable, or run with `--no-tun` \ to keep the tunnels off the operating system." } - #[cfg(not(any(target_os = "linux", target_os = "windows")))] + #[cfg(target_os = "macos")] + { + "Creating a utun interface needs root; start the agent with `sudo` or as a root \ + LaunchDaemon, or run with `--no-tun` to keep the tunnels off the operating system." + } + #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] { "Run with `--no-tun` to keep the tunnels off the operating system." } diff --git a/dist/macos/README.md b/dist/macos/README.md new file mode 100644 index 0000000..d81c1da --- /dev/null +++ b/dist/macos/README.md @@ -0,0 +1,82 @@ +# Running tsunagi on macOS + +The agent needs **root** on macOS: creating a `utun` interface and writing +`/etc/resolver` are both root-only, and macOS has no per-capability grant like +Linux. There are two ways to run it. + +## Quick start — `sudo` + +```sh +sudo tsunagi up +``` + +In another terminal, the control commands talk to it over the system control +socket and do **not** need `sudo`: + +```sh +tsunagi status +tsunagi join +``` + +The agent uses fixed system paths, so the commands find it regardless of whose +shell started `sudo`: + +| what | path | +|---|---| +| mandatory state | `/var/db/tsunagi` | +| disposable cache | `/var/db/tsunagi/cache` | +| control socket | `/var/run/tsunagi/agent.sock` | + +## Native deployment — root LaunchDaemon + +For an always-on agent that starts at boot, install the bundled +[`cy.hexor.tsunagi.plist`](cy.hexor.tsunagi.plist). + +```sh +# Put the binary where the plist expects it (edit the plist for another path). +sudo install -m 0755 target/release/tsunagi /usr/local/bin/tsunagi + +# Install the daemon. launchd refuses a daemon plist that is not owned by +# root:wheel or that is group/world-writable. +sudo install -m 0644 -o root -g wheel \ + dist/macos/cy.hexor.tsunagi.plist \ + /Library/LaunchDaemons/cy.hexor.tsunagi.plist + +# Load and start it. +sudo launchctl bootstrap system /Library/LaunchDaemons/cy.hexor.tsunagi.plist +sudo launchctl enable system/cy.hexor.tsunagi +``` + +Manage it afterwards with: + +```sh +sudo launchctl kickstart -k system/cy.hexor.tsunagi # restart +sudo launchctl bootout system/cy.hexor.tsunagi # stop and unload +tail -f /var/log/tsunagi.log # logs +``` + +The control commands (`status`, `join`, `network`, `dns`) work the same against +the daemon as against a `sudo tsunagi up`, over the same socket. + +## What the agent does to the system + +Everything it installs is tagged to its own interface, computed from local +state, and reversible — it is removed when a network leaves, broadcast is turned +off, or the agent stops: + +- a **`utun` interface**, configured with `ifconfig` (addresses, MTU, up) and a + subnet `route` for the overlay range. The kernel assigns the `utunN` name; the + agent adopts whatever it is given. A `utun` is torn down with the process, so + a crash never leaves one behind. +- `/etc/resolver/` files pointing the system resolver at the overlay + DNS server for each network's zone, with a `port` so the server stays off 53. + Each file carries a marker line; the agent only ever replaces or removes files + it wrote, never one you created by hand. +- while broadcast is on, a `255.255.255.255` **route** through the overlay + interface. The matching inbound-UDP firewall allowance is **not** implemented + yet (it needs a `pf` anchor); `tsunagi status` reports that half as incomplete + and names what to allow by hand if your host firewall drops discovery replies. + +Nothing taken from a remote peer ever becomes a path, a command argument or an +OS setting: the interface name comes from the kernel, addresses and ranges from +signed state the agent already verified. diff --git a/dist/macos/cy.hexor.tsunagi.plist b/dist/macos/cy.hexor.tsunagi.plist new file mode 100644 index 0000000..e267556 --- /dev/null +++ b/dist/macos/cy.hexor.tsunagi.plist @@ -0,0 +1,51 @@ + + + + + + Label + cy.hexor.tsunagi + + + ProgramArguments + + /usr/local/bin/tsunagi + up + + + + RunAtLoad + + KeepAlive + + + + ThrottleInterval + 10 + + + StandardOutPath + /var/log/tsunagi.log + StandardErrorPath + /var/log/tsunagi.log + + + +