Manage the overlay interface instead of asking for it

The agent printed a list of `ip` commands and asked a human to run them.
That is fragile in the way hand-held setup always is: a persistent TUN
does not survive a reboot, a changed address allocation needs another
manual round, and a run that died leaves a half-configured interface the
next run trips over.

On Linux the agent now creates the interface, sets the MTU, brings it up
and assigns both overlay addresses itself, over netlink in process. No
`ip` is invoked, so nothing this path does can be influenced by PATH, a
shell, or anything a remote peer said.

Cleanup stops being an action. The interface is tied to an open file
descriptor and is deliberately not persistent, so the kernel removes it
when the agent goes — cleanly, by panic, by SIGKILL or by power loss
alike. That also retires `keep_addr_on_down` and `nodad`, which existed
only because an interface nobody held open lost carrier.

Anything still left behind is repaired rather than tripped over: an
abandoned TUN is replaced along with its stale addresses. Two cases
refuse instead of guessing — a link that is not a TUN, because a name
collision is no reason to destroy somebody's bridge, and a TUN another
process holds open, because that is a working overlay belonging to
someone else.

CAP_NET_ADMIN is kept out of the effective set except around the calls
that use it. Two facts shape how: capabilities are per thread, and
netlink checks the credentials of whichever thread calls sendmsg, which
with an async client is the connection task rather than the caller. So
netlink runs on one dedicated thread with a current-thread runtime where
nothing is polled outside a block_on, and opening the TUN descriptor is
synchronous with no await between the guard and its release.

