diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 34bb293..c770324 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -126,7 +126,7 @@ jobs: echo "running netns tunnel tests under sudo: $BIN" # --exact so ping_across_yipd_tunnel does not also match _under_loss. for mode in poll uring; do - for t in ping_across_yipd_tunnel ping_across_yipd_tunnel_under_loss arq_recovers_bulk_loss l2_tap_ping_or_arp_across_tunnel; do + for t in ping_across_yipd_tunnel ping_across_yipd_tunnel_under_loss arq_recovers_bulk_loss l2_tap_ping_or_arp_across_tunnel triangle_full_mesh_ping; do LOG="/tmp/netns-$mode-$t.log" if [ "$mode" = "uring" ]; then echo "running $t with UringDriver (opt-in: YIP_USE_URING=1)" diff --git a/Cargo.toml b/Cargo.toml index ef13932..7f98bd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ license = "MPL-2.0" repository = "https://github.com/femboyisp/yip" [workspace.dependencies] +blake2 = "=0.10.6" thiserror = "2.0.9" tracing = "0.1.41" diff --git a/bin/yipd/Cargo.toml b/bin/yipd/Cargo.toml index 2132321..d2eb52c 100644 --- a/bin/yipd/Cargo.toml +++ b/bin/yipd/Cargo.toml @@ -11,7 +11,7 @@ yip-wire = { path = "../../crates/yip-wire" } yip-crypto = { path = "../../crates/yip-crypto" } yip-transport = { path = "../../crates/yip-transport" } yip-device = { path = "../../crates/yip-device" } -blake2 = "0.10.6" +blake2 = { workspace = true } [lints] workspace = true diff --git a/bin/yipd/src/addr.rs b/bin/yipd/src/addr.rs new file mode 100644 index 0000000..44ccb91 --- /dev/null +++ b/bin/yipd/src/addr.rs @@ -0,0 +1,59 @@ +//! Self-certifying, key-derived mesh addresses: a node's inner IPv6 is derived +//! from its X25519 public key, so the address IS the identity — no authority. +#![allow(dead_code)] +use std::net::Ipv6Addr; + +use blake2::digest::{Update, VariableOutput}; +use blake2::Blake2sVar; + +/// Domain-separation context so the address derivation can't collide with any +/// other use of the key. +const DOMAIN: &[u8] = b"yip-addr-v1"; +/// The mesh occupies fd00::/8 (IPv6 ULA); every node address begins with 0xfd. +pub const MESH_PREFIX_LEN: u8 = 8; + +/// Derive a node's inner IPv6 address from its public key: +/// `0xfd || BLAKE2s(DOMAIN || pubkey)[0..15]`. +pub fn node_addr(pubkey: &[u8; 32]) -> Ipv6Addr { + let mut h = Blake2sVar::new(15).expect("15 is a valid blake2s output len"); + h.update(DOMAIN); + h.update(pubkey); + let mut digest = [0u8; 15]; + h.finalize_variable(&mut digest) + .expect("output len matches"); + let mut octets = [0u8; 16]; + octets[0] = 0xfd; + octets[1..].copy_from_slice(&digest); + Ipv6Addr::from(octets) +} + +/// True iff `addr` is the address `pubkey` derives to (self-certification check). +pub fn verify_addr(addr: Ipv6Addr, pubkey: &[u8; 32]) -> bool { + node_addr(pubkey) == addr +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn addr_is_ula_and_deterministic() { + let pk = [7u8; 32]; + let a = node_addr(&pk); + assert_eq!(a.octets()[0], 0xfd, "must be in fd00::/8 ULA space"); + assert_eq!(node_addr(&pk), a, "derivation is deterministic"); + } + + #[test] + fn addr_verifies_only_its_own_key() { + let pk = [7u8; 32]; + let other = [8u8; 32]; + assert!(verify_addr(node_addr(&pk), &pk)); + assert!(!verify_addr(node_addr(&pk), &other)); + } + + #[test] + fn distinct_keys_give_distinct_addrs() { + assert_ne!(node_addr(&[1u8; 32]), node_addr(&[2u8; 32])); + } +} diff --git a/bin/yipd/src/config.rs b/bin/yipd/src/config.rs index efcf0a8..626a767 100644 --- a/bin/yipd/src/config.rs +++ b/bin/yipd/src/config.rs @@ -9,36 +9,36 @@ use std::net::SocketAddr; use crate::mode::TunnelMode; +/// Configuration for a single remote peer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PeerConfig { + pub public_key: [u8; 32], + pub endpoint: SocketAddr, +} + /// Static configuration for one yip tunnel endpoint. #[derive(Debug)] pub struct Config { /// Local X25519 private key (32 bytes). pub local_private: [u8; 32], - /// Local X25519 public key (32 bytes). Carried in config for key-management / - /// re-advertisement in future milestones; not consumed by the M6 data path itself. - #[expect( - dead_code, - reason = "used for key identity; data path reads local_private" - )] + /// Local X25519 public key (32 bytes). Used by `PeerManager` to derive + /// this node's self-certifying mesh address (`node_addr`). pub local_public: [u8; 32], - /// Remote peer's X25519 public key (32 bytes). - pub peer_public: [u8; 32], - /// Remote peer's UDP endpoint (used by the initiator to send the first - /// handshake message; the responder learns it from the incoming datagram). - pub peer_endpoint: SocketAddr, + /// List of remote peers. + pub peers: Vec, /// Local UDP address to bind. pub listen: SocketAddr, /// TUN/TAP device name (e.g. `"yip0"`). pub device: String, /// Tunnel mode selected from `device_kind=tun|tap` (`tun` by default). pub device_kind: TunnelMode, - /// Whether this peer initiates the Noise-IK handshake. - pub initiate: bool, } // ── hex decode helper ───────────────────────────────────────────────────────── -fn hex_to_32(hex: &str) -> io::Result<[u8; 32]> { +/// Decode a 64-char hex string into 32 bytes. Shared with `main.rs`'s +/// `--addr` subcommand so the two paths cannot drift. +pub(crate) fn hex_to_32(hex: &str) -> io::Result<[u8; 32]> { if hex.len() != 64 { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -75,28 +75,62 @@ fn missing(key: &str) -> io::Error { ) } +// ── peer block flush helper ────────────────────────────────────────────────── + +fn flush_peer_block( + cur_pk: Option<[u8; 32]>, + cur_ep: Option, + peers: &mut Vec, +) -> io::Result<()> { + if let (Some(pk), Some(ep)) = (cur_pk, cur_ep) { + peers.push(PeerConfig { + public_key: pk, + endpoint: ep, + }); + } else if cur_pk.is_some() || cur_ep.is_some() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "peer block missing public_key or endpoint".to_string(), + )); + } + Ok(()) +} + // ── Config::parse ───────────────────────────────────────────────────────────── impl Config { /// Parse a `key=value` config text into a [`Config`]. /// /// Lines beginning with `#` and blank lines are ignored. + /// Supports `[peer]` block syntax with `public_key` and `endpoint` fields. + /// Also supports legacy single `peer_public`+`peer_endpoint` fields. /// Returns an `io::Error` for any missing or malformed fields. pub fn parse(text: &str) -> io::Result { let mut local_private: Option<[u8; 32]> = None; let mut local_public: Option<[u8; 32]> = None; - let mut peer_public: Option<[u8; 32]> = None; - let mut peer_endpoint: Option = None; + let mut peers: Vec = Vec::new(); + let mut cur_pk: Option<[u8; 32]> = None; + let mut cur_ep: Option = None; + let mut legacy_peer_public: Option<[u8; 32]> = None; + let mut legacy_peer_endpoint: Option = None; let mut listen: Option = None; let mut device: Option = None; let mut device_kind = TunnelMode::default(); - let mut initiate: Option = None; for line in text.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } + + // Check for [peer] block header + if line == "[peer]" { + flush_peer_block(cur_pk, cur_ep, &mut peers)?; + cur_pk = None; + cur_ep = None; + continue; + } + let (key, val) = line.split_once('=').ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, @@ -108,9 +142,16 @@ impl Config { match key { "local_private" => local_private = Some(hex_to_32(val)?), "local_public" => local_public = Some(hex_to_32(val)?), - "peer_public" => peer_public = Some(hex_to_32(val)?), + "public_key" => cur_pk = Some(hex_to_32(val)?), + "endpoint" => { + cur_ep = + Some(val.parse::().map_err(|e| { + io::Error::new(io::ErrorKind::InvalidData, e.to_string()) + })?) + } + "peer_public" => legacy_peer_public = Some(hex_to_32(val)?), "peer_endpoint" => { - peer_endpoint = + legacy_peer_endpoint = Some(val.parse::().map_err(|e| { io::Error::new(io::ErrorKind::InvalidData, e.to_string()) })?) @@ -123,30 +164,45 @@ impl Config { } "device" => device = Some(val.to_owned()), "device_kind" => device_kind = TunnelMode::parse_device_kind(val)?, - "initiate" => match val { - "true" | "1" | "yes" => initiate = Some(true), - "false" | "0" | "no" => initiate = Some(false), - _ => { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("invalid boolean for 'initiate': {val}"), - )) - } - }, - // Unknown keys are silently ignored for forward-compatibility. + // Silently ignore unknown keys for forward-compatibility. The + // netns config files still contain `initiate=true|false` from + // before Task 5 removed the field; this is intentional so + // those fixtures don't need editing (verified by + // `parse_config_unknown_key_is_silently_ignored`, which this + // arm's removal now also exercises for `initiate` itself). _ => {} } } + // Flush any trailing peer block + flush_peer_block(cur_pk, cur_ep, &mut peers)?; + + // If no [peer] blocks, try legacy single-peer format + if peers.is_empty() { + if let (Some(pk), Some(ep)) = (legacy_peer_public, legacy_peer_endpoint) { + peers.push(PeerConfig { + public_key: pk, + endpoint: ep, + }); + } + } + + // Peers list must not be empty + if peers.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "no peers configured (use [peer] blocks or legacy peer_public/peer_endpoint)" + .to_string(), + )); + } + Ok(Config { local_private: local_private.ok_or_else(|| missing("local_private"))?, local_public: local_public.ok_or_else(|| missing("local_public"))?, - peer_public: peer_public.ok_or_else(|| missing("peer_public"))?, - peer_endpoint: peer_endpoint.ok_or_else(|| missing("peer_endpoint"))?, + peers, listen: listen.ok_or_else(|| missing("listen"))?, device: device.ok_or_else(|| missing("device"))?, device_kind, - initiate: initiate.ok_or_else(|| missing("initiate"))?, }) } } @@ -166,9 +222,8 @@ mod tests { peer_public=00000000000000000000000000000000000000000000000000000000000000bb\n"; let c = Config::parse(text).unwrap(); assert_eq!(c.device, "yip0"); - assert!(c.initiate); assert_eq!(c.local_private[31], 0xff); - assert_eq!(c.peer_public[31], 0xbb); + assert_eq!(c.peers[0].public_key[31], 0xbb); } #[test] @@ -186,7 +241,7 @@ peer_public=0000000000000000000000000000000000000000000000000000000000000003 "; let c = Config::parse(text).unwrap(); assert_eq!(c.device, "yip1"); - assert!(!c.initiate); + assert_eq!(c.peers.len(), 1); } #[test] @@ -277,21 +332,6 @@ peer_public=0000000000000000000000000000000000000000000000000000000000000003 assert!(Config::parse(text).is_err()); } - #[test] - fn parse_config_bad_initiate_returns_error() { - let text = "\ -device=yip0 -listen=0.0.0.0:51820 -peer_endpoint=10.0.0.2:51820 -initiate=maybe -local_private=0000000000000000000000000000000000000000000000000000000000000001 -local_public=0000000000000000000000000000000000000000000000000000000000000002 -peer_public=0000000000000000000000000000000000000000000000000000000000000003 -"; - let err = Config::parse(text).unwrap_err(); - assert!(err.to_string().contains("invalid boolean")); - } - #[test] fn parse_config_unknown_key_is_silently_ignored() { // Unknown keys must not cause an error (forward-compat). @@ -372,26 +412,27 @@ peer_public=0000000000000000000000000000000000000000000000000000000000000003 } #[test] - fn parse_config_initiate_numeric_aliases() { - let yes_text = "\ -device=yip0\nlisten=0.0.0.0:51820\npeer_endpoint=10.0.0.2:51820\ninitiate=1\n\ -local_private=0000000000000000000000000000000000000000000000000000000000000001\n\ -local_public=0000000000000000000000000000000000000000000000000000000000000002\n\ -peer_public=0000000000000000000000000000000000000000000000000000000000000003\n"; - assert!(Config::parse(yes_text).unwrap().initiate); - - let no_text = "\ -device=yip0\nlisten=0.0.0.0:51820\npeer_endpoint=10.0.0.2:51820\ninitiate=0\n\ -local_private=0000000000000000000000000000000000000000000000000000000000000001\n\ -local_public=0000000000000000000000000000000000000000000000000000000000000002\n\ -peer_public=0000000000000000000000000000000000000000000000000000000000000003\n"; - assert!(!Config::parse(no_text).unwrap().initiate); - - let yes_text2 = "\ -device=yip0\nlisten=0.0.0.0:51820\npeer_endpoint=10.0.0.2:51820\ninitiate=yes\n\ -local_private=0000000000000000000000000000000000000000000000000000000000000001\n\ -local_public=0000000000000000000000000000000000000000000000000000000000000002\n\ -peer_public=0000000000000000000000000000000000000000000000000000000000000003\n"; - assert!(Config::parse(yes_text2).unwrap().initiate); + fn parses_multiple_peers_and_legacy_single() { + // New [peer] block form: + let text = "local_private=00000000000000000000000000000000000000000000000000000000000000ff\n\ + local_public=000000000000000000000000000000000000000000000000000000000000aa01\n\ + listen=0.0.0.0:51820\ndevice=yip0\n\ + [peer]\npublic_key=00000000000000000000000000000000000000000000000000000000000000b1\nendpoint=10.0.0.2:51820\n\ + [peer]\npublic_key=00000000000000000000000000000000000000000000000000000000000000b2\nendpoint=10.0.0.3:51820\n"; + let cfg = Config::parse(text).expect("parses"); + assert_eq!(cfg.peers.len(), 2); + assert_eq!(cfg.peers[0].endpoint, "10.0.0.2:51820".parse().unwrap()); + assert_eq!(cfg.peers[1].public_key[31], 0xb2); + } + + #[test] + fn legacy_single_peer_becomes_one_entry() { + let text = "device=yip0\nlisten=0.0.0.0:51820\npeer_endpoint=10.0.0.2:51820\n\ + local_private=00000000000000000000000000000000000000000000000000000000000000ff\n\ + local_public=00000000000000000000000000000000000000000000000000000000000000aa\n\ + peer_public=00000000000000000000000000000000000000000000000000000000000000bb\n"; + let cfg = Config::parse(text).expect("legacy parses"); + assert_eq!(cfg.peers.len(), 1); + assert_eq!(cfg.peers[0].public_key[31], 0xbb); } } diff --git a/bin/yipd/src/dataplane.rs b/bin/yipd/src/dataplane.rs index baa9330..031de6f 100644 --- a/bin/yipd/src/dataplane.rs +++ b/bin/yipd/src/dataplane.rs @@ -2,6 +2,7 @@ //! and auxiliary buffers. Driven by the epoll event loop in `yip_io::poll`. use std::collections::{HashMap, VecDeque}; +use std::net::SocketAddr; use yip_transport::{FlowClass, LossDetector, LossReport, RetxBuffer, Transport}; use yip_wire::{Codec, WireCodec as _}; @@ -91,10 +92,10 @@ pub enum Outcome<'a> { /// Write this slice to the TUN device (data path: decoded inner packet). TunWrite(&'a [u8]), /// Send these datagrams to the peer (control path: ARQ retransmits). - Send(&'a [Vec]), + Send(&'a [yip_io::poll::EgressDatagram]), /// Write to TUN *and* send datagrams (currently unused, reserved for future). #[expect(dead_code, reason = "reserved for future combined TUN+UDP paths")] - TunWriteThenSend(&'a [u8], &'a [Vec]), + TunWriteThenSend(&'a [u8], &'a [yip_io::poll::EgressDatagram]), } // ── DataPlane ───────────────────────────────────────────────────────────────── @@ -115,6 +116,12 @@ pub struct DataPlane { codec: Codec, conn_tag: u64, l2: bool, + /// This (single) peer's UDP endpoint, stamped as `dst` on every egress + /// datagram (data, ARQ retransmit, and feedback/tick alike). Multipeer + /// 2a seam (#33): `on_udp`'s `src` is ignored here — the `PeerManager` + /// arriving in Task 5 is what actually routes by address; until then, + /// one peer must behave exactly as it did when the socket was connected. + peer_addr: SocketAddr, sent_log: SentLog, retx: RetxBuffer, detector: LossDetector, @@ -134,9 +141,10 @@ pub struct DataPlane { /// Reused scratch for the decoded inner packet (TUN write target). inner_scratch: Vec, /// Reused scratch for ARQ retransmit datagrams (control-path sends). - retx_scratch: Vec>, - /// Reused scratch for the sealed feedback Control packet. - feedback_scratch: Vec, + retx_scratch: Vec, + /// Reused scratch holding exactly one entry: the sealed feedback Control + /// packet built by `tick`, addressed to `peer_addr`. + tick_scratch: Vec, } impl DataPlane { @@ -145,7 +153,15 @@ impl DataPlane { /// The wire codec keys are derived from the same channel-binding sub-keys /// that were derived during the handshake (`established.auth_key` / /// `established.hp_key`), so both peers end up with the same codec. - pub fn new(established: Established, conn_tag: u64, mode: TunnelMode) -> Self { + /// + /// `peer_addr` is this (single) peer's UDP endpoint; it is stamped as + /// `dst` on every egress datagram this `DataPlane` produces. + pub fn new( + established: Established, + conn_tag: u64, + mode: TunnelMode, + peer_addr: SocketAddr, + ) -> Self { let codec = Codec::new(established.auth_key, established.hp_key); Self { session: established.session, @@ -153,6 +169,7 @@ impl DataPlane { codec, conn_tag, l2: matches!(mode, TunnelMode::L2Tap), + peer_addr, sent_log: SentLog::new(SENT_LOG_CAPACITY), retx: RetxBuffer::new(RETX_BUFFER_MAX, RETX_BUFFER_TTL_MS), detector: LossDetector::new(5, 1024), @@ -164,10 +181,18 @@ impl DataPlane { egress_scratch: Vec::new(), inner_scratch: Vec::new(), retx_scratch: Vec::new(), - feedback_scratch: Vec::new(), + tick_scratch: Vec::new(), } } + /// The wire `conn_tag` this session's frames carry (both peers derive the + /// same value from the handshake channel binding — see + /// [`conn_tag_from_keys`]). Used by `PeerManager` to register this peer + /// in its `conn_tag -> peer` map once the handshake completes. + pub fn conn_tag(&self) -> u64 { + self.conn_tag + } + /// Seal `inner`, FEC-encode, frame each symbol, and return the resulting /// egress datagrams as a borrow of an internal reused scratch buffer. /// @@ -213,10 +238,12 @@ impl DataPlane { // ── 4. Frame each symbol into the reused scratch ────────────────────── let n_syms = symbols.len(); + let peer_addr = self.peer_addr; if self.egress_scratch.len() < n_syms { self.egress_scratch .resize_with(n_syms, || yip_io::poll::EgressDatagram { fate: 0, + dst: peer_addr, bytes: Vec::new(), }); } @@ -227,6 +254,7 @@ impl DataPlane { // `fate` = the RaptorQ object id: all symbols of one object (source + // its repair) share it, so a GSO driver keeps them in separate skbs. slot.fate = sym.object_id; + slot.dst = peer_addr; slot.bytes.clear(); slot.bytes.push(PacketType::Data as u8); slot.bytes.extend_from_slice(&dg); @@ -422,7 +450,11 @@ impl DataPlane { let mut pkt = Vec::with_capacity(1 + dg_bytes.len()); pkt.push(PacketType::Data as u8); pkt.extend_from_slice(&dg_bytes); - self.retx_scratch.push(pkt); + self.retx_scratch.push(yip_io::poll::EgressDatagram { + fate: oid, + dst: self.peer_addr, + bytes: pkt, + }); } } @@ -443,10 +475,11 @@ impl DataPlane { /// Periodic tick: emit a feedback `Control` packet if enough time has elapsed, /// and drive the periodic diagnostic logs. /// - /// Returns `Some(&[u8])` — a borrow of the internal feedback scratch buffer — - /// when a feedback packet was built (the caller must send it to the peer). + /// Returns `Some(&[EgressDatagram])` — a borrow of the internal + /// single-element tick scratch buffer, addressed to `peer_addr` — when a + /// feedback packet was built (the caller must send it to the peer). /// Returns `None` if no feedback interval has elapsed. - pub fn tick(&mut self, now_ms: u64) -> Option<&[u8]> { + pub fn tick(&mut self, now_ms: u64) -> Option<&[yip_io::poll::EgressDatagram]> { if now_ms.saturating_sub(self.last_sweep_ms) >= MAC_SWEEP_INTERVAL_MS { self.last_sweep_ms = now_ms; self.mac_table.sweep(now_ms); @@ -486,14 +519,23 @@ impl DataPlane { } }; - // Build: [type:1][counter:8be][ciphertext] - self.feedback_scratch.clear(); - self.feedback_scratch.push(PacketType::Control as u8); - self.feedback_scratch - .extend_from_slice(&sealed.counter.to_be_bytes()); - self.feedback_scratch.extend_from_slice(&sealed.ciphertext); - - Some(&self.feedback_scratch) + // Build: [type:1][counter:8be][ciphertext] into the single reused + // tick-scratch entry (allocated once, then just cleared/refilled). + if self.tick_scratch.is_empty() { + self.tick_scratch.push(yip_io::poll::EgressDatagram { + fate: 0, + dst: self.peer_addr, + bytes: Vec::new(), + }); + } + let dg = &mut self.tick_scratch[0]; + dg.dst = self.peer_addr; + dg.bytes.clear(); + dg.bytes.push(PacketType::Control as u8); + dg.bytes.extend_from_slice(&sealed.counter.to_be_bytes()); + dg.bytes.extend_from_slice(&sealed.ciphertext); + + Some(&self.tick_scratch) } } @@ -538,7 +580,16 @@ fn source_mac_from_ethernet_frame(inner: &[u8]) -> Option<[u8; 6]> { // ── Dispatch impl ───────────────────────────────────────────────────────────── impl yip_io::poll::Dispatch for DataPlane { - fn on_udp(&mut self, dg: &[u8], now_ms: u64) -> yip_io::poll::DispatchOut<'_> { + /// `src` is ignored: this `DataPlane` has exactly one peer (`peer_addr`, + /// fixed at construction), so it behaves exactly as it did when the + /// socket was `connect`-ed. The Task 5 `PeerManager` is what will + /// actually route ingress by `src`. + fn on_udp( + &mut self, + _src: SocketAddr, + dg: &[u8], + now_ms: u64, + ) -> yip_io::poll::DispatchOut<'_> { match self.on_udp_datagram(dg, now_ms) { Outcome::None => yip_io::poll::DispatchOut::None, Outcome::TunWrite(buf) => yip_io::poll::DispatchOut::Tun(buf), @@ -551,7 +602,7 @@ impl yip_io::poll::Dispatch for DataPlane { self.on_tun_packet(inner, now_ms) } - fn tick(&mut self, now_ms: u64) -> Option<&[u8]> { + fn tick(&mut self, now_ms: u64) -> Option<&[yip_io::poll::EgressDatagram]> { self.tick(now_ms) } } @@ -566,8 +617,15 @@ mod tests { use crate::wire_glue::derive_wire_keys; + /// `a`'s configured peer address (i.e. `b`'s endpoint) in [`dataplane_pair`]. + const TEST_ADDR_B: &str = "203.0.113.2:51820"; + /// `b`'s configured peer address (i.e. `a`'s endpoint) in [`dataplane_pair`]. + const TEST_ADDR_A: &str = "203.0.113.1:51820"; + /// Build two [`DataPlane`]s whose sessions can talk to each other, by - /// running a full in-process Noise-IK handshake. + /// running a full in-process Noise-IK handshake. `a`'s `peer_addr` is + /// [`TEST_ADDR_B`] and `b`'s is [`TEST_ADDR_A`] — distinct, so tests can + /// assert that each stamps the *other*'s address as `dst`. fn dataplane_pair(mode: TunnelMode) -> (DataPlane, DataPlane) { let resp_kp = generate_keypair(); let init_kp = generate_keypair(); @@ -606,8 +664,8 @@ mod tests { let conn_tag = conn_tag_from_keys(&auth_key, &hp_key); ( - DataPlane::new(est_i, conn_tag, mode), - DataPlane::new(est_r, conn_tag, mode), + DataPlane::new(est_i, conn_tag, mode, TEST_ADDR_B.parse().unwrap()), + DataPlane::new(est_r, conn_tag, mode, TEST_ADDR_A.parse().unwrap()), ) } @@ -638,6 +696,13 @@ mod tests { dgrams.iter().all(|dg| dg.fate == fate), "all symbols of one object must share one fate" ); + // A's DataPlane stamps every egress datagram with its configured peer + // address (TEST_ADDR_B) — the Task 3 addressed-seam contract. + let expected_dst: SocketAddr = TEST_ADDR_B.parse().unwrap(); + assert!( + dgrams.iter().all(|dg| dg.dst == expected_dst), + "every egress datagram must be stamped with the configured peer_addr" + ); // Full round-trip: feed all datagrams to B's ingress; at least one must // produce a TunWrite with the original inner bytes. @@ -669,8 +734,14 @@ mod tests { // with the missing counter. now_ms=50 exceeds both the 5 ms grace // and the 30 ms FEEDBACK_INTERVAL_MS, so a packet is guaranteed. let fb = b.tick(50).expect("feedback emitted").to_vec(); + assert_eq!(fb.len(), 1, "tick emits exactly one feedback datagram"); + let expected_dst: SocketAddr = TEST_ADDR_A.parse().unwrap(); + assert_eq!( + fb[0].dst, expected_dst, + "B's tick stamps its configured peer_addr (A's address)" + ); // A ingests the control packet → attributes loss + (for Bulk) retransmits. - if let Outcome::Send(s) = a.on_udp_datagram(&fb, 51) { + if let Outcome::Send(s) = a.on_udp_datagram(&fb[0].bytes, 51) { assert!(!s.is_empty()); } // (Exact retransmit depends on class; at minimum assert the control packet diff --git a/bin/yipd/src/handshake.rs b/bin/yipd/src/handshake.rs index 5543063..026c56f 100644 --- a/bin/yipd/src/handshake.rs +++ b/bin/yipd/src/handshake.rs @@ -67,6 +67,19 @@ const MAX_RETRIES: u32 = 5; /// then derives an [`Established`] session. Retries up to [`MAX_RETRIES`] times /// (each with a [`RETRY_TIMEOUT`] read timeout) so the companion test is not /// flaky even when the responder thread has not started yet. +/// +/// Superseded in production by [`HandshakeState`]'s step-functions (Task 5): +/// `tunnel.rs` no longer does a pre-loop blocking handshake, so this blocking, +/// socket-owning variant is unreachable outside its own tests. Kept (per the +/// Task 5 addendum) rather than deleted, since it is still the simplest way to +/// exercise a full initiator/responder round-trip in a unit test. +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "superseded by HandshakeState's step-functions; kept for its own unit tests" + ) +)] pub fn run_initiator( sock: &UdpSocket, peer: SocketAddr, @@ -135,6 +148,16 @@ pub fn run_initiator( /// Blocks until a `[HandshakeInit]` datagram arrives, sends the /// `[HandshakeResp]` reply, then returns an [`Established`] session together /// with the initiator's [`SocketAddr`]. +/// +/// Superseded in production by [`HandshakeState`]'s step-functions; see +/// [`run_initiator`]'s doc comment. +#[cfg_attr( + not(test), + expect( + dead_code, + reason = "superseded by HandshakeState's step-functions; kept for its own unit tests" + ) +)] pub fn run_responder( sock: &UdpSocket, local_priv: &[u8; 32], @@ -171,6 +194,111 @@ pub fn run_responder( )) } +// ── step-functions (in-band handshakes) ──────────────────────────────────────── + +/// A handshake in progress, driven step-by-step instead of blocking on a +/// socket. This lets a caller (e.g. `PeerManager`'s event loop) multiplex +/// several concurrent handshakes without dedicating a thread to each. +/// +/// Only the initiator side needs to carry state between steps (it must +/// remember the in-progress [`Handshake`] while awaiting the responder's +/// reply); the responder completes in a single step. +pub struct HandshakeState { + handshake: Handshake, +} + +impl HandshakeState { + /// Start the initiator role: build `[HandshakeInit] ++ msg1`. + /// + /// Returns the in-progress state (to be resumed via [`Self::read_response`]) + /// together with the framed bytes to send to the peer. + pub fn start_initiator( + local_priv: &[u8; 32], + peer_pub: &[u8; 32], + ) -> io::Result<(Self, Vec)> { + let mut handshake = Handshake::initiator(local_priv, peer_pub).map_err(crypto_err)?; + + let msg1 = handshake.write_message().map_err(crypto_err)?; + let mut init_pkt = Vec::with_capacity(1 + msg1.len()); + init_pkt.push(PacketType::HandshakeInit as u8); + init_pkt.extend_from_slice(&msg1); + + Ok((Self { handshake }, init_pkt)) + } + + /// Run the responder role to completion in a single step: read + /// `[HandshakeInit] ++ msg1` from `init_pkt`, and return the + /// `[HandshakeResp] ++ msg2` reply bytes, the completed [`Established`] + /// session (Noise-IK completes for the responder as soon as it has read + /// msg1 and written msg2), and the initiator's recovered static public + /// key. + /// + /// The static key is required by `PeerManager`'s admission check: a + /// `HandshakeInit` must only be admitted (and a peer transitioned to + /// `Established`) if the recovered static key matches a *configured* + /// peer — otherwise any UDP sender could get a `DataPlane` allocated for + /// it. The key is captured from `handshake.remote_static()` before + /// `into_session()` consumes the handshake (the transport-mode + /// conversion drops the handshake state that holds it). + pub fn start_responder( + local_priv: &[u8; 32], + init_pkt: &[u8], + ) -> io::Result<(Established, Vec, [u8; 32])> { + let mut handshake = Handshake::responder(local_priv).map_err(crypto_err)?; + + if init_pkt.is_empty() || init_pkt[0] != PacketType::HandshakeInit as u8 { + return Err(io::Error::other("expected HandshakeInit packet")); + } + handshake.read_message(&init_pkt[1..]).map_err(crypto_err)?; + + let msg2 = handshake.write_message().map_err(crypto_err)?; + let mut resp_pkt = Vec::with_capacity(1 + msg2.len()); + resp_pkt.push(PacketType::HandshakeResp as u8); + resp_pkt.extend_from_slice(&msg2); + + // Capture the initiator's static key and the channel binding BEFORE + // consuming the handshake into a session. + let remote_static = handshake + .remote_static() + .ok_or_else(|| io::Error::other("responder handshake has no remote static key"))?; + let cb = handshake.channel_binding(); + let session = handshake.into_session().map_err(crypto_err)?; + let (auth_key, hp_key) = derive_wire_keys(&cb); + + Ok(( + Established { + session, + auth_key, + hp_key, + }, + resp_pkt, + remote_static, + )) + } + + /// Resume the initiator role: read `[HandshakeResp] ++ msg2` and + /// finalize into an [`Established`] session. + pub fn read_response(mut self, resp_pkt: &[u8]) -> io::Result { + if resp_pkt.is_empty() || resp_pkt[0] != PacketType::HandshakeResp as u8 { + return Err(io::Error::other("expected HandshakeResp packet")); + } + self.handshake + .read_message(&resp_pkt[1..]) + .map_err(crypto_err)?; + + // Capture channel binding BEFORE consuming the handshake. + let cb = self.handshake.channel_binding(); + let session = self.handshake.into_session().map_err(crypto_err)?; + let (auth_key, hp_key) = derive_wire_keys(&cb); + + Ok(Established { + session, + auth_key, + hp_key, + }) + } +} + // ── tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -206,6 +334,24 @@ mod tests { ); } + #[test] + fn step_handshake_initiator_responder_agree() { + let a = generate_keypair(); + let b = generate_keypair(); + + let (ha, init_pkt) = HandshakeState::start_initiator(&a.private, &b.public).unwrap(); + let (b_est, resp_pkt, initiator_static) = + HandshakeState::start_responder(&b.private, &init_pkt).unwrap(); + let a_est = ha.read_response(&resp_pkt).unwrap(); + + // Both derive the same channel binding (conn_tag inputs). + assert_eq!(a_est.auth_key, b_est.auth_key); + assert_eq!(a_est.hp_key, b_est.hp_key); + // The responder recovers the initiator's static public key — this is + // what `PeerManager` admission-checks against configured peers. + assert_eq!(initiator_static, a.public); + } + #[test] fn crypto_err_converts_to_io_error() { // Exercise the crypto_err helper: a CryptoError converts to io::Error. diff --git a/bin/yipd/src/main.rs b/bin/yipd/src/main.rs index d97ee44..4f7f892 100644 --- a/bin/yipd/src/main.rs +++ b/bin/yipd/src/main.rs @@ -3,11 +3,13 @@ //! The yip daemon. M6 wires device <-> transport <-> crypto <-> wire <-> io //! and loads a static 2-peer config from a key=value file. +mod addr; mod config; mod dataplane; mod handshake; mod mac_table; mod mode; +mod peer_manager; mod tunnel; mod wire_glue; @@ -21,6 +23,14 @@ fn hex_encode(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } +/// Inverse of [`hex_encode`]: decode a 64-char hex string into a 32-byte +/// pubkey. Returns `Err` (message on stderr already emitted by the caller) +/// on wrong length or a non-hex digit. +fn hex_decode_32(hex: &str) -> Result<[u8; 32], String> { + // Single-sourced with the config parser so the two decoders can't drift. + crate::config::hex_to_32(hex).map_err(|e| e.to_string()) +} + fn main() -> std::io::Result<()> { let mut args = std::env::args(); let _prog = args.next(); @@ -36,6 +46,18 @@ fn main() -> std::io::Result<()> { println!("public={}", hex_encode(&kp.public)); Ok(()) } + Some("--addr") => { + let hex = args.next().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "--addr requires a 64-char hex pubkey argument", + ) + })?; + let pubkey = hex_decode_32(&hex) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; + println!("{}", addr::node_addr(&pubkey)); + Ok(()) + } Some(path) => { let text = std::fs::read_to_string(path)?; let config = Config::parse(&text)?; @@ -45,6 +67,7 @@ fn main() -> std::io::Result<()> { eprintln!("usage: yipd "); eprintln!(" yipd --version"); eprintln!(" yipd --genkey"); + eprintln!(" yipd --addr "); Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "no config file specified", @@ -61,4 +84,30 @@ mod tests { fn banner_contains_name() { assert!(banner().starts_with("yipd ")); } + + #[test] + fn hex_decode_32_round_trips_through_hex_encode() { + let kp = yip_crypto::generate_keypair(); + let hex = hex_encode(&kp.public); + assert_eq!(hex_decode_32(&hex).unwrap(), kp.public); + } + + #[test] + fn hex_decode_32_matches_node_addr_derivation() { + let kp = yip_crypto::generate_keypair(); + let hex = hex_encode(&kp.public); + let decoded = hex_decode_32(&hex).unwrap(); + assert_eq!(addr::node_addr(&decoded), addr::node_addr(&kp.public)); + } + + #[test] + fn hex_decode_32_rejects_wrong_length() { + assert!(hex_decode_32("deadbeef").is_err()); + } + + #[test] + fn hex_decode_32_rejects_bad_digit() { + let bad = "zz".repeat(32); + assert!(hex_decode_32(&bad).is_err()); + } } diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs new file mode 100644 index 0000000..16d35fa --- /dev/null +++ b/bin/yipd/src/peer_manager.rs @@ -0,0 +1,1067 @@ +//! `PeerManager`: multi-peer routing/demux + in-loop lazy handshake. +//! +//! This is the integration crux of milestone 2a. It owns one [`DataPlane`] +//! per established remote peer, drives the [`HandshakeState`] step-functions +//! to bring a peer up from a cold start (no pre-loop blocking handshake, no +//! `sock.connect`), and implements [`Dispatch`] so [`yip_io::poll::run_poll`] +//! / `yip_io::uring::run_uring` can drive it directly. +//! +//! # Lazy handshake +//! +//! A peer starts in [`PeerState::Idle`]: nothing has been sent to it yet. +//! The first TUN packet routed to that peer (see "TUN routing" below) +//! buffers the packet in `pending_tun`, starts a [`HandshakeState`] initiator, +//! and emits `[HandshakeInit]`. The peer stays `Handshaking` until either: +//! - a `[HandshakeResp]` arrives from that peer's endpoint (→ `Established`, +//! buffered `pending_tun` is drained through the new `DataPlane`), or +//! - `tick` decides a retry/timeout has elapsed (resend, or give up and +//! revert to `Idle`, dropping anything buffered). +//! +//! Symmetrically, an incoming `[HandshakeInit]` is answered (admission +//! permitting) by `start_responder`, which *also* transitions that peer to +//! `Established` and drains its own `pending_tun` — covering the (rare, but +//! possible) race where both sides try to talk before either handshake +//! completes. +//! +//! # TUN routing +//! +//! In `L3Tun` mode, the inner packet's IPv6 destination is looked up in +//! `by_addr` (each configured peer's self-certifying `node_addr`). When +//! there is exactly one configured peer and the lookup misses — e.g. the +//! packet isn't IPv6 at all, or doesn't carry the mesh address, as is true +//! of today's single-peer netns tests, which assign plain IPv4 addresses to +//! the TUN device — the packet still routes to that one peer: with a single +//! peer there is no routing ambiguity to resolve, and requiring "real" mesh +//! addressing here would regress the existing single-peer tunnel tests. +//! With more than one configured peer, an unmatched destination is genuinely +//! ambiguous and the packet is dropped. +//! +//! In `L2Tap` mode there is no IPv6 destination to key off (frames are +//! Ethernet); 2a scope is a single TAP peer, so every frame forwards to the +//! sole configured peer regardless of its inner L2 destination. Multi-peer +//! L2 bridging/flooding across more than one TAP peer is out of scope for +//! 2a and left to a future milestone. +//! +//! # UDP demux: why routing is by source address, not raw `conn_tag` bytes +//! +//! Each peer's `DataPlane` frames `Data` packets through `yip_wire::Codec`, +//! which XORs the entire logical header — including the 8 `conn_tag` bytes +//! at `dg[1..9]` — under a keystream seeded by that frame's own auth tag +//! (see `yip-wire`'s `Codec::frame`). That mask is a function of the whole +//! frame's contents, so it is different on *every* datagram, even between +//! two datagrams of the same connection. The raw bytes at `dg[1..9]` are +//! therefore not recoverable as a stable `conn_tag` without first picking +//! the right peer's codec (`hp_key`) to unmask them — which is exactly the +//! question being asked. `Control` packets are worse: `dg[1..9]` there is +//! the *AEAD counter* (see `DataPlane::on_udp_datagram`'s `Control` arm), +//! not a conn_tag at all, sent unmasked. +//! +//! [`PeerManager::route_data`] therefore demuxes primarily by matching the +//! datagram's source address against each peer's learned/configured +//! `endpoint` — correct uniformly for `Data` and `Control` frames, and +//! exactly the mechanism the addendum itself specifies for routing +//! `[HandshakeResp]`. `by_tag` is still populated and consulted first as a +//! best-effort fast-path hint (it *will* hit for hand-built test datagrams +//! that place the raw tag directly, and costs nothing when it misses on +//! real, masked traffic). If neither the tag hint nor the address match +//! finds a peer (e.g. a NAT rebind changed the peer's source port), a +//! bounded fallback tries every `Established` peer's codec in turn — safe +//! because `DataPlane::on_udp_datagram` authenticates (AEAD / SipHash MAC) +//! before any side effect, so trying the wrong peer just yields +//! `Outcome::None`, never corrupted state. + +use std::collections::HashMap; +use std::net::{Ipv6Addr, SocketAddr}; + +use yip_io::poll::{Dispatch, DispatchOut, EgressDatagram}; + +use crate::addr::node_addr; +use crate::config::PeerConfig; +use crate::dataplane::{conn_tag_from_keys, DataPlane, Outcome}; +use crate::handshake::{HandshakeState, PacketType}; +use crate::mode::TunnelMode; + +/// How long an in-flight initiator handshake waits before resending +/// `[HandshakeInit]`. +const HANDSHAKE_RETRY_MS: u64 = 1_000; +/// Total time an initiator keeps retransmitting *the same* `[HandshakeInit]` +/// (holding one Noise ephemeral) before giving up and reverting to `Idle`. +/// +/// This is deliberately a long window (WireGuard's `REKEY_ATTEMPT_TIME`), not +/// a small retry count. A responder that admits our `Init` caches its +/// `[HandshakeResp]` keyed to *this* ephemeral and replays that cached reply +/// on every retransmit (see `handle_handshake_init`). If we instead gave up +/// early and later re-initiated with a *fresh* ephemeral, the responder — +/// which has no idle-timeout and never rebuilds a live session (there is no +/// anti-replay in the handshake yet, so it cannot safely tell a genuine +/// re-initiation from a replayed old `Init` — see issue: handshake +/// anti-replay) — would keep replaying its stale reply forever and we could +/// never complete. Retransmitting the *same* `Init` keeps our ephemeral +/// matching the responder's cached session, so ordinary handshake-packet loss +/// is overcome by retransmission rather than wedging the peer permanently. +const HANDSHAKE_TOTAL_MS: u64 = 90_000; + +/// Cap on TUN packets buffered per peer while its handshake is in flight. +/// Bounds memory when a peer streams into an unestablished (or unreachable) +/// peer during the `HANDSHAKE_TOTAL_MS` window; the oldest are dropped, like +/// a small tail queue (WireGuard stages a single packet). +const MAX_PENDING_TUN: usize = 16; + +/// An initiator handshake in flight, awaiting `[HandshakeResp]`. Boxed by +/// [`PeerState::Handshaking`] so that variant stays pointer-sized like +/// `Established(Box)` — `HandshakeState`/`init_pkt` are much +/// larger than the other `PeerState` variants (clippy `large_enum_variant`). +struct HandshakingState { + hs: HandshakeState, + /// When this handshake attempt first started. The attempt is abandoned + /// once `now - started_ms >= HANDSHAKE_TOTAL_MS`; until then the same + /// `init_pkt` is retransmitted every `HANDSHAKE_RETRY_MS`. + started_ms: u64, + /// When `[HandshakeInit]` was last (re)sent. + last_sent_ms: u64, + /// How many times `[HandshakeInit]` has been resent (for logging/metrics). + retries: u32, + /// The framed `[HandshakeInit]` datagram, resent verbatim on retry. + /// `HandshakeState` cannot regenerate this: Noise's ephemeral key is + /// drawn once, in `start_initiator`'s `write_message`, and the peer must + /// see that exact message again (not a fresh one) on retry. + init_pkt: Vec, +} + +/// One remote peer's handshake/session state. +enum PeerState { + /// No handshake has been attempted yet. + Idle, + /// An initiator handshake is in flight, awaiting `[HandshakeResp]`. + Handshaking(Box), + /// A completed session; all data-plane traffic routes here. + Established(Box), +} + +/// One configured remote peer plus its live handshake/session state. +struct Peer { + pubkey: [u8; 32], + /// This peer's self-certifying inner IPv6 address (`node_addr(pubkey)`). + /// Routing itself goes through `by_addr` (kept alongside for tests and + /// future logging/debugging use). + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "kept for tests/future logging; routing uses by_addr" + ) + )] + addr: Ipv6Addr, + /// This peer's UDP endpoint: the configured value until a `HandshakeInit` + /// admission *learns* the actual observed source address (see + /// `PeerManager::handle_handshake_init`). + endpoint: SocketAddr, + state: PeerState, + /// TUN packets buffered while no `Established` session exists yet. + pending_tun: Vec>, + /// The `[HandshakeResp]` bytes that established the *current* session, + /// cached when this peer was admitted as responder. A repeated + /// `HandshakeInit` (a duplicate, or a retransmit after our reply was + /// lost) is answered by re-sending these exact bytes rather than running + /// the responder step again — see `handle_handshake_init`. `None` when we + /// have no session, or hold one we built as the initiator. + cached_resp: Option>, +} + +/// Multi-peer router/demuxer + lazy in-loop handshake driver. +/// +/// Implements [`Dispatch`] so it can be driven directly by +/// [`yip_io::poll::run_poll`] / `yip_io::uring::run_uring`. See the module +/// doc for the routing/demux design. +pub struct PeerManager { + local_priv: [u8; 32], + local_pub: [u8; 32], + mode: TunnelMode, + /// Small N (2a scope): linear scan for state transitions is fine. + peers: Vec, + /// `conn_tag -> peers index`, populated whenever a peer reaches + /// `Established`. Consulted as a fast-path hint by `route_data` (see the + /// module doc for why it is not the primary demux mechanism). In 2a a peer + /// establishes exactly once (duplicate/retransmitted inits re-send the + /// cached reply rather than rebuilding — see `handle_handshake_init`), so + /// each peer contributes one entry that never goes stale. M7 rekey will + /// rotate `conn_tag`s per epoch and must evict the superseded entry here. + by_tag: HashMap, + /// `node_addr -> peers index`, populated at construction (addresses are + /// derived from each peer's configured public key and never change). + by_addr: HashMap, + /// Reused scratch for `on_udp`/`on_tun` return values. + egress: Vec, + /// Reused scratch for `tick`'s return value. + tick_egress: Vec, + /// Reused scratch for a `Tun`/`Both` outcome reached via the + /// address-unmatched fallback in `handle_data_or_control`. That path + /// must materialize owned data (see its doc comment) rather than return + /// a slice borrowed straight from a `DataPlane`, to sidestep a + /// borrow-checker limitation around retrying a `&mut self`-returning + /// call across loop iterations. + tun_scratch: Vec, +} + +impl PeerManager { + /// Build a `PeerManager` from the local keypair and the configured peer + /// list. Every peer starts `Idle`; no handshake is attempted until the + /// first TUN packet (or an incoming `HandshakeInit`) needs it. + pub fn new( + local_priv: [u8; 32], + local_pub: [u8; 32], + peers_cfg: &[PeerConfig], + mode: TunnelMode, + ) -> Self { + let mut peers = Vec::with_capacity(peers_cfg.len()); + let mut by_addr = HashMap::with_capacity(peers_cfg.len()); + for (i, p) in peers_cfg.iter().enumerate() { + let addr = node_addr(&p.public_key); + by_addr.insert(addr, i); + peers.push(Peer { + pubkey: p.public_key, + addr, + endpoint: p.endpoint, + state: PeerState::Idle, + pending_tun: Vec::new(), + cached_resp: None, + }); + } + Self { + local_priv, + local_pub, + mode, + peers, + by_tag: HashMap::new(), + by_addr, + egress: Vec::new(), + tick_egress: Vec::new(), + tun_scratch: Vec::new(), + } + } + + /// This node's own self-certifying mesh address, for assigning the + /// local TUN/TAP device's address. + pub fn local_addr(&self) -> Ipv6Addr { + node_addr(&self.local_pub) + } + + /// Append a TUN packet to a peer's pending buffer, dropping the oldest if + /// the buffer is at [`MAX_PENDING_TUN`] so a peer streaming into an + /// unestablished/unreachable peer cannot grow memory without bound. + fn push_pending(pending: &mut Vec>, inner: &[u8]) { + if pending.len() >= MAX_PENDING_TUN { + pending.remove(0); + } + pending.push(inner.to_vec()); + } + + // ── TUN routing ─────────────────────────────────────────────────────── + + /// Which configured peer a TUN/TAP frame should go to, or `None` if it + /// cannot be routed (ambiguous multi-peer destination). See the module + /// doc for the L2/L3 routing rules. + fn route_tun_index(&self, inner: &[u8]) -> Option { + match self.mode { + TunnelMode::L2Tap => { + if self.peers.len() == 1 { + Some(0) + } else { + None + } + } + TunnelMode::L3Tun => { + if let Some(dst) = ipv6_dst(inner) { + if let Some(&idx) = self.by_addr.get(&dst) { + return Some(idx); + } + } + if self.peers.len() == 1 { + Some(0) + } else { + None + } + } + } + } + + // ── UDP demux ───────────────────────────────────────────────────────── + + /// Which `Established` peer a `Data`/`Control` datagram should be + /// dispatched to, or `None` if none can be determined. Pure routing + /// decision — does not touch any `DataPlane` state. See the module doc + /// for why source-address matching is primary and the raw `dg[1..9]` + /// `by_tag` hint is secondary. + fn route_data(&self, src: SocketAddr, dg: &[u8]) -> Option { + if dg.len() >= 9 { + let tag_bytes: [u8; 8] = dg[1..9].try_into().expect("checked len >= 9 above"); + let tag = u64::from_be_bytes(tag_bytes); + if let Some(&idx) = self.by_tag.get(&tag) { + if matches!(self.peers[idx].state, PeerState::Established(_)) { + return Some(idx); + } + } + } + self.peers + .iter() + .position(|p| p.endpoint == src && matches!(p.state, PeerState::Established(_))) + } + + /// Dispatch a `Data`/`Control` datagram to peer `idx`'s `DataPlane` and + /// re-map its `Outcome` into a `DispatchOut`. Returns `DispatchOut::None` + /// if `idx` is not (or no longer) `Established`. + fn dispatch_established(&mut self, idx: usize, dg: &[u8], now_ms: u64) -> DispatchOut<'_> { + let PeerState::Established(dp) = &mut self.peers[idx].state else { + return DispatchOut::None; + }; + match dp.on_udp_datagram(dg, now_ms) { + Outcome::None => DispatchOut::None, + Outcome::TunWrite(buf) => DispatchOut::Tun(buf), + Outcome::Send(pkts) => DispatchOut::Udp(pkts), + Outcome::TunWriteThenSend(buf, pkts) => DispatchOut::Both(buf, pkts), + } + } + + fn handle_data_or_control( + &mut self, + src: SocketAddr, + dg: &[u8], + now_ms: u64, + ) -> DispatchOut<'_> { + if let Some(idx) = self.route_data(src, dg) { + return self.dispatch_established(idx, dg, now_ms); + } + // No address/tag match at all (e.g. the peer roamed) — try every + // Established peer's codec once each. Safe (see module doc): a + // failed authentication is a no-op, not corrupted state. + // + // This loop materializes owned copies of any hit rather than + // returning a slice borrowed straight from `DataPlane::on_udp_datagram`: + // a loop that calls a `&mut self`-borrowing method and conditionally + // returns its (borrowed) result does not type-check under NLL — the + // borrow from the *first* call is typed as lasting until the + // function returns (because *some* branch escapes it), which then + // conflicts with the *next* iteration's call needing its own `&mut + // self`. Cloning decouples each attempt from any borrow so the loop + // itself is unremarkable; the final hit (if any) is copied once into + // `self.tun_scratch`/`self.egress` and returned borrowed from there. + let candidates: Vec = self + .peers + .iter() + .enumerate() + .filter(|(_, p)| matches!(p.state, PeerState::Established(_))) + .map(|(i, _)| i) + .collect(); + for idx in candidates { + let hit = { + let PeerState::Established(dp) = &mut self.peers[idx].state else { + continue; + }; + match dp.on_udp_datagram(dg, now_ms) { + Outcome::None => None, + Outcome::TunWrite(buf) => Some((Some(buf.to_vec()), Vec::new())), + Outcome::Send(pkts) => Some((None, pkts.to_vec())), + Outcome::TunWriteThenSend(buf, pkts) => { + Some((Some(buf.to_vec()), pkts.to_vec())) + } + } + }; + let Some((tun, udp)) = hit else { + continue; + }; + return match (tun, udp.is_empty()) { + (Some(t), true) => { + self.tun_scratch = t; + DispatchOut::Tun(&self.tun_scratch) + } + (Some(t), false) => { + self.tun_scratch = t; + self.egress = udp; + DispatchOut::Both(&self.tun_scratch, &self.egress) + } + (None, false) => { + self.egress = udp; + DispatchOut::Udp(&self.egress) + } + (None, true) => DispatchOut::None, + }; + } + DispatchOut::None + } + + // ── handshake admission ─────────────────────────────────────────────── + + /// Handle an incoming `[HandshakeInit]`: run the responder step, admit + /// only if the recovered static key matches a *configured* peer, and on + /// admission transition that peer to `Established` (learning its + /// endpoint from `src`) and drain any buffered `pending_tun`. + fn handle_handshake_init( + &mut self, + src: SocketAddr, + dg: &[u8], + now_ms: u64, + ) -> DispatchOut<'_> { + let (established, resp_pkt, remote_static) = + match HandshakeState::start_responder(&self.local_priv, dg) { + Ok(t) => t, + Err(e) => { + eprintln!("peer_manager: start_responder failed: {e}"); + return DispatchOut::None; + } + }; + + let Some(idx) = self.peers.iter().position(|p| p.pubkey == remote_static) else { + // Not a configured peer: drop, do not create a peer. + return DispatchOut::None; + }; + + // `start_responder` above drew a fresh Noise ephemeral, so `established` + // is a BRAND-NEW session distinct from any we already hold — installing + // it unconditionally would silently rekey. Branch on our current state + // with that in mind. + match &self.peers[idx].state { + // Already have a live session: this `Init` is a duplicate, a + // retransmit after our earlier reply was lost, or a peer restart. + // Never tear down the running session (2a has no rekey — a rebuilt + // session would strand a peer that stays on the old keys and drops + // the new reply). Re-send the cached `[HandshakeResp]` verbatim so a + // peer still handshaking (its reply was lost) completes on the SAME + // session; a peer already established harmlessly ignores it. Discard + // the freshly-built `established`/`resp_pkt`. + PeerState::Established(_) => match &self.peers[idx].cached_resp { + Some(resp) => { + self.egress.clear(); + self.egress.push(EgressDatagram { + fate: 0, + dst: src, + bytes: resp.clone(), + }); + DispatchOut::Udp(&self.egress) + } + // We hold this session as the initiator (no cached reply): a new + // `Init` from the peer is a restart/rekey, deferred to M7. + None => DispatchOut::None, + }, + // Glare: both sides initiated simultaneously (e.g. the TUN's IPv6 + // autoconf multicast races the peer's traffic at startup). Break + // the tie deterministically by static-key order so both converge on + // ONE session: the larger public key adopts the responder role + // (accepts this `Init`); the smaller key is the designated + // initiator and ignores the competing `Init`, keeping its own + // attempt (it completes when the peer's `[HandshakeResp]` arrives). + PeerState::Handshaking(_) if self.local_pub < self.peers[idx].pubkey => { + DispatchOut::None + } + // `Idle` (no competition — whoever initiates first wins, preserving + // lazy establishment) or `Handshaking` with the larger key (adopt + // responder role): admit this session. + PeerState::Idle | PeerState::Handshaking(_) => { + let conn_tag = conn_tag_from_keys(&established.auth_key, &established.hp_key); + let mut dp = Box::new(DataPlane::new(established, conn_tag, self.mode, src)); + + self.peers[idx].endpoint = src; // learn the observed endpoint + self.peers[idx].cached_resp = Some(resp_pkt.clone()); + self.by_tag.insert(dp.conn_tag(), idx); + + self.egress.clear(); + self.egress.push(EgressDatagram { + fate: 0, + dst: src, + bytes: resp_pkt, + }); + let pending = std::mem::take(&mut self.peers[idx].pending_tun); + for inner in &pending { + let out = dp.on_tun_packet(inner, now_ms); + self.egress.extend(out.iter().cloned()); + } + self.peers[idx].state = PeerState::Established(dp); + + DispatchOut::Udp(&self.egress) + } + } + } + + /// Handle an incoming `[HandshakeResp]`: find the `Handshaking` peer + /// whose endpoint matches `src`, resume via `read_response`, transition + /// to `Established`, and drain any buffered `pending_tun`. + fn handle_handshake_resp( + &mut self, + src: SocketAddr, + dg: &[u8], + now_ms: u64, + ) -> DispatchOut<'_> { + let Some(idx) = self + .peers + .iter() + .position(|p| p.endpoint == src && matches!(p.state, PeerState::Handshaking(_))) + else { + return DispatchOut::None; + }; + + let old_state = std::mem::replace(&mut self.peers[idx].state, PeerState::Idle); + let PeerState::Handshaking(handshaking) = old_state else { + unreachable!("index was matched against PeerState::Handshaking above"); + }; + + match handshaking.hs.read_response(dg) { + Ok(established) => { + let conn_tag = conn_tag_from_keys(&established.auth_key, &established.hp_key); + let mut dp = Box::new(DataPlane::new( + established, + conn_tag, + self.mode, + self.peers[idx].endpoint, + )); + self.by_tag.insert(dp.conn_tag(), idx); + + self.egress.clear(); + let pending = std::mem::take(&mut self.peers[idx].pending_tun); + for inner in &pending { + let out = dp.on_tun_packet(inner, now_ms); + self.egress.extend(out.iter().cloned()); + } + self.peers[idx].state = PeerState::Established(dp); + + if self.egress.is_empty() { + DispatchOut::None + } else { + DispatchOut::Udp(&self.egress) + } + } + Err(e) => { + eprintln!("peer_manager: read_response failed: {e}"); + // State was already reverted to `Idle` above (via the + // `mem::replace`); `pending_tun` stays queued and the next + // `on_tun` call will start a fresh handshake. + DispatchOut::None + } + } + } +} + +impl Dispatch for PeerManager { + fn on_udp(&mut self, src: SocketAddr, dg: &[u8], now_ms: u64) -> DispatchOut<'_> { + if dg.is_empty() { + return DispatchOut::None; + } + if dg[0] == PacketType::HandshakeInit as u8 { + self.handle_handshake_init(src, dg, now_ms) + } else if dg[0] == PacketType::HandshakeResp as u8 { + self.handle_handshake_resp(src, dg, now_ms) + } else { + self.handle_data_or_control(src, dg, now_ms) + } + } + + fn on_tun(&mut self, inner: &[u8], now_ms: u64) -> &[EgressDatagram] { + let Some(idx) = self.route_tun_index(inner) else { + return &[]; + }; + + // Each branch below is a syntactically separate `match`/`if`, rather + // than one `match` with arms that need different sibling `Peer` + // fields (`pending_tun`, `pubkey`) alongside the state borrow: NLL + // unifies a single match expression's borrow across all arms to the + // arm that returns borrowed data, which then conflicts with any + // other arm that also touches `self.peers[idx]`. Splitting into + // independent statements gives each one its own borrow region. + if matches!(self.peers[idx].state, PeerState::Established(_)) { + let PeerState::Established(dp) = &mut self.peers[idx].state else { + unreachable!("just matched Established above"); + }; + return dp.on_tun_packet(inner, now_ms); + } + + if matches!(self.peers[idx].state, PeerState::Handshaking(_)) { + Self::push_pending(&mut self.peers[idx].pending_tun, inner); + return &[]; + } + + // Idle: buffer this packet and kick off a lazy handshake. + Self::push_pending(&mut self.peers[idx].pending_tun, inner); + match HandshakeState::start_initiator(&self.local_priv, &self.peers[idx].pubkey) { + Ok((hs, init_pkt)) => { + let peer_endpoint = self.peers[idx].endpoint; + self.egress.clear(); + self.egress.push(EgressDatagram { + fate: 0, + dst: peer_endpoint, + bytes: init_pkt.clone(), + }); + self.peers[idx].state = PeerState::Handshaking(Box::new(HandshakingState { + hs, + started_ms: now_ms, + last_sent_ms: now_ms, + retries: 0, + init_pkt, + })); + &self.egress + } + Err(e) => { + eprintln!("peer_manager: failed to start handshake: {e}"); + &[] + } + } + } + + fn tick(&mut self, now_ms: u64) -> Option<&[EgressDatagram]> { + self.tick_egress.clear(); + for i in 0..self.peers.len() { + let endpoint = self.peers[i].endpoint; + let old_state = std::mem::replace(&mut self.peers[i].state, PeerState::Idle); + let new_state = match old_state { + PeerState::Established(mut dp) => { + if let Some(pkts) = dp.tick(now_ms) { + self.tick_egress.extend(pkts.iter().cloned()); + } + PeerState::Established(dp) + } + PeerState::Handshaking(mut handshaking) + if now_ms.saturating_sub(handshaking.last_sent_ms) >= HANDSHAKE_RETRY_MS => + { + if now_ms.saturating_sub(handshaking.started_ms) >= HANDSHAKE_TOTAL_MS { + // Whole attempt window elapsed without completing: the + // peer is unreachable. Give up and free the ephemeral; + // the next TUN packet starts a fresh attempt. + self.peers[i].pending_tun.clear(); + PeerState::Idle + } else { + // Retransmit the SAME init (same ephemeral) so the + // responder's cached reply stays valid — see + // HANDSHAKE_TOTAL_MS. + handshaking.retries = handshaking.retries.saturating_add(1); + handshaking.last_sent_ms = now_ms; + self.tick_egress.push(EgressDatagram { + fate: 0, + dst: endpoint, + bytes: handshaking.init_pkt.clone(), + }); + PeerState::Handshaking(handshaking) + } + } + other => other, + }; + self.peers[i].state = new_state; + } + if self.tick_egress.is_empty() { + None + } else { + Some(&self.tick_egress) + } + } +} + +/// Parse an inner packet's IPv6 destination address (bytes 24..40 of a +/// standard fixed IPv6 header), or `None` if `inner` is too short or its +/// first nibble isn't `6` (IPv4, ARP, or a bare Ethernet frame in L2 mode +/// all fail this check, which is intentional — see `route_tun_index`). +fn ipv6_dst(inner: &[u8]) -> Option { + if inner.len() < 40 || inner[0] >> 4 != 6 { + return None; + } + let mut octets = [0u8; 16]; + octets.copy_from_slice(&inner[24..40]); + Some(Ipv6Addr::from(octets)) +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::handshake::Established; + use crate::wire_glue::derive_wire_keys; + use yip_crypto::{generate_keypair, Handshake}; + + fn peer_cfg(tag_byte: u8, endpoint: &str) -> PeerConfig { + PeerConfig { + public_key: [tag_byte; 32], + endpoint: endpoint.parse().unwrap(), + } + } + + /// Build a real `DataPlane` (via an in-process Noise handshake) with a + /// specific `conn_tag`, standing in for "a peer that has already + /// completed its handshake" — the "test seam" for demux tests: rather + /// than a special production API, the test module (being a child of + /// `peer_manager`) can just construct a `DataPlane` directly and splice + /// it into a `PeerManager`'s private `peers`/`by_tag` fields. + fn fake_established_dataplane(conn_tag: u64, peer_addr: SocketAddr) -> DataPlane { + let resp_kp = generate_keypair(); + let init_kp = generate_keypair(); + let mut ini = Handshake::initiator(&init_kp.private, &resp_kp.public).unwrap(); + let mut res = Handshake::responder(&resp_kp.private).unwrap(); + let m1 = ini.write_message().unwrap(); + res.read_message(&m1).unwrap(); + let m2 = res.write_message().unwrap(); + ini.read_message(&m2).unwrap(); + let cb = ini.channel_binding(); + let (auth_key, hp_key) = derive_wire_keys(&cb); + let established = Established { + session: ini.into_session().unwrap(), + auth_key, + hp_key, + }; + DataPlane::new(established, conn_tag, TunnelMode::L3Tun, peer_addr) + } + + #[test] + fn by_addr_maps_each_peers_node_addr() { + let peer_a = peer_cfg(1, "10.0.0.1:1000"); + let peer_b = peer_cfg(2, "10.0.0.2:2000"); + let pm = PeerManager::new( + [9u8; 32], + [8u8; 32], + &[peer_a.clone(), peer_b.clone()], + TunnelMode::L3Tun, + ); + + let addr_a = node_addr(&peer_a.public_key); + let addr_b = node_addr(&peer_b.public_key); + assert_eq!(pm.by_addr.get(&addr_a), Some(&0)); + assert_eq!(pm.by_addr.get(&addr_b), Some(&1)); + assert_eq!(pm.peers[0].addr, addr_a); + assert_eq!(pm.peers[1].addr, addr_b); + } + + #[test] + fn route_tun_index_picks_peer_owning_the_inner_ipv6_dst() { + let peer_a = peer_cfg(1, "10.0.0.1:1000"); + let peer_b = peer_cfg(2, "10.0.0.2:2000"); + let pm = PeerManager::new( + [9u8; 32], + [8u8; 32], + &[peer_a.clone(), peer_b.clone()], + TunnelMode::L3Tun, + ); + let addr_b = node_addr(&peer_b.public_key); + + // Build a minimal 40-byte IPv6 header addressed to peer B. + let mut inner = vec![0u8; 40]; + inner[0] = 0x60; // version 6 + inner[24..40].copy_from_slice(&addr_b.octets()); + + assert_eq!(pm.route_tun_index(&inner), Some(1)); + } + + #[test] + fn route_tun_index_falls_back_to_sole_peer_for_unmatched_l3_traffic() { + // Mirrors the existing single-peer netns tests, which assign plain + // IPv4 addresses to the TUN device (not the IPv6 mesh address). + let peer_a = peer_cfg(1, "10.0.0.1:1000"); + let pm = PeerManager::new([9u8; 32], [8u8; 32], &[peer_a], TunnelMode::L3Tun); + + // A bare IPv4 packet: first nibble is 4, not 6. + let inner = vec![0x45u8; 40]; + assert_eq!(pm.route_tun_index(&inner), Some(0)); + } + + #[test] + fn route_tun_index_l3_ambiguous_multi_peer_drops() { + let peer_a = peer_cfg(1, "10.0.0.1:1000"); + let peer_b = peer_cfg(2, "10.0.0.2:2000"); + let pm = PeerManager::new([9u8; 32], [8u8; 32], &[peer_a, peer_b], TunnelMode::L3Tun); + + let inner = vec![0x45u8; 40]; // IPv4, matches no by_addr entry + assert_eq!(pm.route_tun_index(&inner), None); + } + + #[test] + fn route_tun_index_l2_single_peer_forwards_regardless_of_inner() { + let peer_a = peer_cfg(1, "10.0.0.1:1000"); + let pm = PeerManager::new([9u8; 32], [8u8; 32], &[peer_a], TunnelMode::L2Tap); + + // An arbitrary Ethernet-looking frame; L2 mode ignores its contents + // entirely and forwards to the sole configured peer. + let inner = vec![0xffu8; 14]; + assert_eq!(pm.route_tun_index(&inner), Some(0)); + } + + #[test] + fn routes_inner_dst_to_owning_peer_and_demuxes_by_tag() { + let peer_a = peer_cfg(1, "10.0.0.1:1000"); + let peer_b = peer_cfg(2, "10.0.0.2:2000"); + let mut pm = PeerManager::new( + [9u8; 32], + [8u8; 32], + &[peer_a.clone(), peer_b.clone()], + TunnelMode::L3Tun, + ); + + // by_addr maps each peer's node_addr to its index. + assert_eq!(pm.by_addr.get(&node_addr(&peer_a.public_key)), Some(&0)); + assert_eq!(pm.by_addr.get(&node_addr(&peer_b.public_key)), Some(&1)); + + // Splice in a fake Established peer at index 1 with a known conn_tag + // (the "test seam": direct access to private fields from the child + // `tests` module). + const FAKE_TAG: u64 = 0xAAAA_BBBB_CCCC_DDDD; + pm.peers[1].state = PeerState::Established(Box::new(fake_established_dataplane( + FAKE_TAG, + peer_b.endpoint, + ))); + pm.by_tag.insert(FAKE_TAG, 1); + + // A hand-built "Data" datagram carrying that conn_tag in dg[1..9] + // (real wire traffic never has literal tag bytes here — see the + // module doc — but route_data's by_tag fast path is still exercised + // and verified this way). + let mut dg = vec![PacketType::Data as u8]; + dg.extend_from_slice(&FAKE_TAG.to_be_bytes()); + dg.extend_from_slice(&[0u8; 8]); + + // Demuxes to peer 1 via the tag hint even from an unrelated source + // address (proving the tag path, not the address-match fallback). + let unrelated_src: SocketAddr = "203.0.113.9:9".parse().unwrap(); + assert_eq!(pm.route_data(unrelated_src, &dg), Some(1)); + + // And also demuxes correctly by address alone (no tag hint) once + // the datagram no longer carries the registered tag. + let mut untagged_dg = vec![PacketType::Data as u8]; + untagged_dg.extend_from_slice(&0u64.to_be_bytes()); + untagged_dg.extend_from_slice(&[0u8; 8]); + assert_eq!(pm.route_data(peer_b.endpoint, &untagged_dg), Some(1)); + } + + #[test] + fn handshake_init_from_unconfigured_key_is_not_admitted() { + // A real local keypair, so a HandshakeInit correctly targeting it + // completes the Noise handshake successfully — isolating the + // admission check (not Noise itself) as the thing under test. + let local_kp = generate_keypair(); + let peer_a = peer_cfg(1, "10.0.0.1:1000"); + let mut pm = PeerManager::new( + local_kp.private, + local_kp.public, + &[peer_a], + TunnelMode::L3Tun, + ); + + // A valid HandshakeInit from a real, but unconfigured, key. + let stranger = generate_keypair(); + let (_hs, init_pkt) = + HandshakeState::start_initiator(&stranger.private, &local_kp.public).unwrap(); + + let src: SocketAddr = "203.0.113.5:5".parse().unwrap(); + match pm.on_udp(src, &init_pkt, 0) { + DispatchOut::None => {} + _ => panic!("must not admit or reply to an unconfigured HandshakeInit"), + } + assert!(pm.by_tag.is_empty(), "no peer must have been admitted"); + } + + #[test] + fn local_addr_matches_node_addr_of_local_pub() { + let local_pub = [42u8; 32]; + let pm = PeerManager::new([1u8; 32], local_pub, &[], TunnelMode::L3Tun); + assert_eq!(pm.local_addr(), node_addr(&local_pub)); + } + + /// The `conn_tag` of a peer's Established session, or `None` if it is not + /// (yet) Established. Used by the handshake state-machine tests below. + fn established_tag(pm: &PeerManager, idx: usize) -> Option { + match &pm.peers[idx].state { + PeerState::Established(dp) => Some(dp.conn_tag()), + _ => None, + } + } + + /// Copy out every `[HandshakeResp]` datagram's bytes from a `DispatchOut` + /// (decoupling from the borrow so the caller can keep driving the manager). + fn resp_bytes(out: &DispatchOut<'_>) -> Vec> { + let egress: &[EgressDatagram] = match out { + DispatchOut::Udp(e) | DispatchOut::Both(_, e) => e, + _ => &[], + }; + egress + .iter() + .filter(|d| d.bytes.first() == Some(&(PacketType::HandshakeResp as u8))) + .map(|d| d.bytes.clone()) + .collect() + } + + /// A minimal IPv4 packet, enough to drive `on_tun` (single-peer fallback + /// routes it to the sole peer regardless of contents). + fn dummy_tun_pkt() -> Vec { + vec![0x45u8; 40] + } + + #[test] + fn glare_simultaneous_init_converges_on_one_session() { + // Both peers configured with each other; neither initiates until it + // has traffic. Drive *both* to initiate at once (the startup-glare + // race), then cross-feed the messages and assert both converge on ONE + // shared session (identical conn_tag) rather than two mismatched ones. + let kp_a = generate_keypair(); + let kp_b = generate_keypair(); + let ep_a: SocketAddr = "10.0.0.1:1000".parse().unwrap(); + let ep_b: SocketAddr = "10.0.0.2:2000".parse().unwrap(); + let cfg_b = PeerConfig { + public_key: kp_b.public, + endpoint: ep_b, + }; + let cfg_a = PeerConfig { + public_key: kp_a.public, + endpoint: ep_a, + }; + let mut pm_a = PeerManager::new(kp_a.private, kp_a.public, &[cfg_b], TunnelMode::L3Tun); + let mut pm_b = PeerManager::new(kp_b.private, kp_b.public, &[cfg_a], TunnelMode::L3Tun); + + // Each side sends a HandshakeInit (triggered by its own outbound TUN + // traffic) before hearing from the other — the glare. + let pkt = dummy_tun_pkt(); + let init_a = pm_a.on_tun(&pkt, 0)[0].bytes.clone(); + let init_b = pm_b.on_tun(&pkt, 0)[0].bytes.clone(); + assert_eq!(init_a[0], PacketType::HandshakeInit as u8); + assert_eq!(init_b[0], PacketType::HandshakeInit as u8); + + // Cross-feed the competing inits. Exactly one side (the larger key) + // adopts the responder role and replies; the other (smaller key) + // ignores the competing init and keeps its own attempt. + let resp_from_a = resp_bytes(&pm_a.on_udp(ep_b, &init_b, 0)); + let resp_from_b = resp_bytes(&pm_b.on_udp(ep_a, &init_a, 0)); + let total_resps = resp_from_a.len() + resp_from_b.len(); + assert_eq!( + total_resps, 1, + "exactly one side must adopt the responder role under glare" + ); + + // Deliver whichever HandshakeResp was produced back to the initiator + // that is still handshaking; it completes on the responder's session. + for r in &resp_from_a { + pm_b.on_udp(ep_a, r, 0); + } + for r in &resp_from_b { + pm_a.on_udp(ep_b, r, 0); + } + + let tag_a = established_tag(&pm_a, 0).expect("pm_a must be Established"); + let tag_b = established_tag(&pm_b, 0).expect("pm_b must be Established"); + assert_eq!( + tag_a, tag_b, + "both peers must converge on ONE shared session (matching conn_tag)" + ); + } + + #[test] + fn duplicate_init_after_established_does_not_tear_down_session() { + // Regression: a duplicated/retransmitted HandshakeInit arriving after + // the responder has already established MUST NOT rebuild the session + // (a fresh Noise ephemeral would strand the peer on the old keys). + // The responder re-sends its cached HandshakeResp verbatim instead. + let kp_r = generate_keypair(); + let kp_i = generate_keypair(); + let ep_i: SocketAddr = "10.0.0.7:7000".parse().unwrap(); + let cfg_i = PeerConfig { + public_key: kp_i.public, + endpoint: ep_i, + }; + let mut pm_r = PeerManager::new(kp_r.private, kp_r.public, &[cfg_i], TunnelMode::L3Tun); + + // The initiator's HandshakeInit (built out-of-band, as if received). + let (_hs, init_pkt) = HandshakeState::start_initiator(&kp_i.private, &kp_r.public).unwrap(); + + // First delivery establishes the responder session; capture its reply. + let resp1 = resp_bytes(&pm_r.on_udp(ep_i, &init_pkt, 0)); + assert_eq!(resp1.len(), 1, "first init must produce one HandshakeResp"); + let tag1 = established_tag(&pm_r, 0).expect("responder must be Established"); + + // A duplicate of the SAME init: session must be untouched and the + // reply must be the exact cached bytes (not a freshly-built one). + let resp2 = resp_bytes(&pm_r.on_udp(ep_i, &init_pkt, 0)); + let tag2 = established_tag(&pm_r, 0).expect("responder must stay Established"); + assert_eq!(tag1, tag2, "duplicate init must not rekey the live session"); + assert_eq!( + resp2, resp1, + "duplicate init must re-send the cached HandshakeResp verbatim" + ); + } + + #[test] + fn initiator_retransmits_same_init_within_total_window_then_gives_up() { + // Regression for the loss-induced wedge: the initiator must keep + // retransmitting the SAME init (holding one ephemeral) well past the + // old 5-retry cap, so a responder's cached reply stays valid and + // ordinary handshake-packet loss is overcome by retransmission — never + // resetting to a fresh ephemeral mid-attempt. Only after the whole + // HANDSHAKE_TOTAL_MS window does it give up. + let kp_local = generate_keypair(); + let peer = PeerConfig { + public_key: [7u8; 32], + endpoint: "10.0.0.9:9000".parse().unwrap(), + }; + let mut pm = PeerManager::new( + kp_local.private, + kp_local.public, + &[peer], + TunnelMode::L3Tun, + ); + + // Kick off a lazy handshake with an outbound TUN packet. + let init_out = pm.on_tun(&dummy_tun_pkt(), 0).to_vec(); + assert_eq!(init_out.len(), 1); + let init_bytes = init_out[0].bytes.clone(); + assert_eq!(init_bytes[0], PacketType::HandshakeInit as u8); + assert!(matches!(pm.peers[0].state, PeerState::Handshaking(_))); + + // Drive tick ~20 retry intervals — 4x the old MAX_RETRIES=5 cap. Each + // interval must retransmit the identical init and keep it Handshaking. + let mut t = 0u64; + for _ in 0..20 { + t += HANDSHAKE_RETRY_MS; + let out = pm.tick(t).map(<[_]>::to_vec).unwrap_or_default(); + assert_eq!(out.len(), 1, "a retransmit is emitted every retry interval"); + assert_eq!( + out[0].bytes, init_bytes, + "retransmit reuses the same init (same ephemeral)" + ); + assert!( + matches!(pm.peers[0].state, PeerState::Handshaking(_)), + "peer keeps handshaking within the total window (past the old 5-retry cap)" + ); + } + + // Once the whole window elapses, the attempt is abandoned. + let out = pm + .tick(HANDSHAKE_TOTAL_MS + HANDSHAKE_RETRY_MS) + .map(<[_]>::to_vec) + .unwrap_or_default(); + assert!( + out.is_empty(), + "no further init once the total window elapsed" + ); + assert!( + matches!(pm.peers[0].state, PeerState::Idle), + "peer reverts to Idle after the total window" + ); + assert!( + pm.peers[0].pending_tun.is_empty(), + "pending buffer cleared on give-up" + ); + } + + #[test] + fn pending_tun_is_capped_while_handshaking() { + let kp_local = generate_keypair(); + let peer = PeerConfig { + public_key: [7u8; 32], + endpoint: "10.0.0.9:9000".parse().unwrap(), + }; + let mut pm = PeerManager::new( + kp_local.private, + kp_local.public, + &[peer], + TunnelMode::L3Tun, + ); + + // Stream far more packets than the cap while the peer is Handshaking. + for _ in 0..(MAX_PENDING_TUN + 50) { + let _ = pm.on_tun(&dummy_tun_pkt(), 0); + } + assert!(matches!(pm.peers[0].state, PeerState::Handshaking(_))); + assert!( + pm.peers[0].pending_tun.len() <= MAX_PENDING_TUN, + "pending buffer must stay capped at MAX_PENDING_TUN" + ); + } +} diff --git a/bin/yipd/src/tunnel.rs b/bin/yipd/src/tunnel.rs index b75b272..6513402 100644 --- a/bin/yipd/src/tunnel.rs +++ b/bin/yipd/src/tunnel.rs @@ -1,85 +1,93 @@ -//! The yipd tunnel: binds a UDP socket, runs the Noise-IK handshake, creates -//! the TUN device, then drives the data loop via a single-threaded epoll -//! `PollDriver` backed by [`DataPlane`]. +//! The yipd tunnel: binds a UDP socket, creates the TUN/TAP device, then +//! drives the data loop via a single-threaded epoll `PollDriver` (or +//! `UringDriver`) backed by [`PeerManager`]. //! -//! # Architecture (Task 3) +//! # Architecture (Task 5) //! -//! The two-thread `Arc>` model has been retired. [`DataPlane`] -//! implements [`yip_io::poll::Dispatch`] and is driven by -//! [`yip_io::poll::run_poll`], which multiplexes UDP and TUN I/O via `epoll` -//! from a single OS thread. There are no locks, no channels, and no heap -//! allocation per packet beyond what `DataPlane` already preallocates. +//! There is no pre-loop blocking handshake and no `sock.connect` — every +//! peer starts `Idle` and [`PeerManager`] brings it up lazily, in-loop, the +//! first time a TUN packet needs to reach it (or an incoming +//! `HandshakeInit` arrives first). See `peer_manager.rs`'s module doc for +//! the full routing/handshake design. [`PeerManager`] implements +//! [`yip_io::poll::Dispatch`] and is driven by [`yip_io::poll::run_poll`], +//! which multiplexes UDP and TUN I/O via `epoll` from a single OS thread — +//! there are no locks, no channels, and no per-packet heap allocation beyond +//! what each peer's `DataPlane` already preallocates. //! //! # conn_tag //! //! Each wire frame carries an 8-byte `conn_tag` that the receiver uses to -//! select the right session / decoder. We derive it from `auth_key || hp_key` -//! (both computed identically by both peers from the Noise channel binding). -//! M7+ will rotate the tag every epoch for unlinkability. +//! select the right session / decoder. Both peers derive it identically +//! from `auth_key || hp_key` (computed from the Noise channel binding) once +//! their handshake completes — see `dataplane::conn_tag_from_keys`. M7+ will +//! rotate the tag every epoch for unlinkability. //! //! # Feedback loop //! //! The loss-feedback Control packet and ARQ retransmit logic live entirely -//! inside [`DataPlane::tick`] and [`DataPlane::on_udp_datagram`]; the epoll -//! loop calls `tick` at least every 10 ms, which is within the 30 ms feedback -//! interval. +//! inside each peer's `DataPlane::tick` and `DataPlane::on_udp_datagram`; +//! the epoll loop calls `tick` at least every 10 ms, which is within the +//! 30 ms feedback interval. use std::io; use std::net::UdpSocket; use std::os::fd::AsRawFd; +use std::process::Command; use yip_device::{DeviceKind, TunTap}; use yip_io::set_socket_buffers; +use crate::addr::MESH_PREFIX_LEN; use crate::config::Config; -use crate::dataplane::{conn_tag_from_keys, DataPlane}; -use crate::handshake; use crate::mode::TunnelMode; +use crate::peer_manager::PeerManager; // ── public entry point ──────────────────────────────────────────────────────── -/// Run the tunnel: bind, handshake, create TUN, then loop forever via the -/// selected I/O driver. +/// Run the tunnel: bind, create TUN/TAP, then loop forever via the selected +/// I/O driver, handshaking lazily in-loop as [`PeerManager`] needs to. /// /// Only returns on a fatal I/O error. pub fn run(config: Config) -> io::Result<()> { // ── bind the UDP socket ─────────────────────────────────────────────────── + // Unconnected (the addressed socket seam, #33): drivers use + // recvfrom/sendto (poll.rs) or recvmsg/sendmsg (uring.rs) and carry the + // peer address on every datagram; `PeerManager` routes by that address + // (and, once established, by the per-peer `DataPlane`'s own stamped + // `dst`) instead of relying on a fixed connected peer. let sock = UdpSocket::bind(config.listen)?; - // ── handshake ───────────────────────────────────────────────────────────── - let (established, peer_addr) = if config.initiate { - let est = handshake::run_initiator( - &sock, - config.peer_endpoint, - &config.local_private, - &config.peer_public, - )?; - (est, config.peer_endpoint) - } else { - let (est, addr) = handshake::run_responder(&sock, &config.local_private)?; - (est, addr) - }; - - // Connect so plain send/recv work without carrying the peer address. - sock.connect(peer_addr)?; - // Raise kernel socket buffers to 4 MiB so bursts do not overflow the // OS receive ring. set_socket_buffers(&sock, 4 * 1024 * 1024)?; - // ── derive conn_tag ─────────────────────────────────────────────────────── - // Both peers compute the same auth_key and hp_key from the Noise channel - // binding, so they derive the same conn_tag without extra signaling. - let conn_tag = conn_tag_from_keys(&established.auth_key, &established.hp_key); + // ── build the peer manager ──────────────────────────────────────────────── + let mode = config.device_kind; + let mut manager = PeerManager::new( + config.local_private, + config.local_public, + &config.peers, + mode, + ); + let local_addr = manager.local_addr(); // ── create the tunnel device (TUN or TAP) ──────────────────────────────── - let mode = config.device_kind; let device_kind = match mode { TunnelMode::L3Tun => DeviceKind::Tun, TunnelMode::L2Tap => DeviceKind::Tap, }; let tun = TunTap::create(&config.device, device_kind).map_err(io::Error::other)?; + // Assign this node's self-certifying mesh address and route the mesh + // prefix over the device. Best-effort: shelling out to `ip` (no unsafe, + // no netlink code in this `forbid(unsafe_code)` binary) keeps this + // simple, and a failure here (e.g. the address already present from a + // prior run, or `ip` unavailable in some minimal test environment) must + // not take down the tunnel — existing single-peer netns tests assign + // their own (plain IPv4) tunnel addresses independently of this and do + // not depend on it succeeding. + assign_mesh_address(&config.device, local_addr); + // Set TUN non-blocking before entering the epoll loop. run_poll also // calls fcntl internally (belt-and-suspenders; idempotent). tun.set_nonblocking().map_err(io::Error::other)?; @@ -89,9 +97,6 @@ pub fn run(config: Config) -> io::Result<()> { sock.set_nonblocking(true)?; let udp_fd = sock.as_raw_fd(); - // ── build DataPlane ─────────────────────────────────────────────────────── - let mut dataplane = DataPlane::new(established, conn_tag, mode); - // ── run the selected event loop ─────────────────────────────────────────── // `tun` and `sock` are kept alive on the stack here, so `tun_fd` and // `udp_fd` remain valid for the duration of the selected driver loop. @@ -102,8 +107,34 @@ pub fn run(config: Config) -> io::Result<()> { // work until it beats epoll (SQPOLL / working GSO batching) and re-benchmarks // favourably. See crates/yip-bench/README.md "io_uring Phase B — driver A/B". if std::env::var_os("YIP_USE_URING").is_some() && yip_io::uring::uring_available() { - yip_io::uring::run_uring(udp_fd, tun_fd, &mut dataplane) + yip_io::uring::run_uring(udp_fd, tun_fd, &mut manager) } else { - yip_io::poll::run_poll(udp_fd, tun_fd, &mut dataplane) + yip_io::poll::run_poll(udp_fd, tun_fd, &mut manager) + } +} + +/// Best-effort: assign `local_addr/128` to `device` and route the mesh +/// prefix (`fd00::/8`) over it, via the `ip` CLI. Errors (including `ip` +/// being absent) are logged and swallowed — this is additive to whatever +/// addressing a test harness assigns itself, never required for the tunnel +/// to function in single-peer 2a scope. +fn assign_mesh_address(device: &str, local_addr: std::net::Ipv6Addr) { + let addr_arg = format!("{local_addr}/128"); + match Command::new("ip") + .args(["-6", "addr", "add", &addr_arg, "dev", device]) + .status() + { + Ok(s) if s.success() => {} + Ok(s) => eprintln!("yipd: `ip -6 addr add {addr_arg} dev {device}` exited {s}"), + Err(e) => eprintln!("yipd: could not run ip to assign mesh address {addr_arg}: {e}"), + } + let prefix_arg = format!("fd00::/{MESH_PREFIX_LEN}"); + match Command::new("ip") + .args(["-6", "route", "add", &prefix_arg, "dev", device]) + .status() + { + Ok(s) if s.success() => {} + Ok(s) => eprintln!("yipd: `ip -6 route add {prefix_arg} dev {device}` exited {s}"), + Err(e) => eprintln!("yipd: could not run ip to add mesh route {prefix_arg}: {e}"), } } diff --git a/bin/yipd/tests/run-netns-triangle.sh b/bin/yipd/tests/run-netns-triangle.sh new file mode 100755 index 0000000..3fa00d0 --- /dev/null +++ b/bin/yipd/tests/run-netns-triangle.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +# 3-peer full-mesh netns "triangle" test for yipd. +# Usage: run-netns-triangle.sh +# +# Creates three network namespaces (A/B/C), each with a veth attached to a +# shared Linux bridge in the root namespace (one L2 underlay segment), starts +# a yipd daemon in each with a 2-peer static config (the *other* two nodes), +# assigns each TUN device its own self-certifying mesh address (node_addr, +# derived from the node's public key via `yipd --addr`), and pings across +# every leg of the mesh: A->B, A->C, B->C. +# +# Unlike the single-peer netns tests (which assign plain IPv4 addresses to +# the TUN device and rely on PeerManager's single-peer fallback routing), +# this test exercises the REAL multi-peer routing path: each ping targets a +# peer's node_addr, so PeerManager's `by_addr` lookup must pick the right +# peer's DataPlane out of two configured peers. +set -euo pipefail + +YIPD="${1:?Usage: $0 }" +TMPDIR_TEST="$(mktemp -d /tmp/yipd-netns-triangle-test.XXXXXX)" + +BR="brTri0" + +NS_A="yipTriA" +NS_B="yipTriB" +NS_C="yipTriC" + +# Host-side veth ends (attached to the bridge) and their netns-side peers. +VETH_A_H="vTriA0"; VETH_A_N="vTriA1" +VETH_B_H="vTriB0"; VETH_B_N="vTriB1" +VETH_C_H="vTriC0"; VETH_C_N="vTriC1" + +IP_A="10.9.0.1" +IP_B="10.9.0.2" +IP_C="10.9.0.3" +VETH_PREFIX="24" +PORT="51820" +TUN_DEV="yip0" + +PID_A="" +PID_B="" +PID_C="" + +cleanup() { + echo "[cleanup] killing daemons and removing namespaces/bridge" + [ -n "$PID_A" ] && kill "$PID_A" 2>/dev/null || true + [ -n "$PID_B" ] && kill "$PID_B" 2>/dev/null || true + [ -n "$PID_C" ] && kill "$PID_C" 2>/dev/null || true + sleep 0.2 + [ -n "$PID_A" ] && kill -9 "$PID_A" 2>/dev/null || true + [ -n "$PID_B" ] && kill -9 "$PID_B" 2>/dev/null || true + [ -n "$PID_C" ] && kill -9 "$PID_C" 2>/dev/null || true + ip netns del "$NS_A" 2>/dev/null || true + ip netns del "$NS_B" 2>/dev/null || true + ip netns del "$NS_C" 2>/dev/null || true + ip link del "$BR" 2>/dev/null || true + rm -rf "$TMPDIR_TEST" +} +trap cleanup EXIT + +# ── 1. generate keypairs ────────────────────────────────────────────────────── +echo "[setup] generating keypairs" +GENKEY_A="$("$YIPD" --genkey)" +GENKEY_B="$("$YIPD" --genkey)" +GENKEY_C="$("$YIPD" --genkey)" + +PRIV_A="$(echo "$GENKEY_A" | grep '^private=' | cut -d= -f2)" +PUB_A="$(echo "$GENKEY_A" | grep '^public=' | cut -d= -f2)" +PRIV_B="$(echo "$GENKEY_B" | grep '^private=' | cut -d= -f2)" +PUB_B="$(echo "$GENKEY_B" | grep '^public=' | cut -d= -f2)" +PRIV_C="$(echo "$GENKEY_C" | grep '^private=' | cut -d= -f2)" +PUB_C="$(echo "$GENKEY_C" | grep '^public=' | cut -d= -f2)" + +# ── 2. compute each node's self-certifying mesh address ─────────────────────── +ADDR_A="$("$YIPD" --addr "$PUB_A")" +ADDR_B="$("$YIPD" --addr "$PUB_B")" +ADDR_C="$("$YIPD" --addr "$PUB_C")" +echo "[setup] node_addr A=$ADDR_A B=$ADDR_B C=$ADDR_C" + +# ── 3. write config files (2-peer block syntax — each node lists the OTHER two) ─ +CFG_A="$TMPDIR_TEST/yipA.conf" +CFG_B="$TMPDIR_TEST/yipB.conf" +CFG_C="$TMPDIR_TEST/yipC.conf" + +cat > "$CFG_A" < "$CFG_B" < "$CFG_C" <"$LOG_A" 2>&1 & +PID_A=$! + +echo "[start] starting yipTriB" +ip netns exec "$NS_B" "$YIPD" "$CFG_B" >"$LOG_B" 2>&1 & +PID_B=$! + +echo "[start] starting yipTriC" +ip netns exec "$NS_C" "$YIPD" "$CFG_C" >"$LOG_C" 2>&1 & +PID_C=$! + +# ── 6. wait for TUN devices to appear in all three namespaces ───────────────── +TUN_WAIT=20 +INTERVAL=0.25 + +echo "[wait] waiting for TUN devices to appear (up to ${TUN_WAIT}s)" +elapsed=0 +while true; do + A_UP=0; B_UP=0; C_UP=0 + ip netns exec "$NS_A" ip link show "$TUN_DEV" >/dev/null 2>&1 && A_UP=1 || true + ip netns exec "$NS_B" ip link show "$TUN_DEV" >/dev/null 2>&1 && B_UP=1 || true + ip netns exec "$NS_C" ip link show "$TUN_DEV" >/dev/null 2>&1 && C_UP=1 || true + + if [ "$A_UP" -eq 1 ] && [ "$B_UP" -eq 1 ] && [ "$C_UP" -eq 1 ]; then + echo "[wait] all three TUN devices are up" + break + fi + + for pid_var_name in PID_A:yipTriA PID_B:yipTriB PID_C:yipTriC; do + pid_var="${pid_var_name%%:*}" + node_name="${pid_var_name##*:}" + pid="${!pid_var}" + if ! kill -0 "$pid" 2>/dev/null; then + echo "[error] $node_name daemon died unexpectedly" + dump_logs + exit 1 + fi + done + + elapsed=$(awk "BEGIN {print $elapsed + $INTERVAL}") + if awk "BEGIN {exit ($elapsed >= $TUN_WAIT) ? 0 : 1}"; then + echo "[error] timed out waiting for TUN devices" + dump_logs + exit 1 + fi + sleep "$INTERVAL" +done + +# ── 7. assign each TUN its own node_addr/128 + the mesh-prefix route ────────── +# yipd's own `assign_mesh_address` already does this best-effort (and swallows +# failures), so this is belt-and-suspenders: guard every command so a +# pre-existing address/route (already assigned by the daemon) does not trip +# `set -e`. +echo "[setup] assigning node_addr/128 + fd00::/8 route on each TUN" +assign_mesh() { + local ns="$1" addr="$2" + ip netns exec "$ns" ip -6 addr add "${addr}/128" dev "$TUN_DEV" 2>/dev/null || true + ip netns exec "$ns" ip -6 route add fd00::/8 dev "$TUN_DEV" 2>/dev/null || true + ip netns exec "$ns" ip link show "$TUN_DEV" | grep -q "UP" || \ + ip netns exec "$ns" ip link set "$TUN_DEV" up +} +assign_mesh "$NS_A" "$ADDR_A" +assign_mesh "$NS_B" "$ADDR_B" +assign_mesh "$NS_C" "$ADDR_C" + +echo "[check] interface state in yipTriA:" +ip netns exec "$NS_A" ip -6 addr show "$TUN_DEV" +echo "[check] interface state in yipTriB:" +ip netns exec "$NS_B" ip -6 addr show "$TUN_DEV" +echo "[check] interface state in yipTriC:" +ip netns exec "$NS_C" ip -6 addr show "$TUN_DEV" + +# Brief additional settle time to ensure the data loops are ready. +sleep 0.5 + +# ── 8. full-mesh ping across every leg ──────────────────────────────────────── +# Lazy handshake: the first ping on a leg triggers HandshakeInit/Resp before +# the ICMP echo can flow. A short best-effort warm-up ping (result ignored) +# absorbs that one-time cost so the measured run below can assert 0% loss +# without a flaky first packet. +ping_leg() { + local from_ns="$1" target="$2" leg_name="$3" + echo "[warmup] $leg_name: triggering lazy handshake" + ip netns exec "$from_ns" ping -6 -c 1 -W 5 "$target" >/dev/null 2>&1 || true + + echo "[test] $leg_name: pinging $target from $from_ns" + local out status + set +e + out="$(ip netns exec "$from_ns" ping -6 -c 5 -W 5 "$target" 2>&1)" + status=$? + set -e + echo "$out" + if [ "$status" -ne 0 ] || ! echo "$out" | grep -q '0% packet loss'; then + echo "[FAIL] $leg_name: ping did not achieve 0% loss (exit $status)" + dump_logs + exit 1 + fi + echo "[PASS] $leg_name: 0% loss" +} + +ping_leg "$NS_A" "$ADDR_B" "A->B" +ping_leg "$NS_A" "$ADDR_C" "A->C" +ping_leg "$NS_B" "$ADDR_C" "B->C" + +echo "[PASS] full mesh triangle: all three legs reached 0% loss" diff --git a/bin/yipd/tests/tunnel_netns.rs b/bin/yipd/tests/tunnel_netns.rs index e68c5a6..e572a76 100644 --- a/bin/yipd/tests/tunnel_netns.rs +++ b/bin/yipd/tests/tunnel_netns.rs @@ -66,6 +66,29 @@ fn l2_tap_ping_or_arp_across_tunnel() { ); } +#[test] +fn triangle_full_mesh_ping() { + // Requires root: netns creation + TUN devices + a shared bridge underlay. + let is_root = Command::new("id") + .arg("-u") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim() == "0") + .unwrap_or(false); + if !is_root { + eprintln!("SKIP triangle_full_mesh_ping: needs root (run under sudo in CI)"); + return; + } + let yipd = env!("CARGO_BIN_EXE_yipd"); + let script = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/run-netns-triangle.sh"); + let status = Command::new("bash").arg(script).arg(yipd).status().unwrap(); + assert!( + status.success(), + "3-peer netns triangle full-mesh ping failed" + ); +} + #[test] fn arq_recovers_bulk_loss() { // Requires root: netns creation + TUN device + tc netem. diff --git a/crates/yip-io/src/addr.rs b/crates/yip-io/src/addr.rs new file mode 100644 index 0000000..3666ddc --- /dev/null +++ b/crates/yip-io/src/addr.rs @@ -0,0 +1,167 @@ +//! `sockaddr` ⇄ [`SocketAddr`] conversions for the addressed socket seam. +//! +//! `recvfrom`/`recvmsg`/`sendto`/`sendmsg` all speak `libc::sockaddr_storage` +//! (IPv4 *or* IPv6, selected by `ss_family`); the rest of yip works with +//! `std::net::SocketAddr`. These two helpers are the only place that bridges +//! the two representations, and the only new `unsafe` this task introduces +//! (confined to `yip-io`, per the crate's `unsafe`-only-here contract). + +use std::io; +use std::mem; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6}; + +/// Convert a `sockaddr_storage` (as filled in by `recvfrom`/`recvmsg`) into a +/// [`SocketAddr`], given the address length the kernel reported. +/// +/// # Errors +/// +/// Returns an error if `len` is too short for the address family the kernel +/// reported, or if the family is neither `AF_INET` nor `AF_INET6`. +pub fn sockaddr_to_std( + storage: &libc::sockaddr_storage, + len: libc::socklen_t, +) -> io::Result { + let len = usize::try_from(len).unwrap_or(0); + let family = i32::from(storage.ss_family); + + if family == libc::AF_INET { + if len < mem::size_of::() { + return Err(io::Error::other("sockaddr_in shorter than expected")); + } + // SAFETY: `storage.ss_family == AF_INET` and `len` covers a full + // `sockaddr_in`, so reinterpreting the start of `storage` (a + // `sockaddr_storage`, which is defined to be large enough and + // suitably aligned for any address family) as `sockaddr_in` reads + // only initialized bytes of a compatible layout. + let sin = unsafe { + std::ptr::read_unaligned(std::ptr::from_ref(storage).cast::()) + }; + let ip = Ipv4Addr::from(sin.sin_addr.s_addr.to_ne_bytes()); + let port = u16::from_be(sin.sin_port); + return Ok(SocketAddr::V4(SocketAddrV4::new(ip, port))); + } + + if family == libc::AF_INET6 { + if len < mem::size_of::() { + return Err(io::Error::other("sockaddr_in6 shorter than expected")); + } + // SAFETY: same rationale as the `AF_INET` arm above, for `sockaddr_in6`. + let sin6 = unsafe { + std::ptr::read_unaligned(std::ptr::from_ref(storage).cast::()) + }; + let ip = Ipv6Addr::from(sin6.sin6_addr.s6_addr); + let port = u16::from_be(sin6.sin6_port); + return Ok(SocketAddr::V6(SocketAddrV6::new( + ip, + port, + sin6.sin6_flowinfo, + sin6.sin6_scope_id, + ))); + } + + Err(io::Error::other(format!( + "unsupported sockaddr family: {family}" + ))) +} + +/// Convert a [`SocketAddr`] into a `sockaddr_storage` + length suitable for +/// `sendto`/`sendmsg`'s `msg_name`/`msg_namelen`. +pub fn std_to_sockaddr(addr: SocketAddr) -> (libc::sockaddr_storage, libc::socklen_t) { + // SAFETY: `sockaddr_storage` is a plain-old-data struct of integers and + // byte arrays; the all-zero bit pattern is a valid value for all of them. + let mut storage: libc::sockaddr_storage = unsafe { mem::zeroed() }; + + match addr { + SocketAddr::V4(v4) => { + let sin = libc::sockaddr_in { + sin_family: libc::sa_family_t::try_from(libc::AF_INET) + .expect("AF_INET fits sa_family_t"), + sin_port: v4.port().to_be(), + sin_addr: libc::in_addr { + s_addr: u32::from_ne_bytes(v4.ip().octets()), + }, + sin_zero: [0; 8], + }; + // SAFETY: `sockaddr_storage` is defined to be large enough and + // suitably aligned to hold any address family's sockaddr, so + // writing a fully-initialized `sockaddr_in` at its start is valid. + unsafe { + std::ptr::write_unaligned( + std::ptr::from_mut(&mut storage).cast::(), + sin, + ); + } + let len = libc::socklen_t::try_from(mem::size_of::()) + .expect("size_of::() fits socklen_t"); + (storage, len) + } + SocketAddr::V6(v6) => { + let sin6 = libc::sockaddr_in6 { + sin6_family: libc::sa_family_t::try_from(libc::AF_INET6) + .expect("AF_INET6 fits sa_family_t"), + sin6_port: v6.port().to_be(), + sin6_flowinfo: v6.flowinfo(), + sin6_addr: libc::in6_addr { + s6_addr: v6.ip().octets(), + }, + sin6_scope_id: v6.scope_id(), + }; + // SAFETY: same rationale as the `V4` arm above, for `sockaddr_in6`. + unsafe { + std::ptr::write_unaligned( + std::ptr::from_mut(&mut storage).cast::(), + sin6, + ); + } + let len = libc::socklen_t::try_from(mem::size_of::()) + .expect("size_of::() fits socklen_t"); + (storage, len) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn v4_roundtrips() { + let addr: SocketAddr = "203.0.113.7:51820".parse().unwrap(); + let (storage, len) = std_to_sockaddr(addr); + let back = sockaddr_to_std(&storage, len).unwrap(); + assert_eq!(back, addr); + } + + #[test] + fn v4_loopback_roundtrips() { + let addr: SocketAddr = "127.0.0.1:9".parse().unwrap(); + let (storage, len) = std_to_sockaddr(addr); + let back = sockaddr_to_std(&storage, len).unwrap(); + assert_eq!(back, addr); + } + + #[test] + fn v6_roundtrips() { + let addr: SocketAddr = "[2001:db8::1]:443".parse().unwrap(); + let (storage, len) = std_to_sockaddr(addr); + let back = sockaddr_to_std(&storage, len).unwrap(); + assert_eq!(back, addr); + } + + #[test] + fn truncated_length_is_rejected() { + let addr: SocketAddr = "203.0.113.7:51820".parse().unwrap(); + let (storage, _) = std_to_sockaddr(addr); + let too_short = libc::socklen_t::try_from(2usize).unwrap(); + assert!(sockaddr_to_std(&storage, too_short).is_err()); + } + + #[test] + fn unknown_family_is_rejected() { + // SAFETY: all-zero sockaddr_storage is a valid bit pattern; ss_family + // == 0 (AF_UNSPEC) is neither AF_INET nor AF_INET6. + let storage: libc::sockaddr_storage = unsafe { mem::zeroed() }; + let len = libc::socklen_t::try_from(mem::size_of::()).unwrap(); + assert!(sockaddr_to_std(&storage, len).is_err()); + } +} diff --git a/crates/yip-io/src/lib.rs b/crates/yip-io/src/lib.rs index 622d6eb..9dc7783 100644 --- a/crates/yip-io/src/lib.rs +++ b/crates/yip-io/src/lib.rs @@ -2,9 +2,12 @@ //! servicing UDP + TUN/TAP), then AF_XDP. This is the only crate permitted to //! contain `unsafe`; every `unsafe` block must carry a `// SAFETY:` comment. +pub mod addr; pub mod poll; pub mod uring; +pub use addr::{sockaddr_to_std, std_to_sockaddr}; + /// Maximum number of datagrams in a single batched send/recv call. pub const MAX_DATAGRAM_BATCH: usize = 64; diff --git a/crates/yip-io/src/poll.rs b/crates/yip-io/src/poll.rs index 535239c..dac85e3 100644 --- a/crates/yip-io/src/poll.rs +++ b/crates/yip-io/src/poll.rs @@ -8,43 +8,59 @@ //! `unsafe` block carries a `// SAFETY:` comment explaining the invariants. use std::io; +use std::net::SocketAddr; use std::os::fd::RawFd; use std::time::Instant; -use crate::MAX_WIRE_DATAGRAM; +use crate::{sockaddr_to_std, std_to_sockaddr, MAX_WIRE_DATAGRAM}; /// A single-threaded data-plane dispatch interface. /// /// Implementors hold all mutable state (AEAD session, FEC transport, codec, /// auxiliary logs). [`run_poll`] drives this trait from an `epoll` loop. +/// +/// # Addressing (multipeer 2a seam) +/// +/// `on_udp` is told the datagram's source address and every egress datagram +/// carries its own destination (see [`EgressDatagram::dst`]), so a future +/// multi-peer `Dispatch` can route by address. A single-peer implementor is +/// free to ignore `src` and stamp a fixed `dst` on everything it emits — +/// exactly what [`crate::poll`]'s test dispatches below do, and what +/// `yipd`'s `DataPlane` does until the Task 5 `PeerManager` lands. pub trait Dispatch { - /// Called when a UDP datagram arrives. Returns what [`run_poll`] must - /// forward (to TUN, back to UDP, both, or nothing). - fn on_udp(&mut self, dg: &[u8], now_ms: u64) -> DispatchOut<'_>; + /// Called when a UDP datagram arrives, with the address it came from. + /// Returns what [`run_poll`] must forward (to TUN, back to UDP, both, or + /// nothing). + fn on_udp(&mut self, src: SocketAddr, dg: &[u8], now_ms: u64) -> DispatchOut<'_>; /// Called when a TUN frame arrives. Returns egress datagrams to send on - /// the UDP socket (may be empty), each tagged with its FEC fate group so a - /// GSO-capable driver can coalesce safely (see [`EgressDatagram`]). + /// the UDP socket (may be empty), each tagged with its FEC fate group and + /// destination so a GSO-capable driver can coalesce safely (see + /// [`EgressDatagram`]). fn on_tun(&mut self, inner: &[u8], now_ms: u64) -> &[EgressDatagram]; - /// Called at least every 10 ms. Returns `Some(pkt)` if a feedback - /// control packet should be sent on the UDP socket. - fn tick(&mut self, now_ms: u64) -> Option<&[u8]>; + /// Called at least every 10 ms. Returns addressed feedback/keepalive + /// datagrams (usually 0 or 1) that should be sent on the UDP socket. + fn tick(&mut self, now_ms: u64) -> Option<&[EgressDatagram]>; } -/// One egress datagram plus the FEC "fate group" it belongs to. +/// One egress datagram: its destination, plus the FEC "fate group" it +/// belongs to. /// -/// GSO coalesces same-length UDP datagrams into one `UDP_SEGMENT` super-skb; -/// under loss the whole skb is dropped/delayed as a unit (segmentation is -/// deferred to the receiver). Two datagrams that are symbols of the same -/// RaptorQ object must never share a skb — losing them together can defeat FEC -/// recovery for that object. `fate` is the RaptorQ object id (source symbols and -/// this object's repair symbols share it; a different object gets a different -/// value). A GSO-capable driver must guarantee at most one datagram per distinct -/// `fate` in any single coalesced send. Non-GSO drivers ignore `fate`. +/// GSO coalesces same-length, same-destination UDP datagrams into one +/// `UDP_SEGMENT` super-skb; under loss the whole skb is dropped/delayed as a +/// unit (segmentation is deferred to the receiver). Two datagrams that are +/// symbols of the same RaptorQ object must never share a skb — losing them +/// together can defeat FEC recovery for that object. `fate` is the RaptorQ +/// object id (source symbols and this object's repair symbols share it; a +/// different object gets a different value). A GSO-capable driver must +/// guarantee at most one datagram per distinct `fate` *and* per distinct +/// `dst` in any single coalesced send (datagrams to different peers must +/// never share a skb either). Non-GSO drivers ignore `fate`. #[derive(Debug, Clone)] pub struct EgressDatagram { pub fate: u16, + pub dst: SocketAddr, pub bytes: Vec, } @@ -61,9 +77,9 @@ pub enum DispatchOut<'a> { /// Write this slice to the TUN device (decoded inner packet). Tun(&'a [u8]), /// Send these datagrams on the UDP socket (ARQ retransmits). - Udp(&'a [Vec]), + Udp(&'a [EgressDatagram]), /// Write to TUN *and* send datagrams. - Both(&'a [u8], &'a [Vec]), + Both(&'a [u8], &'a [EgressDatagram]), } // ── internal helpers ────────────────────────────────────────────────────────── @@ -86,15 +102,28 @@ fn set_nonblocking(fd: RawFd) -> io::Result<()> { fn drain_udp(udp_fd: RawFd, tun_fd: RawFd, d: &mut impl Dispatch, now_ms: u64) -> io::Result<()> { let mut buf = [0u8; MAX_WIRE_DATAGRAM]; loop { + // SAFETY: `storage` is a valid, suitably-sized/aligned stack buffer for + // any sockaddr the kernel writes back into it (see `sockaddr_storage`'s + // definition). `addr_len` is initialized to its capacity, as `recvfrom` + // requires on entry. + let mut storage: libc::sockaddr_storage = unsafe { std::mem::zeroed() }; + let mut addr_len = libc::socklen_t::try_from(std::mem::size_of::()) + .expect("size_of::() fits socklen_t"); + // SAFETY: `buf` is a valid stack buffer of length MAX_WIRE_DATAGRAM. - // `recv` with MSG_DONTWAIT is a non-blocking receive into that buffer. - // `udp_fd` is a valid connected UDP socket fd supplied by the caller. + // `recvfrom` with MSG_DONTWAIT is a non-blocking receive into that + // buffer; `storage`/`addr_len` are valid out-parameters for the + // sender's address as described above. `udp_fd` is a valid UDP + // socket fd supplied by the caller (unconnected — the seam is + // addressed, not connected). let n = unsafe { - libc::recv( + libc::recvfrom( udp_fd, buf.as_mut_ptr().cast(), buf.len(), libc::MSG_DONTWAIT, + std::ptr::from_mut(&mut storage).cast::(), + &raw mut addr_len, ) }; @@ -116,11 +145,19 @@ fn drain_udp(udp_fd: RawFd, tun_fd: RawFd, d: &mut impl Dispatch, now_ms: u64) - break; } + let src = match sockaddr_to_std(&storage, addr_len) { + Ok(addr) => addr, + Err(e) => { + eprintln!("poll: dropping datagram with unparseable source address: {e}"); + continue; + } + }; + let dg = &buf[..usize::try_from(n).expect("non-negative recv return fits usize")]; // Dispatch and forward the result. The borrow of `d` from `on_udp` // (inside `DispatchOut`) is dropped at the end of the match arm. - match d.on_udp(dg, now_ms) { + match d.on_udp(src, dg, now_ms) { DispatchOut::None => {} DispatchOut::Tun(inner) => { send_to_tun(tun_fd, inner); @@ -174,7 +211,7 @@ fn drain_tun(tun_fd: RawFd, udp_fd: RawFd, d: &mut impl Dispatch, now_ms: u64) - // while calling the mutable send_to_udp. let pkts_owned: Vec = d.on_tun(inner, now_ms).to_vec(); for pkt in &pkts_owned { - send_to_udp(udp_fd, &pkt.bytes)?; + send_to_udp(udp_fd, pkt)?; } } Ok(()) @@ -197,17 +234,30 @@ fn send_to_tun(tun_fd: RawFd, buf: &[u8]) { } } -/// Send one datagram on the UDP socket. +/// Send one datagram on the UDP socket, to its own [`EgressDatagram::dst`]. /// /// Transient errors (`EWOULDBLOCK`, `EAGAIN`, `ENOBUFS`) cause the datagram to /// be silently dropped — the UDP socket send buffer is momentarily full and this /// single packet loss is acceptable. All other errors (e.g. `EBADF`) propagate /// so that a closed or invalid socket terminates the event loop. #[inline] -fn send_to_udp(udp_fd: RawFd, buf: &[u8]) -> io::Result<()> { - // SAFETY: `buf` is a valid slice. `udp_fd` is a valid connected UDP - // socket fd. MSG_NOSIGNAL suppresses SIGPIPE if the peer has closed. - let rc = unsafe { libc::send(udp_fd, buf.as_ptr().cast(), buf.len(), libc::MSG_NOSIGNAL) }; +fn send_to_udp(udp_fd: RawFd, dg: &EgressDatagram) -> io::Result<()> { + let (storage, addr_len) = std_to_sockaddr(dg.dst); + let buf = &dg.bytes; + // SAFETY: `buf` is a valid slice. `udp_fd` is a valid UDP socket fd + // (unconnected — the seam is addressed). `storage`/`addr_len` describe a + // valid destination sockaddr built by `std_to_sockaddr`. MSG_NOSIGNAL + // suppresses SIGPIPE if the peer has closed. + let rc = unsafe { + libc::sendto( + udp_fd, + buf.as_ptr().cast(), + buf.len(), + libc::MSG_NOSIGNAL, + std::ptr::from_ref(&storage).cast::(), + addr_len, + ) + }; if rc < 0 { let e = io::Error::last_os_error(); // EWOULDBLOCK == EAGAIN on Linux; list both for portability. @@ -323,11 +373,13 @@ pub fn run_poll(udp_fd: RawFd, tun_fd: RawFd, d: &mut D) -> io::Res } // Always tick — even on timeout with no events. - if let Some(pkt) = d.tick(now_ms) { - if let Err(e) = send_to_udp(udp_fd, pkt) { - // SAFETY: `epoll_fd` is valid. - unsafe { libc::close(epoll_fd) }; - return Err(e); + if let Some(pkts) = d.tick(now_ms) { + for pkt in pkts { + if let Err(e) = send_to_udp(udp_fd, pkt) { + // SAFETY: `epoll_fd` is valid. + unsafe { libc::close(epoll_fd) }; + return Err(e); + } } } } @@ -358,7 +410,7 @@ mod tests { } impl Dispatch for CountDispatch { - fn on_udp(&mut self, dg: &[u8], _now_ms: u64) -> DispatchOut<'_> { + fn on_udp(&mut self, _src: SocketAddr, dg: &[u8], _now_ms: u64) -> DispatchOut<'_> { self.received.push(dg.to_vec()); self.call_count += 1; DispatchOut::None @@ -368,7 +420,7 @@ mod tests { &[] } - fn tick(&mut self, _now_ms: u64) -> Option<&[u8]> { + fn tick(&mut self, _now_ms: u64) -> Option<&[EgressDatagram]> { None } } @@ -438,15 +490,16 @@ mod tests { } /// A [`Dispatch`] whose `on_udp` returns `DispatchOut::Udp` — i.e. it - /// reflects the received datagram back out on the UDP socket, optionally - /// replacing its payload. This exercises the forwarding arm and - /// `send_to_udp` end-to-end via `drain_udp`. + /// reflects the received datagram back out on the UDP socket (to the + /// datagram's own source address), optionally replacing its payload. + /// This exercises the forwarding arm and `send_to_udp` end-to-end via + /// `drain_udp`. struct ForwardDispatch { /// Payload to send back. Cloned once per `on_udp` call. reply: Vec, - /// Scratch storage so the `&[Vec]` returned by `on_udp` lives long - /// enough (it borrows `self`). - scratch: Vec>, + /// Scratch storage so the `&[EgressDatagram]` returned by `on_udp` + /// lives long enough (it borrows `self`). + scratch: Vec, } impl ForwardDispatch { @@ -459,8 +512,12 @@ mod tests { } impl Dispatch for ForwardDispatch { - fn on_udp(&mut self, _dg: &[u8], _now_ms: u64) -> DispatchOut<'_> { - self.scratch = vec![self.reply.clone()]; + fn on_udp(&mut self, src: SocketAddr, _dg: &[u8], _now_ms: u64) -> DispatchOut<'_> { + self.scratch = vec![EgressDatagram { + fate: 0, + dst: src, + bytes: self.reply.clone(), + }]; DispatchOut::Udp(&self.scratch) } @@ -468,7 +525,7 @@ mod tests { &[] } - fn tick(&mut self, _now_ms: u64) -> Option<&[u8]> { + fn tick(&mut self, _now_ms: u64) -> Option<&[EgressDatagram]> { None } } diff --git a/crates/yip-io/src/uring.rs b/crates/yip-io/src/uring.rs index 508bb4f..3c9d1e5 100644 --- a/crates/yip-io/src/uring.rs +++ b/crates/yip-io/src/uring.rs @@ -1,21 +1,32 @@ //! io_uring driver using one ring over UDP + TUN with provided-buffer receives. //! -//! This backend keeps one ring alive and drives both fds from it. UDP receives -//! use multishot `recv` with `BUFFER_SELECT`. TUN uses a single pooled read that -//! is re-submitted after each completion; multishot `read` is not yet relied on. +//! This backend keeps one ring alive and drives both fds from it. UDP is now +//! unconnected (the addressed socket seam, #33): receives use single-shot +//! `recvmsg`, each carrying its own dedicated buffer + `sockaddr_storage`, so +//! the driver recovers each datagram's source address; a fresh recv is +//! re-armed on the same slot after every completion. Sends use `sendmsg` with +//! an explicit per-datagram destination (see [`EgressDatagram::dst`]). TUN +//! uses a single pooled read that is re-submitted after each completion; +//! multishot `read` is not yet relied on. use std::io; +use std::net::SocketAddr; use std::os::fd::RawFd; use std::time::Instant; use io_uring::{cqueue, opcode, squeue, types, IoUring}; use crate::poll::{Dispatch, DispatchOut, EgressDatagram}; -use crate::{MAX_DATAGRAM_BATCH, MAX_WIRE_DATAGRAM}; +use crate::{sockaddr_to_std, std_to_sockaddr, MAX_DATAGRAM_BATCH, MAX_WIRE_DATAGRAM}; const RING_ENTRIES: u32 = 512; const RING_BUFS: usize = 256; const TUN_READ_DEPTH: usize = 16; +/// How many single-shot UDP `recvmsg` requests are kept outstanding at once. +/// Each has its own dedicated buffer + `sockaddr_storage` (no provided-buffer +/// pool, unlike TUN reads) — see the module doc for why UDP recv moved off +/// multishot `RecvMulti`/`BUFFER_SELECT`. +const UDP_RECV_DEPTH: usize = 16; const BUF_GROUP: u16 = 17; const SEND_SLOTS: usize = 256; const TAG_SHIFT: u32 = 56; @@ -59,6 +70,11 @@ const GSO_CONTROL_SPACE: usize = 64; struct GsoMeta { segment_size: u16, datagram_count: usize, + /// Shared destination of every datagram in this coalesced send — + /// `can_coalesce_gso_tagged` guarantees every datagram in a GSO batch + /// shares one `dst`, so recovering a fallback/unsent datagram (below) + /// can reuse it directly. + dst: SocketAddr, } /// Which fd an in-flight send slot targets, so completion-error handling can @@ -70,10 +86,16 @@ enum SendKind { Tun, } +/// Per-send-slot `sendmsg` context: the datagram's own destination +/// (`name`/`namelen`), its iovec, and (GSO sends only) the `UDP_SEGMENT` +/// control message. Every UDP send now goes through `sendmsg` (the socket is +/// unconnected — the addressed seam), so both plain and GSO sends need +/// `msg_name` populated; only GSO sends need the `control` cmsg. struct GsoSendContext { iov: libc::iovec, msg: libc::msghdr, control: [u8; GSO_CONTROL_SPACE], + name: libc::sockaddr_storage, } impl GsoSendContext { @@ -93,19 +115,43 @@ impl GsoSendContext { msg_flags: 0, }, control: [0_u8; GSO_CONTROL_SPACE], + // SAFETY: `sockaddr_storage` is plain-old-data (integers/byte + // arrays); the all-zero bit pattern is a valid value for it. + // `set_destination` overwrites it before every send. + name: unsafe { std::mem::zeroed() }, } } - fn prepare( + /// Point `msg_name`/`msg_namelen` at this datagram's destination. + fn set_destination(&mut self, dst: SocketAddr) { + let (storage, len) = std_to_sockaddr(dst); + self.name = storage; + self.msg.msg_name = std::ptr::addr_of_mut!(self.name).cast::(); + self.msg.msg_namelen = len; + } + + /// Prepare a plain (non-GSO) `sendmsg`: payload + destination, no cmsg. + fn prepare_plain(&mut self, payload_ptr: *mut u8, payload_len: usize, dst: SocketAddr) { + self.iov.iov_base = payload_ptr.cast::(); + self.iov.iov_len = payload_len; + self.msg.msg_iov = std::ptr::addr_of_mut!(self.iov); + self.msg.msg_iovlen = 1; + self.msg.msg_control = std::ptr::null_mut(); + self.msg.msg_controllen = 0; + self.msg.msg_flags = 0; + self.set_destination(dst); + } + + /// Prepare a GSO `sendmsg`: payload + destination + `UDP_SEGMENT` cmsg. + fn prepare_gso( &mut self, payload_ptr: *mut u8, payload_len: usize, segment_size: u16, + dst: SocketAddr, ) -> io::Result<()> { self.iov.iov_base = payload_ptr.cast::(); self.iov.iov_len = payload_len; - self.msg.msg_name = std::ptr::null_mut(); - self.msg.msg_namelen = 0; self.msg.msg_iov = std::ptr::addr_of_mut!(self.iov); self.msg.msg_iovlen = 1; self.msg.msg_control = self.control.as_mut_ptr().cast::(); @@ -118,6 +164,7 @@ impl GsoSendContext { } self.msg.msg_controllen = cmsg_space; self.msg.msg_flags = 0; + self.set_destination(dst); // SAFETY: `self.msg` points to valid in-struct iovec/control storage. // We write exactly one SOL_UDP/UDP_SEGMENT cmsg payload (u16 segment size) @@ -138,12 +185,44 @@ impl GsoSendContext { } } +/// One outstanding single-shot UDP `recvmsg` request: its own dedicated +/// payload buffer + `sockaddr_storage`, plus the `iovec`/`msghdr` that +/// self-reference them. +/// +/// These are allocated once into a `Box<[UdpRecvSlot]>` that is never resized +/// after `UringDriver::new` — so the addresses `iov`/`msg` point at (`buf`, +/// `name`, and `iov` itself) stay valid for the driver's whole lifetime, +/// exactly like `recv_pool`'s stable buffer addresses below. +struct UdpRecvSlot { + buf: [u8; MAX_WIRE_DATAGRAM], + name: libc::sockaddr_storage, + iov: libc::iovec, + msg: libc::msghdr, +} + +impl UdpRecvSlot { + /// A slot with every field zeroed and no pointers fixed up yet. Callers + /// must fix up `iov`/`msg` to self-reference `buf`/`name` immediately + /// after the slot reaches its final (never-to-move-again) address — see + /// `UringDriver::new`. + fn zeroed() -> Self { + // SAFETY: every field is plain-old-data (byte array / integers / + // pointers); the all-zero bit pattern is valid for all of them. Null + // `iov`/`msg` pointers are never submitted to the kernel — they are + // fixed up before the first `arm_udp_recv_slot` call. + unsafe { std::mem::zeroed() } + } +} + /// One-ring io_uring driver handling UDP + TUN. pub struct UringDriver { ring: IoUring, udp_fd: RawFd, tun_fd: RawFd, recv_pool: Box<[[u8; MAX_WIRE_DATAGRAM]; RING_BUFS]>, + /// Fixed pool of outstanding single-shot UDP `recvmsg` requests (see + /// [`UdpRecvSlot`]); indexed by `TAG_UDP_RECV`'s payload bits. + udp_recv_slots: Box<[UdpRecvSlot]>, in_flight: Vec>>, gso_meta: Vec>, gso_ctx: Vec>, @@ -189,6 +268,32 @@ impl UringDriver { let ring = IoUring::new(RING_ENTRIES)?; let ext_arg = ring.params().is_feature_ext_arg(); let recv_pool = Box::new([[0_u8; MAX_WIRE_DATAGRAM]; RING_BUFS]); + + // Build the UDP recv slot pool, then fix up each slot's self-referencing + // iovec/msghdr pointers now that every slot has reached its final, + // never-to-move-again address inside the boxed slice. + let mut udp_recv_slots: Box<[UdpRecvSlot]> = (0..UDP_RECV_DEPTH) + .map(|_| UdpRecvSlot::zeroed()) + .collect::>() + .into_boxed_slice(); + let namelen = libc::socklen_t::try_from(std::mem::size_of::()) + .expect("size_of::() fits socklen_t"); + for slot in udp_recv_slots.iter_mut() { + slot.iov = libc::iovec { + iov_base: slot.buf.as_mut_ptr().cast::(), + iov_len: slot.buf.len(), + }; + slot.msg = libc::msghdr { + msg_name: std::ptr::addr_of_mut!(slot.name).cast::(), + msg_namelen: namelen, + msg_iov: std::ptr::addr_of_mut!(slot.iov), + msg_iovlen: 1, + msg_control: std::ptr::null_mut(), + msg_controllen: 0, + msg_flags: 0, + }; + } + let mut in_flight = Vec::with_capacity(SEND_SLOTS); let mut gso_meta = Vec::with_capacity(SEND_SLOTS); let mut gso_ctx = Vec::with_capacity(SEND_SLOTS); @@ -205,6 +310,7 @@ impl UringDriver { udp_fd, tun_fd, recv_pool, + udp_recv_slots, in_flight, gso_meta, gso_ctx, @@ -228,7 +334,9 @@ impl UringDriver { }; driver.provide_all_buffers()?; - driver.arm_udp_recv()?; + for i in 0..UDP_RECV_DEPTH { + driver.arm_udp_recv_slot(i)?; + } for _ in 0..TUN_READ_DEPTH { driver.arm_tun_read()?; } @@ -291,18 +399,28 @@ impl UringDriver { self.push_entry(entry) } - fn arm_udp_recv(&mut self) -> io::Result<()> { - // Multishot recv (high throughput) where supported. On kernels that - // reject it for datagram sockets with EINVAL (notably Debian 13's 6.12, - // issue #25), the fatal completion is caught by `run_uring`, which falls - // back to the PollDriver — so opting into io_uring degrades gracefully - // instead of crashing. - let entry = opcode::RecvMulti::new(types::Fd(self.udp_fd), BUF_GROUP) - .len(MAX_WIRE_DATAGRAM_U32) + /// (Re-)arm one single-shot UDP `recvmsg` on slot `idx`. Unlike the old + /// multishot `RecvMulti`, this surfaces the datagram's source address (via + /// the slot's own `sockaddr_storage`) — the whole point of the addressed + /// socket seam — at the cost of one submission per completion instead of + /// one submission serving an unbounded burst. Acceptable: io_uring is + /// opt-in and correctness (recovering `src`) comes first (see #33). + fn arm_udp_recv_slot(&mut self, idx: usize) -> io::Result<()> { + // Restore `msg_namelen` to the full `sockaddr_storage` capacity before + // re-submitting: `recvmsg` writes back the *actual* source address size + // on completion (16 for a v4 sender, 28 for v6), so without this reset + // a slot that last received a v4 datagram would offer only 16 bytes of + // name capacity to the next `recvmsg`, truncating a v6 source address + // to a wrong value on a dual-stack underlay. + let namelen = libc::socklen_t::try_from(std::mem::size_of::()) + .expect("size_of::() fits socklen_t"); + self.udp_recv_slots[idx].msg.msg_namelen = namelen; + let msg_ptr = std::ptr::addr_of_mut!(self.udp_recv_slots[idx].msg); + let tag = TAG_UDP_RECV | u64::try_from(idx).expect("udp recv slot index fits u64"); + let entry = opcode::RecvMsg::new(types::Fd(self.udp_fd), msg_ptr) .build() - .user_data(TAG_UDP_RECV); - self.push_entry(entry)?; - Ok(()) + .user_data(tag); + self.push_entry(entry) } fn arm_tun_read(&mut self) -> io::Result<()> { @@ -402,20 +520,23 @@ impl UringDriver { } } - fn queue_udp_send(&mut self, datagram: &[u8]) -> io::Result<()> { - let slot_id = self.alloc_in_flight_slot_copy(datagram, MAX_WIRE_DATAGRAM)?; + /// Send one addressed datagram via `sendmsg` (the socket is unconnected — + /// the addressed seam — so every UDP send needs an explicit destination). + fn queue_udp_send(&mut self, dg: &EgressDatagram) -> io::Result<()> { + let slot_id = self.alloc_in_flight_slot_copy(&dg.bytes, MAX_WIRE_DATAGRAM)?; self.send_kind[slot_id] = Some(SendKind::Udp); - let (ptr, len_u32) = { + let (payload_ptr, payload_len) = { let slot_buf = self.in_flight[slot_id] - .as_ref() + .as_mut() .ok_or_else(|| io::Error::other("missing in-flight buffer for udp send"))?; - let len_u32 = u32::try_from(slot_buf.len()) - .map_err(|_| io::Error::other("send buffer too large"))?; - (slot_buf.as_ptr(), len_u32) + (slot_buf.as_mut_ptr(), slot_buf.len()) }; + let ctx = self.gso_ctx[slot_id].get_or_insert_with(GsoSendContext::new); + ctx.prepare_plain(payload_ptr, payload_len, dg.dst); let tag = TAG_SEND_SLOT | u64::try_from(slot_id).expect("slot id fits u64"); - let entry = opcode::Send::new(types::Fd(self.udp_fd), ptr, len_u32) - .flags(libc::MSG_NOSIGNAL) + let msg_ptr = std::ptr::from_ref(&ctx.msg); + let entry = opcode::SendMsg::new(types::Fd(self.udp_fd), msg_ptr) + .flags(u32::try_from(libc::MSG_NOSIGNAL).expect("MSG_NOSIGNAL fits u32")) .build() .user_data(tag); if let Err(e) = self.push_entry(entry) { @@ -459,63 +580,31 @@ impl UringDriver { self.send_kind[slot_id] = None; } - fn can_coalesce_gso(datagrams: &[Vec]) -> Option { - if datagrams.len() < 2 { - return None; - } - let first_len = datagrams.first()?.len(); - if first_len == 0 { - return None; - } - let segment_size = u16::try_from(first_len).ok()?; - if datagrams.iter().any(|dg| dg.len() != first_len) { - return None; - } - Some(segment_size) - } - - fn queue_udp_batch(&mut self, datagrams: &[Vec], allow_gso: bool) -> io::Result<()> { - if datagrams.is_empty() { - return Ok(()); - } - if allow_gso && self.gso_enabled { - if let Some(segment_size) = Self::can_coalesce_gso(datagrams) { - let max_chunk = Self::max_gso_datagrams_for_segment(segment_size); - for chunk in datagrams.chunks(max_chunk) { - if self.queue_udp_gso(chunk, segment_size)? { - continue; - } - eprintln!("uring: GSO submit failed, trying per-datagram sends"); - for datagram in chunk { - self.queue_udp_send(datagram)?; - } - } - return Ok(()); - } - } - for datagram in datagrams { - self.queue_udp_send(datagram)?; - } - Ok(()) - } - - /// Like `can_coalesce_gso` but also rejects any batch that contains two - /// datagrams of the same FEC fate group — the invariant that keeps a source - /// symbol and its own repair out of the same (fate-shared) GSO skb. This is - /// the single correctness choke point for GSO+FEC safety. + /// Rejects any batch that contains two datagrams of the same FEC fate + /// group — the invariant that keeps a source symbol and its own repair + /// out of the same (fate-shared) GSO skb — *or* mixed destinations, since + /// a coalesced `UDP_SEGMENT` send has exactly one `msg_name` and would + /// silently misdirect every datagram after the first to the wrong peer. + /// This is the single correctness choke point for GSO+FEC(+addressing) + /// safety. fn can_coalesce_gso_tagged(datagrams: &[EgressDatagram]) -> Option { if datagrams.len() < 2 { return None; } - let first_len = datagrams.first()?.bytes.len(); + let first = datagrams.first()?; + let first_len = first.bytes.len(); if first_len == 0 { return None; } let segment_size = u16::try_from(first_len).ok()?; + let first_dst = first.dst; for (i, dg) in datagrams.iter().enumerate() { if dg.bytes.len() != first_len { return None; } + if dg.dst != first_dst { + return None; + } if datagrams[..i].iter().any(|prior| prior.fate == dg.fate) { return None; } @@ -523,10 +612,11 @@ impl UringDriver { Some(segment_size) } - /// GSO-send a batch of fate-tagged datagrams. Only coalesces when - /// `can_coalesce_gso_tagged` proves every datagram is the same length *and* - /// a distinct fate group; otherwise (or on any GSO submit failure) falls back - /// to per-datagram sends. + /// GSO-send a batch of fate-tagged, addressed datagrams. Only coalesces + /// when `can_coalesce_gso_tagged` proves every datagram is the same + /// length, a distinct fate group, *and* shares one destination; + /// otherwise (or on any GSO submit failure) falls back to per-datagram + /// sends. fn queue_udp_batch_tagged( &mut self, datagrams: &[EgressDatagram], @@ -539,33 +629,51 @@ impl UringDriver { if let Some(segment_size) = Self::can_coalesce_gso_tagged(datagrams) { let max_chunk = Self::max_gso_datagrams_for_segment(segment_size); for chunk in datagrams.chunks(max_chunk) { - if self.queue_udp_gso(chunk, segment_size)? { + // Every datagram in `datagrams` shares one `dst` (proven by + // `can_coalesce_gso_tagged` above), so any chunk's dst is that + // same shared destination. + let dst = chunk[0].dst; + if self.queue_udp_gso(chunk, segment_size, dst)? { continue; } eprintln!("uring: GSO submit failed, trying per-datagram sends"); for dg in chunk { - self.queue_udp_send(&dg.bytes)?; + self.queue_udp_send(dg)?; } } return Ok(()); } } for dg in datagrams { - self.queue_udp_send(&dg.bytes)?; + self.queue_udp_send(dg)?; } Ok(()) } /// Flush all datagrams staged this `poll_once` in fate-safe GSO batches. - /// Each pass takes at most one datagram per distinct fate group (arrival - /// order) — so a coalesced skb never carries two symbols of one FEC object — - /// and defers the rest to the next pass. Bounded by `MAX_PENDING_GSO_DATAGRAMS`. + /// Each pass takes at most one datagram per distinct `(fate, dst)` pair + /// (arrival order) — so a coalesced skb never carries two symbols of one + /// FEC object *and* never mixes destinations — and defers the rest to the + /// next pass. Bounded by `MAX_PENDING_GSO_DATAGRAMS`. + /// + /// Grouping by fate alone (pre-multipeer) let a batch mix `dst`s whenever + /// two different peers happened to emit distinct-fate datagrams in the + /// same `poll_once`; `queue_udp_batch_tagged`'s `can_coalesce_gso_tagged` + /// check would then reject the *whole* mixed chunk and fall back to + /// per-datagram sends, silently losing the GSO win for same-peer pairs + /// that were incidentally batched with a different peer's datagram. + /// Grouping by `(fate, dst)` keeps distinct peers in separate chunks so + /// same-peer datagrams still coalesce. fn flush_pending_gso(&mut self) { while !self.pending_gso.is_empty() { let mut chunk: Vec = Vec::with_capacity(self.pending_gso.len()); let mut deferred: Vec = Vec::with_capacity(self.pending_gso.len()); for dg in self.pending_gso.drain(..) { - if chunk.iter().any(|c| c.fate == dg.fate) { + let dst_conflict = chunk + .first() + .is_some_and(|c: &EgressDatagram| c.dst != dg.dst); + let fate_conflict = chunk.iter().any(|c| c.fate == dg.fate); + if dst_conflict || fate_conflict { deferred.push(dg); } else { chunk.push(dg); @@ -595,6 +703,7 @@ impl UringDriver { &mut self, datagrams: &[T], segment_size: u16, + dst: SocketAddr, ) -> io::Result { if datagrams.len() > MAX_GSO_DATAGRAMS { return Ok(false); @@ -633,13 +742,14 @@ impl UringDriver { .ok_or_else(|| io::Error::other("missing in-flight GSO payload"))? .len(); let ctx = self.gso_ctx[slot_id].get_or_insert_with(GsoSendContext::new); - if let Err(e) = ctx.prepare(payload_ptr, payload_len_now, segment_size) { + if let Err(e) = ctx.prepare_gso(payload_ptr, payload_len_now, segment_size, dst) { self.release_in_flight_slot(slot_id); return Err(e); } self.gso_meta[slot_id] = Some(GsoMeta { segment_size, datagram_count: datagrams.len(), + dst, }); let tag = TAG_SEND_SLOT | u64::try_from(slot_id).expect("slot id fits u64"); let msg_ptr = std::ptr::from_ref(&ctx.msg); @@ -658,7 +768,13 @@ impl UringDriver { Ok(true) } - fn recover_gso_fallback_datagrams(&self, slot_id: usize) -> Vec> { + /// Recover a failed GSO send's datagrams for per-datagram retry. `fate` is + /// not preserved (set to `0`) — these are always retried via + /// `queue_udp_send`, which ignores `fate` (it's only consulted by the GSO + /// coalescing decision, which this path has already abandoned); `dst` is + /// `meta.dst`, the one destination `can_coalesce_gso_tagged` guaranteed + /// every datagram in this send shared. + fn recover_gso_fallback_datagrams(&self, slot_id: usize) -> Vec { let Some(meta) = self.gso_meta.get(slot_id).and_then(|meta| *meta) else { return Vec::new(); }; @@ -677,12 +793,22 @@ impl UringDriver { if end > payload.len() { break; } - datagrams.push(payload[start..end].to_vec()); + datagrams.push(EgressDatagram { + fate: 0, + dst: meta.dst, + bytes: payload[start..end].to_vec(), + }); } datagrams } - fn recover_gso_unsent_datagrams(&self, slot_id: usize, bytes_sent: usize) -> Vec> { + /// Same as [`Self::recover_gso_fallback_datagrams`] but for a *partial* + /// send completion: only the datagrams past `bytes_sent` are recovered. + fn recover_gso_unsent_datagrams( + &self, + slot_id: usize, + bytes_sent: usize, + ) -> Vec { let Some(meta) = self.gso_meta.get(slot_id).and_then(|meta| *meta) else { return Vec::new(); }; @@ -709,7 +835,11 @@ impl UringDriver { if end > payload.len() { break; } - datagrams.push(payload[start..end].to_vec()); + datagrams.push(EgressDatagram { + fate: 0, + dst: meta.dst, + bytes: payload[start..end].to_vec(), + }); } datagrams } @@ -769,8 +899,14 @@ impl UringDriver { } } - fn handle_dispatch_udp(&mut self, d: &mut impl Dispatch, datagram: &[u8], now_ms: u64) { - match d.on_udp(datagram, now_ms) { + fn handle_dispatch_udp( + &mut self, + d: &mut impl Dispatch, + src: SocketAddr, + datagram: &[u8], + now_ms: u64, + ) { + match d.on_udp(src, datagram, now_ms) { DispatchOut::None => {} DispatchOut::Tun(inner) => { if let Err(e) = self.queue_tun_write(inner) { @@ -783,7 +919,9 @@ impl UringDriver { } DispatchOut::Udp(pkts) => { let pkts_owned = pkts.to_vec(); - if let Err(e) = self.queue_udp_batch(&pkts_owned, false) { + // `allow_gso=false`: control/ARQ-retransmit traffic is never + // GSO-coalesced, matching pre-addressing behavior exactly. + if let Err(e) = self.queue_udp_batch_tagged(&pkts_owned, false) { self.dropped_sends += 1; eprintln!( "uring: drop udp send batch: {e} (dropped_sends={})", @@ -800,7 +938,7 @@ impl UringDriver { ); } let pkts_owned = pkts.to_vec(); - if let Err(e) = self.queue_udp_batch(&pkts_owned, false) { + if let Err(e) = self.queue_udp_batch_tagged(&pkts_owned, false) { self.dropped_sends += 1; eprintln!( "uring: drop udp send batch: {e} (dropped_sends={})", @@ -956,6 +1094,62 @@ impl UringDriver { continue; } + if kind == TAG_UDP_RECV { + let slot_idx_u64 = user_data & TAG_PAYLOAD_MASK; + let slot_idx = + usize::try_from(slot_idx_u64).expect("udp recv slot index fits usize"); + if slot_idx >= self.udp_recv_slots.len() { + return Err(io::Error::other( + "kernel returned udp recv slot out of range", + )); + } + if result < 0 { + let errno = -result; + // Transient: re-arm the same slot and keep going. Anything + // else is fatal — a permanently failing recv would otherwise + // silently blind the tunnel rather than propagating so a + // supervisor can restart (mirrors the TAG_TUN_RECV contract + // below and `poll.rs`'s `drain_udp`). + if errno == libc::EAGAIN || errno == libc::EWOULDBLOCK || errno == libc::ENOBUFS + { + self.arm_udp_recv_slot(slot_idx)?; + continue; + } + return Err(io::Error::other(format!( + "udp recv completion error: {}", + io::Error::from_raw_os_error(errno) + ))); + } + let n = usize::try_from(result).expect("non-negative CQE result fits usize"); + if n > MAX_WIRE_DATAGRAM { + self.arm_udp_recv_slot(slot_idx)?; + return Err(io::Error::other("kernel returned oversized datagram")); + } + // Recover the sender's address from this slot's own + // `sockaddr_storage`/`msg_namelen` — the whole point of moving + // off multishot `RecvMulti` (see the module doc). + let namelen = self.udp_recv_slots[slot_idx].msg.msg_namelen; + let src = match sockaddr_to_std(&self.udp_recv_slots[slot_idx].name, namelen) { + Ok(addr) => addr, + Err(e) => { + eprintln!( + "uring: dropping udp datagram with unparseable source address: {e}" + ); + self.arm_udp_recv_slot(slot_idx)?; + continue; + } + }; + let mut scratch = std::mem::take(&mut self.recv_scratch); + scratch.clear(); + scratch.extend_from_slice(&self.udp_recv_slots[slot_idx].buf[..n]); + self.handle_dispatch_udp(d, src, &scratch, now_ms); + self.recv_scratch = scratch; + self.arm_udp_recv_slot(slot_idx)?; + continue; + } + + // Everything from here on is TAG_TUN_RECV: provided-buffer, + // single-shot, re-armed after every completion. let bid_opt = cqueue::buffer_select(flags); if result < 0 { let errno = -result; @@ -965,25 +1159,15 @@ impl UringDriver { } // ENOBUFS on a recv completion means the provided-buffer ring was // momentarily exhausted (no buffer for the kernel to place this - // datagram) — the multishot recv stops and must be re-armed. Like - // EAGAIN, this is transient, not fatal: drop the datagram and - // re-arm; buffers are re-provided as other completions process. + // frame) — transient, not fatal: drop the frame and re-arm; + // buffers are re-provided as other completions process. // (Treating ENOBUFS as fatal tore the driver down under burst and // flaked the uring unit tests on the CI runner.) - if errno == libc::EAGAIN || errno == libc::EWOULDBLOCK || errno == libc::ENOBUFS { - if kind == TAG_UDP_RECV { - self.arm_udp_recv()?; - continue; - } - if kind == TAG_TUN_RECV { - self.arm_tun_read()?; - continue; - } - } - if kind == TAG_UDP_RECV { - return Err(io::Error::other(format!( - "udp recv completion error: {err}" - ))); + if (errno == libc::EAGAIN || errno == libc::EWOULDBLOCK || errno == libc::ENOBUFS) + && kind == TAG_TUN_RECV + { + self.arm_tun_read()?; + continue; } if kind == TAG_TUN_RECV { return Err(io::Error::other(format!( @@ -995,7 +1179,7 @@ impl UringDriver { ))); } - let bid = if kind == TAG_UDP_RECV || kind == TAG_TUN_RECV { + let bid = if kind == TAG_TUN_RECV { bid_opt.ok_or_else(|| { io::Error::other( "recv completion missing buffer_select; aborting to avoid pool-slot leak", @@ -1020,19 +1204,6 @@ impl UringDriver { return Err(io::Error::other("kernel returned oversized datagram")); } - if kind == TAG_UDP_RECV { - let mut scratch = std::mem::take(&mut self.recv_scratch); - scratch.clear(); - scratch.extend_from_slice(&self.recv_pool[idx][..n]); - self.handle_dispatch_udp(d, &scratch, now_ms); - self.recv_scratch = scratch; - self.reprovide_buffer(bid)?; - if !cqueue::more(flags) { - self.arm_udp_recv()?; - } - continue; - } - let mut scratch = std::mem::take(&mut self.recv_scratch); scratch.clear(); scratch.extend_from_slice(&self.recv_pool[idx][..n]); @@ -1045,9 +1216,11 @@ impl UringDriver { // Flush TUN-egress datagrams staged this pass in fate-safe GSO batches. self.flush_pending_gso(); - if let Some(pkt) = d.tick(now_ms) { - if let Err(e) = self.queue_udp_send(pkt) { - eprintln!("uring: drop tick packet: {e}"); + if let Some(pkts) = d.tick(now_ms) { + for pkt in pkts { + if let Err(e) = self.queue_udp_send(pkt) { + eprintln!("uring: drop tick packet: {e}"); + } } } @@ -1118,7 +1291,7 @@ mod tests { static URING_SERIAL: Mutex<()> = Mutex::new(()); struct EchoDispatch { - scratch: Vec>, + scratch: Vec, } impl EchoDispatch { @@ -1130,8 +1303,12 @@ mod tests { } impl Dispatch for EchoDispatch { - fn on_udp(&mut self, dg: &[u8], _now_ms: u64) -> DispatchOut<'_> { - self.scratch = vec![dg.to_vec()]; + fn on_udp(&mut self, src: SocketAddr, dg: &[u8], _now_ms: u64) -> DispatchOut<'_> { + self.scratch = vec![EgressDatagram { + fate: 0, + dst: src, + bytes: dg.to_vec(), + }]; DispatchOut::Udp(&self.scratch) } @@ -1139,7 +1316,7 @@ mod tests { &[] } - fn tick(&mut self, _now_ms: u64) -> Option<&[u8]> { + fn tick(&mut self, _now_ms: u64) -> Option<&[EgressDatagram]> { None } } @@ -1151,7 +1328,7 @@ mod tests { } impl Dispatch for TickCountDispatch { - fn on_udp(&mut self, _dg: &[u8], _now_ms: u64) -> DispatchOut<'_> { + fn on_udp(&mut self, _src: SocketAddr, _dg: &[u8], _now_ms: u64) -> DispatchOut<'_> { DispatchOut::None } @@ -1159,7 +1336,7 @@ mod tests { &[] } - fn tick(&mut self, _now_ms: u64) -> Option<&[u8]> { + fn tick(&mut self, _now_ms: u64) -> Option<&[EgressDatagram]> { self.ticks += 1; None } @@ -1169,19 +1346,21 @@ mod tests { /// symbols of a single FEC object (source + its repair). A GSO driver must /// NEVER coalesce these — losing them together would defeat FEC. struct GsoDispatch { + dst: SocketAddr, scratch: Vec, } impl GsoDispatch { - fn new() -> Self { + fn new(dst: SocketAddr) -> Self { Self { + dst, scratch: Vec::new(), } } } impl Dispatch for GsoDispatch { - fn on_udp(&mut self, _dg: &[u8], _now_ms: u64) -> DispatchOut<'_> { + fn on_udp(&mut self, _src: SocketAddr, _dg: &[u8], _now_ms: u64) -> DispatchOut<'_> { DispatchOut::None } @@ -1192,13 +1371,14 @@ mod tests { datagram[0] = b'0' + i; self.scratch.push(EgressDatagram { fate: 7, + dst: self.dst, bytes: datagram, }); } &self.scratch } - fn tick(&mut self, _now_ms: u64) -> Option<&[u8]> { + fn tick(&mut self, _now_ms: u64) -> Option<&[EgressDatagram]> { None } } @@ -1208,14 +1388,16 @@ mod tests { /// driver may coalesce these (a dropped skb costs each object at most one /// symbol, recoverable from its repair in a different skb). struct GsoLargeBatchDispatch { + dst: SocketAddr, scratch: Vec, datagram_count: usize, datagram_size: usize, } impl GsoLargeBatchDispatch { - fn new(datagram_count: usize, datagram_size: usize) -> Self { + fn new(dst: SocketAddr, datagram_count: usize, datagram_size: usize) -> Self { Self { + dst, scratch: Vec::new(), datagram_count, datagram_size, @@ -1224,7 +1406,7 @@ mod tests { } impl Dispatch for GsoLargeBatchDispatch { - fn on_udp(&mut self, _dg: &[u8], _now_ms: u64) -> DispatchOut<'_> { + fn on_udp(&mut self, _src: SocketAddr, _dg: &[u8], _now_ms: u64) -> DispatchOut<'_> { DispatchOut::None } @@ -1235,13 +1417,14 @@ mod tests { datagram[0] = u8::try_from(i % 251).expect("modulo bound fits u8"); self.scratch.push(EgressDatagram { fate: u16::try_from(i).expect("datagram_count fits u16"), + dst: self.dst, bytes: datagram, }); } &self.scratch } - fn tick(&mut self, _now_ms: u64) -> Option<&[u8]> { + fn tick(&mut self, _now_ms: u64) -> Option<&[EgressDatagram]> { None } } @@ -1268,9 +1451,22 @@ mod tests { ); } + fn test_dst() -> SocketAddr { + "127.0.0.1:1".parse().expect("valid test address") + } + + fn other_dst() -> SocketAddr { + "127.0.0.1:2".parse().expect("valid test address") + } + fn dg(fate: u16, len: usize) -> EgressDatagram { + dg_to(fate, len, test_dst()) + } + + fn dg_to(fate: u16, len: usize, dst: SocketAddr) -> EgressDatagram { EgressDatagram { fate, + dst, bytes: vec![b'x'; len], } } @@ -1295,6 +1491,15 @@ mod tests { assert!(UringDriver::can_coalesce_gso_tagged(&dgs).is_none()); } + #[test] + fn can_coalesce_gso_tagged_rejects_mixed_destinations() { + // Distinct fates and equal length would otherwise coalesce, but a + // coalesced `UDP_SEGMENT` send has exactly one `msg_name` — datagrams + // bound for different peers must never share a skb. + let dgs = [dg_to(3, 64, test_dst()), dg_to(4, 64, other_dst())]; + assert!(UringDriver::can_coalesce_gso_tagged(&dgs).is_none()); + } + #[test] fn uring_loopback_roundtrip_recycles_recv_buffers() { let _guard = URING_SERIAL @@ -1458,8 +1663,9 @@ mod tests { a.set_nonblocking(true).expect("set sender nonblocking"); let (tun_rd, tun_wr) = make_pipe().expect("make tun placeholder pipe"); - // GsoDispatch returns 5 datagrams all sharing ONE fate (one FEC object). - let mut dispatch = GsoDispatch::new(); + // GsoDispatch returns 5 datagrams all sharing ONE fate (one FEC object), + // all destined for `a` (the loopback peer that will receive them). + let mut dispatch = GsoDispatch::new(a.local_addr().expect("sender local addr")); let mut driver = match UringDriver::new(b.as_raw_fd(), tun_rd) { Ok(driver) => driver, Err(_) => { @@ -1538,7 +1744,8 @@ mod tests { let (tun_rd, tun_wr) = make_pipe().expect("make tun placeholder pipe"); // Distinct fates so GSO is genuinely attempted (then forced to fail). - let mut dispatch = GsoLargeBatchDispatch::new(5, 64); + let mut dispatch = + GsoLargeBatchDispatch::new(a.local_addr().expect("sender local addr"), 5, 64); let mut driver = match UringDriver::new(b.as_raw_fd(), tun_rd) { Ok(driver) => driver, Err(_) => { @@ -1622,7 +1829,11 @@ mod tests { let (tun_rd, tun_wr) = make_pipe().expect("make tun placeholder pipe"); let datagram_count = 64usize; let datagram_size = 1400usize; - let mut dispatch = GsoLargeBatchDispatch::new(datagram_count, datagram_size); + let mut dispatch = GsoLargeBatchDispatch::new( + a.local_addr().expect("sender local addr"), + datagram_count, + datagram_size, + ); let mut driver = match UringDriver::new(b.as_raw_fd(), tun_rd) { Ok(driver) => driver, Err(_) => { diff --git a/docs/configuration.md b/docs/configuration.md index 3d6751a..740b463 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -5,16 +5,25 @@ variables that select the I/O driver, and the command-line flags. This is the si reference for everything `yipd` reads at startup — it is otherwise scattered across `bin/yipd/src/` and the bench harness in `crates/yip-bench/`. -`yipd` today runs a **static two-peer tunnel**: one config file per endpoint, no -control plane yet (discovery, NAT traversal, and relay arrive in sub-project #2). Both -peers must agree on keys and endpoints out of band. +`yipd` today runs a **static multi-peer data plane**: one config file per node, each +listing one or more peers, with keys and endpoints agreed out of band. The remaining +control plane (discovery, NAT traversal, relay) arrives in later sub-project #2 +milestones. There is **no `initiate` flag**: the handshake is lazy and in-loop +(WireGuard-style) — whichever side has traffic for a peer first sends the +`[HandshakeInit]`, and simultaneous initiation is resolved deterministically. + +Each node has a **self-certifying mesh address** derived from its public key +(`fd00::/8`); print it with `yipd --addr `. Assign that `/128` to the node's +TUN device and route the mesh prefix over it so inner packets addressed to a peer's mesh +address are tunnelled to that peer. ## Invocation ```sh -yipd # run a tunnel from a config file -yipd --genkey # generate an X25519 keypair and exit -yipd --version # print "yipd " and exit +yipd # run a tunnel from a config file +yipd --genkey # generate an X25519 keypair and exit +yipd --addr # print the mesh address (node_addr) for a public key and exit +yipd --version # print "yipd " and exit ``` ## Config file @@ -26,55 +35,74 @@ keys are silently ignored for forward-compatibility. Generate a keypair with `yipd --genkey`; it prints `private=` and `public=`. -### Keys +### Node keys | Key | Required | Value | Meaning | |---|---|---|---| -| `local_private` | yes | 64 hex digits | This endpoint's X25519 private key. Feeds the Noise-IK handshake. | -| `local_public` | yes | 64 hex digits | This endpoint's X25519 public key. Carried for key identity / future re-advertisement; the data path itself reads `local_private`. | -| `peer_public` | yes | 64 hex digits | The remote peer's X25519 public key. | +| `local_private` | yes | 64 hex digits | This node's X25519 private key. Feeds the Noise-IK handshake. | +| `local_public` | yes | 64 hex digits | This node's X25519 public key. Determines this node's mesh address (`yipd --addr`); the data path itself reads `local_private`. | | `listen` | yes | `IP:port` socket address | Local UDP address to bind (e.g. `0.0.0.0:51820`). | -| `peer_endpoint` | yes | `IP:port` socket address | The remote peer's UDP endpoint. Used by the initiator to send the first handshake message; the responder learns the peer's address from the incoming datagram, so on a pure responder this can be a placeholder that is reachable-shaped but is not dialed. | | `device` | yes | string | TUN/TAP device name to create (e.g. `yip0`). | | `device_kind` | no | `tun` \| `tap` | Tunnel mode. `tun` = L3 IP tunnel, `tap` = L2 Ethernet bridging. **Defaults to `tun`** when the key is absent. An unrecognized value is a startup error. | -| `initiate` | yes | boolean | Whether this peer initiates the Noise-IK handshake. Exactly one of the two peers should set `true`. Accepted truthy values: `true`, `1`, `yes`; falsy: `false`, `0`, `no`. Any other value is a startup error. | + +### Peers + +List each remote peer in a `[peer]` block. Repeat the block once per peer: + +| Key | Required | Value | Meaning | +|---|---|---|---| +| `public_key` | yes | 64 hex digits | The peer's X25519 public key. Also determines the peer's mesh address you route to (`yipd --addr`). | +| `endpoint` | yes | `IP:port` socket address | The peer's UDP endpoint, used to send it the first handshake message. The actual source address is (re)learned from the peer's own handshake datagram. | + +**Legacy single-peer form:** for a one-peer node you may instead use the flat keys +`peer_public=` and `peer_endpoint=` (no `[peer]` header); they fold into a +single peer entry. The `[peer]` block form is required for two or more peers. A missing required key, malformed line (no `=`), bad hex, unparseable socket address, -or invalid boolean/`device_kind` all cause `yipd` to exit with a parse error. +or invalid `device_kind` all cause `yipd` to exit with a parse error. Unknown keys are +ignored (so a leftover `initiate=` from an older config is harmless). ### Example -Two endpoints, A and B. B initiates; A responds. Keys are illustrative — generate real -ones with `yipd --genkey`. +Two nodes, A and B, peered with each other. There is no initiator/responder role — the +first side with traffic brings the tunnel up. Keys are illustrative — generate real ones +with `yipd --genkey`, and compute each node's mesh address with `yipd --addr `. -`yipA.conf` (responder): +`yipA.conf`: ```ini -# Endpoint A — responder +# Node A local_private=0000000000000000000000000000000000000000000000000000000000000001 local_public=0000000000000000000000000000000000000000000000000000000000000002 -peer_public=00000000000000000000000000000000000000000000000000000000000000bb listen=10.0.0.1:51820 -peer_endpoint=10.0.0.2:51820 device=yip0 device_kind=tun -initiate=false + +[peer] +public_key=00000000000000000000000000000000000000000000000000000000000000bb +endpoint=10.0.0.2:51820 ``` -`yipB.conf` (initiator): +`yipB.conf`: ```ini -# Endpoint B — initiator +# Node B local_private=00000000000000000000000000000000000000000000000000000000000000aa local_public=00000000000000000000000000000000000000000000000000000000000000bb -peer_public=0000000000000000000000000000000000000000000000000000000000000002 listen=10.0.0.2:51820 -peer_endpoint=10.0.0.1:51820 device=yip0 device_kind=tun -initiate=true + +[peer] +public_key=0000000000000000000000000000000000000000000000000000000000000002 +endpoint=10.0.0.1:51820 ``` +Then assign each node its own mesh address and route the mesh prefix over the tunnel, +e.g. on A: `ip -6 addr add $(yipd --addr 0000…0002)/128 dev yip0` and +`ip -6 route add fd00::/8 dev yip0`. A third node C is added by giving A and B a second +`[peer]` block for C (and C a config listing both A and B). + ## Environment variables Both variables select the `yip-io` event-loop driver. They are **presence-based** — @@ -116,6 +144,7 @@ no other flags. |---|---| | `` | Load the config file and run the tunnel. | | `--genkey` | Generate an X25519 keypair, print `private=` / `public=` to stdout, and exit. | +| `--addr ` | Print the self-certifying mesh address (`node_addr`, in `fd00::/8`) derived from a 64-hex-digit public key, and exit. | | `--version`, `-V` | Print `yipd ` and exit. | Running `yipd` with no argument prints a usage message and exits with an error.