Make the overlay dual stack

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>
This commit is contained in:
tsunagi
2026-09-21 13:00:35 +01:00
co-authored by Claude Opus 5
parent be459e5bd0
commit cfab38824d
12 changed files with 638 additions and 46 deletions
+5
View File
@@ -50,6 +50,11 @@ keeping the WireGuard identity, shutdown removing every interface, a forged
overlay claim being rejected, and the core carrying the payload without
interpreting it.
`tests/local_control.rs` covers the local control socket end to end: a client
asking a running agent for status over a real Unix socket, a leftover socket
file being replaced while a live one is not, and the derived socket path
staying short enough to bind.
`tests/discovery.rs` covers the discovery contract itself: a static bootstrap
candidate is enough to join, several backends compose, entries are withdrawn
when a network stops, and a forgotten network stays forgotten across a restart.
+33 -3
View File
@@ -56,6 +56,12 @@ 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](../README.md#checking-that-it-works).
## Deterministic overlay addressing
A mesh with no coordinator cannot hand out addresses, so everyone derives their
@@ -77,6 +83,29 @@ Two consequences matter:
* 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](sync-model.md).
## Address ownership is enforced, not announced
Kernel WireGuard enforces `AllowedIPs`. In userspace that is our job, and
@@ -87,6 +116,8 @@ Kernel WireGuard enforces `AllowedIPs`. In userspace that is our job, and
* **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
@@ -192,9 +223,8 @@ async fn main() -> Result<()> {
* **Full mesh only.** Every member runs a tunnel to every other member.
Routing through an intermediate participant is not implemented.
* **IPv6 overlay only.** Addressing is IPv6 ULA because it can be derived
collision-free. An IPv4 overlay would need an allocator, which needs the
agreed state described in [sync-model.md](sync-model.md).
* **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 `/64` is the operator's business.
* **Membership is session-scoped.** A peer leaves the overlay when its control