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
+110 -1
View File
@@ -20,7 +20,7 @@
//! but it cannot choose to collide with an existing member's address without
//! finding a hash preimage.
use std::net::Ipv6Addr;
use std::net::{Ipv4Addr, Ipv6Addr};
use sha2::{Digest, Sha256};
@@ -37,6 +37,13 @@ pub const OVERLAY_PREFIX_LEN: u8 = 64;
/// Prefix length of one member's address inside the overlay.
pub const OVERLAY_HOST_PREFIX_LEN: u8 = 128;
/// Default IPv4 overlay range: RFC 6598 shared address space.
///
/// Deliberately not RFC 1918, so it rarely collides with the home or office
/// network the machine is already on. It can collide with a carrier-grade NAT
/// that uses the same range, which is why it is configurable.
pub const DEFAULT_IPV4_RANGE: (Ipv4Addr, u8) = (Ipv4Addr::new(100, 64, 0, 0), 10);
fn push_lp(out: &mut Vec<u8>, bytes: &[u8]) {
let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
out.extend_from_slice(&len.to_be_bytes());
@@ -85,6 +92,49 @@ pub fn overlay_address(network: NetworkId, key: &WgPublicKey) -> Ipv6Addr {
Ipv6Addr::from(octets)
}
/// The IPv4 address a member with `key` has in `network`.
///
/// # Why this is weaker than the IPv6 derivation
///
/// A 64 bit interface identifier makes an IPv6 collision impossible in
/// practice. IPv4 has nothing like that much room, so two members *can* derive
/// the same address. In a `/10` with 50 members the chance is roughly 0.03%,
/// which is small but real, so it is detected and resolved rather than
/// assumed away — see [`super::device`]. IPv6 remains the address that always
/// works.
///
/// Returns `None` when the range has no room for hosts.
pub fn overlay_address_v4(
network: NetworkId,
key: &WgPublicKey,
range: (Ipv4Addr, u8),
) -> Option<Ipv4Addr> {
let (base, prefix_len) = range;
if prefix_len > 32 {
return None;
}
let host_bits = 32 - u32::from(prefix_len);
// A usable range needs a network address, a broadcast address and at
// least one host between them.
if host_bits < 2 {
return None;
}
let hash = digest("ipv4", network, Some(key));
let raw = u32::from_be_bytes([hash[0], hash[1], hash[2], hash[3]]);
let usable = (1u64 << host_bits) - 2;
let offset = (u64::from(raw) % usable) + 1;
let mask = if host_bits == 32 {
0
} else {
u32::MAX << host_bits
};
let network_part = u32::from(base) & mask;
Some(Ipv4Addr::from(network_part | offset as u32))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@@ -132,6 +182,65 @@ mod tests {
assert_ne!(overlay_prefix(first), overlay_prefix(second));
}
#[test]
fn ipv4_addresses_land_inside_the_range_and_avoid_its_edges() {
let id = network("v4");
let range = DEFAULT_IPV4_RANGE;
for byte in 0..64u8 {
let key = WgPublicKey::from_bytes([byte; 32]);
let addr = overlay_address_v4(id, &key, range).unwrap();
let raw = u32::from(addr);
assert_eq!(
raw & 0xffc0_0000,
u32::from(range.0),
"outside 100.64.0.0/10"
);
// Never the network address and never the broadcast address.
assert_ne!(raw & 0x003f_ffff, 0);
assert_ne!(raw & 0x003f_ffff, 0x003f_ffff);
}
}
#[test]
fn ipv4_derivation_is_deterministic_and_scoped_like_ipv6() {
let key = WgPublicKey::from_bytes([9u8; 32]);
let first = network("one");
let second = network("two");
let range = DEFAULT_IPV4_RANGE;
assert_eq!(
overlay_address_v4(first, &key, range),
overlay_address_v4(first, &key, range)
);
assert_ne!(
overlay_address_v4(first, &key, range),
overlay_address_v4(second, &key, range)
);
assert_ne!(
overlay_address_v4(first, &key, range),
overlay_address_v4(first, &WgPublicKey::from_bytes([10u8; 32]), range)
);
// A different range moves everybody.
assert_ne!(
overlay_address_v4(first, &key, range),
overlay_address_v4(first, &key, (Ipv4Addr::new(10, 0, 0, 0), 8))
);
}
#[test]
fn a_range_with_no_room_yields_nothing() {
let id = network("tiny");
let key = WgPublicKey::from_bytes([1u8; 32]);
// /31 and /32 have no usable host addresses.
assert!(overlay_address_v4(id, &key, (Ipv4Addr::new(10, 0, 0, 0), 31)).is_none());
assert!(overlay_address_v4(id, &key, (Ipv4Addr::new(10, 0, 0, 1), 32)).is_none());
assert!(overlay_address_v4(id, &key, (Ipv4Addr::new(10, 0, 0, 0), 33)).is_none());
// A /30 has two usable addresses.
assert!(overlay_address_v4(id, &key, (Ipv4Addr::new(10, 0, 0, 0), 30)).is_some());
// A /0 must not overflow.
assert!(overlay_address_v4(id, &key, (Ipv4Addr::UNSPECIFIED, 0)).is_some());
}
#[test]
fn addresses_are_never_the_subnet_router_anycast_address() {
let id = network("anycast");