379 lines
19 KiB
Python
379 lines
19 KiB
Python
"""Host-only RAW point IPv4 framing/MTU/state tests; no sockets, USB or TUN."""
|
|
import io
|
|
from pathlib import Path
|
|
import struct
|
|
import sys
|
|
from types import SimpleNamespace
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools" / "hil"))
|
|
import meshcore_ip_relay as ip
|
|
|
|
|
|
def packet(size=40, reverse=False, flags=0x4000, protocol=6, header=20):
|
|
result = bytearray(size)
|
|
result[0] = 0x40 | (header // 4)
|
|
result[8], result[9] = 64, protocol
|
|
result[12:16] = bytes((10, 90, 5, 2 if reverse else 1))
|
|
result[16:20] = bytes((10, 90, 5, 1 if reverse else 2))
|
|
result[header + 12] = 0x50
|
|
struct.pack_into("!H", result, 2, size)
|
|
struct.pack_into("!H", result, 6, flags)
|
|
checksum = sum(struct.unpack("!" + "H" * (header // 2), result[:header]))
|
|
while checksum >> 16:
|
|
checksum = (checksum & 0xffff) + (checksum >> 16)
|
|
struct.pack_into("!H", result, 10, ~checksum & 0xffff)
|
|
return bytes(result)
|
|
|
|
|
|
def codec(path=None, back=0, **kwargs):
|
|
return ip.IpCodec("10.90.5.1", "10.90.5.2", path or ip.MeshPath(), back,
|
|
session=0x11223344, **kwargs)
|
|
|
|
|
|
def response(frame):
|
|
return b">" + struct.pack("<H", len(frame)) + frame
|
|
|
|
|
|
def incoming(size=40):
|
|
peer = ip.IpCodec("10.90.5.2", "10.90.5.1", ip.MeshPath(), 0, session=7)
|
|
return bytes((0x84, 0, 0, 255)) + peer.command(packet(size, reverse=True))[2:]
|
|
|
|
|
|
def started_buffers(**kwargs):
|
|
buffers = ip.IpRelayBuffers(codec(), **kwargs)
|
|
buffers.write_companion(lambda data: len(data))
|
|
buffers.feed_companion(response(bytes((5, 0))))
|
|
return buffers
|
|
|
|
|
|
class MeshCoreIpTests(unittest.TestCase):
|
|
def test_optional_ready_callback_follows_real_app_start_reply_once(self):
|
|
buffers = ip.IpRelayBuffers(codec())
|
|
events = []
|
|
iteration = [0]
|
|
stream = mock.Mock(read_fd=77, write_fd=77)
|
|
stream.write.side_effect = lambda data: events.append("app-start-written") or len(data)
|
|
stream.read.side_effect = lambda count: events.append("self-info-received") or response(bytes((5, 0)))
|
|
|
|
def ready(reads, writes, errors, timeout):
|
|
iteration[0] += 1
|
|
if iteration[0] == 1: return [], [77], []
|
|
if iteration[0] == 2: return [77], [], []
|
|
return [], [], []
|
|
|
|
with mock.patch.object(ip.select, "select", side_effect=ready):
|
|
stats = ip.relay(stream, 88, buffers, duration=1,
|
|
stop=lambda: iteration[0] >= 4, on_ready=lambda: events.append("ready"))
|
|
self.assertEqual(events, ["app-start-written", "self-info-received", "ready"])
|
|
self.assertTrue(stats["ready"])
|
|
|
|
def test_path_width_count_and_no_flood(self):
|
|
self.assertEqual(ip.MeshPath.from_hex("aabbccdd", 2).encoded, 0x42)
|
|
self.assertEqual(ip.MeshPath().encoded, 0)
|
|
for data, width in ((bytes(64), 1), (bytes(65), 2), (bytes(3), 2), (b"", 4), (b"", 0)):
|
|
with self.subTest(width=width, length=len(data)), self.assertRaises(ValueError):
|
|
ip.MeshPath(data, width)
|
|
self.assertEqual(ip.MeshPath(bytes(64), 2).encoded, 0x60)
|
|
with self.assertRaises(ValueError):
|
|
ip.MeshPath.from_hex("f")
|
|
|
|
def test_mtu_derived_for_both_paths_and_host_frame_cap(self):
|
|
self.assertEqual(ip.mtu_budget(0, 0), (156, 156))
|
|
self.assertEqual(ip.mtu_budget(0, 64), (94, 94))
|
|
self.assertEqual(ip.mtu_budget(64, 0), (94, 94))
|
|
self.assertEqual(ip.mtu_budget(4, 2), (154, 154))
|
|
self.assertEqual(ip.mtu_budget(0, 0, requested=230), (156, 156))
|
|
self.assertEqual(ip.mtu_budget(0, 0, requested=128), (156, 128))
|
|
self.assertEqual(ip.mtu_budget(64, 64, transport_bytes=4), (94, 94))
|
|
for values in ((65, 0, None), (0, -1, None), (0, 0, 93)):
|
|
with self.assertRaises(ValueError):
|
|
ip.mtu_budget(*values)
|
|
|
|
def test_command_wire_golden_matches_dart_and_nonces_change(self):
|
|
link = codec(ip.MeshPath.from_hex("aabb", 2))
|
|
first, second = link.command(packet()), link.command(packet())
|
|
self.assertEqual(first[:16].hex(), "1941aabb535348014433221100000000")
|
|
self.assertEqual(second[12:16], b"\x01\0\0\0")
|
|
self.assertNotEqual(first, second)
|
|
self.assertEqual(first[16:], packet())
|
|
self.assertEqual(ip.companion_frame(first)[:3], b"<\x38\0")
|
|
|
|
def test_sequence_exhaustion_never_wraps(self):
|
|
link = codec(sequence=0xffffffff)
|
|
self.assertEqual(link.command(packet())[10:14], b"\xff" * 4)
|
|
with self.assertRaises(OverflowError):
|
|
link.command(packet())
|
|
|
|
def test_ipv4_limits_direction_checksum_fragments_protocol_and_padding(self):
|
|
link = codec()
|
|
self.assertEqual(len(link.command(packet(156))), 170)
|
|
for value in (packet(157), packet(reverse=True), packet(flags=0x2000), packet(flags=1),
|
|
packet(flags=0x8000), packet(protocol=17), packet() + b"\0", packet()[:10]):
|
|
with self.subTest(value=value[:10]), self.assertRaises(ValueError):
|
|
link.command(value)
|
|
damaged = bytearray(packet())
|
|
damaged[8] ^= 1
|
|
with self.assertRaises(ValueError):
|
|
link.command(damaged)
|
|
self.assertTrue(ip.valid_ipv4(packet(header=24, size=44), link.local, link.remote, 156))
|
|
|
|
def test_raw_push_only_valid_peer_packet_not_text_or_other_apps(self):
|
|
link = codec()
|
|
self.assertEqual(link.raw_push(incoming()), packet(reverse=True))
|
|
for frame in (incoming()[:35], b"\x08" + incoming()[1:], incoming()[:4] + b"OTHER" + incoming()[9:],
|
|
incoming() + b"\0", bytes((0x84, 0, 0, 255)) + link.command(packet())[2:]):
|
|
self.assertIsNone(link.raw_push(frame))
|
|
|
|
def test_stream_every_split_boundary_and_wrong_direction_length(self):
|
|
wire = response(incoming())
|
|
for split in range(len(wire) + 1):
|
|
decoder = ip.CompanionDecoder()
|
|
decoder.feed(wire[:split])
|
|
first = decoder.pop()
|
|
decoder.feed(wire[split:])
|
|
self.assertEqual(first if first is not None else decoder.pop(), incoming())
|
|
self.assertIsNone(decoder.pop())
|
|
for wire in (b"<\x01\0x", b">\0\0", b">\xb1\0"):
|
|
decoder = ip.CompanionDecoder()
|
|
decoder.feed(wire)
|
|
with self.assertRaises(ValueError):
|
|
decoder.pop()
|
|
with self.assertRaises(BufferError):
|
|
ip.CompanionDecoder().feed(bytes(4097))
|
|
|
|
def test_actual176byte_rx_limit_does_not_increase_command_or_ip_budget(self):
|
|
buffers = started_buffers()
|
|
for size in (173, 176):
|
|
frame = bytes((0x88,)) + bytes(size - 1)
|
|
buffers.feed_companion(response(frame))
|
|
self.assertEqual(buffers.counters["ignored_frames"], 2)
|
|
self.assertTrue(buffers.can_read_tun())
|
|
self.assertEqual(codec().mtu, 156)
|
|
with self.assertRaises(ValueError): ip.companion_frame(bytes(173))
|
|
decoder = ip.CompanionDecoder()
|
|
decoder.feed(response(bytes(177)))
|
|
with self.assertRaises(ValueError): decoder.pop()
|
|
|
|
def test_serial_printable_banner_is_bounded_and_only_before_binary(self):
|
|
decoder = ip.CompanionDecoder(ascii_preamble=True)
|
|
banner = b"WELCOME HIL\r\n(+++MESHCORE-TERM-STOP returns to Binary mode)\r\n> "
|
|
wire = banner + response(bytes((5, 0)))
|
|
for byte in wire:
|
|
decoder.feed(bytes((byte,)))
|
|
result = decoder.pop()
|
|
self.assertEqual(result, bytes((5, 0)))
|
|
self.assertEqual(decoder.preamble_bytes, len(banner))
|
|
decoder.feed(b"bad")
|
|
with self.assertRaises(ValueError): decoder.pop()
|
|
for wire, exception in ((b"bad\x00", ValueError), (b"a" * 4096, None)):
|
|
decoder = ip.CompanionDecoder(ascii_preamble=True)
|
|
decoder.feed(wire)
|
|
if exception:
|
|
with self.assertRaises(exception): decoder.pop()
|
|
else:
|
|
decoder.pop()
|
|
decoder.feed(b"x")
|
|
with self.assertRaises(BufferError): decoder.pop()
|
|
|
|
def test_startup_and_one_acknowledged_command_at_a_time(self):
|
|
buffers = ip.IpRelayBuffers(codec())
|
|
self.assertFalse(buffers.can_read_tun())
|
|
self.assertEqual(buffers.output[:4], b"<\x13\0\x01")
|
|
buffers.write_companion(lambda data: len(data))
|
|
buffers.feed_companion(response(bytes((5, 0))))
|
|
self.assertTrue(buffers.can_read_tun())
|
|
self.assertTrue(buffers.queue_tun(packet()))
|
|
with self.assertRaises(BufferError):
|
|
buffers.queue_tun(packet())
|
|
buffers.write_companion(lambda data: 2)
|
|
self.assertTrue(buffers.output)
|
|
buffers.write_companion(lambda data: len(data))
|
|
buffers.feed_companion(response(b"\0"))
|
|
self.assertTrue(buffers.can_read_tun())
|
|
self.assertEqual(buffers.stats()["accepted_commands"], 1)
|
|
self.assertIn("not RF-delivered", buffers.stats()["acceptance_note"])
|
|
|
|
def test_partial_stream_continues_reading_and_receive_queue_backpressures(self):
|
|
buffers = started_buffers(depth=1)
|
|
wire = response(incoming())
|
|
buffers.feed_companion(wire[:1])
|
|
self.assertTrue(buffers.can_read_companion())
|
|
buffers.feed_companion(wire[1:] + wire)
|
|
self.assertEqual(len(buffers.to_tun), 1)
|
|
self.assertFalse(buffers.can_read_companion())
|
|
buffers.write_tun(lambda data: len(data))
|
|
buffers.parse_pending()
|
|
self.assertEqual(len(buffers.to_tun), 1)
|
|
self.assertFalse(buffers.decoder.staging)
|
|
with self.assertRaises(IOError):
|
|
buffers.write_tun(lambda data: 1)
|
|
|
|
def test_error_acceptance_invalid_packet_and_deadline_are_honest(self):
|
|
now = [0.]
|
|
buffers = started_buffers(now=lambda: now[0])
|
|
self.assertFalse(buffers.queue_tun(b"bad IPv4"))
|
|
buffers.queue_tun(packet())
|
|
buffers.write_companion(lambda data: len(data))
|
|
buffers.feed_companion(response(b"\x01\x03"))
|
|
self.assertEqual(buffers.stats()["rejected_commands"], 1)
|
|
buffers.queue_tun(packet())
|
|
now[0] = 12
|
|
with self.assertRaises(TimeoutError):
|
|
buffers.check_deadline()
|
|
|
|
def test_socket_before_namespace_before_new_tun_only(self):
|
|
events = []
|
|
stream = mock.Mock()
|
|
with mock.patch.object(ip, "CompanionStream", side_effect=lambda endpoint, **kwargs: events.append("socket") or stream), \
|
|
mock.patch.object(ip, "enter_network_namespace", side_effect=lambda path: events.append("namespace")), \
|
|
mock.patch.object(ip, "create_tun", side_effect=lambda name: events.append("tun") or (99, "mcip0")), \
|
|
mock.patch.object(ip.subprocess, "run") as run:
|
|
self.assertEqual(ip.open_transport_then_tun("tcp://192.168.1.84:5000", "/var/run/netns/test", "mcip0",
|
|
"10.90.5.1", "10.90.5.2", 156), (stream, 99, "mcip0"))
|
|
self.assertEqual(events, ["socket", "namespace", "tun"])
|
|
self.assertEqual(run.call_args_list[1].args[0], ["ip", "address", "add", "10.90.5.1/32", "peer", "10.90.5.2/32", "dev", "mcip0"])
|
|
stream.close.assert_not_called()
|
|
|
|
def test_namespace_rejection_closes_own_socket_without_touching_tun(self):
|
|
stream = mock.Mock()
|
|
with mock.patch.object(ip, "CompanionStream", return_value=stream), \
|
|
mock.patch.object(ip, "enter_network_namespace", side_effect=ValueError("same host")), \
|
|
mock.patch.object(ip, "create_tun") as create:
|
|
with self.assertRaises(ValueError):
|
|
ip.open_transport_then_tun("endpoint", "host", "mcip0", "10.90.5.1", "10.90.5.2", 156)
|
|
create.assert_not_called()
|
|
stream.close.assert_called_once()
|
|
|
|
|
|
class CompanionStreamTests(unittest.TestCase):
|
|
endpoint = "serial:///dev/serial/by-id/usb-RAK_USB_TEST_SERIAL-if00"
|
|
identity = ("/dev/ttyACM3", "USB_TEST_SERIAL", 11, 22, 33)
|
|
|
|
def test_windows_stream_shared_binary_transport_and_explicit_identity(self):
|
|
shared = mock.Mock(read_fd=77, write_fd=78)
|
|
payload = b"\0\xc0\xdb\xff"
|
|
shared.read.return_value = payload
|
|
shared.write.return_value = len(payload)
|
|
with mock.patch.object(ip, "WindowsUsbStream", return_value=shared) as opened:
|
|
stream = ip.CompanionStream("winusb://COM6", 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()
|
|
with self.assertRaises(ValueError): ip.CompanionStream("winusb://COM6")
|
|
with self.assertRaises(ValueError):
|
|
ip.CompanionStream("tcp://192.168.1.87:5000", windows_python="/mnt/c/python.exe")
|
|
|
|
def test_relay_selects_separate_pipe_read_and_write_descriptors(self):
|
|
stream = mock.Mock(read_fd=77, write_fd=78)
|
|
stream.write.side_effect = len
|
|
stream.read.return_value = response(bytes((5, 0)))
|
|
selections = []
|
|
|
|
def selected(reads, writes, errors, timeout):
|
|
selections.append((reads, writes))
|
|
return ([], [78], []) if len(selections) == 1 else ([77], [], [])
|
|
|
|
with mock.patch.object(ip.select, "select", side_effect=selected):
|
|
stats = ip.relay(stream, 88, ip.IpRelayBuffers(codec()), duration=1, stop=lambda: len(selections) >= 2)
|
|
self.assertEqual(selections, [([77], [78]), ([77], [])])
|
|
self.assertTrue(stats["ready"])
|
|
|
|
def test_tcp_defaults_unchanged_and_no_serial_open(self):
|
|
sock = mock.Mock()
|
|
sock.recv.return_value = b">\x01\0\0"
|
|
sock.send.return_value = 2
|
|
with mock.patch.object(ip.socket, "create_connection", return_value=sock) as connect, \
|
|
mock.patch.object(ip, "serial_identity") as identity:
|
|
stream = ip.CompanionStream("tcp://192.168.1.87:5000")
|
|
self.assertEqual(stream.read(10), b">\x01\0\0")
|
|
self.assertEqual(stream.write(b"<\x01\0\x01"), 2)
|
|
stream.close()
|
|
connect.assert_called_once_with(("192.168.1.87", 5000), timeout=5)
|
|
identity.assert_not_called()
|
|
sock.setblocking.assert_called_once_with(False)
|
|
sock.close.assert_called_once()
|
|
|
|
def test_unstable_alias_wrong_protocol_and_credentials_rejected_without_open(self):
|
|
for endpoint in ("serial:///dev/ttyACM3", "serial:///dev/serial/by-path/port0",
|
|
"serial:///dev/serial/by-id/../../ttyACM3", "serial:///tmp/radio",
|
|
"tcp://192.168.1.87:5003", "tcp://user:secret@192.168.1.87:5000"):
|
|
with self.subTest(endpoint=endpoint), self.assertRaises(ValueError):
|
|
ip.validate_companion_endpoint(endpoint)
|
|
with self.assertRaises(ValueError):
|
|
ip.validate_companion_endpoint("tcp://192.168.1.87:5000", expect_serial="9AB3B64C641BA927")
|
|
|
|
def test_busy_serial_never_opens_or_changes_lines(self):
|
|
with mock.patch.object(ip, "serial_identity", return_value=self.identity), \
|
|
mock.patch.object(ip, "assert_port_idle", side_effect=RuntimeError("busy")), \
|
|
mock.patch.object(ip, "open_configured_session") as opened:
|
|
with self.assertRaisesRegex(RuntimeError, "busy"):
|
|
ip.CompanionStream(self.endpoint)
|
|
opened.assert_not_called()
|
|
|
|
def test_exact_identity_exclusive_safe_dtr_selection_read_write_and_close(self):
|
|
for selected in (False, True):
|
|
port = mock.Mock()
|
|
port.fileno.return_value = 77
|
|
fake_serial = SimpleNamespace(Serial=mock.Mock(return_value=port))
|
|
info = SimpleNamespace(st_mode=0o020600, st_dev=11, st_ino=22, st_rdev=33)
|
|
with mock.patch.dict(sys.modules, serial=fake_serial), \
|
|
mock.patch.object(ip, "serial_identity", return_value=self.identity) as identity, \
|
|
mock.patch.object(ip, "assert_port_idle") as idle, \
|
|
mock.patch.object(ip, "open_configured_session") as opened, \
|
|
mock.patch.object(ip.os, "fstat", return_value=info), \
|
|
mock.patch.object(ip.os, "read", return_value=b">\x02\0\x05\0") as read, \
|
|
mock.patch.object(ip.os, "write", return_value=2) as write:
|
|
stream = ip.CompanionStream(self.endpoint, baud=115200, serial_dtr=selected,
|
|
expect_serial="9AB3B64C641BA927")
|
|
self.assertEqual(stream.read(4096), b">\x02\0\x05\0")
|
|
self.assertEqual(stream.write(b"<\x01\0\x01"), 2)
|
|
stream.close()
|
|
self.assertEqual(identity.call_count, 2)
|
|
idle.assert_called_once_with("/dev/ttyACM3")
|
|
opened.assert_called_once_with(port)
|
|
self.assertEqual(port.port, "/dev/ttyACM3")
|
|
self.assertTrue(port.exclusive)
|
|
self.assertIs(port.dtr, selected)
|
|
self.assertIs(port.rts, False)
|
|
self.assertEqual((port.timeout, port.write_timeout), (0, 0))
|
|
read.assert_called_once_with(77, 4096)
|
|
write.assert_called_once_with(77, b"<\x01\0\x01")
|
|
port.close.assert_called_once()
|
|
|
|
def test_alias_change_after_open_closes_owned_port(self):
|
|
port = mock.Mock()
|
|
fake_serial = SimpleNamespace(Serial=mock.Mock(return_value=port))
|
|
changed = ("/dev/ttyACM4", *self.identity[1:])
|
|
with mock.patch.dict(sys.modules, serial=fake_serial), \
|
|
mock.patch.object(ip, "serial_identity", side_effect=[self.identity, changed]), \
|
|
mock.patch.object(ip, "assert_port_idle"), mock.patch.object(ip, "open_configured_session"), \
|
|
mock.patch.object(ip.os, "fstat"):
|
|
with self.assertRaisesRegex(ValueError, "identity changed"):
|
|
ip.CompanionStream(self.endpoint)
|
|
port.close.assert_called_once()
|
|
|
|
def test_wrong_usb_serial_and_alias_target_fail_closed(self):
|
|
with mock.patch.object(ip.Path, "resolve", return_value=Path("/tmp/not-a-radio")):
|
|
with self.assertRaisesRegex(ValueError, "ttyACM/ttyUSB"):
|
|
ip.serial_identity("/dev/serial/by-id/radio", "EXPECTED")
|
|
info = SimpleNamespace(st_mode=0o020600, st_dev=11, st_ino=22, st_rdev=33)
|
|
with mock.patch.object(ip.Path, "resolve", side_effect=["/dev/ttyACM3", Path("/sys/devices/usb/1-1")]), \
|
|
mock.patch.object(ip.os, "stat", return_value=info), \
|
|
mock.patch.object(ip.Path, "is_file", return_value=True), \
|
|
mock.patch.object(ip.Path, "read_text", return_value="WRONG"):
|
|
with self.assertRaisesRegex(ValueError, "USB serial differs"):
|
|
ip.serial_identity("/dev/serial/by-id/radio", "EXPECTED")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|