2026-09-21 11:55:20 +01:00
|
|
|
# The WireGuard data plane
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
WireGuard is the first IP plugin. It carries user traffic between
|
2026-09-21 11:07:31 +01:00
|
|
|
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](architecture.md), the control
|
2026-09-21 11:55:20 +01:00
|
|
|
protocol in [protocol.md](protocol.md), the security consequences in
|
2026-09-21 11:07:31 +01:00
|
|
|
[threat-model.md](threat-model.md).
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
## Userspace, not the kernel
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
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 |
|
2026-09-21 12:23:37 +01:00
|
|
|
| `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](../README.md#running-unprivileged).
|
2026-09-21 11:55:20 +01:00
|
|
|
|
|
|
|
|
[boringtun]: https://docs.rs/boringtun
|
|
|
|
|
[`TunFactory`]: https://docs.rs/tsunagi
|
|
|
|
|
|
|
|
|
|
## 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:
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
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.
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 13:00:35 +01:00
|
|
|
## 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](../README.md#checking-that-it-works).
|
|
|
|
|
|
2026-09-21 11:07:31 +01:00
|
|
|
## 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):
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
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;
|
2026-09-21 11:55:20 +01:00
|
|
|
* a member's address is bound to its WireGuard public key, so address
|
|
|
|
|
ownership can be checked locally rather than believed.
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 13:43:01 +01:00
|
|
|
## IPv4: allocated, signed, and kept
|
2026-09-21 13:00:35 +01:00
|
|
|
|
2026-09-21 13:43:01 +01:00
|
|
|
IPv6 addresses are *derived*: a 64 bit interface identifier makes a collision
|
|
|
|
|
impossible in practice, so nobody has to agree on anything. IPv4 has nothing
|
|
|
|
|
like that room, so deriving would collide. Instead an address is **allocated
|
|
|
|
|
and then recorded as a signed fact**, using the model in
|
|
|
|
|
[sync-model.md](sync-model.md).
|
2026-09-21 13:11:09 +01:00
|
|
|
|
|
|
|
|
```text
|
2026-09-21 13:43:01 +01:00
|
|
|
default range 10.13.37.0/24 (override with --ipv4-range)
|
|
|
|
|
who decides the first member to claim; later ones adopt what they find
|
|
|
|
|
who signs the claiming member, with its persistent device key
|
|
|
|
|
where it is kept state.sqlite, and every replica that has seen it
|
|
|
|
|
what a return costs nothing: the old address is reclaimed
|
2026-09-21 13:11:09 +01:00
|
|
|
```
|
|
|
|
|
|
2026-09-21 13:43:01 +01:00
|
|
|
How it works:
|
2026-09-21 13:11:09 +01:00
|
|
|
|
2026-09-21 13:43:01 +01:00
|
|
|
1. On joining, an agent reads back the records it already had and learns more
|
|
|
|
|
from its peers.
|
|
|
|
|
2. If it already holds an address, it keeps it. **That is the whole point**: a
|
|
|
|
|
participant that was away for a month comes back to the address it signed
|
|
|
|
|
for, because the claim outlived the session.
|
|
|
|
|
3. Otherwise it picks a free one — starting from a position derived from its
|
|
|
|
|
own identity, so two newcomers rarely start in the same place — signs the
|
|
|
|
|
claim, commits it together with its version counter, and only then
|
|
|
|
|
announces it.
|
|
|
|
|
4. Every replica merges what it receives into what it has. An author missing
|
|
|
|
|
from a snapshot is left alone: absence is not deletion.
|
|
|
|
|
|
|
|
|
|
**No vote is involved, deliberately.** Anyone who knows the network secret can
|
|
|
|
|
mint identities, so a majority proves nothing, and a quorum would stall with
|
|
|
|
|
one participant online and diverge across a partition. Two members who claim
|
|
|
|
|
the same address at the same moment are resolved by a rule both compute
|
|
|
|
|
identically — the lower endpoint id keeps it — and the loser simply allocates
|
|
|
|
|
again with a higher version.
|
|
|
|
|
|
|
|
|
|
**The range is agreed, not configured per member.** `--ipv4-range` says what
|
|
|
|
|
this agent would use; a network that has already settled on something else
|
|
|
|
|
wins, and the agent adopts it. So the flag matters for whoever starts the
|
|
|
|
|
network and is harmless afterwards. Pass `--ipv4-range none` for an IPv6-only
|
|
|
|
|
overlay.
|
|
|
|
|
|
|
|
|
|
A release tombstone exists in the record type and merges correctly, but
|
|
|
|
|
nothing emits one yet, so an address stays claimed until the network is
|
|
|
|
|
forgotten.
|
2026-09-21 13:00:35 +01:00
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
## Address ownership is enforced, not announced
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
Kernel WireGuard enforces `AllowedIPs`. In userspace that is our job, and
|
|
|
|
|
[`device`] does it on both sides:
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
* **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.
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 13:00:35 +01:00
|
|
|
Both apply to IPv4 and IPv6 alike.
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
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.
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
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.
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
[`device`]: https://docs.rs/tsunagi
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
## MTU
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 12:41:57 +01:00
|
|
|
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.
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
## Lifecycle
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
* 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.
|
2026-09-21 11:07:31 +01:00
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
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".
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
## Using it
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
```bash
|
|
|
|
|
# On both machines
|
|
|
|
|
tsunagi up --network lab --secret "$SECRET" --wireguard
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
See the two-machine walkthrough in [../README.md](../README.md#trying-it-on-two-machines).
|
|
|
|
|
|
|
|
|
|
From the library:
|
|
|
|
|
|
2026-09-21 11:07:31 +01:00
|
|
|
```rust,no_run
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
|
|
|
|
|
use tsunagi::dataplane::IpPlugin;
|
2026-09-21 11:55:20 +01:00
|
|
|
use tsunagi::dataplane::wireguard::{MemoryTunFactory, WireguardConfig, WireguardPlugin};
|
2026-09-21 11:07:31 +01:00
|
|
|
use tsunagi::identity::{NetworkName, NetworkSecret};
|
|
|
|
|
use tsunagi::{Agent, Result};
|
|
|
|
|
|
|
|
|
|
#[tokio::main]
|
|
|
|
|
async fn main() -> Result<()> {
|
|
|
|
|
let paths = StoragePaths::user_default()?;
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
// 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");
|
2026-09-21 11:07:31 +01:00
|
|
|
|
|
|
|
|
let agent = Agent::spawn(
|
|
|
|
|
AgentConfig::new(paths)
|
|
|
|
|
.with_transport(TransportPolicy::N0Defaults)
|
|
|
|
|
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
let network = agent
|
2026-09-21 11:55:20 +01:00
|
|
|
.join_network(&NetworkName::new("lab")?, &NetworkSecret::generate())
|
2026-09-21 11:07:31 +01:00
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
if let Some(view) = plugin.overview(network) {
|
2026-09-21 11:55:20 +01:00
|
|
|
println!("{} on {}", view.interface, view.overlay_address);
|
2026-09-21 11:07:31 +01:00
|
|
|
}
|
2026-09-21 11:55:20 +01:00
|
|
|
agent.shutdown().await;
|
2026-09-21 11:07:31 +01:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
## Limits and future work
|
|
|
|
|
|
2026-09-21 11:55:20 +01:00
|
|
|
* **Full mesh only.** Every member runs a tunnel to every other member.
|
|
|
|
|
Routing through an intermediate participant is not implemented.
|
2026-09-21 13:43:01 +01:00
|
|
|
* **Nothing frees an address yet.** The release record exists and merges, but
|
|
|
|
|
no command emits one.
|
|
|
|
|
* **A snapshot grows with the number of members ever seen**, and is capped per
|
|
|
|
|
message rather than compacted.
|
2026-09-21 11:55:20 +01:00
|
|
|
* **No routes, DNS or firewall rules.** The plugin creates its interface and
|
|
|
|
|
nothing else. Anything beyond the overlay `/64` is 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.
|
2026-09-21 12:36:16 +01:00
|
|
|
* **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 by `tsunagi tun-setup` sets it; the agent checks the address is
|
|
|
|
|
present *and usable* — not tentative, not DAD-failed — before attaching, and
|
|
|
|
|
reports what it actually found.
|
2026-09-21 12:23:37 +01:00
|
|
|
* **The agent cannot assign the overlay address itself.** The `tun` crate sets
|
|
|
|
|
addresses through an IPv4-only ioctl, so the IPv6 overlay address must come
|
|
|
|
|
from `ip -6 address add` or 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.
|