429 lines
19 KiB
Python
429 lines
19 KiB
Python
#!/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())
|