Let the agent run unprivileged against a prepared TUN interface

Creating a network interface needs CAP_NET_ADMIN, but that is a one-time
setup step rather than something the agent must hold for its whole life.

SystemTunFactory now attaches to an interface that already exists and only
creates one when it does not. A persistent interface created by root and
owned by the user therefore lets the agent run with no privileges and no
capabilities at all. When attaching, nothing is reconfigured, since doing so
would need exactly the privileges we are avoiding.

New `tsunagi tun-setup` prints the three commands to run once as root,
resolving the derived interface name and overlay address for the network.

This also fixes a real gap: the overlay address was passed to the factory
and thrown away, so an interface the agent created had no address and could
never have received anything. The `tun` crate sets addresses through an
IPv4-only ioctl and cannot assign an IPv6 one at all, so the agent now
verifies the address is present via /proc/net/if_inet6 and refuses with the
exact command to run instead of coming up broken. Doing it in-process would
mean speaking netlink, which is not implemented and is recorded as such.

Not verified on this machine: no sudo is available here, so the privileged
setup and the attach path were not executed end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tsunagi
2026-09-21 12:23:37 +01:00
co-authored by Claude Opus 5
parent fae62892e0
commit 1dd7507bf4
5 changed files with 265 additions and 40 deletions
+104 -16
View File
@@ -45,6 +45,47 @@ enum Command {
Id(PathArgs),
/// Joins a network and runs until interrupted.
Up(UpArgs),
/// Prints the one-time privileged setup for the overlay interface.
///
/// Run its output once as root, then run `tsunagi up` as an ordinary
/// user: the agent attaches to the prepared interface and needs no
/// privileges of its own.
TunSetup(TunSetupArgs),
}
#[derive(Debug, Args)]
struct TunSetupArgs {
#[command(flatten)]
paths: PathArgs,
/// Network name, exactly as passed to `tsunagi up`.
#[arg(long, short = 'n')]
network: String,
/// The shared secret.
#[arg(
long,
short = 's',
env = "TSUNAGI_SECRET",
conflicts_with = "secret_file"
)]
secret: Option<String>,
/// Read the shared secret from a file instead of the command line.
#[arg(long)]
secret_file: Option<PathBuf>,
/// The user that should own the interface. Defaults to the current one.
#[arg(long)]
user: Option<String>,
/// Interface name prefix, matching `tsunagi up --wg-prefix`.
#[arg(long, default_value = "tsun")]
wg_prefix: String,
/// Interface MTU, matching `tsunagi up --wg-mtu`.
#[arg(long)]
wg_mtu: Option<u32>,
}
#[derive(Debug, Args, Clone)]
@@ -162,22 +203,24 @@ struct UpArgs {
status_interval: u64,
}
impl UpArgs {
fn load_secret(&self) -> Result<NetworkSecret, Box<dyn std::error::Error>> {
let text = match (&self.secret, &self.secret_file) {
(Some(secret), _) => secret.clone(),
(None, Some(path)) => std::fs::read_to_string(path)?,
(None, None) => {
return Err("provide --secret, --secret-file or TSUNAGI_SECRET".into());
}
};
let text = text.trim();
// The canonical form is preferred, but a raw high-entropy value is
// accepted so an existing secret can be reused.
match NetworkSecret::decode(text) {
Ok(secret) => Ok(secret),
Err(_) => Ok(NetworkSecret::from_bytes(text.as_bytes().to_vec())?),
/// Reads the shared secret from an argument or a file.
fn load_secret(
secret: Option<&str>,
secret_file: Option<&std::path::Path>,
) -> Result<NetworkSecret, Box<dyn std::error::Error>> {
let text = match (secret, secret_file) {
(Some(secret), _) => secret.to_string(),
(None, Some(path)) => std::fs::read_to_string(path)?,
(None, None) => {
return Err("provide --secret, --secret-file or TSUNAGI_SECRET".into());
}
};
let text = text.trim();
// The canonical form is preferred, but a raw high-entropy value is
// accepted so an existing secret can be reused.
match NetworkSecret::decode(text) {
Ok(secret) => Ok(secret),
Err(_) => Ok(NetworkSecret::from_bytes(text.as_bytes().to_vec())?),
}
}
@@ -246,9 +289,54 @@ async fn run(command: Command) -> Result<(), Box<dyn std::error::Error>> {
Command::Doctor(paths) => doctor(paths).await,
Command::Id(paths) => show_id(paths).await,
Command::Up(args) => up(args).await,
Command::TunSetup(args) => tun_setup(args).await,
}
}
/// Works out the interface name and overlay address, then prints the
/// privileged commands that prepare it.
///
/// The address depends on this agent's WireGuard key for the network, so the
/// key store is opened (and the key created on first use) to compute it.
async fn tun_setup(args: TunSetupArgs) -> Result<(), Box<dyn std::error::Error>> {
use tsunagi::dataplane::wireguard::{
DEFAULT_MTU, OVERLAY_PREFIX_LEN, WgKeyStore, interface_name, overlay_address,
};
use tsunagi::identity::NetworkKeys;
let name = NetworkName::new(args.network.clone())?;
let secret = load_secret(args.secret.as_deref(), args.secret_file.as_deref())?;
let paths = args.paths.resolve()?;
let network = NetworkKeys::derive(&name, &secret).network_id();
let store_path = paths.state_dir.join("wireguard").join("wireguard.sqlite");
let store = tokio::task::spawn_blocking({
let store_path = store_path.clone();
move || WgKeyStore::open(store_path)
})
.await??;
let key = tokio::task::spawn_blocking(move || store.load_or_create(network)).await??;
let interface = interface_name(&args.wg_prefix, network)?;
let address = overlay_address(network, &key.public());
let mtu = args.wg_mtu.unwrap_or(DEFAULT_MTU);
let user = args.user.unwrap_or_else(|| {
std::env::var("SUDO_USER")
.or_else(|_| std::env::var("USER"))
.unwrap_or_else(|_| "$USER".to_string())
});
println!("# Network {name} ({network})");
println!("# Interface {interface}, address {address}/{OVERLAY_PREFIX_LEN}, mtu {mtu}");
println!("# Run once as root; then run `tsunagi up` as {user}.\n");
println!("sudo ip tuntap add dev {interface} mode tun user {user}");
println!("sudo ip -6 address add {address}/{OVERLAY_PREFIX_LEN} dev {interface}");
println!("sudo ip link set dev {interface} mtu {mtu} up");
println!("\n# To remove it again:");
println!("sudo ip link del dev {interface}");
Ok(())
}
async fn show_id(paths: PathArgs) -> Result<(), Box<dyn std::error::Error>> {
let paths = paths.resolve()?;
println!("state directory {}", paths.state_dir.display());
@@ -341,7 +429,7 @@ async fn netwatch_addresses() -> Vec<std::net::IpAddr> {
async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
let name = NetworkName::new(args.network.clone())?;
let secret = args.load_secret()?;
let secret = load_secret(args.secret.as_deref(), args.secret_file.as_deref())?;
let paths = args.paths.resolve()?;
let mut bootstrap: Vec<EndpointAddr> = Vec::new();
+1 -1
View File
@@ -64,4 +64,4 @@ pub use store::WgKeyStore;
pub use tun::{MemoryTun, MemoryTunFactory, TunDevice, TunFactory, TunRequest};
#[cfg(feature = "tun-device")]
pub use tun::SystemTunFactory;
pub use tun::{SystemTunFactory, interface_exists, interface_has_address, setup_commands};
+98 -18
View File
@@ -182,10 +182,11 @@ impl TunFactory for MemoryTunFactory {
}
#[cfg(feature = "tun-device")]
pub use system::SystemTunFactory;
pub use system::{SystemTunFactory, interface_exists, interface_has_address, setup_commands};
#[cfg(feature = "tun-device")]
mod system {
use std::net::Ipv6Addr;
use std::sync::Arc;
use bytes::Bytes;
@@ -197,8 +198,18 @@ mod system {
/// A real TUN interface.
///
/// Creating one needs `CAP_NET_ADMIN` on Linux, or the platform
/// equivalent. Failure is reported, never fatal for the agent.
/// Two ways to get one, and the difference is who needs privileges:
///
/// * **Attach** to an interface that already exists. Needs no privileges
/// at all, as long as the interface was created persistent and owned by
/// this user. This is the recommended way to run the agent unprivileged.
/// * **Create** it here, which needs `CAP_NET_ADMIN`.
///
/// Either way the overlay address has to be assigned by something
/// privileged: assigning an IPv6 address to an interface is not something
/// this crate's dependencies can do, so the agent checks that it is there
/// and says exactly what to run if it is not, rather than coming up in a
/// state where no traffic could ever arrive.
pub struct SystemTun {
name: String,
mtu: u32,
@@ -255,7 +266,53 @@ mod system {
}
}
/// Creates real TUN interfaces.
/// Whether an interface of this name exists.
pub fn interface_exists(name: &str) -> bool {
std::path::Path::new(&format!("/sys/class/net/{name}")).exists()
}
/// Whether an interface already carries an IPv6 address.
///
/// Reads `/proc/net/if_inet6`, which needs no privileges.
pub fn interface_has_address(name: &str, address: Ipv6Addr) -> bool {
let Ok(contents) = std::fs::read_to_string("/proc/net/if_inet6") else {
// Cannot tell. Assume it is there rather than block on a guess.
return true;
};
let wanted = hex::encode(address.octets());
contents.lines().any(|line| {
let mut fields = line.split_whitespace();
let addr = fields.next().unwrap_or_default();
let iface = fields.last().unwrap_or_default();
addr.eq_ignore_ascii_case(&wanted) && iface == name
})
}
/// The commands a privileged user runs once to prepare an interface.
pub fn setup_commands(request: &TunRequest, user: &str) -> Vec<String> {
vec![
format!(
"sudo ip tuntap add dev {} mode tun user {user}",
request.name
),
format!(
"sudo ip -6 address add {}/{} dev {}",
request.address, request.prefix_len, request.name
),
format!(
"sudo ip link set dev {} mtu {} up",
request.name, request.mtu
),
]
}
fn current_user() -> String {
std::env::var("SUDO_USER")
.or_else(|_| std::env::var("USER"))
.unwrap_or_else(|_| "$USER".to_string())
}
/// Opens real TUN interfaces.
#[derive(Debug, Clone, Default)]
pub struct SystemTunFactory;
@@ -276,27 +333,50 @@ mod system {
request: TunRequest,
) -> BoxFuture<'a, Result<Arc<dyn TunDevice>, PluginError>> {
Box::pin(async move {
let existed = interface_exists(&request.name);
let mut config = tun::Configuration::default();
config.tun_name(&request.name).mtu(request.mtu as u16).up();
// The overlay address and its subnet, so the operating system
// routes overlay traffic into this interface.
let _ = (&request.address, request.prefix_len);
config.tun_name(&request.name);
if !existed {
// Only configure what we are creating ourselves.
// Reconfiguring somebody else's prepared interface would
// need privileges we are trying not to require.
config.mtu(request.mtu as u16).up();
}
let device = tun::create_as_async(&config).map_err(|err| {
PluginError::Unavailable(format!(
"cannot create the TUN interface `{}`: {err}. \
This needs CAP_NET_ADMIN (try running as root).",
request.name
))
let hint = if existed {
format!(
"interface `{}` exists but could not be opened: {err}. \
It must be a persistent TUN interface owned by this user.",
request.name
)
} else {
format!(
"cannot create the TUN interface `{}`: {err}. \
Creating one needs CAP_NET_ADMIN. Either prepare it once as root \
(see `tsunagi tun-setup`) and run unprivileged, or grant the \
capability.",
request.name
)
};
PluginError::Unavailable(hint)
})?;
// The name was requested explicitly; creation fails rather
// than silently picking another one.
let name = request.name.clone();
let (reader, writer) = tokio::io::split(device);
// Without its overlay address the interface can never receive
// anything, so say so instead of pretending to be up.
if !interface_has_address(&request.name, request.address) {
let commands = setup_commands(&request, &current_user()).join("\n ");
return Err(PluginError::Unavailable(format!(
"interface `{}` has no {} address. Assigning an IPv6 address needs \
privileges. Run:\n {commands}",
request.name, request.address
)));
}
let (reader, writer) = tokio::io::split(device);
Ok(Arc::new(SystemTun {
name,
name: request.name,
mtu: request.mtu,
reader: Mutex::new(reader),
writer: Mutex::new(writer),