firegex/backend/modules/services/pyworker.py

476 lines
22 KiB
Python

#!/usr/bin/env python3
"""Runs a user's Python filters, one process away from the datapath.
Started by the engine, never by the backend: the engine is the one that has to kill it
when it stops answering, and something else owning its lifetime would defeat the point
of moving the code out of process at all.
The filters are run through the **real** `firegex.pyfilters` library — the same one the
NFQUEUE binary embeds, the same one `fgex pyfilters` simulates against, the same one the
documentation describes. An earlier version of this file defined its own look-alike
`@pyfilter` that took `(data, direction)`, and that was a mistake of exactly the kind a
regex engine written twice would be: a filter written from the docs loaded on one
transport and failed on the other, and the docs were right about neither.
Each connection gets its own set of module globals, which is what the API promises —
code at module level runs once per stream and the same globals are reused for every
packet of that stream. That is why every frame carries a connection id, and why the
engine sends a close frame: without one, a service would accumulate one set of globals
per connection for as long as it ran.
Frames are length-prefixed and binary:
in [u32 len][u8 kind][u64 connection][payload]
kind: 0 client->server, 1 server->client,
2 connection closed, 3 connection opened
out [u32 len][u8 verdict][u8 name len][name]
verdict: 0 accept, 1 reject
The name is the `@pyfilter` function that decided, so a block is attributed to one
function of a file rather than to the file as a whole — a file routinely holds several,
and "this filter blocked 900 connections" is not an answer when you are trying to find
out which of them is doing it.
Neither an open nor a close frame is answered. A close has no verdict to give about a
connection that is already over; an open carries that connection's metadata — the
addresses and ports, as JSON — which the filter reads and cannot change.
Nothing below the application layer crosses in the other direction. There used to be a
`FAKE:IP:TCP:HEADERS:` prefix here, handed to the library as `raw_packet` so that a
filter written against real headers would find something there. It found a lie: the
proxy terminates the connection and writes its own headers, so editing them changed
nothing, while the same filter on NFQUEUE edited a real packet. Metadata in, a verdict
out — that is the one contract both network layers can honestly keep. There is no
payload coming back either: rewriting went with `UNSTABLE_MANGLE`, for the reason the
library's `Action` gives.
"""
import json
import re
import struct
import sys
import time
import traceback
VERDICT_ACCEPT = 0
VERDICT_REJECT = 1
# Sent once, after the user's file has been compiled. It is what lets the engine refuse
# a ruleset whose code does not load, instead of discovering it later as traffic that
# quietly stopped being filtered.
READY = 0xFF
#: Written to stderr when the user's code raises, beside the traceback itself.
#:
#: The traceback says a filter broke and names its file and line, which is what an
#: operator needs to fix it. It does not say what happened to the *connection*, and that
#: is the part they have to know: it went through unfiltered. The engine cannot report it
#: because it never learns of it — the exception is caught here and answered with ACCEPT,
#: so nothing crosses the frame boundary to tell it apart from a filter that simply
#: agreed. So it is marked on the diagnostics channel instead, and `Transport._pump_stderr`
#: turns the marker back into the one sentence `_on_exception` writes for either network
#: layer. A wire token like `BLOCKED` or `[fatal]`, and spelled the same in both places.
EXCEPTION_MARK = "[exception] [filter]"
#: How long between two tracebacks out of the same worker.
#:
#: A filter that throws usually throws on every packet, and a traceback is a dozen lines.
#: Unthrottled, one broken filter writes thousands of lines a minute into a log that
#: holds a few hundred — so the flood does not merely repeat itself, it pushes out the
#: first traceback, which was the one worth reading, along with everything that was in
#: the log before the filter broke.
#:
#: The mark is *not* throttled with it: it is one short token the engine turns into a
#: single rate-limited sentence on the other side, and it is what carries the count. So
#: the operator still learns how often this is happening, and gets one traceback rather
#: than the same one several hundred times.
TRACEBACK_QUIET = 30
KIND_C2S = 0
KIND_S2C = 1
KIND_CLOSE = 2
KIND_OPEN = 3
def _build_filter_code(path: str, enabled: list[str] | None = None) -> str:
"""Turn the user's file into the module the library expects to execute.
Identical in shape to what `firegex.pyfilters.proxysim` builds, because it has to be:
the library decides which filters exist and when each one is callable from these
trailing lines, and a different preamble would mean a different set of filters.
`enabled` is the subset the operator left switched on; `None` means all of them. It
is intersected with what the file actually defines rather than trusted, so a stale
selection naming a function that has since been deleted cannot stop the file from
loading — the library would refuse the whole module over one missing name.
No protocol is written in. The file shows which one it speaks by what its filters
ask for, so there is nothing here that could disagree with the code — an earlier
version passed one in from the outside, and a filter asking for an `HttpRequest`
under a file declared `tcp` was refused with an error about its own annotation.
"""
from firegex.pyfilters.internals import get_filter_names
with open(path) as f:
source = f.read()
names = get_filter_names(source)
if enabled is not None:
wanted = set(enabled)
names = [name for name in names if name in wanted]
return (
source
+ "\n\n__firegex_pyfilter_enabled = ["
+ ", ".join(repr(name) for name in names)
+ "]\n"
"import firegex.pyfilters.internals\n"
"firegex.pyfilters.internals.compile(globals())\n"
)
def _where_in_source(source: str, exc: BaseException, filename: str) -> dict:
"""Point at the line of the operator's own file, when there is one to point at.
Three ways, in order of how directly they know:
* a `SyntaxError` carries its own position, and the code never ran;
* a traceback frame belonging to the file — the module body raised while loading.
`<string>` counts as theirs: the library execs the source to find its filters
before the module is built under its real name, and everything the library itself
runs from has a real path, so an anonymous frame is the user's;
* neither, which is the interesting case. The library refuses a filter by *name*
("Invalid type annotation X for function Y", "Parameter 'p' of Z has none"), from
frames that are all its own, so any identifier in the message is looked back up
among the file's own `def`s. Without this the operator is told which function is
wrong and left to go and find it.
"""
lines = source.splitlines()
def at(line: int) -> dict:
return {"line": line, "column": 0,
"text": lines[line - 1].rstrip() if 0 < line <= len(lines) else ""}
if isinstance(exc, SyntaxError) and exc.lineno:
return {"line": exc.lineno, "column": exc.offset or 0,
"text": (exc.text or "").rstrip()}
mine = {filename, "<string>"}
frames = [f for f in traceback.extract_tb(exc.__traceback__) if f.filename in mine]
if frames:
return at(frames[-1].lineno or 0)
# The *last* definition of a name, because that is the one Python is left holding:
# a file that defines `check` twice runs the second, and pointing at the first would
# send the operator to a function that is not the one being complained about.
defs = {}
for number, text in enumerate(lines, start=1):
found = re.match(r"\s*(?:async\s+)?def\s+(\w+)\s*\(", text)
if found:
defs[found.group(1)] = number
for word in re.findall(r"\w+", str(exc)):
if word in defs:
return at(defs[word])
return {"line": 0, "column": 0, "text": ""}
#: The rename this package went through, and the one message worth saying about it.
#:
#: `firegex.nfproxy` was the old spelling of `firegex.pyfilters`, aliased through 4.x and
#: removed in 5.0.0. `from firegex.nfproxy import pyfilter` is the first line of every
#: filter written before that, and the error Python raises for it — "No module named
#: 'firegex.nfproxy'" — is accurate and tells the operator nothing about what to write
#: instead. They meet it here, in the editor, with the line already marked; the
#: alternative is meeting it at the first start after an upgrade, which at a competition
#: is the start of a round.
RENAMED_IMPORT = "firegex.nfproxy"
RENAMED_TO = "firegex.pyfilters"
def _explain(exc: BaseException, message: str) -> str:
"""Add what to do about it, where there is something to say."""
if isinstance(exc, ImportError) and RENAMED_IMPORT in (str(exc) or ""):
return (f"{message}. `{RENAMED_IMPORT}` was renamed to `{RENAMED_TO}` in 5.0.0 "
f"and the old name no longer resolves: change the import at the top of "
f"this file, and the rest of the filter works unchanged")
return message
def check(path: str) -> dict:
"""Would this file load, and what does it define?
Run by the backend before a save is accepted, in a process of its own — which is
both why the answer can be trusted and why a filter with a `while True:` at module
level cannot take the interface down with it. It is the *same* build and the same
compile the datapath performs, so what is accepted here is what will run.
"""
from firegex.pyfilters.internals import get_filters_info
try:
with open(path) as f:
source = f.read()
except OSError as e:
return {"ok": False, "error": {"type": "OSError", "message": str(e),
"line": 0, "column": 0, "text": "",
"traceback": ""}}
try:
infos = get_filters_info(source) if source.strip() else []
# Not just "does it parse": the whole module, exactly as the worker builds it,
# so an error the library only raises at compile time is raised here too.
Filters(_build_filter_code(path), path)
except BaseException as e: # noqa: BLE001 - anything the user's code can raise
return {
"ok": False,
"error": {
"type": type(e).__name__,
# A SyntaxError's text carries "(<string>, line N)", which names a file
# the operator never wrote and repeats a position reported separately.
"message": _explain(e, re.sub(
r"\s*\((?:<[^>]*>|[^()]*\.py), line \d+\)$", "",
str(e) or type(e).__name__)),
**_where_in_source(source, e, path),
"traceback": traceback.format_exc(),
},
}
return {
"ok": True,
"proto": infos[0].proto if infos else "tcp",
"filters": [info.name for info in infos],
# Which models the file actually asks for. The protocol alone does not say: a
# `tcp` file wanting only a RawPacket runs on datagrams too, while one wanting a
# TCP stream cannot — and the difference decides whether attaching it to a UDP
# service gives you a filter or a filter that never fires.
"models": sorted({
annotation.__name__
for info in infos
for annotation in info.params
}),
}
class Filters:
"""The compiled filter module, plus one set of globals per live connection."""
def __init__(self, code: str, filename: str = "<filter>"):
# Compiled once, under the operator's own file name: every connection reuses the
# code object instead of re-parsing the source, and a traceback names the file
# they wrote rather than `<string>`, with line numbers that match what they see
# in the editor. The preamble is appended *after* their source precisely so
# those numbers line up.
self.code = compile(code, filename, "exec")
self.contexts: dict[int, dict] = {}
#: What the engine said about each live connection, kept so every chunk of it
#: carries the same addresses without them being repeated on the wire.
self.endpoints: dict[int, dict] = {}
# Run once here so a file that cannot load is a startup failure, which the
# engine turns into a refused ruleset rather than a service that silently stops
# filtering.
probe: dict = {}
exec(self.code, probe, probe) # noqa: S102 - running the user's filter is the feature
def context(self, connection: int) -> dict:
ctx = self.contexts.get(connection)
if ctx is None:
ctx = {}
exec(self.code, ctx, ctx) # noqa: S102
# No open frame came for it, so this worker is taking over a connection
# already under way — new code, or one restarted after the last died — and
# its first chunk can be the middle of a message. The library then carries
# what it cannot read instead of judging half a message; see
# `_met_mid_message` in the HTTP model.
if connection not in self.endpoints:
ctx["__firegex_joined_late"] = True
self.contexts[connection] = ctx
return ctx
def opened(self, connection: int, payload: bytes) -> None:
try:
meta = json.loads(payload.decode())
except (ValueError, UnicodeDecodeError):
return
if isinstance(meta, dict):
self.endpoints[connection] = meta
def close(self, connection: int) -> None:
self.contexts.pop(connection, None)
self.endpoints.pop(connection, None)
def run(self, connection: int, data: bytes, is_input: bool) -> tuple[int, str]:
"""One chunk through the chain. Returns the verdict, and which function decided."""
from firegex.pyfilters import ACCEPT, DROP, REJECT
ctx = self.context(connection)
# Missing endpoints are empty strings and zeros rather than an error: the engine
# sends them best-effort, and a filter losing the client's address is a worse
# outcome than a filter that sees "" for it, but neither is a reason to stop
# filtering the traffic.
meta = self.endpoints.get(connection, {})
client_ip = meta.get("client_ip", "")
client_port = int(meta.get("client_port", 0))
server_ip = meta.get("server_ip", "")
server_port = int(meta.get("server_port", 0))
ctx["__firegex_packet_info"] = {
"data": data,
"is_input": is_input,
"is_ipv6": bool(meta.get("is_ipv6", False)),
# Said by the engine rather than assumed: this worker serves TCP connections,
# UDP flows and QUIC streams alike. Two words rather than one, because they
# answer different questions — `is_tcp` is what is on the wire, `l4` is what
# the library's stream and HTTP models are built on, and QUIC answers those
# two differently. Absent, `l4` derives from the other: the NFQUEUE engines
# carry TCP and UDP and have nothing else to say.
"is_tcp": bool(meta.get("is_tcp", True)),
"l4": meta.get("l4"),
"src_ip": client_ip if is_input else server_ip,
"src_port": client_port if is_input else server_port,
"dst_ip": server_ip if is_input else client_ip,
"dst_port": server_port if is_input else client_port,
}
try:
exec( # noqa: S102
"firegex.pyfilters.internals.handle_packet(globals())", ctx, ctx
)
finally:
ctx.pop("__firegex_packet_info", None)
result = ctx.pop("__firegex_pyfilter_result", None)
if not isinstance(result, dict):
# The library did not answer. Saying nothing about a chunk is not a reason
# to stop carrying it.
print("[warn] [pyworker] no verdict from the filter chain", file=sys.stderr)
return VERDICT_ACCEPT, ""
action = result.get("action")
# Which function decided, so the block lands on it rather than on the whole file.
matched = result.get("matched_by")
matched = matched if isinstance(matched, str) else ""
if action == ACCEPT.value:
return VERDICT_ACCEPT, ""
if action == REJECT.value:
return VERDICT_REJECT, matched
if action == DROP.value:
# A proxy has no way to swallow one chunk and keep the stream coherent —
# the application on the other side would be reading a hole. Closing is the
# honest equivalent, and it is what the operator meant by dropping.
return VERDICT_REJECT, matched
print(f"[warn] [pyworker] unknown action {action!r}", file=sys.stderr)
return VERDICT_ACCEPT, ""
def _read_exactly(stream, count: int) -> bytes | None:
buf = b""
while len(buf) < count:
chunk = stream.read(count - len(buf))
if not chunk:
return None
buf += chunk
return buf
def _write(stream, verdict: int, matched: str) -> None:
# The name is length-prefixed with a single byte: a Python identifier fits, and a
# separator would have to be one that cannot appear in one.
name = matched.encode()[:255]
stream.write(struct.pack(">I", len(name) + 2) + bytes([verdict, len(name)]) + name)
stream.flush()
def main() -> int:
if len(sys.argv) < 2:
print("usage: pyworker.py <filter file> [* | =fn,fn] | --check <filter file>",
file=sys.stderr)
return 2
if sys.argv[1] == "--check":
# One JSON object on stdout and nothing else, so the caller never has to guess
# which part of the output was the answer.
if len(sys.argv) < 3:
print("usage: pyworker.py --check <filter file>", file=sys.stderr)
return 2
# Checking a file means *running* its module body, and `print()` is the most
# natural thing in the world to reach for while writing a filter. On stdout it
# would land in the middle of the answer: the backend parses this output as JSON,
# a stray line makes it unparseable, and the operator is told `the check produced
# no answer` about a file whose only sin was printing while it loaded.
#
# So the real stdout is put aside before the user's code can reach it, and
# anything the file prints goes to the diagnostics channel — the same swap the
# worker itself makes below, for the same reason.
answer = sys.stdout
sys.stdout = sys.stderr
try:
result = check(sys.argv[2])
finally:
sys.stdout = answer
answer.write(json.dumps(result))
answer.flush()
return 0
# `*` (or nothing at all) means every function the file defines, which is what a
# hand-run worker wants; `=a,b` means exactly those, and a bare `=` means none.
# A marker rather than a bare list because "all" and "none" are both real answers
# and an empty string cannot stand for both.
enabled = None
if len(sys.argv) > 2 and sys.argv[2].startswith("="):
enabled = [name.strip() for name in sys.argv[2][1:].split(",") if name.strip()]
try:
filters = Filters(_build_filter_code(sys.argv[1], enabled), sys.argv[1])
except Exception:
traceback.print_exc()
return 1
stdin = sys.stdin.buffer
stdout = sys.stdout.buffer
# The user's code shares this process, and `print()` is the most natural thing in
# the world to reach for while debugging a filter. On stdout it would land in the
# middle of a length-prefixed frame: the engine reads the text as a frame header,
# sees an absurd length, and kills the worker — so the filter would be restarted on
# every packet and the operator would be told nothing except that it stopped
# working. Their output belongs on the diagnostics channel, which the backend now
# captures into the service log, so it goes there and stdout stays the protocol.
sys.stdout = sys.stderr
stdout.write(struct.pack(">I", 2) + bytes([READY, 0]))
stdout.flush()
# Monotonic rather than wall clock: the machine's clock moving is not a reason to
# start printing tracebacks again, or to stop.
last_traceback = -TRACEBACK_QUIET
while True:
header = _read_exactly(stdin, 4)
if header is None:
return 0 # the engine went away; so do we
(length,) = struct.unpack(">I", header)
body = _read_exactly(stdin, length)
if body is None or len(body) < 9:
return 0
kind = body[0]
(connection,) = struct.unpack(">Q", body[1:9])
payload = body[9:]
if kind == KIND_OPEN:
filters.opened(connection, payload)
continue # deliberately unanswered
if kind == KIND_CLOSE:
filters.close(connection)
continue # deliberately unanswered
try:
verdict, matched = filters.run(connection, payload, kind == KIND_C2S)
except Exception:
# One connection's filter blowing up must not take the process down: the
# other connections it is serving would lose their filtering with it. The
# chunk is accepted, which is the fail-open half — and the mark is what makes
# that visible rather than silent.
now = time.monotonic()
if now - last_traceback >= TRACEBACK_QUIET:
last_traceback = now
traceback.print_exc()
print(EXCEPTION_MARK, file=sys.stderr, flush=True)
verdict, matched = VERDICT_ACCEPT, ""
_write(stdout, verdict, matched)
if __name__ == "__main__":
sys.exit(main())