mirror of
https://github.com/Av0idD4rk/firegex.git
synced 2026-10-23 16:31:00 +00:00
359 lines
13 KiB
Python
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
|