Separate control and data logically, move WireGuard into userspace, add a CLI

Corrects the architecture on two points raised in review, while the project
is still small enough to change cheaply.

1. Control and data are separated *logically*, not physically.

The old reading — "nothing but control may ride on iroh" — threw away iroh's
whole value and would have forced the data plane to reimplement STUN, ICE and
a relay. Now both planes ride on iroh with different ALPNs and different
connections, so the data plane inherits hole punching and relay fallback,
while proto/ still knows nothing about packets and dataplane/ knows nothing
about the control protocol.

New boundary: PacketTransport / PacketLink, an authenticated unreliable
datagram channel per (network, peer, protocol). tsunagi/data/1 runs the same
membership handshake, then DataOpen/DataOpenAck, then QUIC datagrams. Only
the smaller endpoint id dials, so exactly one link exists per pair.

A plugin is handed links and never learns reachability, so the WireGuard
announcement shrank to a public key: there is no address left to lie about.

2. WireGuard now runs in userspace, on boringtun's protocol state machine.

No kernel module, no wg tool, no ip shell-out, no loopback proxy: the wgtool,
backend and bridge modules are gone. Only creating a TUN device needs
privileges, and that sits behind TunFactory, so the entire data plane —
handshake, encryption, routing, address ownership — is tested with none.

Address ownership is enforced rather than believed: outbound packets go to
the owner of the destination address, inbound packets are dropped unless
their source is the address derived for the peer that sent them.

3. A `tsunagi` binary: secret, doctor, id, up. It owns the runtime, the
logging subscriber and Ctrl-C, which the library still refuses to.

Also fixes a reference cycle where IrohTransport held Arc<Inner>, which kept
the databases open and the directory lock held after shutdown; two storage
tests caught it once the cycle existed.

81 tests pass offline with no privileges, including real IPv6 packets
crossing a real WireGuard tunnel over real iroh connections. Verified by
hand: two CLI processes forming a mesh both on loopback and via n0 discovery
using only an endpoint id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tsunagi
2026-09-21 11:55:20 +01:00
co-authored by Claude Opus 5
parent ea7aaa2b69
commit 21be7e9b44
35 changed files with 3987 additions and 2664 deletions
+125
View File
@@ -0,0 +1,125 @@
//! The little bit of IP parsing the data plane needs.
//!
//! Two questions only: which peer should carry this packet, and did the packet
//! that came back really come from that peer? Everything is bounds checked and
//! nothing here can panic on a hostile packet.
use std::net::{Ipv4Addr, Ipv6Addr};
/// The addresses of an IP packet, as far as routing cares.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IpHeader {
/// An IPv4 packet.
V4 {
/// Source address.
source: Ipv4Addr,
/// Destination address.
destination: Ipv4Addr,
},
/// An IPv6 packet.
V6 {
/// Source address.
source: Ipv6Addr,
/// Destination address.
destination: Ipv6Addr,
},
}
impl IpHeader {
/// Reads the addresses out of a packet, or `None` if it is not one.
pub fn parse(packet: &[u8]) -> Option<Self> {
let version = packet.first()? >> 4;
match version {
4 => {
let source: [u8; 4] = packet.get(12..16)?.try_into().ok()?;
let destination: [u8; 4] = packet.get(16..20)?.try_into().ok()?;
Some(IpHeader::V4 {
source: Ipv4Addr::from(source),
destination: Ipv4Addr::from(destination),
})
}
6 => {
let source: [u8; 16] = packet.get(8..24)?.try_into().ok()?;
let destination: [u8; 16] = packet.get(24..40)?.try_into().ok()?;
Some(IpHeader::V6 {
source: Ipv6Addr::from(source),
destination: Ipv6Addr::from(destination),
})
}
_ => None,
}
}
/// The destination, when the packet is IPv6.
pub fn v6_destination(&self) -> Option<Ipv6Addr> {
match self {
IpHeader::V6 { destination, .. } => Some(*destination),
IpHeader::V4 { .. } => None,
}
}
/// The source, when the packet is IPv6.
pub fn v6_source(&self) -> Option<Ipv6Addr> {
match self {
IpHeader::V6 { source, .. } => Some(*source),
IpHeader::V4 { .. } => None,
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
fn ipv6_packet(source: Ipv6Addr, destination: Ipv6Addr) -> Vec<u8> {
let mut packet = vec![0u8; 48];
packet[0] = 6 << 4;
packet[8..24].copy_from_slice(&source.octets());
packet[24..40].copy_from_slice(&destination.octets());
packet
}
#[test]
fn ipv6_addresses_are_read_correctly() {
let source: Ipv6Addr = "fd00::1".parse().unwrap();
let destination: Ipv6Addr = "fd00::2".parse().unwrap();
let header = IpHeader::parse(&ipv6_packet(source, destination)).unwrap();
assert_eq!(header.v6_source(), Some(source));
assert_eq!(header.v6_destination(), Some(destination));
}
#[test]
fn ipv4_addresses_are_read_correctly() {
let mut packet = vec![0u8; 20];
packet[0] = 4 << 4;
packet[12..16].copy_from_slice(&[10, 0, 0, 1]);
packet[16..20].copy_from_slice(&[10, 0, 0, 2]);
let header = IpHeader::parse(&packet).unwrap();
assert_eq!(
header,
IpHeader::V4 {
source: Ipv4Addr::new(10, 0, 0, 1),
destination: Ipv4Addr::new(10, 0, 0, 2),
}
);
// The overlay is IPv6, so the v6 accessors correctly report nothing.
assert_eq!(header.v6_destination(), None);
}
#[test]
fn truncated_and_nonsense_packets_are_rejected_without_panicking() {
assert!(IpHeader::parse(&[]).is_none());
assert!(IpHeader::parse(&[0x60]).is_none());
assert!(IpHeader::parse(&[0x40; 19]).is_none(), "short IPv4");
assert!(IpHeader::parse(&[0x60; 39]).is_none(), "short IPv6");
assert!(IpHeader::parse(&[0x00; 64]).is_none(), "version 0");
assert!(IpHeader::parse(&[0xf0; 64]).is_none(), "version 15");
// Every possible first byte is safe to feed in.
for byte in 0..=u8::MAX {
let _ = IpHeader::parse(&[byte; 64]);
let _ = IpHeader::parse(&[byte]);
}
}
}