Files
tsunagi/crates/tsunagi-wg-quic/tests/local_control.rs
T
tsunagiandClaude Opus 5 41604225ba Let a device leave a network, and start over
Joining was one command and leaving was nothing at all: a network went into
`state.sqlite` on the first `up` and stayed there, so a mistyped secret
left a second network beside the working one with no way to remove it but
editing the database by hand.

`tsunagi network` lists what this device belongs to. `tsunagi network leave
<id>` publishes a signed release first — while the agent is running and its
sessions are up — and only then deactivates the network and removes it. The
order is the whole point: signed state has no expiry, so the tombstone is
the only thing that ever frees the address and the name for the others, and
after the network is gone there is nothing left here to sign one with.
Peers pass it on, so a member that was away hears it from them rather than
from an agent that has already left.

With no agent running nothing can sign or send, and the command says so
instead of quietly succeeding: `--offline` drops the network locally and
says plainly that the others keep the old claim. The outcome always
distinguishes "published to nobody" from "not published at all", because
they leave the network in different states.

A network is named by its id, and a unique prefix will do. The name is
refused on purpose: two networks can share one — that is exactly the
situation this command exists for — and picking between them for the user
is how the wrong one gets left.

The author's version counter deliberately survives. Rejoining the same
network with the same key must continue above the release, or every replica
that holds the release would treat the new claim as stale and the returning
member would be invisible for good. The protocol key does not survive:
rejoining is joining, not resuming, and coming back with a key the network
was told to let go claims an identity nobody holds any more. Plugins learn
about it through a new `on_network_forgotten`, which is about what outlives
a session rather than what a deactivation tears down.

A released member also drops out of the roster `status` prints. The
tombstone stays in the record set — a replica that never heard of it would
otherwise reinstate the old claim — but listing an author that gave
everything up as a member made leaving look like a peer that had broken.

`tsunagi wipe` is the other half: it empties both directories, so the
device identity, every network, every signed record and everything a
protocol kept beside them go at once and the next start is a stranger. It
refuses while an agent holds the directory, and refuses a directory with no
`state.sqlite` in it, so a mistyped `--state-dir` cannot take somebody's
documents with it. Without `--yes` it only prints what it would remove and
what membership would be lost. It is not a goodbye and says so: leaving the
networks first is what frees their addresses.

The local control protocol is 8 — the socket carries a `Leave` request now,
since only the running agent can publish the release.

Exercised end to end against real agents: leaving by prefix released the
address to a connected peer, leaving by name was refused, `--offline` was
refused until asked for explicitly, wipe was refused while the agent ran,
and the directory afterwards had no identity in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:54:05 +01:00

315 lines
12 KiB
Rust

