Every member now also derives an IPv4 address, from the same inputs as its IPv6 one, into 100.64.0.0/10 by default. The range is configurable and IPv4 can be turned off with --no-ipv4. IPv4 is honestly weaker than IPv6 here and the code says so. A 64 bit interface identifier makes an IPv6 collision impossible in practice; IPv4 has nothing like that room, and in a /10 with 50 members two will derive the same address about 0.03% of the time. A mesh with no coordinator cannot allocate around that, so a collision is detected and resolved instead: the member whose public key sorts lower keeps the address, a rule every member computes identically and therefore agrees on. The other keeps IPv6 and is flagged in the status. IPv6 always works; IPv4 almost always works and degrades predictably. Routing and address-ownership enforcement now cover both families: a packet goes to the peer that owns its destination, and a decrypted packet is dropped unless its source is an address derived for the peer that sent it, IPv4 included. Six new tests, among them a real IPv4 packet crossing a tunnel next to an IPv6 one, a spoofed IPv4 source being dropped, and an IPv6-only overlay. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
11 KiB
The WireGuard data plane
WireGuard is the first IP plugin. It carries user traffic between participants while the control plane keeps doing its own job: deciding who is in the network and carrying each participant's opaque announcement.
Module boundaries are in architecture.md, the control protocol in protocol.md, the security consequences in threat-model.md.
Userspace, not the kernel
WireGuard here is boringtun's protocol state machine running in this
process. There is no kernel WireGuard module and no wg tool: the same
code runs everywhere, and the protocol can be exercised in tests without any
privileges at all.
The only privileged step left is creating a packet interface so the operating
system can hand us IP packets, and even that is behind a trait
(TunFactory) with an in-memory implementation.
| needs privileges | what it proves | |
|---|---|---|
MemoryTunFactory |
no | handshake, encryption, routing, address ownership |
SystemTunFactory, attaching |
none, if the interface was prepared | traffic actually reaches the OS |
SystemTunFactory, creating |
CAP_NET_ADMIN |
the same, at the cost of a capability |
SystemTunFactory attaches to an interface that already exists and only
creates one when it does not. A persistent interface created by root and owned
by the user lets the agent run with no privileges at all; see Running
unprivileged in ../README.md.
Where the packets go
The plugin does not know and does not care. It is handed a PacketLink per
peer by the agent and runs a WireGuard tunnel over it:
TUN device (IP packets) PacketLink per peer
| |
v v
destination address -> peer --Tunn.encapsulate--> ciphertext -> transport
source address checked <--Tunn.decapsulate-- ciphertext <- transport
Reachability — hole punching, relay fallback — belongs to the transport, which today is iroh. That is the whole reason the plugin's announcement says who it is and never where it is: there is no address for a peer to advertise, get wrong, or lie about.
Two peers behind NAT work exactly as well as iroh does. iroh hole punches a direct path when it can and falls back to a relay when it cannot; the tunnel rides on whichever it got. There is no separate STUN, no separate hole punching and no second set of NAT problems to solve for WireGuard.
Checking it from outside
tsunagi status asks a running agent over its local control socket and prints
what it sees, including whether each tunnel has actually handshaken. See
../README.md.
Deterministic overlay addressing
A mesh with no coordinator cannot hand out addresses, so everyone derives their own. The result is an IPv6 unique local address (RFC 4193):
prefix (/64) = 0xfd || SHA-256( LP(domain) || LP("prefix") || LP(network_id) )[0..7]
iid (64b) = SHA-256( LP(domain) || LP("interface") || LP(network_id) || LP(wg_public_key) )[0..8]
address = prefix || iid
with domain = "tsunagi-wireguard-overlay-v1" and LP(x) = u32_be(len(x)) || x,
the same unambiguous encoding the rest of the project uses.
Two consequences matter:
- every member of a network derives the same
/64, so the overlay is one subnet that nobody had to allocate; - a member's address is bound to its WireGuard public key, so address ownership can be checked locally rather than believed.
IPv4 alongside IPv6
The overlay is dual stack by default: every member also derives an IPv4
address, from the same inputs, into 100.64.0.0/10 (RFC 6598 shared address
space — deliberately not RFC 1918, so it rarely clashes with the network the
machine is already on). The range is configurable, and IPv4 can be turned off.
IPv4 is weaker than IPv6 here, and the difference is not cosmetic. A 64 bit
interface identifier makes an IPv6 collision impossible in practice. IPv4 has
nothing like that much room: in a /10 with 50 members the chance that two
derive the same address is roughly 0.03%. Small, but not zero, and a mesh with
no coordinator cannot simply allocate around it.
So a collision is detected and resolved rather than assumed away: the member whose WireGuard public key sorts lower keeps the address, a rule every member computes identically and therefore agrees on without exchanging anything. The other member ends up with no IPv4 address and is still fully reachable over IPv6. The status output flags it.
That is the honest summary: IPv6 always works; IPv4 almost always works and degrades predictably when it does not. Allocating IPv4 properly needs the agreed state described in sync-model.md.
Address ownership is enforced, not announced
Kernel WireGuard enforces AllowedIPs. In userspace that is our job, and
device does it on both sides:
- outbound, a packet is routed to the peer that owns its destination address; a destination nobody owns is counted as unroutable and dropped;
- inbound, a decrypted packet is dropped unless its source is exactly the address derived for the peer whose tunnel decrypted it.
Both apply to IPv4 and IPv6 alike.
So a participant cannot receive traffic addressed to somebody else and cannot forge traffic that appears to come from somebody else. A participant who knows the network secret can mint many keys and therefore occupy many addresses, but it cannot choose to collide with an existing member without finding a hash preimage.
The announcement also carries the address the peer believes it has. It is never used — only cross-checked — so a version skew produces a clear rejection rather than silent non-connectivity.
MTU
Two constraints pull against each other.
IPv6 sets a floor of 1280 bytes (RFC 8200), and Linux enforces it
brutally: an interface whose MTU drops below 1280 loses IPv6 entirely — its
/proc/sys/net/ipv6/conf/<dev> directory disappears and ip -6 address add
answers Invalid argument. So the overlay MTU cannot go below 1280, and the
plugin refuses a smaller one at startup instead of letting it fail obscurely.
The transport sets a ceiling. Every packet rides in one datagram and
WireGuard adds 32 bytes, so a link must carry mtu + 32 = 1312 bytes. A direct
QUIC path typically offers around 1380, which fits. A relayed path can offer
less, and then full-size packets do not fit: they are dropped and counted as
dropped_oversize, never truncated, and the plugin reports the exact numbers
when the tunnel is set up.
There is no room left to trade, so the default MTU is exactly 1280. Fragmenting a packet across several datagrams would lift the ceiling and is not implemented.
Lifecycle
- A network is activated → the plugin loads or creates its key for that network, derives the interface name, and creates the packet interface. If that fails — no privileges, for instance — the key and the announcement still work and the interface is retried on the next reconcile.
- A peer announces its key → recorded.
- A data link to that peer arrives → recorded.
- Reconciliation starts a tunnel for every peer that has both, and removes tunnels for peers that lost either.
- A network is deactivated, or the agent shuts down → the interface and every tunnel go away. The key stays, so coming back keeps the same overlay address.
There is no external configuration file and no command line tool, so unlike a kernel-WireGuard setup there is nothing outside this process for anybody to edit. Reconciliation is purely "do the running tunnels match what is known".
Using it
# On both machines
tsunagi up --network lab --secret "$SECRET" --wireguard
See the two-machine walkthrough in ../README.md.
From the library:
use std::sync::Arc;
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
use tsunagi::dataplane::IpPlugin;
use tsunagi::dataplane::wireguard::{MemoryTunFactory, WireguardConfig, WireguardPlugin};
use tsunagi::identity::{NetworkName, NetworkSecret};
use tsunagi::{Agent, Result};
#[tokio::main]
async fn main() -> Result<()> {
let paths = StoragePaths::user_default()?;
// MemoryTunFactory needs no privileges; swap in SystemTunFactory for a
// real interface.
let plugin = WireguardPlugin::open(
WireguardConfig::new(paths.state_dir.join("wireguard")),
Arc::new(MemoryTunFactory::new()),
)
.await
.expect("wireguard plugin");
let agent = Agent::spawn(
AgentConfig::new(paths)
.with_transport(TransportPolicy::N0Defaults)
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
)
.await?;
let network = agent
.join_network(&NetworkName::new("lab")?, &NetworkSecret::generate())
.await?;
if let Some(view) = plugin.overview(network) {
println!("{} on {}", view.interface, view.overlay_address);
}
agent.shutdown().await;
Ok(())
}
Limits and future work
- Full mesh only. Every member runs a tunnel to every other member. Routing through an intermediate participant is not implemented.
- IPv4 addressing can collide. See above: it is resolved deterministically and the loser keeps IPv6, but a proper allocator needs agreed state.
- No routes, DNS or firewall rules. The plugin creates its interface and
nothing else. Anything beyond the overlay
/64is the operator's business. - Membership is session-scoped. A peer leaves the overlay when its control session ends; surviving a long absence is the same future work.
- Userspace costs CPU. Kernel WireGuard is faster. A kernel backend could return behind the same boundary, but it would give up transport-provided NAT traversal unless paired with a local proxy.
- A persistent TUN interface needs
keep_addr_on_down. Without a process attached it has no carrier, and Linux then flushes its IPv6 addresses. The setup printed bytsunagi tun-setupsets it; the agent checks the address is present and usable — not tentative, not DAD-failed — before attaching, and reports what it actually found. - The agent cannot assign the overlay address itself. The
tuncrate sets addresses through an IPv4-only ioctl, so the IPv6 overlay address must come fromip -6 address addor an equivalent. The agent verifies the address is present, via/proc/net/if_inet6, and refuses with the exact command rather than running an interface that could never receive anything. Doing it in-process would mean speaking netlink, which is not implemented. - The system interface path is not exercised by the default suite, because it needs privileges. Everything else about the data plane is.