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
+30 -20
View File
@@ -6,15 +6,18 @@
//!
//! The two planes stay separate:
//!
//! * **No user IP traffic goes through iroh.** iroh carries this plugin's
//! announcements and nothing else; the packets themselves travel over
//! WireGuard's own UDP sockets.
//! * **An iroh address is not a WireGuard address.** The plugin gathers its
//! own reachability and advertises that.
//! * **The plugin knows nothing about reachability.** It is handed a
//! [`PacketLink`](crate::dataplane::transport::PacketLink) per peer and
//! bridges the kernel WireGuard device onto it. Hole punching and relaying
//! belong to the transport.
//! * **The announcement says who, not where.** It carries a public key, so
//! there is no address for a peer to lie about.
//! * **The core never parses these announcements.** It moves a bounded opaque
//! blob; only [`announcement`] interprets it.
//! * **Keys are separate.** The plugin has its own key per network, in its own
//! store, unrelated to the iroh device key and to the network secret.
//! * **WireGuard's own crypto is untouched.** The bridge is a pipe; the
//! handshake and encryption run end to end between the two kernels.
//!
//! # How a mesh forms
//!
@@ -24,34 +27,41 @@
//! peer's `AllowedIPs` itself instead of believing what the peer claims — a
//! member cannot route another member's traffic to itself.
//!
//! Each agent then builds its own local configuration with one peer entry per
//! other participant ([`config`]) and hands it to a [`backend`]. The
//! [`backend::RecordingBackend`] applies it in memory, which is what the test
//! suite uses; [`wgtool::WgToolBackend`] drives the real `wg` and `ip` tools
//! and needs Linux with `CAP_NET_ADMIN`.
//! WireGuard itself is [`boringtun`]'s protocol state machine, running in this
//! process: no kernel module, no `wg` tool, the same code on every platform.
//! [`device::WireguardDevice`] drives one tunnel per peer and routes packets
//! between them and a [`tun::TunDevice`].
//!
//! The only part that needs privileges is the packet interface. With
//! [`tun::MemoryTunFactory`] the whole data plane — handshake, encryption,
//! routing, address ownership — runs and is tested with no privileges at all;
//! `SystemTunFactory` swaps in a real interface when you want traffic to
//! reach the operating system.
//!
//! See `docs/wireguard.md` for the full picture.
pub mod announcement;
pub mod backend;
pub mod config;
pub mod device;
pub mod keys;
pub mod overlay;
pub mod packet;
pub mod plugin;
pub mod store;
pub mod wgtool;
pub mod tun;
pub use announcement::{ValidatedAnnouncement, WgAnnouncement};
pub use backend::{BackendCall, RecordingBackend, WireguardBackend};
pub use config::{
Cidr, InterfaceConfig, InterfaceParams, InterfaceState, PeerConfig, PeerState, PortPolicy,
build_interface, interface_name,
};
pub use config::{Cidr, DEFAULT_INTERFACE_PREFIX, MAX_INTERFACE_NAME_LEN, interface_name};
pub use device::{PeerHealth, PeerStats, PeerSummary, WireguardDevice};
pub use keys::{WgPublicKey, WgSecretKey};
pub use overlay::{overlay_address, overlay_prefix};
pub use overlay::{OVERLAY_PREFIX_LEN, overlay_address, overlay_prefix};
pub use packet::IpHeader;
pub use plugin::{
AdvertisePolicy, NetworkOverview, PeerOverview, WIREGUARD_PROTOCOL, WireguardConfig,
DEFAULT_MTU, NetworkOverview, PeerOverview, WIREGUARD_PROTOCOL, WireguardConfig,
WireguardPlugin,
};
pub use store::WgKeyStore;
pub use wgtool::{WgToolBackend, plan_apply, plan_remove};
pub use tun::{MemoryTun, MemoryTunFactory, TunDevice, TunFactory, TunRequest};
#[cfg(feature = "tun-device")]
pub use tun::SystemTunFactory;