diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index c770324..8549413 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -123,10 +123,17 @@ jobs: # arq_recovers_bulk_loss drives a real Bulk flow and needs a release yipd # (debug RaptorQ is ~75x slower). cargo build --release -p yipd + # relay_path_ping and hole_punch_ping need the yip-rendezvous server + # binary (a debug build is fine: these are ping-only tests). It lives + # in a different package, so cargo does not build it as a side effect + # of the tunnel_netns test build above; the test locates it at + # target/debug/yip-rendezvous and SKIPs with instructions if absent, + # so build it here to keep this job honest (not vacuously green). + cargo build -p yip-rendezvous-bin 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 triangle_full_mesh_ping; 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 relay_path_ping hole_punch_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.lock b/Cargo.lock index 2d8a003..0a3fe6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1084,6 +1084,20 @@ dependencies = [ "libc", ] +[[package]] +name = "yip-rendezvous" +version = "0.1.0" +dependencies = [ + "blake2", +] + +[[package]] +name = "yip-rendezvous-bin" +version = "0.1.0" +dependencies = [ + "yip-rendezvous", +] + [[package]] name = "yip-transport" version = "0.0.0" @@ -1109,6 +1123,7 @@ dependencies = [ "yip-crypto", "yip-device", "yip-io", + "yip-rendezvous", "yip-transport", "yip-wire", ] diff --git a/bin/yip-rendezvous/Cargo.toml b/bin/yip-rendezvous/Cargo.toml new file mode 100644 index 0000000..a7b0dc0 --- /dev/null +++ b/bin/yip-rendezvous/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "yip-rendezvous-bin" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "yip-rendezvous" +path = "src/main.rs" + +[dependencies] +yip-rendezvous = { path = "../../crates/yip-rendezvous" } + +[lints] +workspace = true diff --git a/bin/yip-rendezvous/src/main.rs b/bin/yip-rendezvous/src/main.rs new file mode 100644 index 0000000..a73453a --- /dev/null +++ b/bin/yip-rendezvous/src/main.rs @@ -0,0 +1,68 @@ +//! The yip rendezvous + blind relay server. Binds one UDP socket, drives the +//! pure `RendezvousServer` state machine, and sweeps expired registrations on a +//! read-timeout cadence. No TUN, no tunnel keys, no unsafe. +#![forbid(unsafe_code)] + +use std::net::UdpSocket; +use std::time::{Duration, Instant}; + +use yip_rendezvous::{decode, encode, RendezvousServer}; + +const SWEEP_INTERVAL: Duration = Duration::from_secs(5); + +fn main() -> std::io::Result<()> { + let mut args = std::env::args(); + let _prog = args.next(); + let listen = match args.next().as_deref() { + Some("--version") | Some("-V") => { + println!("yip-rendezvous {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + Some(addr) => addr.to_string(), + None => { + eprintln!("usage: yip-rendezvous e.g. 0.0.0.0:51821"); + std::process::exit(2); + } + }; + + let sock = UdpSocket::bind(&listen)?; + sock.set_read_timeout(Some(SWEEP_INTERVAL))?; + eprintln!("yip-rendezvous listening on {listen}"); + + // Millisecond clock from a monotonic base (Instant), so `now_ms` never goes + // backwards and needs no wall clock. + let base = Instant::now(); + let now_ms = + |base: Instant| -> u64 { u64::try_from(base.elapsed().as_millis()).unwrap_or(u64::MAX) }; + + let mut server = RendezvousServer::new(now_ms(base)); + let mut last_sweep = Instant::now(); + let mut rx = [0u8; 2048]; + let mut out = Vec::new(); + + loop { + match sock.recv_from(&mut rx) { + Ok((n, src)) => { + if let Some(msg) = decode(&rx[..n]) { + for (dst, reply) in server.handle(src, msg, now_ms(base)) { + out.clear(); + encode(&reply, &mut out); + let _ = sock.send_to(&out, dst); // best-effort; drop on error + } + } + } + Err(e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => {} + Err(e) => return Err(e), + } + if last_sweep.elapsed() >= SWEEP_INTERVAL { + server.sweep(now_ms(base)); + last_sweep = Instant::now(); + // Lets the netns money tests (and operators) grep stderr for the + // final relay-forward count to assert *which path* carried + // traffic, without needing any extra IPC/metrics surface. + eprintln!("relay-forwarded={}", server.forwarded_count()); + } + } +} diff --git a/bin/yip-rendezvous/tests/smoke.rs b/bin/yip-rendezvous/tests/smoke.rs new file mode 100644 index 0000000..ac93292 --- /dev/null +++ b/bin/yip-rendezvous/tests/smoke.rs @@ -0,0 +1,85 @@ +//! Socket-level smoke: spawn the server, register from one socket, look up from +//! another, and relay a payload — asserting the observed reflexive addr and the +//! blind forward both work over real UDP. +use std::net::UdpSocket; +use std::process::{Child, Command}; +use std::time::Duration; + +use yip_rendezvous::{decode, encode, node_id, Message}; + +fn spawn_server(listen: &str) -> Child { + Command::new(env!("CARGO_BIN_EXE_yip-rendezvous")) + .arg(listen) + .spawn() + .expect("spawn server") +} + +#[test] +fn register_lookup_relay_over_udp() { + let listen = "127.0.0.1:51821"; + let mut server = spawn_server(listen); + std::thread::sleep(Duration::from_millis(300)); // let it bind + + let a = UdpSocket::bind("127.0.0.1:0").unwrap(); + a.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let b = UdpSocket::bind("127.0.0.1:0").unwrap(); + b.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + + let a_id = node_id(&[1u8; 32]); + let b_id = node_id(&[2u8; 32]); + + // A registers. + let mut buf = Vec::new(); + encode(&Message::Register { node: a_id }, &mut buf); + a.send_to(&buf, listen).unwrap(); + std::thread::sleep(Duration::from_millis(100)); + + // B looks up A -> expects PeerInfo(A, A's reflexive addr). + buf.clear(); + encode(&Message::Lookup { node: a_id }, &mut buf); + b.send_to(&buf, listen).unwrap(); + let mut rx = [0u8; 2048]; + let (n, _) = b.recv_from(&mut rx).expect("B receives PeerInfo"); + match decode(&rx[..n]) { + Some(Message::PeerInfo { node, reflexive }) => { + assert_eq!(node, a_id); + assert_eq!(reflexive, a.local_addr().unwrap()); + } + other => panic!("expected PeerInfo, got {other:?}"), + } + + // B's Lookup above also caused the server to send A a PunchHint (the + // simultaneous-open trigger): A is told to punch toward B's reflexive addr. + let (n, _) = a + .recv_from(&mut rx) + .expect("A receives PunchHint from the lookup"); + match decode(&rx[..n]) { + Some(Message::PunchHint { reflexive, .. }) => { + assert_eq!(reflexive, b.local_addr().unwrap()); + } + other => panic!("expected PunchHint, got {other:?}"), + } + + // B relays a payload to A -> A receives RelayDeliver{src=B, payload}. + buf.clear(); + encode( + &Message::RelaySend { + src: b_id, + dst: a_id, + payload: vec![7, 7, 7], + }, + &mut buf, + ); + b.send_to(&buf, listen).unwrap(); + let (n, _) = a.recv_from(&mut rx).expect("A receives RelayDeliver"); + match decode(&rx[..n]) { + Some(Message::RelayDeliver { src, payload }) => { + assert_eq!(src, b_id); + assert_eq!(payload, vec![7, 7, 7]); + } + other => panic!("expected RelayDeliver, got {other:?}"), + } + + let _ = server.kill(); + let _ = server.wait(); // reap the child so it doesn't linger as a zombie +} diff --git a/bin/yipd/Cargo.toml b/bin/yipd/Cargo.toml index d2eb52c..baab099 100644 --- a/bin/yipd/Cargo.toml +++ b/bin/yipd/Cargo.toml @@ -11,6 +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" } +yip-rendezvous = { path = "../../crates/yip-rendezvous" } blake2 = { workspace = true } [lints] diff --git a/bin/yipd/src/config.rs b/bin/yipd/src/config.rs index 626a767..a8958a4 100644 --- a/bin/yipd/src/config.rs +++ b/bin/yipd/src/config.rs @@ -13,7 +13,10 @@ use crate::mode::TunnelMode; #[derive(Debug, Clone, PartialEq, Eq)] pub struct PeerConfig { pub public_key: [u8; 32], - pub endpoint: SocketAddr, + /// This peer's known direct UDP endpoint, or `None` if the peer is known + /// only by public key (reachable only via rendezvous/relay once Task 6 + /// wires that path in). + pub endpoint: Option, } /// Static configuration for one yip tunnel endpoint. @@ -32,6 +35,10 @@ pub struct Config { pub device: String, /// Tunnel mode selected from `device_kind=tun|tap` (`tun` by default). pub device_kind: TunnelMode, + /// Configured rendezvous+relay server, if any (`rendezvous=`). + /// Enables lazy Direct→Punch→Relay peer bring-up in `PeerManager` via + /// `ConfiguredServerRendezvous`. + pub rendezvous: Option, } // ── hex decode helper ───────────────────────────────────────────────────────── @@ -82,16 +89,21 @@ fn flush_peer_block( cur_ep: Option, peers: &mut Vec, ) -> io::Result<()> { - if let (Some(pk), Some(ep)) = (cur_pk, cur_ep) { - peers.push(PeerConfig { + match cur_pk { + // `endpoint` is optional: a peer known only by public key is + // rendezvous-only (unreachable directly until Task 6 supplies a + // candidate). + Some(pk) => 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(), - )); + endpoint: cur_ep, + }), + None if cur_ep.is_some() => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "peer block has an endpoint but no public_key".to_string(), + )); + } + None => {} } Ok(()) } @@ -116,6 +128,7 @@ impl Config { let mut listen: Option = None; let mut device: Option = None; let mut device_kind = TunnelMode::default(); + let mut rendezvous: Option = None; for line in text.lines() { let line = line.trim(); @@ -164,6 +177,12 @@ impl Config { } "device" => device = Some(val.to_owned()), "device_kind" => device_kind = TunnelMode::parse_device_kind(val)?, + "rendezvous" => { + rendezvous = + Some(val.parse::().map_err(|e| { + io::Error::new(io::ErrorKind::InvalidData, e.to_string()) + })?) + } // 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 @@ -182,7 +201,7 @@ impl Config { if let (Some(pk), Some(ep)) = (legacy_peer_public, legacy_peer_endpoint) { peers.push(PeerConfig { public_key: pk, - endpoint: ep, + endpoint: Some(ep), }); } } @@ -203,6 +222,7 @@ impl Config { listen: listen.ok_or_else(|| missing("listen"))?, device: device.ok_or_else(|| missing("device"))?, device_kind, + rendezvous, }) } } @@ -421,7 +441,10 @@ peer_public=0000000000000000000000000000000000000000000000000000000000000003 [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[0].endpoint, + Some("10.0.0.2:51820".parse().unwrap()) + ); assert_eq!(cfg.peers[1].public_key[31], 0xb2); } @@ -435,4 +458,33 @@ peer_public=0000000000000000000000000000000000000000000000000000000000000003 assert_eq!(cfg.peers.len(), 1); assert_eq!(cfg.peers[0].public_key[31], 0xbb); } + + #[test] + fn parses_rendezvous_and_optional_endpoint() { + let text = "local_private=00000000000000000000000000000000000000000000000000000000000000ff\n\ + local_public=000000000000000000000000000000000000000000000000000000000000aa01\n\ + listen=0.0.0.0:51820\ndevice=yip0\nrendezvous=203.0.113.1:51821\n\ + [peer]\npublic_key=00000000000000000000000000000000000000000000000000000000000000b1\n"; + let cfg = Config::parse(text).expect("parses"); + assert_eq!(cfg.rendezvous, Some("203.0.113.1:51821".parse().unwrap())); + assert_eq!(cfg.peers.len(), 1); + assert_eq!( + cfg.peers[0].endpoint, None, + "peer with no endpoint is rendezvous-only" + ); + } + + #[test] + fn rendezvous_absent_is_none() { + 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"; + let cfg = Config::parse(text).unwrap(); + assert_eq!(cfg.rendezvous, None); + assert_eq!( + cfg.peers[0].endpoint, + Some("10.0.0.2:51820".parse().unwrap()) + ); + } } diff --git a/bin/yipd/src/main.rs b/bin/yipd/src/main.rs index 4f7f892..472edf7 100644 --- a/bin/yipd/src/main.rs +++ b/bin/yipd/src/main.rs @@ -9,7 +9,9 @@ mod dataplane; mod handshake; mod mac_table; mod mode; +mod path; mod peer_manager; +mod rendezvous; mod tunnel; mod wire_glue; diff --git a/bin/yipd/src/path.rs b/bin/yipd/src/path.rs new file mode 100644 index 0000000..9688cd8 --- /dev/null +++ b/bin/yipd/src/path.rs @@ -0,0 +1,277 @@ +//! Per-peer connection path state machine: escalate Direct -> Punch -> Relay, +//! each with a bounded window, feeding candidate addresses to the caller's +//! handshake machinery. A candidate is ONLY ever a probe target — the caller +//! commits a path (via `committed`) only once a Noise handshake completes over +//! it (the anti-hijack invariant lives in the caller; this SM never sends). +use std::net::SocketAddr; + +/// Direct-stage window before escalating to punch. +pub const DIRECT_MS: u64 = 3_000; +/// Punch-stage window before escalating to relay. +pub const PUNCH_MS: u64 = 5_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathKind { + Direct, + Punched, + Relayed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathStage { + Direct, + Punching, + Relaying, + Failed, +} + +/// What the caller should do this tick for a not-yet-established peer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathAction { + /// Nothing to do (committed, or waiting within a window). + Idle, + /// Send a `Lookup` for this peer (entering/among the punch stage). + NeedLookup, + /// Probe this candidate with a handshake Init. + Probe(SocketAddr), + /// Send the handshake/data via the relay. + Relay, + /// No path available (no direct endpoint and no rendezvous). + Failed, +} + +pub struct PathState { + stage: PathStage, + has_rendezvous: bool, + direct: Option, + candidate: Option, // reflexive addr for the punch stage + stage_started_ms: u64, + committed: bool, + looked_up: bool, +} + +impl PathState { + pub fn new(has_direct: bool, has_rendezvous: bool, now_ms: u64) -> Self { + let stage = if has_direct { + PathStage::Direct + } else if has_rendezvous { + PathStage::Punching + } else { + PathStage::Failed + }; + Self { + stage, + has_rendezvous, + direct: None, + candidate: None, + stage_started_ms: now_ms, + committed: false, + looked_up: false, + } + } + + pub fn stage(&self) -> PathStage { + self.stage + } + + #[expect( + dead_code, + reason = "candidate getter surfaced for later milestones; PeerManager routes via endpoint" + )] + pub fn candidate(&self) -> Option { + match self.stage { + PathStage::Direct => self.direct, + PathStage::Punching => self.candidate, + _ => None, + } + } + + pub fn on_direct_addr(&mut self, addr: SocketAddr) { + self.direct = Some(addr); + } + + pub fn on_peer_candidate(&mut self, addr: SocketAddr, now_ms: u64) { + // A reflexive addr arrived (from PeerInfo or a PunchHint): enter/refresh + // the punch stage targeting it. + self.candidate = Some(addr); + if self.stage == PathStage::Direct || self.stage == PathStage::Punching { + if self.stage != PathStage::Punching { + self.stage_started_ms = now_ms; + } + self.stage = PathStage::Punching; + } + } + + fn enter(&mut self, stage: PathStage, now_ms: u64) { + self.stage = stage; + self.stage_started_ms = now_ms; + } + + pub fn advance(&mut self, now_ms: u64) -> PathAction { + if self.committed { + return PathAction::Idle; + } + let elapsed = now_ms.saturating_sub(self.stage_started_ms); + match self.stage { + PathStage::Direct => { + if let Some(addr) = self.direct { + if elapsed < DIRECT_MS { + return PathAction::Probe(addr); + } + } + // Direct window elapsed (or never had an endpoint): escalate. + if self.has_rendezvous { + self.enter(PathStage::Punching, now_ms); + self.punch_action(now_ms) + } else { + self.enter(PathStage::Failed, now_ms); + PathAction::Failed + } + } + PathStage::Punching => { + if elapsed >= PUNCH_MS { + self.enter(PathStage::Relaying, now_ms); + return PathAction::Relay; + } + self.punch_action(now_ms) + } + PathStage::Relaying => PathAction::Relay, + PathStage::Failed => PathAction::Failed, + } + } + + fn punch_action(&mut self, _now_ms: u64) -> PathAction { + match self.candidate { + Some(addr) => PathAction::Probe(addr), + None => { + if !self.looked_up { + self.looked_up = true; + } + PathAction::NeedLookup + } + } + } + + pub fn committed(&mut self, _kind: PathKind) { + self.committed = true; + } + + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "session-stale reset is a local decision wired in a later milestone" + ) + )] + pub fn reset(&mut self, now_ms: u64) { + self.committed = false; + self.candidate = None; + self.looked_up = false; + self.stage = if self.direct.is_some() { + PathStage::Direct + } else if self.has_rendezvous { + PathStage::Punching + } else { + PathStage::Failed + }; + self.stage_started_ms = now_ms; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::SocketAddr; + + fn a(s: &str) -> SocketAddr { + s.parse().unwrap() + } + + #[test] + fn direct_first_when_endpoint_known() { + let mut p = PathState::new(true, true, 0); + p.on_direct_addr(a("10.0.0.2:51820")); + assert!(matches!(p.advance(0), PathAction::Probe(x) if x == a("10.0.0.2:51820"))); + assert_eq!(p.stage(), PathStage::Direct); + } + + #[test] + fn escalates_direct_to_punch_after_window() { + let mut p = PathState::new(true, true, 0); + p.on_direct_addr(a("10.0.0.2:51820")); + let _ = p.advance(0); + // After the direct window with no commit, ask for a lookup (enter punch). + assert!(matches!(p.advance(DIRECT_MS + 1), PathAction::NeedLookup)); + assert_eq!(p.stage(), PathStage::Punching); + } + + #[test] + fn punch_probes_learned_candidate_then_relays_after_window() { + let mut p = PathState::new(false, true, 0); // no direct endpoint + assert!(matches!(p.advance(0), PathAction::NeedLookup)); + p.on_peer_candidate(a("198.51.100.7:41000"), 10); + assert!(matches!(p.advance(10), PathAction::Probe(x) if x == a("198.51.100.7:41000"))); + // Punch window elapses without commit -> escalate to relay. + assert!(matches!(p.advance(10 + PUNCH_MS + 1), PathAction::Relay)); + assert_eq!(p.stage(), PathStage::Relaying); + } + + #[test] + fn no_rendezvous_and_no_direct_is_failed() { + let mut p = PathState::new(false, false, 0); + assert!(matches!(p.advance(0), PathAction::Failed)); + assert_eq!(p.stage(), PathStage::Failed); + } + + #[test] + fn commit_pins_path_and_stops_escalating() { + let mut p = PathState::new(true, true, 0); + p.on_direct_addr(a("10.0.0.2:51820")); + let _ = p.advance(0); + p.committed(PathKind::Direct); + // Even past the direct window, a committed path does not escalate. + assert!(matches!(p.advance(DIRECT_MS + 100), PathAction::Idle)); + } + + #[test] + fn reset_reenters_from_direct() { + let mut p = PathState::new(true, true, 0); + p.on_direct_addr(a("10.0.0.2:51820")); + p.committed(PathKind::Direct); + p.reset(1000); + assert!(matches!(p.advance(1000), PathAction::Probe(x) if x == a("10.0.0.2:51820"))); + assert_eq!(p.stage(), PathStage::Direct); + } + + #[test] + fn candidate_during_direct_restamps_punch_window() { + let mut p = PathState::new(true, true, 0); + p.on_direct_addr(a("10.0.0.2:51820")); + + // At early time 1000 (still within DIRECT_MS=3000), a reflexive candidate arrives. + let reflexive = a("198.51.100.7:41000"); + p.on_peer_candidate(reflexive, 1000); + + // Transition to Punching happens immediately. + assert_eq!(p.stage(), PathStage::Punching); + + // At the candidate-arrival time, it probes the reflexive addr. + assert!(matches!(p.advance(1000), PathAction::Probe(x) if x == reflexive)); + + // The load-bearing assertion: PUNCH_MS window is measured from candidate arrival (1000), + // not from new()'s time (0). + // At now_ms = 1000 + PUNCH_MS - 1 = 5999, elapsed from 1000 is 4999 < PUNCH_MS. + // Still within the punch window, so it should probe and stay Punching. + assert!(matches!(p.advance(5999), PathAction::Probe(x) if x == reflexive)); + assert_eq!(p.stage(), PathStage::Punching); + + // At now_ms = 1000 + PUNCH_MS + 1 = 6001, elapsed from 1000 is 5001 >= PUNCH_MS. + // Window elapsed, escalate to Relaying. + assert!(matches!(p.advance(6001), PathAction::Relay)); + assert_eq!(p.stage(), PathStage::Relaying); + + // If the bug existed and the window was measured from new()'s time 0: + // At 5999, elapsed from 0 would be 5999 >= PUNCH_MS (5000), causing premature escalation. + // This test would fail on the assertion at 5999 expecting Punching. + } +} diff --git a/bin/yipd/src/peer_manager.rs b/bin/yipd/src/peer_manager.rs index 16d35fa..fbb2923 100644 --- a/bin/yipd/src/peer_manager.rs +++ b/bin/yipd/src/peer_manager.rs @@ -74,12 +74,15 @@ use std::collections::HashMap; use std::net::{Ipv6Addr, SocketAddr}; use yip_io::poll::{Dispatch, DispatchOut, EgressDatagram}; +use yip_rendezvous::{node_id, NodeId}; 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; +use crate::path::{PathAction, PathKind, PathStage, PathState}; +use crate::rendezvous::{RdvEvent, Rendezvous}; /// How long an in-flight initiator handshake waits before resending /// `[HandshakeInit]`. @@ -101,6 +104,16 @@ const HANDSHAKE_RETRY_MS: u64 = 1_000; /// is overcome by retransmission rather than wedging the peer permanently. const HANDSHAKE_TOTAL_MS: u64 = 90_000; +/// How often (ms) we re-emit `register(local_node_id)` to the rendezvous +/// server so it keeps our reflexive UDP binding fresh (only when a rendezvous +/// server is configured). +const REG_REFRESH_MS: u64 = 20_000; + +/// Minimum spacing (ms) between successive `lookup` datagrams for the same +/// peer while it is still searching for a candidate — debounces the +/// `NeedLookup` action so `tick`/`on_tun` do not spam the server every call. +const LOOKUP_INTERVAL_MS: u64 = 1_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 @@ -126,6 +139,12 @@ struct HandshakingState { /// 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, + /// The address this `Init` is being probed toward (the path SM's chosen + /// candidate: the configured endpoint for a Direct probe, a reflexive + /// candidate for a Punch probe, or the rendezvous server for a Relay + /// probe). Retransmits target this address (or are relay-wrapped when the + /// peer is `relay`). + target: SocketAddr, } /// One remote peer's handshake/session state. @@ -154,8 +173,11 @@ struct Peer { 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, + /// `PeerManager::handle_handshake_init`). `None` until a direct candidate + /// is known — a peer configured with no `endpoint` is reachable only via + /// rendezvous/relay, which Task 6 wires into this path; such a peer + /// cannot yet be routed to directly (see `on_tun`'s `Idle` branch). + endpoint: Option, state: PeerState, /// TUN packets buffered while no `Established` session exists yet. pending_tun: Vec>, @@ -166,6 +188,26 @@ struct Peer { /// 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>, + /// This peer's self-certifying rendezvous node id (`node_id(pubkey)`), + /// used to `lookup`/`relay` for it and to demux `RdvEvent`s back to it. + node: NodeId, + /// Per-peer connection path state machine (Direct → Punch → Relay). Only + /// consulted when a rendezvous server is configured; with no rendezvous a + /// peer's direct endpoint is probed exactly as in 2a and this SM is never + /// advanced. + path: PathState, + /// The committed path kind, set once a handshake completes. `None` until + /// the session is established. Drives relay egress re-wrap for `Relayed`. + path_kind: Option, + /// Whether this peer is currently reached via the relay (server) rather + /// than directly: every egress datagram for it (handshake and data plane) + /// is wrapped through `rendezvous.relay`. Set on a Relay-stage probe or on + /// admitting a relayed handshake; only mutated while the peer is + /// non-`Established` (anti-hijack). + relay: bool, + /// When we last emitted a `lookup` for this peer (debounces `NeedLookup`); + /// `None` until the first lookup is sent. + last_lookup_ms: Option, } /// Multi-peer router/demuxer + lazy in-loop handshake driver. @@ -190,6 +232,22 @@ pub struct PeerManager { /// `node_addr -> peers index`, populated at construction (addresses are /// derived from each peer's configured public key and never change). by_addr: HashMap, + /// `node_id -> peers index`, populated at construction. Used to demux + /// `RdvEvent`s (which are keyed by rendezvous node id) back to a peer. + by_node: HashMap, + /// The configured rendezvous+relay client, or `None` for a pure-2a + /// (direct-only) deployment. When `None`, `on_udp`/`on_tun`/`tick` never + /// consult the path SM and behave byte-identically to 2a. + rendezvous: Option>, + /// This node's own rendezvous node id (`node_id(local_pub)`), the `src` + /// for `register`/`relay`. + local_node_id: NodeId, + /// When we last emitted `register(local_node_id)` (see [`REG_REFRESH_MS`]). + last_register_ms: u64, + /// Whether we have registered at least once (so the first `tick` registers + /// promptly rather than waiting a full [`REG_REFRESH_MS`] interval — the + /// loop clock starts at 0). + registered_once: bool, /// Reused scratch for `on_udp`/`on_tun` return values. egress: Vec, /// Reused scratch for `tick`'s return value. @@ -212,12 +270,24 @@ impl PeerManager { local_pub: [u8; 32], peers_cfg: &[PeerConfig], mode: TunnelMode, + rendezvous: Option>, ) -> Self { + let has_rendezvous = rendezvous.is_some(); let mut peers = Vec::with_capacity(peers_cfg.len()); let mut by_addr = HashMap::with_capacity(peers_cfg.len()); + let mut by_node = 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); + let node = node_id(&p.public_key); + by_node.insert(node, i); + // A peer with a configured endpoint starts in the Direct stage with + // that endpoint seeded; a rendezvous-only peer starts in Punching + // (if a server is configured) or Failed. See `PathState::new`. + let mut path = PathState::new(p.endpoint.is_some(), has_rendezvous, 0); + if let Some(ep) = p.endpoint { + path.on_direct_addr(ep); + } peers.push(Peer { pubkey: p.public_key, addr, @@ -225,6 +295,11 @@ impl PeerManager { state: PeerState::Idle, pending_tun: Vec::new(), cached_resp: None, + node, + path, + path_kind: None, + relay: false, + last_lookup_ms: None, }); } Self { @@ -234,6 +309,11 @@ impl PeerManager { peers, by_tag: HashMap::new(), by_addr, + by_node, + rendezvous, + local_node_id: node_id(&local_pub), + last_register_ms: 0, + registered_once: false, egress: Vec::new(), tick_egress: Vec::new(), tun_scratch: Vec::new(), @@ -246,6 +326,365 @@ impl PeerManager { node_addr(&self.local_pub) } + // ── rendezvous / path helpers ───────────────────────────────────────── + + /// The configured rendezvous server address (only meaningful when a + /// rendezvous is configured; falls back to the unspecified address). + fn server_addr(&self) -> SocketAddr { + self.rendezvous + .as_ref() + .map(|r| r.server_addr()) + .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 0))) + } + + /// Map a path stage to the committed [`PathKind`] for a session that + /// completes while in that stage. `Relayed` peers are committed + /// explicitly (they never sit in the `Relaying` *stage* when admitted via + /// a relayed handshake), so `Relaying`/`Failed` fall back to `Punched`. + fn kind_for_stage(stage: PathStage) -> PathKind { + match stage { + PathStage::Direct => PathKind::Direct, + PathStage::Punching => PathKind::Punched, + // Lossy fallback: only reached for a *non-relayed* completion (a + // relayed completion commits `Relayed` explicitly, never routing + // here), so mapping these residual stages to `Punched` is safe. + PathStage::Relaying | PathStage::Failed => PathKind::Punched, + } + } + + /// Wrap a raw egress datagram destined for peer `idx` through the relay + /// (`rendezvous.relay(local, peer_node, raw)` → dst = server). Returns + /// `None` if no rendezvous is configured (should not happen for a peer + /// marked `relay`). + fn relay_wrap(&mut self, idx: usize, raw: Vec) -> Option { + let node = self.peers[idx].node; + let local = self.local_node_id; + self.rendezvous.as_mut().map(|r| r.relay(local, node, &raw)) + } + + /// Start a fresh initiator handshake toward `target` for peer `idx`, + /// returning the framed egress datagram to send (relay-wrapped when + /// `via_relay`). Transitions the peer to `Handshaking`. Returns `None` + /// (leaving the peer as it was) if the Noise step or the relay wrap fails. + /// + /// The caller is responsible for only invoking this on a peer that is not + /// already `Handshaking`/`Established`. + fn begin_handshake( + &mut self, + idx: usize, + target: SocketAddr, + via_relay: bool, + now_ms: u64, + ) -> Option { + let pubkey = self.peers[idx].pubkey; + let (hs, init_pkt) = match HandshakeState::start_initiator(&self.local_priv, &pubkey) { + Ok(t) => t, + Err(e) => { + eprintln!("peer_manager: failed to start handshake: {e}"); + return None; + } + }; + let dg = if via_relay { + self.relay_wrap(idx, init_pkt.clone())? + } else { + EgressDatagram { + fate: 0, + dst: target, + bytes: init_pkt.clone(), + } + }; + if via_relay { + self.peers[idx].relay = true; + } else { + // Direct/Punch probe: route this peer's traffic (and the + // `[HandshakeResp]` match in `handle_handshake_resp`) to `target`. + self.peers[idx].endpoint = Some(target); + } + self.peers[idx].state = PeerState::Handshaking(Box::new(HandshakingState { + hs, + started_ms: now_ms, + last_sent_ms: now_ms, + retries: 0, + init_pkt, + target, + })); + Some(dg) + } + + /// Emit a `lookup(peer_node)` for peer `idx`, debounced to at most one per + /// [`LOOKUP_INTERVAL_MS`]. Returns `None` if throttled or no rendezvous. + fn maybe_lookup(&mut self, idx: usize, now_ms: u64) -> Option { + let due = match self.peers[idx].last_lookup_ms { + None => true, + Some(t) => now_ms.saturating_sub(t) >= LOOKUP_INTERVAL_MS, + }; + if !due { + return None; + } + let node = self.peers[idx].node; + let dg = self.rendezvous.as_mut().map(|r| r.lookup(node))?; + self.peers[idx].last_lookup_ms = Some(now_ms); + Some(dg) + } + + /// Drive the path SM for a non-`Established`, non-`Handshaking` (i.e. + /// `Idle`) peer `idx` and act on the resulting [`PathAction`], pushing any + /// egress into `tick_egress`. Only called when a rendezvous is configured. + fn drive_path_idle(&mut self, idx: usize, now_ms: u64) { + match self.peers[idx].path.advance(now_ms) { + PathAction::Probe(addr) => { + if let Some(dg) = self.begin_handshake(idx, addr, false, now_ms) { + self.tick_egress.push(dg); + } + } + PathAction::Relay => { + let server = self.server_addr(); + if let Some(dg) = self.begin_handshake(idx, server, true, now_ms) { + self.tick_egress.push(dg); + } + } + PathAction::NeedLookup => { + if let Some(dg) = self.maybe_lookup(idx, now_ms) { + self.tick_egress.push(dg); + } + } + PathAction::Idle | PathAction::Failed => {} + } + } + + /// Demux a datagram that arrived from the rendezvous server: parse it into + /// an [`RdvEvent`] and drive the path SM / relay path accordingly. Every + /// mutation is guarded to affect only a non-`Established` peer + /// (anti-hijack): a live session's committed egress target is never + /// redirected by an unauthenticated server message. + fn on_rdv(&mut self, dg: &[u8], now_ms: u64) -> DispatchOut<'_> { + let ev = match self.rendezvous.as_ref() { + Some(r) => r.parse(dg), + None => return DispatchOut::None, + }; + match ev { + RdvEvent::PeerCandidate { node, addr } => { + if let Some(&idx) = self.by_node.get(&node) { + if !matches!(self.peers[idx].state, PeerState::Established(_)) { + self.peers[idx].path.on_peer_candidate(addr, now_ms); + } + } + DispatchOut::None + } + RdvEvent::PunchTo { node, addr } => { + if let Some(&idx) = self.by_node.get(&node) { + if !matches!(self.peers[idx].state, PeerState::Established(_)) { + self.peers[idx].path.on_peer_candidate(addr, now_ms); + // Open our own binding toward `addr` immediately so the + // two NATs punch simultaneously — but only if we are not + // already probing (keep the in-flight ephemeral). + if matches!(self.peers[idx].state, PeerState::Idle) { + if let Some(dg) = self.begin_handshake(idx, addr, false, now_ms) { + self.egress.clear(); + self.egress.push(dg); + return DispatchOut::Udp(&self.egress); + } + } + } + } + DispatchOut::None + } + RdvEvent::Relayed { src, payload } => self.on_relayed(src, &payload, now_ms), + RdvEvent::NotFound { .. } | RdvEvent::Ignored => DispatchOut::None, + } + } + + /// Process a peer datagram delivered *through the relay* (`RdvEvent::Relayed`): + /// it is a handshake or data-plane packet from `src_node`, and any egress it + /// produces must go back out through the relay (dst = server). Mirrors the + /// direct `on_udp` demux but relay-wraps replies and commits `Relayed`. + fn on_relayed(&mut self, src_node: NodeId, payload: &[u8], now_ms: u64) -> DispatchOut<'_> { + if payload.is_empty() { + return DispatchOut::None; + } + let Some(&idx) = self.by_node.get(&src_node) else { + return DispatchOut::None; + }; + // Mark this peer as relay-reached before producing any egress — but only + // while it is not Established (anti-hijack: never re-route a live + // session onto the relay from an unauthenticated server message). + if !matches!(self.peers[idx].state, PeerState::Established(_)) { + self.peers[idx].relay = true; + } + + if payload[0] == PacketType::HandshakeInit as u8 { + self.relayed_handshake_init(idx, payload, now_ms) + } else if payload[0] == PacketType::HandshakeResp as u8 { + self.relayed_handshake_resp(idx, payload, now_ms) + } else { + self.relayed_data(idx, payload, now_ms) + } + } + + /// Relay-path counterpart of [`handle_handshake_init`]: admit a relayed + /// `[HandshakeInit]` from peer `idx`, reply and drain via the relay, and + /// commit `PathKind::Relayed`. + fn relayed_handshake_init(&mut self, idx: usize, 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: relayed start_responder failed: {e}"); + return DispatchOut::None; + } + }; + if remote_static != self.peers[idx].pubkey { + return DispatchOut::None; + } + + match &self.peers[idx].state { + PeerState::Established(_) => match self.peers[idx].cached_resp.clone() { + Some(resp) => { + self.egress.clear(); + if let Some(d) = self.relay_wrap(idx, resp) { + self.egress.push(d); + } + DispatchOut::Udp(&self.egress) + } + None => DispatchOut::None, + }, + PeerState::Handshaking(_) if self.local_pub < self.peers[idx].pubkey => { + DispatchOut::None + } + PeerState::Idle | PeerState::Handshaking(_) => { + let conn_tag = conn_tag_from_keys(&established.auth_key, &established.hp_key); + // A relay peer's egress is always re-wrapped, so the DataPlane's + // stamped `dst` is unused: seed it with the server address. + let placeholder = self.server_addr(); + let mut dp = Box::new(DataPlane::new( + established, + conn_tag, + self.mode, + placeholder, + )); + + self.peers[idx].cached_resp = Some(resp_pkt.clone()); + self.peers[idx].relay = true; + self.peers[idx].path.committed(PathKind::Relayed); + self.peers[idx].path_kind = Some(PathKind::Relayed); + self.by_tag.insert(dp.conn_tag(), idx); + + self.egress.clear(); + if let Some(d) = self.relay_wrap(idx, resp_pkt) { + self.egress.push(d); + } + let pending = std::mem::take(&mut self.peers[idx].pending_tun); + let mut owned: Vec> = Vec::new(); + for inner in &pending { + owned.extend( + dp.on_tun_packet(inner, now_ms) + .iter() + .map(|d| d.bytes.clone()), + ); + } + self.peers[idx].state = PeerState::Established(dp); + for b in owned { + if let Some(d) = self.relay_wrap(idx, b) { + self.egress.push(d); + } + } + DispatchOut::Udp(&self.egress) + } + } + } + + /// Relay-path counterpart of [`handle_handshake_resp`]: complete a relayed + /// `[HandshakeResp]` from peer `idx` and commit `PathKind::Relayed`. + fn relayed_handshake_resp(&mut self, idx: usize, dg: &[u8], now_ms: u64) -> DispatchOut<'_> { + if !matches!(self.peers[idx].state, PeerState::Handshaking(_)) { + return DispatchOut::None; + } + let old_state = std::mem::replace(&mut self.peers[idx].state, PeerState::Idle); + let PeerState::Handshaking(handshaking) = old_state else { + unreachable!("just matched Handshaking above"); + }; + match handshaking.hs.read_response(dg) { + Ok(established) => { + let conn_tag = conn_tag_from_keys(&established.auth_key, &established.hp_key); + let placeholder = self.server_addr(); + let mut dp = Box::new(DataPlane::new( + established, + conn_tag, + self.mode, + placeholder, + )); + self.by_tag.insert(dp.conn_tag(), idx); + self.peers[idx].relay = true; + self.peers[idx].path.committed(PathKind::Relayed); + self.peers[idx].path_kind = Some(PathKind::Relayed); + + self.egress.clear(); + let pending = std::mem::take(&mut self.peers[idx].pending_tun); + let mut owned: Vec> = Vec::new(); + for inner in &pending { + owned.extend( + dp.on_tun_packet(inner, now_ms) + .iter() + .map(|d| d.bytes.clone()), + ); + } + self.peers[idx].state = PeerState::Established(dp); + for b in owned { + if let Some(d) = self.relay_wrap(idx, b) { + self.egress.push(d); + } + } + if self.egress.is_empty() { + DispatchOut::None + } else { + DispatchOut::Udp(&self.egress) + } + } + Err(e) => { + eprintln!("peer_manager: relayed read_response failed: {e}"); + DispatchOut::None + } + } + } + + /// Relay-path counterpart of the `Data`/`Control` demux: dispatch a relayed + /// data-plane datagram to peer `idx`'s `DataPlane` and relay-wrap any UDP + /// egress it produces (TUN writes still go to the local device). + fn relayed_data(&mut self, idx: usize, dg: &[u8], now_ms: u64) -> DispatchOut<'_> { + let (tun, udp): (Option>, Vec>) = { + let PeerState::Established(dp) = &mut self.peers[idx].state else { + return DispatchOut::None; + }; + match dp.on_udp_datagram(dg, now_ms) { + Outcome::None => (None, Vec::new()), + Outcome::TunWrite(buf) => (Some(buf.to_vec()), Vec::new()), + Outcome::Send(pkts) => (None, pkts.iter().map(|d| d.bytes.clone()).collect()), + Outcome::TunWriteThenSend(buf, pkts) => ( + Some(buf.to_vec()), + pkts.iter().map(|d| d.bytes.clone()).collect(), + ), + } + }; + self.egress.clear(); + for b in udp { + if let Some(d) = self.relay_wrap(idx, b) { + self.egress.push(d); + } + } + match (tun, self.egress.is_empty()) { + (Some(t), true) => { + self.tun_scratch = t; + DispatchOut::Tun(&self.tun_scratch) + } + (Some(t), false) => { + self.tun_scratch = t; + DispatchOut::Both(&self.tun_scratch, &self.egress) + } + (None, false) => DispatchOut::Udp(&self.egress), + (None, true) => DispatchOut::None, + } + } + /// 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. @@ -304,7 +743,7 @@ impl PeerManager { } self.peers .iter() - .position(|p| p.endpoint == src && matches!(p.state, PeerState::Established(_))) + .position(|p| p.endpoint == Some(src) && matches!(p.state, PeerState::Established(_))) } /// Dispatch a `Data`/`Control` datagram to peer `idx`'s `DataPlane` and @@ -459,9 +898,21 @@ impl PeerManager { 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].endpoint = Some(src); // learn the observed endpoint self.peers[idx].cached_resp = Some(resp_pkt.clone()); self.by_tag.insert(dp.conn_tag(), idx); + // Commit the path we completed over. `src` is a direct address + // (this arm is only reached for non-relayed inits — relayed + // inits go through `relayed_handshake_init`), so the kind is + // Direct (stage Direct) or Punched (stage Punching). + let kind = Self::kind_for_stage(self.peers[idx].path.stage()); + self.peers[idx].path.committed(kind); + self.peers[idx].path_kind = Some(kind); + // A non-relayed init completed: this is a direct/punched + // session. Clear any stale `relay` flag left by an earlier + // escalation whose relayed attempt this direct/punch completion + // raced (else `on_tun`/`tick` would relay-wrap direct egress). + self.peers[idx].relay = false; self.egress.clear(); self.egress.push(EgressDatagram { @@ -493,7 +944,7 @@ impl PeerManager { let Some(idx) = self .peers .iter() - .position(|p| p.endpoint == src && matches!(p.state, PeerState::Handshaking(_))) + .position(|p| p.endpoint == Some(src) && matches!(p.state, PeerState::Handshaking(_))) else { return DispatchOut::None; }; @@ -506,13 +957,21 @@ impl PeerManager { 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, - )); + // `idx` was matched above via `p.endpoint == Some(src)`, so `src` + // is exactly this peer's endpoint. + let mut dp = Box::new(DataPlane::new(established, conn_tag, self.mode, src)); self.by_tag.insert(dp.conn_tag(), idx); + // `src` == this peer's `endpoint` (matched above). Commit the + // path stage we completed over (Direct or Punched); a relayed + // resp is handled by `relayed_handshake_resp` instead. + self.peers[idx].endpoint = Some(src); + let kind = Self::kind_for_stage(self.peers[idx].path.stage()); + self.peers[idx].path.committed(kind); + self.peers[idx].path_kind = Some(kind); + // Non-relayed resp completed a direct/punched session: clear any + // stale `relay` flag from a raced escalation (see the mirror in + // `handle_handshake_init`). + self.peers[idx].relay = false; self.egress.clear(); let pending = std::mem::take(&mut self.peers[idx].pending_tun); @@ -544,6 +1003,14 @@ impl Dispatch for PeerManager { if dg.is_empty() { return DispatchOut::None; } + // Rendezvous-server demux: a datagram from the configured server is a + // control/relay message, not peer traffic. Skipped entirely when no + // rendezvous is configured (pure-2a: no server-addr check at all). + if let Some(server) = self.rendezvous.as_ref().map(|r| r.server_addr()) { + if src == server { + return self.on_rdv(dg, now_ms); + } + } if dg[0] == PacketType::HandshakeInit as u8 { self.handle_handshake_init(src, dg, now_ms) } else if dg[0] == PacketType::HandshakeResp as u8 { @@ -566,10 +1033,33 @@ impl Dispatch for PeerManager { // 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"); + // A relay-reached peer's data-plane egress must be re-wrapped + // through the server (dst = server); copy the bytes out first (the + // DataPlane borrows `self.peers[idx]`) then wrap. A direct/punched + // peer's datagrams already carry the correct `dst` — return them + // borrowed, byte-identical to 2a. + if !self.peers[idx].relay { + let PeerState::Established(dp) = &mut self.peers[idx].state else { + unreachable!("just matched Established above"); + }; + return dp.on_tun_packet(inner, now_ms); + } + let owned: Vec> = { + let PeerState::Established(dp) = &mut self.peers[idx].state else { + unreachable!("just matched Established above"); + }; + dp.on_tun_packet(inner, now_ms) + .iter() + .map(|d| d.bytes.clone()) + .collect() }; - return dp.on_tun_packet(inner, now_ms); + self.egress.clear(); + for b in owned { + if let Some(d) = self.relay_wrap(idx, b) { + self.egress.push(d); + } + } + return &self.egress; } if matches!(self.peers[idx].state, PeerState::Handshaking(_)) { @@ -577,42 +1067,172 @@ impl Dispatch for PeerManager { return &[]; } - // Idle: buffer this packet and kick off a lazy handshake. + // Idle: buffer this packet and decide how to bring the peer up. 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; + // With no rendezvous configured, behave exactly as 2a: probe the + // configured endpoint if there is one (else the peer is unreachable and + // the packet stays buffered). With a rendezvous configured, ask the + // path SM which candidate/action to take. + let action = if self.rendezvous.is_some() { + self.peers[idx].path.advance(now_ms) + } else { + match self.peers[idx].endpoint { + Some(ep) => PathAction::Probe(ep), + None => PathAction::Idle, + } + }; + let dg = match action { + PathAction::Probe(addr) => self.begin_handshake(idx, addr, false, now_ms), + PathAction::Relay => { + let server = self.server_addr(); + self.begin_handshake(idx, server, true, now_ms) + } + PathAction::NeedLookup => self.maybe_lookup(idx, now_ms), + PathAction::Idle | PathAction::Failed => None, + }; + match dg { + Some(d) => { 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.push(d); &self.egress } - Err(e) => { - eprintln!("peer_manager: failed to start handshake: {e}"); - &[] - } + None => &[], } } fn tick(&mut self, now_ms: u64) -> Option<&[EgressDatagram]> { self.tick_egress.clear(); + + // ── registration refresh ────────────────────────────────────────── + // Keep our reflexive binding fresh on the server so peers can find us. + if self.rendezvous.is_some() + && (!self.registered_once + || now_ms.saturating_sub(self.last_register_ms) >= REG_REFRESH_MS) + { + let node = self.local_node_id; + if let Some(r) = self.rendezvous.as_mut() { + self.tick_egress.push(r.register(node)); + } + self.last_register_ms = now_ms; + self.registered_once = true; + } + for i in 0..self.peers.len() { - let endpoint = self.peers[i].endpoint; + // ── proactive escalation of an in-flight direct/punch handshake ── + // With a rendezvous configured, keep driving the path SM while a + // *non-relay* handshake is in flight (pure-2a peers set no + // rendezvous and never enter this block, so they cannot regress). + // The probed candidate's window may have elapsed; escalate NOW + // rather than retransmitting a doomed Init for the full + // HANDSHAKE_TOTAL_MS. Escalation supersedes the 2a retransmit arm + // below — we `continue`, so a peer is never both retransmitted (old + // target) AND escalated in the same tick. + if self.rendezvous.is_some() + && !self.peers[i].relay + && matches!(self.peers[i].state, PeerState::Handshaking(_)) + { + let target = match &self.peers[i].state { + PeerState::Handshaking(h) => h.target, + _ => unreachable!("matched Handshaking above"), + }; + match self.peers[i].path.advance(now_ms) { + PathAction::Relay => { + // Abandon the in-flight direct/punch handshake (drop its + // ephemeral) and begin a relay handshake. `pending_tun` + // is left intact — it drains when the relay session + // completes (strictly better than the 90s-then-clear + // give-up path). + self.peers[i].state = PeerState::Idle; + // Clear the stale direct/punch `endpoint` (the abandoned + // attempt's candidate `C`): a relayed peer routes egress + // via the `relay` flag through `rendezvous.relay`, NOT + // via `endpoint`, and the relay handshake completes + // through the `RdvEvent::Relayed` -> + // `relayed_handshake_resp` path, which does not use + // `endpoint` matching at all. Without this clear, a + // late-arriving direct `[HandshakeResp]` from `C` for the + // abandoned ephemeral (very plausible on a lossy/ + // high-latency link — a punch reply just past the + // PUNCH_MS window) would still match this peer in + // `handle_handshake_resp` (`p.endpoint == Some(src) && + // Handshaking`) and get fed into the *new* relay + // ephemeral's `read_response`, which fails + // cryptographically and silently discards the fresh + // relay attempt (reverting to `Idle` and re-escalating + // forever, since `PathStage` only moves forward). With + // `endpoint` cleared the stray reply matches no peer and + // is dropped harmlessly instead. (Preferring a late punch + // reply over the already-committed relay attempt would be + // a nicer recovery, but is out of 2b scope.) + self.peers[i].endpoint = None; + let server = self.server_addr(); + if let Some(dg) = self.begin_handshake(i, server, true, now_ms) { + self.tick_egress.push(dg); + } + continue; + } + PathAction::Probe(addr) if addr != target => { + // The SM chose a *different* candidate: re-target by + // abandoning the current attempt and probing `addr`. + self.peers[i].state = PeerState::Idle; + if let Some(dg) = self.begin_handshake(i, addr, false, now_ms) { + self.tick_egress.push(dg); + } + continue; + } + PathAction::NeedLookup => { + // The path SM escalated into (or is still in) the punch + // stage but has no reflexive candidate yet — e.g. a peer + // configured with BOTH a direct endpoint and a + // rendezvous: it starts `Handshaking` on the direct + // endpoint (via `on_tun`'s Idle branch, which never + // touches the path SM again once `Handshaking`), so + // without this arm the escalation-only `advance` call + // above would see `Direct -> Punching` and return + // `NeedLookup` here forever, and this match's old + // catch-all treated that as "do nothing" — no `Lookup` + // is ever sent, no reflexive candidate is learned, and + // the peer can never punch (it just rides out + // `HANDSHAKE_TOTAL_MS` on the doomed direct `Init` and + // eventually gives up). Emit the debounced lookup, same + // as `drive_path_idle` does for an `Idle` peer. + // + // This does NOT abandon the in-flight direct `Init` — + // no state mutation happens here, so the retransmit arm + // below still fires this tick if due, keeping the + // direct attempt alive alongside the new lookup. Once a + // candidate arrives (`on_rdv` -> `on_peer_candidate`), a + // later tick's `advance` returns `Probe(candidate)`, + // which the `addr != target` arm above re-targets to. + if let Some(dg) = self.maybe_lookup(i, now_ms) { + self.tick_egress.push(dg); + } + } + // Same target / Idle / Failed: leave the in-flight + // handshake alone; the retransmit arm below handles it (do + // not double-send). + _ => {} + } + } + + let relay = self.peers[i].relay; 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()); + if relay { + // Relay-reached peer: re-wrap each datagram through + // the server. Copy bytes out (borrow ends) then wrap. + let owned: Vec> = + pkts.iter().map(|d| d.bytes.clone()).collect(); + for b in owned { + if let Some(d) = self.relay_wrap(i, b) { + self.tick_egress.push(d); + } + } + } else { + self.tick_egress.extend(pkts.iter().cloned()); + } } PeerState::Established(dp) } @@ -628,14 +1248,22 @@ impl Dispatch for PeerManager { } else { // Retransmit the SAME init (same ephemeral) so the // responder's cached reply stays valid — see - // HANDSHAKE_TOTAL_MS. + // HANDSHAKE_TOTAL_MS. Relay-reached peers re-wrap the + // retransmit through the server; direct/punched peers + // target the probed `target` address. 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(), - }); + if relay { + if let Some(d) = self.relay_wrap(i, handshaking.init_pkt.clone()) { + self.tick_egress.push(d); + } + } else { + self.tick_egress.push(EgressDatagram { + fate: 0, + dst: handshaking.target, + bytes: handshaking.init_pkt.clone(), + }); + } PeerState::Handshaking(handshaking) } } @@ -643,6 +1271,21 @@ impl Dispatch for PeerManager { }; self.peers[i].state = new_state; } + + // ── proactive path advancement ──────────────────────────────────── + // Only with a rendezvous configured (pure-2a `tick` is byte-identical + // to before this block). For each Idle peer, drive the path SM: probe a + // learned candidate, request a lookup, or escalate to relay — this is + // what brings up a rendezvous-only (endpoint:None) peer, and keeps + // hole-punching proactive rather than waiting on TUN traffic. + if self.rendezvous.is_some() { + for i in 0..self.peers.len() { + if matches!(self.peers[i].state, PeerState::Idle) { + self.drive_path_idle(i, now_ms); + } + } + } + if self.tick_egress.is_empty() { None } else { @@ -676,7 +1319,7 @@ mod tests { fn peer_cfg(tag_byte: u8, endpoint: &str) -> PeerConfig { PeerConfig { public_key: [tag_byte; 32], - endpoint: endpoint.parse().unwrap(), + endpoint: Some(endpoint.parse().unwrap()), } } @@ -714,6 +1357,7 @@ mod tests { [8u8; 32], &[peer_a.clone(), peer_b.clone()], TunnelMode::L3Tun, + None, ); let addr_a = node_addr(&peer_a.public_key); @@ -733,6 +1377,7 @@ mod tests { [8u8; 32], &[peer_a.clone(), peer_b.clone()], TunnelMode::L3Tun, + None, ); let addr_b = node_addr(&peer_b.public_key); @@ -749,7 +1394,7 @@ mod tests { // 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); + let pm = PeerManager::new([9u8; 32], [8u8; 32], &[peer_a], TunnelMode::L3Tun, None); // A bare IPv4 packet: first nibble is 4, not 6. let inner = vec![0x45u8; 40]; @@ -760,7 +1405,13 @@ mod tests { 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 pm = PeerManager::new( + [9u8; 32], + [8u8; 32], + &[peer_a, peer_b], + TunnelMode::L3Tun, + None, + ); let inner = vec![0x45u8; 40]; // IPv4, matches no by_addr entry assert_eq!(pm.route_tun_index(&inner), None); @@ -769,7 +1420,7 @@ mod tests { #[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); + let pm = PeerManager::new([9u8; 32], [8u8; 32], &[peer_a], TunnelMode::L2Tap, None); // An arbitrary Ethernet-looking frame; L2 mode ignores its contents // entirely and forwards to the sole configured peer. @@ -786,6 +1437,7 @@ mod tests { [8u8; 32], &[peer_a.clone(), peer_b.clone()], TunnelMode::L3Tun, + None, ); // by_addr maps each peer's node_addr to its index. @@ -798,7 +1450,7 @@ mod tests { const FAKE_TAG: u64 = 0xAAAA_BBBB_CCCC_DDDD; pm.peers[1].state = PeerState::Established(Box::new(fake_established_dataplane( FAKE_TAG, - peer_b.endpoint, + peer_b.endpoint.unwrap(), ))); pm.by_tag.insert(FAKE_TAG, 1); @@ -820,7 +1472,10 @@ mod tests { 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)); + assert_eq!( + pm.route_data(peer_b.endpoint.unwrap(), &untagged_dg), + Some(1) + ); } #[test] @@ -835,6 +1490,7 @@ mod tests { local_kp.public, &[peer_a], TunnelMode::L3Tun, + None, ); // A valid HandshakeInit from a real, but unconfigured, key. @@ -853,7 +1509,7 @@ mod tests { #[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); + let pm = PeerManager::new([1u8; 32], local_pub, &[], TunnelMode::L3Tun, None); assert_eq!(pm.local_addr(), node_addr(&local_pub)); } @@ -898,14 +1554,16 @@ mod tests { let ep_b: SocketAddr = "10.0.0.2:2000".parse().unwrap(); let cfg_b = PeerConfig { public_key: kp_b.public, - endpoint: ep_b, + endpoint: Some(ep_b), }; let cfg_a = PeerConfig { public_key: kp_a.public, - endpoint: ep_a, + endpoint: Some(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); + let mut pm_a = + PeerManager::new(kp_a.private, kp_a.public, &[cfg_b], TunnelMode::L3Tun, None); + let mut pm_b = + PeerManager::new(kp_b.private, kp_b.public, &[cfg_a], TunnelMode::L3Tun, None); // Each side sends a HandshakeInit (triggered by its own outbound TUN // traffic) before hearing from the other — the glare. @@ -954,9 +1612,10 @@ mod tests { let ep_i: SocketAddr = "10.0.0.7:7000".parse().unwrap(); let cfg_i = PeerConfig { public_key: kp_i.public, - endpoint: ep_i, + endpoint: Some(ep_i), }; - let mut pm_r = PeerManager::new(kp_r.private, kp_r.public, &[cfg_i], TunnelMode::L3Tun); + let mut pm_r = + PeerManager::new(kp_r.private, kp_r.public, &[cfg_i], TunnelMode::L3Tun, None); // 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(); @@ -988,13 +1647,14 @@ mod tests { let kp_local = generate_keypair(); let peer = PeerConfig { public_key: [7u8; 32], - endpoint: "10.0.0.9:9000".parse().unwrap(), + endpoint: Some("10.0.0.9:9000".parse().unwrap()), }; let mut pm = PeerManager::new( kp_local.private, kp_local.public, &[peer], TunnelMode::L3Tun, + None, ); // Kick off a lazy handshake with an outbound TUN packet. @@ -1045,13 +1705,14 @@ mod tests { let kp_local = generate_keypair(); let peer = PeerConfig { public_key: [7u8; 32], - endpoint: "10.0.0.9:9000".parse().unwrap(), + endpoint: Some("10.0.0.9:9000".parse().unwrap()), }; let mut pm = PeerManager::new( kp_local.private, kp_local.public, &[peer], TunnelMode::L3Tun, + None, ); // Stream far more packets than the cap while the peer is Handshaking. @@ -1064,4 +1725,615 @@ mod tests { "pending buffer must stay capped at MAX_PENDING_TUN" ); } + + // ── rendezvous wiring (mock Rendezvous) ─────────────────────────────── + + /// A mock `Rendezvous` that records the messages it is asked to send (so a + /// test can assert on them) and parses injected server datagrams the same + /// way `ConfiguredServerRendezvous` does. `parse` reuses the real decoder, + /// so a test injects an event by `encode`-ing a `Message` and feeding it to + /// `on_udp(server, ..)`. + struct MockRdv { + server: SocketAddr, + sent: std::rc::Rc>>, + } + + impl MockRdv { + fn to_server(&self, msg: yip_rendezvous::Message) -> EgressDatagram { + self.sent.borrow_mut().push(msg.clone()); + let mut bytes = Vec::new(); + yip_rendezvous::encode(&msg, &mut bytes); + EgressDatagram { + fate: 0, + dst: self.server, + bytes, + } + } + } + + impl Rendezvous for MockRdv { + fn register(&mut self, node: NodeId) -> EgressDatagram { + self.to_server(yip_rendezvous::Message::Register { node }) + } + fn lookup(&mut self, node: NodeId) -> EgressDatagram { + self.to_server(yip_rendezvous::Message::Lookup { node }) + } + fn relay(&mut self, src: NodeId, dst: NodeId, payload: &[u8]) -> EgressDatagram { + self.to_server(yip_rendezvous::Message::RelaySend { + src, + dst, + payload: payload.to_vec(), + }) + } + fn parse(&self, dg: &[u8]) -> RdvEvent { + match yip_rendezvous::decode(dg) { + Some(yip_rendezvous::Message::PeerInfo { node, reflexive }) => { + RdvEvent::PeerCandidate { + node, + addr: reflexive, + } + } + Some(yip_rendezvous::Message::PunchHint { node, reflexive }) => RdvEvent::PunchTo { + node, + addr: reflexive, + }, + Some(yip_rendezvous::Message::RelayDeliver { src, payload }) => { + RdvEvent::Relayed { src, payload } + } + Some(yip_rendezvous::Message::NotFound { node }) => RdvEvent::NotFound { node }, + _ => RdvEvent::Ignored, + } + } + fn server_addr(&self) -> SocketAddr { + self.server + } + } + + fn mock_server() -> SocketAddr { + "203.0.113.1:51821".parse().unwrap() + } + + /// Build a `PeerManager` with a `MockRdv` rendezvous, returning the manager + /// and a shared handle to the messages the mock is asked to send. + fn pm_with_mock_rdv( + local: &yip_crypto::Keypair, + peers: &[PeerConfig], + ) -> ( + PeerManager, + std::rc::Rc>>, + ) { + let sent = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let rdv: Box = Box::new(MockRdv { + server: mock_server(), + sent: sent.clone(), + }); + let pm = PeerManager::new( + local.private, + local.public, + peers, + TunnelMode::L3Tun, + Some(rdv), + ); + (pm, sent) + } + + /// (a) A rendezvous-only peer (endpoint `None`) with a rendezvous + /// configured emits a `Lookup` when TUN traffic first needs it. + #[test] + fn rendezvous_only_peer_emits_lookup_on_tun_traffic() { + let local = generate_keypair(); + let peer_kp = generate_keypair(); + let peer = PeerConfig { + public_key: peer_kp.public, + endpoint: None, + }; + let (mut pm, sent) = pm_with_mock_rdv(&local, &[peer]); + + let out = pm.on_tun(&dummy_tun_pkt(), 0).to_vec(); + assert_eq!(out.len(), 1, "one lookup datagram is emitted"); + assert_eq!(out[0].dst, mock_server(), "lookup targets the server"); + assert_eq!( + yip_rendezvous::decode(&out[0].bytes), + Some(yip_rendezvous::Message::Lookup { + node: node_id(&peer_kp.public), + }), + "the datagram is a Lookup for the peer's node id" + ); + assert!( + sent.borrow() + .iter() + .any(|m| matches!(m, yip_rendezvous::Message::Lookup { .. })), + "the mock recorded a Lookup" + ); + // Still Idle (searching), packet buffered. + assert!(matches!(pm.peers[0].state, PeerState::Idle)); + assert_eq!(pm.peers[0].pending_tun.len(), 1); + } + + /// (b) Feeding a `PeerCandidate` and then ticking produces a handshake + /// `Init` whose `dst` is the candidate address. + #[test] + fn peer_candidate_then_tick_probes_candidate_with_init() { + let local = generate_keypair(); + let peer_kp = generate_keypair(); + let peer = PeerConfig { + public_key: peer_kp.public, + endpoint: None, + }; + let (mut pm, _sent) = pm_with_mock_rdv(&local, &[peer]); + + // Inject a PeerInfo (→ PeerCandidate) from the server for this peer. + let candidate: SocketAddr = "198.51.100.7:41000".parse().unwrap(); + let mut buf = Vec::new(); + yip_rendezvous::encode( + &yip_rendezvous::Message::PeerInfo { + node: node_id(&peer_kp.public), + reflexive: candidate, + }, + &mut buf, + ); + // Arrives from the server address → routed to on_rdv → sets candidate. + assert!(matches!( + pm.on_udp(mock_server(), &buf, 0), + DispatchOut::None + )); + assert_eq!(pm.peers[0].path.stage(), PathStage::Punching); + + // Tick drives the path SM: probe the candidate with a fresh Init. + // (Filter by dst — a `Register` control datagram to the server shares + // the leading byte 0 with `HandshakeInit`, but goes to the server.) + let out = pm.tick(1).map(<[_]>::to_vec).unwrap_or_default(); + let init = out + .iter() + .find(|d| d.dst == candidate) + .expect("a handshake Init is emitted toward the candidate"); + assert_eq!( + init.bytes[0], + PacketType::HandshakeInit as u8, + "the datagram to the candidate is a handshake Init" + ); + assert!(matches!(pm.peers[0].state, PeerState::Handshaking(_))); + } + + /// (c) With NO rendezvous configured, a peer with a direct endpoint behaves + /// exactly as 2a: the first TUN packet emits an `Init` to the configured + /// endpoint (no server-addr demux, no path-SM escalation). + #[test] + fn no_rendezvous_direct_endpoint_is_pure_2a() { + let local = generate_keypair(); + let endpoint: SocketAddr = "10.0.0.2:51820".parse().unwrap(); + let peer = PeerConfig { + public_key: [7u8; 32], + endpoint: Some(endpoint), + }; + let mut pm = PeerManager::new( + local.private, + local.public, + &[peer], + TunnelMode::L3Tun, + None, + ); + + let out = pm.on_tun(&dummy_tun_pkt(), 0).to_vec(); + assert_eq!(out.len(), 1); + assert_eq!(out[0].dst, endpoint, "Init targets the configured endpoint"); + assert_eq!(out[0].bytes[0], PacketType::HandshakeInit as u8); + assert!(matches!(pm.peers[0].state, PeerState::Handshaking(_))); + // No relay flag, no path commitment yet. + assert!(!pm.peers[0].relay); + assert_eq!(pm.peers[0].path_kind, None); + } + + /// (d) Anti-hijack: an `Established` peer that receives a `PeerCandidate` + /// or `PunchTo` from the (unauthenticated) server does NOT change its + /// egress target — no path mutation, no fresh probe. + #[test] + fn anti_hijack_established_peer_ignores_rendezvous_candidates() { + let local = generate_keypair(); + let peer_kp = generate_keypair(); + let endpoint: SocketAddr = "10.0.0.2:51820".parse().unwrap(); + let peer = PeerConfig { + public_key: peer_kp.public, + endpoint: Some(endpoint), + }; + let (mut pm, _sent) = pm_with_mock_rdv(&local, &[peer]); + + // Splice in a live Established session reaching `endpoint`. + const TAG: u64 = 0x0102_0304_0506_0708; + pm.peers[0].state = + PeerState::Established(Box::new(fake_established_dataplane(TAG, endpoint))); + pm.by_tag.insert(TAG, 0); + pm.peers[0].path_kind = Some(PathKind::Direct); + + let hijack: SocketAddr = "198.51.100.9:40000".parse().unwrap(); + + // A PeerCandidate pointing at a different address must be ignored. + let mut buf = Vec::new(); + yip_rendezvous::encode( + &yip_rendezvous::Message::PeerInfo { + node: node_id(&peer_kp.public), + reflexive: hijack, + }, + &mut buf, + ); + assert!(matches!( + pm.on_udp(mock_server(), &buf, 0), + DispatchOut::None + )); + + // And a PunchTo must not start a competing probe. + buf.clear(); + yip_rendezvous::encode( + &yip_rendezvous::Message::PunchHint { + node: node_id(&peer_kp.public), + reflexive: hijack, + }, + &mut buf, + ); + assert!(matches!( + pm.on_udp(mock_server(), &buf, 0), + DispatchOut::None + )); + + // Egress target unchanged: still Established, endpoint still `endpoint`, + // relay never enabled, and the path never left Direct (on_peer_candidate + // was never applied — it would have moved the stage to Punching). + assert!(matches!(pm.peers[0].state, PeerState::Established(_))); + assert_eq!(pm.peers[0].endpoint, Some(endpoint)); + assert!(!pm.peers[0].relay); + assert_eq!(pm.peers[0].path.stage(), PathStage::Direct); + } + + /// (e) Escalation regression (the Critical fix): a rendezvous-only peer + /// driven to `Handshaking` on a punch candidate must escalate to the relay + /// at ~`PUNCH_MS` — NOT keep retransmitting the doomed punch `Init` for the + /// full `HANDSHAKE_TOTAL_MS` (90s). Pre-fix `tick` advanced the path SM only + /// for `Idle` peers, so a `Handshaking` peer froze; this test asserts a + /// relay-wrapped `Init` (a `RelaySend` to the server) is emitted just past + /// the punch window, and FAILS against the pre-fix code. + #[test] + fn punch_handshake_escalates_to_relay_at_punch_window_not_90s() { + use crate::path::PUNCH_MS; + let local = generate_keypair(); + let peer_kp = generate_keypair(); + let peer = PeerConfig { + public_key: peer_kp.public, + endpoint: None, // rendezvous-only: starts in the Punching stage + }; + let (mut pm, _sent) = pm_with_mock_rdv(&local, &[peer]); + + // Learn a reflexive candidate for the peer (arrives from the server). + let candidate: SocketAddr = "198.51.100.7:41000".parse().unwrap(); + let mut buf = Vec::new(); + yip_rendezvous::encode( + &yip_rendezvous::Message::PeerInfo { + node: node_id(&peer_kp.public), + reflexive: candidate, + }, + &mut buf, + ); + assert!(matches!( + pm.on_udp(mock_server(), &buf, 0), + DispatchOut::None + )); + + // Tick once inside the punch window: the SM probes the candidate, so the + // peer transitions to Handshaking on a punch probe (dst = candidate). + let out = pm.tick(1).map(<[_]>::to_vec).unwrap_or_default(); + assert!( + out.iter().any(|d| d.dst == candidate + && d.bytes.first() == Some(&(PacketType::HandshakeInit as u8))), + "punch Init is probed toward the candidate" + ); + assert!(matches!(pm.peers[0].state, PeerState::Handshaking(_))); + assert!(!pm.peers[0].relay); + + // Tick just past the punch window (measured from the candidate/stage + // start at 0). Pre-fix: the Handshaking peer only retransmits to the + // candidate — NO server-addressed relay datagram appears until 90s. + // Post-fix: it escalates to the relay now. + let out = pm.tick(PUNCH_MS + 2).map(<[_]>::to_vec).unwrap_or_default(); + let relayed = out.iter().find(|d| { + d.dst == mock_server() + && matches!( + yip_rendezvous::decode(&d.bytes), + Some(yip_rendezvous::Message::RelaySend { .. }) + ) + }); + let relayed = relayed.expect( + "escalated to relay at ~PUNCH_MS: a RelaySend (relay-wrapped Init) is sent to the server", + ); + // The relayed payload is the handshake Init itself. + if let Some(yip_rendezvous::Message::RelaySend { payload, .. }) = + yip_rendezvous::decode(&relayed.bytes) + { + assert_eq!( + payload.first(), + Some(&(PacketType::HandshakeInit as u8)), + "the relay-wrapped payload is a HandshakeInit" + ); + } else { + unreachable!("matched RelaySend above"); + } + // The escalation flipped the peer onto the relay, still handshaking. + assert!(pm.peers[0].relay, "peer is now relay-reached"); + assert!(matches!(pm.peers[0].state, PeerState::Handshaking(_))); + } + + /// (f) Anti-hijack over the relay: an `RdvEvent::Relayed` HandshakeInit whose + /// `src` maps to an ALREADY-`Established` peer must NOT disturb the live + /// session — the `on_relayed`/`relayed_handshake_init` Established-guard keeps + /// `relay`, `endpoint`, and the session (conn_tag) untouched. This fails if + /// either guard is removed (the peer would be flipped onto the relay). + #[test] + fn anti_hijack_established_peer_ignores_relayed_handshake_init() { + let local = generate_keypair(); + let peer_kp = generate_keypair(); + let endpoint: SocketAddr = "10.0.0.2:51820".parse().unwrap(); + let peer = PeerConfig { + public_key: peer_kp.public, + endpoint: Some(endpoint), + }; + let (mut pm, _sent) = pm_with_mock_rdv(&local, &[peer]); + + // Splice in a live direct session reaching `endpoint`. + const TAG: u64 = 0x1122_3344_5566_7788; + pm.peers[0].state = + PeerState::Established(Box::new(fake_established_dataplane(TAG, endpoint))); + pm.by_tag.insert(TAG, 0); + pm.peers[0].path_kind = Some(PathKind::Direct); + assert!(!pm.peers[0].relay); + let tag_before = established_tag(&pm, 0).expect("established"); + + // A valid HandshakeInit from the peer, delivered THROUGH the relay + // (RelayDeliver from the server, src = peer node). + let (_hs, init_pkt) = + HandshakeState::start_initiator(&peer_kp.private, &local.public).unwrap(); + let mut buf = Vec::new(); + yip_rendezvous::encode( + &yip_rendezvous::Message::RelayDeliver { + src: node_id(&peer_kp.public), + payload: init_pkt, + }, + &mut buf, + ); + assert!(matches!( + pm.on_udp(mock_server(), &buf, 0), + DispatchOut::None + )); + + // The live session is untouched: not flipped onto the relay, endpoint + // and conn_tag unchanged. + assert!(!pm.peers[0].relay, "relay flag must not be flipped"); + assert_eq!(pm.peers[0].endpoint, Some(endpoint), "endpoint unchanged"); + assert_eq!( + established_tag(&pm, 0), + Some(tag_before), + "session (conn_tag) unchanged" + ); + } + + /// (g) Fix-pass-2 regression: escalating an in-flight punch handshake to + /// relay MUST clear the stale `endpoint` left pointing at the abandoned + /// punch candidate `C`. Pre-fix, `endpoint` stayed `Some(C)` after + /// escalation, so a late direct `[HandshakeResp]` arriving from `C` (very + /// plausible on a lossy/high-latency link — a punch reply just past the + /// `PUNCH_MS` window) matched this peer in `handle_handshake_resp` + /// (`p.endpoint == Some(src) && Handshaking`) and was fed into the *new* + /// relay ephemeral's `read_response`, which fails cryptographically and + /// silently discards the fresh relay attempt (peer reverts to `Idle`). + /// Post-fix, `endpoint` is cleared on escalation so the stray reply + /// matches no peer and is dropped harmlessly, leaving the relay + /// handshake intact. + #[test] + fn late_punch_reply_after_relay_escalation_does_not_poison_relay() { + use crate::path::PUNCH_MS; + let local = generate_keypair(); + let peer_kp = generate_keypair(); + let peer = PeerConfig { + public_key: peer_kp.public, + endpoint: None, // rendezvous-only: starts in the Punching stage + }; + let (mut pm, _sent) = pm_with_mock_rdv(&local, &[peer]); + + // 1. Learn a reflexive candidate `C` for the peer, then tick inside + // the punch window: the peer probes `C` directly (endpoint = Some(C)). + let candidate: SocketAddr = "198.51.100.7:41000".parse().unwrap(); + let mut buf = Vec::new(); + yip_rendezvous::encode( + &yip_rendezvous::Message::PeerInfo { + node: node_id(&peer_kp.public), + reflexive: candidate, + }, + &mut buf, + ); + assert!(matches!( + pm.on_udp(mock_server(), &buf, 0), + DispatchOut::None + )); + let out = pm.tick(1).map(<[_]>::to_vec).unwrap_or_default(); + assert!( + out.iter().any(|d| d.dst == candidate + && d.bytes.first() == Some(&(PacketType::HandshakeInit as u8))), + "punch Init is probed toward the candidate C" + ); + assert!(matches!(pm.peers[0].state, PeerState::Handshaking(_))); + assert_eq!( + pm.peers[0].endpoint, + Some(candidate), + "endpoint is the punch candidate C while probing directly" + ); + assert!(!pm.peers[0].relay); + + // 2. Tick past PUNCH_MS: escalates to relay. The fix: `endpoint` is + // cleared (no longer pointing at the abandoned punch target C). + let out = pm.tick(PUNCH_MS + 2).map(<[_]>::to_vec).unwrap_or_default(); + assert!( + out.iter().any(|d| d.dst == mock_server() + && matches!( + yip_rendezvous::decode(&d.bytes), + Some(yip_rendezvous::Message::RelaySend { .. }) + )), + "escalated to relay: a RelaySend goes to the server" + ); + assert!(pm.peers[0].relay, "peer is now relay-reached"); + assert!(matches!(pm.peers[0].state, PeerState::Handshaking(_))); + assert_eq!( + pm.peers[0].endpoint, None, + "fix: stale punch-candidate endpoint C must be cleared on escalation \ + to relay, so a late direct reply from C cannot match this peer" + ); + + // 3. Simulate a late direct HandshakeResp arriving from C — a + // plausible handshake-resp-shaped datagram (only the leading + // PacketType byte and the source/state match matter for demux; its + // payload need not decrypt against anything, since — post-fix — it + // must never even reach `read_response`). + let stray = vec![PacketType::HandshakeResp as u8; 64]; + let result = pm.on_udp(candidate, &stray, PUNCH_MS + 3); + assert!( + matches!(result, DispatchOut::None), + "the stray late reply from C produces no egress" + ); + + // The load-bearing assertions: the relay handshake must NOT have been + // poisoned/discarded by the stray datagram. Pre-fix, `endpoint` would + // still equal `Some(candidate)`, so `handle_handshake_resp` would have + // matched this peer, fed the garbage into the relay ephemeral's + // `read_response` (which errors), and reverted the peer to `Idle` — + // silently destroying the in-flight relay attempt. Post-fix, + // `endpoint == None` means no match, so the relay attempt survives + // untouched. + assert!( + matches!(pm.peers[0].state, PeerState::Handshaking(_)), + "relay handshake must survive the stray late punch reply from C \ + (pre-fix this would be Idle, having been poisoned)" + ); + assert!( + pm.peers[0].relay, + "peer must still be relay-reached after the stray datagram" + ); + + // A subsequent tick still drives the (intact) relay attempt rather + // than starting over from a clobbered Idle state. + let out2 = pm + .tick(PUNCH_MS + HANDSHAKE_RETRY_MS + 3) + .map(<[_]>::to_vec) + .unwrap_or_default(); + assert!( + out2.iter().any(|d| d.dst == mock_server() + && matches!( + yip_rendezvous::decode(&d.bytes), + Some(yip_rendezvous::Message::RelaySend { .. }) + )), + "the relay attempt keeps retransmitting via the server, unbroken by the stray reply" + ); + } + + /// (h) F2 fix: a peer configured with BOTH a direct endpoint AND a + /// rendezvous must still hole-punch. It starts `Handshaking` on the direct + /// endpoint via `on_tun`'s `Idle` branch (not via `drive_path_idle`, which + /// only ever runs for `Idle` peers), so the *only* place that can drive its + /// path SM onward is the tick escalation arm. Pre-fix, that arm's `match` + /// treated `PathAction::NeedLookup` as `_ => {}` — once the direct window + /// (`DIRECT_MS`) elapses and the SM escalates `Direct -> Punching` with no + /// candidate yet known, `advance` returns `NeedLookup` every tick and NONE + /// of them ever emit a `Lookup`: no reflexive candidate is ever learned, so + /// this peer can never punch (it just rides the direct `Init` out to + /// `HANDSHAKE_TOTAL_MS` and gives up, or — with the 2b relay-escalation + /// fix — eventually relays instead of punching). Step 2's assertion below + /// is the load-bearing one and FAILS pre-fix (the mock records no `Lookup` + /// at all). + #[test] + fn endpoint_peer_emits_lookup_and_punches_after_direct_window() { + use crate::path::DIRECT_MS; + let local = generate_keypair(); + let peer_kp = generate_keypair(); + let endpoint: SocketAddr = "10.0.0.2:51820".parse().unwrap(); + let peer = PeerConfig { + public_key: peer_kp.public, + endpoint: Some(endpoint), // BOTH a direct endpoint AND (via the mock) a rendezvous + }; + let (mut pm, sent) = pm_with_mock_rdv(&local, &[peer]); + + // 1. First TUN packet: on_tun's Idle branch drives the path SM, which + // (still within DIRECT_MS at t=0) returns Probe(endpoint) — the peer + // starts Handshaking on the direct endpoint, exactly like 2a. + let out = pm.on_tun(&dummy_tun_pkt(), 0).to_vec(); + assert_eq!(out.len(), 1); + assert_eq!(out[0].dst, endpoint, "Init targets the configured endpoint"); + assert!(matches!(pm.peers[0].state, PeerState::Handshaking(_))); + assert_eq!(pm.peers[0].path.stage(), PathStage::Direct); + + // 2. Tick past DIRECT_MS: the peer is still Handshaking (no resp + // arrived), so only the tick escalation arm touches its path SM. The + // SM escalates Direct -> Punching and (no candidate known yet) returns + // NeedLookup. THE LOAD-BEARING ASSERTION: a Lookup for this peer's + // node id must have been emitted — this fails pre-fix, where + // NeedLookup fell into the escalation arm's `_ => {}` and nothing was + // ever sent. + let out = pm + .tick(DIRECT_MS + 1) + .map(<[_]>::to_vec) + .unwrap_or_default(); + assert_eq!(pm.peers[0].path.stage(), PathStage::Punching); + assert!( + out.iter().any(|d| d.dst == mock_server() + && matches!( + yip_rendezvous::decode(&d.bytes), + Some(yip_rendezvous::Message::Lookup { node }) + if node == node_id(&peer_kp.public) + )), + "a Lookup for the peer's node id must be emitted once the direct \ + window elapses and the SM escalates to Punching, even though the \ + peer is still Handshaking on the direct endpoint" + ); + assert!( + sent.borrow() + .iter() + .any(|m| matches!(m, yip_rendezvous::Message::Lookup { .. })), + "the mock recorded a Lookup" + ); + // The direct Init stays in flight alongside the lookup (NeedLookup + // does not abandon it) — the peer is still Handshaking, not relayed. + assert!(matches!(pm.peers[0].state, PeerState::Handshaking(_))); + assert!(!pm.peers[0].relay); + + // 3. A reflexive candidate for the peer now arrives (as if the lookup + // above had been answered). A later tick's `advance` returns + // `Probe(candidate)`, which the escalation arm's existing + // `addr != target` re-target branch handles: abandon the direct Init, + // begin a fresh handshake toward the punch candidate. + let candidate: SocketAddr = "198.51.100.7:41000".parse().unwrap(); + let mut buf = Vec::new(); + yip_rendezvous::encode( + &yip_rendezvous::Message::PeerInfo { + node: node_id(&peer_kp.public), + reflexive: candidate, + }, + &mut buf, + ); + assert!(matches!( + pm.on_udp(mock_server(), &buf, DIRECT_MS + 2), + DispatchOut::None + )); + + let out = pm + .tick(DIRECT_MS + 3) + .map(<[_]>::to_vec) + .unwrap_or_default(); + assert!( + out.iter().any(|d| d.dst == candidate + && d.bytes.first() == Some(&(PacketType::HandshakeInit as u8))), + "the peer re-targets to the punch candidate: a fresh Init is sent \ + to it, proving the punch path is reachable for an \ + endpoint-configured peer" + ); + assert_eq!( + pm.peers[0].endpoint, + Some(candidate), + "endpoint re-stamped to the punch candidate" + ); + assert!(matches!(pm.peers[0].state, PeerState::Handshaking(_))); + } } diff --git a/bin/yipd/src/rendezvous.rs b/bin/yipd/src/rendezvous.rs new file mode 100644 index 0000000..1b28456 --- /dev/null +++ b/bin/yipd/src/rendezvous.rs @@ -0,0 +1,196 @@ +//! The `yipd` side of the rendezvous protocol: a `Rendezvous` trait (so a 2c +//! DHT backend can replace the configured-server one) and the +//! `ConfiguredServerRendezvous` impl that produces `EgressDatagram`s aimed at a +//! configured server and parses server datagrams into `RdvEvent`s the path +//! state machine reacts to. +use std::net::SocketAddr; + +use yip_io::poll::EgressDatagram; +use yip_rendezvous::{decode, encode, Message, NodeId}; + +/// A parsed inbound rendezvous datagram, normalized for the path SM. +/// +/// Not yet consumed outside tests — Task 6 wires this into `PeerManager`'s +/// path state machine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RdvEvent { + /// The server told us where a peer is (answer to our `lookup`). + PeerCandidate { node: NodeId, addr: SocketAddr }, + /// The server asked us to punch toward a peer that looked us up. + PunchTo { node: NodeId, addr: SocketAddr }, + /// A relayed tunnel datagram from `src`; `payload` is fed to the peer path. + Relayed { src: NodeId, payload: Vec }, + /// The looked-up peer is not registered. + NotFound { node: NodeId }, + /// Not a message we act on. + Ignored, +} + +/// Abstraction over "how do I find/reach a peer by node id". 2b ships the +/// configured-server impl; 2c adds a DHT impl without touching `PeerManager`. +/// +/// Not yet consumed outside tests — Task 6 wires a `Rendezvous` impl into +/// `PeerManager`. +pub trait Rendezvous { + fn register(&mut self, node: NodeId) -> EgressDatagram; + fn lookup(&mut self, node: NodeId) -> EgressDatagram; + fn relay(&mut self, src: NodeId, dst: NodeId, payload: &[u8]) -> EgressDatagram; + fn parse(&self, dg: &[u8]) -> RdvEvent; + fn server_addr(&self) -> SocketAddr; +} + +/// Talks to a single configured rendezvous+relay server. +/// +/// Not yet constructed outside tests — Task 6 builds one from +/// `Config::rendezvous` and drives it from `PeerManager`. +pub struct ConfiguredServerRendezvous { + server: SocketAddr, +} + +impl ConfiguredServerRendezvous { + pub fn new(server: SocketAddr) -> Self { + Self { server } + } + + fn to_server(&self, msg: &Message) -> EgressDatagram { + let mut bytes = Vec::new(); + encode(msg, &mut bytes); + EgressDatagram { + fate: 0, + dst: self.server, + bytes, + } + } +} + +impl Rendezvous for ConfiguredServerRendezvous { + fn register(&mut self, node: NodeId) -> EgressDatagram { + self.to_server(&Message::Register { node }) + } + fn lookup(&mut self, node: NodeId) -> EgressDatagram { + self.to_server(&Message::Lookup { node }) + } + fn relay(&mut self, src: NodeId, dst: NodeId, payload: &[u8]) -> EgressDatagram { + self.to_server(&Message::RelaySend { + src, + dst, + payload: payload.to_vec(), + }) + } + fn parse(&self, dg: &[u8]) -> RdvEvent { + match decode(dg) { + Some(Message::PeerInfo { node, reflexive }) => RdvEvent::PeerCandidate { + node, + addr: reflexive, + }, + Some(Message::PunchHint { node, reflexive }) => RdvEvent::PunchTo { + node, + addr: reflexive, + }, + Some(Message::RelayDeliver { src, payload }) => RdvEvent::Relayed { src, payload }, + Some(Message::NotFound { node }) => RdvEvent::NotFound { node }, + _ => RdvEvent::Ignored, + } + } + fn server_addr(&self) -> SocketAddr { + self.server + } +} + +#[cfg(test)] +mod tests { + use super::*; + use yip_rendezvous::{encode, node_id, Message}; + + fn server() -> SocketAddr { + "203.0.113.1:51821".parse().unwrap() + } + + #[test] + fn register_targets_server_with_our_node_id() { + let mut r = ConfiguredServerRendezvous::new(server()); + let me = node_id(&[1u8; 32]); + let dg = r.register(me); + assert_eq!(dg.dst, server()); + assert_eq!( + yip_rendezvous::decode(&dg.bytes), + Some(Message::Register { node: me }) + ); + } + + #[test] + fn lookup_targets_server_with_queried_node_id() { + let mut r = ConfiguredServerRendezvous::new(server()); + let peer = node_id(&[2u8; 32]); + let dg = r.lookup(peer); + assert_eq!(dg.dst, server()); + assert_eq!( + yip_rendezvous::decode(&dg.bytes), + Some(Message::Lookup { node: peer }) + ); + } + + #[test] + fn server_addr_returns_configured_server() { + let r = ConfiguredServerRendezvous::new(server()); + assert_eq!(r.server_addr(), server()); + } + + #[test] + fn relay_wraps_payload_for_dst() { + let mut r = ConfiguredServerRendezvous::new(server()); + let me = node_id(&[1u8; 32]); + let peer = node_id(&[2u8; 32]); + let dg = r.relay(me, peer, &[4, 5, 6]); + assert_eq!(dg.dst, server()); + assert_eq!( + yip_rendezvous::decode(&dg.bytes), + Some(Message::RelaySend { + src: me, + dst: peer, + payload: vec![4, 5, 6] + }) + ); + } + + #[test] + fn parse_maps_server_messages_to_events() { + let r = ConfiguredServerRendezvous::new(server()); + let n = node_id(&[2u8; 32]); + let a: SocketAddr = "198.51.100.7:41000".parse().unwrap(); + let mut buf = Vec::new(); + encode( + &Message::PeerInfo { + node: n, + reflexive: a, + }, + &mut buf, + ); + assert!( + matches!(r.parse(&buf), RdvEvent::PeerCandidate { node, addr } if node == n && addr == a) + ); + buf.clear(); + encode( + &Message::PunchHint { + node: n, + reflexive: a, + }, + &mut buf, + ); + assert!( + matches!(r.parse(&buf), RdvEvent::PunchTo { node, addr } if node == n && addr == a) + ); + buf.clear(); + encode( + &Message::RelayDeliver { + src: n, + payload: vec![1, 2], + }, + &mut buf, + ); + assert!( + matches!(r.parse(&buf), RdvEvent::Relayed { src, payload } if src == n && payload == vec![1, 2]) + ); + assert!(matches!(r.parse(&[0xFF]), RdvEvent::Ignored)); + } +} diff --git a/bin/yipd/src/tunnel.rs b/bin/yipd/src/tunnel.rs index 6513402..ce45f4d 100644 --- a/bin/yipd/src/tunnel.rs +++ b/bin/yipd/src/tunnel.rs @@ -63,11 +63,19 @@ pub fn run(config: Config) -> io::Result<()> { // ── build the peer manager ──────────────────────────────────────────────── let mode = config.device_kind; + // A configured rendezvous server enables lazy Direct→Punch→Relay peer + // bring-up; with none, `PeerManager` is pure-2a (direct endpoints only). + let rendezvous: Option> = + config.rendezvous.map(|addr| { + Box::new(crate::rendezvous::ConfiguredServerRendezvous::new(addr)) + as Box + }); let mut manager = PeerManager::new( config.local_private, config.local_public, &config.peers, mode, + rendezvous, ); let local_addr = manager.local_addr(); diff --git a/bin/yipd/tests/run-netns-punch.sh b/bin/yipd/tests/run-netns-punch.sh new file mode 100755 index 0000000..d1abe3c --- /dev/null +++ b/bin/yipd/tests/run-netns-punch.sh @@ -0,0 +1,267 @@ +#!/usr/bin/env bash +# "hole_punch_ping" money test for yipd's rendezvous/punch path (2b). +# Usage: run-netns-punch.sh +# +# Topology: two client netns A / B, each "behind a NAT" to a shared transit +# netns T that also hosts yip-rendezvous: +# A --10.80.0.0/24-- T --10.81.0.0/24-- B +# +# In A and B, `iptables -t nat -A POSTROUTING -o -j MASQUERADE` is +# applied on the sole egress interface, per the spec's classic-NAT recipe. +# Because each client netns is single-homed (one veth, one address), this +# MASQUERADE rewrites a source address onto itself (a no-op) rather than +# hiding a private subnet behind a distinct public one -- it is kept here for +# topological fidelity with the spec's instructions, not because it changes +# any address on the wire. +# +# T DOES route between the two client subnets (IPv4 forwarding enabled), so +# each peer's server-observed reflexive address (learned via yip-rendezvous) +# IS directly reachable through T. This is the documented fallback from the +# task brief: "if a true post-NAT simultaneous-open punch proves flaky... T +# routes between subnets so the reflexive addr is directly reachable -> the +# punch/direct path carries it, relay-forwarded stays 0" -- the invariant +# under test (punch path used, relay NOT used) still holds, since the peers +# are configured rendezvous-only (public_key, no endpoint) and can only ever +# learn each other's address via the rendezvous protocol's PeerInfo/PunchHint +# messages, never via static config. +# +# Assert: ping succeeds AND the server's final `relay-forwarded=` +# (grepped from its stderr log) stays 0 -- proving the punch/direct path, +# NOT the blind relay, carried the traffic. +set -euo pipefail + +YIPD="${1:?Usage: $0 }" +RDV="${2:?Usage: $0 }" +TMPDIR_TEST="$(mktemp -d /tmp/yipd-netns-punch-test.XXXXXX)" + +NS_A="yipPunchA" +NS_B="yipPunchB" +NS_T="yipPunchT" + +VETH_A_N="vPnA1"; VETH_A_T="vPnA0" # A<->T pair: A-side, T-side +VETH_B_N="vPnB1"; VETH_B_T="vPnB0" # B<->T pair: B-side, T-side + +IP_A="10.80.0.2" +IP_T_A="10.80.0.1" # T's address on A's subnet +IP_B="10.81.0.2" +IP_T_B="10.81.0.1" # T's address on B's subnet +PREFIX="24" + +PORT_A="51820" +PORT_B="51820" +RDV_PORT="51821" +TUN_DEV="yip0" + +PID_A="" +PID_B="" +PID_RDV="" + +cleanup() { + echo "[cleanup] killing daemons and removing namespaces" + [ -n "$PID_A" ] && kill "$PID_A" 2>/dev/null || true + [ -n "$PID_B" ] && kill "$PID_B" 2>/dev/null || true + [ -n "$PID_RDV" ] && kill "$PID_RDV" 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_RDV" ] && kill -9 "$PID_RDV" 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_T" 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)" + +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)" + +ADDR_A="$("$YIPD" --addr "$PUB_A")" +ADDR_B="$("$YIPD" --addr "$PUB_B")" +echo "[setup] node_addr A=$ADDR_A B=$ADDR_B" + +# ── 2. write config files (rendezvous-only peers: public_key, no endpoint) ──── +CFG_A="$TMPDIR_TEST/yipA.conf" +CFG_B="$TMPDIR_TEST/yipB.conf" + +cat > "$CFG_A" < "$CFG_B" <T" +ip link add "$VETH_A_T" type veth peer name "$VETH_A_N" +ip link set "$VETH_A_N" netns "$NS_A" +ip link set "$VETH_A_T" netns "$NS_T" +ip netns exec "$NS_A" ip addr add "${IP_A}/${PREFIX}" dev "$VETH_A_N" +ip netns exec "$NS_A" ip link set "$VETH_A_N" up +ip netns exec "$NS_A" ip link set lo up +ip netns exec "$NS_T" ip addr add "${IP_T_A}/${PREFIX}" dev "$VETH_A_T" +ip netns exec "$NS_T" ip link set "$VETH_A_T" up + +echo "[setup] wiring B<->T" +ip link add "$VETH_B_T" type veth peer name "$VETH_B_N" +ip link set "$VETH_B_N" netns "$NS_B" +ip link set "$VETH_B_T" netns "$NS_T" +ip netns exec "$NS_B" ip addr add "${IP_B}/${PREFIX}" dev "$VETH_B_N" +ip netns exec "$NS_B" ip link set "$VETH_B_N" up +ip netns exec "$NS_B" ip link set lo up +ip netns exec "$NS_T" ip addr add "${IP_T_B}/${PREFIX}" dev "$VETH_B_T" +ip netns exec "$NS_T" ip link set "$VETH_B_T" up +ip netns exec "$NS_T" ip link set lo up + +# A and B each default-route via T (their only path off-subnet). +ip netns exec "$NS_A" ip route add default via "$IP_T_A" dev "$VETH_A_N" +ip netns exec "$NS_B" ip route add default via "$IP_T_B" dev "$VETH_B_N" + +# Simulated NAT in A and B (see header comment: a no-op on a single-homed +# netns, kept for topological fidelity with the spec's recipe). +ip netns exec "$NS_A" iptables -t nat -A POSTROUTING -o "$VETH_A_N" -j MASQUERADE +ip netns exec "$NS_B" iptables -t nat -A POSTROUTING -o "$VETH_B_N" -j MASQUERADE + +# T routes between the two client subnets: this is what makes each peer's +# server-observed reflexive addr directly reachable, so the punch succeeds +# without ever needing the relay. +ip netns exec "$NS_T" sysctl -q -w net.ipv4.ip_forward=1 +ip netns exec "$NS_T" iptables -P FORWARD ACCEPT +ip netns exec "$NS_T" iptables -A FORWARD -i "$VETH_A_T" -o "$VETH_B_T" -j ACCEPT +ip netns exec "$NS_T" iptables -A FORWARD -i "$VETH_B_T" -o "$VETH_A_T" -j ACCEPT + +# ── 4. start yip-rendezvous in T, bound on both subnets ─────────────────────── +LOG_RDV="$TMPDIR_TEST/rdv.log" +echo "[start] starting yip-rendezvous in T on 0.0.0.0:${RDV_PORT}" +ip netns exec "$NS_T" "$RDV" "0.0.0.0:${RDV_PORT}" >"$LOG_RDV" 2>&1 & +PID_RDV=$! +sleep 0.3 + +# ── 5. start yipd in A and B ─────────────────────────────────────────────────── +LOG_A="$TMPDIR_TEST/yipA.log" +LOG_B="$TMPDIR_TEST/yipB.log" + +dump_logs() { + echo "=== rendezvous log ===" + cat "$LOG_RDV" || true + echo "=== yipPunchA log ===" + cat "$LOG_A" || true + echo "=== yipPunchB log ===" + cat "$LOG_B" || true +} + +echo "[start] starting yipPunchA" +ip netns exec "$NS_A" "$YIPD" "$CFG_A" >"$LOG_A" 2>&1 & +PID_A=$! + +echo "[start] starting yipPunchB" +ip netns exec "$NS_B" "$YIPD" "$CFG_B" >"$LOG_B" 2>&1 & +PID_B=$! + +# ── 6. wait for TUN devices to appear in A and B ────────────────────────────── +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 + 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 + + if [ "$A_UP" -eq 1 ] && [ "$B_UP" -eq 1 ]; then + echo "[wait] both TUN devices are up" + break + fi + + if ! kill -0 "$PID_A" 2>/dev/null; then + echo "[error] yipPunchA daemon died unexpectedly"; dump_logs; exit 1 + fi + if ! kill -0 "$PID_B" 2>/dev/null; then + echo "[error] yipPunchB daemon died unexpectedly"; dump_logs; exit 1 + fi + if ! kill -0 "$PID_RDV" 2>/dev/null; then + echo "[error] yip-rendezvous died unexpectedly"; dump_logs; exit 1 + fi + + 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 node_addr/128 + the mesh-prefix route ───────────── +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" + +echo "[check] interface state in yipPunchA:" +ip netns exec "$NS_A" ip -6 addr show "$TUN_DEV" +echo "[check] interface state in yipPunchB:" +ip netns exec "$NS_B" ip -6 addr show "$TUN_DEV" + +# ── 8. ping A->B, tolerating warm-up loss while the punch path comes up ────── +# Escalation timing: Lookup -> reflexive candidate -> punch Init, which +# succeeds directly here (T routes between subnets) well within PUNCH_MS, so +# no relay escalation should ever happen. A generous count/timeout still +# absorbs ordinary lookup/handshake warm-up; ping's own exit code already +# only requires >=1 reply, so no `|| true` is needed to keep the measured +# result load-bearing. +echo "[test] pinging ${ADDR_B} from yipPunchA (expect direct/punch success, no relay)" +set +e +ip netns exec "$NS_A" ping -6 -c 20 -W 2 "$ADDR_B" +PING_STATUS=$? +set -e +if [ "$PING_STATUS" -ne 0 ]; then + echo "[FAIL] ping A->B did not succeed (exit $PING_STATUS)" + dump_logs + exit 1 +fi +echo "[PASS] ping A->B succeeded" + +# ── 9. assert the relay was NOT used: relay-forwarded stays 0 ─────────────── +sleep 5.5 +FINAL_COUNT="$(grep -oE 'relay-forwarded=[0-9]+' "$LOG_RDV" | tail -1 | cut -d= -f2)" +echo "[check] server's final relay-forwarded count: ${FINAL_COUNT:-}" +if [ -n "${FINAL_COUNT:-}" ] && [ "$FINAL_COUNT" -ne 0 ]; then + echo "[FAIL] relay-forwarded=${FINAL_COUNT} (expected 0) — traffic went through the relay, not the punch path" + dump_logs + exit 1 +fi +echo "[PASS] relay-forwarded=${FINAL_COUNT:-0}: the punch/direct path carried the traffic, relay unused" diff --git a/bin/yipd/tests/run-netns-relay.sh b/bin/yipd/tests/run-netns-relay.sh new file mode 100755 index 0000000..9ffe6d6 --- /dev/null +++ b/bin/yipd/tests/run-netns-relay.sh @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +# "relay_path_ping" money test for yipd's rendezvous/relay path (2b). +# Usage: run-netns-relay.sh +# +# Topology: three netns, A / B / R (rendezvous+relay server). +# A --10.70.0.0/24-- R --10.71.0.0/24-- B +# Two point-to-point veth pairs (A<->R, B<->R); no shared bridge. A's only +# route beyond its own /24 is a DEFAULT route via R (10.70.0.1); B's only +# route beyond its own /24 is a DEFAULT route via R (10.71.0.1). R does NOT +# have IPv4 forwarding enabled, so a packet A sends toward B's subnet reaches +# R (the default gateway) and is silently dropped there instead of being +# forwarded on to B — A and B have NO reachability to each other, only to +# R's yip-rendezvous socket (bound on both subnets via 0.0.0.0). +# +# yipd A and B each list the OTHER by public_key only (no endpoint) and set +# rendezvous=. On startup each peer's path +# state machine: registers with R, looks up the other, learns the other's +# server-observed reflexive addr (on the UNREACHABLE far subnet), attempts a +# punch Init toward it (silently dropped by R), and after ~PUNCH_MS (5s) +# escalates to the blind relay through R — which DOES reach the peer, since +# R's relay forwards by rewriting to the registered reflexive addr rather +# than routing the original packet. +# +# Assert: ping succeeds (tolerating warm-up loss during +# lookup->punch-attempt->escalate->relay-handshake) AND the server's final +# `relay-forwarded=` (grepped from its stderr log) has N>0 — proving the +# BLIND RELAY, not a direct/punched path, carried the traffic. +set -euo pipefail + +YIPD="${1:?Usage: $0 }" +RDV="${2:?Usage: $0 }" +TMPDIR_TEST="$(mktemp -d /tmp/yipd-netns-relay-test.XXXXXX)" + +NS_A="yipRelA" +NS_B="yipRelB" +NS_R="yipRelR" + +VETH_A_N="vRelA1"; VETH_A_R="vRelA0" # A<->R pair: A-side, R-side +VETH_B_N="vRelB1"; VETH_B_R="vRelB0" # B<->R pair: B-side, R-side + +IP_A="10.70.0.2" +IP_R_A="10.70.0.1" # R's address on A's subnet +IP_B="10.71.0.2" +IP_R_B="10.71.0.1" # R's address on B's subnet +PREFIX="24" + +PORT_A="51820" +PORT_B="51820" +RDV_PORT="51821" +TUN_DEV="yip0" + +PID_A="" +PID_B="" +PID_RDV="" + +cleanup() { + echo "[cleanup] killing daemons and removing namespaces" + [ -n "$PID_A" ] && kill "$PID_A" 2>/dev/null || true + [ -n "$PID_B" ] && kill "$PID_B" 2>/dev/null || true + [ -n "$PID_RDV" ] && kill "$PID_RDV" 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_RDV" ] && kill -9 "$PID_RDV" 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_R" 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)" + +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)" + +ADDR_A="$("$YIPD" --addr "$PUB_A")" +ADDR_B="$("$YIPD" --addr "$PUB_B")" +echo "[setup] node_addr A=$ADDR_A B=$ADDR_B" + +# ── 2. write config files (rendezvous-only peers: public_key, no endpoint) ──── +CFG_A="$TMPDIR_TEST/yipA.conf" +CFG_B="$TMPDIR_TEST/yipB.conf" + +cat > "$CFG_A" < "$CFG_B" <R" +ip link add "$VETH_A_R" type veth peer name "$VETH_A_N" +ip link set "$VETH_A_N" netns "$NS_A" +ip link set "$VETH_A_R" netns "$NS_R" +ip netns exec "$NS_A" ip addr add "${IP_A}/${PREFIX}" dev "$VETH_A_N" +ip netns exec "$NS_A" ip link set "$VETH_A_N" up +ip netns exec "$NS_A" ip link set lo up +ip netns exec "$NS_R" ip addr add "${IP_R_A}/${PREFIX}" dev "$VETH_A_R" +ip netns exec "$NS_R" ip link set "$VETH_A_R" up + +echo "[setup] wiring B<->R" +ip link add "$VETH_B_R" type veth peer name "$VETH_B_N" +ip link set "$VETH_B_N" netns "$NS_B" +ip link set "$VETH_B_R" netns "$NS_R" +ip netns exec "$NS_B" ip addr add "${IP_B}/${PREFIX}" dev "$VETH_B_N" +ip netns exec "$NS_B" ip link set "$VETH_B_N" up +ip netns exec "$NS_B" ip link set lo up +ip netns exec "$NS_R" ip addr add "${IP_R_B}/${PREFIX}" dev "$VETH_B_R" +ip netns exec "$NS_R" ip link set "$VETH_B_R" up +ip netns exec "$NS_R" ip link set lo up + +# A's and B's only route beyond their own /24 is via R -- and R does NOT +# forward, so this is a route to nowhere for cross-subnet traffic (the kernel +# accepts the sendto() instead of failing it with ENETUNREACH, but the +# packet dies silently at R). This is what makes A and B mutually +# unreachable while keeping the punch attempt a normal (silently-dropped) +# packet rather than a synchronous socket error that would kill yipd's event +# loop. +ip netns exec "$NS_A" ip route add default via "$IP_R_A" dev "$VETH_A_N" +ip netns exec "$NS_B" ip route add default via "$IP_R_B" dev "$VETH_B_N" + +# Explicitly disable IPv4 forwarding in R (belt-and-suspenders: a fresh netns +# already defaults to this, but the isolation invariant this whole test rests +# on deserves to be asserted, not assumed). +ip netns exec "$NS_R" sysctl -q -w net.ipv4.ip_forward=0 +ip netns exec "$NS_R" sysctl -q -w net.ipv4.conf.all.forwarding=0 + +# ── 4. start yip-rendezvous in R, bound on both subnets ─────────────────────── +LOG_RDV="$TMPDIR_TEST/rdv.log" +echo "[start] starting yip-rendezvous in R on 0.0.0.0:${RDV_PORT}" +ip netns exec "$NS_R" "$RDV" "0.0.0.0:${RDV_PORT}" >"$LOG_RDV" 2>&1 & +PID_RDV=$! +sleep 0.3 + +# ── 5. start yipd in A and B ─────────────────────────────────────────────────── +LOG_A="$TMPDIR_TEST/yipA.log" +LOG_B="$TMPDIR_TEST/yipB.log" + +dump_logs() { + echo "=== rendezvous log ===" + cat "$LOG_RDV" || true + echo "=== yipRelA log ===" + cat "$LOG_A" || true + echo "=== yipRelB log ===" + cat "$LOG_B" || true +} + +echo "[start] starting yipRelA" +ip netns exec "$NS_A" "$YIPD" "$CFG_A" >"$LOG_A" 2>&1 & +PID_A=$! + +echo "[start] starting yipRelB" +ip netns exec "$NS_B" "$YIPD" "$CFG_B" >"$LOG_B" 2>&1 & +PID_B=$! + +# ── 6. wait for TUN devices to appear in A and B ────────────────────────────── +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 + 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 + + if [ "$A_UP" -eq 1 ] && [ "$B_UP" -eq 1 ]; then + echo "[wait] both TUN devices are up" + break + fi + + if ! kill -0 "$PID_A" 2>/dev/null; then + echo "[error] yipRelA daemon died unexpectedly"; dump_logs; exit 1 + fi + if ! kill -0 "$PID_B" 2>/dev/null; then + echo "[error] yipRelB daemon died unexpectedly"; dump_logs; exit 1 + fi + if ! kill -0 "$PID_RDV" 2>/dev/null; then + echo "[error] yip-rendezvous died unexpectedly"; dump_logs; exit 1 + fi + + 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 node_addr/128 + the mesh-prefix route ───────────── +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" + +echo "[check] interface state in yipRelA:" +ip netns exec "$NS_A" ip -6 addr show "$TUN_DEV" +echo "[check] interface state in yipRelB:" +ip netns exec "$NS_B" ip -6 addr show "$TUN_DEV" + +# ── 8. ping A->B, tolerating warm-up loss while the path escalates to relay ── +# Escalation timing (PUNCH_MS = 5s): Lookup -> reflexive candidate -> punch +# Init (silently dropped by R) -> ~5s later, escalate to relay -> relay +# handshake completes in ~1 RTT. A generous count/timeout absorbs that +# warm-up; ping's own exit code already only requires >=1 reply (see +# `ping_across_yipd_tunnel_under_loss` for the same tolerance pattern), so no +# `|| true` is needed to keep the measured result load-bearing. +echo "[test] pinging ${ADDR_B} from yipRelA (expect escalate-to-relay warm-up loss, then success)" +set +e +ip netns exec "$NS_A" ping -6 -c 20 -W 2 "$ADDR_B" +PING_STATUS=$? +set -e +if [ "$PING_STATUS" -ne 0 ]; then + echo "[FAIL] ping A->B did not succeed (exit $PING_STATUS)" + dump_logs + exit 1 +fi +echo "[PASS] ping A->B succeeded" + +# ── 9. assert the relay actually carried it: relay-forwarded=, N>0 ──────── +# Give the server one more sweep interval to emit a final relay-forwarded +# line reflecting the traffic that just flowed. +sleep 5.5 +FINAL_COUNT="$(grep -oE 'relay-forwarded=[0-9]+' "$LOG_RDV" | tail -1 | cut -d= -f2)" +echo "[check] server's final relay-forwarded count: ${FINAL_COUNT:-}" +if [ -z "${FINAL_COUNT:-}" ] || [ "$FINAL_COUNT" -eq 0 ]; then + echo "[FAIL] relay-forwarded count is 0 (or missing) — traffic did not go through the relay" + dump_logs + exit 1 +fi +echo "[PASS] relay-forwarded=${FINAL_COUNT} (>0): the blind relay carried the traffic" diff --git a/bin/yipd/tests/tunnel_netns.rs b/bin/yipd/tests/tunnel_netns.rs index e572a76..d873345 100644 --- a/bin/yipd/tests/tunnel_netns.rs +++ b/bin/yipd/tests/tunnel_netns.rs @@ -1,5 +1,15 @@ //! End-to-end tunnel test: two yipd in separate netns ping across the tunnel. //! Requires root (CAP_NET_ADMIN + netns); SKIPs otherwise. Run in CI under sudo. +//! +//! `relay_path_ping` and `hole_punch_ping` (2b Task 7) are the rendezvous +//! money tests: each asserts not just that the ping succeeds, but *which* +//! path (blind relay vs. punch/direct) carried the traffic, via the +//! server's `relay-forwarded=` counter. Graceful degradation (no +//! `rendezvous` configured) is already covered by the plain 2a tests above +//! (`ping_across_yipd_tunnel`, `triangle_full_mesh_ping`, etc.), and +//! optional-endpoint reachability is exercised by both money tests, whose +//! peers are configured by `public_key` only (no `endpoint`) — so no +//! separate script is needed for either. use std::process::Command; #[test] @@ -131,3 +141,95 @@ fn arq_recovers_bulk_loss() { "ARQ integrity test failed: FEC+ARQ did not recover 5% bulk loss or ARQ did not fire" ); } + +/// Locate the `yip-rendezvous` debug binary in the shared workspace target +/// dir. Unlike `yipd` (built in-package via `CARGO_BIN_EXE_yipd`, resolved at +/// compile time), `yip-rendezvous` lives in a different workspace package +/// (`yip-rendezvous-bin`); Cargo only populates `CARGO_BIN_EXE_` for a +/// package's own binaries on stable (cross-package binary exe paths need the +/// nightly-only `artifact-dependencies`/`-Z bindeps` feature), so this +/// resolves the path the same way `arq_recovers_bulk_loss` resolves the +/// release `yipd` binary: relative to `CARGO_MANIFEST_DIR`, two levels up to +/// the workspace root, then into `target/debug`. +fn yip_rendezvous_bin() -> std::path::PathBuf { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let workspace_root = std::path::Path::new(manifest_dir) + .parent() + .and_then(|p| p.parent()) + .expect("workspace root two levels up from CARGO_MANIFEST_DIR"); + workspace_root.join("target/debug/yip-rendezvous") +} + +#[test] +fn relay_path_ping() { + // Requires root: netns creation + TUN devices + yip-rendezvous. + 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 relay_path_ping: needs root (run under sudo in CI)"); + return; + } + let rdv = yip_rendezvous_bin(); + if !rdv.exists() { + eprintln!( + "SKIP relay_path_ping: yip-rendezvous binary not found at {}; \ + run `cargo build -p yip-rendezvous-bin` first", + rdv.display() + ); + return; + } + let yipd = env!("CARGO_BIN_EXE_yipd"); + let script = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/run-netns-relay.sh"); + let status = Command::new("bash") + .arg(script) + .arg(yipd) + .arg(&rdv) + .status() + .unwrap(); + assert!( + status.success(), + "relay-path netns test failed (ping did not succeed, or relay-forwarded stayed 0)" + ); +} + +#[test] +fn hole_punch_ping() { + // Requires root: netns creation + TUN devices + yip-rendezvous + NAT. + 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 hole_punch_ping: needs root (run under sudo in CI)"); + return; + } + let rdv = yip_rendezvous_bin(); + if !rdv.exists() { + eprintln!( + "SKIP hole_punch_ping: yip-rendezvous binary not found at {}; \ + run `cargo build -p yip-rendezvous-bin` first", + rdv.display() + ); + return; + } + let yipd = env!("CARGO_BIN_EXE_yipd"); + let script = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/run-netns-punch.sh"); + let status = Command::new("bash") + .arg(script) + .arg(yipd) + .arg(&rdv) + .status() + .unwrap(); + assert!( + status.success(), + "hole-punch netns test failed (ping did not succeed, or relay-forwarded was nonzero)" + ); +} diff --git a/crates/yip-rendezvous/Cargo.toml b/crates/yip-rendezvous/Cargo.toml new file mode 100644 index 0000000..d2aa38c --- /dev/null +++ b/crates/yip-rendezvous/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "yip-rendezvous" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +blake2 = { workspace = true } + +[lints] +workspace = true diff --git a/crates/yip-rendezvous/src/lib.rs b/crates/yip-rendezvous/src/lib.rs new file mode 100644 index 0000000..9eddfa2 --- /dev/null +++ b/crates/yip-rendezvous/src/lib.rs @@ -0,0 +1,10 @@ +//! Rendezvous + relay control protocol shared by `yipd` (client) and the +//! `yip-rendezvous` server: node-id derivation, the wire `Message` codec, and +//! the pure server state machine. +#![forbid(unsafe_code)] + +pub mod proto; +pub mod server; + +pub use proto::{decode, encode, node_id, Message, NodeId}; +pub use server::RendezvousServer; diff --git a/crates/yip-rendezvous/src/proto.rs b/crates/yip-rendezvous/src/proto.rs new file mode 100644 index 0000000..3b3d690 --- /dev/null +++ b/crates/yip-rendezvous/src/proto.rs @@ -0,0 +1,248 @@ +//! Node-id derivation and the rendezvous wire `Message` codec. +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + +use blake2::digest::{Update, VariableOutput}; +use blake2::Blake2sVar; + +/// Domain separation so node-id can't collide with the mesh-address derivation. +const DOMAIN: &[u8] = b"yip-rdv-v1"; + +/// A rendezvous identity: `BLAKE2s(DOMAIN || pubkey)[..16]`. Distinct domain +/// from `yipd`'s `node_addr` so the two derivations never coincide. +pub type NodeId = [u8; 16]; + +/// Derive a node's rendezvous id from its X25519 public key. +pub fn node_id(pubkey: &[u8; 32]) -> NodeId { + let mut h = Blake2sVar::new(16).expect("16 is a valid blake2s output len"); + h.update(DOMAIN); + h.update(pubkey); + let mut out = [0u8; 16]; + h.finalize_variable(&mut out).expect("output len matches"); + out +} + +/// Message-type discriminants (the only permitted `as u8` in this crate). +#[repr(u8)] +enum Tag { + Register = 0, + Lookup = 1, + PeerInfo = 2, + NotFound = 3, + PunchHint = 4, + RelaySend = 5, + RelayDeliver = 6, +} + +/// A rendezvous/relay control message. See the 2b spec for direction/semantics. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Message { + Register { + node: NodeId, + }, + Lookup { + node: NodeId, + }, + PeerInfo { + node: NodeId, + reflexive: SocketAddr, + }, + NotFound { + node: NodeId, + }, + PunchHint { + node: NodeId, + reflexive: SocketAddr, + }, + RelaySend { + src: NodeId, + dst: NodeId, + payload: Vec, + }, + RelayDeliver { + src: NodeId, + payload: Vec, + }, +} + +fn put_addr(out: &mut Vec, addr: &SocketAddr) { + match addr.ip() { + IpAddr::V4(ip) => { + out.push(4); + out.extend_from_slice(&ip.octets()); + } + IpAddr::V6(ip) => { + out.push(6); + out.extend_from_slice(&ip.octets()); + } + } + out.extend_from_slice(&addr.port().to_be_bytes()); +} + +fn take_addr(buf: &[u8]) -> Option<(SocketAddr, usize)> { + let (&fam, rest) = buf.split_first()?; + let (ip, used): (IpAddr, usize) = match fam { + 4 => { + let o: [u8; 4] = rest.get(..4)?.try_into().ok()?; + (IpAddr::V4(Ipv4Addr::from(o)), 4) + } + 6 => { + let o: [u8; 16] = rest.get(..16)?.try_into().ok()?; + (IpAddr::V6(Ipv6Addr::from(o)), 16) + } + _ => return None, + }; + let port_bytes: [u8; 2] = rest.get(used..used + 2)?.try_into().ok()?; + let port = u16::from_be_bytes(port_bytes); + Some((SocketAddr::new(ip, port), 1 + used + 2)) +} + +/// Serialize `msg` onto `out` (appends; caller clears if reusing). +pub fn encode(msg: &Message, out: &mut Vec) { + match msg { + Message::Register { node } => { + out.push(Tag::Register as u8); + out.extend_from_slice(node); + } + Message::Lookup { node } => { + out.push(Tag::Lookup as u8); + out.extend_from_slice(node); + } + Message::PeerInfo { node, reflexive } => { + out.push(Tag::PeerInfo as u8); + out.extend_from_slice(node); + put_addr(out, reflexive); + } + Message::NotFound { node } => { + out.push(Tag::NotFound as u8); + out.extend_from_slice(node); + } + Message::PunchHint { node, reflexive } => { + out.push(Tag::PunchHint as u8); + out.extend_from_slice(node); + put_addr(out, reflexive); + } + Message::RelaySend { src, dst, payload } => { + out.push(Tag::RelaySend as u8); + out.extend_from_slice(src); + out.extend_from_slice(dst); + out.extend_from_slice(payload); + } + Message::RelayDeliver { src, payload } => { + out.push(Tag::RelayDeliver as u8); + out.extend_from_slice(src); + out.extend_from_slice(payload); + } + } +} + +/// Parse one datagram into a `Message`, or `None` if malformed/truncated. +pub fn decode(buf: &[u8]) -> Option { + let (&tag, rest) = buf.split_first()?; + let node16 = |b: &[u8]| -> Option { b.get(..16)?.try_into().ok() }; + match tag { + t if t == Tag::Register as u8 => Some(Message::Register { + node: node16(rest)?, + }), + t if t == Tag::Lookup as u8 => Some(Message::Lookup { + node: node16(rest)?, + }), + t if t == Tag::NotFound as u8 => Some(Message::NotFound { + node: node16(rest)?, + }), + t if t == Tag::PeerInfo as u8 => { + let node = node16(rest)?; + let (reflexive, _) = take_addr(rest.get(16..)?)?; + Some(Message::PeerInfo { node, reflexive }) + } + t if t == Tag::PunchHint as u8 => { + let node = node16(rest)?; + let (reflexive, _) = take_addr(rest.get(16..)?)?; + Some(Message::PunchHint { node, reflexive }) + } + t if t == Tag::RelaySend as u8 => { + let src = node16(rest)?; + let dst = node16(rest.get(16..)?)?; + Some(Message::RelaySend { + src, + dst, + payload: rest.get(32..)?.to_vec(), + }) + } + t if t == Tag::RelayDeliver as u8 => { + let src = node16(rest)?; + Some(Message::RelayDeliver { + src, + payload: rest.get(16..)?.to_vec(), + }) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::SocketAddr; + + #[test] + fn node_id_is_deterministic_and_16_bytes() { + let pk = [7u8; 32]; + let a = node_id(&pk); + assert_eq!(a.len(), 16); + assert_eq!(node_id(&pk), a); + assert_ne!(node_id(&pk), node_id(&[8u8; 32])); + } + + fn roundtrip(msg: Message) { + let mut buf = Vec::new(); + encode(&msg, &mut buf); + assert_eq!(decode(&buf), Some(msg)); + } + + #[test] + fn all_messages_roundtrip() { + let n = [1u8; 16]; + let v4: SocketAddr = "203.0.113.9:5000".parse().unwrap(); + let v6: SocketAddr = "[2001:db8::1]:5000".parse().unwrap(); + roundtrip(Message::Register { node: n }); + roundtrip(Message::Lookup { node: n }); + roundtrip(Message::PeerInfo { + node: n, + reflexive: v4, + }); + roundtrip(Message::PeerInfo { + node: n, + reflexive: v6, + }); + roundtrip(Message::NotFound { node: n }); + roundtrip(Message::PunchHint { + node: n, + reflexive: v4, + }); + roundtrip(Message::RelaySend { + src: [3u8; 16], + dst: n, + payload: vec![9, 8, 7], + }); + roundtrip(Message::RelayDeliver { + src: n, + payload: vec![1, 2, 3, 4], + }); + } + + #[test] + fn decode_rejects_garbage_and_truncation() { + assert_eq!(decode(&[]), None); + assert_eq!(decode(&[0xFF]), None); // unknown discriminant + let mut buf = Vec::new(); + encode( + &Message::PeerInfo { + node: [2u8; 16], + reflexive: "1.2.3.4:5".parse().unwrap(), + }, + &mut buf, + ); + buf.truncate(buf.len() - 1); + assert_eq!(decode(&buf), None); // truncated addr + } +} diff --git a/crates/yip-rendezvous/src/server.rs b/crates/yip-rendezvous/src/server.rs new file mode 100644 index 0000000..dcd8374 --- /dev/null +++ b/crates/yip-rendezvous/src/server.rs @@ -0,0 +1,342 @@ +//! Pure rendezvous/relay server state machine: soft-state registration with +//! TTL, per-source rate limiting, and blind relay forwarding. No I/O — the +//! `bin/yip-rendezvous` loop owns the socket and the clock. +use std::collections::HashMap; +use std::net::SocketAddr; + +use crate::proto::{Message, NodeId}; + +/// Registration lifetime; clients refresh well within this. +pub const REG_TTL_MS: u64 = 60_000; +/// Hard cap on concurrent registrations (memory bound). +pub const MAX_REGISTRATIONS: usize = 65_536; +/// Hard cap on distinct source addresses tracked for rate limiting (memory +/// bound). Set to 2x `MAX_REGISTRATIONS` as generous headroom for legitimate +/// distinct sources (registered peers plus in-flight lookups/relays from +/// addresses that never register) while still bounding memory against a +/// flood of packets from many distinct (or spoofed) source addresses. +pub const MAX_RATE_ENTRIES: usize = 131_072; +/// Rate-limit window and per-source message cap within it. +pub const RATE_WINDOW_MS: u64 = 1_000; +pub const MAX_MSGS_PER_WINDOW: usize = 64; + +struct Reg { + addr: SocketAddr, + expiry_ms: u64, +} + +struct Rate { + window_start_ms: u64, + count: usize, +} + +/// Soft-state rendezvous + blind relay. Keyed by `NodeId`. +pub struct RendezvousServer { + regs: HashMap, + rates: HashMap, + forwarded: u64, +} + +impl RendezvousServer { + pub fn new(_now_ms: u64) -> Self { + Self { + regs: HashMap::new(), + rates: HashMap::new(), + forwarded: 0, + } + } + + pub fn forwarded_count(&self) -> u64 { + self.forwarded + } + + /// True iff `src` is within its per-window budget (and records the hit). + fn rate_ok(&mut self, src: SocketAddr, now_ms: u64) -> bool { + // At capacity, refuse to start tracking a brand-new source rather than + // growing the map unbounded (e.g. a flood of packets from many + // distinct/spoofed addresses): treat it as over-limit and drop it. + // Actively-tracked sources are never evicted mid-window by this + // guard, and `sweep` continuously frees entries whose window has + // aged out, so capacity is self-healing under normal load. + if self.rates.len() >= MAX_RATE_ENTRIES && !self.rates.contains_key(&src) { + return false; + } + let r = self.rates.entry(src).or_insert(Rate { + window_start_ms: now_ms, + count: 0, + }); + if now_ms.saturating_sub(r.window_start_ms) >= RATE_WINDOW_MS { + r.window_start_ms = now_ms; + r.count = 0; + } + if r.count >= MAX_MSGS_PER_WINDOW { + return false; + } + r.count += 1; + true + } + + /// Evict expired registrations. Call on a timer from the socket loop. + pub fn sweep(&mut self, now_ms: u64) { + self.regs.retain(|_, reg| reg.expiry_ms > now_ms); + // Rate windows are cheap; drop stale ones opportunistically. + self.rates + .retain(|_, r| now_ms.saturating_sub(r.window_start_ms) < RATE_WINDOW_MS); + } + + /// Process one received message; return datagrams to send as `(dst, msg)`. + pub fn handle( + &mut self, + src: SocketAddr, + msg: Message, + now_ms: u64, + ) -> Vec<(SocketAddr, Message)> { + if !self.rate_ok(src, now_ms) { + return Vec::new(); + } + match msg { + Message::Register { node } => { + if self.regs.len() >= MAX_REGISTRATIONS && !self.regs.contains_key(&node) { + return Vec::new(); // at capacity; refuse new ids (existing refresh ok) + } + self.regs.insert( + node, + Reg { + addr: src, + expiry_ms: now_ms.saturating_add(REG_TTL_MS), + }, + ); + Vec::new() + } + Message::Lookup { node } => match self.regs.get(&node) { + Some(reg) if reg.expiry_ms > now_ms => { + let peer_addr = reg.addr; + let mut out = vec![( + src, + Message::PeerInfo { + node, + reflexive: peer_addr, + }, + )]; + // Tell the looked-up peer to punch back toward the requester. + out.push(( + peer_addr, + Message::PunchHint { + node, + reflexive: src, + }, + )); + out + } + _ => vec![(src, Message::NotFound { node })], + }, + Message::RelaySend { + src: sender, + dst, + payload, + } => match self.regs.get(&dst) { + Some(reg) if reg.expiry_ms > now_ms => { + self.forwarded += 1; + vec![( + reg.addr, + Message::RelayDeliver { + src: sender, + payload, + }, + )] + } + _ => Vec::new(), // dst unknown: drop + }, + // Server never receives these (they are server->client); ignore. + Message::PeerInfo { .. } + | Message::NotFound { .. } + | Message::PunchHint { .. } + | Message::RelayDeliver { .. } => Vec::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proto::{node_id, Message}; + use std::net::SocketAddr; + + fn addr(s: &str) -> SocketAddr { + s.parse().unwrap() + } + + /// Synthesize a distinct `SocketAddr` from an index, without relying on + /// string formatting (kept fast for large-`i` loops) or `as` casts. + fn synth_addr(i: u32) -> SocketAddr { + let a = u8::try_from((i >> 24) & 0xff).expect("byte in range"); + let b = u8::try_from((i >> 16) & 0xff).expect("byte in range"); + let c = u8::try_from((i >> 8) & 0xff).expect("byte in range"); + let d = u8::try_from(i & 0xff).expect("byte in range"); + SocketAddr::from((std::net::Ipv4Addr::new(a, b, c, d), 40_000)) + } + + #[test] + fn register_then_lookup_returns_observed_reflexive() { + let mut s = RendezvousServer::new(0); + let a = node_id(&[1u8; 32]); + let _b = node_id(&[2u8; 32]); // documents which peer looks A up; id itself unused + // A registers from its observed reflexive addr. + let out = s.handle(addr("198.51.100.7:41000"), Message::Register { node: a }, 0); + assert!(out.is_empty(), "register produces no reply"); + // B looks up A: gets A's reflexive via PeerInfo, and A gets a PunchHint + // carrying B's reflexive. + let out = s.handle(addr("203.0.113.9:52000"), Message::Lookup { node: a }, 10); + // one reply to B (PeerInfo), one to A (PunchHint) + assert!(out.iter().any(|(d, m)| *d == addr("203.0.113.9:52000") + && matches!(m, Message::PeerInfo { node, reflexive } if *node == a && *reflexive == addr("198.51.100.7:41000")))); + assert!(out.iter().any(|(d, m)| *d == addr("198.51.100.7:41000") + && matches!(m, Message::PunchHint { reflexive, .. } if *reflexive == addr("203.0.113.9:52000")))); + } + + #[test] + fn lookup_unregistered_returns_notfound() { + let mut s = RendezvousServer::new(0); + let a = node_id(&[1u8; 32]); + let out = s.handle(addr("203.0.113.9:52000"), Message::Lookup { node: a }, 0); + assert_eq!( + out, + vec![(addr("203.0.113.9:52000"), Message::NotFound { node: a })] + ); + } + + #[test] + fn ttl_expiry_evicts_registration() { + let mut s = RendezvousServer::new(0); + let a = node_id(&[1u8; 32]); + s.handle(addr("198.51.100.7:41000"), Message::Register { node: a }, 0); + s.sweep(REG_TTL_MS + 1); + let out = s.handle( + addr("203.0.113.9:52000"), + Message::Lookup { node: a }, + REG_TTL_MS + 2, + ); + assert!(matches!(out.as_slice(), [(_, Message::NotFound { .. })])); + } + + #[test] + fn relay_forwards_to_registered_dst_and_counts() { + let mut s = RendezvousServer::new(0); + let a = node_id(&[1u8; 32]); + let b = node_id(&[2u8; 32]); + s.handle(addr("198.51.100.7:41000"), Message::Register { node: a }, 0); // A registered + // B relays a payload to A -> A gets RelayDeliver{src=B, payload}. + let out = s.handle( + addr("203.0.113.9:52000"), + Message::RelaySend { + src: b, + dst: a, + payload: vec![9, 9], + }, + 5, + ); + assert_eq!( + out, + vec![( + addr("198.51.100.7:41000"), + Message::RelayDeliver { + src: b, + payload: vec![9, 9] + } + )] + ); + assert_eq!(s.forwarded_count(), 1); + } + + #[test] + fn relay_to_unregistered_dst_drops_no_forward() { + let mut s = RendezvousServer::new(0); + let a = node_id(&[1u8; 32]); + let b = node_id(&[2u8; 32]); + let out = s.handle( + addr("203.0.113.9:52000"), + Message::RelaySend { + src: b, + dst: a, + payload: vec![1], + }, + 0, + ); + assert!(out.is_empty()); + assert_eq!(s.forwarded_count(), 0); + } + + #[test] + fn rate_limit_caps_messages_per_source_window() { + let mut s = RendezvousServer::new(0); + let a = node_id(&[1u8; 32]); + let src = addr("203.0.113.9:52000"); + // Exceed the per-window cap; excess Lookups must produce no replies. + let mut replies = 0; + for _ in 0..(MAX_MSGS_PER_WINDOW + 10) { + replies += s.handle(src, Message::Lookup { node: a }, 0).len(); + } + // Only up to the cap are serviced (each serviced Lookup -> 1 NotFound). + assert!( + replies <= MAX_MSGS_PER_WINDOW, + "rate limit must drop excess" + ); + } + + #[test] + fn rates_map_grows_with_distinct_sources_but_stays_within_cap() { + let mut s = RendezvousServer::new(0); + let a = node_id(&[1u8; 32]); + // Comfortably below MAX_RATE_ENTRIES: exercises normal growth and + // confirms the map only ever holds one entry per distinct source. + for i in 0..2_000u32 { + s.handle(synth_addr(i), Message::Lookup { node: a }, 0); + } + assert_eq!(s.rates.len(), 2_000); + assert!(s.rates.len() <= MAX_RATE_ENTRIES); + } + + #[test] + fn rate_capacity_guard_blocks_new_source_but_services_existing() { + let mut s = RendezvousServer::new(0); + // Pre-fill the rates map to capacity with dummy tracked sources via + // direct field access (same module) -- a 131_072-iteration `handle` + // loop would be needlessly slow; this exercises the same guard. + for i in 0..MAX_RATE_ENTRIES { + let idx = u32::try_from(i).expect("index fits u32"); + s.rates.insert( + synth_addr(idx), + Rate { + window_start_ms: 0, + count: 0, + }, + ); + } + assert_eq!(s.rates.len(), MAX_RATE_ENTRIES); + + let a = node_id(&[1u8; 32]); + + // A brand-new source arriving while at capacity must be treated as + // rate-limited (dropped) rather than growing the map further. Without + // the capacity guard this Lookup would be serviced (regs is empty, + // so it would return a NotFound reply) and the map would grow past + // the cap. + let new_src = addr("198.51.100.50:9000"); + let out = s.handle(new_src, Message::Lookup { node: a }, 0); + assert!(out.is_empty(), "new source over capacity must be dropped"); + assert_eq!( + s.rates.len(), + MAX_RATE_ENTRIES, + "map must not grow past the cap" + ); + + // An already-tracked source must still be serviced normally even + // while the map is at capacity. + let existing_src = synth_addr(0); + let out = s.handle(existing_src, Message::Lookup { node: a }, 0); + assert!( + !out.is_empty(), + "already-tracked source must still be serviced" + ); + } +} diff --git a/docs/superpowers/plans/2026-07-06-rendezvous-nat-traversal-2b.md b/docs/superpowers/plans/2026-07-06-rendezvous-nat-traversal-2b.md new file mode 100644 index 0000000..715de2f --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-rendezvous-nat-traversal-2b.md @@ -0,0 +1,1344 @@ +# Milestone 2b: Rendezvous + NAT Traversal + Relay — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let two NATed yip peers reach each other with no pre-arranged endpoint — discover each peer's reflexive address via a configured rendezvous server, UDP hole-punch a direct path, and fall back to a ciphertext-blind relay when the punch fails. + +**Architecture:** A new shared `crates/yip-rendezvous` library holds the wire protocol (`node_id` + `Message` codec) and a pure `RendezvousServer` state machine. A thin `bin/yip-rendezvous` binary drives that state machine over a plain `UdpSocket`. In `yipd`, a `Rendezvous` trait (impl `ConfiguredServerRendezvous`) plus a per-peer path state machine (`Direct → Punch → Relay`, `path.rs`) layer onto 2a's lazy handshake inside `PeerManager`. Learned endpoints are only handshake-probe candidates — an established session's egress commits only after a Noise handshake completes over the path (anti-hijack). + +**Tech Stack:** Rust, `blake2` (=0.10.6, node_id), `std::net::UdpSocket` (server loop), the existing `yip-io` `Dispatch` seam, `snow`/`yip-crypto` Noise-IK (unchanged), netns + `iptables MASQUERADE` for NAT simulation. + +## Global Constraints + +- `yipd` and `yip-rendezvous` stay `#![forbid(unsafe_code)]`; `unsafe` only in `yip-io`/`yip-device`. +- No `as` numeric casts except a message-type/`PacketType` discriminant `as u8`. +- **Anti-hijack invariant:** a rendezvous/punch-learned address is only ever a handshake-probe target; an `Established` session's egress is never redirected to a new address without a fresh completed handshake over it. +- **No data-plane wire regression:** the 2a single-peer netns tests (`ping_across_yipd_tunnel`, `ping_across_yipd_tunnel_under_loss`, `arq_recovers_bulk_loss`, `l2_tap_ping_or_arp_across_tunnel`) and `triangle_full_mesh_ping` stay green under BOTH `poll` and `YIP_USE_URING=1`. +- The `arq_recovers_bulk_loss` netns test runs the **release** `yipd` (debug RaptorQ is ~75× slower) — rebuild `--release` after any `yipd` change before running it. +- `node_id(pubkey) = BLAKE2s("yip-rdv-v1" || pubkey)[..16]` — exact domain string, 16-byte output. +- Green bar every task: `cargo fmt --all --check`, `cargo build --workspace`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test -p `. +- Deferred / non-goals (do NOT build): UPnP/NAT-PMP/PCP + NAT-type classification (2b.1), ICE parallel candidate racing, discovery/DHT (2c), handshake anti-replay (#34), anti-DPI obfuscation of the new framing (#3), metadata-privacy tokens, federated/discoverable relay network. + +--- + +## File Structure + +- `crates/yip-rendezvous/` (NEW lib crate) + - `src/lib.rs` — crate root, re-exports. + - `src/proto.rs` — `NodeId`, `node_id(pubkey)`, `Message` enum + `encode`/`decode`. + - `src/server.rs` — `RendezvousServer` pure state machine (registration TTL map, rate limit, forward counter). +- `bin/yip-rendezvous/` (NEW bin crate) — `src/main.rs`: `UdpSocket` loop driving `RendezvousServer`. +- `bin/yipd/src/rendezvous.rs` (NEW) — `Rendezvous` trait + `ConfiguredServerRendezvous` + `RdvEvent`. +- `bin/yipd/src/path.rs` (NEW) — per-peer path state machine (`PathStage`/`PathKind`/`PathState`). +- `bin/yipd/src/config.rs` (MODIFY) — `rendezvous: Option`; `PeerConfig.endpoint: Option`. +- `bin/yipd/src/peer_manager.rs` (MODIFY) — server-addr demux, path SM in `on_udp`/`on_tun`/`tick`, relay egress. +- `bin/yipd/src/tunnel.rs` (MODIFY) — build the rendezvous client from `config.rendezvous`, pass into `PeerManager`. +- `bin/yipd/src/main.rs` (MODIFY) — `mod rendezvous; mod path;`. +- `bin/yipd/tests/{run-netns-relay.sh,run-netns-punch.sh}` (NEW) + `tunnel_netns.rs` (MODIFY) + `.github/workflows/integration.yml` (MODIFY). + +--- + +### Task 1: `yip-rendezvous` wire protocol (`proto.rs`) + +**Files:** +- Create: `crates/yip-rendezvous/Cargo.toml`, `crates/yip-rendezvous/src/lib.rs`, `crates/yip-rendezvous/src/proto.rs` +- Test: inline `#[cfg(test)]` in `proto.rs` + +**Interfaces:** +- Produces: + - `pub type NodeId = [u8; 16];` + - `pub fn node_id(pubkey: &[u8; 32]) -> NodeId` — `BLAKE2s("yip-rdv-v1" || pubkey)[..16]`. + - `pub enum Message { Register { node: NodeId }, Lookup { node: NodeId }, PeerInfo { node: NodeId, reflexive: SocketAddr }, NotFound { node: NodeId }, PunchHint { node: NodeId, reflexive: SocketAddr }, RelaySend { src: NodeId, dst: NodeId, payload: Vec }, RelayDeliver { src: NodeId, payload: Vec } }` + - `RelaySend` carries **both** the sender's and destination's node ids: the server can't derive a sender's `NodeId` from its UDP address, so it copies `src` into the `RelayDeliver` it forwards, giving the receiver the origin id to reply through the relay. The sender knows both ids (its own key + the peer's configured key). + - `pub fn encode(msg: &Message, out: &mut Vec)` and `pub fn decode(buf: &[u8]) -> Option`. + +- [ ] **Step 1: Create the crate.** `crates/yip-rendezvous/Cargo.toml`: + +```toml +[package] +name = "yip-rendezvous" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +blake2 = { workspace = true } + +[lints] +workspace = true +``` + +`crates/yip-rendezvous/src/lib.rs`: + +```rust +//! Rendezvous + relay control protocol shared by `yipd` (client) and the +//! `yip-rendezvous` server: node-id derivation, the wire `Message` codec, and +//! the pure server state machine. +#![forbid(unsafe_code)] + +pub mod proto; +pub mod server; + +pub use proto::{decode, encode, node_id, Message, NodeId}; +pub use server::RendezvousServer; +``` + +- [ ] **Step 2: Write failing tests** in `crates/yip-rendezvous/src/proto.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use std::net::SocketAddr; + + #[test] + fn node_id_is_deterministic_and_16_bytes() { + let pk = [7u8; 32]; + let a = node_id(&pk); + assert_eq!(a.len(), 16); + assert_eq!(node_id(&pk), a); + assert_ne!(node_id(&pk), node_id(&[8u8; 32])); + } + + fn roundtrip(msg: Message) { + let mut buf = Vec::new(); + encode(&msg, &mut buf); + assert_eq!(decode(&buf), Some(msg)); + } + + #[test] + fn all_messages_roundtrip() { + let n = [1u8; 16]; + let v4: SocketAddr = "203.0.113.9:5000".parse().unwrap(); + let v6: SocketAddr = "[2001:db8::1]:5000".parse().unwrap(); + roundtrip(Message::Register { node: n }); + roundtrip(Message::Lookup { node: n }); + roundtrip(Message::PeerInfo { node: n, reflexive: v4 }); + roundtrip(Message::PeerInfo { node: n, reflexive: v6 }); + roundtrip(Message::NotFound { node: n }); + roundtrip(Message::PunchHint { node: n, reflexive: v4 }); + roundtrip(Message::RelaySend { src: [3u8; 16], dst: n, payload: vec![9, 8, 7] }); + roundtrip(Message::RelayDeliver { src: n, payload: vec![1, 2, 3, 4] }); + } + + #[test] + fn decode_rejects_garbage_and_truncation() { + assert_eq!(decode(&[]), None); + assert_eq!(decode(&[0xFF]), None); // unknown discriminant + let mut buf = Vec::new(); + encode(&Message::PeerInfo { node: [2u8; 16], reflexive: "1.2.3.4:5".parse().unwrap() }, &mut buf); + buf.truncate(buf.len() - 1); + assert_eq!(decode(&buf), None); // truncated addr + } +} +``` + +- [ ] **Step 3: Run tests → fail.** `cargo test -p yip-rendezvous` → FAIL (module `proto` empty / functions missing). + +- [ ] **Step 4: Implement `proto.rs`.** Prepend to the test module: + +```rust +//! Node-id derivation and the rendezvous wire `Message` codec. +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + +use blake2::digest::{Update, VariableOutput}; +use blake2::Blake2sVar; + +/// Domain separation so node-id can't collide with the mesh-address derivation. +const DOMAIN: &[u8] = b"yip-rdv-v1"; + +/// A rendezvous identity: `BLAKE2s(DOMAIN || pubkey)[..16]`. Distinct domain +/// from `yipd`'s `node_addr` so the two derivations never coincide. +pub type NodeId = [u8; 16]; + +/// Derive a node's rendezvous id from its X25519 public key. +pub fn node_id(pubkey: &[u8; 32]) -> NodeId { + let mut h = Blake2sVar::new(16).expect("16 is a valid blake2s output len"); + h.update(DOMAIN); + h.update(pubkey); + let mut out = [0u8; 16]; + h.finalize_variable(&mut out).expect("output len matches"); + out +} + +/// Message-type discriminants (the only permitted `as u8` in this crate). +#[repr(u8)] +enum Tag { + Register = 0, + Lookup = 1, + PeerInfo = 2, + NotFound = 3, + PunchHint = 4, + RelaySend = 5, + RelayDeliver = 6, +} + +/// A rendezvous/relay control message. See the 2b spec for direction/semantics. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Message { + Register { node: NodeId }, + Lookup { node: NodeId }, + PeerInfo { node: NodeId, reflexive: SocketAddr }, + NotFound { node: NodeId }, + PunchHint { node: NodeId, reflexive: SocketAddr }, + RelaySend { dst: NodeId, payload: Vec }, + RelayDeliver { src: NodeId, payload: Vec }, +} + +fn put_addr(out: &mut Vec, addr: &SocketAddr) { + match addr.ip() { + IpAddr::V4(ip) => { + out.push(4); + out.extend_from_slice(&ip.octets()); + } + IpAddr::V6(ip) => { + out.push(6); + out.extend_from_slice(&ip.octets()); + } + } + out.extend_from_slice(&addr.port().to_be_bytes()); +} + +fn take_addr(buf: &[u8]) -> Option<(SocketAddr, usize)> { + let (&fam, rest) = buf.split_first()?; + let (ip, used): (IpAddr, usize) = match fam { + 4 => { + let o: [u8; 4] = rest.get(..4)?.try_into().ok()?; + (IpAddr::V4(Ipv4Addr::from(o)), 4) + } + 6 => { + let o: [u8; 16] = rest.get(..16)?.try_into().ok()?; + (IpAddr::V6(Ipv6Addr::from(o)), 16) + } + _ => return None, + }; + let port_bytes: [u8; 2] = rest.get(used..used + 2)?.try_into().ok()?; + let port = u16::from_be_bytes(port_bytes); + Some((SocketAddr::new(ip, port), 1 + used + 2)) +} + +/// Serialize `msg` onto `out` (appends; caller clears if reusing). +pub fn encode(msg: &Message, out: &mut Vec) { + match msg { + Message::Register { node } => { + out.push(Tag::Register as u8); + out.extend_from_slice(node); + } + Message::Lookup { node } => { + out.push(Tag::Lookup as u8); + out.extend_from_slice(node); + } + Message::PeerInfo { node, reflexive } => { + out.push(Tag::PeerInfo as u8); + out.extend_from_slice(node); + put_addr(out, reflexive); + } + Message::NotFound { node } => { + out.push(Tag::NotFound as u8); + out.extend_from_slice(node); + } + Message::PunchHint { node, reflexive } => { + out.push(Tag::PunchHint as u8); + out.extend_from_slice(node); + put_addr(out, reflexive); + } + Message::RelaySend { src, dst, payload } => { + out.push(Tag::RelaySend as u8); + out.extend_from_slice(src); + out.extend_from_slice(dst); + out.extend_from_slice(payload); + } + Message::RelayDeliver { src, payload } => { + out.push(Tag::RelayDeliver as u8); + out.extend_from_slice(src); + out.extend_from_slice(payload); + } + } +} + +/// Parse one datagram into a `Message`, or `None` if malformed/truncated. +pub fn decode(buf: &[u8]) -> Option { + let (&tag, rest) = buf.split_first()?; + let node16 = |b: &[u8]| -> Option { b.get(..16)?.try_into().ok() }; + match tag { + t if t == Tag::Register as u8 => Some(Message::Register { node: node16(rest)? }), + t if t == Tag::Lookup as u8 => Some(Message::Lookup { node: node16(rest)? }), + t if t == Tag::NotFound as u8 => Some(Message::NotFound { node: node16(rest)? }), + t if t == Tag::PeerInfo as u8 => { + let node = node16(rest)?; + let (reflexive, _) = take_addr(rest.get(16..)?)?; + Some(Message::PeerInfo { node, reflexive }) + } + t if t == Tag::PunchHint as u8 => { + let node = node16(rest)?; + let (reflexive, _) = take_addr(rest.get(16..)?)?; + Some(Message::PunchHint { node, reflexive }) + } + t if t == Tag::RelaySend as u8 => { + let src = node16(rest)?; + let dst = node16(rest.get(16..)?)?; + Some(Message::RelaySend { src, dst, payload: rest.get(32..)?.to_vec() }) + } + t if t == Tag::RelayDeliver as u8 => { + let src = node16(rest)?; + Some(Message::RelayDeliver { src, payload: rest.get(16..)?.to_vec() }) + } + _ => None, + } +} +``` + +- [ ] **Step 5: Run tests → pass; build/clippy/fmt clean.** + +```bash +cargo test -p yip-rendezvous +cargo clippy -p yip-rendezvous --all-targets -- -D warnings && cargo fmt --all --check +``` +Expected: all tests pass, no warnings. + +- [ ] **Step 6: Commit.** + +```bash +git add crates/yip-rendezvous/Cargo.toml crates/yip-rendezvous/src/lib.rs crates/yip-rendezvous/src/proto.rs Cargo.lock +git commit -m "feat(yip-rendezvous): node-id + wire message codec (2b)" +``` + +--- + +### Task 2: `RendezvousServer` state machine (`server.rs`) + +**Files:** +- Create: `crates/yip-rendezvous/src/server.rs` +- Test: inline `#[cfg(test)]` in `server.rs` + +**Interfaces:** +- Consumes: `crate::proto::{Message, NodeId}`. +- Produces: + - `pub struct RendezvousServer { /* private */ }` + - `pub fn new(now_ms: u64) -> Self` — `now_ms` seeds rate-limit windows. + - `pub fn handle(&mut self, src: SocketAddr, msg: Message, now_ms: u64) -> Vec<(SocketAddr, Message)>` — process one message, return `(dst_addr, reply)` datagrams to send. + - `pub fn sweep(&mut self, now_ms: u64)` — evict expired registrations (called on a timer). + - `pub fn forwarded_count(&self) -> u64` — relay datagrams forwarded (test observability). + - Constants: `REG_TTL_MS = 60_000`, `MAX_REGISTRATIONS = 65_536`, `RATE_WINDOW_MS = 1_000`, `MAX_MSGS_PER_WINDOW = 64`. + +- [ ] **Step 1: Write failing tests** in `crates/yip-rendezvous/src/server.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use crate::proto::{node_id, Message}; + use std::net::SocketAddr; + + fn addr(s: &str) -> SocketAddr { s.parse().unwrap() } + + #[test] + fn register_then_lookup_returns_observed_reflexive() { + let mut s = RendezvousServer::new(0); + let a = node_id(&[1u8; 32]); + let b = node_id(&[2u8; 32]); + // A registers from its observed reflexive addr. + let out = s.handle(addr("198.51.100.7:41000"), Message::Register { node: a }, 0); + assert!(out.is_empty(), "register produces no reply"); + // B looks up A: gets A's reflexive via PeerInfo, and A gets a PunchHint + // carrying B's reflexive. + let out = s.handle(addr("203.0.113.9:52000"), Message::Lookup { node: a }, 10); + // one reply to B (PeerInfo), one to A (PunchHint) + assert!(out.iter().any(|(d, m)| *d == addr("203.0.113.9:52000") + && matches!(m, Message::PeerInfo { node, reflexive } if *node == a && *reflexive == addr("198.51.100.7:41000")))); + assert!(out.iter().any(|(d, m)| *d == addr("198.51.100.7:41000") + && matches!(m, Message::PunchHint { reflexive, .. } if *reflexive == addr("203.0.113.9:52000")))); + } + + #[test] + fn lookup_unregistered_returns_notfound() { + let mut s = RendezvousServer::new(0); + let a = node_id(&[1u8; 32]); + let out = s.handle(addr("203.0.113.9:52000"), Message::Lookup { node: a }, 0); + assert_eq!(out, vec![(addr("203.0.113.9:52000"), Message::NotFound { node: a })]); + } + + #[test] + fn ttl_expiry_evicts_registration() { + let mut s = RendezvousServer::new(0); + let a = node_id(&[1u8; 32]); + s.handle(addr("198.51.100.7:41000"), Message::Register { node: a }, 0); + s.sweep(REG_TTL_MS + 1); + let out = s.handle(addr("203.0.113.9:52000"), Message::Lookup { node: a }, REG_TTL_MS + 2); + assert!(matches!(out.as_slice(), [(_, Message::NotFound { .. })])); + } + + #[test] + fn relay_forwards_to_registered_dst_and_counts() { + let mut s = RendezvousServer::new(0); + let a = node_id(&[1u8; 32]); + let b = node_id(&[2u8; 32]); + s.handle(addr("198.51.100.7:41000"), Message::Register { node: a }, 0); // A registered + // B relays a payload to A -> A gets RelayDeliver{src=B, payload}. + let out = s.handle(addr("203.0.113.9:52000"), + Message::RelaySend { src: b, dst: a, payload: vec![9, 9] }, 5); + assert_eq!(out, vec![(addr("198.51.100.7:41000"), + Message::RelayDeliver { src: b, payload: vec![9, 9] })]); + assert_eq!(s.forwarded_count(), 1); + } + + #[test] + fn relay_to_unregistered_dst_drops_no_forward() { + let mut s = RendezvousServer::new(0); + let a = node_id(&[1u8; 32]); + let b = node_id(&[2u8; 32]); + let out = s.handle(addr("203.0.113.9:52000"), + Message::RelaySend { src: b, dst: a, payload: vec![1] }, 0); + assert!(out.is_empty()); + assert_eq!(s.forwarded_count(), 0); + } + + #[test] + fn rate_limit_caps_messages_per_source_window() { + let mut s = RendezvousServer::new(0); + let a = node_id(&[1u8; 32]); + let src = addr("203.0.113.9:52000"); + // Exceed the per-window cap; excess Lookups must produce no replies. + let mut replies = 0; + for _ in 0..(MAX_MSGS_PER_WINDOW + 10) { + replies += s.handle(src, Message::Lookup { node: a }, 0).len(); + } + // Only up to the cap are serviced (each serviced Lookup -> 1 NotFound). + assert!(replies <= MAX_MSGS_PER_WINDOW, "rate limit must drop excess"); + } +} +``` + +- [ ] **Step 2: Run server tests → fail**, then implement `server.rs`. Prepend: + +```rust +//! Pure rendezvous/relay server state machine: soft-state registration with +//! TTL, per-source rate limiting, and blind relay forwarding. No I/O — the +//! `bin/yip-rendezvous` loop owns the socket and the clock. +use std::collections::HashMap; +use std::net::SocketAddr; + +use crate::proto::{Message, NodeId}; + +/// Registration lifetime; clients refresh well within this. +pub const REG_TTL_MS: u64 = 60_000; +/// Hard cap on concurrent registrations (memory bound). +pub const MAX_REGISTRATIONS: usize = 65_536; +/// Rate-limit window and per-source message cap within it. +pub const RATE_WINDOW_MS: u64 = 1_000; +pub const MAX_MSGS_PER_WINDOW: usize = 64; + +struct Reg { + addr: SocketAddr, + expiry_ms: u64, +} + +struct Rate { + window_start_ms: u64, + count: usize, +} + +/// Soft-state rendezvous + blind relay. Keyed by `NodeId`. +pub struct RendezvousServer { + regs: HashMap, + rates: HashMap, + forwarded: u64, +} + +impl RendezvousServer { + pub fn new(_now_ms: u64) -> Self { + Self { regs: HashMap::new(), rates: HashMap::new(), forwarded: 0 } + } + + pub fn forwarded_count(&self) -> u64 { + self.forwarded + } + + /// True iff `src` is within its per-window budget (and records the hit). + fn rate_ok(&mut self, src: SocketAddr, now_ms: u64) -> bool { + let r = self.rates.entry(src).or_insert(Rate { window_start_ms: now_ms, count: 0 }); + if now_ms.saturating_sub(r.window_start_ms) >= RATE_WINDOW_MS { + r.window_start_ms = now_ms; + r.count = 0; + } + if r.count >= MAX_MSGS_PER_WINDOW { + return false; + } + r.count += 1; + true + } + + /// Evict expired registrations. Call on a timer from the socket loop. + pub fn sweep(&mut self, now_ms: u64) { + self.regs.retain(|_, reg| reg.expiry_ms > now_ms); + // Rate windows are cheap; drop stale ones opportunistically. + self.rates.retain(|_, r| now_ms.saturating_sub(r.window_start_ms) < RATE_WINDOW_MS); + } + + /// Process one received message; return datagrams to send as `(dst, msg)`. + pub fn handle(&mut self, src: SocketAddr, msg: Message, now_ms: u64) -> Vec<(SocketAddr, Message)> { + if !self.rate_ok(src, now_ms) { + return Vec::new(); + } + match msg { + Message::Register { node } => { + if self.regs.len() >= MAX_REGISTRATIONS && !self.regs.contains_key(&node) { + return Vec::new(); // at capacity; refuse new ids (existing refresh ok) + } + self.regs.insert(node, Reg { addr: src, expiry_ms: now_ms.saturating_add(REG_TTL_MS) }); + Vec::new() + } + Message::Lookup { node } => match self.regs.get(&node) { + Some(reg) if reg.expiry_ms > now_ms => { + let peer_addr = reg.addr; + let mut out = vec![(src, Message::PeerInfo { node, reflexive: peer_addr })]; + // Tell the looked-up peer to punch back toward the requester. + out.push((peer_addr, Message::PunchHint { node, reflexive: src })); + out + } + _ => vec![(src, Message::NotFound { node })], + }, + Message::RelaySend { src: sender, dst, payload } => match self.regs.get(&dst) { + Some(reg) if reg.expiry_ms > now_ms => { + self.forwarded += 1; + vec![(reg.addr, Message::RelayDeliver { src: sender, payload })] + } + _ => Vec::new(), // dst unknown: drop + }, + // Server never receives these (they are server->client); ignore. + Message::PeerInfo { .. } + | Message::NotFound { .. } + | Message::PunchHint { .. } + | Message::RelayDeliver { .. } => Vec::new(), + } + } +} +``` + +(The consts `REG_TTL_MS`/`MAX_MSGS_PER_WINDOW` are `pub` on the `server` module and reachable in the inline tests via `use super::*`.) + +- [ ] **Step 3: Run tests → pass; build/clippy/fmt clean.** + +```bash +cargo test -p yip-rendezvous +cargo clippy -p yip-rendezvous --all-targets -- -D warnings && cargo fmt --all --check +``` + +- [ ] **Step 4: Commit.** + +```bash +git add crates/yip-rendezvous/src/server.rs crates/yip-rendezvous/src/proto.rs +git commit -m "feat(yip-rendezvous): server state machine — registration TTL, rate limit, blind relay (2b)" +``` + +--- + +### Task 3: `bin/yip-rendezvous` server binary + socket smoke + +**Files:** +- Create: `bin/yip-rendezvous/Cargo.toml`, `bin/yip-rendezvous/src/main.rs` +- Test: `bin/yip-rendezvous/tests/smoke.rs` + +**Interfaces:** +- Consumes: `yip_rendezvous::{decode, encode, RendezvousServer, Message, node_id}`. +- Produces: a runnable binary `yip-rendezvous ` (e.g. `yip-rendezvous 0.0.0.0:51821`). + +- [ ] **Step 1: Create the crate.** `bin/yip-rendezvous/Cargo.toml`: + +```toml +[package] +name = "yip-rendezvous-bin" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "yip-rendezvous" +path = "src/main.rs" + +[dependencies] +yip-rendezvous = { path = "../../crates/yip-rendezvous" } + +[lints] +workspace = true +``` + +- [ ] **Step 2: Write the failing smoke test** `bin/yip-rendezvous/tests/smoke.rs`: + +```rust +//! Socket-level smoke: spawn the server, register from one socket, look up from +//! another, and relay a payload — asserting the observed reflexive addr and the +//! blind forward both work over real UDP. +use std::net::UdpSocket; +use std::process::{Child, Command}; +use std::time::Duration; + +use yip_rendezvous::{decode, encode, node_id, Message}; + +fn spawn_server(listen: &str) -> Child { + Command::new(env!("CARGO_BIN_EXE_yip-rendezvous")) + .arg(listen) + .spawn() + .expect("spawn server") +} + +#[test] +fn register_lookup_relay_over_udp() { + let listen = "127.0.0.1:51821"; + let mut server = spawn_server(listen); + std::thread::sleep(Duration::from_millis(300)); // let it bind + + let a = UdpSocket::bind("127.0.0.1:0").unwrap(); + a.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let b = UdpSocket::bind("127.0.0.1:0").unwrap(); + b.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + + let a_id = node_id(&[1u8; 32]); + let b_id = node_id(&[2u8; 32]); + + // A registers. + let mut buf = Vec::new(); + encode(&Message::Register { node: a_id }, &mut buf); + a.send_to(&buf, listen).unwrap(); + std::thread::sleep(Duration::from_millis(100)); + + // B looks up A -> expects PeerInfo(A, A's reflexive addr). + buf.clear(); + encode(&Message::Lookup { node: a_id }, &mut buf); + b.send_to(&buf, listen).unwrap(); + let mut rx = [0u8; 2048]; + let (n, _) = b.recv_from(&mut rx).expect("B receives PeerInfo"); + match decode(&rx[..n]) { + Some(Message::PeerInfo { node, reflexive }) => { + assert_eq!(node, a_id); + assert_eq!(reflexive, a.local_addr().unwrap()); + } + other => panic!("expected PeerInfo, got {other:?}"), + } + + // B relays a payload to A -> A receives RelayDeliver{src=B, payload}. + buf.clear(); + encode(&Message::RelaySend { src: b_id, dst: a_id, payload: vec![7, 7, 7] }, &mut buf); + b.send_to(&buf, listen).unwrap(); + let (n, _) = a.recv_from(&mut rx).expect("A receives RelayDeliver"); + match decode(&rx[..n]) { + Some(Message::RelayDeliver { src, payload }) => { + assert_eq!(src, b_id); + assert_eq!(payload, vec![7, 7, 7]); + } + other => panic!("expected RelayDeliver, got {other:?}"), + } + + let _ = server.kill(); +} +``` + +- [ ] **Step 3: Run → fail** (`cargo test -p yip-rendezvous-bin` — binary missing). + +- [ ] **Step 4: Implement `bin/yip-rendezvous/src/main.rs`:** + +```rust +//! The yip rendezvous + blind relay server. Binds one UDP socket, drives the +//! pure `RendezvousServer` state machine, and sweeps expired registrations on a +//! read-timeout cadence. No TUN, no tunnel keys, no unsafe. +#![forbid(unsafe_code)] + +use std::net::UdpSocket; +use std::time::{Duration, Instant}; + +use yip_rendezvous::{decode, encode, Message, RendezvousServer}; + +const SWEEP_INTERVAL: Duration = Duration::from_secs(5); + +fn main() -> std::io::Result<()> { + let mut args = std::env::args(); + let _prog = args.next(); + let listen = match args.next().as_deref() { + Some("--version") | Some("-V") => { + println!("yip-rendezvous {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + Some(addr) => addr.to_string(), + None => { + eprintln!("usage: yip-rendezvous e.g. 0.0.0.0:51821"); + std::process::exit(2); + } + }; + + let sock = UdpSocket::bind(&listen)?; + sock.set_read_timeout(Some(SWEEP_INTERVAL))?; + eprintln!("yip-rendezvous listening on {listen}"); + + // Millisecond clock from a monotonic base (Instant), so `now_ms` never goes + // backwards and needs no wall clock. + let base = Instant::now(); + let now_ms = |base: Instant| -> u64 { + u64::try_from(base.elapsed().as_millis()).unwrap_or(u64::MAX) + }; + + let mut server = RendezvousServer::new(now_ms(base)); + let mut last_sweep = Instant::now(); + let mut rx = [0u8; 2048]; + let mut out = Vec::new(); + + loop { + match sock.recv_from(&mut rx) { + Ok((n, src)) => { + if let Some(msg) = decode(&rx[..n]) { + for (dst, reply) in server.handle(src, msg, now_ms(base)) { + out.clear(); + encode(&reply, &mut out); + let _ = sock.send_to(&out, dst); // best-effort; drop on error + } + } + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => {} + Err(e) => return Err(e), + } + if last_sweep.elapsed() >= SWEEP_INTERVAL { + server.sweep(now_ms(base)); + last_sweep = Instant::now(); + } + } +} +``` + +- [ ] **Step 5: Run → pass; build/clippy/fmt clean.** `cargo test -p yip-rendezvous-bin` (the smoke spawns a real server). If it flakes on bind timing, raise the initial sleep. Confirm `cargo build --workspace` includes the new binary. + +- [ ] **Step 6: Commit.** + +```bash +git add bin/yip-rendezvous/Cargo.toml bin/yip-rendezvous/src/main.rs bin/yip-rendezvous/tests/smoke.rs Cargo.lock +git commit -m "feat(yip-rendezvous): server binary + UDP register/lookup/relay smoke (2b)" +``` + +--- + +### Task 4: `Rendezvous` trait + `ConfiguredServerRendezvous` client + config + +**Files:** +- Create: `bin/yipd/src/rendezvous.rs` +- Modify: `bin/yipd/src/config.rs` (add `rendezvous`, make `PeerConfig.endpoint` optional), `bin/yipd/src/main.rs` (`mod rendezvous;`), `bin/yipd/Cargo.toml` (dep on `yip-rendezvous`) +- Test: inline `#[cfg(test)]` in both files + +**Interfaces:** +- Consumes: `yip_rendezvous::{node_id, encode, decode, Message, NodeId}`, `yip_io::poll::EgressDatagram`. +- Produces: + - `pub enum RdvEvent { PeerCandidate { node: NodeId, addr: SocketAddr }, PunchTo { node: NodeId, addr: SocketAddr }, Relayed { src: NodeId, payload: Vec }, NotFound { node: NodeId }, Ignored }` + - `pub trait Rendezvous { fn register(&mut self, node: NodeId) -> EgressDatagram; fn lookup(&mut self, node: NodeId) -> EgressDatagram; fn relay(&mut self, src: NodeId, dst: NodeId, payload: &[u8]) -> EgressDatagram; fn parse(&self, dg: &[u8]) -> RdvEvent; fn server_addr(&self) -> SocketAddr; }` + - `pub struct ConfiguredServerRendezvous { server: SocketAddr }` + `pub fn new(server: SocketAddr) -> Self`. + - `config::Config.rendezvous: Option`, `config::PeerConfig.endpoint: Option`. + +- [ ] **Step 1: Make `PeerConfig.endpoint` optional + add `rendezvous`.** In `bin/yipd/src/config.rs`: change `pub endpoint: SocketAddr` → `pub endpoint: Option`; add `pub rendezvous: Option` to `Config`. In the parser: a `[peer]` block without an `endpoint`/`peer_endpoint` key yields `endpoint: None` (do NOT error); add a top-level `rendezvous=` key parsed into `Config.rendezvous` (absent → `None`). Update all existing `PeerConfig { endpoint: X }` literals in this file's tests to `endpoint: Some(X)`. + +- [ ] **Step 2: Write failing config tests** (append to `config.rs` tests): + +```rust +#[test] +fn parses_rendezvous_and_optional_endpoint() { + let text = "local_private=00000000000000000000000000000000000000000000000000000000000000ff\n\ + local_public=000000000000000000000000000000000000000000000000000000000000aa01\n\ + listen=0.0.0.0:51820\ndevice=yip0\nrendezvous=203.0.113.1:51821\n\ + [peer]\npublic_key=00000000000000000000000000000000000000000000000000000000000000b1\n"; + let cfg = Config::parse(text).expect("parses"); + assert_eq!(cfg.rendezvous, Some("203.0.113.1:51821".parse().unwrap())); + assert_eq!(cfg.peers.len(), 1); + assert_eq!(cfg.peers[0].endpoint, None, "peer with no endpoint is rendezvous-only"); +} + +#[test] +fn rendezvous_absent_is_none() { + 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"; + let cfg = Config::parse(text).unwrap(); + assert_eq!(cfg.rendezvous, None); + assert_eq!(cfg.peers[0].endpoint, Some("10.0.0.2:51820".parse().unwrap())); +} +``` + +- [ ] **Step 3: Run → fail; implement the config changes** (Step 1) until these pass. `cargo test -p yipd --bins config`. + +- [ ] **Step 4: Add the `yip-rendezvous` dep** to `bin/yipd/Cargo.toml`: `yip-rendezvous = { path = "../../crates/yip-rendezvous" }`. Add `mod rendezvous;` to `bin/yipd/src/main.rs`. + +- [ ] **Step 5: Write failing client tests** in `bin/yipd/src/rendezvous.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use yip_rendezvous::{encode, node_id, Message}; + + fn server() -> SocketAddr { "203.0.113.1:51821".parse().unwrap() } + + #[test] + fn register_targets_server_with_our_node_id() { + let mut r = ConfiguredServerRendezvous::new(server()); + let me = node_id(&[1u8; 32]); + let dg = r.register(me); + assert_eq!(dg.dst, server()); + assert_eq!(yip_rendezvous::decode(&dg.bytes), Some(Message::Register { node: me })); + } + + #[test] + fn relay_wraps_payload_for_dst() { + let mut r = ConfiguredServerRendezvous::new(server()); + let me = node_id(&[1u8; 32]); + let peer = node_id(&[2u8; 32]); + let dg = r.relay(me, peer, &[4, 5, 6]); + assert_eq!(dg.dst, server()); + assert_eq!( + yip_rendezvous::decode(&dg.bytes), + Some(Message::RelaySend { src: me, dst: peer, payload: vec![4, 5, 6] }) + ); + } + + #[test] + fn parse_maps_server_messages_to_events() { + let r = ConfiguredServerRendezvous::new(server()); + let n = node_id(&[2u8; 32]); + let a: SocketAddr = "198.51.100.7:41000".parse().unwrap(); + let mut buf = Vec::new(); + encode(&Message::PeerInfo { node: n, reflexive: a }, &mut buf); + assert!(matches!(r.parse(&buf), RdvEvent::PeerCandidate { node, addr } if node == n && addr == a)); + buf.clear(); + encode(&Message::PunchHint { node: n, reflexive: a }, &mut buf); + assert!(matches!(r.parse(&buf), RdvEvent::PunchTo { node, addr } if node == n && addr == a)); + buf.clear(); + encode(&Message::RelayDeliver { src: n, payload: vec![1, 2] }, &mut buf); + assert!(matches!(r.parse(&buf), RdvEvent::Relayed { src, payload } if src == n && payload == vec![1, 2])); + assert!(matches!(r.parse(&[0xFF]), RdvEvent::Ignored)); + } +} +``` + +- [ ] **Step 6: Run → fail; implement `rendezvous.rs`.** Prepend: + +```rust +//! The `yipd` side of the rendezvous protocol: a `Rendezvous` trait (so a 2c +//! DHT backend can replace the configured-server one) and the +//! `ConfiguredServerRendezvous` impl that produces `EgressDatagram`s aimed at a +//! configured server and parses server datagrams into `RdvEvent`s the path +//! state machine reacts to. +use std::net::SocketAddr; + +use yip_io::poll::EgressDatagram; +use yip_rendezvous::{decode, encode, Message, NodeId}; + +/// A parsed inbound rendezvous datagram, normalized for the path SM. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RdvEvent { + /// The server told us where a peer is (answer to our `lookup`). + PeerCandidate { node: NodeId, addr: SocketAddr }, + /// The server asked us to punch toward a peer that looked us up. + PunchTo { node: NodeId, addr: SocketAddr }, + /// A relayed tunnel datagram from `src`; `payload` is fed to the peer path. + Relayed { src: NodeId, payload: Vec }, + /// The looked-up peer is not registered. + NotFound { node: NodeId }, + /// Not a message we act on. + Ignored, +} + +/// Abstraction over "how do I find/reach a peer by node id". 2b ships the +/// configured-server impl; 2c adds a DHT impl without touching `PeerManager`. +pub trait Rendezvous { + fn register(&mut self, node: NodeId) -> EgressDatagram; + fn lookup(&mut self, node: NodeId) -> EgressDatagram; + fn relay(&mut self, src: NodeId, dst: NodeId, payload: &[u8]) -> EgressDatagram; + fn parse(&self, dg: &[u8]) -> RdvEvent; + fn server_addr(&self) -> SocketAddr; +} + +/// Talks to a single configured rendezvous+relay server. +pub struct ConfiguredServerRendezvous { + server: SocketAddr, +} + +impl ConfiguredServerRendezvous { + pub fn new(server: SocketAddr) -> Self { + Self { server } + } + + fn to_server(&self, msg: &Message) -> EgressDatagram { + let mut bytes = Vec::new(); + encode(msg, &mut bytes); + EgressDatagram { fate: 0, dst: self.server, bytes } + } +} + +impl Rendezvous for ConfiguredServerRendezvous { + fn register(&mut self, node: NodeId) -> EgressDatagram { + self.to_server(&Message::Register { node }) + } + fn lookup(&mut self, node: NodeId) -> EgressDatagram { + self.to_server(&Message::Lookup { node }) + } + fn relay(&mut self, src: NodeId, dst: NodeId, payload: &[u8]) -> EgressDatagram { + self.to_server(&Message::RelaySend { src, dst, payload: payload.to_vec() }) + } + fn parse(&self, dg: &[u8]) -> RdvEvent { + match decode(dg) { + Some(Message::PeerInfo { node, reflexive }) => RdvEvent::PeerCandidate { node, addr: reflexive }, + Some(Message::PunchHint { node, reflexive }) => RdvEvent::PunchTo { node, addr: reflexive }, + Some(Message::RelayDeliver { src, payload }) => RdvEvent::Relayed { src, payload }, + Some(Message::NotFound { node }) => RdvEvent::NotFound { node }, + _ => RdvEvent::Ignored, + } + } + fn server_addr(&self) -> SocketAddr { + self.server + } +} +``` + +- [ ] **Step 7: Run → pass; build/clippy/fmt clean.** `cargo test -p yipd --bins` (config + rendezvous). Note: making `endpoint` optional will break `peer_manager.rs`/`tunnel.rs` references — Task 6 fixes those; for THIS task, adjust only the minimum in `peer_manager.rs`/`tunnel.rs` so the workspace still builds (e.g. `p.endpoint.unwrap_or_else(|| /* placeholder unspecified addr */)` is NOT acceptable — instead, in `PeerManager::new`, store `endpoint: Option` on `Peer` and default the not-yet-wired paths; if that is too invasive for this task, temporarily map `None` peers by skipping them with a `// TODO(task6)` and a compile-guarding `expect`). Keep the change minimal and localized; Task 6 does the real wiring. Ensure `cargo build --workspace` is green. + +> Implementer note: cleanest minimal approach for Step 7 — change `Peer.endpoint` to `Option` now and make the 2a direct-path code use `if let Some(ep) = peer.endpoint` (a `None` peer simply has no direct candidate yet; it can't handshake until Task 6 supplies one, which is acceptable because no test in this task exercises a `None`-endpoint peer end-to-end). This avoids placeholder addresses entirely. + +- [ ] **Step 8: Commit.** + +```bash +git add bin/yipd/src/rendezvous.rs bin/yipd/src/config.rs bin/yipd/src/main.rs bin/yipd/Cargo.toml bin/yipd/src/peer_manager.rs bin/yipd/src/tunnel.rs Cargo.lock +git commit -m "feat(yipd): Rendezvous trait + configured-server client; optional endpoint + rendezvous config (2b)" +``` + +--- + +### Task 5: per-peer path state machine (`path.rs`) + +**Files:** +- Create: `bin/yipd/src/path.rs` +- Modify: `bin/yipd/src/main.rs` (`mod path;`) +- Test: inline `#[cfg(test)]` in `path.rs` + +**Interfaces:** +- Produces: + - `pub enum PathKind { Direct, Punched, Relayed }` + - `pub enum PathStage { Direct, Punching, Relaying, Failed }` + - `pub struct PathState { /* private */ }` with: + - `pub fn new(has_direct: bool, has_rendezvous: bool, now_ms: u64) -> Self` + - `pub fn candidate(&self) -> Option` — the address to probe now (None ⇒ relay or nothing). + - `pub fn stage(&self) -> PathStage` + - `pub fn on_direct_addr(&mut self, addr: SocketAddr)` — supply the configured direct endpoint. + - `pub fn on_peer_candidate(&mut self, addr: SocketAddr, now_ms: u64)` — a reflexive addr arrived (enter Punching). + - `pub fn advance(&mut self, now_ms: u64) -> PathAction` — deadline-driven escalation. + - `pub fn committed(&mut self, kind: PathKind)` — handshake completed over the current path. + - `pub fn reset(&mut self, now_ms: u64)` — session went stale; re-enter from Direct. + - `pub enum PathAction { Idle, NeedLookup, Probe(SocketAddr), Relay, Failed }` + - Constants: `DIRECT_MS = 3_000`, `PUNCH_MS = 5_000` (per-stage windows). + +- [ ] **Step 1: Write failing tests** in `bin/yipd/src/path.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use std::net::SocketAddr; + + fn a(s: &str) -> SocketAddr { s.parse().unwrap() } + + #[test] + fn direct_first_when_endpoint_known() { + let mut p = PathState::new(true, true, 0); + p.on_direct_addr(a("10.0.0.2:51820")); + assert!(matches!(p.advance(0), PathAction::Probe(x) if x == a("10.0.0.2:51820"))); + assert_eq!(p.stage(), PathStage::Direct); + } + + #[test] + fn escalates_direct_to_punch_after_window() { + let mut p = PathState::new(true, true, 0); + p.on_direct_addr(a("10.0.0.2:51820")); + let _ = p.advance(0); + // After the direct window with no commit, ask for a lookup (enter punch). + assert!(matches!(p.advance(DIRECT_MS + 1), PathAction::NeedLookup)); + assert_eq!(p.stage(), PathStage::Punching); + } + + #[test] + fn punch_probes_learned_candidate_then_relays_after_window() { + let mut p = PathState::new(false, true, 0); // no direct endpoint + assert!(matches!(p.advance(0), PathAction::NeedLookup)); + p.on_peer_candidate(a("198.51.100.7:41000"), 10); + assert!(matches!(p.advance(10), PathAction::Probe(x) if x == a("198.51.100.7:41000"))); + // Punch window elapses without commit -> escalate to relay. + assert!(matches!(p.advance(10 + PUNCH_MS + 1), PathAction::Relay)); + assert_eq!(p.stage(), PathStage::Relaying); + } + + #[test] + fn no_rendezvous_and_no_direct_is_failed() { + let mut p = PathState::new(false, false, 0); + assert!(matches!(p.advance(0), PathAction::Failed)); + assert_eq!(p.stage(), PathStage::Failed); + } + + #[test] + fn commit_pins_path_and_stops_escalating() { + let mut p = PathState::new(true, true, 0); + p.on_direct_addr(a("10.0.0.2:51820")); + let _ = p.advance(0); + p.committed(PathKind::Direct); + // Even past the direct window, a committed path does not escalate. + assert!(matches!(p.advance(DIRECT_MS + 100), PathAction::Idle)); + } + + #[test] + fn reset_reenters_from_direct() { + let mut p = PathState::new(true, true, 0); + p.on_direct_addr(a("10.0.0.2:51820")); + p.committed(PathKind::Direct); + p.reset(1000); + assert!(matches!(p.advance(1000), PathAction::Probe(x) if x == a("10.0.0.2:51820"))); + assert_eq!(p.stage(), PathStage::Direct); + } +} +``` + +- [ ] **Step 2: Run → fail.** `cargo test -p yipd --bins path`. + +- [ ] **Step 3: Implement `path.rs`.** Prepend (design: a small deadline-driven state machine; `advance` is called from `tick` and returns the next action the caller performs; `on_*` feed external inputs; `committed` freezes it): + +```rust +//! Per-peer connection path state machine: escalate Direct -> Punch -> Relay, +//! each with a bounded window, feeding candidate addresses to the caller's +//! handshake machinery. A candidate is ONLY ever a probe target — the caller +//! commits a path (via `committed`) only once a Noise handshake completes over +//! it (the anti-hijack invariant lives in the caller; this SM never sends). +use std::net::SocketAddr; + +/// Direct-stage window before escalating to punch. +pub const DIRECT_MS: u64 = 3_000; +/// Punch-stage window before escalating to relay. +pub const PUNCH_MS: u64 = 5_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathKind { + Direct, + Punched, + Relayed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathStage { + Direct, + Punching, + Relaying, + Failed, +} + +/// What the caller should do this tick for a not-yet-established peer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathAction { + /// Nothing to do (committed, or waiting within a window). + Idle, + /// Send a `Lookup` for this peer (entering/among the punch stage). + NeedLookup, + /// Probe this candidate with a handshake Init. + Probe(SocketAddr), + /// Send the handshake/data via the relay. + Relay, + /// No path available (no direct endpoint and no rendezvous). + Failed, +} + +pub struct PathState { + stage: PathStage, + has_rendezvous: bool, + direct: Option, + candidate: Option, // reflexive addr for the punch stage + stage_started_ms: u64, + committed: bool, + looked_up: bool, +} + +impl PathState { + pub fn new(has_direct: bool, has_rendezvous: bool, now_ms: u64) -> Self { + let stage = if has_direct { + PathStage::Direct + } else if has_rendezvous { + PathStage::Punching + } else { + PathStage::Failed + }; + Self { + stage, + has_rendezvous, + direct: None, + candidate: None, + stage_started_ms: now_ms, + committed: false, + looked_up: false, + } + } + + pub fn stage(&self) -> PathStage { + self.stage + } + + pub fn candidate(&self) -> Option { + match self.stage { + PathStage::Direct => self.direct, + PathStage::Punching => self.candidate, + _ => None, + } + } + + pub fn on_direct_addr(&mut self, addr: SocketAddr) { + self.direct = Some(addr); + } + + pub fn on_peer_candidate(&mut self, addr: SocketAddr, now_ms: u64) { + // A reflexive addr arrived (from PeerInfo or a PunchHint): enter/refresh + // the punch stage targeting it. + self.candidate = Some(addr); + if self.stage == PathStage::Direct || self.stage == PathStage::Punching { + if self.stage != PathStage::Punching { + self.stage_started_ms = now_ms; + } + self.stage = PathStage::Punching; + } + } + + fn enter(&mut self, stage: PathStage, now_ms: u64) { + self.stage = stage; + self.stage_started_ms = now_ms; + } + + pub fn advance(&mut self, now_ms: u64) -> PathAction { + if self.committed { + return PathAction::Idle; + } + let elapsed = now_ms.saturating_sub(self.stage_started_ms); + match self.stage { + PathStage::Direct => { + if let Some(addr) = self.direct { + if elapsed < DIRECT_MS { + return PathAction::Probe(addr); + } + } + // Direct window elapsed (or never had an endpoint): escalate. + if self.has_rendezvous { + self.enter(PathStage::Punching, now_ms); + self.punch_action(now_ms) + } else { + self.enter(PathStage::Failed, now_ms); + PathAction::Failed + } + } + PathStage::Punching => { + if elapsed >= PUNCH_MS { + self.enter(PathStage::Relaying, now_ms); + return PathAction::Relay; + } + self.punch_action(now_ms) + } + PathStage::Relaying => PathAction::Relay, + PathStage::Failed => PathAction::Failed, + } + } + + fn punch_action(&mut self, _now_ms: u64) -> PathAction { + match self.candidate { + Some(addr) => PathAction::Probe(addr), + None => { + if !self.looked_up { + self.looked_up = true; + } + PathAction::NeedLookup + } + } + } + + pub fn committed(&mut self, _kind: PathKind) { + self.committed = true; + } + + pub fn reset(&mut self, now_ms: u64) { + self.committed = false; + self.candidate = None; + self.looked_up = false; + self.stage = if self.direct.is_some() { + PathStage::Direct + } else if self.has_rendezvous { + PathStage::Punching + } else { + PathStage::Failed + }; + self.stage_started_ms = now_ms; + } +} +``` + +Add `mod path;` to `bin/yipd/src/main.rs`. + +- [ ] **Step 4: Run → pass; build/clippy/fmt clean.** `cargo test -p yipd --bins path`. (Note: `new(has_direct, ...)` ignores its `now_ms` for `Direct` start until `advance`; the tests above pass with this design. If `escalates_direct_to_punch_after_window` needs `stage_started_ms=0`, it is — `new` sets it to `now_ms`.) + +- [ ] **Step 5: Commit.** + +```bash +git add bin/yipd/src/path.rs bin/yipd/src/main.rs +git commit -m "feat(yipd): per-peer path state machine — Direct/Punch/Relay escalation (2b)" +``` + +--- + +### Task 6: wire rendezvous + path SM into `PeerManager` and `tunnel.rs` + +**Files:** +- Modify: `bin/yipd/src/peer_manager.rs`, `bin/yipd/src/tunnel.rs` +- Test: inline `#[cfg(test)]` in `peer_manager.rs` (mock `Rendezvous`) + +**Interfaces:** +- Consumes: `crate::rendezvous::{Rendezvous, ConfiguredServerRendezvous, RdvEvent}`, `crate::path::{PathState, PathStage, PathKind, PathAction}`, `yip_rendezvous::node_id`. +- Produces: `PeerManager::new(local_private, local_public, peers, mode, rendezvous: Option>)` (extended signature); `PeerManager` demuxes server datagrams, drives the path SM, and relays. + +This is the integration crux — read `peer_manager.rs` in full first. Key wiring (implement to this behavior): + +1. **Struct + `new`:** add `rendezvous: Option>`, `local_node_id: NodeId`, and a `by_node: HashMap` (peer node_id → index, built from configured pubkeys). Give each `Peer` a `path: PathState` (`PathState::new(peer.endpoint.is_some(), rendezvous.is_some(), 0)`; if the peer has a configured endpoint, immediately `path.on_direct_addr(ep)`), and change `Peer.endpoint` to `Option` (from Task 4) plus a committed `path_kind: Option`. + +2. **`on_udp` demux:** if `src == rendezvous.server_addr()`, route to `on_rdv(dg, now)`: + - `RdvEvent::PeerCandidate{node,addr}` / `PunchTo{node,addr}` → `by_node[node]` → `peer.path.on_peer_candidate(addr, now)`; on `PunchTo`, ALSO start a probe immediately (a fresh `start_initiator` to `addr` if not already Handshaking) so both sides open bindings. + - `RdvEvent::Relayed{src,payload}` → treat `payload` as a peer datagram FROM the relayed peer: process it via the normal peer path, but any egress it produces must go back **via relay** (wrap through `rendezvous.relay(local_node_id, src, &out)`), and if it completes a handshake, commit `PathKind::Relayed`. (Track "this peer is currently reached via relay" so `Established` egress relays too.) + - `RdvEvent::NotFound` / `Ignored` → drive/ignore. + Otherwise (src ≠ server) → the existing 2a peer path unchanged. **Guard:** if `rendezvous` is `None`, skip the server-addr check entirely (pure 2a). + +3. **`on_tun` / `tick`:** for a non-`Established` peer, call `peer.path.advance(now)` and act on `PathAction`: + - `Probe(addr)` → ensure a handshake initiator is in flight to `addr` (reuse 2a's lazy `start_initiator`, but target `addr` — the candidate — instead of only the configured endpoint); buffer the TUN packet. + - `NeedLookup` → emit `rendezvous.lookup(node_id(peer.pubkey))` (once per punch entry; also emit `rendezvous.register(local_node_id)` periodically — every ~20s — from `tick`). + - `Relay` → send the handshake Init (and, once Established-via-relay, data) wrapped via `rendezvous.relay(local_node_id, peer_node, &bytes)`. + - `Failed` → drop (no path). + On handshake completion (existing `handle_handshake_resp`/`handle_handshake_init` success), call `peer.path.committed(kind)` where `kind` reflects which candidate completed, and set `Peer.endpoint = Some(committed_addr)` so 2a's `Established` egress targets it (for Direct/Punched). For Relayed, mark the peer relayed and route its egress through `rendezvous.relay`. + +4. **Anti-hijack:** never change an `Established` peer's committed egress target from an unauthenticated event. `on_peer_candidate` only affects a non-`Established` peer's `path`; an `Established` peer ignores new candidates until it re-enters the SM via `reset` (which only happens on a local decision that the session is stale, not on a received packet). + +5. **Registration:** in `tick`, if `rendezvous` is `Some`, emit a `register(local_node_id)` datagram every `REG_REFRESH_MS` (define `const REG_REFRESH_MS: u64 = 20_000;`) so the server keeps our reflexive binding fresh. + +- [ ] **Step 1: Write a failing integration test** in `peer_manager.rs` tests using a **mock `Rendezvous`** that records datagrams and lets the test inject events. Assert: (a) a peer with `endpoint: None` and a rendezvous configured emits a `Lookup` (via the mock) when TUN traffic arrives; (b) feeding a `PeerCandidate` event then ticking produces a handshake Init `EgressDatagram` whose `dst` is the candidate addr; (c) with NO rendezvous configured, a peer with a direct endpoint behaves exactly as 2a (Init to the configured endpoint). Write the mock inline: + +```rust +struct MockRdv { server: SocketAddr, sent: std::cell::RefCell> } +// impl Rendezvous for MockRdv: register/lookup/relay push the Message into `sent` +// and return an EgressDatagram{dst: server, bytes: encoded}; parse() decodes; +// server_addr() returns server. +``` + +(Full assertions per (a)-(c) above; use `node_id` from `yip_rendezvous`.) + +- [ ] **Step 2: Run → fail.** `cargo test -p yipd --bins peer_manager`. + +- [ ] **Step 3: Implement the wiring** per points 1–5. Read the current `on_udp`/`on_tun`/`tick`/`handle_handshake_*` and thread the path SM through them. Keep the 2a single-peer/glare/duplicate-init logic intact — the path SM only chooses *which address* to hand the existing handshake machinery, and adds the relay wrapping + server demux. + +- [ ] **Step 4: Update `tunnel.rs`** to build the client and pass it in: + +```rust +let rendezvous: Option> = config + .rendezvous + .map(|addr| Box::new(crate::rendezvous::ConfiguredServerRendezvous::new(addr)) as Box); +let mut manager = PeerManager::new( + config.local_private, + config.local_public, + &config.peers, + mode, + rendezvous, +); +``` + +- [ ] **Step 5: Run the unit tests + the FULL data-plane regression gate.** + +```bash +cargo test -p yipd --bins +cargo build --release -p yipd +cargo test -p yipd --test tunnel_netns --no-run +BIN=$(ls -t target/debug/deps/tunnel_netns-* | grep -v '\.d$' | head -1) +for E in "" "YIP_USE_URING=1"; 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 + echo -n "$E $t: "; sudo -E env $E "$BIN" "$t" --exact --test-threads=1 2>&1 | grep -oE "test result: (ok|FAILED)"; done; done +``` +Expected: all 10 `ok` — the 2a tests configure no `rendezvous`, so the path SM stays on the Direct stage and behavior is byte-identical (this is the no-regression guarantee). + +- [ ] **Step 6: Commit.** + +```bash +git add bin/yipd/src/peer_manager.rs bin/yipd/src/tunnel.rs +git commit -m "feat(yipd): wire rendezvous + path SM into PeerManager (lazy punch/relay escalation) (2b)" +``` + +--- + +### Task 7: netns integration — relay + hole-punch money tests + CI + +**Files:** +- Create: `bin/yipd/tests/run-netns-relay.sh`, `bin/yipd/tests/run-netns-punch.sh` +- Modify: `bin/yipd/tests/tunnel_netns.rs`, `.github/workflows/integration.yml` + +**Interfaces:** none (integration). Uses `env!("CARGO_BIN_EXE_yipd")` and `env!("CARGO_BIN_EXE_yip-rendezvous")`. + +The two "money tests" must assert *which path carried traffic*, using the relay forward counter. Expose it: have `yip-rendezvous` print a periodic line `relay-forwarded=` to stderr (from `RendezvousServer::forwarded_count()`), so the scripts can grep the server log. + +- [ ] **Step 1: `run-netns-relay.sh`** — three netns: `A`, `B`, and `R` (relay). Topology so A and B have **no route to each other**, but both reach R: + - `R` on a bridge; `A`–`R` veth on subnet `10.70.0.0/24`, `B`–`R` veth on subnet `10.71.0.0/24`; **do NOT** enable forwarding between the two subnets on R's netns (so A cannot reach B directly — only R's yip-rendezvous, bound on both, is reachable). + - Configs: each of A, B lists the other as a `[peer]` with `public_key` only (**no endpoint**), and sets `rendezvous=`. Assign each TUN its `node_addr/128` (`yipd --addr`) + `fd00::/8` route (mirror `run-netns-triangle.sh`). + - Start `yip-rendezvous` in R (log to a file), start `yipd` in A and B, `ping6` B's node_addr from A. + - **Assert:** ping succeeds AND the server log shows `relay-forwarded=` with N>0 (traffic went through the relay). Cleanup trap removes all netns + bridge. Root-gated (SKIP line if not root, matching existing scripts). Mirror `run-netns-triangle.sh` for boilerplate. + +- [ ] **Step 2: `run-netns-punch.sh`** — two client netns `A`, `B` each behind a **NAT** to a shared transit netns `T` that also hosts `yip-rendezvous`: + - `A`–`T` and `B`–`T` veths; in `A` and `B` netns add `iptables -t nat -A POSTROUTING -o -j MASQUERADE` so their source addr is rewritten (simulating NAT) — the classic hole-punch scenario where each sees the other's reflexive (post-NAT) addr via the server. + - T forwards between the two transit subnets (so once punched, A↔B packets route through T at L3 — a hole-punch through the NATs, NOT through the relay). + - Configs: A, B list each other by `public_key` only, `rendezvous=`. + - Start server, start both yipd, `ping6` across. + - **Assert:** ping succeeds AND the server log shows `relay-forwarded=0` (or the counter never increments) — proving the **punch** carried it, not the relay. Cleanup trap. Root-gated. + + > If a true post-NAT simultaneous-open punch is not reliably reproducible in netns on the CI kernel, fall back to asserting the connection succeeds with `relay-forwarded=0` via direct reflexive reachability (T routes between subnets, so the reflexive addr the server observes IS reachable) — the invariant under test ("punch path used, relay not used") still holds. Document whichever topology you land on in the script header. + +- [ ] **Step 3: Add the Rust harness tests** in `tunnel_netns.rs` (mirror `triangle_full_mesh_ping`): `relay_path_ping` and `hole_punch_ping`, each root-gated with a `SKIP : needs root` line, invoking the respective script via `bash