firegex/backend/modules/services/conntrack.py
2026-10-01 10:05:17 +02:00

359 lines
13 KiB
Python

"""Forgetting UDP flows, so the next datagram of each is steered by the rules in force now.
Conntrack translates a flow once, at its first packet, and the proxy layer's `redirect`
is a translation. TCP lives with that: a connection is one thing from its handshake to
its close, and one opened before the service started is simply carried to the end. UDP
has no close — a flow lasts for as long as datagrams keep arriving inside the timeout —
and that turned the same rule into three failures:
* **a flow older than the service was never filtered**, for as long as it kept talking:
nothing redirected it, and nothing ever would. A client could open one before the
protection went up and keep it open indefinitely;
* **a flow carried across a restart stayed with the engine being retired**, under the
filters it started with, and kept that engine alive until the drain gave up on it;
* **and once that engine was gone, the flow went nowhere.** Conntrack went on sending it
to a relay port nobody listened on, every datagram refreshing the entry that did so,
until the client happened to fall silent for two minutes.
A UDP flow can simply be forgotten: its next datagram is a new flow, judged by whatever
rules are there now, and neither end had any state that depended on the old entry. So
this deletes the entries, over ctnetlink — the kernel's own interface, the one the
`conntrack` tool speaks, with nothing to install and nothing to spawn.
TCP is never touched here: forgetting a connection breaks it.
"""
import ipaddress
import socket
import struct
from dataclasses import dataclass
from typing import Callable, Iterable
NETLINK_NETFILTER = 12
NLM_F_REQUEST = 0x1
NLM_F_ACK = 0x4
NLM_F_DUMP = 0x300
NLMSG_ERROR = 2
NLMSG_DONE = 3
NFNL_SUBSYS_CTNETLINK = 1
IPCTNL_MSG_CT_GET = 1
IPCTNL_MSG_CT_DELETE = 2
NLA_TYPE_MASK = 0x3FFF
CTA_TUPLE_ORIG = 1
CTA_TUPLE_REPLY = 2
CTA_STATUS = 3
CTA_MARK = 8
CTA_ZONE = 18
CTA_TUPLE_IP = 1
CTA_TUPLE_PROTO = 2
CTA_IP_V4_SRC = 1
CTA_IP_V4_DST = 2
CTA_IP_V6_SRC = 3
CTA_IP_V6_DST = 4
CTA_PROTO_NUM = 1
CTA_PROTO_SRC_PORT = 2
CTA_PROTO_DST_PORT = 3
#: `IPS_DST_NAT`: the flow's destination was rewritten — by a `redirect`, among others.
IPS_DST_NAT = 1 << 5
IPPROTO_UDP = 17
_HEADER = struct.Struct("=IHHII")
_NFGEN = struct.Struct("=BBH")
_ATTR = struct.Struct("=HH")
@dataclass
class Flow:
"""One conntrack entry, as much of it as deciding and deleting need."""
family: int
l4proto: int
orig_src: ipaddress._BaseAddress | None
orig_dst: ipaddress._BaseAddress | None
orig_sport: int | None
orig_dport: int | None
reply_src: ipaddress._BaseAddress | None
reply_sport: int | None
status: int
mark: int
#: The original tuple and the zone exactly as the kernel sent them, which is what a
#: delete names the entry by.
raw_orig: bytes
raw_zone: bytes = b""
@property
def redirected(self) -> bool:
return bool(self.status & IPS_DST_NAT)
# --- the wire -------------------------------------------------------------------
def _align(n: int) -> int:
return (n + 3) & ~3
def attr(kind: int, payload: bytes) -> bytes:
"""One netlink attribute, padded."""
raw = _ATTR.pack(_ATTR.size + len(payload), kind) + payload
return raw + b"\0" * (_align(len(raw)) - len(raw))
def attrs(data: bytes) -> Iterable[tuple[int, bytes, bytes]]:
"""`(type, payload, the whole attribute)` for each attribute in `data`."""
pos = 0
while pos + _ATTR.size <= len(data):
length, kind = _ATTR.unpack_from(data, pos)
if length < _ATTR.size or pos + length > len(data):
return
yield kind & NLA_TYPE_MASK, data[pos + _ATTR.size:pos + length], data[pos:pos + length]
pos += _align(length)
def message(kind: int, flags: int, seq: int, family: int, payload: bytes = b"") -> bytes:
body = _NFGEN.pack(family, 0, 0) + payload
return _HEADER.pack(_HEADER.size + len(body), (NFNL_SUBSYS_CTNETLINK << 8) | kind,
flags, seq, 0) + body
def messages(data: bytes) -> Iterable[tuple[int, bytes]]:
"""`(type, payload)` for each netlink message in one read."""
pos = 0
while pos + _HEADER.size <= len(data):
length, kind, _flags, _seq, _pid = _HEADER.unpack_from(data, pos)
if length < _HEADER.size or pos + length > len(data):
return
yield kind, data[pos + _HEADER.size:pos + length]
pos += _align(length)
def _tuple(data: bytes) -> tuple:
src = dst = sport = dport = None
proto = 0
for kind, payload, _ in attrs(data):
if kind == CTA_TUPLE_IP:
for ip_kind, value, _ in attrs(payload):
if ip_kind in (CTA_IP_V4_SRC, CTA_IP_V6_SRC):
src = ipaddress.ip_address(value)
elif ip_kind in (CTA_IP_V4_DST, CTA_IP_V6_DST):
dst = ipaddress.ip_address(value)
elif kind == CTA_TUPLE_PROTO:
for proto_kind, value, _ in attrs(payload):
if proto_kind == CTA_PROTO_NUM:
proto = value[0]
elif proto_kind == CTA_PROTO_SRC_PORT:
sport = struct.unpack("!H", value[:2])[0]
elif proto_kind == CTA_PROTO_DST_PORT:
dport = struct.unpack("!H", value[:2])[0]
return src, dst, sport, dport, proto
def parse_flow(payload: bytes) -> Flow | None:
"""A conntrack entry out of one `IPCTNL_MSG_CT_NEW` message's payload."""
if len(payload) < _NFGEN.size:
return None
family = payload[0]
orig = reply = None
raw_orig = raw_zone = b""
status = mark = 0
for kind, value, whole in attrs(payload[_NFGEN.size:]):
if kind == CTA_TUPLE_ORIG:
orig, raw_orig = _tuple(value), whole
elif kind == CTA_TUPLE_REPLY:
reply = _tuple(value)
elif kind == CTA_STATUS:
status = struct.unpack("!I", value[:4])[0]
elif kind == CTA_MARK:
mark = struct.unpack("!I", value[:4])[0]
elif kind == CTA_ZONE:
raw_zone = whole
if orig is None:
return None
reply_src, _, reply_sport, _, _ = reply if reply else (None, None, None, None, 0)
return Flow(
family=family, l4proto=orig[4],
orig_src=orig[0], orig_dst=orig[1], orig_sport=orig[2], orig_dport=orig[3],
reply_src=reply_src, reply_sport=reply_sport,
status=status, mark=mark, raw_orig=raw_orig, raw_zone=raw_zone,
)
def delete_request(flow: Flow, seq: int) -> bytes:
return message(IPCTNL_MSG_CT_DELETE, NLM_F_REQUEST | NLM_F_ACK, seq, flow.family,
flow.raw_orig + flow.raw_zone)
# --- talking to the kernel ------------------------------------------------------
def _read_until_done(sock: socket.socket) -> Iterable[bytes]:
"""The payload of every entry a dump answers with."""
while True:
data = sock.recv(1 << 18)
if not data:
return
for kind, payload in messages(data):
if kind == NLMSG_DONE:
return
if kind == NLMSG_ERROR:
error = struct.unpack_from("=i", payload)[0] if len(payload) >= 4 else 0
if error:
raise OSError(-error, "conntrack dump refused")
return
yield payload
def _acknowledged(sock: socket.socket) -> int:
"""The error a request was answered with, 0 for none."""
while True:
for kind, payload in messages(sock.recv(1 << 16)):
if kind == NLMSG_ERROR:
return -struct.unpack_from("=i", payload)[0] if len(payload) >= 4 else 0
def delete_where(chosen: Callable[[Flow], bool]) -> int:
"""Delete every UDP entry `chosen` picks. Returns how many went.
The table is read once and the deletes follow: a flow that ended in between is
answered `ENOENT`, which is the state being asked for.
"""
with socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, NETLINK_NETFILTER) as sock:
sock.settimeout(5)
sock.bind((0, 0))
sock.send(message(IPCTNL_MSG_CT_GET, NLM_F_REQUEST | NLM_F_DUMP, 1, socket.AF_UNSPEC))
doomed = []
for payload in _read_until_done(sock):
flow = parse_flow(payload)
if flow is not None and flow.l4proto == IPPROTO_UDP and chosen(flow):
doomed.append(flow)
gone = 0
for seq, flow in enumerate(doomed, start=2):
sock.send(delete_request(flow, seq))
error = _acknowledged(sock)
if error == 0:
gone += 1
elif error != 2: # ENOENT: it ended by itself meanwhile
raise OSError(error, "conntrack refused a delete")
return gone
# --- what to forget -------------------------------------------------------------
Target = tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, int]
def targets(pairs: Iterable[tuple[str, int]]) -> list[Target]:
"""`(address or network, port)` pairs as something a flow can be compared with."""
return [(ipaddress.ip_network(str(ip), strict=False), int(port)) for ip, port in pairs]
def headed_for(flow: Flow, wanted: list[Target]) -> bool:
"""Whether a flow was going to one of `wanted` before anything rewrote it."""
if flow.orig_dst is None or flow.orig_dport is None:
return False
return any(
flow.orig_dport == port and flow.orig_dst.version == network.version
and flow.orig_dst in network
for network, port in wanted
)
def chooser(*, moved: list[Target], unsteered: list[Target], spare_mark: int,
bound: set[int]) -> Callable[[Flow], bool]:
"""Which flows `forget_udp` lets go of. Split out so the decision can be tested
without a kernel."""
def chosen(flow: Flow) -> bool:
if flow.mark == spare_mark:
return False
if headed_for(flow, moved):
return True
if headed_for(flow, unsteered):
return not flow.redirected or flow.reply_sport not in bound
return False
return chosen
def forget_udp(*, moved: Iterable[tuple[str, int]] = (),
unsteered: Iterable[tuple[str, int]] = (), spare_mark: int) -> int:
"""Forget UDP flows headed for these addresses, so their next datagram is steered by
the rules in force now.
* `moved`: every flow headed there, including the ones a redirect already took to an
engine — a datagram flow can change engines between two datagrams and lose nothing
but the filter's own state for it, which is what a new configuration costs anyway.
* `unsteered`: only the ones nothing is carrying — never redirected at all, or
redirected to a port nobody listens on any more. What a QUIC edge needs: its
connections are terminated by an engine, and moved to another one mid-connection
they would end, while one still draining is carrying them.
`spare_mark` is the engine's own: its dials to the service are flows headed for the
same address, and they are its to keep.
"""
moved_to, unsteered_to = targets(moved), targets(unsteered)
if not moved_to and not unsteered_to:
return 0
return delete_where(chooser(
moved=moved_to, unsteered=unsteered_to, spare_mark=spare_mark,
bound=bound_udp_ports() if unsteered_to else set(),
))
def bound_udp_ports() -> set[int]:
"""Every UDP port some socket on this host is bound to, from `/proc/net/udp{,6}`."""
found = set()
for path in ("/proc/net/udp", "/proc/net/udp6"):
try:
with open(path) as table:
next(table, None)
for line in table:
fields = line.split()
if len(fields) > 1 and ":" in fields[1]:
found.add(int(fields[1].rsplit(":", 1)[1], 16))
except OSError:
continue
return found
def forget_udp_redirected_to(ports: Iterable[int], local: set) -> int:
"""Forget the UDP flows a redirect sent to one of these ports on this host.
What is left of an engine once it has gone: every such flow points at a relay nobody
listens on any more. Recognised by the reply coming from that port on one of this
host's own addresses — which is what a redirect writes into the reply tuple, and what
no flow forwarded to a container or another host can have.
"""
wanted = {int(port) for port in ports}
if not wanted:
return 0
return delete_where(
lambda flow: flow.redirected and flow.reply_sport in wanted
and flow.reply_src is not None and flow.reply_src in local
)
def local_addresses() -> set:
"""Every address this host holds right now."""
import psutil
found = set()
for entries in psutil.net_if_addrs().values():
for entry in entries:
if entry.family in (socket.AF_INET, socket.AF_INET6):
try:
found.add(ipaddress.ip_address(entry.address.split("%")[0]))
except ValueError:
pass
return found