firegex/tests/helpers/udpserver.py
2026-10-01 10:05:17 +02:00

152 lines
5.8 KiB
Python

"""The stand-in UDP service, and a client that talks to it through firegex.
Deliberately not the TCP helper with the protocol swapped. A datagram exchange has no
connection to establish, so "was it refused" is "did an answer come back before the
timeout" rather than "did the socket close" — and there is nothing to keep open between
two datagrams, which is what makes a UDP flow's per-flow state worth testing at all.
A thread rather than a process: an echo loop on a socket needs no isolation, and closing
the socket is a cleaner way to stop it than killing a child.
"""
import socket
import threading
import time
class UdpEcho:
def __init__(self, port: int, ipv6: bool = False):
self.port = port
self.ipv6 = ipv6
self.sock: socket.socket | None = None
self.thread: threading.Thread | None = None
#: Every peer address a datagram arrived from, in order. What the *service* saw,
#: which is the only way to check that the client's own address survived the
#: relay rather than being replaced by firegex's.
self.seen_peers: list[tuple] = []
@property
def host(self) -> str:
return "::1" if self.ipv6 else "127.0.0.1"
def start(self):
family = socket.AF_INET6 if self.ipv6 else socket.AF_INET
self.sock = socket.socket(family, socket.SOCK_DGRAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind((self.host, self.port))
# The thread holds its own reference. `stop()` closes the socket and then sets
# the attribute to `None`, so a loop reading `self.sock` each time round races
# with it and raises `AttributeError` on a `None` instead of the `OSError` that
# is how this loop is meant to end.
sock = self.sock
def serve():
while True:
try:
data, peer = sock.recvfrom(65535)
except OSError:
return # the socket was closed; that is how this loop ends
self.seen_peers.append(peer)
try:
sock.sendto(data, peer)
except OSError:
return
self.thread = threading.Thread(target=serve, daemon=True)
self.thread.start()
time.sleep(0.3)
def stop(self):
if self.sock:
self.sock.close()
self.sock = None
if self.thread:
self.thread.join(timeout=2)
self.thread = None
def exchange(self, payload: bytes, timeout: float = 1.5,
bind: str | None = None) -> bytes | None:
"""Send one datagram and return the answer, or `None` if none came back.
`bind` puts the client on a chosen source address, which is how the transparency
test can tell the address the service sees apart from the loopback default.
"""
family = socket.AF_INET6 if self.ipv6 else socket.AF_INET
sock = socket.socket(family, socket.SOCK_DGRAM)
sock.settimeout(timeout)
try:
if bind:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((bind, 0))
sock.sendto(payload, (self.host, self.port))
return sock.recvfrom(65535)[0]
except (TimeoutError, OSError):
return None
finally:
sock.close()
def flow(self, timeout: float = 1.5) -> "HeldFlow":
"""One client socket kept across several exchanges — one flow, to conntrack.
`exchange` opens a socket per datagram, so every one of them is a flow of its own
and meets whatever rules are in force when it is sent. What a long-lived client
does is keep its socket, and that is the case where what conntrack remembers
decides where the next datagram goes.
"""
return HeldFlow(self, timeout)
def answered_from(self, payload: bytes, timeout: float = 1.5) -> tuple | None:
"""Which address the answer came back from.
A reply has to appear to come from the address the client dialled: it leaves
through the listener socket so conntrack rewrites it, and a client that sees an
internal relay port instead simply drops it.
"""
family = socket.AF_INET6 if self.ipv6 else socket.AF_INET
sock = socket.socket(family, socket.SOCK_DGRAM)
sock.settimeout(timeout)
try:
sock.sendto(payload, (self.host, self.port))
return sock.recvfrom(65535)[1]
except (TimeoutError, OSError):
return None
finally:
sock.close()
class HeldFlow:
"""A UDP client that keeps its source port for as long as it is open. See `flow`."""
def __init__(self, echo: UdpEcho, timeout: float):
self.echo = echo
self.sock = socket.socket(socket.AF_INET6 if echo.ipv6 else socket.AF_INET,
socket.SOCK_DGRAM)
self.sock.settimeout(timeout)
def exchange(self, payload: bytes) -> bytes | None:
"""Send one datagram on this flow and return its echo, or `None` if none came.
Answers to earlier datagrams arriving late are passed over rather than taken for
this one's, which on one socket is a real possibility.
"""
try:
self.sock.sendto(payload, (self.echo.host, self.echo.port))
deadline = time.monotonic() + (self.sock.gettimeout() or 1.5)
while time.monotonic() < deadline:
data = self.sock.recvfrom(65535)[0]
if data == payload:
return data
except (TimeoutError, OSError):
pass
return None
def close(self):
self.sock.close()
def __enter__(self) -> "HeldFlow":
return self
def __exit__(self, *exc):
self.close()