The decision of what to change is a pure function, tested on every
platform; only the execution is behind the provisioner trait. macOS and
Windows get an implementation that refuses with an explanation and falls
back to attaching to a prepared interface, plus a mock host the tests
drive the whole plugin lifecycle against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tsunagi
2026-09-21 14:33:19 +01:00
co-authored by Claude Opus 5
parent 944d98389f
commit b23e832a73
15 changed files with 2598 additions and 150 deletions
+110 -49
View File
@@ -10,7 +10,8 @@
//! is what the test suite uses, so the entire data plane — handshake,
//! encryption, routing — is exercised without touching the host.
//! * `SystemTun`, behind the `tun-device` feature, is a real TUN interface.
//! Creating one needs `CAP_NET_ADMIN` on Linux or the equivalent elsewhere.
//! Creating one needs `CAP_NET_ADMIN`; attaching to one somebody else
//! prepared needs nothing.
use std::net::Ipv6Addr;
use std::sync::Arc;
@@ -21,7 +22,7 @@ use crate::BoxFuture;
use crate::dataplane::PluginError;
/// What a device should look like once created.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TunRequest {
/// Interface name to ask for.
pub name: String,
@@ -37,6 +38,23 @@ pub struct TunRequest {
pub mtu: u32,
}
impl TunRequest {
/// A request carrying nothing but a name and an MTU.
///
/// Used where the addresses have already been applied to the host, so the
/// device itself only needs opening.
pub fn bare(name: impl Into<String>, mtu: u32) -> Self {
Self {
name: name.into(),
address: Ipv6Addr::UNSPECIFIED,
prefix_len: 0,
address_v4: None,
prefix_len_v4: 0,
mtu,
}
}
}
/// A packet interface.
///
/// `recv` yields packets the operating system wants sent; `send` delivers
@@ -66,6 +84,28 @@ pub trait TunFactory: Send + Sync + std::fmt::Debug + 'static {
&'a self,
request: TunRequest,
) -> BoxFuture<'a, Result<Arc<dyn TunDevice>, PluginError>>;
/// Applies a changed request to an interface that already exists.
///
/// The overlay IPv4 address is allocated at run time, so it can change
/// while the agent runs. A factory that manages the host applies that to
/// the live interface, without recreating it: recreating would drop every
/// tunnel riding on it.
///
/// The default does nothing, which is right for a factory that only
/// attaches to an interface somebody else prepared.
fn reconfigure<'a>(&'a self, _request: TunRequest) -> BoxFuture<'a, Result<(), PluginError>> {
Box::pin(async move { Ok(()) })
}
/// Removes an interface this factory created.
///
/// Runs on the teardown path, so it reports rather than fails: there is
/// nothing useful to do about a failure at that point, and an interface
/// that is already gone is the desired outcome anyway.
fn destroy<'a>(&'a self, _name: &'a str) -> BoxFuture<'a, ()> {
Box::pin(async move {})
}
}
/// An in-memory packet interface.
@@ -196,6 +236,8 @@ impl TunFactory for MemoryTunFactory {
}
}
#[cfg(feature = "tun-device")]
pub(crate) use system::open_tun;
#[cfg(feature = "tun-device")]
pub use system::{
Assigned, SystemTunFactory, interface_addresses, interface_exists, parse_if_inet6,
@@ -223,11 +265,16 @@ mod system {
/// 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.
/// `SystemTunFactory` is the **attach** path, for a host where the agent
/// has no privileges at all: the interface and its addresses were put
/// there by something else, so it checks they are present and says
/// exactly what to run if they are not, rather than coming up in a state
/// where no traffic could ever arrive.
///
/// The other path is
/// [`ManagedTunFactory`](super::super::provision::ManagedTunFactory),
/// where the agent creates and configures the interface itself. That is
/// the default on Linux and needs no preparation at all.
pub struct SystemTun {
name: String,
mtu: u32,
@@ -426,6 +473,60 @@ mod system {
commands
}
/// Opens the TUN interface, creating it if it is not already there.
///
/// Synchronous, and deliberately so: on the managed path the caller holds
/// a capability guard across this call, and a guard must not span an
/// `await` because Linux capabilities are per thread.
///
/// `attach_only` says the interface already exists and was prepared by
/// something else, so nothing beyond `TUNSETIFF` is issued — reconfiguring
/// it would need exactly the privileges that path is avoiding.
pub(crate) fn open_tun(
request: &TunRequest,
attach_only: bool,
) -> Result<Arc<dyn TunDevice>, PluginError> {
let mut config = tun::Configuration::default();
config.tun_name(&request.name);
config.platform_config(|platform| {
// The crate's own root check is not the check we want: the
// managed path holds CAP_NET_ADMIN without being root, and the
// attach path needs no privileges at all. Whether the open
// succeeds is the honest answer either way.
platform.ensure_root_privileges(false);
});
// Packet information stays off, so reads and writes are raw IP
// packets. `ip tuntap add ... mode tun` also defaults to no packet
// information, so the flags match when attaching to one.
let device = tun::create_as_async(&config).map_err(|err| {
let hint = if attach_only {
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 grant it with \
`setcap cap_net_admin+p`, or prepare the interface once as root \
(see `tsunagi tun-setup`) and run unprivileged.",
request.name
)
};
PluginError::Unavailable(hint)
})?;
let (reader, writer) = tokio::io::split(device);
Ok(Arc::new(SystemTun {
name: request.name.clone(),
mtu: request.mtu,
reader: Mutex::new(reader),
writer: Mutex::new(writer),
}) as Arc<dyn TunDevice>)
}
fn current_user() -> String {
std::env::var("SUDO_USER")
.or_else(|_| std::env::var("USER"))
@@ -468,41 +569,7 @@ mod system {
)));
}
let mut config = tun::Configuration::default();
config.tun_name(&request.name);
if existed {
// Attach only. Reconfiguring an interface somebody
// prepared for us would need exactly the privileges we
// are avoiding, so no ioctl beyond TUNSETIFF is issued.
config.platform_config(|platform| {
platform.ensure_root_privileges(false);
});
} else {
// We are creating it, so we configure it.
config.mtu(request.mtu as u16).up();
}
// Packet information stays off, so reads and writes are raw IP
// packets. `ip tuntap add ... mode tun` also defaults to no
// packet information, so the flags match when attaching.
let device = tun::create_as_async(&config).map_err(|err| {
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)
})?;
let device = open_tun(&request, existed)?;
// An interface we just created has no address yet either.
if let Err(reason) = check_address(&request.name, request.address) {
@@ -512,13 +579,7 @@ mod system {
)));
}
let (reader, writer) = tokio::io::split(device);
Ok(Arc::new(SystemTun {
name: request.name,
mtu: request.mtu,
reader: Mutex::new(reader),
writer: Mutex::new(writer),
}) as Arc<dyn TunDevice>)
Ok(device)
})
}
}