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