Implement fast userspace multihop mesh routing

Replace the one-intermediate-peer relay with protocol-scoped connectivity
graphs and precomputed shortest-path/ECMP forwarding snapshots. Independent
transport readers forward opaque transit frames without a plugin or TUN
round trip. Carry source, destination, a bounded hop limit and stable flow
tags; preserve end-to-end WireGuard links across topology changes.

Classify IP flows before encryption and preserve their tags through the
WireGuard pending queue. Add offline four-agent path-change coverage, loop
and isolation tests, and an opt-in release forwarding microbenchmark. Bump
control/data ALPNs while preserving persistent network identities and state.

Also include the pending Windows Mainline idle-timeout fix and its regression
test, using a reproducible vendored dependency patch.

Validation: fmt, workspace Clippy with warnings denied, and 307 release tests
passed. Two pre-existing Windows SQLite wipe failures were excluded; public
DHT and the manual benchmark remain ignored by default. The forwarding
microbenchmark measured 103 ns (64 B) and 202 ns (1280 B) per transit packet,
excluding encryption and socket I/O.
This commit is contained in:
ab
2026-09-22 18:08:26 +03:00
parent d1a0eca723
commit b4f3e57c8d
50 changed files with 11073 additions and 857 deletions
+160 -21
View File
@@ -28,7 +28,7 @@
//! system level and signed by its holder, and that is what is compared
//! against.
use std::collections::HashMap;
use std::collections::{HashMap, VecDeque};
use std::net::Ipv4Addr;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
@@ -39,6 +39,7 @@ use bytes::Bytes;
use iroh::EndpointId;
use tokio::task::JoinHandle;
use tsunagi::dataplane::routing::{FlowId, flow::ip_flow};
use tsunagi::dataplane::transport::{SharedLink, TransportError};
use tsunagi::dataplane::{PacketSink, PluginError};
use tsunagi::identity::NetworkId;
@@ -54,6 +55,66 @@ const TIMER_INTERVAL: Duration = Duration::from_millis(250);
/// Scratch space for one encapsulate or decapsulate call.
const SCRATCH: usize = 4096;
/// Mirrors boringtun 0.7's bounded pending-packet FIFO with opaque flow tags.
/// All three operations share the same mutex as the cryptographic state.
struct FlowTunnel {
tunn: Tunn,
queued: VecDeque<FlowId>,
}
fn is_data(result: &TunnResult<'_>) -> bool {
matches!(result, TunnResult::WriteToNetwork(bytes) if bytes.starts_with(&[4, 0, 0, 0]))
}
impl FlowTunnel {
fn encapsulate<'a>(
&mut self,
packet: &[u8],
scratch: &'a mut [u8],
flow: FlowId,
) -> (TunnResult<'a>, FlowId) {
let result = self.tunn.encapsulate(packet, scratch);
if is_data(&result) {
return (result, flow);
}
// MAX_QUEUE_DEPTH in boringtun 0.7: the newest packet is dropped at 256.
if self.queued.len() < 256 {
self.queued.push_back(flow);
}
(result, 0)
}
fn decapsulate<'a>(
&mut self,
packet: &[u8],
scratch: &'a mut [u8],
) -> (TunnResult<'a>, FlowId) {
let result = self.tunn.decapsulate(None, packet, scratch);
let mut flow = 0;
if packet.is_empty() {
if is_data(&result) {
flow = self.queued.pop_front().unwrap_or(0);
} else if let Some(pending) = self.queued.pop_front() {
// Without a session, draining pops the oldest IP packet and
// encapsulate queues it again at the back.
self.queued.push_back(pending);
}
}
(result, flow)
}
fn update_timers<'a>(&mut self, scratch: &'a mut [u8]) -> TunnResult<'a> {
let result = self.tunn.update_timers(scratch);
if matches!(
result,
TunnResult::Err(boringtun::noise::errors::WireGuardError::ConnectionExpired)
) {
self.queued.clear();
}
result
}
}
/// Counters for one peer's tunnel.
#[derive(Debug, Default)]
struct PeerCounters {
@@ -120,7 +181,7 @@ struct Peer {
/// allocates it, the peer signs the claim, and every protocol carries
/// traffic for the same address.
overlay_v4: Mutex<Option<Ipv4Addr>>,
tunn: Mutex<Tunn>,
tunn: Mutex<FlowTunnel>,
link: SharedLink,
counters: Arc<PeerCounters>,
task: Mutex<Option<JoinHandle<()>>>,
@@ -165,7 +226,7 @@ impl Peer {
Err(poisoned) => poisoned.into_inner(),
};
PeerHealth {
since_handshake: guard.time_since_last_handshake(),
since_handshake: guard.tunn.time_since_last_handshake(),
}
}
}
@@ -254,32 +315,34 @@ impl WireguardDevice {
return false;
};
let mut scratch = vec![0u8; SCRATCH];
let mut scratch = [0u8; SCRATCH];
let flow = ip_flow(packet);
// The encryption is the whole of what this protocol contributes, so
// it happens here rather than anywhere the packet passes through.
// The lock is released before the send: a slow link must not hold up
// the tunnel's timers.
let len = {
let result = {
let mut tunn = match peer.tunn.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
match tunn.encapsulate(packet, &mut scratch) {
TunnResult::WriteToNetwork(out) => Some(out.len()),
let (result, selected_flow) = tunn.encapsulate(packet, &mut scratch, flow);
match result {
TunnResult::WriteToNetwork(out) => Some((out.len(), selected_flow)),
// No session yet, so nothing to send. Counted as dropped
// rather than reported: the handshake is in flight and the
// next packet will go.
_ => None,
}
};
let Some(len) = len else {
let Some((len, selected_flow)) = result else {
peer.counters
.dropped_no_session
.fetch_add(1, Ordering::Relaxed);
return false;
};
send_to_peer(&peer, &scratch[..len]);
send_to_peer_flow(&peer, &scratch[..len], selected_flow);
peer.counters.tx_packets.fetch_add(1, Ordering::Relaxed);
peer.counters
.tx_bytes
@@ -320,7 +383,10 @@ impl WireguardDevice {
endpoint_id,
public_key,
overlay_v4: Mutex::new(overlay_v4),
tunn: Mutex::new(tunn),
tunn: Mutex::new(FlowTunnel {
tunn,
queued: VecDeque::new(),
}),
link,
counters: Arc::new(PeerCounters::default()),
task: Mutex::new(None),
@@ -412,9 +478,7 @@ fn write_lock<T>(lock: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
/// Asks boringtun for a handshake initiation and sends it.
///
/// Encapsulating an empty packet is how the protocol state machine is told
/// "there is something to say"; with no session yet it answers with the
/// handshake initiation.
/// This does not enqueue an empty IP packet in boringtun's pending queue.
fn kick_handshake(peer: &Peer) {
let mut scratch = vec![0u8; SCRATCH];
let len = {
@@ -422,7 +486,7 @@ fn kick_handshake(peer: &Peer) {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
match tunn.encapsulate(&[], &mut scratch) {
match tunn.tunn.format_handshake_initiation(&mut scratch, false) {
TunnResult::WriteToNetwork(out) => Some(out.len()),
_ => None,
}
@@ -434,7 +498,11 @@ fn kick_handshake(peer: &Peer) {
/// Sends whatever boringtun produced, without holding the tunnel lock.
fn send_to_peer(peer: &Peer, payload: &[u8]) {
match peer.link.send(Bytes::copy_from_slice(payload)) {
send_to_peer_flow(peer, payload, 0);
}
fn send_to_peer_flow(peer: &Peer, payload: &[u8], flow: FlowId) {
match peer.link.send_flow(Bytes::copy_from_slice(payload), flow) {
Ok(()) => {}
Err(TransportError::TooLarge { .. }) => {
peer.counters
@@ -449,12 +517,12 @@ fn send_to_peer(peer: &Peer, payload: &[u8]) {
}
async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
let mut scratch = vec![0u8; SCRATCH];
loop {
let Some(datagram) = peer.link.recv().await else {
return;
};
let mut scratch = vec![0u8; SCRATCH];
// boringtun may need several passes: a handshake reply first, then
// any packets that were queued while the session was coming up.
let mut input: Option<&[u8]> = Some(&datagram);
@@ -464,8 +532,9 @@ async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
match tunn.decapsulate(None, input.unwrap_or(&[]), &mut scratch) {
TunnResult::WriteToNetwork(out) => Outcome::ToNetwork(out.len()),
let (result, flow) = tunn.decapsulate(input.unwrap_or(&[]), &mut scratch);
match result {
TunnResult::WriteToNetwork(out) => Outcome::ToNetwork(out.len(), flow),
// The source boringtun reports is not consulted here:
// whether the peer may use it is checked where the
// claims live.
@@ -480,8 +549,8 @@ async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
};
match outcome {
Outcome::ToNetwork(len) => {
send_to_peer(&peer, &scratch[..len]);
Outcome::ToNetwork(len, flow) => {
send_to_peer_flow(&peer, &scratch[..len], flow);
// Keep draining with an empty datagram, as boringtun asks.
input = None;
continue;
@@ -515,7 +584,7 @@ async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
}
enum Outcome {
ToNetwork(usize),
ToNetwork(usize, FlowId),
ToTunnel(usize),
Done,
Failed,
@@ -550,3 +619,73 @@ async fn drive_timers(inner: Arc<Inner>) {
}
}
}
#[cfg(test)]
mod flow_tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
fn tunnel(ours: &WgSecretKey, theirs: &WgSecretKey) -> FlowTunnel {
FlowTunnel {
tunn: Tunn::new(
ours.to_static_secret(),
theirs.public().into_x25519(),
None,
None,
1,
None,
),
queued: VecDeque::new(),
}
}
fn wire(result: TunnResult<'_>) -> Vec<u8> {
match result {
TunnResult::WriteToNetwork(bytes) => bytes.to_vec(),
_ => panic!("expected WireGuard frame"),
}
}
#[test]
fn flow_tags_survive_pending_queue_overflow_handshake_and_encryption() {
let ak = WgSecretKey::from_bytes(&[1; 32]);
let bk = WgSecretKey::from_bytes(&[2; 32]);
let mut a = tunnel(&ak, &bk);
let mut b = tunnel(&bk, &ak);
let mut scratch = [0u8; SCRATCH];
let mut packet = [0u8; 40];
packet[0] = 0x45;
packet[2..4].copy_from_slice(&40u16.to_be_bytes());
packet[9] = 6;
packet[12..20].copy_from_slice(&[10, 0, 0, 1, 10, 0, 0, 2]);
let (result, flow) = a.encapsulate(&packet, &mut scratch, 100);
assert_eq!(
flow, 0,
"handshake does not masquerade as application traffic"
);
let hello = wire(result);
for flow in 101..400 {
let _ = a.encapsulate(&packet, &mut scratch, flow);
}
assert_eq!(a.queued.len(), 256);
let response = wire(b.decapsulate(&hello, &mut scratch).0);
let keepalive = wire(a.decapsulate(&response, &mut scratch).0);
let _ = b.decapsulate(&keepalive, &mut scratch);
for expected in 100..356 {
let (result, flow) = a.decapsulate(&[], &mut scratch);
assert_eq!(flow, expected);
let ciphertext = wire(result);
match b.decapsulate(&ciphertext, &mut scratch).0 {
TunnResult::WriteToTunnelV4(bytes, _) => assert_eq!(bytes, packet),
_ => panic!("queued IP packet must decrypt"),
}
}
assert!(a.queued.is_empty());
assert!(matches!(
a.decapsulate(&[], &mut scratch).0,
TunnResult::Done
));
let (result, flow) = a.encapsulate(&packet, &mut scratch, 12345);
assert!(is_data(&result));
assert_eq!(flow, 12345);
}
}
+117
View File
@@ -1510,3 +1510,120 @@ async fn a_peer_with_no_direct_path_is_reached_through_one_that_has_both() {
b.shutdown().await;
middle.shutdown().await;
}
#[tokio::test]
async fn a_chain_routes_through_two_transit_peers_without_touching_their_tuns() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("wg-multihop");
let mut agents = Vec::new();
let mut blocked = Vec::new();
for index in 0..4 {
let cuts = Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()));
agents.push(WgAgent::spawn_cut_off(&discovery, &format!("mh{index}"), cuts.clone()).await);
blocked.push(cuts);
}
// The authenticated control plane stays connected. The only available
// data links are A--B--C--D; A and D need two transit routers.
for (i, cuts) in blocked.iter().enumerate() {
for (j, agent) in agents.iter().enumerate() {
if i.abs_diff(j) > 1 {
cuts.lock().unwrap().insert(agent.endpoint_id());
}
}
}
let mut id = None;
for agent in &agents {
id = Some(agent.agent.join_network(&name, &secret).await.unwrap());
}
let id = id.unwrap();
for agent in &agents {
wait_for_peers(&agent.agent, id, 3).await;
agent.wait_for_tunnels(id, 3).await;
}
let a = &agents[0];
let d = &agents[3];
let a_addr = a.overlay(id).await;
let d_addr = d.overlay(id).await;
let a_tun = a.tun(id).await;
let d_tun = d.tun(id).await;
for (from, to, source, destination) in [
(&a_tun, &d_tun, a_addr, d_addr),
(&d_tun, &a_tun, d_addr, a_addr),
] {
let packet = tcp_packet(source, destination, 1280);
from.push_from_os(packet.clone());
let received = tokio::time::timeout(tsunagi::testing::DEADLINE, to.pop_to_os())
.await
.unwrap()
.unwrap();
assert_eq!(received, packet);
}
for middle in &agents[1..3] {
assert!(
middle
.agent
.network_status(id)
.await
.unwrap()
.relay
.forwarded
> 0
);
assert!(
tokio::time::timeout(Duration::from_millis(100), middle.tun(id).await.pop_to_os())
.await
.is_err(),
"transit ciphertext must never enter the intermediate host's TUN"
);
}
// A direct shortcut wins immediately; losing it restores the chain using
// the same end-to-end tunnels and overlay addresses.
for allow_direct in [true, false] {
for (from, to) in [(0, 3), (3, 0)] {
if allow_direct {
blocked[from]
.lock()
.unwrap()
.remove(&agents[to].endpoint_id());
} else {
blocked[from]
.lock()
.unwrap()
.insert(agents[to].endpoint_id());
}
agents[from].agent.recheck_network(id).await.unwrap();
}
for (from, to) in [(a, d), (d, a)] {
wait_until("route follows the changed topology", || async {
let view = from.plugin.overview(id)?;
let tunnel = view
.peers
.iter()
.find(|peer| peer.endpoint_id == to.endpoint_id())?
.tunnel
.as_ref()?;
let relayed = tunnel.path.contains("relay 3 hops");
(tunnel.health.is_up()
&& if allow_direct {
!tunnel.path.contains("relay") && !tunnel.path.contains("unreachable")
} else {
relayed
})
.then_some(())
})
.await;
}
let packet = tcp_packet(a_addr, d_addr, 1280);
a_tun.push_from_os(packet.clone());
assert_eq!(
tokio::time::timeout(tsunagi::testing::DEADLINE, d_tun.pop_to_os())
.await
.unwrap()
.unwrap(),
packet
);
}
for agent in agents {
agent.shutdown().await;
}
}