Add isolated Ethernet and SSH throughput test harnesses

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

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
"""Identity-recorded USB CLI for the explicitly erasable Ethernet RF bench.
No reset/erase/flash is performed. WiFi credentials are read only from a local
JSON file and never included in the printed command/response log.
"""
import argparse
import json
import os
from pathlib import Path
import time
import serial
from serial.tools import list_ports
from serial_session import (SerialCommandTimeout, assert_port_idle,
open_configured_session, read_command_reply)
def read_quiet(port, timeout=3):
until = time.monotonic() + timeout
# Wait the full response deadline for the first byte; silence at attach is
# not proof that a cooperative USB console has finished opening its session.
quiet = until
data = bytearray()
while time.monotonic() < min(until, quiet):
chunk = port.read(min(port.in_waiting or 1, 4096))
if chunk:
data.extend(chunk)
quiet = time.monotonic() + 0.35
return data.decode("utf-8", errors="replace")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", required=True)
parser.add_argument("--command", action="append", default=[])
parser.add_argument("--wifi-json", type=Path)
parser.add_argument("--wait", type=float, default=10)
args = parser.parse_args()
matches = [p for p in list_ports.comports()
if os.path.realpath(p.device) == os.path.realpath(args.port)]
if len(matches) != 1:
raise SystemExit("Selected USB identity is absent or ambiguous")
info = matches[0]
secrets = []
commands = list(args.command)
if args.wifi_json:
credentials = json.loads(args.wifi_json.read_text())
ssid, password = credentials["ssid"], credentials["password"]
if not isinstance(ssid, str) or not isinstance(password, str) or any(
c in ssid + password for c in "\r\n\0"):
raise SystemExit("Invalid credential file")
secrets = [password] if password else []
commands.extend(["set wifi.ssid " + ssid, "set wifi.pwd " + password, "set wifi on"])
if args.wait <= 0 or any(not command or any(value in command for value in "\r\n\0") for command in commands):
raise SystemExit("Use single-line nonempty commands and a positive --wait")
def redact(text):
for secret in secrets:
text = text.replace(secret, "[redacted]")
return text
port = serial.Serial(port=None, baudrate=115200, timeout=0.04, exclusive=True)
port.port = info.device
port.rts = False
port.dtr = info.vid in (0x303A, 0x2886, 0x239A)
output = {"port": info.device, "usb_serial": info.serial_number,
"vid": info.vid, "pid": info.pid, "commands": []}
try:
assert_port_idle(info.device)
open_configured_session(port)
read_quiet(port, 0.5)
port.write(b"+++MESHCORE-TERM-START\n")
port.flush()
output["hello"] = redact(read_quiet(port))
for command in commands:
port.write((command + "\n").encode())
port.flush()
try:
reply = read_command_reply(port, command, args.wait)
except SerialCommandTimeout as error:
output["commands"].append({"command": redact(command),
"reply": redact(error.partial_reply), "completed": False})
output["error"] = str(error)
break # Never misattribute this pending reply to another command.
output["commands"].append({"command": redact(command), "reply": redact(reply), "completed": True})
finally:
if port.is_open:
try:
port.write(b"+++MESHCORE-TERM-STOP\n")
port.flush()
except Exception:
pass
try:
port.close()
except Exception:
pass
print(json.dumps(output), flush=True)
return 1 if "error" in output else 0
if __name__ == "__main__":
raise SystemExit(main())
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""Configure an identity-bound full KISS modem for the Ethernet RF bench.
NO RF DATA is sent. A compiled-in 909.5 MHz TX lock must be proved over the
HIL-only read-only command before any setter. R1 listens on the USA profile;
R2 uses raw Ethernet at 909.5/SF5/CR5, selected BW125/500 and preamble32..1024
(defaults BW500/preamble512). Settings are session-only.
The USB port is closed before exit so a separate TAP/KISS relay may own it.
"""
from __future__ import annotations
import argparse
from collections import deque
import hashlib
import json
import os
import struct
import time
from serial_session import assert_port_idle, open_configured_session
FEND, FESC, TFEND, TFESC = 0xC0, 0xDB, 0xDC, 0xDD
GUARD_HZ = 909_500_000
PRIMARY = (910_525_000, 62_500, 7, 5)
SECONDARY = (GUARD_HZ, 500_000, 5, 5, 2, 512, 2)
PREAMBLES = (32, 64, 96, 128, 192, 256, 384, 512, 768, 1024)
PRIMARY_STRUCT = struct.Struct("<IIBB")
SECONDARY_STRUCT = struct.Struct("<IIBBBHB")
MAX_FRAME = 514
class ProtocolError(RuntimeError):
pass
def encode_frame(command: int, payload: bytes) -> bytes:
if type(command) is not int or not 0 <= command <= 0x0F:
raise ValueError("Only port-zero KISS controls are permitted")
if command == 0:
raise ValueError("This configuration tool never sends RF DATA")
body = bytes((command,)) + payload
if len(body) > MAX_FRAME:
raise ValueError("KISS control exceeds the modem frame limit")
return bytes((FEND,)) + body.replace(b"\xdb", b"\xdb\xdd").replace(
b"\xc0", b"\xdb\xdc") + bytes((FEND,))
class Decoder:
"""Bounded full-frame decoder, including command byte and hardware replies."""
def __init__(self):
self.started = self.escaped = False
self.buffer = bytearray()
def feed(self, data: bytes) -> list[bytes]:
result = []
for value in data:
if value == FEND:
if self.escaped:
raise ProtocolError("Truncated KISS escape")
if self.started and self.buffer:
result.append(bytes(self.buffer))
self.buffer.clear()
self.started = True
elif not self.started:
continue # Startup console text is not a KISS frame.
elif self.escaped:
self.escaped = False
if value not in (TFEND, TFESC):
raise ProtocolError("Invalid KISS escape")
self.buffer.append(FEND if value == TFEND else FESC)
elif value == FESC:
self.escaped = True
else:
self.buffer.append(value)
if len(self.buffer) > MAX_FRAME:
raise ProtocolError("Oversized KISS frame")
return result
class ControlLink:
def __init__(self, port, *, timeout=3.0, clock=time.monotonic):
if not 0 < timeout <= 10:
raise ValueError("Use a transaction timeout of at most ten seconds")
self.port, self.timeout, self.clock = port, timeout, clock
self.decoder = Decoder()
self.pending = deque()
self.ignored_data_frames = 0
self.rx_metadata_frames = 0
def control(self, command: int, payload: bytes):
frame = encode_frame(command, payload)
if self.port.write(frame) != len(frame):
raise ProtocolError("Short serial write")
self.port.flush()
def request(self, command: int, payload=b"", *, size: int, setter=False) -> bytes:
self.control(6, bytes((command,)) + payload)
expected = 0xF0 if setter else command | 0x80
deadline = self.clock() + self.timeout
byte_count = frame_count = 0
while self.clock() < deadline:
if not self.pending:
chunk = self.port.read(1024)
byte_count += len(chunk)
if byte_count > 65536:
raise ProtocolError("KISS transaction exceeded its byte limit")
self.pending.extend(self.decoder.feed(chunk))
while self.pending:
frame = self.pending.popleft()
frame_count += 1
if frame_count > 256:
raise ProtocolError("KISS transaction exceeded its frame limit")
if frame[0] in (0x00, 0x10):
self.ignored_data_frames += 1
continue
if frame[0] != 6 or len(frame) < 2:
raise ProtocolError("Unexpected KISS control port or frame")
response, body = frame[1], frame[2:]
if response == 0xF1:
if len(body) != 1:
raise ProtocolError("Malformed hardware error")
raise ProtocolError(f"Hardware command 0x{command:02x} failed: 0x{body[0]:02x}")
if response == 0xF8:
if len(body) != 1 or body[0] not in (0, 1):
raise ProtocolError("Malformed TX_DONE notification")
raise ProtocolError("Unexpected TX_DONE: setup never sends RF DATA")
if response == 0xF9:
if len(body) != 2:
raise ProtocolError("Malformed RX metadata")
self.rx_metadata_frames += 1
continue
if response != expected or len(body) != size:
raise ProtocolError(f"Unexpected response to hardware command 0x{command:02x}")
return body
raise ProtocolError(f"Timed out waiting for hardware command 0x{command:02x}")
def _stats(link: ControlLink) -> dict:
rx, tx, errors = struct.unpack("<III", link.request(0x12, size=12))
return {"rx": rx, "tx": tx, "errors": errors}
def _primary(link: ControlLink) -> dict:
observed = PRIMARY_STRUCT.unpack(link.request(0x0B, size=PRIMARY_STRUCT.size))
# The driver stores MHz as float32; 910.525 becomes 910525024 whole Hz.
# No tolerance is allowed for bandwidth/SF/CR or the separate TX guard.
if abs(observed[0] - PRIMARY[0]) > 50 or observed[1:] != PRIMARY[1:]:
raise ProtocolError("Primary USA RX-profile readback does not match")
return dict(zip(("frequency_hz", "bandwidth_hz", "sf", "cr"), observed))
def secondary_tuple(*, bandwidth=500, preamble=512):
if type(bandwidth) is not int or bandwidth not in (125, 500):
raise ValueError("Select bandwidth 125 or 500 kHz")
if type(preamble) is not int or preamble not in PREAMBLES:
raise ValueError("Select an explicit supported preamble from 32..1024 symbols")
return (GUARD_HZ, bandwidth * 1000, 5, 5, 2, preamble, 2)
def configure_modem(link: ControlLink, *, bandwidth=500, preamble=512) -> dict:
expected_secondary = secondary_tuple(bandwidth=bandwidth, preamble=preamble)
guard = struct.unpack("<I", link.request(0x21, size=4))[0]
if guard != GUARD_HZ:
raise ProtocolError("Required compiled-in 909.5 MHz TX lock is absent")
version = link.request(0x11, size=2)
if version != b"\x02\x00":
raise ProtocolError("Expected full MeshCore KISS protocol version 2.0")
public_key = link.request(0x01, size=32)
link.request(0x17, size=0) # Standard OpenHop liveness handshake.
before = _stats(link)
link.request(0x0A, b"\0", size=0, setter=True)
link.request(0x09, PRIMARY_STRUCT.pack(*PRIMARY), size=0, setter=True)
link.request(0x1F, SECONDARY_STRUCT.pack(*expected_secondary), size=0, setter=True)
secondary = SECONDARY_STRUCT.unpack(link.request(0x20, size=SECONDARY_STRUCT.size))
if secondary != expected_secondary:
raise ProtocolError("Secondary Ethernet-profile readback does not match")
primary = _primary(link)
power = struct.unpack("b", link.request(0x0C, size=1))[0]
if power != 0:
raise ProtocolError("TX power readback is not zero dBm")
# Standard KISS controls do not return hardware acknowledgements.
link.control(1, b"\0")
link.control(2, b"\xff")
if struct.unpack("<I", link.request(0x21, size=4))[0] != GUARD_HZ:
raise ProtocolError("Compiled TX lock changed during configuration")
after = _stats(link)
if after["tx"] != before["tx"]:
raise ProtocolError("TX counter changed during no-RF configuration")
return {"success": True, "rf_data_sent": 0, "tx_guard_hz": guard,
"version": {"major": version[0], "minor": version[1]},
"public_identity_sha256": hashlib.sha256(public_key).hexdigest(),
"primary_rx_only": primary,
"radio2": dict(zip(("frequency_hz", "bandwidth_hz", "sf", "cr", "mode",
"preamble", "protocol"), secondary)),
"tx_power_dbm": power, "stats_before": before, "stats_after": after,
"ignored_rx_data_frames": link.ignored_data_frames,
"rx_metadata_frames": link.rx_metadata_frames}
def open_verified_port(path: str, expected_serial: str):
import serial
from serial.tools import list_ports
if not expected_serial or any(c in expected_serial for c in "\r\n\0"):
raise ValueError("An exact stable USB serial is required")
resolved = os.path.normcase(os.path.realpath(path))
matches = [p for p in list_ports.comports()
if os.path.normcase(os.path.realpath(p.device)) == resolved]
if len(matches) != 1 or (matches[0].serial_number or "").upper() != expected_serial.upper():
raise ProtocolError("Selected port does not match the expected USB serial")
info = matches[0]
assert_port_idle(info.device)
port = serial.Serial(port=None, baudrate=115200, timeout=0.05,
write_timeout=0.5, exclusive=True)
# Keep a caller's stable by-id path through the actual open, rather than
# converting it to a transient ttyACM number that another board may reuse.
port.port = path
port.rts = False
port.dtr = info.vid in (0x239A, 0x2886)
try:
open_configured_session(port)
current = [p for p in list_ports.comports()
if os.path.normcase(os.path.realpath(p.device)) ==
os.path.normcase(os.path.realpath(path))]
if len(current) != 1 or (current[0].serial_number or "").upper() != expected_serial.upper():
raise ProtocolError("USB identity changed while opening the selected port")
except BaseException:
port.close()
raise
return port, info
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", required=True)
parser.add_argument("--expect-serial", required=True)
parser.add_argument("--timeout", type=float, default=3.0)
parser.add_argument("--bandwidth", type=int, choices=(125, 500), default=500)
parser.add_argument("--preamble", type=int, choices=PREAMBLES, default=512)
args = parser.parse_args(argv)
if not 0 < args.timeout <= 10:
parser.error("--timeout must be positive and at most ten seconds")
port = None
try:
port, info = open_verified_port(args.port, args.expect_serial)
report = configure_modem(ControlLink(port, timeout=args.timeout),
bandwidth=args.bandwidth, preamble=args.preamble)
report["usb"] = {"serial": info.serial_number, "port": info.device,
"vid": info.vid, "pid": info.pid}
except (RuntimeError, OSError, ValueError) as error:
report = {"success": False, "rf_data_sent": 0, "error": str(error)}
finally:
if port is not None:
port.close()
print(json.dumps(report, sort_keys=True))
return 0 if report["success"] else 1
if __name__ == "__main__":
raise SystemExit(main())
+335
View File
@@ -0,0 +1,335 @@
#!/usr/bin/env python3
"""Byte-exact Ethernet-over-KISS RF probe; no flashing or radio configuration.
TRANSMITS RF through the two explicitly selected KISS endpoints. Configure the
isolated lab radios first: 909.5 MHz / 500 kHz / SF5 / CR5, low TX power. This
script never changes those settings, opens Companion port 5000, or creates TAPs.
Examples (TCP port forwards may point at another computer or the Mercerwood Pi):
python3 ethernet_kiss_probe.py --a tcp://192.168.4.1:5003 --b tcp://127.0.0.1:15003
python3 ethernet_kiss_probe.py --a tcp://192.168.4.1:5003 --b serial:///dev/ttyUSB0
python3 ethernet_kiss_probe.py --plan
Works with Companion KISS-over-TCP and a stock port-0 KISS raw-Ethernet peer.
Checksums in generated ARP/IPv4/UDP/TCP/ICMP frames are real. Passing synthetic
TCP frames is NOT a TCP connection or an SSH test; use Linux TAP/tncattach and a
real SSH session separately. --duplex intentionally overlaps half-duplex RF TX
and is a stress test, not the baseline. No automatic retries conceal RF loss.
"""
from __future__ import annotations
import argparse
from collections import Counter
from datetime import datetime, timezone
import hashlib
import ipaddress
import json
import queue
import socket
import struct
import threading
import time
from urllib.parse import urlparse
from serial_session import assert_port_idle, open_configured_session
FEND, FESC, TFEND, TFESC = 0xC0, 0xDB, 0xDC, 0xDD
def checksum(data: bytes) -> int:
if len(data) % 2:
data += b"\0"
total = sum(struct.unpack("!" + "H" * (len(data) // 2), data))
while total >> 16:
total = (total & 0xFFFF) + (total >> 16)
return ~total & 0xFFFF
def kiss_encode(frame: bytes, *, port: int = 0) -> bytes:
if type(port) is not int or port not in (0, 1):
raise ValueError("Select KISS data port 0 or 1")
return bytes((FEND, port << 4)) + frame.replace(b"\xdb", b"\xdb\xdd").replace(
b"\xc0", b"\xdb\xdc") + bytes((FEND,))
class KissDecoder:
"""Bounded streaming decoder: ignore control frames and reject bad escapes."""
def __init__(self, port: int = 0, control=None):
if type(port) is not int or port not in (0, 1):
raise ValueError("Select KISS data port 0 or 1")
self.port = port
self.control = control
self.buffer = bytearray()
self.started = self.escaped = self.discard = False
self.errors = 0
def feed(self, data: bytes) -> list[bytes]:
frames = []
for byte in data:
if byte == FEND:
if self.started and self.buffer and not self.discard:
if self.escaped:
self.errors += 1
elif self.buffer[0] == self.port << 4:
frames.append(bytes(self.buffer[1:]))
elif self.buffer[0] & 15 and self.control is not None:
self.control(self.buffer[0], bytes(self.buffer[1:]))
self.buffer.clear()
self.started = True
self.escaped = self.discard = False
elif not self.started or self.discard:
continue
elif self.escaped:
self.escaped = False
if byte not in (TFEND, TFESC):
self.errors += 1
self.discard = True
else:
self.buffer.append(FEND if byte == TFEND else FESC)
elif byte == FESC:
self.escaped = True
else:
self.buffer.append(byte)
if len(self.buffer) > 256:
self.errors += 1
self.discard = True
return frames
def make_frame(kind: str, size: int, sequence: int, reverse: bool = False) -> bytes:
"""Produce independently identifiable, valid network test frames up to 255B."""
source = bytes.fromhex("0200c0db0001")
destination = bytes.fromhex("0200c0db0002")
src_ip = ipaddress.ip_address("192.0.2.1").packed
dst_ip = ipaddress.ip_address("192.0.2.2").packed
if reverse:
source, destination = destination, source
src_ip, dst_ip = dst_ip, src_ip
if kind == "arp":
# Vary sender protocol address to distinguish every repeated ARP probe.
src_ip = bytes((192, 0, (sequence >> 8) & 255, sequence & 255))
arp = struct.pack("!HHBBH", 1, 0x0800, 6, 4, 1)
arp += source + src_ip + b"\0" * 6 + dst_ip
return b"\xff" * 6 + source + b"\x08\x06" + arp + b"\0" * max(0, size - 42)
minimum = {"raw": 14, "udp": 42, "icmp": 42, "tcp": 54}[kind]
if not minimum <= size <= 255:
raise ValueError(f"{kind} frame requires {minimum}..255 bytes")
payload_size = size - minimum
seed = b"ETHK" + struct.pack("!I?", sequence, reverse) + bytes((FEND, FESC))
payload = (seed + bytes(range(256))) * ((payload_size // 267) + 1)
payload = payload[:payload_size]
if kind == "raw":
# Even a minimum 14-byte frame must be unique across repetitions:
# otherwise a delayed duplicate could impersonate a later probe.
source = source[:2] + struct.pack("!I", sequence)
return destination + source + b"\x88\xb5" + payload
protocol = {"udp": 17, "tcp": 6, "icmp": 1}[kind]
if kind == "udp":
transport = struct.pack("!HHHH", 49152, 22, 8 + len(payload), 0) + payload
elif kind == "tcp":
transport = struct.pack("!HHIIHHHH", 49152, 22, sequence, 0, 0x5002, 4096, 0, 0) + payload
else:
transport = struct.pack("!BBHHH", 8, 0, 0, 0xC0DB, sequence & 0xFFFF) + payload
pseudo = src_ip + dst_ip + struct.pack("!BBH", 0, protocol, len(transport))
check = checksum(transport if kind == "icmp" else pseudo + transport)
offset = {"udp": 6, "tcp": 16, "icmp": 2}[kind]
# UDP checksum zero means disabled; wire representation of computed zero is FFFF.
if kind == "udp" and not check:
check = 0xFFFF
transport = transport[:offset] + struct.pack("!H", check) + transport[offset + 2:]
header = struct.pack("!BBHHHBBH4s4s", 0x45, 0, 20 + len(transport), sequence & 0xFFFF,
0x4000, 64, protocol, 0, src_ip, dst_ip)
header = header[:10] + struct.pack("!H", checksum(header)) + header[12:]
return destination + source + b"\x08\x00" + header + transport
CASES = (("raw", 14), ("arp", 60), ("icmp", 60), ("udp", 64), ("tcp", 74),
("udp", 244), ("raw", 255), ("tcp", 255))
class KissEndpoint:
def __init__(self, url: str, baud: int = 115200):
self.url = url
self.decoder = KissDecoder()
self.received = queue.Queue(maxsize=256)
self.overflow = 0
self.error = None
self.closed = threading.Event()
self.serial = None
self.socket = None
parsed = urlparse(url)
if parsed.scheme == "tcp":
self.socket = socket.create_connection((parsed.hostname, parsed.port or 5003), timeout=5)
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self.socket.settimeout(0.1)
elif parsed.scheme == "serial":
import serial # Optional: pure TCP/--plan needs only Python stdlib.
port = (parsed.netloc + parsed.path) if parsed.netloc else parsed.path
if port.startswith("/COM"):
port = port[1:]
self.serial = serial.Serial()
self.serial.baudrate = baud
self.serial.timeout = self.serial.write_timeout = 0.1
self.serial.dtr = self.serial.rts = False
self.serial.port = port
assert_port_idle(port)
open_configured_session(self.serial)
else:
raise ValueError("endpoint must be tcp://host:port or serial:///device")
self.thread = threading.Thread(target=self._read, daemon=True)
self.thread.start()
def _read(self):
try:
while not self.closed.is_set():
try:
data = self.socket.recv(1024) if self.socket else self.serial.read(1024)
except socket.timeout:
continue
if not data and self.socket:
raise ConnectionError("KISS TCP peer disconnected")
for frame in self.decoder.feed(data):
try:
self.received.put_nowait((frame, time.monotonic()))
except queue.Full:
self.overflow += 1
except Exception as error:
if not self.closed.is_set():
self.error = str(error)
def send(self, frame: bytes, fragment: int = 0):
encoded = kiss_encode(frame)
step = fragment or len(encoded)
for at in range(0, len(encoded), step):
chunk = encoded[at:at + step]
if self.socket:
self.socket.sendall(chunk)
elif self.serial.write(chunk) != len(chunk):
raise IOError("short serial write")
def receive(self, timeout: float):
if self.error:
raise ConnectionError(self.error)
try:
return self.received.get(timeout=timeout)
except queue.Empty:
return None
def close(self):
self.closed.set()
if self.socket:
self.socket.close()
if self.serial:
self.serial.close()
self.thread.join(timeout=1)
def run_probe(a, b, *, count=3, timeout=3.0, interval=0.05, duplex=False, fragment=0):
report = {"started_utc": datetime.now(timezone.utc).isoformat(),
"baseline": "909.5 MHz / 500 kHz / SF5 / CR5 (operator configured; not verified)",
"duplex_stress": duplex, "retries": 0, "transfers": [], "unexpected": []}
endpoints = {"a": a, "b": b}
sequence = 0
for repetition in range(count):
for kind, size in CASES:
pending = []
for source, destination in (("a", "b"), ("b", "a")):
sequence += 1
frame = make_frame(kind, size, sequence, source == "b")
item = {"source": source, "destination": destination, "repetition": repetition,
"kind": kind, "size": len(frame), "sequence": sequence,
"sha256": hashlib.sha256(frame).hexdigest(), "received": False,
"sent_hex": frame.hex()}
sent = time.monotonic()
endpoints[source].send(frame, fragment)
pending.append((item, frame, sent))
if not duplex:
_collect(endpoints, pending, timeout, report)
pending = []
time.sleep(interval)
if pending:
_collect(endpoints, pending, timeout, report)
time.sleep(interval)
counts = Counter()
for item in report["transfers"]:
direction = item["source"] + "_to_" + item["destination"]
counts[direction + "_attempted"] += 1
counts[direction + "_received"] += bool(item["received"])
report["counts"] = dict(counts)
report["passed"] = all(item["received"] for item in report["transfers"])
report["note"] = "Exact Ethernet bytes verified; this is not an established TCP/SSH session."
return report
def _collect(endpoints, pending, timeout, report):
deadline = max(item[2] for item in pending) + timeout
while any(not item[0]["received"] for item in pending) and time.monotonic() < deadline:
for name, endpoint in endpoints.items():
received = endpoint.receive(min(0.01, max(0, deadline - time.monotonic())))
if received is None:
continue
frame, timestamp = received
match = next((item for item in pending
if item[0]["destination"] == name and item[1] == frame
and not item[0]["received"]), None)
if match:
match[0].update(received=True, received_hex=frame.hex(),
latency_ms=round((timestamp - match[2]) * 1000, 3))
else:
report["unexpected"].append({"at": name, "size": len(frame), "hex": frame.hex()})
report["transfers"].extend(item[0] for item in pending)
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--a", help="first KISS TCP or serial endpoint")
parser.add_argument("--b", help="second KISS TCP or serial endpoint")
parser.add_argument("--baud", type=int, default=115200)
parser.add_argument("--count", type=int, default=3, help="repetitions per frame type and direction")
parser.add_argument("--timeout", type=float, default=3.0)
parser.add_argument("--interval", type=float, default=0.05)
parser.add_argument("--fragment", type=int, default=0, help="split host KISS writes into N-byte chunks")
parser.add_argument("--duplex", action="store_true", help="simultaneous TX collision stress test")
parser.add_argument("--report", help="save complete byte-exact JSON evidence")
parser.add_argument("--plan", action="store_true", help="show cases without opening any endpoint")
args = parser.parse_args(argv)
if args.count <= 0 or args.timeout <= 0 or args.interval < 0 or args.fragment < 0:
parser.error("count/timeout must be positive; interval/fragment must be nonnegative")
if args.plan:
print(json.dumps({"cases": CASES, "frames_each_direction": args.count * len(CASES),
"radio_settings": "909.5 MHz / 500 kHz / SF5 / CR5; manually verified"}, indent=2))
return 0
if not args.a or not args.b:
parser.error("both --a and --b are required, unless using --plan")
if args.a == args.b:
parser.error("--a and --b must be different endpoints")
a = b = None
try:
a = KissEndpoint(args.a, args.baud)
b = KissEndpoint(args.b, args.baud)
result = run_probe(a, b, count=args.count, timeout=args.timeout, interval=args.interval,
duplex=args.duplex, fragment=args.fragment)
result["endpoints"] = {"a": args.a, "b": args.b}
result["host_diagnostics"] = {
name: {"decode_errors": endpoint.decoder.errors, "queue_overflows": endpoint.overflow}
for name, endpoint in (("a", a), ("b", b))}
result["passed"] = result["passed"] and not any(
value for diag in result["host_diagnostics"].values() for value in diag.values())
output = json.dumps(result, indent=2)
if args.report:
with open(args.report, "w", encoding="utf-8") as destination:
destination.write(output + "\n")
print(output)
return 0 if result["passed"] else 1
finally:
if b:
b.close()
if a:
a.close()
if __name__ == "__main__":
raise SystemExit(main())
+325
View File
@@ -0,0 +1,325 @@
#!/usr/bin/env python3
"""Measure real SSH payload speed versus R2 preamble, never nominal bitrate.
R2 is fixed to 909.5/CR5, with explicit BW125 or500 (default500) and SF5..9
(default SF5); R1 must already be USA receive-only under
the immutable HIL TX lock. This tool never changes R1, WiFi, keys or TX power.
The runner owns its KISS/TAP/SSH resources and must close them before returning;
it receives one JSON request on stdin and returns one JSON object on stdout.
LORA_SSH_BENCH_CONFIG is inherited as a local configuration-file path. No
automatic retries: explicitly repeat a preamble or use --runs for replication.
"""
from __future__ import annotations
import argparse
from datetime import datetime, timezone
from decimal import Decimal
import json
import math
import os
from pathlib import Path
import random
import re
import subprocess
import sys
import tempfile
import time
from ethernet_primary_probe import SerialNode
from ethernet_ssh_serial import SshSerialNode, ssh_command
from ethernet_radio2_sweep import (GUARD, PRIMARY, data_lines, primary_tx,
radio_tuple, require_ok, safety_snapshot)
DEFAULT_PREAMBLES = (128, 192, 256, 384, 512, 768)
def validate_preambles(values):
values = list(values)
if not values or len(values) > 120 or any(type(n) is not int or not 32 <= n <= 2048 for n in values):
raise ValueError("Select 1..120 explicit preambles, each 32..2048 symbols")
return values
def validate_sf(sf):
if type(sf) is not int or sf not in range(5, 10):
raise ValueError("Select a fixed spreading factor from 5..9")
return sf
def validate_bandwidth(bandwidth):
if type(bandwidth) is not int or bandwidth not in (125, 500):
raise ValueError("Select a fixed bandwidth of 125 or 500 kHz")
return bandwidth
def paired_counters(reply, field):
matches = re.findall(r"(?:^|\s)" + field + r"=(\d+),(\d+)(?=\s|;|$)",
"\n".join(data_lines(reply)))
return list(map(int, matches[0])) if len(matches) == 1 else None
def ethernet_counters(reply):
matches = re.findall(r"\bRX=(\d+) TX=(\d+) fail=(\d+)(?=\s|;|$)",
"\n".join(data_lines(reply)))
return dict(zip(("rx", "tx", "fail"), map(int, matches[0]))) if len(matches) == 1 else None
def snapshot(nodes, timeout, preamble=None, *, sf=5, bandwidth=500):
validate_sf(sf)
validate_bandwidth(bandwidth)
states = safety_snapshot(nodes, timeout)
for name, node in nodes.items():
state = states[name]
for key, command in (("status", "get radio2.status"), ("ethernet", "get ethernet"),
("ethernet_radio", "get ethernet.radio"),
("timing", "get radio2.timing"), ("radio_diag", "stats-radio-diag")):
state[key] = node.command(command, timeout=timeout)
if primary_tx(state["status"]) != 0:
raise RuntimeError("Primary TX counter is nonzero; aborting all remaining cases")
if preamble is not None:
if radio_tuple(state["ethernet_radio"]) != (Decimal("909.5"), Decimal(bandwidth), sf, 5, preamble):
raise RuntimeError(f"Ethernet readback does not match 909.5,{bandwidth},{sf},5 and selected preamble")
if "ethernet=on; active (RX time-shared)" not in state["ethernet"]:
raise RuntimeError("Ethernet R2 is not active on station WiFi")
state["counters"] = dict(primary_tx=0, profile_tx=paired_counters(state["status"], "TX"),
profile_rx=paired_counters(state["status"], "RX"),
ethernet=ethernet_counters(state["ethernet"]))
return states
def counter_delta(before, after):
"""Unknown/reset counters stay unknown, never fabricated as zero loss."""
if isinstance(before, dict) and isinstance(after, dict):
return {key: counter_delta(value, after.get(key)) for key, value in before.items()}
if isinstance(before, list) and isinstance(after, list) and len(before) == len(after):
return [counter_delta(a, b) for a, b in zip(before, after)]
if type(before) is int and type(after) is int and after >= before:
return after - before
return None
def number(value):
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
def payload_metrics(result):
verified = result.get("verified") is True
total_bytes = total_seconds = 0
directions = {}
for name in ("downlink", "uplink"):
value = result.get(name)
metrics = None
if isinstance(value, dict):
size = value.get("bytes")
seconds = value.get("elapsed_seconds")
if seconds is None and number(value.get("elapsed_ms")):
seconds = value["elapsed_ms"] / 1000
if type(size) is int and size > 0 and number(seconds) and seconds > 0:
metrics = dict(bytes=size, elapsed_seconds=seconds, goodput_kbps=size * 8 / seconds / 1000)
total_bytes += size
total_seconds += seconds
if value.get("hash_verified") is False:
verified = False
directions[name] = metrics
# A boolean alone is not byte/time evidence; require both verified hashes
# and actual payload durations before claiming a successful benchmark.
verified = verified and all(directions.values()) and all(
isinstance(result.get(name), dict) and result[name].get("hash_verified") is True
for name in ("downlink", "uplink"))
combined = total_bytes * 8 / total_seconds / 1000 if all(directions.values()) else None
total_ms = result.get("total_ms")
end_to_end = total_bytes * 8 / total_ms if combined is not None and number(total_ms) and total_ms > 0 else None
return dict(verified=bool(verified), directions=directions, combined_payload_kbps=combined,
end_to_end_kbps=end_to_end, handshake_ms=result.get("handshake_ms"),
tcp_client=result.get("tcp_client"), tcp_server=result.get("tcp_server"),
definition="Combined verified SSH payload bytes / sum of downlink and uplink durations; excludes handshake. End-to-end uses runner total_ms.")
def python_runner(path, timeout=240):
path = Path(path)
if not path.is_absolute() or not path.is_file() or path.suffix.lower() != ".py":
raise ValueError("--runner must be an existing absolute local Python script")
if not number(timeout) or not 1 <= timeout <= 900:
raise ValueError("runner timeout must be finite, 1..900 seconds")
def run(request):
completed = subprocess.run([sys.executable, str(path)], input=json.dumps(request),
text=True, encoding="utf-8", errors="replace", stdout=subprocess.PIPE,
stderr=subprocess.PIPE, timeout=timeout, shell=False, check=False)
# Stdout is a machine-readable, single JSON object. Do not copy stderr
# into public reports: a misconfigured external SSH tool may log secrets.
result = json.loads(completed.stdout)
if not isinstance(result, dict) or type(result.get("verified")) is not bool:
raise ValueError("Runner must return one JSON object with boolean verified")
if completed.returncode:
result["verified"] = False
result["exit_code"] = completed.returncode
result.setdefault("error", "Runner exited unsuccessfully")
return result
return run
def run_cases(nodes, preambles, runner, *, sf=5, bandwidth=500, command_timeout=12,
persist=lambda report: None, progress=lambda event: None):
preambles = validate_preambles(preambles)
sf = validate_sf(sf)
bandwidth = validate_bandwidth(bandwidth)
if len(nodes) != 2 or len({id(node) for node in nodes.values()}) != 2:
raise ValueError("Exactly two distinct CLI nodes are required")
report = dict(started_utc=datetime.now(timezone.utc).isoformat(), cases=[], aborted=False,
primary_rx_only=list(map(str, PRIMARY)), required_guard=GUARD,
usb={name: node.identity for name, node in nodes.items()}, retries=0,
sf=sf, bandwidth_khz=bandwidth,
note=f"Only R2 transmits, at 909.5/{bandwidth}/SF{sf}/CR5. No claim of actual USA receive reliability.")
def checkpoint(item, phase):
item["phase"] = phase
persist(report)
progress(dict(index=item["index"], preamble=item["preamble"], sf=sf, bandwidth_khz=bandwidth, phase=phase,
verified=item.get("verified", False), error=item.get("error")))
try:
report["initial_safety"] = snapshot(nodes, command_timeout, sf=sf, bandwidth=bandwidth)
for index, preamble in enumerate(preambles, 1):
item = dict(index=index, case_id=f"case-{index:03d}-p{preamble}", preamble=preamble, sf=sf,
bandwidth_khz=bandwidth, verified=False, preamble_ms=preamble * (2 ** sf) / bandwidth)
report["cases"].append(item)
checkpoint(item, "checking")
item["before_configuration"] = snapshot(nodes, command_timeout, sf=sf, bandwidth=bandwidth)
for node in nodes.values():
for command in ("set ethernet off", f"set ethernet.radio 909.5,{bandwidth},{sf},5,{preamble}",
"set ethernet on"):
require_ok(node.command(command, timeout=command_timeout))
item["before"] = snapshot(nodes, command_timeout, preamble, sf=sf, bandwidth=bandwidth)
request = dict(index=index, case_id=item["case_id"], preamble=preamble,
frequency_mhz=909.5, bandwidth_khz=bandwidth, sf=sf, cr=5)
item["request"] = request
checkpoint(item, "running")
started = time.monotonic()
runner_failed = False
try:
result = runner(request)
if not isinstance(result, dict) or type(result.get("verified")) is not bool:
raise ValueError("Runner did not return boolean verified")
item["runner"] = result
item["metrics"] = payload_metrics(result)
item["verified"] = item["metrics"]["verified"]
if not item["verified"]:
item["error"] = str(result.get("error", "Payload integrity/SSH run was not verified"))
runner_failed = True
except Exception as error:
runner_failed = True
item["error"] = ("Runner timed out" if isinstance(error, subprocess.TimeoutExpired)
else f"Runner failed: {type(error).__name__}")
item["runner_wall_seconds"] = time.monotonic() - started
# Timeout kills the owned runner. Explicit off also invalidates its
# KISS clients before another tuple is configured; no reconnects.
if runner_failed:
for node in nodes.values():
require_ok(node.command("set ethernet off", timeout=command_timeout))
item["after"] = snapshot(nodes, command_timeout, None if runner_failed else preamble, sf=sf, bandwidth=bandwidth)
item["counter_deltas"] = {name: counter_delta(item["before"][name]["counters"],
item["after"][name]["counters"]) for name in nodes}
checkpoint(item, "complete")
except Exception as error:
report["aborted"] = True
report["error"] = str(error)
if report["cases"]:
checkpoint(report["cases"][-1], "aborted")
report["completed"] = not report["aborted"]
eligible = [item for item in report["cases"] if item.get("phase") == "complete"
and item["verified"] and item.get("metrics", {}).get("combined_payload_kbps") is not None]
report["ranking"] = [dict(index=item["index"], preamble=item["preamble"], sf=item["sf"],
bandwidth_khz=item["bandwidth_khz"],
combined_payload_kbps=item["metrics"]["combined_payload_kbps"],
downlink_kbps=item["metrics"]["directions"]["downlink"]["goodput_kbps"],
end_to_end_kbps=item["metrics"]["end_to_end_kbps"])
for item in sorted(eligible, key=lambda value: value["metrics"]["combined_payload_kbps"], reverse=True)]
report["passed"] = report["completed"] and all(item["verified"] for item in report["cases"])
report["commands"] = {name: node.commands for name, node in nodes.items()}
persist(report)
return report
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
for name in ("a-port", "b-port", "a-ssh", "a-key", "a-helper"):
parser.add_argument("--" + name)
parser.add_argument("--preambles", default=",".join(map(str, DEFAULT_PREAMBLES)))
parser.add_argument("--sf", type=int, choices=range(5, 10), default=5,
help="fixed spreading factor (5..9), never changes R1")
parser.add_argument("--bandwidth", type=int, choices=(125, 500), default=500,
help="fixed R2 bandwidth in kHz (125 or 500), never changes R1")
parser.add_argument("--runs", type=int, default=1, help="explicit repetitions, no hidden retry")
parser.add_argument("--seed", type=int, help="optional reproducible shuffle of each run")
parser.add_argument("--runner", type=Path, help="absolute local Python runner; JSON stdin/stdout")
parser.add_argument("--runner-timeout", type=float, default=240)
parser.add_argument("--command-timeout", type=float, default=12)
parser.add_argument("--report-dir", type=Path)
parser.add_argument("--plan", action="store_true", help="no port/socket/process/report I/O")
args = parser.parse_args(argv)
try:
selected = validate_preambles([int(value) for value in args.preambles.split(",")])
if not 1 <= args.runs <= 10 or len(selected) * args.runs > 120:
raise ValueError("runs 1..10 and at most 120 total cases required")
if not number(args.command_timeout) or not 0 < args.command_timeout <= 30:
raise ValueError("command timeout must be finite, (0,30] seconds")
if not number(args.runner_timeout) or not 1 <= args.runner_timeout <= 900:
raise ValueError("runner timeout must be finite, 1..900 seconds")
except ValueError as error:
parser.error(str(error))
rng = random.Random(args.seed)
preambles = []
for _ in range(args.runs):
one_run = selected.copy()
if args.seed is not None:
rng.shuffle(one_run)
preambles.extend(one_run)
if args.plan:
print(json.dumps(dict(preambles=preambles, sf=args.sf, bandwidth_khz=args.bandwidth,
radio=f"909.5,{args.bandwidth},{args.sf},5", primary_rx_only="910.525,62.5,7,5",
required_guard=GUARD, runner_timeout=args.runner_timeout), indent=2))
return 0
if not args.a_port or not args.b_port or not args.report_dir or not args.report_dir.is_absolute():
parser.error("both CLI ports and absolute --report-dir required")
try:
if any((args.a_ssh, args.a_key, args.a_helper)):
if not all((args.a_ssh, args.a_key, args.a_helper)):
raise ValueError("--a-ssh, --a-key, --a-helper must be supplied together")
ssh_command(args.a_ssh, args.a_key, args.a_helper, args.a_port)
elif os.path.realpath(args.a_port) == os.path.realpath(args.b_port):
raise ValueError("CLI ports must be distinct")
runner = python_runner(args.runner, args.runner_timeout)
except (TypeError, ValueError) as error:
parser.error(str(error))
args.report_dir.mkdir(parents=True, exist_ok=True)
folder = Path(tempfile.mkdtemp(prefix="ethernet-preamble-", dir=args.report_dir))
def persist(report):
for name, value in [("summary", report)] + [(item["case_id"], item) for item in report["cases"]]:
temporary = folder / (name + ".json.tmp")
temporary.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
temporary.replace(folder / (name + ".json"))
a = b = None
try:
a = (SshSerialNode(args.a_ssh, args.a_key, args.a_helper, args.a_port)
if args.a_ssh else SerialNode(args.a_port))
b = SerialNode(args.b_port)
report = run_cases(dict(a=a, b=b), preambles, runner, sf=args.sf, bandwidth=args.bandwidth,
command_timeout=args.command_timeout,
persist=persist, progress=lambda event: print(json.dumps(event), flush=True))
except Exception as error:
report = dict(passed=False, aborted=True, cases=[], error=str(error))
persist(report)
finally:
for node in (b, a):
if node:
node.close()
print(json.dumps(dict(report=str(folder / "summary.json"), passed=report["passed"],
aborted=report["aborted"], ranking=report.get("ranking", []))))
return 0 if report["passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())
+361
View File
@@ -0,0 +1,361 @@
#!/usr/bin/env python3
"""One guarded-orchestrator case: real Flutter SSH through two isolated TAPs.
Invoked by ethernet_preamble_bench.py, not directly with firmware settings.
JSON configuration path: LORA_SSH_BENCH_CONFIG. JSON request: stdin; result:
stdout only. Keys are copied as files, never read/printed here, and removed in
finally. Only uniquely named helper resources are stopped; host networking,
system sshd, accounts and firmware are never configured by this runner.
"""
from __future__ import annotations
import ipaddress
import json
import math
import os
from pathlib import Path, PurePosixPath, PureWindowsPath
import queue
import re
import secrets
import shlex
import subprocess
import sys
import tempfile
import threading
import time
from urllib.parse import urlparse
from ethernet_tcp_stats import parse_tcp, summarize
ARTIFACTS = ("speed.json", "tcp-stats.json", "tcp-before.snmp", "tcp-after.snmp",
"tcp-before.utc", "tcp-after.utc", "tcp-ss.txt", "relay.json",
"result.txt", "routes.txt", "flutter.txt")
def posix_path(value):
if not isinstance(value, str) or not value.startswith("/") or value == "/" \
or ".." in PurePosixPath(value).parts or any(ord(c) < 32 for c in value):
raise ValueError("An explicit absolute non-root Linux path is required")
return str(PurePosixPath(value))
def wsl_path(value):
value = str(value)
path = PureWindowsPath(value)
if re.fullmatch(r"[A-Za-z]:", path.drive) and path.is_absolute():
return "/mnt/" + path.drive[0].lower() + "/" + "/".join(path.parts[1:])
return posix_path(value) # Supports running host-only tests under WSL too.
def endpoint(value):
parsed = urlparse(value)
host = ipaddress.IPv4Address(parsed.hostname)
private = any(host in ipaddress.IPv4Network(network) for network in
("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"))
if parsed.scheme != "tcp" or parsed.port != 5003 or not private \
or parsed.username or parsed.password or parsed.path not in ("", "/") \
or parsed.query or parsed.fragment:
raise ValueError("Use an explicit private numeric IPv4 TCP port-5003 endpoint")
return value
def validate_config(value):
required = ("ssh_host", "ssh_key", "pi_bench", "server_endpoint", "client_endpoint",
"wsl_workspace", "flutter", "account", "out_root")
if not isinstance(value, dict) or any(not isinstance(value.get(key), str) for key in required):
raise ValueError("Required benchmark configuration fields are missing")
config = {key: value[key] for key in required}
if not re.fullmatch(r"(?:[A-Za-z0-9_][A-Za-z0-9_.-]*@)?[A-Za-z0-9_][A-Za-z0-9_.-]*", config["ssh_host"]):
raise ValueError("Invalid SSH host")
if not re.fullmatch(r"[a-z_][a-z0-9_-]*", config["account"]) or config["account"] == "root":
raise ValueError("An unprivileged WSL test account is required")
for key in ("pi_bench", "wsl_workspace", "flutter"):
config[key] = posix_path(config[key])
for key in ("ssh_key", "out_root"):
path = Path(config[key])
if not path.is_absolute() or any(ord(c) < 32 for c in config[key]):
raise ValueError("Explicit absolute local paths are required")
if key == "ssh_key" and not path.is_file():
raise ValueError("The selected local SSH key file is unavailable")
config[key] = str(path)
endpoint(config["server_endpoint"]); endpoint(config["client_endpoint"])
if config["server_endpoint"] == config["client_endpoint"]:
raise ValueError("Two distinct radios are required")
return config
def validate_request(value):
if not isinstance(value, dict) or type(value.get("index")) is not int or not 1 <= value["index"] <= 120 \
or type(value.get("preamble")) is not int or not 32 <= value["preamble"] <= 2048:
raise ValueError("Invalid preamble case")
if type(value.get("frequency_mhz")) is not float or value["frequency_mhz"] != 909.5 \
or type(value.get("bandwidth_khz")) is not int or value["bandwidth_khz"] not in (125, 500) \
or type(value.get("sf")) is not int or not 5 <= value["sf"] <= 9 \
or type(value.get("cr")) is not int or value["cr"] != 5:
raise ValueError("Only the orchestrator's 909.5/125-or-500/SF5-SF9/CR5 cases are permitted")
expected = f"case-{value['index']:03d}-p{value['preamble']}"
if value.get("case_id") != expected:
raise ValueError("Invalid case identifier")
return value
def ssh_args(config):
return ["ssh", "-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes",
"-o", "ConnectTimeout=8", "-i", config["ssh_key"], "--", config["ssh_host"]]
def remote_args(config, command):
return ssh_args(config) + [shlex.join(command)]
def artifact_dir(text, parent, stem):
matches = re.findall(r"^" + re.escape(stem) + r": (.+)$", text, re.MULTILINE)
if len(matches) != 1:
raise ValueError("Missing or ambiguous helper artifact directory")
path = PurePosixPath(posix_path(matches[0].strip()))
expected = "ethernet-phone" if stem == "Artifacts" else "ethernet-flutter"
if path.parent != PurePosixPath(parent) or not re.fullmatch(expected + r"\.[A-Za-z0-9]{8}", path.name):
raise ValueError("Helper artifacts escaped the explicitly selected bench directory")
return str(path)
def parse_server_ready(text, config):
if sum(line.startswith("READY: ") for line in text.splitlines()) != 1 \
or f"READY: gateway={config['server_endpoint']} local=10.90.5.1 remote=10.90.5.2 SSH=2222 user=mikec" not in text.splitlines():
raise ValueError("Server helper did not confirm the exact isolated endpoint")
fingerprints = re.findall(r"\bSHA256:[A-Za-z0-9+/]{43}(?=\s|$)", text)
pids = re.findall(r"^Server helper PID: ([1-9][0-9]{0,9})$", text, re.MULTILINE)
if len(fingerprints) != 1 or len(pids) != 1 or int(pids[0]) <= 1:
raise ValueError("Missing or ambiguous server fingerprint/PID")
return dict(pid=int(pids[0]), fingerprint=fingerprints[0],
directory=artifact_dir(text, config["pi_bench"], "Artifacts"))
def read_ready(process, config, timeout):
lines = queue.Queue(maxsize=128)
def read():
try:
while True:
line = process.stdout.readline(16385)
if len(line) > 16384:
raise ValueError("Oversized helper startup line")
lines.put_nowait(line)
if not line:
break
except Exception:
try:
lines.put_nowait(None)
except queue.Full:
pass
threading.Thread(target=read, daemon=True).start()
observed = ""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
line = lines.get(timeout=max(.001, deadline - time.monotonic()))
except queue.Empty:
break
if not line:
raise RuntimeError("Server helper ended before readiness")
observed += line
if len(observed) > 65536:
raise RuntimeError("Oversized helper startup output")
pids = re.findall(r"^Server helper PID: ([1-9][0-9]{0,9})$", observed, re.MULTILINE)
if len(pids) == 1 and int(pids[0]) > 1:
# Retain identity for finally even if another readiness field fails.
process.helper_pid = int(pids[0])
if "Artifacts: " in observed and "Server helper PID: " in observed:
return parse_server_ready(observed, config)
raise TimeoutError("Server helper readiness deadline exceeded")
def normalized_speed(speed, helper_exit_code):
def finite(value):
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
directions = {}
for source, target in (("download", "downlink"), ("upload", "uplink")):
item = speed.get(source)
normalized = None
if isinstance(item, dict) and type(item.get("bytes")) is int and item["bytes"] > 0 \
and finite(item.get("elapsed_ms")) and item["elapsed_ms"] > 0:
normalized = dict(bytes=item["bytes"], elapsed_ms=item["elapsed_ms"],
elapsed_seconds=item["elapsed_ms"] / 1000,
kbps=item["bytes"] * 8 / item["elapsed_ms"], hash_verified=item.get("sha256_valid") is True)
for key in ("data_elapsed_ms", "data_kbps", "first_byte_ms", "ready_ms", "command_elapsed_ms"):
if key in item:
normalized[key] = item[key]
directions[target] = normalized
verified = (helper_exit_code == 0 and speed.get("success") is True and speed.get("host_verified") is True
and all(value and value["hash_verified"] for value in directions.values()))
return dict(verified=bool(verified), handshake_ms=speed.get("handshake_ms"),
total_ms=speed.get("total_elapsed_ms"), **directions)
def stop_server_args(config, pid, prefix):
if type(pid) is not int or pid <= 1 or not re.fullmatch(r"ethphone-speed-[a-f0-9]{8}", prefix):
raise ValueError("Invalid owned helper identity")
# Match whole argv tokens, including the exact generated prefix. A recycled
# PID or unrelated helper can never satisfy this check by substring alone.
program = """import os,signal,sys
pid=int(sys.argv[1]); script=sys.argv[2]; prefix=sys.argv[3]
try:
args=open('/proc/%d/cmdline'%pid,'rb').read().decode().strip('\\0').split('\\0')
except FileNotFoundError:
raise SystemExit(0)
if script not in args or '--prefix' not in args or args[args.index('--prefix')+1] != prefix:
raise SystemExit(3)
os.kill(pid,signal.SIGTERM)
"""
return remote_args(config, ["sudo", "-n", "python3", "-c", program, str(pid),
config["pi_bench"] + "/ethernet_ssh_phone_bench.sh", prefix])
def execute(config, request):
config = validate_config(config); request = validate_request(request)
root = Path(config["out_root"]); root.mkdir(parents=True, exist_ok=True)
folder = Path(tempfile.mkdtemp(prefix=request["case_id"] + "-", dir=root))
token = secrets.token_hex(4)
server_prefix, client_prefix = "ethphone-speed-" + token, "ethflutter-speed-" + token
windows_key = folder / "client_key.ephemeral"
wsl_key = config["wsl_workspace"] + "/.key-" + client_prefix
server = ready = None
wsl_key_owned = False
result = dict(verified=False, tcp_client=None, tcp_server=None,
artifacts=dict(directory=str(folder)), case_id=request["case_id"])
deadline = time.monotonic() + 225
def run(argv, timeout=10, check=True):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("Case deadline exceeded")
return subprocess.run(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
timeout=min(timeout, remaining), shell=False, check=check)
def server_tcp(phase):
try:
text = run(remote_args(config, ["sudo", "-n", "ip", "netns", "exec", server_prefix,
"cat", "/proc/net/snmp"]), timeout=5).stdout
(folder / ("server-tcp-" + phase + ".snmp")).write_text(text, encoding="ascii")
return parse_tcp(text)
except Exception:
return None
before = None
try:
command = ["sudo", "-n", "bash", config["pi_bench"] + "/ethernet_ssh_phone_bench.sh",
"--endpoint", config["server_endpoint"], "--bench-dir", config["pi_bench"],
"--prefix", server_prefix, "--duration", "300"]
server = subprocess.Popen(remote_args(config, command), stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
encoding="utf-8", errors="replace", shell=False)
ready = read_ready(server, config, timeout=20)
result["artifacts"]["server_directory"] = ready["directory"]
scp = ["scp", "-q", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes",
"-o", "ConnectTimeout=8", "-i", config["ssh_key"], "--",
config["ssh_host"] + ":" + ready["directory"] + "/client_key", str(windows_key)]
run(scp, timeout=10)
# noclobber opens the new private file atomically. A stale/colliding
# name is refused, never overwritten or removed by our cleanup.
prepare = ('set -euC; umask 077; exec 3>"$2"; cat -- "$1" >&3; '
'chmod 600 -- "$2"; chown "$3": -- "$2"')
run(["wsl", "--user", "root", "--exec", "bash", "-c", prepare, "--",
wsl_path(windows_key), wsl_key, config["account"]], timeout=10)
wsl_key_owned = True
before = server_tcp("before")
helper = wsl_path(Path(__file__).resolve().with_name("ethernet_ssh_flutter_bench.sh"))
client = run(["wsl", "--user", "root", "--exec", "bash", helper,
"--endpoint", config["client_endpoint"], "--bench-dir", config["wsl_workspace"],
"--workspace", config["wsl_workspace"], "--flutter", config["flutter"],
"--key", wsl_key, "--fingerprint", ready["fingerprint"], "--prefix", client_prefix,
"--account", config["account"], "--test-name", "speed", "--phase-timeout", "45"],
timeout=220, check=False)
(folder / "client-helper.txt").write_text(client.stdout, encoding="utf-8")
client_folder = artifact_dir(client.stdout, config["wsl_workspace"], "Flutter RF artifacts")
result["artifacts"]["client_directory"] = client_folder
# The allowlist contains evidence only, never arbitrary remote files or
# the ephemeral client key. Missing evidence remains explicitly absent.
copy = 'set -eu; src=$1; dst=$2; shift 2; for name do if [ -f "$src/$name" ]; then cp -- "$src/$name" "$dst/$name"; fi; done'
run(["wsl", "--user", "root", "--exec", "bash", "-c", copy, "--",
client_folder, wsl_path(folder), *ARTIFACTS], timeout=10)
speed = json.loads((folder / "speed.json").read_text(encoding="utf-8"))
if not isinstance(speed, dict):
raise ValueError("Invalid speed evidence")
result.update(normalized_speed(speed, client.returncode))
if not result["verified"]:
result["error"] = "SSH payload run did not complete with both hashes verified"
try:
result["tcp_client"] = json.loads((folder / "tcp-stats.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
result["tcp_client"] = dict(available=False, scope="isolated_network_namespace")
except Exception as error:
result["verified"] = False
result["error"] = ("SSH runner deadline exceeded" if isinstance(error, (TimeoutError, subprocess.TimeoutExpired))
else "SSH runner failed; inspect the bounded non-secret evidence")
finally:
if ready:
after = server_tcp("after")
if before is not None and after is not None:
try:
result["tcp_server"] = summarize(before, after)
except ValueError:
pass
if result["tcp_server"] is None:
result["tcp_server"] = dict(available=False, scope="isolated_network_namespace")
helper_pid = ready["pid"] if ready else getattr(server, "helper_pid", None)
if type(helper_pid) is int:
# Cleanup gets a separate bounded budget even after case timeout.
try:
subprocess.run(stop_server_args(config, helper_pid, server_prefix),
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=5, shell=False, check=True)
except Exception:
result["verified"] = False
result["cleanup_error"] = "Owned server stop could not be confirmed; helper remains duration-bounded"
if wsl_key_owned:
try:
subprocess.run(["wsl", "--user", "root", "--exec", "rm", "-f", "--", wsl_key],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=5, shell=False, check=True)
except Exception:
result["verified"] = False
result["cleanup_error"] = "Ephemeral WSL key cleanup could not be confirmed"
try:
if windows_key.exists():
windows_key.unlink()
except OSError:
result["verified"] = False
result["cleanup_error"] = "Ephemeral local key cleanup could not be confirmed"
if server is not None:
try:
server.wait(timeout=3)
except subprocess.TimeoutExpired:
server.kill() # Only the exact local Popen child, no PID search.
server.wait(timeout=2)
if server.stdout:
server.stdout.close()
(folder / "runner-result.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
return result
def main():
try:
config_path = Path(os.environ.get("LORA_SSH_BENCH_CONFIG", ""))
if not config_path.is_absolute() or not config_path.is_file() or config_path.stat().st_size > 65536:
raise ValueError("An explicit local configuration file is required")
config = json.loads(config_path.read_text(encoding="utf-8-sig"))
request_text = sys.stdin.read(4097)
if len(request_text) > 4096:
raise ValueError("Oversized case request")
result = execute(config, json.loads(request_text))
except Exception:
result = dict(verified=False, error="Invalid benchmark configuration/request or unavailable runner resources")
print(json.dumps(result), flush=True)
return 0 if result["verified"] else 1
if __name__ == "__main__":
raise SystemExit(main())
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""Verify primary MeshCore chat while Ethernet RF traffic uses radio2.
TRANSMITS LoRa and saves channel-0 TX policy=radio on the two explicit lab
Companions. Does not flash, reset, edit WiFi, read channel secrets, or configure
RF. Both primary and Ethernet tuples MUST already be 909.5/500/SF5/CR5; the
script refuses all test transmission unless both devices report those tuples.
Only an opposite radio's structured CHANNEL MSG reception proves chat delivery;
a command echo or the sender's local Sent response never passes the check.
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import re
import secrets
import threading
import time
from ethernet_kiss_probe import KissEndpoint, run_probe
from serial_session import assert_port_idle, open_configured_session, read_command_reply
def assert_lab_radio(reply):
match = re.search(r"(?:^|\n)\s*(?:->\s*)?>\s*(\d+(?:\.\d+)?),(\d+(?:\.\d+)?),(\d+),(\d+)(?=[,;\s]|$)", reply)
if not match or tuple(float(value) for value in match.groups()) != (909.5, 500, 5, 5):
raise RuntimeError("Refusing TX: both primary and Ethernet must report 909.5,500,5,5")
def received_channel_marker(text, marker):
messages = re.findall(r"CHANNEL MSG -> [^\r\n]*\((?:FLOOD|DIRECT)\)\r?\n[ \t]*([^\r\n]*)", text)
return any(re.search(r"(?<![A-Za-z0-9_-])" + re.escape(marker) + r"(?![A-Za-z0-9_-])", item)
for item in messages)
class SerialNode:
def __init__(self, path):
import serial
from serial.tools import list_ports
matches = [item for item in list_ports.comports()
if os.path.realpath(item.device) == os.path.realpath(path)]
if len(matches) != 1:
raise RuntimeError("Selected USB identity is absent or ambiguous: " + path)
info = matches[0]
self.identity = dict(port=info.device, usb_serial=info.serial_number, vid=info.vid,
pid=info.pid, location=info.location)
self.commands = []
self.port = serial.Serial(port=None, baudrate=115200, timeout=0.04, exclusive=True)
self.port.port = info.device
self.port.rts = False
self.port.dtr = info.vid in (0x303A, 0x2886, 0x239A)
assert_port_idle(info.device)
open_configured_session(self.port)
self.read(0.3)
self.port.write(b"+++MESHCORE-TERM-START\n")
self.port.flush()
self.hello = self.read(3)
def read(self, timeout=1, quiet=0.2):
deadline = time.monotonic() + timeout
quiet_until = deadline
data = bytearray()
while time.monotonic() < min(deadline, quiet_until):
chunk = self.port.read(min(self.port.in_waiting or 1, 4096))
if chunk:
data.extend(chunk)
quiet_until = time.monotonic() + quiet
return data.decode("utf-8", errors="replace")
def command(self, text, timeout=10):
if any(value in text for value in "\r\n\0"):
raise ValueError("single-line commands only")
self.port.write((text + "\n").encode("ascii"))
self.port.flush()
reply = read_command_reply(self.port, text, timeout)
self.commands.append(dict(command=text, reply=reply))
return reply
def close(self):
try:
self.port.write(b"+++MESHCORE-TERM-STOP\n")
self.port.flush()
finally:
self.port.close()
def snapshot(node):
return {command: node.command(command) for command in
("get name", "get radio", "get ethernet.radio", "get ethernet", "get tx.channel 0", "channels")}
def run_primary(a, b, a_endpoint, b_endpoint, *, timeout=10, factory=KissEndpoint):
report = {"usb": {"a": a.identity, "b": b.identity}, "chat": [], "passed": False}
nodes = {"a": a, "b": b}
# All four RF assertions happen before opening KISS or queueing any chat.
report["before"] = {name: snapshot(node) for name, node in nodes.items()}
for state in report["before"].values():
assert_lab_radio(state["get radio"])
assert_lab_radio(state["get ethernet.radio"])
if "ethernet=on; active (RX time-shared)" not in state["get ethernet"]:
raise RuntimeError("Ethernet radio2 must already be enabled and active")
for node in nodes.values():
reply = node.command("set tx.channel 0 radio")
if "Error" in reply or "ERROR" in reply or "OK" not in reply:
raise RuntimeError("Could not save channel-0 primary-only policy: " + reply)
if "radio; active=radio" not in node.command("get tx.channel 0"):
raise RuntimeError("Channel 0 primary-only routing was not confirmed")
if "channels on" not in node.command("show channels on"):
raise RuntimeError("Could not enable incoming channel display")
endpoints = []
result = {}
worker = None
try:
endpoints = [factory(a_endpoint)]
endpoints.append(factory(b_endpoint))
for node in nodes.values():
reply = node.command("get ethernet")
if "TCP 5003 connected" not in reply:
raise RuntimeError("Both Ethernet KISS sessions must be connected before chat")
def ethernet_worker():
try:
result["ethernet"] = run_probe(*endpoints, count=1, timeout=3, interval=0.1)
except Exception as error:
result["ethernet_error"] = str(error)
worker = threading.Thread(target=ethernet_worker, daemon=True)
worker.start()
token = secrets.token_hex(8)
for source, destination in (("a", "b"), ("b", "a")):
marker = f"ETH-PRIMARY-{token}-{source}"
# Only the destination's serial output is consulted for reception.
destination_node = nodes[destination]
destination_node.read(0.1)
concurrent = worker.is_alive()
sent = time.monotonic()
sender_reply = nodes[source].command("channel 0 " + marker)
received_text = ""
deadline = sent + timeout
while time.monotonic() < deadline and not received_channel_marker(received_text, marker):
received_text += destination_node.read(min(0.4, deadline - time.monotonic()))
report["chat"].append(dict(source=source, destination=destination, marker=marker,
ethernet_worker_active_at_send=concurrent,
sender_reply=sender_reply, receiver_output=received_text,
received=received_channel_marker(received_text, marker),
elapsed_seconds=round(time.monotonic() - sent, 3)))
worker.join(timeout=60)
if worker.is_alive():
raise RuntimeError("Ethernet worker did not finish within bounded test window")
report.update(result)
report["after"] = {name: snapshot(node) for name, node in nodes.items()}
for name in nodes:
assert_lab_radio(report["after"][name]["get radio"])
assert_lab_radio(report["after"][name]["get ethernet.radio"])
report["passed"] = bool(result.get("ethernet", {}).get("passed")) and all(
item["received"] and item["ethernet_worker_active_at_send"] for item in report["chat"])
return report
finally:
for endpoint in endpoints:
endpoint.close()
if worker and worker.is_alive():
worker.join(timeout=2)
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--a-port", required=True)
parser.add_argument("--b-port", required=True)
parser.add_argument("--a-endpoint", required=True)
parser.add_argument("--b-endpoint", required=True)
parser.add_argument("--report", required=True, type=Path)
parser.add_argument("--timeout", type=float, default=10)
args = parser.parse_args(argv)
if args.timeout <= 0 or os.path.realpath(args.a_port) == os.path.realpath(args.b_port):
parser.error("positive timeout and two distinct serial ports are required")
a = b = None
try:
a = SerialNode(args.a_port)
b = SerialNode(args.b_port)
result = run_primary(a, b, args.a_endpoint, args.b_endpoint, timeout=args.timeout)
except Exception as error:
result = {"passed": False, "error": str(error)}
finally:
for node in (b, a):
if node:
node.close()
result["commands"] = {name: node.commands for name, node in (("a", a), ("b", b)) if node}
output = json.dumps(result, indent=2)
args.report.write_text(output + "\n", encoding="utf-8")
print(output)
return 0 if result["passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())
+310
View File
@@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""Guarded Ethernet-only R2 sweep; R1 stays USA receive-only.
Requires two explicitly selected USB CLI ports and KISS TCP endpoints. Refuses
old firmware without the immutable 909.500 MHz HIL TX lock. Never writes R1,
WiFi, TX power, channels, or credentials; never flashes or reboots. Raw frame
loss/goodput is measured, not an SSH connection or USA receive qualification.
"""
from __future__ import annotations
import argparse
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from decimal import Decimal
import json
import math
import os
from pathlib import Path
import re
import tempfile
import time
from urllib.parse import urlparse
from ethernet_primary_probe import SerialNode
from ethernet_ssh_serial import SshSerialNode, ssh_command
from ethernet_kiss_probe import KissEndpoint, run_probe
GUARD = "HIL TX lock=909.500 MHz; off-frequency TX blocked; primary RX-only when off-frequency"
PRIMARY = (Decimal("910.525"), Decimal("62.5"), 7, 5)
PRIMARY_KBPS = 2.734375
@dataclass(frozen=True)
class Candidate:
index: int
bandwidth: float
sf: int
preamble: int
@property
def tuple_text(self):
return f"909.5,{self.bandwidth:g},{self.sf},5,{self.preamble}"
def describe(self):
kbps = self.sf * 4 * self.bandwidth / (5 * (2 ** self.sf))
return dict(asdict(self), frequency_mhz=909.5, cr=5,
nominal_kbps=kbps, nominal_ratio_to_usa=kbps / PRIMARY_KBPS,
preamble_ms=self.preamble * (2 ** self.sf) / self.bandwidth)
# The 1x control gets extra margin for the Indicator's slow I2C hop. Other
# candidates hold approximately 65.5 ms of preamble, not a fixed symbol count.
CANDIDATES = tuple(Candidate(*item) for item in (
(1, 62.5, 7, 64), (2, 125, 7, 64), (3, 250, 7, 128),
(4, 500, 7, 256), (5, 500, 8, 128), (6, 500, 6, 512), (7, 500, 5, 1024),
# Longer-preamble controls distinguish acquisition losses from PHY limits.
(8, 500, 5, 2048), (9, 500, 7, 512),
# Fast-switch-board controls, including ~16 ms chirp acquisition windows.
(10, 500, 5, 32), (11, 500, 5, 64), (12, 500, 5, 128), (13, 500, 5, 256),
(14, 500, 7, 32), (15, 500, 7, 64), (16, 250, 7, 32), (17, 500, 8, 32),
(18, 125, 7, 32), (19, 250, 8, 32), (20, 250, 7, 48),
(21, 500, 5, 512), (22, 125, 7, 48)))
def data_lines(reply):
result = []
for line in reply.splitlines():
match = re.match(r"^\s*(?:->\s*)?>\s*(.*?)\s*$", line)
if match and match[1]:
result.append(match[1])
return result
def require_guard(reply):
matches = [line for line in data_lines(reply) if line.startswith("HIL TX")]
if matches != [GUARD]:
raise RuntimeError("Refusing TX/configuration: exact immutable 909.500 MHz HIL lock is not confirmed")
def radio_tuple(reply):
matches = []
for line in data_lines(reply):
match = re.match(r"^(\d+(?:\.\d+)?),(\d+(?:\.\d+)?),(\d+),(\d+)"
r"(?:,(\d+))?(?=[;\s]|$)", line)
if match:
matches.append((Decimal(match[1]), Decimal(match[2]), int(match[3]), int(match[4]),
int(match[5]) if match[5] is not None else None))
if len(matches) != 1:
raise RuntimeError("Missing or ambiguous structured radio tuple")
return matches[0]
def primary_tx(reply):
matches = []
for line in data_lines(reply):
matches.extend(re.findall(r"(?:^|\s)TX=(\d+),(\d+)(?=\s|;|$)", line))
if len(matches) != 1:
raise RuntimeError("Missing or ambiguous per-profile TX counters")
return int(matches[0][0])
def safety_snapshot(nodes, timeout):
states = {}
# Complete all four assertions before permitting ANY R2 setting or KISS TX.
for name, node in nodes.items():
guard = node.command("get lab.tx", timeout=timeout)
require_guard(guard)
radio = node.command("get radio", timeout=timeout)
actual = radio_tuple(radio)
# ESP32's float getter can print 910.5250244 for requested 910.525.
# Accept only this sub-50 Hz representation error, never a nearby RF
# channel or any bandwidth/SF/coding-rate change.
if abs(actual[0] - PRIMARY[0]) > Decimal("0.00005") or actual[1:4] != PRIMARY[1:4]:
raise RuntimeError("Refusing TX/configuration: R1 must remain exactly 910.525,62.5,7,5")
states[name] = {"lab_tx": guard, "primary_radio": radio}
return states
def require_ok(reply):
if re.search(r"\b(?:error|err)\b", reply, re.IGNORECASE) or not re.search(r"\bOK\b", reply):
raise RuntimeError("Radio2 configuration did not complete successfully: " + reply)
def measurement(probe, elapsed):
transfers = probe.get("transfers", [])
if not transfers:
raise RuntimeError("Probe returned no frame evidence")
received = [item for item in transfers if item["received"]]
received_bytes = sum(item["size"] for item in received)
payload_bytes = sum(max(0, item["size"] - 14) for item in received)
return dict(elapsed_seconds=round(elapsed, 6), frames_attempted=len(transfers),
frames_received=len(received), frames_lost=len(transfers) - len(received),
loss_fraction=1 - len(received) / len(transfers),
ethernet_bytes_received=received_bytes,
ethernet_goodput_bps=received_bytes * 8 / elapsed if elapsed else 0,
ethernet_payload_goodput_bps=payload_bytes * 8 / elapsed if elapsed else 0,
goodput_definition="received Ethernet bytes (payload excludes 14-byte MAC header), both directions; not SSH application throughput")
def run_sweep(a, b, endpoint_a, endpoint_b, candidates, *, count=1, timeout=3,
interval=0.1, command_timeout=12, endpoint_factory=KissEndpoint,
probe_function=run_probe, persist=lambda report: None):
if not candidates or any(case not in CANDIDATES for case in candidates):
raise ValueError("Select only predefined 909.5 MHz cases")
nodes = {"a": a, "b": b}
report = dict(started_utc=datetime.now(timezone.utc).isoformat(), cases=[], aborted=False,
primary_usa=list(map(str, PRIMARY)), primary_nominal_kbps=PRIMARY_KBPS,
probe_parameters=dict(count=count, timeout=timeout, interval=interval,
command_timeout=command_timeout, duplex=False, retries=0),
usb={name: node.identity for name, node in nodes.items()},
note="No USA TX. Primary configuration/counters only; no claim of actual USA receive reliability. Raw Ethernet probe, not SSH.")
try:
report["initial_safety"] = safety_snapshot(nodes, command_timeout)
for case in candidates:
item = dict(candidate=case.describe(), passed=False)
report["cases"].append(item)
endpoints = []
try:
item["safety_before_configuration"] = safety_snapshot(nodes, command_timeout)
before = {}
for name, node in nodes.items():
status = node.command("get radio2.status", timeout=command_timeout)
before[name] = primary_tx(status)
if before[name] != 0:
raise RuntimeError("Primary TX counter is nonzero; refusing any further test TX")
item["primary_tx_before"] = before
# Previous case's endpoints were closed in finally before any
# new tuple. Explicit off releases only the Ethernet R2 lease.
for node in nodes.values():
for command in ("set ethernet off", "set ethernet.radio " + case.tuple_text,
"set ethernet on"):
require_ok(node.command(command, timeout=command_timeout))
states = {}
for name, node in nodes.items():
tuple_reply = node.command("get ethernet.radio", timeout=command_timeout)
expected = (Decimal("909.5"), Decimal(str(case.bandwidth)), case.sf, 5, case.preamble)
if radio_tuple(tuple_reply) != expected:
raise RuntimeError("Ethernet radio2 does not match the selected tuple/preamble")
states[name] = {"ethernet_radio": tuple_reply,
"timing": node.command("get radio2.timing", timeout=command_timeout),
"status": node.command("get radio2.status", timeout=command_timeout),
"ethernet": node.command("get ethernet", timeout=command_timeout)}
if "ethernet=on; active (RX time-shared)" not in states[name]["ethernet"]:
raise RuntimeError("Ethernet radio2 is not active on station WiFi")
if primary_tx(states[name]["status"]) != before[name]:
raise RuntimeError("Primary TX counter changed during radio2 setup")
item["configured"] = states
item["safety_before_tx"] = safety_snapshot(nodes, command_timeout)
endpoints.append(endpoint_factory(endpoint_a))
endpoints.append(endpoint_factory(endpoint_b))
started = time.monotonic()
probe = probe_function(*endpoints, count=count, timeout=timeout, interval=interval, duplex=False)
elapsed = time.monotonic() - started
# Replace the generic helper's old fixed SF5/BW500 label with
# the exact PHY verified above; preserve all original frames.
probe["baseline"] = "verified R2 " + case.tuple_text + "; R1 USA RX-only"
item["probe"] = probe
item["measurement"] = measurement(probe, elapsed)
item["host_diagnostics"] = {name: dict(decode_errors=endpoint.decoder.errors,
queue_overflows=endpoint.overflow, error=endpoint.error)
for name, endpoint in zip(("a", "b"), endpoints)}
finally:
for endpoint in endpoints:
endpoint.close()
item["safety_after_tx"] = safety_snapshot(nodes, command_timeout)
item["primary_tx_after"] = {}
for name, node in nodes.items():
status = node.command("get radio2.status", timeout=command_timeout)
item["primary_tx_after"][name] = primary_tx(status)
if item["primary_tx_after"][name] != before[name]:
raise RuntimeError("Primary TX counter changed; aborting all remaining cases")
item["passed"] = bool(item["probe"].get("passed")) and not any(
value for state in item["host_diagnostics"].values() for value in state.values())
persist(report)
except Exception as error:
report["aborted"] = True
report["error"] = str(error)
report["completed"] = not report["aborted"]
report["passed"] = report["completed"] and all(item["passed"] for item in report["cases"])
report["commands"] = {name: node.commands for name, node in nodes.items()}
persist(report)
return report
def endpoint_valid(endpoint):
parsed = urlparse(endpoint)
return (parsed.scheme == "tcp" and bool(parsed.hostname) and parsed.port in (None, 5003)
and not parsed.username and not parsed.password and parsed.path in ("", "/")
and not parsed.query and not parsed.fragment)
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
for name in ("a-port", "b-port", "a-endpoint", "b-endpoint"):
parser.add_argument("--" + name)
parser.add_argument("--a-ssh", help="optional user@host owning A's USB port")
parser.add_argument("--a-key", help="absolute local private-key path for --a-ssh")
parser.add_argument("--a-helper", help="absolute remote ethernet_serial_rpc.py path")
parser.add_argument("--cases", default="1,2,3,4,5,6,7", help="comma-separated predefined case indexes")
parser.add_argument("--count", type=int, default=1)
parser.add_argument("--timeout", type=float, default=3)
parser.add_argument("--command-timeout", type=float, default=12)
parser.add_argument("--interval", type=float, default=0.1)
parser.add_argument("--report-dir", type=Path)
parser.add_argument("--plan", action="store_true", help="print candidates without opening any port/socket")
args = parser.parse_args(argv)
try:
indexes = [int(value) for value in args.cases.split(",")]
if len(set(indexes)) != len(indexes):
raise ValueError("duplicate cases")
cases = [next(case for case in CANDIDATES if case.index == index) for index in indexes]
except (ValueError, StopIteration):
parser.error(f"--cases must select unique indexes from 1..{len(CANDIDATES)}")
if not 1 <= args.count <= 4 or not 0 < args.timeout <= 15 or not 0 < args.command_timeout <= 30 \
or not 0 <= args.interval <= 5 or not all(map(math.isfinite, (args.timeout, args.command_timeout, args.interval))):
parser.error("count 1..4, timeout (0,15], command-timeout (0,30], interval [0,5] required")
if args.plan:
print(json.dumps({"candidates": [case.describe() for case in cases], "required_guard": GUARD,
"primary_rx_only": "910.525,62.5,7,5"}, indent=2))
return 0
if not all((args.a_port, args.b_port, args.a_endpoint, args.b_endpoint, args.report_dir)):
parser.error("both USB ports, both TCP endpoints and an absolute --report-dir are required")
if any((args.a_ssh, args.a_key, args.a_helper)):
if not all((args.a_ssh, args.a_key, args.a_helper)):
parser.error("--a-ssh, --a-key and --a-helper must be supplied together")
try:
ssh_command(args.a_ssh, args.a_key, args.a_helper, args.a_port)
except (ValueError, TypeError) as error:
parser.error(str(error))
try:
valid_endpoints = endpoint_valid(args.a_endpoint) and endpoint_valid(args.b_endpoint)
except ValueError:
valid_endpoints = False
if not args.report_dir.is_absolute() or not valid_endpoints or args.a_endpoint == args.b_endpoint \
or (not args.a_ssh and os.path.realpath(args.a_port) == os.path.realpath(args.b_port)):
parser.error("use an absolute report directory and distinct USB ports/TCP port-5003 endpoints")
args.report_dir.mkdir(parents=True, exist_ok=True)
folder = Path(tempfile.mkdtemp(prefix="ethernet-r2-sweep-", dir=args.report_dir))
def persist(report):
output = json.dumps(report, indent=2) + "\n"
temporary = folder / "summary.json.tmp"
temporary.write_text(output, encoding="utf-8")
temporary.replace(folder / "summary.json")
for item in report["cases"]:
(folder / f"case-{item['candidate']['index']}.json").write_text(json.dumps(item, indent=2) + "\n", encoding="utf-8")
a = b = None
try:
a = (SshSerialNode(args.a_ssh, args.a_key, args.a_helper, args.a_port)
if args.a_ssh else SerialNode(args.a_port))
b = SerialNode(args.b_port)
report = run_sweep(a, b, args.a_endpoint, args.b_endpoint, cases, count=args.count,
timeout=args.timeout, interval=args.interval, command_timeout=args.command_timeout,
persist=persist)
except Exception as error:
report = dict(passed=False, aborted=True, cases=[], error=str(error))
persist(report)
finally:
for node in (b, a):
if node:
node.close()
print(json.dumps({"report": str(folder / "summary.json"), "passed": report["passed"],
"aborted": report["aborted"], "error": report.get("error")}))
return 0 if report["passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Private SSH-stdio transport for one explicitly selected lab USB CLI port.
Stdout is exclusively bounded JSON-lines. The SSH connection owns the serial
session; EOF or a malformed/incomplete command closes it, without rebooting.
"""
from __future__ import annotations
import argparse
import contextlib
import json
import math
import sys
MAX_LINE = 65536
MAX_COMMAND = 512
PROTOCOL = "meshcore-serial-rpc-1"
def validate_command(command, timeout):
if not isinstance(command, str) or not command or len(command) > MAX_COMMAND \
or any(character in command for character in "\r\n\0"):
raise ValueError("a bounded single-line command is required")
command.encode("ascii")
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) \
or not math.isfinite(timeout) or not 0 < timeout <= 30:
raise ValueError("command timeout must be finite and in (0,30]")
def encode_message(message):
data = (json.dumps(message, ensure_ascii=True, allow_nan=False) + "\n").encode("ascii")
if len(data) > MAX_LINE:
raise ValueError("RPC message exceeds the line limit")
return data
def decode_message(line):
if not line or len(line) > MAX_LINE or not line.endswith(b"\n"):
raise ValueError("RPC requires bounded, complete JSON lines")
message = json.loads(line.decode("utf-8"), parse_constant=lambda value: (_ for _ in ()).throw(ValueError(value)))
if not isinstance(message, dict):
raise ValueError("RPC message must be an object")
return message
def serve(node, source, destination):
def send(message):
destination.write(encode_message(message))
destination.flush()
try:
send(dict(type="ready", protocol=PROTOCOL, identity=node.identity,
hello=getattr(node, "hello", "")))
while True:
line = source.readline(MAX_LINE + 1)
if not line:
return 0
request_id = None
try:
request = decode_message(line)
if set(request) != {"id", "command", "timeout"}:
raise ValueError("unexpected RPC request fields")
request_id = request["id"]
if type(request_id) is not int or request_id <= 0:
raise ValueError("positive integer request id required")
validate_command(request["command"], request["timeout"])
# No incidental diagnostic output may corrupt the protocol.
with contextlib.redirect_stdout(sys.stderr):
reply = node.command(request["command"], timeout=request["timeout"])
if not isinstance(reply, str):
raise ValueError("serial command did not return a text reply")
send(dict(id=request_id, completed=True, reply=reply))
except Exception as error:
send(dict(id=request_id, completed=False, error=str(error)[:1024]))
return 1 # Never attribute a late serial reply to another command.
finally:
with contextlib.redirect_stdout(sys.stderr):
node.close()
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", required=True)
args = parser.parse_args(argv)
try:
from ethernet_primary_probe import SerialNode
with contextlib.redirect_stdout(sys.stderr):
node = SerialNode(args.port)
return serve(node, sys.stdin.buffer, sys.stdout.buffer)
except Exception as error:
print("Serial RPC stopped: " + str(error), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env bash
# Explicitly requested, destructive-to-its-own-namespaces-only RF bench.
# Never changes host LAN/interfaces/routes/firewall or the host sshd service.
set -euo pipefail
endpoint_a=''
endpoint_b=''
bench_dir=''
prefix=''
usage() {
printf '%s\n' 'Usage: sudo bash ethernet_ssh_bench.sh --a tcp://RADIO_A:5003 --b tcp://RADIO_B:5003' \
' --bench-dir EXISTING_ABSOLUTE_DIRECTORY --prefix ethssh-UNIQUE' \
'Both radios must already have matching Ethernet PHY settings and station WiFi.' \
'Creates only two fresh isolated namespaces, temporary TAPs and a private test sshd.' \
'Runs ten pings and verifies actual SSH transfers of 4096 bytes in each direction.' \
'The network test has a shared 180-second deadline. Artifacts stay under bench-dir.'
}
die() { printf '%s\n' "$*" >&2; exit 2; }
while (($#)); do
case "$1" in
--a|--b|--bench-dir|--prefix)
(($# >= 2)) || die 'Missing option value.'
case "$1" in
--a) endpoint_a=$2 ;; --b) endpoint_b=$2 ;;
--bench-dir) bench_dir=$2 ;; --prefix) prefix=$2 ;;
esac
shift 2 ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; die "Unknown option: $1" ;;
esac
done
[[ "$prefix" =~ ^ethssh-[a-z0-9][a-z0-9-]{0,19}$ ]] \
|| die '--prefix must be ethssh- followed by 1..20 lowercase letters/digits/hyphens.'
[[ "$bench_dir" == /* && -d "$bench_dir" && "$bench_dir" != / ]] \
|| die '--bench-dir must be an existing absolute non-root directory.'
[[ -n "$endpoint_a" && -n "$endpoint_b" && "$endpoint_a" != "$endpoint_b" ]] \
|| die 'Supply two different, explicitly selected TCP radio endpoints.'
[[ ${EUID:-$(id -u)} == 0 ]] || die 'Root is required for isolated network namespaces/TAP.'
for dependency in ip python3 ssh ssh-keygen sha256sum dd timeout mktemp realpath stat runuser sysctl ss; do
command -v "$dependency" >/dev/null || die "Missing dependency: $dependency"
done
[[ -x /usr/sbin/sshd ]] || die 'Missing /usr/sbin/sshd.'
[[ -c /dev/net/tun ]] || die 'Missing /dev/net/tun.'
id mikec >/dev/null 2>&1 || die 'The local bench account mikec is missing.'
python3 - "$endpoint_a" "$endpoint_b" <<'PY'
import sys
from urllib.parse import urlparse
for endpoint in sys.argv[1:]:
url = urlparse(endpoint)
if (url.scheme != 'tcp' or not url.hostname or url.username or url.password
or url.path not in ('', '/') or url.query or url.fragment
or url.port not in (None, 5003)):
raise SystemExit('Endpoints must be tcp://HOST[:5003], without credentials/path/query.')
PY
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
relay="$script_dir/ethernet_tap_relay.py"
[[ -r "$relay" ]] || die 'ethernet_tap_relay.py must be beside this script.'
bench_dir=$(realpath -e -- "$bench_dir")
ns_a="$prefix-a"
ns_b="$prefix-b"
[[ ! -e "/var/run/netns/$ns_a" && ! -e "/var/run/netns/$ns_b" ]] \
|| die 'A requested namespace already exists; refusing to reuse or remove it.'
run_dir=$(mktemp -d -- "$bench_dir/ethernet-ssh.XXXXXXXX")
[[ "$run_dir" == "$bench_dir/ethernet-ssh."* ]] || die 'Unexpected artifact directory.'
# Public-key lookup runs as mikec. Generated private keys remain root-only 0600.
chmod 755 -- "$run_dir"
created_a=0; created_b=0; inode_a=''; inode_b=''
relay_a=''; relay_b=''; sshd_pid=''
deadline=$((SECONDS + 180))
bounded() {
local remaining=$((deadline - SECONDS))
((remaining > 0)) || { echo 'Shared 180-second bench deadline expired.' >&2; return 124; }
timeout --kill-after=3s "${remaining}s" "$@"
}
namespace_matches() {
local name=$1 expected=$2 observed
[[ -n "$expected" ]] || return 1
observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$name" 2>/dev/null) || return 1
[[ "$observed" == "$expected" ]]
}
cleanup_namespace() {
local name=$1 expected=$2 pid
namespace_matches "$name" "$expected" || return 0
# Only processes in this exact newly created namespace are ours to stop.
while read -r pid; do
[[ "$pid" =~ ^[0-9]+$ ]] && kill -TERM "$pid" 2>/dev/null || true
done < <(ip netns pids "$name")
sleep 0.3
namespace_matches "$name" "$expected" || return 0
while read -r pid; do
[[ "$pid" =~ ^[0-9]+$ ]] && kill -KILL "$pid" 2>/dev/null || true
done < <(ip netns pids "$name")
namespace_matches "$name" "$expected" && ip netns delete "$name" || true
}
cleanup() {
local result=$? child parent
trap - EXIT INT TERM HUP
set +e
# A relay can still be in its bounded host-side TCP connect before setns.
# Check parentage before touching tracked children; never kill a reused PID.
for child in "$relay_a" "$relay_b" "$sshd_pid"; do
[[ "$child" =~ ^[0-9]+$ ]] || continue
parent=$(awk '/^PPid:/ {print $2}' "/proc/$child/status" 2>/dev/null)
[[ "$parent" == "$$" ]] && kill -TERM "$child" 2>/dev/null || true
done
((created_a)) && cleanup_namespace "$ns_a" "$inode_a"
((created_b)) && cleanup_namespace "$ns_b" "$inode_b"
for child in "$relay_a" "$relay_b" "$sshd_pid"; do
[[ "$child" =~ ^[0-9]+$ ]] || continue
parent=$(awk '/^PPid:/ {print $2}' "/proc/$child/status" 2>/dev/null)
[[ "$parent" == "$$" ]] && kill -KILL "$child" 2>/dev/null || true
done
wait 2>/dev/null
# Exact generated files only; evidence and public keys are retained.
rm -f -- "$run_dir/client_key" "$run_dir/host_key"
printf 'exit=%s\n' "$result" >> "$run_dir/result.txt"
printf 'Bench artifacts: %s\n' "$run_dir"
exit "$result"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
ssh-keygen -q -t ed25519 -N '' -f "$run_dir/host_key"
ssh-keygen -q -t ed25519 -N '' -f "$run_dir/client_key"
chmod 644 -- "$run_dir/client_key.pub"
runuser -u mikec -- test -r "$run_dir/client_key.pub" \
|| die 'mikec cannot traverse bench-dir and read its generated public key.'
awk '{print "[10.90.5.2]:2222 " $1 " " $2}' "$run_dir/host_key.pub" > "$run_dir/known_hosts"
dd if=/dev/urandom of="$run_dir/a-input.bin" bs=4096 count=1 status=none
dd if=/dev/urandom of="$run_dir/b-input.bin" bs=4096 count=1 status=none
chmod 644 -- "$run_dir/b-input.bin"
sha256sum "$run_dir/a-input.bin" "$run_dir/b-input.bin" > "$run_dir/source.sha256"
sshd_options=(-f /dev/null -h "$run_dir/host_key" -p 2222
-o ListenAddress=10.90.5.2 -o "PidFile=$run_dir/sshd.pid"
-o "AuthorizedKeysFile=$run_dir/client_key.pub" -o StrictModes=no
-o AllowUsers=mikec -o PermitRootLogin=no -o UsePAM=no
-o PasswordAuthentication=no -o KbdInteractiveAuthentication=no
-o AuthenticationMethods=publickey -o AllowTcpForwarding=no
-o PermitTunnel=no -o X11Forwarding=no -o PermitTTY=no)
/usr/sbin/sshd -t "${sshd_options[@]}"
ip netns add "$ns_a"; created_a=1
inode_a=$(stat -Lc '%d:%i' -- "/var/run/netns/$ns_a")
ip netns add "$ns_b"; created_b=1
inode_b=$(stat -Lc '%d:%i' -- "/var/run/netns/$ns_b")
for ns in "$ns_a" "$ns_b"; do
ip -n "$ns" link set lo up
ip netns exec "$ns" sysctl -qw net.ipv6.conf.all.disable_ipv6=1
ip netns exec "$ns" sysctl -qw net.ipv6.conf.default.disable_ipv6=1
done
# The relay connects in the host namespace BEFORE entering the isolated one.
# No veth/default route exists, so inner IP traffic can only cross the radios.
python3 "$relay" --endpoint "$endpoint_a" --netns "/var/run/netns/$ns_a" \
--interface lora0 --duration 180 --status-file "$run_dir/a-relay.json" \
> "$run_dir/a-relay.log" 2>&1 &
relay_a=$!
python3 "$relay" --endpoint "$endpoint_b" --netns "/var/run/netns/$ns_b" \
--interface lora0 --duration 180 --status-file "$run_dir/b-relay.json" \
> "$run_dir/b-relay.log" 2>&1 &
relay_b=$!
for _ in {1..50}; do
kill -0 "$relay_a" && kill -0 "$relay_b" || die 'A TAP relay exited; inspect its bench log.'
if ip -n "$ns_a" link show dev lora0 >/dev/null 2>&1 \
&& ip -n "$ns_b" link show dev lora0 >/dev/null 2>&1; then break; fi
sleep 0.2
done
ip -n "$ns_a" link set lora0 address 02:90:05:00:00:01 mtu 230 up
ip -n "$ns_b" link set lora0 address 02:90:05:00:00:02 mtu 230 up
ip -n "$ns_a" address add 10.90.5.1/24 dev lora0
ip -n "$ns_b" address add 10.90.5.2/24 dev lora0
ip -n "$ns_a" route show > "$run_dir/a-routes.txt"
ip -n "$ns_b" route show > "$run_dir/b-routes.txt"
ip netns exec "$ns_b" /usr/sbin/sshd -D -e "${sshd_options[@]}" > "$run_dir/sshd.log" 2>&1 &
sshd_pid=$!
for _ in {1..30}; do
kill -0 "$sshd_pid" || die 'The isolated sshd exited; inspect sshd.log.'
if ip netns exec "$ns_b" ss -H -ltn | grep -q '10\.90\.5\.2:2222'; then break; fi
sleep 0.1
done
# With both -c and -w, Linux ping may keep sending until it receives count
# replies, building an unrealistic LoRa backlog. Send only ten paced requests.
# Loss is evidence, not a reason to skip the actual reliable-transport test.
if ! bounded ip netns exec "$ns_a" ping -n -c 10 -i 1 -W 3 -s 32 10.90.5.2 \
> "$run_dir/ping.txt" 2>&1; then
printf 'Diagnostic ping had loss; continuing to the SSH checksum test.\n' >> "$run_dir/result.txt"
fi
ssh_options=(-F /dev/null -T -p 2222 -i "$run_dir/client_key"
-o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes
-o "UserKnownHostsFile=$run_dir/known_hosts" -o GlobalKnownHostsFile=/dev/null
-o PasswordAuthentication=no -o KbdInteractiveAuthentication=no
-o ConnectionAttempts=1 -o ConnectTimeout=60 -o ServerAliveInterval=0
-o ControlMaster=no -o ClearAllForwardings=yes
-o Compression=no -o KexAlgorithms=curve25519-sha256
-o HostKeyAlgorithms=ssh-ed25519 -o Ciphers=chacha20-poly1305@openssh.com)
bounded ip netns exec "$ns_a" ssh "${ssh_options[@]}" mikec@10.90.5.2 sha256sum \
< "$run_dir/a-input.bin" > "$run_dir/a-to-b.sha256" 2> "$run_dir/a-to-b-ssh.log"
expected_a=$(sha256sum "$run_dir/a-input.bin"); expected_a=${expected_a%% *}
read -r actual_a _ < "$run_dir/a-to-b.sha256"
[[ "$actual_a" == "$expected_a" ]] || die 'SSH A-to-B payload checksum mismatch.'
remote_path=$(python3 -c 'import shlex,sys;print(shlex.quote(sys.argv[1]))' "$run_dir/b-input.bin")
bounded ip netns exec "$ns_a" ssh "${ssh_options[@]}" mikec@10.90.5.2 "exec /bin/cat -- $remote_path" \
> "$run_dir/b-received.bin" 2> "$run_dir/b-to-a-ssh.log"
[[ $(stat -c %s "$run_dir/b-received.bin") == 4096 ]] || die 'SSH B-to-A payload length mismatch.'
expected_b=$(sha256sum "$run_dir/b-input.bin"); expected_b=${expected_b%% *}
actual_b=$(sha256sum "$run_dir/b-received.bin"); actual_b=${actual_b%% *}
[[ "$actual_b" == "$expected_b" ]] || die 'SSH B-to-A payload checksum mismatch.'
printf 'PASS: real SSH carried 4096 bytes each way; both SHA-256 values match.\na_to_b=%s\nb_to_a=%s\n' \
"$actual_a" "$actual_b" | tee "$run_dir/result.txt"
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env bash
# Isolated Linux client for the explicitly opt-in Flutter SSH-over-radio test.
set -euo pipefail
endpoint=''; bench_dir=''; workspace=''; flutter=''; private_key=''; fingerprint=''
prefix=''; account=''; test_name=rf; test_timeout=210; phase_timeout=45
die() { printf '%s\n' "$*" >&2; exit 2; }
while (($#)); do
(($# >= 2)) || die 'Every option requires a value.'
case "$1" in
--endpoint) endpoint=$2 ;; --bench-dir) bench_dir=$2 ;;
--workspace) workspace=$2 ;; --flutter) flutter=$2 ;;
--key) private_key=$2 ;; --fingerprint) fingerprint=$2 ;;
--prefix) prefix=$2 ;; --account) account=$2 ;;
--test-name) test_name=$2 ;;
--phase-timeout)
[[ "$2" =~ ^[0-9]{1,2}$ ]] && ((10#$2 >= 5 && 10#$2 <= 60)) \
|| die '--phase-timeout must be 5..60 seconds.'
phase_timeout=$((10#$2)) ;;
--test-timeout)
[[ "$2" =~ ^[0-9]{1,3}$ ]] && ((10#$2 >= 1 && 10#$2 <= 180)) \
|| die '--test-timeout must be 1..180 seconds.'
test_timeout=$((10#$2)) ;;
*) die "Unknown option: $1" ;;
esac
shift 2
done
[[ "$test_name" == rf || "$test_name" == speed ]] || die '--test-name must be rf or speed.'
test_file="test/services/lora_ssh_${test_name}_test.dart"
[[ ${EUID:-$(id -u)} == 0 ]] || die 'Root is required for the isolated namespace/TAP.'
[[ "$prefix" =~ ^ethflutter-[a-z0-9][a-z0-9-]{0,19}$ ]] || die 'Use a unique ethflutter- prefix.'
[[ "$account" =~ ^[a-z_][a-z0-9_-]*$ ]] || die 'Specify an unprivileged test account.'
[[ $(id -u "$account") -ne 0 ]] || die 'The Flutter test account must not be root.'
for directory in "$bench_dir" "$workspace"; do
[[ "$directory" == /* && "$directory" != / && -d "$directory" ]] || die 'Use existing absolute directories.'
done
[[ "$flutter" == /* && -x "$flutter" && "$private_key" == /* && -f "$private_key" ]] || die 'Use explicit Flutter/key paths.'
[[ -f "$workspace/$test_file" ]] || die 'The selected opt-in SSH backend test is missing.'
[[ "$fingerprint" =~ ^SHA256:[A-Za-z0-9+/]{43}$ ]] || die 'Use the expected OpenSSH SHA-256 fingerprint.'
runuser -u "$account" -- test -r "$private_key" || die 'The test account cannot read its selected key.'
[[ ! -e "/var/run/netns/$prefix" ]] || die 'Refusing to reuse an existing namespace.'
python3 - "$endpoint" <<'PY'
import ipaddress, sys
from urllib.parse import urlparse
u = urlparse(sys.argv[1])
try:
host = ipaddress.IPv4Address(u.hostname)
valid = (u.scheme == 'tcp' and u.port == 5003 and not u.username and not u.password
and u.path in ('', '/') and not u.query and not u.fragment
and not host.is_multicast and not host.is_unspecified)
except ValueError:
valid = False
if not valid:
raise SystemExit('Use an explicit numeric IPv4 tcp://RADIO:5003 endpoint.')
PY
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
run_dir=$(mktemp -d -- "$bench_dir/ethernet-flutter.XXXXXXXX")
chown "$account": "$run_dir"
chmod 700 -- "$run_dir"
# Keep evidence readable by the selected account even if sudo inherited 077.
# The account-owned parent remains private (0700); no keys are copied here.
umask 022
created=0; inode=''; relay_pid=''; test_pid=''; monitor_pid=''; tcp_started=0; tcp_finished=0
capture_tcp() {
local phase=$1 observed=''
observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$prefix" 2>/dev/null) || return 1
[[ -n "$inode" && "$observed" == "$inode" ]] || return 1
date -u +%Y-%m-%dT%H:%M:%SZ > "$run_dir/tcp-$phase.utc"
ip netns exec "$prefix" cat /proc/net/snmp > "$run_dir/tcp-$phase.snmp" \
2> "$run_dir/tcp-$phase.error"
}
finish_tcp() {
((tcp_started && !tcp_finished)) || return 0
tcp_finished=1
capture_tcp after || true
python3 "$script_dir/ethernet_tcp_stats.py" --directory "$run_dir" \
> "$run_dir/tcp-stats.error" 2>&1 || true
}
sample_tcp() {
local remaining=300 sample lines observed
while ((remaining > 0)) && kill -0 "$test_pid" 2>/dev/null; do
observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$prefix" 2>/dev/null) || break
[[ "$observed" == "$inode" ]] || break
sample=$(printf '\nUTC %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
timeout --kill-after=1s 2s ip netns exec "$prefix" ss -tin 2>&1 | head -n 12 || true)
printf '%s\n' "$sample" | head -n "$remaining" >> "$run_dir/tcp-ss.txt"
lines=$(printf '%s\n' "$sample" | wc -l)
remaining=$((remaining - lines))
sleep 1
done
}
cleanup() {
local result=$? observed='' child parent
trap - EXIT INT TERM HUP
set +e
finish_tcp
for child in "$test_pid" "$monitor_pid" "$relay_pid"; do
if [[ "$child" =~ ^[0-9]+$ ]]; then
parent=$(awk '/^PPid:/ {print $2}' "/proc/$child/status" 2>/dev/null)
[[ "$parent" == "$$" ]] && kill -TERM "$child" 2>/dev/null
fi
done
if ((created)); then
observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$prefix" 2>/dev/null)
if [[ -n "$inode" && "$observed" == "$inode" ]]; then
while read -r child; do
[[ "$child" =~ ^[0-9]+$ ]] && kill -TERM "$child" 2>/dev/null
done < <(ip netns pids "$prefix")
sleep 0.3
observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$prefix" 2>/dev/null)
if [[ "$observed" == "$inode" ]]; then
while read -r child; do
[[ "$child" =~ ^[0-9]+$ ]] && kill -KILL "$child" 2>/dev/null
done < <(ip netns pids "$prefix")
ip netns delete "$prefix"
fi
fi
fi
# Reap only our explicit children. A relay still connecting outside the
# namespace must not make cleanup wait forever after ignoring SIGTERM.
for child in "$test_pid" "$monitor_pid" "$relay_pid"; do
[[ "$child" =~ ^[0-9]+$ ]] || continue
parent=$(awk '/^PPid:/ {print $2}' "/proc/$child/status" 2>/dev/null)
if [[ "$parent" == "$$" ]]; then
for _ in {1..20}; do
kill -0 "$child" 2>/dev/null || break
sleep 0.1
done
parent=$(awk '/^PPid:/ {print $2}' "/proc/$child/status" 2>/dev/null)
[[ "$parent" == "$$" ]] && kill -KILL "$child" 2>/dev/null
fi
wait "$child" 2>/dev/null
done
printf 'exit=%s\n' "$result" >> "$run_dir/result.txt"
printf 'Flutter RF artifacts: %s\n' "$run_dir"
exit "$result"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM HUP
ip netns add "$prefix"; created=1
inode=$(stat -Lc '%d:%i' -- "/var/run/netns/$prefix")
ip -n "$prefix" link set lo up
ip netns exec "$prefix" sysctl -qw net.ipv6.conf.all.disable_ipv6=1
ip netns exec "$prefix" sysctl -qw net.ipv6.conf.default.disable_ipv6=1
python3 "$script_dir/ethernet_tap_relay.py" --endpoint "$endpoint" \
--netns "/var/run/netns/$prefix" --interface lora0 --duration 240 \
--status-file "$run_dir/relay.json" > "$run_dir/relay.log" 2>&1 &
relay_pid=$!
for _ in {1..60}; do
kill -0 "$relay_pid" || die 'TAP relay exited; inspect relay.log.'
ip -n "$prefix" link show dev lora0 >/dev/null 2>&1 && break
sleep 0.2
done
ip -n "$prefix" link set lora0 address 02:90:05:00:00:01 mtu 230 up
ip -n "$prefix" address add 10.90.5.1/24 dev lora0
ip -n "$prefix" route show > "$run_dir/routes.txt"
cd -- "$workspace"
tcp_started=1
capture_tcp before || true
timeout --kill-after=5s "${test_timeout}s" ip netns exec "$prefix" runuser -u "$account" -- env \
LORA_SSH_RF_KEY="$private_key" EXPECTED_HOST_FINGERPRINT_SHA256="$fingerprint" \
LORA_SSH_RF_PORT=2222 LORA_SSH_RF_USER=mikec \
LORA_SSH_SPEED_REPORT="$run_dir/speed.json" LORA_SSH_SPEED_BYTES=8192 \
LORA_SSH_SPEED_TIMEOUT_SECONDS="$phase_timeout" LORA_SSH_SPEED_TIMEOUT="$phase_timeout" \
"$flutter" test --no-pub --reporter expanded "$test_file" \
> "$run_dir/flutter.txt" 2>&1 &
test_pid=$!
sample_tcp &
monitor_pid=$!
test_result=0
wait "$test_pid" || test_result=$?
test_pid=''
if [[ "$monitor_pid" =~ ^[0-9]+$ ]]; then
kill -TERM "$monitor_pid" 2>/dev/null || true
wait "$monitor_pid" 2>/dev/null || true
fi
monitor_pid=''
finish_tcp
printf 'scenario=%s\n' "$test_name" > "$run_dir/result.txt"
if ((test_result == 0)); then
printf 'PASS: real Flutter SSH backend test completed through the isolated radio TAP.\n' >> "$run_dir/result.txt"
fi
exit "$test_result"
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# One explicitly selected radio, one isolated SSH server for the phone test.
# No host routes, firewall, accounts, system sshd or other radios are changed.
set -euo pipefail
endpoint=''; bench_dir=''; prefix=''; duration=1200; kiss_port=0; baud=115200; serial_dtr=off; kiss_modem=off
die() { printf '%s\n' "$*" >&2; exit 2; }
while (($#)); do
(($# >= 2)) || die 'Every option requires a value.'
case "$1" in
--endpoint) endpoint=$2 ;; --bench-dir) bench_dir=$2 ;;
--prefix) prefix=$2 ;; --duration) duration=$2 ;;
--kiss-port) kiss_port=$2 ;; --baud) baud=$2 ;; --serial-dtr) serial_dtr=$2 ;;
--kiss-modem) kiss_modem=$2 ;;
*) die "Unknown option: $1" ;;
esac
shift 2
done
[[ ${EUID:-$(id -u)} == 0 ]] || die 'Run with sudo for the isolated namespace/TAP.'
[[ "$prefix" =~ ^ethphone-[a-z0-9][a-z0-9-]{0,19}$ ]] || die 'Use a unique ethphone- prefix.'
[[ "$duration" =~ ^[0-9]+$ && "$duration" -ge 60 && "$duration" -le 1800 ]] || die 'Duration must be 60..1800 seconds.'
[[ "$bench_dir" == /* && -d "$bench_dir" && "$bench_dir" != / ]] || die 'Use an existing absolute bench directory.'
[[ "$kiss_port" == 0 || "$kiss_port" == 1 ]] || die 'KISS port must be 0 or 1.'
[[ "$baud" =~ ^[0-9]{4,7}$ && "$baud" -ge 1200 && "$baud" -le 2000000 ]] || die 'Baud must be 1200..2000000.'
[[ "$serial_dtr" == on || "$serial_dtr" == off ]] || die 'Serial DTR must be on or off.'
[[ "$kiss_modem" == on || "$kiss_modem" == off ]] || die 'Full KISS modem mode must be on or off.'
[[ "$kiss_modem" == off || "$endpoint" == serial:///* ]] || die 'Full KISS modem mode requires an explicit serial endpoint.'
[[ -x /usr/sbin/sshd && -c /dev/net/tun ]] || die 'sshd and /dev/net/tun are required.'
for dependency in ip python3 ssh-keygen stat realpath runuser sysctl ss mktemp; do
command -v "$dependency" >/dev/null || die "Missing dependency: $dependency"
done
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
python3 - "$endpoint" "$script_dir" <<'PY'
import sys
sys.path.insert(0, sys.argv[2])
from ethernet_tap_relay import validate_endpoint
try:
validate_endpoint(sys.argv[1], strict_tcp=True)
except ValueError:
raise SystemExit('Use numeric IPv4 tcp://RADIO:5003 or an explicit serial:///dev/serial/by-id/DEVICE endpoint.')
PY
relay="$script_dir/ethernet_tap_relay.py"
[[ -r "$relay" ]] || die 'The TAP relay must be beside this script.'
bench_dir=$(realpath -e -- "$bench_dir")
namespace="$prefix"
[[ ! -e "/var/run/netns/$namespace" ]] || die 'Namespace already exists; refusing to reuse it.'
run_dir=$(mktemp -d -- "$bench_dir/ethernet-phone.XXXXXXXX")
[[ "$run_dir" == "$bench_dir/ethernet-phone."* ]] || die 'Unexpected artifact directory.'
# Only the explicitly selected test account can retrieve its ephemeral key.
chown mikec: "${run_dir}"
chmod 700 -- "$run_dir"
created=0; inode=''; relay_pid=''; sshd_pid=''
cleanup() {
local result=$? observed='' child parent
trap - EXIT INT TERM HUP
set +e
for child in "$relay_pid" "$sshd_pid"; do
[[ "$child" =~ ^[0-9]+$ ]] || continue
parent=$(awk '/^PPid:/ {print $2}' "/proc/$child/status" 2>/dev/null)
[[ "$parent" == "$$" ]] && kill -TERM "$child" 2>/dev/null
done
if ((created)); then
observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$namespace" 2>/dev/null)
if [[ -n "$inode" && "$observed" == "$inode" ]]; then
while read -r child; do
[[ "$child" =~ ^[0-9]+$ ]] && kill -TERM "$child" 2>/dev/null
done < <(ip netns pids "$namespace")
sleep 0.3
observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$namespace" 2>/dev/null)
if [[ "$observed" == "$inode" ]]; then
while read -r child; do
[[ "$child" =~ ^[0-9]+$ ]] && kill -KILL "$child" 2>/dev/null
done < <(ip netns pids "$namespace")
ip netns delete "$namespace"
fi
fi
fi
wait 2>/dev/null
rm -f -- "$run_dir/host_key" "$run_dir/client_key"
printf 'exit=%s\n' "$result" >> "$run_dir/result.txt"
chmod 644 -- "$run_dir/result.txt"
printf 'Phone bench stopped; ephemeral keys removed. Evidence: %s\n' "$run_dir"
exit "$result"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM HUP
ssh-keygen -q -t ed25519 -N '' -f "$run_dir/host_key"
runuser -u mikec -- ssh-keygen -q -t ed25519 -N '' -f "$run_dir/client_key"
ip netns add "$namespace"; created=1
inode=$(stat -Lc '%d:%i' -- "/var/run/netns/$namespace")
ip -n "$namespace" link set lo up
ip netns exec "$namespace" sysctl -qw net.ipv6.conf.all.disable_ipv6=1
ip netns exec "$namespace" sysctl -qw net.ipv6.conf.default.disable_ipv6=1
deadline=$((SECONDS + duration))
modem_args=()
if [[ "$kiss_modem" == on ]]; then modem_args+=(--kiss-modem); fi
python3 "$relay" --endpoint "$endpoint" --netns "/var/run/netns/$namespace" \
--baud "$baud" --kiss-port "$kiss_port" --serial-dtr "$serial_dtr" \
"${modem_args[@]}" \
--interface lora0 --duration "$duration" --status-file "$run_dir/relay.json" \
> "$run_dir/relay.log" 2>&1 &
relay_pid=$!
for _ in {1..60}; do
kill -0 "$relay_pid" || die 'TAP relay exited; inspect relay.log.'
ip -n "$namespace" link show dev lora0 >/dev/null 2>&1 && break
sleep 0.2
done
ip -n "$namespace" link set lora0 address 02:90:05:00:00:02 mtu 230 up
ip -n "$namespace" address add 10.90.5.2/24 dev lora0
ip -n "$namespace" route show > "$run_dir/routes.txt"
options=(-f /dev/null -h "$run_dir/host_key" -p 2222
-o ListenAddress=10.90.5.2 -o "PidFile=$run_dir/sshd.pid"
-o "AuthorizedKeysFile=$run_dir/client_key.pub" -o StrictModes=no
-o AllowUsers=mikec -o PermitRootLogin=no -o UsePAM=no
-o PasswordAuthentication=no -o KbdInteractiveAuthentication=no
-o AuthenticationMethods=publickey -o DisableForwarding=yes
-o PermitUserEnvironment=no -o X11Forwarding=no -o PermitTTY=yes
-o 'Subsystem=sftp internal-sftp')
/usr/sbin/sshd -t "${options[@]}"
ip netns exec "$namespace" /usr/sbin/sshd -D -e "${options[@]}" > "$run_dir/sshd.log" 2>&1 &
sshd_pid=$!
listening=0
for _ in {1..30}; do
kill -0 "$sshd_pid" || die 'Isolated sshd exited; inspect sshd.log.'
if ip netns exec "$namespace" ss -H -ltn | grep -q '10\.90\.5\.2:2222'; then listening=1; break; fi
sleep 0.1
done
((listening)) || die 'The isolated SSH listener did not become ready.'
printf 'READY: gateway=%s local=10.90.5.1 remote=10.90.5.2 SSH=2222 user=mikec\n' "$endpoint"
printf 'Ephemeral client private key file (never print contents): %s/client_key\n' "$run_dir"
ssh-keygen -lf "$run_dir/host_key.pub"
printf 'Artifacts: %s\n' "$run_dir"
printf 'Server helper PID: %s\n' "$$"
while ((SECONDS < deadline)); do
kill -0 "$relay_pid" && kill -0 "$sshd_pid" || die 'A bench child exited.'
sleep 1
done
+126
View File
@@ -0,0 +1,126 @@
"""SerialNode-compatible persistent SSH transport; no RF or settings policy."""
from __future__ import annotations
from pathlib import Path, PurePosixPath
import queue
import re
import shlex
import subprocess
import threading
from ethernet_serial_rpc import (MAX_LINE, PROTOCOL, decode_message,
encode_message, validate_command)
def ssh_command(host, key, helper, port):
if not isinstance(host, str) or not re.fullmatch(r"(?:[A-Za-z0-9_][A-Za-z0-9_.-]*@)?[A-Za-z0-9_][A-Za-z0-9_.-]*", host):
raise ValueError("SSH host must be a hostname/IP, optionally user@host")
for name, value in (("helper", helper), ("port", port)):
if not isinstance(value, str) or not PurePosixPath(value).is_absolute() \
or any(ord(character) < 32 for character in value):
raise ValueError("SSH " + name + " must be an absolute remote path")
key_path = Path(key)
if not key_path.is_absolute() or not key_path.is_file():
raise ValueError("SSH key must be an existing absolute local file")
return ["ssh", "-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8",
"-o", "StrictHostKeyChecking=yes", "-i", str(key_path), "--", host,
shlex.join(["python3", helper, "--port", port])]
class SshSerialNode:
def __init__(self, host, key, helper, path, *, process_factory=subprocess.Popen,
startup_timeout=15):
self.commands = []
self._closed = False
self._sequence = 0
self._messages = queue.Queue(maxsize=4)
self._reader_error = None
self._lock = threading.Lock()
self.process = process_factory(ssh_command(host, key, helper, path), stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=0)
self._reader = threading.Thread(target=self._read_lines, daemon=True)
self._reader.start()
try:
ready = self._receive(startup_timeout)
if ready.get("type") != "ready" or ready.get("protocol") != PROTOCOL \
or not isinstance(ready.get("identity"), dict) or not ready["identity"] \
or not isinstance(ready.get("hello"), str):
raise RuntimeError("Invalid serial RPC startup identity")
self.identity = dict(ready["identity"], ssh_host=host)
self.hello = ready["hello"]
except Exception:
self._stop(force=True)
raise
def _read_lines(self):
try:
while True:
line = self.process.stdout.readline(MAX_LINE + 1)
if line and (len(line) > MAX_LINE or not line.endswith(b"\n")):
raise RuntimeError("Oversized or incomplete SSH RPC line")
self._messages.put_nowait(line)
if not line:
return
except Exception as error:
self._reader_error = str(error) or "SSH RPC receive queue overflow"
if self.process.poll() is None:
self.process.kill() # Only this Popen child, never a process group.
def _receive(self, timeout):
if self._reader_error:
raise RuntimeError(self._reader_error)
try:
line = self._messages.get(timeout=timeout)
except queue.Empty:
self._stop(force=True)
raise TimeoutError("SSH serial RPC deadline exceeded") from None
if self._reader_error:
raise RuntimeError(self._reader_error)
if not line:
raise RuntimeError("SSH serial RPC closed before a complete response")
return decode_message(line)
def command(self, text, timeout=10):
validate_command(text, timeout)
with self._lock:
if self._closed:
raise RuntimeError("SSH serial RPC is closed")
self._sequence += 1
try:
self.process.stdin.write(encode_message(dict(id=self._sequence, command=text, timeout=timeout)))
self.process.stdin.flush()
response = self._receive(timeout + 2)
if type(response.get("id")) is not int or response["id"] != self._sequence \
or response.get("completed") is not True or not isinstance(response.get("reply"), str):
raise RuntimeError("SSH serial command was not confirmed complete: " + str(response.get("error", "invalid response")))
reply = response["reply"]
self.commands.append(dict(command=text, reply=reply))
return reply
except Exception:
self._stop(force=True)
raise
def _stop(self, force=False):
if self._closed:
return
self._closed = True
try:
self.process.stdin.close() # EOF lets the remote server close its USB.
except (OSError, ValueError):
pass
if force and self.process.poll() is None:
self.process.kill()
try:
self.process.wait(timeout=3)
except subprocess.TimeoutExpired:
self.process.terminate()
try:
self.process.wait(timeout=1)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait(timeout=1)
self._reader.join(timeout=1)
self.process.stdout.close()
def close(self):
self._stop()
+428
View File
@@ -0,0 +1,428 @@
#!/usr/bin/env python3
"""Isolated Linux TAP <-> selected port-0/1 KISS relay for real Ethernet/TCP/SSH RF tests.
REQUIRES ROOT and an already-created, explicitly named network namespace. Opens
the KISS socket in the host namespace FIRST, then enters --netns and creates a
nonpersistent TAP there. Never changes host LAN, addresses, routes, firewall or
sshd. A TAP vanishes when this process exits. Configure its MTU to 230 and its
address separately with `ip -n NAME ...`; do not connect the namespace by veth.
The KISS socket retains its original network namespace despite setns().
Example (operator must first create eth-a namespace):
sudo python3 ethernet_tap_relay.py --endpoint tcp://RADIO:5003 \
--netns /var/run/netns/eth-a --interface lora0
The relay starts no network services. A passing actual SSH session between two
otherwise-isolated namespaces then exercises ARP, TCP handshake, retransmission,
SSH negotiation and payload over the radios, not a synthetic packet imitation.
Use only operator-configured 909.5 MHz / BW500 / SF5 / CR5 low-power lab radios.
"""
from __future__ import annotations
import argparse
from collections import deque
import ctypes
import ipaddress
import json
import math
import os
import re
from pathlib import Path
import select
import signal
import socket
import struct
import sys
import time
from urllib.parse import urlparse
from ethernet_kiss_probe import KissDecoder, kiss_encode
from serial_session import assert_port_idle, open_configured_session
from windows_usb_stdio_bridge import (WindowsUsbStream, add_windows_arguments,
validate_windows_endpoint, validate_windows_options)
class ModemFlowError(RuntimeError):
def __init__(self, message, stats):
super().__init__(message)
self.stats = stats
class RelayBuffers:
"""Bounded queues; retain partial stream writes and unparsed input bytes."""
def __init__(self, depth=8, kiss_port=0, *, kiss_modem=False, tx_done_timeout=10,
now=time.monotonic):
if depth <= 0:
raise ValueError("queue depth must be positive")
self.depth = depth
self.to_tap = deque()
self.to_kiss = deque()
self.staging = bytearray()
if not math.isfinite(tx_done_timeout) or not .5 <= tx_done_timeout <= 120:
raise ValueError("TX_DONE timeout must be .5..120 seconds")
self.kiss_modem = kiss_modem
self.tx_done_timeout = tx_done_timeout
self.now = now
self.tx_deadline = None
# Standard port-0 KISS pacing controls only, never RF tuples or keys.
self.controls = deque((b"\xc0\x01\x00\xc0", b"\xc0\x02\xff\xc0", b"\xc0\x03\x01\xc0") if kiss_modem else ())
self.decoder = KissDecoder(port=kiss_port, control=self.modem_control if kiss_modem else None)
self.kiss_port = kiss_port
self.counters = {"tap_read": 0, "kiss_written": 0, "kiss_read": 0,
"tap_written": 0, "bad_frame_length": 0,
"tap_read_bytes": 0, "tap_written_bytes": 0,
"modem_controls_written": 0, "modem_tx_done": 0,
"modem_tx_error": 0, "modem_tx_timeout": 0,
"modem_unexpected_tx_done": 0, "modem_rx_meta": 0}
def can_write_kiss(self):
return bool(self.controls or self.to_kiss and self.tx_deadline is None)
def check_tx_done(self):
if self.tx_deadline is not None and self.now() >= self.tx_deadline:
self.counters["modem_tx_timeout"] += 1
raise ModemFlowError("Full KISS modem TX_DONE deadline exceeded; no retry attempted", self.stats())
def modem_control(self, command, payload):
if command != 0x06 or not payload:
return
if payload[0] == 0xF9:
self.counters["modem_rx_meta"] += 1
elif payload[0] == 0xF8:
if self.tx_deadline is None:
self.counters["modem_unexpected_tx_done"] += 1
elif len(payload) != 2 or payload[1] not in (0, 1):
self.counters["modem_tx_error"] += 1
raise ModemFlowError("Full KISS modem returned malformed TX_DONE", self.stats())
elif payload[1] == 0:
# A completed local submission can lose its RF opportunity
# (e.g. RX wins the final IRQ race). Drop this one datagram;
# normal TCP, not a hidden host/firmware ARQ, owns retrying it.
self.counters["modem_tx_error"] += 1
self.tx_deadline = None
else:
self.counters["modem_tx_done"] += 1
self.tx_deadline = None
elif payload[0] == 0xF1 and self.tx_deadline is not None:
self.counters["modem_tx_error"] += 1
raise ModemFlowError("Full KISS modem returned a hardware error during DATA", self.stats())
def can_read_tap(self):
return len(self.to_kiss) < self.depth
def can_read_kiss(self):
return not self.staging and len(self.to_tap) < self.depth
def queue_tap_frame(self, frame):
if not self.can_read_tap():
raise BufferError("TAP queue full; caller must stop reading")
if not 14 <= len(frame) <= 255:
self.counters["bad_frame_length"] += 1
return False
self.to_kiss.append(kiss_encode(frame, port=self.kiss_port))
self.counters["tap_read"] += 1
self.counters["tap_read_bytes"] += len(frame)
return True
def feed_kiss(self, data):
if not self.can_read_kiss() or len(data) > 4096:
raise BufferError("KISS staging full; caller must stop reading")
self.staging.extend(data)
self.parse_pending()
def parse_pending(self):
consumed = 0
for byte in self.staging:
if len(self.to_tap) >= self.depth:
break
consumed += 1
for frame in self.decoder.feed(bytes((byte,))):
if not 14 <= len(frame) <= 255:
self.counters["bad_frame_length"] += 1
continue
self.to_tap.append(frame)
self.counters["kiss_read"] += 1
del self.staging[:consumed]
def write_kiss(self, write):
self.check_tx_done()
if not self.can_write_kiss():
return
control = bool(self.controls)
queue = self.controls if control else self.to_kiss
pending = queue[0]
count = write(pending)
if not 0 <= count <= len(pending):
raise IOError("invalid KISS write length")
if count == len(pending):
queue.popleft()
if control:
self.counters["modem_controls_written"] += 1
else:
self.counters["kiss_written"] += 1
if self.kiss_modem:
self.tx_deadline = self.now() + self.tx_done_timeout
elif count:
queue[0] = pending[count:]
def write_tap(self, write):
if not self.to_tap:
return
pending = self.to_tap[0]
if write(pending) != len(pending):
raise IOError("partial TAP datagram write; refusing to split Ethernet frame")
self.to_tap.popleft()
self.counters["tap_written"] += 1
self.counters["tap_written_bytes"] += len(pending)
def stats(self):
return dict(self.counters, decode_errors=self.decoder.errors,
pending_to_tap=len(self.to_tap), pending_to_kiss=len(self.to_kiss),
staged_kiss_bytes=len(self.staging), awaiting_tx_done=self.tx_deadline is not None)
def validate_endpoint(endpoint, *, strict_tcp=False):
"""Lexical allowlist only: never opens devices, namespaces or sockets."""
parsed = urlparse(endpoint)
if parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ValueError("KISS endpoint must not contain credentials, query or fragment")
if parsed.scheme == "tcp":
if not parsed.hostname or parsed.path not in ("", "/") or parsed.port not in range(1, 65536) and parsed.port is not None:
raise ValueError("Use an explicit KISS TCP host and port")
if strict_tcp:
host = ipaddress.IPv4Address(parsed.hostname)
if parsed.port != 5003 or host.is_multicast or host.is_unspecified:
raise ValueError("Use numeric IPv4 tcp://RADIO:5003")
elif parsed.scheme == "winusb":
return validate_windows_endpoint(endpoint)
elif parsed.scheme == "serial":
path = parsed.path
if parsed.netloc or not re.fullmatch(r"/dev/(?:tty(?:ACM|USB)[A-Za-z0-9_-]+|serial/(?:by-id|by-path)/[A-Za-z0-9_.:+-]+)", path) \
or path.rsplit("/", 1)[-1] in (".", ".."):
raise ValueError("Use an explicit /dev/ttyACM*, ttyUSB*, or serial/by-id|by-path device")
else:
raise ValueError("Use tcp://RADIO:5003, serial:///dev/serial/by-id/DEVICE or winusb://COM6")
return parsed
class KissStream:
def __init__(self, endpoint, baud, *, serial_dtr=False, windows_python=None,
expect_serial=None, expect_vid=None, expect_pid=None, duration=300):
self.socket = self.serial = self.windows = None
parsed = validate_endpoint(endpoint)
validate_windows_options(parsed, python=windows_python, expected_serial=expect_serial,
expected_vid=expect_vid, expected_pid=expect_pid, baud=baud,
serial_dtr=serial_dtr, duration=duration)
if parsed.scheme == "tcp":
self.socket = socket.create_connection((parsed.hostname, parsed.port or 5003), timeout=5)
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self.socket.setblocking(False)
self.fd = self.socket.fileno()
elif parsed.scheme == "serial":
if not re.fullmatch(r"/dev/tty(?:ACM|USB)[A-Za-z0-9_-]+", os.path.realpath(parsed.path)):
raise ValueError("Selected serial alias must resolve to a ttyACM/ttyUSB device")
import serial
self.serial = serial.Serial()
self.serial.port = parsed.netloc + parsed.path
self.serial.baudrate = baud
self.serial.timeout = self.serial.write_timeout = 0
self.serial.exclusive = True
self.serial.dtr = serial_dtr
self.serial.rts = False
assert_port_idle(self.serial.port)
open_configured_session(self.serial)
self.fd = self.serial.fileno()
elif parsed.scheme == "winusb":
self.windows = WindowsUsbStream(endpoint, python=windows_python, expected_serial=expect_serial,
expected_vid=expect_vid, expected_pid=expect_pid, baud=baud, serial_dtr=serial_dtr,
duration=duration)
self.fd = self.windows.read_fd
self.read_fd = self.windows.read_fd if self.windows else self.fd
self.write_fd = self.windows.write_fd if self.windows else self.fd
def read(self, count):
if self.windows:
return self.windows.read(count)
data = self.socket.recv(count) if self.socket else os.read(self.fd, count)
if not data:
raise ConnectionError("KISS endpoint disconnected")
return data
def write(self, data):
if self.windows:
return self.windows.write(data)
return self.socket.send(data) if self.socket else os.write(self.fd, data)
def close(self):
if self.socket:
self.socket.close()
if self.serial:
self.serial.close()
if self.windows:
self.windows.close()
def enter_network_namespace(path):
descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC)
try:
target = os.fstat(descriptor)
current = os.stat("/proc/self/ns/net")
if (target.st_dev, target.st_ino) == (current.st_dev, current.st_ino):
raise ValueError("Refusing to create a test TAP in the current host network namespace")
if hasattr(os, "setns"):
os.setns(descriptor, 0x40000000) # CLONE_NEWNET
else:
libc = ctypes.CDLL(None, use_errno=True)
libc.setns.argtypes = (ctypes.c_int, ctypes.c_int)
libc.setns.restype = ctypes.c_int
if libc.setns(descriptor, 0x40000000) != 0:
failure = ctypes.get_errno()
raise OSError(failure, os.strerror(failure), path)
finally:
os.close(descriptor)
def create_tap(name):
import fcntl
encoded = name.encode("ascii")
if not encoded or len(encoded) > 15 or b"\0" in encoded:
raise ValueError("TAP interface name must contain 1..15 ASCII characters")
descriptor = os.open("/dev/net/tun", os.O_RDWR | os.O_NONBLOCK | os.O_CLOEXEC)
try:
request = struct.pack("16sH", encoded, 0x0002 | 0x1000) # IFF_TAP | IFF_NO_PI
result = fcntl.ioctl(descriptor, 0x400454CA, request) # TUNSETIFF
return descriptor, result[:16].split(b"\0", 1)[0].decode("ascii")
except BaseException:
os.close(descriptor)
raise
def open_transport_then_tap(endpoint, baud, netns, name, *, serial_dtr=False, **transport_options):
"""Ordering is intentional: socket routes must remain in host namespace."""
stream = KissStream(endpoint, baud, serial_dtr=serial_dtr, **transport_options)
try:
enter_network_namespace(netns)
tap_fd, actual_name = create_tap(name)
return stream, tap_fd, actual_name
except BaseException:
stream.close()
raise
def emit(value, status_file=None):
text = json.dumps(value, sort_keys=True)
print(text, flush=True)
if status_file:
# Evidence only; no system configuration is written by this helper.
target = Path(status_file)
temporary = target.with_name(target.name + ".tmp")
temporary.write_text(text + "\n", encoding="utf-8")
os.replace(temporary, target)
def relay(stream, tap_fd, *, duration=0, queue_depth=8, kiss_port=0, kiss_modem=False,
tx_done_timeout=10, stop=None):
buffers = RelayBuffers(queue_depth, kiss_port=kiss_port, kiss_modem=kiss_modem,
tx_done_timeout=tx_done_timeout)
start = time.monotonic()
while not (stop and stop()) and (not duration or time.monotonic() - start < duration):
buffers.check_tx_done()
buffers.parse_pending()
reads = ([tap_fd] if buffers.can_read_tap() else [])
if buffers.can_read_kiss():
reads.append(stream.read_fd)
writes = ([tap_fd] if buffers.to_tap else [])
if buffers.can_write_kiss():
writes.append(stream.write_fd)
ready_read, ready_write, _ = select.select(reads, writes, [], 0.2)
for descriptor in ready_write:
try:
if descriptor == tap_fd:
buffers.write_tap(lambda data: os.write(tap_fd, data))
else:
buffers.write_kiss(stream.write)
except BlockingIOError:
pass
for descriptor in ready_read:
try:
if descriptor == tap_fd:
# A too-big datagram is discarded, never sent as a truncated frame.
frame = os.read(tap_fd, 65536)
buffers.queue_tap_frame(frame)
else:
buffers.feed_kiss(stream.read(4096))
except BlockingIOError:
pass
return dict(buffers.stats(), elapsed_seconds=round(time.monotonic() - start, 3))
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--endpoint", required=True)
parser.add_argument("--netns", required=True, help="existing namespace handle, e.g. /var/run/netns/eth-a")
parser.add_argument("--interface", default="lora0")
parser.add_argument("--baud", type=int, default=115200)
parser.add_argument("--kiss-port", type=int, choices=(0, 1), default=0)
parser.add_argument("--serial-dtr", choices=("off", "on"), default="off",
help="explicitly assert native USB CDC DTR; never sends a command")
parser.add_argument("--expect-serial", help="exact USB identity required for winusb")
add_windows_arguments(parser)
parser.add_argument("--kiss-modem", action="store_true",
help="explicit full serial modem: set standard pacing, await each TX_DONE")
parser.add_argument("--tx-done-timeout", type=float, default=10)
parser.add_argument("--queue-depth", type=int, default=8)
parser.add_argument("--duration", type=float, default=0, help="seconds; zero means until terminated")
parser.add_argument("--status-file", help="atomically updated ready/final JSON evidence")
args = parser.parse_args(argv)
if not sys.platform.startswith("linux"):
parser.error("the TAP relay requires Linux")
if args.queue_depth <= 0 or args.queue_depth > 256 or args.duration < 0:
parser.error("queue-depth must be 1..256 and duration must be nonnegative")
if not 1200 <= args.baud <= 2000000:
parser.error("baud must be 1200..2000000")
try:
parsed = validate_endpoint(args.endpoint)
if args.kiss_modem and parsed.scheme not in ("serial", "winusb"):
raise ValueError("--kiss-modem is explicit full serial-modem mode only")
validate_windows_options(parsed, python=args.windows_python, expected_serial=args.expect_serial,
expected_vid=args.expect_vid, expected_pid=args.expect_pid, baud=args.baud,
serial_dtr=args.serial_dtr == "on", duration=args.duration)
if not math.isfinite(args.tx_done_timeout) or not .5 <= args.tx_done_timeout <= 120:
raise ValueError("TX_DONE timeout must be .5..120 seconds")
except ValueError as error:
parser.error(str(error))
stopping = False
def stop_signal(signum, frame):
nonlocal stopping
stopping = True
signal.signal(signal.SIGINT, stop_signal)
signal.signal(signal.SIGTERM, stop_signal)
stream, tap_fd, name = open_transport_then_tap(args.endpoint, args.baud, args.netns, args.interface,
serial_dtr=args.serial_dtr == "on", windows_python=args.windows_python,
expect_serial=args.expect_serial, expect_vid=args.expect_vid, expect_pid=args.expect_pid,
duration=args.duration)
try:
emit({"state": "ready", "endpoint": args.endpoint, "netns": args.netns,
"interface": name, "configure_mtu": 230, "queue_depth": args.queue_depth,
"kiss_port": args.kiss_port, "serial_dtr": args.serial_dtr,
"kiss_modem": args.kiss_modem}, args.status_file)
stats = relay(stream, tap_fd, duration=args.duration, queue_depth=args.queue_depth, kiss_port=args.kiss_port,
kiss_modem=args.kiss_modem, tx_done_timeout=args.tx_done_timeout,
stop=lambda: stopping)
emit({"state": "stopped", "interface": name, "stats": stats}, args.status_file)
return 0
except Exception as error:
emit({"state": "error", "interface": name, "error": str(error),
"stats": getattr(error, "stats", None)}, args.status_file)
return 1
finally:
os.close(tap_fd)
stream.close()
if __name__ == "__main__":
raise SystemExit(main())
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Summarize only the two explicitly captured isolated-namespace TCP snapshots."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
FIELDS = ("ActiveOpens", "PassiveOpens", "AttemptFails", "EstabResets", "CurrEstab",
"InSegs", "OutSegs", "RetransSegs", "InErrs", "OutRsts", "InCsumErrors")
def parse_tcp(text):
lines = [line.split() for line in text.splitlines() if line.startswith("Tcp:")]
if len(lines) != 2 or len(lines[0]) != len(lines[1]) or len(set(lines[0])) != len(lines[0]):
raise ValueError("missing or ambiguous TCP header/value pair")
values = dict(zip(lines[0][1:], map(int, lines[1][1:])))
if any(values.get(field, -1) < 0 for field in ("InSegs", "OutSegs", "RetransSegs")):
raise ValueError("required TCP counters are missing or negative")
return {field: values[field] for field in FIELDS if field in values}
def summarize(before, after):
delta = {field: after[field] - before[field] for field in before.keys() & after.keys()
if field != "CurrEstab"}
if any(value < 0 for value in delta.values()):
raise ValueError("TCP counters decreased; snapshots are not comparable")
return dict(available=True, scope="isolated_network_namespace", before=before, after=after,
delta=delta, retransmission_scope="total only: includes SSH handshake, commands, data and close; no data-only attribution",
out_segment_retransmission_ratio=(delta["RetransSegs"] / delta["OutSegs"]
if delta["OutSegs"] else None))
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--directory", required=True, type=Path)
args = parser.parse_args(argv)
folder = args.directory
if not folder.is_absolute() or not folder.is_dir():
parser.error("an existing absolute evidence directory is required")
try:
result = summarize(*(parse_tcp((folder / f"tcp-{phase}.snmp").read_text(encoding="ascii"))
for phase in ("before", "after")))
result["captured_utc"] = {phase: (folder / f"tcp-{phase}.utc").read_text(encoding="ascii").strip()
for phase in ("before", "after")}
except (OSError, ValueError) as error:
result = dict(available=False, scope="isolated_network_namespace", error=str(error))
(folder / "tcp-stats.json").write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+519
View File
@@ -0,0 +1,519 @@
#!/usr/bin/env python3
"""Point IPv4 TUN <-> existing MeshCore Companion RAW_CUSTOM API (cmd25/push84).
Uses one explicit TCP5000 or identity-bound USB Companion session, NOT KISS or Ethernet. The radio's
tx.ssh policy and MeshCore radio2 profiles must be configured by the operator;
this helper never edits radio settings, channels, keys or the host network.
Connects the socket before entering an existing isolated namespace, where its
own nonpersistent TUN receives only an explicit peer /32 route. SSH is encrypted
by the hosts, not by this public RAW_CUSTOM envelope. Command OK means accepted
by the Companion, never proof that RF delivered a packet.
"""
from __future__ import annotations
import argparse
from collections import deque
import ipaddress
import math
import os
from pathlib import Path
import re
import secrets
import select
import signal
import socket
import stat
import struct
import subprocess
import sys
import time
from ethernet_tap_relay import emit, enter_network_namespace, validate_endpoint
from serial_session import assert_port_idle, open_configured_session
from windows_usb_stdio_bridge import WindowsUsbStream, add_windows_arguments, validate_windows_options
FRAME_SIZE = 172
RESPONSE_FRAME_SIZE = 176 # Actual firmware MAX_FRAME_SIZE; does not enlarge outgoing/IP budgets.
ENVELOPE_SIZE = 12
MAGIC = b"SSH\x01"
MINIMUM_MTU = 94
class MeshPath:
def __init__(self, data=b"", width=1):
self.data = bytes(data)
self.width = width
if type(width) is not int or not 1 <= width <= 3 or len(self.data) > 64 \
or len(self.data) % width or len(self.data) // width > 63:
raise ValueError("path requires width1..3, count0..63 and at most64 bytes")
@classmethod
def from_hex(cls, text, width=1):
if not re.fullmatch(r"(?:[a-fA-F0-9]{2})*", text):
raise ValueError("path must contain complete hexadecimal bytes without separators")
return cls(bytes.fromhex(text), width)
@property
def encoded(self):
return ((self.width - 1) << 6) | (len(self.data) // self.width)
def mtu_budget(forward_bytes, return_bytes, requested=None, frame_size=FRAME_SIZE, transport_bytes=0):
if any(type(value) is not int or not 0 <= value <= 64 for value in (forward_bytes, return_bytes)) \
or not FRAME_SIZE <= frame_size <= 176 or transport_bytes not in (0, 4) \
or (requested is not None and (type(requested) is not int or requested < MINIMUM_MTU)):
raise ValueError("invalid path/frame/MTU budget; minimum SSH MTU94")
path_bytes = max(forward_bytes, return_bytes)
maximum = min(184, frame_size - 2 - path_bytes, frame_size - 4,
255 - 2 - path_bytes - transport_bytes) - ENVELOPE_SIZE
return maximum, min(requested if requested is not None else maximum, maximum)
def private_host(text):
address = ipaddress.IPv4Address(text)
if str(address) != text or not (address in ipaddress.IPv4Network("10.0.0.0/8")
or address in ipaddress.IPv4Network("172.16.0.0/12")
or address in ipaddress.IPv4Network("192.168.0.0/16")) or address.packed[-1] in (0, 255):
raise ValueError("use explicit private IPv4 host addresses")
return address.packed
def valid_ipv4(packet, source, destination, mtu):
if not 20 <= len(packet) <= mtu or packet[0] >> 4 != 4:
return False
header = (packet[0] & 15) * 4
length = int.from_bytes(packet[2:4], "big")
if not 20 <= header <= 60 or length < header or length != len(packet) \
or int.from_bytes(packet[6:8], "big") & 0xbfff \
or packet[12:16] != source or packet[16:20] != destination:
return False
checksum = sum(struct.unpack("!" + "H" * (header // 2), packet[:header]))
while checksum >> 16:
checksum = (checksum & 0xffff) + (checksum >> 16)
if checksum != 0xffff:
return False
if packet[9] == 1:
return length >= header + 8
return packet[9] == 6 and length >= header + 20 and 20 <= (packet[header + 12] >> 4) * 4 <= length - header
class IpCodec:
def __init__(self, local, remote, path, return_bytes, *, mtu=None, session=None, sequence=0):
self.local, self.remote = private_host(local), private_host(remote)
if self.local == self.remote:
raise ValueError("point IPv4 peers must differ")
self.path = path
self.maximum_mtu, self.mtu = mtu_budget(len(path.data), return_bytes, mtu)
self.session = secrets.randbits(32) if session is None else session
self.sequence = sequence
if any(type(value) is not int or not 0 <= value <= 0xffffffff for value in (self.session, sequence)):
raise ValueError("session/sequence must be unsigned32-bit integers")
def command(self, packet):
if self.sequence > 0xffffffff:
raise OverflowError("sequence exhausted; start a new session")
if not valid_ipv4(packet, self.local, self.remote, self.mtu):
raise ValueError("invalid, fragmented or oversized point IPv4 packet")
envelope = MAGIC + struct.pack("<II", self.session, self.sequence) + packet
self.sequence += 1
return bytes((25, self.path.encoded)) + self.path.data + envelope
def raw_push(self, frame):
if not 4 + ENVELOPE_SIZE + 20 <= len(frame) <= FRAME_SIZE \
or frame[0] != 0x84 or frame[4:8] != MAGIC:
return None
packet = frame[4 + ENVELOPE_SIZE:]
return packet if valid_ipv4(packet, self.remote, self.local, self.mtu) else None
def companion_frame(frame):
if not 1 <= len(frame) <= FRAME_SIZE:
raise ValueError("Companion command exceeds conservative host frame limit")
return b"<" + struct.pack("<H", len(frame)) + frame
class CompanionDecoder:
def __init__(self, *, ascii_preamble=False):
self.staging = bytearray()
self.ascii_preamble = ascii_preamble
self.preamble_bytes = 0
def feed(self, data):
if len(data) > 4096 or len(self.staging) + len(data) > 4096 + RESPONSE_FRAME_SIZE + 3:
raise BufferError("Companion input staging full")
self.staging.extend(data)
def pop(self):
while self.ascii_preamble and self.staging:
if self.staging[0] == ord(">"):
if len(self.staging) < 3:
return None
if 1 <= int.from_bytes(self.staging[1:3], "little") <= RESPONSE_FRAME_SIZE:
break
byte = self.staging[0]
if byte not in (9, 10, 13) and not 32 <= byte <= 126:
raise ValueError("non-ASCII bytes before the first Companion frame")
self.preamble_bytes += 1
if self.preamble_bytes > 4096:
raise BufferError("USB ASCII startup banner exceeded4096 bytes")
del self.staging[0]
if len(self.staging) < 3:
return None
length = int.from_bytes(self.staging[1:3], "little")
if self.staging[0] != ord(">") or not 1 <= length <= RESPONSE_FRAME_SIZE:
raise ValueError("invalid Companion response direction/length")
if len(self.staging) < length + 3:
return None
frame = bytes(self.staging[3:length + 3])
del self.staging[:length + 3]
self.ascii_preamble = False # Never resynchronize/discard data after binary ownership begins.
return frame
class IpRelayBuffers:
def __init__(self, codec, depth=8, command_timeout=12, now=time.monotonic, *, ascii_preamble=False):
if not 1 <= depth <= 64 or not math.isfinite(command_timeout) or not 0 < command_timeout <= 30:
raise ValueError("queue depth1..64 and command timeout(0,30] required")
self.codec, self.depth, self.command_timeout, self.now = codec, depth, command_timeout, now
self.decoder = CompanionDecoder(ascii_preamble=ascii_preamble)
self.to_tun = deque()
self.pending = 1
self.pending_since = now()
self.output = companion_frame(bytes((1,)) + bytes(7) + b"MeshCore IP")
self.ready = False
self.counters = dict(tun_read=0, tun_read_bytes=0, tun_written=0, tun_written_bytes=0,
accepted_commands=0, rejected_commands=0, invalid_ip=0,
ignored_frames=0, raw_pushes=0)
def can_read_tun(self):
return self.ready and self.pending is None and not self.output
def can_read_companion(self):
# A partial TCP header/body MUST allow another socket read. Complete
# staged frames are drained by parse_pending before this is consulted.
return len(self.to_tun) < self.depth
def queue_tun(self, packet):
if not self.can_read_tun():
raise BufferError("one outstanding Companion command only")
try:
frame = self.codec.command(packet)
except ValueError:
self.counters["invalid_ip"] += 1
return False
self.output = companion_frame(frame)
self.pending = 25
self.pending_since = self.now()
self.counters["tun_read"] += 1
self.counters["tun_read_bytes"] += len(packet)
return True
def write_companion(self, write):
if not self.output:
return
count = write(self.output)
if not 0 <= count <= len(self.output):
raise IOError("invalid Companion stream write count")
self.output = self.output[count:]
def feed_companion(self, data):
if not self.can_read_companion():
raise BufferError("Companion input must apply backpressure")
self.decoder.feed(data)
self.parse_pending()
def parse_pending(self):
while len(self.to_tun) < self.depth:
frame = self.decoder.pop()
if frame is None:
return
if frame[0] == 0x84:
self.counters["raw_pushes"] += 1
packet = self.codec.raw_push(frame)
if packet is None:
self.counters["invalid_ip"] += 1
else:
self.to_tun.append(packet)
elif self.pending == 1 and frame[0] == 5 and not self.output:
self.ready = True
self.pending = None
elif self.pending == 1 and frame[0] == 1:
raise RuntimeError("Companion rejected APP_START")
elif self.pending == 25 and ((frame[0] == 0 and len(frame) == 1)
or (frame[0] == 1 and len(frame) == 2)) and not self.output:
self.counters["accepted_commands" if frame[0] == 0 else "rejected_commands"] += 1
self.pending = None
else:
self.counters["ignored_frames"] += 1
def write_tun(self, write):
if not self.to_tun:
return
packet = self.to_tun[0]
if write(packet) != len(packet):
raise IOError("partial TUN datagram write")
self.to_tun.popleft()
self.counters["tun_written"] += 1
self.counters["tun_written_bytes"] += len(packet)
def check_deadline(self):
if self.pending is not None and self.now() - self.pending_since >= self.command_timeout:
raise TimeoutError("Companion command deadline; delivery was not confirmed")
def stats(self):
return dict(self.counters, ready=self.ready, pending_command=self.pending,
queued_to_tun=len(self.to_tun), staged_bytes=len(self.decoder.staging),
usb_ascii_preamble_bytes=self.decoder.preamble_bytes,
acceptance_note="Command OK means queued, not RF-delivered; TCP owns retries")
def validate_companion_endpoint(endpoint, *, expect_serial=None):
parsed = validate_endpoint(endpoint)
if parsed.scheme == "tcp":
private_host(parsed.hostname or "")
if parsed.port != 5000 or expect_serial is not None:
raise ValueError("use explicit private IPv4 tcp://RADIO:5000 without USB identity options")
elif parsed.scheme == "winusb":
if expect_serial is None:
raise ValueError("Windows USB requires an exact --expect-serial identity")
elif Path(parsed.path).parent != Path("/dev/serial/by-id"):
raise ValueError("RAW USB requires a stable explicit /dev/serial/by-id/ device")
if expect_serial is not None and not re.fullmatch(r"[A-Za-z0-9_.:+-]{1,128}", expect_serial):
raise ValueError("invalid expected USB serial")
return parsed
def serial_identity(path, expect_serial=None):
"""Read only sysfs/stat; never open or pulse the selected radio."""
resolved = str(Path(path).resolve(strict=True))
if not re.fullmatch(r"/dev/tty(?:ACM|USB)[A-Za-z0-9_-]+", resolved):
raise ValueError("Selected USB alias must resolve to ttyACM/ttyUSB")
info = os.stat(resolved)
if not stat.S_ISCHR(info.st_mode):
raise ValueError("Selected USB alias is not a character device")
device = (Path("/sys/class/tty") / Path(resolved).name / "device").resolve(strict=True)
for parent in (device, *device.parents):
serial_path = parent / "serial"
if serial_path.is_file():
actual_serial = serial_path.read_text().strip()
if not actual_serial or (expect_serial is not None and actual_serial != expect_serial):
raise ValueError("Selected USB serial differs from expected identity")
return resolved, actual_serial, info.st_dev, info.st_ino, info.st_rdev
raise ValueError("Selected USB identity is unavailable")
class CompanionStream:
def __init__(self, endpoint, *, baud=115200, serial_dtr=False, expect_serial=None,
windows_python=None, expect_vid=None, expect_pid=None, duration=300):
self.socket = self.serial = self.windows = None
parsed = validate_companion_endpoint(endpoint, expect_serial=expect_serial)
if type(baud) is not int or not 1200 <= baud <= 2000000 or type(serial_dtr) is not bool:
raise ValueError("use baud1200..2000000 and an explicit boolean DTR selection")
validate_windows_options(parsed, python=windows_python, expected_serial=expect_serial,
expected_vid=expect_vid, expected_pid=expect_pid, baud=baud,
serial_dtr=serial_dtr, duration=duration)
try:
if parsed.scheme == "tcp":
self.socket = socket.create_connection((parsed.hostname, 5000), timeout=5)
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self.socket.setblocking(False)
self.fd = self.socket.fileno()
elif parsed.scheme == "winusb":
self.windows = WindowsUsbStream(endpoint, python=windows_python,
expected_serial=expect_serial, expected_vid=expect_vid, expected_pid=expect_pid,
baud=baud, serial_dtr=serial_dtr, duration=duration)
self.fd = self.windows.read_fd
else:
before = serial_identity(parsed.path, expect_serial)
assert_port_idle(before[0])
import serial
self.serial = serial.Serial()
# Open the verified resolved device, not a mutable symlink.
self.serial.port = before[0]
self.serial.baudrate = baud
self.serial.timeout = self.serial.write_timeout = 0
self.serial.exclusive = True
self.serial.dtr = serial_dtr
self.serial.rts = False
open_configured_session(self.serial)
self.fd = self.serial.fileno()
after = serial_identity(parsed.path, expect_serial)
opened = os.fstat(self.fd)
if before != after or not stat.S_ISCHR(opened.st_mode) or \
(opened.st_dev, opened.st_ino, opened.st_rdev) != after[2:]:
raise ValueError("USB alias/device identity changed during the exclusive open")
self.read_fd = self.windows.read_fd if self.windows else self.fd
self.write_fd = self.windows.write_fd if self.windows else self.fd
except BaseException:
self.close()
raise
def read(self, count):
if self.windows:
return self.windows.read(count)
data = self.socket.recv(count) if self.socket else os.read(self.fd, count)
if not data:
raise ConnectionError("Companion endpoint disconnected")
return data
def write(self, data):
if self.windows:
return self.windows.write(data)
return self.socket.send(data) if self.socket else os.write(self.fd, data)
def close(self):
if self.socket:
self.socket.close()
if self.serial:
self.serial.close()
if self.windows:
self.windows.close()
def create_tun(name):
import fcntl
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,14}", name):
raise ValueError("use a1..15-character TUN interface name")
descriptor = os.open("/dev/net/tun", os.O_RDWR | os.O_NONBLOCK | os.O_CLOEXEC)
try:
# IFF_TUN_EXCL refuses to attach an existing/persistent interface.
request = struct.pack("16sH", name.encode("ascii"), 0x0001 | 0x1000 | 0x8000)
result = fcntl.ioctl(descriptor, 0x400454CA, request)
return descriptor, result[:16].split(b"\0", 1)[0].decode("ascii")
except BaseException:
os.close(descriptor)
raise
def open_transport_then_tun(endpoint, netns, name, local, remote, mtu, *,
baud=115200, serial_dtr=False, expect_serial=None, **transport_options):
stream = CompanionStream(endpoint, baud=baud, serial_dtr=serial_dtr, expect_serial=expect_serial,
**transport_options)
# Open the USB/socket in the original namespace before creating our TUN.
descriptor = None
try:
enter_network_namespace(netns) # Rejects current host netns before any interface creation.
descriptor, actual = create_tun(name)
subprocess.run(["ip", "link", "set", "dev", actual, "mtu", str(mtu), "up"], check=True, timeout=5)
subprocess.run(["ip", "address", "add", local + "/32", "peer", remote + "/32", "dev", actual], check=True, timeout=5)
return stream, descriptor, actual
except BaseException:
if descriptor is not None:
os.close(descriptor)
stream.close()
raise
def relay(stream, tun_fd, buffers, *, duration, stop=None, on_ready=None):
started = time.monotonic()
ready_announced = False
while not (stop and stop()) and time.monotonic() - started < duration:
buffers.parse_pending()
buffers.check_deadline()
if buffers.ready and not ready_announced:
ready_announced = True
if on_ready:
on_ready()
reads = ([tun_fd] if buffers.can_read_tun() else [])
if buffers.can_read_companion():
reads.append(stream.read_fd)
writes = ([tun_fd] if buffers.to_tun else []) + ([stream.write_fd] if buffers.output else [])
ready_read, ready_write, _ = select.select(reads, writes, [], .1)
for descriptor in ready_write:
try:
if descriptor == tun_fd:
buffers.write_tun(lambda packet: os.write(tun_fd, packet))
else:
buffers.write_companion(stream.write)
except BlockingIOError:
pass
for descriptor in ready_read:
try:
if descriptor == tun_fd:
buffers.queue_tun(os.read(tun_fd, 65536)) # Never truncate an oversized datagram.
else:
buffers.feed_companion(stream.read(4096))
except BlockingIOError:
pass
return dict(buffers.stats(), elapsed_seconds=round(time.monotonic() - started, 3))
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--endpoint", required=True)
parser.add_argument("--baud", type=int, default=115200)
parser.add_argument("--serial-dtr", choices=("off", "on"), default="off")
parser.add_argument("--expect-serial", help="require this USB chip serial before and after opening")
add_windows_arguments(parser)
parser.add_argument("--netns", required=True)
parser.add_argument("--interface", default="mcip0")
parser.add_argument("--local-address", default="10.90.5.1")
parser.add_argument("--remote-address", default="10.90.5.2")
parser.add_argument("--path-hex", default="")
parser.add_argument("--path-hash-width", type=int, default=1)
parser.add_argument("--return-path-byte-count", type=int)
parser.add_argument("--mtu", type=int)
parser.add_argument("--session", type=lambda value: int(value, 0))
parser.add_argument("--duration", type=float, default=180)
parser.add_argument("--command-timeout", type=float, default=12)
parser.add_argument("--status-file", type=Path)
parser.add_argument("--ready-after-handshake", action="store_true",
help="publish ready only after the Companion APP_START/SelfInfo handshake")
args = parser.parse_args(argv)
try:
endpoint = validate_companion_endpoint(args.endpoint, expect_serial=args.expect_serial)
validate_windows_options(endpoint, python=args.windows_python, expected_serial=args.expect_serial,
expected_vid=args.expect_vid, expected_pid=args.expect_pid, baud=args.baud,
serial_dtr=args.serial_dtr == "on", duration=args.duration)
if not 1200 <= args.baud <= 2000000:
raise ValueError("baud must be in1200..2000000")
path = MeshPath.from_hex(args.path_hex, args.path_hash_width)
if path.data and args.return_path_byte_count is None:
raise ValueError("nonzero forward path also requires explicit --return-path-byte-count")
if not math.isfinite(args.duration) or not 0 < args.duration <= 3600:
raise ValueError("duration must be finite and in(0,3600]")
if not Path(args.netns).is_absolute() or not Path(args.netns).is_file():
raise ValueError("use an existing absolute isolated namespace handle")
if args.status_file and (not args.status_file.is_absolute() or not args.status_file.parent.is_dir()):
raise ValueError("status-file requires an absolute existing evidence directory")
codec = IpCodec(args.local_address, args.remote_address, path, args.return_path_byte_count or 0,
mtu=args.mtu, session=args.session)
buffers = IpRelayBuffers(codec, command_timeout=args.command_timeout,
ascii_preamble=endpoint.scheme in ("serial", "winusb"))
except (ValueError, TypeError) as error:
parser.error(str(error))
if not sys.platform.startswith("linux") or os.geteuid() != 0:
parser.error("the isolated TUN helper requires Linux root")
stopping = False
def stopped(signum, frame):
nonlocal stopping
stopping = True
signal.signal(signal.SIGINT, stopped)
signal.signal(signal.SIGTERM, stopped)
stream, tun_fd, name = open_transport_then_tun(args.endpoint, args.netns, args.interface,
args.local_address, args.remote_address, codec.mtu, baud=args.baud,
serial_dtr=args.serial_dtr == "on", expect_serial=args.expect_serial,
windows_python=args.windows_python, expect_vid=args.expect_vid, expect_pid=args.expect_pid,
duration=args.duration)
buffers.pending_since = time.monotonic() # Exclude socket/TUN setup from APP_START deadline.
try:
ready_state = dict(state="ready", transport="meshcore-raw-ip", interface=name, mtu=codec.mtu,
maximum_mtu=codec.maximum_mtu, session=codec.session,
forward_path_bytes=len(path.data), return_path_bytes=args.return_path_byte_count or 0)
emit(dict(ready_state, state="starting" if args.ready_after_handshake else "ready"), args.status_file)
stats = relay(stream, tun_fd, buffers, duration=args.duration, stop=lambda: stopping,
on_ready=(lambda: emit(ready_state, args.status_file)) if args.ready_after_handshake else None)
emit(dict(state="stopped", interface=name, stats=stats), args.status_file)
return 0
except Exception as error:
emit(dict(state="error", interface=name, error=str(error), stats=buffers.stats()), args.status_file)
return 1
finally:
os.close(tun_fd)
stream.close()
if __name__ == "__main__":
raise SystemExit(main())
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env bash
# Explicit RAW Companion endpoint -> one isolated SSH/SFTP server for Android.
# No host routes, firewall, accounts, system sshd or firmware settings change.
set -euo pipefail
umask 077
endpoint=''; bench_dir=''; prefix=''; account=''; ssh_user=''; duration=1200
baud=115200; serial_dtr=off; expect_serial=''
die() { printf '%s\n' "$*" >&2; exit 2; }
while (($#)); do
(($# >= 2)) || die 'Every option requires a value.'
case "$1" in
--endpoint) endpoint=$2 ;; --bench-dir) bench_dir=$2 ;;
--prefix) prefix=$2 ;; --duration) duration=$2 ;;
--account) account=$2 ;; --ssh-user) ssh_user=$2 ;;
--baud) baud=$2 ;; --serial-dtr) serial_dtr=$2 ;; --expect-serial) expect_serial=$2 ;;
*) die "Unknown option: $1" ;;
esac
shift 2
done
[[ ${EUID:-$(id -u)} == 0 ]] || die 'Run as root for the isolated namespace/TUN.'
[[ "$prefix" =~ ^mcphone-[a-z0-9][a-z0-9-]{0,19}$ ]] || die 'Use a unique mcphone- prefix.'
[[ "$duration" =~ ^[0-9]+$ && "$duration" -ge 60 && "$duration" -le 1800 ]] || die 'Duration must be 60..1800 seconds.'
[[ "$baud" =~ ^[0-9]+$ && "$baud" -ge 1200 && "$baud" -le 2000000 ]] || die 'Baud must be 1200..2000000.'
[[ "$serial_dtr" == off || "$serial_dtr" == on ]] || die 'Serial DTR must be off or explicitly on.'
[[ -z "$expect_serial" || "$expect_serial" =~ ^[A-Za-z0-9_-]{1,128}$ ]] || die 'Invalid expected USB serial.'
[[ "$account" =~ ^[a-z_][a-z0-9_-]*$ ]] || die 'Specify an existing unprivileged --account.'
[[ $(id -u "$account") -ne 0 ]] || die 'The test account must not be root.'
ssh_user=${ssh_user:-$account}
[[ "$ssh_user" == "$account" ]] || die 'SSH user must match the private artifact/key owner account.'
[[ "$bench_dir" == /* && -d "$bench_dir" && "$bench_dir" != / ]] || die 'Use an existing absolute bench directory.'
[[ -x /usr/sbin/sshd && -c /dev/net/tun ]] || die 'sshd and /dev/net/tun are required.'
for dependency in ip python3 ssh-keygen stat realpath runuser sysctl ss mktemp; do
command -v "$dependency" >/dev/null || die "Missing dependency: $dependency"
done
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
python3 - "$endpoint" "$script_dir" "$expect_serial" <<'PY'
import sys
sys.path.insert(0, sys.argv[2])
from meshcore_ip_relay import validate_companion_endpoint
try:
validate_companion_endpoint(sys.argv[1], expect_serial=(sys.argv[3] or None) if len(sys.argv) > 3 else None)
except ValueError as error:
raise SystemExit(str(error))
PY
relay="$script_dir/meshcore_ip_relay.py"
[[ -r "$relay" ]] || die 'The RAW IP relay must be beside this script.'
bench_dir=$(realpath -e -- "$bench_dir")
namespace="$prefix"
[[ ! -e "/var/run/netns/$namespace" ]] || die 'Namespace already exists; refusing to reuse it.'
run_dir=$(mktemp -d -- "$bench_dir/meshcore-phone.XXXXXXXX")
[[ "$run_dir" == "$bench_dir/meshcore-phone."* ]] || die 'Unexpected artifact directory.'
created=0; inode=''; relay_pid=''; sshd_pid=''
cleanup() {
local result=$? observed='' child parent
trap - EXIT INT TERM HUP
set +e
for child in "$relay_pid" "$sshd_pid"; do
[[ "$child" =~ ^[0-9]+$ ]] || continue
parent=$(awk '/^PPid:/ {print $2}' "/proc/$child/status" 2>/dev/null)
[[ "$parent" == "$$" ]] && kill -TERM "$child" 2>/dev/null
done
if ((created)); then
observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$namespace" 2>/dev/null)
if [[ -n "$inode" && "$observed" == "$inode" ]]; then
while read -r child; do
[[ "$child" =~ ^[0-9]+$ ]] && kill -TERM "$child" 2>/dev/null
done < <(ip netns pids "$namespace")
sleep 0.3
observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$namespace" 2>/dev/null)
if [[ "$observed" == "$inode" ]]; then
while read -r child; do
[[ "$child" =~ ^[0-9]+$ ]] && kill -KILL "$child" 2>/dev/null
done < <(ip netns pids "$namespace")
ip netns delete "$namespace"
fi
fi
fi
# A connecting relay still in the host namespace must not strand cleanup.
# Reap only our explicit children, never search/kill another test's process.
for child in "$relay_pid" "$sshd_pid"; do
[[ "$child" =~ ^[0-9]+$ ]] || continue
for _ in {1..20}; do
kill -0 "$child" 2>/dev/null || break
sleep 0.1
done
parent=$(awk '/^PPid:/ {print $2}' "/proc/$child/status" 2>/dev/null)
[[ "$parent" == "$$" ]] && kill -KILL "$child" 2>/dev/null
wait "$child" 2>/dev/null
done
rm -f -- "$run_dir/host_key" "$run_dir/client_key"
printf 'exit=%s\n' "$result" >> "$run_dir/result.txt"
chown "$account": "$run_dir/result.txt" 2>/dev/null
printf 'RAW phone bench stopped; ephemeral keys removed. Evidence: %s\n' "$run_dir"
exit "$result"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM HUP
chown "$account": "$run_dir"
chmod 700 -- "$run_dir"
ssh-keygen -q -t ed25519 -N '' -f "$run_dir/host_key"
runuser -u "$account" -- ssh-keygen -q -t ed25519 -N '' -f "$run_dir/client_key"
ip netns add "$namespace"; created=1
inode=$(stat -Lc '%d:%i' -- "/var/run/netns/$namespace")
ip -n "$namespace" link set lo up
ip netns exec "$namespace" sysctl -qw net.ipv6.conf.all.disable_ipv6=1
ip netns exec "$namespace" sysctl -qw net.ipv6.conf.default.disable_ipv6=1
deadline=$((SECONDS + duration))
serial_options=(--baud "$baud" --serial-dtr "$serial_dtr")
[[ -z "$expect_serial" ]] || serial_options+=(--expect-serial "$expect_serial")
python3 "$relay" --endpoint "$endpoint" --netns "/var/run/netns/$namespace" \
"${serial_options[@]}" \
--interface mcip0 --local-address 10.90.5.2 --remote-address 10.90.5.1 \
--path-hex '' --path-hash-width 1 --return-path-byte-count 0 --mtu 156 \
--duration "$duration" --ready-after-handshake --status-file "$run_dir/relay.json" \
> "$run_dir/relay.log" 2>&1 &
relay_pid=$!
ready=0
for _ in {1..100}; do
kill -0 "$relay_pid" 2>/dev/null || die 'RAW relay exited; inspect its bounded evidence.'
if python3 - "$run_dir/relay.json" <<'PY'
import json, sys
try:
with open(sys.argv[1], encoding='utf-8') as source:
state = json.load(source)
valid = (state.get('state') == 'ready' and state.get('transport') == 'meshcore-raw-ip'
and state.get('interface') == 'mcip0' and state.get('mtu') == 156
and state.get('forward_path_bytes') == 0 and state.get('return_path_bytes') == 0)
except (OSError, ValueError):
valid = False
raise SystemExit(0 if valid else 1)
PY
then ready=1; break; fi
sleep 0.2
done
((ready)) || die 'Companion APP_START/SelfInfo handshake did not become ready.'
ip -n "$namespace" link show dev mcip0 >/dev/null
ip -n "$namespace" route show > "$run_dir/routes.txt"
options=(-f /dev/null -h "$run_dir/host_key" -p 2222
-o ListenAddress=10.90.5.2 -o "PidFile=$run_dir/sshd.pid"
-o "AuthorizedKeysFile=$run_dir/client_key.pub" -o StrictModes=no
-o "AllowUsers=$ssh_user" -o PermitRootLogin=no -o UsePAM=no
-o PasswordAuthentication=no -o KbdInteractiveAuthentication=no
-o AuthenticationMethods=publickey -o DisableForwarding=yes
-o PermitUserEnvironment=no -o X11Forwarding=no -o PermitTTY=yes
-o 'Subsystem=sftp internal-sftp')
/usr/sbin/sshd -t "${options[@]}"
ip netns exec "$namespace" /usr/sbin/sshd -D -e "${options[@]}" > "$run_dir/sshd.log" 2>&1 &
sshd_pid=$!
listening=0
for _ in {1..30}; do
kill -0 "$relay_pid" && kill -0 "$sshd_pid" || die 'An owned fixture child exited.'
if ip netns exec "$namespace" ss -H -ltn | grep -q '10\.90\.5\.2:2222'; then listening=1; break; fi
sleep 0.1
done
((listening)) || die 'The isolated SSH/SFTP listener did not become ready.'
printf 'READY: transport=raw gateway=%s local=10.90.5.1 remote=10.90.5.2 MTU=156 SSH=2222 user=%s\n' "$endpoint" "$ssh_user"
printf 'Ephemeral client private key file (never print contents): %s/client_key\n' "$run_dir"
ssh-keygen -lf "$run_dir/host_key.pub"
printf 'Artifacts: %s\n' "$run_dir"
printf 'Server helper PID: %s\n' "$$"
while ((SECONDS < deadline)); do
kill -0 "$relay_pid" && kill -0 "$sshd_pid" || die 'An owned fixture child exited.'
sleep 1
done
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env bash
# Matched RAW156 / full KISS230 USB-LoRa-USB SFTP client. No host routes.
set -euo pipefail
mode=''; endpoint=''; windows_python=''; expect_serial=''; expect_vid=''; expect_pid=''
bench_dir=''; workspace=''; dart=''; config=''; prefix=''; account=''; baud=115200; dtr=off
die() { printf '%s\n' "$*" >&2; exit 2; }
while (($#)); do
(($# >= 2)) || die 'Every option requires a value.'
case "$1" in
--mode) mode=$2 ;; --endpoint) endpoint=$2 ;;
--windows-python) windows_python=$2 ;; --expect-serial) expect_serial=$2 ;;
--expect-vid) expect_vid=$2 ;; --expect-pid) expect_pid=$2 ;;
--bench-dir) bench_dir=$2 ;; --workspace) workspace=$2 ;; --dart) dart=$2 ;;
--config) config=$2 ;; --prefix) prefix=$2 ;; --account) account=$2 ;;
--baud) baud=$2 ;; --serial-dtr) dtr=$2 ;;
*) die "Unknown option: $1" ;;
esac
shift 2
done
[[ ${EUID:-$(id -u)} == 0 ]] || die 'Root is required only for the isolated namespace/TUN.'
[[ "$mode" == raw || "$mode" == kiss ]] || die 'Choose --mode raw or kiss explicitly.'
[[ "$endpoint" =~ ^winusb://COM[1-9][0-9]*$ ]] || die 'Choose one explicit winusb://COMn endpoint.'
[[ "$prefix" =~ ^mcusb-[a-z0-9][a-z0-9-]{0,19}$ ]] || die 'Use a unique mcusb- prefix.'
[[ "$account" =~ ^[a-z_][a-z0-9_-]*$ && $(id -u "$account") -ne 0 ]] || die 'Choose an unprivileged existing account.'
[[ "$baud" =~ ^[0-9]+$ && "$baud" -ge 1200 && "$baud" -le 2000000 ]] || die 'Invalid baud.'
[[ "$dtr" == off || "$dtr" == on ]] || die 'Choose DTR off or on explicitly.'
[[ "$expect_serial" =~ ^[A-Za-z0-9_-]{1,128}$ ]] || die 'An exact USB serial is required.'
[[ "$expect_vid" =~ ^0x[0-9A-Fa-f]{4}$ && "$expect_pid" =~ ^0x[0-9A-Fa-f]{4}$ ]] || die 'Exact hexadecimal USB VID/PID are required.'
for directory in "$bench_dir" "$workspace"; do
[[ "$directory" == /* && "$directory" != / && -d "$directory" ]] || die 'Use existing absolute directories.'
done
[[ "$windows_python" == /* && -x "$windows_python" && "$dart" == /* && -x "$dart" ]] || die 'Choose absolute Python.exe and Dart executable paths.'
[[ "$config" == /* && -f "$config" && ! -L "$config" ]] || die 'Choose the private regular configuration file.'
[[ -f "$workspace/tool/hil/lora_sftp_bench.dart" && -f "$workspace/.dart_tool/package_config.json" ]] || die 'The prepared Dart workspace is required.'
[[ ! -e "/var/run/netns/$prefix" ]] || die 'Refusing to reuse a namespace.'
mtu=156; interface=mcip0
if [[ "$mode" == kiss ]]; then mtu=230; interface=lora0; fi
python3 - "$config" "$mtu" "$account" <<'PY'
import json, os, pwd, stat, sys
path, mtu, account = sys.argv[1:]
info = os.lstat(path)
if not stat.S_ISREG(info.st_mode) or info.st_uid != pwd.getpwnam(account).pw_uid or info.st_mode & 0o077:
raise SystemExit('Configuration must be owner-only and owned by the selected account.')
with open(path, encoding='utf-8') as source:
config = json.load(source)
if config.get('mtu') != int(mtu) or config.get('enabled') is not True:
raise SystemExit('Configuration MTU must match the explicitly selected transport.')
PY
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
run_dir=$(mktemp -d -- "$bench_dir/usb-sftp.XXXXXXXX")
chown "$account": "$run_dir"; chmod 700 -- "$run_dir"
created=0; inode=''; relay_pid=''; client_pid=''; monitor_pid=''; tcp_started=0
capture_tcp() {
local phase=$1 observed=''
observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$prefix" 2>/dev/null) || return 1
[[ -n "$inode" && "$observed" == "$inode" ]] || return 1
date -u +%Y-%m-%dT%H:%M:%SZ > "$run_dir/tcp-$phase.utc"
ip netns exec "$prefix" cat /proc/net/snmp > "$run_dir/tcp-$phase.snmp"
}
cleanup() {
local result=$? observed='' child parent
trap - EXIT INT TERM HUP
set +e
if ((tcp_started)); then
capture_tcp after
python3 "$script_dir/ethernet_tcp_stats.py" --directory "$run_dir" > "$run_dir/tcp-stats.log" 2>&1
fi
for child in "$client_pid" "$monitor_pid" "$relay_pid"; do
[[ "$child" =~ ^[0-9]+$ ]] || continue
parent=$(awk '/^PPid:/ {print $2}' "/proc/$child/status" 2>/dev/null)
[[ "$parent" == "$$" ]] && kill -TERM "$child" 2>/dev/null
done
if ((created)); then
observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$prefix" 2>/dev/null)
if [[ -n "$inode" && "$observed" == "$inode" ]]; then
while read -r child; do
[[ "$child" =~ ^[0-9]+$ ]] && kill -TERM "$child" 2>/dev/null
done < <(ip netns pids "$prefix")
sleep 0.3
observed=$(stat -Lc '%d:%i' -- "/var/run/netns/$prefix" 2>/dev/null)
if [[ "$observed" == "$inode" ]]; then
while read -r child; do
[[ "$child" =~ ^[0-9]+$ ]] && kill -KILL "$child" 2>/dev/null
done < <(ip netns pids "$prefix")
ip netns delete "$prefix"
fi
fi
fi
for child in "$client_pid" "$monitor_pid" "$relay_pid"; do
[[ "$child" =~ ^[0-9]+$ ]] || continue
for _ in {1..30}; do
kill -0 "$child" 2>/dev/null || break
sleep 0.1
done
parent=$(awk '/^PPid:/ {print $2}' "/proc/$child/status" 2>/dev/null)
[[ "$parent" == "$$" ]] && kill -KILL "$child" 2>/dev/null
wait "$child" 2>/dev/null
done
printf 'mode=%s\nmtu=%s\nexit=%s\n' "$mode" "$mtu" "$result" >> "$run_dir/result.txt"
for evidence in relay.json relay.log result.txt client.log interface.json routes.txt \
tcp-before.utc tcp-before.snmp tcp-after.utc tcp-after.snmp tcp-stats.log tcp-stats.json tcp-ss.txt; do
[[ -f "$run_dir/$evidence" && ! -L "$run_dir/$evidence" ]] && chown "$account": "$run_dir/$evidence"
done
printf 'USB SFTP artifacts: %s\n' "$run_dir"
exit "$result"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM HUP
ip netns add "$prefix"; created=1
inode=$(stat -Lc '%d:%i' -- "/var/run/netns/$prefix")
ip -n "$prefix" link set lo up
ip netns exec "$prefix" sysctl -qw net.ipv6.conf.all.disable_ipv6=1
ip netns exec "$prefix" sysctl -qw net.ipv6.conf.default.disable_ipv6=1
usb_args=(--endpoint "$endpoint" --windows-python "$windows_python" --expect-serial "$expect_serial"
--expect-vid "$expect_vid" --expect-pid "$expect_pid" --baud "$baud" --serial-dtr "$dtr"
--netns "/var/run/netns/$prefix" --interface "$interface" --duration 660 --status-file "$run_dir/relay.json")
if [[ "$mode" == raw ]]; then
python3 "$script_dir/meshcore_ip_relay.py" "${usb_args[@]}" --local-address 10.90.5.1 \
--remote-address 10.90.5.2 --path-hex '' --path-hash-width 1 --return-path-byte-count 0 \
--mtu 156 --ready-after-handshake > "$run_dir/relay.log" 2>&1 &
else
python3 "$script_dir/ethernet_tap_relay.py" "${usb_args[@]}" --kiss-port 1 --kiss-modem \
> "$run_dir/relay.log" 2>&1 &
fi
relay_pid=$!
ready=0
for _ in {1..150}; do
kill -0 "$relay_pid" 2>/dev/null || die 'USB relay exited; inspect its bounded log.'
if python3 - "$run_dir/relay.json" "$mode" <<'PY'
import json, sys
try:
with open(sys.argv[1], encoding='utf-8') as source:
value = json.load(source)
ready = value.get('state') == 'ready'
except (OSError, ValueError):
ready = False
raise SystemExit(0 if ready else 1)
PY
then ready=1; break; fi
sleep 0.2
done
((ready)) || die 'USB relay handshake did not become ready.'
if [[ "$mode" == kiss ]]; then
ip -n "$prefix" link set "$interface" address 02:90:05:00:00:01 mtu 230 up
ip -n "$prefix" address add 10.90.5.1/24 dev "$interface"
fi
actual_mtu=$(ip -n "$prefix" -j link show dev "$interface" | python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["mtu"])')
[[ "$actual_mtu" == "$mtu" ]] || die 'The actual isolated interface MTU differs.'
ip -n "$prefix" -j address show dev "$interface" > "$run_dir/interface.json"
ip -n "$prefix" route show > "$run_dir/routes.txt"
capture_tcp before; tcp_started=1
printf 'READY: mode=%s MTU=%s namespace=%s\nArtifacts: %s\n' "$mode" "$mtu" "$prefix" "$run_dir"
timeout --kill-after=5s 600s ip netns exec "$prefix" runuser -u "$account" -- \
"$dart" "--packages=$workspace/.dart_tool/package_config.json" "$workspace/tool/hil/lora_sftp_bench.dart" \
--config "$config" --report "$run_dir/sftp.json" > "$run_dir/client.log" 2>&1 &
client_pid=$!
(
for _ in {1..100}; do
kill -0 "$client_pid" 2>/dev/null || break
date -u +%Y-%m-%dT%H:%M:%SZ
timeout --kill-after=1s 2s ip netns exec "$prefix" ss -tin dst 10.90.5.2 | head -n 8
sleep 2
done
) > "$run_dir/tcp-ss.txt" 2>&1 &
monitor_pid=$!
client_result=0
wait "$client_pid" || client_result=$?
client_pid=''
exit "$client_result"
+76
View File
@@ -1,5 +1,81 @@
"""Open an already configured serial session without a bridge reset pulse."""
import os
import re
import shutil
import subprocess
import sys
import time
class SerialCommandTimeout(TimeoutError):
def __init__(self, partial_reply):
super().__init__("Timed out waiting for this command's exact echo and completed terminal prompt")
self.partial_reply = partial_reply
def _command_echoes(command):
echoes = [command]
# Match the firmware's intentional password masking, not a wildcard echo.
# Masked UTF-8 values occupy one '*' for each input byte on the device.
for pattern in (r"^(\s*login[ \t]+)(.+)$", r"^(\s*set[ \t]+wifi\.pwd[ \t]+)(.+)$",
r"^(\s*set[ \t]+mt\.channel[ \t]+\S+[ \t]+\S+[ \t]+)(.+)$"):
match = re.match(pattern, command)
if match:
echoes.append(match[1] + "*" * len(match[2].encode("utf-8")))
break
return echoes
def read_command_reply(port, command, timeout=10):
"""Wait for this exact echoed command and its completion prompt, not silence.
A settings save can pause for over 350 ms after the immediate input echo.
Returning at that silence would attribute the delayed OK to the next command.
Stale responses/prompts before this command's echo cannot finish the read.
"""
if not command or any(value in command for value in "\r\n\0") or timeout <= 0:
raise ValueError("one nonempty command and a positive timeout are required")
echoed = re.compile(r"(?:^|\r?\n)(?:> ?)?(?:" + "|".join(
re.escape(value) for value in _command_echoes(command)) + r")\r?\n")
# The real terminal writes "> ". Requiring its trailing space avoids
# returning on a fragmented '>' and leaving that space to poison next echo.
prompt = re.compile(r"(?:^|\r?\n)>[ \t]+(?=$|\r?\n)")
deadline = time.monotonic() + timeout
data = bytearray()
while time.monotonic() < deadline:
chunk = port.read(min(port.in_waiting or 1, 4096))
if not chunk:
continue
data.extend(chunk)
if len(data) > 65536:
raise RuntimeError("CLI transaction output exceeded the bounded capture limit")
text = data.decode("utf-8", errors="replace")
match = echoed.search(text)
if match and prompt.search(text[match.end():]):
return text
raise SerialCommandTimeout(data.decode("utf-8", errors="replace"))
def assert_port_idle(path):
"""Optional Linux preflight: never open/reset a port owned by another tool.
fuser does not open the serial device. This is an extra immediate check,
not a substitute for an exclusive open or coordination with the flasher.
"""
if not sys.platform.startswith("linux"):
return
inspector = shutil.which("fuser")
if not inspector:
return
try:
result = subprocess.run([inspector, "--", os.path.realpath(path)],
capture_output=True, text=True, timeout=3)
except (OSError, subprocess.TimeoutExpired) as error:
raise RuntimeError("Cannot safely inspect serial-port ownership") from error
if result.returncode == 0:
raise RuntimeError("Selected serial port is already in use; finish the flasher/client first")
if result.returncode != 1:
raise RuntimeError("Cannot safely inspect serial-port ownership (fuser error)")
def open_configured_session(port):
+311
View File
@@ -0,0 +1,311 @@
#!/usr/bin/env python3
"""Identity-bound Windows USB <-> binary stdio, and a WSL pipe transport.
No commands, resets, listening sockets, drivers or USB assignments are changed.
Only the selected COM handle is opened. Child stdout is exclusively raw bytes;
stderr carries at most a ready record and a final, payload-free status record.
The Windows supervisor closes USB on stdin EOF or its own deadline, including
when its daemon stdout pump is blocked by a stopped Linux consumer.
"""
from __future__ import annotations
import argparse
import json
import math
import os
from pathlib import Path
import re
import select
import subprocess
import sys
import threading
import time
from urllib.parse import urlparse
from serial_session import open_configured_session
PROTOCOL = "meshcore-usb-stdio-1"
MAX_STATUS_BYTES = 4096
def validate_windows_endpoint(endpoint):
parsed = urlparse(endpoint)
if parsed.scheme != "winusb" or not re.fullmatch(r"COM[1-9][0-9]{0,3}", parsed.netloc, re.I) \
or parsed.path or parsed.query or parsed.fragment:
raise ValueError("use an explicit winusb://COM6 endpoint without other URL fields")
return parsed
def validate_settings(port, expected_serial, expected_vid, expected_pid, baud, serial_dtr, duration):
if not re.fullmatch(r"COM[1-9][0-9]{0,3}", port, re.I):
raise ValueError("invalid selected COM port")
if not isinstance(expected_serial, str) or not re.fullmatch(r"[A-Za-z0-9_.:+-]{1,128}", expected_serial):
raise ValueError("an exact nonempty USB serial is required")
if any(type(value) is not int or not 1 <= value <= 0xffff for value in (expected_vid, expected_pid)):
raise ValueError("exact USB VID and PID are required")
if type(baud) is not int or not 1200 <= baud <= 2000000 or type(serial_dtr) is not bool:
raise ValueError("invalid baud or explicit DTR selection")
if not isinstance(duration, (int, float)) or not math.isfinite(duration) or not 0 < duration <= 3600:
raise ValueError("a finite USB ownership deadline in (0,3600] is required")
def selected_identity(port, expected_serial, expected_vid, expected_pid, inventory):
matches = [item for item in inventory() if item.device.upper() == port.upper()]
if len(matches) != 1:
raise ValueError("selected COM identity is missing or ambiguous")
item = matches[0]
identity = dict(port=port.upper(), serial=item.serial_number, vid=item.vid, pid=item.pid)
if (identity["serial"], identity["vid"], identity["pid"]) != (expected_serial, expected_vid, expected_pid):
raise ValueError("selected COM USB identity does not match")
return identity
def open_selected_port(port, expected_serial, expected_vid, expected_pid, *, baud, serial_dtr,
inventory, serial_factory):
"""Verify twice; Windows CreateFile/pySerial opens the handle exclusively."""
before = selected_identity(port, expected_serial, expected_vid, expected_pid, inventory)
device = serial_factory()
try:
device.port, device.baudrate = port.upper(), baud
device.timeout, device.write_timeout = .05, .5
device.exclusive = True
device.xonxoff = device.rtscts = device.dsrdtr = False
device.dtr, device.rts = serial_dtr, False
open_configured_session(device)
after = selected_identity(port, expected_serial, expected_vid, expected_pid, inventory)
if before != after:
raise ValueError("selected USB identity changed during open")
return device, dict(after, baud=baud, serial_dtr=serial_dtr)
except BaseException:
device.close()
raise
def pump_stdio(device, input_fd, output_fd, *, duration, read=os.read, write=os.write,
now=time.monotonic):
"""Bounded USB ownership; daemon workers cannot hold COM past supervision."""
stopping = threading.Event()
counters = {"to_usb": 0, "from_usb": 0}
reason = ["deadline"]
def failed(code):
reason[0] = code
stopping.set()
def to_usb():
try:
while not stopping.is_set():
data = read(input_fd, 4096) # os.read returns short pipe data immediately.
if not data:
failed("stdin_eof")
return
offset = 0
until = now() + 1
while offset < len(data) and not stopping.is_set():
count = device.write(data[offset:])
if not isinstance(count, int) or not 0 <= count <= len(data) - offset:
raise IOError("invalid serial write length")
if count:
offset += count
counters["to_usb"] += count
elif now() >= until:
raise TimeoutError("serial write deadline")
else:
stopping.wait(.005)
except Exception:
failed("input_or_usb_write_error")
def from_usb():
try:
while not stopping.is_set():
# A short interactive SSH packet must not wait to fill 4096
# bytes (or pay one serial timeout on every round trip).
data = device.read(min(device.in_waiting or 1, 4096))
offset = 0
while offset < len(data) and not stopping.is_set():
count = write(output_fd, data[offset:])
if not isinstance(count, int) or not 0 < count <= len(data) - offset:
raise IOError("invalid stdout write length")
offset += count
counters["from_usb"] += count
except Exception:
failed("usb_read_or_output_error")
workers = [threading.Thread(target=target, daemon=True) for target in (to_usb, from_usb)]
start = now()
try:
for worker in workers:
worker.start()
while not stopping.wait(min(.05, max(0, duration - (now() - start)))):
if now() - start >= duration:
break
finally:
stopping.set()
# The supervisor never joins a potentially blocked stdout writer first.
for method in ("cancel_read", "cancel_write"):
try:
getattr(device, method)()
except Exception:
pass
device.close()
for worker in workers:
worker.join(.1)
return dict(counters, reason=reason[0])
def _ready_record(process, timeout=10):
captured = bytearray()
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
ready, _, _ = select.select([process.stderr.fileno()], [], [], min(.1, max(0, deadline - time.monotonic())))
if not ready:
if process.poll() is not None:
break
continue
chunk = os.read(process.stderr.fileno(), 512)
if not chunk:
break
captured.extend(chunk)
if len(captured) > MAX_STATUS_BYTES:
raise RuntimeError("Windows USB status exceeded bounded limit")
if b"\n" in captured:
try:
value = json.loads(captured.split(b"\n", 1)[0])
except (ValueError, UnicodeError) as error:
raise RuntimeError("Invalid Windows USB status") from error
if not isinstance(value, dict) or value.get("state") != "ready" or value.get("protocol") != PROTOCOL:
raise RuntimeError("Windows USB bridge did not become ready")
return value
raise TimeoutError("Windows USB bridge ready deadline")
class WindowsUsbStream:
"""Linux select-compatible separate FDs backed by one owned Windows COM."""
def __init__(self, endpoint, *, python, expected_serial, expected_vid, expected_pid,
baud=115200, serial_dtr=False, duration=300):
self.process = None
parsed = validate_windows_endpoint(endpoint)
port = parsed.netloc.upper()
validate_settings(port, expected_serial, expected_vid, expected_pid, baud, serial_dtr, duration)
executable = Path(python or "")
if not executable.is_absolute() or executable.name.lower() != "python.exe" or not executable.is_file():
raise ValueError("use an existing absolute WSL path to the trusted Windows python.exe")
if not sys.platform.startswith("linux"):
raise ValueError("WindowsUsbStream is a WSL Linux transport")
script = subprocess.run(["wslpath", "-w", str(Path(__file__).resolve())],
check=True, capture_output=True, text=True, timeout=5).stdout.strip()
if not re.fullmatch(r"[A-Za-z]:\\[^\r\n\0]+\.py", script):
raise ValueError("trusted bridge must reside on an accessible Windows drive")
command = [str(executable), "-u", script, "--port", port,
"--expect-serial", expected_serial, "--expect-vid", str(expected_vid),
"--expect-pid", str(expected_pid), "--baud", str(baud),
"--serial-dtr", "on" if serial_dtr else "off", "--duration", str(duration)]
try:
self.process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, bufsize=0, shell=False)
record = _ready_record(self.process)
expected = dict(port=port, serial=expected_serial, vid=expected_vid, pid=expected_pid,
baud=baud, serial_dtr=serial_dtr)
if record.get("identity") != expected:
raise ValueError("Windows bridge ready identity does not match")
self.identity = expected
self.read_fd, self.write_fd = self.process.stdout.fileno(), self.process.stdin.fileno()
self.fd = self.read_fd # Compatibility only; relays use separate read/write FDs.
os.set_blocking(self.read_fd, False)
os.set_blocking(self.write_fd, False)
except BaseException:
self.close()
raise
def read(self, count):
data = os.read(self.read_fd, count)
if not data:
raise ConnectionError("Windows USB bridge disconnected")
return data
def write(self, data):
return os.write(self.write_fd, data)
def close(self):
process, self.process = self.process, None
if process is None:
return
# EOF is an independent Windows-side stop signal, not a USB reset.
if process.stdin:
process.stdin.close()
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.terminate()
try:
process.wait(timeout=1)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=1)
finally:
for pipe in (process.stdout, process.stderr):
if pipe:
pipe.close()
def add_windows_arguments(parser):
parser.add_argument("--windows-python", help="absolute WSL path to trusted Windows python.exe for winusb")
parser.add_argument("--expect-vid", type=lambda value: int(value, 0))
parser.add_argument("--expect-pid", type=lambda value: int(value, 0))
def validate_windows_options(parsed, *, python, expected_serial, expected_vid, expected_pid,
baud, serial_dtr, duration):
if parsed.scheme == "winusb":
validate_settings(parsed.netloc.upper(), expected_serial, expected_vid, expected_pid,
baud, serial_dtr, duration)
if not python:
raise ValueError("winusb requires --windows-python and exact --expect-serial/vid/pid")
elif python is not None or expected_vid is not None or expected_pid is not None:
raise ValueError("Windows USB options require winusb://COM6")
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", required=True)
parser.add_argument("--expect-serial", required=True)
parser.add_argument("--expect-vid", type=lambda value: int(value, 0), required=True)
parser.add_argument("--expect-pid", type=lambda value: int(value, 0), required=True)
parser.add_argument("--baud", type=int, default=115200)
parser.add_argument("--serial-dtr", choices=("off", "on"), default="off")
parser.add_argument("--duration", type=float, required=True)
args = parser.parse_args(argv)
device = None
def status(value):
# Exactly two small records maximum. Never include payloads/exceptions.
sys.stderr.write(json.dumps(dict(protocol=PROTOCOL, **value), sort_keys=True) + "\n")
sys.stderr.flush()
try:
if os.name != "nt":
raise ValueError("Windows child only")
validate_settings(args.port, args.expect_serial, args.expect_vid, args.expect_pid,
args.baud, args.serial_dtr == "on", args.duration)
import msvcrt
import serial
from serial.tools.list_ports import comports
msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY)
msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY)
device, identity = open_selected_port(args.port, args.expect_serial, args.expect_vid, args.expect_pid,
baud=args.baud, serial_dtr=args.serial_dtr == "on", inventory=comports, serial_factory=serial.Serial)
status(dict(state="ready", identity=identity))
stats = pump_stdio(device, sys.stdin.fileno(), sys.stdout.fileno(), duration=args.duration)
device = None # pump owns close, even on EOF/deadline.
status(dict(state="stopped", stats=stats))
return 0
except Exception:
status(dict(state="error", error="USB bridge stopped; check exact identity and exclusive ownership"))
return 1
finally:
if device:
device.close()
if __name__ == "__main__":
raise SystemExit(main())