diff --git a/README.md b/README.md index 373bd64..0192312 100644 --- a/README.md +++ b/README.md @@ -480,12 +480,18 @@ The original IP packet, including its DF bit, is preserved. See | `sudo tsunagi up` | root | everything works, nothing is isolated | | `--no-tun` | ordinary user, no capabilities | tunnels run and handshake, traffic never reaches the OS | -**Not implemented yet.** macOS and Windows have no provisioner: both need -real platform work — `utun` and `SystemConfiguration` on one, the IP Helper -API and a Wintun adapter on the other. There the agent says so and `--no-tun` -is the way to run it; the control plane and the tunnels are unaffected. The -decision logic that says *what* to change is shared and tested on every -platform, so only the execution is left to write. +On Windows, put `wintun.dll` beside the executable and start `tsunagi up` +from PowerShell or Command Prompt opened with **Run as administrator**. +Run commands controlling that agent (`join`, `status`, `dns`, `network`) +as the same Windows user with the same elevation. If Windows denies access +to the agent's control pipe, the command reports a permission error instead +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. ## Checks diff --git a/crates/tsunagi-cli/src/main.rs b/crates/tsunagi-cli/src/main.rs index 0e305ed..4b4b2ee 100644 --- a/crates/tsunagi-cli/src/main.rs +++ b/crates/tsunagi-cli/src/main.rs @@ -2256,14 +2256,8 @@ async fn status(args: StatusArgs) -> Result<(), Box> { // Nothing running is an ordinary answer to "what is running", not // a fault; a socket that will not answer is a fault. agent.push(if *socket_present { - // The version check in the framing names a mismatch only for - // whichever side is newer. An older agent reading a newer - // request just drops the connection, so the hint has to be - // offered rather than asserted. - Row::new(Health::Degraded, "running", "not answering").with_note(format!( - "{why} · it may be an older build: restart it with this binary. \ - The rest was read from the store" - )) + Row::new(Health::Degraded, "running", "not answering") + .with_note(format!("{why} · the rest was read from the store")) } else { Row::new(Health::Info, "running", "no") .with_note(format!("{why} · the rest was read from the store")) diff --git a/crates/tsunagi/src/ipc/mod.rs b/crates/tsunagi/src/ipc/mod.rs index f4abb33..344ff31 100644 --- a/crates/tsunagi/src/ipc/mod.rs +++ b/crates/tsunagi/src/ipc/mod.rs @@ -584,6 +584,8 @@ async fn exchange(path: &Path, request: &Request, within: Duration) -> Result) -> bool { transport::probe(path.as_ref()).await } diff --git a/crates/tsunagi/src/ipc/windows.rs b/crates/tsunagi/src/ipc/windows.rs index 54c1c1d..f629223 100644 --- a/crates/tsunagi/src/ipc/windows.rs +++ b/crates/tsunagi/src/ipc/windows.rs @@ -25,6 +25,8 @@ //! other unprivileged users, the default is the practical equivalent of the //! owner-only Unix socket. There is no leftover to clean up: a pipe exists only //! while its server does. +//! Commands must run as the same Windows user with the same elevation as the +//! agent. A permission denial is reported as such, never as an absent agent. use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -41,8 +43,8 @@ pub use super::{ request_status, set_active, set_dns, set_hostname, }; -/// `ERROR_ACCESS_DENIED`: what creating the first pipe instance returns when -/// one already exists, so another agent already owns the name. +/// `ERROR_ACCESS_DENIED`: creating a first instance finds an existing pipe, +/// or a client cannot access an existing pipe with its current privileges. const ERROR_ACCESS_DENIED: i32 = 5; /// `ERROR_PIPE_BUSY`: every instance is serving a client right now. A server /// is there; the client only has to wait for a free instance. @@ -177,6 +179,20 @@ pub(crate) async fn connect(path: &Path) -> Result { Err(err) if err.raw_os_error() == Some(ERROR_PIPE_BUSY) => { tokio::time::sleep(Duration::from_millis(50)).await; } + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { + return Err(Error::Io { + path: path.to_path_buf(), + source: std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!( + "the agent's control pipe exists, but Windows denied access. \ + Run this command as the same Windows user and with the same \ + elevation as `tsunagi up` (use an administrator terminal if \ + the agent is elevated). Windows error: {err}" + ), + ), + }); + } Err(source) => { return Err(Error::Io { path: path.to_path_buf(), @@ -189,13 +205,17 @@ pub(crate) async fn connect(path: &Path) -> Result { /// Whether an agent is serving the pipe for `path`. /// -/// A busy pipe still means a server is there; only a name nothing has created -/// counts as not serving. +/// A busy or inaccessible pipe still means a server is there. In particular, +/// access denied must not send a mutating command down the offline path, +/// where it would only produce a misleading state-directory lock error. pub(crate) async fn probe(path: &Path) -> bool { let name = pipe_name(path); match ClientOptions::new().open(&name) { Ok(_) => true, - Err(err) => err.raw_os_error() == Some(ERROR_PIPE_BUSY), + Err(err) => { + err.raw_os_error() == Some(ERROR_PIPE_BUSY) + || err.kind() == std::io::ErrorKind::PermissionDenied + } } } @@ -248,6 +268,39 @@ mod tests { control.shutdown().await; } + #[tokio::test] + async fn an_inaccessible_agent_is_not_mistaken_for_an_absent_one() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("agent.sock"); + // An outbound-only pipe denies the client's duplex open even when + // both processes have the same privileges. This exercises the same + // Windows error as a client unable to access an elevated agent, + // without requiring elevation or changing any ACLs. + let _server = ServerOptions::new() + .first_pipe_instance(true) + .access_inbound(false) + .create(pipe_name(&path)) + .unwrap(); + + assert!(is_serving(&path).await, "access denied is not absence"); + for error in [ + request_status(&path).await.unwrap_err(), + join_network(&path, "test", "local-ipc-test-secret") + .await + .unwrap_err(), + set_dns(&path, false, None).await.unwrap_err(), + ] { + let Error::Io { source, .. } = error else { + panic!("expected an access error, got {error}"); + }; + assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied); + let message = source.to_string(); + assert!(message.contains("same Windows user"), "{message}"); + assert!(message.contains("administrator"), "{message}"); + assert!(!message.contains("local-ipc-test-secret"), "{message}"); + } + } + #[tokio::test] async fn a_silent_agent_is_reported_rather_than_waited_out() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/tsunagi/src/overlay/tun.rs b/crates/tsunagi/src/overlay/tun.rs index f2cf472..7fd60dc 100644 --- a/crates/tsunagi/src/overlay/tun.rs +++ b/crates/tsunagi/src/overlay/tun.rs @@ -401,13 +401,7 @@ mod system { // applies those to the interface separately, the same way on every // platform, so the plan stays the single source of what it carries. - let device = tun::create_as_async(&config).map_err(|err| { - OverlayError::Unavailable(format!( - "cannot create the TUN interface `{}`: {err}. {}", - request.name, - open_hint() - )) - })?; + let device = tun::create_as_async(&config).map_err(|err| open_error(&request.name, err))?; let (reader, writer) = tokio::io::split(device); Ok(Arc::new(SystemTun { @@ -418,6 +412,44 @@ mod system { }) as Arc) } + fn open_error(name: &str, error: tun::Error) -> OverlayError { + // Wintun preserves the Windows code in its I/O variant. Normalize it + // before testing permissions; matching localized error text would + // miss the same failure on another Windows language. + #[cfg(target_os = "windows")] + let error = match error { + tun::Error::WintunError(error) => tun::Error::Io(error.into()), + error => error, + }; + let denied = match &error { + tun::Error::Io(error) => { + error.kind() == std::io::ErrorKind::PermissionDenied + || (cfg!(target_os = "windows") + && matches!(error.raw_os_error(), Some(5 | 740 | 1314))) + } + _ => false, + }; + if denied { + let hint = if cfg!(target_os = "windows") { + "Open PowerShell or Command Prompt with 'Run as administrator' and \ + start `tsunagi up` there." + } else if cfg!(target_os = "linux") { + "Start `tsunagi up` as root or grant CAP_NET_ADMIN with \ + `sudo setcap cap_net_admin+p /path/to/tsunagi`." + } else { + "Start `tsunagi up` with the privileges required to create a TUN interface." + }; + return OverlayError::Unavailable(format!( + "permission denied while creating the TUN interface `{name}`. \ + {hint} OS error: {error}" + )); + } + OverlayError::Unavailable(format!( + "cannot create the TUN interface `{name}`: {error}. {}", + open_hint() + )) + } + /// The platform-appropriate tail of a "cannot create the interface" error: /// what privilege it needs and the fallback that always works. fn open_hint() -> &'static str { @@ -437,4 +469,42 @@ mod system { "Run with `--no-tun` to keep the tunnels off the operating system." } } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn a_tun_permission_error_explains_how_to_start_the_agent() { + let error = tun::Error::Io(std::io::Error::from(std::io::ErrorKind::PermissionDenied)); + let message = open_error("tsun0", error).to_string(); + assert!(message.contains("permission denied"), "{message}"); + assert!(message.contains("tsun0"), "{message}"); + assert!(message.contains("tsunagi up"), "{message}"); + #[cfg(target_os = "windows")] + assert!(message.contains("Run as administrator"), "{message}"); + #[cfg(target_os = "linux")] + assert!(message.contains("CAP_NET_ADMIN"), "{message}"); + } + + #[cfg(target_os = "windows")] + #[test] + fn wintun_access_and_elevation_errors_get_the_permission_hint() { + for code in [5, 740, 1314] { + let error = tun::Error::WintunError(std::io::Error::from_raw_os_error(code).into()); + let message = open_error("tsun0", error).to_string(); + assert!(message.contains("permission denied"), "{message}"); + assert!(message.contains("Run as administrator"), "{message}"); + assert!(message.contains(&format!("os error {code}")), "{message}"); + assert!(!message.contains("wintun.dll"), "{message}"); + } + } + + #[test] + fn other_tun_errors_keep_their_cause_without_claiming_access_was_denied() { + let message = open_error("tsun0", tun::Error::InvalidName).to_string(); + assert!(message.contains("invalid device tun name"), "{message}"); + assert!(!message.contains("permission denied"), "{message}"); + } + } }