From 5495cb5b34a367e0e501715fd361c9253e585bae Mon Sep 17 00:00:00 2001 From: Aleksandr Bogomiakov Date: Wed, 30 Sep 2026 16:53:33 +0100 Subject: [PATCH] Support CAP_NET_BIND_SERVICE raising for privileged TCP TLS ports When file capabilities are granted with `sudo setcap ...+p`, capabilities are placed into the thread's permitted set rather than its effective set. While TUN interface provisioning in `tsunagi` properly acquired and dropped CAP_NET_ADMIN using `caps::raise`, `tsunagi-tcp-tls` attempted to bind port 443 directly without activating CAP_NET_BIND_SERVICE, resulting in EACCES (os error 13 Permission denied). Fixes and improvements: - Add `caps` dependency to `tsunagi-tcp-tls` on Linux. - Implement `NetBindServiceGuard` to temporarily raise CAP_NET_BIND_SERVICE into the effective set for the synchronous `TcpListener::bind` invocation and immediately drop it afterwards. - Clarify diagnostic warnings to distinguish between default port 443 fallback and explicitly requested port failure. - Update all privilege diagnostics and error hints across the codebase to uniformly recommend granting both capabilities: `sudo setcap cap_net_admin,cap_net_bind_service+p `. Validation on Linux: - `cargo fmt --all -- --check` passed. - `cargo clippy --locked --workspace --all-targets -- -D warnings` passed. - `cargo test --locked --workspace --all-targets` passed (all workspace unit, integration, and protocol tests passed). --- Cargo.lock | 1 + crates/tsunagi-cli/src/main.rs | 8 +- crates/tsunagi-tcp-tls/Cargo.toml | 3 + crates/tsunagi-tcp-tls/src/transport.rs | 96 ++++++++++++++++--- .../src/overlay/provision/privilege.rs | 8 +- crates/tsunagi/src/overlay/tun.rs | 4 +- 6 files changed, 102 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3bbb88d..7864a03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4170,6 +4170,7 @@ name = "tsunagi-tcp-tls" version = "0.1.0" dependencies = [ "bytes", + "caps", "chacha20poly1305", "hex", "hkdf", diff --git a/crates/tsunagi-cli/src/main.rs b/crates/tsunagi-cli/src/main.rs index 8338334..5f84c72 100644 --- a/crates/tsunagi-cli/src/main.rs +++ b/crates/tsunagi-cli/src/main.rs @@ -2890,8 +2890,12 @@ fn host_section() -> report::Section { // explanation belongs in the runtime error, not in a column // the eye is meant to scan. host.push( - Row::new(Health::Degraded, "privileges", "CAP_NET_ADMIN not held") - .with_note(format!("sudo setcap cap_net_admin+p {}", program_path())), + Row::new(Health::Degraded, "privileges", "CAP_NET_ADMIN not held").with_note( + format!( + "sudo setcap cap_net_admin,cap_net_bind_service+p {}", + program_path() + ), + ), ); host.push(Row::new( Health::Degraded, diff --git a/crates/tsunagi-tcp-tls/Cargo.toml b/crates/tsunagi-tcp-tls/Cargo.toml index 1649f97..34e1a3b 100644 --- a/crates/tsunagi-tcp-tls/Cargo.toml +++ b/crates/tsunagi-tcp-tls/Cargo.toml @@ -29,6 +29,9 @@ thiserror.workspace = true hex.workspace = true netdev = "0.46" +[target.'cfg(target_os = "linux")'.dependencies] +caps = "0.5" + [dev-dependencies] tsunagi = { path = "../tsunagi", features = ["testing"] } tempfile.workspace = true diff --git a/crates/tsunagi-tcp-tls/src/transport.rs b/crates/tsunagi-tcp-tls/src/transport.rs index 3e728b4..e8f7b0c 100644 --- a/crates/tsunagi-tcp-tls/src/transport.rs +++ b/crates/tsunagi-tcp-tls/src/transport.rs @@ -264,6 +264,60 @@ impl std::fmt::Debug for TcpTlsTransport { } } +#[cfg(target_os = "linux")] +struct NetBindServiceGuard { + raised: bool, +} + +#[cfg(target_os = "linux")] +impl NetBindServiceGuard { + fn acquire() -> Self { + let already = caps::has_cap( + None, + caps::CapSet::Effective, + caps::Capability::CAP_NET_BIND_SERVICE, + ) + .unwrap_or(false); + + if already { + return Self { raised: false }; + } + + let permitted = caps::has_cap( + None, + caps::CapSet::Permitted, + caps::Capability::CAP_NET_BIND_SERVICE, + ) + .unwrap_or(false); + + if permitted + && caps::raise( + None, + caps::CapSet::Effective, + caps::Capability::CAP_NET_BIND_SERVICE, + ) + .is_ok() + { + Self { raised: true } + } else { + Self { raised: false } + } + } +} + +#[cfg(target_os = "linux")] +impl Drop for NetBindServiceGuard { + fn drop(&mut self) { + if self.raised { + let _ = caps::drop( + None, + caps::CapSet::Effective, + caps::Capability::CAP_NET_BIND_SERVICE, + ); + } + } +} + impl TcpTlsTransport { /// Binds a new TCP TLS transport using the device identity and configured port. pub async fn bind( @@ -276,8 +330,21 @@ impl TcpTlsTransport { let target_port = requested_port.unwrap_or(DEFAULT_PORT); let bind_addr = SocketAddr::from(([0, 0, 0, 0], target_port)); - let listener = match TcpListener::bind(bind_addr).await { - Ok(l) => { + let bind_result = { + #[cfg(target_os = "linux")] + let _guard = if target_port < 1024 { + Some(NetBindServiceGuard::acquire()) + } else { + None + }; + + std::net::TcpListener::bind(bind_addr) + }; + + let listener = match bind_result { + Ok(std_listener) => { + std_listener.set_nonblocking(true)?; + let l = TcpListener::from_std(std_listener)?; tracing::info!(port = target_port, "TCP TLS listening"); Some(Arc::new(l)) } @@ -286,21 +353,28 @@ impl TcpTlsTransport { .map(|p| p.display().to_string()) .unwrap_or_else(|_| "tsunagi".into()); - tracing::warn!( - port = target_port, - %err, - "Cannot bind to port {target_port} without privileges. \ - To allow port 443 and TUN on Linux, grant capabilities once with:\n \ - sudo setcap cap_net_admin,cap_net_bind_service+p {exe}\n\ - Falling back to port {FALLBACK_PORT}." - ); - if requested_port.is_none() { + tracing::warn!( + port = target_port, + %err, + "Cannot bind to default port {target_port} without privileges. \ + To allow port 443 and TUN on Linux, grant capabilities once with:\n \ + sudo setcap cap_net_admin,cap_net_bind_service+p {exe}\n\ + Falling back to port {FALLBACK_PORT}." + ); + let fallback_addr = SocketAddr::from(([0, 0, 0, 0], FALLBACK_PORT)); let l = TcpListener::bind(fallback_addr).await?; tracing::info!(port = FALLBACK_PORT, "TCP TLS listening on fallback port"); Some(Arc::new(l)) } else { + tracing::warn!( + port = target_port, + %err, + "Cannot bind to requested port {target_port} without privileges. \ + To allow port {target_port} and TUN on Linux, grant capabilities once with:\n \ + sudo setcap cap_net_admin,cap_net_bind_service+p {exe}" + ); return Err(Box::new(err)); } } diff --git a/crates/tsunagi/src/overlay/provision/privilege.rs b/crates/tsunagi/src/overlay/provision/privilege.rs index 2f31e39..8d9c10c 100644 --- a/crates/tsunagi/src/overlay/provision/privilege.rs +++ b/crates/tsunagi/src/overlay/provision/privilege.rs @@ -15,7 +15,7 @@ //! Grant it with: //! //! ```text -//! sudo setcap cap_net_admin+p /usr/local/bin/tsunagi +//! sudo setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsunagi //! ``` //! //! `+p` rather than `+ep`: with `+p` the capability is permitted but not @@ -49,7 +49,7 @@ impl Privilege { /// How to obtain it, for a diagnostic. pub fn how_to_grant(program: &str) -> String { format!( - "Grant it once with `sudo setcap cap_net_admin+p {program}` \ + "Grant it once with `sudo setcap cap_net_admin,cap_net_bind_service+p {program}` \ and the agent manages its own interface. Without it, run with \ `--no-tun`: the tunnels still form, they just do not reach the \ operating system." @@ -181,7 +181,9 @@ mod tests { #[test] fn the_grant_instructions_name_the_program() { let text = Privilege::how_to_grant("/usr/local/bin/tsunagi"); - assert!(text.contains("setcap cap_net_admin+p /usr/local/bin/tsunagi")); + assert!( + text.contains("setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsunagi") + ); assert!(text.contains("--no-tun"), "the fallback is offered too"); } diff --git a/crates/tsunagi/src/overlay/tun.rs b/crates/tsunagi/src/overlay/tun.rs index 7fd60dc..7f71526 100644 --- a/crates/tsunagi/src/overlay/tun.rs +++ b/crates/tsunagi/src/overlay/tun.rs @@ -435,7 +435,7 @@ mod system { 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`." + `sudo setcap cap_net_admin,cap_net_bind_service+p /path/to/tsunagi`." } else { "Start `tsunagi up` with the privileges required to create a TUN interface." }; @@ -455,7 +455,7 @@ mod system { fn open_hint() -> &'static str { #[cfg(target_os = "linux")] { - "Creating one needs CAP_NET_ADMIN; grant it with `setcap cap_net_admin+p`, \ + "Creating one needs CAP_NET_ADMIN; grant it with `setcap cap_net_admin,cap_net_bind_service+p`, \ or run with `--no-tun` to keep the tunnels off the operating system." } #[cfg(target_os = "windows")]