mirror of
https://github.com/Av0idD4rk/firegex.git
synced 2026-10-23 21:42:05 +00:00
1201 lines
60 KiB
Python
1201 lines
60 KiB
Python
"""One nftables owner for both transports.
|
|
|
|
The rules a service needs depend entirely on how its traffic is intercepted, and
|
|
nothing else — not on what filters it runs. That is the whole point of separating the
|
|
two layers, and it is why this file dispatches on `srv.transport` and never looks at
|
|
a filter.
|
|
|
|
**NFQUEUE** is the simple half: match the service's address in prerouting and
|
|
postrouting, mark the direction, and queue with `bypass` so the kernel keeps
|
|
forwarding if userspace stops answering.
|
|
|
|
**Proxy** has to arrange the return path by hand, because it terminates connections.
|
|
The subtle part is that the service must still see the client's address. When the
|
|
engine dials the service as the client, two flows look almost identical:
|
|
|
|
engine -> client src service:port dst client:client_port must leave the box
|
|
service -> engine src service:port dst client:<ephemeral> must stay local
|
|
|
|
No address distinguishes them. Conntrack does: the first belongs to the intercepted,
|
|
redirected connection, the second is a separate entry the engine opened itself. So
|
|
`ct status dnat` lets the first out, and everything else from a protected service — and
|
|
anything answering a connection the engine opened, from whichever port — is marked home. That is what makes source preservation work even with the service on
|
|
this very host — which is also the case that ruled tproxy out, since tproxy leaves no
|
|
NAT entry to key off and so could never reach a local service at all.
|
|
"""
|
|
|
|
import subprocess
|
|
|
|
from modules.services.models import TRANSPORT, UPSTREAM, Address, Service
|
|
from utils import (
|
|
NFTableManager,
|
|
addr_parse,
|
|
get_interface_ips,
|
|
ip_family,
|
|
ip_parse,
|
|
is_ip_parse,
|
|
nftables_int_to_json,
|
|
)
|
|
|
|
# Direction marks the NFQUEUE binaries read back off a packet.
|
|
QUEUE_MARK_INPUT = 0x1337
|
|
QUEUE_MARK_OUTPUT = 0x1338
|
|
|
|
#: How many filters an NFQUEUE service can chain.
|
|
#:
|
|
#: Each position is its own pair of base chains, because that is what makes a chain
|
|
#: possible there at all: a packet a filter accepts carries on to the next base chain in
|
|
#: the same hook, so filter order is chain priority order. Eight is not a technical
|
|
#: limit, it is a number of always-present chains worth paying for; nothing stops it
|
|
#: growing except that every position costs two chains whether or not anyone uses it.
|
|
MAX_CHAIN_POSITIONS = 8
|
|
|
|
#: Where the NFQUEUE layer meets a connection this host opened to one of its own addresses
|
|
#: that somebody rewrote on the way — a port a container runtime publishes. Docker's DNAT
|
|
#: for this host's own traffic sits in `nat OUTPUT` at `dstnat` (-100) and sends it out
|
|
#: towards the container, so it never comes back in through prerouting where the queue
|
|
#: chains are, and neither does the container's answer: it arrives for a local socket
|
|
#: and goes to `input`. These run after that rewrite on the way out and before it is
|
|
#: undone on the way in (`srcnat`, 100), so both halves show the container's address and
|
|
#: a stream follower sees one stream.
|
|
LOCAL_QUEUE_PRIORITY = -90
|
|
|
|
#: Where the proxy layer's redirect sits in the prerouting and output hooks.
|
|
#:
|
|
#: **Before `dstnat` (-100), and that is the whole point.** Docker and podman publish a
|
|
#: container's port by appending their own chain to `nat PREROUTING`, which `iptables-nft`
|
|
#: registers at exactly `dstnat` — the priority this used to share. Two base chains at one
|
|
#: priority in two tables are ordered by neither of them, and measured on a host where the
|
|
#: DNAT went first: it rewrote the destination to the container's address, firegex's rule
|
|
#: (which matches the *published* address) stopped matching, its counter stayed at zero,
|
|
#: and traffic carrying a blocked pattern was answered by the container. The service still
|
|
#: read `ACTIVE`. Protection that disappears without saying so is the worst failure this
|
|
#: module has, and a race nobody arbitrates is not a thing to leave to luck.
|
|
#:
|
|
#: So the redirect runs first and the published port is captured before anybody can
|
|
#: rewrite it — which is the property the NFQUEUE layer already had for free by living at
|
|
#: `raw`, three hundred below any of this. The engine's own dial is not caught by the
|
|
#: rule it would otherwise match (`SELF_MARK`), so whatever DNAT the container runtime
|
|
#: installs still does its job on the way out, exactly as before.
|
|
#:
|
|
#: The floor is conntrack at -200: a `nat` chain registered before it has no connection to
|
|
#: attach a translation to. Anything between the two behaves identically; this one is far
|
|
#: enough from both to leave room.
|
|
NAT_PRIORITY = -120
|
|
|
|
# Shared by the divert rule and the return rule; policy routing keys off it.
|
|
PROXY_MARK = 0x1339
|
|
# Routing table holding the `local` default that makes marked packets be delivered
|
|
# here instead of routed by destination.
|
|
PROXY_ROUTE_TABLE = 1339
|
|
# Stamped by the engine on the connections it opens. Without excluding it, an
|
|
# intercept rule cannot tell the engine's own dial from the traffic it should
|
|
# intercept — and where both ends are local, the engine would redirect itself in a
|
|
# loop. Deliberately not PROXY_MARK:
|
|
# that one means "deliver this here", which would strand an outbound connection.
|
|
# Matches `transparent::SELF_MARK` in the engine.
|
|
PROXY_SELF_MARK = 0x133A
|
|
|
|
|
|
def resolve_target(srv: Service, addr: Address) -> tuple[str, int] | None:
|
|
"""Where one address's rules should point: at the address itself.
|
|
|
|
This is the address the **world knocks on**, which is what a rule has to match, and
|
|
it is not always where the service is — see [`service_at`]. The two were one answer
|
|
for as long as firegex was transparent and only transparent, and making this one
|
|
return the other quietly redirected the service's own port twice and left the
|
|
published one unprotected, which is a service that looks configured and answers
|
|
nothing.
|
|
|
|
It used to be somewhere else for a TLS service. nginx terminated the connection on
|
|
one derived loopback port and re-encrypted from a second, and the rules were pointed
|
|
at the plaintext leg in between — which is why protecting one address cost two ports
|
|
chosen by hashing `ip:port`, and why those ports could collide with something real.
|
|
The engine terminates TLS itself now, so there is no leg to point at and no port to
|
|
choose: the ciphertext arrives at the address the world dials, and is decrypted
|
|
inside the process that filters it.
|
|
|
|
The return type keeps its `None`, because a caller that installs nothing is still
|
|
the right answer for an address that cannot be resolved.
|
|
"""
|
|
return addr.ip_int, addr.port
|
|
|
|
|
|
def service_at(srv: Service, addr: Address) -> tuple[str, int] | None:
|
|
"""Where the service reached through one address actually is.
|
|
|
|
The address itself, unless that address is a **publication**: one daemon answering
|
|
HTTP/1.1 in the clear on `:80` can be reached over TLS on `:443` and over HTTP/3 on a
|
|
UDP port, all three sent back to the port it already listens on. Firegex is then not
|
|
transparent on those addresses, which is the point of them and the reason it is said
|
|
per address rather than per service.
|
|
|
|
Everything that *dials* asks this — the UDP and QUIC relays, whose upstream is fixed
|
|
when they are bound, and the TCP listener, which is told through `FGEX_PROXY_TARGETS`
|
|
because one listener fronts every address and recovers each connection's destination
|
|
from conntrack. Everything that *matches* asks [`resolve_target`].
|
|
"""
|
|
return addr.ip_int, (addr.target_port or addr.port)
|
|
|
|
|
|
def one_address(ip: str) -> str:
|
|
"""Strip any prefix: a rewrite target is one host, never a network.
|
|
|
|
Service addresses are normalised to network form (`127.0.0.1/32`) because that is
|
|
what a *match* wants. A `mangle` writes a single address into the packet and refuses
|
|
anything else, so the two cannot share a representation.
|
|
"""
|
|
return str(ip).split("/")[0]
|
|
|
|
|
|
def hijack_endpoint(proxy_ip: str | None, service_ip: str) -> str:
|
|
"""Where the operator's own proxy listens for one address, as a single host.
|
|
|
|
The one spelling of the default, because it is asked in three places that must agree:
|
|
the rule that sends traffic to that proxy, the rule that undoes the rewrite on the way
|
|
back, and the uniqueness check that stops two addresses sharing one endpoint. Loopback
|
|
is where such a proxy normally is, in the family of the address it fronts.
|
|
|
|
It is resolved when an address is **written**, not only when its rules are built. The
|
|
column used to be left NULL and defaulted here — and SQLite's UNIQUE treats every NULL
|
|
as distinct, so the partial index meant to forbid two addresses behind one endpoint
|
|
happily accepted any number of them as long as nobody typed the address out. They then
|
|
all resolved to the same loopback port, which is exactly the collision the index exists
|
|
to prevent: the return rule recognises the proxy by address and port, so it could not
|
|
tell them apart on the way out.
|
|
"""
|
|
return one_address(proxy_ip or "") or (
|
|
"::1" if ip_family(one_address(service_ip)) == "ip6" else "127.0.0.1"
|
|
)
|
|
|
|
|
|
class NoRelayAddress(Exception):
|
|
"""An address a UDP relay cannot be bound for."""
|
|
|
|
|
|
def udp_relay_host(ip: str) -> str:
|
|
"""The concrete address a UDP relay binds for `ip`, which may be an interface name.
|
|
|
|
A relay has to bind somewhere, and an interface name is not somewhere — so the
|
|
interface's own address stands in for it, resolved when it is needed rather than
|
|
stored, because an interface's address is the host's to change. IPv4 first and
|
|
link-local last: an interface almost always carries a link-local IPv6 address it was
|
|
never configured with, and binding the relay to that one would put the service on an
|
|
address nobody dials.
|
|
"""
|
|
if is_ip_parse(ip):
|
|
return one_address(ip)
|
|
candidates = [one_address(addr.split("%")[0]) for addr in get_interface_ips(ip)]
|
|
ordered = sorted(
|
|
candidates,
|
|
key=lambda a: (ip_family(a) == "ip6", a.lower().startswith(("fe80:", "169.254."))),
|
|
)
|
|
if not ordered:
|
|
raise NoRelayAddress(
|
|
f"interface '{ip}' has no IP address assigned, so there is nothing for a "
|
|
f"UDP relay to bind to"
|
|
)
|
|
return ordered[0]
|
|
|
|
|
|
#: Written on the output-hook rules an interface address installs, so they can be found
|
|
#: again. There is nothing in the packet that names the interface there — see
|
|
#: `interface_addresses` — so the rule says which interface it belongs to itself.
|
|
IFACE_COMMENT = "iface "
|
|
|
|
#: Written on a rule that matches its address through conntrack rather than in the packet
|
|
#: — `<l4> <port> <address>` — for the same reason: `get()` reads what a rule matches to
|
|
#: know whose it is, and there is nothing in these for it to read.
|
|
ADDRESS_COMMENT = "address "
|
|
|
|
|
|
def interface_addresses(ip: str) -> list[str]:
|
|
"""Every address an interface carries right now, as a rule can match them.
|
|
|
|
Link-local is left out for the reason `udp_relay_host` takes it last: an interface
|
|
almost always carries an `fe80::` it was never configured with, and nothing dials a
|
|
service there.
|
|
"""
|
|
if is_ip_parse(ip):
|
|
return [one_address(ip)]
|
|
return [
|
|
one_address(addr.split("%")[0])
|
|
for addr in get_interface_ips(ip)
|
|
if not addr.lower().startswith(("fe80:", "169.254."))
|
|
]
|
|
|
|
|
|
def udp_relay_key(ip: str, port: int) -> str:
|
|
"""How a UDP relay's upstream is spelled, everywhere it is spelled.
|
|
|
|
The engine reports one `UDP <upstream> <port>` line per relay and is asked for a new
|
|
one by the same token, so the transport's map of them, the rule that points traffic
|
|
at one, and the manager adding an address to a running service all have to agree on
|
|
it exactly. Written out at each of those it stops agreeing the first time one of
|
|
them is changed, and the symptom is a service whose UDP silently goes nowhere.
|
|
"""
|
|
host = one_address(ip)
|
|
return f"[{host}]:{port}" if ip_family(host) == "ip6" else f"{host}:{port}"
|
|
|
|
|
|
def udp_relay_slot(ip: str, port: int, onward: str) -> str:
|
|
"""Which relay carries one address: where it sends, and what is spoken there.
|
|
|
|
The engine names each relay this way on its `UDP <slot> <port>` line, and it is what
|
|
the transport's map, the rule that points at a relay and the manager adding an address
|
|
are keyed on. Where it sends alone was not enough: two addresses reaching one service
|
|
port can want different answers — HTTP/3 relayed as QUIC to `udp/443` beside HTTP/3
|
|
turned into HTTPS on `tcp/443` — and an address whose answer was edited on a running
|
|
service was handed the relay it already had, so the new choice reached nothing until a
|
|
restart. `onward` is `UPSTREAM.env(...)`'s word.
|
|
"""
|
|
return f"{udp_relay_key(ip, port)}|{onward}"
|
|
|
|
|
|
class InstalledRule:
|
|
"""A rule already in the ruleset, matched back to the service that owns it.
|
|
|
|
Rules are found by what they match rather than by a handle we remember, so a
|
|
restart that lost its bookkeeping still cleans up after itself.
|
|
"""
|
|
|
|
def __init__(self, chain: str, handle: int, proto: str, port: int, ip_int: str,
|
|
packets: int = 0, bytes_: int = 0):
|
|
self.chain = chain
|
|
self.handle = handle
|
|
self.proto = proto
|
|
self.port = int(port)
|
|
self.ip_int = str(ip_int)
|
|
#: What the kernel has counted on this rule.
|
|
self.packets = int(packets)
|
|
self.bytes = int(bytes_)
|
|
|
|
def _is(self, ip: str, port: int) -> bool:
|
|
"""Whether this rule matches one particular address and port.
|
|
|
|
Both halves together, because an installed rule is identified by the pair: the
|
|
two legs of a hand-off match different addresses on **different ports**, and
|
|
comparing the port against only one of them is what made the outbound one
|
|
invisible.
|
|
"""
|
|
if self.port != int(port):
|
|
return False
|
|
if is_ip_parse(self.ip_int) and is_ip_parse(ip):
|
|
return ip_parse(self.ip_int) == ip_parse(ip)
|
|
if not is_ip_parse(self.ip_int) and not is_ip_parse(ip):
|
|
return self.ip_int == ip
|
|
return False
|
|
|
|
def matches(self, srv: Service, addr: Address) -> bool:
|
|
"""Whether this installed rule is one of `addr`'s."""
|
|
# The address already carries the transport the kernel matches on, derived
|
|
# from the service's: `tls` is TCP on the wire.
|
|
if self.proto != str(addr.proto):
|
|
return False
|
|
target = resolve_target(srv, addr)
|
|
if target is None:
|
|
return False
|
|
target_ip, target_port = target
|
|
if self._is(target_ip, target_port):
|
|
return True
|
|
# The return leg of an external hand-off matches the operator's proxy, not the
|
|
# service — **and their port, not the service's**. The port was compared against
|
|
# the service's before either identity was considered, so this branch was only
|
|
# ever reached when the two happened to be the same number: every other hand-off
|
|
# left its outbound rule behind on stop, still rewriting the source of anything
|
|
# from that proxy endpoint, and installed a second copy on the next start.
|
|
if srv.transport == TRANSPORT.EXTERNAL and addr.proxy_port and is_ip_parse(target_ip):
|
|
proxy_ip = hijack_endpoint(addr.proxy_ip, target_ip)
|
|
return self._is(proxy_ip, addr.proxy_port)
|
|
return False
|
|
|
|
def matches_any(self, srv: Service, addresses: list[Address]) -> bool:
|
|
return any(self.matches(srv, addr) for addr in addresses)
|
|
|
|
|
|
def _chain(name: str, ctype: str, hook: str, prio: int) -> dict:
|
|
return {
|
|
"add": {
|
|
"chain": {
|
|
"family": "inet",
|
|
"table": FiregexTables.table_name,
|
|
"name": name,
|
|
"type": ctype,
|
|
"hook": hook,
|
|
"prio": prio,
|
|
"policy": "accept",
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
class FiregexTables(NFTableManager):
|
|
# NFQUEUE transport: one pair of chains per position in the filter chain.
|
|
@staticmethod
|
|
def queue_input_chain(position: int) -> str:
|
|
return f"fgex_queue_in_{position}"
|
|
|
|
@staticmethod
|
|
def queue_output_chain(position: int) -> str:
|
|
return f"fgex_queue_out_{position}"
|
|
|
|
# And the connections this host opens to a rewritten address: see LOCAL_QUEUE_PRIORITY.
|
|
@staticmethod
|
|
def queue_local_request_chain(position: int) -> str:
|
|
return f"fgex_queue_lreq_{position}"
|
|
|
|
@staticmethod
|
|
def queue_local_reply_chain(position: int) -> str:
|
|
return f"fgex_queue_lrep_{position}"
|
|
|
|
# External transport: hand the traffic to a proxy the operator runs
|
|
hijack_in_chain = "fgex_hijack_in"
|
|
hijack_local_chain = "fgex_hijack_local"
|
|
hijack_out_chain = "fgex_hijack_out"
|
|
# Proxy transport
|
|
divert_chain = "fgex_divert"
|
|
nat_chain = "fgex_nat"
|
|
nat_output_chain = "fgex_nat_out"
|
|
route_chain = "fgex_route"
|
|
guard_chain = "fgex_guard"
|
|
#: `l4proto . port` of every listener and relay a proxy-layer redirect points at.
|
|
datapath_set = "fgex_datapath"
|
|
|
|
def __init__(self):
|
|
super().__init__(
|
|
[
|
|
*[
|
|
chain
|
|
for position in range(MAX_CHAIN_POSITIONS)
|
|
# Increasing priority means increasing position: netfilter walks the
|
|
# base chains of a hook in priority order, so this *is* the chain.
|
|
for chain in (
|
|
_chain(self.queue_input_chain(position), "filter", "prerouting",
|
|
-307 + position),
|
|
_chain(self.queue_output_chain(position), "filter", "postrouting",
|
|
107 + position),
|
|
_chain(self.queue_local_request_chain(position), "filter", "output",
|
|
LOCAL_QUEUE_PRIORITY + position),
|
|
_chain(self.queue_local_reply_chain(position), "filter", "input",
|
|
LOCAL_QUEUE_PRIORITY + position),
|
|
)
|
|
],
|
|
# Earlier than the queue chains, so traffic destined for somebody else's
|
|
# proxy is redirected before anything here tries to inspect it.
|
|
_chain(self.hijack_in_chain, "filter", "prerouting", -310),
|
|
# Locally generated traffic never reaches prerouting: it goes straight
|
|
# from output to postrouting. Without this chain a hand-off would work
|
|
# for the outside world and silently not for anything on this host —
|
|
# including firegex's own tests, which is how the gap was found.
|
|
_chain(self.hijack_local_chain, "filter", "output", -310),
|
|
_chain(self.hijack_out_chain, "filter", "postrouting", 110),
|
|
# Before conntrack, so a reply to one of the engine's transparent
|
|
# sockets is recognised as ours before anything rewrites it.
|
|
_chain(self.divert_chain, "filter", "prerouting", -150),
|
|
# Installed once, never per service: a packet that already belongs to
|
|
# one of the engine's transparent sockets is a reply to a connection
|
|
# it opened while impersonating a client, and has to be delivered
|
|
# locally rather than forwarded on towards that client.
|
|
{
|
|
"add": {
|
|
"rule": {
|
|
"family": "inet",
|
|
"table": self.table_name,
|
|
"chain": self.divert_chain,
|
|
"expr": [
|
|
{
|
|
"match": {
|
|
"op": "==",
|
|
"left": {"socket": {"key": "transparent"}},
|
|
"right": 1,
|
|
}
|
|
},
|
|
{"mangle": {"key": {"meta": {"key": "mark"}}, "value": PROXY_MARK}},
|
|
{"accept": None},
|
|
],
|
|
}
|
|
}
|
|
},
|
|
# The same, for a reply the socket lookup cannot place: one to a connection
|
|
# the engine opened towards an address somebody rewrote — a port a container
|
|
# runtime publishes, above all. The engine dials the published address as
|
|
# the client, Docker's DNAT sends it on to the container, and the container
|
|
# answers from its own address: `socket transparent` looks for a socket
|
|
# talking to *that* and finds none, so the answer was routed on to the real
|
|
# client, which reset it. A client on another host reached a Docker service
|
|
# through the proxy layer only after the dial timed out and was retried as
|
|
# this host — slowly, and with the client's address lost. Conntrack still
|
|
# knows whose connection it is, by the mark `fgex_route` copied onto it.
|
|
{
|
|
"add": {
|
|
"rule": {
|
|
"family": "inet",
|
|
"table": self.table_name,
|
|
"chain": self.divert_chain,
|
|
"expr": [
|
|
{
|
|
"match": {
|
|
"op": "==",
|
|
"left": {"ct": {"key": "direction"}},
|
|
"right": "reply",
|
|
}
|
|
},
|
|
{
|
|
"match": {
|
|
"op": "==",
|
|
"left": {"ct": {"key": "mark"}},
|
|
"right": PROXY_SELF_MARK,
|
|
}
|
|
},
|
|
{"mangle": {"key": {"meta": {"key": "mark"}}, "value": PROXY_MARK}},
|
|
{"accept": None},
|
|
],
|
|
}
|
|
}
|
|
},
|
|
_chain(self.nat_chain, "nat", "prerouting", NAT_PRIORITY),
|
|
# Locally generated traffic never reaches the nat prerouting hook, and
|
|
# a client on this host protecting a service on this host is exactly that.
|
|
# Same priority for the same reason: a container runtime publishes a port
|
|
# in `nat OUTPUT` as well, so the race is the same one on this hook.
|
|
_chain(self.nat_output_chain, "nat", "output", NAT_PRIORITY),
|
|
# `route`, so changing the mark forces the packet to be re-routed.
|
|
_chain(self.route_chain, "route", "output", -150),
|
|
# **The engine's own ports are reached through a redirect or not at all.**
|
|
# Its TCP listener and every UDP and QUIC relay are bound to the wildcard,
|
|
# because a redirect delivers to whichever address the packet arrived on,
|
|
# so each of them could be dialled straight from anywhere that reaches the
|
|
# host. A relay forwards to its service whoever sent the datagram: a
|
|
# service protected on `127.0.0.1:53` was on the network through the
|
|
# relay's port. What arrived by the redirect carries conntrack's DNAT
|
|
# status; anything else aimed at one of these ports is dropped. Replies
|
|
# to the engine's transparent sockets are marked by `fgex_divert` and
|
|
# left alone, since a client's source port can be any number at all.
|
|
{
|
|
"add": {
|
|
"set": {
|
|
"family": "inet",
|
|
"table": self.table_name,
|
|
"name": self.datapath_set,
|
|
"type": ["inet_proto", "inet_service"],
|
|
}
|
|
}
|
|
},
|
|
_chain(self.guard_chain, "filter", "input", 0),
|
|
{
|
|
"add": {
|
|
"rule": {
|
|
"family": "inet",
|
|
"table": self.table_name,
|
|
"chain": self.guard_chain,
|
|
"expr": [
|
|
{
|
|
"match": {
|
|
"op": "!=",
|
|
"left": {"meta": {"key": "mark"}},
|
|
"right": PROXY_MARK,
|
|
}
|
|
},
|
|
{
|
|
"match": {
|
|
"op": "==",
|
|
"left": {"&": [{"ct": {"key": "status"}}, "dnat"]},
|
|
"right": 0,
|
|
}
|
|
},
|
|
{
|
|
"match": {
|
|
"op": "==",
|
|
"left": {"concat": [
|
|
{"meta": {"key": "l4proto"}},
|
|
{"payload": {"protocol": "th", "field": "dport"}},
|
|
]},
|
|
"right": f"@{self.datapath_set}",
|
|
}
|
|
},
|
|
self.COUNTER,
|
|
{"drop": None},
|
|
],
|
|
}
|
|
}
|
|
},
|
|
# Installed once. A packet on the intercepted (redirected) connection
|
|
# is the engine answering a client and has to leave normally; a reply to
|
|
# the engine's own dial is marked home by the two rules after this one.
|
|
{
|
|
"add": {
|
|
"rule": {
|
|
"family": "inet",
|
|
"table": self.table_name,
|
|
"chain": self.route_chain,
|
|
"expr": [
|
|
{
|
|
"match": {
|
|
"op": "in",
|
|
"left": {"ct": {"key": "status"}},
|
|
"right": "dnat",
|
|
}
|
|
},
|
|
{"accept": None},
|
|
],
|
|
}
|
|
}
|
|
},
|
|
# **A reply to a connection the engine opened, from whatever port it
|
|
# comes — and nothing else.** A per-address rule used to do this, knowing
|
|
# the service by the address it is protected on. That is where the engine
|
|
# dials it only while the address *is* the service: one published on
|
|
# another port is dialled there, and an HTTP/3 edge in front of an
|
|
# HTTP/1.1 service is dialled over TCP, and their answers were routed out
|
|
# towards the client instead of home. It also matched the answers on
|
|
# connections the engine never saw, the ones open before the service
|
|
# started, and brought those home to nobody. Conntrack knows who opened a
|
|
# connection: the engine's mark is copied onto it on the way out and read
|
|
# back off everything that answers it.
|
|
{
|
|
"add": {
|
|
"rule": {
|
|
"family": "inet",
|
|
"table": self.table_name,
|
|
"chain": self.route_chain,
|
|
"expr": [
|
|
{
|
|
"match": {
|
|
"op": "==",
|
|
"left": {"meta": {"key": "mark"}},
|
|
"right": PROXY_SELF_MARK,
|
|
}
|
|
},
|
|
{"mangle": {"key": {"ct": {"key": "mark"}},
|
|
"value": PROXY_SELF_MARK}},
|
|
],
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"add": {
|
|
"rule": {
|
|
"family": "inet",
|
|
"table": self.table_name,
|
|
"chain": self.route_chain,
|
|
"expr": [
|
|
{
|
|
"match": {
|
|
"op": "==",
|
|
"left": {"ct": {"key": "direction"}},
|
|
"right": "reply",
|
|
}
|
|
},
|
|
{
|
|
"match": {
|
|
"op": "==",
|
|
"left": {"ct": {"key": "mark"}},
|
|
"right": PROXY_SELF_MARK,
|
|
}
|
|
},
|
|
{"mangle": {"key": {"meta": {"key": "mark"}},
|
|
"value": PROXY_MARK}},
|
|
],
|
|
}
|
|
}
|
|
},
|
|
],
|
|
# The whole table, which is firegex's alone. It used to be flushed chain by
|
|
# chain in one batch, and nft refuses a batch outright if any object in it is
|
|
# missing — so a table left by a version with a different set of chains (an
|
|
# upgrade after a crash, say) was never cleared at all: its rules went on
|
|
# steering traffic at queues and ports nobody listened on any more.
|
|
[{"delete": {"table": {"family": "inet", "name": self.table_name}}}],
|
|
)
|
|
|
|
#: How the mark and the routing table are spelled to `ip`, derived from the constants
|
|
#: above rather than written out. They were four literal `"0x1339"` and `"1339"`
|
|
#: strings beside the two names that already held those numbers — the shape of drift
|
|
#: this file argues against everywhere else, and the one place where it would be
|
|
#: silent: the policy route would simply point at a table nothing marks.
|
|
PROXY_MARK_ARG = f"{PROXY_MARK:#x}"
|
|
PROXY_TABLE_ARG = str(PROXY_ROUTE_TABLE)
|
|
|
|
def _policy_route(self, action: str) -> None:
|
|
"""Add or delete the rule and route that bring the engine's return traffic home.
|
|
|
|
`check=False` throughout: adding one that is already there and deleting one that
|
|
is not are both the state being asked for, and neither is worth an exception.
|
|
"""
|
|
for family in ([], ["-6"]):
|
|
subprocess.run(
|
|
["ip", *family, "rule", action, "fwmark", self.PROXY_MARK_ARG,
|
|
"lookup", self.PROXY_TABLE_ARG],
|
|
check=False, stderr=subprocess.DEVNULL,
|
|
)
|
|
if action != "add":
|
|
# The route goes with the rule that reached it, so deleting it is belt and
|
|
# braces rather than a step of its own.
|
|
return
|
|
for family in ([], ["-6"]):
|
|
subprocess.run(
|
|
["ip", *family, "route", "add", "local", "default", "dev", "lo",
|
|
"table", self.PROXY_TABLE_ARG],
|
|
check=False, stderr=subprocess.DEVNULL,
|
|
)
|
|
|
|
def intact(self) -> bool:
|
|
"""Whether what `init()` put in the kernel is still there.
|
|
|
|
One small chain rather than the whole table, because this is asked every few
|
|
seconds: `init()` always leaves rules in the route chain, so a table that was
|
|
deleted (`nft flush ruleset`, which is how a Debian `/etc/nftables.conf` begins)
|
|
and one that was only emptied (`nft flush table`) both fail it.
|
|
"""
|
|
code, listed, _ = self.raw_cmd({"list": {"chain": {
|
|
"family": "inet", "table": self.table_name, "name": self.route_chain,
|
|
}}})
|
|
return code == 0 and any("rule" in item for item in listed["nftables"])
|
|
|
|
def reset(self):
|
|
super().reset()
|
|
# The set went with the table.
|
|
self._guarded.clear()
|
|
try:
|
|
self._policy_route("del")
|
|
except Exception:
|
|
pass
|
|
|
|
def init(self):
|
|
super().init()
|
|
# The `local` default route is what makes marked packets be delivered here
|
|
# instead of routed by destination; without it a preserved-source connection
|
|
# does not fail, it hangs.
|
|
try:
|
|
self._policy_route("add")
|
|
except Exception as e:
|
|
print("Failed to configure ip rules for transparent proxy:", e)
|
|
|
|
# --- rule fragments -------------------------------------------------------
|
|
|
|
def _not_ours(self) -> list:
|
|
"""Skip the connections the engine opened itself."""
|
|
return [
|
|
{
|
|
"match": {
|
|
"op": "!=",
|
|
"left": {"meta": {"key": "mark"}},
|
|
"right": PROXY_SELF_MARK,
|
|
}
|
|
}
|
|
]
|
|
|
|
#: Counted by the kernel on the rules that intercept a service's traffic.
|
|
#:
|
|
#: Free — netfilter already evaluates these rules — and it is the only measure of
|
|
#: "how much arrived" that no filter can skew, because nothing in userspace is
|
|
#: involved in producing it. It counts packets, which is what the wire carries; a
|
|
#: block is a refused connection, so the two are never divided by one another.
|
|
COUNTER = {"counter": {"packets": 0, "bytes": 0}}
|
|
|
|
def _match(self, ip: str, port: int, family: str, l4: str, addr: str, field: str) -> list:
|
|
if not is_ip_parse(ip):
|
|
iface_key = "iifname" if addr == "daddr" else "oifname"
|
|
return [
|
|
{
|
|
"match": {
|
|
"op": "==",
|
|
"left": {"meta": {"key": iface_key}},
|
|
"right": ip,
|
|
}
|
|
},
|
|
{
|
|
"match": {
|
|
"op": "==",
|
|
"left": {"payload": {"protocol": l4, "field": field}},
|
|
"right": int(port),
|
|
}
|
|
},
|
|
]
|
|
return [
|
|
{
|
|
"match": {
|
|
"op": "==",
|
|
"left": {"payload": {"protocol": family, "field": addr}},
|
|
"right": nftables_int_to_json(ip),
|
|
}
|
|
},
|
|
{
|
|
"match": {
|
|
"op": "==",
|
|
"left": {"payload": {"protocol": l4, "field": field}},
|
|
"right": int(port),
|
|
}
|
|
},
|
|
]
|
|
|
|
def _rule(self, chain: str, expr: list, insert: bool = False,
|
|
comment: str | None = None) -> dict:
|
|
verb = "insert" if insert else "add"
|
|
return {
|
|
verb: {
|
|
"rule": {
|
|
"family": "inet",
|
|
"table": self.table_name,
|
|
"chain": chain,
|
|
"expr": expr,
|
|
**({"comment": comment} if comment else {}),
|
|
}
|
|
}
|
|
}
|
|
|
|
# --- installing -----------------------------------------------------------
|
|
|
|
def add(self, srv: Service, addresses: list[Address] | None = None, *,
|
|
queue_nums: list[int] | None = None, proxy_port: int | None = None,
|
|
udp_ports: dict | None = None):
|
|
"""Steer a started service's traffic into whatever is now listening for it.
|
|
|
|
Always called after the process is up: a rule pointing at a queue nobody is
|
|
reading, or a port nobody is bound to, is worse than no rule at all.
|
|
|
|
One address at a time, each skipped if it is already steered. Passing a subset
|
|
is how an address is added to a running service without the others losing their
|
|
connections — the datapath is the same one, only the rules that point at it are
|
|
new.
|
|
"""
|
|
installed = self.get()
|
|
for addr in addresses if addresses is not None else srv.addresses:
|
|
if any(rule.matches(srv, addr) for rule in installed):
|
|
continue # already steered; nothing to do and nothing to duplicate
|
|
|
|
target = resolve_target(srv, addr)
|
|
if target is None:
|
|
continue
|
|
target_ip, target_port = target
|
|
is_iface = not is_ip_parse(target_ip)
|
|
family = "ip" if is_iface else ip_family(target_ip)
|
|
l4 = str(addr.proto)
|
|
|
|
if srv.transport == TRANSPORT.NFQUEUE:
|
|
self._add_queue(srv, target_ip, target_port, family, l4, queue_nums or [])
|
|
elif srv.transport == TRANSPORT.EXTERNAL:
|
|
self._add_external(srv, addr, target_ip, target_port, family, l4)
|
|
else:
|
|
# UDP is relayed by a socket of its own per address, so the rule points
|
|
# at that address's port rather than at the one shared TCP listener.
|
|
port = proxy_port
|
|
if l4 == "udp":
|
|
# Keyed by where the relay *sends* and what it speaks there, which is
|
|
# how the engine reported it: a published address and the service
|
|
# behind it are two ports, and the relay is filed under the second.
|
|
service_ip, service_port = service_at(srv, addr)
|
|
key = udp_relay_slot(udp_relay_host(service_ip), service_port,
|
|
UPSTREAM.env(addr.upstream))
|
|
port = (udp_ports or {}).get(key)
|
|
if port is None:
|
|
raise Exception(
|
|
f"the proxy transport has no UDP relay listening for {key}"
|
|
)
|
|
self._add_proxy(srv, target_ip, target_port, family, l4, port)
|
|
self._guard(srv, l4, port)
|
|
|
|
def _add_queue(self, srv: Service, ip: str, port: int, family: str, l4: str,
|
|
queue_nums: list[int]):
|
|
"""One rule pair per filter, each in the chain that runs at its position.
|
|
|
|
A packet a filter accepts goes on to the next base chain in the same hook, so
|
|
the filters see it in position order and the first one to refuse it is the last
|
|
one that sees it. That is the whole mechanism: the chain is the priorities.
|
|
"""
|
|
if not queue_nums:
|
|
raise Exception("the nfqueue transport needs at least one queue number")
|
|
if len(queue_nums) > MAX_CHAIN_POSITIONS:
|
|
raise Exception(
|
|
f"the nfqueue transport chains at most {MAX_CHAIN_POSITIONS} filters"
|
|
)
|
|
cmds = []
|
|
for position, queue_num in enumerate(queue_nums):
|
|
# **`bypass` is the service's own fail-open policy, not a constant.** It tells
|
|
# the kernel to accept a packet when nothing is bound to the queue, which is
|
|
# exactly what happens when the interceptor dies — and it was set on every
|
|
# rule regardless. So a service with fail_open *off*, whose whole point is
|
|
# that traffic stops rather than passes when filtering stops, went wide open
|
|
# the moment its binary crashed: the one case the setting exists for. The
|
|
# other half of the same policy is `FIREGEX_NFQUEUE_FAIL_OPEN`, which decides
|
|
# what the process does while it is alive; the two have to agree or the
|
|
# answer depends on how the filtering stopped.
|
|
queue = {"queue": {
|
|
"num": str(queue_num),
|
|
**({"flags": ["bypass"]} if srv.fail_open else {}),
|
|
}}
|
|
cmds.append(self._rule(
|
|
self.queue_output_chain(position),
|
|
self._match(ip, port, family, l4, "saddr", "sport")
|
|
+ [
|
|
self.COUNTER,
|
|
{"mangle": {"key": {"meta": {"key": "mark"}}, "value": QUEUE_MARK_OUTPUT}},
|
|
queue,
|
|
],
|
|
insert=True,
|
|
))
|
|
cmds.append(self._rule(
|
|
self.queue_input_chain(position),
|
|
self._match(ip, port, family, l4, "daddr", "dport")
|
|
+ [
|
|
self.COUNTER,
|
|
{"mangle": {"key": {"meta": {"key": "mark"}}, "value": QUEUE_MARK_INPUT}},
|
|
queue,
|
|
],
|
|
insert=True,
|
|
))
|
|
if not is_ip_parse(ip):
|
|
# A connection this host opens is not matched by interface, for the reason
|
|
# the proxy layer's output-hook rules give — and what these catch is one
|
|
# to an address, rewritten.
|
|
continue
|
|
# This host dialling one of these addresses when something rewrote it on the
|
|
# way out: a port Docker publishes, which is how a CTF service is usually
|
|
# reached. Recognised by where the connection was *going*, which conntrack
|
|
# keeps, because by now the packet itself says the container's address.
|
|
# Without these the request and the answer both went round every queue here,
|
|
# and a payload the filter refuses from anywhere else reached the service
|
|
# from this host untouched.
|
|
original = self._rewritten_to(ip, port, family, l4)
|
|
note = f"{ADDRESS_COMMENT}{l4} {int(port)} {ip}"
|
|
cmds.append(self._rule(
|
|
self.queue_local_request_chain(position),
|
|
original + self._ct_direction("original") + [
|
|
self.COUNTER,
|
|
{"mangle": {"key": {"meta": {"key": "mark"}}, "value": QUEUE_MARK_INPUT}},
|
|
queue,
|
|
],
|
|
insert=True, comment=note,
|
|
))
|
|
cmds.append(self._rule(
|
|
self.queue_local_reply_chain(position),
|
|
original + self._ct_direction("reply") + [
|
|
{"mangle": {"key": {"meta": {"key": "mark"}}, "value": QUEUE_MARK_OUTPUT}},
|
|
queue,
|
|
],
|
|
insert=True, comment=note,
|
|
))
|
|
self.cmd(*cmds)
|
|
|
|
@staticmethod
|
|
def _rewritten_to(ip: str, port: int, family: str, l4: str) -> list:
|
|
"""A connection that was headed for `ip:port` and had its destination rewritten."""
|
|
return [
|
|
{"match": {"op": "in", "left": {"ct": {"key": "status"}}, "right": "dnat"}},
|
|
{"match": {"op": "==", "left": {"meta": {"key": "l4proto"}}, "right": l4}},
|
|
{"match": {"op": "==",
|
|
"left": {"ct": {"key": f"{family} daddr", "dir": "original"}},
|
|
"right": nftables_int_to_json(ip)}},
|
|
{"match": {"op": "==",
|
|
"left": {"ct": {"key": "proto-dst", "dir": "original"}},
|
|
"right": int(port)}},
|
|
]
|
|
|
|
@staticmethod
|
|
def _ct_direction(direction: str) -> list:
|
|
return [{"match": {"op": "==", "left": {"ct": {"key": "direction"}},
|
|
"right": direction}}]
|
|
|
|
def _add_external(self, srv: Service, addr: Address, ip: str, port: int, family: str,
|
|
l4: str):
|
|
"""Put somebody else's proxy in the path, invisibly.
|
|
|
|
A stateless rewrite rather than NAT: the destination is changed on the way in
|
|
and changed back on the way out, so the operator's proxy sees the connection
|
|
arrive and the client sees answers from the address it dialled. Conntrack is
|
|
not involved, which is what lets this coexist with a service that is also being
|
|
proxied or queued elsewhere.
|
|
|
|
The proxy endpoint belongs to the address, not to the service: the return rule
|
|
recognises traffic by the proxy's own address and port and rewrites the source
|
|
port back, so two addresses sharing one proxy endpoint could not be told apart
|
|
on the way out. Every address gets its own.
|
|
"""
|
|
if not is_ip_parse(ip):
|
|
raise Exception("the external transport requires a concrete IP address, not an interface")
|
|
if not addr.proxy_port:
|
|
raise Exception("the external transport needs the port your proxy listens on")
|
|
# One host, not a network: a hand-off points at the single address the
|
|
# operator's proxy is listening on, so any prefix a normaliser added is dropped.
|
|
proxy_ip = hijack_endpoint(addr.proxy_ip, ip)
|
|
inbound = self._match(ip, port, family, l4, "daddr", "dport") + [
|
|
self.COUNTER,
|
|
{"mangle": {"key": {"payload": {"protocol": l4, "field": "dport"}},
|
|
"value": int(addr.proxy_port)}},
|
|
{"mangle": {"key": {"payload": {"protocol": family, "field": "daddr"}},
|
|
"value": addr_parse(one_address(proxy_ip))}},
|
|
]
|
|
self.cmd(
|
|
self._rule(self.hijack_in_chain, inbound, insert=True),
|
|
self._rule(self.hijack_local_chain, inbound, insert=True),
|
|
self._rule(
|
|
self.hijack_out_chain,
|
|
self._match(proxy_ip, addr.proxy_port, ip_family(proxy_ip), l4, "saddr", "sport")
|
|
+ [
|
|
{"mangle": {"key": {"payload": {"protocol": l4, "field": "sport"}},
|
|
"value": int(port)}},
|
|
{"mangle": {"key": {"payload": {"protocol": ip_family(proxy_ip), "field": "saddr"}},
|
|
"value": addr_parse(one_address(ip))}},
|
|
],
|
|
insert=True,
|
|
),
|
|
)
|
|
|
|
def _add_proxy(self, srv: Service, ip: str, port: int, family: str, l4: str, proxy_port: int):
|
|
if proxy_port is None:
|
|
raise Exception("the proxy transport needs a listening port")
|
|
# Conntrack rewrites the destination and remembers the original, which the
|
|
# engine reads back with SO_ORIGINAL_DST. It is also what tells the return
|
|
# rule which packets are the engine answering a client.
|
|
#
|
|
# Both hooks: prerouting catches what arrives from outside, output catches
|
|
# what this host generates, which is the only way a client on this host reaches
|
|
# a service on it. Conntrack NATs a connection once, so a rule in each is not a
|
|
# double translation.
|
|
is_iface = not is_ip_parse(ip)
|
|
# **The match is the interface, and nothing narrower.** Pinning the destination to
|
|
# the addresses the interface carries was tried, to stop a service protecting
|
|
# `docker0:443` from also taking every container's outbound HTTPS — which it does,
|
|
# measured, terminating it with the service's certificate. It was reverted: an
|
|
# interface name exists precisely for the case where **the address is not the
|
|
# operator's to know**, which includes firegex sitting in front of a service on
|
|
# another machine reached through that link, and pinning to this host's own
|
|
# addresses quietly removes that. Protecting an interface protects what crosses
|
|
# it; naming a link that carries other people's traffic is a choice, and the
|
|
# answer to not wanting it is a narrower address, not a narrower rule.
|
|
redirect = (
|
|
self._not_ours()
|
|
+ self._match(ip, port, family, l4, "daddr", "dport")
|
|
+ [self.COUNTER, {"redirect": {"port": int(proxy_port)}}]
|
|
)
|
|
# Nothing per service on the way back. The engine's answers are brought home by
|
|
# the conntrack mark on the connections it opened (`init()`), whatever address
|
|
# and port they come from. A rule here used to mark everything leaving the
|
|
# service's address and port as well, which added nothing for the engine and
|
|
# caught the connections that were open before the service started: nothing
|
|
# redirected those, so their answers belonged to the client, and a client on
|
|
# another host stopped hearing back the moment protection began.
|
|
cmds = [self._rule(self.nat_chain, redirect)]
|
|
if not is_iface:
|
|
cmds.append(self._rule(self.nat_output_chain, redirect))
|
|
else:
|
|
# An interface address has to be caught in the output hook too, and cannot be
|
|
# caught the same way. `iifname` means nothing for a packet this host is
|
|
# generating, and `oifname` is the wrong question twice over: `oifname eth0
|
|
# dport 80` would mean *we* are calling somebody else's port 80, which is the
|
|
# host's own outbound traffic and none of firegex's business — and a
|
|
# connection from this host to one of its **own** addresses is routed through
|
|
# `lo` anyway, so it would never match.
|
|
#
|
|
# So the destination is matched instead, against the addresses that interface
|
|
# carries. That is exactly "this host calling a service it protects", and it
|
|
# is what an operator means when they protect `lo` and then curl `127.0.0.1`.
|
|
# Without it the service was intercepted for the outside world and silently
|
|
# not for anything on the box, which is the shape of an interception canary
|
|
# failing and is how this was reported.
|
|
#
|
|
# The addresses are resolved now rather than stored, and that is the cost:
|
|
# one the interface gains later is not covered *for traffic this host
|
|
# generates* until the service restarts. Inbound keeps matching `iifname`, so
|
|
# it follows the interface as it always did. `udp_relay_host` pays the same
|
|
# price for the same reason.
|
|
#
|
|
# The rule carries the interface's name as a comment because nothing else in
|
|
# it does. `delete()` finds a rule by what it matches, and these match an
|
|
# address; without the comment they would be left behind on every stop — a
|
|
# redirect to a port nobody is listening on any more.
|
|
for addr in interface_addresses(ip):
|
|
cmds.append(self._rule(
|
|
self.nat_output_chain,
|
|
self._not_ours()
|
|
+ self._match(addr, port, ip_family(addr), l4, "daddr", "dport")
|
|
+ [self.COUNTER, {"redirect": {"port": int(proxy_port)}}],
|
|
comment=f"{IFACE_COMMENT}{ip}",
|
|
))
|
|
self.cmd(*cmds)
|
|
|
|
# --- reading back ---------------------------------------------------------
|
|
|
|
def get(self) -> list[InstalledRule]:
|
|
res = []
|
|
chains = [
|
|
*[self.queue_input_chain(p) for p in range(MAX_CHAIN_POSITIONS)],
|
|
*[self.queue_output_chain(p) for p in range(MAX_CHAIN_POSITIONS)],
|
|
*[self.queue_local_request_chain(p) for p in range(MAX_CHAIN_POSITIONS)],
|
|
*[self.queue_local_reply_chain(p) for p in range(MAX_CHAIN_POSITIONS)],
|
|
self.hijack_in_chain,
|
|
self.hijack_local_chain,
|
|
self.hijack_out_chain,
|
|
self.divert_chain,
|
|
self.nat_chain,
|
|
self.nat_output_chain,
|
|
self.route_chain,
|
|
]
|
|
for rule in self.list_rules(tables=[self.table_name], chains=chains, family="inet"):
|
|
try:
|
|
expr = rule["expr"]
|
|
note = str(rule.get("comment") or "")
|
|
counter = next(
|
|
(e["counter"] for e in expr if isinstance(e, dict) and "counter" in e),
|
|
{},
|
|
)
|
|
if note.startswith(ADDRESS_COMMENT):
|
|
proto, port, ip_int = note[len(ADDRESS_COMMENT):].split(" ", 2)
|
|
res.append(InstalledRule(
|
|
chain=rule["chain"], handle=int(rule["handle"]), proto=proto,
|
|
port=int(port), ip_int=ip_int,
|
|
packets=counter.get("packets", 0), bytes_=counter.get("bytes", 0),
|
|
))
|
|
continue
|
|
# An intercept rule starts with the self-mark exclusion; every other
|
|
# per-service rule starts with the address. The two rules init()
|
|
# installs match on `socket` or on `ct`, and fall out here.
|
|
if "meta" in expr[0].get("match", {}).get("left", {}):
|
|
if expr[0].get("match", {}).get("left", {}).get("meta", {}).get("key") == "mark":
|
|
expr = expr[1:]
|
|
match_left = expr[0].get("match", {}).get("left", {})
|
|
if "payload" in match_left:
|
|
right = expr[0]["match"]["right"]
|
|
if isinstance(right, str):
|
|
ip_int = str(ip_parse(right))
|
|
else:
|
|
ip_int = f'{right["prefix"]["addr"]}/{right["prefix"]["len"]}'
|
|
elif "meta" in match_left and match_left["meta"].get("key") in ("iifname", "oifname"):
|
|
ip_int = str(expr[0]["match"]["right"])
|
|
else:
|
|
continue
|
|
# An output-hook rule installed for an interface matches one of that
|
|
# interface's addresses, so what it matches does not say whose it is.
|
|
# The comment does, and reading it back here is what lets the rest of
|
|
# this file go on treating the rule as the interface's — including
|
|
# `delete()`, which would otherwise never find it.
|
|
if note.startswith(IFACE_COMMENT):
|
|
ip_int = note[len(IFACE_COMMENT):]
|
|
res.append(
|
|
InstalledRule(
|
|
chain=rule["chain"],
|
|
handle=int(rule["handle"]),
|
|
proto=expr[1]["match"]["left"]["payload"]["protocol"],
|
|
port=expr[1]["match"]["right"],
|
|
ip_int=ip_int,
|
|
packets=counter.get("packets", 0),
|
|
bytes_=counter.get("bytes", 0),
|
|
)
|
|
)
|
|
except (KeyError, TypeError, IndexError, ValueError):
|
|
continue # a rule of a shape we did not write; not ours to touch
|
|
return res
|
|
|
|
def traffic(self, srv: Service, addresses: list[Address] | None = None) -> dict:
|
|
"""How much has arrived for this service, counted by the kernel.
|
|
|
|
Only the *inbound* rules, and only one of them per address. The nfqueue layer
|
|
installs a rule pair per filter, all seeing the same packets, so summing them
|
|
would multiply the answer by the length of the chain; and the return-path rules
|
|
count traffic going the other way, which is a different question. Position zero
|
|
is the first filter in the chain, so it sees everything.
|
|
|
|
Packets, never connections: this is what the wire carried. A block is a refused
|
|
connection, so the two are reported side by side and never divided.
|
|
"""
|
|
# Only the layers whose rules the kernel evaluates for **every** packet.
|
|
#
|
|
# The proxy layer's rule lives in a `nat` chain, and conntrack translates a
|
|
# connection once: every packet after the first is handled from the conntrack
|
|
# entry without the rule being walked again. Its counter therefore counts new
|
|
# connections, not packets — a perfectly good number, and not the one the label
|
|
# says. The engine already reports connections properly, so this reports nothing
|
|
# there rather than a packet count that is off by the length of every flow.
|
|
inbound = {
|
|
# Two chains, but never both for one packet: from elsewhere a request passes
|
|
# prerouting, and from this host to a rewritten address it passes only output.
|
|
TRANSPORT.NFQUEUE: {self.queue_input_chain(0), self.queue_local_request_chain(0)},
|
|
TRANSPORT.EXTERNAL: {self.hijack_in_chain, self.hijack_local_chain},
|
|
}.get(srv.transport, set())
|
|
targets = addresses if addresses is not None else srv.addresses
|
|
packets = total_bytes = 0
|
|
for rule in self.get():
|
|
if rule.chain in inbound and rule.matches_any(srv, targets):
|
|
packets += rule.packets
|
|
total_bytes += rule.bytes
|
|
return {"packets": packets, "bytes": total_bytes}
|
|
|
|
def delete(self, srv: Service, addresses: list[Address] | None = None,
|
|
keep_guards: bool = False) -> set[tuple[str, int]]:
|
|
"""Take back the rules for these addresses, or for all of the service's.
|
|
|
|
Passing a subset is how one address is removed from a running service: the
|
|
others keep their rules, and the datapath keeps their connections.
|
|
|
|
`keep_guards` leaves the engine's ports guarded and hands them back instead, for
|
|
`release_guards` once the engine has gone: one draining the connections it still
|
|
carries is listening on them until then.
|
|
"""
|
|
targets = addresses if addresses is not None else srv.addresses
|
|
cmds = [
|
|
{
|
|
"delete": {
|
|
"rule": {
|
|
"family": "inet",
|
|
"table": self.table_name,
|
|
"chain": rule.chain,
|
|
"handle": rule.handle,
|
|
}
|
|
}
|
|
}
|
|
for rule in self.get()
|
|
if rule.matches_any(srv, targets)
|
|
]
|
|
if cmds:
|
|
self.cmd(*cmds)
|
|
# Only when the whole service comes down. An address taken off a running service
|
|
# leaves its relay bound in the engine — relays live as long as the engine does —
|
|
# so its port stays guarded until the engine goes; released earlier, the relay
|
|
# would be reachable directly again with nothing steering traffic at it.
|
|
if addresses is None:
|
|
guarded = self._guarded.pop(str(srv.id), set())
|
|
if keep_guards:
|
|
return guarded
|
|
self.release_guards(guarded)
|
|
return set()
|
|
|
|
def release_guards(self, ports: set[tuple[str, int]]) -> None:
|
|
"""Stop guarding ports an engine no longer listens on.
|
|
|
|
Except one some running service is guarding now: an engine that drained for
|
|
minutes gave its port back, and the kernel is free to have handed it to another.
|
|
"""
|
|
owned = set().union(*self._guarded.values()) if self._guarded else set()
|
|
for l4, port in ports:
|
|
if (l4, port) not in owned:
|
|
self._unguard(l4, port)
|
|
|
|
def _element(self, verb: str, l4: str, port: int) -> dict:
|
|
return {
|
|
verb: {
|
|
"element": {
|
|
"family": "inet",
|
|
"table": self.table_name,
|
|
"name": self.datapath_set,
|
|
"elem": [{"concat": [l4, int(port)]}],
|
|
}
|
|
}
|
|
}
|
|
|
|
#: Which engine ports each running service has had guarded, so that all of them are
|
|
#: released when it stops — including a relay whose address was removed while it ran.
|
|
#: Kept here rather than read back from the rules for exactly that relay: nothing
|
|
#: points at it any more, and an element left in the set would drop traffic for
|
|
#: whatever process is given that port next.
|
|
_guarded: dict[str, set[tuple[str, int]]] = {}
|
|
|
|
def _guard(self, srv: Service, l4: str, port: int) -> None:
|
|
"""Refuse whatever reaches `port` without having been redirected there."""
|
|
self.cmd(self._element("add", l4, port))
|
|
self._guarded.setdefault(str(srv.id), set()).add((l4, int(port)))
|
|
|
|
def _unguard(self, l4: str, port: int) -> None:
|
|
# Separately, and tolerated: an element that is not there makes nft refuse the
|
|
# command, and the rules it belonged to are already gone either way.
|
|
self.raw_cmd(self._element("delete", l4, port))
|