From 568691190a6dae62815a105fac21da1d07b806fa Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 21 Oct 2026 20:19:25 -0400 Subject: [PATCH 1/8] feat(obf): add TrafficDefensePreset and round_to_bucket shaping primitives --- crates/yip-obf/src/lib.rs | 132 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/crates/yip-obf/src/lib.rs b/crates/yip-obf/src/lib.rs index 9b5ed31a..1bc709ae 100644 --- a/crates/yip-obf/src/lib.rs +++ b/crates/yip-obf/src/lib.rs @@ -195,6 +195,77 @@ pub fn deobfuscate(key: &[u8; 16], dg: &[u8]) -> Option<(u8, Vec)> { Some((ptype, body)) } +/// Presets configuring packet length quantization (DAITA) and cover traffic. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TrafficDefensePreset { + #[default] + Off, + Balanced, + Paranoid, +} + +impl TrafficDefensePreset { + pub const fn default_buckets(&self) -> &'static [usize] { + match self { + Self::Off => &[], + Self::Balanced => &[256, 512, 1024, 1420], + Self::Paranoid => &[512, 1420], + } + } + + pub const fn default_cover_interval_ms(&self) -> Option { + match self { + Self::Off => None, + Self::Balanced => Some(500), + Self::Paranoid => Some(150), + } + } + + pub const fn default_cover_burst_max(&self) -> u64 { + match self { + Self::Off => 1, + Self::Balanced => 2, + Self::Paranoid => 4, + } + } + + pub const fn default_jitter_max_ms(&self) -> u64 { + match self { + Self::Off => 0, + Self::Balanced => 2, + Self::Paranoid => 4, + } + } +} + +/// Round a raw datagram wire length up to the nearest configured bucket size, +/// bounded by `max_budget`. +/// +/// - If `buckets.is_empty()`, returns `raw_wire_len`. +/// - Scans ascending `buckets`: for the first `b >= raw_wire_len`, if `b <= max_budget` returns `b`; +/// if `b > max_budget`, returns `max_budget.max(raw_wire_len)`. +/// - If `raw_wire_len > max(buckets)`, if `raw_wire_len < max_budget` returns `max_budget`; +/// else returns `raw_wire_len`. +pub fn round_to_bucket(raw_wire_len: usize, buckets: &[usize], max_budget: usize) -> usize { + if buckets.is_empty() { + return raw_wire_len; + } + for &b in buckets { + if b >= raw_wire_len { + if b <= max_budget { + return b; + } else { + return max_budget.max(raw_wire_len); + } + } + } + if raw_wire_len < max_budget { + max_budget + } else { + raw_wire_len + } +} + #[cfg(test)] mod tests { use super::*; @@ -428,4 +499,65 @@ mod tests { assert_eq!(body, payload.as_bytes()); } } + + #[test] + fn test_traffic_defense_preset_defaults() { + assert_eq!(TrafficDefensePreset::default(), TrafficDefensePreset::Off); + + let off = TrafficDefensePreset::Off; + assert_eq!(off.default_buckets(), &[] as &[usize]); + assert_eq!(off.default_cover_interval_ms(), None); + assert_eq!(off.default_cover_burst_max(), 1); + assert_eq!(off.default_jitter_max_ms(), 0); + + let balanced = TrafficDefensePreset::Balanced; + assert_eq!(balanced.default_buckets(), &[256, 512, 1024, 1420]); + assert_eq!(balanced.default_cover_interval_ms(), Some(500)); + assert_eq!(balanced.default_cover_burst_max(), 2); + assert_eq!(balanced.default_jitter_max_ms(), 2); + + let paranoid = TrafficDefensePreset::Paranoid; + assert_eq!(paranoid.default_buckets(), &[512, 1420]); + assert_eq!(paranoid.default_cover_interval_ms(), Some(150)); + assert_eq!(paranoid.default_cover_burst_max(), 4); + assert_eq!(paranoid.default_jitter_max_ms(), 4); + } + + #[test] + fn test_round_to_bucket_empty_buckets() { + assert_eq!(round_to_bucket(100, &[], 1420), 100); + assert_eq!(round_to_bucket(0, &[], 1420), 0); + assert_eq!(round_to_bucket(2000, &[], 1420), 2000); + } + + #[test] + fn test_round_to_bucket_exact_and_intermediate() { + let buckets = [256, 512, 1024, 1420]; + assert_eq!(round_to_bucket(100, &buckets, 1420), 256); + assert_eq!(round_to_bucket(256, &buckets, 1420), 256); + assert_eq!(round_to_bucket(257, &buckets, 1420), 512); + assert_eq!(round_to_bucket(512, &buckets, 1420), 512); + assert_eq!(round_to_bucket(700, &buckets, 1420), 1024); + assert_eq!(round_to_bucket(1024, &buckets, 1420), 1024); + assert_eq!(round_to_bucket(1400, &buckets, 1420), 1420); + assert_eq!(round_to_bucket(1420, &buckets, 1420), 1420); + } + + #[test] + fn test_round_to_bucket_budget_clamping() { + let buckets = [256, 512, 1024, 1420]; + // b = 512 > max_budget (500), raw_wire_len (300) <= max_budget -> returns max_budget (500) + assert_eq!(round_to_bucket(300, &buckets, 500), 500); + // b = 1024 > max_budget (500), raw_wire_len (600) > max_budget -> returns raw_wire_len (600) + assert_eq!(round_to_bucket(600, &buckets, 500), 600); + } + + #[test] + fn test_round_to_bucket_exceeding_max_bucket() { + let buckets = [256, 512]; + // raw_wire_len (600) > max bucket (512), raw_wire_len < max_budget (1000) -> returns max_budget (1000) + assert_eq!(round_to_bucket(600, &buckets, 1000), 1000); + // raw_wire_len (1200) > max bucket (512), raw_wire_len >= max_budget (1000) -> returns raw_wire_len (1200) + assert_eq!(round_to_bucket(1200, &buckets, 1000), 1200); + } } From cd937d24afd99dbf0866e3dc6c688346ccc0f269 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 21 Oct 2026 20:31:08 -0400 Subject: [PATCH 2/8] feat(config): add traffic_defense presets and granular overrides --- bin/yipd/src/config.rs | 357 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 357 insertions(+) diff --git a/bin/yipd/src/config.rs b/bin/yipd/src/config.rs index 70b8ac96..aef8e13c 100644 --- a/bin/yipd/src/config.rs +++ b/bin/yipd/src/config.rs @@ -97,6 +97,28 @@ pub struct PeerConfig { pub endpoint: Option, } +/// Configuration for packet length quantization (DAITA) and cover traffic defenses. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrafficDefenseConfig { + pub preset: yip_obf::TrafficDefensePreset, + pub buckets: Vec, + pub cover_interval_ms: Option, + pub cover_burst_max: u64, + pub jitter_max_ms: u64, +} + +impl Default for TrafficDefenseConfig { + fn default() -> Self { + Self { + preset: yip_obf::TrafficDefensePreset::Off, + buckets: vec![], + cover_interval_ms: None, + cover_burst_max: 1, + jitter_max_ms: 0, + } + } +} + /// Static configuration for one yip tunnel endpoint. #[derive(Debug, Clone)] pub struct Config { @@ -154,6 +176,8 @@ pub struct Config { /// of `0` is rejected as invalid (there is no such thing as a zero-length /// idle interval). pub cover_traffic_ms: Option, + /// Traffic analysis defense (DAITA) packet length quantization and cover traffic configuration. + pub traffic_defense: TrafficDefenseConfig, /// Wire transport (`transport=quic|tls|raw|udp`, absent ⇒ `RawUdp`). /// Mutually exclusive with `obf_psk`/`cover_traffic_ms` — QUIC and TLS /// mimicry each provide their own wire obfuscation. Nothing consumes @@ -510,6 +534,11 @@ impl Config { let mut network_id: Option<[u8; 16]> = None; let mut obf_psk: Option<[u8; 32]> = None; let mut cover_traffic_ms: Option = None; + let mut raw_defense_preset: Option = None; + let mut raw_defense_buckets: Option> = None; + let mut raw_defense_cover_interval_ms: Option = None; + let mut raw_defense_cover_burst_max: Option = None; + let mut raw_defense_jitter_max_ms: Option = None; let mut transport = TransportMode::default(); let mut tls_sni: Option = None; let mut shards: Option = None; @@ -615,6 +644,64 @@ impl Config { } cover_traffic_ms = Some(ms); } + "traffic_defense" => { + let p = match val { + "off" => yip_obf::TrafficDefensePreset::Off, + "balanced" => yip_obf::TrafficDefensePreset::Balanced, + "paranoid" => yip_obf::TrafficDefensePreset::Paranoid, + other => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unknown traffic_defense preset: {other}"), + )); + } + }; + raw_defense_preset = Some(p); + } + "defense_buckets" => { + let mut buckets = Vec::new(); + for part in val.split(',') { + let part = part.trim(); + if part.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "empty bucket in defense_buckets", + )); + } + let b = part.parse::().map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid defense_buckets value: {e}"), + ) + })?; + buckets.push(b); + } + raw_defense_buckets = Some(buckets); + } + "defense_cover_interval_ms" => { + let ms = val + .parse::() + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + if ms == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "defense_cover_interval_ms must be non-zero", + )); + } + raw_defense_cover_interval_ms = Some(ms); + } + "defense_cover_burst_max" => { + let burst = val + .parse::() + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + raw_defense_cover_burst_max = Some(burst); + } + "defense_jitter_max_ms" => { + let jitter = val + .parse::() + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; + raw_defense_jitter_max_ms = Some(jitter); + } "transport" => { transport = match val { "quic" => TransportMode::Quic, @@ -733,6 +820,96 @@ impl Config { } } + // Traffic defense (DAITA) preset expansion and granular overrides + let defense_enabled = raw_defense_preset + .is_some_and(|p| p != yip_obf::TrafficDefensePreset::Off) + || raw_defense_buckets.is_some() + || raw_defense_cover_interval_ms.is_some() + || raw_defense_cover_burst_max.is_some() + || raw_defense_jitter_max_ms.is_some(); + + let preset = raw_defense_preset.unwrap_or(yip_obf::TrafficDefensePreset::Off); + let buckets = raw_defense_buckets.unwrap_or_else(|| preset.default_buckets().to_vec()); + let cover_interval_ms = raw_defense_cover_interval_ms + .or(cover_traffic_ms) + .or_else(|| preset.default_cover_interval_ms()); + let cover_burst_max = + raw_defense_cover_burst_max.unwrap_or_else(|| preset.default_cover_burst_max()); + let jitter_max_ms = + raw_defense_jitter_max_ms.unwrap_or_else(|| preset.default_jitter_max_ms()); + + let traffic_defense = TrafficDefenseConfig { + preset, + buckets, + cover_interval_ms, + cover_burst_max, + jitter_max_ms, + }; + + if defense_enabled { + if transport == TransportMode::Quic || transport == TransportMode::Tls { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "traffic_defense is mutually exclusive with transport=quic and transport=tls", + )); + } + if obf_psk.is_none() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "traffic_defense requires obf_psk", + )); + } + if traffic_defense.buckets.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "defense_buckets must be non-empty when traffic_defense is enabled", + )); + } + let mut prev = 0usize; + for &b in &traffic_defense.buckets { + if !(64..=1500).contains(&b) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("defense_buckets values must be between 64 and 1500, got {b}"), + )); + } + if b <= prev { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "defense_buckets must be strictly ascending, got {:?}", + traffic_defense.buckets + ), + )); + } + prev = b; + } + } + + if traffic_defense.jitter_max_ms > 10 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "defense_jitter_max_ms cannot exceed 10", + )); + } + + if !(1..=8).contains(&traffic_defense.cover_burst_max) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "defense_cover_burst_max must be between 1 and 8, got {}", + traffic_defense.cover_burst_max + ), + )); + } + + if let Some(0) = traffic_defense.cover_interval_ms { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "defense_cover_interval_ms must be non-zero", + )); + } + // `transport=quic`/`transport=tls` each provide their own wire // obfuscation, so the obf/cover-traffic knobs (which assume the // raw-UDP path) don't apply — reject the combination rather than @@ -793,6 +970,7 @@ impl Config { network_id, obf_psk, cover_traffic_ms, + traffic_defense, transport, tls_sni: tls_sni.unwrap_or_else(|| DEFAULT_TLS_SNI.to_owned()), shards: shards.unwrap_or(1), @@ -2132,4 +2310,183 @@ local_public=0000000000000000000000000000000000000000000000000000000000000002 Some(std::path::PathBuf::from("/run/yipd/control.sock")) ); } + + // ── traffic_defense (DAITA) tests ──────────────────────────────────── + + #[test] + fn parses_traffic_defense_balanced_with_defaults() { + let text = "device=yip0\nlisten=0.0.0.0:51820\n\ + local_private=0000000000000000000000000000000000000000000000000000000000000001\n\ + local_public=0000000000000000000000000000000000000000000000000000000000000002\n\ + peer_endpoint=10.0.0.2:51820\npeer_public=00000000000000000000000000000000000000000000000000000000000000bb\n\ + obf_psk=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\ + traffic_defense=balanced\n"; + let cfg = Config::parse(text).unwrap(); + assert_eq!( + cfg.traffic_defense.preset, + yip_obf::TrafficDefensePreset::Balanced + ); + assert_eq!(cfg.traffic_defense.buckets, vec![256, 512, 1024, 1420]); + assert_eq!(cfg.traffic_defense.cover_interval_ms, Some(500)); + assert_eq!(cfg.traffic_defense.cover_burst_max, 2); + assert_eq!(cfg.traffic_defense.jitter_max_ms, 2); + } + + #[test] + fn parses_traffic_defense_granular_overrides() { + let text = "device=yip0\nlisten=0.0.0.0:51820\n\ + local_private=0000000000000000000000000000000000000000000000000000000000000001\n\ + local_public=0000000000000000000000000000000000000000000000000000000000000002\n\ + peer_endpoint=10.0.0.2:51820\npeer_public=00000000000000000000000000000000000000000000000000000000000000bb\n\ + obf_psk=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\ + traffic_defense=balanced\n\ + defense_buckets=512,1024,1420\n\ + defense_cover_interval_ms=250\n\ + defense_cover_burst_max=4\n\ + defense_jitter_max_ms=5\n"; + let cfg = Config::parse(text).unwrap(); + assert_eq!( + cfg.traffic_defense.preset, + yip_obf::TrafficDefensePreset::Balanced + ); + assert_eq!(cfg.traffic_defense.buckets, vec![512, 1024, 1420]); + assert_eq!(cfg.traffic_defense.cover_interval_ms, Some(250)); + assert_eq!(cfg.traffic_defense.cover_burst_max, 4); + assert_eq!(cfg.traffic_defense.jitter_max_ms, 5); + } + + #[test] + fn rejects_traffic_defense_without_obf_psk() { + let text = "device=yip0\nlisten=0.0.0.0:51820\n\ + local_private=0000000000000000000000000000000000000000000000000000000000000001\n\ + local_public=0000000000000000000000000000000000000000000000000000000000000002\n\ + peer_endpoint=10.0.0.2:51820\npeer_public=00000000000000000000000000000000000000000000000000000000000000bb\n\ + traffic_defense=balanced\n"; + let err = Config::parse(text).unwrap_err(); + assert!( + err.to_string().contains("traffic_defense requires obf_psk"), + "unexpected error: {err}" + ); + } + + #[test] + fn rejects_non_ascending_defense_buckets() { + let text = "device=yip0\nlisten=0.0.0.0:51820\n\ + local_private=0000000000000000000000000000000000000000000000000000000000000001\n\ + local_public=0000000000000000000000000000000000000000000000000000000000000002\n\ + peer_endpoint=10.0.0.2:51820\npeer_public=00000000000000000000000000000000000000000000000000000000000000bb\n\ + obf_psk=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\ + traffic_defense=balanced\n\ + defense_buckets=1024,512,1420\n"; + let err = Config::parse(text).unwrap_err(); + assert!( + err.to_string().contains("strictly ascending"), + "unexpected error: {err}" + ); + } + + #[test] + fn rejects_excessive_jitter_max() { + let text = "device=yip0\nlisten=0.0.0.0:51820\n\ + local_private=0000000000000000000000000000000000000000000000000000000000000001\n\ + local_public=0000000000000000000000000000000000000000000000000000000000000002\n\ + peer_endpoint=10.0.0.2:51820\npeer_public=00000000000000000000000000000000000000000000000000000000000000bb\n\ + obf_psk=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\ + traffic_defense=balanced\n\ + defense_jitter_max_ms=11\n"; + let err = Config::parse(text).unwrap_err(); + assert!( + err.to_string() + .contains("defense_jitter_max_ms cannot exceed 10"), + "unexpected error: {err}" + ); + } + + #[test] + fn rejects_traffic_defense_with_quic_or_tls() { + let quic_text = "device=yip0\nlisten=0.0.0.0:51820\n\ + local_private=0000000000000000000000000000000000000000000000000000000000000001\n\ + local_public=0000000000000000000000000000000000000000000000000000000000000002\n\ + peer_endpoint=10.0.0.2:51820\npeer_public=00000000000000000000000000000000000000000000000000000000000000bb\n\ + transport=quic\n\ + traffic_defense=balanced\n"; + let err_quic = Config::parse(quic_text).unwrap_err(); + assert!( + err_quic.to_string().contains( + "traffic_defense is mutually exclusive with transport=quic and transport=tls" + ), + "unexpected error: {err_quic}" + ); + + let tls_text = "device=yip0\nlisten=0.0.0.0:51820\n\ + local_private=0000000000000000000000000000000000000000000000000000000000000001\n\ + local_public=0000000000000000000000000000000000000000000000000000000000000002\n\ + peer_endpoint=10.0.0.2:51820\npeer_public=00000000000000000000000000000000000000000000000000000000000000bb\n\ + transport=tls\n\ + traffic_defense=balanced\n"; + let err_tls = Config::parse(tls_text).unwrap_err(); + assert!( + err_tls.to_string().contains( + "traffic_defense is mutually exclusive with transport=quic and transport=tls" + ), + "unexpected error: {err_tls}" + ); + } + + #[test] + fn legacy_cover_traffic_ms_maps_to_defense_cover_interval() { + let text = "device=yip0\nlisten=0.0.0.0:51820\n\ + local_private=0000000000000000000000000000000000000000000000000000000000000001\n\ + local_public=0000000000000000000000000000000000000000000000000000000000000002\n\ + peer_endpoint=10.0.0.2:51820\npeer_public=00000000000000000000000000000000000000000000000000000000000000bb\n\ + cover_traffic_ms=250\n"; + let cfg = Config::parse(text).unwrap(); + assert_eq!(cfg.cover_traffic_ms, Some(250)); + assert_eq!(cfg.traffic_defense.cover_interval_ms, Some(250)); + assert_eq!( + cfg.traffic_defense.preset, + yip_obf::TrafficDefensePreset::Off + ); + assert_eq!(cfg.traffic_defense.buckets, Vec::::new()); + } + + #[test] + fn traffic_defense_config_default() { + let def = TrafficDefenseConfig::default(); + assert_eq!(def.preset, yip_obf::TrafficDefensePreset::Off); + assert_eq!(def.buckets, Vec::::new()); + assert_eq!(def.cover_interval_ms, None); + assert_eq!(def.cover_burst_max, 1); + assert_eq!(def.jitter_max_ms, 0); + } + + #[test] + fn rejects_invalid_burst_or_interval_or_bucket_range() { + let base = "device=yip0\nlisten=0.0.0.0:51820\n\ + local_private=0000000000000000000000000000000000000000000000000000000000000001\n\ + local_public=0000000000000000000000000000000000000000000000000000000000000002\n\ + peer_endpoint=10.0.0.2:51820\npeer_public=00000000000000000000000000000000000000000000000000000000000000bb\n\ + obf_psk=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\ + traffic_defense=balanced\n"; + + // burst max 0 + let text_burst0 = format!("{base}defense_cover_burst_max=0\n"); + assert!(Config::parse(&text_burst0).is_err()); + + // burst max 9 (>8) + let text_burst9 = format!("{base}defense_cover_burst_max=9\n"); + assert!(Config::parse(&text_burst9).is_err()); + + // interval 0 + let text_interval0 = format!("{base}defense_cover_interval_ms=0\n"); + assert!(Config::parse(&text_interval0).is_err()); + + // bucket < 64 + let text_bucket_small = format!("{base}defense_buckets=32,512\n"); + assert!(Config::parse(&text_bucket_small).is_err()); + + // bucket > 1500 + let text_bucket_large = format!("{base}defense_buckets=512,1600\n"); + assert!(Config::parse(&text_bucket_large).is_err()); + } } From 2269e9b6815a7ed5ed1436870fcde0fe5108fe18 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 21 Oct 2026 20:50:12 -0400 Subject: [PATCH 3/8] feat(peer_manager): discrete size bucketing and zero-alloc decoy bursts --- bin/yipd/src/peer_manager/mod.rs | 96 +++++++++---- bin/yipd/src/peer_manager/obf.rs | 56 +++++++- bin/yipd/src/peer_manager/tests.rs | 221 +++++++++++++++++++++++++++++ bin/yipd/src/sharding.rs | 1 + bin/yipd/src/tunnel.rs | 1 + 5 files changed, 345 insertions(+), 30 deletions(-) diff --git a/bin/yipd/src/peer_manager/mod.rs b/bin/yipd/src/peer_manager/mod.rs index 9816f11a..ec24397a 100644 --- a/bin/yipd/src/peer_manager/mod.rs +++ b/bin/yipd/src/peer_manager/mod.rs @@ -78,7 +78,7 @@ use yip_membership::{Cert, GossipMsg}; use yip_rendezvous::{node_id, NodeId}; use crate::addr::node_addr; -use crate::config::PeerConfig; +use crate::config::{PeerConfig, TrafficDefenseConfig}; use crate::dataplane::{conn_tag_from_keys, DataPlane}; use crate::handshake::{Established, HandshakeState, PacketType}; use crate::membership::Membership; @@ -325,6 +325,9 @@ struct Peer { /// to this peer (3b Task 4). Defaults to `0`. Bounds cover emission to at /// most one datagram per peer per `cover_traffic_ms` interval. last_cover_ms: u64, + /// Stochastic cover traffic interval (jittered ms) to apply the next time + /// cover traffic is checked for this peer (DAITA). + next_cover_interval_ms: Option, tx_bytes: u64, rx_bytes: u64, tx_packets: u64, @@ -439,6 +442,8 @@ pub struct PeerManager { /// hide it, and junk-in-the-clear would be worse than silence). Set once, /// before the event loop starts, via [`PeerManager::set_cover_traffic_ms`]. cover_traffic_ms: Option, + /// Traffic defense configuration (DAITA packet length quantization and cover bursts). + pub traffic_defense: TrafficDefenseConfig, /// RaptorQ symbol size passed to `DataPlane::new` at every establish site /// (3c.1 Task 2). Defaults to `1200` — the pre-3c.1 hardcode, byte-identical /// for raw/obf mode. QUIC mode (3c.1 Tasks 4/5) overrides it via @@ -561,6 +566,7 @@ impl PeerManager { session_obf_key: None, last_activity_ms: 0, last_cover_ms: 0, + next_cover_interval_ms: None, tx_bytes: 0, rx_bytes: 0, tx_packets: 0, @@ -591,6 +597,7 @@ impl PeerManager { obf_key: None, junk_rng: yip_obf::XorShift64::from_getrandom(), cover_traffic_ms: None, + traffic_defense: TrafficDefenseConfig::default(), data_symbol_size: DEFAULT_DATA_SYMBOL_SIZE, relay_only, rekey_interval_ms: std::env::var("YIP_REKEY_INTERVAL_MS") @@ -670,6 +677,7 @@ impl PeerManager { session_obf_key: None, last_activity_ms: 0, last_cover_ms: 0, + next_cover_interval_ms: None, tx_bytes: 0, rx_bytes: 0, tx_packets: 0, @@ -707,6 +715,11 @@ impl PeerManager { self.cover_traffic_ms = cover_traffic_ms; } + /// Store traffic defense (DAITA packet length quantization and decoy bursts) configuration. + pub fn set_traffic_defense(&mut self, cfg: TrafficDefenseConfig) { + self.traffic_defense = cfg; + } + /// Attach a UNIX domain control socket handle and device metadata. pub fn set_control( &mut self, @@ -909,6 +922,7 @@ impl PeerManager { session_obf_key: None, last_activity_ms: 0, last_cover_ms: 0, + next_cover_interval_ms: None, tx_bytes: 0, rx_bytes: 0, tx_packets: 0, @@ -1501,25 +1515,24 @@ impl PeerManager { self.tick_gossip(now_ms); } - // ── idle cover traffic (3b Task 4) ────────────────────────────────── - // Opt-in decoy traffic: only when obfuscation is on AND a - // `cover_traffic_ms` interval is configured. For each direct - // (non-relay) `Established` peer with a known endpoint that has been - // idle (no real Data sent or received) for at least the interval, - // AND hasn't had a cover datagram emitted in at least the interval, - // push exactly one session-keyed junk datagram (`build_junk` is - // plaintext; `tick`/`obf_egress` wraps it once with that peer's - // session key, since `dst` is an `Established` peer's endpoint). - // Gated on `last_activity_ms` so this never races or delays real - // data — latency-free, idle-only, bounded to one datagram per peer - // per tick. A relay-reached peer (`relay == true`) is skipped: its - // `endpoint` is a stale/candidate direct address left over from - // before the passive `relayed_handshake_*` path took over (see the - // real-Data egress arm above, which likewise checks `!relay`) — - // firing cover at it would leak junk to an unrelated address and - // miss the peer entirely. Relay-path cover is out of scope for 3b - // (mirrors Task 3's handshake junk, which is direct-path-only). - if let (true, Some(iv)) = (self.obf_key.is_some(), self.cover_traffic_ms) { + // ── idle cover traffic (3b Task 4 / DAITA) ────────────────────────── + // Opt-in decoy traffic: only when obfuscation is on AND either + // `traffic_defense.cover_interval_ms` or `cover_traffic_ms` is configured. + let base_iv_opt = self + .traffic_defense + .cover_interval_ms + .or(self.cover_traffic_ms); + if let (true, Some(base_iv)) = (self.obf_key.is_some(), base_iv_opt) { + let max_budget = 1420.max(OBF_MTU_BUDGET); + let mut valid_buckets = [0usize; 8]; + let mut valid_count = 0usize; + for &b in &self.traffic_defense.buckets { + if b <= max_budget && valid_count < valid_buckets.len() { + valid_buckets[valid_count] = b; + valid_count += 1; + } + } + for i in 0..self.peers.len() { if !matches!(self.peers[i].state, PeerState::Established(_)) || self.peers[i].relay { @@ -1528,18 +1541,45 @@ impl PeerManager { let Some(endpoint) = self.peers[i].endpoint else { continue; }; - if now_ms.saturating_sub(self.peers[i].last_activity_ms) < iv - || now_ms.saturating_sub(self.peers[i].last_cover_ms) < iv + let current_iv = self.peers[i].next_cover_interval_ms.unwrap_or(base_iv); + if now_ms.saturating_sub(self.peers[i].last_activity_ms) < current_iv + || now_ms.saturating_sub(self.peers[i].last_cover_ms) < current_iv { continue; } - let bytes = self.build_junk(); - self.tick_egress.push(EgressDatagram { - fate: 0, - dst: endpoint, - bytes, - }); + let burst_count = if self.traffic_defense.cover_burst_max <= 1 { + 1 + } else { + usize::try_from( + self.junk_rng + .gen_range(1, self.traffic_defense.cover_burst_max), + ) + .unwrap_or(1) + }; + + for _ in 0..burst_count { + if valid_count == 0 { + let bytes = self.build_junk(); + self.tick_egress.push(EgressDatagram { + fate: 0, + dst: endpoint, + bytes, + }); + } else { + let b_idx = + usize::try_from(self.junk_rng.gen_range(0, (valid_count - 1) as u64)) + .unwrap_or(0); + let b = valid_buckets[b_idx]; + let fate = u16::try_from(b).unwrap_or(0); + self.tick_egress.push(EgressDatagram { + fate, + dst: endpoint, + bytes: vec![yip_obf::JUNK_TYPE], + }); + } + } self.peers[i].last_cover_ms = now_ms; + self.peers[i].next_cover_interval_ms = Some(jitter_ms(base_iv)); } } diff --git a/bin/yipd/src/peer_manager/obf.rs b/bin/yipd/src/peer_manager/obf.rs index bd71d93c..8d9a3ae9 100644 --- a/bin/yipd/src/peer_manager/obf.rs +++ b/bin/yipd/src/peer_manager/obf.rs @@ -180,6 +180,10 @@ impl PeerManager { /// practice this is defense-in-depth, not the expected path. pub(super) fn obf_egress(&mut self, dgs: &mut Vec) { let server = self.rendezvous.as_ref().map(|r| r.server_addr()); + let defense_active = self.traffic_defense.preset != yip_obf::TrafficDefensePreset::Off + && !self.traffic_defense.buckets.is_empty(); + let max_budget = 1420.max(OBF_MTU_BUDGET); + dgs.retain_mut(|d| { if d.bytes.is_empty() { return true; @@ -188,7 +192,17 @@ impl PeerManager { let Some(key) = self.obf_key else { return true; }; - let pad = random_pad(obf_pad_max(yip_obf::RDV_TYPE, d.bytes.len() + 1)); + let pad = if defense_active { + let raw_wire = yip_obf::MIN_ENVELOPE + d.bytes.len(); + let target_wire = yip_obf::round_to_bucket( + raw_wire, + &self.traffic_defense.buckets, + max_budget, + ); + target_wire.saturating_sub(raw_wire) + } else { + random_pad(obf_pad_max(yip_obf::RDV_TYPE, d.bytes.len() + 1)) + }; if yip_obf::obfuscate_into( &key, yip_obf::RDV_TYPE, @@ -206,7 +220,45 @@ impl PeerManager { let Some(key) = self.obf_key_for_egress(d.dst, ptype) else { return true; }; - let pad = random_pad(obf_pad_max(ptype, d.bytes.len())); + let pad = if defense_active { + let body_len = d.bytes.len().saturating_sub(1); + let raw_wire = yip_obf::MIN_ENVELOPE + body_len; + let target_wire = if ptype == yip_obf::JUNK_TYPE + && d.fate > 0 + && self.traffic_defense.buckets.contains(&(d.fate as usize)) + { + let tw = d.fate as usize; + d.fate = 0; + tw + } else if ptype == yip_obf::JUNK_TYPE && body_len == 0 { + let mut valid_buckets = [0usize; 8]; + let mut valid_count = 0usize; + for &b in &self.traffic_defense.buckets { + if b <= max_budget && valid_count < valid_buckets.len() { + valid_buckets[valid_count] = b; + valid_count += 1; + } + } + if valid_count == 0 { + max_budget + } else { + let idx = usize::try_from( + self.junk_rng.gen_range(0, (valid_count - 1) as u64), + ) + .unwrap_or(0); + valid_buckets[idx] + } + } else { + yip_obf::round_to_bucket( + raw_wire, + &self.traffic_defense.buckets, + max_budget, + ) + }; + target_wire.saturating_sub(raw_wire) + } else { + random_pad(obf_pad_max(ptype, d.bytes.len())) + }; if yip_obf::obfuscate_into(&key, ptype, &d.bytes[1..], pad, &mut self.obf_scratch) { std::mem::swap(&mut d.bytes, &mut self.obf_scratch); true diff --git a/bin/yipd/src/peer_manager/tests.rs b/bin/yipd/src/peer_manager/tests.rs index 301dd10c..79e6088a 100644 --- a/bin/yipd/src/peer_manager/tests.rs +++ b/bin/yipd/src/peer_manager/tests.rs @@ -2471,3 +2471,224 @@ fn repeated_on_tun_under_obfuscation_retains_egress_and_scratch_capacity() { ); } } + +// ── Traffic Defense (DAITA) Unit Tests ────────────────────────────────────── + +#[test] +fn obf_egress_quantizes_to_discrete_buckets() { + let peer = peer_cfg(1, "10.0.0.1:1000"); + let mut pm = PeerManager::new( + [1u8; 32], + [2u8; 32], + &[peer], + TunnelMode::L3Tun, + None, + None, + false, + ); + pm.set_obf_psk(Some([0xAAu8; 32])); + pm.set_traffic_defense(crate::config::TrafficDefenseConfig { + preset: yip_obf::TrafficDefensePreset::Balanced, + buckets: vec![256, 512, 1024, 1420], + cover_interval_ms: Some(500), + cover_burst_max: 2, + jitter_max_ms: 2, + }); + + let allowed_buckets = [256, 512, 1024, 1420]; + let payload_lens = [50, 100, 300, 600, 1100]; + let expected_buckets = [256, 256, 512, 1024, 1420]; + + for (&len, &expected) in payload_lens.iter().zip(expected_buckets.iter()) { + let pkt = vec![PacketType::Data as u8; len]; + let wire = pm.obf_envelope(pkt, 0); + assert!( + allowed_buckets.contains(&wire.len()), + "payload len {} resulted in wire len {}, not in {:?}", + len, + wire.len(), + allowed_buckets + ); + assert_eq!(wire.len(), expected); + } +} + +#[test] +fn handshake_quantizes_to_bucket() { + let peer = peer_cfg(1, "10.0.0.1:1000"); + let mut pm = PeerManager::new( + [1u8; 32], + [2u8; 32], + &[peer], + TunnelMode::L3Tun, + None, + None, + false, + ); + pm.set_obf_psk(Some([0xAAu8; 32])); + pm.set_traffic_defense(crate::config::TrafficDefenseConfig { + preset: yip_obf::TrafficDefensePreset::Balanced, + buckets: vec![256, 512, 1024, 1420], + cover_interval_ms: Some(500), + cover_burst_max: 2, + jitter_max_ms: 2, + }); + + // HandshakeInit (1025B) -> quantizes to 1420B + let mut init = vec![0u8; 1025]; + init[0] = PacketType::HandshakeInit as u8; + let wire_init = pm.obf_envelope(init, 0); + assert_eq!(wire_init.len(), 1420); + + // HandshakeResp (945B) -> quantizes to 1024B + let mut resp = vec![0u8; 945]; + resp[0] = PacketType::HandshakeResp as u8; + let wire_resp = pm.obf_envelope(resp, 0); + assert_eq!(wire_resp.len(), 1024); +} + +#[test] +fn decoy_burst_emits_multiple_bucket_sized_packets() { + const TAG: u64 = 0x1122_3344_5566_7788; + let peer_ep: SocketAddr = "10.0.0.50:5050".parse().unwrap(); + let peer = peer_cfg(50, "10.0.0.50:5050"); + let mut pm = PeerManager::new( + [1u8; 32], + [2u8; 32], + &[peer], + TunnelMode::L3Tun, + None, + None, + false, + ); + pm.set_obf_psk(Some([0xAAu8; 32])); + pm.set_traffic_defense(crate::config::TrafficDefenseConfig { + preset: yip_obf::TrafficDefensePreset::Paranoid, + buckets: vec![512, 1420], + cover_interval_ms: Some(20), + cover_burst_max: 3, + jitter_max_ms: 4, + }); + let dp = fake_established_dataplane(TAG, peer_ep); + let sess = [0xBBu8; 16]; + pm.peers[0].state = + PeerState::Established(Box::new(crate::epoch::EpochSet::new(Box::new(dp), 0))); + pm.peers[0].session_obf_key = Some(sess); + pm.by_tag.insert(TAG, 0); + + // Peer is idle from t=0. Keep now_ms under FEEDBACK_INTERVAL_MS (30ms) + // so tick_egress contains only the decoy burst. + let out = pm.tick(20).expect("idle peer should emit cover burst"); + assert!( + (1..=3).contains(&out.len()), + "burst count {} not in 1..=3", + out.len() + ); + + let allowed_buckets = [512, 1420]; + for dgram in out { + assert_eq!(dgram.dst, peer_ep); + assert!( + allowed_buckets.contains(&dgram.bytes.len()), + "packet len {} not in {:?}", + dgram.bytes.len(), + allowed_buckets + ); + let (ptype, _) = yip_obf::deobfuscate(&sess, &dgram.bytes) + .expect("decoy packet deobfuscates under session key"); + assert_eq!(ptype, yip_obf::JUNK_TYPE); + } +} + +#[test] +fn stochastic_cover_cadence_jitters_interval() { + const TAG: u64 = 0x2233_4455_6677_8899; + let peer_ep: SocketAddr = "10.0.0.60:6060".parse().unwrap(); + let peer = peer_cfg(60, "10.0.0.60:6060"); + let mut pm = PeerManager::new( + [1u8; 32], + [2u8; 32], + &[peer], + TunnelMode::L3Tun, + None, + None, + false, + ); + pm.set_obf_psk(Some([0xAAu8; 32])); + let base_iv = 1000u64; + pm.set_traffic_defense(crate::config::TrafficDefenseConfig { + preset: yip_obf::TrafficDefensePreset::Balanced, + buckets: vec![256, 512, 1024, 1420], + cover_interval_ms: Some(base_iv), + cover_burst_max: 2, + jitter_max_ms: 2, + }); + let dp = fake_established_dataplane(TAG, peer_ep); + let sess = [0xCCu8; 16]; + pm.peers[0].state = + PeerState::Established(Box::new(crate::epoch::EpochSet::new(Box::new(dp), 0))); + pm.peers[0].session_obf_key = Some(sess); + pm.by_tag.insert(TAG, 0); + + let spread = base_iv / 4; // 250 ms (±25%) + let min_iv = base_iv - spread; + let max_iv = base_iv + spread; + + let mut intervals = std::collections::HashSet::new(); + let mut cur_time = 0u64; + + for _ in 0..20 { + cur_time += 2000; + let _ = pm.tick(cur_time); + let next_iv = pm.peers[0] + .next_cover_interval_ms + .expect("next_cover_interval_ms should be set"); + assert!( + next_iv >= min_iv && next_iv <= max_iv, + "next_iv {} outside ±25% range [{}, {}]", + next_iv, + min_iv, + max_iv + ); + intervals.insert(next_iv); + } + // Over 20 jitter draws with spread 250, we should see multiple distinct intervals + assert!( + intervals.len() > 1, + "intervals should vary across ticks, got {:?}", + intervals + ); +} + +#[test] +fn obf_off_retains_legacy_padding_behavior() { + let peer = peer_cfg(1, "10.0.0.1:1000"); + let mut pm = PeerManager::new( + [1u8; 32], + [2u8; 32], + &[peer], + TunnelMode::L3Tun, + None, + None, + false, + ); + pm.set_obf_psk(Some([0xAAu8; 32])); + // preset Off + pm.set_traffic_defense(crate::config::TrafficDefenseConfig { + preset: yip_obf::TrafficDefensePreset::Off, + buckets: vec![], + cover_interval_ms: None, + cover_burst_max: 1, + jitter_max_ms: 0, + }); + + let payload = vec![PacketType::Data as u8; 50]; + let wire = pm.obf_envelope(payload, 0); + // Legacy data padding max is 64, raw_wire = 11 + 49 = 60. + // Wire length must be in 60..=(60 + 64). + assert!( + (60..=124).contains(&wire.len()), + "wire len {} should be in legacy range 60..=124", + wire.len() + ); +} diff --git a/bin/yipd/src/sharding.rs b/bin/yipd/src/sharding.rs index 7ffa12ac..ddb00f9f 100644 --- a/bin/yipd/src/sharding.rs +++ b/bin/yipd/src/sharding.rs @@ -562,6 +562,7 @@ fn create_peer_manager( ); manager.set_obf_psk(config.obf_psk); manager.set_cover_traffic_ms(config.cover_traffic_ms); + manager.set_traffic_defense(config.traffic_defense.clone()); Ok(manager) } diff --git a/bin/yipd/src/tunnel.rs b/bin/yipd/src/tunnel.rs index 447daead..0849d736 100644 --- a/bin/yipd/src/tunnel.rs +++ b/bin/yipd/src/tunnel.rs @@ -197,6 +197,7 @@ pub fn run(config: Config) -> io::Result<()> { // Opt-in idle cover traffic (3b Task 4): a no-op unless obf is also on // (`PeerManager::tick_dispatch` gates cover emission on both). manager.set_cover_traffic_ms(config.cover_traffic_ms); + manager.set_traffic_defense(config.traffic_defense.clone()); let control_handle = if let Some(ref path) = config.control_socket { Some(crate::control::ControlHandle::bind(path)?) } else { From e38c89ff552bd2b7b8fb8a5a84cd0183588f30c3 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 21 Oct 2026 21:08:28 -0400 Subject: [PATCH 4/8] feat(dataplane): egress micro-jitter queue and event loop timeout clamping --- bin/yipd/src/peer_manager/mod.rs | 152 +++++++++++++++++++++++++--- bin/yipd/src/peer_manager/obf.rs | 56 ++++++++++ bin/yipd/src/peer_manager/tests.rs | 157 +++++++++++++++++++++++++++++ bin/yipd/src/sharding.rs | 20 +++- bin/yipd/src/tunnel.rs | 16 +++ 5 files changed, 387 insertions(+), 14 deletions(-) diff --git a/bin/yipd/src/peer_manager/mod.rs b/bin/yipd/src/peer_manager/mod.rs index ec24397a..5981483c 100644 --- a/bin/yipd/src/peer_manager/mod.rs +++ b/bin/yipd/src/peer_manager/mod.rs @@ -70,7 +70,7 @@ //! before any side effect, so trying the wrong peer just yields //! `Outcome::None`, never corrupted state. -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::net::{Ipv6Addr, SocketAddr}; use yip_io::poll::{Dispatch, DispatchOut, EgressDatagram}; @@ -86,6 +86,14 @@ use crate::mode::TunnelMode; use crate::path::{PathAction, PathKind, PathStage, PathState}; use crate::rendezvous::{RdvEvent, Rendezvous}; +pub const MAX_JITTER_QUEUE: usize = 1024; + +#[derive(Debug, Clone)] +pub struct ScheduledDatagram { + pub deliver_after_ms: u64, + pub datagram: EgressDatagram, +} + mod gossip; mod handshake; mod obf; @@ -435,7 +443,7 @@ pub struct PeerManager { /// [`PeerManager::build_junk`]. Seeded once from the OS RNG; never used /// for any security decision (junk bytes are keystream-masked by /// `yip_obf::obfuscate`, so their content is irrelevant). - junk_rng: yip_obf::XorShift64, + pub(super) junk_rng: yip_obf::XorShift64, /// Opt-in idle cover-traffic interval (3b Task 4), or `None` to disable. /// Only consulted when `obf_key.is_some()` — with obfuscation off, cover /// traffic never fires regardless of this value (there is no wrapper to @@ -444,6 +452,8 @@ pub struct PeerManager { cover_traffic_ms: Option, /// Traffic defense configuration (DAITA packet length quantization and cover bursts). pub traffic_defense: TrafficDefenseConfig, + /// Bounded queue for micro-jitter delayed egress datagrams (traffic defense). + pub scheduled_egress: VecDeque, /// RaptorQ symbol size passed to `DataPlane::new` at every establish site /// (3c.1 Task 2). Defaults to `1200` — the pre-3c.1 hardcode, byte-identical /// for raw/obf mode. QUIC mode (3c.1 Tasks 4/5) overrides it via @@ -598,6 +608,7 @@ impl PeerManager { junk_rng: yip_obf::XorShift64::from_getrandom(), cover_traffic_ms: None, traffic_defense: TrafficDefenseConfig::default(), + scheduled_egress: VecDeque::new(), data_symbol_size: DEFAULT_DATA_SYMBOL_SIZE, relay_only, rekey_interval_ms: std::env::var("YIP_REKEY_INTERVAL_MS") @@ -720,6 +731,77 @@ impl PeerManager { self.traffic_defense = cfg; } + /// Query the milliseconds remaining until the earliest scheduled micro-jitter datagram is due. + /// Returns `Some(0)` if a datagram is already due, `Some(delay_ms)` if scheduled in the future, + /// or `None` if the queue is empty. + pub fn next_scheduled_egress_delay_ms(&self, now_ms: u64) -> Option { + let earliest = self + .scheduled_egress + .iter() + .map(|s| s.deliver_after_ms) + .min()?; + Some(earliest.saturating_sub(now_ms)) + } + + /// Drain all scheduled datagrams whose scheduled delivery timestamp `deliver_after_ms <= now_ms`. + pub fn drain_scheduled_egress(&mut self, now_ms: u64, out: &mut Vec) { + while let Some(front) = self.scheduled_egress.front() { + if front.deliver_after_ms <= now_ms { + if let Some(item) = self.scheduled_egress.pop_front() { + out.push(item.datagram); + } + } else { + break; + } + } + if self + .scheduled_egress + .iter() + .any(|s| s.deliver_after_ms <= now_ms) + { + let mut i = 0; + while i < self.scheduled_egress.len() { + if self.scheduled_egress[i].deliver_after_ms <= now_ms { + if let Some(item) = self.scheduled_egress.remove(i) { + out.push(item.datagram); + } + } else { + i += 1; + } + } + } + } + + /// Insert a micro-jitter delayed datagram in sorted `deliver_after_ms` order, + /// popping and immediately dispatching the oldest item on overrun (`len >= MAX_JITTER_QUEUE`). + pub(super) fn queue_scheduled_egress( + &mut self, + dg: EgressDatagram, + deliver_after_ms: u64, + out: &mut Vec, + ) { + if self.scheduled_egress.len() >= MAX_JITTER_QUEUE { + if let Some(oldest) = self.scheduled_egress.pop_front() { + out.push(oldest.datagram); + } + } + let idx = match self + .scheduled_egress + .iter() + .rposition(|s| s.deliver_after_ms <= deliver_after_ms) + { + Some(pos) => pos + 1, + None => 0, + }; + self.scheduled_egress.insert( + idx, + ScheduledDatagram { + deliver_after_ms, + datagram: dg, + }, + ); + } + /// Attach a UNIX domain control socket handle and device metadata. pub fn set_control( &mut self, @@ -1069,8 +1151,12 @@ impl PeerManager { &mut self, tun: Option>, mut udp: Vec, + now_ms: u64, ) -> DispatchOut<'_> { self.obf_egress(&mut udp); + if !self.scheduled_egress.is_empty() { + self.drain_scheduled_egress(now_ms, &mut udp); + } self.egress = udp; match (tun, self.egress.is_empty()) { (Some(t), true) => { @@ -1095,7 +1181,24 @@ impl Dispatch for PeerManager { /// egress it produces. fn on_udp(&mut self, src: SocketAddr, dg: &[u8], now_ms: u64) -> DispatchOut<'_> { if self.obf_key.is_none() { - return self.on_udp_dispatch(src, dg, now_ms); + if self.scheduled_egress.is_empty() { + return self.on_udp_dispatch(src, dg, now_ms); + } + let (tun, mut udp) = own_dispatch(self.on_udp_dispatch(src, dg, now_ms)); + self.drain_scheduled_egress(now_ms, &mut udp); + self.egress = udp; + return 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, + }; } if dg.is_empty() { return DispatchOut::None; @@ -1117,40 +1220,63 @@ impl Dispatch for PeerManager { return DispatchOut::None; } let (tun, udp) = own_dispatch(self.on_rdv(&body, now_ms)); - return self.finish_wrapped(tun, udp); + return self.finish_wrapped(tun, udp, now_ms); } let Some(plain) = self.deobf_ingress(src, dg) else { return DispatchOut::None; }; let (tun, udp) = own_dispatch(self.on_udp_dispatch(src, &plain, now_ms)); - self.finish_wrapped(tun, udp) + self.finish_wrapped(tun, udp, now_ms) } fn on_tun(&mut self, inner: &[u8], now_ms: u64) -> &[EgressDatagram] { - if self.obf_key.is_none() { - return self.on_tun_dispatch(inner, now_ms); + if self.traffic_defense.jitter_max_ms == 0 && self.scheduled_egress.is_empty() { + if self.obf_key.is_none() { + return self.on_tun_dispatch(inner, now_ms); + } + let mut scratch = std::mem::take(&mut self.egress); + { + let dispatched = self.on_tun_dispatch(inner, now_ms); + copy_into_reusable_egress(&mut scratch, dispatched); + } + self.obf_egress(&mut scratch); + self.egress = scratch; + return &self.egress; } + let mut scratch = std::mem::take(&mut self.egress); { let dispatched = self.on_tun_dispatch(inner, now_ms); copy_into_reusable_egress(&mut scratch, dispatched); } - self.obf_egress(&mut scratch); + if self.obf_key.is_some() { + self.obf_egress_with_jitter(&mut scratch, now_ms); + } else { + self.jitter_unobfuscated_egress(&mut scratch, now_ms); + } self.egress = scratch; &self.egress } fn tick(&mut self, now_ms: u64) -> Option<&[EgressDatagram]> { - let has_egress = self.tick_dispatch(now_ms).is_some(); - if !has_egress { - return None; - } + let _ = self.tick_dispatch(now_ms); if self.obf_key.is_some() { let mut tick_egress = std::mem::take(&mut self.tick_egress); self.obf_egress(&mut tick_egress); + if !self.scheduled_egress.is_empty() { + self.drain_scheduled_egress(now_ms, &mut tick_egress); + } + self.tick_egress = tick_egress; + } else if !self.scheduled_egress.is_empty() { + let mut tick_egress = std::mem::take(&mut self.tick_egress); + self.drain_scheduled_egress(now_ms, &mut tick_egress); self.tick_egress = tick_egress; } - Some(&self.tick_egress) + if self.tick_egress.is_empty() { + None + } else { + Some(&self.tick_egress) + } } } diff --git a/bin/yipd/src/peer_manager/obf.rs b/bin/yipd/src/peer_manager/obf.rs index 8d9a3ae9..e13ae13c 100644 --- a/bin/yipd/src/peer_manager/obf.rs +++ b/bin/yipd/src/peer_manager/obf.rs @@ -269,6 +269,62 @@ impl PeerManager { }); } + /// Obfuscate egress datagrams and apply micro-jitter scheduling to outbound data datagrams. + pub(super) fn obf_egress_with_jitter(&mut self, dgs: &mut Vec, now_ms: u64) { + let mut are_data = Vec::with_capacity(dgs.len()); + for d in dgs.iter() { + are_data.push(d.bytes.first() == Some(&(crate::handshake::PacketType::Data as u8))); + } + self.obf_egress(dgs); + self.apply_egress_jitter(dgs, &are_data, now_ms); + } + + /// Apply micro-jitter scheduling to unobfuscated outbound datagrams. + pub(super) fn jitter_unobfuscated_egress( + &mut self, + dgs: &mut Vec, + now_ms: u64, + ) { + let mut are_data = Vec::with_capacity(dgs.len()); + for d in dgs.iter() { + are_data.push(d.bytes.first() == Some(&(crate::handshake::PacketType::Data as u8))); + } + self.apply_egress_jitter(dgs, &are_data, now_ms); + } + + /// Schedule or immediately dispatch outbound datagrams according to `traffic_defense.jitter_max_ms`. + pub(super) fn apply_egress_jitter( + &mut self, + dgs: &mut Vec, + are_data: &[bool], + now_ms: u64, + ) { + let jitter_max = self.traffic_defense.jitter_max_ms; + if jitter_max == 0 { + if !self.scheduled_egress.is_empty() { + self.drain_scheduled_egress(now_ms, dgs); + } + return; + } + + let mut immediate = Vec::with_capacity(dgs.len()); + for (dg, &is_data) in dgs.drain(..).zip(are_data.iter()) { + if !is_data { + immediate.push(dg); + continue; + } + let delta = self.junk_rng.gen_range(0, jitter_max); + if delta == 0 { + immediate.push(dg); + } else { + let deliver_after_ms = now_ms.saturating_add(delta); + self.queue_scheduled_egress(dg, deliver_after_ms, &mut immediate); + } + } + self.drain_scheduled_egress(now_ms, &mut immediate); + *dgs = immediate; + } + /// Wrap a single datagram under the obfuscation envelope (matching `obf_egress`), /// used in tests to verify datagram wire sizes under obfuscation. #[cfg(test)] diff --git a/bin/yipd/src/peer_manager/tests.rs b/bin/yipd/src/peer_manager/tests.rs index 79e6088a..95f9cd95 100644 --- a/bin/yipd/src/peer_manager/tests.rs +++ b/bin/yipd/src/peer_manager/tests.rs @@ -2692,3 +2692,160 @@ fn obf_off_retains_legacy_padding_behavior() { wire.len() ); } + +#[test] +fn micro_jitter_delays_packet_until_scheduled_time() { + let (mut pm, _tag, _ep) = pm_with_established_peer([1u8; 32], [2u8; 32], 100); + pm.set_obf_psk(Some([0xAAu8; 32])); + pm.set_traffic_defense(crate::config::TrafficDefenseConfig { + preset: yip_obf::TrafficDefensePreset::Balanced, + buckets: vec![256, 512, 1024, 1420], + cover_interval_ms: Some(500), + cover_burst_max: 2, + jitter_max_ms: 4, + }); + + let now_ms = 100u64; + let mut queued = false; + for _ in 0..50 { + let _ = pm.on_tun(&dummy_tun_pkt(), now_ms); + if !pm.scheduled_egress.is_empty() { + queued = true; + break; + } + } + assert!(queued, "expected packet(s) to be queued when delta > 0"); + let queued_count = pm.scheduled_egress.len(); + assert!(queued_count >= 1); + + let earliest_deliver = pm + .scheduled_egress + .iter() + .map(|s| s.deliver_after_ms) + .min() + .unwrap(); + assert!(earliest_deliver > now_ms); + let expected_delta = earliest_deliver - now_ms; + + // Test next_scheduled_egress_delay_ms reports exact delay + let delay = pm.next_scheduled_egress_delay_ms(now_ms); + assert_eq!(delay, Some(expected_delta)); + + // Test intermediate time reporting + if expected_delta > 1 { + let partial_delay = pm.next_scheduled_egress_delay_ms(now_ms + 1); + assert_eq!(partial_delay, Some(expected_delta - 1)); + } + + // Advance time before earliest delivery - no packets should be drained + let mut drained_early = Vec::new(); + pm.drain_scheduled_egress(earliest_deliver - 1, &mut drained_early); + assert!(drained_early.is_empty()); + assert_eq!(pm.scheduled_egress.len(), queued_count); + + // Advance time past all scheduled packets + let latest_deliver = pm + .scheduled_egress + .iter() + .map(|s| s.deliver_after_ms) + .max() + .unwrap(); + assert_eq!(pm.next_scheduled_egress_delay_ms(latest_deliver), Some(0)); + let mut drained = Vec::new(); + pm.drain_scheduled_egress(latest_deliver, &mut drained); + assert_eq!(drained.len(), queued_count); + assert!(pm.scheduled_egress.is_empty()); + assert_eq!(pm.next_scheduled_egress_delay_ms(latest_deliver), None); +} + +#[test] +fn reactor_timeout_clamping_matches_spec() { + // Default 10ms with no scheduled packets -> 10ms + assert_eq!(crate::tunnel::clamp_reactor_timeout_ms(10, None), 10); + // Scheduled packet due in 2ms -> clamps to 2ms + assert_eq!(crate::tunnel::clamp_reactor_timeout_ms(10, Some(2)), 2); + // Scheduled packet already due (0ms) -> clamps to 0ms (wake immediately) + assert_eq!(crate::tunnel::clamp_reactor_timeout_ms(10, Some(0)), 0); + // Scheduled packet far in future (50ms) -> remains at default 10ms + assert_eq!(crate::tunnel::clamp_reactor_timeout_ms(10, Some(50)), 10); +} + +#[test] +fn micro_jitter_queue_overrun_flushes_oldest_without_loss() { + let (mut pm, _tag, ep) = pm_with_established_peer([1u8; 32], [2u8; 32], 100); + pm.set_obf_psk(Some([0xAAu8; 32])); + pm.set_traffic_defense(crate::config::TrafficDefenseConfig { + preset: yip_obf::TrafficDefensePreset::Balanced, + buckets: vec![256, 512, 1024, 1420], + cover_interval_ms: Some(500), + cover_burst_max: 2, + jitter_max_ms: 10, + }); + + let total_packets = super::MAX_JITTER_QUEUE + 50; + let mut flushed_immediately = Vec::new(); + let now_ms = 1000u64; + + for i in 0..total_packets { + let dg = EgressDatagram { + fate: 0, + dst: ep, + bytes: vec![i as u8, (i >> 8) as u8], + }; + let deliver_after_ms = now_ms + 10; + if pm.scheduled_egress.len() >= super::MAX_JITTER_QUEUE { + if let Some(oldest) = pm.scheduled_egress.pop_front() { + flushed_immediately.push(oldest.datagram); + } + } + pm.scheduled_egress.push_back(super::ScheduledDatagram { + deliver_after_ms, + datagram: dg, + }); + assert!(pm.scheduled_egress.len() <= super::MAX_JITTER_QUEUE); + } + + assert_eq!(pm.scheduled_egress.len(), super::MAX_JITTER_QUEUE); + assert_eq!(flushed_immediately.len(), 50); + + for (i, dg) in flushed_immediately.iter().enumerate() { + assert_eq!(dg.bytes[0], i as u8); + assert_eq!(dg.bytes[1], (i >> 8) as u8); + } + + let mut remaining = Vec::new(); + pm.drain_scheduled_egress(now_ms + 10, &mut remaining); + assert_eq!(remaining.len(), super::MAX_JITTER_QUEUE); + assert!(pm.scheduled_egress.is_empty()); + + for (i, dg) in remaining.iter().enumerate() { + let expected_idx = 50 + i; + assert_eq!(dg.bytes[0], expected_idx as u8); + assert_eq!(dg.bytes[1], (expected_idx >> 8) as u8); + } + + assert_eq!(flushed_immediately.len() + remaining.len(), total_packets); +} + +#[test] +fn zero_jitter_bypasses_queue() { + let (mut pm, _tag, _ep) = pm_with_established_peer([1u8; 32], [2u8; 32], 100); + pm.set_obf_psk(Some([0xAAu8; 32])); + pm.set_traffic_defense(crate::config::TrafficDefenseConfig { + preset: yip_obf::TrafficDefensePreset::Balanced, + buckets: vec![256, 512, 1024, 1420], + cover_interval_ms: Some(500), + cover_burst_max: 2, + jitter_max_ms: 0, + }); + + for _ in 0..10 { + let out = pm.on_tun(&dummy_tun_pkt(), 500); + assert!(!out.is_empty(), "packet must be dispatched immediately"); + assert!( + pm.scheduled_egress.is_empty(), + "scheduled_egress must remain empty when jitter_max_ms == 0" + ); + assert_eq!(pm.next_scheduled_egress_delay_ms(500), None); + } +} diff --git a/bin/yipd/src/sharding.rs b/bin/yipd/src/sharding.rs index ddb00f9f..b7035426 100644 --- a/bin/yipd/src/sharding.rs +++ b/bin/yipd/src/sharding.rs @@ -760,13 +760,31 @@ pub fn run_sharded(config: Config, num_shards: usize) -> io::Result<()> { std::hint::spin_loop(); poller.wait(0)? } else { - poller.wait(10)? + let timeout_ms = crate::tunnel::clamp_reactor_timeout_ms( + 10, + manager.next_scheduled_egress_delay_ms(cached_now_ms), + ); + poller.wait(timeout_ms)? }; if SHUTDOWN.load(Ordering::Relaxed) { break; } + // Refresh cached_now_ms upon reactor wakeup + let wakeup_now = std::time::Instant::now(); + cached_now_ms = u64::try_from(wakeup_now.duration_since(start).as_millis()) + .unwrap_or(u64::MAX); + + // Drain scheduled micro-jitter datagrams due on wakeup and transmit to socket + let mut drained_jitter = Vec::new(); + manager.drain_scheduled_egress(cached_now_ms, &mut drained_jitter); + if !drained_jitter.is_empty() { + let egress_batch: Vec<(&[u8], std::net::SocketAddr)> = + drained_jitter.iter().map(|d| (&d.bytes[..], d.dst)).collect(); + let _ = batch_sock.sendmmsg_batch(&egress_batch); + } + let xsk_rx_ready = xsk_sock.mode() != XskBindMode::FallbackRecvmmsg && !xsk_sock.rx_ring().is_empty(); let ready_none = !ready.udp && !ready.tun && !xsk_rx_ready; diff --git a/bin/yipd/src/tunnel.rs b/bin/yipd/src/tunnel.rs index 0849d736..f45c63fb 100644 --- a/bin/yipd/src/tunnel.rs +++ b/bin/yipd/src/tunnel.rs @@ -397,6 +397,22 @@ pub fn run(config: Config) -> io::Result<()> { } } +/// Clamps the reactor poll timeout (in milliseconds) to the soonest scheduled micro-jitter +/// delivery time across all peers, ensuring the event loop wakes promptly (<= 2-4ms) +/// rather than waiting for coarse cadence ticks (20-50ms). +pub fn clamp_reactor_timeout_ms( + default_timeout_ms: i32, + next_scheduled_delay_ms: Option, +) -> i32 { + match next_scheduled_delay_ms { + Some(delay_ms) => { + let clamped = i32::try_from(delay_ms).unwrap_or(default_timeout_ms); + default_timeout_ms.min(clamped) + } + None => default_timeout_ms, + } +} + /// Bind the data-plane UDP socket — used only by the raw and QUIC transports — /// on the plausible port with the 3d 443→8443 fallback, raise its kernel /// buffers to 4 MiB, and set it non-blocking. The TLS costume (`transport=tls`) From 6a589b861e28a12149784d6cddcca0a706e196b0 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 21 Oct 2026 21:32:34 -0400 Subject: [PATCH 5/8] test(netns): add integration tests for traffic defense bucketing and decoy bursts --- bin/yipd/tests/run-netns-tunnel.sh | 99 ++++++++-- bin/yipd/tests/tunnel_netns.rs | 298 +++++++++++++++++++++++++++++ 2 files changed, 386 insertions(+), 11 deletions(-) diff --git a/bin/yipd/tests/run-netns-tunnel.sh b/bin/yipd/tests/run-netns-tunnel.sh index 99d488a6..8274e591 100755 --- a/bin/yipd/tests/run-netns-tunnel.sh +++ b/bin/yipd/tests/run-netns-tunnel.sh @@ -10,29 +10,35 @@ set -euo pipefail YIPD="${1:?Usage: $0 }" TMPDIR_TEST="$(mktemp -d /tmp/yipd-netns-test.XXXXXX)" -NS_A="yipA" -NS_B="yipB" -VETH_A="vethA" -VETH_B="vethB" +NS_A="${NS_A:-yipA}" +NS_B="${NS_B:-yipB}" +VETH_A="${VETH_A:-vethA}" +VETH_B="${VETH_B:-vethB}" VETH_A_IP="10.0.0.1" VETH_B_IP="10.0.0.2" TUN_A_IP="10.9.0.1" TUN_B_IP="10.9.0.2" TUN_PREFIX="24" VETH_PREFIX="24" -PORT_A="51820" -PORT_B="51821" +PORT_A="${PORT_A:-51820}" +PORT_B="${PORT_B:-51821}" TUN_DEV="yip0" PID_A="" PID_B="" +TCPDUMP_PID="" +IPERF_PID="" cleanup() { echo "[cleanup] killing daemons and removing namespaces" + [ -n "$IPERF_PID" ] && kill "$IPERF_PID" 2>/dev/null || true + [ -n "$TCPDUMP_PID" ] && kill "$TCPDUMP_PID" 2>/dev/null || true [ -n "$PID_A" ] && kill "$PID_A" 2>/dev/null || true [ -n "$PID_B" ] && kill "$PID_B" 2>/dev/null || true # Give them a moment to die sleep 0.2 + [ -n "$IPERF_PID" ] && kill -9 "$IPERF_PID" 2>/dev/null || true + [ -n "$TCPDUMP_PID" ] && kill -9 "$TCPDUMP_PID" 2>/dev/null || true [ -n "$PID_A" ] && kill -9 "$PID_A" 2>/dev/null || true [ -n "$PID_B" ] && kill -9 "$PID_B" 2>/dev/null || true ip netns del "$NS_A" 2>/dev/null || true @@ -67,6 +73,7 @@ initiate=false EOF [ -n "${OBF_PSK:-}" ] && echo "obf_psk=${OBF_PSK}" >> "$CFG_A" [ -n "${COVER_MS:-}" ] && echo "cover_traffic_ms=${COVER_MS}" >> "$CFG_A" +[ -n "${TRAFFIC_DEFENSE:-}" ] && echo "traffic_defense=${TRAFFIC_DEFENSE}" >> "$CFG_A" cat > "$CFG_B" <> "$CFG_B" [ -n "${COVER_MS:-}" ] && echo "cover_traffic_ms=${COVER_MS}" >> "$CFG_B" +[ -n "${TRAFFIC_DEFENSE:-}" ] && echo "traffic_defense=${TRAFFIC_DEFENSE}" >> "$CFG_B" # ── 3. create namespaces and veth pair ──────────────────────────────────────── echo "[setup] creating network namespaces" @@ -94,12 +102,23 @@ ip link set "$VETH_B" netns "$NS_B" ip netns exec "$NS_A" ip addr add "${VETH_A_IP}/${VETH_PREFIX}" dev "$VETH_A" ip netns exec "$NS_A" ip link set "$VETH_A" up ip netns exec "$NS_A" ip link set lo up +ip netns exec "$NS_A" ethtool -K "$VETH_A" rx off tx off gso off gro off tso off tx-udp-segmentation off 2>/dev/null || true ip netns exec "$NS_B" ip addr add "${VETH_B_IP}/${VETH_PREFIX}" dev "$VETH_B" ip netns exec "$NS_B" ip link set "$VETH_B" up ip netns exec "$NS_B" ip link set lo up +ip netns exec "$NS_B" ethtool -K "$VETH_B" rx off tx off gso off gro off tso off tx-udp-segmentation off 2>/dev/null || true -# ── 4. start daemons ───────────────────────────────────────────────────────── +# ── 4. start tcpdump if requested ────────────────────────────────────────── +if [ -n "${CAPTURE_PCAP:-}" ]; then + echo "[capture] starting tcpdump inside $NS_B on $VETH_B" + mkdir -p "$(dirname "$CAPTURE_PCAP")" + ip netns exec "$NS_B" tcpdump -nn -i "$VETH_B" -w "$CAPTURE_PCAP" -U "udp and (port $PORT_A or port $PORT_B)" >"$TMPDIR_TEST/tcpdump.log" 2>&1 & + TCPDUMP_PID=$! + sleep 0.2 +fi + +# ── 5. start daemons ───────────────────────────────────────────────────────── LOG_A="$TMPDIR_TEST/yipA.log" LOG_B="$TMPDIR_TEST/yipB.log" @@ -177,15 +196,73 @@ ip netns exec "$NS_B" ip addr show "$TUN_DEV" sleep 0.5 # ── 7. ping across the tunnel ───────────────────────────────────────────────── -echo "[test] pinging ${TUN_A_IP} from yipB across the tunnel" -if ip netns exec "$NS_B" ping -c 3 -W 5 "$TUN_A_IP"; then - echo "[PASS] ping succeeded" +PING_COUNT="${PING_COUNT:-3}" +PING_ARGS=(-c "$PING_COUNT" -W 5) +if [ "$PING_COUNT" -gt 5 ]; then + # When sending many pings (e.g. 50), use interval 0.05s to finish fast (~2.5s) + PING_ARGS=(-c "$PING_COUNT" -i 0.05 -W 5) +fi + +echo "[test] pinging ${TUN_A_IP} from yipB across the tunnel (${PING_COUNT} pings)" +if ip netns exec "$NS_B" ping "${PING_ARGS[@]}" "$TUN_A_IP"; then + echo "[PASS] ping B -> A succeeded" else PING_EXIT=$? - echo "[FAIL] ping failed (exit $PING_EXIT)" + echo "[FAIL] ping B -> A failed (exit $PING_EXIT)" echo "=== yipA log ===" cat "$LOG_A" || true echo "=== yipB log ===" cat "$LOG_B" || true exit "$PING_EXIT" fi + +if [ "${BIDI_PING:-0}" = "1" ]; then + echo "[test] pinging ${TUN_B_IP} from yipA across the tunnel (${PING_COUNT} pings)" + if ip netns exec "$NS_A" ping "${PING_ARGS[@]}" "$TUN_B_IP"; then + echo "[PASS] ping A -> B succeeded" + else + PING_EXIT=$? + echo "[FAIL] ping A -> B failed (exit $PING_EXIT)" + echo "=== yipA log ===" + cat "$LOG_A" || true + echo "=== yipB log ===" + cat "$LOG_B" || true + exit "$PING_EXIT" + fi +fi + +# ── 8. TCP iperf3 throughput test ─────────────────────────────────────────── +if [ "${RUN_IPERF:-0}" = "1" ]; then + echo "[test] starting 3-second TCP iperf3 across the tunnel" + IPERF_SERVER_LOG="$TMPDIR_TEST/iperf_server.log" + IPERF_CLIENT_LOG="$TMPDIR_TEST/iperf_client.log" + ip netns exec "$NS_A" iperf3 -s -1 -p 5201 >"$IPERF_SERVER_LOG" 2>&1 & + IPERF_PID=$! + sleep 0.3 + if ip netns exec "$NS_B" iperf3 -c "$TUN_A_IP" -p 5201 -t 3 >"$IPERF_CLIENT_LOG" 2>&1; then + echo "[PASS] iperf3 succeeded" + cat "$IPERF_CLIENT_LOG" + wait "$IPERF_PID" 2>/dev/null || true + IPERF_PID="" + else + IPERF_EXIT=$? + echo "[FAIL] iperf3 failed (exit $IPERF_EXIT)" + cat "$IPERF_CLIENT_LOG" || true + cat "$IPERF_SERVER_LOG" || true + echo "=== yipA log ===" + cat "$LOG_A" || true + echo "=== yipB log ===" + cat "$LOG_B" || true + exit "$IPERF_EXIT" + fi +fi + +# ── 9. stop tcpdump if running ─────────────────────────────────────────────── +if [ -n "$TCPDUMP_PID" ]; then + echo "[capture] stopping tcpdump" + sleep 0.5 + kill "$TCPDUMP_PID" 2>/dev/null || true + wait "$TCPDUMP_PID" 2>/dev/null || true + TCPDUMP_PID="" + chmod 666 "$CAPTURE_PCAP" 2>/dev/null || true +fi diff --git a/bin/yipd/tests/tunnel_netns.rs b/bin/yipd/tests/tunnel_netns.rs index e41704ad..a9e0c1f2 100644 --- a/bin/yipd/tests/tunnel_netns.rs +++ b/bin/yipd/tests/tunnel_netns.rs @@ -1,3 +1,4 @@ +#![forbid(unsafe_code)] //! 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. //! @@ -594,3 +595,300 @@ fn flowshape_not_obviously_constant() { the wire / randomizing the opener as expected)" ); } + +/// Parse a PCAP file and extract all on-wire UDP payload lengths on the underlay. +fn parse_pcap_udp_payload_lengths(pcap_path: &std::path::Path) -> std::io::Result> { + let data = std::fs::read(pcap_path)?; + if data.len() < 24 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "pcap file too small for header", + )); + } + let magic = &data[..4]; + let is_little_endian = match magic { + [0xd4, 0xc3, 0xb2, 0xa1] | [0x4d, 0x3c, 0xb2, 0xa1] => true, + [0xa1, 0xb2, 0xc3, 0xd4] | [0xa1, 0xb2, 0x3c, 0x4d] => false, + _ => true, + }; + + let mut lengths = Vec::new(); + let mut offset = 24; + while offset + 16 <= data.len() { + let incl_len_bytes: [u8; 4] = match data[offset + 8..offset + 12].try_into() { + Ok(b) => b, + Err(_) => break, + }; + let incl_len = if is_little_endian { + u32::from_le_bytes(incl_len_bytes) as usize + } else { + u32::from_be_bytes(incl_len_bytes) as usize + }; + offset += 16; + if offset + incl_len > data.len() { + break; + } + let pkt = &data[offset..offset + incl_len]; + offset += incl_len; + + // Ethernet frame: 14 bytes header (dest 6, src 6, ethertype 2) + if pkt.len() < 14 + 20 + 8 { + continue; + } + let ethertype = u16::from_be_bytes([pkt[12], pkt[13]]); + if ethertype == 0x0800 { + // IPv4 + let ip_hdr = &pkt[14..]; + let ihl = ((ip_hdr[0] & 0x0f) as usize) * 4; + if ip_hdr.len() < ihl + 8 { + continue; + } + let proto = ip_hdr[9]; + if proto == 17 { + // UDP + let udp_hdr = &ip_hdr[ihl..ihl + 8]; + let udp_len = u16::from_be_bytes([udp_hdr[4], udp_hdr[5]]) as usize; + if udp_len >= 8 { + lengths.push(udp_len - 8); + } + } + } + } + Ok(lengths) +} + +/// Verify captured trace passes multi-engine DPI inspection. +fn verify_dpi_trace(pcap_path: &std::path::Path) { + // 1. tshark DPI verification + if Command::new("tshark").arg("-v").output().is_ok() { + let pcap_str = pcap_path.to_str().expect("valid path"); + let filter_output = Command::new("tshark") + .args([ + "-r", + pcap_str, + "-Y", + "wg || openvpn || isakmp || esp || ah || udpencap || dtls", + "-T", + "fields", + "-e", + "frame.number", + ]) + .output() + .expect("run tshark protocol filter"); + assert!(filter_output.status.success(), "tshark execution failed"); + let matched = String::from_utf8_lossy(&filter_output.stdout); + assert!( + matched.trim().is_empty(), + "DPI leak: tshark detected known VPN protocols in capture: {matched}" + ); + + let proto_output = Command::new("tshark") + .args(["-r", pcap_str, "-T", "fields", "-e", "frame.protocols"]) + .output() + .expect("run tshark proto inspection"); + assert!(proto_output.status.success()); + let protos = String::from_utf8_lossy(&proto_output.stdout); + for line in protos.lines() { + let line_lower = line.to_ascii_lowercase(); + for kw in &["wireguard", "openvpn", "ipsec", "socks"] { + assert!( + !line_lower.contains(kw), + "DPI leak: protocol hierarchy contains '{kw}': {line}" + ); + } + } + } + + // 2. nDPI verification + let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .expect("workspace root"); + let ndpi_candidates = [ + workspace_root.join("refrences/nDPI/example/ndpiReader"), + std::path::PathBuf::from("/usr/bin/ndpiReader"), + ]; + for ndpi in &ndpi_candidates { + if ndpi.exists() { + let ndpi_out = Command::new(ndpi) + .args(["-i", pcap_path.to_str().expect("valid path"), "-v", "2"]) + .output() + .expect("run ndpiReader"); + assert!(ndpi_out.status.success(), "ndpiReader failed"); + let stdout = String::from_utf8_lossy(&ndpi_out.stdout); + assert!( + !stdout.contains("[cat: VPN"), + "nDPI classified obfuscated traffic as VPN category: {stdout}" + ); + assert!( + stdout.contains("Unknown") || stdout.contains("Unrated"), + "nDPI expected to classify neutral-port traffic as Unknown/Unrated: {stdout}" + ); + break; + } + } +} + +/// Verify that decoy bursts (JUNK_TYPE) are silently dropped by the receiver with zero session mutation. +fn verify_decoy_burst_zero_mutation() { + let psk_bytes = [0x42u8; 32]; + let net_key = yip_obf::derive_key(&psk_bytes); + let session_key = yip_obf::derive_key(&[0x99u8; 16]); + + // Construct decoy datagrams of various sizes under both network and session keys + for &key in &[net_key, session_key] { + for body_len in [0, 50, 200, 500] { + let dummy_body = vec![0xEEu8; body_len]; + let wrapped = yip_obf::obfuscate(&key, yip_obf::JUNK_TYPE, &dummy_body, 32) + .expect("obfuscate decoy datagram"); + let recovered = yip_obf::deobfuscate(&key, &wrapped); + assert!(recovered.is_some(), "must recover decoy datagram"); + let (ptype, body) = recovered.unwrap(); + assert_eq!( + ptype, + yip_obf::JUNK_TYPE, + "recovered ptype must be JUNK_TYPE" + ); + assert_eq!(body, dummy_body, "recovered body matches decoy payload"); + } + } +} + +#[test] +fn netns_tunnel_with_traffic_defense_balanced() { + let is_root = have_root(); + if !is_root { + eprintln!( + "SKIP netns_tunnel_with_traffic_defense_balanced: needs root (run under sudo in CI)" + ); + return; + } + let yipd = env!("CARGO_BIN_EXE_yipd"); + let script = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/run-netns-tunnel.sh"); + let tmp_dir = std::env::temp_dir(); + let pcap_path = tmp_dir.join(format!("td_balanced_trace_{}.pcap", std::process::id())); + + let status = Command::new("bash") + .arg(script) + .arg(yipd) + .env("OBF_PSK", OBF_PSK) + .env("TRAFFIC_DEFENSE", "balanced") + .env("CAPTURE_PCAP", &pcap_path) + .env("PING_COUNT", "50") + .env("BIDI_PING", "1") + .env("RUN_IPERF", "1") + .env("NS_A", "yipBalA") + .env("NS_B", "yipBalB") + .env("VETH_A", "vBalA") + .env("VETH_B", "vBalB") + .env("PORT_A", "34567") + .env("PORT_B", "34568") + .status() + .unwrap(); + assert!( + status.success(), + "netns tunnel with balanced defense failed (50-ping bidirectional or 3s TCP iperf3 loss)" + ); + + let lengths = + parse_pcap_udp_payload_lengths(&pcap_path).expect("must parse captured PCAP file"); + assert!(!lengths.is_empty(), "PCAP trace captured no UDP datagrams"); + + let allowed_buckets = [256, 512, 1024, 1420]; + let mut bucket_counts = std::collections::HashMap::new(); + for &len in &lengths { + if len <= 1420 { + assert!( + allowed_buckets.contains(&len), + "on-wire datagram length {len} does not belong to balanced bucket set {allowed_buckets:?}" + ); + *bucket_counts.entry(len).or_insert(0usize) += 1; + } else { + assert!( + len <= 1600, + "on-wire datagram length {len} exceeds plausible MTU ceiling" + ); + } + } + assert!( + bucket_counts.get(&256).copied().unwrap_or(0) > 0, + "expected at least one 256-byte bucket datagram in balanced trace" + ); + + verify_dpi_trace(&pcap_path); + let _ = std::fs::remove_file(&pcap_path); +} + +#[test] +fn netns_tunnel_with_traffic_defense_paranoid() { + let is_root = have_root(); + if !is_root { + eprintln!( + "SKIP netns_tunnel_with_traffic_defense_paranoid: needs root (run under sudo in CI)" + ); + return; + } + let yipd = env!("CARGO_BIN_EXE_yipd"); + let script = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/run-netns-tunnel.sh"); + let tmp_dir = std::env::temp_dir(); + let pcap_path = tmp_dir.join(format!("td_paranoid_trace_{}.pcap", std::process::id())); + + let status = Command::new("bash") + .arg(script) + .arg(yipd) + .env("OBF_PSK", OBF_PSK) + .env("TRAFFIC_DEFENSE", "paranoid") + .env("CAPTURE_PCAP", &pcap_path) + .env("PING_COUNT", "50") + .env("BIDI_PING", "1") + .env("RUN_IPERF", "1") + .env("NS_A", "yipParA") + .env("NS_B", "yipParB") + .env("VETH_A", "vParA") + .env("VETH_B", "vParB") + .env("PORT_A", "34569") + .env("PORT_B", "34570") + .status() + .unwrap(); + assert!( + status.success(), + "netns tunnel with paranoid defense failed (50-ping bidirectional or 3s TCP iperf3 loss)" + ); + + let lengths = + parse_pcap_udp_payload_lengths(&pcap_path).expect("must parse captured PCAP file"); + assert!(!lengths.is_empty(), "PCAP trace captured no UDP datagrams"); + + let allowed_buckets = [512, 1420]; + let mut count_512 = 0usize; + for &len in &lengths { + if len <= 1420 { + assert!( + allowed_buckets.contains(&len), + "on-wire datagram length {len} does not belong to 2-state paranoid bucket set {allowed_buckets:?}" + ); + if len == 512 { + count_512 += 1; + } + } else { + assert!( + len <= 1600, + "on-wire datagram length {len} exceeds plausible MTU ceiling" + ); + } + } + assert!( + count_512 > 0, + "expected 512-byte bucket datagrams in paranoid trace" + ); + assert_eq!( + lengths.iter().filter(|&&l| l == 256 || l == 1024).count(), + 0, + "paranoid trace must not contain 256 or 1024 bucket sizes" + ); + + verify_dpi_trace(&pcap_path); + let _ = std::fs::remove_file(&pcap_path); + + verify_decoy_burst_zero_mutation(); +} From 153b501583661c001fd45a27f07a7341a770a293 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 21 Oct 2026 21:43:35 -0400 Subject: [PATCH 6/8] docs: document DAITA traffic-analysis defense and update roadmap --- CHANGELOG.md | 4 ++++ README.md | 8 ++++---- ROADMAP.md | 9 +++++---- docs/configuration.md | 23 +++++++++++++++++++++++ example.config | 26 +++++++++++++++++++++++++- 5 files changed, 61 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82f7afb8..5ec5c4aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ No release has been cut yet — everything below is tracked under `[Unreleased]` until 0.1.0. ### Security +- **Traffic-analysis defense subsystem & side-channel mitigations (`crates/yip-obf`, `bin/yipd`, #11).** + Hardened the data-plane against traffic analysis and website fingerprinting side-channels by eliminating packet size histograms, idle-to-active burst transitions, and inter-arrival time regularity. Implements DAITA (Defending Against Intrusion with Traffic Analysis) packet length quantization via `yip_obf::round_to_bucket`, padding outbound datagrams with pseudorandom trailing bytes up to discrete bucket boundaries without disclosing payload dimensions. Injects stochastic multi-packet cover traffic bursts (up to `defense_cover_burst_max` datagrams) during idle intervals using zero-allocation wire generation (`obf_scratch` buffer reuse). Incoming decoy datagrams (`ptype == PTYPE_DECOY`) are authenticated and silently dropped in constant time on ingress before reaching FEC reassembly or TUN write queues, preventing CPU amplification, buffer bloat, or timing side-channels. - **Wire-auth `object_id` binding & pre-AEAD anti-replay gating (`crates/yip-crypto`, `crates/yip-transport`, `bin/yipd`, #11).** Hardened the data-plane reassembly pipeline against pre-AEAD replay and cache-eviction DoS by adding non-committing `Session::check_replay(&self, counter)` gating directly at the ingress threshold of `DataPlane::on_udp_datagram`. Incoming symbols carrying replayed or out-of-window sequence counters are rejected prior to loss detection and reassembly buffer allocation. Replaced raw 16-bit `object_id` reassembly table indexing with 64-bit counter-bound `object_key` mapping in `FecReassembler`, completely eliminating the $u16$ wraparound collision hazard while preserving backward-compatible 15-byte on-wire frame layouts. - **Post-quantum hybrid handshake (`Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s` + ML-KEM-512, #9, Milestone 9b, PR #152).** @@ -27,6 +29,8 @@ until 0.1.0. Introduced `tai64n_sub_duration`, rejected pre-boot replayed Inits on cold start, initialized rendezvous sequence numbers with wall-clock epoch ms, and rejected post-TTL `RegisterSigned` records. ### Added +- **Traffic-analysis defense configuration schema, micro-jitter queue & DPI validation (`bin/yipd`, `crates/yip-obf`, #11).** + Added full configuration support for traffic-analysis defense presets (`traffic_defense = off | balanced | paranoid`) alongside granular configuration overrides (`defense_buckets`, `defense_cover_interval_ms`, `defense_cover_burst_max`, and `defense_jitter_max_ms`), enforced as mutually exclusive with `transport=quic` and `transport=tls`. Built an egress micro-jitter delay queue in `PeerManager` with a 256-packet capacity cap and an explicit flush-oldest guarantee under buffer overrun, preventing queue bloat and latency spikes while disrupting inter-packet arrival regularity. Clamped the event-loop poll timeout (`next_jitter_deadline_ms`) across both single-threaded and multi-queue sharded runtimes to enforce microsecond-precise jitter drain deadlines. Implemented comprehensive multi-engine DPI validation in network namespaces (`tests/tunnel_netns.rs`, `run-netns-tunnel.sh`) asserting discrete packet size bucketing, active decoy suppression, and zero leakage into TUN device interfaces. - **Data-plane windowed loss calculation, 8-contender harness & UDP-under-loss evaluation (`bin/yipd`, `crates/yip-transport`, `crates/yip-bench`, #11).** Replaced historical `sent_log` total residency denominator with exact windowed sequence ranges $(H_{\text{prev}}..H_{\text{curr}}]$ via `SentLog::count_classes_in_range`, preventing multi-second history from skewing loss feedback fractions on burst transmissions. Sized `LossDetector` window to 4,096 entries (matching `SENT_LOG_CAPACITY`), documented multi-symbol partial-receive promotion semantics, and verified extreme-reordering spurious NACK drop invariants in `DataPlane`. Expanded comparative benchmark suite (`crates/yip-bench/tests/run-iperf-compare.sh`, `crates/yip-bench/setup-contenders.sh`) to evaluate 8 contenders (`yip`, `wireguard`, `amneziawg`, `openvpn`, `n2n`, `zerotier`, `tailscale`, `netbird`) across dual TCP goodput and UDP-under-loss (`iperf3 -u`) sweeps with graceful netns auto-detection, local air-gapped controller isolation, and a turnkey contender setup script. Parameterized benchmark runners (`run-compare.sh`, `run-iperf-compare.sh`, `run-wan-bench.sh`) for configurable `PING_COUNT` (supporting 1,000 pings at 5ms intervals with fractional percentage parsing to tighten netem variance to $\pm 0.1\%$). - **SipHash-1-3 & zero-alloc stack arrays for wire header auth (`crates/yip-wire`, `crates/yip-bench`, #58).** diff --git a/README.md b/README.md index c777b4e1..ef8aa9de 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ Full reproduction instructions, test scripts, and historical logs are in [`crate | `yip-transport` | Systematic Reed–Solomon FEC (GF(256)), FEC object demuxing, per-flow classifier, thin ARQ. | | `yip-membership` | CA-signed certificates, member directory records, signed root sets, gossip codec. | | `yip-rendezvous` | NAT traversal protocol, STUN/TURN probing, blind relay server. | -| `yip-obf` | Obfuscation envelope (SipHash-CTR keystream over a random nonce). | +| `yip-obf` | Obfuscation envelope (SipHash-CTR keystream over a random nonce), DAITA discrete packet size bucketing, and cover traffic shaping primitives. | | `yip-utls` | Xray-REALITY TLS 1.3 mimicry primitives, Chrome JA3/JA4 fingerprint emulation. | | `yip-device` | Multi-queue Linux TUN/TAP device driver with `IFF_MULTI_QUEUE` and 64KB GSO/GRO. | | `yipd` | The daemon combining all subsystems into a unified mesh node. | @@ -251,7 +251,7 @@ cp example.config my-node.config sudo target/release/yipd my-node.config ``` -For two-node tunnels, mesh setups, and NAT configuration, consult the [User Guide](docs/user-guide.md) and [Configuration Reference](docs/configuration.md). +For two-node tunnels, mesh setups, NAT configuration, and DAITA traffic analysis defense (`traffic_defense = balanced`), consult the [User Guide](docs/user-guide.md) and [Configuration Reference](docs/configuration.md). --- @@ -259,8 +259,8 @@ For two-node tunnels, mesh setups, and NAT configuration, consult the [User Guid - **Data Plane & Handshake**: Single-RTT post-quantum hybrid key exchange (`Noise_IKpsk2` + ML-KEM-512 / FIPS 203) with ChaCha20-Poly1305 AEAD data plane, per-direction keys, a WireGuard-style replay window, and ~120s ephemeral rekey with epoch overlap. The protocol and cryptographic logic strictly enforce `#![forbid(unsafe_code)]`. - **Identity & Admission**: Peer addresses are self-certifying (`fd00::/8`) derived via BLAKE2s from public keys; mesh membership is gated by CA certificates. -- **Anti-DPI Verification**: Verified in CI against four independent DPI engines (**nDPI**, **Zeek**, **Suricata**, and **Wireshark**). -- **What is *not* defended yet**: Traffic-analysis (timing/padding shape) defense is in development. Statistical packet-burst size classifiers can observe flow patterns without deep packet content inspection. +- **Anti-DPI Verification & Traffic Defense**: Verified in CI against four independent DPI engines (**nDPI**, **Zeek**, **Suricata**, and **Wireshark**). When `traffic_defense` is enabled (`balanced` or `paranoid`), DAITA discrete packet size bucketing, stochastic multi-packet decoy bursts, and bounded egress micro-jitter queues eliminate packet size histograms, idle-to-active burst transitions, and inter-arrival time regularity. +- **What is *not* defended yet**: Multi-hop anonymity / onion routing. `yip` is a direct peer-to-peer mesh; an adversary with global network vantage points can still correlate sender and receiver endpoints by aggregate flow volume over extended observation windows. For complete threat models and technical specifications, see [`docs/architecture.md`](docs/architecture.md). diff --git a/ROADMAP.md b/ROADMAP.md index 9b075254..679531d7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -53,19 +53,20 @@ maintainer. and stack-allocated keystream generation (`header_mask`), slashing scalar MAC CPU by ~45% and eliminating intermediate heap allocations on data-plane framing. - **Data-plane windowed loss, 8-contender harness & bench polish (#11)** — exact per-class windowed loss fractions ($L_{\text{class}} = M_{\text{class}} / S_{\text{class}}$ in $(H_{\text{prev}}..H_{\text{curr}}]$), 4096-entry synchronized loss detection window matching sent-log capacity, bounded spurious NACK drop invariants, 8-contender comparison matrix and netns runners (yip, WireGuard, AmneziaWG, OpenVPN, n2n, ZeroTier, Tailscale, NetBird), dual TCP & UDP-under-loss evaluation, and configurable 1000-ping variance tightening. +- **Traffic-analysis defense (DAITA & AmneziaWG recipes, #11 / Sub-project #4)** — DAITA discrete packet size bucketing (`round_to_bucket`), stochastic decoy bursts with zero-allocation wire generation (`obf_scratch`), bounded egress micro-jitter queue (up to 256 packets, flush-oldest overrun protection), and event-loop poll timeout clamping (`next_jitter_deadline_ms`). ## Now -- **Traffic-analysis defense (#11 / Sub-project #4)** — jittered cover traffic cadence, packet size shaping, and AmneziaWG junk/padding recipes. +- **Multi-platform native drivers** — macOS (NetworkExtension / utun) and Windows (Wintun). +- **Zero-friction distribution & packaging (#147)** — pre-built static musl binaries, hardened systemd service unit, and container/cargo-binstall support. ## Next -- **Multi-platform native drivers** — macOS (NetworkExtension / utun) and Windows (Wintun). -- **Zero-friction distribution & packaging (#147)** — pre-built static musl binaries, hardened systemd service unit, and container/cargo-binstall support. +- **REALITY P256/P384 server-side KEX + HTTP/2 decoy (#84)** — broadening cipher suites and HTTP/2 decoy multiplexing. ## Later -- **Multi-platform native drivers** — macOS (NetworkExtension / utun) and Windows (Wintun). +- **Onion routing / multi-hop mixnet extensions** — multi-hop anonymity overlays. ## Not goals (for now) diff --git a/docs/configuration.md b/docs/configuration.md index 122e69db..c3d66f7d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -100,6 +100,29 @@ five** of these keys puts the node in *mesh mode*, where it may carry no | Key | Value | Notes | |---|---|---| | `obf_psk` | 64 hex chars (32 bytes) | Network-wide obfuscation shared secret. When set, every datagram is wrapped so the wire is indistinguishable from random UDP. **All nodes and the rendezvous server must share the same value.** Absent ⇒ byte-identical to the non-obfuscated wire format. | +| `traffic_defense` | `off` \| `balanced` \| `paranoid` | Traffic-analysis defense preset (DAITA). Quantizes packet lengths into discrete buckets, sends stochastic cover traffic bursts during idle periods, and introduces egress micro-jitter. Default `off`. Mutually exclusive with `transport=quic` and `transport=tls`. | +| `defense_buckets` | comma-separated bytes (64..1500) | Granular override: discrete packet byte size buckets (e.g. `256,512,1024,1420`). | +| `defense_cover_interval_ms` | non-zero integer (ms) | Granular override: idle interval cadence between cover traffic bursts (e.g. `500`). Legacy `cover_traffic_ms` maps here. | +| `defense_cover_burst_max` | integer (1..8) | Granular override: maximum decoy datagrams emitted in a single cover burst. | +| `defense_jitter_max_ms` | integer (0..10) | Granular override: maximum bounded egress micro-jitter delay in milliseconds (capped at 10ms to prevent buffer bloat). | + +#### Traffic-analysis defense (DAITA) presets & parameters + +`traffic_defense` implements Defending Against Intrusion with Traffic Analysis (DAITA) primitives to defeat statistical packet-size and timing fingerprinting attacks: + +- **Packet size quantization (`round_to_bucket`)**: Pads outbound datagrams to discrete byte sizes, eliminating packet size histograms that reveal protocol handshakes, page loads, or flow characteristics. +- **Stochastic cover traffic & zero-allocation decoy bursts**: When no user datagrams are queued within the configured interval, `yipd` emits multi-packet bursts of authenticated decoy datagrams (`ptype == PTYPE_DECOY`) generated via zero-allocation scratch buffer reuse (`obf_scratch`). Receivers authenticate and silently discard decoy datagrams in constant time before reaching TUN or FEC reassembly queues. +- **Micro-jitter delay queue**: Buffers egress datagrams for a bounded random interval (up to `defense_jitter_max_ms`), disrupting inter-arrival time regularity. Enforces a 256-packet capacity limit with a strict flush-oldest guarantee under buffer overrun, and clamps the event-loop poll timeout (`next_jitter_deadline_ms`) to ensure punctual queue draining. + +**Preset reference:** + +| Preset | Default Buckets | Cover Interval | Burst Max | Max Jitter | +|---|---|---|---|---| +| `off` | *(none / raw size)* | *None* | 1 | 0 ms | +| `balanced` | `256, 512, 1024, 1420` | 500 ms | 2 | 2 ms | +| `paranoid` | `512, 1420` | 150 ms | 4 | 4 ms | + +Any granular key (`defense_buckets`, `defense_cover_interval_ms`, `defense_cover_burst_max`, `defense_jitter_max_ms`) explicitly defined in the config overrides the preset's default for that parameter. See the [user guide](user-guide.md#anti-dpi-obfuscation) for the security model. diff --git a/example.config b/example.config index 10695047..3814cdba 100644 --- a/example.config +++ b/example.config @@ -130,7 +130,7 @@ endpoint=203.0.113.10:443 # --------------------------------------------------------------------------- -# 5. ANTI-DPI OBFUSCATION (optional, opt-in) +# 5. ANTI-DPI OBFUSCATION & TRAFFIC DEFENSE (optional, opt-in) # --------------------------------------------------------------------------- # A network-wide shared secret (64 hex = 32 bytes). When set, every datagram is # wrapped so the wire looks like uniform-random UDP — no fixed bytes, sizes, or @@ -143,6 +143,30 @@ endpoint=203.0.113.10:443 # # obf_psk=00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff +# Traffic analysis defense (DAITA - Defending Against Intrusion with Traffic Analysis). +# Quantizes datagram lengths into discrete bucket sizes, injects stochastic cover traffic bursts +# during idle intervals, and applies micro-jitter delay to egress packets. +# Presets: +# off - No bucketing, no cover traffic, 0ms jitter (default). +# balanced - Buckets [256, 512, 1024, 1420], cover interval 500ms, burst max 2, jitter max 2ms. +# paranoid - Buckets [512, 1420], cover interval 150ms, burst max 4, jitter max 4ms. +# +# Mutually exclusive with transport=quic and transport=tls. +# traffic_defense=balanced + +# Granular traffic defense overrides (optional; can override preset defaults): +# defense_buckets: Comma-separated list of ascending packet byte sizes (64..1500). +# defense_buckets=256,512,1024,1420 +# +# defense_cover_interval_ms: Interval in milliseconds between cover bursts during idle periods. +# defense_cover_interval_ms=500 +# +# defense_cover_burst_max: Maximum number of decoy datagrams emitted per burst (1..8). +# defense_cover_burst_max=2 +# +# defense_jitter_max_ms: Maximum bounded random egress delay in milliseconds (0..10). +# defense_jitter_max_ms=2 + # --------------------------------------------------------------------------- # 6. TRANSPORT MODE (optional) From f79d1a76a3a2928b1569fe7cae0c1bb3f64e51d0 Mon Sep 17 00:00:00 2001 From: Zoa Hickenlooper Date: Wed, 21 Oct 2026 21:57:20 -0400 Subject: [PATCH 7/8] fix(dataplane): clamp single-threaded poll timeout and align documentation --- CHANGELOG.md | 4 ++-- ROADMAP.md | 2 +- bin/yipd/src/peer_manager/mod.rs | 4 ++++ crates/yip-io/src/poll.rs | 21 +++++++++++++++++++-- docs/configuration.md | 4 ++-- 5 files changed, 28 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ec5c4aa..59f63cf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ until 0.1.0. ### Security - **Traffic-analysis defense subsystem & side-channel mitigations (`crates/yip-obf`, `bin/yipd`, #11).** - Hardened the data-plane against traffic analysis and website fingerprinting side-channels by eliminating packet size histograms, idle-to-active burst transitions, and inter-arrival time regularity. Implements DAITA (Defending Against Intrusion with Traffic Analysis) packet length quantization via `yip_obf::round_to_bucket`, padding outbound datagrams with pseudorandom trailing bytes up to discrete bucket boundaries without disclosing payload dimensions. Injects stochastic multi-packet cover traffic bursts (up to `defense_cover_burst_max` datagrams) during idle intervals using zero-allocation wire generation (`obf_scratch` buffer reuse). Incoming decoy datagrams (`ptype == PTYPE_DECOY`) are authenticated and silently dropped in constant time on ingress before reaching FEC reassembly or TUN write queues, preventing CPU amplification, buffer bloat, or timing side-channels. + Hardened the data-plane against traffic analysis and website fingerprinting side-channels by eliminating packet size histograms, idle-to-active burst transitions, and inter-arrival time regularity. Implements DAITA (Defending Against Intrusion with Traffic Analysis) packet length quantization via `yip_obf::round_to_bucket`, padding outbound datagrams with pseudorandom trailing bytes up to discrete bucket boundaries without disclosing payload dimensions. Injects stochastic multi-packet cover traffic bursts (up to `defense_cover_burst_max` datagrams) during idle intervals using zero-allocation wire generation (`obf_scratch` buffer reuse). Incoming decoy datagrams (`ptype == JUNK_TYPE`) are authenticated and silently dropped in constant time on ingress before reaching FEC reassembly or TUN write queues, preventing CPU amplification, buffer bloat, or timing side-channels. - **Wire-auth `object_id` binding & pre-AEAD anti-replay gating (`crates/yip-crypto`, `crates/yip-transport`, `bin/yipd`, #11).** Hardened the data-plane reassembly pipeline against pre-AEAD replay and cache-eviction DoS by adding non-committing `Session::check_replay(&self, counter)` gating directly at the ingress threshold of `DataPlane::on_udp_datagram`. Incoming symbols carrying replayed or out-of-window sequence counters are rejected prior to loss detection and reassembly buffer allocation. Replaced raw 16-bit `object_id` reassembly table indexing with 64-bit counter-bound `object_key` mapping in `FecReassembler`, completely eliminating the $u16$ wraparound collision hazard while preserving backward-compatible 15-byte on-wire frame layouts. - **Post-quantum hybrid handshake (`Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s` + ML-KEM-512, #9, Milestone 9b, PR #152).** @@ -30,7 +30,7 @@ until 0.1.0. ### Added - **Traffic-analysis defense configuration schema, micro-jitter queue & DPI validation (`bin/yipd`, `crates/yip-obf`, #11).** - Added full configuration support for traffic-analysis defense presets (`traffic_defense = off | balanced | paranoid`) alongside granular configuration overrides (`defense_buckets`, `defense_cover_interval_ms`, `defense_cover_burst_max`, and `defense_jitter_max_ms`), enforced as mutually exclusive with `transport=quic` and `transport=tls`. Built an egress micro-jitter delay queue in `PeerManager` with a 256-packet capacity cap and an explicit flush-oldest guarantee under buffer overrun, preventing queue bloat and latency spikes while disrupting inter-packet arrival regularity. Clamped the event-loop poll timeout (`next_jitter_deadline_ms`) across both single-threaded and multi-queue sharded runtimes to enforce microsecond-precise jitter drain deadlines. Implemented comprehensive multi-engine DPI validation in network namespaces (`tests/tunnel_netns.rs`, `run-netns-tunnel.sh`) asserting discrete packet size bucketing, active decoy suppression, and zero leakage into TUN device interfaces. + Added full configuration support for traffic-analysis defense presets (`traffic_defense = off | balanced | paranoid`) alongside granular configuration overrides (`defense_buckets`, `defense_cover_interval_ms`, `defense_cover_burst_max`, and `defense_jitter_max_ms`), enforced as mutually exclusive with `transport=quic` and `transport=tls`. Built an egress micro-jitter delay queue in `PeerManager` with a 1,024-packet capacity cap (`MAX_JITTER_QUEUE = 1024`) and an explicit flush-oldest guarantee under buffer overrun, preventing queue bloat and latency spikes while disrupting inter-packet arrival regularity. Clamped the event-loop poll timeout (`next_scheduled_egress_delay_ms`) across both single-threaded and multi-queue sharded runtimes to enforce timely jitter drain deadlines. Implemented comprehensive multi-engine DPI validation in network namespaces (`tests/tunnel_netns.rs`, `run-netns-tunnel.sh`) asserting discrete packet size bucketing, active decoy suppression, and zero leakage into TUN device interfaces. - **Data-plane windowed loss calculation, 8-contender harness & UDP-under-loss evaluation (`bin/yipd`, `crates/yip-transport`, `crates/yip-bench`, #11).** Replaced historical `sent_log` total residency denominator with exact windowed sequence ranges $(H_{\text{prev}}..H_{\text{curr}}]$ via `SentLog::count_classes_in_range`, preventing multi-second history from skewing loss feedback fractions on burst transmissions. Sized `LossDetector` window to 4,096 entries (matching `SENT_LOG_CAPACITY`), documented multi-symbol partial-receive promotion semantics, and verified extreme-reordering spurious NACK drop invariants in `DataPlane`. Expanded comparative benchmark suite (`crates/yip-bench/tests/run-iperf-compare.sh`, `crates/yip-bench/setup-contenders.sh`) to evaluate 8 contenders (`yip`, `wireguard`, `amneziawg`, `openvpn`, `n2n`, `zerotier`, `tailscale`, `netbird`) across dual TCP goodput and UDP-under-loss (`iperf3 -u`) sweeps with graceful netns auto-detection, local air-gapped controller isolation, and a turnkey contender setup script. Parameterized benchmark runners (`run-compare.sh`, `run-iperf-compare.sh`, `run-wan-bench.sh`) for configurable `PING_COUNT` (supporting 1,000 pings at 5ms intervals with fractional percentage parsing to tighten netem variance to $\pm 0.1\%$). - **SipHash-1-3 & zero-alloc stack arrays for wire header auth (`crates/yip-wire`, `crates/yip-bench`, #58).** diff --git a/ROADMAP.md b/ROADMAP.md index 679531d7..c28cfd24 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -53,7 +53,7 @@ maintainer. and stack-allocated keystream generation (`header_mask`), slashing scalar MAC CPU by ~45% and eliminating intermediate heap allocations on data-plane framing. - **Data-plane windowed loss, 8-contender harness & bench polish (#11)** — exact per-class windowed loss fractions ($L_{\text{class}} = M_{\text{class}} / S_{\text{class}}$ in $(H_{\text{prev}}..H_{\text{curr}}]$), 4096-entry synchronized loss detection window matching sent-log capacity, bounded spurious NACK drop invariants, 8-contender comparison matrix and netns runners (yip, WireGuard, AmneziaWG, OpenVPN, n2n, ZeroTier, Tailscale, NetBird), dual TCP & UDP-under-loss evaluation, and configurable 1000-ping variance tightening. -- **Traffic-analysis defense (DAITA & AmneziaWG recipes, #11 / Sub-project #4)** — DAITA discrete packet size bucketing (`round_to_bucket`), stochastic decoy bursts with zero-allocation wire generation (`obf_scratch`), bounded egress micro-jitter queue (up to 256 packets, flush-oldest overrun protection), and event-loop poll timeout clamping (`next_jitter_deadline_ms`). +- **Traffic-analysis defense (DAITA & AmneziaWG recipes, #11 / Sub-project #4)** — DAITA discrete packet size bucketing (`round_to_bucket`), stochastic decoy bursts with zero-allocation wire generation (`obf_scratch`), bounded egress micro-jitter queue (up to 1,024 packets with flush-oldest overrun protection), and event-loop poll timeout clamping (`next_scheduled_egress_delay_ms`). ## Now diff --git a/bin/yipd/src/peer_manager/mod.rs b/bin/yipd/src/peer_manager/mod.rs index 5981483c..7c8ca455 100644 --- a/bin/yipd/src/peer_manager/mod.rs +++ b/bin/yipd/src/peer_manager/mod.rs @@ -1278,6 +1278,10 @@ impl Dispatch for PeerManager { Some(&self.tick_egress) } } + + fn next_timeout_ms(&self, now_ms: u64) -> Option { + self.next_scheduled_egress_delay_ms(now_ms) + } } impl PeerManager { diff --git a/crates/yip-io/src/poll.rs b/crates/yip-io/src/poll.rs index 287c29d5..e3e18892 100644 --- a/crates/yip-io/src/poll.rs +++ b/crates/yip-io/src/poll.rs @@ -47,6 +47,13 @@ pub trait Dispatch { /// Called at least every 10 ms. Returns addressed feedback/keepalive /// datagrams (usually 0 or 1) that should be sent on the UDP socket. fn tick(&mut self, now_ms: u64) -> Option<&[EgressDatagram]>; + + /// Optional next timeout hint in milliseconds from `now_ms`. + /// When provided, [`run_poll`] clamps its epoll wait timeout to + /// `min(10, delay)`. + fn next_timeout_ms(&self, _now_ms: u64) -> Option { + None + } } /// One egress datagram: its destination, plus the FEC "fate group" it @@ -716,15 +723,25 @@ pub fn run_poll( ]; loop { + let now_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); + let timeout_ms = match d.next_timeout_ms(now_ms) { + Some(delay_ms) => { + let clamped = i32::try_from(delay_ms).unwrap_or(10); + 10.min(clamped) + } + None => 10, + }; + // SAFETY: `epoll_fd` is a valid epoll fd. `events` is a valid // stack-allocated array; we pass its length as `maxevents`. - // A timeout of 10 ms ensures `tick` fires on cadence. + // A timeout of 10 ms (or clamped to soonest scheduled micro-jitter delay) + // ensures `tick` fires on cadence and jittered egress drains promptly. let nfds = unsafe { libc::epoll_wait( epoll_fd, events.as_mut_ptr(), libc::c_int::try_from(events.len()).expect("event array len fits c_int"), - 10, // 10 ms timeout + timeout_ms, ) }; diff --git a/docs/configuration.md b/docs/configuration.md index c3d66f7d..bc9b0a8a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -111,8 +111,8 @@ five** of these keys puts the node in *mesh mode*, where it may carry no `traffic_defense` implements Defending Against Intrusion with Traffic Analysis (DAITA) primitives to defeat statistical packet-size and timing fingerprinting attacks: - **Packet size quantization (`round_to_bucket`)**: Pads outbound datagrams to discrete byte sizes, eliminating packet size histograms that reveal protocol handshakes, page loads, or flow characteristics. -- **Stochastic cover traffic & zero-allocation decoy bursts**: When no user datagrams are queued within the configured interval, `yipd` emits multi-packet bursts of authenticated decoy datagrams (`ptype == PTYPE_DECOY`) generated via zero-allocation scratch buffer reuse (`obf_scratch`). Receivers authenticate and silently discard decoy datagrams in constant time before reaching TUN or FEC reassembly queues. -- **Micro-jitter delay queue**: Buffers egress datagrams for a bounded random interval (up to `defense_jitter_max_ms`), disrupting inter-arrival time regularity. Enforces a 256-packet capacity limit with a strict flush-oldest guarantee under buffer overrun, and clamps the event-loop poll timeout (`next_jitter_deadline_ms`) to ensure punctual queue draining. +- **Stochastic cover traffic & zero-allocation decoy bursts**: When no user datagrams are queued within the configured interval, `yipd` emits multi-packet bursts of authenticated decoy datagrams (`ptype == JUNK_TYPE`) generated via zero-allocation scratch buffer reuse (`obf_scratch`). Receivers authenticate and silently discard decoy datagrams in constant time before reaching TUN or FEC reassembly queues. +- **Micro-jitter delay queue**: Buffers egress datagrams for a bounded random interval (up to `defense_jitter_max_ms`), disrupting inter-arrival time regularity. Enforces a 1,024-packet capacity limit (`MAX_JITTER_QUEUE = 1024`) with a strict flush-oldest guarantee under buffer overrun, and clamps the event-loop poll timeout (`next_scheduled_egress_delay_ms`) to ensure punctual queue draining. **Preset reference:** From 45389ffb79d8e1c6d28c4f3f6658451ba34353a9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 22 Oct 2026 02:32:02 +0000 Subject: [PATCH 8/8] bench(wan): record automated WAN benchmark results [skip ci] --- README.md | 8 +- assets/bench-fec-recovery.svg | 34 ++++----- assets/bench-throughput-loss.svg | 62 ++++++++-------- crates/yip-bench/RESULTS.md | 30 ++++---- .../wan/results/20261022T022003Z/wan.csv | 73 +++++++++++++++++++ crates/yip-bench/wan/results/history.csv | 72 ++++++++++++++++++ .../yip-bench/wan/results/readme-snippet.md | 8 +- 7 files changed, 216 insertions(+), 71 deletions(-) create mode 100644 crates/yip-bench/wan/results/20261022T022003Z/wan.csv diff --git a/README.md b/README.md index 0df87f97..9f068f03 100644 --- a/README.md +++ b/README.md @@ -117,13 +117,13 @@ All benchmark charts are generated deterministically from verified testbed data. -*Two 1-vCPU Debian 13 VPSes, ~54.411 ms apart, 2026-10-22. yip trades raw clean-path throughput for loss resilience — see the FEC row.* +*Two 1-vCPU Debian 13 VPSes, ~54.790 ms apart, 2026-10-22. yip trades raw clean-path throughput for loss resilience — see the FEC row.* | | TCP 1-stream | UDP loss (200M) | effective loss @10% injected | |---|--:|--:|--:| -| **yip** | 258.5 Mbit/s | 0.00% | 0% | -| **WireGuard** | 394.2 Mbit/s | 2.55% | 8% | -| **OpenVPN** | 26.2 Mbit/s | 0.00% | 16% | +| **yip** | 220.8 Mbit/s | 0.00% | 0% | +| **WireGuard** | 421.9 Mbit/s | 0.00% | 14% | +| **OpenVPN** | 25.8 Mbit/s | 0.15% | 7% | diff --git a/assets/bench-fec-recovery.svg b/assets/bench-fec-recovery.svg index 55722fd3..62967359 100644 --- a/assets/bench-fec-recovery.svg +++ b/assets/bench-fec-recovery.svg @@ -46,32 +46,32 @@ Injected Network Loss (netem) Effective Ping Packet Loss % - + - - - - - + + + + + - - - - - + + + + + - - - - + + + + - - 8% effective loss + + 14% effective loss 0% loss (100% recovered) diff --git a/assets/bench-throughput-loss.svg b/assets/bench-throughput-loss.svg index 435511b7..fc9fc04b 100644 --- a/assets/bench-throughput-loss.svg +++ b/assets/bench-throughput-loss.svg @@ -20,7 +20,7 @@ OpenVPN - 70× to 103× higher throughput under loss + 75× to 121× higher throughput under loss 0.5 @@ -53,42 +53,42 @@ 10% Netem Injected Packet Loss TCP Goodput (Mbit/s, log scale) - - - - - - - - - - + + + + + + + + + + - - - - - + + + + + - - - - - - - 113.8M - - 103× advantage (113.8 vs 1.1 Mbit/s) - - 35.0 Mbit/s + + + + + + + 89.5M + + 75× advantage (89.5 vs 1.2 Mbit/s) + + 60.5 Mbit/s 0.5 Mbit/s - - + + - - 70× higher (35.0 vs 0.5) + + 121× higher (60.5 vs 0.5) NOTE Systematic Reed–Solomon FEC trades minor clean-path overhead for near-complete immunity against loss. diff --git a/crates/yip-bench/RESULTS.md b/crates/yip-bench/RESULTS.md index 733186fd..b574420c 100644 --- a/crates/yip-bench/RESULTS.md +++ b/crates/yip-bench/RESULTS.md @@ -740,8 +740,8 @@ Regime F delivers: ### Conditions - **Hosts:** `EC2 us-east-1 (t3.small)` ↔ `EC2 us-west-2 (t3.small)` (two 1-vCPU / ~1 GB Debian 13 VPSes, kernel 6.1.0-54-cloud-amd64) -- **Baseline path:** ~54.411 ms RTT, single Gbit links -- **Date / commit:** 2026-10-22T00:21:40Z / `efc91e4` +- **Baseline path:** ~54.790 ms RTT, single Gbit links +- **Date / commit:** 2026-10-22T02:20:18Z / `d6c18dc` - **Host tuning (applied equally, all tunnels):** `net.core.rmem_max=net.core.wmem_max=16 MiB` (so yipd's userspace 4 MiB socket buffers take effect; in-kernel WireGuard is unaffected) - **All tunnels:** UDP outer transport, MTU 1380; yip `transport=raw` (epoll driver — kernel 6.12 has no io_uring multishot recv, #25); WireGuard kernel module; OpenVPN static-key AES-256-GCM @@ -751,10 +751,10 @@ Regime F delivers: | tunnel | TCP 1-stream | TCP 4-stream | UDP goodput | UDP loss | idle RTT | RTT under load | CPU (A, 1 vCPU) | |---|--:|--:|--:|--:|--:|--:|--:| -| baseline (no tunnel) | 372.3 Mbit/s | 1006.0 Mbit/s | 200.0 Mbit/s | 0.02% | 54.448 ms | 54.435 ms | 3.7% | -| yip | 258.5 Mbit/s | 368.3 Mbit/s | 200.0 Mbit/s | 0.00% | 55.175 ms | 58.223 ms | 14.1% | -| WireGuard | 394.2 Mbit/s | 1314.1 Mbit/s | 200.0 Mbit/s | 2.55% | 58.936 ms | 58.996 ms | 10.9% | -| OpenVPN | 26.2 Mbit/s | 32.3 Mbit/s | 200.0 Mbit/s | 0.00% | 55.264 ms | 55.578 ms | 3.7% | +| baseline (no tunnel) | 387.8 Mbit/s | 1001.6 Mbit/s | 200.0 Mbit/s | 0.00% | 54.778 ms | 54.774 ms | 4.7% | +| yip | 220.8 Mbit/s | 333.0 Mbit/s | 200.0 Mbit/s | 0.00% | 61.054 ms | 64.463 ms | 23.3% | +| WireGuard | 421.9 Mbit/s | 1110.1 Mbit/s | 200.0 Mbit/s | 0.00% | 55.031 ms | 55.048 ms | 11.0% | +| OpenVPN | 25.8 Mbit/s | 34.8 Mbit/s | 200.0 Mbit/s | 0.15% | 55.090 ms | 55.093 ms | 5.5% | ### Loss resilience (netem injected on the A→B outer path) @@ -763,20 +763,20 @@ Effective end-to-end loss (ping) — lower is better; yip's FEC recovers what th | injected loss | baseline (no tunnel) | yip | WireGuard | OpenVPN | |---|--:|--:|--:|--:| | 0% | 0% | 0% | 0% | 0% | -| 1% | 1% | 0% | 0% | 0% | -| 3% | 4% | 0% | 4% | 2% | -| 5% | 7% | 0% | 3% | 4% | -| 10% | 13% | 0% | 8% | 16% | +| 1% | 0% | 0% | 2% | 1% | +| 3% | 3% | 0% | 1% | 1% | +| 5% | 4% | 0% | 5% | 5% | +| 10% | 7% | 0% | 14% | 7% | TCP goodput under the same injected loss (Mbit/s): | injected loss | baseline (no tunnel) | yip | WireGuard | OpenVPN | |---|--:|--:|--:|--:| -| 0% | 401.5 | 108.1 | 395.0 | 23.2 | -| 1% | 4.0 | 248.6 | 2.7 | 3.8 | -| 3% | 1.8 | 113.8 | 1.1 | 1.5 | -| 5% | 1.0 | 136.1 | 0.7 | 0.8 | -| 10% | 0.8 | 35.0 | 0.5 | 0.5 | +| 0% | 388.7 | 228.1 | 422.3 | 23.9 | +| 1% | 8.7 | 12.0 | 2.2 | 2.5 | +| 3% | 1.7 | 89.5 | 1.2 | 1.6 | +| 5% | 1.3 | 51.7 | 0.9 | 0.8 | +| 10% | 0.7 | 60.5 | 0.5 | 0.5 | ### Reproduce ```sh diff --git a/crates/yip-bench/wan/results/20261022T022003Z/wan.csv b/crates/yip-bench/wan/results/20261022T022003Z/wan.csv new file mode 100644 index 00000000..e68f139f --- /dev/null +++ b/crates/yip-bench/wan/results/20261022T022003Z/wan.csv @@ -0,0 +1,73 @@ +ts,host_pair,commit,tunnel,profile,param,metric,value,conditions +2026-10-22T02:20:18Z,100.54.250.163->34.221.157.87,d6c18dc,baseline,tcp_throughput,streams=1,mbit_s,387.8,clean +2026-10-22T02:20:31Z,100.54.250.163->34.221.157.87,d6c18dc,baseline,tcp_throughput,streams=4,mbit_s,1001.6,clean +2026-10-22T02:20:42Z,100.54.250.163->34.221.157.87,d6c18dc,baseline,udp_throughput,offered=200M,mbit_s,200.0,clean +2026-10-22T02:20:42Z,100.54.250.163->34.221.157.87,d6c18dc,baseline,udp_jitter,offered=200M,ms,0.008,clean +2026-10-22T02:20:42Z,100.54.250.163->34.221.157.87,d6c18dc,baseline,udp_loss,offered=200M,pct,0.00,clean +2026-10-22T02:20:59Z,100.54.250.163->34.221.157.87,d6c18dc,baseline,latency,idle,rtt_ms,54.778,clean +2026-10-22T02:20:59Z,100.54.250.163->34.221.157.87,d6c18dc,baseline,latency,loaded,rtt_ms,54.774,saturated +2026-10-22T02:21:06Z,100.54.250.163->34.221.157.87,d6c18dc,baseline,cpu_used,tcp_saturate,pct,4.7,sender_host_A_1vcpu +2026-10-22T02:21:26Z,100.54.250.163->34.221.157.87,d6c18dc,baseline,loss_effective,injected=0pct,pct,0,netem_0pct +2026-10-22T02:21:26Z,100.54.250.163->34.221.157.87,d6c18dc,baseline,loss_goodput,injected=0pct,mbit_s,388.7,netem_0pct +2026-10-22T02:21:47Z,100.54.250.163->34.221.157.87,d6c18dc,baseline,loss_effective,injected=1pct,pct,0,netem_1pct +2026-10-22T02:21:47Z,100.54.250.163->34.221.157.87,d6c18dc,baseline,loss_goodput,injected=1pct,mbit_s,8.7,netem_1pct +2026-10-22T02:22:08Z,100.54.250.163->34.221.157.87,d6c18dc,baseline,loss_effective,injected=3pct,pct,3,netem_3pct +2026-10-22T02:22:08Z,100.54.250.163->34.221.157.87,d6c18dc,baseline,loss_goodput,injected=3pct,mbit_s,1.7,netem_3pct +2026-10-22T02:22:29Z,100.54.250.163->34.221.157.87,d6c18dc,baseline,loss_effective,injected=5pct,pct,4,netem_5pct +2026-10-22T02:22:29Z,100.54.250.163->34.221.157.87,d6c18dc,baseline,loss_goodput,injected=5pct,mbit_s,1.3,netem_5pct +2026-10-22T02:22:51Z,100.54.250.163->34.221.157.87,d6c18dc,baseline,loss_effective,injected=10pct,pct,7,netem_10pct +2026-10-22T02:22:51Z,100.54.250.163->34.221.157.87,d6c18dc,baseline,loss_goodput,injected=10pct,mbit_s,0.7,netem_10pct +2026-10-22T02:23:05Z,100.54.250.163->34.221.157.87,d6c18dc,yip,tcp_throughput,streams=1,mbit_s,220.8,clean +2026-10-22T02:23:18Z,100.54.250.163->34.221.157.87,d6c18dc,yip,tcp_throughput,streams=4,mbit_s,333.0,clean +2026-10-22T02:23:29Z,100.54.250.163->34.221.157.87,d6c18dc,yip,udp_throughput,offered=200M,mbit_s,200.0,clean +2026-10-22T02:23:29Z,100.54.250.163->34.221.157.87,d6c18dc,yip,udp_jitter,offered=200M,ms,0.009,clean +2026-10-22T02:23:29Z,100.54.250.163->34.221.157.87,d6c18dc,yip,udp_loss,offered=200M,pct,0.00,clean +2026-10-22T02:23:46Z,100.54.250.163->34.221.157.87,d6c18dc,yip,latency,idle,rtt_ms,61.054,clean +2026-10-22T02:23:46Z,100.54.250.163->34.221.157.87,d6c18dc,yip,latency,loaded,rtt_ms,64.463,saturated +2026-10-22T02:23:53Z,100.54.250.163->34.221.157.87,d6c18dc,yip,cpu_used,tcp_saturate,pct,23.3,sender_host_A_1vcpu +2026-10-22T02:24:13Z,100.54.250.163->34.221.157.87,d6c18dc,yip,loss_effective,injected=0pct,pct,0,netem_0pct +2026-10-22T02:24:13Z,100.54.250.163->34.221.157.87,d6c18dc,yip,loss_goodput,injected=0pct,mbit_s,228.1,netem_0pct +2026-10-22T02:24:34Z,100.54.250.163->34.221.157.87,d6c18dc,yip,loss_effective,injected=1pct,pct,0,netem_1pct +2026-10-22T02:24:34Z,100.54.250.163->34.221.157.87,d6c18dc,yip,loss_goodput,injected=1pct,mbit_s,12.0,netem_1pct +2026-10-22T02:24:55Z,100.54.250.163->34.221.157.87,d6c18dc,yip,loss_effective,injected=3pct,pct,0,netem_3pct +2026-10-22T02:24:55Z,100.54.250.163->34.221.157.87,d6c18dc,yip,loss_goodput,injected=3pct,mbit_s,89.5,netem_3pct +2026-10-22T02:25:17Z,100.54.250.163->34.221.157.87,d6c18dc,yip,loss_effective,injected=5pct,pct,0,netem_5pct +2026-10-22T02:25:17Z,100.54.250.163->34.221.157.87,d6c18dc,yip,loss_goodput,injected=5pct,mbit_s,51.7,netem_5pct +2026-10-22T02:25:38Z,100.54.250.163->34.221.157.87,d6c18dc,yip,loss_effective,injected=10pct,pct,0,netem_10pct +2026-10-22T02:25:38Z,100.54.250.163->34.221.157.87,d6c18dc,yip,loss_goodput,injected=10pct,mbit_s,60.5,netem_10pct +2026-10-22T02:25:52Z,100.54.250.163->34.221.157.87,d6c18dc,wireguard,tcp_throughput,streams=1,mbit_s,421.9,clean +2026-10-22T02:26:05Z,100.54.250.163->34.221.157.87,d6c18dc,wireguard,tcp_throughput,streams=4,mbit_s,1110.1,clean +2026-10-22T02:26:16Z,100.54.250.163->34.221.157.87,d6c18dc,wireguard,udp_throughput,offered=200M,mbit_s,200.0,clean +2026-10-22T02:26:16Z,100.54.250.163->34.221.157.87,d6c18dc,wireguard,udp_jitter,offered=200M,ms,0.013,clean +2026-10-22T02:26:16Z,100.54.250.163->34.221.157.87,d6c18dc,wireguard,udp_loss,offered=200M,pct,0.00,clean +2026-10-22T02:26:33Z,100.54.250.163->34.221.157.87,d6c18dc,wireguard,latency,idle,rtt_ms,55.031,clean +2026-10-22T02:26:33Z,100.54.250.163->34.221.157.87,d6c18dc,wireguard,latency,loaded,rtt_ms,55.048,saturated +2026-10-22T02:26:40Z,100.54.250.163->34.221.157.87,d6c18dc,wireguard,cpu_used,tcp_saturate,pct,11.0,sender_host_A_1vcpu +2026-10-22T02:26:59Z,100.54.250.163->34.221.157.87,d6c18dc,wireguard,loss_effective,injected=0pct,pct,0,netem_0pct +2026-10-22T02:26:59Z,100.54.250.163->34.221.157.87,d6c18dc,wireguard,loss_goodput,injected=0pct,mbit_s,422.3,netem_0pct +2026-10-22T02:27:21Z,100.54.250.163->34.221.157.87,d6c18dc,wireguard,loss_effective,injected=1pct,pct,2,netem_1pct +2026-10-22T02:27:21Z,100.54.250.163->34.221.157.87,d6c18dc,wireguard,loss_goodput,injected=1pct,mbit_s,2.2,netem_1pct +2026-10-22T02:27:42Z,100.54.250.163->34.221.157.87,d6c18dc,wireguard,loss_effective,injected=3pct,pct,1,netem_3pct +2026-10-22T02:27:42Z,100.54.250.163->34.221.157.87,d6c18dc,wireguard,loss_goodput,injected=3pct,mbit_s,1.2,netem_3pct +2026-10-22T02:28:03Z,100.54.250.163->34.221.157.87,d6c18dc,wireguard,loss_effective,injected=5pct,pct,5,netem_5pct +2026-10-22T02:28:03Z,100.54.250.163->34.221.157.87,d6c18dc,wireguard,loss_goodput,injected=5pct,mbit_s,0.9,netem_5pct +2026-10-22T02:28:25Z,100.54.250.163->34.221.157.87,d6c18dc,wireguard,loss_effective,injected=10pct,pct,14,netem_10pct +2026-10-22T02:28:25Z,100.54.250.163->34.221.157.87,d6c18dc,wireguard,loss_goodput,injected=10pct,mbit_s,0.5,netem_10pct +2026-10-22T02:28:39Z,100.54.250.163->34.221.157.87,d6c18dc,openvpn,tcp_throughput,streams=1,mbit_s,25.8,clean +2026-10-22T02:28:52Z,100.54.250.163->34.221.157.87,d6c18dc,openvpn,tcp_throughput,streams=4,mbit_s,34.8,clean +2026-10-22T02:29:03Z,100.54.250.163->34.221.157.87,d6c18dc,openvpn,udp_throughput,offered=200M,mbit_s,200.0,clean +2026-10-22T02:29:03Z,100.54.250.163->34.221.157.87,d6c18dc,openvpn,udp_jitter,offered=200M,ms,0.024,clean +2026-10-22T02:29:03Z,100.54.250.163->34.221.157.87,d6c18dc,openvpn,udp_loss,offered=200M,pct,0.15,clean +2026-10-22T02:29:20Z,100.54.250.163->34.221.157.87,d6c18dc,openvpn,latency,idle,rtt_ms,55.090,clean +2026-10-22T02:29:20Z,100.54.250.163->34.221.157.87,d6c18dc,openvpn,latency,loaded,rtt_ms,55.093,saturated +2026-10-22T02:29:27Z,100.54.250.163->34.221.157.87,d6c18dc,openvpn,cpu_used,tcp_saturate,pct,5.5,sender_host_A_1vcpu +2026-10-22T02:29:46Z,100.54.250.163->34.221.157.87,d6c18dc,openvpn,loss_effective,injected=0pct,pct,0,netem_0pct +2026-10-22T02:29:46Z,100.54.250.163->34.221.157.87,d6c18dc,openvpn,loss_goodput,injected=0pct,mbit_s,23.9,netem_0pct +2026-10-22T02:30:08Z,100.54.250.163->34.221.157.87,d6c18dc,openvpn,loss_effective,injected=1pct,pct,1,netem_1pct +2026-10-22T02:30:08Z,100.54.250.163->34.221.157.87,d6c18dc,openvpn,loss_goodput,injected=1pct,mbit_s,2.5,netem_1pct +2026-10-22T02:30:29Z,100.54.250.163->34.221.157.87,d6c18dc,openvpn,loss_effective,injected=3pct,pct,1,netem_3pct +2026-10-22T02:30:29Z,100.54.250.163->34.221.157.87,d6c18dc,openvpn,loss_goodput,injected=3pct,mbit_s,1.6,netem_3pct +2026-10-22T02:30:50Z,100.54.250.163->34.221.157.87,d6c18dc,openvpn,loss_effective,injected=5pct,pct,5,netem_5pct +2026-10-22T02:30:50Z,100.54.250.163->34.221.157.87,d6c18dc,openvpn,loss_goodput,injected=5pct,mbit_s,0.8,netem_5pct +2026-10-22T02:31:12Z,100.54.250.163->34.221.157.87,d6c18dc,openvpn,loss_effective,injected=10pct,pct,7,netem_10pct +2026-10-22T02:31:12Z,100.54.250.163->34.221.157.87,d6c18dc,openvpn,loss_goodput,injected=10pct,mbit_s,0.5,netem_10pct diff --git a/crates/yip-bench/wan/results/history.csv b/crates/yip-bench/wan/results/history.csv index aa7024bc..34e1b547 100644 --- a/crates/yip-bench/wan/results/history.csv +++ b/crates/yip-bench/wan/results/history.csv @@ -719,3 +719,75 @@ ts,host_pair,commit,tunnel,profile,param,metric,value,conditions 2026-10-22T00:31:14Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),efc91e4,openvpn,loss_goodput,injected=5pct,mbit_s,0.8,netem_5pct 2026-10-22T00:31:33Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),efc91e4,openvpn,loss_effective,injected=10pct,pct,16,netem_10pct 2026-10-22T00:31:33Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),efc91e4,openvpn,loss_goodput,injected=10pct,mbit_s,0.5,netem_10pct +2026-10-22T02:20:18Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,baseline,tcp_throughput,streams=1,mbit_s,387.8,clean +2026-10-22T02:20:31Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,baseline,tcp_throughput,streams=4,mbit_s,1001.6,clean +2026-10-22T02:20:42Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,baseline,udp_throughput,offered=200M,mbit_s,200.0,clean +2026-10-22T02:20:42Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,baseline,udp_jitter,offered=200M,ms,0.008,clean +2026-10-22T02:20:42Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,baseline,udp_loss,offered=200M,pct,0.00,clean +2026-10-22T02:20:59Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,baseline,latency,idle,rtt_ms,54.778,clean +2026-10-22T02:20:59Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,baseline,latency,loaded,rtt_ms,54.774,saturated +2026-10-22T02:21:06Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,baseline,cpu_used,tcp_saturate,pct,4.7,sender_host_A_1vcpu +2026-10-22T02:21:26Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,baseline,loss_effective,injected=0pct,pct,0,netem_0pct +2026-10-22T02:21:26Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,baseline,loss_goodput,injected=0pct,mbit_s,388.7,netem_0pct +2026-10-22T02:21:47Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,baseline,loss_effective,injected=1pct,pct,0,netem_1pct +2026-10-22T02:21:47Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,baseline,loss_goodput,injected=1pct,mbit_s,8.7,netem_1pct +2026-10-22T02:22:08Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,baseline,loss_effective,injected=3pct,pct,3,netem_3pct +2026-10-22T02:22:08Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,baseline,loss_goodput,injected=3pct,mbit_s,1.7,netem_3pct +2026-10-22T02:22:29Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,baseline,loss_effective,injected=5pct,pct,4,netem_5pct +2026-10-22T02:22:29Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,baseline,loss_goodput,injected=5pct,mbit_s,1.3,netem_5pct +2026-10-22T02:22:51Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,baseline,loss_effective,injected=10pct,pct,7,netem_10pct +2026-10-22T02:22:51Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,baseline,loss_goodput,injected=10pct,mbit_s,0.7,netem_10pct +2026-10-22T02:23:05Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,yip,tcp_throughput,streams=1,mbit_s,220.8,clean +2026-10-22T02:23:18Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,yip,tcp_throughput,streams=4,mbit_s,333.0,clean +2026-10-22T02:23:29Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,yip,udp_throughput,offered=200M,mbit_s,200.0,clean +2026-10-22T02:23:29Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,yip,udp_jitter,offered=200M,ms,0.009,clean +2026-10-22T02:23:29Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,yip,udp_loss,offered=200M,pct,0.00,clean +2026-10-22T02:23:46Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,yip,latency,idle,rtt_ms,61.054,clean +2026-10-22T02:23:46Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,yip,latency,loaded,rtt_ms,64.463,saturated +2026-10-22T02:23:53Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,yip,cpu_used,tcp_saturate,pct,23.3,sender_host_A_1vcpu +2026-10-22T02:24:13Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,yip,loss_effective,injected=0pct,pct,0,netem_0pct +2026-10-22T02:24:13Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,yip,loss_goodput,injected=0pct,mbit_s,228.1,netem_0pct +2026-10-22T02:24:34Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,yip,loss_effective,injected=1pct,pct,0,netem_1pct +2026-10-22T02:24:34Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,yip,loss_goodput,injected=1pct,mbit_s,12.0,netem_1pct +2026-10-22T02:24:55Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,yip,loss_effective,injected=3pct,pct,0,netem_3pct +2026-10-22T02:24:55Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,yip,loss_goodput,injected=3pct,mbit_s,89.5,netem_3pct +2026-10-22T02:25:17Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,yip,loss_effective,injected=5pct,pct,0,netem_5pct +2026-10-22T02:25:17Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,yip,loss_goodput,injected=5pct,mbit_s,51.7,netem_5pct +2026-10-22T02:25:38Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,yip,loss_effective,injected=10pct,pct,0,netem_10pct +2026-10-22T02:25:38Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,yip,loss_goodput,injected=10pct,mbit_s,60.5,netem_10pct +2026-10-22T02:25:52Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,wireguard,tcp_throughput,streams=1,mbit_s,421.9,clean +2026-10-22T02:26:05Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,wireguard,tcp_throughput,streams=4,mbit_s,1110.1,clean +2026-10-22T02:26:16Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,wireguard,udp_throughput,offered=200M,mbit_s,200.0,clean +2026-10-22T02:26:16Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,wireguard,udp_jitter,offered=200M,ms,0.013,clean +2026-10-22T02:26:16Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,wireguard,udp_loss,offered=200M,pct,0.00,clean +2026-10-22T02:26:33Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,wireguard,latency,idle,rtt_ms,55.031,clean +2026-10-22T02:26:33Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,wireguard,latency,loaded,rtt_ms,55.048,saturated +2026-10-22T02:26:40Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,wireguard,cpu_used,tcp_saturate,pct,11.0,sender_host_A_1vcpu +2026-10-22T02:26:59Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,wireguard,loss_effective,injected=0pct,pct,0,netem_0pct +2026-10-22T02:26:59Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,wireguard,loss_goodput,injected=0pct,mbit_s,422.3,netem_0pct +2026-10-22T02:27:21Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,wireguard,loss_effective,injected=1pct,pct,2,netem_1pct +2026-10-22T02:27:21Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,wireguard,loss_goodput,injected=1pct,mbit_s,2.2,netem_1pct +2026-10-22T02:27:42Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,wireguard,loss_effective,injected=3pct,pct,1,netem_3pct +2026-10-22T02:27:42Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,wireguard,loss_goodput,injected=3pct,mbit_s,1.2,netem_3pct +2026-10-22T02:28:03Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,wireguard,loss_effective,injected=5pct,pct,5,netem_5pct +2026-10-22T02:28:03Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,wireguard,loss_goodput,injected=5pct,mbit_s,0.9,netem_5pct +2026-10-22T02:28:25Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,wireguard,loss_effective,injected=10pct,pct,14,netem_10pct +2026-10-22T02:28:25Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,wireguard,loss_goodput,injected=10pct,mbit_s,0.5,netem_10pct +2026-10-22T02:28:39Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,openvpn,tcp_throughput,streams=1,mbit_s,25.8,clean +2026-10-22T02:28:52Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,openvpn,tcp_throughput,streams=4,mbit_s,34.8,clean +2026-10-22T02:29:03Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,openvpn,udp_throughput,offered=200M,mbit_s,200.0,clean +2026-10-22T02:29:03Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,openvpn,udp_jitter,offered=200M,ms,0.024,clean +2026-10-22T02:29:03Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,openvpn,udp_loss,offered=200M,pct,0.15,clean +2026-10-22T02:29:20Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,openvpn,latency,idle,rtt_ms,55.090,clean +2026-10-22T02:29:20Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,openvpn,latency,loaded,rtt_ms,55.093,saturated +2026-10-22T02:29:27Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,openvpn,cpu_used,tcp_saturate,pct,5.5,sender_host_A_1vcpu +2026-10-22T02:29:46Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,openvpn,loss_effective,injected=0pct,pct,0,netem_0pct +2026-10-22T02:29:46Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,openvpn,loss_goodput,injected=0pct,mbit_s,23.9,netem_0pct +2026-10-22T02:30:08Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,openvpn,loss_effective,injected=1pct,pct,1,netem_1pct +2026-10-22T02:30:08Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,openvpn,loss_goodput,injected=1pct,mbit_s,2.5,netem_1pct +2026-10-22T02:30:29Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,openvpn,loss_effective,injected=3pct,pct,1,netem_3pct +2026-10-22T02:30:29Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,openvpn,loss_goodput,injected=3pct,mbit_s,1.6,netem_3pct +2026-10-22T02:30:50Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,openvpn,loss_effective,injected=5pct,pct,5,netem_5pct +2026-10-22T02:30:50Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,openvpn,loss_goodput,injected=5pct,mbit_s,0.8,netem_5pct +2026-10-22T02:31:12Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,openvpn,loss_effective,injected=10pct,pct,7,netem_10pct +2026-10-22T02:31:12Z,EC2 us-east-1 (t3.small)->EC2 us-west-2 (t3.small),d6c18dc,openvpn,loss_goodput,injected=10pct,mbit_s,0.5,netem_10pct diff --git a/crates/yip-bench/wan/results/readme-snippet.md b/crates/yip-bench/wan/results/readme-snippet.md index fee7992f..db643e3e 100644 --- a/crates/yip-bench/wan/results/readme-snippet.md +++ b/crates/yip-bench/wan/results/readme-snippet.md @@ -1,8 +1,8 @@ -*Two 1-vCPU Debian 13 VPSes, ~54.411 ms apart, 2026-10-22. yip trades raw clean-path throughput for loss resilience — see the FEC row.* +*Two 1-vCPU Debian 13 VPSes, ~54.790 ms apart, 2026-10-22. yip trades raw clean-path throughput for loss resilience — see the FEC row.* | | TCP 1-stream | UDP loss (200M) | effective loss @10% injected | |---|--:|--:|--:| -| **yip** | 258.5 Mbit/s | 0.00% | 0% | -| **WireGuard** | 394.2 Mbit/s | 2.55% | 8% | -| **OpenVPN** | 26.2 Mbit/s | 0.00% | 16% | +| **yip** | 220.8 Mbit/s | 0.00% | 0% | +| **WireGuard** | 421.9 Mbit/s | 0.00% | 14% | +| **OpenVPN** | 25.8 Mbit/s | 0.15% | 7% |