velxio/backend/app/services/picow_net/tcp_nat.py

444 lines
16 KiB
Python

"""
TCP NAT — chip-initiated outbound connections to the host network.
The implementation follows RFC 793 §3.4-§3.9 closely enough to handle
real-world MicroPython workloads:
- Three-way handshake (SYN → SYN+ACK → ACK)
- Bidirectional data flow with proper seq/ack accounting
- Half-close handling (FIN from either side)
- RST as the cheap escape hatch on protocol errors
- MSS option negotiation (we advertise TCP_MSS = MTU - 40)
- Window clamped to TCP_WINDOW (no window scaling)
Per-connection state lives in a TcpConnection object keyed by
(chip_port, dst_ip, dst_port). Each connection owns an asyncio
StreamReader/StreamWriter to the real host endpoint.
States we transition through, simplified to chip-initiated only:
CLOSED
│ chip SYN
SYN_RCVD ── send SYN+ACK back to chip
│ chip ACK
ESTABLISHED ── pump bytes both ways
├── chip FIN ──► CLOSE_WAIT ── after host close: LAST_ACK ──► CLOSED
└── host EOF ──► FIN_WAIT_1 ── after chip ACK: FIN_WAIT_2 ──► CLOSED
We deliberately don't implement TIME_WAIT — the chip does, we just GC
once both sides have FIN'd. This is the same simplification slirp uses.
Sequence numbers wrap at 2³² — every comparison goes through
``_seq_lt`` / ``_seq_geq`` which use modular arithmetic.
"""
from __future__ import annotations
import asyncio
import logging
import random
import struct
from dataclasses import dataclass
from typing import Awaitable, Callable, Dict, Optional, Tuple
from .consts import (
GATEWAY_MAC,
IPPROTO_TCP,
TCP_ACK,
TCP_FIN,
TCP_MSS,
TCP_PSH,
TCP_RST,
TCP_SYN,
TCP_WINDOW,
bytes_to_ip,
)
from .protocols import IPv4, TCP, make_frame_ipv4, parse_tcp_options
logger = logging.getLogger(__name__)
InjectFn = Callable[[bytes], Awaitable[None]]
# ─── Sequence number arithmetic (modular 32-bit) ─────────────────────
def _seq_add(a: int, b: int) -> int:
return (a + b) & 0xffffffff
def _seq_lt(a: int, b: int) -> bool:
"""RFC 1323-style: a < b modulo 2^32."""
return ((a - b) & 0xffffffff) >= 0x80000000
def _seq_leq(a: int, b: int) -> bool:
return a == b or _seq_lt(a, b)
def _seq_diff(a: int, b: int) -> int:
"""Distance a - b modulo 2^32, signed."""
d = (a - b) & 0xffffffff
if d & 0x80000000:
d -= 0x100000000
return d
# ─── Per-connection state ────────────────────────────────────────────
class _State:
SYN_RCVD = 'SYN_RCVD'
ESTABLISHED = 'ESTABLISHED'
FIN_WAIT_1 = 'FIN_WAIT_1' # we (host side) sent FIN, waiting for chip ACK
FIN_WAIT_2 = 'FIN_WAIT_2' # chip ACKed our FIN
CLOSE_WAIT = 'CLOSE_WAIT' # chip sent FIN, host still has more to send
LAST_ACK = 'LAST_ACK' # both sides FIN'd, waiting for last ACK
CLOSED = 'CLOSED'
@dataclass
class TcpConnection:
chip_ip: bytes
chip_port: int
dst_ip: bytes
dst_port: int
chip_mac: bytes
state: str = _State.CLOSED
chip_isn: int = 0 # initial chip seq we observed
our_isn: int = 0 # initial seq we picked
our_seq: int = 0 # next seq we'll put on the wire chipward
chip_seq: int = 0 # next seq we expect from chip
chip_window: int = 0
mss: int = TCP_MSS
host_reader: Optional[asyncio.StreamReader] = None
host_writer: Optional[asyncio.StreamWriter] = None
host_pump_task: Optional[asyncio.Task] = None
last_activity: float = 0.0
def key(self) -> Tuple[bytes, int, bytes, int]:
return (self.chip_ip, self.chip_port, self.dst_ip, self.dst_port)
# ─── NAT manager ─────────────────────────────────────────────────────
class TcpNat:
"""
Manages every chip-initiated TCP connection. ``inject`` is the
callback the bridge gives us to push Ethernet frames back to the
chip; calls into the manager are made from the bridge whenever an
IP-with-protocol-TCP frame arrives from the chip.
"""
def __init__(self, inject: InjectFn) -> None:
self._inject = inject
self._conns: Dict[Tuple[bytes, int, bytes, int], TcpConnection] = {}
# ── Entry point from the bridge ────────────────────────────────
async def handle_chip_segment(
self, chip_mac: bytes, ip: IPv4, tcp: TCP,
) -> None:
key = (bytes(ip.src), tcp.src_port, bytes(ip.dst), tcp.dst_port)
conn = self._conns.get(key)
if tcp.flags & TCP_RST:
# Chip aborted — tear down silently.
if conn:
await self._close(conn, send_rst=False)
return
if conn is None:
if tcp.flags & TCP_SYN and not (tcp.flags & TCP_ACK):
await self._on_passive_syn(chip_mac, ip, tcp)
else:
# Stray segment with no connection: respond with RST.
await self._send_rst(chip_mac, ip, tcp)
return
# Update bookkeeping that's the same in every state.
conn.chip_window = tcp.window
if conn.state == _State.SYN_RCVD:
await self._on_handshake_complete(conn, tcp)
elif conn.state == _State.ESTABLISHED:
await self._on_data(conn, tcp)
elif conn.state == _State.FIN_WAIT_1:
await self._on_fin_wait_1(conn, tcp)
elif conn.state == _State.FIN_WAIT_2:
await self._on_fin_wait_2(conn, tcp)
elif conn.state == _State.CLOSE_WAIT:
# Chip should be quiet; ignore unless RST/FIN retransmit.
pass
elif conn.state == _State.LAST_ACK:
if tcp.flags & TCP_ACK and _seq_geq_or_eq(tcp.ack, _seq_add(conn.our_seq, 0)):
await self._close(conn, send_rst=False)
# ── State handlers ─────────────────────────────────────────────
async def _on_passive_syn(self, chip_mac: bytes, ip: IPv4, tcp: TCP) -> None:
"""Chip is opening a new connection — we play the server."""
opts = parse_tcp_options(tcp.options)
mss = opts.get('mss', TCP_MSS)
if mss > TCP_MSS:
mss = TCP_MSS
our_isn = random.randint(0, 0xffffffff)
conn = TcpConnection(
chip_ip=bytes(ip.src),
chip_port=tcp.src_port,
dst_ip=bytes(ip.dst),
dst_port=tcp.dst_port,
chip_mac=chip_mac,
state=_State.CLOSED,
chip_isn=tcp.seq,
our_isn=our_isn,
our_seq=_seq_add(our_isn, 1), # SYN counts as 1 byte
chip_seq=_seq_add(tcp.seq, 1),
chip_window=tcp.window,
mss=mss,
last_activity=asyncio.get_event_loop().time(),
)
# Try to establish the host-side connection. If it fails we
# send RST to the chip and never store the connection.
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(bytes_to_ip(conn.dst_ip), conn.dst_port),
timeout=10.0,
)
except (OSError, asyncio.TimeoutError) as e:
logger.info(
'[picow-tcp] connect %s:%d failed: %s',
bytes_to_ip(conn.dst_ip), conn.dst_port, e,
)
await self._send_rst(chip_mac, ip, tcp)
return
conn.host_reader = reader
conn.host_writer = writer
conn.state = _State.SYN_RCVD
self._conns[conn.key()] = conn
# Send SYN+ACK back. Advertise our MSS option.
await self._send(conn, flags=TCP_SYN | TCP_ACK,
seq=conn.our_isn, ack=conn.chip_seq,
options=_mss_option(conn.mss))
# Start the host → chip pump. It will block until handshake completes.
conn.host_pump_task = asyncio.create_task(self._pump_host_to_chip(conn))
async def _on_handshake_complete(self, conn: TcpConnection, tcp: TCP) -> None:
"""We're SYN_RCVD; this should be the chip's ACK of our SYN+ACK."""
if not (tcp.flags & TCP_ACK):
return
if tcp.ack != conn.our_seq:
# Stale or duplicate; ignore.
return
conn.state = _State.ESTABLISHED
# Some clients piggyback data on the final handshake ACK.
if tcp.payload:
await self._on_data(conn, tcp)
async def _on_data(self, conn: TcpConnection, tcp: TCP) -> None:
# Reject out-of-order. The chip will retransmit.
if tcp.payload:
if tcp.seq != conn.chip_seq:
# Re-ACK what we have (forces retransmit).
await self._ack_only(conn)
return
assert conn.host_writer is not None
try:
conn.host_writer.write(tcp.payload)
await conn.host_writer.drain()
except (ConnectionError, OSError):
await self._close(conn, send_rst=True)
return
conn.chip_seq = _seq_add(conn.chip_seq, len(tcp.payload))
await self._ack_only(conn)
elif (tcp.flags & TCP_ACK) and tcp.ack and tcp.seq == conn.chip_seq:
# Pure ACK or keep-alive — nothing to do.
pass
if tcp.flags & TCP_FIN:
conn.chip_seq = _seq_add(conn.chip_seq, 1)
conn.state = _State.CLOSE_WAIT
# Tell the host side we're done sending.
if conn.host_writer is not None:
try:
conn.host_writer.write_eof()
except (OSError, ConnectionError):
pass
await self._ack_only(conn)
# Stay in CLOSE_WAIT until the host pump finishes draining
# whatever's still inbound, then it transitions to LAST_ACK.
async def _on_fin_wait_1(self, conn: TcpConnection, tcp: TCP) -> None:
# Waiting for the chip to ACK our FIN.
if (tcp.flags & TCP_ACK) and tcp.ack == conn.our_seq:
conn.state = _State.FIN_WAIT_2
if tcp.flags & TCP_FIN:
conn.chip_seq = _seq_add(conn.chip_seq, 1)
await self._ack_only(conn)
await self._close(conn, send_rst=False)
async def _on_fin_wait_2(self, conn: TcpConnection, tcp: TCP) -> None:
if tcp.flags & TCP_FIN:
conn.chip_seq = _seq_add(conn.chip_seq, 1)
await self._ack_only(conn)
await self._close(conn, send_rst=False)
# ── Host → chip pump ───────────────────────────────────────────
async def _pump_host_to_chip(self, conn: TcpConnection) -> None:
"""Read bytes from the real host socket and segment them to the chip."""
try:
assert conn.host_reader is not None
# Wait for handshake to complete before pushing.
while conn.state == _State.SYN_RCVD:
await asyncio.sleep(0.005)
while conn.state in (_State.ESTABLISHED, _State.CLOSE_WAIT):
chunk = await conn.host_reader.read(conn.mss)
if not chunk:
break
# Segment if needed (read() should already cap at mss).
while chunk:
seg = chunk[:conn.mss]
chunk = chunk[conn.mss:]
await self._send(
conn, flags=TCP_ACK | TCP_PSH,
seq=conn.our_seq, ack=conn.chip_seq,
payload=seg,
)
conn.our_seq = _seq_add(conn.our_seq, len(seg))
conn.last_activity = asyncio.get_event_loop().time()
# Host side EOF — send FIN.
if conn.state == _State.ESTABLISHED:
conn.state = _State.FIN_WAIT_1
await self._send(conn, flags=TCP_ACK | TCP_FIN,
seq=conn.our_seq, ack=conn.chip_seq)
conn.our_seq = _seq_add(conn.our_seq, 1)
elif conn.state == _State.CLOSE_WAIT:
conn.state = _State.LAST_ACK
await self._send(conn, flags=TCP_ACK | TCP_FIN,
seq=conn.our_seq, ack=conn.chip_seq)
conn.our_seq = _seq_add(conn.our_seq, 1)
except asyncio.CancelledError:
raise
except Exception:
logger.exception('[picow-tcp] pump crashed')
await self._close(conn, send_rst=True)
# ── Frame emission ─────────────────────────────────────────────
async def _send(
self,
conn: TcpConnection,
flags: int,
seq: int,
ack: int,
options: bytes = b'',
payload: bytes = b'',
) -> None:
tcp = TCP(
src_port=conn.dst_port, # chip's "remote" = our destination
dst_port=conn.chip_port,
seq=seq & 0xffffffff,
ack=ack & 0xffffffff,
flags=flags,
window=TCP_WINDOW,
options=options,
payload=payload,
)
# Note: we swap src/dst here because we're emitting the chip's
# peer's segment — what would have come back from the host.
ipv4_payload = tcp.to_bytes(conn.dst_ip, conn.chip_ip)
frame = make_frame_ipv4(
dst_mac=conn.chip_mac,
src_mac=GATEWAY_MAC,
src_ip=conn.dst_ip,
dst_ip=conn.chip_ip,
protocol=IPPROTO_TCP,
l4_payload=ipv4_payload,
)
await self._inject(frame)
async def _ack_only(self, conn: TcpConnection) -> None:
await self._send(
conn, flags=TCP_ACK,
seq=conn.our_seq, ack=conn.chip_seq,
)
async def _send_rst(self, chip_mac: bytes, ip: IPv4, tcp: TCP) -> None:
rst = TCP(
src_port=tcp.dst_port,
dst_port=tcp.src_port,
seq=tcp.ack if (tcp.flags & TCP_ACK) else 0,
ack=_seq_add(tcp.seq, 1 if (tcp.flags & TCP_SYN) else len(tcp.payload)),
flags=TCP_RST | TCP_ACK,
window=0,
)
ipv4_payload = rst.to_bytes(bytes(ip.dst), bytes(ip.src))
frame = make_frame_ipv4(
dst_mac=chip_mac,
src_mac=GATEWAY_MAC,
src_ip=bytes(ip.dst),
dst_ip=bytes(ip.src),
protocol=IPPROTO_TCP,
l4_payload=ipv4_payload,
)
await self._inject(frame)
# ── Teardown ───────────────────────────────────────────────────
async def _close(self, conn: TcpConnection, send_rst: bool) -> None:
if conn.state == _State.CLOSED:
return
conn.state = _State.CLOSED
if send_rst:
try:
rst = TCP(
src_port=conn.dst_port,
dst_port=conn.chip_port,
seq=conn.our_seq,
ack=conn.chip_seq,
flags=TCP_RST,
)
ipv4_payload = rst.to_bytes(conn.dst_ip, conn.chip_ip)
await self._inject(make_frame_ipv4(
dst_mac=conn.chip_mac,
src_mac=GATEWAY_MAC,
src_ip=conn.dst_ip,
dst_ip=conn.chip_ip,
protocol=IPPROTO_TCP,
l4_payload=ipv4_payload,
))
except Exception:
pass
if conn.host_writer is not None:
try:
conn.host_writer.close()
except Exception:
pass
if conn.host_pump_task is not None and not conn.host_pump_task.done():
conn.host_pump_task.cancel()
self._conns.pop(conn.key(), None)
async def shutdown(self) -> None:
for conn in list(self._conns.values()):
await self._close(conn, send_rst=True)
# ─── helpers ────────────────────────────────────────────────────────
def _mss_option(mss: int) -> bytes:
return b'\x02\x04' + struct.pack('!H', mss)
def _seq_geq_or_eq(a: int, b: int) -> bool:
return a == b or not _seq_lt(a, b)