diff --git a/CHANGELOG.md b/CHANGELOG.md
index 82f7afb8..59f63cf9 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 == 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).**
@@ -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 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/README.md b/README.md
index 17cde98d..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% |
@@ -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..c28cfd24 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 1,024 packets with flush-oldest overrun protection), and event-loop poll timeout clamping (`next_scheduled_egress_delay_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/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/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());
+ }
}
diff --git a/bin/yipd/src/peer_manager/mod.rs b/bin/yipd/src/peer_manager/mod.rs
index 9816f11a..7c8ca455 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};
@@ -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;
@@ -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;
@@ -325,6 +333,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,
@@ -432,13 +443,17 @@ 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
/// 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,
+ /// 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
@@ -561,6 +576,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 +607,8 @@ impl PeerManager {
obf_key: None,
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")
@@ -670,6 +688,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 +726,82 @@ 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;
+ }
+
+ /// 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,
@@ -909,6 +1004,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,
@@ -1055,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) => {
@@ -1081,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;
@@ -1103,40 +1220,67 @@ 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)
+ }
+ }
+
+ fn next_timeout_ms(&self, now_ms: u64) -> Option {
+ self.next_scheduled_egress_delay_ms(now_ms)
}
}
@@ -1501,25 +1645,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 +1671,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..e13ae13c 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
@@ -217,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 301dd10c..95f9cd95 100644
--- a/bin/yipd/src/peer_manager/tests.rs
+++ b/bin/yipd/src/peer_manager/tests.rs
@@ -2471,3 +2471,381 @@ 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()
+ );
+}
+
+#[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 7ffa12ac..b7035426 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)
}
@@ -759,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 447daead..f45c63fb 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 {
@@ -396,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`)
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();
+}
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% |
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/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);
+ }
}
diff --git a/docs/configuration.md b/docs/configuration.md
index 122e69db..bc9b0a8a 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 == 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:**
+
+| 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)