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
+164 -1
View File
@@ -10,7 +10,7 @@
mod common;
use std::net::{IpAddr, Ipv6Addr};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::sync::Arc;
use std::time::Duration;
@@ -123,6 +123,24 @@ impl WgAgent {
}
}
/// Builds a minimal well-formed IPv4 packet.
fn ipv4_packet(source: Ipv4Addr, destination: Ipv4Addr, payload: &[u8]) -> Bytes {
let total = 20 + payload.len();
let mut packet = Vec::with_capacity(total);
packet.push((4 << 4) | 5); // version 4, header length 5 words
packet.push(0); // dscp/ecn
packet.extend_from_slice(&(total as u16).to_be_bytes());
packet.extend_from_slice(&[0, 0]); // identification
packet.extend_from_slice(&[0, 0]); // flags and fragment offset
packet.push(64); // ttl
packet.push(253); // an experimental protocol number
packet.extend_from_slice(&[0, 0]); // checksum, not verified by the overlay
packet.extend_from_slice(&source.octets());
packet.extend_from_slice(&destination.octets());
packet.extend_from_slice(payload);
Bytes::from(packet)
}
/// Builds a minimal well-formed IPv6 packet.
fn ipv6_packet(source: Ipv6Addr, destination: Ipv6Addr, payload: &[u8]) -> Bytes {
let mut packet = Vec::with_capacity(40 + payload.len());
@@ -244,6 +262,151 @@ async fn a_peer_cannot_send_from_an_address_it_does_not_own() {
b.shutdown().await;
}
#[tokio::test]
async fn the_overlay_carries_ipv4_alongside_ipv6() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("wg-dual-stack");
let a = WgAgent::spawn(&discovery, "ta").await;
let b = WgAgent::spawn(&discovery, "tb").await;
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
a.wait_for_tunnels(network_id, 1).await;
b.wait_for_tunnels(network_id, 1).await;
let view_a = a.plugin.overview(network_id).unwrap();
let view_b = b.plugin.overview(network_id).unwrap();
let v4_a = view_a.overlay_address_v4.expect("dual stack by default");
let v4_b = view_b.overlay_address_v4.expect("dual stack by default");
assert_ne!(v4_a, v4_b);
// Both inside the configured range.
for addr in [v4_a, v4_b] {
assert_eq!(
u32::from(addr) & 0xffc0_0000,
u32::from(Ipv4Addr::new(100, 64, 0, 0))
);
}
// Each side derived the other's address identically.
assert_eq!(view_a.peers[0].overlay_address_v4, Some(v4_b));
assert_eq!(view_b.peers[0].overlay_address_v4, Some(v4_a));
assert!(!view_a.peers[0].tunnel.as_ref().unwrap().ipv4_conflict);
let tun_a = a.tun(network_id).await;
let tun_b = b.tun(network_id).await;
// A real IPv4 packet through the same tunnel.
tun_a.push_from_os(ipv4_packet(v4_a, v4_b, b"ipv4 over the overlay"));
let received = tokio::time::timeout(common::DEADLINE, tun_b.pop_to_os())
.await
.expect("the IPv4 packet should arrive")
.unwrap();
assert_eq!(received[0] >> 4, 4, "still an IPv4 packet");
assert_eq!(&received[12..16], &v4_a.octets());
assert_eq!(&received[16..20], &v4_b.octets());
assert_eq!(&received[20..], b"ipv4 over the overlay");
// IPv6 keeps working on the same tunnel.
let addr_a = a.overlay(network_id).await;
let addr_b = b.overlay(network_id).await;
tun_a.push_from_os(ipv6_packet(addr_a, addr_b, b"and ipv6 too"));
let received = tokio::time::timeout(common::DEADLINE, tun_b.pop_to_os())
.await
.expect("the IPv6 packet should arrive")
.unwrap();
assert_eq!(received[0] >> 4, 6);
assert_eq!(&received[40..], b"and ipv6 too");
a.shutdown().await;
b.shutdown().await;
}
#[tokio::test]
async fn an_ipv4_source_a_peer_does_not_own_is_dropped() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("wg-v4-spoof");
let a = WgAgent::spawn(&discovery, "ta").await;
let b = WgAgent::spawn(&discovery, "tb").await;
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
a.wait_for_tunnels(network_id, 1).await;
b.wait_for_tunnels(network_id, 1).await;
let v4_a = a
.plugin
.overview(network_id)
.unwrap()
.overlay_address_v4
.unwrap();
let v4_b = b
.plugin
.overview(network_id)
.unwrap()
.overlay_address_v4
.unwrap();
let tun_a = a.tun(network_id).await;
let tun_b = b.tun(network_id).await;
// A claims an IPv4 address that is not the one derived from its key.
let forged = Ipv4Addr::from(u32::from(v4_a) ^ 0x0000_00ff);
tun_a.push_from_os(ipv4_packet(forged, v4_b, b"spoofed v4"));
wait_until("the spoofed IPv4 packet is dropped", || async {
let view = b.plugin.overview(network_id)?;
let tunnel = view.peers.first()?.tunnel.as_ref()?;
(tunnel.stats.dropped_wrong_source >= 1).then_some(())
})
.await;
// The honest one still gets through.
tun_a.push_from_os(ipv4_packet(v4_a, v4_b, b"honest v4"));
let received = tokio::time::timeout(common::DEADLINE, tun_b.pop_to_os())
.await
.expect("the honest packet should arrive")
.unwrap();
assert_eq!(&received[20..], b"honest v4");
a.shutdown().await;
b.shutdown().await;
}
#[tokio::test]
async fn an_ipv6_only_overlay_can_be_asked_for() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("wg-v6-only");
let a = WgAgent::spawn_with(&discovery, "ta", |config| config.with_ipv4_range(None)).await;
let b = WgAgent::spawn_with(&discovery, "tb", |config| config.with_ipv4_range(None)).await;
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
b.agent.join_network(&name, &secret).await.unwrap();
a.wait_for_tunnels(network_id, 1).await;
let view = a.plugin.overview(network_id).unwrap();
assert_eq!(view.overlay_address_v4, None);
assert_eq!(view.ipv4_range, None);
assert_eq!(view.peers[0].overlay_address_v4, None);
// No IPv4 configured is not a conflict.
assert!(!view.peers[0].tunnel.as_ref().unwrap().ipv4_conflict);
// IPv6 is unaffected.
let addr_a = a.overlay(network_id).await;
let addr_b = b.overlay(network_id).await;
a.tun(network_id)
.await
.push_from_os(ipv6_packet(addr_a, addr_b, b"v6 only"));
let received = tokio::time::timeout(common::DEADLINE, b.tun(network_id).await.pop_to_os())
.await
.expect("the packet should arrive")
.unwrap();
assert_eq!(&received[40..], b"v6 only");
a.shutdown().await;
b.shutdown().await;
}
#[tokio::test]
async fn packets_for_an_unknown_address_are_counted_not_broadcast() {
let discovery = SharedMemoryDiscovery::new();