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 <path>`.

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).
This commit is contained in:
Aleksandr Bogomiakov committed 2026-09-30 16:53:33 +01:00
1 parent fac3bd7bb9
commit 5495cb5b34
6 files changed
+102 -18

No files matched your search

Generated
+1
View File
@@ -4170,6 +4170,7 @@ name = "tsunagi-tcp-tls"
version = "0.1.0"
dependencies = [
"bytes",
"caps",
"chacha20poly1305",
"hex",
"hkdf",
+6 -2
View File
@@ -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,
+3
View File
@@ -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
+85 -11
View File
@@ -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));
}
}
@@ -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");
}
+2 -2
View File
@@ -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")]