Files
tsunagi/crates/tsunagi-wg-quic/tests/exit_node.rs
T
Aleksandr BogomiakovandClaude b2431d7eb1 Exit nodes: offer one per network, send all traffic through a member
A member can offer itself as an exit node per network (off by default); it is
announced over the control channel (ctrl/4) once its iptables masquerade and
forward rules are in place, with a warning when kernel forwarding is off. A
device picks one member as its exit node via policy routing, with the agent's
own uid exempt from the tunnel; if the exit node disappears the rules stay
and traffic is dropped, loudly reported. CLI: `network exit-node`,
`exit-node`; tray: tile checkbox, warnings and a per-device icon with
confirmation. Linux only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-03 01:14:51 +01:00

314 lines
10 KiB
Rust

//! Exit nodes, end to end: real agents, a real control plane, real iroh data
//! links and real WireGuard, with an in-memory packet interface and a mock
//! host, so no privileges are needed and the machine's network is untouched.
//!
//! One agent offers itself as an exit node; another chooses it, and an
//! ordinary internet packet leaves the second agent's interface, arrives on
//! the first one's, and the answer comes back with the internet's own source
//! address.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::net::Ipv4Addr;
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use iroh::EndpointId;
use tempfile::TempDir;
use tsunagi::dataplane::IpPlugin;
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::identity::NetworkId;
use tsunagi::overlay::{
BroadcastHostRules, MemoryTun, MemoryTunFactory, MockHostRules, OverlayError, TunDevice,
TunFactory, TunRequest,
};
use tsunagi::testing::{network, wait_until};
use tsunagi::{Agent, NetworkStatus};
use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin};
const INTERNET: Ipv4Addr = Ipv4Addr::new(93, 184, 216, 34);
/// An in-memory interface on a host whose rules are a mock.
///
/// Not "on the host": the agent must not go looking for an address on a real
/// link. It only has host *rules*, which is what an exit node needs from it.
#[derive(Debug, Clone)]
struct RuledTuns {
tuns: MemoryTunFactory,
rules: MockHostRules,
}
impl TunFactory for RuledTuns {
fn name(&self) -> &str {
"memory-with-rules"
}
fn on_host(&self) -> bool {
false
}
fn host_rules(&self) -> Option<Arc<dyn BroadcastHostRules>> {
Some(Arc::new(self.rules.clone()))
}
fn create<'a>(
&'a self,
request: TunRequest,
) -> tsunagi::BoxFuture<'a, Result<Arc<dyn TunDevice>, OverlayError>> {
self.tuns.create(request)
}
fn reconfigure<'a>(
&'a self,
request: TunRequest,
) -> tsunagi::BoxFuture<'a, Result<(), OverlayError>> {
self.tuns.reconfigure(request)
}
fn destroy<'a>(&'a self, name: &'a str) -> tsunagi::BoxFuture<'a, ()> {
self.tuns.destroy(name)
}
}
struct Node {
_dir: TempDir,
agent: Agent,
plugin: Arc<WireguardPlugin>,
tuns: MemoryTunFactory,
rules: MockHostRules,
}
impl Node {
async fn spawn(discovery: &SharedMemoryDiscovery, tag: &str) -> Self {
let dir = TempDir::new().unwrap();
let tuns = MemoryTunFactory::new();
let rules = MockHostRules::new();
let plugin = WireguardPlugin::open(
WireguardConfig::new(dir.path().join("wireguard"))
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
)
.await
.unwrap();
let agent = Agent::spawn(
tsunagi::testing::config_with(dir.path(), discovery)
.with_overlay_ipv4_range(Some(tsunagi::state::DEFAULT_IPV4_RANGE))
.with_interface(
Arc::new(RuledTuns {
tuns: tuns.clone(),
rules: rules.clone(),
}),
tag,
1280,
)
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
)
.await
.unwrap();
Self {
_dir: dir,
agent,
plugin,
tuns,
rules,
}
}
fn id(&self) -> EndpointId {
self.agent.endpoint_id()
}
async fn overlay(&self, network: NetworkId) -> Ipv4Addr {
wait_until("the network agreed an overlay address", || async {
self.plugin.overview(network)?.overlay_address_v4
})
.await
}
async fn tun(&self) -> Arc<MemoryTun> {
let name = wait_until("the packet interface exists", || async {
self.agent.overlay().map(|overlay| overlay.interface)
})
.await;
self.tuns.device(&name).expect("the device was created")
}
async fn wait_for_tunnels(&self, network: NetworkId, count: usize) {
wait_until("established WireGuard tunnels", || async {
let view = self.plugin.overview(network)?;
(view.established_peers() == count).then_some(())
})
.await;
}
async fn status(&self, network: NetworkId) -> NetworkStatus {
self.agent.network_status(network).await.unwrap()
}
}
fn ipv4_packet(source: Ipv4Addr, destination: Ipv4Addr, payload: &[u8]) -> Bytes {
let total = 20 + payload.len();
let mut packet = Vec::with_capacity(total);
packet.push((4 << 4) | 5);
packet.push(0);
packet.extend_from_slice(&(total as u16).to_be_bytes());
packet.extend_from_slice(&[0, 0, 0, 0]);
packet.push(64);
packet.push(253);
packet.extend_from_slice(&[0, 0]);
packet.extend_from_slice(&source.octets());
packet.extend_from_slice(&destination.octets());
packet.extend_from_slice(payload);
Bytes::from(packet)
}
async fn next(tun: &MemoryTun) -> Bytes {
tokio::time::timeout(Duration::from_secs(10), tun.pop_to_os())
.await
.expect("a packet reaches the operating system")
.unwrap()
}
#[tokio::test]
async fn a_member_sends_its_internet_traffic_through_an_exit_node_and_gets_the_answers() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("exit-node");
let server = Node::spawn(&discovery, "texita").await;
let client = Node::spawn(&discovery, "texitb").await;
let id = server.agent.join_network(&name, &secret).await.unwrap();
client.agent.join_network(&name, &secret).await.unwrap();
server.wait_for_tunnels(id, 1).await;
client.wait_for_tunnels(id, 1).await;
let server_addr = server.overlay(id).await;
let client_addr = client.overlay(id).await;
let server_tun = server.tun().await;
let client_tun = client.tun().await;
// Off until its owner turns it on: nothing is announced, and choosing it
// is refused rather than quietly accepted.
assert!(!server.status(id).await.exit_offer);
assert!(
client
.status(id)
.await
.peers
.iter()
.all(|peer| !peer.exit_node)
);
let refused = client.agent.set_exit_via(id, Some(server.id())).await;
assert!(refused.is_err(), "{refused:?}");
assert!(client.status(id).await.exit_via.is_none());
// The server turns it on. Its host rules are applied, and the client
// learns of it over the control channel.
server.agent.set_exit_offer(id, true).await.unwrap();
let plan = server.rules.exit().installed().expect("rules were applied");
assert_eq!(plan.offer, vec![tsunagi::state::DEFAULT_IPV4_RANGE]);
assert!(!plan.client);
let status = server.status(id).await;
assert!(status.exit_offer);
assert!(status.exit_rules.offer.as_ref().unwrap().ok);
wait_until("the client sees an exit node", || async {
client
.status(id)
.await
.peers
.iter()
.any(|peer| peer.endpoint_id == server.id() && peer.exit_node)
.then_some(())
})
.await;
// The client chooses it; its routes are installed.
client
.agent
.set_exit_via(id, Some(server.id()))
.await
.unwrap();
let status = client.status(id).await;
assert_eq!(status.exit_via, Some(server.id()));
assert!(status.exit_via_online);
assert!(
client
.rules
.exit()
.installed()
.expect("client rules")
.client
);
// An ordinary packet for the internet leaves the client's interface and
// arrives on the server's, from the client's overlay address.
client_tun.push_from_os(ipv4_packet(client_addr, INTERNET, b"GET /"));
let request = next(&server_tun).await;
assert_eq!(&request[12..16], &client_addr.octets());
assert_eq!(&request[16..20], &INTERNET.octets());
assert_eq!(&request[20..], b"GET /");
// The host's answer, from the internet's own address, comes back.
server_tun.push_from_os(ipv4_packet(INTERNET, client_addr, b"200 OK"));
let reply = next(&client_tun).await;
assert_eq!(&reply[12..16], &INTERNET.octets());
assert_eq!(&reply[16..20], &client_addr.octets());
assert_eq!(&reply[20..], b"200 OK");
// Members still reach each other directly, exit node or not.
client_tun.push_from_os(ipv4_packet(client_addr, server_addr, b"ping"));
let direct = next(&server_tun).await;
assert_eq!(&direct[12..16], &client_addr.octets());
assert_eq!(&direct[16..20], &server_addr.octets());
// Stopping puts everything back, and the choice is gone.
client.agent.set_exit_via(id, None).await.unwrap();
assert!(client.status(id).await.exit_via.is_none());
assert!(client.rules.exit().installed().is_none());
server.agent.set_exit_offer(id, false).await.unwrap();
assert!(server.rules.exit().installed().is_none());
wait_until("the client sees it withdrawn", || async {
client
.status(id)
.await
.peers
.iter()
.all(|peer| !peer.exit_node)
.then_some(())
})
.await;
client.agent.shutdown().await;
server.agent.shutdown().await;
}
#[tokio::test]
async fn an_agent_that_is_not_offering_drops_a_members_internet_packets() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("exit-node-off");
let server = Node::spawn(&discovery, "texitc").await;
let client = Node::spawn(&discovery, "texitd").await;
let id = server.agent.join_network(&name, &secret).await.unwrap();
client.agent.join_network(&name, &secret).await.unwrap();
server.wait_for_tunnels(id, 1).await;
client.wait_for_tunnels(id, 1).await;
let client_addr = client.overlay(id).await;
let server_tun = server.tun().await;
let client_tun = client.tun().await;
// With no exit node chosen, the client's own interface has nowhere to
// send the packet, and nothing reaches the server.
client_tun.push_from_os(ipv4_packet(client_addr, INTERNET, b"nowhere"));
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(
tokio::time::timeout(Duration::from_millis(300), server_tun.pop_to_os())
.await
.is_err(),
"nothing was sent"
);
assert!(
server.rules.exit().installed().is_none(),
"no rules without the switch"
);
client.agent.shutdown().await;
server.agent.shutdown().await;
}