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:
@@ -104,11 +104,56 @@ Notes:
|
||||
so two machines behind NAT find each other. `--transport local` keeps everything
|
||||
on the local network. See *How peers find each other* below — it is worth
|
||||
understanding what gets published.
|
||||
- Without `CAP_NET_ADMIN`, add `--no-tun`: the mesh, the data links and the
|
||||
- Without a network interface, add `--no-tun`: the mesh, the data links and the
|
||||
WireGuard handshakes all still run and are visible in the status output, only
|
||||
traffic does not reach the operating system. That is the quickest way to
|
||||
confirm the network forms.
|
||||
- Run as root (or grant `CAP_NET_ADMIN`) to get a real interface.
|
||||
- For real traffic, see *Running unprivileged* below.
|
||||
|
||||
## Running unprivileged
|
||||
|
||||
The agent does not need to run as root. Creating a network interface and
|
||||
giving it an address do need privileges, but they are a **one-time setup step**
|
||||
that can be done separately.
|
||||
|
||||
Ask the agent what it needs, then run that once as root:
|
||||
|
||||
```bash
|
||||
tsunagi tun-setup --network lab --secret "$SECRET"
|
||||
```
|
||||
|
||||
```text
|
||||
# Network lab (jwc6dcrtmo5zzdk7f6wfpkcqrpvqwr6po7vz3q2fvttgolt4ijfa)
|
||||
# Interface tsunjwc6dcrtmo5, address fd80:1210:f724:f620:d1bb:f982:3b6e:19bd/64, mtu 1100
|
||||
# Run once as root; then run `tsunagi up` as ab.
|
||||
|
||||
sudo ip tuntap add dev tsunjwc6dcrtmo5 mode tun user ab
|
||||
sudo ip -6 address add fd80:1210:f724:f620:d1bb:f982:3b6e:19bd/64 dev tsunjwc6dcrtmo5
|
||||
sudo ip link set dev tsunjwc6dcrtmo5 mtu 1100 up
|
||||
```
|
||||
|
||||
`user ab` is the point: the interface is persistent and owned by that user, so
|
||||
`tsunagi up` afterwards opens it with **no privileges and no capabilities at
|
||||
all**. The interface name and address are derived, so they are stable — the
|
||||
setup survives restarts and only has to be redone if the network name, the
|
||||
secret or this agent's WireGuard key changes.
|
||||
|
||||
Other ways, and their trade-offs:
|
||||
|
||||
| approach | agent runs as | notes |
|
||||
|---|---|---|
|
||||
| `tsunagi tun-setup` (above) | ordinary user, no capabilities | recommended |
|
||||
| `sudo setcap cap_net_admin+ep ./tsunagi` | ordinary user, one capability | the agent can then create the interface itself, but **still cannot assign the IPv6 address** (see below), so the `ip -6 address add` line is needed anyway. The capability is lost on every rebuild or copy. |
|
||||
| systemd service | `User=`, `AmbientCapabilities=CAP_NET_ADMIN` | same caveat about the address |
|
||||
| plain `sudo tsunagi up` | root | everything works, nothing is isolated |
|
||||
|
||||
**Known limitation.** The agent cannot assign the IPv6 overlay address itself:
|
||||
the `tun` crate sets addresses through an IPv4-only ioctl, so an IPv6 address
|
||||
has to come from `ip -6 address add` or an equivalent. Rather than start with
|
||||
an interface that could never receive anything, the agent checks for the
|
||||
address in `/proc/net/if_inet6` and refuses with the exact command to run.
|
||||
Doing it in-process would mean talking netlink directly, which is possible but
|
||||
not implemented.
|
||||
|
||||
## Checks
|
||||
|
||||
|
||||
+15
-3
@@ -22,7 +22,13 @@ system can hand us IP packets, and even that is behind a trait
|
||||
| | needs privileges | what it proves |
|
||||
|---|---|---|
|
||||
| `MemoryTunFactory` | no | handshake, encryption, routing, address ownership |
|
||||
| `SystemTunFactory` | `CAP_NET_ADMIN` | traffic actually reaches the OS |
|
||||
| `SystemTunFactory`, attaching | none, if the interface was prepared | traffic actually reaches the OS |
|
||||
| `SystemTunFactory`, creating | `CAP_NET_ADMIN` | the same, at the cost of a capability |
|
||||
|
||||
`SystemTunFactory` 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 lets the agent run with no privileges at all; see *Running
|
||||
unprivileged* in [../README.md](../README.md#running-unprivileged).
|
||||
|
||||
[boringtun]: https://docs.rs/boringtun
|
||||
[`TunFactory`]: https://docs.rs/tsunagi
|
||||
@@ -184,5 +190,11 @@ async fn main() -> Result<()> {
|
||||
* **Userspace costs CPU.** Kernel WireGuard is faster. A kernel backend could
|
||||
return behind the same boundary, but it would give up transport-provided NAT
|
||||
traversal unless paired with a local proxy.
|
||||
* **The system interface path is barely exercised by the default suite**,
|
||||
because it needs privileges. Everything else about the data plane is.
|
||||
* **The agent cannot assign the overlay address itself.** The `tun` crate sets
|
||||
addresses through an IPv4-only ioctl, so the IPv6 overlay address must come
|
||||
from `ip -6 address add` or an equivalent. The agent verifies the address is
|
||||
present, via `/proc/net/if_inet6`, and refuses with the exact command rather
|
||||
than running an interface that could never receive anything. Doing it
|
||||
in-process would mean speaking netlink, which is not implemented.
|
||||
* **The system interface path is not exercised by the default suite**, because
|
||||
it needs privileges. Everything else about the data plane is.
|
||||
|
||||
+104
-16
@@ -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();
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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, ¤t_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),
|
||||
|
||||
Reference in New Issue
Block a user