From ecbca6a84ca2e954b2ca1265769cf2398b15b17d Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 15 Oct 2026 11:05:46 -0700 Subject: [PATCH] Add isolated Ethernet and SSH throughput test harnesses --- test/test_ethernet_hil_serial_open.py | 140 ++++++ test/test_ethernet_kiss_modem_probe.py | 289 ++++++++++++ test/test_ethernet_kiss_probe.py | 126 ++++++ test/test_ethernet_preamble_bench.py | 281 ++++++++++++ test/test_ethernet_preamble_ssh_runner.py | 217 +++++++++ test/test_ethernet_primary_probe.py | 74 +++ test/test_ethernet_radio2_sweep.py | 185 ++++++++ test/test_ethernet_serial_guard.py | 215 +++++++++ test/test_ethernet_serial_rpc.py | 206 +++++++++ test/test_ethernet_ssh_phone_bench.py | 61 +++ test/test_ethernet_tap_relay.py | 279 ++++++++++++ test/test_ethernet_tcp_stats.py | 57 +++ test/test_meshcore_ip_relay.py | 378 ++++++++++++++++ test/test_meshcore_ip_ssh_phone_bench.py | 83 ++++ test/test_meshcore_usb_sftp_bench.py | 44 ++ test/test_windows_usb_stdio_bridge.py | 207 +++++++++ tools/hil/ethernet_bench_serial.py | 103 +++++ tools/hil/ethernet_kiss_modem_probe.py | 263 +++++++++++ tools/hil/ethernet_kiss_probe.py | 335 ++++++++++++++ tools/hil/ethernet_preamble_bench.py | 325 ++++++++++++++ tools/hil/ethernet_preamble_ssh_runner.py | 361 +++++++++++++++ tools/hil/ethernet_primary_probe.py | 198 +++++++++ tools/hil/ethernet_radio2_sweep.py | 310 +++++++++++++ tools/hil/ethernet_serial_rpc.py | 96 ++++ tools/hil/ethernet_ssh_bench.sh | 210 +++++++++ tools/hil/ethernet_ssh_flutter_bench.sh | 183 ++++++++ tools/hil/ethernet_ssh_phone_bench.sh | 137 ++++++ tools/hil/ethernet_ssh_serial.py | 126 ++++++ tools/hil/ethernet_tap_relay.py | 428 ++++++++++++++++++ tools/hil/ethernet_tcp_stats.py | 53 +++ tools/hil/meshcore_ip_relay.py | 519 ++++++++++++++++++++++ tools/hil/meshcore_ip_ssh_phone_bench.sh | 165 +++++++ tools/hil/meshcore_usb_sftp_bench.sh | 170 +++++++ tools/hil/serial_session.py | 76 ++++ tools/hil/windows_usb_stdio_bridge.py | 311 +++++++++++++ 35 files changed, 7211 insertions(+) create mode 100644 test/test_ethernet_hil_serial_open.py create mode 100644 test/test_ethernet_kiss_modem_probe.py create mode 100644 test/test_ethernet_kiss_probe.py create mode 100644 test/test_ethernet_preamble_bench.py create mode 100644 test/test_ethernet_preamble_ssh_runner.py create mode 100644 test/test_ethernet_primary_probe.py create mode 100644 test/test_ethernet_radio2_sweep.py create mode 100644 test/test_ethernet_serial_guard.py create mode 100644 test/test_ethernet_serial_rpc.py create mode 100644 test/test_ethernet_ssh_phone_bench.py create mode 100644 test/test_ethernet_tap_relay.py create mode 100644 test/test_ethernet_tcp_stats.py create mode 100644 test/test_meshcore_ip_relay.py create mode 100644 test/test_meshcore_ip_ssh_phone_bench.py create mode 100644 test/test_meshcore_usb_sftp_bench.py create mode 100644 test/test_windows_usb_stdio_bridge.py create mode 100644 tools/hil/ethernet_bench_serial.py create mode 100644 tools/hil/ethernet_kiss_modem_probe.py create mode 100644 tools/hil/ethernet_kiss_probe.py create mode 100644 tools/hil/ethernet_preamble_bench.py create mode 100644 tools/hil/ethernet_preamble_ssh_runner.py create mode 100644 tools/hil/ethernet_primary_probe.py create mode 100644 tools/hil/ethernet_radio2_sweep.py create mode 100644 tools/hil/ethernet_serial_rpc.py create mode 100644 tools/hil/ethernet_ssh_bench.sh create mode 100644 tools/hil/ethernet_ssh_flutter_bench.sh create mode 100644 tools/hil/ethernet_ssh_phone_bench.sh create mode 100644 tools/hil/ethernet_ssh_serial.py create mode 100644 tools/hil/ethernet_tap_relay.py create mode 100644 tools/hil/ethernet_tcp_stats.py create mode 100644 tools/hil/meshcore_ip_relay.py create mode 100644 tools/hil/meshcore_ip_ssh_phone_bench.sh create mode 100644 tools/hil/meshcore_usb_sftp_bench.sh create mode 100644 tools/hil/windows_usb_stdio_bridge.py diff --git a/test/test_ethernet_hil_serial_open.py b/test/test_ethernet_hil_serial_open.py new file mode 100644 index 000000000..a88c8c8ee --- /dev/null +++ b/test/test_ethernet_hil_serial_open.py @@ -0,0 +1,140 @@ +"""Actual HIL constructors must not pulse CH340 reset on POSIX serial open.""" + +import contextlib +import importlib.util +import io +import os +from pathlib import Path +import sys +from types import ModuleType, SimpleNamespace +import unittest +from unittest.mock import Mock, patch + + +BASE = Path(__file__).resolve().parents[1] / "tools/hil" +sys.path.insert(0, str(BASE)) +import ethernet_kiss_probe as probe +import ethernet_tap_relay as tap + + +class FakeSerial: + def __init__(self, **kwargs): + self.dsrdtr = False + self._dtr = True + self._rts = True + self.driver_rts = True + self.driver_dtr = True + self.is_open = False + self.resets = 0 + self.writes = [] + self.trace = [] + + @property + def dtr(self): + return self._dtr + + @dtr.setter + def dtr(self, value): + self._dtr = value + if self.is_open: + self._apply_dtr(value) + + @property + def rts(self): + return self._rts + + @rts.setter + def rts(self, value): + self._rts = value + if self.is_open: + self.driver_rts = value + self.trace.append(("rts", value)) + + def _apply_dtr(self, value): + if not value and self.driver_rts: + self.resets += 1 + self.driver_dtr = value + self.trace.append(("dtr", value)) + + def open(self): + self.is_open = True + # POSIX pySerial's real open order: DTR before RTS unless deferred. + if not self.dsrdtr: + self._apply_dtr(self._dtr) + self.rts = self._rts + + def close(self): + self.is_open = False + + def write(self, data): + self.writes.append(data) + return len(data) + + def flush(self): + pass + + def fileno(self): + return 77 + + +def serial_modules(port): + serial = ModuleType("serial") + serial.Serial = Mock(return_value=port) + tools = ModuleType("serial.tools") + list_ports = ModuleType("serial.tools.list_ports") + info = SimpleNamespace(device="/dev/ttyUSB-bench", serial_number="bench-only", + vid=0x1A86, pid=0x7523) + list_ports.comports = Mock(return_value=[info]) + tools.list_ports = list_ports + serial.tools = tools + return {"serial": serial, "serial.tools": tools, "serial.tools.list_ports": list_ports} + + +@unittest.skipUnless(os.name == "posix", "POSIX CH340 pin-order behavior") +class EthernetHilSerialTests(unittest.TestCase): + def assert_safe(self, port): + self.assertEqual(port.resets, 0) + self.assertEqual(port.trace[0], ("rts", False)) + self.assertFalse(port.dsrdtr) + self.assertFalse(port.driver_rts) + self.assertFalse(port.driver_dtr) + + def test_reset_simulator_detects_original_plain_open_bug(self): + port = FakeSerial() + port.dtr = port.rts = False + port.open() + self.assertEqual(port.resets, 1) + + def test_raw_kiss_probe_uses_shared_safe_opener(self): + port = FakeSerial() + with patch.dict(sys.modules, serial_modules(port)), patch.object(probe.threading, "Thread"): + endpoint = probe.KissEndpoint("serial:///dev/ttyUSB-bench") + endpoint.close() + self.assert_safe(port) + + def test_tap_serial_transport_uses_shared_safe_opener(self): + port = FakeSerial() + with patch.dict(sys.modules, serial_modules(port)): + endpoint = tap.KissStream("serial:///dev/ttyUSB-bench", 115200) + endpoint.close() + self.assert_safe(port) + + def test_companion_cli_uses_shared_safe_opener(self): + port = FakeSerial() + spec = importlib.util.spec_from_file_location("ethernet_bench_serial_under_test", BASE / "ethernet_bench_serial.py") + module = importlib.util.module_from_spec(spec) + with patch.dict(sys.modules, serial_modules(port)): + spec.loader.exec_module(module) + with patch.object(sys, "argv", ["ethernet_bench_serial.py", "--port", "/dev/ttyUSB-bench", + "--command", "get ethernet"]), \ + patch.object(module, "read_quiet", return_value="OK"), \ + patch.object(module, "read_command_reply", return_value="get ethernet\r\n -> on\r\n> "), \ + contextlib.redirect_stdout(io.StringIO()): + module.main() + self.assert_safe(port) + self.assertIn(b"get ethernet\n", port.writes) + self.assertFalse(port.is_open) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ethernet_kiss_modem_probe.py b/test/test_ethernet_kiss_modem_probe.py new file mode 100644 index 000000000..969289904 --- /dev/null +++ b/test/test_ethernet_kiss_modem_probe.py @@ -0,0 +1,289 @@ +"""Host-only full KISS configuration tests; never open a serial device.""" + +from collections import deque +from pathlib import Path +import struct +import sys +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/hil")) +import ethernet_kiss_modem_probe as probe + + +def fake_serial_modules(): + serial = SimpleNamespace(Serial=Mock()) + list_ports = SimpleNamespace(comports=Mock()) + modules = {"serial": serial, "serial.tools": SimpleNamespace(list_ports=list_ports)} + return serial, list_ports, modules + + +def response(code, body=b""): + return probe.encode_frame(6, bytes((code,)) + body) + + +class Clock: + def __init__(self): + self.now = 0 + + def __call__(self): + self.now += 0.01 + return self.now + + +class FakePort: + def __init__(self): + self.decoder = probe.Decoder() + self.input = deque() + self.writes = [] + self.override = {} + self.tx = 0 + + def write(self, data): + for frame in self.decoder.feed(data): + self.writes.append(frame) + if frame[0] != 6: + continue + command = frame[1] + body = {0x21: struct.pack(" rxtx; RX=0,{self.rx} TX={self.primary},{self.secondary} switches=24" + if text == "get radio2.timing": return "> need=32,128; switch=2000us" + if text == "stats-radio-diag": return "> state=1(RX), recv=7, sent=9, errors=0" + if text == "get ethernet": + return f"> ethernet={'on; active (RX time-shared)' if self.enabled else 'off; off'}; TCP 5003 listening; RX={self.rx} TX={self.secondary} fail={self.fail}; radio2 only" + if text == "get ethernet.radio": return "> " + ("909.5,500,6,5,128" if self.bad_readback else self.tuple) + "; sync=0x16; saved" + if self.bad_set: return "Error: could not save" + if text == "set ethernet off": self.enabled = False + if text == "set ethernet on": self.enabled = True + if text.startswith("set ethernet.radio "): self.tuple = text.removeprefix("set ethernet.radio ") + return "OK - saved" + + +def result(seconds=2, **extra): + return dict(verified=True, handshake_ms=7000, total_ms=11000, + downlink=dict(bytes=4096, elapsed_ms=seconds * 1000, hash_verified=True), + uplink=dict(bytes=4096, elapsed_seconds=seconds, hash_verified=True), **extra) + + +class EthernetPreambleBenchTests(unittest.TestCase): + def run_fake(self, preambles=(128, 192), runner=None, nodes=None, **kwargs): + nodes = nodes or dict(a=FakeNode(), b=FakeNode()) + return bench.run_cases(nodes, preambles, runner or (lambda request: result()), **kwargs), nodes + + def test_default_only_requested_preambles_and_explicit_controls(self): + self.assertEqual(bench.DEFAULT_PREAMBLES, (128, 192, 256, 384, 512, 768)) + self.assertEqual(bench.validate_preambles([32, 64, 2048, 32]), [32, 64, 2048, 32]) + for values in ([], [31], [2049], [True], [128.0], [128] * 121): + with self.assertRaises(ValueError): bench.validate_preambles(values) + + def test_sf5_default_unchanged_and_allowed_fixed_spreading_factors(self): + default, _ = self.run_fake(preambles=(128,)) + self.assertEqual(default["sf"], 5) + self.assertEqual(default["cases"][0]["preamble_ms"], 8.192) + for sf in range(5, 10): + calls = [] + report, nodes = self.run_fake(preambles=(64,), sf=sf, + runner=lambda request: calls.append(request) or result()) + self.assertTrue(report["passed"], report.get("error")) + self.assertEqual(calls[0]["sf"], sf) + self.assertEqual(report["ranking"][0]["sf"], sf) + self.assertEqual(report["cases"][0]["preamble_ms"], 64 * (2 ** sf) / 500) + self.assertIn(f"SF{sf}", report["note"]) + for node in nodes.values(): + self.assertIn(f"set ethernet.radio 909.5,500,{sf},5,64", node.commands) + + def test_bad_sf_fails_before_commands_runner_or_snapshot(self): + for sf in (4, 10, True, 7.0, "7", None): + nodes = dict(a=FakeNode(), b=FakeNode()); run = Mock() + with self.assertRaises(ValueError): bench.run_cases(nodes, [128], run, sf=sf) + run.assert_not_called() + self.assertTrue(all(not node.commands for node in nodes.values())) + with self.assertRaises(ValueError): bench.snapshot(nodes, 12, sf=sf) + self.assertTrue(all(not node.commands for node in nodes.values())) + + def test_bandwidth_defaults_to500_and125_propagates_to_fixed_tuple_and_timing(self): + default, _ = self.run_fake(preambles=(128,)) + self.assertEqual(default["bandwidth_khz"], 500) + for bandwidth in (125, 500): + calls = [] + report, nodes = self.run_fake(preambles=(64,), bandwidth=bandwidth, + runner=lambda request: calls.append(request) or result()) + self.assertTrue(report["passed"], report.get("error")) + self.assertEqual(calls[0]["bandwidth_khz"], bandwidth) + self.assertEqual(report["ranking"][0]["bandwidth_khz"], bandwidth) + self.assertEqual(report["cases"][0]["preamble_ms"], 64 * 32 / bandwidth) + self.assertEqual(report["cases"][0]["case_id"], "case-001-p64") + for node in nodes.values(): + self.assertIn(f"set ethernet.radio 909.5,{bandwidth},5,5,64", node.commands) + self.assertIn(f"> 909.5,{bandwidth},5,5,64", report["cases"][0]["before"]["a"]["ethernet_radio"]) + + def test_invalid_bandwidth_fails_before_all_commands_and_runner(self): + for bandwidth in (62.5, 250, 126, True, 125.0, "125", None): + nodes = dict(a=FakeNode(), b=FakeNode()); run = Mock() + with self.assertRaises(ValueError): bench.run_cases(nodes, [64], run, bandwidth=bandwidth) + with self.assertRaises(ValueError): bench.snapshot(nodes, 12, bandwidth=bandwidth) + run.assert_not_called(); self.assertTrue(all(not node.commands for node in nodes.values())) + + def test_cli_bandwidth125_plan_has_no_io_and_wrong_bandwidth_readback_aborts(self): + output = io.StringIO() + with patch.object(bench, "SerialNode") as serial, contextlib.redirect_stdout(output): + self.assertEqual(bench.main(["--plan", "--bandwidth", "125", "--sf", "5"]), 0) + serial.assert_not_called() + plan = json.loads(output.getvalue()) + self.assertEqual(plan["radio"], "909.5,125,5,5") + self.assertEqual(plan["bandwidth_khz"], 125) + for value in ("250", "125.0"): + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + bench.main(["--plan", "--bandwidth", value]) + nodes = dict(a=FakeNode(), b=FakeNode()) + original = nodes["b"].command + nodes["b"].command = lambda text, **kwargs: "> 909.5,500,5,5,64" if text == "get ethernet.radio" else original(text, **kwargs) + run = Mock(return_value=result()) + report, _ = self.run_fake(nodes=nodes, bandwidth=125, preambles=(64,), runner=run) + self.assertTrue(report["aborted"]); run.assert_not_called() + + def test_sf7_readback_requires_sf7_not_previous_sf5_or_other_sf(self): + nodes = dict(a=FakeNode(), b=FakeNode()) + run = Mock(return_value=result()) + report, _ = self.run_fake(nodes=nodes, sf=7, preambles=(128,), runner=run) + self.assertTrue(report["passed"]) + self.assertIn("> 909.5,500,7,5,128", report["cases"][0]["before"]["a"]["ethernet_radio"]) + nodes = dict(a=FakeNode(), b=FakeNode()); nodes["b"].bad_readback = True + run = Mock(return_value=result()) + report, _ = self.run_fake(nodes=nodes, sf=7, preambles=(128,), runner=run) + self.assertTrue(report["aborted"]); run.assert_not_called() + + def test_cli_plan_sf_choices_and_no_io(self): + for sf in range(5, 10): + output = io.StringIO() + with patch.object(bench, "SerialNode") as serial, patch.object(bench, "python_runner") as runner, \ + contextlib.redirect_stdout(output): + self.assertEqual(bench.main(["--plan", "--sf", str(sf), "--preambles", "32,64"]), 0) + serial.assert_not_called(); runner.assert_not_called() + self.assertEqual(json.loads(output.getvalue())["radio"], f"909.5,500,{sf},5") + for sf in ("4", "10", "7.0"): + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + bench.main(["--plan", "--sf", sf]) + + def test_bad_initial_guard_radio_or_counter_prevents_all_writes_and_runner(self): + for field, value in (("guard", "> Error: unavailable"), ("radio", "> 909.5,500,5,5"), ("primary", 1)): + nodes = dict(a=FakeNode(), b=FakeNode()); setattr(nodes["b"], field, value) + runner = Mock() + report, _ = self.run_fake(nodes=nodes, runner=runner) + self.assertTrue(report["aborted"]) + runner.assert_not_called() + self.assertFalse(any(text.startswith("set ") for node in nodes.values() for text in node.commands)) + + def test_exact_fixed_lab_tuple_only_and_complete_rf_counters(self): + nodes = dict(a=FakeNode(), b=FakeNode()) + calls = [] + def run(request): + calls.append(request) + for node in nodes.values(): + node.secondary += 20; node.rx += 19; node.fail += 1 + return result(tcp_client=dict(OutSegs=80, RetransSegs=3)) + report, _ = self.run_fake(nodes=nodes, runner=run) + self.assertTrue(report["passed"], report.get("error")) + self.assertEqual([call["preamble"] for call in calls], [128, 192]) + for call in calls: + self.assertEqual((call["frequency_mhz"], call["bandwidth_khz"], call["sf"], call["cr"]), (909.5, 500, 5, 5)) + for node in nodes.values(): + self.assertEqual([c for c in node.commands if c.startswith("set ")], [ + "set ethernet off", "set ethernet.radio 909.5,500,5,5,128", "set ethernet on", + "set ethernet off", "set ethernet.radio 909.5,500,5,5,192", "set ethernet on"]) + for case in report["cases"]: + self.assertEqual(case["counter_deltas"]["a"]["profile_tx"], [0, 20]) + self.assertEqual(case["counter_deltas"]["b"]["ethernet"], dict(rx=19, tx=20, fail=1)) + self.assertIn("sent=9", case["after"]["a"]["radio_diag"]) + self.assertEqual(case["metrics"]["tcp_client"]["RetransSegs"], 3) + + def test_failed_config_or_readback_aborts_before_runner(self): + for field in ("bad_set", "bad_readback"): + nodes = dict(a=FakeNode(), b=FakeNode()); setattr(nodes["b"], field, True) + runner = Mock() + report, _ = self.run_fake(nodes=nodes, runner=runner) + self.assertTrue(report["aborted"]) + runner.assert_not_called() + + def test_runner_timeout_records_failure_disables_both_then_continues_no_retry(self): + runner = Mock(side_effect=[subprocess.TimeoutExpired("redacted", 240), result()]) + report, nodes = self.run_fake(runner=runner) + self.assertFalse(report["aborted"], report.get("error")) + self.assertFalse(report["passed"]) + self.assertEqual(runner.call_count, 2) + self.assertEqual(report["cases"][0]["error"], "Runner timed out") + self.assertEqual([case["verified"] for case in report["cases"]], [False, True]) + for node in nodes.values(): + self.assertEqual(node.commands.count("set ethernet off"), 3) + + def test_runner_false_hash_or_bad_protocol_does_not_rank_and_continues(self): + for failed in (dict(verified=False, error="SSH deadline"), "not a result", dict(verified=1)): + report, _ = self.run_fake(runner=Mock(side_effect=[failed, result()])) + self.assertFalse(report["aborted"]) + self.assertEqual([item["index"] for item in report["ranking"]], [2]) + bad_hash = result(); bad_hash["uplink"]["hash_verified"] = False + report, _ = self.run_fake(runner=lambda request: bad_hash) + self.assertFalse(report["passed"]) + self.assertEqual(report["ranking"], []) + + def test_safety_change_after_runner_aborts_remaining_cases(self): + for field, value in (("primary", 1), ("guard", "> unlocked"), ("radio", "> 909.5,500,5,5")): + nodes = dict(a=FakeNode(), b=FakeNode()) + def changed(request): + setattr(nodes["b"], field, value) + return result() + report, _ = self.run_fake(nodes=nodes, runner=changed) + self.assertTrue(report["aborted"]) + self.assertEqual(len(report["cases"]), 1) + self.assertEqual(report["ranking"], []) + + def test_actual_verified_goodput_ranks_even_with_retransmissions_and_excludes_handshake(self): + first = result(1, tcp_client=dict(RetransSegs=6)); second = result(2, tcp_client=dict(RetransSegs=0)) + first["handshake_ms"] = 9000 + report, _ = self.run_fake(runner=Mock(side_effect=[first, second])) + self.assertEqual([item["preamble"] for item in report["ranking"]], [128, 192]) + self.assertEqual(report["ranking"][0]["combined_payload_kbps"], 32.768) + self.assertAlmostEqual(report["ranking"][0]["end_to_end_kbps"], 8192 * 8 / 11000) + self.assertEqual(report["ranking"][0]["downlink_kbps"], 32.768) + + def test_unknown_tcp_metrics_and_missing_or_reset_counters_are_not_zero(self): + metrics = bench.payload_metrics(result()) + self.assertIsNone(metrics["tcp_client"]); self.assertIsNone(metrics["tcp_server"]) + self.assertIsNone(bench.ethernet_counters("> unavailable")) + self.assertIsNone(bench.counter_delta(10, 2)) + self.assertIsNone(bench.counter_delta(None, None)) + self.assertIsNone(bench.payload_metrics(dict(verified=True))["combined_payload_kbps"]) + self.assertFalse(bench.payload_metrics(dict(verified=True))["verified"]) + unknown_hash = result(); del unknown_hash["uplink"]["hash_verified"] + self.assertFalse(bench.payload_metrics(unknown_hash)["verified"]) + + def test_progress_persists_running_before_runner_and_complete_after_checks(self): + events, saves = [], [] + report, _ = self.run_fake(preambles=(128,), progress=events.append, + persist=lambda value: saves.append(json.loads(json.dumps(value)))) + self.assertEqual([event["phase"] for event in events], ["checking", "running", "complete"]) + running = next(save for save in saves if save["cases"] and save["cases"][0]["phase"] == "running") + self.assertNotIn("runner", running["cases"][0]) + self.assertIn("commands", saves[-1]) + + def test_plan_has_no_io_and_seeded_repeats_are_explicit(self): + output = io.StringIO() + with patch.object(bench, "SerialNode") as serial, patch.object(bench, "SshSerialNode") as ssh, \ + patch.object(bench, "python_runner") as runner, patch.object(bench.tempfile, "mkdtemp") as folder, \ + contextlib.redirect_stdout(output): + self.assertEqual(bench.main(["--plan", "--preambles", "512,128,384,192,768,256", "--runs", "2", "--seed", "7"]), 0) + for mock in (serial, ssh, runner, folder): mock.assert_not_called() + plan = json.loads(output.getvalue()) + self.assertEqual(len(plan["preambles"]), 12) + self.assertEqual(sorted(plan["preambles"][:6]), list(bench.DEFAULT_PREAMBLES)) + + def test_runner_subprocess_exact_json_no_shell_and_no_stderr_secrets(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "fixture.py"; path.touch() + completed = SimpleNamespace(stdout=json.dumps(result()), stderr="PRIVATE KEY", returncode=0) + with patch.object(bench.subprocess, "run", return_value=completed) as process: + value = bench.python_runner(path)(dict(preamble=128)) + args, kwargs = process.call_args + self.assertEqual(args[0], [sys.executable, str(path)]) + self.assertFalse(kwargs["shell"]) + self.assertEqual(kwargs["timeout"], 240) + self.assertEqual(json.loads(kwargs["input"]), dict(preamble=128)) + self.assertNotIn("PRIVATE KEY", json.dumps(value)) + completed.returncode = 2 + with patch.object(bench.subprocess, "run", return_value=completed): + self.assertFalse(bench.python_runner(path)(dict(preamble=128))["verified"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ethernet_preamble_ssh_runner.py b/test/test_ethernet_preamble_ssh_runner.py new file mode 100644 index 000000000..883f2aad0 --- /dev/null +++ b/test/test_ethernet_preamble_ssh_runner.py @@ -0,0 +1,217 @@ +"""Physical SSH-runner protocol/ownership tests: no SSH, WSL, RF or key reads.""" +import io +import json +from pathlib import Path +import shlex +import subprocess +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/hil")) +import ethernet_preamble_ssh_runner as runner + + +def request(**changes): + return dict(index=1, case_id="case-001-p128", preamble=128, frequency_mhz=909.5, + bandwidth_khz=500, sf=5, cr=5, **changes) + + +def config(directory): + return dict(ssh_host="bench@example.test", ssh_key=str(Path(directory) / "ssh_key"), + pi_bench="/home/mikec/bench", server_endpoint="tcp://192.168.1.84:5003", + client_endpoint="tcp://192.168.1.87:5003", wsl_workspace="/home/mcarper/app", + flutter="/home/mcarper/flutter/bin/flutter", account="mcarper", out_root=directory) + + +def ready_text(config, pid=123): + return (f"READY: gateway={config['server_endpoint']} local=10.90.5.1 remote=10.90.5.2 SSH=2222 user=mikec\n" + "Ephemeral client private key file (never print contents): /home/mikec/bench/ethernet-phone.A1234567/client_key\n" + f"256 SHA256:{'a' * 43} root@host (ED25519)\n" + "Artifacts: /home/mikec/bench/ethernet-phone.A1234567\n" + f"Server helper PID: {pid}\n") + + +class EthernetPreambleSshRunnerTests(unittest.TestCase): + def test_config_uses_explicit_hosts_private_port5003_and_absolute_paths(self): + with tempfile.TemporaryDirectory() as directory: + value = config(directory); Path(value["ssh_key"]).touch() + self.assertEqual(runner.validate_config(value), value) + for field, bad in (("ssh_host", "-oProxyCommand=evil"), ("account", "root"), + ("server_endpoint", "tcp://192.168.1.84:5002"), + ("server_endpoint", "tcp://127.0.0.1:5003"), + ("client_endpoint", value["server_endpoint"]), + ("pi_bench", "/"), ("wsl_workspace", "/home/../etc"), + ("flutter", "relative"), ("out_root", "relative")): + changed = dict(value); changed[field] = bad + with self.subTest(field=field, value=bad), self.assertRaises((ValueError, TypeError)): + runner.validate_config(changed) + + def test_requests_accept_sf5_through_sf9_without_changing_case_identifier(self): + for bandwidth in (125, 500): + for sf in range(5, 10): + value = request(); value.update(sf=sf, bandwidth_khz=bandwidth) + with self.subTest(bandwidth=bandwidth, sf=sf): + self.assertIs(runner.validate_request(value), value) + self.assertEqual(value["case_id"], "case-001-p128") + + def test_requests_only_allow_fixed_lab_frequency_bandwidth_cr_and_safe_case_id(self): + for field, bad in (("frequency_mhz", 910.525), ("bandwidth_khz", 250), + ("cr", 7), ("preamble", 31), ("index", True), + ("case_id", "../escape")): + changed = request(); changed[field] = bad + with self.subTest(field=field), self.assertRaises(ValueError): + runner.validate_request(changed) + + def test_request_radio_fields_reject_wrong_types_and_invalid_sf(self): + invalid = { + "frequency_mhz": (True, False, "909.5", None), + "bandwidth_khz": (True, False, 125.0, 500.0, "125", "500", None), + "sf": (True, False, 4, 10, 5.0, 9.0, "5", None), + "cr": (True, False, 5.0, "5", None), + } + for field, values in invalid.items(): + for bad in values: + changed = request(); changed[field] = bad + with self.subTest(field=field, value=bad), self.assertRaises(ValueError): + runner.validate_request(changed) + + def test_windows_drive_mapping_never_expands_shell_metacharacters(self): + self.assertEqual(runner.wsl_path(r"C:\git\out with spaces\file.key"), "/mnt/c/git/out with spaces/file.key") + self.assertEqual(runner.wsl_path("/mnt/c/git/key"), "/mnt/c/git/key") + for path in (r"\\host\share\key", "relative", "/", "/tmp/../etc/key"): + with self.assertRaises(ValueError): runner.wsl_path(path) + + def test_ready_requires_exact_endpoint_one_fingerprint_pid_and_scoped_artifacts(self): + value = config("/tmp/fixture") + ready = runner.parse_server_ready(ready_text(value), value) + self.assertEqual(ready["pid"], 123) + self.assertEqual(ready["fingerprint"], "SHA256:" + "a" * 43) + for text in (ready_text(value).replace("192.168.1.84", "192.168.1.87"), + ready_text(value).replace("Server helper PID: 123\n", ""), + ready_text(value) + "Server helper PID: 234\n", + ready_text(value) + "256 SHA256:" + "b" * 43 + " (ED25519)\n", + ready_text(value).replace("Artifacts: /home/mikec/bench/", "Artifacts: /etc/")): + with self.assertRaises(ValueError): runner.parse_server_ready(text, value) + + def test_read_ready_waits_past_artifact_line_for_pid(self): + value = config("/tmp/fixture") + process = SimpleNamespace(stdout=io.StringIO(ready_text(value))) + self.assertEqual(runner.read_ready(process, value, .5)["pid"], 123) + with self.assertRaises(RuntimeError): + runner.read_ready(SimpleNamespace(stdout=io.StringIO(ready_text(value).split("Server helper PID")[0])), value, .5) + + def test_flutter_artifact_path_must_be_exact_child_of_selected_workspace(self): + self.assertEqual(runner.artifact_dir("Flutter RF artifacts: /home/test/app/ethernet-flutter.Ab123456\n", + "/home/test/app", "Flutter RF artifacts"), "/home/test/app/ethernet-flutter.Ab123456") + for text in ("Flutter RF artifacts: /tmp/ethernet-flutter.Ab123456\n", + "Flutter RF artifacts: /home/test/app/sub/ethernet-flutter.Ab123456\n", + "Flutter RF artifacts: /home/test/app/ethernet-flutter.short\n"): + with self.assertRaises(ValueError): runner.artifact_dir(text, "/home/test/app", "Flutter RF artifacts") + + def test_speed_normalization_uses_actual_time_and_both_sha256_checks(self): + speed = dict(success=True, host_verified=True, handshake_ms=7000, total_elapsed_ms=11000, + download=dict(bytes=8192, elapsed_ms=2000, kbps=9999, sha256_valid=True, + data_elapsed_ms=1000, data_kbps=65.536), + upload=dict(bytes=8192, elapsed_ms=4000, sha256_valid=True)) + result = runner.normalized_speed(speed, 0) + self.assertTrue(result["verified"]) + self.assertEqual(result["downlink"]["kbps"], 32.768) + self.assertEqual(result["uplink"]["elapsed_seconds"], 4) + self.assertEqual(result["downlink"]["data_kbps"], 65.536) + for field in ("success", "host_verified"): + changed = dict(speed); changed[field] = False + self.assertFalse(runner.normalized_speed(changed, 0)["verified"]) + self.assertFalse(runner.normalized_speed(speed, 1)["verified"]) + for bad in (None, dict(bytes=8192, elapsed_ms=0, sha256_valid=True), + dict(bytes=8192, elapsed_ms=float("nan"), sha256_valid=True), + dict(bytes=8192, elapsed_ms=2000, sha256_valid=False)): + changed = dict(speed); changed["download"] = bad + self.assertFalse(runner.normalized_speed(changed, 0)["verified"]) + + def test_remote_stop_checks_whole_pid_argv_script_and_unique_prefix_before_signal(self): + value = config("/tmp/fixture") + command = runner.stop_server_args(value, 123, "ethphone-speed-abcdef01") + self.assertIn("BatchMode=yes", command); self.assertIn("StrictHostKeyChecking=yes", command) + remote = shlex.split(command[-1]) + self.assertEqual(remote[:4], ["sudo", "-n", "python3", "-c"]) + self.assertEqual(remote[-3:], ["123", "/home/mikec/bench/ethernet_ssh_phone_bench.sh", "ethphone-speed-abcdef01"]) + code = remote[4] + self.assertIn("script not in args", code) + self.assertIn("args[args.index('--prefix')+1] != prefix", code) + self.assertIn("os.kill(pid,signal.SIGTERM)", code) + self.assertNotIn("pkill", code) + for pid, prefix in ((1, "ethphone-speed-abcdef01"), (123, "ethphone-speed-abcdef01;killall")): + with self.assertRaises(ValueError): runner.stop_server_args(value, pid, prefix) + + def test_failed_startup_still_removes_only_owned_key_and_owned_popen_child(self): + with tempfile.TemporaryDirectory() as directory: + value = config(directory); Path(value["ssh_key"]).touch() + process = Mock(); process.stdout = io.StringIO("") + process.wait.side_effect = [subprocess.TimeoutExpired("ssh", 3), None] + with patch.object(runner.subprocess, "Popen", return_value=process), \ + patch.object(runner.subprocess, "run") as run: + result = runner.execute(value, request()) + self.assertFalse(result["verified"]) + process.kill.assert_called_once() + run.assert_not_called() # No WSL key was created; don't remove one. + self.assertFalse(any(path.name == "client_key.ephemeral" for path in Path(directory).rglob("*"))) + + def test_full_fake_case_normalizes_copies_evidence_and_cleans_only_its_resources(self): + with tempfile.TemporaryDirectory() as directory: + value = config(directory); Path(value["ssh_key"]).touch() + process = Mock(); process.stdout = io.StringIO(ready_text(value)) + tcp_count = 0 + commands = [] + + def run(argv, **kwargs): + nonlocal tcp_count + commands.append(argv) + self.assertFalse(kwargs["shell"]) + self.assertNotIn("PRIVATE_KEY_CONTENT", " ".join(argv)) + if argv[0] == "scp": + Path(argv[-1]).write_text("PRIVATE_KEY_CONTENT") + if argv[0] == "ssh" and "/proc/net/snmp" in argv[-1]: + tcp_count += 1 + return SimpleNamespace(stdout=("Tcp: InSegs OutSegs RetransSegs\n" + f"Tcp: {tcp_count * 10} {tcp_count * 20} {tcp_count - 1}\n"), returncode=0) + if any(str(argument).endswith("/ethernet_ssh_flutter_bench.sh") for argument in argv): + return SimpleNamespace(stdout="Flutter RF artifacts: /home/mcarper/app/ethernet-flutter.Ab123456\n", returncode=0) + if argv[0] == "wsl" and "-c" in argv and "src=$1" in argv[argv.index("-c") + 1]: + output = next(Path(directory).glob("case-001-p128-*")) + (output / "speed.json").write_text(json.dumps(dict(success=True, host_verified=True, + handshake_ms=7000, total_elapsed_ms=11000, + download=dict(bytes=8192, elapsed_ms=2000, sha256_valid=True), + upload=dict(bytes=8192, elapsed_ms=2000, sha256_valid=True)))) + (output / "tcp-stats.json").write_text(json.dumps(dict(available=True, delta=dict(OutSegs=40, RetransSegs=2)))) + return SimpleNamespace(stdout="", returncode=0) + + with patch.object(runner.subprocess, "Popen", return_value=process), \ + patch.object(runner.subprocess, "run", side_effect=run): + result = runner.execute(value, request()) + self.assertTrue(result["verified"], result.get("error")) + self.assertEqual(result["downlink"]["kbps"], 32.768) + self.assertEqual(result["tcp_server"]["delta"]["RetransSegs"], 1) + self.assertEqual(result["tcp_client"]["delta"]["RetransSegs"], 2) + self.assertTrue(any(argv[:7] == ["wsl", "--user", "root", "--exec", "rm", "-f", "--"] for argv in commands)) + self.assertTrue(any(argv[0] == "ssh" and "os.kill(pid,signal.SIGTERM)" in argv[-1] for argv in commands)) + self.assertFalse((Path(result["artifacts"]["directory"]) / "client_key.ephemeral").exists()) + self.assertNotIn("PRIVATE_KEY_CONTENT", json.dumps(result)) + process.kill.assert_not_called() + + def test_source_has_no_network_mutation_or_key_content_copy_in_evidence_allowlist(self): + source = Path(runner.__file__).read_text() + self.assertNotIn("shell=True", source) + self.assertNotIn("pkill", source) + self.assertNotIn("killall", source) + self.assertNotIn("client_key", runner.ARTIFACTS) + self.assertNotIn("host_key", runner.ARTIFACTS) + self.assertNotIn("iptables", source) + self.assertIn('"--duration", "300"', source) + self.assertIn('deadline = time.monotonic() + 225', source) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ethernet_primary_probe.py b/test/test_ethernet_primary_probe.py new file mode 100644 index 000000000..bdac0245d --- /dev/null +++ b/test/test_ethernet_primary_probe.py @@ -0,0 +1,74 @@ +"""Primary/Ethernet coexistence harness guards; never opens hardware.""" + +from pathlib import Path +import sys +import unittest +from unittest.mock import Mock, patch + + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/hil")) +import ethernet_primary_probe as probe + + +class FakeNode: + def __init__(self, radio="> 909.5,500.000,5,5", ethernet="> 909.500,500,5,5,32; sync=0x16; saved"): + self.identity = {"port": "fake-only"} + self.commands = [] + self.radio = radio + self.ethernet = ethernet + + def command(self, text): + self.commands.append(text) + return {"get radio": self.radio, "get ethernet.radio": self.ethernet, + "get ethernet": "> ethernet=on; active (RX time-shared); TCP 5003 connected", + "get tx.channel 0": '> tx.channel 0 "Public" radio; active=radio', + "set tx.channel 0 radio": 'OK - tx.channel 0 "Public" radio; active=radio', + "show channels on": "Terminal display: channels on"}.get(text, "> public") + + +class EthernetPrimaryProbeTests(unittest.TestCase): + def test_lab_tuple_parser_is_strict_and_accepts_known_cli_formats(self): + for text in ("> 909.5,500,5,5", "get radio\r\n> 909.500,500.000,5,5\r\n> ", + "get radio\r\n -> > 909.5,500,5,5\r\n> ", + "> 909.500,500,5,5,32; sync=0x16; saved"): + probe.assert_lab_radio(text) + for text in ("> 910.525,500,5,5", "> 909.5,125,5,5", "> 909.5,500,7,5", + "> 909.5,500,5,7", "Error: unavailable", "set radio 909.5,500,5,5"): + with self.assertRaises(RuntimeError): + probe.assert_lab_radio(text) + + def test_own_command_echo_is_never_an_incoming_message(self): + marker = "ETH-PRIMARY-12345678-a" + for text in ("channel 0 " + marker, "Sent to Public.\r\n> ", + "debug incoming " + marker, "CHANNEL MSG -> Public (FLOOD)\r\n wrong-message"): + self.assertFalse(probe.received_channel_marker(text, marker)) + self.assertTrue(probe.received_channel_marker( + "\r\nCHANNEL MSG -> Public (FLOOD)\r\n EthernetA: " + marker + "\r\n> ", marker)) + self.assertFalse(probe.received_channel_marker( + "CHANNEL MSG -> Public (FLOOD)\r\n Node: " + marker + "-different", marker)) + + def test_bad_primary_or_ethernet_profile_fails_before_any_tx(self): + for name in ("radio", "ethernet"): + a, b = FakeNode(), FakeNode() + setattr(b, name, "> 910.525,500,5,5") + factory = Mock() + with self.assertRaises(RuntimeError): + probe.run_primary(a, b, "tcp://a:5003", "tcp://b:5003", factory=factory) + factory.assert_not_called() + self.assertFalse(any(command.startswith("set ") or command.startswith("channel ") + for node in (a, b) for command in node.commands)) + + def test_channel_zero_primary_policy_is_set_and_confirmed_before_kiss(self): + a, b = FakeNode(), FakeNode() + factory = Mock(side_effect=RuntimeError("stop before opening test socket")) + with self.assertRaisesRegex(RuntimeError, "stop before"): + probe.run_primary(a, b, "tcp://a:5003", "tcp://b:5003", factory=factory) + for node in (a, b): + self.assertIn("set tx.channel 0 radio", node.commands) + self.assertIn("show channels on", node.commands) + self.assertLess(node.commands.index("set tx.channel 0 radio"), + len(node.commands) - 1 - node.commands[::-1].index("get tx.channel 0")) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ethernet_radio2_sweep.py b/test/test_ethernet_radio2_sweep.py new file mode 100644 index 000000000..43c668e33 --- /dev/null +++ b/test/test_ethernet_radio2_sweep.py @@ -0,0 +1,185 @@ +"""R2 sweep safety gates and honest RF-loss reporting, without hardware.""" +import contextlib +import io +from pathlib import Path +import sys +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/hil")) +import ethernet_radio2_sweep as sweep + + +class FakeNode: + def __init__(self): + self.identity = {"port": "fake-only"} + self.commands = [] + self.guard = "> " + sweep.GUARD + self.radio = "> 910.525,62.5,7,5" + self.ethernet = "909.5,62.5,7,5,64" + self.primary = 0 + self.bad_config = self.bad_set = False + + def command(self, text, timeout=10): + self.commands.append(text) + if text == "get lab.tx": return self.guard + if text == "get radio": return self.radio + if text == "get radio2.status": return f"> rxtx; RX=0,4 TX={self.primary},2 switches=20" + if text == "get radio2.timing": return "> chirps=4.60,20.0; need=32,64; switch=2000us" + if text == "get ethernet": return "> ethernet=on; active (RX time-shared); TCP 5003 listening" + if text == "get ethernet.radio": + return "> " + ("910.525,62.5,7,5,64" if self.bad_config else self.ethernet) + "; sync=0x16; saved" + if text.startswith("set ethernet.radio "): + self.ethernet = text.removeprefix("set ethernet.radio ") + return "Error: test failed" if self.bad_set else "OK - Ethernet saved" + + +class FakeEndpoint: + def __init__(self): + self.decoder = SimpleNamespace(errors=0) + self.overflow = 0 + self.error = None + self.closed = False + + def close(self): self.closed = True + + +def fake_probe(*endpoints, **kwargs): + return {"passed": True, "transfers": [ + {"received": True, "size": 244}, {"received": True, "size": 244}], "retries": 0} + + +class EthernetRadio2SweepTests(unittest.TestCase): + def run_fake(self, a=None, b=None, cases=None, probe=fake_probe, factory=None): + endpoints = [] + def make(endpoint): + item = FakeEndpoint(); endpoints.append(item); return item + report = sweep.run_sweep(a or FakeNode(), b or FakeNode(), "tcp://a:5003", "tcp://b:5003", + cases or sweep.CANDIDATES[:2], probe_function=probe, + endpoint_factory=factory or make) + return report, endpoints + + def test_guard_is_exact_structured_unambiguous_and_not_an_echo(self): + for text in ("> " + sweep.GUARD, "get lab.tx\r\n -> > " + sweep.GUARD + "\r\n> "): + sweep.require_guard(text) + for text in ("get lab.tx " + sweep.GUARD, "> lock=909.500 MHz", "> Error: unknown command", + "> " + sweep.GUARD.replace("909.500", "910.525"), + "> " + sweep.GUARD + "\n> " + sweep.GUARD): + with self.assertRaises(RuntimeError): sweep.require_guard(text) + + def test_tuple_parser_rejects_echoes_ambiguous_and_wrong_preamble(self): + self.assertEqual(sweep.radio_tuple("get radio\n -> > 910.525,62.500,7,5\n> ")[:4], sweep.PRIMARY) + for text in ("set radio 910.525,62.5,7,5", "> unavailable", + "> 910.525,62.5,7,5\n> 910.525,62.5,7,5"): + with self.assertRaises(RuntimeError): sweep.radio_tuple(text) + + def test_bad_initial_guard_or_primary_prevents_every_setting_and_socket(self): + for field, value in (("guard", "> Error: unknown command"), ("radio", "> 909.5,500,5,5")): + a, b = FakeNode(), FakeNode(); setattr(b, field, value) + factory = Mock() + report, _ = self.run_fake(a, b, factory=factory) + self.assertTrue(report["aborted"]) + factory.assert_not_called() + self.assertFalse(any(command.startswith("set ") for node in (a, b) for command in node.commands)) + + def test_primary_nonzero_counter_refuses_configuration_and_tx(self): + a, b = FakeNode(), FakeNode(); b.primary = 1 + report, endpoints = self.run_fake(a, b) + self.assertTrue(report["aborted"]) + self.assertEqual(endpoints, []) + self.assertFalse(any(command.startswith("set ") for node in (a, b) for command in node.commands)) + + def test_primary_float_rounding_tolerates_only_50_hz_and_exact_phy(self): + for value in ("> 910.5250244,62.5,7,5", "> 910.5249756,62.5,7,5"): + a, b = FakeNode(), FakeNode(); b.radio = value + report, _ = self.run_fake(a, b) + self.assertTrue(report["passed"], report.get("error")) + for value in ("> 910.526,62.5,7,5", "> 910.5250501,62.5,7,5", + "> 910.5250244,62.5001,7,5", "> 910.5250244,62.5,8,5", + "> 910.5250244,62.5,7,6"): + a, b = FakeNode(), FakeNode(); b.radio = value + factory = Mock() + report, _ = self.run_fake(a, b, factory=factory) + self.assertTrue(report["aborted"]) + factory.assert_not_called() + self.assertFalse(any(command.startswith("set ") for node in (a, b) for command in node.commands)) + + def test_only_predefined_9095_ethernet_settings_and_no_primary_writes(self): + a, b = FakeNode(), FakeNode() + report, endpoints = self.run_fake(a, b) + self.assertTrue(report["passed"]) + self.assertTrue(all(endpoint.closed for endpoint in endpoints)) + for node in (a, b): + writes = [command for command in node.commands if command.startswith("set ")] + self.assertEqual(writes, ["set ethernet off", "set ethernet.radio 909.5,62.5,7,5,64", "set ethernet on", + "set ethernet off", "set ethernet.radio 909.5,125,7,5,64", "set ethernet on"]) + for item in report["cases"]: + self.assertEqual(item["primary_tx_before"], {"a": 0, "b": 0}) + self.assertEqual(item["primary_tx_after"], {"a": 0, "b": 0}) + self.assertIn("verified R2", item["probe"]["baseline"]) + + def test_wrong_readback_or_failed_setting_aborts_before_sockets(self): + for field in ("bad_config", "bad_set"): + a, b = FakeNode(), FakeNode(); setattr(b, field, True) + report, endpoints = self.run_fake(a, b) + self.assertTrue(report["aborted"]) + self.assertEqual(endpoints, []) + self.assertEqual(len(report["cases"]), 1) + + def test_rf_packet_loss_is_reported_and_remaining_cases_continue(self): + def loss(*endpoints, **kwargs): + report = fake_probe(); report["passed"] = False + report["transfers"][0]["received"] = False + return report + report, endpoints = self.run_fake(probe=loss) + self.assertFalse(report["aborted"]) + self.assertFalse(report["passed"]) + self.assertEqual(len(report["cases"]), 2) + self.assertTrue(all(item["measurement"]["loss_fraction"] == 0.5 for item in report["cases"])) + self.assertTrue(all(endpoint.closed for endpoint in endpoints)) + + def test_primary_counter_change_or_guard_change_stops_later_cases(self): + for field, value in (("primary", 1), ("guard", "> Error: lock disappeared")): + a, b = FakeNode(), FakeNode() + def changed(*endpoints, **kwargs): + setattr(b, field, value) + return fake_probe() + report, endpoints = self.run_fake(a, b, probe=changed) + self.assertTrue(report["aborted"]) + self.assertEqual(len(report["cases"]), 1) + self.assertTrue(all(endpoint.closed for endpoint in endpoints)) + + def test_transport_error_aborts_without_retry_and_closes_both_sockets(self): + probe = Mock(side_effect=ConnectionError("WiFi lost")) + report, endpoints = self.run_fake(probe=probe) + self.assertTrue(report["aborted"]) + self.assertEqual(probe.call_count, 1) + self.assertTrue(all(endpoint.closed for endpoint in endpoints)) + + def test_candidate_rate_ratios_and_real_payload_definition(self): + self.assertEqual([case.describe()["nominal_ratio_to_usa"] for case in sweep.CANDIDATES[:4]], [1, 2, 4, 8]) + self.assertAlmostEqual(sweep.CANDIDATES[6].describe()["nominal_ratio_to_usa"], 22.857142857) + self.assertEqual(sweep.CANDIDATES[7].describe()["nominal_kbps"], 62.5) + self.assertEqual(sweep.CANDIDATES[7].preamble, 2048) + metrics = sweep.measurement(fake_probe(), 2) + self.assertEqual(metrics["ethernet_goodput_bps"], 1952) + self.assertEqual(metrics["ethernet_payload_goodput_bps"], 1840) + self.assertIn("not SSH", metrics["goodput_definition"]) + + def test_plan_does_not_open_serial_or_tcp_or_create_reports(self): + with patch.object(sweep, "SerialNode") as serial, patch.object(sweep, "KissEndpoint") as tcp, \ + patch.object(sweep.tempfile, "mkdtemp") as directory, contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(sweep.main(["--plan", "--cases", "2,3,4"]), 0) + serial.assert_not_called(); tcp.assert_not_called(); directory.assert_not_called() + + def test_only_port5003_tcp_endpoints_without_credentials_are_valid(self): + for value in ("tcp://radio", "tcp://192.168.1.84:5003"): + self.assertTrue(sweep.endpoint_valid(value)) + for value in ("serial:///dev/ttyUSB0", "tcp://radio:5002", "tcp://user:password@radio:5003", + "tcp://radio:5003/path", "tcp://radio:5003?x=1"): + self.assertFalse(sweep.endpoint_valid(value)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ethernet_serial_guard.py b/test/test_ethernet_serial_guard.py new file mode 100644 index 000000000..6128fa31b --- /dev/null +++ b/test/test_ethernet_serial_guard.py @@ -0,0 +1,215 @@ +"""Read-only serial ownership preflight and delayed USB reply regressions.""" + +import importlib.util +import contextlib +import io +import json +from pathlib import Path +import subprocess +import sys +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch + + +BASE = Path(__file__).resolve().parents[1] / "tools/hil" +sys.path.insert(0, str(BASE)) +import serial_session as session +import ethernet_primary_probe as primary + + +class PortOwnershipTests(unittest.TestCase): + def run_inspector(self, result): + return patch.object(session.subprocess, "run", return_value=result) + + def test_existing_owner_refuses_without_killing_anything(self): + with patch.object(session.sys, "platform", "linux"), \ + patch.object(session.shutil, "which", return_value="/usr/bin/fuser"), \ + self.run_inspector(SimpleNamespace(returncode=0)) as run: + with self.assertRaisesRegex(RuntimeError, "already in use"): + session.assert_port_idle("/dev/ttyUSB0") + self.assertEqual(run.call_args.args[0][:2], ["/usr/bin/fuser", "--"]) + self.assertNotIn("-k", run.call_args.args[0]) + + def test_idle_port_is_allowed(self): + with patch.object(session.sys, "platform", "linux"), \ + patch.object(session.shutil, "which", return_value="/usr/bin/fuser"), \ + self.run_inspector(SimpleNamespace(returncode=1)): + session.assert_port_idle("/dev/ttyUSB0") + + def test_inspection_failure_refuses(self): + with patch.object(session.sys, "platform", "linux"), \ + patch.object(session.shutil, "which", return_value="/usr/bin/fuser"), \ + self.run_inspector(SimpleNamespace(returncode=2)): + with self.assertRaisesRegex(RuntimeError, "fuser error"): + session.assert_port_idle("/dev/ttyUSB0") + + def test_timeout_refuses(self): + with patch.object(session.sys, "platform", "linux"), \ + patch.object(session.shutil, "which", return_value="/usr/bin/fuser"), \ + patch.object(session.subprocess, "run", side_effect=subprocess.TimeoutExpired("fuser", 3)): + with self.assertRaisesRegex(RuntimeError, "Cannot safely inspect"): + session.assert_port_idle("/dev/ttyUSB0") + + def test_missing_optional_inspector_does_not_spawn_a_process(self): + with patch.object(session.sys, "platform", "linux"), \ + patch.object(session.shutil, "which", return_value=None), \ + patch.object(session.subprocess, "run") as run: + session.assert_port_idle("/dev/ttyUSB0") + run.assert_not_called() + + def test_non_linux_does_not_try_fuser(self): + with patch.object(session.sys, "platform", "win32"), \ + patch.object(session.subprocess, "run") as run: + session.assert_port_idle("COM4") + run.assert_not_called() + + +class DelayedPort: + def __init__(self, clock, first_byte=1): + self.clock = clock + self.first_byte = first_byte + self.sent = False + self.in_waiting = 0 + + def read(self, count): + self.clock.now += 0.04 + if not self.sent and self.clock.now >= self.first_byte: + self.sent = True + return b"delayed reply\r\n" + return b"" + + +class DelayedReplyTests(unittest.TestCase): + def bench_module(self): + # Avoid importing optional pySerial just to exercise the pure read loop. + from types import ModuleType + serial = ModuleType("serial") + tools = ModuleType("serial.tools") + tools.list_ports = ModuleType("serial.tools.list_ports") + spec = importlib.util.spec_from_file_location("ethernet_bench_delayed_test", BASE / "ethernet_bench_serial.py") + module = importlib.util.module_from_spec(spec) + with patch.dict(sys.modules, {"serial": serial, "serial.tools": tools, + "serial.tools.list_ports": tools.list_ports}): + spec.loader.exec_module(module) + return module + + def test_bench_waits_full_deadline_for_first_reply_byte(self): + module = self.bench_module() + clock = SimpleNamespace(now=0) + port = DelayedPort(clock, first_byte=1.2) + with patch.object(module.time, "monotonic", side_effect=lambda: clock.now): + self.assertEqual(module.read_quiet(port, timeout=3), "delayed reply\r\n") + self.assertGreaterEqual(clock.now, 1.2) + self.assertLess(clock.now, 2) + + +class TimedChunks: + def __init__(self, clock, events): + self.clock = clock + self.events = list(events) + self.pending = bytearray() + + @property + def in_waiting(self): + return len(self.pending) + + def read(self, count): + self.clock.now += 0.04 + while self.events and self.events[0][0] <= self.clock.now: + self.pending.extend(self.events.pop(0)[1]) + out = bytes(self.pending[:count]) + del self.pending[:count] + return out + + +class CommandTransactionTests(unittest.TestCase): + def transact(self, events, command="set radio 909.5,500,5,5", timeout=3): + clock = SimpleNamespace(now=0) + port = TimedChunks(clock, events) + with patch.object(session.time, "monotonic", side_effect=lambda: clock.now): + reply = session.read_command_reply(port, command, timeout) + return reply, clock.now + + def test_immediate_echo_then_long_nvs_pause_waits_for_actual_reply(self): + reply, elapsed = self.transact([ + (0.04, b"> set radio 909.5,500,5,5\r\n"), + (1.1, b" -> OK - saved\r\n> ")]) + self.assertIn("OK - saved", reply) + self.assertGreaterEqual(elapsed, 1.1) + + def test_stale_other_command_reply_and_prompt_cannot_complete_this_command(self): + reply, elapsed = self.transact([ + (0.04, b" -> OK - previous command\r\n> "), + (0.7, b"set radio 909.5,500,5,5\r\n"), + (1.4, b" -> OK - current command\r\n> ")]) + self.assertIn("OK - current command", reply) + self.assertGreaterEqual(elapsed, 1.4) + + def test_wrong_echo_does_not_count_even_with_prompt(self): + clock = SimpleNamespace(now=0) + port = TimedChunks(clock, [(0.04, b"> get radio\r\n -> > 909.5,500,5,5\r\n> ")]) + with patch.object(session.time, "monotonic", side_effect=lambda: clock.now): + with self.assertRaises(session.SerialCommandTimeout) as caught: + session.read_command_reply(port, "get radio2", timeout=1) + self.assertIn("get radio", caught.exception.partial_reply) + self.assertGreaterEqual(clock.now, 1) + + def test_getter_value_prefix_is_not_a_completion_prompt(self): + reply, elapsed = self.transact([ + (0.04, b"> get radio\r\n -> > 909.5,500,5,5\r\n"), + (0.9, b">"), (1.2, b" ")], command="get radio") + self.assertTrue(reply.endswith("> ")) + self.assertGreaterEqual(elapsed, 1.2) + + def test_masked_password_echo_is_matched_exactly_without_requiring_plaintext(self): + reply, _ = self.transact([(0.04, b"> set wifi.pwd ********\r\n"), + (1.0, b" -> OK\r\n> ")], command="set wifi.pwd secret42") + self.assertNotIn("secret42", reply) + self.assertIn("OK", reply) + + def test_timeout_stops_remaining_commands_and_still_attempts_stop(self): + module = DelayedReplyTests().bench_module() + port = Mock(is_open=True) + info = SimpleNamespace(device="/dev/ttyUSB-test", serial_number="test", vid=0x1A86, pid=0x7523) + output = io.StringIO() + args = ["ethernet_bench_serial.py", "--port", "/dev/ttyUSB-test", + "--command", "set radio 909.5,500,5,5", "--command", "get radio"] + with patch.object(module.serial, "Serial", return_value=port, create=True), \ + patch.object(module.list_ports, "comports", return_value=[info], create=True), \ + patch.object(module, "assert_port_idle"), \ + patch.object(module, "open_configured_session"), \ + patch.object(module, "read_quiet", return_value="WELCOME"), \ + patch.object(module, "read_command_reply", side_effect=session.SerialCommandTimeout("echo only")), \ + patch.object(sys, "argv", args), contextlib.redirect_stdout(output): + self.assertEqual(module.main(), 1) + written = [call.args[0] for call in port.write.call_args_list] + self.assertNotIn(b"get radio\n", written) + self.assertEqual(written[-1], b"+++MESHCORE-TERM-STOP\n") + self.assertFalse(json.loads(output.getvalue())["commands"][0]["completed"]) + port.close.assert_called_once() + + def test_bench_stops_after_post_reply_quiet_interval(self): + module = DelayedReplyTests().bench_module() + clock = SimpleNamespace(now=0) + with patch.object(module.time, "monotonic", side_effect=lambda: clock.now): + module.read_quiet(DelayedPort(clock, first_byte=0), timeout=3) + self.assertLess(clock.now, 0.6) + + def test_bench_silence_waits_whole_deadline(self): + module = DelayedReplyTests().bench_module() + clock = SimpleNamespace(now=0) + with patch.object(module.time, "monotonic", side_effect=lambda: clock.now): + self.assertEqual(module.read_quiet(DelayedPort(clock, first_byte=99), timeout=3), "") + self.assertGreaterEqual(clock.now, 3) + + def test_primary_helper_also_waits_for_delayed_first_byte(self): + clock = SimpleNamespace(now=0) + node = SimpleNamespace(port=DelayedPort(clock, first_byte=1.2)) + with patch.object(primary.time, "monotonic", side_effect=lambda: clock.now): + self.assertEqual(primary.SerialNode.read(node, timeout=3), "delayed reply\r\n") + self.assertGreaterEqual(clock.now, 1.2) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ethernet_serial_rpc.py b/test/test_ethernet_serial_rpc.py new file mode 100644 index 000000000..2fd4131b7 --- /dev/null +++ b/test/test_ethernet_serial_rpc.py @@ -0,0 +1,206 @@ +"""Host-only SSH/serial framing and ownership tests; never opens hardware.""" +import io +import json +from pathlib import Path +import shlex +import subprocess +import sys +import tempfile +import threading +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools" / "hil")) +import ethernet_serial_rpc as rpc +from ethernet_ssh_serial import SshSerialNode, ssh_command +import ethernet_radio2_sweep as sweep + + +def ready(**changes): + message = dict(type="ready", protocol=rpc.PROTOCOL, identity={"port": "/dev/ttyUSB0"}, hello="WELCOME") + message.update(changes) + return message + + +class Node: + identity = {"port": "/dev/ttyUSB0"} + hello = "WELCOME" + + def __init__(self, error=None): + self.commands = [] + self.closed = False + self.error = error + + def command(self, command, timeout): + self.commands.append((command, timeout)) + if self.error: + raise self.error + return "> OK\n" + + def close(self): + self.closed = True + + +class BlockingOutput: + def __init__(self): + self.event = threading.Event() + + def readline(self, limit): + self.event.wait(5) + return b"" + + def close(self): + self.event.set() + + +class Process: + def __init__(self, output, stall_close=False): + self.stdin = io.BytesIO() + self.stdout = output if hasattr(output, "readline") else io.BytesIO(output) + self.returncode = None + self.kills = self.terminates = 0 + self.stall_close = stall_close + + def poll(self): + return self.returncode + + def kill(self): + self.kills += 1 + self.returncode = -9 + if isinstance(self.stdout, BlockingOutput): + self.stdout.event.set() + + def terminate(self): + self.terminates += 1 + self.returncode = -15 + + def wait(self, timeout): + if self.stall_close and self.returncode is None: + raise subprocess.TimeoutExpired("owned ssh", timeout) + self.returncode = self.returncode or 0 + return self.returncode + + +class SerialRpcTests(unittest.TestCase): + def setUp(self): + self.folder = tempfile.TemporaryDirectory() + self.key = Path(self.folder.name) / "key" + self.key.touch() + + def tearDown(self): + self.folder.cleanup() + + def client(self, messages, **kwargs): + process = Process(b"".join(rpc.encode_message(item) for item in messages)) + node = SshSerialNode("bench@example.test", str(self.key), "/tmp/helper.py", "/dev/ttyUSB0", + process_factory=lambda *args, **kw: process, **kwargs) + return node, process + + def test_server_success_eof_closes_only_owned_serial(self): + node, output = Node(), io.BytesIO() + request = rpc.encode_message(dict(id=1, command="get lab.tx", timeout=12)) + self.assertEqual(rpc.serve(node, io.BytesIO(request), output), 0) + messages = [json.loads(line) for line in output.getvalue().splitlines()] + self.assertEqual(messages[0]["protocol"], rpc.PROTOCOL) + self.assertEqual(messages[1], dict(id=1, completed=True, reply="> OK\n")) + self.assertEqual(node.commands, [("get lab.tx", 12)]) + self.assertTrue(node.closed) + + def test_invalid_requests_never_reach_usb_and_close(self): + invalid = [b"{}\n", b"[]\n", b"{}", b"x" * (rpc.MAX_LINE + 1), + rpc.encode_message(dict(id=1, command="get radio\nset radio bad", timeout=1)), + rpc.encode_message(dict(id=True, command="get radio", timeout=1)), + rpc.encode_message(dict(id=1, command="get radio", timeout=31)), + b'{"id":1,"command":"get radio","timeout":NaN}\n'] + for line in invalid: + with self.subTest(line=line[:80]): + node, output = Node(), io.BytesIO() + self.assertEqual(rpc.serve(node, io.BytesIO(line), output), 1) + self.assertFalse(node.commands) + self.assertTrue(node.closed) + self.assertFalse(json.loads(output.getvalue().splitlines()[-1])["completed"]) + + def test_serial_timeout_not_reported_completed_or_followed_by_new_command(self): + node, output = Node(TimeoutError("serial deadline")), io.BytesIO() + request = rpc.encode_message(dict(id=1, command="get radio", timeout=.1)) + rpc.serve(node, io.BytesIO(request + request), output) + self.assertEqual(len(node.commands), 1) + self.assertFalse(json.loads(output.getvalue().splitlines()[-1])["completed"]) + self.assertTrue(node.closed) + + def test_shell_arguments_are_quoted_and_host_keys_stay_pinned(self): + helper, port = "/tmp/has ' quotes/helper; nope.py", "/dev/serial/by-id/a; $(false)" + argv = ssh_command("bench@example.test", str(self.key), helper, port) + self.assertIn("StrictHostKeyChecking=yes", argv) + self.assertIn("BatchMode=yes", argv) + self.assertEqual(shlex.split(argv[-1]), ["python3", helper, "--port", port]) + for host in ("-oProxyCommand=bad", "host\nother", "user@host;touch x", "host other"): + with self.assertRaises(ValueError): + ssh_command(host, str(self.key), helper, port) + with self.assertRaises(ValueError): + ssh_command("host", str(self.key), "relative.py", port) + + def test_client_complete_response_logged_and_normal_close_is_eof(self): + node, process = self.client([ready(), dict(id=1, completed=True, reply="> OK\n")]) + self.assertEqual(node.command("get radio", timeout=1), "> OK\n") + request = json.loads(process.stdin.getvalue()) + self.assertEqual(request, dict(id=1, command="get radio", timeout=1)) + self.assertEqual(node.commands, [dict(command="get radio", reply="> OK\n")]) + node.close() + node.close() + self.assertTrue(process.stdin.closed) + self.assertEqual(process.kills, 0) + + def test_client_refuses_incomplete_mismatched_and_eof_replies(self): + for messages in ([ready()], [ready(), dict(id=2, completed=True, reply="OK")], + [ready(), dict(id=1, completed=False, error="timeout")], + [ready(), dict(id=1, completed=1, reply="OK")]): + with self.subTest(messages=messages): + node, process = self.client(messages) + with self.assertRaises(RuntimeError): + node.command("get radio", timeout=.01) + self.assertFalse(node.commands) + self.assertTrue(node._closed) + self.assertEqual(process.kills, 1) + + def test_invalid_startup_identity_protocol_or_framing(self): + for data in (rpc.encode_message(ready(protocol="other")), rpc.encode_message(ready(identity={})), + b"debug text\n", b"x" * (rpc.MAX_LINE + 1), b"{}"): + process = Process(data) + with self.subTest(data=data[:80]), self.assertRaises((ValueError, RuntimeError)): + SshSerialNode("host", str(self.key), "/tmp/helper", "/dev/ttyUSB0", + process_factory=lambda *args, **kw: process) + self.assertTrue(process.stdin.closed) + + def test_startup_deadline_kills_only_owned_child(self): + process = Process(BlockingOutput()) + with self.assertRaises(TimeoutError): + SshSerialNode("host", str(self.key), "/tmp/helper", "/dev/ttyUSB0", + process_factory=lambda *args, **kw: process, startup_timeout=.01) + self.assertEqual(process.kills, 1) + self.assertTrue(process.stdin.closed) + + def test_normal_close_has_bounded_terminate_fallback(self): + node, process = self.client([ready()]) + process.stall_close = True + node.close() + self.assertEqual(process.terminates, 1) + self.assertEqual(process.kills, 0) + + def test_client_rejects_bad_commands_before_writing(self): + node, process = self.client([ready()]) + for command, timeout in (("get x\nset x", 1), ("x" * 513, 1), ("get x", float("inf")), ("get x", True)): + with self.assertRaises(ValueError): + node.command(command, timeout) + self.assertEqual(process.stdin.getvalue(), b"") + node.close() + + def test_fast_board_cases_are_exact_and_909_5_only(self): + self.assertEqual([case.tuple_text for case in sweep.CANDIDATES[9:]], [ + "909.5,500,5,5,32", "909.5,500,5,5,64", "909.5,500,5,5,128", "909.5,500,5,5,256", + "909.5,500,7,5,32", "909.5,500,7,5,64", "909.5,250,7,5,32", "909.5,500,8,5,32", + "909.5,125,7,5,32", "909.5,250,8,5,32", "909.5,250,7,5,48", + "909.5,500,5,5,512", "909.5,125,7,5,48"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ethernet_ssh_phone_bench.py b/test/test_ethernet_ssh_phone_bench.py new file mode 100644 index 000000000..e01571a68 --- /dev/null +++ b/test/test_ethernet_ssh_phone_bench.py @@ -0,0 +1,61 @@ +"""Isolated KISS SSH/SFTP fixture contract; no services, namespaces or radios.""" +from pathlib import Path +import re +import shlex +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "tools/hil/ethernet_ssh_phone_bench.sh" + + +def sshd_arguments(source): + block = re.search(r"^options=\((.*?)\)\s*$", source, re.M | re.S) + if block is None: + raise AssertionError("Missing explicit isolated sshd option array") + return shlex.split(block[1]) + + +class EthernetPhoneSftpFixtureTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.source = SCRIPT.read_text() + + def test_dev_null_configuration_explicitly_enables_internal_sftp(self): + arguments = sshd_arguments(self.source) + self.assertEqual(arguments[:2], ["-f", "/dev/null"]) + self.assertIn("Subsystem=sftp internal-sftp", arguments) + position = arguments.index("Subsystem=sftp internal-sftp") + self.assertEqual(arguments[position - 1], "-o") + self.assertEqual(arguments.count("Subsystem=sftp internal-sftp"), 1) + # With -f /dev/null the previous array inherited no system subsystem. + old = self.source.replace(" -o 'Subsystem=sftp internal-sftp'", "") + self.assertNotIn("Subsystem=sftp internal-sftp", sshd_arguments(old)) + + def test_server_remains_private_key_only_and_namespace_scoped(self): + arguments = sshd_arguments(self.source) + for option in ("ListenAddress=10.90.5.2", "AllowUsers=mikec", + "PermitRootLogin=no", "UsePAM=no", + "PasswordAuthentication=no", "KbdInteractiveAuthentication=no", + "AuthenticationMethods=publickey", "DisableForwarding=yes", + "PermitUserEnvironment=no", "X11Forwarding=no"): + self.assertIn(option, arguments) + self.assertIn('chmod 700 -- "$run_dir"', self.source) + self.assertIn('ip netns exec "$namespace" /usr/sbin/sshd -D -e "${options[@]}"', self.source) + self.assertIn('/usr/sbin/sshd -t "${options[@]}"', self.source) + for forbidden in ("systemctl", "service ssh", "/etc/ssh/sshd_config", + "useradd", "usermod", "iptables", "nft ", "ip_forward"): + self.assertNotIn(forbidden, self.source) + + def test_sftp_change_preserves_kiss_mtu_and_owned_cleanup(self): + for required in ('mtu 230 up', '--interface lora0', + '"$observed" == "$inode"', '"$parent" == "$$"', + 'rm -f -- "$run_dir/host_key" "$run_dir/client_key"', + 'Namespace already exists; refusing to reuse it.'): + self.assertIn(required, self.source) + self.assertNotIn("killall", self.source) + self.assertNotIn("pkill", self.source) + self.assertNotIn("rm -rf", self.source) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ethernet_tap_relay.py b/test/test_ethernet_tap_relay.py new file mode 100644 index 000000000..3632db0c1 --- /dev/null +++ b/test/test_ethernet_tap_relay.py @@ -0,0 +1,279 @@ +"""TAP relay byte/backpressure checks without privileged resources or sockets.""" + +from pathlib import Path +import sys +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch + + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/hil")) +import ethernet_tap_relay as relay +from ethernet_kiss_probe import kiss_encode, KissDecoder + + +class EthernetTapRelayTests(unittest.TestCase): + def test_windows_kiss_transport_keeps_raw_bytes_and_separate_fds(self): + shared = Mock(read_fd=77, write_fd=78) + payload = b"\xc0\x10\0\xdb\xdc\xff\xc0" + shared.read.return_value = payload + shared.write.return_value = len(payload) + with patch.object(relay, "WindowsUsbStream", return_value=shared) as opened: + stream = relay.KissStream("winusb://COM6", 115200, windows_python="/mnt/c/python.exe", + expect_serial="EXACT", expect_vid=0x303a, expect_pid=0x1001, + serial_dtr=True, duration=300) + self.assertEqual((stream.read_fd, stream.write_fd), (77, 78)) + self.assertEqual(stream.read(99), payload) + self.assertEqual(stream.write(payload), len(payload)) + stream.close() + opened.assert_called_once_with("winusb://COM6", python="/mnt/c/python.exe", expected_serial="EXACT", + expected_vid=0x303a, expected_pid=0x1001, baud=115200, serial_dtr=True, duration=300) + shared.write.assert_called_once_with(payload) + shared.close.assert_called_once() + + def test_kiss_relay_uses_separate_pipe_fds_without_changing_modem_pacing(self): + stream = Mock(read_fd=77, write_fd=78) + stream.write.side_effect = len + selections = [] + + def selected(reads, writes, errors, timeout): + selections.append((reads, writes)) + return [], [78], [] + + with patch.object(relay.select, "select", side_effect=selected): + stats = relay.relay(stream, 88, duration=1, kiss_modem=True, kiss_port=1, + stop=lambda: len(selections) >= 3) + self.assertEqual(selections, [([88, 77], [78])] * 3) + self.assertEqual(stats["modem_controls_written"], 3) + self.assertEqual(stats["kiss_written"], 0) + def test_selected_port_preserves_bytes_and_ignores_other_data_and_full_modem_metadata(self): + frame = bytes(range(255)) + metadata = b"\xc0\x06\xf9\x00\x01\xc0\x06\xf8\x01\xc0" + for port in (0, 1): + buffers = relay.RelayBuffers(kiss_port=port) + buffers.queue_tap_frame(frame) + self.assertEqual(buffers.to_kiss[0][1], port << 4) + self.assertEqual(KissDecoder(port=port).feed(buffers.to_kiss[0]), [frame]) + buffers.feed_kiss(metadata + kiss_encode(b"other" * 12, port=1 - port) + kiss_encode(frame, port=port)) + self.assertEqual(list(buffers.to_tap), [frame]) + self.assertEqual(buffers.stats()["decode_errors"], 0) + for invalid in (True, -1, 2, 1.0): + with self.assertRaises(ValueError): relay.RelayBuffers(kiss_port=invalid) + with self.assertRaises(ValueError): kiss_encode(frame, port=invalid) + + def test_bench_endpoint_allowlist_is_explicit_and_does_not_access_devices(self): + for value in ("serial:///dev/serial/by-id/usb-RAK_4631_ABC-if00", "serial:///dev/serial/by-path/platform-usb-0:1.2:1.0", + "serial:///dev/ttyACM0", "serial:///dev/ttyUSB0", "tcp://192.168.1.84:5003"): + self.assertIn(relay.validate_endpoint(value, strict_tcp=True).scheme, ("serial", "tcp")) + for value in ("serial:///dev/mem", "serial:///etc/passwd", "serial://host/dev/ttyACM0", + "serial:///dev/serial/by-id/../by-path/device", "serial:///dev/ttyACM0?baud=1", + "serial:///dev/serial/by-id/%2fetc", "tcp://radio:5003", "tcp://192.168.1.84:5002", + "tcp://user:secret@192.168.1.84:5003", "tcp://192.168.1.84:5003/path"): + with self.assertRaises(ValueError): relay.validate_endpoint(value, strict_tcp=True) + with patch.object(relay.os.path, "realpath", return_value="/dev/mem"), \ + patch.object(relay, "open_configured_session") as opened: + with self.assertRaises(ValueError): + relay.KissStream("serial:///dev/serial/by-id/explicit-device", 115200) + opened.assert_not_called() + + def test_explicit_full_modem_initialization_then_one_data_until_successful_tx_done(self): + clock = [100.0] + buffers = relay.RelayBuffers(kiss_port=1, kiss_modem=True, now=lambda: clock[0]) + frames = [b"a" * 60, b"b" * 60] + for frame in frames: buffers.queue_tap_frame(frame) + written = [] + while buffers.controls: + buffers.write_kiss(lambda data: written.append(data) or len(data)) + self.assertEqual(written, [b"\xc0\x01\0\xc0", b"\xc0\x02\xff\xc0", b"\xc0\x03\x01\xc0"]) + buffers.write_kiss(lambda data: written.append(data) or len(data)) + self.assertEqual(written[-1], kiss_encode(frames[0], port=1)) + self.assertFalse(buffers.can_write_kiss()) + writer = Mock(); buffers.write_kiss(writer); writer.assert_not_called() + buffers.feed_kiss(b"\xc0\x06\xf9\x00\x01\xc0") + self.assertEqual(buffers.stats()["modem_rx_meta"], 1) + self.assertFalse(buffers.can_write_kiss()) + buffers.feed_kiss(b"\xc0\x16\xf8\x01\xc0") # Wrong control port cannot release it. + self.assertFalse(buffers.can_write_kiss()) + buffers.feed_kiss(b"\xc0\x06\xf8\x01\xc0") + self.assertTrue(buffers.can_write_kiss()) + self.assertEqual(buffers.stats()["modem_tx_done"], 1) + buffers.write_kiss(lambda data: written.append(data) or len(data)) + self.assertEqual(written[-1], kiss_encode(frames[1], port=1)) + clock[0] += 10 + with self.assertRaises(relay.ModemFlowError) as failure: buffers.check_tx_done() + self.assertEqual(failure.exception.stats["modem_tx_timeout"], 1) + + def test_full_modem_errors_fail_closed_no_next_data_or_automatic_retry(self): + for control in (b"\xc0\x06\xf8\x02\xc0", b"\xc0\x06\xf1\x07\xc0", b"\xc0\x06\xf8\xc0"): + buffers = relay.RelayBuffers(kiss_modem=True) + buffers.queue_tap_frame(b"a" * 60); buffers.queue_tap_frame(b"b" * 60) + while buffers.controls: buffers.write_kiss(len) + buffers.write_kiss(len) + with self.assertRaises(relay.ModemFlowError) as failure: buffers.feed_kiss(control) + self.assertEqual(failure.exception.stats["modem_tx_error"], 1) + self.assertEqual(len(buffers.to_kiss), 1) + self.assertFalse(buffers.can_write_kiss()) + + def test_rejected_tx_done_drops_one_frame_then_allows_next_without_replay(self): + buffers = relay.RelayBuffers(kiss_port=1, kiss_modem=True) + frames = [b"first" * 12, b"other" * 12] + for frame in frames: buffers.queue_tap_frame(frame) + written = [] + while buffers.controls: buffers.write_kiss(len) + buffers.write_kiss(lambda data: written.append(data) or len(data)) + self.assertFalse(buffers.can_write_kiss()) + buffers.feed_kiss(b"\xc0\x06\xf8\x00\xc0") + self.assertTrue(buffers.can_write_kiss()) + self.assertEqual(buffers.stats()["modem_tx_error"], 1) + self.assertEqual(buffers.stats()["modem_tx_done"], 0) + buffers.write_kiss(lambda data: written.append(data) or len(data)) + self.assertEqual(written, [kiss_encode(frame, port=1) for frame in frames]) + self.assertEqual(list(buffers.to_kiss), []) + self.assertEqual(buffers.stats()["kiss_written"], 2) + self.assertTrue(buffers.stats()["awaiting_tx_done"]) + buffers.feed_kiss(b"\xc0\x06\xf8\x01\xc0") + self.assertEqual(buffers.stats()["modem_tx_done"], 1) + self.assertFalse(buffers.stats()["awaiting_tx_done"]) + + def test_unsolicited_tx_done_does_not_create_a_credit_or_change_default_tcp_flow(self): + buffers = relay.RelayBuffers(kiss_modem=True) + buffers.feed_kiss(b"\xc0\x06\xf8\x01\xc0") + self.assertEqual(buffers.stats()["modem_unexpected_tx_done"], 1) + default = relay.RelayBuffers() + self.assertFalse(default.controls) + default.queue_tap_frame(b"a" * 60); default.queue_tap_frame(b"b" * 60) + default.write_kiss(len); default.write_kiss(len) + self.assertEqual(default.stats()["kiss_written"], 2) + self.assertFalse(default.stats()["awaiting_tx_done"]) + + def test_phone_fixture_keeps_default_tcp_and_explicitly_forwards_serial_modem_options(self): + source = (Path(relay.__file__).parent / "ethernet_ssh_phone_bench.sh").read_text() + self.assertIn("kiss_port=0; baud=115200; serial_dtr=off; kiss_modem=off", source) + self.assertIn('validate_endpoint(sys.argv[1], strict_tcp=True)', source) + self.assertIn('--baud "$baud" --kiss-port "$kiss_port" --serial-dtr "$serial_dtr"', source) + self.assertIn('modem_args+=(--kiss-modem)', source) + self.assertIn('Server helper PID: %s', source) + self.assertIn('"$observed" == "$inode"', source) + + def test_tcp_transport_defaults_to_dedicated_kiss_port(self): + with patch.object(relay.socket, "create_connection") as connect: + endpoint = relay.KissStream("tcp://bench-radio", 115200) + connect.assert_called_once_with(("bench-radio", 5003), timeout=5) + endpoint.close() + + def test_current_host_namespace_is_rejected_before_setns(self): + identity = SimpleNamespace(st_dev=5, st_ino=700) + with patch.object(relay.os, "open", return_value=88), \ + patch.object(relay.os, "O_CLOEXEC", 0, create=True), \ + patch.object(relay.os, "fstat", return_value=identity), \ + patch.object(relay.os, "stat", return_value=identity), \ + patch.object(relay.os, "close") as close, \ + patch.object(relay.os, "setns", create=True) as setns: + with self.assertRaisesRegex(ValueError, "current host"): + relay.enter_network_namespace("/proc/self/ns/net") + setns.assert_not_called() + close.assert_called_once_with(88) + + def test_distinct_namespace_is_entered_and_handle_closed(self): + with patch.object(relay.os, "open", return_value=88), \ + patch.object(relay.os, "O_CLOEXEC", 0, create=True), \ + patch.object(relay.os, "fstat", return_value=SimpleNamespace(st_dev=5, st_ino=701)), \ + patch.object(relay.os, "stat", return_value=SimpleNamespace(st_dev=5, st_ino=700)), \ + patch.object(relay.os, "close") as close, \ + patch.object(relay.os, "setns", create=True) as setns: + relay.enter_network_namespace("/var/run/netns/bench-a") + setns.assert_called_once_with(88, 0x40000000) + close.assert_called_once_with(88) + + def test_kiss_is_opened_before_namespace_or_tap(self): + calls = [] + stream = Mock() + with patch.object(relay, "KissStream", side_effect=lambda *args, **kwargs: calls.append("socket") or stream), \ + patch.object(relay, "enter_network_namespace", side_effect=lambda path: calls.append("netns")), \ + patch.object(relay, "create_tap", side_effect=lambda name: calls.append("tap") or (77, name)): + self.assertEqual(relay.open_transport_then_tap("tcp://host:5003", 115200, "/ns", "lora0"), + (stream, 77, "lora0")) + self.assertEqual(calls, ["socket", "netns", "tap"]) + stream.close.assert_not_called() + + def test_failed_namespace_or_tap_closes_kiss(self): + for stage in ("enter_network_namespace", "create_tap"): + stream = Mock() + with patch.object(relay, "KissStream", return_value=stream), \ + patch.object(relay, "enter_network_namespace"), \ + patch.object(relay, "create_tap", return_value=(77, "lora0")), \ + patch.object(relay, stage, side_effect=OSError("denied")): + with self.assertRaises(OSError): + relay.open_transport_then_tap("tcp://host:5003", 115200, "/ns", "lora0") + stream.close.assert_called_once() + + def test_partial_stream_writes_preserve_exact_frame(self): + buffers = relay.RelayBuffers(1) + frame = bytes(range(255)) + self.assertTrue(buffers.queue_tap_frame(frame)) + self.assertFalse(buffers.can_read_tap()) + with self.assertRaises(BufferError): + buffers.queue_tap_frame(frame) + written = bytearray() + + def write(data): + written.extend(data[:3]) + return min(3, len(data)) + + buffers.write_kiss(lambda data: 0) + while buffers.to_kiss: + buffers.write_kiss(write) + self.assertTrue(buffers.can_read_tap()) + self.assertEqual(bytes(written), kiss_encode(frame)) + self.assertEqual(KissDecoder().feed(bytes(written)), [frame]) + self.assertEqual(buffers.stats()["kiss_written"], 1) + + def test_incoming_staging_applies_backpressure_without_losing_frames(self): + buffers = relay.RelayBuffers(1) + frames = [bytes((index,)) * 255 for index in range(3)] + buffers.feed_kiss(b"".join(kiss_encode(frame) for frame in frames)) + self.assertEqual(len(buffers.to_tap), 1) + self.assertTrue(buffers.staging) + self.assertFalse(buffers.can_read_kiss()) + with self.assertRaises(BufferError): + buffers.feed_kiss(b"more bytes") + written = [] + for frame in frames: + buffers.write_tap(lambda data: written.append(data) or len(data)) + buffers.parse_pending() + self.assertEqual(written, frames) + self.assertTrue(buffers.can_read_kiss()) + self.assertEqual(buffers.stats()["kiss_read"], 3) + self.assertEqual(buffers.stats()["tap_written"], 3) + + def test_partial_tap_write_is_not_retried_as_a_new_frame(self): + buffers = relay.RelayBuffers() + buffers.feed_kiss(kiss_encode(b"x" * 60)) + with self.assertRaisesRegex(IOError, "partial TAP"): + buffers.write_tap(lambda frame: 20) + self.assertEqual(buffers.stats()["tap_written"], 0) + self.assertEqual(len(buffers.to_tap), 1) + + def test_invalid_frame_sizes_are_dropped_not_truncated(self): + buffers = relay.RelayBuffers() + for size in (0, 13, 256, 1500): + self.assertFalse(buffers.queue_tap_frame(b"x" * size)) + self.assertEqual(len(buffers.to_kiss), 0) + buffers.feed_kiss(kiss_encode(b"x" * 13)) + self.assertEqual(len(buffers.to_tap), 0) + self.assertEqual(buffers.stats()["bad_frame_length"], 5) + for size in (14, 255): + self.assertTrue(buffers.queue_tap_frame(b"x" * size)) + + def test_bad_stream_cannot_grow_unbounded(self): + buffers = relay.RelayBuffers() + with self.assertRaises(BufferError): + buffers.feed_kiss(b"x" * 4097) + buffers.feed_kiss(b"\xc0\0" + b"x" * 3500) + self.assertEqual(buffers.staging, b"") + self.assertLessEqual(len(buffers.decoder.buffer), 257) + self.assertEqual(buffers.stats()["decode_errors"], 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_ethernet_tcp_stats.py b/test/test_ethernet_tcp_stats.py new file mode 100644 index 000000000..9d5c8683c --- /dev/null +++ b/test/test_ethernet_tcp_stats.py @@ -0,0 +1,57 @@ +"""No network namespaces or radio access: production TCP evidence parser tests.""" +import json +from pathlib import Path +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools" / "hil")) +import ethernet_tcp_stats as stats + + +class TcpStatsTests(unittest.TestCase): + def test_linux_snmp_counter_pair_and_handshake_inclusive_delta(self): + before = stats.parse_tcp("Ip: ignored\nTcp: RtoAlgorithm CurrEstab InSegs OutSegs RetransSegs OutRsts\nTcp: 1 0 20 30 2 0\n") + after = stats.parse_tcp("Tcp: RtoAlgorithm CurrEstab InSegs OutSegs RetransSegs OutRsts\nTcp: 1 1 40 80 7 1\n") + report = stats.summarize(before, after) + self.assertEqual(report["delta"], dict(InSegs=20, OutSegs=50, RetransSegs=5, OutRsts=1)) + self.assertEqual(report["out_segment_retransmission_ratio"], .1) + self.assertNotIn("CurrEstab", report["delta"]) + self.assertIn("total only", report["retransmission_scope"]) + + def test_ambiguous_missing_negative_or_reset_counters_are_not_evidence(self): + for text in ("", "Tcp: InSegs OutSegs\nTcp: 1 2\n", "Tcp: InSegs OutSegs RetransSegs\nTcp: 1 2 -1\n", + "Tcp: InSegs OutSegs RetransSegs\nTcp: 1 2 0\nTcp: 2 3 0\n"): + with self.assertRaises(ValueError): + stats.parse_tcp(text) + with self.assertRaises(ValueError): + stats.summarize(dict(InSegs=2, OutSegs=2, RetransSegs=0), dict(InSegs=1, OutSegs=2, RetransSegs=0)) + + def test_zero_out_segments_has_no_invented_ratio(self): + value = dict(InSegs=0, OutSegs=0, RetransSegs=0) + self.assertIsNone(stats.summarize(value, value)["out_segment_retransmission_ratio"]) + + def test_missing_snapshot_produces_explicit_unavailable_artifact(self): + with tempfile.TemporaryDirectory() as directory: + self.assertEqual(stats.main(["--directory", directory]), 0) + report = json.loads((Path(directory) / "tcp-stats.json").read_text()) + self.assertFalse(report["available"]) + + def test_shell_default_mode_bounds_namespace_ownership_and_evidence_paths(self): + source = (Path(__file__).resolve().parents[1] / "tools/hil/ethernet_ssh_flutter_bench.sh").read_text() + self.assertIn("test_name=rf; test_timeout=210", source) + self.assertIn('--test-name) test_name=$2', source) + self.assertIn('10#$2 <= 180', source) + self.assertIn('--phase-timeout)', source) + self.assertIn('LORA_SSH_SPEED_TIMEOUT_SECONDS="$phase_timeout"', source) + self.assertIn('LORA_SSH_SPEED_REPORT="$run_dir/speed.json"', source) + self.assertIn('capture_tcp after || true', source) + self.assertIn('wait "$test_pid" || test_result=$?', source) + self.assertIn('exit "$test_result"', source) + self.assertIn('local remaining=300', source) + self.assertIn('"$observed" == "$inode"', source) + self.assertNotIn('cat /proc/net/snmp > /', source) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_meshcore_ip_relay.py b/test/test_meshcore_ip_relay.py new file mode 100644 index 000000000..abd48fc1a --- /dev/null +++ b/test/test_meshcore_ip_relay.py @@ -0,0 +1,378 @@ +"""Host-only RAW point IPv4 framing/MTU/state tests; no sockets, USB or TUN.""" +import io +from pathlib import Path +import struct +import sys +from types import SimpleNamespace +import unittest +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools" / "hil")) +import meshcore_ip_relay as ip + + +def packet(size=40, reverse=False, flags=0x4000, protocol=6, header=20): + result = bytearray(size) + result[0] = 0x40 | (header // 4) + result[8], result[9] = 64, protocol + result[12:16] = bytes((10, 90, 5, 2 if reverse else 1)) + result[16:20] = bytes((10, 90, 5, 1 if reverse else 2)) + result[header + 12] = 0x50 + struct.pack_into("!H", result, 2, size) + struct.pack_into("!H", result, 6, flags) + checksum = sum(struct.unpack("!" + "H" * (header // 2), result[:header])) + while checksum >> 16: + checksum = (checksum & 0xffff) + (checksum >> 16) + struct.pack_into("!H", result, 10, ~checksum & 0xffff) + return bytes(result) + + +def codec(path=None, back=0, **kwargs): + return ip.IpCodec("10.90.5.1", "10.90.5.2", path or ip.MeshPath(), back, + session=0x11223344, **kwargs) + + +def response(frame): + return b">" + struct.pack("= 4, on_ready=lambda: events.append("ready")) + self.assertEqual(events, ["app-start-written", "self-info-received", "ready"]) + self.assertTrue(stats["ready"]) + + def test_path_width_count_and_no_flood(self): + self.assertEqual(ip.MeshPath.from_hex("aabbccdd", 2).encoded, 0x42) + self.assertEqual(ip.MeshPath().encoded, 0) + for data, width in ((bytes(64), 1), (bytes(65), 2), (bytes(3), 2), (b"", 4), (b"", 0)): + with self.subTest(width=width, length=len(data)), self.assertRaises(ValueError): + ip.MeshPath(data, width) + self.assertEqual(ip.MeshPath(bytes(64), 2).encoded, 0x60) + with self.assertRaises(ValueError): + ip.MeshPath.from_hex("f") + + def test_mtu_derived_for_both_paths_and_host_frame_cap(self): + self.assertEqual(ip.mtu_budget(0, 0), (156, 156)) + self.assertEqual(ip.mtu_budget(0, 64), (94, 94)) + self.assertEqual(ip.mtu_budget(64, 0), (94, 94)) + self.assertEqual(ip.mtu_budget(4, 2), (154, 154)) + self.assertEqual(ip.mtu_budget(0, 0, requested=230), (156, 156)) + self.assertEqual(ip.mtu_budget(0, 0, requested=128), (156, 128)) + self.assertEqual(ip.mtu_budget(64, 64, transport_bytes=4), (94, 94)) + for values in ((65, 0, None), (0, -1, None), (0, 0, 93)): + with self.assertRaises(ValueError): + ip.mtu_budget(*values) + + def test_command_wire_golden_matches_dart_and_nonces_change(self): + link = codec(ip.MeshPath.from_hex("aabb", 2)) + first, second = link.command(packet()), link.command(packet()) + self.assertEqual(first[:16].hex(), "1941aabb535348014433221100000000") + self.assertEqual(second[12:16], b"\x01\0\0\0") + self.assertNotEqual(first, second) + self.assertEqual(first[16:], packet()) + self.assertEqual(ip.companion_frame(first)[:3], b"<\x38\0") + + def test_sequence_exhaustion_never_wraps(self): + link = codec(sequence=0xffffffff) + self.assertEqual(link.command(packet())[10:14], b"\xff" * 4) + with self.assertRaises(OverflowError): + link.command(packet()) + + def test_ipv4_limits_direction_checksum_fragments_protocol_and_padding(self): + link = codec() + self.assertEqual(len(link.command(packet(156))), 170) + for value in (packet(157), packet(reverse=True), packet(flags=0x2000), packet(flags=1), + packet(flags=0x8000), packet(protocol=17), packet() + b"\0", packet()[:10]): + with self.subTest(value=value[:10]), self.assertRaises(ValueError): + link.command(value) + damaged = bytearray(packet()) + damaged[8] ^= 1 + with self.assertRaises(ValueError): + link.command(damaged) + self.assertTrue(ip.valid_ipv4(packet(header=24, size=44), link.local, link.remote, 156)) + + def test_raw_push_only_valid_peer_packet_not_text_or_other_apps(self): + link = codec() + self.assertEqual(link.raw_push(incoming()), packet(reverse=True)) + for frame in (incoming()[:35], b"\x08" + incoming()[1:], incoming()[:4] + b"OTHER" + incoming()[9:], + incoming() + b"\0", bytes((0x84, 0, 0, 255)) + link.command(packet())[2:]): + self.assertIsNone(link.raw_push(frame)) + + def test_stream_every_split_boundary_and_wrong_direction_length(self): + wire = response(incoming()) + for split in range(len(wire) + 1): + decoder = ip.CompanionDecoder() + decoder.feed(wire[:split]) + first = decoder.pop() + decoder.feed(wire[split:]) + self.assertEqual(first if first is not None else decoder.pop(), incoming()) + self.assertIsNone(decoder.pop()) + for wire in (b"<\x01\0x", b">\0\0", b">\xb1\0"): + decoder = ip.CompanionDecoder() + decoder.feed(wire) + with self.assertRaises(ValueError): + decoder.pop() + with self.assertRaises(BufferError): + ip.CompanionDecoder().feed(bytes(4097)) + + def test_actual176byte_rx_limit_does_not_increase_command_or_ip_budget(self): + buffers = started_buffers() + for size in (173, 176): + frame = bytes((0x88,)) + bytes(size - 1) + buffers.feed_companion(response(frame)) + self.assertEqual(buffers.counters["ignored_frames"], 2) + self.assertTrue(buffers.can_read_tun()) + self.assertEqual(codec().mtu, 156) + with self.assertRaises(ValueError): ip.companion_frame(bytes(173)) + decoder = ip.CompanionDecoder() + decoder.feed(response(bytes(177))) + with self.assertRaises(ValueError): decoder.pop() + + def test_serial_printable_banner_is_bounded_and_only_before_binary(self): + decoder = ip.CompanionDecoder(ascii_preamble=True) + banner = b"WELCOME HIL\r\n(+++MESHCORE-TERM-STOP returns to Binary mode)\r\n> " + wire = banner + response(bytes((5, 0))) + for byte in wire: + decoder.feed(bytes((byte,))) + result = decoder.pop() + self.assertEqual(result, bytes((5, 0))) + self.assertEqual(decoder.preamble_bytes, len(banner)) + decoder.feed(b"bad") + with self.assertRaises(ValueError): decoder.pop() + for wire, exception in ((b"bad\x00", ValueError), (b"a" * 4096, None)): + decoder = ip.CompanionDecoder(ascii_preamble=True) + decoder.feed(wire) + if exception: + with self.assertRaises(exception): decoder.pop() + else: + decoder.pop() + decoder.feed(b"x") + with self.assertRaises(BufferError): decoder.pop() + + def test_startup_and_one_acknowledged_command_at_a_time(self): + buffers = ip.IpRelayBuffers(codec()) + self.assertFalse(buffers.can_read_tun()) + self.assertEqual(buffers.output[:4], b"<\x13\0\x01") + buffers.write_companion(lambda data: len(data)) + buffers.feed_companion(response(bytes((5, 0)))) + self.assertTrue(buffers.can_read_tun()) + self.assertTrue(buffers.queue_tun(packet())) + with self.assertRaises(BufferError): + buffers.queue_tun(packet()) + buffers.write_companion(lambda data: 2) + self.assertTrue(buffers.output) + buffers.write_companion(lambda data: len(data)) + buffers.feed_companion(response(b"\0")) + self.assertTrue(buffers.can_read_tun()) + self.assertEqual(buffers.stats()["accepted_commands"], 1) + self.assertIn("not RF-delivered", buffers.stats()["acceptance_note"]) + + def test_partial_stream_continues_reading_and_receive_queue_backpressures(self): + buffers = started_buffers(depth=1) + wire = response(incoming()) + buffers.feed_companion(wire[:1]) + self.assertTrue(buffers.can_read_companion()) + buffers.feed_companion(wire[1:] + wire) + self.assertEqual(len(buffers.to_tun), 1) + self.assertFalse(buffers.can_read_companion()) + buffers.write_tun(lambda data: len(data)) + buffers.parse_pending() + self.assertEqual(len(buffers.to_tun), 1) + self.assertFalse(buffers.decoder.staging) + with self.assertRaises(IOError): + buffers.write_tun(lambda data: 1) + + def test_error_acceptance_invalid_packet_and_deadline_are_honest(self): + now = [0.] + buffers = started_buffers(now=lambda: now[0]) + self.assertFalse(buffers.queue_tun(b"bad IPv4")) + buffers.queue_tun(packet()) + buffers.write_companion(lambda data: len(data)) + buffers.feed_companion(response(b"\x01\x03")) + self.assertEqual(buffers.stats()["rejected_commands"], 1) + buffers.queue_tun(packet()) + now[0] = 12 + with self.assertRaises(TimeoutError): + buffers.check_deadline() + + def test_socket_before_namespace_before_new_tun_only(self): + events = [] + stream = mock.Mock() + with mock.patch.object(ip, "CompanionStream", side_effect=lambda endpoint, **kwargs: events.append("socket") or stream), \ + mock.patch.object(ip, "enter_network_namespace", side_effect=lambda path: events.append("namespace")), \ + mock.patch.object(ip, "create_tun", side_effect=lambda name: events.append("tun") or (99, "mcip0")), \ + mock.patch.object(ip.subprocess, "run") as run: + self.assertEqual(ip.open_transport_then_tun("tcp://192.168.1.84:5000", "/var/run/netns/test", "mcip0", + "10.90.5.1", "10.90.5.2", 156), (stream, 99, "mcip0")) + self.assertEqual(events, ["socket", "namespace", "tun"]) + self.assertEqual(run.call_args_list[1].args[0], ["ip", "address", "add", "10.90.5.1/32", "peer", "10.90.5.2/32", "dev", "mcip0"]) + stream.close.assert_not_called() + + def test_namespace_rejection_closes_own_socket_without_touching_tun(self): + stream = mock.Mock() + with mock.patch.object(ip, "CompanionStream", return_value=stream), \ + mock.patch.object(ip, "enter_network_namespace", side_effect=ValueError("same host")), \ + mock.patch.object(ip, "create_tun") as create: + with self.assertRaises(ValueError): + ip.open_transport_then_tun("endpoint", "host", "mcip0", "10.90.5.1", "10.90.5.2", 156) + create.assert_not_called() + stream.close.assert_called_once() + + +class CompanionStreamTests(unittest.TestCase): + endpoint = "serial:///dev/serial/by-id/usb-RAK_USB_TEST_SERIAL-if00" + identity = ("/dev/ttyACM3", "USB_TEST_SERIAL", 11, 22, 33) + + def test_windows_stream_shared_binary_transport_and_explicit_identity(self): + shared = mock.Mock(read_fd=77, write_fd=78) + payload = b"\0\xc0\xdb\xff" + shared.read.return_value = payload + shared.write.return_value = len(payload) + with mock.patch.object(ip, "WindowsUsbStream", return_value=shared) as opened: + stream = ip.CompanionStream("winusb://COM6", windows_python="/mnt/c/python.exe", + expect_serial="EXACT", expect_vid=0x303a, expect_pid=0x1001, + serial_dtr=True, duration=300) + self.assertEqual((stream.read_fd, stream.write_fd), (77, 78)) + self.assertEqual(stream.read(99), payload) + self.assertEqual(stream.write(payload), len(payload)) + stream.close() + opened.assert_called_once_with("winusb://COM6", python="/mnt/c/python.exe", expected_serial="EXACT", + expected_vid=0x303a, expected_pid=0x1001, baud=115200, serial_dtr=True, duration=300) + shared.write.assert_called_once_with(payload) + shared.close.assert_called_once() + with self.assertRaises(ValueError): ip.CompanionStream("winusb://COM6") + with self.assertRaises(ValueError): + ip.CompanionStream("tcp://192.168.1.87:5000", windows_python="/mnt/c/python.exe") + + def test_relay_selects_separate_pipe_read_and_write_descriptors(self): + stream = mock.Mock(read_fd=77, write_fd=78) + stream.write.side_effect = len + stream.read.return_value = response(bytes((5, 0))) + selections = [] + + def selected(reads, writes, errors, timeout): + selections.append((reads, writes)) + return ([], [78], []) if len(selections) == 1 else ([77], [], []) + + with mock.patch.object(ip.select, "select", side_effect=selected): + stats = ip.relay(stream, 88, ip.IpRelayBuffers(codec()), duration=1, stop=lambda: len(selections) >= 2) + self.assertEqual(selections, [([77], [78]), ([77], [])]) + self.assertTrue(stats["ready"]) + + def test_tcp_defaults_unchanged_and_no_serial_open(self): + sock = mock.Mock() + sock.recv.return_value = b">\x01\0\0" + sock.send.return_value = 2 + with mock.patch.object(ip.socket, "create_connection", return_value=sock) as connect, \ + mock.patch.object(ip, "serial_identity") as identity: + stream = ip.CompanionStream("tcp://192.168.1.87:5000") + self.assertEqual(stream.read(10), b">\x01\0\0") + self.assertEqual(stream.write(b"<\x01\0\x01"), 2) + stream.close() + connect.assert_called_once_with(("192.168.1.87", 5000), timeout=5) + identity.assert_not_called() + sock.setblocking.assert_called_once_with(False) + sock.close.assert_called_once() + + def test_unstable_alias_wrong_protocol_and_credentials_rejected_without_open(self): + for endpoint in ("serial:///dev/ttyACM3", "serial:///dev/serial/by-path/port0", + "serial:///dev/serial/by-id/../../ttyACM3", "serial:///tmp/radio", + "tcp://192.168.1.87:5003", "tcp://user:secret@192.168.1.87:5000"): + with self.subTest(endpoint=endpoint), self.assertRaises(ValueError): + ip.validate_companion_endpoint(endpoint) + with self.assertRaises(ValueError): + ip.validate_companion_endpoint("tcp://192.168.1.87:5000", expect_serial="9AB3B64C641BA927") + + def test_busy_serial_never_opens_or_changes_lines(self): + with mock.patch.object(ip, "serial_identity", return_value=self.identity), \ + mock.patch.object(ip, "assert_port_idle", side_effect=RuntimeError("busy")), \ + mock.patch.object(ip, "open_configured_session") as opened: + with self.assertRaisesRegex(RuntimeError, "busy"): + ip.CompanionStream(self.endpoint) + opened.assert_not_called() + + def test_exact_identity_exclusive_safe_dtr_selection_read_write_and_close(self): + for selected in (False, True): + port = mock.Mock() + port.fileno.return_value = 77 + fake_serial = SimpleNamespace(Serial=mock.Mock(return_value=port)) + info = SimpleNamespace(st_mode=0o020600, st_dev=11, st_ino=22, st_rdev=33) + with mock.patch.dict(sys.modules, serial=fake_serial), \ + mock.patch.object(ip, "serial_identity", return_value=self.identity) as identity, \ + mock.patch.object(ip, "assert_port_idle") as idle, \ + mock.patch.object(ip, "open_configured_session") as opened, \ + mock.patch.object(ip.os, "fstat", return_value=info), \ + mock.patch.object(ip.os, "read", return_value=b">\x02\0\x05\0") as read, \ + mock.patch.object(ip.os, "write", return_value=2) as write: + stream = ip.CompanionStream(self.endpoint, baud=115200, serial_dtr=selected, + expect_serial="9AB3B64C641BA927") + self.assertEqual(stream.read(4096), b">\x02\0\x05\0") + self.assertEqual(stream.write(b"<\x01\0\x01"), 2) + stream.close() + self.assertEqual(identity.call_count, 2) + idle.assert_called_once_with("/dev/ttyACM3") + opened.assert_called_once_with(port) + self.assertEqual(port.port, "/dev/ttyACM3") + self.assertTrue(port.exclusive) + self.assertIs(port.dtr, selected) + self.assertIs(port.rts, False) + self.assertEqual((port.timeout, port.write_timeout), (0, 0)) + read.assert_called_once_with(77, 4096) + write.assert_called_once_with(77, b"<\x01\0\x01") + port.close.assert_called_once() + + def test_alias_change_after_open_closes_owned_port(self): + port = mock.Mock() + fake_serial = SimpleNamespace(Serial=mock.Mock(return_value=port)) + changed = ("/dev/ttyACM4", *self.identity[1:]) + with mock.patch.dict(sys.modules, serial=fake_serial), \ + mock.patch.object(ip, "serial_identity", side_effect=[self.identity, changed]), \ + mock.patch.object(ip, "assert_port_idle"), mock.patch.object(ip, "open_configured_session"), \ + mock.patch.object(ip.os, "fstat"): + with self.assertRaisesRegex(ValueError, "identity changed"): + ip.CompanionStream(self.endpoint) + port.close.assert_called_once() + + def test_wrong_usb_serial_and_alias_target_fail_closed(self): + with mock.patch.object(ip.Path, "resolve", return_value=Path("/tmp/not-a-radio")): + with self.assertRaisesRegex(ValueError, "ttyACM/ttyUSB"): + ip.serial_identity("/dev/serial/by-id/radio", "EXPECTED") + info = SimpleNamespace(st_mode=0o020600, st_dev=11, st_ino=22, st_rdev=33) + with mock.patch.object(ip.Path, "resolve", side_effect=["/dev/ttyACM3", Path("/sys/devices/usb/1-1")]), \ + mock.patch.object(ip.os, "stat", return_value=info), \ + mock.patch.object(ip.Path, "is_file", return_value=True), \ + mock.patch.object(ip.Path, "read_text", return_value="WRONG"): + with self.assertRaisesRegex(ValueError, "USB serial differs"): + ip.serial_identity("/dev/serial/by-id/radio", "EXPECTED") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_meshcore_ip_ssh_phone_bench.py b/test/test_meshcore_ip_ssh_phone_bench.py new file mode 100644 index 000000000..680b431fa --- /dev/null +++ b/test/test_meshcore_ip_ssh_phone_bench.py @@ -0,0 +1,83 @@ +"""RAW phone SSH/SFTP fixture contract; no root, services, sockets or radios.""" +from pathlib import Path +import re +import subprocess +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "tools/hil/meshcore_ip_ssh_phone_bench.sh" + + +class MeshCoreIpPhoneFixtureTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.source = SCRIPT.read_text() + cls.validator = re.search(r"python3 - \"\$endpoint\" \"\$script_dir\" \"\$expect_serial\" <<'PY'\n(.*?)\nPY", cls.source, re.S)[1] + + def test_endpoint_validator_only_explicit_private_numeric_companion_tcp5000(self): + for endpoint in ("tcp://192.168.1.87:5000", "tcp://10.1.2.3:5000", "tcp://172.20.1.2:5000/", + "serial:///dev/serial/by-id/usb-RAK_USB_TEST_SERIAL-if00"): + result = subprocess.run([sys.executable, "-B", "-c", self.validator, endpoint, str(ROOT / "tools/hil")], + capture_output=True, timeout=3, check=False) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + for endpoint in ("tcp://8.8.8.8:5000", "tcp://127.0.0.1:5000", "tcp://radio.local:5000", + "tcp://192.168.1.87:5003", "tcp://user:secret@192.168.1.87:5000", + "serial:///dev/ttyACM0", "tcp://192.168.1.87:5000/path", + "tcp://192.168.1.87:5000?x=1", "tcp://192.168.1.0:5000"): + result = subprocess.run([sys.executable, "-B", "-c", self.validator, endpoint, str(ROOT / "tools/hil")], + capture_output=True, timeout=3, check=False) + self.assertNotEqual(result.returncode, 0, endpoint) + + def test_raw_point_ip_configuration_and_actual_handshake_readiness(self): + self.assertIn('--interface mcip0 --local-address 10.90.5.2 --remote-address 10.90.5.1', self.source) + self.assertIn("--path-hex '' --path-hash-width 1 --return-path-byte-count 0 --mtu 156", self.source) + self.assertIn('--ready-after-handshake', self.source) + self.assertIn("state.get('state') == 'ready'", self.source) + self.assertIn("state.get('mtu') == 156", self.source) + self.assertIn('Companion APP_START/SelfInfo handshake did not become ready.', self.source) + self.assertLess(self.source.index('((ready))'), self.source.index("printf 'READY:")) + self.assertNotIn('ethernet_tap_relay.py --endpoint', self.source) + + def test_serial_identity_and_explicit_line_options_forwarded_without_kiss(self): + for required in ('--baud) baud=$2', '--serial-dtr) serial_dtr=$2', '--expect-serial) expect_serial=$2', + 'serial_dtr=off', 'serial_options=(--baud "$baud" --serial-dtr "$serial_dtr")', + 'serial_options+=(--expect-serial "$expect_serial")', '"${serial_options[@]}"'): + self.assertIn(required, self.source) + + def test_account_private_keys_and_internal_sftp_not_system_ssh(self): + for required in ('--account) account=$2', '$(id -u "$account") -ne 0', + 'ssh_user=${ssh_user:-$account}', '"$ssh_user" == "$account"', + 'chown "$account": "$run_dir"', 'chmod 700 -- "$run_dir"', + 'runuser -u "$account" -- ssh-keygen', '-o "AllowUsers=$ssh_user"', + "-o 'Subsystem=sftp internal-sftp'", '-o ListenAddress=10.90.5.2', + '-o AuthenticationMethods=publickey', '-o PasswordAuthentication=no', + '-o DisableForwarding=yes', 'options=(-f /dev/null'): + self.assertIn(required, self.source) + self.assertIn('umask 077', self.source) + self.assertNotIn('chown mikec', self.source) + self.assertNotIn('AllowUsers=mikec', self.source) + for forbidden in ('systemctl', 'service ssh', 'useradd', 'usermod', 'passwd ', '/etc/ssh/sshd_config', + 'iptables', 'nft ', 'route add default', 'ip_forward'): + self.assertNotIn(forbidden, self.source) + + def test_cleanup_is_owned_namespace_inode_and_explicit_children_only_with_key_removal(self): + for required in ('^mcphone-', '"$duration" -ge 60', '"$duration" -le 1800', + 'Namespace already exists; refusing to reuse it.', + 'mktemp -d -- "$bench_dir/meshcore-phone.XXXXXXXX"', + 'trap cleanup EXIT', "trap 'exit 130' INT", "trap 'exit 143' TERM HUP", + '"$observed" == "$inode"', '"$parent" == "$$"', + 'for child in "$relay_pid" "$sshd_pid"', 'for _ in {1..20}', + 'rm -f -- "$run_dir/host_key" "$run_dir/client_key"'): + self.assertIn(required, self.source) + self.assertEqual(self.source.count('ip netns delete "$namespace"'), 1) + self.assertNotIn('pkill', self.source) + self.assertNotIn('killall', self.source) + self.assertNotIn('rm -rf', self.source) + self.assertNotIn('cat "$run_dir/client_key"', self.source) + self.assertNotIn('cat "$run_dir/host_key"', self.source) + self.assertIn('Server helper PID: %s', self.source) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_meshcore_usb_sftp_bench.py b/test/test_meshcore_usb_sftp_bench.py new file mode 100644 index 000000000..752e9baa9 --- /dev/null +++ b/test/test_meshcore_usb_sftp_bench.py @@ -0,0 +1,44 @@ +"""Non-hardware contract checks for the matched USB SFTP runner.""" +from pathlib import Path +import shutil +import subprocess +import unittest + +SCRIPT = Path(__file__).resolve().parents[1] / "tools/hil/meshcore_usb_sftp_bench.sh" + + +class UsbSftpBenchContract(unittest.TestCase): + def test_bash_syntax(self): + if not shutil.which("bash"): + self.skipTest("bash is unavailable") + # Parse stdin so Windows' WSL bash never interprets a C:\ path as a + # Linux filename (and no host-specific path conversion is required). + result = subprocess.run(["bash", "-n"], input=SCRIPT.read_text().encode("utf-8"), + capture_output=True, timeout=20) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_distinct_fixed_transports_and_real_mtu_check(self): + source = SCRIPT.read_text() + self.assertIn("mtu=156; interface=mcip0", source) + self.assertIn("mtu=230; interface=lora0", source) + self.assertIn("meshcore_ip_relay.py", source) + self.assertIn("ethernet_tap_relay.py", source) + self.assertIn("--ready-after-handshake", source) + self.assertIn("--kiss-port 1 --kiss-modem", source) + self.assertIn('[[ "$actual_mtu" == "$mtu" ]]', source) + self.assertIn('ip -n "$prefix" -j link show dev "$interface"', source) + + def test_cleanup_scope_and_no_host_route_or_firewall(self): + source = SCRIPT.read_text() + self.assertIn('[[ -n "$inode" && "$observed" == "$inode" ]]', source) + self.assertIn('[[ "$parent" == "$$" ]]', source) + self.assertIn('ip netns delete "$prefix"', source) + self.assertNotIn("chown -R", source) + self.assertNotIn("iptables", source) + self.assertNotIn("nft ", source) + self.assertNotIn("route add default", source) + self.assertNotIn("rm -rf", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_windows_usb_stdio_bridge.py b/test/test_windows_usb_stdio_bridge.py new file mode 100644 index 000000000..90c970537 --- /dev/null +++ b/test/test_windows_usb_stdio_bridge.py @@ -0,0 +1,207 @@ +"""Binary/identity/ownership tests; no USB hardware, namespaces or listeners.""" +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import time +from types import SimpleNamespace +import unittest +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/hil")) +import windows_usb_stdio_bridge as usb + + +SERIAL = "AABBCCDDEEFF" +IDENTITY = dict(port="COM6", serial=SERIAL, vid=0x303a, pid=0x1001) + + +def device_info(serial=SERIAL, port="COM6", vid=0x303a, pid=0x1001): + return SimpleNamespace(device=port, serial_number=serial, vid=vid, pid=pid) + + +class WindowsBridgeTests(unittest.TestCase): + def test_exact_endpoint_and_required_settings_fail_before_open(self): + self.assertEqual(usb.validate_windows_endpoint("winusb://com6").netloc, "com6") + for value in ("winusb://COM0", "winusb://COM6/", "winusb://COM6:2", "winusb://user@COM6", + "winusb://COM6?baud=1", "winusb://COM6#x", "winusb:///COM6", "tcp://COM6"): + with self.subTest(value=value), self.assertRaises(ValueError): + usb.validate_windows_endpoint(value) + for serial, vid, pid, duration in ((None, 1, 1, 5), ("bad\n", 1, 1, 5), (SERIAL, None, 1, 5), + (SERIAL, 1, 0, 5), (SERIAL, 1, 1, 0), (SERIAL, 1, 1, float("nan")), (SERIAL, 1, 1, 3601)): + with self.assertRaises(ValueError): + usb.validate_settings("COM6", serial, vid, pid, 115200, False, duration) + + def test_wrong_missing_ambiguous_usb_identity_never_opens(self): + for inventory in ([], [device_info("OTHER")], [device_info(vid=1)], [device_info(pid=2)], + [device_info(port="COM7")], [device_info(), device_info()]): + factory = mock.Mock() + with self.assertRaises(ValueError): + usb.open_selected_port("COM6", SERIAL, 0x303a, 0x1001, baud=115200, serial_dtr=False, + inventory=lambda: inventory, serial_factory=factory) + factory.assert_not_called() + + def test_safe_exclusive_open_checks_twice_and_selects_dtr_without_reset(self): + for dtr in (False, True): + device = mock.Mock() + inventory = mock.Mock(return_value=[device_info()]) + with mock.patch.object(usb, "open_configured_session") as opened: + result, identity = usb.open_selected_port("com6", SERIAL, 0x303a, 0x1001, + baud=115200, serial_dtr=dtr, inventory=inventory, serial_factory=lambda: device) + self.assertIs(result, device) + self.assertEqual(identity, dict(IDENTITY, baud=115200, serial_dtr=dtr)) + self.assertEqual(inventory.call_count, 2) + opened.assert_called_once_with(device) + self.assertEqual(device.port, "COM6") + self.assertIs(device.exclusive, True) + self.assertIs(device.dtr, dtr) + self.assertIs(device.rts, False) + self.assertEqual((device.timeout, device.write_timeout), (.05, .5)) + device.close.assert_not_called() + + def test_busy_or_swapped_identity_closes_only_owned_handle(self): + for swap in (False, True): + device = mock.Mock() + inventory = mock.Mock(side_effect=[[device_info()], [device_info("OTHER")]]) + with mock.patch.object(usb, "open_configured_session", side_effect=None if swap else OSError("busy")): + with self.assertRaises((ValueError, OSError)): + usb.open_selected_port("COM6", SERIAL, 0x303a, 0x1001, baud=115200, serial_dtr=True, + inventory=inventory, serial_factory=lambda: device) + device.close.assert_called_once() + + def test_duplex_short_writes_preserve_every_binary_byte(self): + payload = b"\0\xc0\xdb\xff\r\n" + bytes(range(256)) + written, output = bytearray(), bytearray() + output_done = threading.Event() + supplied = [False] + returned = [False] + device = mock.Mock() + device.in_waiting = 0 + + def host_read(fd, count): + if not supplied[0]: + supplied[0] = True + return payload + output_done.wait(1) + return b"" + + def serial_read(count): + if not returned[0] and bytes(written) == payload: + returned[0] = True + return payload + time.sleep(.001) + return b"" + + def serial_write(data): + written.extend(data[:3]) + return min(3, len(data)) + + def host_write(fd, data): + output.extend(data[:2]) + if len(output) == len(payload): + output_done.set() + return min(2, len(data)) + + device.write.side_effect, device.read.side_effect = serial_write, serial_read + stats = usb.pump_stdio(device, 11, 12, duration=2, read=host_read, write=host_write) + self.assertEqual(bytes(written), payload) + self.assertEqual(bytes(output), payload) + self.assertEqual(stats, dict(to_usb=len(payload), from_usb=len(payload), reason="stdin_eof")) + device.close.assert_called_once() + + def test_eof_and_deadline_close_usb_even_if_stdout_worker_is_blocked(self): + for eof in (False, True): + blocked, release = threading.Event(), threading.Event() + device = mock.Mock() + device.in_waiting = 4 + device.read.return_value = b"\0\xc0\xdb\xff" + + def read(fd, count): + blocked.wait(.2) + if not eof: + release.wait(1) + return b"" + + def write(fd, data): + blocked.set() + release.wait(1) + return len(data) + + started = time.monotonic() + try: + stats = usb.pump_stdio(device, 11, 12, duration=.05, read=read, write=write) + self.assertLess(time.monotonic() - started, .6) + device.close.assert_called_once() + self.assertEqual(stats["reason"], "stdin_eof" if eof else "deadline") + finally: + release.set() + + def test_parent_uses_absolute_trusted_argv_no_shell_and_distinct_nonblocking_fds(self): + with tempfile.TemporaryDirectory() as directory: + executable = Path(directory) / "python.exe" + executable.touch() + child = mock.Mock() + child.stdout.fileno.return_value, child.stdin.fileno.return_value = 11, 12 + identity = dict(IDENTITY, baud=115200, serial_dtr=True) + with mock.patch.object(usb.sys, "platform", "linux"), \ + mock.patch.object(usb.subprocess, "run", return_value=SimpleNamespace(stdout="C:\\git\\MeshCore\\tools\\hil\\windows_usb_stdio_bridge.py\n")), \ + mock.patch.object(usb.subprocess, "Popen", return_value=child) as popen, \ + mock.patch.object(usb, "_ready_record", return_value=dict(identity=identity)), \ + mock.patch.object(usb.os, "set_blocking") as nonblocking: + stream = usb.WindowsUsbStream("winusb://COM6", python=str(executable), expected_serial=SERIAL, + expected_vid=0x303a, expected_pid=0x1001, serial_dtr=True, duration=300) + self.assertEqual((stream.read_fd, stream.write_fd), (11, 12)) + self.assertEqual(stream.identity, identity) + argv = popen.call_args.args[0] + self.assertEqual(argv[:3], [str(executable), "-u", "C:\\git\\MeshCore\\tools\\hil\\windows_usb_stdio_bridge.py"]) + self.assertIn(SERIAL, argv) + self.assertIs(popen.call_args.kwargs["shell"], False) + self.assertEqual(nonblocking.call_args_list, [mock.call(11, False), mock.call(12, False)]) + stream.close() + child.stdin.close.assert_called_once() + child.wait.assert_called_once_with(timeout=2) + child.terminate.assert_not_called() + + def test_parent_rejects_changed_ready_identity_and_closes_owned_child(self): + with tempfile.TemporaryDirectory() as directory: + executable = Path(directory) / "python.exe" + executable.touch() + child = mock.Mock() + with mock.patch.object(usb.sys, "platform", "linux"), \ + mock.patch.object(usb.subprocess, "run", return_value=SimpleNamespace(stdout="C:\\trusted\\windows_usb_stdio_bridge.py")), \ + mock.patch.object(usb.subprocess, "Popen", return_value=child), \ + mock.patch.object(usb, "_ready_record", return_value=dict(identity=dict(IDENTITY, serial="OTHER"))): + with self.assertRaisesRegex(ValueError, "identity"): + usb.WindowsUsbStream("winusb://COM6", python=str(executable), expected_serial=SERIAL, + expected_vid=0x303a, expected_pid=0x1001) + child.stdin.close.assert_called_once() + child.wait.assert_called_once_with(timeout=2) + + def test_parent_close_eof_then_bounded_owned_process_cleanup(self): + stream = usb.WindowsUsbStream.__new__(usb.WindowsUsbStream) + child = mock.Mock() + child.wait.side_effect = [subprocess.TimeoutExpired("owned", 2), subprocess.TimeoutExpired("owned", 1), 0] + stream.process = child + stream.close() + stream.close() + child.stdin.close.assert_called_once() + child.terminate.assert_called_once() + child.kill.assert_called_once() + self.assertEqual(child.wait.call_args_list, [mock.call(timeout=2), mock.call(timeout=1), mock.call(timeout=1)]) + + def test_ready_status_bound_and_invalid_records_do_not_expose_child_stderr(self): + for data in (b"secret payload, not JSON\n", b'{"state":"error","error":"private"}\n', b"x" * 4097): + child = mock.Mock() + child.stderr.fileno.return_value = 11 + with mock.patch.object(usb.select, "select", return_value=([11], [], [])), \ + mock.patch.object(usb.os, "read", return_value=data): + with self.assertRaises(RuntimeError) as failure: + usb._ready_record(child) + self.assertNotIn("secret", str(failure.exception)) + self.assertNotIn("private", str(failure.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/hil/ethernet_bench_serial.py b/tools/hil/ethernet_bench_serial.py new file mode 100644 index 000000000..f24ec7111 --- /dev/null +++ b/tools/hil/ethernet_bench_serial.py @@ -0,0 +1,103 @@ +"""Identity-recorded USB CLI for the explicitly erasable Ethernet RF bench. + +No reset/erase/flash is performed. WiFi credentials are read only from a local +JSON file and never included in the printed command/response log. +""" +import argparse +import json +import os +from pathlib import Path +import time + +import serial +from serial.tools import list_ports + +from serial_session import (SerialCommandTimeout, assert_port_idle, + open_configured_session, read_command_reply) + + +def read_quiet(port, timeout=3): + until = time.monotonic() + timeout + # Wait the full response deadline for the first byte; silence at attach is + # not proof that a cooperative USB console has finished opening its session. + quiet = until + data = bytearray() + while time.monotonic() < min(until, quiet): + chunk = port.read(min(port.in_waiting or 1, 4096)) + if chunk: + data.extend(chunk) + quiet = time.monotonic() + 0.35 + return data.decode("utf-8", errors="replace") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", required=True) + parser.add_argument("--command", action="append", default=[]) + parser.add_argument("--wifi-json", type=Path) + parser.add_argument("--wait", type=float, default=10) + args = parser.parse_args() + matches = [p for p in list_ports.comports() + if os.path.realpath(p.device) == os.path.realpath(args.port)] + if len(matches) != 1: + raise SystemExit("Selected USB identity is absent or ambiguous") + info = matches[0] + secrets = [] + commands = list(args.command) + if args.wifi_json: + credentials = json.loads(args.wifi_json.read_text()) + ssid, password = credentials["ssid"], credentials["password"] + if not isinstance(ssid, str) or not isinstance(password, str) or any( + c in ssid + password for c in "\r\n\0"): + raise SystemExit("Invalid credential file") + secrets = [password] if password else [] + commands.extend(["set wifi.ssid " + ssid, "set wifi.pwd " + password, "set wifi on"]) + if args.wait <= 0 or any(not command or any(value in command for value in "\r\n\0") for command in commands): + raise SystemExit("Use single-line nonempty commands and a positive --wait") + + def redact(text): + for secret in secrets: + text = text.replace(secret, "[redacted]") + return text + + port = serial.Serial(port=None, baudrate=115200, timeout=0.04, exclusive=True) + port.port = info.device + port.rts = False + port.dtr = info.vid in (0x303A, 0x2886, 0x239A) + output = {"port": info.device, "usb_serial": info.serial_number, + "vid": info.vid, "pid": info.pid, "commands": []} + try: + assert_port_idle(info.device) + open_configured_session(port) + read_quiet(port, 0.5) + port.write(b"+++MESHCORE-TERM-START\n") + port.flush() + output["hello"] = redact(read_quiet(port)) + for command in commands: + port.write((command + "\n").encode()) + port.flush() + try: + reply = read_command_reply(port, command, args.wait) + except SerialCommandTimeout as error: + output["commands"].append({"command": redact(command), + "reply": redact(error.partial_reply), "completed": False}) + output["error"] = str(error) + break # Never misattribute this pending reply to another command. + output["commands"].append({"command": redact(command), "reply": redact(reply), "completed": True}) + finally: + if port.is_open: + try: + port.write(b"+++MESHCORE-TERM-STOP\n") + port.flush() + except Exception: + pass + try: + port.close() + except Exception: + pass + print(json.dumps(output), flush=True) + return 1 if "error" in output else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/hil/ethernet_kiss_modem_probe.py b/tools/hil/ethernet_kiss_modem_probe.py new file mode 100644 index 000000000..8dd91cae9 --- /dev/null +++ b/tools/hil/ethernet_kiss_modem_probe.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Configure an identity-bound full KISS modem for the Ethernet RF bench. + +NO RF DATA is sent. A compiled-in 909.5 MHz TX lock must be proved over the +HIL-only read-only command before any setter. R1 listens on the USA profile; +R2 uses raw Ethernet at 909.5/SF5/CR5, selected BW125/500 and preamble32..1024 +(defaults BW500/preamble512). Settings are session-only. +The USB port is closed before exit so a separate TAP/KISS relay may own it. +""" + +from __future__ import annotations + +import argparse +from collections import deque +import hashlib +import json +import os +import struct +import time + +from serial_session import assert_port_idle, open_configured_session + + +FEND, FESC, TFEND, TFESC = 0xC0, 0xDB, 0xDC, 0xDD +GUARD_HZ = 909_500_000 +PRIMARY = (910_525_000, 62_500, 7, 5) +SECONDARY = (GUARD_HZ, 500_000, 5, 5, 2, 512, 2) +PREAMBLES = (32, 64, 96, 128, 192, 256, 384, 512, 768, 1024) +PRIMARY_STRUCT = struct.Struct(" bytes: + if type(command) is not int or not 0 <= command <= 0x0F: + raise ValueError("Only port-zero KISS controls are permitted") + if command == 0: + raise ValueError("This configuration tool never sends RF DATA") + body = bytes((command,)) + payload + if len(body) > MAX_FRAME: + raise ValueError("KISS control exceeds the modem frame limit") + return bytes((FEND,)) + body.replace(b"\xdb", b"\xdb\xdd").replace( + b"\xc0", b"\xdb\xdc") + bytes((FEND,)) + + +class Decoder: + """Bounded full-frame decoder, including command byte and hardware replies.""" + + def __init__(self): + self.started = self.escaped = False + self.buffer = bytearray() + + def feed(self, data: bytes) -> list[bytes]: + result = [] + for value in data: + if value == FEND: + if self.escaped: + raise ProtocolError("Truncated KISS escape") + if self.started and self.buffer: + result.append(bytes(self.buffer)) + self.buffer.clear() + self.started = True + elif not self.started: + continue # Startup console text is not a KISS frame. + elif self.escaped: + self.escaped = False + if value not in (TFEND, TFESC): + raise ProtocolError("Invalid KISS escape") + self.buffer.append(FEND if value == TFEND else FESC) + elif value == FESC: + self.escaped = True + else: + self.buffer.append(value) + if len(self.buffer) > MAX_FRAME: + raise ProtocolError("Oversized KISS frame") + return result + + +class ControlLink: + def __init__(self, port, *, timeout=3.0, clock=time.monotonic): + if not 0 < timeout <= 10: + raise ValueError("Use a transaction timeout of at most ten seconds") + self.port, self.timeout, self.clock = port, timeout, clock + self.decoder = Decoder() + self.pending = deque() + self.ignored_data_frames = 0 + self.rx_metadata_frames = 0 + + def control(self, command: int, payload: bytes): + frame = encode_frame(command, payload) + if self.port.write(frame) != len(frame): + raise ProtocolError("Short serial write") + self.port.flush() + + def request(self, command: int, payload=b"", *, size: int, setter=False) -> bytes: + self.control(6, bytes((command,)) + payload) + expected = 0xF0 if setter else command | 0x80 + deadline = self.clock() + self.timeout + byte_count = frame_count = 0 + while self.clock() < deadline: + if not self.pending: + chunk = self.port.read(1024) + byte_count += len(chunk) + if byte_count > 65536: + raise ProtocolError("KISS transaction exceeded its byte limit") + self.pending.extend(self.decoder.feed(chunk)) + while self.pending: + frame = self.pending.popleft() + frame_count += 1 + if frame_count > 256: + raise ProtocolError("KISS transaction exceeded its frame limit") + if frame[0] in (0x00, 0x10): + self.ignored_data_frames += 1 + continue + if frame[0] != 6 or len(frame) < 2: + raise ProtocolError("Unexpected KISS control port or frame") + response, body = frame[1], frame[2:] + if response == 0xF1: + if len(body) != 1: + raise ProtocolError("Malformed hardware error") + raise ProtocolError(f"Hardware command 0x{command:02x} failed: 0x{body[0]:02x}") + if response == 0xF8: + if len(body) != 1 or body[0] not in (0, 1): + raise ProtocolError("Malformed TX_DONE notification") + raise ProtocolError("Unexpected TX_DONE: setup never sends RF DATA") + if response == 0xF9: + if len(body) != 2: + raise ProtocolError("Malformed RX metadata") + self.rx_metadata_frames += 1 + continue + if response != expected or len(body) != size: + raise ProtocolError(f"Unexpected response to hardware command 0x{command:02x}") + return body + raise ProtocolError(f"Timed out waiting for hardware command 0x{command:02x}") + + +def _stats(link: ControlLink) -> dict: + rx, tx, errors = struct.unpack(" dict: + observed = PRIMARY_STRUCT.unpack(link.request(0x0B, size=PRIMARY_STRUCT.size)) + # The driver stores MHz as float32; 910.525 becomes 910525024 whole Hz. + # No tolerance is allowed for bandwidth/SF/CR or the separate TX guard. + if abs(observed[0] - PRIMARY[0]) > 50 or observed[1:] != PRIMARY[1:]: + raise ProtocolError("Primary USA RX-profile readback does not match") + return dict(zip(("frequency_hz", "bandwidth_hz", "sf", "cr"), observed)) + + +def secondary_tuple(*, bandwidth=500, preamble=512): + if type(bandwidth) is not int or bandwidth not in (125, 500): + raise ValueError("Select bandwidth 125 or 500 kHz") + if type(preamble) is not int or preamble not in PREAMBLES: + raise ValueError("Select an explicit supported preamble from 32..1024 symbols") + return (GUARD_HZ, bandwidth * 1000, 5, 5, 2, preamble, 2) + + +def configure_modem(link: ControlLink, *, bandwidth=500, preamble=512) -> dict: + expected_secondary = secondary_tuple(bandwidth=bandwidth, preamble=preamble) + guard = struct.unpack(" int: + if len(data) % 2: + data += b"\0" + total = sum(struct.unpack("!" + "H" * (len(data) // 2), data)) + while total >> 16: + total = (total & 0xFFFF) + (total >> 16) + return ~total & 0xFFFF + + +def kiss_encode(frame: bytes, *, port: int = 0) -> bytes: + if type(port) is not int or port not in (0, 1): + raise ValueError("Select KISS data port 0 or 1") + return bytes((FEND, port << 4)) + frame.replace(b"\xdb", b"\xdb\xdd").replace( + b"\xc0", b"\xdb\xdc") + bytes((FEND,)) + + +class KissDecoder: + """Bounded streaming decoder: ignore control frames and reject bad escapes.""" + + def __init__(self, port: int = 0, control=None): + if type(port) is not int or port not in (0, 1): + raise ValueError("Select KISS data port 0 or 1") + self.port = port + self.control = control + self.buffer = bytearray() + self.started = self.escaped = self.discard = False + self.errors = 0 + + def feed(self, data: bytes) -> list[bytes]: + frames = [] + for byte in data: + if byte == FEND: + if self.started and self.buffer and not self.discard: + if self.escaped: + self.errors += 1 + elif self.buffer[0] == self.port << 4: + frames.append(bytes(self.buffer[1:])) + elif self.buffer[0] & 15 and self.control is not None: + self.control(self.buffer[0], bytes(self.buffer[1:])) + self.buffer.clear() + self.started = True + self.escaped = self.discard = False + elif not self.started or self.discard: + continue + elif self.escaped: + self.escaped = False + if byte not in (TFEND, TFESC): + self.errors += 1 + self.discard = True + else: + self.buffer.append(FEND if byte == TFEND else FESC) + elif byte == FESC: + self.escaped = True + else: + self.buffer.append(byte) + if len(self.buffer) > 256: + self.errors += 1 + self.discard = True + return frames + + +def make_frame(kind: str, size: int, sequence: int, reverse: bool = False) -> bytes: + """Produce independently identifiable, valid network test frames up to 255B.""" + source = bytes.fromhex("0200c0db0001") + destination = bytes.fromhex("0200c0db0002") + src_ip = ipaddress.ip_address("192.0.2.1").packed + dst_ip = ipaddress.ip_address("192.0.2.2").packed + if reverse: + source, destination = destination, source + src_ip, dst_ip = dst_ip, src_ip + if kind == "arp": + # Vary sender protocol address to distinguish every repeated ARP probe. + src_ip = bytes((192, 0, (sequence >> 8) & 255, sequence & 255)) + arp = struct.pack("!HHBBH", 1, 0x0800, 6, 4, 1) + arp += source + src_ip + b"\0" * 6 + dst_ip + return b"\xff" * 6 + source + b"\x08\x06" + arp + b"\0" * max(0, size - 42) + minimum = {"raw": 14, "udp": 42, "icmp": 42, "tcp": 54}[kind] + if not minimum <= size <= 255: + raise ValueError(f"{kind} frame requires {minimum}..255 bytes") + payload_size = size - minimum + seed = b"ETHK" + struct.pack("!I?", sequence, reverse) + bytes((FEND, FESC)) + payload = (seed + bytes(range(256))) * ((payload_size // 267) + 1) + payload = payload[:payload_size] + if kind == "raw": + # Even a minimum 14-byte frame must be unique across repetitions: + # otherwise a delayed duplicate could impersonate a later probe. + source = source[:2] + struct.pack("!I", sequence) + return destination + source + b"\x88\xb5" + payload + protocol = {"udp": 17, "tcp": 6, "icmp": 1}[kind] + if kind == "udp": + transport = struct.pack("!HHHH", 49152, 22, 8 + len(payload), 0) + payload + elif kind == "tcp": + transport = struct.pack("!HHIIHHHH", 49152, 22, sequence, 0, 0x5002, 4096, 0, 0) + payload + else: + transport = struct.pack("!BBHHH", 8, 0, 0, 0xC0DB, sequence & 0xFFFF) + payload + pseudo = src_ip + dst_ip + struct.pack("!BBH", 0, protocol, len(transport)) + check = checksum(transport if kind == "icmp" else pseudo + transport) + offset = {"udp": 6, "tcp": 16, "icmp": 2}[kind] + # UDP checksum zero means disabled; wire representation of computed zero is FFFF. + if kind == "udp" and not check: + check = 0xFFFF + transport = transport[:offset] + struct.pack("!H", check) + transport[offset + 2:] + header = struct.pack("!BBHHHBBH4s4s", 0x45, 0, 20 + len(transport), sequence & 0xFFFF, + 0x4000, 64, protocol, 0, src_ip, dst_ip) + header = header[:10] + struct.pack("!H", checksum(header)) + header[12:] + return destination + source + b"\x08\x00" + header + transport + + +CASES = (("raw", 14), ("arp", 60), ("icmp", 60), ("udp", 64), ("tcp", 74), + ("udp", 244), ("raw", 255), ("tcp", 255)) + + +class KissEndpoint: + def __init__(self, url: str, baud: int = 115200): + self.url = url + self.decoder = KissDecoder() + self.received = queue.Queue(maxsize=256) + self.overflow = 0 + self.error = None + self.closed = threading.Event() + self.serial = None + self.socket = None + parsed = urlparse(url) + if parsed.scheme == "tcp": + self.socket = socket.create_connection((parsed.hostname, parsed.port or 5003), timeout=5) + self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + self.socket.settimeout(0.1) + elif parsed.scheme == "serial": + import serial # Optional: pure TCP/--plan needs only Python stdlib. + port = (parsed.netloc + parsed.path) if parsed.netloc else parsed.path + if port.startswith("/COM"): + port = port[1:] + self.serial = serial.Serial() + self.serial.baudrate = baud + self.serial.timeout = self.serial.write_timeout = 0.1 + self.serial.dtr = self.serial.rts = False + self.serial.port = port + assert_port_idle(port) + open_configured_session(self.serial) + else: + raise ValueError("endpoint must be tcp://host:port or serial:///device") + self.thread = threading.Thread(target=self._read, daemon=True) + self.thread.start() + + def _read(self): + try: + while not self.closed.is_set(): + try: + data = self.socket.recv(1024) if self.socket else self.serial.read(1024) + except socket.timeout: + continue + if not data and self.socket: + raise ConnectionError("KISS TCP peer disconnected") + for frame in self.decoder.feed(data): + try: + self.received.put_nowait((frame, time.monotonic())) + except queue.Full: + self.overflow += 1 + except Exception as error: + if not self.closed.is_set(): + self.error = str(error) + + def send(self, frame: bytes, fragment: int = 0): + encoded = kiss_encode(frame) + step = fragment or len(encoded) + for at in range(0, len(encoded), step): + chunk = encoded[at:at + step] + if self.socket: + self.socket.sendall(chunk) + elif self.serial.write(chunk) != len(chunk): + raise IOError("short serial write") + + def receive(self, timeout: float): + if self.error: + raise ConnectionError(self.error) + try: + return self.received.get(timeout=timeout) + except queue.Empty: + return None + + def close(self): + self.closed.set() + if self.socket: + self.socket.close() + if self.serial: + self.serial.close() + self.thread.join(timeout=1) + + +def run_probe(a, b, *, count=3, timeout=3.0, interval=0.05, duplex=False, fragment=0): + report = {"started_utc": datetime.now(timezone.utc).isoformat(), + "baseline": "909.5 MHz / 500 kHz / SF5 / CR5 (operator configured; not verified)", + "duplex_stress": duplex, "retries": 0, "transfers": [], "unexpected": []} + endpoints = {"a": a, "b": b} + sequence = 0 + for repetition in range(count): + for kind, size in CASES: + pending = [] + for source, destination in (("a", "b"), ("b", "a")): + sequence += 1 + frame = make_frame(kind, size, sequence, source == "b") + item = {"source": source, "destination": destination, "repetition": repetition, + "kind": kind, "size": len(frame), "sequence": sequence, + "sha256": hashlib.sha256(frame).hexdigest(), "received": False, + "sent_hex": frame.hex()} + sent = time.monotonic() + endpoints[source].send(frame, fragment) + pending.append((item, frame, sent)) + if not duplex: + _collect(endpoints, pending, timeout, report) + pending = [] + time.sleep(interval) + if pending: + _collect(endpoints, pending, timeout, report) + time.sleep(interval) + counts = Counter() + for item in report["transfers"]: + direction = item["source"] + "_to_" + item["destination"] + counts[direction + "_attempted"] += 1 + counts[direction + "_received"] += bool(item["received"]) + report["counts"] = dict(counts) + report["passed"] = all(item["received"] for item in report["transfers"]) + report["note"] = "Exact Ethernet bytes verified; this is not an established TCP/SSH session." + return report + + +def _collect(endpoints, pending, timeout, report): + deadline = max(item[2] for item in pending) + timeout + while any(not item[0]["received"] for item in pending) and time.monotonic() < deadline: + for name, endpoint in endpoints.items(): + received = endpoint.receive(min(0.01, max(0, deadline - time.monotonic()))) + if received is None: + continue + frame, timestamp = received + match = next((item for item in pending + if item[0]["destination"] == name and item[1] == frame + and not item[0]["received"]), None) + if match: + match[0].update(received=True, received_hex=frame.hex(), + latency_ms=round((timestamp - match[2]) * 1000, 3)) + else: + report["unexpected"].append({"at": name, "size": len(frame), "hex": frame.hex()}) + report["transfers"].extend(item[0] for item in pending) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--a", help="first KISS TCP or serial endpoint") + parser.add_argument("--b", help="second KISS TCP or serial endpoint") + parser.add_argument("--baud", type=int, default=115200) + parser.add_argument("--count", type=int, default=3, help="repetitions per frame type and direction") + parser.add_argument("--timeout", type=float, default=3.0) + parser.add_argument("--interval", type=float, default=0.05) + parser.add_argument("--fragment", type=int, default=0, help="split host KISS writes into N-byte chunks") + parser.add_argument("--duplex", action="store_true", help="simultaneous TX collision stress test") + parser.add_argument("--report", help="save complete byte-exact JSON evidence") + parser.add_argument("--plan", action="store_true", help="show cases without opening any endpoint") + args = parser.parse_args(argv) + if args.count <= 0 or args.timeout <= 0 or args.interval < 0 or args.fragment < 0: + parser.error("count/timeout must be positive; interval/fragment must be nonnegative") + if args.plan: + print(json.dumps({"cases": CASES, "frames_each_direction": args.count * len(CASES), + "radio_settings": "909.5 MHz / 500 kHz / SF5 / CR5; manually verified"}, indent=2)) + return 0 + if not args.a or not args.b: + parser.error("both --a and --b are required, unless using --plan") + if args.a == args.b: + parser.error("--a and --b must be different endpoints") + a = b = None + try: + a = KissEndpoint(args.a, args.baud) + b = KissEndpoint(args.b, args.baud) + result = run_probe(a, b, count=args.count, timeout=args.timeout, interval=args.interval, + duplex=args.duplex, fragment=args.fragment) + result["endpoints"] = {"a": args.a, "b": args.b} + result["host_diagnostics"] = { + name: {"decode_errors": endpoint.decoder.errors, "queue_overflows": endpoint.overflow} + for name, endpoint in (("a", a), ("b", b))} + result["passed"] = result["passed"] and not any( + value for diag in result["host_diagnostics"].values() for value in diag.values()) + output = json.dumps(result, indent=2) + if args.report: + with open(args.report, "w", encoding="utf-8") as destination: + destination.write(output + "\n") + print(output) + return 0 if result["passed"] else 1 + finally: + if b: + b.close() + if a: + a.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/hil/ethernet_preamble_bench.py b/tools/hil/ethernet_preamble_bench.py new file mode 100644 index 000000000..4b43bf869 --- /dev/null +++ b/tools/hil/ethernet_preamble_bench.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +"""Measure real SSH payload speed versus R2 preamble, never nominal bitrate. + +R2 is fixed to 909.5/CR5, with explicit BW125 or500 (default500) and SF5..9 +(default SF5); R1 must already be USA receive-only under +the immutable HIL TX lock. This tool never changes R1, WiFi, keys or TX power. +The runner owns its KISS/TAP/SSH resources and must close them before returning; +it receives one JSON request on stdin and returns one JSON object on stdout. +LORA_SSH_BENCH_CONFIG is inherited as a local configuration-file path. No +automatic retries: explicitly repeat a preamble or use --runs for replication. +""" +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +from decimal import Decimal +import json +import math +import os +from pathlib import Path +import random +import re +import subprocess +import sys +import tempfile +import time + +from ethernet_primary_probe import SerialNode +from ethernet_ssh_serial import SshSerialNode, ssh_command +from ethernet_radio2_sweep import (GUARD, PRIMARY, data_lines, primary_tx, + radio_tuple, require_ok, safety_snapshot) + +DEFAULT_PREAMBLES = (128, 192, 256, 384, 512, 768) + + +def validate_preambles(values): + values = list(values) + if not values or len(values) > 120 or any(type(n) is not int or not 32 <= n <= 2048 for n in values): + raise ValueError("Select 1..120 explicit preambles, each 32..2048 symbols") + return values + + +def validate_sf(sf): + if type(sf) is not int or sf not in range(5, 10): + raise ValueError("Select a fixed spreading factor from 5..9") + return sf + + +def validate_bandwidth(bandwidth): + if type(bandwidth) is not int or bandwidth not in (125, 500): + raise ValueError("Select a fixed bandwidth of 125 or 500 kHz") + return bandwidth + + +def paired_counters(reply, field): + matches = re.findall(r"(?:^|\s)" + field + r"=(\d+),(\d+)(?=\s|;|$)", + "\n".join(data_lines(reply))) + return list(map(int, matches[0])) if len(matches) == 1 else None + + +def ethernet_counters(reply): + matches = re.findall(r"\bRX=(\d+) TX=(\d+) fail=(\d+)(?=\s|;|$)", + "\n".join(data_lines(reply))) + return dict(zip(("rx", "tx", "fail"), map(int, matches[0]))) if len(matches) == 1 else None + + +def snapshot(nodes, timeout, preamble=None, *, sf=5, bandwidth=500): + validate_sf(sf) + validate_bandwidth(bandwidth) + states = safety_snapshot(nodes, timeout) + for name, node in nodes.items(): + state = states[name] + for key, command in (("status", "get radio2.status"), ("ethernet", "get ethernet"), + ("ethernet_radio", "get ethernet.radio"), + ("timing", "get radio2.timing"), ("radio_diag", "stats-radio-diag")): + state[key] = node.command(command, timeout=timeout) + if primary_tx(state["status"]) != 0: + raise RuntimeError("Primary TX counter is nonzero; aborting all remaining cases") + if preamble is not None: + if radio_tuple(state["ethernet_radio"]) != (Decimal("909.5"), Decimal(bandwidth), sf, 5, preamble): + raise RuntimeError(f"Ethernet readback does not match 909.5,{bandwidth},{sf},5 and selected preamble") + if "ethernet=on; active (RX time-shared)" not in state["ethernet"]: + raise RuntimeError("Ethernet R2 is not active on station WiFi") + state["counters"] = dict(primary_tx=0, profile_tx=paired_counters(state["status"], "TX"), + profile_rx=paired_counters(state["status"], "RX"), + ethernet=ethernet_counters(state["ethernet"])) + return states + + +def counter_delta(before, after): + """Unknown/reset counters stay unknown, never fabricated as zero loss.""" + if isinstance(before, dict) and isinstance(after, dict): + return {key: counter_delta(value, after.get(key)) for key, value in before.items()} + if isinstance(before, list) and isinstance(after, list) and len(before) == len(after): + return [counter_delta(a, b) for a, b in zip(before, after)] + if type(before) is int and type(after) is int and after >= before: + return after - before + return None + + +def number(value): + return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) + + +def payload_metrics(result): + verified = result.get("verified") is True + total_bytes = total_seconds = 0 + directions = {} + for name in ("downlink", "uplink"): + value = result.get(name) + metrics = None + if isinstance(value, dict): + size = value.get("bytes") + seconds = value.get("elapsed_seconds") + if seconds is None and number(value.get("elapsed_ms")): + seconds = value["elapsed_ms"] / 1000 + if type(size) is int and size > 0 and number(seconds) and seconds > 0: + metrics = dict(bytes=size, elapsed_seconds=seconds, goodput_kbps=size * 8 / seconds / 1000) + total_bytes += size + total_seconds += seconds + if value.get("hash_verified") is False: + verified = False + directions[name] = metrics + # A boolean alone is not byte/time evidence; require both verified hashes + # and actual payload durations before claiming a successful benchmark. + verified = verified and all(directions.values()) and all( + isinstance(result.get(name), dict) and result[name].get("hash_verified") is True + for name in ("downlink", "uplink")) + combined = total_bytes * 8 / total_seconds / 1000 if all(directions.values()) else None + total_ms = result.get("total_ms") + end_to_end = total_bytes * 8 / total_ms if combined is not None and number(total_ms) and total_ms > 0 else None + return dict(verified=bool(verified), directions=directions, combined_payload_kbps=combined, + end_to_end_kbps=end_to_end, handshake_ms=result.get("handshake_ms"), + tcp_client=result.get("tcp_client"), tcp_server=result.get("tcp_server"), + definition="Combined verified SSH payload bytes / sum of downlink and uplink durations; excludes handshake. End-to-end uses runner total_ms.") + + +def python_runner(path, timeout=240): + path = Path(path) + if not path.is_absolute() or not path.is_file() or path.suffix.lower() != ".py": + raise ValueError("--runner must be an existing absolute local Python script") + if not number(timeout) or not 1 <= timeout <= 900: + raise ValueError("runner timeout must be finite, 1..900 seconds") + + def run(request): + completed = subprocess.run([sys.executable, str(path)], input=json.dumps(request), + text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE, + stderr=subprocess.PIPE, timeout=timeout, shell=False, check=False) + # Stdout is a machine-readable, single JSON object. Do not copy stderr + # into public reports: a misconfigured external SSH tool may log secrets. + result = json.loads(completed.stdout) + if not isinstance(result, dict) or type(result.get("verified")) is not bool: + raise ValueError("Runner must return one JSON object with boolean verified") + if completed.returncode: + result["verified"] = False + result["exit_code"] = completed.returncode + result.setdefault("error", "Runner exited unsuccessfully") + return result + return run + + +def run_cases(nodes, preambles, runner, *, sf=5, bandwidth=500, command_timeout=12, + persist=lambda report: None, progress=lambda event: None): + preambles = validate_preambles(preambles) + sf = validate_sf(sf) + bandwidth = validate_bandwidth(bandwidth) + if len(nodes) != 2 or len({id(node) for node in nodes.values()}) != 2: + raise ValueError("Exactly two distinct CLI nodes are required") + report = dict(started_utc=datetime.now(timezone.utc).isoformat(), cases=[], aborted=False, + primary_rx_only=list(map(str, PRIMARY)), required_guard=GUARD, + usb={name: node.identity for name, node in nodes.items()}, retries=0, + sf=sf, bandwidth_khz=bandwidth, + note=f"Only R2 transmits, at 909.5/{bandwidth}/SF{sf}/CR5. No claim of actual USA receive reliability.") + + def checkpoint(item, phase): + item["phase"] = phase + persist(report) + progress(dict(index=item["index"], preamble=item["preamble"], sf=sf, bandwidth_khz=bandwidth, phase=phase, + verified=item.get("verified", False), error=item.get("error"))) + + try: + report["initial_safety"] = snapshot(nodes, command_timeout, sf=sf, bandwidth=bandwidth) + for index, preamble in enumerate(preambles, 1): + item = dict(index=index, case_id=f"case-{index:03d}-p{preamble}", preamble=preamble, sf=sf, + bandwidth_khz=bandwidth, verified=False, preamble_ms=preamble * (2 ** sf) / bandwidth) + report["cases"].append(item) + checkpoint(item, "checking") + item["before_configuration"] = snapshot(nodes, command_timeout, sf=sf, bandwidth=bandwidth) + for node in nodes.values(): + for command in ("set ethernet off", f"set ethernet.radio 909.5,{bandwidth},{sf},5,{preamble}", + "set ethernet on"): + require_ok(node.command(command, timeout=command_timeout)) + item["before"] = snapshot(nodes, command_timeout, preamble, sf=sf, bandwidth=bandwidth) + request = dict(index=index, case_id=item["case_id"], preamble=preamble, + frequency_mhz=909.5, bandwidth_khz=bandwidth, sf=sf, cr=5) + item["request"] = request + checkpoint(item, "running") + started = time.monotonic() + runner_failed = False + try: + result = runner(request) + if not isinstance(result, dict) or type(result.get("verified")) is not bool: + raise ValueError("Runner did not return boolean verified") + item["runner"] = result + item["metrics"] = payload_metrics(result) + item["verified"] = item["metrics"]["verified"] + if not item["verified"]: + item["error"] = str(result.get("error", "Payload integrity/SSH run was not verified")) + runner_failed = True + except Exception as error: + runner_failed = True + item["error"] = ("Runner timed out" if isinstance(error, subprocess.TimeoutExpired) + else f"Runner failed: {type(error).__name__}") + item["runner_wall_seconds"] = time.monotonic() - started + # Timeout kills the owned runner. Explicit off also invalidates its + # KISS clients before another tuple is configured; no reconnects. + if runner_failed: + for node in nodes.values(): + require_ok(node.command("set ethernet off", timeout=command_timeout)) + item["after"] = snapshot(nodes, command_timeout, None if runner_failed else preamble, sf=sf, bandwidth=bandwidth) + item["counter_deltas"] = {name: counter_delta(item["before"][name]["counters"], + item["after"][name]["counters"]) for name in nodes} + checkpoint(item, "complete") + except Exception as error: + report["aborted"] = True + report["error"] = str(error) + if report["cases"]: + checkpoint(report["cases"][-1], "aborted") + report["completed"] = not report["aborted"] + eligible = [item for item in report["cases"] if item.get("phase") == "complete" + and item["verified"] and item.get("metrics", {}).get("combined_payload_kbps") is not None] + report["ranking"] = [dict(index=item["index"], preamble=item["preamble"], sf=item["sf"], + bandwidth_khz=item["bandwidth_khz"], + combined_payload_kbps=item["metrics"]["combined_payload_kbps"], + downlink_kbps=item["metrics"]["directions"]["downlink"]["goodput_kbps"], + end_to_end_kbps=item["metrics"]["end_to_end_kbps"]) + for item in sorted(eligible, key=lambda value: value["metrics"]["combined_payload_kbps"], reverse=True)] + report["passed"] = report["completed"] and all(item["verified"] for item in report["cases"]) + report["commands"] = {name: node.commands for name, node in nodes.items()} + persist(report) + return report + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("a-port", "b-port", "a-ssh", "a-key", "a-helper"): + parser.add_argument("--" + name) + parser.add_argument("--preambles", default=",".join(map(str, DEFAULT_PREAMBLES))) + parser.add_argument("--sf", type=int, choices=range(5, 10), default=5, + help="fixed spreading factor (5..9), never changes R1") + parser.add_argument("--bandwidth", type=int, choices=(125, 500), default=500, + help="fixed R2 bandwidth in kHz (125 or 500), never changes R1") + parser.add_argument("--runs", type=int, default=1, help="explicit repetitions, no hidden retry") + parser.add_argument("--seed", type=int, help="optional reproducible shuffle of each run") + parser.add_argument("--runner", type=Path, help="absolute local Python runner; JSON stdin/stdout") + parser.add_argument("--runner-timeout", type=float, default=240) + parser.add_argument("--command-timeout", type=float, default=12) + parser.add_argument("--report-dir", type=Path) + parser.add_argument("--plan", action="store_true", help="no port/socket/process/report I/O") + args = parser.parse_args(argv) + try: + selected = validate_preambles([int(value) for value in args.preambles.split(",")]) + if not 1 <= args.runs <= 10 or len(selected) * args.runs > 120: + raise ValueError("runs 1..10 and at most 120 total cases required") + if not number(args.command_timeout) or not 0 < args.command_timeout <= 30: + raise ValueError("command timeout must be finite, (0,30] seconds") + if not number(args.runner_timeout) or not 1 <= args.runner_timeout <= 900: + raise ValueError("runner timeout must be finite, 1..900 seconds") + except ValueError as error: + parser.error(str(error)) + rng = random.Random(args.seed) + preambles = [] + for _ in range(args.runs): + one_run = selected.copy() + if args.seed is not None: + rng.shuffle(one_run) + preambles.extend(one_run) + if args.plan: + print(json.dumps(dict(preambles=preambles, sf=args.sf, bandwidth_khz=args.bandwidth, + radio=f"909.5,{args.bandwidth},{args.sf},5", primary_rx_only="910.525,62.5,7,5", + required_guard=GUARD, runner_timeout=args.runner_timeout), indent=2)) + return 0 + if not args.a_port or not args.b_port or not args.report_dir or not args.report_dir.is_absolute(): + parser.error("both CLI ports and absolute --report-dir required") + try: + if any((args.a_ssh, args.a_key, args.a_helper)): + if not all((args.a_ssh, args.a_key, args.a_helper)): + raise ValueError("--a-ssh, --a-key, --a-helper must be supplied together") + ssh_command(args.a_ssh, args.a_key, args.a_helper, args.a_port) + elif os.path.realpath(args.a_port) == os.path.realpath(args.b_port): + raise ValueError("CLI ports must be distinct") + runner = python_runner(args.runner, args.runner_timeout) + except (TypeError, ValueError) as error: + parser.error(str(error)) + args.report_dir.mkdir(parents=True, exist_ok=True) + folder = Path(tempfile.mkdtemp(prefix="ethernet-preamble-", dir=args.report_dir)) + + def persist(report): + for name, value in [("summary", report)] + [(item["case_id"], item) for item in report["cases"]]: + temporary = folder / (name + ".json.tmp") + temporary.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + temporary.replace(folder / (name + ".json")) + + a = b = None + try: + a = (SshSerialNode(args.a_ssh, args.a_key, args.a_helper, args.a_port) + if args.a_ssh else SerialNode(args.a_port)) + b = SerialNode(args.b_port) + report = run_cases(dict(a=a, b=b), preambles, runner, sf=args.sf, bandwidth=args.bandwidth, + command_timeout=args.command_timeout, + persist=persist, progress=lambda event: print(json.dumps(event), flush=True)) + except Exception as error: + report = dict(passed=False, aborted=True, cases=[], error=str(error)) + persist(report) + finally: + for node in (b, a): + if node: + node.close() + print(json.dumps(dict(report=str(folder / "summary.json"), passed=report["passed"], + aborted=report["aborted"], ranking=report.get("ranking", [])))) + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/hil/ethernet_preamble_ssh_runner.py b/tools/hil/ethernet_preamble_ssh_runner.py new file mode 100644 index 000000000..4ddfb070f --- /dev/null +++ b/tools/hil/ethernet_preamble_ssh_runner.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +"""One guarded-orchestrator case: real Flutter SSH through two isolated TAPs. + +Invoked by ethernet_preamble_bench.py, not directly with firmware settings. +JSON configuration path: LORA_SSH_BENCH_CONFIG. JSON request: stdin; result: +stdout only. Keys are copied as files, never read/printed here, and removed in +finally. Only uniquely named helper resources are stopped; host networking, +system sshd, accounts and firmware are never configured by this runner. +""" +from __future__ import annotations + +import ipaddress +import json +import math +import os +from pathlib import Path, PurePosixPath, PureWindowsPath +import queue +import re +import secrets +import shlex +import subprocess +import sys +import tempfile +import threading +import time +from urllib.parse import urlparse + +from ethernet_tcp_stats import parse_tcp, summarize + +ARTIFACTS = ("speed.json", "tcp-stats.json", "tcp-before.snmp", "tcp-after.snmp", + "tcp-before.utc", "tcp-after.utc", "tcp-ss.txt", "relay.json", + "result.txt", "routes.txt", "flutter.txt") + + +def posix_path(value): + if not isinstance(value, str) or not value.startswith("/") or value == "/" \ + or ".." in PurePosixPath(value).parts or any(ord(c) < 32 for c in value): + raise ValueError("An explicit absolute non-root Linux path is required") + return str(PurePosixPath(value)) + + +def wsl_path(value): + value = str(value) + path = PureWindowsPath(value) + if re.fullmatch(r"[A-Za-z]:", path.drive) and path.is_absolute(): + return "/mnt/" + path.drive[0].lower() + "/" + "/".join(path.parts[1:]) + return posix_path(value) # Supports running host-only tests under WSL too. + + +def endpoint(value): + parsed = urlparse(value) + host = ipaddress.IPv4Address(parsed.hostname) + private = any(host in ipaddress.IPv4Network(network) for network in + ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")) + if parsed.scheme != "tcp" or parsed.port != 5003 or not private \ + or parsed.username or parsed.password or parsed.path not in ("", "/") \ + or parsed.query or parsed.fragment: + raise ValueError("Use an explicit private numeric IPv4 TCP port-5003 endpoint") + return value + + +def validate_config(value): + required = ("ssh_host", "ssh_key", "pi_bench", "server_endpoint", "client_endpoint", + "wsl_workspace", "flutter", "account", "out_root") + if not isinstance(value, dict) or any(not isinstance(value.get(key), str) for key in required): + raise ValueError("Required benchmark configuration fields are missing") + config = {key: value[key] for key in required} + if not re.fullmatch(r"(?:[A-Za-z0-9_][A-Za-z0-9_.-]*@)?[A-Za-z0-9_][A-Za-z0-9_.-]*", config["ssh_host"]): + raise ValueError("Invalid SSH host") + if not re.fullmatch(r"[a-z_][a-z0-9_-]*", config["account"]) or config["account"] == "root": + raise ValueError("An unprivileged WSL test account is required") + for key in ("pi_bench", "wsl_workspace", "flutter"): + config[key] = posix_path(config[key]) + for key in ("ssh_key", "out_root"): + path = Path(config[key]) + if not path.is_absolute() or any(ord(c) < 32 for c in config[key]): + raise ValueError("Explicit absolute local paths are required") + if key == "ssh_key" and not path.is_file(): + raise ValueError("The selected local SSH key file is unavailable") + config[key] = str(path) + endpoint(config["server_endpoint"]); endpoint(config["client_endpoint"]) + if config["server_endpoint"] == config["client_endpoint"]: + raise ValueError("Two distinct radios are required") + return config + + +def validate_request(value): + if not isinstance(value, dict) or type(value.get("index")) is not int or not 1 <= value["index"] <= 120 \ + or type(value.get("preamble")) is not int or not 32 <= value["preamble"] <= 2048: + raise ValueError("Invalid preamble case") + if type(value.get("frequency_mhz")) is not float or value["frequency_mhz"] != 909.5 \ + or type(value.get("bandwidth_khz")) is not int or value["bandwidth_khz"] not in (125, 500) \ + or type(value.get("sf")) is not int or not 5 <= value["sf"] <= 9 \ + or type(value.get("cr")) is not int or value["cr"] != 5: + raise ValueError("Only the orchestrator's 909.5/125-or-500/SF5-SF9/CR5 cases are permitted") + expected = f"case-{value['index']:03d}-p{value['preamble']}" + if value.get("case_id") != expected: + raise ValueError("Invalid case identifier") + return value + + +def ssh_args(config): + return ["ssh", "-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", + "-o", "ConnectTimeout=8", "-i", config["ssh_key"], "--", config["ssh_host"]] + + +def remote_args(config, command): + return ssh_args(config) + [shlex.join(command)] + + +def artifact_dir(text, parent, stem): + matches = re.findall(r"^" + re.escape(stem) + r": (.+)$", text, re.MULTILINE) + if len(matches) != 1: + raise ValueError("Missing or ambiguous helper artifact directory") + path = PurePosixPath(posix_path(matches[0].strip())) + expected = "ethernet-phone" if stem == "Artifacts" else "ethernet-flutter" + if path.parent != PurePosixPath(parent) or not re.fullmatch(expected + r"\.[A-Za-z0-9]{8}", path.name): + raise ValueError("Helper artifacts escaped the explicitly selected bench directory") + return str(path) + + +def parse_server_ready(text, config): + if sum(line.startswith("READY: ") for line in text.splitlines()) != 1 \ + or f"READY: gateway={config['server_endpoint']} local=10.90.5.1 remote=10.90.5.2 SSH=2222 user=mikec" not in text.splitlines(): + raise ValueError("Server helper did not confirm the exact isolated endpoint") + fingerprints = re.findall(r"\bSHA256:[A-Za-z0-9+/]{43}(?=\s|$)", text) + pids = re.findall(r"^Server helper PID: ([1-9][0-9]{0,9})$", text, re.MULTILINE) + if len(fingerprints) != 1 or len(pids) != 1 or int(pids[0]) <= 1: + raise ValueError("Missing or ambiguous server fingerprint/PID") + return dict(pid=int(pids[0]), fingerprint=fingerprints[0], + directory=artifact_dir(text, config["pi_bench"], "Artifacts")) + + +def read_ready(process, config, timeout): + lines = queue.Queue(maxsize=128) + + def read(): + try: + while True: + line = process.stdout.readline(16385) + if len(line) > 16384: + raise ValueError("Oversized helper startup line") + lines.put_nowait(line) + if not line: + break + except Exception: + try: + lines.put_nowait(None) + except queue.Full: + pass + + threading.Thread(target=read, daemon=True).start() + observed = "" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + line = lines.get(timeout=max(.001, deadline - time.monotonic())) + except queue.Empty: + break + if not line: + raise RuntimeError("Server helper ended before readiness") + observed += line + if len(observed) > 65536: + raise RuntimeError("Oversized helper startup output") + pids = re.findall(r"^Server helper PID: ([1-9][0-9]{0,9})$", observed, re.MULTILINE) + if len(pids) == 1 and int(pids[0]) > 1: + # Retain identity for finally even if another readiness field fails. + process.helper_pid = int(pids[0]) + if "Artifacts: " in observed and "Server helper PID: " in observed: + return parse_server_ready(observed, config) + raise TimeoutError("Server helper readiness deadline exceeded") + + +def normalized_speed(speed, helper_exit_code): + def finite(value): + return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) + + directions = {} + for source, target in (("download", "downlink"), ("upload", "uplink")): + item = speed.get(source) + normalized = None + if isinstance(item, dict) and type(item.get("bytes")) is int and item["bytes"] > 0 \ + and finite(item.get("elapsed_ms")) and item["elapsed_ms"] > 0: + normalized = dict(bytes=item["bytes"], elapsed_ms=item["elapsed_ms"], + elapsed_seconds=item["elapsed_ms"] / 1000, + kbps=item["bytes"] * 8 / item["elapsed_ms"], hash_verified=item.get("sha256_valid") is True) + for key in ("data_elapsed_ms", "data_kbps", "first_byte_ms", "ready_ms", "command_elapsed_ms"): + if key in item: + normalized[key] = item[key] + directions[target] = normalized + verified = (helper_exit_code == 0 and speed.get("success") is True and speed.get("host_verified") is True + and all(value and value["hash_verified"] for value in directions.values())) + return dict(verified=bool(verified), handshake_ms=speed.get("handshake_ms"), + total_ms=speed.get("total_elapsed_ms"), **directions) + + +def stop_server_args(config, pid, prefix): + if type(pid) is not int or pid <= 1 or not re.fullmatch(r"ethphone-speed-[a-f0-9]{8}", prefix): + raise ValueError("Invalid owned helper identity") + # Match whole argv tokens, including the exact generated prefix. A recycled + # PID or unrelated helper can never satisfy this check by substring alone. + program = """import os,signal,sys +pid=int(sys.argv[1]); script=sys.argv[2]; prefix=sys.argv[3] +try: + args=open('/proc/%d/cmdline'%pid,'rb').read().decode().strip('\\0').split('\\0') +except FileNotFoundError: + raise SystemExit(0) +if script not in args or '--prefix' not in args or args[args.index('--prefix')+1] != prefix: + raise SystemExit(3) +os.kill(pid,signal.SIGTERM) +""" + return remote_args(config, ["sudo", "-n", "python3", "-c", program, str(pid), + config["pi_bench"] + "/ethernet_ssh_phone_bench.sh", prefix]) + + +def execute(config, request): + config = validate_config(config); request = validate_request(request) + root = Path(config["out_root"]); root.mkdir(parents=True, exist_ok=True) + folder = Path(tempfile.mkdtemp(prefix=request["case_id"] + "-", dir=root)) + token = secrets.token_hex(4) + server_prefix, client_prefix = "ethphone-speed-" + token, "ethflutter-speed-" + token + windows_key = folder / "client_key.ephemeral" + wsl_key = config["wsl_workspace"] + "/.key-" + client_prefix + server = ready = None + wsl_key_owned = False + result = dict(verified=False, tcp_client=None, tcp_server=None, + artifacts=dict(directory=str(folder)), case_id=request["case_id"]) + deadline = time.monotonic() + 225 + + def run(argv, timeout=10, check=True): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Case deadline exceeded") + return subprocess.run(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", + timeout=min(timeout, remaining), shell=False, check=check) + + def server_tcp(phase): + try: + text = run(remote_args(config, ["sudo", "-n", "ip", "netns", "exec", server_prefix, + "cat", "/proc/net/snmp"]), timeout=5).stdout + (folder / ("server-tcp-" + phase + ".snmp")).write_text(text, encoding="ascii") + return parse_tcp(text) + except Exception: + return None + + before = None + try: + command = ["sudo", "-n", "bash", config["pi_bench"] + "/ethernet_ssh_phone_bench.sh", + "--endpoint", config["server_endpoint"], "--bench-dir", config["pi_bench"], + "--prefix", server_prefix, "--duration", "300"] + server = subprocess.Popen(remote_args(config, command), stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, + encoding="utf-8", errors="replace", shell=False) + ready = read_ready(server, config, timeout=20) + result["artifacts"]["server_directory"] = ready["directory"] + scp = ["scp", "-q", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", + "-o", "ConnectTimeout=8", "-i", config["ssh_key"], "--", + config["ssh_host"] + ":" + ready["directory"] + "/client_key", str(windows_key)] + run(scp, timeout=10) + # noclobber opens the new private file atomically. A stale/colliding + # name is refused, never overwritten or removed by our cleanup. + prepare = ('set -euC; umask 077; exec 3>"$2"; cat -- "$1" >&3; ' + 'chmod 600 -- "$2"; chown "$3": -- "$2"') + run(["wsl", "--user", "root", "--exec", "bash", "-c", prepare, "--", + wsl_path(windows_key), wsl_key, config["account"]], timeout=10) + wsl_key_owned = True + before = server_tcp("before") + helper = wsl_path(Path(__file__).resolve().with_name("ethernet_ssh_flutter_bench.sh")) + client = run(["wsl", "--user", "root", "--exec", "bash", helper, + "--endpoint", config["client_endpoint"], "--bench-dir", config["wsl_workspace"], + "--workspace", config["wsl_workspace"], "--flutter", config["flutter"], + "--key", wsl_key, "--fingerprint", ready["fingerprint"], "--prefix", client_prefix, + "--account", config["account"], "--test-name", "speed", "--phase-timeout", "45"], + timeout=220, check=False) + (folder / "client-helper.txt").write_text(client.stdout, encoding="utf-8") + client_folder = artifact_dir(client.stdout, config["wsl_workspace"], "Flutter RF artifacts") + result["artifacts"]["client_directory"] = client_folder + # The allowlist contains evidence only, never arbitrary remote files or + # the ephemeral client key. Missing evidence remains explicitly absent. + copy = 'set -eu; src=$1; dst=$2; shift 2; for name do if [ -f "$src/$name" ]; then cp -- "$src/$name" "$dst/$name"; fi; done' + run(["wsl", "--user", "root", "--exec", "bash", "-c", copy, "--", + client_folder, wsl_path(folder), *ARTIFACTS], timeout=10) + speed = json.loads((folder / "speed.json").read_text(encoding="utf-8")) + if not isinstance(speed, dict): + raise ValueError("Invalid speed evidence") + result.update(normalized_speed(speed, client.returncode)) + if not result["verified"]: + result["error"] = "SSH payload run did not complete with both hashes verified" + try: + result["tcp_client"] = json.loads((folder / "tcp-stats.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + result["tcp_client"] = dict(available=False, scope="isolated_network_namespace") + except Exception as error: + result["verified"] = False + result["error"] = ("SSH runner deadline exceeded" if isinstance(error, (TimeoutError, subprocess.TimeoutExpired)) + else "SSH runner failed; inspect the bounded non-secret evidence") + finally: + if ready: + after = server_tcp("after") + if before is not None and after is not None: + try: + result["tcp_server"] = summarize(before, after) + except ValueError: + pass + if result["tcp_server"] is None: + result["tcp_server"] = dict(available=False, scope="isolated_network_namespace") + helper_pid = ready["pid"] if ready else getattr(server, "helper_pid", None) + if type(helper_pid) is int: + # Cleanup gets a separate bounded budget even after case timeout. + try: + subprocess.run(stop_server_args(config, helper_pid, server_prefix), + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=5, shell=False, check=True) + except Exception: + result["verified"] = False + result["cleanup_error"] = "Owned server stop could not be confirmed; helper remains duration-bounded" + if wsl_key_owned: + try: + subprocess.run(["wsl", "--user", "root", "--exec", "rm", "-f", "--", wsl_key], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=5, shell=False, check=True) + except Exception: + result["verified"] = False + result["cleanup_error"] = "Ephemeral WSL key cleanup could not be confirmed" + try: + if windows_key.exists(): + windows_key.unlink() + except OSError: + result["verified"] = False + result["cleanup_error"] = "Ephemeral local key cleanup could not be confirmed" + if server is not None: + try: + server.wait(timeout=3) + except subprocess.TimeoutExpired: + server.kill() # Only the exact local Popen child, no PID search. + server.wait(timeout=2) + if server.stdout: + server.stdout.close() + (folder / "runner-result.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + return result + + +def main(): + try: + config_path = Path(os.environ.get("LORA_SSH_BENCH_CONFIG", "")) + if not config_path.is_absolute() or not config_path.is_file() or config_path.stat().st_size > 65536: + raise ValueError("An explicit local configuration file is required") + config = json.loads(config_path.read_text(encoding="utf-8-sig")) + request_text = sys.stdin.read(4097) + if len(request_text) > 4096: + raise ValueError("Oversized case request") + result = execute(config, json.loads(request_text)) + except Exception: + result = dict(verified=False, error="Invalid benchmark configuration/request or unavailable runner resources") + print(json.dumps(result), flush=True) + return 0 if result["verified"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/hil/ethernet_primary_probe.py b/tools/hil/ethernet_primary_probe.py new file mode 100644 index 000000000..61adb45b3 --- /dev/null +++ b/tools/hil/ethernet_primary_probe.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Verify primary MeshCore chat while Ethernet RF traffic uses radio2. + +TRANSMITS LoRa and saves channel-0 TX policy=radio on the two explicit lab +Companions. Does not flash, reset, edit WiFi, read channel secrets, or configure +RF. Both primary and Ethernet tuples MUST already be 909.5/500/SF5/CR5; the +script refuses all test transmission unless both devices report those tuples. +Only an opposite radio's structured CHANNEL MSG reception proves chat delivery; +a command echo or the sender's local Sent response never passes the check. +""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import secrets +import threading +import time + +from ethernet_kiss_probe import KissEndpoint, run_probe +from serial_session import assert_port_idle, open_configured_session, read_command_reply + + +def assert_lab_radio(reply): + match = re.search(r"(?:^|\n)\s*(?:->\s*)?>\s*(\d+(?:\.\d+)?),(\d+(?:\.\d+)?),(\d+),(\d+)(?=[,;\s]|$)", reply) + if not match or tuple(float(value) for value in match.groups()) != (909.5, 500, 5, 5): + raise RuntimeError("Refusing TX: both primary and Ethernet must report 909.5,500,5,5") + + +def received_channel_marker(text, marker): + messages = re.findall(r"CHANNEL MSG -> [^\r\n]*\((?:FLOOD|DIRECT)\)\r?\n[ \t]*([^\r\n]*)", text) + return any(re.search(r"(?\s*)?>\s*(.*?)\s*$", line) + if match and match[1]: + result.append(match[1]) + return result + + +def require_guard(reply): + matches = [line for line in data_lines(reply) if line.startswith("HIL TX")] + if matches != [GUARD]: + raise RuntimeError("Refusing TX/configuration: exact immutable 909.500 MHz HIL lock is not confirmed") + + +def radio_tuple(reply): + matches = [] + for line in data_lines(reply): + match = re.match(r"^(\d+(?:\.\d+)?),(\d+(?:\.\d+)?),(\d+),(\d+)" + r"(?:,(\d+))?(?=[;\s]|$)", line) + if match: + matches.append((Decimal(match[1]), Decimal(match[2]), int(match[3]), int(match[4]), + int(match[5]) if match[5] is not None else None)) + if len(matches) != 1: + raise RuntimeError("Missing or ambiguous structured radio tuple") + return matches[0] + + +def primary_tx(reply): + matches = [] + for line in data_lines(reply): + matches.extend(re.findall(r"(?:^|\s)TX=(\d+),(\d+)(?=\s|;|$)", line)) + if len(matches) != 1: + raise RuntimeError("Missing or ambiguous per-profile TX counters") + return int(matches[0][0]) + + +def safety_snapshot(nodes, timeout): + states = {} + # Complete all four assertions before permitting ANY R2 setting or KISS TX. + for name, node in nodes.items(): + guard = node.command("get lab.tx", timeout=timeout) + require_guard(guard) + radio = node.command("get radio", timeout=timeout) + actual = radio_tuple(radio) + # ESP32's float getter can print 910.5250244 for requested 910.525. + # Accept only this sub-50 Hz representation error, never a nearby RF + # channel or any bandwidth/SF/coding-rate change. + if abs(actual[0] - PRIMARY[0]) > Decimal("0.00005") or actual[1:4] != PRIMARY[1:4]: + raise RuntimeError("Refusing TX/configuration: R1 must remain exactly 910.525,62.5,7,5") + states[name] = {"lab_tx": guard, "primary_radio": radio} + return states + + +def require_ok(reply): + if re.search(r"\b(?:error|err)\b", reply, re.IGNORECASE) or not re.search(r"\bOK\b", reply): + raise RuntimeError("Radio2 configuration did not complete successfully: " + reply) + + +def measurement(probe, elapsed): + transfers = probe.get("transfers", []) + if not transfers: + raise RuntimeError("Probe returned no frame evidence") + received = [item for item in transfers if item["received"]] + received_bytes = sum(item["size"] for item in received) + payload_bytes = sum(max(0, item["size"] - 14) for item in received) + return dict(elapsed_seconds=round(elapsed, 6), frames_attempted=len(transfers), + frames_received=len(received), frames_lost=len(transfers) - len(received), + loss_fraction=1 - len(received) / len(transfers), + ethernet_bytes_received=received_bytes, + ethernet_goodput_bps=received_bytes * 8 / elapsed if elapsed else 0, + ethernet_payload_goodput_bps=payload_bytes * 8 / elapsed if elapsed else 0, + goodput_definition="received Ethernet bytes (payload excludes 14-byte MAC header), both directions; not SSH application throughput") + + +def run_sweep(a, b, endpoint_a, endpoint_b, candidates, *, count=1, timeout=3, + interval=0.1, command_timeout=12, endpoint_factory=KissEndpoint, + probe_function=run_probe, persist=lambda report: None): + if not candidates or any(case not in CANDIDATES for case in candidates): + raise ValueError("Select only predefined 909.5 MHz cases") + nodes = {"a": a, "b": b} + report = dict(started_utc=datetime.now(timezone.utc).isoformat(), cases=[], aborted=False, + primary_usa=list(map(str, PRIMARY)), primary_nominal_kbps=PRIMARY_KBPS, + probe_parameters=dict(count=count, timeout=timeout, interval=interval, + command_timeout=command_timeout, duplex=False, retries=0), + usb={name: node.identity for name, node in nodes.items()}, + note="No USA TX. Primary configuration/counters only; no claim of actual USA receive reliability. Raw Ethernet probe, not SSH.") + try: + report["initial_safety"] = safety_snapshot(nodes, command_timeout) + for case in candidates: + item = dict(candidate=case.describe(), passed=False) + report["cases"].append(item) + endpoints = [] + try: + item["safety_before_configuration"] = safety_snapshot(nodes, command_timeout) + before = {} + for name, node in nodes.items(): + status = node.command("get radio2.status", timeout=command_timeout) + before[name] = primary_tx(status) + if before[name] != 0: + raise RuntimeError("Primary TX counter is nonzero; refusing any further test TX") + item["primary_tx_before"] = before + # Previous case's endpoints were closed in finally before any + # new tuple. Explicit off releases only the Ethernet R2 lease. + for node in nodes.values(): + for command in ("set ethernet off", "set ethernet.radio " + case.tuple_text, + "set ethernet on"): + require_ok(node.command(command, timeout=command_timeout)) + states = {} + for name, node in nodes.items(): + tuple_reply = node.command("get ethernet.radio", timeout=command_timeout) + expected = (Decimal("909.5"), Decimal(str(case.bandwidth)), case.sf, 5, case.preamble) + if radio_tuple(tuple_reply) != expected: + raise RuntimeError("Ethernet radio2 does not match the selected tuple/preamble") + states[name] = {"ethernet_radio": tuple_reply, + "timing": node.command("get radio2.timing", timeout=command_timeout), + "status": node.command("get radio2.status", timeout=command_timeout), + "ethernet": node.command("get ethernet", timeout=command_timeout)} + if "ethernet=on; active (RX time-shared)" not in states[name]["ethernet"]: + raise RuntimeError("Ethernet radio2 is not active on station WiFi") + if primary_tx(states[name]["status"]) != before[name]: + raise RuntimeError("Primary TX counter changed during radio2 setup") + item["configured"] = states + item["safety_before_tx"] = safety_snapshot(nodes, command_timeout) + endpoints.append(endpoint_factory(endpoint_a)) + endpoints.append(endpoint_factory(endpoint_b)) + started = time.monotonic() + probe = probe_function(*endpoints, count=count, timeout=timeout, interval=interval, duplex=False) + elapsed = time.monotonic() - started + # Replace the generic helper's old fixed SF5/BW500 label with + # the exact PHY verified above; preserve all original frames. + probe["baseline"] = "verified R2 " + case.tuple_text + "; R1 USA RX-only" + item["probe"] = probe + item["measurement"] = measurement(probe, elapsed) + item["host_diagnostics"] = {name: dict(decode_errors=endpoint.decoder.errors, + queue_overflows=endpoint.overflow, error=endpoint.error) + for name, endpoint in zip(("a", "b"), endpoints)} + finally: + for endpoint in endpoints: + endpoint.close() + item["safety_after_tx"] = safety_snapshot(nodes, command_timeout) + item["primary_tx_after"] = {} + for name, node in nodes.items(): + status = node.command("get radio2.status", timeout=command_timeout) + item["primary_tx_after"][name] = primary_tx(status) + if item["primary_tx_after"][name] != before[name]: + raise RuntimeError("Primary TX counter changed; aborting all remaining cases") + item["passed"] = bool(item["probe"].get("passed")) and not any( + value for state in item["host_diagnostics"].values() for value in state.values()) + persist(report) + except Exception as error: + report["aborted"] = True + report["error"] = str(error) + report["completed"] = not report["aborted"] + report["passed"] = report["completed"] and all(item["passed"] for item in report["cases"]) + report["commands"] = {name: node.commands for name, node in nodes.items()} + persist(report) + return report + + +def endpoint_valid(endpoint): + parsed = urlparse(endpoint) + return (parsed.scheme == "tcp" and bool(parsed.hostname) and parsed.port in (None, 5003) + and not parsed.username and not parsed.password and parsed.path in ("", "/") + and not parsed.query and not parsed.fragment) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + for name in ("a-port", "b-port", "a-endpoint", "b-endpoint"): + parser.add_argument("--" + name) + parser.add_argument("--a-ssh", help="optional user@host owning A's USB port") + parser.add_argument("--a-key", help="absolute local private-key path for --a-ssh") + parser.add_argument("--a-helper", help="absolute remote ethernet_serial_rpc.py path") + parser.add_argument("--cases", default="1,2,3,4,5,6,7", help="comma-separated predefined case indexes") + parser.add_argument("--count", type=int, default=1) + parser.add_argument("--timeout", type=float, default=3) + parser.add_argument("--command-timeout", type=float, default=12) + parser.add_argument("--interval", type=float, default=0.1) + parser.add_argument("--report-dir", type=Path) + parser.add_argument("--plan", action="store_true", help="print candidates without opening any port/socket") + args = parser.parse_args(argv) + try: + indexes = [int(value) for value in args.cases.split(",")] + if len(set(indexes)) != len(indexes): + raise ValueError("duplicate cases") + cases = [next(case for case in CANDIDATES if case.index == index) for index in indexes] + except (ValueError, StopIteration): + parser.error(f"--cases must select unique indexes from 1..{len(CANDIDATES)}") + if not 1 <= args.count <= 4 or not 0 < args.timeout <= 15 or not 0 < args.command_timeout <= 30 \ + or not 0 <= args.interval <= 5 or not all(map(math.isfinite, (args.timeout, args.command_timeout, args.interval))): + parser.error("count 1..4, timeout (0,15], command-timeout (0,30], interval [0,5] required") + if args.plan: + print(json.dumps({"candidates": [case.describe() for case in cases], "required_guard": GUARD, + "primary_rx_only": "910.525,62.5,7,5"}, indent=2)) + return 0 + if not all((args.a_port, args.b_port, args.a_endpoint, args.b_endpoint, args.report_dir)): + parser.error("both USB ports, both TCP endpoints and an absolute --report-dir are required") + if any((args.a_ssh, args.a_key, args.a_helper)): + if not all((args.a_ssh, args.a_key, args.a_helper)): + parser.error("--a-ssh, --a-key and --a-helper must be supplied together") + try: + ssh_command(args.a_ssh, args.a_key, args.a_helper, args.a_port) + except (ValueError, TypeError) as error: + parser.error(str(error)) + try: + valid_endpoints = endpoint_valid(args.a_endpoint) and endpoint_valid(args.b_endpoint) + except ValueError: + valid_endpoints = False + if not args.report_dir.is_absolute() or not valid_endpoints or args.a_endpoint == args.b_endpoint \ + or (not args.a_ssh and os.path.realpath(args.a_port) == os.path.realpath(args.b_port)): + parser.error("use an absolute report directory and distinct USB ports/TCP port-5003 endpoints") + args.report_dir.mkdir(parents=True, exist_ok=True) + folder = Path(tempfile.mkdtemp(prefix="ethernet-r2-sweep-", dir=args.report_dir)) + + def persist(report): + output = json.dumps(report, indent=2) + "\n" + temporary = folder / "summary.json.tmp" + temporary.write_text(output, encoding="utf-8") + temporary.replace(folder / "summary.json") + for item in report["cases"]: + (folder / f"case-{item['candidate']['index']}.json").write_text(json.dumps(item, indent=2) + "\n", encoding="utf-8") + + a = b = None + try: + a = (SshSerialNode(args.a_ssh, args.a_key, args.a_helper, args.a_port) + if args.a_ssh else SerialNode(args.a_port)) + b = SerialNode(args.b_port) + report = run_sweep(a, b, args.a_endpoint, args.b_endpoint, cases, count=args.count, + timeout=args.timeout, interval=args.interval, command_timeout=args.command_timeout, + persist=persist) + except Exception as error: + report = dict(passed=False, aborted=True, cases=[], error=str(error)) + persist(report) + finally: + for node in (b, a): + if node: + node.close() + print(json.dumps({"report": str(folder / "summary.json"), "passed": report["passed"], + "aborted": report["aborted"], "error": report.get("error")})) + return 0 if report["passed"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/hil/ethernet_serial_rpc.py b/tools/hil/ethernet_serial_rpc.py new file mode 100644 index 000000000..4ea84ea53 --- /dev/null +++ b/tools/hil/ethernet_serial_rpc.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Private SSH-stdio transport for one explicitly selected lab USB CLI port. + +Stdout is exclusively bounded JSON-lines. The SSH connection owns the serial +session; EOF or a malformed/incomplete command closes it, without rebooting. +""" +from __future__ import annotations + +import argparse +import contextlib +import json +import math +import sys + +MAX_LINE = 65536 +MAX_COMMAND = 512 +PROTOCOL = "meshcore-serial-rpc-1" + + +def validate_command(command, timeout): + if not isinstance(command, str) or not command or len(command) > MAX_COMMAND \ + or any(character in command for character in "\r\n\0"): + raise ValueError("a bounded single-line command is required") + command.encode("ascii") + if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) \ + or not math.isfinite(timeout) or not 0 < timeout <= 30: + raise ValueError("command timeout must be finite and in (0,30]") + + +def encode_message(message): + data = (json.dumps(message, ensure_ascii=True, allow_nan=False) + "\n").encode("ascii") + if len(data) > MAX_LINE: + raise ValueError("RPC message exceeds the line limit") + return data + + +def decode_message(line): + if not line or len(line) > MAX_LINE or not line.endswith(b"\n"): + raise ValueError("RPC requires bounded, complete JSON lines") + message = json.loads(line.decode("utf-8"), parse_constant=lambda value: (_ for _ in ()).throw(ValueError(value))) + if not isinstance(message, dict): + raise ValueError("RPC message must be an object") + return message + + +def serve(node, source, destination): + def send(message): + destination.write(encode_message(message)) + destination.flush() + + try: + send(dict(type="ready", protocol=PROTOCOL, identity=node.identity, + hello=getattr(node, "hello", ""))) + while True: + line = source.readline(MAX_LINE + 1) + if not line: + return 0 + request_id = None + try: + request = decode_message(line) + if set(request) != {"id", "command", "timeout"}: + raise ValueError("unexpected RPC request fields") + request_id = request["id"] + if type(request_id) is not int or request_id <= 0: + raise ValueError("positive integer request id required") + validate_command(request["command"], request["timeout"]) + # No incidental diagnostic output may corrupt the protocol. + with contextlib.redirect_stdout(sys.stderr): + reply = node.command(request["command"], timeout=request["timeout"]) + if not isinstance(reply, str): + raise ValueError("serial command did not return a text reply") + send(dict(id=request_id, completed=True, reply=reply)) + except Exception as error: + send(dict(id=request_id, completed=False, error=str(error)[:1024])) + return 1 # Never attribute a late serial reply to another command. + finally: + with contextlib.redirect_stdout(sys.stderr): + node.close() + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", required=True) + args = parser.parse_args(argv) + try: + from ethernet_primary_probe import SerialNode + with contextlib.redirect_stdout(sys.stderr): + node = SerialNode(args.port) + return serve(node, sys.stdin.buffer, sys.stdout.buffer) + except Exception as error: + print("Serial RPC stopped: " + str(error), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/hil/ethernet_ssh_bench.sh b/tools/hil/ethernet_ssh_bench.sh new file mode 100644 index 000000000..8f54321fa --- /dev/null +++ b/tools/hil/ethernet_ssh_bench.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# Explicitly requested, destructive-to-its-own-namespaces-only RF bench. +# Never changes host LAN/interfaces/routes/firewall or the host sshd service. +set -euo pipefail + +endpoint_a='' +endpoint_b='' +bench_dir='' +prefix='' +usage() { + printf '%s\n' 'Usage: sudo bash ethernet_ssh_bench.sh --a tcp://RADIO_A:5003 --b tcp://RADIO_B:5003' \ + ' --bench-dir EXISTING_ABSOLUTE_DIRECTORY --prefix ethssh-UNIQUE' \ + 'Both radios must already have matching Ethernet PHY settings and station WiFi.' \ + 'Creates only two fresh isolated namespaces, temporary TAPs and a private test sshd.' \ + 'Runs ten pings and verifies actual SSH transfers of 4096 bytes in each direction.' \ + 'The network test has a shared 180-second deadline. Artifacts stay under bench-dir.' +} +die() { printf '%s\n' "$*" >&2; exit 2; } +while (($#)); do + case "$1" in + --a|--b|--bench-dir|--prefix) + (($# >= 2)) || die 'Missing option value.' + case "$1" in + --a) endpoint_a=$2 ;; --b) endpoint_b=$2 ;; + --bench-dir) bench_dir=$2 ;; --prefix) prefix=$2 ;; + esac + shift 2 ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; die "Unknown option: $1" ;; + esac +done +[[ "$prefix" =~ ^ethssh-[a-z0-9][a-z0-9-]{0,19}$ ]] \ + || die '--prefix must be ethssh- followed by 1..20 lowercase letters/digits/hyphens.' +[[ "$bench_dir" == /* && -d "$bench_dir" && "$bench_dir" != / ]] \ + || die '--bench-dir must be an existing absolute non-root directory.' +[[ -n "$endpoint_a" && -n "$endpoint_b" && "$endpoint_a" != "$endpoint_b" ]] \ + || die 'Supply two different, explicitly selected TCP radio endpoints.' +[[ ${EUID:-$(id -u)} == 0 ]] || die 'Root is required for isolated network namespaces/TAP.' +for dependency in ip python3 ssh ssh-keygen sha256sum dd timeout mktemp realpath stat runuser sysctl ss; do + command -v "$dependency" >/dev/null || die "Missing dependency: $dependency" +done +[[ -x /usr/sbin/sshd ]] || die 'Missing /usr/sbin/sshd.' +[[ -c /dev/net/tun ]] || die 'Missing /dev/net/tun.' +id mikec >/dev/null 2>&1 || die 'The local bench account mikec is missing.' +python3 - "$endpoint_a" "$endpoint_b" <<'PY' +import sys +from urllib.parse import urlparse +for endpoint in sys.argv[1:]: + url = urlparse(endpoint) + if (url.scheme != 'tcp' or not url.hostname or url.username or url.password + or url.path not in ('', '/') or url.query or url.fragment + or url.port not in (None, 5003)): + raise SystemExit('Endpoints must be tcp://HOST[:5003], without credentials/path/query.') +PY +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +relay="$script_dir/ethernet_tap_relay.py" +[[ -r "$relay" ]] || die 'ethernet_tap_relay.py must be beside this script.' +bench_dir=$(realpath -e -- "$bench_dir") +ns_a="$prefix-a" +ns_b="$prefix-b" +[[ ! -e "/var/run/netns/$ns_a" && ! -e "/var/run/netns/$ns_b" ]] \ + || die 'A requested namespace already exists; refusing to reuse or remove it.' +run_dir=$(mktemp -d -- "$bench_dir/ethernet-ssh.XXXXXXXX") +[[ "$run_dir" == "$bench_dir/ethernet-ssh."* ]] || die 'Unexpected artifact directory.' +# Public-key lookup runs as mikec. Generated private keys remain root-only 0600. +chmod 755 -- "$run_dir" +created_a=0; created_b=0; inode_a=''; inode_b='' +relay_a=''; relay_b=''; sshd_pid='' +deadline=$((SECONDS + 180)) +bounded() { + local remaining=$((deadline - SECONDS)) + ((remaining > 0)) || { echo 'Shared 180-second bench deadline expired.' >&2; return 124; } + timeout --kill-after=3s "${remaining}s" "$@" +} +namespace_matches() { + local name=$1 expected=$2 observed + [[ -n "$expected" ]] || return 1 + observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$name" 2>/dev/null) || return 1 + [[ "$observed" == "$expected" ]] +} +cleanup_namespace() { + local name=$1 expected=$2 pid + namespace_matches "$name" "$expected" || return 0 + # Only processes in this exact newly created namespace are ours to stop. + while read -r pid; do + [[ "$pid" =~ ^[0-9]+$ ]] && kill -TERM "$pid" 2>/dev/null || true + done < <(ip netns pids "$name") + sleep 0.3 + namespace_matches "$name" "$expected" || return 0 + while read -r pid; do + [[ "$pid" =~ ^[0-9]+$ ]] && kill -KILL "$pid" 2>/dev/null || true + done < <(ip netns pids "$name") + namespace_matches "$name" "$expected" && ip netns delete "$name" || true +} +cleanup() { + local result=$? child parent + trap - EXIT INT TERM HUP + set +e + # A relay can still be in its bounded host-side TCP connect before setns. + # Check parentage before touching tracked children; never kill a reused PID. + for child in "$relay_a" "$relay_b" "$sshd_pid"; do + [[ "$child" =~ ^[0-9]+$ ]] || continue + parent=$(awk '/^PPid:/ {print $2}' "/proc/$child/status" 2>/dev/null) + [[ "$parent" == "$$" ]] && kill -TERM "$child" 2>/dev/null || true + done + ((created_a)) && cleanup_namespace "$ns_a" "$inode_a" + ((created_b)) && cleanup_namespace "$ns_b" "$inode_b" + for child in "$relay_a" "$relay_b" "$sshd_pid"; do + [[ "$child" =~ ^[0-9]+$ ]] || continue + parent=$(awk '/^PPid:/ {print $2}' "/proc/$child/status" 2>/dev/null) + [[ "$parent" == "$$" ]] && kill -KILL "$child" 2>/dev/null || true + done + wait 2>/dev/null + # Exact generated files only; evidence and public keys are retained. + rm -f -- "$run_dir/client_key" "$run_dir/host_key" + printf 'exit=%s\n' "$result" >> "$run_dir/result.txt" + printf 'Bench artifacts: %s\n' "$run_dir" + exit "$result" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP + +ssh-keygen -q -t ed25519 -N '' -f "$run_dir/host_key" +ssh-keygen -q -t ed25519 -N '' -f "$run_dir/client_key" +chmod 644 -- "$run_dir/client_key.pub" +runuser -u mikec -- test -r "$run_dir/client_key.pub" \ + || die 'mikec cannot traverse bench-dir and read its generated public key.' +awk '{print "[10.90.5.2]:2222 " $1 " " $2}' "$run_dir/host_key.pub" > "$run_dir/known_hosts" +dd if=/dev/urandom of="$run_dir/a-input.bin" bs=4096 count=1 status=none +dd if=/dev/urandom of="$run_dir/b-input.bin" bs=4096 count=1 status=none +chmod 644 -- "$run_dir/b-input.bin" +sha256sum "$run_dir/a-input.bin" "$run_dir/b-input.bin" > "$run_dir/source.sha256" +sshd_options=(-f /dev/null -h "$run_dir/host_key" -p 2222 + -o ListenAddress=10.90.5.2 -o "PidFile=$run_dir/sshd.pid" + -o "AuthorizedKeysFile=$run_dir/client_key.pub" -o StrictModes=no + -o AllowUsers=mikec -o PermitRootLogin=no -o UsePAM=no + -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no + -o AuthenticationMethods=publickey -o AllowTcpForwarding=no + -o PermitTunnel=no -o X11Forwarding=no -o PermitTTY=no) +/usr/sbin/sshd -t "${sshd_options[@]}" + +ip netns add "$ns_a"; created_a=1 +inode_a=$(stat -Lc '%d:%i' -- "/var/run/netns/$ns_a") +ip netns add "$ns_b"; created_b=1 +inode_b=$(stat -Lc '%d:%i' -- "/var/run/netns/$ns_b") +for ns in "$ns_a" "$ns_b"; do + ip -n "$ns" link set lo up + ip netns exec "$ns" sysctl -qw net.ipv6.conf.all.disable_ipv6=1 + ip netns exec "$ns" sysctl -qw net.ipv6.conf.default.disable_ipv6=1 +done +# The relay connects in the host namespace BEFORE entering the isolated one. +# No veth/default route exists, so inner IP traffic can only cross the radios. +python3 "$relay" --endpoint "$endpoint_a" --netns "/var/run/netns/$ns_a" \ + --interface lora0 --duration 180 --status-file "$run_dir/a-relay.json" \ + > "$run_dir/a-relay.log" 2>&1 & +relay_a=$! +python3 "$relay" --endpoint "$endpoint_b" --netns "/var/run/netns/$ns_b" \ + --interface lora0 --duration 180 --status-file "$run_dir/b-relay.json" \ + > "$run_dir/b-relay.log" 2>&1 & +relay_b=$! +for _ in {1..50}; do + kill -0 "$relay_a" && kill -0 "$relay_b" || die 'A TAP relay exited; inspect its bench log.' + if ip -n "$ns_a" link show dev lora0 >/dev/null 2>&1 \ + && ip -n "$ns_b" link show dev lora0 >/dev/null 2>&1; then break; fi + sleep 0.2 +done +ip -n "$ns_a" link set lora0 address 02:90:05:00:00:01 mtu 230 up +ip -n "$ns_b" link set lora0 address 02:90:05:00:00:02 mtu 230 up +ip -n "$ns_a" address add 10.90.5.1/24 dev lora0 +ip -n "$ns_b" address add 10.90.5.2/24 dev lora0 +ip -n "$ns_a" route show > "$run_dir/a-routes.txt" +ip -n "$ns_b" route show > "$run_dir/b-routes.txt" +ip netns exec "$ns_b" /usr/sbin/sshd -D -e "${sshd_options[@]}" > "$run_dir/sshd.log" 2>&1 & +sshd_pid=$! +for _ in {1..30}; do + kill -0 "$sshd_pid" || die 'The isolated sshd exited; inspect sshd.log.' + if ip netns exec "$ns_b" ss -H -ltn | grep -q '10\.90\.5\.2:2222'; then break; fi + sleep 0.1 +done +# With both -c and -w, Linux ping may keep sending until it receives count +# replies, building an unrealistic LoRa backlog. Send only ten paced requests. +# Loss is evidence, not a reason to skip the actual reliable-transport test. +if ! bounded ip netns exec "$ns_a" ping -n -c 10 -i 1 -W 3 -s 32 10.90.5.2 \ + > "$run_dir/ping.txt" 2>&1; then + printf 'Diagnostic ping had loss; continuing to the SSH checksum test.\n' >> "$run_dir/result.txt" +fi +ssh_options=(-F /dev/null -T -p 2222 -i "$run_dir/client_key" + -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes + -o "UserKnownHostsFile=$run_dir/known_hosts" -o GlobalKnownHostsFile=/dev/null + -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no + -o ConnectionAttempts=1 -o ConnectTimeout=60 -o ServerAliveInterval=0 + -o ControlMaster=no -o ClearAllForwardings=yes + -o Compression=no -o KexAlgorithms=curve25519-sha256 + -o HostKeyAlgorithms=ssh-ed25519 -o Ciphers=chacha20-poly1305@openssh.com) +bounded ip netns exec "$ns_a" ssh "${ssh_options[@]}" mikec@10.90.5.2 sha256sum \ + < "$run_dir/a-input.bin" > "$run_dir/a-to-b.sha256" 2> "$run_dir/a-to-b-ssh.log" +expected_a=$(sha256sum "$run_dir/a-input.bin"); expected_a=${expected_a%% *} +read -r actual_a _ < "$run_dir/a-to-b.sha256" +[[ "$actual_a" == "$expected_a" ]] || die 'SSH A-to-B payload checksum mismatch.' +remote_path=$(python3 -c 'import shlex,sys;print(shlex.quote(sys.argv[1]))' "$run_dir/b-input.bin") +bounded ip netns exec "$ns_a" ssh "${ssh_options[@]}" mikec@10.90.5.2 "exec /bin/cat -- $remote_path" \ + > "$run_dir/b-received.bin" 2> "$run_dir/b-to-a-ssh.log" +[[ $(stat -c %s "$run_dir/b-received.bin") == 4096 ]] || die 'SSH B-to-A payload length mismatch.' +expected_b=$(sha256sum "$run_dir/b-input.bin"); expected_b=${expected_b%% *} +actual_b=$(sha256sum "$run_dir/b-received.bin"); actual_b=${actual_b%% *} +[[ "$actual_b" == "$expected_b" ]] || die 'SSH B-to-A payload checksum mismatch.' +printf 'PASS: real SSH carried 4096 bytes each way; both SHA-256 values match.\na_to_b=%s\nb_to_a=%s\n' \ + "$actual_a" "$actual_b" | tee "$run_dir/result.txt" diff --git a/tools/hil/ethernet_ssh_flutter_bench.sh b/tools/hil/ethernet_ssh_flutter_bench.sh new file mode 100644 index 000000000..4c27d45f4 --- /dev/null +++ b/tools/hil/ethernet_ssh_flutter_bench.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# Isolated Linux client for the explicitly opt-in Flutter SSH-over-radio test. +set -euo pipefail +endpoint=''; bench_dir=''; workspace=''; flutter=''; private_key=''; fingerprint='' +prefix=''; account=''; test_name=rf; test_timeout=210; phase_timeout=45 +die() { printf '%s\n' "$*" >&2; exit 2; } +while (($#)); do + (($# >= 2)) || die 'Every option requires a value.' + case "$1" in + --endpoint) endpoint=$2 ;; --bench-dir) bench_dir=$2 ;; + --workspace) workspace=$2 ;; --flutter) flutter=$2 ;; + --key) private_key=$2 ;; --fingerprint) fingerprint=$2 ;; + --prefix) prefix=$2 ;; --account) account=$2 ;; + --test-name) test_name=$2 ;; + --phase-timeout) + [[ "$2" =~ ^[0-9]{1,2}$ ]] && ((10#$2 >= 5 && 10#$2 <= 60)) \ + || die '--phase-timeout must be 5..60 seconds.' + phase_timeout=$((10#$2)) ;; + --test-timeout) + [[ "$2" =~ ^[0-9]{1,3}$ ]] && ((10#$2 >= 1 && 10#$2 <= 180)) \ + || die '--test-timeout must be 1..180 seconds.' + test_timeout=$((10#$2)) ;; + *) die "Unknown option: $1" ;; + esac + shift 2 +done +[[ "$test_name" == rf || "$test_name" == speed ]] || die '--test-name must be rf or speed.' +test_file="test/services/lora_ssh_${test_name}_test.dart" +[[ ${EUID:-$(id -u)} == 0 ]] || die 'Root is required for the isolated namespace/TAP.' +[[ "$prefix" =~ ^ethflutter-[a-z0-9][a-z0-9-]{0,19}$ ]] || die 'Use a unique ethflutter- prefix.' +[[ "$account" =~ ^[a-z_][a-z0-9_-]*$ ]] || die 'Specify an unprivileged test account.' +[[ $(id -u "$account") -ne 0 ]] || die 'The Flutter test account must not be root.' +for directory in "$bench_dir" "$workspace"; do + [[ "$directory" == /* && "$directory" != / && -d "$directory" ]] || die 'Use existing absolute directories.' +done +[[ "$flutter" == /* && -x "$flutter" && "$private_key" == /* && -f "$private_key" ]] || die 'Use explicit Flutter/key paths.' +[[ -f "$workspace/$test_file" ]] || die 'The selected opt-in SSH backend test is missing.' +[[ "$fingerprint" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]] || die 'Use the expected OpenSSH SHA-256 fingerprint.' +runuser -u "$account" -- test -r "$private_key" || die 'The test account cannot read its selected key.' +[[ ! -e "/var/run/netns/$prefix" ]] || die 'Refusing to reuse an existing namespace.' +python3 - "$endpoint" <<'PY' +import ipaddress, sys +from urllib.parse import urlparse +u = urlparse(sys.argv[1]) +try: + host = ipaddress.IPv4Address(u.hostname) + valid = (u.scheme == 'tcp' and u.port == 5003 and not u.username and not u.password + and u.path in ('', '/') and not u.query and not u.fragment + and not host.is_multicast and not host.is_unspecified) +except ValueError: + valid = False +if not valid: + raise SystemExit('Use an explicit numeric IPv4 tcp://RADIO:5003 endpoint.') +PY +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +run_dir=$(mktemp -d -- "$bench_dir/ethernet-flutter.XXXXXXXX") +chown "$account": "$run_dir" +chmod 700 -- "$run_dir" +# Keep evidence readable by the selected account even if sudo inherited 077. +# The account-owned parent remains private (0700); no keys are copied here. +umask 022 +created=0; inode=''; relay_pid=''; test_pid=''; monitor_pid=''; tcp_started=0; tcp_finished=0 +capture_tcp() { + local phase=$1 observed='' + observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$prefix" 2>/dev/null) || return 1 + [[ -n "$inode" && "$observed" == "$inode" ]] || return 1 + date -u +%Y-%m-%dT%H:%M:%SZ > "$run_dir/tcp-$phase.utc" + ip netns exec "$prefix" cat /proc/net/snmp > "$run_dir/tcp-$phase.snmp" \ + 2> "$run_dir/tcp-$phase.error" +} +finish_tcp() { + ((tcp_started && !tcp_finished)) || return 0 + tcp_finished=1 + capture_tcp after || true + python3 "$script_dir/ethernet_tcp_stats.py" --directory "$run_dir" \ + > "$run_dir/tcp-stats.error" 2>&1 || true +} +sample_tcp() { + local remaining=300 sample lines observed + while ((remaining > 0)) && kill -0 "$test_pid" 2>/dev/null; do + observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$prefix" 2>/dev/null) || break + [[ "$observed" == "$inode" ]] || break + sample=$(printf '\nUTC %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + timeout --kill-after=1s 2s ip netns exec "$prefix" ss -tin 2>&1 | head -n 12 || true) + printf '%s\n' "$sample" | head -n "$remaining" >> "$run_dir/tcp-ss.txt" + lines=$(printf '%s\n' "$sample" | wc -l) + remaining=$((remaining - lines)) + sleep 1 + done +} +cleanup() { + local result=$? observed='' child parent + trap - EXIT INT TERM HUP + set +e + finish_tcp + for child in "$test_pid" "$monitor_pid" "$relay_pid"; do + if [[ "$child" =~ ^[0-9]+$ ]]; then + parent=$(awk '/^PPid:/ {print $2}' "/proc/$child/status" 2>/dev/null) + [[ "$parent" == "$$" ]] && kill -TERM "$child" 2>/dev/null + fi + done + if ((created)); then + observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$prefix" 2>/dev/null) + if [[ -n "$inode" && "$observed" == "$inode" ]]; then + while read -r child; do + [[ "$child" =~ ^[0-9]+$ ]] && kill -TERM "$child" 2>/dev/null + done < <(ip netns pids "$prefix") + sleep 0.3 + observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$prefix" 2>/dev/null) + if [[ "$observed" == "$inode" ]]; then + while read -r child; do + [[ "$child" =~ ^[0-9]+$ ]] && kill -KILL "$child" 2>/dev/null + done < <(ip netns pids "$prefix") + ip netns delete "$prefix" + fi + fi + fi + # Reap only our explicit children. A relay still connecting outside the + # namespace must not make cleanup wait forever after ignoring SIGTERM. + for child in "$test_pid" "$monitor_pid" "$relay_pid"; do + [[ "$child" =~ ^[0-9]+$ ]] || continue + parent=$(awk '/^PPid:/ {print $2}' "/proc/$child/status" 2>/dev/null) + if [[ "$parent" == "$$" ]]; then + for _ in {1..20}; do + kill -0 "$child" 2>/dev/null || break + sleep 0.1 + done + parent=$(awk '/^PPid:/ {print $2}' "/proc/$child/status" 2>/dev/null) + [[ "$parent" == "$$" ]] && kill -KILL "$child" 2>/dev/null + fi + wait "$child" 2>/dev/null + done + printf 'exit=%s\n' "$result" >> "$run_dir/result.txt" + printf 'Flutter RF artifacts: %s\n' "$run_dir" + exit "$result" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM HUP +ip netns add "$prefix"; created=1 +inode=$(stat -Lc '%d:%i' -- "/var/run/netns/$prefix") +ip -n "$prefix" link set lo up +ip netns exec "$prefix" sysctl -qw net.ipv6.conf.all.disable_ipv6=1 +ip netns exec "$prefix" sysctl -qw net.ipv6.conf.default.disable_ipv6=1 +python3 "$script_dir/ethernet_tap_relay.py" --endpoint "$endpoint" \ + --netns "/var/run/netns/$prefix" --interface lora0 --duration 240 \ + --status-file "$run_dir/relay.json" > "$run_dir/relay.log" 2>&1 & +relay_pid=$! +for _ in {1..60}; do + kill -0 "$relay_pid" || die 'TAP relay exited; inspect relay.log.' + ip -n "$prefix" link show dev lora0 >/dev/null 2>&1 && break + sleep 0.2 +done +ip -n "$prefix" link set lora0 address 02:90:05:00:00:01 mtu 230 up +ip -n "$prefix" address add 10.90.5.1/24 dev lora0 +ip -n "$prefix" route show > "$run_dir/routes.txt" +cd -- "$workspace" +tcp_started=1 +capture_tcp before || true +timeout --kill-after=5s "${test_timeout}s" ip netns exec "$prefix" runuser -u "$account" -- env \ + LORA_SSH_RF_KEY="$private_key" EXPECTED_HOST_FINGERPRINT_SHA256="$fingerprint" \ + LORA_SSH_RF_PORT=2222 LORA_SSH_RF_USER=mikec \ + LORA_SSH_SPEED_REPORT="$run_dir/speed.json" LORA_SSH_SPEED_BYTES=8192 \ + LORA_SSH_SPEED_TIMEOUT_SECONDS="$phase_timeout" LORA_SSH_SPEED_TIMEOUT="$phase_timeout" \ + "$flutter" test --no-pub --reporter expanded "$test_file" \ + > "$run_dir/flutter.txt" 2>&1 & +test_pid=$! +sample_tcp & +monitor_pid=$! +test_result=0 +wait "$test_pid" || test_result=$? +test_pid='' +if [[ "$monitor_pid" =~ ^[0-9]+$ ]]; then + kill -TERM "$monitor_pid" 2>/dev/null || true + wait "$monitor_pid" 2>/dev/null || true +fi +monitor_pid='' +finish_tcp +printf 'scenario=%s\n' "$test_name" > "$run_dir/result.txt" +if ((test_result == 0)); then + printf 'PASS: real Flutter SSH backend test completed through the isolated radio TAP.\n' >> "$run_dir/result.txt" +fi +exit "$test_result" diff --git a/tools/hil/ethernet_ssh_phone_bench.sh b/tools/hil/ethernet_ssh_phone_bench.sh new file mode 100644 index 000000000..9f7b1accc --- /dev/null +++ b/tools/hil/ethernet_ssh_phone_bench.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# One explicitly selected radio, one isolated SSH server for the phone test. +# No host routes, firewall, accounts, system sshd or other radios are changed. +set -euo pipefail +endpoint=''; bench_dir=''; prefix=''; duration=1200; kiss_port=0; baud=115200; serial_dtr=off; kiss_modem=off +die() { printf '%s\n' "$*" >&2; exit 2; } +while (($#)); do + (($# >= 2)) || die 'Every option requires a value.' + case "$1" in + --endpoint) endpoint=$2 ;; --bench-dir) bench_dir=$2 ;; + --prefix) prefix=$2 ;; --duration) duration=$2 ;; + --kiss-port) kiss_port=$2 ;; --baud) baud=$2 ;; --serial-dtr) serial_dtr=$2 ;; + --kiss-modem) kiss_modem=$2 ;; + *) die "Unknown option: $1" ;; + esac + shift 2 +done +[[ ${EUID:-$(id -u)} == 0 ]] || die 'Run with sudo for the isolated namespace/TAP.' +[[ "$prefix" =~ ^ethphone-[a-z0-9][a-z0-9-]{0,19}$ ]] || die 'Use a unique ethphone- prefix.' +[[ "$duration" =~ ^[0-9]+$ && "$duration" -ge 60 && "$duration" -le 1800 ]] || die 'Duration must be 60..1800 seconds.' +[[ "$bench_dir" == /* && -d "$bench_dir" && "$bench_dir" != / ]] || die 'Use an existing absolute bench directory.' +[[ "$kiss_port" == 0 || "$kiss_port" == 1 ]] || die 'KISS port must be 0 or 1.' +[[ "$baud" =~ ^[0-9]{4,7}$ && "$baud" -ge 1200 && "$baud" -le 2000000 ]] || die 'Baud must be 1200..2000000.' +[[ "$serial_dtr" == on || "$serial_dtr" == off ]] || die 'Serial DTR must be on or off.' +[[ "$kiss_modem" == on || "$kiss_modem" == off ]] || die 'Full KISS modem mode must be on or off.' +[[ "$kiss_modem" == off || "$endpoint" == serial:///* ]] || die 'Full KISS modem mode requires an explicit serial endpoint.' +[[ -x /usr/sbin/sshd && -c /dev/net/tun ]] || die 'sshd and /dev/net/tun are required.' +for dependency in ip python3 ssh-keygen stat realpath runuser sysctl ss mktemp; do + command -v "$dependency" >/dev/null || die "Missing dependency: $dependency" +done +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +python3 - "$endpoint" "$script_dir" <<'PY' +import sys +sys.path.insert(0, sys.argv[2]) +from ethernet_tap_relay import validate_endpoint +try: + validate_endpoint(sys.argv[1], strict_tcp=True) +except ValueError: + raise SystemExit('Use numeric IPv4 tcp://RADIO:5003 or an explicit serial:///dev/serial/by-id/DEVICE endpoint.') +PY +relay="$script_dir/ethernet_tap_relay.py" +[[ -r "$relay" ]] || die 'The TAP relay must be beside this script.' +bench_dir=$(realpath -e -- "$bench_dir") +namespace="$prefix" +[[ ! -e "/var/run/netns/$namespace" ]] || die 'Namespace already exists; refusing to reuse it.' +run_dir=$(mktemp -d -- "$bench_dir/ethernet-phone.XXXXXXXX") +[[ "$run_dir" == "$bench_dir/ethernet-phone."* ]] || die 'Unexpected artifact directory.' +# Only the explicitly selected test account can retrieve its ephemeral key. +chown mikec: "${run_dir}" +chmod 700 -- "$run_dir" +created=0; inode=''; relay_pid=''; sshd_pid='' +cleanup() { + local result=$? observed='' child parent + trap - EXIT INT TERM HUP + set +e + for child in "$relay_pid" "$sshd_pid"; do + [[ "$child" =~ ^[0-9]+$ ]] || continue + parent=$(awk '/^PPid:/ {print $2}' "/proc/$child/status" 2>/dev/null) + [[ "$parent" == "$$" ]] && kill -TERM "$child" 2>/dev/null + done + if ((created)); then + observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$namespace" 2>/dev/null) + if [[ -n "$inode" && "$observed" == "$inode" ]]; then + while read -r child; do + [[ "$child" =~ ^[0-9]+$ ]] && kill -TERM "$child" 2>/dev/null + done < <(ip netns pids "$namespace") + sleep 0.3 + observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$namespace" 2>/dev/null) + if [[ "$observed" == "$inode" ]]; then + while read -r child; do + [[ "$child" =~ ^[0-9]+$ ]] && kill -KILL "$child" 2>/dev/null + done < <(ip netns pids "$namespace") + ip netns delete "$namespace" + fi + fi + fi + wait 2>/dev/null + rm -f -- "$run_dir/host_key" "$run_dir/client_key" + printf 'exit=%s\n' "$result" >> "$run_dir/result.txt" + chmod 644 -- "$run_dir/result.txt" + printf 'Phone bench stopped; ephemeral keys removed. Evidence: %s\n' "$run_dir" + exit "$result" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM HUP +ssh-keygen -q -t ed25519 -N '' -f "$run_dir/host_key" +runuser -u mikec -- ssh-keygen -q -t ed25519 -N '' -f "$run_dir/client_key" +ip netns add "$namespace"; created=1 +inode=$(stat -Lc '%d:%i' -- "/var/run/netns/$namespace") +ip -n "$namespace" link set lo up +ip netns exec "$namespace" sysctl -qw net.ipv6.conf.all.disable_ipv6=1 +ip netns exec "$namespace" sysctl -qw net.ipv6.conf.default.disable_ipv6=1 +deadline=$((SECONDS + duration)) +modem_args=() +if [[ "$kiss_modem" == on ]]; then modem_args+=(--kiss-modem); fi +python3 "$relay" --endpoint "$endpoint" --netns "/var/run/netns/$namespace" \ + --baud "$baud" --kiss-port "$kiss_port" --serial-dtr "$serial_dtr" \ + "${modem_args[@]}" \ + --interface lora0 --duration "$duration" --status-file "$run_dir/relay.json" \ + > "$run_dir/relay.log" 2>&1 & +relay_pid=$! +for _ in {1..60}; do + kill -0 "$relay_pid" || die 'TAP relay exited; inspect relay.log.' + ip -n "$namespace" link show dev lora0 >/dev/null 2>&1 && break + sleep 0.2 +done +ip -n "$namespace" link set lora0 address 02:90:05:00:00:02 mtu 230 up +ip -n "$namespace" address add 10.90.5.2/24 dev lora0 +ip -n "$namespace" route show > "$run_dir/routes.txt" +options=(-f /dev/null -h "$run_dir/host_key" -p 2222 + -o ListenAddress=10.90.5.2 -o "PidFile=$run_dir/sshd.pid" + -o "AuthorizedKeysFile=$run_dir/client_key.pub" -o StrictModes=no + -o AllowUsers=mikec -o PermitRootLogin=no -o UsePAM=no + -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no + -o AuthenticationMethods=publickey -o DisableForwarding=yes + -o PermitUserEnvironment=no -o X11Forwarding=no -o PermitTTY=yes + -o 'Subsystem=sftp internal-sftp') +/usr/sbin/sshd -t "${options[@]}" +ip netns exec "$namespace" /usr/sbin/sshd -D -e "${options[@]}" > "$run_dir/sshd.log" 2>&1 & +sshd_pid=$! +listening=0 +for _ in {1..30}; do + kill -0 "$sshd_pid" || die 'Isolated sshd exited; inspect sshd.log.' + if ip netns exec "$namespace" ss -H -ltn | grep -q '10\.90\.5\.2:2222'; then listening=1; break; fi + sleep 0.1 +done +((listening)) || die 'The isolated SSH listener did not become ready.' +printf 'READY: gateway=%s local=10.90.5.1 remote=10.90.5.2 SSH=2222 user=mikec\n' "$endpoint" +printf 'Ephemeral client private key file (never print contents): %s/client_key\n' "$run_dir" +ssh-keygen -lf "$run_dir/host_key.pub" +printf 'Artifacts: %s\n' "$run_dir" +printf 'Server helper PID: %s\n' "$$" +while ((SECONDS < deadline)); do + kill -0 "$relay_pid" && kill -0 "$sshd_pid" || die 'A bench child exited.' + sleep 1 +done diff --git a/tools/hil/ethernet_ssh_serial.py b/tools/hil/ethernet_ssh_serial.py new file mode 100644 index 000000000..95c92e9eb --- /dev/null +++ b/tools/hil/ethernet_ssh_serial.py @@ -0,0 +1,126 @@ +"""SerialNode-compatible persistent SSH transport; no RF or settings policy.""" +from __future__ import annotations + +from pathlib import Path, PurePosixPath +import queue +import re +import shlex +import subprocess +import threading + +from ethernet_serial_rpc import (MAX_LINE, PROTOCOL, decode_message, + encode_message, validate_command) + + +def ssh_command(host, key, helper, port): + if not isinstance(host, str) or not re.fullmatch(r"(?:[A-Za-z0-9_][A-Za-z0-9_.-]*@)?[A-Za-z0-9_][A-Za-z0-9_.-]*", host): + raise ValueError("SSH host must be a hostname/IP, optionally user@host") + for name, value in (("helper", helper), ("port", port)): + if not isinstance(value, str) or not PurePosixPath(value).is_absolute() \ + or any(ord(character) < 32 for character in value): + raise ValueError("SSH " + name + " must be an absolute remote path") + key_path = Path(key) + if not key_path.is_absolute() or not key_path.is_file(): + raise ValueError("SSH key must be an existing absolute local file") + return ["ssh", "-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", + "-o", "StrictHostKeyChecking=yes", "-i", str(key_path), "--", host, + shlex.join(["python3", helper, "--port", port])] + + +class SshSerialNode: + def __init__(self, host, key, helper, path, *, process_factory=subprocess.Popen, + startup_timeout=15): + self.commands = [] + self._closed = False + self._sequence = 0 + self._messages = queue.Queue(maxsize=4) + self._reader_error = None + self._lock = threading.Lock() + self.process = process_factory(ssh_command(host, key, helper, path), stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=0) + self._reader = threading.Thread(target=self._read_lines, daemon=True) + self._reader.start() + try: + ready = self._receive(startup_timeout) + if ready.get("type") != "ready" or ready.get("protocol") != PROTOCOL \ + or not isinstance(ready.get("identity"), dict) or not ready["identity"] \ + or not isinstance(ready.get("hello"), str): + raise RuntimeError("Invalid serial RPC startup identity") + self.identity = dict(ready["identity"], ssh_host=host) + self.hello = ready["hello"] + except Exception: + self._stop(force=True) + raise + + def _read_lines(self): + try: + while True: + line = self.process.stdout.readline(MAX_LINE + 1) + if line and (len(line) > MAX_LINE or not line.endswith(b"\n")): + raise RuntimeError("Oversized or incomplete SSH RPC line") + self._messages.put_nowait(line) + if not line: + return + except Exception as error: + self._reader_error = str(error) or "SSH RPC receive queue overflow" + if self.process.poll() is None: + self.process.kill() # Only this Popen child, never a process group. + + def _receive(self, timeout): + if self._reader_error: + raise RuntimeError(self._reader_error) + try: + line = self._messages.get(timeout=timeout) + except queue.Empty: + self._stop(force=True) + raise TimeoutError("SSH serial RPC deadline exceeded") from None + if self._reader_error: + raise RuntimeError(self._reader_error) + if not line: + raise RuntimeError("SSH serial RPC closed before a complete response") + return decode_message(line) + + def command(self, text, timeout=10): + validate_command(text, timeout) + with self._lock: + if self._closed: + raise RuntimeError("SSH serial RPC is closed") + self._sequence += 1 + try: + self.process.stdin.write(encode_message(dict(id=self._sequence, command=text, timeout=timeout))) + self.process.stdin.flush() + response = self._receive(timeout + 2) + if type(response.get("id")) is not int or response["id"] != self._sequence \ + or response.get("completed") is not True or not isinstance(response.get("reply"), str): + raise RuntimeError("SSH serial command was not confirmed complete: " + str(response.get("error", "invalid response"))) + reply = response["reply"] + self.commands.append(dict(command=text, reply=reply)) + return reply + except Exception: + self._stop(force=True) + raise + + def _stop(self, force=False): + if self._closed: + return + self._closed = True + try: + self.process.stdin.close() # EOF lets the remote server close its USB. + except (OSError, ValueError): + pass + if force and self.process.poll() is None: + self.process.kill() + try: + self.process.wait(timeout=3) + except subprocess.TimeoutExpired: + self.process.terminate() + try: + self.process.wait(timeout=1) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=1) + self._reader.join(timeout=1) + self.process.stdout.close() + + def close(self): + self._stop() diff --git a/tools/hil/ethernet_tap_relay.py b/tools/hil/ethernet_tap_relay.py new file mode 100644 index 000000000..c99f9ef75 --- /dev/null +++ b/tools/hil/ethernet_tap_relay.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python3 +"""Isolated Linux TAP <-> selected port-0/1 KISS relay for real Ethernet/TCP/SSH RF tests. + +REQUIRES ROOT and an already-created, explicitly named network namespace. Opens +the KISS socket in the host namespace FIRST, then enters --netns and creates a +nonpersistent TAP there. Never changes host LAN, addresses, routes, firewall or +sshd. A TAP vanishes when this process exits. Configure its MTU to 230 and its +address separately with `ip -n NAME ...`; do not connect the namespace by veth. +The KISS socket retains its original network namespace despite setns(). + +Example (operator must first create eth-a namespace): + sudo python3 ethernet_tap_relay.py --endpoint tcp://RADIO:5003 \ + --netns /var/run/netns/eth-a --interface lora0 + +The relay starts no network services. A passing actual SSH session between two +otherwise-isolated namespaces then exercises ARP, TCP handshake, retransmission, +SSH negotiation and payload over the radios, not a synthetic packet imitation. +Use only operator-configured 909.5 MHz / BW500 / SF5 / CR5 low-power lab radios. +""" + +from __future__ import annotations + +import argparse +from collections import deque +import ctypes +import ipaddress +import json +import math +import os +import re +from pathlib import Path +import select +import signal +import socket +import struct +import sys +import time +from urllib.parse import urlparse + +from ethernet_kiss_probe import KissDecoder, kiss_encode +from serial_session import assert_port_idle, open_configured_session +from windows_usb_stdio_bridge import (WindowsUsbStream, add_windows_arguments, + validate_windows_endpoint, validate_windows_options) + + +class ModemFlowError(RuntimeError): + def __init__(self, message, stats): + super().__init__(message) + self.stats = stats + + +class RelayBuffers: + """Bounded queues; retain partial stream writes and unparsed input bytes.""" + + def __init__(self, depth=8, kiss_port=0, *, kiss_modem=False, tx_done_timeout=10, + now=time.monotonic): + if depth <= 0: + raise ValueError("queue depth must be positive") + self.depth = depth + self.to_tap = deque() + self.to_kiss = deque() + self.staging = bytearray() + if not math.isfinite(tx_done_timeout) or not .5 <= tx_done_timeout <= 120: + raise ValueError("TX_DONE timeout must be .5..120 seconds") + self.kiss_modem = kiss_modem + self.tx_done_timeout = tx_done_timeout + self.now = now + self.tx_deadline = None + # Standard port-0 KISS pacing controls only, never RF tuples or keys. + self.controls = deque((b"\xc0\x01\x00\xc0", b"\xc0\x02\xff\xc0", b"\xc0\x03\x01\xc0") if kiss_modem else ()) + self.decoder = KissDecoder(port=kiss_port, control=self.modem_control if kiss_modem else None) + self.kiss_port = kiss_port + self.counters = {"tap_read": 0, "kiss_written": 0, "kiss_read": 0, + "tap_written": 0, "bad_frame_length": 0, + "tap_read_bytes": 0, "tap_written_bytes": 0, + "modem_controls_written": 0, "modem_tx_done": 0, + "modem_tx_error": 0, "modem_tx_timeout": 0, + "modem_unexpected_tx_done": 0, "modem_rx_meta": 0} + + def can_write_kiss(self): + return bool(self.controls or self.to_kiss and self.tx_deadline is None) + + def check_tx_done(self): + if self.tx_deadline is not None and self.now() >= self.tx_deadline: + self.counters["modem_tx_timeout"] += 1 + raise ModemFlowError("Full KISS modem TX_DONE deadline exceeded; no retry attempted", self.stats()) + + def modem_control(self, command, payload): + if command != 0x06 or not payload: + return + if payload[0] == 0xF9: + self.counters["modem_rx_meta"] += 1 + elif payload[0] == 0xF8: + if self.tx_deadline is None: + self.counters["modem_unexpected_tx_done"] += 1 + elif len(payload) != 2 or payload[1] not in (0, 1): + self.counters["modem_tx_error"] += 1 + raise ModemFlowError("Full KISS modem returned malformed TX_DONE", self.stats()) + elif payload[1] == 0: + # A completed local submission can lose its RF opportunity + # (e.g. RX wins the final IRQ race). Drop this one datagram; + # normal TCP, not a hidden host/firmware ARQ, owns retrying it. + self.counters["modem_tx_error"] += 1 + self.tx_deadline = None + else: + self.counters["modem_tx_done"] += 1 + self.tx_deadline = None + elif payload[0] == 0xF1 and self.tx_deadline is not None: + self.counters["modem_tx_error"] += 1 + raise ModemFlowError("Full KISS modem returned a hardware error during DATA", self.stats()) + + def can_read_tap(self): + return len(self.to_kiss) < self.depth + + def can_read_kiss(self): + return not self.staging and len(self.to_tap) < self.depth + + def queue_tap_frame(self, frame): + if not self.can_read_tap(): + raise BufferError("TAP queue full; caller must stop reading") + if not 14 <= len(frame) <= 255: + self.counters["bad_frame_length"] += 1 + return False + self.to_kiss.append(kiss_encode(frame, port=self.kiss_port)) + self.counters["tap_read"] += 1 + self.counters["tap_read_bytes"] += len(frame) + return True + + def feed_kiss(self, data): + if not self.can_read_kiss() or len(data) > 4096: + raise BufferError("KISS staging full; caller must stop reading") + self.staging.extend(data) + self.parse_pending() + + def parse_pending(self): + consumed = 0 + for byte in self.staging: + if len(self.to_tap) >= self.depth: + break + consumed += 1 + for frame in self.decoder.feed(bytes((byte,))): + if not 14 <= len(frame) <= 255: + self.counters["bad_frame_length"] += 1 + continue + self.to_tap.append(frame) + self.counters["kiss_read"] += 1 + del self.staging[:consumed] + + def write_kiss(self, write): + self.check_tx_done() + if not self.can_write_kiss(): + return + control = bool(self.controls) + queue = self.controls if control else self.to_kiss + pending = queue[0] + count = write(pending) + if not 0 <= count <= len(pending): + raise IOError("invalid KISS write length") + if count == len(pending): + queue.popleft() + if control: + self.counters["modem_controls_written"] += 1 + else: + self.counters["kiss_written"] += 1 + if self.kiss_modem: + self.tx_deadline = self.now() + self.tx_done_timeout + elif count: + queue[0] = pending[count:] + + def write_tap(self, write): + if not self.to_tap: + return + pending = self.to_tap[0] + if write(pending) != len(pending): + raise IOError("partial TAP datagram write; refusing to split Ethernet frame") + self.to_tap.popleft() + self.counters["tap_written"] += 1 + self.counters["tap_written_bytes"] += len(pending) + + def stats(self): + return dict(self.counters, decode_errors=self.decoder.errors, + pending_to_tap=len(self.to_tap), pending_to_kiss=len(self.to_kiss), + staged_kiss_bytes=len(self.staging), awaiting_tx_done=self.tx_deadline is not None) + + +def validate_endpoint(endpoint, *, strict_tcp=False): + """Lexical allowlist only: never opens devices, namespaces or sockets.""" + parsed = urlparse(endpoint) + if parsed.username or parsed.password or parsed.query or parsed.fragment: + raise ValueError("KISS endpoint must not contain credentials, query or fragment") + if parsed.scheme == "tcp": + if not parsed.hostname or parsed.path not in ("", "/") or parsed.port not in range(1, 65536) and parsed.port is not None: + raise ValueError("Use an explicit KISS TCP host and port") + if strict_tcp: + host = ipaddress.IPv4Address(parsed.hostname) + if parsed.port != 5003 or host.is_multicast or host.is_unspecified: + raise ValueError("Use numeric IPv4 tcp://RADIO:5003") + elif parsed.scheme == "winusb": + return validate_windows_endpoint(endpoint) + elif parsed.scheme == "serial": + path = parsed.path + if parsed.netloc or not re.fullmatch(r"/dev/(?:tty(?:ACM|USB)[A-Za-z0-9_-]+|serial/(?:by-id|by-path)/[A-Za-z0-9_.:+-]+)", path) \ + or path.rsplit("/", 1)[-1] in (".", ".."): + raise ValueError("Use an explicit /dev/ttyACM*, ttyUSB*, or serial/by-id|by-path device") + else: + raise ValueError("Use tcp://RADIO:5003, serial:///dev/serial/by-id/DEVICE or winusb://COM6") + return parsed + + +class KissStream: + def __init__(self, endpoint, baud, *, serial_dtr=False, windows_python=None, + expect_serial=None, expect_vid=None, expect_pid=None, duration=300): + self.socket = self.serial = self.windows = None + parsed = validate_endpoint(endpoint) + validate_windows_options(parsed, python=windows_python, expected_serial=expect_serial, + expected_vid=expect_vid, expected_pid=expect_pid, baud=baud, + serial_dtr=serial_dtr, duration=duration) + if parsed.scheme == "tcp": + self.socket = socket.create_connection((parsed.hostname, parsed.port or 5003), timeout=5) + self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + self.socket.setblocking(False) + self.fd = self.socket.fileno() + elif parsed.scheme == "serial": + if not re.fullmatch(r"/dev/tty(?:ACM|USB)[A-Za-z0-9_-]+", os.path.realpath(parsed.path)): + raise ValueError("Selected serial alias must resolve to a ttyACM/ttyUSB device") + import serial + self.serial = serial.Serial() + self.serial.port = parsed.netloc + parsed.path + self.serial.baudrate = baud + self.serial.timeout = self.serial.write_timeout = 0 + self.serial.exclusive = True + self.serial.dtr = serial_dtr + self.serial.rts = False + assert_port_idle(self.serial.port) + open_configured_session(self.serial) + self.fd = self.serial.fileno() + elif parsed.scheme == "winusb": + self.windows = WindowsUsbStream(endpoint, python=windows_python, expected_serial=expect_serial, + expected_vid=expect_vid, expected_pid=expect_pid, baud=baud, serial_dtr=serial_dtr, + duration=duration) + self.fd = self.windows.read_fd + self.read_fd = self.windows.read_fd if self.windows else self.fd + self.write_fd = self.windows.write_fd if self.windows else self.fd + + def read(self, count): + if self.windows: + return self.windows.read(count) + data = self.socket.recv(count) if self.socket else os.read(self.fd, count) + if not data: + raise ConnectionError("KISS endpoint disconnected") + return data + + def write(self, data): + if self.windows: + return self.windows.write(data) + return self.socket.send(data) if self.socket else os.write(self.fd, data) + + def close(self): + if self.socket: + self.socket.close() + if self.serial: + self.serial.close() + if self.windows: + self.windows.close() + + +def enter_network_namespace(path): + descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC) + try: + target = os.fstat(descriptor) + current = os.stat("/proc/self/ns/net") + if (target.st_dev, target.st_ino) == (current.st_dev, current.st_ino): + raise ValueError("Refusing to create a test TAP in the current host network namespace") + if hasattr(os, "setns"): + os.setns(descriptor, 0x40000000) # CLONE_NEWNET + else: + libc = ctypes.CDLL(None, use_errno=True) + libc.setns.argtypes = (ctypes.c_int, ctypes.c_int) + libc.setns.restype = ctypes.c_int + if libc.setns(descriptor, 0x40000000) != 0: + failure = ctypes.get_errno() + raise OSError(failure, os.strerror(failure), path) + finally: + os.close(descriptor) + + +def create_tap(name): + import fcntl + encoded = name.encode("ascii") + if not encoded or len(encoded) > 15 or b"\0" in encoded: + raise ValueError("TAP interface name must contain 1..15 ASCII characters") + descriptor = os.open("/dev/net/tun", os.O_RDWR | os.O_NONBLOCK | os.O_CLOEXEC) + try: + request = struct.pack("16sH", encoded, 0x0002 | 0x1000) # IFF_TAP | IFF_NO_PI + result = fcntl.ioctl(descriptor, 0x400454CA, request) # TUNSETIFF + return descriptor, result[:16].split(b"\0", 1)[0].decode("ascii") + except BaseException: + os.close(descriptor) + raise + + +def open_transport_then_tap(endpoint, baud, netns, name, *, serial_dtr=False, **transport_options): + """Ordering is intentional: socket routes must remain in host namespace.""" + stream = KissStream(endpoint, baud, serial_dtr=serial_dtr, **transport_options) + try: + enter_network_namespace(netns) + tap_fd, actual_name = create_tap(name) + return stream, tap_fd, actual_name + except BaseException: + stream.close() + raise + + +def emit(value, status_file=None): + text = json.dumps(value, sort_keys=True) + print(text, flush=True) + if status_file: + # Evidence only; no system configuration is written by this helper. + target = Path(status_file) + temporary = target.with_name(target.name + ".tmp") + temporary.write_text(text + "\n", encoding="utf-8") + os.replace(temporary, target) + + +def relay(stream, tap_fd, *, duration=0, queue_depth=8, kiss_port=0, kiss_modem=False, + tx_done_timeout=10, stop=None): + buffers = RelayBuffers(queue_depth, kiss_port=kiss_port, kiss_modem=kiss_modem, + tx_done_timeout=tx_done_timeout) + start = time.monotonic() + while not (stop and stop()) and (not duration or time.monotonic() - start < duration): + buffers.check_tx_done() + buffers.parse_pending() + reads = ([tap_fd] if buffers.can_read_tap() else []) + if buffers.can_read_kiss(): + reads.append(stream.read_fd) + writes = ([tap_fd] if buffers.to_tap else []) + if buffers.can_write_kiss(): + writes.append(stream.write_fd) + ready_read, ready_write, _ = select.select(reads, writes, [], 0.2) + for descriptor in ready_write: + try: + if descriptor == tap_fd: + buffers.write_tap(lambda data: os.write(tap_fd, data)) + else: + buffers.write_kiss(stream.write) + except BlockingIOError: + pass + for descriptor in ready_read: + try: + if descriptor == tap_fd: + # A too-big datagram is discarded, never sent as a truncated frame. + frame = os.read(tap_fd, 65536) + buffers.queue_tap_frame(frame) + else: + buffers.feed_kiss(stream.read(4096)) + except BlockingIOError: + pass + return dict(buffers.stats(), elapsed_seconds=round(time.monotonic() - start, 3)) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--endpoint", required=True) + parser.add_argument("--netns", required=True, help="existing namespace handle, e.g. /var/run/netns/eth-a") + parser.add_argument("--interface", default="lora0") + parser.add_argument("--baud", type=int, default=115200) + parser.add_argument("--kiss-port", type=int, choices=(0, 1), default=0) + parser.add_argument("--serial-dtr", choices=("off", "on"), default="off", + help="explicitly assert native USB CDC DTR; never sends a command") + parser.add_argument("--expect-serial", help="exact USB identity required for winusb") + add_windows_arguments(parser) + parser.add_argument("--kiss-modem", action="store_true", + help="explicit full serial modem: set standard pacing, await each TX_DONE") + parser.add_argument("--tx-done-timeout", type=float, default=10) + parser.add_argument("--queue-depth", type=int, default=8) + parser.add_argument("--duration", type=float, default=0, help="seconds; zero means until terminated") + parser.add_argument("--status-file", help="atomically updated ready/final JSON evidence") + args = parser.parse_args(argv) + if not sys.platform.startswith("linux"): + parser.error("the TAP relay requires Linux") + if args.queue_depth <= 0 or args.queue_depth > 256 or args.duration < 0: + parser.error("queue-depth must be 1..256 and duration must be nonnegative") + if not 1200 <= args.baud <= 2000000: + parser.error("baud must be 1200..2000000") + try: + parsed = validate_endpoint(args.endpoint) + if args.kiss_modem and parsed.scheme not in ("serial", "winusb"): + raise ValueError("--kiss-modem is explicit full serial-modem mode only") + validate_windows_options(parsed, python=args.windows_python, expected_serial=args.expect_serial, + expected_vid=args.expect_vid, expected_pid=args.expect_pid, baud=args.baud, + serial_dtr=args.serial_dtr == "on", duration=args.duration) + if not math.isfinite(args.tx_done_timeout) or not .5 <= args.tx_done_timeout <= 120: + raise ValueError("TX_DONE timeout must be .5..120 seconds") + except ValueError as error: + parser.error(str(error)) + stopping = False + + def stop_signal(signum, frame): + nonlocal stopping + stopping = True + + signal.signal(signal.SIGINT, stop_signal) + signal.signal(signal.SIGTERM, stop_signal) + stream, tap_fd, name = open_transport_then_tap(args.endpoint, args.baud, args.netns, args.interface, + serial_dtr=args.serial_dtr == "on", windows_python=args.windows_python, + expect_serial=args.expect_serial, expect_vid=args.expect_vid, expect_pid=args.expect_pid, + duration=args.duration) + try: + emit({"state": "ready", "endpoint": args.endpoint, "netns": args.netns, + "interface": name, "configure_mtu": 230, "queue_depth": args.queue_depth, + "kiss_port": args.kiss_port, "serial_dtr": args.serial_dtr, + "kiss_modem": args.kiss_modem}, args.status_file) + stats = relay(stream, tap_fd, duration=args.duration, queue_depth=args.queue_depth, kiss_port=args.kiss_port, + kiss_modem=args.kiss_modem, tx_done_timeout=args.tx_done_timeout, + stop=lambda: stopping) + emit({"state": "stopped", "interface": name, "stats": stats}, args.status_file) + return 0 + except Exception as error: + emit({"state": "error", "interface": name, "error": str(error), + "stats": getattr(error, "stats", None)}, args.status_file) + return 1 + finally: + os.close(tap_fd) + stream.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/hil/ethernet_tcp_stats.py b/tools/hil/ethernet_tcp_stats.py new file mode 100644 index 000000000..972d7d6b9 --- /dev/null +++ b/tools/hil/ethernet_tcp_stats.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Summarize only the two explicitly captured isolated-namespace TCP snapshots.""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +FIELDS = ("ActiveOpens", "PassiveOpens", "AttemptFails", "EstabResets", "CurrEstab", + "InSegs", "OutSegs", "RetransSegs", "InErrs", "OutRsts", "InCsumErrors") + + +def parse_tcp(text): + lines = [line.split() for line in text.splitlines() if line.startswith("Tcp:")] + if len(lines) != 2 or len(lines[0]) != len(lines[1]) or len(set(lines[0])) != len(lines[0]): + raise ValueError("missing or ambiguous TCP header/value pair") + values = dict(zip(lines[0][1:], map(int, lines[1][1:]))) + if any(values.get(field, -1) < 0 for field in ("InSegs", "OutSegs", "RetransSegs")): + raise ValueError("required TCP counters are missing or negative") + return {field: values[field] for field in FIELDS if field in values} + + +def summarize(before, after): + delta = {field: after[field] - before[field] for field in before.keys() & after.keys() + if field != "CurrEstab"} + if any(value < 0 for value in delta.values()): + raise ValueError("TCP counters decreased; snapshots are not comparable") + return dict(available=True, scope="isolated_network_namespace", before=before, after=after, + delta=delta, retransmission_scope="total only: includes SSH handshake, commands, data and close; no data-only attribution", + out_segment_retransmission_ratio=(delta["RetransSegs"] / delta["OutSegs"] + if delta["OutSegs"] else None)) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--directory", required=True, type=Path) + args = parser.parse_args(argv) + folder = args.directory + if not folder.is_absolute() or not folder.is_dir(): + parser.error("an existing absolute evidence directory is required") + try: + result = summarize(*(parse_tcp((folder / f"tcp-{phase}.snmp").read_text(encoding="ascii")) + for phase in ("before", "after"))) + result["captured_utc"] = {phase: (folder / f"tcp-{phase}.utc").read_text(encoding="ascii").strip() + for phase in ("before", "after")} + except (OSError, ValueError) as error: + result = dict(available=False, scope="isolated_network_namespace", error=str(error)) + (folder / "tcp-stats.json").write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/hil/meshcore_ip_relay.py b/tools/hil/meshcore_ip_relay.py new file mode 100644 index 000000000..735f5dc82 --- /dev/null +++ b/tools/hil/meshcore_ip_relay.py @@ -0,0 +1,519 @@ +#!/usr/bin/env python3 +"""Point IPv4 TUN <-> existing MeshCore Companion RAW_CUSTOM API (cmd25/push84). + +Uses one explicit TCP5000 or identity-bound USB Companion session, NOT KISS or Ethernet. The radio's +tx.ssh policy and MeshCore radio2 profiles must be configured by the operator; +this helper never edits radio settings, channels, keys or the host network. +Connects the socket before entering an existing isolated namespace, where its +own nonpersistent TUN receives only an explicit peer /32 route. SSH is encrypted +by the hosts, not by this public RAW_CUSTOM envelope. Command OK means accepted +by the Companion, never proof that RF delivered a packet. +""" +from __future__ import annotations + +import argparse +from collections import deque +import ipaddress +import math +import os +from pathlib import Path +import re +import secrets +import select +import signal +import socket +import stat +import struct +import subprocess +import sys +import time + +from ethernet_tap_relay import emit, enter_network_namespace, validate_endpoint +from serial_session import assert_port_idle, open_configured_session +from windows_usb_stdio_bridge import WindowsUsbStream, add_windows_arguments, validate_windows_options + +FRAME_SIZE = 172 +RESPONSE_FRAME_SIZE = 176 # Actual firmware MAX_FRAME_SIZE; does not enlarge outgoing/IP budgets. +ENVELOPE_SIZE = 12 +MAGIC = b"SSH\x01" +MINIMUM_MTU = 94 + + +class MeshPath: + def __init__(self, data=b"", width=1): + self.data = bytes(data) + self.width = width + if type(width) is not int or not 1 <= width <= 3 or len(self.data) > 64 \ + or len(self.data) % width or len(self.data) // width > 63: + raise ValueError("path requires width1..3, count0..63 and at most64 bytes") + + @classmethod + def from_hex(cls, text, width=1): + if not re.fullmatch(r"(?:[a-fA-F0-9]{2})*", text): + raise ValueError("path must contain complete hexadecimal bytes without separators") + return cls(bytes.fromhex(text), width) + + @property + def encoded(self): + return ((self.width - 1) << 6) | (len(self.data) // self.width) + + +def mtu_budget(forward_bytes, return_bytes, requested=None, frame_size=FRAME_SIZE, transport_bytes=0): + if any(type(value) is not int or not 0 <= value <= 64 for value in (forward_bytes, return_bytes)) \ + or not FRAME_SIZE <= frame_size <= 176 or transport_bytes not in (0, 4) \ + or (requested is not None and (type(requested) is not int or requested < MINIMUM_MTU)): + raise ValueError("invalid path/frame/MTU budget; minimum SSH MTU94") + path_bytes = max(forward_bytes, return_bytes) + maximum = min(184, frame_size - 2 - path_bytes, frame_size - 4, + 255 - 2 - path_bytes - transport_bytes) - ENVELOPE_SIZE + return maximum, min(requested if requested is not None else maximum, maximum) + + +def private_host(text): + address = ipaddress.IPv4Address(text) + if str(address) != text or not (address in ipaddress.IPv4Network("10.0.0.0/8") + or address in ipaddress.IPv4Network("172.16.0.0/12") + or address in ipaddress.IPv4Network("192.168.0.0/16")) or address.packed[-1] in (0, 255): + raise ValueError("use explicit private IPv4 host addresses") + return address.packed + + +def valid_ipv4(packet, source, destination, mtu): + if not 20 <= len(packet) <= mtu or packet[0] >> 4 != 4: + return False + header = (packet[0] & 15) * 4 + length = int.from_bytes(packet[2:4], "big") + if not 20 <= header <= 60 or length < header or length != len(packet) \ + or int.from_bytes(packet[6:8], "big") & 0xbfff \ + or packet[12:16] != source or packet[16:20] != destination: + return False + checksum = sum(struct.unpack("!" + "H" * (header // 2), packet[:header])) + while checksum >> 16: + checksum = (checksum & 0xffff) + (checksum >> 16) + if checksum != 0xffff: + return False + if packet[9] == 1: + return length >= header + 8 + return packet[9] == 6 and length >= header + 20 and 20 <= (packet[header + 12] >> 4) * 4 <= length - header + + +class IpCodec: + def __init__(self, local, remote, path, return_bytes, *, mtu=None, session=None, sequence=0): + self.local, self.remote = private_host(local), private_host(remote) + if self.local == self.remote: + raise ValueError("point IPv4 peers must differ") + self.path = path + self.maximum_mtu, self.mtu = mtu_budget(len(path.data), return_bytes, mtu) + self.session = secrets.randbits(32) if session is None else session + self.sequence = sequence + if any(type(value) is not int or not 0 <= value <= 0xffffffff for value in (self.session, sequence)): + raise ValueError("session/sequence must be unsigned32-bit integers") + + def command(self, packet): + if self.sequence > 0xffffffff: + raise OverflowError("sequence exhausted; start a new session") + if not valid_ipv4(packet, self.local, self.remote, self.mtu): + raise ValueError("invalid, fragmented or oversized point IPv4 packet") + envelope = MAGIC + struct.pack(" 4096 or len(self.staging) + len(data) > 4096 + RESPONSE_FRAME_SIZE + 3: + raise BufferError("Companion input staging full") + self.staging.extend(data) + + def pop(self): + while self.ascii_preamble and self.staging: + if self.staging[0] == ord(">"): + if len(self.staging) < 3: + return None + if 1 <= int.from_bytes(self.staging[1:3], "little") <= RESPONSE_FRAME_SIZE: + break + byte = self.staging[0] + if byte not in (9, 10, 13) and not 32 <= byte <= 126: + raise ValueError("non-ASCII bytes before the first Companion frame") + self.preamble_bytes += 1 + if self.preamble_bytes > 4096: + raise BufferError("USB ASCII startup banner exceeded4096 bytes") + del self.staging[0] + if len(self.staging) < 3: + return None + length = int.from_bytes(self.staging[1:3], "little") + if self.staging[0] != ord(">") or not 1 <= length <= RESPONSE_FRAME_SIZE: + raise ValueError("invalid Companion response direction/length") + if len(self.staging) < length + 3: + return None + frame = bytes(self.staging[3:length + 3]) + del self.staging[:length + 3] + self.ascii_preamble = False # Never resynchronize/discard data after binary ownership begins. + return frame + + +class IpRelayBuffers: + def __init__(self, codec, depth=8, command_timeout=12, now=time.monotonic, *, ascii_preamble=False): + if not 1 <= depth <= 64 or not math.isfinite(command_timeout) or not 0 < command_timeout <= 30: + raise ValueError("queue depth1..64 and command timeout(0,30] required") + self.codec, self.depth, self.command_timeout, self.now = codec, depth, command_timeout, now + self.decoder = CompanionDecoder(ascii_preamble=ascii_preamble) + self.to_tun = deque() + self.pending = 1 + self.pending_since = now() + self.output = companion_frame(bytes((1,)) + bytes(7) + b"MeshCore IP") + self.ready = False + self.counters = dict(tun_read=0, tun_read_bytes=0, tun_written=0, tun_written_bytes=0, + accepted_commands=0, rejected_commands=0, invalid_ip=0, + ignored_frames=0, raw_pushes=0) + + def can_read_tun(self): + return self.ready and self.pending is None and not self.output + + def can_read_companion(self): + # A partial TCP header/body MUST allow another socket read. Complete + # staged frames are drained by parse_pending before this is consulted. + return len(self.to_tun) < self.depth + + def queue_tun(self, packet): + if not self.can_read_tun(): + raise BufferError("one outstanding Companion command only") + try: + frame = self.codec.command(packet) + except ValueError: + self.counters["invalid_ip"] += 1 + return False + self.output = companion_frame(frame) + self.pending = 25 + self.pending_since = self.now() + self.counters["tun_read"] += 1 + self.counters["tun_read_bytes"] += len(packet) + return True + + def write_companion(self, write): + if not self.output: + return + count = write(self.output) + if not 0 <= count <= len(self.output): + raise IOError("invalid Companion stream write count") + self.output = self.output[count:] + + def feed_companion(self, data): + if not self.can_read_companion(): + raise BufferError("Companion input must apply backpressure") + self.decoder.feed(data) + self.parse_pending() + + def parse_pending(self): + while len(self.to_tun) < self.depth: + frame = self.decoder.pop() + if frame is None: + return + if frame[0] == 0x84: + self.counters["raw_pushes"] += 1 + packet = self.codec.raw_push(frame) + if packet is None: + self.counters["invalid_ip"] += 1 + else: + self.to_tun.append(packet) + elif self.pending == 1 and frame[0] == 5 and not self.output: + self.ready = True + self.pending = None + elif self.pending == 1 and frame[0] == 1: + raise RuntimeError("Companion rejected APP_START") + elif self.pending == 25 and ((frame[0] == 0 and len(frame) == 1) + or (frame[0] == 1 and len(frame) == 2)) and not self.output: + self.counters["accepted_commands" if frame[0] == 0 else "rejected_commands"] += 1 + self.pending = None + else: + self.counters["ignored_frames"] += 1 + + def write_tun(self, write): + if not self.to_tun: + return + packet = self.to_tun[0] + if write(packet) != len(packet): + raise IOError("partial TUN datagram write") + self.to_tun.popleft() + self.counters["tun_written"] += 1 + self.counters["tun_written_bytes"] += len(packet) + + def check_deadline(self): + if self.pending is not None and self.now() - self.pending_since >= self.command_timeout: + raise TimeoutError("Companion command deadline; delivery was not confirmed") + + def stats(self): + return dict(self.counters, ready=self.ready, pending_command=self.pending, + queued_to_tun=len(self.to_tun), staged_bytes=len(self.decoder.staging), + usb_ascii_preamble_bytes=self.decoder.preamble_bytes, + acceptance_note="Command OK means queued, not RF-delivered; TCP owns retries") + + +def validate_companion_endpoint(endpoint, *, expect_serial=None): + parsed = validate_endpoint(endpoint) + if parsed.scheme == "tcp": + private_host(parsed.hostname or "") + if parsed.port != 5000 or expect_serial is not None: + raise ValueError("use explicit private IPv4 tcp://RADIO:5000 without USB identity options") + elif parsed.scheme == "winusb": + if expect_serial is None: + raise ValueError("Windows USB requires an exact --expect-serial identity") + elif Path(parsed.path).parent != Path("/dev/serial/by-id"): + raise ValueError("RAW USB requires a stable explicit /dev/serial/by-id/ device") + if expect_serial is not None and not re.fullmatch(r"[A-Za-z0-9_.:+-]{1,128}", expect_serial): + raise ValueError("invalid expected USB serial") + return parsed + + +def serial_identity(path, expect_serial=None): + """Read only sysfs/stat; never open or pulse the selected radio.""" + resolved = str(Path(path).resolve(strict=True)) + if not re.fullmatch(r"/dev/tty(?:ACM|USB)[A-Za-z0-9_-]+", resolved): + raise ValueError("Selected USB alias must resolve to ttyACM/ttyUSB") + info = os.stat(resolved) + if not stat.S_ISCHR(info.st_mode): + raise ValueError("Selected USB alias is not a character device") + device = (Path("/sys/class/tty") / Path(resolved).name / "device").resolve(strict=True) + for parent in (device, *device.parents): + serial_path = parent / "serial" + if serial_path.is_file(): + actual_serial = serial_path.read_text().strip() + if not actual_serial or (expect_serial is not None and actual_serial != expect_serial): + raise ValueError("Selected USB serial differs from expected identity") + return resolved, actual_serial, info.st_dev, info.st_ino, info.st_rdev + raise ValueError("Selected USB identity is unavailable") + + +class CompanionStream: + def __init__(self, endpoint, *, baud=115200, serial_dtr=False, expect_serial=None, + windows_python=None, expect_vid=None, expect_pid=None, duration=300): + self.socket = self.serial = self.windows = None + parsed = validate_companion_endpoint(endpoint, expect_serial=expect_serial) + if type(baud) is not int or not 1200 <= baud <= 2000000 or type(serial_dtr) is not bool: + raise ValueError("use baud1200..2000000 and an explicit boolean DTR selection") + validate_windows_options(parsed, python=windows_python, expected_serial=expect_serial, + expected_vid=expect_vid, expected_pid=expect_pid, baud=baud, + serial_dtr=serial_dtr, duration=duration) + try: + if parsed.scheme == "tcp": + self.socket = socket.create_connection((parsed.hostname, 5000), timeout=5) + self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + self.socket.setblocking(False) + self.fd = self.socket.fileno() + elif parsed.scheme == "winusb": + self.windows = WindowsUsbStream(endpoint, python=windows_python, + expected_serial=expect_serial, expected_vid=expect_vid, expected_pid=expect_pid, + baud=baud, serial_dtr=serial_dtr, duration=duration) + self.fd = self.windows.read_fd + else: + before = serial_identity(parsed.path, expect_serial) + assert_port_idle(before[0]) + import serial + self.serial = serial.Serial() + # Open the verified resolved device, not a mutable symlink. + self.serial.port = before[0] + self.serial.baudrate = baud + self.serial.timeout = self.serial.write_timeout = 0 + self.serial.exclusive = True + self.serial.dtr = serial_dtr + self.serial.rts = False + open_configured_session(self.serial) + self.fd = self.serial.fileno() + after = serial_identity(parsed.path, expect_serial) + opened = os.fstat(self.fd) + if before != after or not stat.S_ISCHR(opened.st_mode) or \ + (opened.st_dev, opened.st_ino, opened.st_rdev) != after[2:]: + raise ValueError("USB alias/device identity changed during the exclusive open") + self.read_fd = self.windows.read_fd if self.windows else self.fd + self.write_fd = self.windows.write_fd if self.windows else self.fd + except BaseException: + self.close() + raise + + def read(self, count): + if self.windows: + return self.windows.read(count) + data = self.socket.recv(count) if self.socket else os.read(self.fd, count) + if not data: + raise ConnectionError("Companion endpoint disconnected") + return data + + def write(self, data): + if self.windows: + return self.windows.write(data) + return self.socket.send(data) if self.socket else os.write(self.fd, data) + + def close(self): + if self.socket: + self.socket.close() + if self.serial: + self.serial.close() + if self.windows: + self.windows.close() + + +def create_tun(name): + import fcntl + if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,14}", name): + raise ValueError("use a1..15-character TUN interface name") + descriptor = os.open("/dev/net/tun", os.O_RDWR | os.O_NONBLOCK | os.O_CLOEXEC) + try: + # IFF_TUN_EXCL refuses to attach an existing/persistent interface. + request = struct.pack("16sH", name.encode("ascii"), 0x0001 | 0x1000 | 0x8000) + result = fcntl.ioctl(descriptor, 0x400454CA, request) + return descriptor, result[:16].split(b"\0", 1)[0].decode("ascii") + except BaseException: + os.close(descriptor) + raise + + +def open_transport_then_tun(endpoint, netns, name, local, remote, mtu, *, + baud=115200, serial_dtr=False, expect_serial=None, **transport_options): + stream = CompanionStream(endpoint, baud=baud, serial_dtr=serial_dtr, expect_serial=expect_serial, + **transport_options) + # Open the USB/socket in the original namespace before creating our TUN. + descriptor = None + try: + enter_network_namespace(netns) # Rejects current host netns before any interface creation. + descriptor, actual = create_tun(name) + subprocess.run(["ip", "link", "set", "dev", actual, "mtu", str(mtu), "up"], check=True, timeout=5) + subprocess.run(["ip", "address", "add", local + "/32", "peer", remote + "/32", "dev", actual], check=True, timeout=5) + return stream, descriptor, actual + except BaseException: + if descriptor is not None: + os.close(descriptor) + stream.close() + raise + + +def relay(stream, tun_fd, buffers, *, duration, stop=None, on_ready=None): + started = time.monotonic() + ready_announced = False + while not (stop and stop()) and time.monotonic() - started < duration: + buffers.parse_pending() + buffers.check_deadline() + if buffers.ready and not ready_announced: + ready_announced = True + if on_ready: + on_ready() + reads = ([tun_fd] if buffers.can_read_tun() else []) + if buffers.can_read_companion(): + reads.append(stream.read_fd) + writes = ([tun_fd] if buffers.to_tun else []) + ([stream.write_fd] if buffers.output else []) + ready_read, ready_write, _ = select.select(reads, writes, [], .1) + for descriptor in ready_write: + try: + if descriptor == tun_fd: + buffers.write_tun(lambda packet: os.write(tun_fd, packet)) + else: + buffers.write_companion(stream.write) + except BlockingIOError: + pass + for descriptor in ready_read: + try: + if descriptor == tun_fd: + buffers.queue_tun(os.read(tun_fd, 65536)) # Never truncate an oversized datagram. + else: + buffers.feed_companion(stream.read(4096)) + except BlockingIOError: + pass + return dict(buffers.stats(), elapsed_seconds=round(time.monotonic() - started, 3)) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--endpoint", required=True) + parser.add_argument("--baud", type=int, default=115200) + parser.add_argument("--serial-dtr", choices=("off", "on"), default="off") + parser.add_argument("--expect-serial", help="require this USB chip serial before and after opening") + add_windows_arguments(parser) + parser.add_argument("--netns", required=True) + parser.add_argument("--interface", default="mcip0") + parser.add_argument("--local-address", default="10.90.5.1") + parser.add_argument("--remote-address", default="10.90.5.2") + parser.add_argument("--path-hex", default="") + parser.add_argument("--path-hash-width", type=int, default=1) + parser.add_argument("--return-path-byte-count", type=int) + parser.add_argument("--mtu", type=int) + parser.add_argument("--session", type=lambda value: int(value, 0)) + parser.add_argument("--duration", type=float, default=180) + parser.add_argument("--command-timeout", type=float, default=12) + parser.add_argument("--status-file", type=Path) + parser.add_argument("--ready-after-handshake", action="store_true", + help="publish ready only after the Companion APP_START/SelfInfo handshake") + args = parser.parse_args(argv) + try: + endpoint = validate_companion_endpoint(args.endpoint, expect_serial=args.expect_serial) + validate_windows_options(endpoint, python=args.windows_python, expected_serial=args.expect_serial, + expected_vid=args.expect_vid, expected_pid=args.expect_pid, baud=args.baud, + serial_dtr=args.serial_dtr == "on", duration=args.duration) + if not 1200 <= args.baud <= 2000000: + raise ValueError("baud must be in1200..2000000") + path = MeshPath.from_hex(args.path_hex, args.path_hash_width) + if path.data and args.return_path_byte_count is None: + raise ValueError("nonzero forward path also requires explicit --return-path-byte-count") + if not math.isfinite(args.duration) or not 0 < args.duration <= 3600: + raise ValueError("duration must be finite and in(0,3600]") + if not Path(args.netns).is_absolute() or not Path(args.netns).is_file(): + raise ValueError("use an existing absolute isolated namespace handle") + if args.status_file and (not args.status_file.is_absolute() or not args.status_file.parent.is_dir()): + raise ValueError("status-file requires an absolute existing evidence directory") + codec = IpCodec(args.local_address, args.remote_address, path, args.return_path_byte_count or 0, + mtu=args.mtu, session=args.session) + buffers = IpRelayBuffers(codec, command_timeout=args.command_timeout, + ascii_preamble=endpoint.scheme in ("serial", "winusb")) + except (ValueError, TypeError) as error: + parser.error(str(error)) + if not sys.platform.startswith("linux") or os.geteuid() != 0: + parser.error("the isolated TUN helper requires Linux root") + stopping = False + + def stopped(signum, frame): + nonlocal stopping + stopping = True + + signal.signal(signal.SIGINT, stopped) + signal.signal(signal.SIGTERM, stopped) + stream, tun_fd, name = open_transport_then_tun(args.endpoint, args.netns, args.interface, + args.local_address, args.remote_address, codec.mtu, baud=args.baud, + serial_dtr=args.serial_dtr == "on", expect_serial=args.expect_serial, + windows_python=args.windows_python, expect_vid=args.expect_vid, expect_pid=args.expect_pid, + duration=args.duration) + buffers.pending_since = time.monotonic() # Exclude socket/TUN setup from APP_START deadline. + try: + ready_state = dict(state="ready", transport="meshcore-raw-ip", interface=name, mtu=codec.mtu, + maximum_mtu=codec.maximum_mtu, session=codec.session, + forward_path_bytes=len(path.data), return_path_bytes=args.return_path_byte_count or 0) + emit(dict(ready_state, state="starting" if args.ready_after_handshake else "ready"), args.status_file) + stats = relay(stream, tun_fd, buffers, duration=args.duration, stop=lambda: stopping, + on_ready=(lambda: emit(ready_state, args.status_file)) if args.ready_after_handshake else None) + emit(dict(state="stopped", interface=name, stats=stats), args.status_file) + return 0 + except Exception as error: + emit(dict(state="error", interface=name, error=str(error), stats=buffers.stats()), args.status_file) + return 1 + finally: + os.close(tun_fd) + stream.close() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/hil/meshcore_ip_ssh_phone_bench.sh b/tools/hil/meshcore_ip_ssh_phone_bench.sh new file mode 100644 index 000000000..ca5f6fbc1 --- /dev/null +++ b/tools/hil/meshcore_ip_ssh_phone_bench.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# Explicit RAW Companion endpoint -> one isolated SSH/SFTP server for Android. +# No host routes, firewall, accounts, system sshd or firmware settings change. +set -euo pipefail +umask 077 +endpoint=''; bench_dir=''; prefix=''; account=''; ssh_user=''; duration=1200 +baud=115200; serial_dtr=off; expect_serial='' +die() { printf '%s\n' "$*" >&2; exit 2; } +while (($#)); do + (($# >= 2)) || die 'Every option requires a value.' + case "$1" in + --endpoint) endpoint=$2 ;; --bench-dir) bench_dir=$2 ;; + --prefix) prefix=$2 ;; --duration) duration=$2 ;; + --account) account=$2 ;; --ssh-user) ssh_user=$2 ;; + --baud) baud=$2 ;; --serial-dtr) serial_dtr=$2 ;; --expect-serial) expect_serial=$2 ;; + *) die "Unknown option: $1" ;; + esac + shift 2 +done +[[ ${EUID:-$(id -u)} == 0 ]] || die 'Run as root for the isolated namespace/TUN.' +[[ "$prefix" =~ ^mcphone-[a-z0-9][a-z0-9-]{0,19}$ ]] || die 'Use a unique mcphone- prefix.' +[[ "$duration" =~ ^[0-9]+$ && "$duration" -ge 60 && "$duration" -le 1800 ]] || die 'Duration must be 60..1800 seconds.' +[[ "$baud" =~ ^[0-9]+$ && "$baud" -ge 1200 && "$baud" -le 2000000 ]] || die 'Baud must be 1200..2000000.' +[[ "$serial_dtr" == off || "$serial_dtr" == on ]] || die 'Serial DTR must be off or explicitly on.' +[[ -z "$expect_serial" || "$expect_serial" =~ ^[A-Za-z0-9_-]{1,128}$ ]] || die 'Invalid expected USB serial.' +[[ "$account" =~ ^[a-z_][a-z0-9_-]*$ ]] || die 'Specify an existing unprivileged --account.' +[[ $(id -u "$account") -ne 0 ]] || die 'The test account must not be root.' +ssh_user=${ssh_user:-$account} +[[ "$ssh_user" == "$account" ]] || die 'SSH user must match the private artifact/key owner account.' +[[ "$bench_dir" == /* && -d "$bench_dir" && "$bench_dir" != / ]] || die 'Use an existing absolute bench directory.' +[[ -x /usr/sbin/sshd && -c /dev/net/tun ]] || die 'sshd and /dev/net/tun are required.' +for dependency in ip python3 ssh-keygen stat realpath runuser sysctl ss mktemp; do + command -v "$dependency" >/dev/null || die "Missing dependency: $dependency" +done +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +python3 - "$endpoint" "$script_dir" "$expect_serial" <<'PY' +import sys +sys.path.insert(0, sys.argv[2]) +from meshcore_ip_relay import validate_companion_endpoint +try: + validate_companion_endpoint(sys.argv[1], expect_serial=(sys.argv[3] or None) if len(sys.argv) > 3 else None) +except ValueError as error: + raise SystemExit(str(error)) +PY +relay="$script_dir/meshcore_ip_relay.py" +[[ -r "$relay" ]] || die 'The RAW IP relay must be beside this script.' +bench_dir=$(realpath -e -- "$bench_dir") +namespace="$prefix" +[[ ! -e "/var/run/netns/$namespace" ]] || die 'Namespace already exists; refusing to reuse it.' +run_dir=$(mktemp -d -- "$bench_dir/meshcore-phone.XXXXXXXX") +[[ "$run_dir" == "$bench_dir/meshcore-phone."* ]] || die 'Unexpected artifact directory.' +created=0; inode=''; relay_pid=''; sshd_pid='' +cleanup() { + local result=$? observed='' child parent + trap - EXIT INT TERM HUP + set +e + for child in "$relay_pid" "$sshd_pid"; do + [[ "$child" =~ ^[0-9]+$ ]] || continue + parent=$(awk '/^PPid:/ {print $2}' "/proc/$child/status" 2>/dev/null) + [[ "$parent" == "$$" ]] && kill -TERM "$child" 2>/dev/null + done + if ((created)); then + observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$namespace" 2>/dev/null) + if [[ -n "$inode" && "$observed" == "$inode" ]]; then + while read -r child; do + [[ "$child" =~ ^[0-9]+$ ]] && kill -TERM "$child" 2>/dev/null + done < <(ip netns pids "$namespace") + sleep 0.3 + observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$namespace" 2>/dev/null) + if [[ "$observed" == "$inode" ]]; then + while read -r child; do + [[ "$child" =~ ^[0-9]+$ ]] && kill -KILL "$child" 2>/dev/null + done < <(ip netns pids "$namespace") + ip netns delete "$namespace" + fi + fi + fi + # A connecting relay still in the host namespace must not strand cleanup. + # Reap only our explicit children, never search/kill another test's process. + for child in "$relay_pid" "$sshd_pid"; do + [[ "$child" =~ ^[0-9]+$ ]] || continue + for _ in {1..20}; do + kill -0 "$child" 2>/dev/null || break + sleep 0.1 + done + parent=$(awk '/^PPid:/ {print $2}' "/proc/$child/status" 2>/dev/null) + [[ "$parent" == "$$" ]] && kill -KILL "$child" 2>/dev/null + wait "$child" 2>/dev/null + done + rm -f -- "$run_dir/host_key" "$run_dir/client_key" + printf 'exit=%s\n' "$result" >> "$run_dir/result.txt" + chown "$account": "$run_dir/result.txt" 2>/dev/null + printf 'RAW phone bench stopped; ephemeral keys removed. Evidence: %s\n' "$run_dir" + exit "$result" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM HUP +chown "$account": "$run_dir" +chmod 700 -- "$run_dir" +ssh-keygen -q -t ed25519 -N '' -f "$run_dir/host_key" +runuser -u "$account" -- ssh-keygen -q -t ed25519 -N '' -f "$run_dir/client_key" +ip netns add "$namespace"; created=1 +inode=$(stat -Lc '%d:%i' -- "/var/run/netns/$namespace") +ip -n "$namespace" link set lo up +ip netns exec "$namespace" sysctl -qw net.ipv6.conf.all.disable_ipv6=1 +ip netns exec "$namespace" sysctl -qw net.ipv6.conf.default.disable_ipv6=1 +deadline=$((SECONDS + duration)) +serial_options=(--baud "$baud" --serial-dtr "$serial_dtr") +[[ -z "$expect_serial" ]] || serial_options+=(--expect-serial "$expect_serial") +python3 "$relay" --endpoint "$endpoint" --netns "/var/run/netns/$namespace" \ + "${serial_options[@]}" \ + --interface mcip0 --local-address 10.90.5.2 --remote-address 10.90.5.1 \ + --path-hex '' --path-hash-width 1 --return-path-byte-count 0 --mtu 156 \ + --duration "$duration" --ready-after-handshake --status-file "$run_dir/relay.json" \ + > "$run_dir/relay.log" 2>&1 & +relay_pid=$! +ready=0 +for _ in {1..100}; do + kill -0 "$relay_pid" 2>/dev/null || die 'RAW relay exited; inspect its bounded evidence.' + if python3 - "$run_dir/relay.json" <<'PY' +import json, sys +try: + with open(sys.argv[1], encoding='utf-8') as source: + state = json.load(source) + valid = (state.get('state') == 'ready' and state.get('transport') == 'meshcore-raw-ip' + and state.get('interface') == 'mcip0' and state.get('mtu') == 156 + and state.get('forward_path_bytes') == 0 and state.get('return_path_bytes') == 0) +except (OSError, ValueError): + valid = False +raise SystemExit(0 if valid else 1) +PY + then ready=1; break; fi + sleep 0.2 +done +((ready)) || die 'Companion APP_START/SelfInfo handshake did not become ready.' +ip -n "$namespace" link show dev mcip0 >/dev/null +ip -n "$namespace" route show > "$run_dir/routes.txt" +options=(-f /dev/null -h "$run_dir/host_key" -p 2222 + -o ListenAddress=10.90.5.2 -o "PidFile=$run_dir/sshd.pid" + -o "AuthorizedKeysFile=$run_dir/client_key.pub" -o StrictModes=no + -o "AllowUsers=$ssh_user" -o PermitRootLogin=no -o UsePAM=no + -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no + -o AuthenticationMethods=publickey -o DisableForwarding=yes + -o PermitUserEnvironment=no -o X11Forwarding=no -o PermitTTY=yes + -o 'Subsystem=sftp internal-sftp') +/usr/sbin/sshd -t "${options[@]}" +ip netns exec "$namespace" /usr/sbin/sshd -D -e "${options[@]}" > "$run_dir/sshd.log" 2>&1 & +sshd_pid=$! +listening=0 +for _ in {1..30}; do + kill -0 "$relay_pid" && kill -0 "$sshd_pid" || die 'An owned fixture child exited.' + if ip netns exec "$namespace" ss -H -ltn | grep -q '10\.90\.5\.2:2222'; then listening=1; break; fi + sleep 0.1 +done +((listening)) || die 'The isolated SSH/SFTP listener did not become ready.' +printf 'READY: transport=raw gateway=%s local=10.90.5.1 remote=10.90.5.2 MTU=156 SSH=2222 user=%s\n' "$endpoint" "$ssh_user" +printf 'Ephemeral client private key file (never print contents): %s/client_key\n' "$run_dir" +ssh-keygen -lf "$run_dir/host_key.pub" +printf 'Artifacts: %s\n' "$run_dir" +printf 'Server helper PID: %s\n' "$$" +while ((SECONDS < deadline)); do + kill -0 "$relay_pid" && kill -0 "$sshd_pid" || die 'An owned fixture child exited.' + sleep 1 +done diff --git a/tools/hil/meshcore_usb_sftp_bench.sh b/tools/hil/meshcore_usb_sftp_bench.sh new file mode 100644 index 000000000..36d9e3fe7 --- /dev/null +++ b/tools/hil/meshcore_usb_sftp_bench.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# Matched RAW156 / full KISS230 USB-LoRa-USB SFTP client. No host routes. +set -euo pipefail +mode=''; endpoint=''; windows_python=''; expect_serial=''; expect_vid=''; expect_pid='' +bench_dir=''; workspace=''; dart=''; config=''; prefix=''; account=''; baud=115200; dtr=off +die() { printf '%s\n' "$*" >&2; exit 2; } +while (($#)); do + (($# >= 2)) || die 'Every option requires a value.' + case "$1" in + --mode) mode=$2 ;; --endpoint) endpoint=$2 ;; + --windows-python) windows_python=$2 ;; --expect-serial) expect_serial=$2 ;; + --expect-vid) expect_vid=$2 ;; --expect-pid) expect_pid=$2 ;; + --bench-dir) bench_dir=$2 ;; --workspace) workspace=$2 ;; --dart) dart=$2 ;; + --config) config=$2 ;; --prefix) prefix=$2 ;; --account) account=$2 ;; + --baud) baud=$2 ;; --serial-dtr) dtr=$2 ;; + *) die "Unknown option: $1" ;; + esac + shift 2 +done +[[ ${EUID:-$(id -u)} == 0 ]] || die 'Root is required only for the isolated namespace/TUN.' +[[ "$mode" == raw || "$mode" == kiss ]] || die 'Choose --mode raw or kiss explicitly.' +[[ "$endpoint" =~ ^winusb://COM[1-9][0-9]*$ ]] || die 'Choose one explicit winusb://COMn endpoint.' +[[ "$prefix" =~ ^mcusb-[a-z0-9][a-z0-9-]{0,19}$ ]] || die 'Use a unique mcusb- prefix.' +[[ "$account" =~ ^[a-z_][a-z0-9_-]*$ && $(id -u "$account") -ne 0 ]] || die 'Choose an unprivileged existing account.' +[[ "$baud" =~ ^[0-9]+$ && "$baud" -ge 1200 && "$baud" -le 2000000 ]] || die 'Invalid baud.' +[[ "$dtr" == off || "$dtr" == on ]] || die 'Choose DTR off or on explicitly.' +[[ "$expect_serial" =~ ^[A-Za-z0-9_-]{1,128}$ ]] || die 'An exact USB serial is required.' +[[ "$expect_vid" =~ ^0x[0-9A-Fa-f]{4}$ && "$expect_pid" =~ ^0x[0-9A-Fa-f]{4}$ ]] || die 'Exact hexadecimal USB VID/PID are required.' +for directory in "$bench_dir" "$workspace"; do + [[ "$directory" == /* && "$directory" != / && -d "$directory" ]] || die 'Use existing absolute directories.' +done +[[ "$windows_python" == /* && -x "$windows_python" && "$dart" == /* && -x "$dart" ]] || die 'Choose absolute Python.exe and Dart executable paths.' +[[ "$config" == /* && -f "$config" && ! -L "$config" ]] || die 'Choose the private regular configuration file.' +[[ -f "$workspace/tool/hil/lora_sftp_bench.dart" && -f "$workspace/.dart_tool/package_config.json" ]] || die 'The prepared Dart workspace is required.' +[[ ! -e "/var/run/netns/$prefix" ]] || die 'Refusing to reuse a namespace.' +mtu=156; interface=mcip0 +if [[ "$mode" == kiss ]]; then mtu=230; interface=lora0; fi +python3 - "$config" "$mtu" "$account" <<'PY' +import json, os, pwd, stat, sys +path, mtu, account = sys.argv[1:] +info = os.lstat(path) +if not stat.S_ISREG(info.st_mode) or info.st_uid != pwd.getpwnam(account).pw_uid or info.st_mode & 0o077: + raise SystemExit('Configuration must be owner-only and owned by the selected account.') +with open(path, encoding='utf-8') as source: + config = json.load(source) +if config.get('mtu') != int(mtu) or config.get('enabled') is not True: + raise SystemExit('Configuration MTU must match the explicitly selected transport.') +PY +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +run_dir=$(mktemp -d -- "$bench_dir/usb-sftp.XXXXXXXX") +chown "$account": "$run_dir"; chmod 700 -- "$run_dir" +created=0; inode=''; relay_pid=''; client_pid=''; monitor_pid=''; tcp_started=0 +capture_tcp() { + local phase=$1 observed='' + observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$prefix" 2>/dev/null) || return 1 + [[ -n "$inode" && "$observed" == "$inode" ]] || return 1 + date -u +%Y-%m-%dT%H:%M:%SZ > "$run_dir/tcp-$phase.utc" + ip netns exec "$prefix" cat /proc/net/snmp > "$run_dir/tcp-$phase.snmp" +} +cleanup() { + local result=$? observed='' child parent + trap - EXIT INT TERM HUP + set +e + if ((tcp_started)); then + capture_tcp after + python3 "$script_dir/ethernet_tcp_stats.py" --directory "$run_dir" > "$run_dir/tcp-stats.log" 2>&1 + fi + for child in "$client_pid" "$monitor_pid" "$relay_pid"; do + [[ "$child" =~ ^[0-9]+$ ]] || continue + parent=$(awk '/^PPid:/ {print $2}' "/proc/$child/status" 2>/dev/null) + [[ "$parent" == "$$" ]] && kill -TERM "$child" 2>/dev/null + done + if ((created)); then + observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$prefix" 2>/dev/null) + if [[ -n "$inode" && "$observed" == "$inode" ]]; then + while read -r child; do + [[ "$child" =~ ^[0-9]+$ ]] && kill -TERM "$child" 2>/dev/null + done < <(ip netns pids "$prefix") + sleep 0.3 + observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$prefix" 2>/dev/null) + if [[ "$observed" == "$inode" ]]; then + while read -r child; do + [[ "$child" =~ ^[0-9]+$ ]] && kill -KILL "$child" 2>/dev/null + done < <(ip netns pids "$prefix") + ip netns delete "$prefix" + fi + fi + fi + for child in "$client_pid" "$monitor_pid" "$relay_pid"; do + [[ "$child" =~ ^[0-9]+$ ]] || continue + for _ in {1..30}; do + kill -0 "$child" 2>/dev/null || break + sleep 0.1 + done + parent=$(awk '/^PPid:/ {print $2}' "/proc/$child/status" 2>/dev/null) + [[ "$parent" == "$$" ]] && kill -KILL "$child" 2>/dev/null + wait "$child" 2>/dev/null + done + printf 'mode=%s\nmtu=%s\nexit=%s\n' "$mode" "$mtu" "$result" >> "$run_dir/result.txt" + for evidence in relay.json relay.log result.txt client.log interface.json routes.txt \ + tcp-before.utc tcp-before.snmp tcp-after.utc tcp-after.snmp tcp-stats.log tcp-stats.json tcp-ss.txt; do + [[ -f "$run_dir/$evidence" && ! -L "$run_dir/$evidence" ]] && chown "$account": "$run_dir/$evidence" + done + printf 'USB SFTP artifacts: %s\n' "$run_dir" + exit "$result" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM HUP +ip netns add "$prefix"; created=1 +inode=$(stat -Lc '%d:%i' -- "/var/run/netns/$prefix") +ip -n "$prefix" link set lo up +ip netns exec "$prefix" sysctl -qw net.ipv6.conf.all.disable_ipv6=1 +ip netns exec "$prefix" sysctl -qw net.ipv6.conf.default.disable_ipv6=1 +usb_args=(--endpoint "$endpoint" --windows-python "$windows_python" --expect-serial "$expect_serial" + --expect-vid "$expect_vid" --expect-pid "$expect_pid" --baud "$baud" --serial-dtr "$dtr" + --netns "/var/run/netns/$prefix" --interface "$interface" --duration 660 --status-file "$run_dir/relay.json") +if [[ "$mode" == raw ]]; then + python3 "$script_dir/meshcore_ip_relay.py" "${usb_args[@]}" --local-address 10.90.5.1 \ + --remote-address 10.90.5.2 --path-hex '' --path-hash-width 1 --return-path-byte-count 0 \ + --mtu 156 --ready-after-handshake > "$run_dir/relay.log" 2>&1 & +else + python3 "$script_dir/ethernet_tap_relay.py" "${usb_args[@]}" --kiss-port 1 --kiss-modem \ + > "$run_dir/relay.log" 2>&1 & +fi +relay_pid=$! +ready=0 +for _ in {1..150}; do + kill -0 "$relay_pid" 2>/dev/null || die 'USB relay exited; inspect its bounded log.' + if python3 - "$run_dir/relay.json" "$mode" <<'PY' +import json, sys +try: + with open(sys.argv[1], encoding='utf-8') as source: + value = json.load(source) + ready = value.get('state') == 'ready' +except (OSError, ValueError): + ready = False +raise SystemExit(0 if ready else 1) +PY + then ready=1; break; fi + sleep 0.2 +done +((ready)) || die 'USB relay handshake did not become ready.' +if [[ "$mode" == kiss ]]; then + ip -n "$prefix" link set "$interface" address 02:90:05:00:00:01 mtu 230 up + ip -n "$prefix" address add 10.90.5.1/24 dev "$interface" +fi +actual_mtu=$(ip -n "$prefix" -j link show dev "$interface" | python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["mtu"])') +[[ "$actual_mtu" == "$mtu" ]] || die 'The actual isolated interface MTU differs.' +ip -n "$prefix" -j address show dev "$interface" > "$run_dir/interface.json" +ip -n "$prefix" route show > "$run_dir/routes.txt" +capture_tcp before; tcp_started=1 +printf 'READY: mode=%s MTU=%s namespace=%s\nArtifacts: %s\n' "$mode" "$mtu" "$prefix" "$run_dir" +timeout --kill-after=5s 600s ip netns exec "$prefix" runuser -u "$account" -- \ + "$dart" "--packages=$workspace/.dart_tool/package_config.json" "$workspace/tool/hil/lora_sftp_bench.dart" \ + --config "$config" --report "$run_dir/sftp.json" > "$run_dir/client.log" 2>&1 & +client_pid=$! +( + for _ in {1..100}; do + kill -0 "$client_pid" 2>/dev/null || break + date -u +%Y-%m-%dT%H:%M:%SZ + timeout --kill-after=1s 2s ip netns exec "$prefix" ss -tin dst 10.90.5.2 | head -n 8 + sleep 2 + done +) > "$run_dir/tcp-ss.txt" 2>&1 & +monitor_pid=$! +client_result=0 +wait "$client_pid" || client_result=$? +client_pid='' +exit "$client_result" diff --git a/tools/hil/serial_session.py b/tools/hil/serial_session.py index 828f7ad7e..16af4ed68 100644 --- a/tools/hil/serial_session.py +++ b/tools/hil/serial_session.py @@ -1,5 +1,81 @@ """Open an already configured serial session without a bridge reset pulse.""" import os +import re +import shutil +import subprocess +import sys +import time + + +class SerialCommandTimeout(TimeoutError): + def __init__(self, partial_reply): + super().__init__("Timed out waiting for this command's exact echo and completed terminal prompt") + self.partial_reply = partial_reply + + +def _command_echoes(command): + echoes = [command] + # Match the firmware's intentional password masking, not a wildcard echo. + # Masked UTF-8 values occupy one '*' for each input byte on the device. + for pattern in (r"^(\s*login[ \t]+)(.+)$", r"^(\s*set[ \t]+wifi\.pwd[ \t]+)(.+)$", + r"^(\s*set[ \t]+mt\.channel[ \t]+\S+[ \t]+\S+[ \t]+)(.+)$"): + match = re.match(pattern, command) + if match: + echoes.append(match[1] + "*" * len(match[2].encode("utf-8"))) + break + return echoes + + +def read_command_reply(port, command, timeout=10): + """Wait for this exact echoed command and its completion prompt, not silence. + + A settings save can pause for over 350 ms after the immediate input echo. + Returning at that silence would attribute the delayed OK to the next command. + Stale responses/prompts before this command's echo cannot finish the read. + """ + if not command or any(value in command for value in "\r\n\0") or timeout <= 0: + raise ValueError("one nonempty command and a positive timeout are required") + echoed = re.compile(r"(?:^|\r?\n)(?:> ?)?(?:" + "|".join( + re.escape(value) for value in _command_echoes(command)) + r")\r?\n") + # The real terminal writes "> ". Requiring its trailing space avoids + # returning on a fragmented '>' and leaving that space to poison next echo. + prompt = re.compile(r"(?:^|\r?\n)>[ \t]+(?=$|\r?\n)") + deadline = time.monotonic() + timeout + data = bytearray() + while time.monotonic() < deadline: + chunk = port.read(min(port.in_waiting or 1, 4096)) + if not chunk: + continue + data.extend(chunk) + if len(data) > 65536: + raise RuntimeError("CLI transaction output exceeded the bounded capture limit") + text = data.decode("utf-8", errors="replace") + match = echoed.search(text) + if match and prompt.search(text[match.end():]): + return text + raise SerialCommandTimeout(data.decode("utf-8", errors="replace")) + + +def assert_port_idle(path): + """Optional Linux preflight: never open/reset a port owned by another tool. + + fuser does not open the serial device. This is an extra immediate check, + not a substitute for an exclusive open or coordination with the flasher. + """ + if not sys.platform.startswith("linux"): + return + inspector = shutil.which("fuser") + if not inspector: + return + try: + result = subprocess.run([inspector, "--", os.path.realpath(path)], + capture_output=True, text=True, timeout=3) + except (OSError, subprocess.TimeoutExpired) as error: + raise RuntimeError("Cannot safely inspect serial-port ownership") from error + if result.returncode == 0: + raise RuntimeError("Selected serial port is already in use; finish the flasher/client first") + if result.returncode != 1: + raise RuntimeError("Cannot safely inspect serial-port ownership (fuser error)") def open_configured_session(port): diff --git a/tools/hil/windows_usb_stdio_bridge.py b/tools/hil/windows_usb_stdio_bridge.py new file mode 100644 index 000000000..8ee200d95 --- /dev/null +++ b/tools/hil/windows_usb_stdio_bridge.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +"""Identity-bound Windows USB <-> binary stdio, and a WSL pipe transport. + +No commands, resets, listening sockets, drivers or USB assignments are changed. +Only the selected COM handle is opened. Child stdout is exclusively raw bytes; +stderr carries at most a ready record and a final, payload-free status record. +The Windows supervisor closes USB on stdin EOF or its own deadline, including +when its daemon stdout pump is blocked by a stopped Linux consumer. +""" +from __future__ import annotations + +import argparse +import json +import math +import os +from pathlib import Path +import re +import select +import subprocess +import sys +import threading +import time +from urllib.parse import urlparse + +from serial_session import open_configured_session + +PROTOCOL = "meshcore-usb-stdio-1" +MAX_STATUS_BYTES = 4096 + + +def validate_windows_endpoint(endpoint): + parsed = urlparse(endpoint) + if parsed.scheme != "winusb" or not re.fullmatch(r"COM[1-9][0-9]{0,3}", parsed.netloc, re.I) \ + or parsed.path or parsed.query or parsed.fragment: + raise ValueError("use an explicit winusb://COM6 endpoint without other URL fields") + return parsed + + +def validate_settings(port, expected_serial, expected_vid, expected_pid, baud, serial_dtr, duration): + if not re.fullmatch(r"COM[1-9][0-9]{0,3}", port, re.I): + raise ValueError("invalid selected COM port") + if not isinstance(expected_serial, str) or not re.fullmatch(r"[A-Za-z0-9_.:+-]{1,128}", expected_serial): + raise ValueError("an exact nonempty USB serial is required") + if any(type(value) is not int or not 1 <= value <= 0xffff for value in (expected_vid, expected_pid)): + raise ValueError("exact USB VID and PID are required") + if type(baud) is not int or not 1200 <= baud <= 2000000 or type(serial_dtr) is not bool: + raise ValueError("invalid baud or explicit DTR selection") + if not isinstance(duration, (int, float)) or not math.isfinite(duration) or not 0 < duration <= 3600: + raise ValueError("a finite USB ownership deadline in (0,3600] is required") + + +def selected_identity(port, expected_serial, expected_vid, expected_pid, inventory): + matches = [item for item in inventory() if item.device.upper() == port.upper()] + if len(matches) != 1: + raise ValueError("selected COM identity is missing or ambiguous") + item = matches[0] + identity = dict(port=port.upper(), serial=item.serial_number, vid=item.vid, pid=item.pid) + if (identity["serial"], identity["vid"], identity["pid"]) != (expected_serial, expected_vid, expected_pid): + raise ValueError("selected COM USB identity does not match") + return identity + + +def open_selected_port(port, expected_serial, expected_vid, expected_pid, *, baud, serial_dtr, + inventory, serial_factory): + """Verify twice; Windows CreateFile/pySerial opens the handle exclusively.""" + before = selected_identity(port, expected_serial, expected_vid, expected_pid, inventory) + device = serial_factory() + try: + device.port, device.baudrate = port.upper(), baud + device.timeout, device.write_timeout = .05, .5 + device.exclusive = True + device.xonxoff = device.rtscts = device.dsrdtr = False + device.dtr, device.rts = serial_dtr, False + open_configured_session(device) + after = selected_identity(port, expected_serial, expected_vid, expected_pid, inventory) + if before != after: + raise ValueError("selected USB identity changed during open") + return device, dict(after, baud=baud, serial_dtr=serial_dtr) + except BaseException: + device.close() + raise + + +def pump_stdio(device, input_fd, output_fd, *, duration, read=os.read, write=os.write, + now=time.monotonic): + """Bounded USB ownership; daemon workers cannot hold COM past supervision.""" + stopping = threading.Event() + counters = {"to_usb": 0, "from_usb": 0} + reason = ["deadline"] + + def failed(code): + reason[0] = code + stopping.set() + + def to_usb(): + try: + while not stopping.is_set(): + data = read(input_fd, 4096) # os.read returns short pipe data immediately. + if not data: + failed("stdin_eof") + return + offset = 0 + until = now() + 1 + while offset < len(data) and not stopping.is_set(): + count = device.write(data[offset:]) + if not isinstance(count, int) or not 0 <= count <= len(data) - offset: + raise IOError("invalid serial write length") + if count: + offset += count + counters["to_usb"] += count + elif now() >= until: + raise TimeoutError("serial write deadline") + else: + stopping.wait(.005) + except Exception: + failed("input_or_usb_write_error") + + def from_usb(): + try: + while not stopping.is_set(): + # A short interactive SSH packet must not wait to fill 4096 + # bytes (or pay one serial timeout on every round trip). + data = device.read(min(device.in_waiting or 1, 4096)) + offset = 0 + while offset < len(data) and not stopping.is_set(): + count = write(output_fd, data[offset:]) + if not isinstance(count, int) or not 0 < count <= len(data) - offset: + raise IOError("invalid stdout write length") + offset += count + counters["from_usb"] += count + except Exception: + failed("usb_read_or_output_error") + + workers = [threading.Thread(target=target, daemon=True) for target in (to_usb, from_usb)] + start = now() + try: + for worker in workers: + worker.start() + while not stopping.wait(min(.05, max(0, duration - (now() - start)))): + if now() - start >= duration: + break + finally: + stopping.set() + # The supervisor never joins a potentially blocked stdout writer first. + for method in ("cancel_read", "cancel_write"): + try: + getattr(device, method)() + except Exception: + pass + device.close() + for worker in workers: + worker.join(.1) + return dict(counters, reason=reason[0]) + + +def _ready_record(process, timeout=10): + captured = bytearray() + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + ready, _, _ = select.select([process.stderr.fileno()], [], [], min(.1, max(0, deadline - time.monotonic()))) + if not ready: + if process.poll() is not None: + break + continue + chunk = os.read(process.stderr.fileno(), 512) + if not chunk: + break + captured.extend(chunk) + if len(captured) > MAX_STATUS_BYTES: + raise RuntimeError("Windows USB status exceeded bounded limit") + if b"\n" in captured: + try: + value = json.loads(captured.split(b"\n", 1)[0]) + except (ValueError, UnicodeError) as error: + raise RuntimeError("Invalid Windows USB status") from error + if not isinstance(value, dict) or value.get("state") != "ready" or value.get("protocol") != PROTOCOL: + raise RuntimeError("Windows USB bridge did not become ready") + return value + raise TimeoutError("Windows USB bridge ready deadline") + + +class WindowsUsbStream: + """Linux select-compatible separate FDs backed by one owned Windows COM.""" + def __init__(self, endpoint, *, python, expected_serial, expected_vid, expected_pid, + baud=115200, serial_dtr=False, duration=300): + self.process = None + parsed = validate_windows_endpoint(endpoint) + port = parsed.netloc.upper() + validate_settings(port, expected_serial, expected_vid, expected_pid, baud, serial_dtr, duration) + executable = Path(python or "") + if not executable.is_absolute() or executable.name.lower() != "python.exe" or not executable.is_file(): + raise ValueError("use an existing absolute WSL path to the trusted Windows python.exe") + if not sys.platform.startswith("linux"): + raise ValueError("WindowsUsbStream is a WSL Linux transport") + script = subprocess.run(["wslpath", "-w", str(Path(__file__).resolve())], + check=True, capture_output=True, text=True, timeout=5).stdout.strip() + if not re.fullmatch(r"[A-Za-z]:\\[^\r\n\0]+\.py", script): + raise ValueError("trusted bridge must reside on an accessible Windows drive") + command = [str(executable), "-u", script, "--port", port, + "--expect-serial", expected_serial, "--expect-vid", str(expected_vid), + "--expect-pid", str(expected_pid), "--baud", str(baud), + "--serial-dtr", "on" if serial_dtr else "off", "--duration", str(duration)] + try: + self.process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, bufsize=0, shell=False) + record = _ready_record(self.process) + expected = dict(port=port, serial=expected_serial, vid=expected_vid, pid=expected_pid, + baud=baud, serial_dtr=serial_dtr) + if record.get("identity") != expected: + raise ValueError("Windows bridge ready identity does not match") + self.identity = expected + self.read_fd, self.write_fd = self.process.stdout.fileno(), self.process.stdin.fileno() + self.fd = self.read_fd # Compatibility only; relays use separate read/write FDs. + os.set_blocking(self.read_fd, False) + os.set_blocking(self.write_fd, False) + except BaseException: + self.close() + raise + + def read(self, count): + data = os.read(self.read_fd, count) + if not data: + raise ConnectionError("Windows USB bridge disconnected") + return data + + def write(self, data): + return os.write(self.write_fd, data) + + def close(self): + process, self.process = self.process, None + if process is None: + return + # EOF is an independent Windows-side stop signal, not a USB reset. + if process.stdin: + process.stdin.close() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.terminate() + try: + process.wait(timeout=1) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=1) + finally: + for pipe in (process.stdout, process.stderr): + if pipe: + pipe.close() + + +def add_windows_arguments(parser): + parser.add_argument("--windows-python", help="absolute WSL path to trusted Windows python.exe for winusb") + parser.add_argument("--expect-vid", type=lambda value: int(value, 0)) + parser.add_argument("--expect-pid", type=lambda value: int(value, 0)) + + +def validate_windows_options(parsed, *, python, expected_serial, expected_vid, expected_pid, + baud, serial_dtr, duration): + if parsed.scheme == "winusb": + validate_settings(parsed.netloc.upper(), expected_serial, expected_vid, expected_pid, + baud, serial_dtr, duration) + if not python: + raise ValueError("winusb requires --windows-python and exact --expect-serial/vid/pid") + elif python is not None or expected_vid is not None or expected_pid is not None: + raise ValueError("Windows USB options require winusb://COM6") + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", required=True) + parser.add_argument("--expect-serial", required=True) + parser.add_argument("--expect-vid", type=lambda value: int(value, 0), required=True) + parser.add_argument("--expect-pid", type=lambda value: int(value, 0), required=True) + parser.add_argument("--baud", type=int, default=115200) + parser.add_argument("--serial-dtr", choices=("off", "on"), default="off") + parser.add_argument("--duration", type=float, required=True) + args = parser.parse_args(argv) + device = None + + def status(value): + # Exactly two small records maximum. Never include payloads/exceptions. + sys.stderr.write(json.dumps(dict(protocol=PROTOCOL, **value), sort_keys=True) + "\n") + sys.stderr.flush() + + try: + if os.name != "nt": + raise ValueError("Windows child only") + validate_settings(args.port, args.expect_serial, args.expect_vid, args.expect_pid, + args.baud, args.serial_dtr == "on", args.duration) + import msvcrt + import serial + from serial.tools.list_ports import comports + msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY) + msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY) + device, identity = open_selected_port(args.port, args.expect_serial, args.expect_vid, args.expect_pid, + baud=args.baud, serial_dtr=args.serial_dtr == "on", inventory=comports, serial_factory=serial.Serial) + status(dict(state="ready", identity=identity)) + stats = pump_stdio(device, sys.stdin.fileno(), sys.stdout.fileno(), duration=args.duration) + device = None # pump owns close, even on EOF/deadline. + status(dict(state="stopped", stats=stats)) + return 0 + except Exception: + status(dict(state="error", error="USB bridge stopped; check exact identity and exclusive ownership")) + return 1 + finally: + if device: + device.close() + + +if __name__ == "__main__": + raise SystemExit(main())