Files
MeshCore/test/test_ethernet_tap_relay.py
T

280 lines
15 KiB
Python

"""TAP relay byte/backpressure checks without privileged resources or sockets."""
from pathlib import Path
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/hil"))
import ethernet_tap_relay as relay
from ethernet_kiss_probe import kiss_encode, KissDecoder
class EthernetTapRelayTests(unittest.TestCase):
def test_windows_kiss_transport_keeps_raw_bytes_and_separate_fds(self):
shared = Mock(read_fd=77, write_fd=78)
payload = b"\xc0\x10\0\xdb\xdc\xff\xc0"
shared.read.return_value = payload
shared.write.return_value = len(payload)
with patch.object(relay, "WindowsUsbStream", return_value=shared) as opened:
stream = relay.KissStream("winusb://COM6", 115200, windows_python="/mnt/c/python.exe",
expect_serial="EXACT", expect_vid=0x303a, expect_pid=0x1001,
serial_dtr=True, duration=300)
self.assertEqual((stream.read_fd, stream.write_fd), (77, 78))
self.assertEqual(stream.read(99), payload)
self.assertEqual(stream.write(payload), len(payload))
stream.close()
opened.assert_called_once_with("winusb://COM6", python="/mnt/c/python.exe", expected_serial="EXACT",
expected_vid=0x303a, expected_pid=0x1001, baud=115200, serial_dtr=True, duration=300)
shared.write.assert_called_once_with(payload)
shared.close.assert_called_once()
def test_kiss_relay_uses_separate_pipe_fds_without_changing_modem_pacing(self):
stream = Mock(read_fd=77, write_fd=78)
stream.write.side_effect = len
selections = []
def selected(reads, writes, errors, timeout):
selections.append((reads, writes))
return [], [78], []
with patch.object(relay.select, "select", side_effect=selected):
stats = relay.relay(stream, 88, duration=1, kiss_modem=True, kiss_port=1,
stop=lambda: len(selections) >= 3)
self.assertEqual(selections, [([88, 77], [78])] * 3)
self.assertEqual(stats["modem_controls_written"], 3)
self.assertEqual(stats["kiss_written"], 0)
def test_selected_port_preserves_bytes_and_ignores_other_data_and_full_modem_metadata(self):
frame = bytes(range(255))
metadata = b"\xc0\x06\xf9\x00\x01\xc0\x06\xf8\x01\xc0"
for port in (0, 1):
buffers = relay.RelayBuffers(kiss_port=port)
buffers.queue_tap_frame(frame)
self.assertEqual(buffers.to_kiss[0][1], port << 4)
self.assertEqual(KissDecoder(port=port).feed(buffers.to_kiss[0]), [frame])
buffers.feed_kiss(metadata + kiss_encode(b"other" * 12, port=1 - port) + kiss_encode(frame, port=port))
self.assertEqual(list(buffers.to_tap), [frame])
self.assertEqual(buffers.stats()["decode_errors"], 0)
for invalid in (True, -1, 2, 1.0):
with self.assertRaises(ValueError): relay.RelayBuffers(kiss_port=invalid)
with self.assertRaises(ValueError): kiss_encode(frame, port=invalid)
def test_bench_endpoint_allowlist_is_explicit_and_does_not_access_devices(self):
for value in ("serial:///dev/serial/by-id/usb-RAK_4631_ABC-if00", "serial:///dev/serial/by-path/platform-usb-0:1.2:1.0",
"serial:///dev/ttyACM0", "serial:///dev/ttyUSB0", "tcp://192.168.1.84:5003"):
self.assertIn(relay.validate_endpoint(value, strict_tcp=True).scheme, ("serial", "tcp"))
for value in ("serial:///dev/mem", "serial:///etc/passwd", "serial://host/dev/ttyACM0",
"serial:///dev/serial/by-id/../by-path/device", "serial:///dev/ttyACM0?baud=1",
"serial:///dev/serial/by-id/%2fetc", "tcp://radio:5003", "tcp://192.168.1.84:5002",
"tcp://user:secret@192.168.1.84:5003", "tcp://192.168.1.84:5003/path"):
with self.assertRaises(ValueError): relay.validate_endpoint(value, strict_tcp=True)
with patch.object(relay.os.path, "realpath", return_value="/dev/mem"), \
patch.object(relay, "open_configured_session") as opened:
with self.assertRaises(ValueError):
relay.KissStream("serial:///dev/serial/by-id/explicit-device", 115200)
opened.assert_not_called()
def test_explicit_full_modem_initialization_then_one_data_until_successful_tx_done(self):
clock = [100.0]
buffers = relay.RelayBuffers(kiss_port=1, kiss_modem=True, now=lambda: clock[0])
frames = [b"a" * 60, b"b" * 60]
for frame in frames: buffers.queue_tap_frame(frame)
written = []
while buffers.controls:
buffers.write_kiss(lambda data: written.append(data) or len(data))
self.assertEqual(written, [b"\xc0\x01\0\xc0", b"\xc0\x02\xff\xc0", b"\xc0\x03\x01\xc0"])
buffers.write_kiss(lambda data: written.append(data) or len(data))
self.assertEqual(written[-1], kiss_encode(frames[0], port=1))
self.assertFalse(buffers.can_write_kiss())
writer = Mock(); buffers.write_kiss(writer); writer.assert_not_called()
buffers.feed_kiss(b"\xc0\x06\xf9\x00\x01\xc0")
self.assertEqual(buffers.stats()["modem_rx_meta"], 1)
self.assertFalse(buffers.can_write_kiss())
buffers.feed_kiss(b"\xc0\x16\xf8\x01\xc0") # Wrong control port cannot release it.
self.assertFalse(buffers.can_write_kiss())
buffers.feed_kiss(b"\xc0\x06\xf8\x01\xc0")
self.assertTrue(buffers.can_write_kiss())
self.assertEqual(buffers.stats()["modem_tx_done"], 1)
buffers.write_kiss(lambda data: written.append(data) or len(data))
self.assertEqual(written[-1], kiss_encode(frames[1], port=1))
clock[0] += 10
with self.assertRaises(relay.ModemFlowError) as failure: buffers.check_tx_done()
self.assertEqual(failure.exception.stats["modem_tx_timeout"], 1)
def test_full_modem_errors_fail_closed_no_next_data_or_automatic_retry(self):
for control in (b"\xc0\x06\xf8\x02\xc0", b"\xc0\x06\xf1\x07\xc0", b"\xc0\x06\xf8\xc0"):
buffers = relay.RelayBuffers(kiss_modem=True)
buffers.queue_tap_frame(b"a" * 60); buffers.queue_tap_frame(b"b" * 60)
while buffers.controls: buffers.write_kiss(len)
buffers.write_kiss(len)
with self.assertRaises(relay.ModemFlowError) as failure: buffers.feed_kiss(control)
self.assertEqual(failure.exception.stats["modem_tx_error"], 1)
self.assertEqual(len(buffers.to_kiss), 1)
self.assertFalse(buffers.can_write_kiss())
def test_rejected_tx_done_drops_one_frame_then_allows_next_without_replay(self):
buffers = relay.RelayBuffers(kiss_port=1, kiss_modem=True)
frames = [b"first" * 12, b"other" * 12]
for frame in frames: buffers.queue_tap_frame(frame)
written = []
while buffers.controls: buffers.write_kiss(len)
buffers.write_kiss(lambda data: written.append(data) or len(data))
self.assertFalse(buffers.can_write_kiss())
buffers.feed_kiss(b"\xc0\x06\xf8\x00\xc0")
self.assertTrue(buffers.can_write_kiss())
self.assertEqual(buffers.stats()["modem_tx_error"], 1)
self.assertEqual(buffers.stats()["modem_tx_done"], 0)
buffers.write_kiss(lambda data: written.append(data) or len(data))
self.assertEqual(written, [kiss_encode(frame, port=1) for frame in frames])
self.assertEqual(list(buffers.to_kiss), [])
self.assertEqual(buffers.stats()["kiss_written"], 2)
self.assertTrue(buffers.stats()["awaiting_tx_done"])
buffers.feed_kiss(b"\xc0\x06\xf8\x01\xc0")
self.assertEqual(buffers.stats()["modem_tx_done"], 1)
self.assertFalse(buffers.stats()["awaiting_tx_done"])
def test_unsolicited_tx_done_does_not_create_a_credit_or_change_default_tcp_flow(self):
buffers = relay.RelayBuffers(kiss_modem=True)
buffers.feed_kiss(b"\xc0\x06\xf8\x01\xc0")
self.assertEqual(buffers.stats()["modem_unexpected_tx_done"], 1)
default = relay.RelayBuffers()
self.assertFalse(default.controls)
default.queue_tap_frame(b"a" * 60); default.queue_tap_frame(b"b" * 60)
default.write_kiss(len); default.write_kiss(len)
self.assertEqual(default.stats()["kiss_written"], 2)
self.assertFalse(default.stats()["awaiting_tx_done"])
def test_phone_fixture_keeps_default_tcp_and_explicitly_forwards_serial_modem_options(self):
source = (Path(relay.__file__).parent / "ethernet_ssh_phone_bench.sh").read_text()
self.assertIn("kiss_port=0; baud=115200; serial_dtr=off; kiss_modem=off", source)
self.assertIn('validate_endpoint(sys.argv[1], strict_tcp=True)', source)
self.assertIn('--baud "$baud" --kiss-port "$kiss_port" --serial-dtr "$serial_dtr"', source)
self.assertIn('modem_args+=(--kiss-modem)', source)
self.assertIn('Server helper PID: %s', source)
self.assertIn('"$observed" == "$inode"', source)
def test_tcp_transport_defaults_to_dedicated_kiss_port(self):
with patch.object(relay.socket, "create_connection") as connect:
endpoint = relay.KissStream("tcp://bench-radio", 115200)
connect.assert_called_once_with(("bench-radio", 5003), timeout=5)
endpoint.close()
def test_current_host_namespace_is_rejected_before_setns(self):
identity = SimpleNamespace(st_dev=5, st_ino=700)
with patch.object(relay.os, "open", return_value=88), \
patch.object(relay.os, "O_CLOEXEC", 0, create=True), \
patch.object(relay.os, "fstat", return_value=identity), \
patch.object(relay.os, "stat", return_value=identity), \
patch.object(relay.os, "close") as close, \
patch.object(relay.os, "setns", create=True) as setns:
with self.assertRaisesRegex(ValueError, "current host"):
relay.enter_network_namespace("/proc/self/ns/net")
setns.assert_not_called()
close.assert_called_once_with(88)
def test_distinct_namespace_is_entered_and_handle_closed(self):
with patch.object(relay.os, "open", return_value=88), \
patch.object(relay.os, "O_CLOEXEC", 0, create=True), \
patch.object(relay.os, "fstat", return_value=SimpleNamespace(st_dev=5, st_ino=701)), \
patch.object(relay.os, "stat", return_value=SimpleNamespace(st_dev=5, st_ino=700)), \
patch.object(relay.os, "close") as close, \
patch.object(relay.os, "setns", create=True) as setns:
relay.enter_network_namespace("/var/run/netns/bench-a")
setns.assert_called_once_with(88, 0x40000000)
close.assert_called_once_with(88)
def test_kiss_is_opened_before_namespace_or_tap(self):
calls = []
stream = Mock()
with patch.object(relay, "KissStream", side_effect=lambda *args, **kwargs: calls.append("socket") or stream), \
patch.object(relay, "enter_network_namespace", side_effect=lambda path: calls.append("netns")), \
patch.object(relay, "create_tap", side_effect=lambda name: calls.append("tap") or (77, name)):
self.assertEqual(relay.open_transport_then_tap("tcp://host:5003", 115200, "/ns", "lora0"),
(stream, 77, "lora0"))
self.assertEqual(calls, ["socket", "netns", "tap"])
stream.close.assert_not_called()
def test_failed_namespace_or_tap_closes_kiss(self):
for stage in ("enter_network_namespace", "create_tap"):
stream = Mock()
with patch.object(relay, "KissStream", return_value=stream), \
patch.object(relay, "enter_network_namespace"), \
patch.object(relay, "create_tap", return_value=(77, "lora0")), \
patch.object(relay, stage, side_effect=OSError("denied")):
with self.assertRaises(OSError):
relay.open_transport_then_tap("tcp://host:5003", 115200, "/ns", "lora0")
stream.close.assert_called_once()
def test_partial_stream_writes_preserve_exact_frame(self):
buffers = relay.RelayBuffers(1)
frame = bytes(range(255))
self.assertTrue(buffers.queue_tap_frame(frame))
self.assertFalse(buffers.can_read_tap())
with self.assertRaises(BufferError):
buffers.queue_tap_frame(frame)
written = bytearray()
def write(data):
written.extend(data[:3])
return min(3, len(data))
buffers.write_kiss(lambda data: 0)
while buffers.to_kiss:
buffers.write_kiss(write)
self.assertTrue(buffers.can_read_tap())
self.assertEqual(bytes(written), kiss_encode(frame))
self.assertEqual(KissDecoder().feed(bytes(written)), [frame])
self.assertEqual(buffers.stats()["kiss_written"], 1)
def test_incoming_staging_applies_backpressure_without_losing_frames(self):
buffers = relay.RelayBuffers(1)
frames = [bytes((index,)) * 255 for index in range(3)]
buffers.feed_kiss(b"".join(kiss_encode(frame) for frame in frames))
self.assertEqual(len(buffers.to_tap), 1)
self.assertTrue(buffers.staging)
self.assertFalse(buffers.can_read_kiss())
with self.assertRaises(BufferError):
buffers.feed_kiss(b"more bytes")
written = []
for frame in frames:
buffers.write_tap(lambda data: written.append(data) or len(data))
buffers.parse_pending()
self.assertEqual(written, frames)
self.assertTrue(buffers.can_read_kiss())
self.assertEqual(buffers.stats()["kiss_read"], 3)
self.assertEqual(buffers.stats()["tap_written"], 3)
def test_partial_tap_write_is_not_retried_as_a_new_frame(self):
buffers = relay.RelayBuffers()
buffers.feed_kiss(kiss_encode(b"x" * 60))
with self.assertRaisesRegex(IOError, "partial TAP"):
buffers.write_tap(lambda frame: 20)
self.assertEqual(buffers.stats()["tap_written"], 0)
self.assertEqual(len(buffers.to_tap), 1)
def test_invalid_frame_sizes_are_dropped_not_truncated(self):
buffers = relay.RelayBuffers()
for size in (0, 13, 256, 1500):
self.assertFalse(buffers.queue_tap_frame(b"x" * size))
self.assertEqual(len(buffers.to_kiss), 0)
buffers.feed_kiss(kiss_encode(b"x" * 13))
self.assertEqual(len(buffers.to_tap), 0)
self.assertEqual(buffers.stats()["bad_frame_length"], 5)
for size in (14, 255):
self.assertTrue(buffers.queue_tap_frame(b"x" * size))
def test_bad_stream_cannot_grow_unbounded(self):
buffers = relay.RelayBuffers()
with self.assertRaises(BufferError):
buffers.feed_kiss(b"x" * 4097)
buffers.feed_kiss(b"\xc0\0" + b"x" * 3500)
self.assertEqual(buffers.staging, b"")
self.assertLessEqual(len(buffers.decoder.buffer), 257)
self.assertEqual(buffers.stats()["decode_errors"], 1)
if __name__ == "__main__":
unittest.main()