Add isolated Ethernet and SSH throughput test harnesses

This commit is contained in:
mikecarper committed 2026-10-15 11:05:46 -07:00
1 parent 2c4a51c9ba
commit ecbca6a84c
35 files changed
+7211

No files matched your search

+140
View File
@@ -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()
+289
View File
@@ -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("<I", probe.GUARD_HZ), 0x11: b"\2\0",
0x01: bytes(range(32)), 0x17: b"",
0x12: struct.pack("<III", 10, self.tx, 0),
0x0B: probe.PRIMARY_STRUCT.pack(*probe.PRIMARY),
0x20: probe.SECONDARY_STRUCT.pack(*probe.SECONDARY), 0x0C: b"\0"}
result = self.override.get(command, response(command | 0x80, body[command])
if command in body else response(0xF0))
self.input.extend(result[i:i + 1] for i in range(len(result)))
return len(data)
def flush(self):
pass
def read(self, size):
return self.input.popleft() if self.input else b""
class ProbeTest(unittest.TestCase):
def link(self, port=None):
return probe.ControlLink(port or FakePort(), timeout=1, clock=Clock())
def test_full_configuration_exact_tuple_no_data_or_crypto(self):
port = FakePort()
result = probe.configure_modem(self.link(port))
self.assertTrue(result["success"])
self.assertEqual(result["radio2"]["preamble"], 512)
self.assertEqual(result["radio2"]["protocol"], 2)
self.assertEqual(port.writes[0], b"\6\x21")
self.assertIn(b"\6\x1f" + probe.SECONDARY_STRUCT.pack(*probe.SECONDARY), port.writes)
self.assertIn(b"\1\0", port.writes)
self.assertIn(b"\2\xff", port.writes)
self.assertTrue(all(frame[0] in (1, 2, 6) for frame in port.writes))
self.assertEqual(result["stats_before"], result["stats_after"])
self.assertNotIn(bytes(range(32)).hex(), str(result))
def test_bandwidth125_preamble128_exact_setter_and_readback_without_global_changes(self):
port = FakePort()
expected = (probe.GUARD_HZ, 125000, 5, 5, 2, 128, 2)
port.override[0x20] = response(0xA0, probe.SECONDARY_STRUCT.pack(*expected))
report = probe.configure_modem(self.link(port), bandwidth=125, preamble=128)
self.assertTrue(report["success"])
self.assertEqual(report["radio2"], dict(zip(("frequency_hz", "bandwidth_hz", "sf", "cr",
"mode", "preamble", "protocol"), expected)))
self.assertIn(b"\6\x1f" + probe.SECONDARY_STRUCT.pack(*expected), port.writes)
self.assertEqual(probe.SECONDARY, (probe.GUARD_HZ, 500000, 5, 5, 2, 512, 2))
self.assertEqual(probe.secondary_tuple(), probe.SECONDARY)
self.assertTrue(all(frame[0] in (1, 2, 6) for frame in port.writes))
def test_selected_secondary_readback_cannot_silently_use_default_width_or_preamble(self):
for observed in (probe.SECONDARY, (probe.GUARD_HZ, 125000, 5, 5, 2, 512, 2),
(probe.GUARD_HZ, 500000, 5, 5, 2, 128, 2)):
port = FakePort()
port.override[0x20] = response(0xA0, probe.SECONDARY_STRUCT.pack(*observed))
with self.assertRaisesRegex(probe.ProtocolError, "Secondary"):
probe.configure_modem(self.link(port), bandwidth=125, preamble=128)
def test_invalid_bandwidth_or_preamble_fails_before_any_hardware_request(self):
for options in ([dict(bandwidth=value) for value in (250, 125.0, True, "125", None)] +
[dict(preamble=value) for value in (31, 65, 1025, 128.0, True, "128", None)]):
port = FakePort()
with self.assertRaises(ValueError): probe.configure_modem(self.link(port), **options)
self.assertEqual(port.writes, [])
for preamble in probe.PREAMBLES:
self.assertEqual(probe.secondary_tuple(bandwidth=125, preamble=preamble),
(probe.GUARD_HZ, 125000, 5, 5, 2, preamble, 2))
def test_cli_passes_explicit_width_and_preamble_and_rejects_invalid_before_open(self):
from contextlib import redirect_stdout, redirect_stderr
from io import StringIO
port = FakePort(); port.close = Mock()
info = SimpleNamespace(serial_number="known", device="/dev/example", vid=0x239A, pid=0x8029)
with patch.object(probe, "open_verified_port", return_value=(port, info)), \
patch.object(probe, "configure_modem", return_value={"success": True}) as configure, \
redirect_stdout(StringIO()):
result = probe.main(["--port", "/dev/example", "--expect-serial", "known",
"--bandwidth", "125", "--preamble", "128"])
self.assertEqual(result, 0)
self.assertEqual(configure.call_args.kwargs, dict(bandwidth=125, preamble=128))
port.close.assert_called_once()
for option, value in (("--bandwidth", "250"), ("--preamble", "65")):
with patch.object(probe, "open_verified_port") as opened, redirect_stderr(StringIO()), \
self.assertRaises(SystemExit):
probe.main(["--port", "/dev/example", "--expect-serial", "known", option, value])
opened.assert_not_called()
def test_guard_unknown_fails_before_any_setter(self):
port = FakePort()
port.override[0x21] = response(0xF1, b"\5")
with self.assertRaisesRegex(probe.ProtocolError, "failed: 0x05"):
probe.configure_modem(self.link(port))
self.assertEqual(port.writes, [b"\6\x21"])
def test_wrong_guard_never_sets_anything(self):
port = FakePort()
port.override[0x21] = response(0xA1, struct.pack("<I", 910_525_000))
with self.assertRaisesRegex(probe.ProtocolError, "TX lock"):
probe.configure_modem(self.link(port))
self.assertEqual(len(port.writes), 1)
def test_bad_version_fails_before_setters(self):
port = FakePort()
port.override[0x11] = response(0x91, b"\1\0")
with self.assertRaisesRegex(probe.ProtocolError, "version"):
probe.configure_modem(self.link(port))
self.assertEqual(len(port.writes), 2)
def test_primary_float_rounding_only_frequency(self):
port = FakePort()
port.override[0x0B] = response(0x8B, probe.PRIMARY_STRUCT.pack(910_525_024, 62500, 7, 5))
self.assertEqual(probe.configure_modem(self.link(port))["primary_rx_only"]["frequency_hz"],
910_525_024)
for bad in ((910_526_000, 62500, 7, 5), (910_525_000, 125000, 7, 5)):
port = FakePort()
port.override[0x0B] = response(0x8B, probe.PRIMARY_STRUCT.pack(*bad))
with self.assertRaisesRegex(probe.ProtocolError, "Primary"):
probe.configure_modem(self.link(port))
def test_secondary_legacy_truncated_wrong_protocol_or_preamble_rejected(self):
for body in (probe.SECONDARY_STRUCT.pack(*probe.SECONDARY)[:-1],
probe.SECONDARY_STRUCT.pack(*probe.SECONDARY[:-1], 0),
probe.SECONDARY_STRUCT.pack(*probe.SECONDARY[:5], 32, 2)):
port = FakePort()
port.override[0x20] = response(0xA0, body)
with self.assertRaises(probe.ProtocolError):
probe.configure_modem(self.link(port))
def test_unknown_or_malformed_error_is_immediate(self):
for frame in (response(0xF1, b"\5"), response(0xF1), response(0xF1, b"\5\0")):
port = FakePort()
port.override[0x21] = frame
with self.assertRaises(probe.ProtocolError):
self.link(port).request(0x21, size=4)
def test_timeout_bounded_and_no_retries(self):
port = FakePort()
port.override[0x21] = b""
with self.assertRaisesRegex(probe.ProtocolError, "Timed out"):
self.link(port).request(0x21, size=4)
self.assertEqual(len(port.writes), 1)
def test_metadata_and_rx_data_allowed_but_tx_done_fails(self):
port = FakePort()
port.override[0x21] = (response(0xF9, b"\0\0") + b"\xc0\x10rx\xc0" +
response(0xA1, struct.pack("<I", probe.GUARD_HZ)))
link = self.link(port)
self.assertEqual(len(link.request(0x21, size=4)), 4)
self.assertEqual((link.rx_metadata_frames, link.ignored_data_frames), (1, 1))
port = FakePort()
port.override[0x21] = response(0xF8, b"\1")
with self.assertRaisesRegex(probe.ProtocolError, "TX_DONE"):
self.link(port).request(0x21, size=4)
def test_escape_split_and_overflow(self):
decoder = probe.Decoder()
wire = response(0x81, b"\xc0\xdb")
frames = []
for value in wire:
frames.extend(decoder.feed(bytes((value,))))
self.assertEqual(frames, [b"\6\x81\xc0\xdb"])
for wire in (b"\xc0\6\xdb\0", b"\xc0\6\xdb\xc0",
b"\xc0" + b"x" * 515):
with self.assertRaises(probe.ProtocolError):
probe.Decoder().feed(wire)
def test_encoder_refuses_data_and_nonzero_port(self):
for command in (0, 0x10, -1):
with self.assertRaises(ValueError):
probe.encode_frame(command, b"test")
def test_tx_counter_change_is_not_reported_as_success(self):
with patch.object(probe, "_stats", side_effect=[{"rx": 1, "tx": 0, "errors": 0},
{"rx": 2, "tx": 1, "errors": 0}]):
with self.assertRaisesRegex(probe.ProtocolError, "TX counter changed"):
probe.configure_modem(self.link())
def test_busy_or_wrong_usb_identity_is_never_opened(self):
serial, list_ports, modules = fake_serial_modules()
info = SimpleNamespace(device="/dev/example", serial_number="wrong", vid=0x239A)
with patch.dict(sys.modules, modules), \
patch.object(list_ports, "comports", return_value=[info]), \
patch.object(serial, "Serial") as constructor:
with self.assertRaisesRegex(probe.ProtocolError, "USB serial"):
probe.open_verified_port("/dev/example", "9AB3B64C641BA927")
constructor.assert_not_called()
info.serial_number = "9AB3B64C641BA927"
with patch.dict(sys.modules, modules), \
patch.object(list_ports, "comports", return_value=[info]), \
patch.object(probe, "assert_port_idle", side_effect=RuntimeError("busy")), \
patch.object(serial, "Serial") as constructor:
with self.assertRaisesRegex(RuntimeError, "busy"):
probe.open_verified_port("/dev/example", info.serial_number)
constructor.assert_not_called()
def test_native_nrf_usb_open_is_exclusive_dtr_on_rts_off(self):
serial, list_ports, modules = fake_serial_modules()
info = SimpleNamespace(device="/dev/example", serial_number="9AB3B64C641BA927", vid=0x239A)
port = SimpleNamespace(close=Mock())
with patch.dict(sys.modules, modules), \
patch.object(list_ports, "comports", return_value=[info]), \
patch.object(probe, "assert_port_idle"), \
patch.object(serial, "Serial", return_value=port) as constructor, \
patch.object(probe, "open_configured_session") as opener:
selected, observed = probe.open_verified_port("/dev/example", info.serial_number)
self.assertIs(selected, port)
self.assertIs(observed, info)
self.assertTrue(port.dtr)
self.assertFalse(port.rts)
self.assertTrue(constructor.call_args.kwargs["exclusive"])
self.assertEqual(constructor.call_args.kwargs["baudrate"], 115200)
opener.assert_called_once_with(port)
def test_post_open_usb_identity_change_closes_before_commands(self):
serial, list_ports, modules = fake_serial_modules()
info = SimpleNamespace(device="/dev/example", serial_number="9AB3B64C641BA927", vid=0x239A)
wrong = SimpleNamespace(device="/dev/example", serial_number="different", vid=0x239A)
port = SimpleNamespace(close=Mock())
with patch.dict(sys.modules, modules), \
patch.object(list_ports, "comports", side_effect=[[info], [wrong]]), \
patch.object(probe, "assert_port_idle"), \
patch.object(serial, "Serial", return_value=port), \
patch.object(probe, "open_configured_session"):
with self.assertRaisesRegex(probe.ProtocolError, "identity changed"):
probe.open_verified_port("/dev/example", info.serial_number)
port.close.assert_called_once()
def test_cli_releases_port_on_protocol_failure(self):
from contextlib import redirect_stdout
from io import StringIO
port = FakePort()
port.close = Mock()
info = SimpleNamespace(serial_number="9AB3B64C641BA927", device="/dev/example", vid=0x239A, pid=0x8029)
with patch.object(probe, "open_verified_port", return_value=(port, info)), \
patch.object(probe, "configure_modem", side_effect=probe.ProtocolError("rejected")), \
redirect_stdout(StringIO()):
result = probe.main(["--port", "/dev/example", "--expect-serial", info.serial_number])
self.assertEqual(result, 1)
port.close.assert_called_once()
if __name__ == "__main__":
unittest.main()
+126
View File
@@ -0,0 +1,126 @@
"""Host-only regression tests; no radio, network connection or TAP is opened."""
import contextlib
import importlib.util
import io
from pathlib import Path
import queue
import struct
import sys
import time
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools/hil"))
SPEC = importlib.util.spec_from_file_location("ethernet_kiss_probe", ROOT / "tools/hil/ethernet_kiss_probe.py")
probe = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(probe)
class FakeEndpoint:
def __init__(self):
self.incoming = queue.Queue()
self.peer = None
self.drop = False
self.frames = []
def send(self, frame, fragment=0):
self.frames.append(frame)
if not self.drop:
self.peer.incoming.put((frame, time.monotonic()))
def receive(self, timeout):
try:
return self.incoming.get(timeout=timeout)
except queue.Empty:
return None
class EthernetKissProbeTests(unittest.TestCase):
def test_tcp_endpoint_defaults_to_dedicated_kiss_port(self):
with patch.object(probe.socket, "create_connection") as connect, \
patch.object(probe.threading, "Thread"):
endpoint = probe.KissEndpoint("tcp://bench-radio")
connect.assert_called_once_with(("bench-radio", 5003), timeout=5)
endpoint.close()
def test_streaming_kiss_preserves_all_bytes(self):
frame = bytes(range(256))[:255]
encoded = probe.kiss_encode(frame)
decoder = probe.KissDecoder()
frames = []
for byte in encoded:
frames.extend(decoder.feed(bytes((byte,))))
self.assertEqual(frames, [frame])
self.assertEqual(decoder.errors, 0)
def test_decoder_ignores_controls_and_rejects_invalid_escapes(self):
decoder = probe.KissDecoder()
encoded = b"noise\xc0\x01control\xc0\x00\xdbbad\xc0"
self.assertEqual(decoder.feed(encoded), [])
self.assertEqual(decoder.errors, 1)
self.assertEqual(decoder.feed(probe.kiss_encode(b"valid")), [b"valid"])
self.assertEqual(decoder.feed(b"\xc0\x00" + b"X" * 300 + b"\xc0"), [])
self.assertEqual(decoder.errors, 2)
def test_ipv4_and_transport_checksums_are_valid_both_directions(self):
for kind in ("udp", "tcp", "icmp"):
for reverse in (False, True):
frame = probe.make_frame(kind, 255, 0x12345678, reverse)
self.assertEqual(len(frame), 255)
self.assertEqual(probe.checksum(frame[14:34]), 0)
self.assertEqual(struct.unpack("!H", frame[16:18])[0], 241)
transport = frame[34:]
protocol = frame[23]
pseudo = frame[26:34] + struct.pack("!BBH", 0, protocol, len(transport))
self.assertEqual(probe.checksum(transport if kind == "icmp" else pseudo + transport), 0)
self.assertIn(b"\xc0\xdb", transport)
def test_arp_and_boundaries_are_distinct(self):
arp = probe.make_frame("arp", 60, 1)
self.assertEqual(len(arp), 60)
self.assertEqual(arp[:6], b"\xff" * 6)
self.assertEqual(arp[12:14], b"\x08\x06")
self.assertNotEqual(arp, probe.make_frame("arp", 60, 2))
self.assertNotEqual(probe.make_frame("raw", 14, 1), probe.make_frame("raw", 14, 2))
for kind, size in probe.CASES:
self.assertEqual(len(probe.make_frame(kind, size, 1)), size)
for kind, size in (("raw", 13), ("udp", 41), ("tcp", 53), ("icmp", 256)):
with self.assertRaises(ValueError):
probe.make_frame(kind, size, 1)
def test_probe_exact_integrity_and_both_directions(self):
for duplex in (False, True):
a, b = FakeEndpoint(), FakeEndpoint()
a.peer, b.peer = b, a
result = probe.run_probe(a, b, count=2, timeout=0.1, interval=0, duplex=duplex)
self.assertTrue(result["passed"])
self.assertEqual(result["counts"], {"a_to_b_attempted": 16, "a_to_b_received": 16,
"b_to_a_attempted": 16, "b_to_a_received": 16})
for transfer in result["transfers"]:
self.assertEqual(transfer["sent_hex"], transfer["received_hex"])
self.assertGreaterEqual(transfer["latency_ms"], 0)
def test_lost_frames_are_not_retried_or_hidden(self):
a, b = FakeEndpoint(), FakeEndpoint()
a.peer, b.peer = b, a
a.drop = True
b.incoming.put((b"foreign traffic", time.monotonic()))
result = probe.run_probe(a, b, count=1, timeout=0.001, interval=0)
self.assertFalse(result["passed"])
self.assertEqual(result["counts"]["a_to_b_received"], 0)
self.assertEqual(result["counts"]["b_to_a_received"], 8)
self.assertEqual(len(a.frames), 8)
self.assertEqual(len(result["unexpected"]), 1)
self.assertEqual(result["retries"], 0)
def test_plan_does_not_open_endpoints(self):
with patch.object(probe, "KissEndpoint") as endpoint, contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(probe.main(["--plan"]), 0)
endpoint.assert_not_called()
if __name__ == "__main__":
unittest.main()
+281
View File
@@ -0,0 +1,281 @@
"""Preamble benchmark policy, accounting and subprocess protocol, no hardware."""
import contextlib
import io
import json
from pathlib import Path
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_bench as bench
class FakeNode:
def __init__(self):
self.identity = {"port": "fake-only"}
self.commands = []
self.guard = "> " + bench.GUARD
self.radio = "> 910.5250244,62.5,7,5"
self.tuple = "909.5,500,5,5,128"
self.primary = self.secondary = self.rx = self.fail = 0
self.enabled = True
self.bad_set = self.bad_readback = 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,{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()
+217
View File
@@ -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()
+74
View File
@@ -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()
+185
View File
@@ -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()
+215
View File
@@ -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()
+206
View File
@@ -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()
+61
View File
@@ -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()
+279
View File
@@ -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()
+57
View File
@@ -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()
+378
View File
@@ -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("<H", len(frame)) + frame
def incoming(size=40):
peer = ip.IpCodec("10.90.5.2", "10.90.5.1", ip.MeshPath(), 0, session=7)
return bytes((0x84, 0, 0, 255)) + peer.command(packet(size, reverse=True))[2:]
def started_buffers(**kwargs):
buffers = ip.IpRelayBuffers(codec(), **kwargs)
buffers.write_companion(lambda data: len(data))
buffers.feed_companion(response(bytes((5, 0))))
return buffers
class MeshCoreIpTests(unittest.TestCase):
def test_optional_ready_callback_follows_real_app_start_reply_once(self):
buffers = ip.IpRelayBuffers(codec())
events = []
iteration = [0]
stream = mock.Mock(read_fd=77, write_fd=77)
stream.write.side_effect = lambda data: events.append("app-start-written") or len(data)
stream.read.side_effect = lambda count: events.append("self-info-received") or response(bytes((5, 0)))
def ready(reads, writes, errors, timeout):
iteration[0] += 1
if iteration[0] == 1: return [], [77], []
if iteration[0] == 2: return [77], [], []
return [], [], []
with mock.patch.object(ip.select, "select", side_effect=ready):
stats = ip.relay(stream, 88, buffers, duration=1,
stop=lambda: iteration[0] >= 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()
+83
View File
@@ -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()
+44
View File
@@ -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()
+207
View File
@@ -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()
+103
View File
@@ -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())
+263
View File
@@ -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("<IIBB")
SECONDARY_STRUCT = struct.Struct("<IIBBBHB")
MAX_FRAME = 514
class ProtocolError(RuntimeError):
pass
def encode_frame(command: int, payload: bytes) -> 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("<III", link.request(0x12, size=12))
return {"rx": rx, "tx": tx, "errors": errors}
def _primary(link: ControlLink) -> 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("<I", link.request(0x21, size=4))[0]
if guard != GUARD_HZ:
raise ProtocolError("Required compiled-in 909.5 MHz TX lock is absent")
version = link.request(0x11, size=2)
if version != b"\x02\x00":
raise ProtocolError("Expected full MeshCore KISS protocol version 2.0")
public_key = link.request(0x01, size=32)
link.request(0x17, size=0) # Standard OpenHop liveness handshake.
before = _stats(link)
link.request(0x0A, b"\0", size=0, setter=True)
link.request(0x09, PRIMARY_STRUCT.pack(*PRIMARY), size=0, setter=True)
link.request(0x1F, SECONDARY_STRUCT.pack(*expected_secondary), size=0, setter=True)
secondary = SECONDARY_STRUCT.unpack(link.request(0x20, size=SECONDARY_STRUCT.size))
if secondary != expected_secondary:
raise ProtocolError("Secondary Ethernet-profile readback does not match")
primary = _primary(link)
power = struct.unpack("b", link.request(0x0C, size=1))[0]
if power != 0:
raise ProtocolError("TX power readback is not zero dBm")
# Standard KISS controls do not return hardware acknowledgements.
link.control(1, b"\0")
link.control(2, b"\xff")
if struct.unpack("<I", link.request(0x21, size=4))[0] != GUARD_HZ:
raise ProtocolError("Compiled TX lock changed during configuration")
after = _stats(link)
if after["tx"] != before["tx"]:
raise ProtocolError("TX counter changed during no-RF configuration")
return {"success": True, "rf_data_sent": 0, "tx_guard_hz": guard,
"version": {"major": version[0], "minor": version[1]},
"public_identity_sha256": hashlib.sha256(public_key).hexdigest(),
"primary_rx_only": primary,
"radio2": dict(zip(("frequency_hz", "bandwidth_hz", "sf", "cr", "mode",
"preamble", "protocol"), secondary)),
"tx_power_dbm": power, "stats_before": before, "stats_after": after,
"ignored_rx_data_frames": link.ignored_data_frames,
"rx_metadata_frames": link.rx_metadata_frames}
def open_verified_port(path: str, expected_serial: str):
import serial
from serial.tools import list_ports
if not expected_serial or any(c in expected_serial for c in "\r\n\0"):
raise ValueError("An exact stable USB serial is required")
resolved = os.path.normcase(os.path.realpath(path))
matches = [p for p in list_ports.comports()
if os.path.normcase(os.path.realpath(p.device)) == resolved]
if len(matches) != 1 or (matches[0].serial_number or "").upper() != expected_serial.upper():
raise ProtocolError("Selected port does not match the expected USB serial")
info = matches[0]
assert_port_idle(info.device)
port = serial.Serial(port=None, baudrate=115200, timeout=0.05,
write_timeout=0.5, exclusive=True)
# Keep a caller's stable by-id path through the actual open, rather than
# converting it to a transient ttyACM number that another board may reuse.
port.port = path
port.rts = False
port.dtr = info.vid in (0x239A, 0x2886)
try:
open_configured_session(port)
current = [p for p in list_ports.comports()
if os.path.normcase(os.path.realpath(p.device)) ==
os.path.normcase(os.path.realpath(path))]
if len(current) != 1 or (current[0].serial_number or "").upper() != expected_serial.upper():
raise ProtocolError("USB identity changed while opening the selected port")
except BaseException:
port.close()
raise
return port, info
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("--timeout", type=float, default=3.0)
parser.add_argument("--bandwidth", type=int, choices=(125, 500), default=500)
parser.add_argument("--preamble", type=int, choices=PREAMBLES, default=512)
args = parser.parse_args(argv)
if not 0 < args.timeout <= 10:
parser.error("--timeout must be positive and at most ten seconds")
port = None
try:
port, info = open_verified_port(args.port, args.expect_serial)
report = configure_modem(ControlLink(port, timeout=args.timeout),
bandwidth=args.bandwidth, preamble=args.preamble)
report["usb"] = {"serial": info.serial_number, "port": info.device,
"vid": info.vid, "pid": info.pid}
except (RuntimeError, OSError, ValueError) as error:
report = {"success": False, "rf_data_sent": 0, "error": str(error)}
finally:
if port is not None:
port.close()
print(json.dumps(report, sort_keys=True))
return 0 if report["success"] else 1
if __name__ == "__main__":
raise SystemExit(main())
+335
View File
@@ -0,0 +1,335 @@
#!/usr/bin/env python3
"""Byte-exact Ethernet-over-KISS RF probe; no flashing or radio configuration.
TRANSMITS RF through the two explicitly selected KISS endpoints. Configure the
isolated lab radios first: 909.5 MHz / 500 kHz / SF5 / CR5, low TX power. This
script never changes those settings, opens Companion port 5000, or creates TAPs.
Examples (TCP port forwards may point at another computer or the Mercerwood Pi):
python3 ethernet_kiss_probe.py --a tcp://192.168.4.1:5003 --b tcp://127.0.0.1:15003
python3 ethernet_kiss_probe.py --a tcp://192.168.4.1:5003 --b serial:///dev/ttyUSB0
python3 ethernet_kiss_probe.py --plan
Works with Companion KISS-over-TCP and a stock port-0 KISS raw-Ethernet peer.
Checksums in generated ARP/IPv4/UDP/TCP/ICMP frames are real. Passing synthetic
TCP frames is NOT a TCP connection or an SSH test; use Linux TAP/tncattach and a
real SSH session separately. --duplex intentionally overlaps half-duplex RF TX
and is a stress test, not the baseline. No automatic retries conceal RF loss.
"""
from __future__ import annotations
import argparse
from collections import Counter
from datetime import datetime, timezone
import hashlib
import ipaddress
import json
import queue
import socket
import struct
import threading
import time
from urllib.parse import urlparse
from serial_session import assert_port_idle, open_configured_session
FEND, FESC, TFEND, TFESC = 0xC0, 0xDB, 0xDC, 0xDD
def checksum(data: bytes) -> 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())
+325
View File
@@ -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())
+361
View File
@@ -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())
+198
View File
@@ -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"(?<![A-Za-z0-9_-])" + re.escape(marker) + r"(?![A-Za-z0-9_-])", item)
for item in messages)
class SerialNode:
def __init__(self, path):
import serial
from serial.tools import list_ports
matches = [item for item in list_ports.comports()
if os.path.realpath(item.device) == os.path.realpath(path)]
if len(matches) != 1:
raise RuntimeError("Selected USB identity is absent or ambiguous: " + path)
info = matches[0]
self.identity = dict(port=info.device, usb_serial=info.serial_number, vid=info.vid,
pid=info.pid, location=info.location)
self.commands = []
self.port = serial.Serial(port=None, baudrate=115200, timeout=0.04, exclusive=True)
self.port.port = info.device
self.port.rts = False
self.port.dtr = info.vid in (0x303A, 0x2886, 0x239A)
assert_port_idle(info.device)
open_configured_session(self.port)
self.read(0.3)
self.port.write(b"+++MESHCORE-TERM-START\n")
self.port.flush()
self.hello = self.read(3)
def read(self, timeout=1, quiet=0.2):
deadline = time.monotonic() + timeout
quiet_until = deadline
data = bytearray()
while time.monotonic() < min(deadline, quiet_until):
chunk = self.port.read(min(self.port.in_waiting or 1, 4096))
if chunk:
data.extend(chunk)
quiet_until = time.monotonic() + quiet
return data.decode("utf-8", errors="replace")
def command(self, text, timeout=10):
if any(value in text for value in "\r\n\0"):
raise ValueError("single-line commands only")
self.port.write((text + "\n").encode("ascii"))
self.port.flush()
reply = read_command_reply(self.port, text, timeout)
self.commands.append(dict(command=text, reply=reply))
return reply
def close(self):
try:
self.port.write(b"+++MESHCORE-TERM-STOP\n")
self.port.flush()
finally:
self.port.close()
def snapshot(node):
return {command: node.command(command) for command in
("get name", "get radio", "get ethernet.radio", "get ethernet", "get tx.channel 0", "channels")}
def run_primary(a, b, a_endpoint, b_endpoint, *, timeout=10, factory=KissEndpoint):
report = {"usb": {"a": a.identity, "b": b.identity}, "chat": [], "passed": False}
nodes = {"a": a, "b": b}
# All four RF assertions happen before opening KISS or queueing any chat.
report["before"] = {name: snapshot(node) for name, node in nodes.items()}
for state in report["before"].values():
assert_lab_radio(state["get radio"])
assert_lab_radio(state["get ethernet.radio"])
if "ethernet=on; active (RX time-shared)" not in state["get ethernet"]:
raise RuntimeError("Ethernet radio2 must already be enabled and active")
for node in nodes.values():
reply = node.command("set tx.channel 0 radio")
if "Error" in reply or "ERROR" in reply or "OK" not in reply:
raise RuntimeError("Could not save channel-0 primary-only policy: " + reply)
if "radio; active=radio" not in node.command("get tx.channel 0"):
raise RuntimeError("Channel 0 primary-only routing was not confirmed")
if "channels on" not in node.command("show channels on"):
raise RuntimeError("Could not enable incoming channel display")
endpoints = []
result = {}
worker = None
try:
endpoints = [factory(a_endpoint)]
endpoints.append(factory(b_endpoint))
for node in nodes.values():
reply = node.command("get ethernet")
if "TCP 5003 connected" not in reply:
raise RuntimeError("Both Ethernet KISS sessions must be connected before chat")
def ethernet_worker():
try:
result["ethernet"] = run_probe(*endpoints, count=1, timeout=3, interval=0.1)
except Exception as error:
result["ethernet_error"] = str(error)
worker = threading.Thread(target=ethernet_worker, daemon=True)
worker.start()
token = secrets.token_hex(8)
for source, destination in (("a", "b"), ("b", "a")):
marker = f"ETH-PRIMARY-{token}-{source}"
# Only the destination's serial output is consulted for reception.
destination_node = nodes[destination]
destination_node.read(0.1)
concurrent = worker.is_alive()
sent = time.monotonic()
sender_reply = nodes[source].command("channel 0 " + marker)
received_text = ""
deadline = sent + timeout
while time.monotonic() < deadline and not received_channel_marker(received_text, marker):
received_text += destination_node.read(min(0.4, deadline - time.monotonic()))
report["chat"].append(dict(source=source, destination=destination, marker=marker,
ethernet_worker_active_at_send=concurrent,
sender_reply=sender_reply, receiver_output=received_text,
received=received_channel_marker(received_text, marker),
elapsed_seconds=round(time.monotonic() - sent, 3)))
worker.join(timeout=60)
if worker.is_alive():
raise RuntimeError("Ethernet worker did not finish within bounded test window")
report.update(result)
report["after"] = {name: snapshot(node) for name, node in nodes.items()}
for name in nodes:
assert_lab_radio(report["after"][name]["get radio"])
assert_lab_radio(report["after"][name]["get ethernet.radio"])
report["passed"] = bool(result.get("ethernet", {}).get("passed")) and all(
item["received"] and item["ethernet_worker_active_at_send"] for item in report["chat"])
return report
finally:
for endpoint in endpoints:
endpoint.close()
if worker and worker.is_alive():
worker.join(timeout=2)
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--a-port", required=True)
parser.add_argument("--b-port", required=True)
parser.add_argument("--a-endpoint", required=True)
parser.add_argument("--b-endpoint", required=True)
parser.add_argument("--report", required=True, type=Path)
parser.add_argument("--timeout", type=float, default=10)
args = parser.parse_args(argv)
if args.timeout <= 0 or os.path.realpath(args.a_port) == os.path.realpath(args.b_port):
parser.error("positive timeout and two distinct serial ports are required")
a = b = None
try:
a = SerialNode(args.a_port)
b = SerialNode(args.b_port)
result = run_primary(a, b, args.a_endpoint, args.b_endpoint, timeout=args.timeout)
except Exception as error:
result = {"passed": False, "error": str(error)}
finally:
for node in (b, a):
if node:
node.close()
result["commands"] = {name: node.commands for name, node in (("a", a), ("b", b)) if node}
output = json.dumps(result, indent=2)
args.report.write_text(output + "\n", encoding="utf-8")
print(output)
return 0 if result["passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())
+310
View File
@@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""Guarded Ethernet-only R2 sweep; R1 stays USA receive-only.
Requires two explicitly selected USB CLI ports and KISS TCP endpoints. Refuses
old firmware without the immutable 909.500 MHz HIL TX lock. Never writes R1,
WiFi, TX power, channels, or credentials; never flashes or reboots. Raw frame
loss/goodput is measured, not an SSH connection or USA receive qualification.
"""
from __future__ import annotations
import argparse
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from decimal import Decimal
import json
import math
import os
from pathlib import Path
import re
import tempfile
import time
from urllib.parse import urlparse
from ethernet_primary_probe import SerialNode
from ethernet_ssh_serial import SshSerialNode, ssh_command
from ethernet_kiss_probe import KissEndpoint, run_probe
GUARD = "HIL TX lock=909.500 MHz; off-frequency TX blocked; primary RX-only when off-frequency"
PRIMARY = (Decimal("910.525"), Decimal("62.5"), 7, 5)
PRIMARY_KBPS = 2.734375
@dataclass(frozen=True)
class Candidate:
index: int
bandwidth: float
sf: int
preamble: int
@property
def tuple_text(self):
return f"909.5,{self.bandwidth:g},{self.sf},5,{self.preamble}"
def describe(self):
kbps = self.sf * 4 * self.bandwidth / (5 * (2 ** self.sf))
return dict(asdict(self), frequency_mhz=909.5, cr=5,
nominal_kbps=kbps, nominal_ratio_to_usa=kbps / PRIMARY_KBPS,
preamble_ms=self.preamble * (2 ** self.sf) / self.bandwidth)
# The 1x control gets extra margin for the Indicator's slow I2C hop. Other
# candidates hold approximately 65.5 ms of preamble, not a fixed symbol count.
CANDIDATES = tuple(Candidate(*item) for item in (
(1, 62.5, 7, 64), (2, 125, 7, 64), (3, 250, 7, 128),
(4, 500, 7, 256), (5, 500, 8, 128), (6, 500, 6, 512), (7, 500, 5, 1024),
# Longer-preamble controls distinguish acquisition losses from PHY limits.
(8, 500, 5, 2048), (9, 500, 7, 512),
# Fast-switch-board controls, including ~16 ms chirp acquisition windows.
(10, 500, 5, 32), (11, 500, 5, 64), (12, 500, 5, 128), (13, 500, 5, 256),
(14, 500, 7, 32), (15, 500, 7, 64), (16, 250, 7, 32), (17, 500, 8, 32),
(18, 125, 7, 32), (19, 250, 8, 32), (20, 250, 7, 48),
(21, 500, 5, 512), (22, 125, 7, 48)))
def data_lines(reply):
result = []
for line in reply.splitlines():
match = re.match(r"^\s*(?:->\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())
+96
View File
@@ -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())
+210
View File
@@ -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"
+183
View File
@@ -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"
+137
View File
@@ -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
+126
View File
@@ -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()
+428
View File
@@ -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())
+53
View File
@@ -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())
+519
View File
@@ -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("<II", self.session, self.sequence) + packet
self.sequence += 1
return bytes((25, self.path.encoded)) + self.path.data + envelope
def raw_push(self, frame):
if not 4 + ENVELOPE_SIZE + 20 <= len(frame) <= FRAME_SIZE \
or frame[0] != 0x84 or frame[4:8] != MAGIC:
return None
packet = frame[4 + ENVELOPE_SIZE:]
return packet if valid_ipv4(packet, self.remote, self.local, self.mtu) else None
def companion_frame(frame):
if not 1 <= len(frame) <= FRAME_SIZE:
raise ValueError("Companion command exceeds conservative host frame limit")
return b"<" + struct.pack("<H", len(frame)) + frame
class CompanionDecoder:
def __init__(self, *, ascii_preamble=False):
self.staging = bytearray()
self.ascii_preamble = ascii_preamble
self.preamble_bytes = 0
def feed(self, data):
if len(data) > 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())
+165
View File
@@ -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
+170
View File
@@ -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"
+76
View File
@@ -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):
+311
View File
@@ -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())