Files
fips/testing/chaos/sim/traffic.py
T
Johnathan Corgan 56d39f223b Add ECN congestion signaling and transport congestion detection
Implement hop-by-hop ECN congestion signaling through the FMP layer,
transport-level congestion detection via kernel drop counters, and
chaos harness integration for end-to-end validation.

FMP/session ECN plumbing:

- Thread ce_flag parsed at link layer through dispatch_link_message,
  handle_session_datagram, handle_session_payload, and
  handle_encrypted_session_msg to session delivery
- Replace hardcoded false in session-layer record_recv() with actual
  ce_flag, activating ecn_ce_count tracking in session MMP

ECN congestion detection and CE relay:

- Add EcnConfig (node.ecn.*) with configurable loss_threshold (5%)
  and etx_threshold (3.0) for transit congestion detection
- Add send_encrypted_link_message_with_ce() that ORs FLAG_CE into FMP
  header flags; original method delegates with ce_flag=false
- Compute outgoing_ce = incoming_ce || local congestion on next-hop
  link, enabling hop-by-hop CE relay through transit nodes

IPv6 ECN-CE marking:

- Mark ECN-CE (0b11) in IPv6 Traffic Class on received DataPackets
  before TUN delivery when FMP CE flag is set
- Only marks ECN-capable packets (ECT(0)/ECT(1)); Not-ECT packets
  unchanged per RFC 3168

Transport congestion abstraction and UDP kernel drop detection:

- Add TransportCongestion struct to transport layer for transport-
  agnostic local congestion indicators
- Replace tokio::UdpSocket with AsyncFd<socket2::Socket> using
  libc::recvmsg() with ancillary data parsing
- Enable SO_RXQ_OVFL for kernel receive buffer drop counter on every
  packet, wiring up previously-stubbed UdpStats.kernel_drops
- Add TransportDropState for per-transport delta tracking with 1s
  tick sampling via sample_transport_congestion()
- Extend detect_congestion() with transport kernel drop check
  alongside MMP loss metrics

Congestion monitoring and control:

- Add CongestionStats (ce_forwarded, ce_received, congestion_detected,
  kernel_drop_events) to NodeStats with snapshot serialization
- Wire counters into forwarding path, session handler, and transport
  drop sampling with rate-limited warn logging (5s interval)
- Expose congestion data in show_routing control query and
  ecn_ce_count in show_mmp peer entries
- Add congestion counters to fipstop routing tab in two-column layout

Chaos harness integration:

- Add query_routing(), query_transports(), snapshot_all_congestion()
  to chaos control module
- Add congestion/kernel-drop log analysis in logs module
- Add congestion-stress scenario: 10-node tree, 1 Mbps bandwidth,
  5-10% netem loss, heavy iperf3 traffic
- Add IngressConfig for tc ingress policing with per-peer policer
  filters simulating upstream bandwidth bottlenecks
- Add iperf3 JSON result capture to traffic manager for throughput
  measurement across scenarios
- Add ECN A/B test scenarios (ecn-ab-on/off.yaml) with ingress
  policing and comparison script
- Enable TCP ECN negotiation (tcp_ecn=1 sysctl) in container
  entrypoint for end-to-end CE propagation

Tests:

- 10 ECN unit/integration tests: mark_ipv6_ecn_ce variants, CE relay
  chain (3-node propagation), EcnConfig serde roundtrip
- 3 transport drop congestion detection unit tests

Documentation:

- Update fips-mesh-layer.md: replace outdated CE Echo stub with full
  ECN Congestion Signaling section covering detection logic, CE relay,
  IPv6 marking, session tracking, and monitoring counters
- Update fips-configuration.md: add node.ecn.* parameter table and
  ecn block in complete reference YAML
- Update fips-transport-layer.md: add Congestion Reporting section
  with TransportCongestion struct, congestion() trait method, and
  per-transport status; document AsyncFd/recvmsg/SO_RXQ_OVFL in UDP
- Update chaos README: add congestion/ECN scenario docs, ingress
  traffic control, and iperf3 JSON capture sections
- Update README.md: add ECN to features list and "What works today";
  update transport and tooling entries
2026-03-05 17:05:57 +00:00

172 lines
5.6 KiB
Python

