firegex/CLAUDE.md
2026-09-13 15:54:56 +02:00

74 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

Firegex is a firewall built for CTF Attack-Defense competitions: it sits in front of a vulnerable service and blocks/mangles malicious traffic before it reaches it. Behind a single FastAPI backend and React frontend it ships services (a network layer plus an ordered chain of filters) and plain nftables rules (firewall). That is the whole product surface: nfregex, nfproxy, tls and porthijack were folded into services as choices rather than modules.

Commands

Running the whole app (Docker or standalone)

python3 run.py                      # build+start (Docker if available, else standalone chroot on Linux)
python3 run.py --standalone         # force standalone mode (no Docker)
python3 run.py stop [--clear]       # stop (optionally wipe the DB volume/rootfs)
python3 run.py restart              # recreate the container WITHOUT rebuilding (stale image if source changed)
python3 run.py status
python3 run.py config --password [newpass]   # change the running instance's password; omit the value to be prompted interactively
python3 run.py config --show        # print the persisted settings (port, host, socket dir, auth)
python3 run.py start --unsafe-disable-auth   # no authentication at all — see "Authentication" below

run.py has zero external dependencies (stdlib only) by design. restart runs docker compose up -d --force-recreate, so it re-reads the generated compose file and re-applies env vars (ALLOWED_IPS, PROXY_IP_HEADER, UNSAFE_DISABLE_AUTH) — a plain docker compose restart would keep the old container environment and silently ignore those. It still does not rebuild the image; after a source change use stop then the bare start command to force docker compose up -d --build.

Most start/restart flags are persisted to .firegex-conf.json and become the default for later runs, so a bare python3 run.py start inherits whatever was last passed.

Frontend (frontend/)

bun install          # bun.lock is canonical
bun run dev           # vite dev server
bun run build         # tsc && vite build
npx tsc --noEmit -p . # typecheck only (no separate lint script is configured)

Backend (backend/)

Needs Linux with NET_ADMIN (nftables/nfqueue) and root, so it's normally only run via run.py (Docker or standalone). Direct iteration on a Linux box/VM:

cd backend && DEBUG=1 python3 app.py DEBUG

Tests (tests/)

