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
+30 -5
View File
@@ -12,7 +12,7 @@
//! * `SystemTun`, behind the `tun-device` feature, is a real TUN interface.
//! Creating one needs `CAP_NET_ADMIN` on Linux or the equivalent elsewhere.
use std::net::Ipv6Addr;
use std::net::{Ipv4Addr, Ipv6Addr};
use std::sync::Arc;
use bytes::Bytes;
@@ -29,6 +29,8 @@ pub struct TunRequest {
pub address: Ipv6Addr,
/// Prefix length of the overlay subnet, so the OS routes it here.
pub prefix_len: u8,
/// The IPv4 overlay address and its prefix length, when dual stack.
pub address_v4: Option<(Ipv4Addr, u8)>,
/// Interface MTU.
pub mtu: u32,
}
@@ -381,7 +383,7 @@ mod system {
/// reason: duplicate address detection can never finish with no carrier,
/// and the address would stay tentative and unusable.
pub fn setup_commands(request: &TunRequest, user: &str) -> Vec<String> {
vec![
let mut commands = vec![
format!(
"sudo ip tuntap add dev {} mode tun user {user}",
request.name
@@ -398,7 +400,16 @@ mod system {
"sudo ip -6 address add {}/{} dev {} nodad",
request.address, request.prefix_len, request.name
),
]
];
if let Some((address, prefix_len)) = request.address_v4 {
// IPv4 is not sensitive to carrier the way IPv6 is, so it needs
// no extra settings.
commands.push(format!(
"sudo ip address add {address}/{prefix_len} dev {}",
request.name
));
}
commands
}
fn current_user() -> String {
@@ -578,7 +589,8 @@ fd559caf9652cb86321feac65c73bd84 05 40 00 08 tsun0
name: "tsun0".into(),
address: "fd00::1".parse().unwrap(),
prefix_len: 64,
mtu: 1100,
address_v4: Some(("100.64.1.2".parse().unwrap(), 10)),
mtu: 1280,
};
let commands = setup_commands(&request, "someone");
@@ -586,11 +598,24 @@ fd559caf9652cb86321feac65c73bd84 05 40 00 08 tsun0
// must survive losing carrier, and it must not wait for duplicate
// address detection that can never complete.
let joined = commands.join("\n");
let up = joined.find("link set dev tsun0 mtu 1100 up").unwrap();
let up = joined.find("link set dev tsun0 mtu 1280 up").unwrap();
let keep = joined.find("keep_addr_on_down=1").unwrap();
let add = joined.find("address add fd00::1/64").unwrap();
assert!(up < keep && keep < add, "wrong order:\n{joined}");
assert!(joined.contains("nodad"));
assert!(joined.contains("user someone"));
// IPv4 needs no carrier tricks, just the address.
assert!(joined.contains("ip address add 100.64.1.2/10 dev tsun0"));
// An IPv6-only overlay says nothing about IPv4.
let v6_only = TunRequest {
address_v4: None,
..request
};
assert!(
!setup_commands(&v6_only, "someone")
.join("\n")
.contains("100.64")
);
}
}