Files
MeshCore/tools/hil/meshcore_ip_relay.py
T

520 lines
24 KiB
Python

#!/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())