diff --git a/crates/tsunagi-cli/src/main.rs b/crates/tsunagi-cli/src/main.rs index 3844184..1570244 100644 --- a/crates/tsunagi-cli/src/main.rs +++ b/crates/tsunagi-cli/src/main.rs @@ -1152,7 +1152,7 @@ fn member_rows<'a>(network: &'a tsunagi::ipc::NetworkReport, own_id: &str) -> Ve let row = entry(&mut rows, &peer.endpoint_id); row.tunnel = Some(peer); if row.overlay_address_v4.is_none() { - row.overlay_address_v4 = peer.address_v4.as_deref(); + row.overlay_address_v4 = peer.address.as_deref(); } } } @@ -1197,13 +1197,11 @@ fn network_section(network: &tsunagi::ipc::NetworkReport, own_id: &str) -> repor Health::Info, "overlay", format!( - "{} {}/{}{} mtu {}", + "{} {} mtu {}", overlay.interface, - overlay.address, - overlay.prefix_len, - match &overlay.address_v4 { - Some(v4) => format!(" and {v4}"), - None => String::new(), + match &overlay.address { + Some(address) => format!("{address}/{}", overlay.prefix_len), + None => "no address agreed yet".to_string(), }, overlay.mtu ), @@ -2055,18 +2053,15 @@ async fn build_report( .map(|view| OverlayReport { interface: view.interface.clone(), mtu: view.mtu, - address: view.overlay_address.to_string(), - address_v4: view.overlay_address_v4.map(|addr| addr.to_string()), - prefix: view.overlay_prefix.to_string(), - prefix_len: view.overlay_prefix_len, + address: view.overlay_address_v4.map(|addr| addr.to_string()), + prefix_len: view.ipv4_range.map_or(0, |range| range.prefix_len), peers: view .peers .iter() .map(|peer| OverlayPeerReport { endpoint_id: peer.endpoint_id.to_string(), public_key: peer.public_key.to_string(), - address: peer.overlay_address.to_string(), - address_v4: peer.overlay_address_v4.map(|addr| addr.to_string()), + address: peer.overlay_address_v4.map(|addr| addr.to_string()), handshake_secs_ago: peer .tunnel .as_ref() @@ -2283,8 +2278,9 @@ async fn print_status(agent: &Agent, network: NetworkId, wireguard: Option<&Wire println!( "wireguard: {} on {}/{} mtu {}, {}/{} tunnel(s) established", view.interface, - view.overlay_address, - view.overlay_prefix_len, + view.overlay_address_v4 + .map_or_else(|| "no address yet".to_string(), |addr| addr.to_string()), + view.ipv4_range.map_or(0, |range| range.prefix_len), view.mtu, view.established_peers(), view.peers.len() @@ -2294,7 +2290,8 @@ async fn print_status(agent: &Agent, network: NetworkId, wireguard: Option<&Wire Some(tunnel) => println!( " {} {} {} tx={} rx={} dropped={} path={}", peer.public_key.fmt_short(), - peer.overlay_address, + peer.overlay_address_v4 + .map_or_else(|| "no address".to_string(), |addr| addr.to_string()), match tunnel.health.since_handshake { Some(since) => format!("handshake {}s ago", since.as_secs()), None => "NOT HANDSHAKEN".to_string(), @@ -2307,7 +2304,8 @@ async fn print_status(agent: &Agent, network: NetworkId, wireguard: Option<&Wire None => println!( " {} {} waiting for a data link", peer.public_key.fmt_short(), - peer.overlay_address + peer.overlay_address_v4 + .map_or_else(|| "no address".to_string(), |addr| addr.to_string()) ), } } @@ -2337,10 +2335,8 @@ mod status_tests { OverlayReport { interface: "tsundemo".into(), mtu: 1280, - address: "fd55::1".into(), - address_v4: Some("10.13.37.69".into()), - prefix: "fd55::".into(), - prefix_len: 64, + address: Some("10.13.37.69".into()), + prefix_len: 24, peers, ..Default::default() } @@ -2350,8 +2346,7 @@ mod status_tests { OverlayPeerReport { endpoint_id: endpoint_id.into(), public_key: "keykeykey".into(), - address: "fd55::2".into(), - address_v4: Some("10.13.37.237".into()), + address: Some("10.13.37.237".into()), handshake_secs_ago: handshake, tx_packets: 32, rx_packets: 887, diff --git a/crates/tsunagi/src/dataplane/wireguard/announcement.rs b/crates/tsunagi/src/dataplane/wireguard/announcement.rs index 6447a88..904ff1a 100644 --- a/crates/tsunagi/src/dataplane/wireguard/announcement.rs +++ b/crates/tsunagi/src/dataplane/wireguard/announcement.rs @@ -10,15 +10,12 @@ //! [`crate::dataplane::transport`]. A plugin that also tried to advertise //! addresses would be reimplementing NAT traversal badly. -use std::net::Ipv6Addr; - use serde::{Deserialize, Serialize}; use crate::dataplane::PluginError; use crate::identity::NetworkId; use super::keys::WgPublicKey; -use super::overlay::overlay_address; /// Version of the announcement format. /// @@ -26,20 +23,27 @@ use super::overlay::overlay_address; /// signed records in [`crate::state`], which carry the range and survive a /// participant being away. postcard is not self-describing, so an older peer /// cannot read a newer announcement; the mismatch is reported, not misparsed. -pub const ANNOUNCEMENT_VERSION: u16 = 3; +pub const ANNOUNCEMENT_VERSION: u16 = 4; /// What one participant advertises for the WireGuard data plane. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WgAnnouncement { /// Announcement format version. pub version: u16, - /// The peer's WireGuard public key. Its overlay address is derived from it. - pub public_key: [u8; 32], - /// The overlay address the peer believes it has. + /// The peer's WireGuard public key. /// - /// Carried for diagnostics and cross-checking only. Addresses are always - /// derived locally, never taken from this field. - pub overlay_address: Ipv6Addr, + /// The whole announcement, now that addresses belong to the system + /// level: this says *who* is at the other end of a tunnel, and nothing + /// about where. + pub public_key: [u8; 32], + /// The network this key is for. + /// + /// Strictly redundant — a capability arrives on a session that already + /// proved membership of one network — and kept anyway, because the + /// binding used to be a side effect of checking a derived address and + /// losing it silently when that check went would be the wrong way to + /// lose it. + pub network: [u8; 32], } /// A peer announcement that has been validated against a specific network. @@ -47,8 +51,6 @@ pub struct WgAnnouncement { pub struct ValidatedAnnouncement { /// The peer's WireGuard public key. pub public_key: WgPublicKey, - /// The overlay address derived locally for this key. Authoritative. - pub overlay_address: Ipv6Addr, } impl WgAnnouncement { @@ -57,7 +59,7 @@ impl WgAnnouncement { Self { version: ANNOUNCEMENT_VERSION, public_key: *public_key.as_bytes(), - overlay_address: overlay_address(network, public_key), + network: *network.as_bytes(), } } @@ -105,20 +107,13 @@ impl WgAnnouncement { "peer announced this agent's own WireGuard key".into(), )); } - // AllowedIPs are derived, never trusted. A mismatch means the peer is - // confused or lying, and either way its own claim is discarded. - let derived = overlay_address(network, &public_key); - if self.overlay_address != derived { + if self.network != *network.as_bytes() { return Err(PluginError::Rejected( - "announced overlay address does not match the one derived from the peer's key" - .into(), + "announcement is for a different network".into(), )); } - Ok(ValidatedAnnouncement { - public_key, - overlay_address: derived, - }) + Ok(ValidatedAnnouncement { public_key }) } } @@ -149,7 +144,6 @@ mod tests { let validated = WgAnnouncement::decode_and_validate(&payload, id, &local).unwrap(); assert_eq!(validated.public_key, peer); - assert_eq!(validated.overlay_address, overlay_address(id, &peer)); } #[test] @@ -167,20 +161,27 @@ mod tests { } #[test] - fn allowed_ips_are_derived_not_taken_from_the_peer() { + fn there_is_nothing_address_like_to_forge() { + // Addresses belong to the system level, are allocated there and are + // signed by the member that holds one. A protocol announcement + // carries no address at all, so this is not a thing a peer can lie + // about here — and a peer sending traffic from an address it does + // not hold is rejected by the agreed address, not by anything it + // said in this message. let id = network("no-hijack"); - let victim = WgSecretKey::generate().public(); - let attacker = WgSecretKey::generate().public(); + let peer = WgSecretKey::generate().public(); let local = WgSecretKey::generate().public(); - // An attacker claims the victim's overlay address with its own key. - let mut forged = WgAnnouncement::new(id, &attacker); - forged.overlay_address = overlay_address(id, &victim); + let announcement = WgAnnouncement::new(id, &peer); + let validated = + WgAnnouncement::decode_and_validate(&announcement.encode().unwrap(), id, &local) + .unwrap(); + assert_eq!(validated.public_key, peer); - let result = WgAnnouncement::decode_and_validate(&forged.encode().unwrap(), id, &local); - assert!( - matches!(result, Err(PluginError::Rejected(ref reason)) if reason.contains("does not match")), - "claiming another member's overlay address must be rejected: {result:?}" + // The validated form has one field, and it is an identity. + assert_eq!( + std::mem::size_of_val(&validated), + std::mem::size_of::() ); } diff --git a/crates/tsunagi/src/dataplane/wireguard/device.rs b/crates/tsunagi/src/dataplane/wireguard/device.rs index b6f4af9..b6c905c 100644 --- a/crates/tsunagi/src/dataplane/wireguard/device.rs +++ b/crates/tsunagi/src/dataplane/wireguard/device.rs @@ -27,7 +27,7 @@ //! what it announced. use std::collections::HashMap; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::net::{IpAddr, Ipv4Addr}; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use std::time::Duration; @@ -42,10 +42,8 @@ use crate::dataplane::transport::{SharedLink, TransportError}; use crate::identity::NetworkId; use super::keys::{WgPublicKey, WgSecretKey}; -use super::overlay::overlay_address; use crate::overlay::packet::IpHeader; use crate::overlay::tun::TunDevice; -use crate::state::Ipv4Range; /// How often WireGuard's own timers are driven. /// @@ -109,9 +107,11 @@ impl PeerHealth { struct Peer { endpoint_id: EndpointId, public_key: WgPublicKey, - overlay: Ipv6Addr, - /// The IPv4 address this peer owns, when the overlay is dual stack and - /// nobody else derived the same one. + /// The overlay address this peer holds, as the control plane agreed it. + /// + /// Not derived here and not taken from the peer: the system level + /// allocates it, the peer signs the claim, and every protocol carries + /// traffic for the same address. overlay_v4: Mutex>, tunn: Mutex, link: SharedLink, @@ -124,7 +124,6 @@ impl std::fmt::Debug for Peer { f.debug_struct("Peer") .field("peer", &self.endpoint_id.fmt_short().to_string()) .field("public_key", &self.public_key) - .field("overlay", &self.overlay) .finish() } } @@ -170,15 +169,8 @@ pub struct PeerSummary { pub endpoint_id: EndpointId, /// The peer's WireGuard public key. pub public_key: WgPublicKey, - /// The overlay address this agent derived for it. - pub overlay_address: Ipv6Addr, - /// Its IPv4 overlay address, when the overlay is dual stack. - /// - /// `None` with `ipv4_conflict` set means another member derived the same - /// address and won it; that peer is still fully reachable over IPv6. + /// The overlay address it holds, once the control plane has agreed one. pub overlay_address_v4: Option, - /// Whether this peer lost an IPv4 address to a derivation collision. - pub ipv4_conflict: bool, /// Whether the tunnel has handshaken. pub health: PeerHealth, /// Traffic counters. @@ -194,7 +186,6 @@ struct Inner { private_key: WgSecretKey, tun: Arc, /// The IPv4 overlay range, when the overlay is dual stack. - ipv4_range: Option, peers: RwLock>>, /// Both families, so one lookup routes any packet. routes: RwLock>, @@ -224,17 +215,11 @@ pub struct WireguardDevice { impl WireguardDevice { /// Starts a device on top of `tun`. - pub fn start( - network: NetworkId, - private_key: WgSecretKey, - tun: Arc, - ipv4_range: Option, - ) -> Self { + pub fn start(network: NetworkId, private_key: WgSecretKey, tun: Arc) -> Self { let inner = Arc::new(Inner { network, private_key, tun, - ipv4_range, peers: RwLock::new(HashMap::new()), routes: RwLock::new(HashMap::new()), next_index: AtomicU32::new(1), @@ -292,12 +277,9 @@ impl WireguardDevice { None, ); - let overlay = overlay_address(self.inner.network, &public_key); - let overlay_v4 = self.claim_ipv4(&public_key, overlay_v4); let peer = Arc::new(Peer { endpoint_id, public_key, - overlay, overlay_v4: Mutex::new(overlay_v4), tunn: Mutex::new(tunn), link, @@ -311,7 +293,6 @@ impl WireguardDevice { } write_lock(&self.inner.peers).insert(public_key, Arc::clone(&peer)); - write_lock(&self.inner.routes).insert(IpAddr::V6(overlay), public_key); if let Some(v4) = overlay_v4 { write_lock(&self.inner.routes).insert(IpAddr::V4(v4), public_key); } @@ -322,48 +303,10 @@ impl WireguardDevice { Ok(()) } - /// Decides which IPv4 address a new peer gets, if any. - /// - /// IPv4 has far too little room for a derived address to be collision - /// free. When two members derive the same one, the member whose public - /// key sorts lower keeps it — a rule every member computes identically, - /// so they all agree on the outcome without talking about it. The other - /// member simply has no IPv4 address; it is still fully reachable over - /// IPv6, which never collides. - fn claim_ipv4(&self, public_key: &WgPublicKey, wanted: Option) -> Option { - let wanted = wanted?; - - let holder = read_lock(&self.inner.routes) - .get(&IpAddr::V4(wanted)) - .copied(); - let Some(holder) = holder else { - return Some(wanted); - }; - if holder == *public_key { - return Some(wanted); - } - - self.inner.ipv4_conflicts.fetch_add(1, Ordering::Relaxed); - if holder.as_bytes() <= public_key.as_bytes() { - // The peer already holding it wins. - return None; - } - // The newcomer wins; take the address away from the other peer. - if let Some(loser) = read_lock(&self.inner.peers).get(&holder).cloned() { - let mut slot = match loser.overlay_v4.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - }; - *slot = None; - } - Some(wanted) - } - /// Removes a peer and stops its tunnel. pub fn remove_peer(&self, public_key: &WgPublicKey) { if let Some(peer) = write_lock(&self.inner.peers).remove(public_key) { let mut routes = write_lock(&self.inner.routes); - routes.remove(&IpAddr::V6(peer.overlay)); let v4 = match peer.overlay_v4.lock() { Ok(guard) => *guard, Err(poisoned) => *poisoned.into_inner(), @@ -408,9 +351,7 @@ impl WireguardDevice { PeerSummary { endpoint_id: peer.endpoint_id, public_key: peer.public_key, - overlay_address: peer.overlay, overlay_address_v4: overlay_v4, - ipv4_conflict: overlay_v4.is_none() && self.inner.ipv4_range.is_some(), health: peer.health(), stats: peer.stats(), path: peer.link.path_description(), @@ -623,10 +564,12 @@ async fn read_from_link(inner: Arc, peer: Arc) { } Outcome::ToTunnel(len, source) => { let payload = Bytes::copy_from_slice(&scratch[..len]); - // Enforce address ownership: a peer may only send from an - // address derived for its own key, in either family. + // Enforce address ownership: a peer may only send from + // the address the control plane agreed it holds. An + // overlay address is signed by its holder, so this is + // checked against the agreement and never against + // anything the peer said here. let owned = match source { - IpAddr::V6(addr) => addr == peer.overlay, IpAddr::V4(addr) => { let held = match peer.overlay_v4.lock() { Ok(guard) => *guard, @@ -634,6 +577,9 @@ async fn read_from_link(inner: Arc, peer: Arc) { }; held == Some(addr) } + // The overlay is IPv4. Anything else has no owner + // here and is dropped rather than guessed at. + IpAddr::V6(_) => false, }; if !owned { peer.counters diff --git a/crates/tsunagi/src/dataplane/wireguard/mod.rs b/crates/tsunagi/src/dataplane/wireguard/mod.rs index 22d98da..ec989e1 100644 --- a/crates/tsunagi/src/dataplane/wireguard/mod.rs +++ b/crates/tsunagi/src/dataplane/wireguard/mod.rs @@ -44,7 +44,6 @@ pub mod announcement; pub mod device; pub mod keys; -pub mod overlay; pub mod plugin; pub mod store; @@ -64,7 +63,6 @@ pub use crate::overlay::{ }; pub use device::{PeerHealth, PeerStats, PeerSummary, WireguardDevice}; pub use keys::{WgPublicKey, WgSecretKey}; -pub use overlay::{OVERLAY_PREFIX_LEN, overlay_address, overlay_address_v4, overlay_prefix}; pub use plugin::{ DEFAULT_MTU, MIN_MTU, NetworkOverview, PeerOverview, WIREGUARD_OVERHEAD, WIREGUARD_PROTOCOL, WireguardConfig, WireguardPlugin, diff --git a/crates/tsunagi/src/dataplane/wireguard/overlay.rs b/crates/tsunagi/src/dataplane/wireguard/overlay.rs deleted file mode 100644 index ec75066..0000000 --- a/crates/tsunagi/src/dataplane/wireguard/overlay.rs +++ /dev/null @@ -1,266 +0,0 @@ -//! Deterministic overlay addressing. -//! -//! A mesh with no coordinator cannot hand out addresses, so every participant -//! derives its own from values everybody already knows. 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 -//! ``` -//! -//! Two properties matter: -//! -//! * Every member of a network derives the **same** `/64`, so the overlay is -//! one subnet without anybody allocating it. -//! * A member's address is bound to its WireGuard public key, so a peer's -//! `AllowedIPs` can be **derived locally and never taken from what the peer -//! claims**. A participant can mint many keys and therefore many addresses, -//! but it cannot choose to collide with an existing member's address without -//! finding a hash preimage. - -use std::net::{Ipv4Addr, Ipv6Addr}; - -use sha2::{Digest, Sha256}; - -use crate::state::Ipv4Range; - -use crate::identity::NetworkId; - -use super::keys::WgPublicKey; - -/// Frozen domain separator for overlay address derivation. -pub const OVERLAY_DOMAIN: &str = "tsunagi-wireguard-overlay-v1"; - -/// Prefix length of the overlay subnet. -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; - -fn push_lp(out: &mut Vec, bytes: &[u8]) { - let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX); - out.extend_from_slice(&len.to_be_bytes()); - out.extend_from_slice(bytes); -} - -fn digest(label: &str, network: NetworkId, key: Option<&WgPublicKey>) -> [u8; 32] { - let mut input = Vec::with_capacity(128); - push_lp(&mut input, OVERLAY_DOMAIN.as_bytes()); - push_lp(&mut input, label.as_bytes()); - push_lp(&mut input, network.as_bytes()); - if let Some(key) = key { - push_lp(&mut input, key.as_bytes()); - } - Sha256::digest(&input).into() -} - -/// The `/64` every member of `network` shares. -/// -/// Returned as the network address of the prefix, i.e. with a zero interface -/// identifier. -pub fn overlay_prefix(network: NetworkId) -> Ipv6Addr { - let hash = digest("prefix", network, None); - let mut octets = [0u8; 16]; - // fd00::/8 marks a locally assigned unique local address. - octets[0] = 0xfd; - // 40 bits of global id followed by a 16 bit subnet id fill the rest of /64. - octets[1..8].copy_from_slice(&hash[0..7]); - Ipv6Addr::from(octets) -} - -/// The address a member with `key` has in `network`. -pub fn overlay_address(network: NetworkId, key: &WgPublicKey) -> Ipv6Addr { - let prefix = overlay_prefix(network).octets(); - let hash = digest("interface", network, Some(key)); - - let mut octets = [0u8; 16]; - octets[0..8].copy_from_slice(&prefix[0..8]); - octets[8..16].copy_from_slice(&hash[0..8]); - - // The all-zero interface identifier is the subnet-router anycast address - // and must not be handed to a host. - if octets[8..16] == [0u8; 8] { - octets[15] = 1; - } - 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: Ipv4Range, -) -> Option { - let Ipv4Range { 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)] - - use super::*; - use crate::identity::{NetworkKeys, NetworkName, NetworkSecret}; - - fn network(name: &str) -> NetworkId { - NetworkKeys::derive( - &NetworkName::new(name).unwrap(), - &NetworkSecret::from_bytes(vec![7u8; 32]).unwrap(), - ) - .network_id() - } - - #[test] - fn the_prefix_is_a_unique_local_address() { - let prefix = overlay_prefix(network("home")); - assert_eq!(prefix.octets()[0], 0xfd); - assert!(prefix.is_unique_local()); - assert_eq!(&prefix.octets()[8..16], &[0u8; 8], "a /64 network address"); - } - - #[test] - fn everyone_in_a_network_shares_one_prefix() { - let id = network("shared"); - let a = overlay_address(id, &WgPublicKey::from_bytes([1u8; 32])); - let b = overlay_address(id, &WgPublicKey::from_bytes([2u8; 32])); - assert_eq!(a.octets()[0..8], b.octets()[0..8]); - assert_ne!(a, b, "different keys get different addresses"); - assert_eq!(&overlay_prefix(id).octets()[0..8], &a.octets()[0..8]); - } - - #[test] - fn derivation_is_deterministic_and_network_scoped() { - let key = WgPublicKey::from_bytes([9u8; 32]); - let first = network("one"); - let second = network("two"); - assert_eq!(overlay_address(first, &key), overlay_address(first, &key)); - assert_ne!( - overlay_address(first, &key), - overlay_address(second, &key), - "the same key in a different network gets a different address" - ); - 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: Ipv4Range = "100.64.0.0/10".parse().unwrap(); - 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.base), - "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 a_range_parses_and_prints_round_trip() { - let range: Ipv4Range = "10.77.0.0/16".parse().unwrap(); - assert_eq!(range.to_string(), "10.77.0.0/16"); - assert!(range.contains("10.77.3.4".parse().unwrap())); - assert!(!range.contains("10.78.3.4".parse().unwrap())); - - assert!("10.77.0.0".parse::().is_err()); - assert!("nonsense/16".parse::().is_err()); - assert!("10.77.0.0/zz".parse::().is_err()); - assert!("10.77.0.0/31".parse::().is_err()); - } - - #[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: Ipv4Range = "100.64.0.0/10".parse().unwrap(); - - 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, - Ipv4Range::new(Ipv4Addr::new(10, 0, 0, 0), 8).unwrap() - ) - ); - } - - #[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, so they are refused. - assert!(Ipv4Range::new(Ipv4Addr::new(10, 0, 0, 0), 31).is_err()); - assert!(Ipv4Range::new(Ipv4Addr::new(10, 0, 0, 1), 32).is_err()); - assert!(Ipv4Range::new(Ipv4Addr::new(10, 0, 0, 0), 33).is_err()); - // A /30 has two usable addresses. - let small = Ipv4Range::new(Ipv4Addr::new(10, 0, 0, 0), 30).unwrap(); - assert!(overlay_address_v4(id, &key, small).is_some()); - // A /0 must not overflow. - let everything = Ipv4Range::new(Ipv4Addr::UNSPECIFIED, 0).unwrap(); - assert!(overlay_address_v4(id, &key, everything).is_some()); - } - - #[test] - fn addresses_are_never_the_subnet_router_anycast_address() { - let id = network("anycast"); - for byte in 0..64u8 { - let address = overlay_address(id, &WgPublicKey::from_bytes([byte; 32])); - assert_ne!(&address.octets()[8..16], &[0u8; 8]); - } - } -} diff --git a/crates/tsunagi/src/dataplane/wireguard/plugin.rs b/crates/tsunagi/src/dataplane/wireguard/plugin.rs index f082dbb..de09e0a 100644 --- a/crates/tsunagi/src/dataplane/wireguard/plugin.rs +++ b/crates/tsunagi/src/dataplane/wireguard/plugin.rs @@ -42,7 +42,6 @@ use crate::identity::NetworkId; use super::announcement::{ValidatedAnnouncement, WgAnnouncement}; use super::device::{PeerSummary, WireguardDevice}; use super::keys::{WgPublicKey, WgSecretKey}; -use super::overlay::{OVERLAY_PREFIX_LEN, overlay_address, overlay_prefix}; use super::store::WgKeyStore; use crate::overlay::config::{DEFAULT_INTERFACE_PREFIX, interface_name}; use crate::overlay::tun::{TunFactory, TunRequest}; @@ -51,20 +50,20 @@ use crate::state::Ipv4Range; /// The protocol identifier this plugin announces. pub const WIREGUARD_PROTOCOL: &str = "wireguard"; -/// Smallest interface MTU IPv6 permits, from RFC 8200. +/// Smallest interface MTU the overlay accepts. /// -/// This is not advice, it is a hard limit. Linux tears IPv6 down entirely on -/// an interface whose MTU is below it — the per-device `/proc/sys/net/ipv6` -/// entries disappear and `ip -6 address add` fails with `Invalid argument` — -/// so the overlay address could never be assigned. Anything smaller is -/// rejected up front instead of failing obscurely later. -pub const MIN_MTU: u32 = 1280; +/// 576 bytes is what IPv4 guarantees every host can reassemble (RFC 1122), +/// so nothing below it is worth offering. The floor used to be 1280 because +/// Linux tears IPv6 down on an interface below that; the overlay is IPv4 +/// now, so that constraint is gone and a path with small datagrams — a +/// relay, typically — can be matched instead of warned about. +pub const MIN_MTU: u32 = 576; /// Default interface MTU. /// -/// Equal to [`MIN_MTU`], because the overlay is IPv6 and there is no room -/// below it. -pub const DEFAULT_MTU: u32 = MIN_MTU; +/// Comfortably under what a direct path carries, and the same number the +/// overlay used before, so an existing network does not have to change. +pub const DEFAULT_MTU: u32 = 1280; /// Bytes WireGuard adds to a packet: type and reserved, receiver index, /// counter and the Poly1305 tag. @@ -145,13 +144,7 @@ pub struct NetworkOverview { pub mtu: u32, /// This agent's WireGuard public key in this network. pub public_key: WgPublicKey, - /// This agent's overlay address. - pub overlay_address: IpAddr, - /// The overlay subnet every member shares. - pub overlay_prefix: IpAddr, - /// Prefix length of the overlay subnet. - pub overlay_prefix_len: u8, - /// This agent's IPv4 overlay address, when the overlay is dual stack. + /// This agent's overlay address, once the network has agreed one. pub overlay_address_v4: Option, /// The IPv4 overlay range in use. pub ipv4_range: Option, @@ -179,9 +172,7 @@ pub struct PeerOverview { pub endpoint_id: EndpointId, /// The peer's WireGuard public key. pub public_key: WgPublicKey, - /// The overlay address derived for it locally. - pub overlay_address: IpAddr, - /// Its IPv4 overlay address, once a tunnel exists and it won the address. + /// The overlay address the network agreed it holds. pub overlay_address_v4: Option, /// Whether a data plane link to it exists. pub has_link: bool, @@ -278,9 +269,8 @@ impl WireguardPlugin { if config.mtu < MIN_MTU { return Err(PluginError::Other(format!( - "an MTU of {} is below the {MIN_MTU} bytes IPv6 requires (RFC 8200). \ - Linux disables IPv6 on an interface below that, so the overlay address \ - could never be assigned.", + "an MTU of {} is below the {MIN_MTU} bytes every IPv4 host must be able \ + to reassemble (RFC 1122)", config.mtu ))); } @@ -332,7 +322,6 @@ impl WireguardPlugin { .map(|(endpoint_id, announcement)| PeerOverview { endpoint_id: *endpoint_id, public_key: announcement.public_key, - overlay_address: IpAddr::V6(announcement.overlay_address), overlay_address_v4: state.allocations.get(endpoint_id).copied(), has_link: state.links.contains_key(endpoint_id), tunnel: tunnels.get(&announcement.public_key).cloned(), @@ -345,9 +334,6 @@ impl WireguardPlugin { interface: state.interface.clone(), mtu: self.worker.config.mtu, public_key: state.key.public(), - overlay_address: IpAddr::V6(overlay_address(network, &state.key.public())), - overlay_prefix: IpAddr::V6(overlay_prefix(network)), - overlay_prefix_len: OVERLAY_PREFIX_LEN, overlay_address_v4: state.allocations.get(&self.worker.local_id()).copied(), ipv4_range: state.ipv4_range, peers, @@ -464,35 +450,31 @@ impl Worker { } /// What the host interface for a network should look like. - fn desired_request(&self, state: &NetworkState, network: NetworkId) -> TunRequest { + fn desired_request(&self, state: &NetworkState) -> TunRequest { let own_range = state.ipv4_range; TunRequest { name: state.interface.clone(), - address: overlay_address(network, &state.key.public()), - prefix_len: OVERLAY_PREFIX_LEN, - address_v4: state.allocations.get(&self.local_id()).copied(), - prefix_len_v4: own_range.map_or(0, |range| range.prefix_len), + address: state.allocations.get(&self.local_id()).copied(), + prefix_len: own_range.map_or(0, |range| range.prefix_len), mtu: self.config.mtu, } } /// Creates the packet interface and starts the WireGuard device. async fn ensure_device(&self, network: NetworkId) -> Result<(), PluginError> { - let (request, key, own_range) = { + let (request, key) = { let shared = self.lock_shared(); match shared.networks.get(&network) { - Some(state) if state.device.is_none() => ( - self.desired_request(state, network), - state.key.clone(), - state.ipv4_range, - ), + Some(state) if state.device.is_none() => { + (self.desired_request(state), state.key.clone()) + } _ => return Ok(()), } }; let applied = request.clone(); let tun = self.tun_factory.create(request).await?; - let device = Arc::new(WireguardDevice::start(network, key, tun, own_range)); + let device = Arc::new(WireguardDevice::start(network, key, tun)); let mut shared = self.lock_shared(); if let Some(state) = shared.networks.get_mut(&network) { @@ -510,7 +492,7 @@ impl Worker { let shared = self.lock_shared(); match shared.networks.get(&network) { Some(state) if state.device.is_some() => { - let wanted = self.desired_request(state, network); + let wanted = self.desired_request(state); if state.applied.as_ref() == Some(&wanted) { return; } diff --git a/crates/tsunagi/src/ipc/mod.rs b/crates/tsunagi/src/ipc/mod.rs index 3575cb6..63c19aa 100644 --- a/crates/tsunagi/src/ipc/mod.rs +++ b/crates/tsunagi/src/ipc/mod.rs @@ -185,13 +185,9 @@ pub struct OverlayReport { pub interface: String, /// Interface MTU. pub mtu: u32, - /// This agent's overlay address. - pub address: String, - /// This agent's IPv4 overlay address, when the overlay is dual stack. - pub address_v4: Option, - /// The subnet every member shares. - pub prefix: String, - /// Prefix length of that subnet. + /// This agent's overlay address, once the network has agreed one. + pub address: Option, + /// Prefix length of the overlay range every member shares. pub prefix_len: u8, /// One entry per overlay peer. pub peers: Vec, @@ -211,10 +207,8 @@ pub struct OverlayPeerReport { pub endpoint_id: String, /// The peer's WireGuard public key. pub public_key: String, - /// Its overlay address. - pub address: String, - /// Its IPv4 overlay address, when it has one. - pub address_v4: Option, + /// The overlay address the network agreed it holds. + pub address: Option, /// Seconds since the last WireGuard handshake. /// /// `None` means the tunnel has never handshaken and cannot carry traffic. diff --git a/crates/tsunagi/src/ipc/unix.rs b/crates/tsunagi/src/ipc/unix.rs index 4aa82c4..97f339c 100644 --- a/crates/tsunagi/src/ipc/unix.rs +++ b/crates/tsunagi/src/ipc/unix.rs @@ -205,7 +205,7 @@ pub async fn set_hostname(path: impl AsRef, hostname: &str) -> Result(stream: &mut UnixStream, value: &T) -> Result<()> { let encoded = postcard::to_stdvec(value) diff --git a/crates/tsunagi/src/overlay/provision/factory.rs b/crates/tsunagi/src/overlay/provision/factory.rs index 8e30a07..6dd715d 100644 --- a/crates/tsunagi/src/overlay/provision/factory.rs +++ b/crates/tsunagi/src/overlay/provision/factory.rs @@ -17,9 +17,9 @@ use super::{InterfacePlan, InterfaceProvisioner}; /// Turns a [`TunRequest`] into the plan for a host interface. fn plan_for(request: &TunRequest) -> Result { - let mut addresses = vec![Cidr::new(request.address.into(), request.prefix_len)?]; - if let Some(address) = request.address_v4 { - addresses.push(Cidr::new(address.into(), request.prefix_len_v4)?); + let mut addresses = Vec::new(); + if let Some(address) = request.address { + addresses.push(Cidr::new(address.into(), request.prefix_len)?); } Ok(InterfacePlan::new( request.name.clone(), @@ -106,7 +106,7 @@ impl TunFactory for ManagedTunFactory { mod tests { #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - use std::net::{Ipv4Addr, Ipv6Addr}; + use std::net::Ipv4Addr; use super::super::{LinkKind, MockHost, MockProvisioner}; use super::*; @@ -114,10 +114,8 @@ mod tests { fn request(v4: Option) -> TunRequest { TunRequest { name: "tsunfactory".into(), - address: Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1), - prefix_len: 64, - address_v4: v4, - prefix_len_v4: 24, + address: v4, + prefix_len: 24, mtu: 1280, } } @@ -137,7 +135,7 @@ mod tests { let state = provisioner.host().get("tsunfactory").unwrap(); assert_eq!(state.kind, LinkKind::Tun); assert_eq!(state.mtu, 1280); - assert_eq!(state.addresses.len(), 2, "both families are assigned"); + assert_eq!(state.addresses.len(), 1, "the overlay address is assigned"); } #[tokio::test] diff --git a/crates/tsunagi/src/overlay/tun.rs b/crates/tsunagi/src/overlay/tun.rs index b172b34..afd69fa 100644 --- a/crates/tsunagi/src/overlay/tun.rs +++ b/crates/tsunagi/src/overlay/tun.rs @@ -13,7 +13,6 @@ //! Creating one needs `CAP_NET_ADMIN`, and it is //! [`provision`](super::provision) that holds that and creates it. -use std::net::Ipv6Addr; use std::sync::Arc; use bytes::Bytes; @@ -26,14 +25,13 @@ use crate::overlay::OverlayError; pub struct TunRequest { /// Interface name to ask for. pub name: String, - /// The overlay address this host answers to. - pub address: Ipv6Addr, - /// Prefix length of the overlay subnet, so the OS routes it here. + /// The overlay address this host answers to, and its prefix length. + /// + /// Allocated and signed at the system level, never derived from a + /// protocol's key: every protocol carries traffic for the same address. + pub address: Option, + /// Prefix length of the overlay range. pub prefix_len: u8, - /// The IPv4 overlay address this host answers to, when dual stack. - pub address_v4: Option, - /// Prefix length of the IPv4 overlay range. - pub prefix_len_v4: u8, /// Interface MTU. pub mtu: u32, } @@ -46,10 +44,8 @@ impl TunRequest { pub fn bare(name: impl Into, mtu: u32) -> Self { Self { name: name.into(), - address: Ipv6Addr::UNSPECIFIED, + address: None, prefix_len: 0, - address_v4: None, - prefix_len_v4: 0, mtu, } } diff --git a/crates/tsunagi/tests/interface_provisioning.rs b/crates/tsunagi/tests/interface_provisioning.rs index 915afbb..6f62e22 100644 --- a/crates/tsunagi/tests/interface_provisioning.rs +++ b/crates/tsunagi/tests/interface_provisioning.rs @@ -94,11 +94,12 @@ fn v4(state: &InterfaceState) -> Vec { .collect() } -fn has_v6(state: &InterfaceState) -> bool { - state - .addresses - .iter() - .any(|cidr| matches!(cidr.addr, IpAddr::V6(_))) +/// Whether the overlay address has been put on the interface yet. +/// +/// It is allocated and signed at the system level, so it arrives on a later +/// reconciliation than the interface itself rather than with it. +fn addressed(state: &InterfaceState) -> bool { + !state.addresses.is_empty() } #[tokio::test] @@ -111,18 +112,13 @@ async fn an_agent_creates_and_configures_its_own_overlay_interface() { let interface = agent.interface(network_id).await; let state = agent - .wait_for_host("the interface to be created", &interface, |state| { - state.filter(|state| !state.addresses.is_empty()) - }) + .wait_for_host("the interface to be created", &interface, |state| state) .await; assert_eq!(state.kind, LinkKind::Tun); assert!(state.up, "the agent brought the link up itself"); assert_eq!(state.mtu, 1280); - assert!(has_v6(&state), "the derived overlay address is assigned"); - // The IPv4 address is allocated at run time, so it arrives on a later - // reconciliation than the interface itself. let addresses = agent .wait_for_host("the allocated IPv4 address", &interface, |state| { state.map(|state| v4(&state)).filter(|v4| !v4.is_empty()) @@ -160,9 +156,14 @@ async fn an_interface_left_by_a_crashed_run_is_replaced_rather_than_tripped_over let network_id = agent.agent.join_network(&name, &secret).await.unwrap(); assert_eq!(agent.interface(network_id).await, interface); + // Waited on directly rather than on "it has some address": the address + // this agent was allocated arrives a reconciliation after the interface + // does, and in between the leftover is still the only one there. let state = agent - .wait_for_host("the interface to be rebuilt", &interface, |state| { - state.filter(|state| state.attached && has_v6(state)) + .wait_for_host("the stale address to be replaced", &interface, |state| { + state.filter(|state| { + state.attached && addressed(state) && !state.addresses.contains(&stale) + }) }) .await; assert!( diff --git a/crates/tsunagi/tests/local_control.rs b/crates/tsunagi/tests/local_control.rs index a2fc2ed..508efd5 100644 --- a/crates/tsunagi/tests/local_control.rs +++ b/crates/tsunagi/tests/local_control.rs @@ -48,15 +48,14 @@ fn source(agent: Agent, plugin: Arc) -> Arc Ipv6Addr { - let view = wait_until("the plugin prepared the network", || async { - self.plugin.overview(network) + /// + /// Allocated and signed at the system level, so it appears once the + /// network has agreed on it rather than the moment the plugin starts. + async fn overlay(&self, network: NetworkId) -> Ipv4Addr { + wait_until("the network agreed an overlay address", || async { + self.plugin.overview(network)?.overlay_address_v4 }) - .await; - match view.overlay_address { - IpAddr::V6(addr) => addr, - IpAddr::V4(_) => panic!("the overlay is IPv6"), - } + .await } /// The in-memory packet interface for a network. @@ -184,20 +183,6 @@ fn ipv4_packet(source: Ipv4Addr, destination: Ipv4Addr, payload: &[u8]) -> Bytes 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()); - packet.push(6 << 4); // version 6 - packet.extend_from_slice(&[0, 0, 0]); // traffic class and flow label - packet.extend_from_slice(&(payload.len() as u16).to_be_bytes()); - packet.push(59); // "no next header" - packet.push(64); // hop limit - packet.extend_from_slice(&source.octets()); - packet.extend_from_slice(&destination.octets()); - packet.extend_from_slice(payload); - Bytes::from(packet) -} - #[tokio::test] async fn two_agents_carry_real_ip_packets_through_a_wireguard_tunnel() { let discovery = SharedMemoryDiscovery::new(); @@ -217,12 +202,10 @@ async fn two_agents_carry_real_ip_packets_through_a_wireguard_tunnel() { let addr_a = a.overlay(network_id).await; let addr_b = b.overlay(network_id).await; assert_ne!(addr_a, addr_b); - // One shared /64, derived by both sides independently. - assert_eq!(addr_a.octets()[0..8], addr_b.octets()[0..8]); - assert_eq!( - &overlay_prefix(network_id).octets()[0..8], - &addr_a.octets()[0..8] - ); + // One shared range, agreed at the system level and signed by each + // member, not derived from anything either protocol holds. + let range = a.plugin.overview(network_id).unwrap().ipv4_range.unwrap(); + assert!(range.contains(addr_a) && range.contains(addr_b)); let tun_a = a.tun(network_id).await; let tun_b = b.tun(network_id).await; @@ -230,23 +213,23 @@ async fn two_agents_carry_real_ip_packets_through_a_wireguard_tunnel() { // A real IP packet, encrypted by WireGuard, carried over iroh, decrypted // on the other side and handed to that host's packet interface. let payload = b"hello over the overlay"; - tun_a.push_from_os(ipv6_packet(addr_a, addr_b, payload)); + tun_a.push_from_os(ipv4_packet(addr_a, addr_b, payload)); let received = tokio::time::timeout(common::DEADLINE, tun_b.pop_to_os()) .await .expect("the packet should arrive") .expect("the interface should still be open"); - assert_eq!(&received[40..], payload); - assert_eq!(&received[8..24], &addr_a.octets(), "source preserved"); - assert_eq!(&received[24..40], &addr_b.octets(), "destination preserved"); + assert_eq!(&received[20..], payload); + assert_eq!(&received[12..16], &addr_a.octets(), "source preserved"); + assert_eq!(&received[16..20], &addr_b.octets(), "destination preserved"); // And back the other way. - tun_b.push_from_os(ipv6_packet(addr_b, addr_a, b"and back")); + tun_b.push_from_os(ipv4_packet(addr_b, addr_a, b"and back")); let back = tokio::time::timeout(common::DEADLINE, tun_a.pop_to_os()) .await .expect("the reply should arrive") .unwrap(); - assert_eq!(&back[40..], b"and back"); + assert_eq!(&back[20..], b"and back"); let view = a.plugin.overview(network_id).unwrap(); let tunnel = view.peers[0].tunnel.as_ref().unwrap(); @@ -278,14 +261,14 @@ async fn a_peer_cannot_send_from_an_address_it_does_not_own() { let tun_b = b.tun(network_id).await; // A sends a packet claiming to come from a third party's address. - let someone_else: Ipv6Addr = { + let someone_else = { let mut octets = addr_a.octets(); - octets[15] ^= 0xff; - Ipv6Addr::from(octets) + octets[3] ^= 0xff; + Ipv4Addr::from(octets) }; - tun_a.push_from_os(ipv6_packet(someone_else, addr_b, b"spoofed")); + tun_a.push_from_os(ipv4_packet(someone_else, addr_b, b"spoofed")); - // B must drop it: the source is not the address derived for A's key. + // B must drop it: the source is not the address A holds. wait_until("the spoofed packet is dropped", || async { let view = b.plugin.overview(network_id)?; let tunnel = view.peers.first()?.tunnel.as_ref()?; @@ -294,19 +277,19 @@ async fn a_peer_cannot_send_from_an_address_it_does_not_own() { .await; // A legitimate packet still goes through, so the tunnel is not broken. - tun_a.push_from_os(ipv6_packet(addr_a, addr_b, b"honest")); + tun_a.push_from_os(ipv4_packet(addr_a, addr_b, b"honest")); let received = tokio::time::timeout(common::DEADLINE, tun_b.pop_to_os()) .await .expect("the honest packet should arrive") .unwrap(); - assert_eq!(&received[40..], b"honest"); + assert_eq!(&received[20..], b"honest"); a.shutdown().await; b.shutdown().await; } #[tokio::test] -async fn the_overlay_carries_ipv4_alongside_ipv6() { +async fn the_overlay_uses_a_range_the_network_was_told_to_use() { let discovery = SharedMemoryDiscovery::new(); let (name, secret) = network("wg-dual-stack"); @@ -332,10 +315,9 @@ async fn the_overlay_carries_ipv4_alongside_ipv6() { u32::from(Ipv4Addr::new(10, 77, 0, 0)) ); } - // Each side derived the other's address identically. + // Each side learned the other's address from the same signed state. 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; @@ -351,17 +333,6 @@ async fn the_overlay_carries_ipv4_alongside_ipv6() { 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; } @@ -417,41 +388,6 @@ async fn an_ipv4_source_a_peer_does_not_own_is_dropped() { 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_range(&discovery, "ta", None).await; - let b = WgAgent::spawn_range(&discovery, "tb", 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 a_joining_member_adopts_the_range_the_network_already_uses() { let discovery = SharedMemoryDiscovery::new(); @@ -670,8 +606,8 @@ async fn packets_for_an_unknown_address_are_counted_not_broadcast() { let tun_b = b.tun(network_id).await; // Nobody owns this address, so it must not be sent to anybody. - let nowhere: Ipv6Addr = "fd00:dead:beef::1".parse().unwrap(); - tun_a.push_from_os(ipv6_packet(addr_a, nowhere, b"lost")); + let nowhere: Ipv4Addr = "192.0.2.111".parse().unwrap(); + tun_a.push_from_os(ipv4_packet(addr_a, nowhere, b"lost")); wait_until("the packet is counted as unroutable", || async { let view = a.plugin.overview(network_id)?; @@ -714,13 +650,12 @@ async fn a_mesh_of_three_establishes_every_tunnel() { let mut addresses = Vec::new(); for agent in [&a, &b, &c] { let view = agent.plugin.overview(network_id).unwrap(); - assert_eq!(view.overlay_prefix, IpAddr::V6(overlay_prefix(network_id))); assert!( view.peers .iter() .all(|peer| peer.public_key != view.public_key) ); - addresses.push(view.overlay_address); + addresses.push(agent.overlay(network_id).await); } addresses.sort(); addresses.dedup(); @@ -731,12 +666,12 @@ async fn a_mesh_of_three_establishes_every_tunnel() { let addr_c = c.overlay(network_id).await; a.tun(network_id) .await - .push_from_os(ipv6_packet(addr_a, addr_c, b"a to c")); + .push_from_os(ipv4_packet(addr_a, addr_c, b"a to c")); let received = tokio::time::timeout(common::DEADLINE, c.tun(network_id).await.pop_to_os()) .await .expect("the packet should arrive") .unwrap(); - assert_eq!(&received[40..], b"a to c"); + assert_eq!(&received[20..], b"a to c"); a.shutdown().await; b.shutdown().await; @@ -801,23 +736,19 @@ async fn two_networks_get_separate_interfaces_keys_and_overlays() { view_alpha.public_key, view_beta.public_key, "one WireGuard identity per network, not one per host" ); - assert_ne!(view_alpha.overlay_prefix, view_beta.overlay_prefix); assert_eq!(hub.tuns.devices().len(), 2); // Traffic in one overlay never surfaces in the other. - let hub_alpha = match view_alpha.overlay_address { - IpAddr::V6(addr) => addr, - IpAddr::V4(_) => panic!("ipv6"), - }; + let hub_alpha = hub.overlay(alpha).await; let left_addr = left.overlay(alpha).await; hub.tun(alpha) .await - .push_from_os(ipv6_packet(hub_alpha, left_addr, b"alpha only")); + .push_from_os(ipv4_packet(hub_alpha, left_addr, b"alpha only")); let seen = tokio::time::timeout(common::DEADLINE, left.tun(alpha).await.pop_to_os()) .await .expect("the packet should arrive") .unwrap(); - assert_eq!(&seen[40..], b"alpha only"); + assert_eq!(&seen[20..], b"alpha only"); assert!( tokio::time::timeout( Duration::from_millis(200), @@ -863,7 +794,6 @@ async fn restarting_keeps_the_wireguard_identity_and_overlay_address() { }) .await; assert_eq!(after.public_key, before.public_key); - assert_eq!(after.overlay_address, before.overlay_address); assert_eq!(after.interface, before.interface); // The tunnel comes back on its own. @@ -930,20 +860,25 @@ async fn the_core_carries_the_payload_without_interpreting_it() { } #[tokio::test] -async fn a_forged_overlay_claim_is_rejected_and_never_reaches_a_tunnel() { +async fn an_announcement_for_another_network_never_reaches_a_tunnel() { let discovery = SharedMemoryDiscovery::new(); let name = NetworkName::new("wg-hijack").unwrap(); let secret = NetworkSecret::generate(); let victim = WgAgent::spawn(&discovery, "tv").await; let network_id = victim.agent.join_network(&name, &secret).await.unwrap(); - let victim_address = victim.overlay(network_id).await; - // A legitimate member — it knows the secret — claims the victim's overlay - // address with its own WireGuard key. + // Addresses are not in an announcement any more, so there is no address + // to forge. What is left to lie about is which network the key is for — + // and a capability is scoped to the session it arrived on, so claiming + // another network's is the shape a confused or hostile member takes. let attacker_key = WgSecretKey::generate().public(); - let mut forged = WgAnnouncement::new(network_id, &attacker_key); - forged.overlay_address = victim_address; + let elsewhere = tsunagi::identity::NetworkKeys::derive( + &NetworkName::new("somewhere-else").unwrap(), + &NetworkSecret::generate(), + ) + .network_id(); + let forged = WgAnnouncement::new(elsewhere, &attacker_key); let forger = Arc::new(ForgingPlugin { payload: std::sync::Mutex::new(Some(forged.encode().unwrap())), @@ -960,14 +895,18 @@ async fn a_forged_overlay_claim_is_rejected_and_never_reaches_a_tunnel() { attacker.join_network(&name, &secret).await.unwrap(); wait_for_peers(&victim.agent, network_id, 1).await; + // Filtered on the reason: this agent reports other things too, and the + // first plugin error to arrive is not necessarily this one. let reason = wait_event(&mut events, |event| match event { Event::PluginError { protocol, reason, .. - } if protocol == WIREGUARD_PROTOCOL => Some(reason.clone()), + } if protocol == WIREGUARD_PROTOCOL && reason.contains("different network") => { + Some(reason.clone()) + } _ => None, }) .await; - assert!(reason.contains("does not match"), "unexpected: {reason}"); + assert!(reason.contains("different network"), "unexpected: {reason}"); settle().await; let view = victim.plugin.overview(network_id).unwrap(); @@ -977,8 +916,6 @@ async fn a_forged_overlay_claim_is_rejected_and_never_reaches_a_tunnel() { .all(|peer| peer.public_key != attacker_key), "a rejected announcement must never become a tunnel" ); - assert_eq!(view.overlay_address, IpAddr::V6(victim_address)); - attacker.shutdown().await; victim.shutdown().await; drop(attacker_dir); @@ -1026,12 +963,13 @@ impl IpPlugin for ForgingPlugin { } #[tokio::test] -async fn an_mtu_below_the_ipv6_minimum_is_refused() { +async fn an_mtu_below_what_ipv4_guarantees_is_refused() { use tsunagi::dataplane::wireguard::{DEFAULT_MTU, MIN_MTU, WIREGUARD_OVERHEAD}; - // Linux disables IPv6 outright on an interface below 1280 bytes, so the - // overlay address could never be assigned. Catch it here rather than as - // an obscure RTNETLINK error much later. + // 576 bytes is what every IPv4 host must be able to reassemble, so + // nothing below it is worth offering. The floor used to be 1280 for + // IPv6's sake; the overlay is IPv4 now and a relayed path with small + // datagrams can be matched instead of warned about. let dir = TempDir::new().unwrap(); let result = WireguardPlugin::open( WireguardConfig::new(dir.path()).with_mtu(MIN_MTU - 1), @@ -1041,15 +979,14 @@ async fn an_mtu_below_the_ipv6_minimum_is_refused() { match result { Err(err) => { let text = err.to_string(); - assert!(text.contains("1280"), "unexpected message: {text}"); - assert!(text.contains("IPv6"), "unexpected message: {text}"); + assert!(text.contains("576"), "unexpected message: {text}"); } - Ok(_) => panic!("an MTU below the IPv6 minimum must be refused"), + Ok(_) => panic!("an MTU below what IPv4 guarantees must be refused"), } - // The default is exactly the minimum, and a link has to carry it plus - // WireGuard's own overhead. - assert_eq!(DEFAULT_MTU, MIN_MTU); + // The default leaves room below it now, which is the point of lowering + // the floor, and a link has to carry it plus WireGuard's own overhead. + const { assert!(DEFAULT_MTU > MIN_MTU) }; assert_eq!(WIREGUARD_OVERHEAD, 32); assert!( WireguardPlugin::open( @@ -1060,15 +997,3 @@ async fn an_mtu_below_the_ipv6_minimum_is_refused() { .is_ok() ); } - -#[tokio::test] -async fn the_overlay_address_is_derived_from_the_key_alone() { - let (name, secret) = network("wg-derivation"); - let id = tsunagi::identity::NetworkKeys::derive(&name, &secret).network_id(); - let key = WgSecretKey::generate().public(); - assert_eq!(overlay_address(id, &key), overlay_address(id, &key)); - assert_ne!( - overlay_address(id, &key), - overlay_address(id, &WgSecretKey::generate().public()) - ); -}