Files
mitm-webserver/tools/network_timing_benchmark.py
malmert 945b259ebb
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 12s
scripts and texts
2026-05-03 16:38:37 +02:00

1135 lines
37 KiB
Python
Executable File

#!/usr/bin/env python3
"""Measure latency, jitter, throughput, packet loss, CPU, and interface counters.
The script is intended for repeated thesis experiments such as:
direct link
bridge only
bridge + AF_PACKET capture
bridge + tc/eBPF telemetry
bridge + nftables
bridge + NFQUEUE script
It uses only the Python standard library and common Linux tools. Raw command
outputs and machine-readable summaries are written to the output directory.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import os
import platform
import re
import shutil
import socket
import statistics
import subprocess
import sys
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
PING_TIME_RE = re.compile(r"time[=<]([0-9.]+)\s*ms")
PING_SENT_RE = re.compile(
r"(?P<sent>\d+)\s+packets transmitted,\s+"
r"(?P<received>\d+)\s+(?:packets )?received"
)
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def has_cmd(name: str) -> bool:
return shutil.which(name) is not None
def safe_name(value: str) -> str:
return re.sub(r"[^A-Za-z0-9_.-]+", "_", value).strip("_") or "run"
def format_command(command: list[str]) -> str:
return " ".join(command)
def read_text_file(path: Path, default: str | None = None) -> str | None:
try:
return path.read_text(encoding="utf-8").strip()
except OSError:
return default
def percentile(values: list[float], pct: float) -> float | None:
if not values:
return None
ordered = sorted(values)
if len(ordered) == 1:
return ordered[0]
rank = (len(ordered) - 1) * pct
lower = math.floor(rank)
upper = math.ceil(rank)
if lower == upper:
return ordered[lower]
return ordered[lower] + (ordered[upper] - ordered[lower]) * (rank - lower)
def numeric_stats(values: list[float]) -> dict[str, Any]:
if not values:
return {
"count": 0,
"min": None,
"max": None,
"mean": None,
"median": None,
"stdev": None,
"p90": None,
"p95": None,
"p99": None,
}
return {
"count": len(values),
"min": min(values),
"max": max(values),
"mean": statistics.fmean(values),
"median": statistics.median(values),
"stdev": statistics.stdev(values) if len(values) >= 2 else 0.0,
"p90": percentile(values, 0.90),
"p95": percentile(values, 0.95),
"p99": percentile(values, 0.99),
}
def jitter_stats_ms(rtts: list[float]) -> dict[str, Any]:
if len(rtts) < 2:
return {"mean_abs_delta_ms": None, "max_abs_delta_ms": None}
deltas = [abs(rtts[i] - rtts[i - 1]) for i in range(1, len(rtts))]
return {
"mean_abs_delta_ms": statistics.fmean(deltas),
"max_abs_delta_ms": max(deltas),
"delta_stats_ms": numeric_stats(deltas),
}
def read_proc_stat() -> tuple[int, int]:
with open("/proc/stat", "r", encoding="utf-8") as fh:
parts = fh.readline().split()
values = [int(value) for value in parts[1:]]
idle = values[3] + (values[4] if len(values) > 4 else 0)
total = sum(values)
return total, idle
def cpu_percent(prev: tuple[int, int], cur: tuple[int, int]) -> float:
total_delta = cur[0] - prev[0]
idle_delta = cur[1] - prev[1]
if total_delta <= 0:
return 0.0
return 100.0 * (1.0 - (idle_delta / total_delta))
def read_iface_counters(iface: str | None) -> dict[str, int]:
if not iface:
return {}
path = Path("/sys/class/net") / iface / "statistics"
if not path.is_dir():
return {}
counters: dict[str, int] = {}
for name in (
"rx_packets",
"tx_packets",
"rx_bytes",
"tx_bytes",
"rx_dropped",
"tx_dropped",
"rx_errors",
"tx_errors",
):
try:
counters[name] = int((path / name).read_text(encoding="utf-8").strip())
except OSError:
counters[name] = 0
return counters
def counter_delta(before: dict[str, int], after: dict[str, int]) -> dict[str, int]:
keys = set(before) | set(after)
return {key: after.get(key, 0) - before.get(key, 0) for key in sorted(keys)}
@dataclass
class Sample:
ts: float
cpu_percent: float | None
iface_counters: dict[str, int]
def to_dict(self) -> dict[str, Any]:
return {
"timestamp": self.ts,
"cpu_percent": self.cpu_percent,
"iface_counters": self.iface_counters,
}
@dataclass
class SystemSampler:
iface: str | None
interval: float = 0.5
samples: list[Sample] = field(default_factory=list)
_stop: threading.Event = field(default_factory=threading.Event)
_thread: threading.Thread | None = None
def start(self) -> None:
self._stop.clear()
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=2.0)
def _run(self) -> None:
previous_cpu = read_proc_stat()
while not self._stop.wait(self.interval):
current_cpu = read_proc_stat()
self.samples.append(
Sample(
ts=time.time(),
cpu_percent=cpu_percent(previous_cpu, current_cpu),
iface_counters=read_iface_counters(self.iface),
)
)
previous_cpu = current_cpu
def summary(self) -> dict[str, Any]:
cpu_values = [
sample.cpu_percent
for sample in self.samples
if sample.cpu_percent is not None
]
return {
"sample_count": len(self.samples),
"cpu_percent": numeric_stats(cpu_values),
}
def write_csv(self, path: Path) -> None:
counter_names: list[str] = sorted(
{
name
for sample in self.samples
for name in sample.iface_counters
}
)
fieldnames = ["timestamp", "cpu_percent", *counter_names]
with path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=fieldnames)
writer.writeheader()
for sample in self.samples:
row: dict[str, Any] = {
"timestamp": sample.ts,
"cpu_percent": sample.cpu_percent,
}
row.update(sample.iface_counters)
writer.writerow(row)
@dataclass
class CommandRun:
args: list[str]
returncode: int | None
stdout: str
stderr: str
timed_out: bool = False
error: str | None = None
def run_command(
command: list[str],
*,
cwd: Path,
raw_name: str,
iface: str | None,
timeout: float | None,
) -> tuple[CommandRun, dict[str, Any]]:
before = read_iface_counters(iface)
sampler = SystemSampler(iface=iface)
start_monotonic = time.perf_counter()
start_wall = now_iso()
sampler.start()
try:
completed = subprocess.run(
command,
cwd=str(cwd),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=timeout,
check=False,
)
result = CommandRun(
args=command,
returncode=completed.returncode,
stdout=completed.stdout or "",
stderr=completed.stderr or "",
)
except subprocess.TimeoutExpired as exc:
stdout = exc.stdout.decode("utf-8", errors="replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "")
stderr = exc.stderr.decode("utf-8", errors="replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "")
result = CommandRun(
args=command,
returncode=None,
stdout=stdout,
stderr=stderr,
timed_out=True,
error=f"command timed out after {timeout} seconds",
)
except OSError as exc:
result = CommandRun(
args=command,
returncode=None,
stdout="",
stderr=str(exc),
error=str(exc),
)
finally:
sampler.stop()
end_monotonic = time.perf_counter()
after = read_iface_counters(iface)
raw_path = cwd / raw_name
samples_path = cwd / f"{Path(raw_name).stem}.system_samples.csv"
sampler.write_csv(samples_path)
raw_path.write_text(
"$ " + format_command(command) + "\n\n"
+ "STDOUT\n"
+ (result.stdout or "")
+ "\nSTDERR\n"
+ (result.stderr or ""),
encoding="utf-8",
)
meta = {
"command": command,
"returncode": result.returncode,
"timed_out": result.timed_out,
"error": result.error,
"start_time": start_wall,
"duration_seconds": end_monotonic - start_monotonic,
"interface_counters_before": before,
"interface_counters_after": after,
"interface_counters_delta": counter_delta(before, after),
"system": sampler.summary(),
"raw_output": str(raw_path),
"system_samples_csv": str(samples_path),
}
(cwd / f"{Path(raw_name).stem}.command.json").write_text(
json.dumps(meta, indent=2),
encoding="utf-8",
)
return result, meta
def parse_ping_output(text: str, sent_fallback: int) -> dict[str, Any]:
rtts: list[float] = []
for line in text.splitlines():
match = PING_TIME_RE.search(line)
if match:
rtts.append(float(match.group(1)))
sent = sent_fallback
received = len(rtts)
for line in text.splitlines():
match = PING_SENT_RE.search(line)
if match:
sent = int(match.group("sent"))
received = int(match.group("received"))
break
loss_percent = None
if sent > 0:
loss_percent = 100.0 * (sent - received) / sent
return {
"sent": sent,
"received": received,
"lost": max(sent - received, 0),
"loss_percent": loss_percent,
"rtt_ms": numeric_stats(rtts),
"jitter": jitter_stats_ms(rtts),
"samples_ms": rtts,
}
def run_ping_tests(args: argparse.Namespace, out_dir: Path) -> list[dict[str, Any]]:
if args.skip_ping:
return []
if not has_cmd("ping"):
return [{"type": "ping", "skipped": True, "reason": "ping command not found"}]
results: list[dict[str, Any]] = []
for size in args.payload_sizes:
label = f"ping_{size}B"
command = [
"ping",
"-n",
"-D",
"-c",
str(args.ping_count),
"-i",
str(args.ping_interval),
"-s",
str(size),
args.target,
]
if args.interface:
command[1:1] = ["-I", args.interface]
timeout = args.ping_count * args.ping_interval + args.ping_timeout_extra
result, meta = run_command(
command,
cwd=out_dir,
raw_name=f"{label}.txt",
iface=args.interface,
timeout=timeout,
)
parsed = parse_ping_output(result.stdout + "\n" + result.stderr, args.ping_count)
if result.returncode != 0 and not parsed["samples_ms"]:
parsed["error"] = (result.stderr or result.stdout or "ping failed").strip()
parsed.update(
{
"type": "ping",
"payload_size_bytes": size,
"meta": meta,
}
)
results.append(parsed)
return results
def iperf_bits_per_second(payload: dict[str, Any]) -> float | None:
try:
end = payload.get("end", {})
if "sum_received" in end:
return float(end["sum_received"].get("bits_per_second"))
if "sum" in end:
return float(end["sum"].get("bits_per_second"))
except (TypeError, ValueError):
return None
return None
def run_iperf_tcp(args: argparse.Namespace, out_dir: Path) -> dict[str, Any]:
if args.skip_iperf:
return {"type": "iperf_tcp", "skipped": True, "reason": "--skip-iperf"}
if not has_cmd("iperf3"):
return {"type": "iperf_tcp", "skipped": True, "reason": "iperf3 command not found"}
command = [
"iperf3",
"-c",
args.target,
"-p",
str(args.iperf_tcp_port),
"-t",
str(args.iperf_duration),
"-J",
]
if args.iperf_parallel > 1:
command.extend(["-P", str(args.iperf_parallel)])
result, meta = run_command(
command,
cwd=out_dir,
raw_name="iperf_tcp.txt",
iface=args.interface,
timeout=args.iperf_duration + args.command_timeout_extra,
)
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError:
payload = {}
bps = iperf_bits_per_second(payload)
retransmits = None
try:
retransmits = payload["end"]["sum_sent"].get("retransmits")
except (KeyError, TypeError):
pass
return {
"type": "iperf_tcp",
"direction": "forward",
"bits_per_second": bps,
"mbit_per_second": (bps / 1_000_000.0) if bps is not None else None,
"retransmits": retransmits,
"json": payload,
"meta": meta,
}
def run_iperf_tcp_reverse(args: argparse.Namespace, out_dir: Path) -> dict[str, Any]:
if args.skip_iperf or args.skip_iperf_reverse:
return {"type": "iperf_tcp", "direction": "reverse", "skipped": True, "reason": "disabled"}
if not has_cmd("iperf3"):
return {"type": "iperf_tcp", "direction": "reverse", "skipped": True, "reason": "iperf3 command not found"}
command = [
"iperf3",
"-c",
args.target,
"-p",
str(args.iperf_tcp_port),
"-t",
str(args.iperf_duration),
"-R",
"-J",
]
if args.iperf_parallel > 1:
command.extend(["-P", str(args.iperf_parallel)])
result, meta = run_command(
command,
cwd=out_dir,
raw_name="iperf_tcp_reverse.txt",
iface=args.interface,
timeout=args.iperf_duration + args.command_timeout_extra,
)
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError:
payload = {}
bps = iperf_bits_per_second(payload)
retransmits = None
try:
retransmits = payload["end"]["sum_sent"].get("retransmits")
except (KeyError, TypeError):
pass
return {
"type": "iperf_tcp",
"direction": "reverse",
"bits_per_second": bps,
"mbit_per_second": (bps / 1_000_000.0) if bps is not None else None,
"retransmits": retransmits,
"json": payload,
"meta": meta,
}
def run_iperf_udp(args: argparse.Namespace, out_dir: Path) -> dict[str, Any]:
if args.skip_iperf or args.skip_udp_iperf:
return {"type": "iperf_udp", "skipped": True, "reason": "disabled"}
if not has_cmd("iperf3"):
return {"type": "iperf_udp", "skipped": True, "reason": "iperf3 command not found"}
command = [
"iperf3",
"-c",
args.target,
"-p",
str(args.iperf_udp_port),
"-t",
str(args.iperf_duration),
"-u",
"-b",
args.udp_bandwidth,
"-J",
]
result, meta = run_command(
command,
cwd=out_dir,
raw_name="iperf_udp.txt",
iface=args.interface,
timeout=args.iperf_duration + args.command_timeout_extra,
)
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError:
payload = {}
end = payload.get("end", {}) if isinstance(payload, dict) else {}
udp_sum = end.get("sum", {}) if isinstance(end, dict) else {}
bps = udp_sum.get("bits_per_second")
try:
bps = float(bps) if bps is not None else None
except (TypeError, ValueError):
bps = None
return {
"type": "iperf_udp",
"direction": "forward",
"bits_per_second": bps,
"mbit_per_second": (bps / 1_000_000.0) if bps is not None else None,
"jitter_ms": udp_sum.get("jitter_ms"),
"lost_packets": udp_sum.get("lost_packets"),
"packets": udp_sum.get("packets"),
"lost_percent": udp_sum.get("lost_percent"),
"json": payload,
"meta": meta,
}
def run_iperf_udp_reverse(args: argparse.Namespace, out_dir: Path) -> dict[str, Any]:
if args.skip_iperf or args.skip_udp_iperf or args.skip_iperf_reverse:
return {"type": "iperf_udp", "direction": "reverse", "skipped": True, "reason": "disabled"}
if not has_cmd("iperf3"):
return {"type": "iperf_udp", "direction": "reverse", "skipped": True, "reason": "iperf3 command not found"}
command = [
"iperf3",
"-c",
args.target,
"-p",
str(args.iperf_udp_port),
"-t",
str(args.iperf_duration),
"-u",
"-b",
args.udp_bandwidth,
"-R",
"-J",
]
result, meta = run_command(
command,
cwd=out_dir,
raw_name="iperf_udp_reverse.txt",
iface=args.interface,
timeout=args.iperf_duration + args.command_timeout_extra,
)
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError:
payload = {}
end = payload.get("end", {}) if isinstance(payload, dict) else {}
udp_sum = end.get("sum", {}) if isinstance(end, dict) else {}
bps = udp_sum.get("bits_per_second")
try:
bps = float(bps) if bps is not None else None
except (TypeError, ValueError):
bps = None
return {
"type": "iperf_udp",
"direction": "reverse",
"bits_per_second": bps,
"mbit_per_second": (bps / 1_000_000.0) if bps is not None else None,
"jitter_ms": udp_sum.get("jitter_ms"),
"lost_packets": udp_sum.get("lost_packets"),
"packets": udp_sum.get("packets"),
"lost_percent": udp_sum.get("lost_percent"),
"json": payload,
"meta": meta,
}
def run_arping(args: argparse.Namespace, out_dir: Path) -> dict[str, Any]:
if args.skip_arping:
return {"type": "arping", "skipped": True, "reason": "--skip-arping"}
if not args.interface:
return {"type": "arping", "skipped": True, "reason": "requires --interface"}
if not has_cmd("arping"):
return {"type": "arping", "skipped": True, "reason": "arping command not found"}
command = ["arping", "-I", args.interface, "-c", str(args.arping_count), args.target]
result, meta = run_command(
command,
cwd=out_dir,
raw_name="arping.txt",
iface=args.interface,
timeout=args.arping_count + args.command_timeout_extra,
)
samples: list[float] = []
for match in re.finditer(r"time[=\s]([0-9.]+)\s*ms", result.stdout + "\n" + result.stderr):
samples.append(float(match.group(1)))
return {
"type": "arping",
"samples_ms": samples,
"rtt_ms": numeric_stats(samples),
"jitter": jitter_stats_ms(samples),
"meta": meta,
"error": result.stderr.strip() if result.returncode not in (0, None) else None,
}
def run_traceroute(args: argparse.Namespace, out_dir: Path) -> dict[str, Any]:
if args.skip_path_tools:
return {"type": "traceroute", "skipped": True, "reason": "--skip-path-tools"}
if not has_cmd("traceroute"):
return {"type": "traceroute", "skipped": True, "reason": "traceroute command not found"}
command = ["traceroute", "-n", "-q", "3", args.target]
result, meta = run_command(
command,
cwd=out_dir,
raw_name="traceroute.txt",
iface=args.interface,
timeout=args.command_timeout_extra + 60,
)
return {
"type": "traceroute",
"meta": meta,
"output": result.stdout,
"error": result.stderr.strip() if result.returncode not in (0, None) else None,
}
def run_mtr(args: argparse.Namespace, out_dir: Path) -> dict[str, Any]:
if args.skip_path_tools:
return {"type": "mtr", "skipped": True, "reason": "--skip-path-tools"}
if not has_cmd("mtr"):
return {"type": "mtr", "skipped": True, "reason": "mtr command not found"}
command = ["mtr", "-n", "-r", "-c", str(args.mtr_count), "--json", args.target]
result, meta = run_command(
command,
cwd=out_dir,
raw_name="mtr.json.txt",
iface=args.interface,
timeout=args.mtr_count + args.command_timeout_extra + 20,
)
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError:
payload = {}
return {
"type": "mtr",
"json": payload,
"meta": meta,
"error": result.stderr.strip() if result.returncode not in (0, None) else None,
}
def run_sockperf(args: argparse.Namespace, out_dir: Path) -> list[dict[str, Any]]:
if args.skip_sockperf:
return [{"type": "sockperf", "skipped": True, "reason": "--skip-sockperf"}]
if not has_cmd("sockperf"):
return [{"type": "sockperf", "skipped": True, "reason": "sockperf command not found"}]
results: list[dict[str, Any]] = []
for protocol in args.sockperf_protocols:
raw_name = f"sockperf_{safe_name(protocol)}.txt"
port = args.sockperf_tcp_port if protocol == "tcp" else args.sockperf_udp_port
command = [
"sockperf",
"ping-pong",
"-i",
args.target,
"--time",
str(args.sockperf_duration),
"--port",
str(port),
]
if protocol == "tcp":
command.append("--tcp")
elif protocol == "udp":
command.append("--udp")
result, meta = run_command(
command,
cwd=out_dir,
raw_name=raw_name,
iface=args.interface,
timeout=args.sockperf_duration + args.command_timeout_extra,
)
text = result.stdout + "\n" + result.stderr
extracted: dict[str, float] = {}
for key, pattern in {
"avg_latency_usec": r"avg-latency[=\s:]+([0-9.]+)",
"min_latency_usec": r"min-latency[=\s:]+([0-9.]+)",
"max_latency_usec": r"max-latency[=\s:]+([0-9.]+)",
}.items():
match = re.search(pattern, text, flags=re.IGNORECASE)
if match:
extracted[key] = float(match.group(1))
results.append(
{
"type": "sockperf",
"protocol": protocol,
"port": port,
"extracted": extracted,
"meta": meta,
"error": result.stderr.strip() if result.returncode not in (0, None) else None,
}
)
return results
def run_flent(args: argparse.Namespace, out_dir: Path) -> list[dict[str, Any]]:
if args.skip_flent:
return [{"type": "flent", "skipped": True, "reason": "--skip-flent"}]
if not has_cmd("flent"):
return [{"type": "flent", "skipped": True, "reason": "flent command not found"}]
results: list[dict[str, Any]] = []
for test_name in args.flent_tests:
data_file = out_dir / f"flent_{safe_name(test_name)}.flent.gz"
raw_name = f"flent_{safe_name(test_name)}.txt"
command = [
"flent",
test_name,
"-l",
str(args.flent_duration),
"-H",
args.target,
"-t",
f"{args.label}-{test_name}",
"-o",
str(data_file),
]
result, meta = run_command(
command,
cwd=out_dir,
raw_name=raw_name,
iface=args.interface,
timeout=args.flent_duration + args.command_timeout_extra + 60,
)
results.append(
{
"type": "flent",
"test": test_name,
"data_file": str(data_file),
"meta": meta,
"error": result.stderr.strip() if result.returncode not in (0, None) else None,
}
)
return results
def run_metadata_command(command: list[str], out_dir: Path, name: str, timeout: float = 10.0) -> dict[str, Any]:
if not command or not has_cmd(command[0]):
return {"name": name, "command": command, "skipped": True, "reason": f"{command[0] if command else 'command'} not found"}
result, meta = run_command(
command,
cwd=out_dir,
raw_name=f"env_{safe_name(name)}.txt",
iface=None,
timeout=timeout,
)
return {
"name": name,
"command": command,
"returncode": result.returncode,
"meta": meta,
"error": result.stderr.strip() if result.returncode not in (0, None) else None,
}
def collect_environment(args: argparse.Namespace, out_dir: Path) -> dict[str, Any]:
env_dir = out_dir / "environment"
env_dir.mkdir(exist_ok=True)
iface = args.interface
sysfs: dict[str, Any] = {}
if iface:
base = Path("/sys/class/net") / iface
for name in ("address", "operstate", "mtu", "speed", "duplex", "carrier", "flags"):
sysfs[name] = read_text_file(base / name)
sysfs["statistics"] = read_iface_counters(iface)
proc_files: dict[str, str | None] = {}
for proc_name in ("net/dev", "net/softnet_stat", "interrupts", "softirqs"):
source = Path("/proc") / proc_name
content = read_text_file(source)
proc_files[proc_name] = str(env_dir / f"proc_{safe_name(proc_name)}.txt") if content is not None else None
if content is not None:
(env_dir / f"proc_{safe_name(proc_name)}.txt").write_text(content + "\n", encoding="utf-8")
commands: list[tuple[str, list[str]]] = [
("uname", ["uname", "-a"]),
("ip_addr", ["ip", "addr", "show"]),
("ip_route", ["ip", "route", "show", "table", "all"]),
("ip_neigh", ["ip", "neigh", "show"]),
("tc_qdisc", ["tc", "qdisc", "show"]),
("nft_ruleset", ["nft", "list", "ruleset"]),
]
if iface:
commands.extend(
[
("ip_link_iface", ["ip", "-details", "link", "show", "dev", iface]),
("tc_qdisc_iface", ["tc", "qdisc", "show", "dev", iface]),
("tc_filter_ingress_iface", ["tc", "filter", "show", "dev", iface, "ingress"]),
("tc_filter_egress_iface", ["tc", "filter", "show", "dev", iface, "egress"]),
("ethtool_iface", ["ethtool", iface]),
("ethtool_offloads_iface", ["ethtool", "-k", iface]),
("ethtool_stats_iface", ["ethtool", "-S", iface]),
]
)
command_results = [
run_metadata_command(command, env_dir, name)
for name, command in commands
]
environment = {
"host": {
"hostname": socket.gethostname(),
"platform": platform.platform(),
"python": sys.version,
"kernel": platform.release(),
},
"target": args.target,
"interface": iface,
"interface_sysfs": sysfs,
"proc_snapshots": proc_files,
"tool_availability": {
name: has_cmd(name)
for name in (
"ping",
"arping",
"iperf3",
"flent",
"sockperf",
"mtr",
"traceroute",
"ip",
"tc",
"nft",
"ethtool",
)
},
"commands": command_results,
}
(env_dir / "environment.json").write_text(json.dumps(environment, indent=2), encoding="utf-8")
return environment
def write_ping_csv(results: list[dict[str, Any]], out_dir: Path) -> None:
path = out_dir / "ping_samples.csv"
with path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=["payload_size_bytes", "sample_index", "rtt_ms"])
writer.writeheader()
for result in results:
if result.get("type") != "ping":
continue
for idx, rtt in enumerate(result.get("samples_ms", []), start=1):
writer.writerow(
{
"payload_size_bytes": result.get("payload_size_bytes"),
"sample_index": idx,
"rtt_ms": rtt,
}
)
def write_summary_md(summary: dict[str, Any], out_dir: Path) -> None:
lines = [
"# Network timing benchmark",
"",
f"- Label: `{summary['label']}`",
f"- Target: `{summary['target']}`",
f"- Interface: `{summary.get('interface') or 'default route'}`",
f"- Started: `{summary['started_at']}`",
"",
"## Ping",
"",
"| Payload | Sent | Received | Loss % | Mean RTT ms | Median | p95 | p99 | Mean jitter ms |",
"|---:|---:|---:|---:|---:|---:|---:|---:|---:|",
]
for result in summary["tests"]:
if result.get("type") != "ping" or result.get("skipped"):
continue
if result.get("error"):
lines.append(
f"| {result.get('payload_size_bytes')} | {result.get('sent')} | "
f"{result.get('received')} | {result.get('loss_percent') or 0.0:.3f} | "
"0.000 | 0.000 | 0.000 | 0.000 | 0.000 |"
)
lines.append("")
lines.append(f"Ping error for payload {result.get('payload_size_bytes')} B: `{result['error']}`")
lines.append("")
continue
rtt = result.get("rtt_ms", {})
jitter = result.get("jitter", {})
lines.append(
"| {payload} | {sent} | {received} | {loss:.3f} | {mean:.3f} | "
"{median:.3f} | {p95:.3f} | {p99:.3f} | {jitter:.3f} |".format(
payload=result.get("payload_size_bytes"),
sent=result.get("sent"),
received=result.get("received"),
loss=result.get("loss_percent") or 0.0,
mean=rtt.get("mean") or 0.0,
median=rtt.get("median") or 0.0,
p95=rtt.get("p95") or 0.0,
p99=rtt.get("p99") or 0.0,
jitter=jitter.get("mean_abs_delta_ms") or 0.0,
)
)
lines.extend(["", "## Throughput", ""])
for result in summary["tests"]:
if result.get("type") not in {"iperf_tcp", "iperf_udp"}:
continue
if result.get("skipped"):
direction = result.get("direction")
direction_text = f" `{direction}`" if direction else ""
lines.append(f"- `{result['type']}`{direction_text} skipped: {result.get('reason')}")
continue
lines.append(
f"- `{result['type']}` `{result.get('direction', 'forward')}`: {result.get('mbit_per_second')} Mbit/s"
+ (
f", loss={result.get('lost_percent')}%, jitter={result.get('jitter_ms')} ms"
if result["type"] == "iperf_udp"
else f", retransmits={result.get('retransmits')}"
)
)
lines.extend(["", "## Additional Tests", ""])
for result in summary["tests"]:
result_type = result.get("type")
if result_type in {"ping", "iperf_tcp", "iperf_udp"}:
continue
if result.get("skipped"):
lines.append(f"- `{result_type}` skipped: {result.get('reason')}")
continue
if result_type == "arping":
stats = result.get("rtt_ms", {})
lines.append(
f"- `arping`: count={stats.get('count')}, mean={stats.get('mean')} ms, "
f"p95={stats.get('p95')} ms"
)
elif result_type == "sockperf":
lines.append(f"- `sockperf` `{result.get('protocol')}`: {result.get('extracted')}")
elif result_type == "flent":
lines.append(f"- `flent` `{result.get('test')}`: data={result.get('data_file')}")
else:
lines.append(f"- `{result_type}`: raw={result.get('meta', {}).get('raw_output')}")
lines.extend(["", "## Raw Data", ""])
lines.append("- Full structured data: `summary.json`")
lines.append("- Ping samples: `ping_samples.csv`")
lines.append("- Environment snapshot: `environment/environment.json`")
lines.append("- Per-command raw output: `*.txt` files beside `*.command.json` metadata")
(out_dir / "summary.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
def parse_payload_sizes(value: str) -> list[int]:
try:
sizes = [int(item.strip()) for item in value.split(",") if item.strip()]
except ValueError as exc:
raise argparse.ArgumentTypeError("payload sizes must be comma-separated integers") from exc
if not sizes or any(size < 0 for size in sizes):
raise argparse.ArgumentTypeError("payload sizes must contain non-negative integers")
return sizes
def parse_csv_strings(value: str) -> list[str]:
items = [item.strip() for item in value.split(",") if item.strip()]
if not items:
raise argparse.ArgumentTypeError("value must contain at least one item")
return items
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Measure latency, jitter, throughput, loss, CPU, and interface counters.",
)
parser.add_argument("--target", required=True, help="Target host or IP address.")
parser.add_argument("--interface", "-i", help="Interface used for source binding/counters.")
parser.add_argument("--label", default="experiment", help="Experiment label, e.g. bridge-ebpf.")
parser.add_argument("--out-dir", default="measurements", help="Base output directory.")
parser.add_argument("--payload-sizes", type=parse_payload_sizes, default=[56, 512, 1472])
parser.add_argument("--ping-count", type=int, default=1000)
parser.add_argument("--ping-interval", type=float, default=0.02)
parser.add_argument("--ping-timeout-extra", type=float, default=20.0)
parser.add_argument("--skip-ping", action="store_true")
parser.add_argument("--skip-arping", action="store_true")
parser.add_argument("--arping-count", type=int, default=100)
parser.add_argument("--skip-iperf", action="store_true")
parser.add_argument("--skip-udp-iperf", action="store_true")
parser.add_argument("--skip-iperf-reverse", action="store_true")
parser.add_argument("--iperf-tcp-port", type=int, default=5201)
parser.add_argument("--iperf-udp-port", type=int, default=5202)
parser.add_argument("--iperf-duration", type=int, default=20)
parser.add_argument("--iperf-parallel", type=int, default=1)
parser.add_argument("--udp-bandwidth", default="100M")
parser.add_argument("--skip-flent", action="store_true")
parser.add_argument("--flent-tests", type=parse_csv_strings, default=["rrul", "tcp_upload", "tcp_download"])
parser.add_argument("--flent-duration", type=int, default=60)
parser.add_argument("--skip-sockperf", action="store_true")
parser.add_argument("--sockperf-protocols", type=parse_csv_strings, default=["udp", "tcp"])
parser.add_argument("--sockperf-tcp-port", type=int, default=11111)
parser.add_argument("--sockperf-udp-port", type=int, default=11112)
parser.add_argument("--sockperf-duration", type=int, default=30)
parser.add_argument("--skip-path-tools", action="store_true")
parser.add_argument("--mtr-count", type=int, default=100)
parser.add_argument("--no-archive", action="store_true")
parser.add_argument("--command-timeout-extra", type=float, default=15.0)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
if args.ping_count < 1:
parser.error("--ping-count must be >= 1")
if args.ping_interval <= 0:
parser.error("--ping-interval must be > 0")
if args.iperf_duration < 1:
parser.error("--iperf-duration must be >= 1")
if args.flent_duration < 1:
parser.error("--flent-duration must be >= 1")
if args.sockperf_duration < 1:
parser.error("--sockperf-duration must be >= 1")
for protocol in args.sockperf_protocols:
if protocol not in {"tcp", "udp"}:
parser.error("--sockperf-protocols may only contain tcp and/or udp")
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
out_dir = Path(args.out_dir) / f"{timestamp}-{args.label}"
out_dir.mkdir(parents=True, exist_ok=True)
environment = collect_environment(args, out_dir)
tests: list[dict[str, Any]] = []
tests.extend(run_ping_tests(args, out_dir))
tests.append(run_arping(args, out_dir))
tests.append(run_iperf_tcp(args, out_dir))
tests.append(run_iperf_tcp_reverse(args, out_dir))
tests.append(run_iperf_udp(args, out_dir))
tests.append(run_iperf_udp_reverse(args, out_dir))
tests.extend(run_sockperf(args, out_dir))
tests.extend(run_flent(args, out_dir))
tests.append(run_traceroute(args, out_dir))
tests.append(run_mtr(args, out_dir))
summary = {
"label": args.label,
"target": args.target,
"interface": args.interface,
"started_at": now_iso(),
"arguments": vars(args),
"environment": environment,
"tests": tests,
}
(out_dir / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
write_ping_csv([test for test in tests if test.get("type") == "ping"], out_dir)
write_summary_md(summary, out_dir)
archive_path = None
if not args.no_archive:
archive_path = shutil.make_archive(str(out_dir), "gztar", root_dir=out_dir)
print(f"Wrote benchmark results to {out_dir}")
print(f"Summary: {out_dir / 'summary.md'}")
if archive_path:
print(f"Archive: {archive_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())