velxio/test/backend/integration/test_picow_net_bridge.py

542 lines
18 KiB
Python

"""
Pico W backend network bridge — full unit + integration coverage.
The bridge is split across:
backend/app/services/picow_net/
bridge.py — orchestrator
protocols.py — Ethernet/IPv4/TCP/UDP/ICMP/ARP/DHCP/DNS codecs
checksums.py — RFC 1071 + pseudo-header
arp.py / dhcp.py / dns.py / icmp.py / tcp_nat.py / udp_nat.py
These tests run *without* the frontend — we synthesise the chip's
side of every interaction (ARP requests, DHCP DISCOVERs, TCP segments,
UDP datagrams, ICMP echo) and assert the bridge produces correct
replies. Where real network I/O is involved (TCP open, DNS lookup,
UDP datagram), we stand up a tiny local server inside the test.
"""
from __future__ import annotations
import asyncio
import socket
import struct
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / 'backend'))
from app.services.picow_net.checksums import ( # noqa: E402
internet_checksum,
tcp_udp_checksum,
)
from app.services.picow_net.consts import ( # noqa: E402
ETHERTYPE_ARP,
ETHERTYPE_IPV4,
GATEWAY_IP,
GATEWAY_MAC,
IPPROTO_ICMP,
IPPROTO_TCP,
IPPROTO_UDP,
STA_IP,
STA_MAC,
TCP_ACK,
TCP_FIN,
TCP_PSH,
TCP_SYN,
ip_to_bytes,
)
from app.services.picow_net.protocols import ( # noqa: E402
Arp,
Dhcp,
DnsMessage,
Ethernet,
ICMP,
IPv4,
TCP,
UDP,
DHCP_MAGIC,
parse_tcp_options,
)
from app.services.picow_net.bridge import PicowNetBridge # noqa: E402
# ─── checksum primitives ────────────────────────────────────────────
class TestChecksums:
"""Hand-computed RFC 1071 examples + a real TCP segment."""
def test_internet_checksum_zero_input(self):
# Sum of nothing is 0; one's complement of 0 is 0xffff.
assert internet_checksum(b'\x00\x00') == 0xffff
def test_internet_checksum_all_ones_cancels(self):
# 0xffff + 0xffff carries to 1, end-around adds → 0xffff.
# complement → 0.
assert internet_checksum(b'\xff\xff\xff\xff') == 0
def test_ipv4_header_checksum_known_value(self):
# IPv4 header with checksum=0:
# 45 00 00 28 00 00 40 00 40 06 00 00 7f 00 00 01 7f 00 00 01
# Per RFC 1071 worked example: cksum = 0xb96a-ish (actually 0xb961).
hdr = bytes.fromhex('4500002800004000400600007f0000017f000001')
c = internet_checksum(hdr)
# Re-inserting and re-summing should yield 0xffff.
verified = (hdr[:10] + struct.pack('!H', c) + hdr[12:])
s = internet_checksum(verified)
assert s == 0
def test_tcp_checksum_round_trip(self):
src_ip = ip_to_bytes('10.0.0.1')
dst_ip = ip_to_bytes('10.0.0.2')
# Bare TCP segment: SYN, src 1234, dst 80, seq=1, ack=0
seg = struct.pack('!HHIIHHHH',
1234, 80, 1, 0, (5 << 12) | TCP_SYN, 65535, 0, 0,
)
c = tcp_udp_checksum(src_ip, dst_ip, IPPROTO_TCP, seg)
seg_with = seg[:16] + struct.pack('!H', c) + seg[18:]
# Rolling checksum over the whole thing should verify.
from app.services.picow_net.checksums import (
tcp_udp_pseudo_header, ones_complement_sum,
)
pseudo = tcp_udp_pseudo_header(src_ip, dst_ip, IPPROTO_TCP, len(seg_with))
assert ones_complement_sum(pseudo + seg_with) == 0xffff
# ─── protocol parsers ───────────────────────────────────────────────
class TestProtocols:
def test_ethernet_round_trip(self):
f = Ethernet(dst=STA_MAC, src=GATEWAY_MAC, ethertype=ETHERTYPE_IPV4,
payload=b'hello')
assert Ethernet.parse(f.to_bytes()) == f
def test_ipv4_round_trip(self):
ip = IPv4(
protocol=IPPROTO_TCP,
src=ip_to_bytes('1.2.3.4'),
dst=ip_to_bytes('5.6.7.8'),
payload=b'\x00' * 20,
)
out = ip.to_bytes()
ip2 = IPv4.parse(out)
assert ip2.src == ip.src and ip2.dst == ip.dst
assert ip2.protocol == IPPROTO_TCP
# Header checksum verifies.
from app.services.picow_net.checksums import internet_checksum
assert internet_checksum(out[:20]) == 0
def test_tcp_options_parser(self):
# MSS=1460, NOP, SACK-permitted (kind=4 length=2)
opts = b'\x02\x04\x05\xb4\x01\x04\x02'
parsed = parse_tcp_options(opts)
assert parsed['mss'] == 1460
assert 4 in parsed
def test_arp_round_trip(self):
a = Arp(
opcode=1,
sha=STA_MAC,
spa=ip_to_bytes(STA_IP),
tha=b'\x00' * 6,
tpa=ip_to_bytes(GATEWAY_IP),
)
b = a.to_bytes()
a2 = Arp.parse(b)
assert a2.opcode == 1
assert bytes(a2.spa) == ip_to_bytes(STA_IP)
def test_dhcp_round_trip(self):
d = Dhcp(
op=1, xid=0xdeadbeef,
chaddr=STA_MAC + b'\x00' * 10,
options={53: bytes([1])},
)
out = d.to_bytes()
assert DHCP_MAGIC in out
d2 = Dhcp.parse(out)
assert d2.xid == 0xdeadbeef
assert d2.options.get(53) == bytes([1])
def test_dns_round_trip(self):
m = DnsMessage(
txid=0x4242,
flags=0x0100,
qd=[('example.com', 1, 1)],
)
b = m.to_bytes()
m2 = DnsMessage.parse(b)
assert m2.txid == 0x4242
assert m2.qd[0][0] == 'example.com'
# ─── bridge — ARP ───────────────────────────────────────────────────
class _Capture:
"""Helper that captures every (kind, data) emit() call."""
def __init__(self):
self.events: list[tuple[str, dict]] = []
async def __call__(self, kind: str, data: dict) -> None:
self.events.append((kind, data))
def latest_packet_in(self) -> bytes | None:
for k, d in reversed(self.events):
if k == 'picow_packet_in':
import base64
return base64.b64decode(d['ether_b64'])
return None
def all_packets_in(self) -> list[bytes]:
out: list[bytes] = []
import base64
for k, d in self.events:
if k == 'picow_packet_in':
out.append(base64.b64decode(d['ether_b64']))
return out
def _make_arp_request(sender_mac: bytes, sender_ip: str, target_ip: str) -> bytes:
arp = Arp(
opcode=1,
sha=sender_mac,
spa=ip_to_bytes(sender_ip),
tha=b'\x00' * 6,
tpa=ip_to_bytes(target_ip),
)
return Ethernet(
dst=b'\xff' * 6,
src=sender_mac,
ethertype=ETHERTYPE_ARP,
payload=arp.to_bytes(),
).to_bytes()
@pytest.mark.asyncio
async def test_arp_responder_replies_for_gateway():
cap = _Capture()
bridge = PicowNetBridge('test', cap, wifi_enabled=True)
await bridge.start()
await bridge.deliver_packet_out(_make_arp_request(STA_MAC, STA_IP, GATEWAY_IP))
pkt = cap.latest_packet_in()
assert pkt is not None
eth = Ethernet.parse(pkt)
assert eth.ethertype == ETHERTYPE_ARP
arp = Arp.parse(eth.payload)
assert arp.opcode == 2
assert bytes(arp.sha) == GATEWAY_MAC
assert bytes(arp.spa) == ip_to_bytes(GATEWAY_IP)
@pytest.mark.asyncio
async def test_arp_responder_answers_for_arbitrary_host():
"""Slirp-style: every IP outside the LAN resolves to the gateway MAC."""
cap = _Capture()
bridge = PicowNetBridge('test', cap, wifi_enabled=True)
await bridge.start()
await bridge.deliver_packet_out(_make_arp_request(STA_MAC, STA_IP, '8.8.8.8'))
pkt = cap.latest_packet_in()
assert pkt is not None
arp = Arp.parse(Ethernet.parse(pkt).payload)
assert arp.opcode == 2
assert bytes(arp.sha) == GATEWAY_MAC
assert bytes(arp.spa) == ip_to_bytes('8.8.8.8')
# ─── bridge — DHCP ──────────────────────────────────────────────────
def _make_dhcp_msg(msg_type: int, xid: int = 0xdeadbeef) -> bytes:
dhcp = Dhcp(
op=1, xid=xid,
chaddr=STA_MAC + b'\x00' * 10,
options={53: bytes([msg_type])},
)
udp = UDP(src_port=68, dst_port=67, payload=dhcp.to_bytes())
src_ip = ip_to_bytes('0.0.0.0')
dst_ip = ip_to_bytes('255.255.255.255')
ip = IPv4(
protocol=IPPROTO_UDP, src=src_ip, dst=dst_ip,
payload=udp.to_bytes(src_ip, dst_ip),
)
return Ethernet(
dst=b'\xff' * 6, src=STA_MAC,
ethertype=ETHERTYPE_IPV4, payload=ip.to_bytes(),
).to_bytes()
@pytest.mark.asyncio
async def test_dhcp_offer_then_ack():
cap = _Capture()
bridge = PicowNetBridge('test', cap, wifi_enabled=True)
await bridge.start()
await bridge.deliver_packet_out(_make_dhcp_msg(1)) # DISCOVER
await bridge.deliver_packet_out(_make_dhcp_msg(3)) # REQUEST
pkts = cap.all_packets_in()
assert len(pkts) == 2
for raw in pkts:
eth = Ethernet.parse(raw)
ip = IPv4.parse(eth.payload)
udp = UDP.parse(ip.payload)
d = Dhcp.parse(udp.payload)
assert d.op == 2 # BOOTREPLY
assert bytes(d.yiaddr) == ip_to_bytes(STA_IP)
# Server-id option (54) must be the gateway IP.
assert d.options[54] == ip_to_bytes(GATEWAY_IP)
# ─── bridge — ICMP echo ─────────────────────────────────────────────
@pytest.mark.asyncio
async def test_icmp_echo_reply():
cap = _Capture()
bridge = PicowNetBridge('test', cap, wifi_enabled=True)
await bridge.start()
icmp = ICMP(
type=8, code=0,
rest=struct.pack('!HH', 0x1234, 0x0001),
payload=b'velxio-ping-payload',
)
ip = IPv4(
protocol=IPPROTO_ICMP,
src=ip_to_bytes(STA_IP),
dst=ip_to_bytes('1.1.1.1'),
payload=icmp.to_bytes(),
)
frame = Ethernet(GATEWAY_MAC, STA_MAC, ETHERTYPE_IPV4, ip.to_bytes()).to_bytes()
await bridge.deliver_packet_out(frame)
pkt = cap.latest_packet_in()
assert pkt is not None
rip = IPv4.parse(Ethernet.parse(pkt).payload)
rcmp = ICMP.parse(rip.payload)
assert rcmp.type == 0 # ECHO REPLY
assert rcmp.payload == b'velxio-ping-payload'
assert rcmp.rest == struct.pack('!HH', 0x1234, 0x0001)
# ─── bridge — UDP NAT (full round-trip via local echo server) ───────
@pytest.mark.asyncio
async def test_udp_nat_round_trip():
"""Stand up a local UDP echo server and verify chip→host→chip flow."""
loop = asyncio.get_event_loop()
received: list[bytes] = []
class EchoProto(asyncio.DatagramProtocol):
def __init__(self):
self.transport: asyncio.DatagramTransport | None = None
def connection_made(self, t):
self.transport = t
def datagram_received(self, data, addr):
received.append(data)
assert self.transport is not None
self.transport.sendto(b'echo:' + data, addr)
server_transport, _ = await loop.create_datagram_endpoint(
EchoProto, local_addr=('127.0.0.1', 0),
)
server_host, server_port = server_transport.get_extra_info('sockname')
cap = _Capture()
bridge = PicowNetBridge('test', cap, wifi_enabled=True)
await bridge.start()
udp = UDP(src_port=33333, dst_port=server_port, payload=b'velxio-udp')
ip = IPv4(
protocol=IPPROTO_UDP,
src=ip_to_bytes(STA_IP),
dst=ip_to_bytes(server_host),
payload=udp.to_bytes(ip_to_bytes(STA_IP), ip_to_bytes(server_host)),
)
frame = Ethernet(GATEWAY_MAC, STA_MAC, ETHERTYPE_IPV4, ip.to_bytes()).to_bytes()
await bridge.deliver_packet_out(frame)
# Wait up to 2 s for the echo to arrive back.
for _ in range(40):
if cap.latest_packet_in() is not None:
break
await asyncio.sleep(0.05)
server_transport.close()
await bridge.stop()
assert received == [b'velxio-udp']
pkt = cap.latest_packet_in()
assert pkt is not None
rip = IPv4.parse(Ethernet.parse(pkt).payload)
rudp = UDP.parse(rip.payload)
assert rudp.payload == b'echo:velxio-udp'
assert rudp.dst_port == 33333
# ─── bridge — TCP NAT (full RFC 793 handshake + data + close) ──────
@pytest.mark.asyncio
async def test_tcp_nat_full_round_trip():
"""Spin up a local HTTP-shaped server and walk through the whole TCP
state machine: SYN → SYN+ACK → ACK → data → FIN."""
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
data = await reader.readuntil(b'\r\n\r\n')
assert b'GET /hello' in data
writer.write(b'HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello')
await writer.drain()
writer.close()
await writer.wait_closed()
server = await asyncio.start_server(handle, '127.0.0.1', 0)
server_host, server_port = server.sockets[0].getsockname()
cap = _Capture()
bridge = PicowNetBridge('test', cap, wifi_enabled=True)
await bridge.start()
chip_port = 44444
chip_isn = 0x1000
async def send_tcp(seq: int, ack: int, flags: int, payload: bytes = b''):
tcp = TCP(
src_port=chip_port, dst_port=server_port,
seq=seq, ack=ack, flags=flags, window=65535,
payload=payload,
)
ip = IPv4(
protocol=IPPROTO_TCP,
src=ip_to_bytes(STA_IP),
dst=ip_to_bytes(server_host),
payload=tcp.to_bytes(ip_to_bytes(STA_IP), ip_to_bytes(server_host)),
)
frame = Ethernet(GATEWAY_MAC, STA_MAC, ETHERTYPE_IPV4, ip.to_bytes()).to_bytes()
await bridge.deliver_packet_out(frame)
async def wait_for_segment_with_flags(
expected_flags: int, start_idx: int = 0, timeout: float = 2.0,
) -> tuple[TCP, int]:
"""Scan from start_idx onward; return (tcp, index_after_match)."""
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
for i in range(start_idx, len(cap.events)):
k, d = cap.events[i]
if k != 'picow_packet_in':
continue
import base64
raw = base64.b64decode(d['ether_b64'])
eth = Ethernet.parse(raw)
if eth.ethertype != ETHERTYPE_IPV4:
continue
ip = IPv4.parse(eth.payload)
if ip.protocol != IPPROTO_TCP:
continue
tcp = TCP.parse(ip.payload)
if (tcp.flags & expected_flags) == expected_flags:
return tcp, i + 1
await asyncio.sleep(0.02)
raise AssertionError(f'no TCP segment with flags 0x{expected_flags:x} arrived')
# 1. SYN
await send_tcp(seq=chip_isn, ack=0, flags=TCP_SYN)
syn_ack, idx = await wait_for_segment_with_flags(TCP_SYN | TCP_ACK)
assert syn_ack.ack == chip_isn + 1
server_isn = syn_ack.seq
# 2. ACK the SYN+ACK and immediately PSH a GET request.
request = b'GET /hello HTTP/1.1\r\nHost: x\r\n\r\n'
await send_tcp(
seq=chip_isn + 1, ack=server_isn + 1,
flags=TCP_ACK | TCP_PSH, payload=request,
)
# 3. Read the response. We expect:
# - an ACK for our request
# - a PSH+ACK with HTTP/1.1 200 OK + body
# - a FIN+ACK from the server
response_segments = []
deadline = asyncio.get_event_loop().time() + 3.0
fin_seen = False
while asyncio.get_event_loop().time() < deadline and not fin_seen:
for i in range(idx, len(cap.events)):
k, d = cap.events[i]
if k != 'picow_packet_in':
continue
import base64
raw = base64.b64decode(d['ether_b64'])
eth = Ethernet.parse(raw)
if eth.ethertype != ETHERTYPE_IPV4:
continue
ip = IPv4.parse(eth.payload)
if ip.protocol != IPPROTO_TCP:
continue
tcp = TCP.parse(ip.payload)
if tcp.payload:
response_segments.append(tcp.payload)
if tcp.flags & TCP_FIN:
fin_seen = True
idx = len(cap.events)
await asyncio.sleep(0.02)
assert fin_seen
body = b''.join(response_segments)
assert b'HTTP/1.1 200 OK' in body
assert body.endswith(b'hello')
# 4. Send our own FIN to close the chip side cleanly.
await send_tcp(
seq=chip_isn + 1 + len(request),
ack=server_isn + 1 + len(body) + 1,
flags=TCP_ACK | TCP_FIN,
)
server.close()
await server.wait_closed()
await bridge.stop()
# ─── bridge — DNS proxy (uses host resolver) ────────────────────────
@pytest.mark.asyncio
async def test_dns_proxy_localhost():
"""Resolve 'localhost' through the bridge — should return 127.0.0.1."""
cap = _Capture()
bridge = PicowNetBridge('test', cap, wifi_enabled=True)
await bridge.start()
query = DnsMessage(txid=0x1234, flags=0x0100, qd=[('localhost', 1, 1)])
udp = UDP(src_port=12345, dst_port=53, payload=query.to_bytes())
src_ip = ip_to_bytes(STA_IP)
dst_ip = ip_to_bytes(GATEWAY_IP)
ip = IPv4(
protocol=IPPROTO_UDP, src=src_ip, dst=dst_ip,
payload=udp.to_bytes(src_ip, dst_ip),
)
frame = Ethernet(GATEWAY_MAC, STA_MAC, ETHERTYPE_IPV4, ip.to_bytes()).to_bytes()
await bridge.deliver_packet_out(frame)
pkt = cap.latest_packet_in()
assert pkt is not None
rip = IPv4.parse(Ethernet.parse(pkt).payload)
rudp = UDP.parse(rip.payload)
resp = DnsMessage.parse(rudp.payload)
assert resp.txid == 0x1234
assert resp.flags & 0x8000 # response bit
# localhost should always resolve, regardless of host config.
assert any(rdata == b'\x7f\x00\x00\x01' for (_, _, _, _, rdata) in resp.an)
# ─── bridge — wifi disabled = silent drop ──────────────────────────
@pytest.mark.asyncio
async def test_wifi_disabled_silently_drops_packets():
cap = _Capture()
bridge = PicowNetBridge('test', cap, wifi_enabled=False)
await bridge.start()
await bridge.deliver_packet_out(_make_arp_request(STA_MAC, STA_IP, GATEWAY_IP))
# No packets should have been injected.
assert all(k != 'picow_packet_in' for k, _ in cap.events)