One suite, one runner: pytest. Everything drives a live, already-running firegex (http://127.0.0.1:4444 by default), root/Linux, except unit/ which needs nothing running at all.

cd tests
./run_tests.sh                      # installs deps, waits for the instance, runs pytest
./run_tests.sh --set-pass           # against one still in its initial-setup state
pytest unit                         # needs no instance
pytest -k udp                       # one subject
pytest --layer proxy --no-ipv6 -x   # narrow, stop at the first failure
pytest standalone                   # restarts the instance; opt-in, see below
Directory Needs an instance What it holds
unit/ no firegex.regex against libhs, the firegex.pyfilters models, the address→nftables translation
integration/ yes the product, parametrised over the combinations
standalone/ yes, and restarts it settings read once at process startup
helpers/ — the API client, stand-in services, certificates, the traffic Channel
bench/ yes measurement, not testing; pytest does not collect it

The combinations are parameters, not a shell loop. integration/conftest.py holds Layer(transport, ipv6, tls) and the fixtures that fan out over it — inspecting_layer, filtering_layer, proxy_layer, tls_layer, external_layer, any_layer. Running both network layers with the same filters is the point: a filter that only works on one of them is exactly the failure the unified model exists to prevent. This was a shell script invoking one 1100-line program eight times, which meant the first failure ended the run and the other seven combinations were never reached, and a host without IPv6 on loopback failed rather than skipped.

Three things that have to stay true of this layout:

  • helpers/, never utils/. The backend's own package is called utils, and this directory's used to be too — so whichever imported first won, and unit/test_nftables_addresses.py (which imports the backend) and everything importing helpers.firegexapi could not be collected in one pytest process. That name collision is why the suite could not be unified before.
  • The interception canary. It is perfectly possible to reach the API of an instance whose datapath protects a different machine — a container without host networking, a VM with the port forwarded. Every call returns ok and not one packet goes near any of it, which without a check reads as dozens of "the filter did not block" failures rather than as a suite pointed somewhere it cannot reach. datapath_in_our_path asks once, plainly, and everything needing interception is skipped with the reason.
  • Skip with a reason rather than fail. A laptop with no IPv6 loopback, a non-root run, an instance elsewhere — none of those are firegex bugs, and -ra is in addopts so the summary says which half of the suite the machine declined.

Channel (helpers/traffic.py) is the one place that knows whether TLS is in the way. The old suite branched on it at every traffic check, about twenty times.

Run the suite twice in a row when changing anything about addresses or TLS: state left behind by the first pass only shows up on the second.

standalone/ is opt-in because it drives run.py stop/start itself — the IP allowlist and the boot-time auth mode are read once at startup, so there is no way to test them against a running instance. It restores an unrestricted one at the end, which every other module depends on.

The Rust datapath has its own suite, which needs no running instance but does need the production toolchain (vectorscan and Python 3.14 — the filter library imports compression.zstd):

docker build --target compiler -t firegex-compiler .   # then run cargo test inside it
cd backend/proxysrc && cargo test                       # on a host with libhs + fgex installed
cargo test --release --test throughput -- --ignored --nocapture   # the matcher benchmark

The suite runs against an instance with authentication turned off as well: login() reads a 403 there as "there is nothing to log in to" rather than as a failure, since every request is already allowed. integration/test_auth.py is the exception and skips itself — it is about passwords, and those endpoints answer 403 in that mode by design. Note BearerSession.post copies the header dict rather than writing into it: the form Content-Type belongs to the login request alone, and it used to stick, which went unnoticed only because a successful login replaces the whole dict a moment later.

CI (.github/workflows/release.yml) runs only on release: published, not on pushes or pull requests — nothing validates a PR automatically. Its run_tests job (ubuntu-latest + ubuntu-24.04-arm) does python3 run.py start -P testpassword then cd tests && ./run_tests.sh, and gates every publish job below it. The tests' dependencies are pure python; iperf3, matplotlib and numpy belong to bench/requirements.txt and CI installs none of them.

Architecture

Three tiers

React (Vite + Mantine + react-router + TanStack Query + socket.io-client) → FastAPI backend → per-transport datapath binaries that do the actual interception. The backend mostly orchestrates: SQLite-backed config, spawning/talking to those processes, and deploying the built React app.

The layers are compared in the interface, not only in the docs

components/Services/LayerChoice.tsx puts the trade in front of the operator where the choice is made: a table of what each layer costs, plus the paragraph for the one selected. transportSummary() carries the same sentence onto every service row and detail page. Keep them in step with docs/services.md — the point is that an operator never has to read documentation to find out that one layer reassembles in the kernel and the other has a kernel-guaranteed fail-open. There used to be a losesClientAddress() beside them, marking the one combination that gave the client's address up; source transparency is unconditional on every layer now, including UDP, so it answered false for every input and the badge it gated could not render. Do not reintroduce it without a combination that actually loses something.

The two layers (backend/modules/services/)

The central idea, and the thing to preserve when changing anything here: how traffic is intercepted and what is done with it are independent choices.

  • Network layer (transports.py): nfqueue — packets queued to userspace, verdict handed back, nothing terminated, kernel fail-open via NFQA_CFG_F_FAIL_OPEN plus bypass on the nft rule. proxy — the connection is terminated and reopened, which buys exact rewriting, kernel reassembly, backpressure and an ordered chain, and costs the kernel backstop (rebuilt by hand in proxysrc/src/filter.rs).

    The proxy is not the slow one, and the docs used to say it was. Measured: 2.2× NFQUEUE's bulk throughput at one thread and 4.7× at eight, because NFQUEUE pays a userspace round trip per packet while the proxy pays once per connection and then the kernel moves the bytes. NTHREADS reaches the Rust engine too since 5.0.0 — main.rs builds its tokio runtime with that many workers rather than #[tokio::main] taking every core, so run.py --threads means one thing on both layers instead of one thing on one and nothing on the other. On short connections the two are indistinguishable — that claim cost three contradictory measurements before tests/bench/connbench.py started interleaving the layers within each pass instead of measuring one and then the other, which was handing the machine's drift to whichever went second. The trade between the layers is fail-open and transparency, not speed; keep docs/services.md, LayerChoice.tsx and transportSummary() saying that. external — a stateless destination rewrite that puts a proxy the operator wrote in the path (this is what porthijack used to be); no firegex process is involved, so check() refuses any filter, since a filter attached there would be one that never runs.

  • Filter layer: regex (patterns compiled into one hyperscan database) or pyfilter (the user's Python). A service holds an ordered list of them.

Transport.check() is where an impossible combination is refused before anything starts: more than MAX_CHAIN_POSITIONS filters on nfqueue, any filter at all on external, and the tls protocol anywhere but the proxy layer. It is asked twice: at creation with an empty chain, for what the service alone makes impossible, and again whenever a chain is pushed. UDP was never removed from the datapath: cppregex runs in block mode for it and cpproxy handles it, and the only thing that had gone missing was the choice in the UI.

UDP on the proxy layer (proxysrc/src/udp.rs). The TCP path recovers where a connection was headed with SO_ORIGINAL_DST, which the kernel implements for TCP and SCTP only (ENOPROTOOPT otherwise). So UDP is relayed with one socket per protected address, each with its upstream fixed — nothing has to be recovered because nothing was lost. The engine prints one UDP <upstream> <port> line per relay after PORT, and _add_proxy points each address's rule at its own port.

One spelling of an upstream, and one owner of the map. udp_relay_key() in nftables.py is the only place the host:port / [host]:port token is built, and udp_relay_host() the only place an interface name is resolved to something a relay can bind — IPv4 first and link-local last, because an interface almost always carries an fe80:: address it was never configured with and binding there puts the service where nobody dials. Both were written out by hand in four and three places respectively, and the engine's map, the rule that points at a relay and the manager adding an address to a running service all have to agree on the token exactly; when they stop, the symptom is a service whose UDP silently goes nowhere. For the same reason ProxyTransport.udp_ports is the only map of relays: start() returns that dict as _steer["udp_ports"], so it is the same object the rules read, and ServiceManager.address_added asks add_udp_target() for a relay rather than keeping a second copy of the answer beside it.

Client address transparency is preserved via transparent::connect_as_udp using IP_TRANSPARENT with mark 0x1339 and policy routing table 1339. New UDP addresses can be added to running services dynamically via ADD_UDP over the control channel without restarting the proxy.

What UDP keeps: per-flow filter state keyed by client address and released after IDLE — UDP has no close to observe, so a timeout is the only thing that ends a flow — and both directions inspected. Verdict::Reject drops the datagram rather than closing anything, because there is nothing to close. Replies leave through the listener socket so conntrack rewrites them to appear from the address the client dialled; sending from the upstream socket would reach a client not expecting that source. The relay's own dial carries SELF_MARK, or the redirect rule would catch it and loop.

ConnectionMeta.tcp carries the L4 protocol to the filter, so the library's stream and HTTP models — which only apply to TCP — key off what the engine says rather than a hardcoded True in the worker.

A service is a list of addresses

service_addresses holds (ip, port, proto) rows; the service itself holds the name, transport, protocol and TLS material. One daemon routinely answers on more than one address — a v4 and a v6 one, a public and an internal one — and all of them deserve the same chain. Two services with hand-copied chains is how one of them silently stops being protected.

Consequences worth keeping:

  • Adding an address to a running service drops nothing. ServiceManager.address_added installs only that address's rules against the datapath already running (self._steer holds what to point at). On TCP and TLS the proxy listener is dual-stack from the start, and on UDP new relays are bound dynamically on the fly, so adding any address (IPv4, IPv6, UDP, TCP, TLS) never drops connections or restarts the service.
  • address_removed drops the address from self.srv.addresses itself rather than re-reading the table, because the caller takes the rules back before deleting the row. Re-reading would put it straight back, and the next restart would re-protect an address that no longer exists — with TLS, by claiming a port for a stream nobody owns, which then refuses the next service that wants it.
  • proto is denormalised onto each address and rewritten when the service's changes. That is what makes (ip, port, proto) a usable uniqueness key, so a TCP service and a UDP one can share an address exactly as the kernel allows.
  • The external proxy endpoint belongs to the address, not the service, and a partial unique index forbids two addresses sharing one: the return rule recognises the operator's proxy by address and port to put the original port back, so two addresses behind one endpoint could not be told apart on the way out.
  • The proxy listener is dual-stack whenever the host supports IPv6 ([::]:0), and transparent::unmap turns the v4-mapped peer and local addresses back into IPv4 before anything else in the engine touches them — conntrack, the source-preserving dial and the metadata a filter reads all want the address the packet actually had.
  • Addresses accept network interface names as well as IPs. An entry in service_addresses can be an IP/CIDR or a network interface name (1-15 chars, e.g. eth0, wg0, tun0, lo), checked via utils.is_interface_name and marked by Address.is_interface. The interface is not a second spelling of an address, and the interface is what the operator is choosing when the address is somebody else's to hand out. The frontend says which of the two it understood while it is being typed — addressKind in components/InterfaceInput.tsx puts it on a badge in the field, because eth-0 is a perfectly good interface name and a perfectly wrong address, and the two are matched by completely different rules. That rule is written once on the frontend too, as isInterfaceName/isIpAddress in js/utils.tsx, reading the same charset the backend does — it used to be pasted into AddEditService.tsx and AddressList.tsx, two copies of a rule the picker beside them did not consult at all, which is how the hand-off layer came to offer interface names in the list and refuse them on submit. InterfaceInput lists interfaces and addresses as two groups, each interface carrying what it holds right now (one question decides between them: "and what is on it"), and stays a combobox rather than a select because an address a service will answer on need not exist on this host yet. Link-local is not offered — every interface carries an fe80:: it was never configured with, which doubled the list with addresses that would put a service where no client looks for it, the same reason udp_relay_host() takes them last; one typed by hand is still accepted, and an interface holding nothing else says "link-local only" rather than listing it. A row is the badge as a column, and everything else in the one beside it — value, then the note, wrapping inside that column and never under the badge. Neither of those two is given a fixed share, because which one is long changes with the group (the addresses a link carries, or the address itself): fixing it took the whole width from a full-length IPv6 and left the other wrapping a character per line, a row a screen tall beside an address cut off mid-digit; and three items in one wrapping row put that same address at the container's left edge, under the badge, three lines tall. The list's own width is computed from the longest value in it, between a floor and a ceiling, and three simpler rules were wrong first: inherited from the content it collapsed to the field's width, because content allowed to wrap asks for less than it needs to stay on one line; pinned to the widest row any host could produce, every list was that wide beside fields half the size; and a floor alone put the one long address back over three lines. A host answering on 10.60.3.1 gets the floor, one carrying a full-length IPv6 gets the room to show it with its note beside it, and monospace is what makes the arithmetic honest. AddressList reads in the same words it was chosen in — the same badge at the same BADGE_WIDTH, then the value, then on eth0 for an address and now 10.10.0.3 for an interface — because a row an operator comes back to check, described differently from the list it came out of, is a reason to wonder whether it is the same thing. What is shown is bareAddress: the backend stores what it parsed, so 127.0.0.1 comes back as 127.0.0.1/32, a spelling nobody types that reads like a range beside real ones; a host-length prefix is dropped for display and a real one is kept, which is also what tells addressKind apart. A real range survives an edit for the same reason — the modal seeded itself by cutting at the first slash, so opening "move this address" on a /16 and saving narrowed a service to one host of it. Where interfaces are not an answer — the external proxy endpoint, and the service address on that layer — they are not listed at all and a name typed there is badged red, rather than offered and then refused on submit. Nftables matches use meta iifname <iface> for inbound/prerouting and meta oifname <iface> for outbound/postrouting/route. On proxy (TCP/TLS), redirect rules match iifname and route_chain marks return packets with 0x1339 on oifname, skipping nat_output_chain to avoid hijacking unrelated outbound host traffic. On UDP proxy, the primary IP assigned to the interface is resolved dynamically via psutil.net_if_addrs() to bind the relay. On nfqueue, interface traffic is queued natively in prerouting/postrouting without needing an IP. On external, interfaces are disallowed because return rewrites require a concrete IP.

An ordered chain works on every layer, and gets there differently. The proxy walks a list inside one process. NFQUEUE cannot — its binaries fuse transport and filter — so there a chain is a chain of processes: one binary per filter, each with its own queue and its own pair of base chains, at -307 + position (prerouting) and 107 + position (postrouting). A packet a filter accepts carries on to the next base chain in the same hook, so filter order is base-chain priority order. This was established by experiment, not assumed: two cppregex processes at adjacent priorities, where the second refuses traffic the first passed. The cost is a process and a reassembly pass per filter, which is why MAX_CHAIN_POSITIONS exists at all.

Changing the shape of an NFQUEUE chain (adding, removing, reordering) cannot be pushed: the shape is the arrangement of processes and rules. NfqueueTransport.reload raises ChainShapeChanged and ServiceManager.update_chain rebuilds, logging that it did. Editing a pattern inside an existing filter still costs nobody their connection.

One engine per concern, never two

Two implementations of the same concept is the mistake this module was restructured to remove, and it is worth restating because it keeps being tempting:

  • Regex matching is hyperscan, everywhere — there is no second regex engine in the tree. binsrc/regex/ on the NFQUEUE side, proxysrc/src/hyperscan.rs on the proxy side, a binding rather than a reimplementation. Adding regex back to Cargo.toml would undo this.
  • A pattern blocks. There is no second action, and rewrite is not coming back. It existed, and was withdrawn because it could not keep the promise it made: rewriting scanned one chunk at a time, since bytes already forwarded cannot be taken back, so a match straddling two chunks was never rewritten. Measured — a pattern sent whole arrived redacted, the same pattern split across two TCP segments arrived intact, and blocking (which scans in stream mode) caught both. The miss was not the problem; its silence was — no block, no log, no counter, just a rule the operator trusted that had quietly not applied. The removal has to be complete to mean anything, and it was half-done for a while: an action column that was gone from the datapath still had a rewritten field travelling from debug.rs through the backend model to a panel in RegexDebugger.tsx that could never render, a single-member Action enum in firegex.regex, and a replace_with on the frontend Regex type. If any of those spellings reappears, it is residue, not a feature.
  • A pattern is validated against the mode it will run in, which is streaming, because that is how a pattern follows a connection across chunk boundaries. hyperscan does not accept quite the same patterns in every mode (a{1,1000}b streams but does not block-scan), and judging by the wrong one refuses rules that work — the same class of lie as a tester that disagrees with the engine. Block-mode scanning survives for one job only: reporting where a match starts, so the tester can highlight it. debug.rs reports unscannable for patterns that are valid, will run, and cannot be highlighted.
  • The Python API is firegex.pyfilters, everywhere. cpproxy embeds it; modules/services/pyworker.py runs it out of process. An earlier version of that worker defined a look-alike @pyfilter taking (data, direction), which meant a filter written from the docs loaded on one transport and failed on the other.
  • A filter file's protocol is read off the file, never declared. The library already decides when to call a filter from what its parameters are annotated with, so infer_proto in firegex/pyfilters/internals reads the same table back the other way: asking for an HttpRequest makes a file an HTTP filter, asking only for a RawPacket makes one run on anything, and asking for two different application protocols is refused naming both functions. type_annotations_associations is the single place a protocol is defined; a second list of names would be one more thing to keep in step, and the day it fell behind the symptom would be a filter that silently never runs. The backend stores the detected value on the row so the interface can show it, and PUT .../code is what recomputes it. Passing one in from outside is exactly what used to make an HTTP filter on a TCP service a 500: the service's protocol was sent to the datapath, so the filter was refused for asking for an HttpRequest, which is the only thing an HTTP filter does.
  • A filter file's functions run in the order the file defines them, and the first to answer anything but ACCEPT ends the packet. @pyfilter sets a mark on the function and hands it back; collect_pyfilters reads a module's globals, which are in definition order. What that replaced was a set of names on a module-global attribute of the decorator, so the order was whatever hashing produced — which decided which function got credited with a block and whether an earlier UNSTABLE_MANGLE survived, with nothing in the file able to tell you which. The position column on pyfilters carries the same order to the interface, which used to list them ORDER BY name. Three consequences of that registry are also gone with it: get_filters_info had to clear it before and after (and reached it by execing import strings), two compilations could race, and a function registered from somewhere the module globals could not reach was registered and then not found.
  • A pyfilter file's functions are switched on and off individually, and the code is never edited to do it. pyfilters holds one row per @pyfilter the code defines, reconciled with the file on every PUT .../code: functions that have gone are dropped, new ones arrive active, survivors keep the operator's active and blocked. Only the enabled names reach __firegex_pyfilter_enabled, which is already what decides whether the library ever calls a function. Three states are distinct all the way down (Option<Vec<String>> in the spec, * / =a,b / = on the worker's argv): absent means every function the file defines — the case of a filter whose list was never derived, where defaulting to "none" would silently stop every filter running — while an empty list means the operator switched them all off. ChainLink.enabled_functions returns None for no rows and [] for all-inactive, which is the same distinction.
  • RawPacket is the only model that survives a datagram, and that fact is stated once, as DATAGRAM_SAFE_MODELS in routers/services.py. Every other model declines to be built when is_tcp is false, so a filter asking for one on a UDP service would sit in the chain and never be called. --check reports the models a file asks for, saving refuses the combination naming them, and Transport.check() catches the case where the service was switched to UDP afterwards. Python filters do work on UDP on both layers: cpproxy gained filter_action_udp (no stream follower, no ack fixing — a datagram is complete in itself), and its per-flow contexts are bounded by enforce_limit because a datagram has no close to observe and a spoofed-source flood would otherwise accumulate one set of module globals per forged address. handle_next_packet used to throw for anything but TCP, with a message claiming UDP was supported.
  • A filter file is validated by the process that will run it. pyworker.py --check <file> builds the module exactly as the worker does, compiles it, and prints one JSON object: {ok, proto, filters} or an error carrying type, message, line, column, the source line and the traceback. validate_code() runs it in a subprocess with a timeout — user code used to be execed inside the backend's own event loop by detect_proto, so a while True: at module level would have taken the interface down. POST .../check answers 200 even when the answer is no: a refusal is the result, and the editor wants a position to mark, not a string to print. Saving goes through the same check, so the operator is never handed "the filter code did not load: worker exited" and left to find the traceback in the service log.
    • _where_in_source finds the line three ways: a SyntaxError's own position; a traceback frame belonging to the file (<string> counts as theirs — the library execs the source to find its filters, and everything the library runs from has a real path); otherwise any identifier in the message looked back up among the file's defs, last definition wins, because that is the one Python is left holding.
    • Filters compiles once under the operator's file name instead of re-parsing per connection, so runtime tracebacks in the log name their file and their line too.
    • A filter is called directly. context_call used to write the arguments into the module globals, eval a call expression built from them, and delete the three keys again — per filter, per packet — which is exactly func(*args), because the operator's code was execed into those globals and so the function's __globals__ is that dict.
    • The default for FGEX_STREAM_MAX_SIZE is 1 MiB, as documented. It read 1*8e20 under a comment saying "1MB", so FGEX_FULL_STREAM_ACTION never fired unless a size was set by hand, and the float it produced would have been refused by the setter beside it.
  • The editor's hints are introspected, never written down. modules/services/pyapi.py reads the models out of type_annotations_associations and their members out of the classes — including writable, from property.fset, which is how the interface can say that the payload is the only thing a filter may assign to. GET /api/services/pyfilter-api serves it and pyfilterHints.ts feeds Monaco's completion and hover from it. A second, hand-written description would be wrong the first time a model gained a property, and autocompletion that lies is worse than none. The route is declared before /{service_id}, or that one matches it and looks for a service by that name.
  • A filter that raises says so the same way on both layers. _on_exception writes one sentence — the traffic was forwarded unfiltered — and it has to be reachable from either. NFQUEUE gets there directly: the interpreter is embedded in the binary, which sends EXCEPTION. The proxy cannot, because pyworker.py catches the exception in a separate process and answers with an ordinary ACCEPT, so nothing crosses the frame boundary that the engine could tell apart from a filter agreeing. It marks stderr with EXCEPTION_MARK instead and Transport._pump_stderr turns that back into the same call. The traceback is printed beside it, not instead of it: the traceback says which filter and which line, and the sentence says what happened to the connection — for a while the proxy printed only the first, which tells an operator a filter broke and not that traffic went through unchecked. The marker is spelled in both files because the worker is launched as a script and imports nothing from the backend, which is what keeps the user's code out of this process's imports; tests/integration/test_resilience.py asks for the sentence on both layers and so keeps them in step.
  • A block names the function, not just the file. Both layers report <filter_id>/<function>: the NFQUEUE binaries already reported the function name, and the proxy worker now returns it in the verdict frame ([verdict][name len][name][payload]). ServiceManager._on_block splits that one token in one place. A file with ten functions blocking 900 connections is not an answer to "which of them is doing it".
  • Nothing below the application layer crosses into a filter. RawPacket exposes addresses, ports, family and direction as read-only metadata, and data — the payload — as the only writable thing; mangled_data comes back, never a packet. That boundary exists because the two layers cannot honestly offer the same thing underneath it: NFQUEUE has a real header whose rewriting desynchronises the stream, while the proxy terminated the connection and writes its own. The old model papered over that by handing the proxy a literal FAKE:IP:TCP:HEADERS: prefix as raw_packet, so the same filter did different things depending on where it was attached. On the proxy the metadata is better than a header would be — the engine knows both ends and sends them in a KIND_OPEN frame (ConnectionMeta → Filter::connection_opened), once per connection rather than on every chunk.
  • The order the operator set is the order that runs. parse_ruleset groups patterns by their filter id and keeps the positions the backend sent. An earlier version grouped every block, then the Python — which turned the chain into a set, so a filter placed after another could run before it, and nothing said so. Within one filter the operator expressed no order, so blocking goes before rewriting there; across filters, never reorder. tests/rules.rs pins both.
  • The regex tester runs the engine. POST /api/services/debug-regex shells out to fgex-proxy --debug-regex, so what it says is what will happen. A re-based approximation would bless patterns hyperscan rejects.

Per-module pattern

Each module (services, firewall) follows the same shape:

  • backend/routers/<mod>.py — FastAPI endpoints + its own SQLite DB, via utils.sqlite.SQLite.
  • backend/modules/<mod>/models.py — plain model classes, from_dict.
  • backend/modules/<mod>/firewall.py — FirewallManager/ServiceManager pair: the manager tracks one ServiceManager per DB row, which starts/stops that one service.
  • backend/modules/<mod>/nftables.py — builds/tears down the module's nftables rules; extends utils.NFTableManager (a Singleton), so there is exactly one rule-set owner per module.

Every nftables object firegex creates is named fgex_, and lives in a table firegex owns. The services module holds table inet fgex with fgex_queue_in_<n>, fgex_queue_out_<n>, fgex_hijack_*, fgex_divert, fgex_nat, fgex_nat_out, fgex_route; the firewall module holds fgex_filter and fgex_mangle per family, with base chains fgex_input/fgex_forward/fgex_output/fgex_prerouting/fgex_postrouting jumping to fgex_rules_in/_out/_fwd. One grep fgex_ over the ruleset is the whole footprint.

The firewall module is the one that had to move to get there, and the reason is worth keeping. It used to write into the tables called filter and mangle — the ones iptables-nft claims — adding base chains named INPUT and FORWARD beside iptables' own and driving their policy. That broke two things, both reproduced before being fixed:

  • iptables stopped working on the host. iptables-nft refuses an entire table as soon as one rule in it cannot be expressed in iptables' own format, and it stores conntrack matches as an xt blob where nft writes a native ct state. Bisecting the rules firegex writes, ct state was the only one that tripped it — which is to say allow_established and drop_invalid, on in any sane configuration. From then on iptables -L and iptables-save answer table `filter' is incompatible, use 'nft' tool, on a box where Docker, ufw or the organisers' scripts are the ones asking.
  • Stopping firegex opened the host. reset() re-added filter INPUT with policy accept, and in nftables add chain on an existing chain updates its policy. That chain was the administrator's: a host carrying default-deny had it silently removed when the firegex firewall was switched off.

A base chain carries its own policy wherever it lives, so default-deny is expressed exactly as before and a drop is terminal whichever table's chain reached it — nothing about what gets blocked changed. What changed is that firegex now adds a layer instead of taking one over: an accept in fgex_rules_in means "firegex does not block this", never "nothing else will", which is all it ever honestly meant. reset() is now deleting two tables per family (add before delete, because a batch is atomic and deleting a table that is not there fails all of it).

Rule.table is still filter or mangle in the database, the API and the interface — it names the hook a rule is evaluated at. NFT_TABLES maps it to the table it lands in; the two were one string only because that string used to be somebody else's table.<>

A refused edit must change nothing: _apply_chain(service_id, undo) in routers/services.py rolls the write back when the transport rejects the resulting chain. Without it an operator is told their filter was rejected and then finds it in the list, and the next start fails for the reason they thought they had avoided.

Order is the load-bearing detail in services/firewall.py, because getting it wrong is not an error anybody sees — it is a window during which traffic goes unfiltered. Starting: the capture interface if the service decrypts (the engine opens its socket to it at startup, so one that appears afterwards is one this process will not find), then the datapath, then the nft rules that point at it. Stopping is the exact reverse.

Block statistics (modules/services/stats.py)

A cumulative counter per filter, per pattern and per @pyfilter function lives in the database; the bucketed history answers the other question — when did this start. Blocks are counted per filter into one-minute buckets, kept for HISTORY_BUCKETS, and flushed at most every FLUSH_INTERVAL seconds. Both properties are load-bearing for the same reason the live log's are: a service refusing thousands of connections a minute must not be able to take the backend down through its own bookkeeping, which is what one row per block would do. There is deliberately no background task — flushing happens on the next write once the interval has passed, and always on a read — so nothing has to be started, cancelled or waited for at shutdown. The frontend draws it as hand-written SVG (StatsPanel.tsx) rather than pulling in a charting library several times the size of the page it sits on.

share is computed in the backend, not the browser, so a chart and the table beside it cannot disagree about what a rule accounts for.

History is stored per rule, not per filter, and that is what makes a time range mean one thing. block_history keys on the token the datapath reported — a pattern id, <filter>/<function>, or a filter id — so GET .../stats?range_from=&range_to= can answer the chart, the per-filter totals, the per-pattern totals and the shares over the same window. Storing only per filter would have left the chart honouring the range and the tables beside it reporting all of time. A filter's total is the sum of its rules, not a lookup of its own id: that token appears only when nothing named itself.

bucket_width takes either a target bar count (the default, which is what makes the bar widen with the range) or an explicit want in seconds from the operator, and both are floored at MAX_STEPS bars — a step fine enough to melt the chart is widened, never refused, and bucket_seconds reports what was used rather than what was asked. A range never grows the number of buckets, only their width (bucket_width): everything is an aggregate of the one-minute rows, so twelve hours costs the same rows as one and is drawn in the same handful of bars. Ranges are clamped to two floors — retention_start() and the service's filtering_since — and the served range is reported back, so the interface can say what it actually showed. filtering_since is written by _note_filtering(), which is called from start_service and from _apply_chain (the two places the two halves of "running, with an active filter" change) and once per service at startup(), which is what covers services restored from a backup or predating the column. It is written once and never moved: a service stopped and started again has a real gap in its chart, and moving the mark would hide it. Note the clamp happens after the caller's ends are put in order, and the filtering_since floor applies only to a window that reaches into the service's life: a window that ended before it began is answered exactly as asked and comes back empty, because it is. Dragging its start forward instead would have reported the current minute's blocks under a range an hour old — buckets are minute-aligned, so a zero-width range is not an empty one. all_time is reported alongside total so a narrow window showing nothing is not read as a contradiction against the cards.

StatsCharts.tsx draws bars, lines, stacked area and 100%-share from the same series — four views, one dataset, still hand-written SVG for the reason the first chart was. Hover is tracked once on the container and turned into a step index, not per shape: a line and an area have nothing under the cursor to attach a handler to, and one handler means the four charts cannot disagree about which step is being pointed at. The readout itself is HTML positioned over the plot, for the same reason the axis labels are — preserveAspectRatio="none" stretches rectangles correctly and distorts glyphs. clock() is exported from there and used by the axis labels, so a bar's tick and its readout cannot disagree about what a step is called.

Editing a pattern keeps the row, resets its counters, and hands its history to the filter. PUT .../regexes/{id} validates the resulting row through the engine (changing the action alone can invalidate a stored pattern, since blocking compiles for streaming and rewriting for block scanning), and when the pattern text itself changed it resets blocked and calls stats.disown_rule — counts belong to the text that produced them, so the new text must not inherit them. That call happens after _apply_chain accepts, never before: a refused edit has to change nothing, and history moved ahead of the verdict cannot be put back by undo(). The rule's pending batch moves with it, or the last few blocks of the old pattern would be flushed under the new one.

What disown_rule must not do is delete those rows, which is what it used to do. Those connections were refused, by that filter, in those minutes, and the chart is answering when this service was under attack — a question a later typo fix has nothing to say about. Deleting took a bite out of the timeline on every correction, and on a filter holding one pattern it emptied the chart. So the rows are re-keyed to the filter's own id, already the token the datapath reports when nothing finer named itself: window() and totals_by_filter() group by filter_id and do not change at all, while totals() groups by rule_id and correctly credits the new text with nothing.

The cost is that some blocks in a range then belong to no rule still present — which was already true of a deleted pattern, whose rows are left behind on purpose. The stats route closes that by adding one residual entry per filter for the difference between what the filter was credited with and what its current rules account for (residual: true, prose rather than a pattern, so StatsPanel prints it as text and not in a Code box). Without it the per-pattern list quietly adds up to less than the chart above it, and the operator is left hunting for the difference. Note check_pattern coerces case_sensitive: a caller checking a row it read back from SQLite hands over 1, and the engine's request format refuses an integer where a boolean belongs.

Traffic totals are reported in the unit each layer can honestly produce, and the two are never divided by one another. This is the part to preserve:

  • Packets and bytes come from counter statements on the intercept rules — free, kernel-side, and unskewable because no userspace filter is involved in producing them. FiregexTables.traffic() reads them back, from one inbound chain per transport: the nfqueue layer installs a rule pair per filter position all seeing the same packets, so summing them would multiply the answer by the length of the chain.
  • Proxy services report no packet count at all. Their rule lives in a nat chain, and conntrack translates a connection once — every packet after the first is handled without the rule being walked again. That counter is new connections wearing a packet label, so it is dropped rather than shown.
  • Connections come from the engine's periodic STATS seen=… refused=… line. They live on ProxyStats, not on ChainStats: a chain is replaced wholesale on every ruleset push, and counters that reset on a rule edit would make the ratio jump for a reason nobody could see. Because both numbers are connections, refused_share there is exact.
  • NFQUEUE has no connection to count, so it reports null for the share rather than dividing refused connections by a packet count. The asymmetry is the difference between the layers showing through; do not "fix" it by inventing a denominator. The engine's counters arrive on a timer, so a test that reads them has to wait for them.

The live log (modules/services/logs.py)

A bounded ring per service plus a coalescing flush over a log Socket.IO event (distinct from update, which only invalidates queries). Two properties are load-bearing and easy to lose: the ring is capped, and the flush is on a timer with a per-batch cap that reports a dropped count rather than carrying it — a service refusing thousands of connections a minute must not be able to take the backend or the browser down through its own logging.

Sources: block attribution (resolved to the filter name and pattern, one query per block, on a path that only runs when something was already refused), the user's print(), and the datapath's own stderr. That last one is why pyworker.py reassigns sys.stdout = sys.stderr before running user code: stdout is the length-prefixed protocol channel, and a print() landing in the middle of a frame gets the worker killed on every packet with nothing to explain it. tests/pyworker.rs has a regression test for exactly that.

The proxy datapath (backend/proxysrc/)

A Rust binary, built in the Dockerfile's compiler stage rather than a rust image on purpose: it links the same libhs the C++ binaries do, and a binary built against one distro's vectorscan and run against another's is a bad afternoon.

  • filter.rs — the chain, and everything that keeps a filter from taking the datapath down: catch_unwind, a deadline on spawn_blocking, and a filter that misbehaves losing its say rather than the traffic being held. panic = "unwind" in Cargo.toml is load-bearing, not tuning.
  • Filters that keep state do so per connection, per direction (ChainSessions), because one client's bytes deciding another client's verdict is both a false positive and a way to smuggle a pattern past a filter by splitting it across two connections.
  • pyworker.rs frames carry a connection id and a close frame, so the Python side can honour the documented promise that each stream gets its own module globals.
  • Source preservation is unconditional: the engine always dials the service from the client's address. ct status dnat is what separates the engine answering a client from the service answering the engine, which is why redirect (not tproxy) works even when the service is on this host.

The external hand-off

Three nft chains, not two: prerouting for traffic from outside, output for traffic this host generates (which never reaches prerouting — the same reason the proxy transport has fgex_nat_out), and postrouting to undo the rewrite on the way back. A mangle target is one host, so one_address() strips the prefix that service addresses carry;

Note for tests: Linux refuses a loopback connection to a port with no listener without emitting a packet, so no rule can rescue it. A hand-off test has to keep the real service listening and tell the two apart by their replies, not by which one answers.

Holding up under load

A service carries at most max_connections at once, and the operator says what happens to the rest. It exists because the alternative was measured, not argued: silent connections cost two descriptors each — one from the client, one to the service, since the upstream is dialled on accept — and ~505 of them from one host exhausted the container's 1024 and took every service down. UDP was cheaper still: 600 datagrams from 600 forged sources in 0.03 s took 400 descriptors and held them a minute after the sender left. The same cap on the same shape already existed on the NFQUEUE side (MAX_UDP_FLOWS in pyproxy.cpp) and had never been carried to the Rust engine; TCP had none anywhere.

Say what it buys, and no more. A cap does not save the service being attacked — it cannot tell a connection that is silent because it is an attack from one that is silent because the client is slow, so an attacker who fills the limit still fills it. What it buys is that one service's attacker stops being everyone's: measured, 400 silent connections against a limited service left the service beside it answering. Do not let the interface or the docs imply otherwise.

  • One number for both halves, because TCP connections and UDP flows spend the same descriptors and two limits for that would be two things to keep in step. 0 is no limit, kept as a value rather than an absence, because "unlimited" is a choice.
  • over_limit_forwards is the same trade as fail-open at a different moment, and off by default for the same reason: a filter that quietly stops filtering is the worse surprise.
  • Folded into the form's one Advanced settings accordion, not dropped: these are settings an operator reaches for once — after something went wrong, or before a competition — and never again, and left in the open they are more things to read past on every service. One fold rather than one per topic, and its summary is built from what differs from the defaults (changed), so an untuned service says "Everything at its default" and a tuned one says exactly how. Folded is not hidden. What is inside depends on the layer, because the settings belong to different ones: the connection limits are the proxy's, fail-open is NFQUEUE's, and the hand-off layer has neither, so it gets no accordion at all.
  • Proxy layer only, and absent from the form elsewhere. NFQUEUE hands the kernel a verdict on packets in flight; it accepts nothing and dials nothing. The form also resets it to 0 when the layer changes, because a stored setting that does nothing is the trap this whole file keeps arguing against.
  • Slot is a guard, not a decrement at the end of handle_connection: that function has a dozen ways out — a refused chain, a failed dial, a timed-out handshake, a panic — and a counter that leaks on any one of them is a limit that tightens until it refuses everything.
  • The accept loop backs off after a failed accept. That one is not a policy question: out of descriptors, the loop spun on accept → EMFILE → print → accept, measured at ~40% of a core with four clients knocking, writing a log line per attempt into the pipe the backend reads.

first_byte_timeout is what the cap cannot be. A cap contains a phantom flood; a deadline ends it — measured, with the limit full and the attacker still holding every socket, clients got back in once it passed. Two properties are load-bearing and easy to break:

  • Either direction counts. The flag is set by whichever pump moves a byte first, so a service that greets its client (SMTP, SSH, most game protocols) satisfies it with its banner. Requiring the client to speak would hang every server-speaks-first protocol, and the failure would look like firegex breaking them at random.
  • Only until the first byte, never again. A connection that has spoken and gone quiet is a session, and sessions think. This is not an idle timeout; making it one would close long-lived connections that are legitimately waiting.

The flag is set before the chain runs, because a chunk a filter goes on to refuse is still a connection that said something. The watchdog fires through the same stop_tx a refusal uses, so both directions shut down cleanly rather than being dropped — a dropped peer reads a truncation, and this connection has done nothing to deserve one. Zero is off, and there is no default worth picking: how long a legitimate client may stay silent is a fact about the service, not about firegex.

The trace is deliberately in two places. _on_over_limit writes over_limit_hits/_first/_last to the database and logs at warning level, rate-limited by OVER_LIMIT_QUIET. The log answers "why are clients failing right now" and is a bounded ring, so a burst at three in the morning is gone by breakfast; the columns answer "did we ever hit the wall", which is asked the next day. The engine's counter is cumulative per process, so the transport hands up only the increase — passing the absolute number would reset the durable count on every restart of the service.

TLS

The two controls that constrain each other read one rule. carries(transport, proto) in components/Services/utils.ts is the only place the constraint lives: the protocol picker hides what the chosen layer cannot carry, the layer picker disables what cannot carry the chosen protocol, and Transport.check() refuses it on the server. Written twice, the two halves drift and the form ends up offering a combination the backend then refuses — being told no after filling in a form is a worse way to learn it than not being offered the option. Both pickers say no the same way: the option stays where it is, disabled, with a sentence underneath saying why and what would make it available. An option that vanishes leaves nothing to ask about — the operator is left wondering whether it exists at all or whether they misremembered seeing it — and two controls side by side that refuse in two different ways are a confusion of their own.

TLS is one of L4's values, beside tcp and udp — not a boolean on top of one. It is what the service speaks, and as a flag it made three combinations expressible that are not real: TLS on a UDP service, on NFQUEUE, and on a hand-off, each caught by a check written by hand. Two of those are now unrepresentable, and the third (NfqueueTransport.check()) is asked at creation as well as at start — add_service calls transports.build_class(...).check(srv, []) with an empty chain, which catches what the service makes impossible while leaving what the chain makes impossible to the moment there is one. A row that is created happily and then refuses to start every time is a trap.

The second answer that split off from it: an address carries the transport the kernel matches on (L4.l4_of), not the service's own, because tls is TCP on the wire. Without that, (ip, port, proto) would let a TCP service and a TLS one claim one ip:port between them, which is the same port twice. l4_proto() in nftables.py is gone with it — the address already holds the answer it used to compute.

It belongs to the proxy layer: decrypting means terminating the connection, and terminating is what that layer does. That is not a lost feature so much as an honest one — TLS on NFQUEUE worked by putting nginx in front to terminate and re-encrypt, so the connection was terminated and the layer's one distinguishing property was already gone.

The engine terminates it, and a TLS service therefore occupies no port at all. proxysrc/src/tls.rs decrypts from the client and re-encrypts towards the service inside the process that filters, so resolve_target() points the rules at the address the world dials, exactly as for a plain service. ProxyTransport._tls_env() hands the engine FGEX_PROXY_TLS_CERT/_KEY — paths, because a certificate on a command line or in an environment dump is a certificate in a log — written 0600 under TLS_DIR and removed after the engine is gone, never before.

What that replaced: nginx terminated on one derived loopback port and re-encrypted from a second, with the filters attached to the leg in between. Protecting one address cost a pair of ports produced by hashing ip:port — chosen ports, which could collide with something real — plus a generated config, a tls_streams table keyed by address id, an nft chain of its own, and a failure mode where one unloadable certificate stopped nginx and took every TLS service on the instance down with it. All of that is gone: modules/tls/ no longer exists, and the blast radius is now structural, since each service carries its certificate into its own engine process.

ALPN is mirrored, and the ordering that makes it possible is the design. The client's handshake is started and held at the ClientHello (tokio_rustls::LazyConfigAcceptor), because that message carries the protocols the client will speak; the upstream handshake goes next offering exactly that list, and whatever the service picked is the single protocol the client is then told (tls::with_alpn / tls::answering_with, both cloning the config per connection — the list comes from the client, so there is no fixed set to precompute, and a config clone is nothing beside a handshake). Answering something the service did not say is how a proxy breaks a protocol it was only carrying: a client told h2 against an HTTP/1.1 service sends frames nothing can read. When the service picks nothing the client is told nothing — no agreement is invented. tests/integration/test_tls.py pins all five cases, including both mismatches and a client that offers only one protocol. Before this, ALPN was dropped in both directions and every client silently fell back to HTTP/1.1.

What cannot be carried, and is a limit rather than a missing setting: TLS 1.0 and 1.1, which rustls does not implement at all; the cipher list, which is fixed and forward-secret only; and client certificates, since with_no_client_auth() is the only honest answer when the certificate a client would present is one only the service can check. Say these are impossible rather than unconfigured — an operator who needs TLS 1.0 needs the external layer, not a flag.

The key must be one ring will sign with — RSA ≥ 2048 bits, ECDSA, or Ed25519. nginx could be told to take a 1024-bit key with @SECLEVEL=1; rustls has no equivalent, so a smaller key is refused when the service starts. That refusal is worth having land well: _pump_stderr keeps the engine's last [fatal] line and _died_because puts it in the exception, because "the proxy engine did not report a listening port" is the symptom and the engine already printed the reason.

The decrypted traffic is written to an interface of its own, and those are reconstructed packets. Terminating in-process means the plaintext is never a packet, so proxysrc/src/capture.rs frames it as the TCP stream it was and sends it to firegex0 over AF_PACKET: one interface carrying every TLS service's plaintext and nothing else, which is what a capture tool wants pointed at it. The bytes are real — what the filters saw and what was forwarded — but the framing is invented, because the framing on the wire was encrypted. Sequence numbers start at zero, there are no retransmissions, and the segmentation is the engine's read sizes; a SYN and a FIN are emitted so Wireshark has a stream to follow rather than orphan segments. Say so wherever it is offered: a capture from here is not evidence of what was on the wire.

Three things keep it from being able to hurt anything. modules/services/mirror.py creates the device in FirewallManager.init(), before any engine starts (a socket opened before the interface exists finds nothing), and release_capture() removes it from the process's shutdown() hook and nowhere else — deliberately not from close(), which reset() also calls, or the device would vanish and reappear underneath whatever was capturing from it. It is the instance's device, not each TLS service's: it used to be created and released per service, on the argument that an interface present while nothing is decrypting is one somebody points a capture at and watches stay empty — but a capture tool is attached once, for a round, and Zeek and Suricata exit rather than wait when the interface under them disappears, which restarting a single TLS service was enough to cause. Watching it stay empty is the better failure. release_capture_if_idle() is gone; do not add a per-service release back. Every failure is silent and local — no interface, no socket, a failed send — and the traffic goes on exactly as it would have. And the AF_PACKET socket needs CAP_NET_RAW, which docker-entrypoint.sh asks for and starts without: it tries the full capability set, falls back to the set without cap_net_raw while saying what that costs, and only then runs as root. A host that will not grant it loses the capture, not the firewall.

Note for tests: a client now dials the service's own address, because there is no second port to be pointed at. The old note about the DNAT skipping loopback belonged to nginx's public-port redirect and went with it.

Authentication (backend/app.py)

Every module router is mounted onto api = APIRouter(prefix="/api", dependencies=[Depends(is_loggined)]), so a new file in routers/ is authenticated by construction. The whole auth surface is four places: that dependency, the Socket.IO connect handler (reads the token out of auth), and the three routes declared on app directly rather than on api — /api/status, /api/login, /api/set-password. Adding an unauthenticated route means declaring it on app, which should stay a deliberate, rare act.

--unsafe-disable-auth (env UNSAFE_DISABLE_AUTH) hands access control to a reverse proxy: check_login returns True, the socket.io handler skips its check, and APP_STATUS() reports run even with no password stored. The password endpoints (/api/login, /api/set-password, /api/change-password) answer 403 in this mode on purpose — otherwise an anonymous caller could plant a credential that keeps working once authentication is turned back on. run.py config --password still works, writing the hash straight into the DB from the host, and an explicit -P at first start is kept so that re-enabling authentication lands on a known password instead of the open initial-setup state.

The flag is state, not a constant. It lives in keys_values["auth_disabled"] and every check reads it (auth_disabled()); the environment is only the seed, written once per boot in startup_main. It used to be a module-level constant read at import, which is why run.py config --password could print "it will take effect immediately" at a process that had already decided every caller was an administrator — the hash landed in the database and nothing ever asked for it. run.py's put_runtime_settings()/get_runtime_setting() are the one way in and out of a running instance's keys_values (Docker exec or the standalone rootfs, same function), used by both --password and --[no-]unsafe-disable-auth, and config --show says when the running instance disagrees with the persisted config. config --password re-enables authentication when it was off, live and persisted: setting a password is asking for one to be asked for, and a stored-but-unchecked password is worse than none because it reads like one. It stands down when the same command line said otherwise — an explicit --unsafe-disable-auth, or --keep-auth-disabled for an instance deliberately held open behind a proxy. Note the password block runs after the auth block in handle_config_command, which is why the auth block's "there is no password set" warning checks whether one is about to be set.

POST /api/auth-mode turns it off and back on at runtime. The two directions are not symmetrical, and that asymmetry is the design: off is an administrator's deployment choice, but on, asked while it is off, would let any passer-by set a password and keep the real operator out for good — a lasting foothold, unlike the vandalism the mode already permits. So re-enabling takes a token this instance signed before it was turned off. Nothing new is signed while it is off (login and set-password both 403), so holding one is exactly that proof, and the operator who turned it off still has theirs. token_is_valid() is split out of check_login for this single caller: it needs the question without the "authentication is off, so yes" that check_login answers first. Either direction lasts only as long as the process, because the environment is what firegex comes up with — run.py config writes both the live database and .firegex-conf.json.

Frontend note: a 401 used to call window.location.reload(). A token left over from a database that has since been recreated (a new secret) 401s on the first query, the page reloads itself, and what comes back is whatever APP_STATUS() calls for — "choose a password", if none is set. That is the "sometimes a password screen appears on refresh" report. The token is now dropped and the app re-renders from the next /api/status, and no Authorization: Bearer null is sent when there is no token at all.

Independently of that, IPFilterMiddleware restricts access by CIDR (--allowed-ips, optionally reading the client IP from --proxy-ip-header). It fails closed: a missing, unparseable, or non-matching IP is denied. Note the header variant trusts a client-supplied value, so it only holds if the proxy overwrites it.

SQLite wrapper (backend/utils/sqlite.py)

Each DB file's schema is versioned by hashing the Python schema dict; on mismatch the entire file is deleted and recreated (not migrated) — any schema dict change wipes that DB file on next startup, including db/firegex.db's keys_values table (password/JWT secret). dump()/load() back the export/import backup feature; dump() deliberately excludes password, secret and auth_disabled from exported keys_values rows, so /api/import explicitly re-preserves all three around the import. The third one is the newest and the easiest to lose: it is not a credential, it is whether credentials are asked for, which belongs to the deployment and not to the configuration being restored — a backup able to carry it is a backup able to turn a firewall's authentication off. auth_disabled is also put back after startup_main(), not before like the other two, because startup_main seeds it from the environment: right when a process is starting, wrong during an import where nothing restarted and the environment did not change. tests/integration/test_backup.py turns authentication off around the import on purpose — asserting it is still on afterwards passes either way on a container whose environment agrees, and the bug only shows where the running state and the boot-time environment differ.

Real-time frontend updates

One Socket.IO event, "update", whose payload is a list of strings treated as a react-query queryKey prefix (e.g. ["services"], ["firewall"]). App.tsx does queryClient.invalidateQueries({ queryKey: data }) — prefix matching invalidates any query whose key starts with that array. Convention: each module's mutating endpoints emit the same tag string that module's frontend queries use as the first queryKey element. staleTime: Infinity is set globally, so this event is the only automatic refresh mechanism — a missing/mismatched tag means silent staleness for other clients/tabs.

Frontend structure

pages/<Module>/ (routes) + components/<Module>/ (ServiceRow, AddEditService, utils.ts with query key + API wrapper) per module, mirroring the backend split. Detail drill-down lives at pages/<Module>/ServiceDetails.tsx under a nested :srv route (see App.tsx's PageRouting). Theme tokens are CSS variables in src/index.css; Mantine Card defaults are set globally in index.tsx. Prefer Mantine Group/Stack with gap over the legacy Box className="center-flex" utility classes still present in older parts of the codebase.

fgex-lib/

Separate, independently-published PyPI package (pip install fgex/firegex) — the library users write Python filters against (@pyfilter, RawPacket/HttpRequest/etc.), plus the fgex CLI, a local proxysim for testing filters, and firegex.regex for testing pattern rulesets, without a real firegex instance.

The library module is firegex.pyfilters, and the CLI command is fgex pyfilters. Both were nfproxy when that was one of several modules. A compatibility alias carried the old spellings through 4.x and was removed in 5.0.0, which is a breaking change and the reason the version is a major one: from firegex.nfproxy import pyfilter is the first line of every filter written before it, including the ones already sitting in a running firegex's database.

Two things about that removal are worth keeping. The directory has to go, not just its contents — a firegex/nfproxy/ left behind with only a __pycache__ in it is a namespace package, so the import succeeds and hands back something empty, and from firegex.nfproxy import pyfilter then fails with an ImportError about a name rather than a ModuleNotFoundError about a module. And the failure is made to explain itself where the operator meets it: pyworker.py --check recognises this one import and says what to write instead, because the alternative is a filter that stops loading at the first start after an upgrade, which at a competition is the start of a round.

Do not reintroduce the alias as a file of re-exports if it ever comes back. type_annotations_associations maps model classes to protocols, and both the library and the datapath decide when to call a filter by looking an annotation up in that table, so a shim building second copies of HttpRequest would leave a filter annotating a class the table has never heard of — importing cleanly and then never being called. The old one aliased submodules into sys.modules for exactly that reason: one class per model, two spellings of one package.

Deploying is run.py, and only run.py. The package used to carry a fgex deploy passthrough that exec'd a copy of it; it is gone, along with the build-time copy setup.py made. The package is for writing and testing filters — firegex.pyfilters, firegex.regex, the two simulators — and starting a firegex is a separate job done from a clone. Do not reintroduce a deployment command here: the reason the old one was a verbatim passthrough was that any second description of run.py's argument surface drifts, and not shipping one at all is the same argument taken one step further.

The HTTP parser ships as source, and nobody upstream will bump it. fgex-lib/llhttp/ carries llhttp itself (lib/, verbatim from a release/* tag) and our binding beside it, because pyllhttp is archived, every binding with prebuilt wheels fails to load in the subinterpreters cpproxy runs filters in, and a ctypes binding against the system libllhttp would make pip install firegex assume a package installed beforehand. PROVENANCE.md there is what makes that maintainable: which tag, which checksums, what is ours and what is theirs, and how to move it. Each release changes what the parser accepts — going from 9.2.1 to 9.4.3, an empty Transfer-Encoding stopped being accepted and tabs around Content-Length started being — so tests/unit/test_http_parsing.py pins both through the public models: a change there is a decision about what filters are shown, never a test to fix. A parser more lenient than the service behind it is how a filter gets bypassed while still looking switched on. The binding names itself and nothing else: pyllhttp is where it was forked from, not something it uses, and the error path that imported it at runtime raised SystemError: ... returned NULL without setting an exception on every malformed request anywhere that archived package was not installed — which is everywhere except the machines it was written on. wheel_smoke.py is the check that could have caught it, and the reason it is a file rather than a -c string: cibuildwheel runs it against each wheel in an environment with nothing else in it, parsing something malformed as well as something valid.

firegex/regex/ binds libhs through ctypes rather than depending on a wheel: no build step, nothing to go stale, and by construction the same library the product loads. It is a third binding to one engine, not a third engine — the distinction that matters is that there is still only one thing deciding what matches. Where libhs is absent the tools raise HyperscanMissing and stop; adding an re fallback would reintroduce exactly the lie the in-app tester was built to avoid. tests/unit/test_regex_library.py pins the agreement: validity in the mode a pattern will run in, direction, the shared file format, and that a ruleset asking to rewrite is refused rather than quietly treated as a block. Not part of the backend/frontend build. fgex-lib/README.md is a short index (PyPI-facing) linking out to docs/*.md; it isn't rendered anywhere itself.

Module documentation (docs/*.md)

Each module's user-facing docs (docs/services.md, docs/pyfilter.md, docs/firewall.md) are single markdown sources, each imported directly with Vite's ?raw suffix by a matching <Module>Docs.tsx component (e.g. components/Services/ServicesDocs.tsx) and rendered through the shared components/MarkdownDocs.tsx (React Markdown + remark-gfm, code fences routed through CodeHighlight, relative .md cross-links rewritten to the equivalent GitHub blob URL since they'd otherwise resolve against the app's own origin). DocsButton.tsx's DocType enum wires each module to its component. Every language a fence uses has to be in index.tsx's createHighlighter({ langs }) — Shiki throws for one it was not given, and that throw is uncaught, so it takes the whole page down rather than the block it could not colour. Editing a module's docs means editing its one docs/*.md file — never the *Docs.tsx component directly. Reading these files across the frontend/↔repo-root boundary requires vite.config.ts's server.fs.allow: ['..'] (dev) and the Dockerfile frontend build stage's COPY ./docs/*.md /docs/ (prod/standalone rootfs) to stay in sync with any path changes.