61 KiB
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/)
No pytest — plain scripts driven against a live, already-running firegex instance (http://127.0.0.1:4444 by default), root/Linux required.
cd tests
./run_tests.sh [password] # full suite (defaults to "testpassword")
python3 regex_lib_test.py # firegex.regex — needs libhs, not an instance
python3 services_test.py -p <pw> -t proxy|nfqueue|external [--ipv6] [--tls]
python3 api_test.py -p <pw>
python3 benchmark.py -p <pw> -r <n regexes> [-t proxy|nfqueue]
python3 ipfilter_test.py # access control
services_test.py is the important one: the same filters are exercised on both network layers, because a filter that only works on one of them is exactly the failure the unified model exists to prevent. It also covers the parts that are easy to get subtly wrong — a second address added to a running service, a filter whose protocol is detected from its code, the bucketed statistics, and UDP being refused on the proxy layer with a reason. Run it twice in a row when changing anything about addresses or TLS: state left behind by the first pass only shows up on the second.
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. api_test.py is the exception and says so — half of 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.
ipfilter_test.py is the odd one out: those settings are only read at process startup, so it drives run.py stop/start itself for each scenario and restores an unrestricted instance at the end. It is not part of run_tests.sh — run it on its own, and expect it to bounce the instance.
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.
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, and losesClientAddress() marks the one combination that gives something up. 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 rewrites exactly and the other has a kernel-guaranteed fail-open.
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 viaNFQA_CFG_F_FAIL_OPENplusbypasson 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 inproxysrc/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.
NTHREADSreaches the Rust engine too since 5.0.0 —main.rsbuilds its tokio runtime with that many workers rather than#[tokio::main]taking every core, sorun.py --threadsmeans 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 beforetests/connbench.pystarted 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; keepdocs/services.md,LayerChoice.tsxandtransportSummary()saying that.external— a stateless destination rewrite that puts a proxy the operator wrote in the path (this is whatporthijackused to be); no firegex process is involved, socheck()refuses any filter, since a filter attached there would be one that never runs. -
Filter layer:
regex(patterns compiled into one hyperscan database) orpyfilter(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.
The cost is that the client's address is not preserved there: no transparent dial, so the service sees the engine. That is stated in the form where the layer is chosen, on the service itself (a NO CLIENT IP badge, losesClientAddress()), and in the docs. Do not quietly "fix" it with TPROXY: that leaves no NAT entry to key the return path off, so it cannot reach a service on this host, which is why it was ruled out before UDP came up.
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_addedinstalls only that address's rules against the datapath already running (self._steerholds what to point at). The one exception is a proxy service gaining its first IPv6 address, because the listener must be reopened in a family that can accept one — it restarts and logs that it did. address_removeddrops the address fromself.srv.addressesitself 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.protois 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
externalproxy 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 when any address is IPv6 (
[::]:0), andtransparent::unmapturns 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.
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.rson the proxy side, a binding rather than a reimplementation. Rewriting uses it too: block-mode scanning gives start-of-match offsets, and the matched span is replaced with a literal. That is why the replacement has no capture-group references — hyperscan reports where a match is, not what its groups captured, and inventing them would mean a second engine deciding them. Addingregexback toCargo.tomlwould undo this. - A pattern is validated against the mode it will run in. Blocking compiles for streaming, rewriting for block scanning, and the two do not accept quite the same patterns (
a{1,1000}bstreams but does not block-scan). Judging by the wrong one refuses rules that work — the same class of lie as a tester that disagrees with the engine.debug.rsreportsunscannablefor patterns that are valid but cannot be highlighted. - The Python API is
firegex.pyfilters, everywhere.cpproxyembeds it;modules/services/pyworker.pyruns it out of process. An earlier version of that worker defined a look-alike@pyfiltertaking(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_protoinfiregex/pyfilters/internalsreads the same table back the other way: asking for anHttpRequestmakes a file an HTTP filter, asking only for aRawPacketmakes one run on anything, and asking for two different application protocols is refused naming both functions.type_annotations_associationsis 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, andPUT .../codeis 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 anHttpRequest, 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.
@pyfiltersets a mark on the function and hands it back;collect_pyfiltersreads 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 earlierUNSTABLE_MANGLEsurvived, with nothing in the file able to tell you which. Thepositioncolumn onpyfilterscarries the same order to the interface, which used to list themORDER BY name. Three consequences of that registry are also gone with it:get_filters_infohad to clear it before and after (and reached it byexecing 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.
pyfiltersholds one row per@pyfilterthe code defines, reconciled with the file on everyPUT .../code: functions that have gone are dropped, new ones arrive active, survivors keep the operator'sactiveandblocked. 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_functionsreturnsNonefor no rows and[]for all-inactive, which is the same distinction. RawPacketis the only model that survives a datagram, and that fact is stated once, asDATAGRAM_SAFE_MODELSinrouters/services.py. Every other model declines to be built whenis_tcpis false, so a filter asking for one on a UDP service would sit in the chain and never be called.--checkreports the models a file asks for, saving refuses the combination naming them, andTransport.check()catches the case where the service was switched to UDP afterwards. Python filters do work on UDP on both layers:cpproxygainedfilter_action_udp(no stream follower, no ack fixing — a datagram is complete in itself), and its per-flow contexts are bounded byenforce_limitbecause 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_packetused 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 beexeced inside the backend's own event loop bydetect_proto, so awhile True:at module level would have taken the interface down.POST .../checkanswers 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_sourcefinds the line three ways: aSyntaxError'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'sdefs, last definition wins, because that is the one Python is left holding.Filterscompiles 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_callused to write the arguments into the module globals,evala call expression built from them, and delete the three keys again — per filter, per packet — which is exactlyfunc(*args), because the operator's code wasexeced into those globals and so the function's__globals__is that dict. - The default for
FGEX_STREAM_MAX_SIZEis 1 MiB, as documented. It read1*8e20under a comment saying "1MB", soFGEX_FULL_STREAM_ACTIONnever 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.pyreads the models out oftype_annotations_associationsand their members out of the classes — includingwritable, fromproperty.fset, which is how the interface can say that the payload is the only thing a filter may assign to.GET /api/services/pyfilter-apiserves it andpyfilterHints.tsfeeds 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 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_blocksplits 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.
RawPacketexposes addresses, ports, family and direction as read-only metadata, anddata— the payload — as the only writable thing;mangled_datacomes 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 literalFAKE:IP:TCP:HEADERS:prefix asraw_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 aKIND_OPENframe (ConnectionMeta→Filter::connection_opened), once per connection rather than on every chunk. - The order the operator set is the order that runs.
parse_rulesetgroups patterns by theirfilterid 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.rspins both. - The regex tester runs the engine.
POST /api/services/debug-regexshells out tofgex-proxy --debug-regex, so what it says is what will happen. Are-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, viautils.sqlite.SQLite.backend/modules/<mod>/models.py— plain model classes,from_dict.backend/modules/<mod>/firewall.py—FirewallManager/ServiceManagerpair: the manager tracks oneServiceManagerper DB row, which starts/stops that one service.backend/modules/<mod>/nftables.py— builds/tears down the module's nftables rules; extendsutils.NFTableManager(aSingleton), 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:
iptablesstopped working on the host.iptables-nftrefuses an entire table as soon as one rule in it cannot be expressed in iptables' own format, and it stores conntrack matches as anxtblob where nft writes a nativect state. Bisecting the rules firegex writes,ct statewas the only one that tripped it — which is to sayallow_establishedanddrop_invalid, on in any sane configuration. From then oniptables -Landiptables-saveanswertable `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-addedfilter INPUTwithpolicy accept, and in nftablesadd chainon 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
counterstatements 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
natchain, 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 onProxyStats, not onChainStats: 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_sharethere is exact. - NFQUEUE has no connection to count, so it reports
nullfor 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 onspawn_blocking, and a filter that misbehaves losing its say rather than the traffic being held.panic = "unwind"inCargo.tomlis 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.rsframes 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 dnatis 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.
0is no limit, kept as a value rather than an absence, because "unlimited" is a choice. over_limit_forwardsis 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
0when the layer changes, because a stored setting that does nothing is the trap this whole file keeps arguing against. Slotis a guard, not a decrement at the end ofhandle_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/services_test.py pins all four cases including both mismatches. 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 before the engine starts (a socket opened before the interface exists finds nothing) and FirewallManager.release_capture_if_idle() removes it once nothing is decrypting, so nobody points a capture at an interface that will stay empty. 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/api_test.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. It was firegex.nfproxy when that was one of several modules, and firegex/nfproxy/__init__.py still resolves — by aliasing the submodules into sys.modules, not by re-exporting them. That distinction is load-bearing: 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 that built second copies of HttpRequest would leave old filter files annotating a class the table has never heard of — importing cleanly and then never being called. One class per model, two spellings of one package. The CLI is fgex pyfilters, with fgex nfproxy kept as a hidden alias.
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.
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/regex_lib_test.py pins the agreement (validity per mode, direction, rewriting precedence, the shared file format). 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.