//! The local control interface: a client asking a running agent for status.
//!
//! Uses a real Unix socket on a temporary path, the real agent and the real
//! WireGuard data plane, so what a `tsunagi status` client would see is what
//! is checked here.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::Arc;
use std::time::Duration;
use tempfile::TempDir;
use tsunagi::dataplane::IpPlugin;
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::ipc::unix::{ControlSocket, request_status};
use tsunagi::ipc::{StatusReport, control_socket_path};
use tsunagi::overlay::MemoryTunFactory;
use tsunagi::testing::{config_with, network, wait_for_peers, wait_until};
use tsunagi::{Agent, BoxFuture};
use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin};
/// Builds the report the way the binary does, from the agent plus the plugin.
fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::unix::ReportSource> {
Arc::new(move || -> BoxFuture<'static, StatusReport> {
let agent = agent.clone();
let plugin = Arc::clone(&plugin);
Box::pin(async move {
let status = agent.status().await.unwrap();
let networks = status
.networks
.iter()
.map(|net| tsunagi::ipc::NetworkReport {
name: net.name.to_string(),
network_id: net.network_id.to_string(),
active: true,
peers: net
.peers
.iter()
.map(|peer| tsunagi::ipc::PeerReport {
endpoint_id: peer.endpoint_id.to_string(),
hostname: peer.hostname.clone(),
transport: format!("{:?}", peer.transport),
rtt_ms: peer.rtt.map(|rtt| rtt.as_millis() as u64),
})
.collect(),
overlay: plugin.overview(net.network_id).map(|view| {
tsunagi::ipc::OverlayReport {
// The interface belongs to the agent now.
interface: agent
.overlay()
.map_or_else(String::new, |overlay| overlay.interface),
mtu: agent.overlay().map_or(0, |overlay| overlay.mtu),
address: view.overlay_address_v4.map(|a| a.to_string()),
prefix_len: view.ipv4_range.map_or(0, |range| range.prefix_len),
peers: view
.peers
.iter()
.map(|peer| tsunagi::ipc::OverlayPeerReport {
public_key: peer.public_key.to_string(),
address: peer.overlay_address_v4.map(|a| a.to_string()),
handshake_secs_ago: peer
.tunnel
.as_ref()
.and_then(|t| t.health.since_handshake)
.map(|since| since.as_secs()),
..Default::default()
})
.collect(),
..Default::default()
}
}),
..Default::default()
})
.collect();
StatusReport {
endpoint_id: status.endpoint_id.to_string(),
hostname: status.hostname.clone(),
bound_sockets: status
.bound_sockets
.iter()
.map(ToString::to_string)
.collect(),
cache_healthy: status.cache_healthy,
networks,
dns: None,
}
})
})
}
#[tokio::test]
async fn a_client_sees_the_agent_and_its_overlay() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("control-socket");
let dir_a = TempDir::new().unwrap();
let tuns = MemoryTunFactory::new();
let plugin = WireguardPlugin::open(
WireguardConfig::new(dir_a.path().join("wg"))
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
)
.await
.unwrap();
let agent = Agent::spawn(
config_with(dir_a.path(), &discovery)
.with_interface(Arc::new(tuns), "tca0", 1280)
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
)
.await
.unwrap();
let dir_b = TempDir::new().unwrap();
let plugin_b = WireguardPlugin::open(
WireguardConfig::new(dir_b.path().join("wg"))
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
)
.await
.unwrap();
let agent_b = Agent::spawn(
config_with(dir_b.path(), &discovery)
.with_interface(Arc::new(MemoryTunFactory::new()), "tcb0", 1280)
.with_plugin(plugin_b.clone() as Arc<dyn IpPlugin>),
)
.await
.unwrap();
let network_id = agent.join_network(&name, &secret).await.unwrap();
agent_b.join_network(&name, &secret).await.unwrap();
wait_for_peers(&agent, network_id, 1).await;
// A short path: a Unix socket address is limited to about 100 bytes.
let socket_path = dir_a.path().join("agent.sock");
let control = ControlSocket::bind(&socket_path, source(agent.clone(), plugin.clone()))
.await
.unwrap();
let report = wait_until("the overlay is reported as up", || {
let socket_path = socket_path.clone();
async move {
let report = request_status(&socket_path).await.ok()?;
let overlay = report.networks.first()?.overlay.as_ref()?;
overlay
.peers
.iter()
.any(|peer| peer.is_up())
.then_some(report)
}
})
.await;
assert_eq!(report.endpoint_id, agent.endpoint_id().to_string());
assert_eq!(report.networks.len(), 1);
let net = &report.networks[0];
assert_eq!(net.network_id, network_id.to_string());
assert_eq!(net.peers.len(), 1);
assert_eq!(net.peers[0].endpoint_id, agent_b.endpoint_id().to_string());
let overlay = net.overlay.as_ref().unwrap();
assert!(overlay.interface.starts_with("tca"));
assert_eq!(overlay.mtu, 1280);
assert_eq!(overlay.peers.len(), 1);
// The report carries what a reader needs, in structured form: how it is
// laid out is the CLI's business and is tested there.
assert_eq!(report.endpoint_id, agent.endpoint_id().to_string());
assert_eq!(
overlay.peers.iter().filter(|peer| peer.is_up()).count(),
1,
"the tunnel is up: {:?}",
overlay.peers
);
assert!(overlay.address.is_some());
control.shutdown().await;
assert!(!socket_path.exists(), "the socket is removed on shutdown");
// With nothing listening, a client gets an error rather than hanging.
assert!(request_status(&socket_path).await.is_err());
agent.shutdown().await;
agent_b.shutdown().await;
}
#[tokio::test]
async fn a_leftover_socket_file_is_replaced_but_a_live_one_is_not() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("agent.sock");
let empty: Arc<dyn tsunagi::ipc::unix::ReportSource> =
Arc::new(|| -> BoxFuture<'static, StatusReport> {
Box::pin(async { StatusReport::default() })
});
// A file with nobody listening is a leftover from a crash.
std::fs::write(&path, b"stale").unwrap();
let first = ControlSocket::bind(&path, Arc::clone(&empty))
.await
.unwrap();
assert!(request_status(&path).await.is_ok());
// A live socket is not stolen from the agent that owns it.
let second = ControlSocket::bind(&path, Arc::clone(&empty)).await;
assert!(
matches!(second, Err(tsunagi::Error::StateLocked { .. })),
"a second agent must not take over a live control socket"
);
first.shutdown().await;
}
#[test]
fn the_socket_path_is_derived_and_short_enough() {
let deep = std::path::PathBuf::from(
"/home/someone/.local/share/with/a/very/deeply/nested/directory/that/goes/on/and/on/and/on/tsunagi/state",
);
let path = control_socket_path(&deep);
// A Unix socket address is limited to roughly 100 bytes, so a deep state
// directory must not produce a path that cannot be bound.
if std::env::var_os("XDG_RUNTIME_DIR").is_some() {
assert!(
path.as_os_str().len() < 100,
"derived path is {} bytes: {}",
path.as_os_str().len(),
path.display()
);
}
// Deterministic, and different state directories never share a socket.
assert_eq!(path, control_socket_path(&deep));
assert_ne!(
path,
control_socket_path(&std::path::PathBuf::from("/somewhere/else"))
);
}
/// A source that can also leave, the way the binary's one does.
#[derive(Debug)]
struct Control(Agent);
impl tsunagi::ipc::unix::ReportSource for Control {
fn report(&self) -> BoxFuture<'_, StatusReport> {
Box::pin(async move { StatusReport::default() })
}
fn leave(&self, network_id: String) -> BoxFuture<'_, Result<tsunagi::ipc::LeftReport, String>> {
Box::pin(async move {
let wanted: tsunagi::NetworkId = network_id.parse().map_err(|_| "not an id")?;
let name = self
.0
.list_networks()
.await
.map_err(|err| err.to_string())?
.into_iter()
.find(|network| network.network_id == wanted)
.map(|network| network.name.as_str().to_string())
.ok_or("not a network this agent is in")?;
let outcome = self
.0
.leave_network(wanted)
.await
.map_err(|err| err.to_string())?;
Ok(tsunagi::ipc::LeftReport {
name,
announced: outcome.announced,
peers_told: outcome.peers_told as u32,
})
})
}
}
#[tokio::test]
async fn a_client_can_leave_a_network_through_the_running_agent() {
// The release can only be published by the agent that is running, and
// only while its sessions are up, so leaving goes over this socket
// rather than being done behind its back in the state store.
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("control-leave");
let dir = TempDir::new().unwrap();
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
let other = TempDir::new().unwrap();
let peer = Agent::spawn(config_with(other.path(), &discovery))
.await
.unwrap();
let network_id = agent.join_network(&name, &secret).await.unwrap();
peer.join_network(&name, &secret).await.unwrap();
wait_for_peers(&agent, network_id, 1).await;
let socket_path = dir.path().join("control.sock");
let control = ControlSocket::bind(&socket_path, Arc::new(Control(agent.clone())))
.await
.unwrap();
let report = tsunagi::ipc::unix::leave_network(&socket_path, &network_id.to_string())
.await
.unwrap();
assert_eq!(report.name, name.as_str());
assert!(report.announced);
assert_eq!(report.peers_told, 1);
assert!(agent.list_networks().await.unwrap().is_empty());
// Asking again names the state it is in rather than failing obscurely.
let err = tsunagi::ipc::unix::leave_network(&socket_path, &network_id.to_string())
.await
.unwrap_err();
assert!(err.to_string().contains("not a network"), "{err}");
control.shutdown().await;
agent.shutdown().await;
peer.shutdown().await;
}