diff --git a/Cargo.lock b/Cargo.lock index 8f870b9..7388607 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4231,6 +4231,7 @@ dependencies = [ "tracing-subscriber", "tsunagi", "tun", + "windows-sys 0.61.2", "zbus", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index 88e172f..8f70d46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ repository = "https://github.com/tsunagi-net/tsunagi" [workspace.lints.rust] missing_docs = "warn" -unsafe_code = "forbid" +unsafe_code = "deny" [workspace.lints.clippy] unwrap_used = "warn" diff --git a/crates/tsunagi-cli/src/main.rs b/crates/tsunagi-cli/src/main.rs index bda260d..6691e2c 100644 --- a/crates/tsunagi-cli/src/main.rs +++ b/crates/tsunagi-cli/src/main.rs @@ -82,7 +82,7 @@ struct DnsArgs { paths: PathArgs, /// Control socket to talk to. Derived from the state directory by default. - #[arg(long, global = true)] + #[arg(long, env = "TSUNAGI_CONTROL_SOCKET", global = true)] control_socket: Option, #[command(subcommand)] @@ -107,7 +107,7 @@ struct NetworkArgs { paths: PathArgs, /// Control socket to talk to. Derived from the state directory by default. - #[arg(long, global = true)] + #[arg(long, env = "TSUNAGI_CONTROL_SOCKET", global = true)] control_socket: Option, #[command(subcommand)] @@ -132,7 +132,7 @@ struct JoinArgs { paths: PathArgs, /// Control socket to talk to. Derived from the state directory by default. - #[arg(long)] + #[arg(long, env = "TSUNAGI_CONTROL_SOCKET")] control_socket: Option, /// Network name. Must be identical on every participant. @@ -233,7 +233,7 @@ struct WipeArgs { paths: PathArgs, /// Control socket to check for a running agent. - #[arg(long)] + #[arg(long, env = "TSUNAGI_CONTROL_SOCKET")] control_socket: Option, /// Actually remove it. Without this the command only says what it would. @@ -247,7 +247,7 @@ struct IdArgs { paths: PathArgs, /// Control socket to talk to. Derived from the state directory by default. - #[arg(long, global = true)] + #[arg(long, env = "TSUNAGI_CONTROL_SOCKET", global = true)] control_socket: Option, #[command(subcommand)] @@ -284,7 +284,7 @@ struct StatusArgs { paths: PathArgs, /// Control socket to talk to. Derived from the state directory by default. - #[arg(long)] + #[arg(long, env = "TSUNAGI_CONTROL_SOCKET")] control_socket: Option, } @@ -476,7 +476,7 @@ struct UpArgs { status_interval: u64, /// Control socket to serve. Derived from the state directory by default. - #[arg(long)] + #[arg(long, env = "TSUNAGI_CONTROL_SOCKET")] control_socket: Option, } @@ -745,11 +745,19 @@ async fn run(command: Command) -> Result<(), Box> { } /// Path of the local control socket for a state directory. -fn control_socket(paths: &StoragePaths, override_path: Option<&PathBuf>) -> PathBuf { - match override_path { - Some(path) => path.clone(), - None => tsunagi::ipc::control_socket_path(&paths.state_dir), +fn control_socket( + paths: &StoragePaths, + override_path: Option<&PathBuf>, + _is_custom_state_dir: bool, +) -> PathBuf { + if let Some(path) = override_path { + return path.clone(); } + #[cfg(windows)] + if !_is_custom_state_dir { + return tsunagi::ipc::default_control_socket_path(); + } + tsunagi::ipc::control_socket_path(&paths.state_dir) } /// What could be learned about this device, and from where. @@ -1580,7 +1588,8 @@ fn resolve_network<'a>( /// `tsunagi network`: what this device belongs to, and leaving it. async fn network_command(args: NetworkArgs) -> Result<(), Box> { let paths = args.paths.resolve()?; - let socket = control_socket(&paths, args.control_socket.as_ref()); + let is_custom_state = args.paths.state_dir.is_some(); + let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state); match args.action { None => show_networks(&paths, &socket).await, Some(NetworkAction::Broadcast { network, choice }) => { @@ -1614,7 +1623,8 @@ async fn network_command(args: NetworkArgs) -> Result<(), Box Result<(), Box> { let paths = args.paths.resolve()?; - let socket = control_socket(&paths, args.control_socket.as_ref()); + let is_custom_state = args.paths.state_dir.is_some(); + let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state); let name = NetworkName::new(args.network)?; // A name this device already has means that network; a name nobody // has means a new one, and no secret is needed to make a network with @@ -2000,7 +2010,8 @@ fn forget_protocol_state(paths: &StoragePaths, network: tsunagi::NetworkId) { /// `tsunagi wipe`: back to a device that has never joined anything. async fn wipe(args: WipeArgs) -> Result<(), Box> { let paths = args.paths.resolve()?; - let socket = control_socket(&paths, args.control_socket.as_ref()); + let is_custom_state = args.paths.state_dir.is_some(); + let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state); if tsunagi::ipc::is_serving(&socket).await { return Err( "stop the agent first: a wipe removes the state it is using, and leaving a \ @@ -2060,7 +2071,8 @@ async fn wipe(args: WipeArgs) -> Result<(), Box> { /// `tsunagi dns`: the local resolver, and turning it on or off. async fn dns_command(args: DnsArgs) -> Result<(), Box> { let paths = args.paths.resolve()?; - let socket = control_socket(&paths, args.control_socket.as_ref()); + let is_custom_state = args.paths.state_dir.is_some(); + let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state); let enable = match args.action { None => return show_dns(&paths, &socket).await, @@ -2152,7 +2164,8 @@ async fn show_dns( /// `tsunagi id`: what this device is, and what changes it. async fn id(args: IdArgs) -> Result<(), Box> { let paths = args.paths.resolve()?; - let socket = control_socket(&paths, args.control_socket.as_ref()); + let is_custom_state = args.paths.state_dir.is_some(); + let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state); match args.action { None => show_identity(&paths, &socket).await, @@ -2343,7 +2356,8 @@ async fn status(args: StatusArgs) -> Result<(), Box> { use report::{Health, Report, Row, Section}; let paths = args.paths.resolve()?; - let socket = control_socket(&paths, args.control_socket.as_ref()); + let is_custom_state = args.paths.state_dir.is_some(); + let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state); let observed = observe(&paths, &socket).await; let mut out = Report::new(); @@ -3559,7 +3573,8 @@ async fn up(args: UpArgs) -> Result<(), Box> { // agent that is already running, so the lock on its own is an // answer to a question nobody asked. Err(tsunagi::Error::StateLocked { path }) => { - let socket = control_socket(&paths, args.control_socket.as_ref()); + let is_custom_state = args.paths.state_dir.is_some(); + let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state); if tsunagi::ipc::is_serving(&socket).await { return Err(format!( "an agent is already running for {}, and one state directory is one \ @@ -3645,7 +3660,8 @@ async fn up(args: UpArgs) -> Result<(), Box> { dns, paths: paths.clone(), }); - let path = control_socket(&paths, args.control_socket.as_ref()); + let is_custom_state = args.paths.state_dir.is_some(); + let path = control_socket(&paths, args.control_socket.as_ref(), is_custom_state); match tsunagi::ipc::ControlSocket::bind(path, source).await { Ok(socket) => { println!(" control {}", socket.path().display()); diff --git a/crates/tsunagi/Cargo.toml b/crates/tsunagi/Cargo.toml index fc974d7..470ef89 100644 --- a/crates/tsunagi/Cargo.toml +++ b/crates/tsunagi/Cargo.toml @@ -94,6 +94,11 @@ futures-util = { version = "0.3", default-features = false, optional = true } # elevated and `wintun.dll` to be reachable at run time. [target.'cfg(target_os = "windows")'.dependencies] netdev = { version = "0.45", optional = true } +windows-sys = { version = "0.61", features = [ + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Foundation", +] } [dev-dependencies] # Its own tests use the harness it publishes. diff --git a/crates/tsunagi/src/ipc/mod.rs b/crates/tsunagi/src/ipc/mod.rs index 4689927..ccd5e29 100644 --- a/crates/tsunagi/src/ipc/mod.rs +++ b/crates/tsunagi/src/ipc/mod.rs @@ -82,6 +82,33 @@ pub fn control_socket_path(state_dir: &Path) -> PathBuf { state_dir.join("agent.sock") } +/// Where the default system control socket lives on this platform. +/// +/// 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. +pub fn default_control_socket_path() -> PathBuf { + #[cfg(windows)] + { + if let Some(program_data) = std::env::var_os("ProgramData") { + let pd = PathBuf::from(program_data); + if pd.is_absolute() { + return pd.join("tsunagi").join("agent.sock"); + } + } + PathBuf::from(r"C:\ProgramData\tsunagi\agent.sock") + } + #[cfg(not(windows))] + { + if let Ok(paths) = crate::config::StoragePaths::user_default() { + control_socket_path(&paths.state_dir) + } else { + PathBuf::from("/run/tsunagi/agent.sock") + } + } +} + /// What a client asks for. /// /// `Debug` is written by hand rather than derived: one of these carries a diff --git a/crates/tsunagi/src/ipc/windows.rs b/crates/tsunagi/src/ipc/windows.rs index f629223..603bd04 100644 --- a/crates/tsunagi/src/ipc/windows.rs +++ b/crates/tsunagi/src/ipc/windows.rs @@ -18,15 +18,16 @@ //! //! # Who may connect //! -//! The pipe rejects clients from other machines, and takes the process's -//! default security, under which the creating user has full access. Narrowing -//! that further with an explicit ACL needs a raw security-descriptor call this -//! crate forbids, so it is not attempted; on a single-user machine, and against -//! 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. +//! The pipe rejects clients from other machines (`reject_remote_clients(true)`). +//! An explicit SDDL security descriptor grants Full Control to Administrators +//! and LocalSystem, while granting Read and Write (`GRGW`) to all local users +//! with a Medium Integrity SACL label (`S:(ML;;NW;;;ME)`). This allows unprivileged +//! user-space tools (such as tray applications, status monitors, or CLI commands +//! launched by regular users) to connect to the agent even when the agent was +//! started by an elevated Administrator or as a system service. +//! A permission denial is reported as such, never as an absent agent. + +#![allow(unsafe_code)] use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -50,6 +51,82 @@ const ERROR_ACCESS_DENIED: i32 = 5; /// is there; the client only has to wait for a free instance. const ERROR_PIPE_BUSY: i32 = 231; +/// RAII wrapper for Windows named pipe security attributes with permissive local ACL. +/// +/// SDDL specification: +/// - `D:(A;;GRGW;;;WD)`: DACL allows Generic Read and Generic Write to Everyone (`WD`). +/// Full control (`GA`) remains reserved to Administrators and LocalSystem. +/// - `(A;;GA;;;BA)`: DACL allows Generic All to Builtin Administrators (`BA`). +/// - `(A;;GA;;;SY)`: DACL allows Generic All to LocalSystem (`SY`). +/// - `S:(ML;;NW;;;ME)`: SACL Mandatory Label with Medium Integrity (`ME`) and No Write Up (`NW`). +/// This allows unprivileged user-space processes (Medium Integrity) to write to the +/// named pipe even when the agent runs as an elevated Administrator (High Integrity). +#[derive(Debug)] +struct PipeSecurityAttributes { + attrs: windows_sys::Win32::Security::SECURITY_ATTRIBUTES, +} + +impl PipeSecurityAttributes { + fn new() -> Result { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + }; + + const SDDL: &str = "D:(A;;GRGW;;;WD)(A;;GA;;;BA)(A;;GA;;;SY)S:(ML;;NW;;;ME)"; + let wide: Vec = OsStr::new(SDDL) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let mut p_sd = std::ptr::null_mut(); + // SAFETY: We pass a valid null-terminated wide string, standard revision 1, + // and a valid pointer to receive the descriptor. + let success = unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + wide.as_ptr(), + SDDL_REVISION_1, + &mut p_sd, + std::ptr::null_mut(), + ) + }; + + if success == 0 || p_sd.is_null() { + let err = std::io::Error::last_os_error(); + return Err(Error::Storage(format!( + "cannot create pipe security descriptor: {err}" + ))); + } + + let attrs = windows_sys::Win32::Security::SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() + as u32, + lpSecurityDescriptor: p_sd, + bInheritHandle: 0, + }; + + Ok(Self { attrs }) + } + + fn as_raw_mut(&mut self) -> *mut std::ffi::c_void { + &mut self.attrs as *mut _ as *mut std::ffi::c_void + } +} + +impl Drop for PipeSecurityAttributes { + fn drop(&mut self) { + if !self.attrs.lpSecurityDescriptor.is_null() { + // SAFETY: The descriptor was allocated by ConvertStringSecurityDescriptorToSecurityDescriptorW + // and must be freed with LocalFree. + unsafe { + windows_sys::Win32::Foundation::LocalFree(self.attrs.lpSecurityDescriptor); + } + self.attrs.lpSecurityDescriptor = std::ptr::null_mut(); + } + } +} + /// Serves the local control interface on a named pipe. #[derive(Debug)] pub struct ControlSocket { @@ -67,11 +144,14 @@ impl ControlSocket { let path = path.as_ref().to_path_buf(); let name = pipe_name(&path); - let server = match ServerOptions::new() - .first_pipe_instance(true) - .reject_remote_clients(true) - .create(&name) - { + let mut sec_attrs = PipeSecurityAttributes::new()?; + // SAFETY: sec_attrs points to valid initialized SECURITY_ATTRIBUTES. + let server = match unsafe { + ServerOptions::new() + .first_pipe_instance(true) + .reject_remote_clients(true) + .create_with_security_attributes_raw(&name, sec_attrs.as_raw_mut()) + } { Ok(server) => server, Err(err) if err.raw_os_error() == Some(ERROR_ACCESS_DENIED) => { return Err(Error::StateLocked { path }); @@ -155,10 +235,19 @@ async fn serve( /// Creates the next pipe instance, or `None` if the name can no longer be /// served. fn next_instance(name: &str) -> Option { - match ServerOptions::new() - .reject_remote_clients(true) - .create(name) - { + let mut sec_attrs = match PipeSecurityAttributes::new() { + Ok(attrs) => attrs, + Err(err) => { + tracing::debug!(%err, "cannot build pipe security attributes for next instance"); + return None; + } + }; + // SAFETY: sec_attrs points to valid initialized SECURITY_ATTRIBUTES. + match unsafe { + ServerOptions::new() + .reject_remote_clients(true) + .create_with_security_attributes_raw(name, sec_attrs.as_raw_mut()) + } { Ok(server) => Some(server), Err(err) => { tracing::debug!(%err, "cannot create the next control pipe instance");