Add isolated Ethernet and SSH throughput test harnesses
This commit is contained in:
1 parent
2c4a51c9ba
commit
ecbca6a84c
35 files changed
+7211
No files matched your search
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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):
|
||||
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user