"""Random iperf3 traffic generation between node pairs.
Spawns iperf3 clients as background processes in containers. The iperf3
server is already running in each container (started by the Dockerfile
entrypoint).
"""
from __future__ import annotations
import json
import logging
import random
import time
from dataclasses import dataclass, field
from .docker_exec import docker_exec_quiet
from .scenario import TrafficConfig
from .topology import SimTopology
log = logging.getLogger(__name__)
@dataclass
class TrafficSession:
client_node: str
server_node: str
started_at: float
duration_secs: int
container: str
result_file: str = ""
class TrafficManager:
"""Manages random iperf3 sessions across the mesh."""
def __init__(
self,
topology: SimTopology,
config: TrafficConfig,
rng: random.Random,
down_nodes: set[str] | None = None,
):
self.topology = topology
self.config = config
self.rng = rng
self.down_nodes = down_nodes or set()
self.active_sessions: list[TrafficSession] = []
self.completed_results: list[dict] = []
@property
def active_count(self) -> int:
return len(self.active_sessions)
def maybe_spawn(self):
"""Spawn a new iperf3 session if under the concurrency limit."""
if self.active_count >= self.config.max_concurrent:
log.debug(
"At max_concurrent (%d), skipping traffic spawn",
self.config.max_concurrent,
)
return
node_ids = [nid for nid in self.topology.nodes if nid not in self.down_nodes]
if len(node_ids) < 2:
return
# Pick random client and server (different nodes, both up)
client, server = self.rng.sample(node_ids, 2)
server_npub = self.topology.nodes[server].npub
container = self.topology.container_name(client)
duration = int(
self.rng.uniform(
self.config.duration_secs.min,
self.config.duration_secs.max,
)
)
streams = self.config.parallel_streams
# Result file inside the container for JSON capture
ts = int(time.time())
result_file = f"/tmp/iperf3-{client}-{server}-{ts}.json"
# Start iperf3 in background with JSON output
cmd = (
f"nohup iperf3 -c {server_npub}.fips -t {duration} "
f"-P {streams} --json > {result_file} 2>&1 &"
)
result = docker_exec_quiet(container, cmd)
if result is not None:
session = TrafficSession(
client_node=client,
server_node=server,
started_at=time.time(),
duration_secs=duration,
container=container,
result_file=result_file,
)
self.active_sessions.append(session)
log.info(
"Traffic: %s -> %s (%ds, %d streams)",
client,
server,
duration,
streams,
)
else:
log.warning("Failed to start iperf3 on %s", container)
def cleanup_expired(self):
"""Remove sessions that have completed (based on time)."""
now = time.time()
grace = 5 # seconds after expected completion
still_active = []
for s in self.active_sessions:
if now - s.started_at >= s.duration_secs + grace:
self._collect_result(s)
else:
still_active.append(s)
removed = len(self.active_sessions) - len(still_active)
self.active_sessions = still_active
if removed > 0:
log.debug("Cleaned up %d expired traffic sessions", removed)
def _collect_result(self, session: TrafficSession):
"""Retrieve iperf3 JSON result from container."""
if not session.result_file:
return
if session.client_node in self.down_nodes:
return
stdout = docker_exec_quiet(
session.container,
f"cat {session.result_file} 2>/dev/null; rm -f {session.result_file}",
)
if stdout is None:
return
try:
data = json.loads(stdout.strip())
except (json.JSONDecodeError, ValueError):
log.debug("Could not parse iperf3 result for %s -> %s",
session.client_node, session.server_node)
return
data["_meta"] = {
"client": session.client_node,
"server": session.server_node,
"duration_secs": session.duration_secs,
}
self.completed_results.append(data)
def collect_results(self) -> list[dict]:
"""Return all completed iperf3 JSON results."""
# Collect any remaining active sessions that may have finished
for s in self.active_sessions:
self._collect_result(s)
return self.completed_results
def stop_all(self):
"""Kill all iperf3 client processes in running containers."""
seen = set()
for session in self.active_sessions:
if session.container not in seen:
if session.client_node not in self.down_nodes:
docker_exec_quiet(
session.container,
"killall iperf3 2>/dev/null; true",
)
seen.add(session.container)
# Collect results before clearing (iperf3 writes partial JSON on kill)
for s in self.active_sessions:
self._collect_result(s)
self.active_sessions.clear()