36 KiB
The proxy datapath engine
fgex-proxy: one of the two network layers a service can be put on. Instead of lifting
packets to userspace and handing a verdict back to the kernel — which is what
backend/binsrc/ does — it terminates the connection and owns both halves.
It is what a service selects as its proxy layer in modules/services/transports.py,
and it is what makes TLS, QUIC, HTTP/2 and HTTP/3 filterable at all.
This file is the engine's own documentation: what it does, and why it is shaped this way. The product-level view — which layer to choose and what each one costs — is
docs/services.md, and the rules a change here has to keep are inAGENTS.md. Where any of the three disagree,AGENTS.mdwins; this file has been wrong before by being left behind while the engine moved.
What it has to prove first
The NFQUEUE engine fails open in the kernel: the nft rule carries flags: ["bypass"] and
the binary asks for NFQA_CFG_F_FAIL_OPEN. If firegex dies, stalls, or falls behind,
packets flow anyway. In attack/defense that is the difference between "the filter didn't
fire" and "I lost the SLA".
A userspace proxy has no such backstop: if the process dies, the socket dies with it. So the property is rebuilt by hand, and it has to hold against user code that is assumed to be broken:
| Failure | Handled by | Outcome |
|---|---|---|
| Filter panics | catch_unwind around every call |
Chunk forwarded, filter disabled at once |
| Filter never returns | Deadline on the blocking task | Chunk forwarded, filter disabled after 3 in a row |
| Every filter disabled | degraded flag |
Chain skipped entirely — no copy, no thread hop |
| Connection handler panics | One task per connection | That connection only; the listener lives |
| Python worker hangs or dies | Killed and respawned on the next chunk | That chunk forwarded |
| Backend goes away | stdin closing is not a shutdown | The current ruleset keeps being enforced |
The degraded path is deliberately the cheapest one. Failing open must not cost more than
working normally, or the failure mode becomes a second failure. panic = "unwind" in
Cargo.toml is load-bearing rather than tuning: an aborting binary cannot catch anything.
tests/fail_open.rs asserts each row over real sockets.
What the proxy buys
- Every version of HTTP, filtered the same way. HTTP/2 and HTTP/3 compress their method, path and headers (HPACK, QPACK), so a layer that only forwards shows a filter a compression format. This one terminates them and renders each exchange as the HTTP/1.1 it would have been, so one pattern and one Python file cover all three.
- TLS and QUIC. The plaintext never leaves the process.
- Reassembly from the kernel, instead of a stream follower and the out-of-order caveat that comes with it.
- An ordered chain inside one process, rather than one process and one reassembly pass per filter.
- Real connection metadata. A filter is told the client's and the service's address
and port once per connection (
ConnectionMeta, aKIND_OPENframe), and nothing below the application layer beyond that. That is more than a header could honestly say here: this engine terminated the connection, so the headers on the wire are its own. - Backpressure for free. A slow filter slows the sender through TCP flow control rather than overflowing a fixed-size queue and dropping packets.
- Clean closes. A refused connection is shut down properly instead of leaving the peer to infer something from missing packets.
What it costs is the kernel backstop above, and being in the path at all: the connection the service sees is one this process opened.
The limitation that decided the design
A blocking filter thread cannot be cancelled. When one blows the deadline the engine walks away, but the thread lives until its own code decides to stop — it holds a blocking-pool slot until the process restarts. The damage is bounded (a filter that times out repeatedly is disabled and never called again) but bounded is not zero.
That is why user Python runs out of process: a hung worker can be killed, a hung
thread cannot. It also keeps libpython out of the datapath binary, which is where a good
share of the C++ engine's complexity lives.
Compiled patterns cannot hang, so they declare may_block() == false and run inline
against the relay's own buffers — no thread hop, no copy. That is not a micro-optimisation:
it took ten rules from ~1260 to ~2950 MB/s in the benchmark that found it. The deadline
machinery is still there and still the default; it is simply no longer charged to filters
that cannot use it.
Rules
One JSON line on stdin carries the whole ruleset, one ACK OK / ACK FAIL <why> line
comes back — the same shape cppregex uses for its filter codes. Blocks are reported as
BLOCKED <id> so the backend can attribute them.
[{"kind":"regex","id":"r1","filter":"f1","pattern":"FLAG\\{[a-z]+\\}","direction":"s2c","case_sensitive":true},
{"kind":"python","id":"f2","code_path":"/execute/db/service_filters/f2.py",
"enabled":["block_traversal"],"command":["python3","/execute/modules/services/pyworker.py"],
"timeout_ms":1000}]
Four decisions worth knowing:
- A pattern blocks. There is no second action. There used to be a
replacerule, and it was withdrawn because it could not keep its promise: rewriting scanned one chunk at a time — 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 hyperscan's stream mode) caught both. The miss was not the problem; its silence was. - The order the backend sent is the order that runs.
filtergroups the patterns of one link so they share a compiled database, which is what makes fifty patterns cost about what one costs — and a group is exactly a filter, so grouping never crosses a boundary the operator can see. An earlier version grouped every regex and then every Python rule, which turned the chain into a set.tests/rules.rspins both. - A rejected ruleset changes nothing. One bad pattern refuses the whole update and the previous rules keep enforcing. Failing open covers a filter that breaks at runtime; it is not a licence to quietly drop rules the operator asked for. Each pattern is compiled alone first and thrown away, only so a failure can name which rule is wrong — hyperscan reports the first one and stops.
- A block closes the connection, not the direction. Half-closing would let a service that answers without waiting for the request still reach a client whose request was refused. On QUIC and HTTP/2 the refusal closes the connection rather than the stream, for the same reason: a block a client can simply retry on the next stream is not a block.
Matching is hyperscan (src/hyperscan.rs, a binding to libhs), the same library
cppregex uses on the other layer. There is no second regex engine in this tree, and
adding regex to Cargo.toml would undo that.
Python filters
The API is the one a firegex user already knows, and it is the same library the
NFQUEUE binary embeds — firegex.pyfilters, with all of its models:
from firegex.pyfilters import pyfilter, ACCEPT, REJECT, HttpRequest
@pyfilter
def block_path_traversal(req: HttpRequest):
return REJECT if "../" in req.url else ACCEPT
modules/services/pyworker.py is the child process that runs it. A filter file's protocol
is read off the annotations, never declared; what a filter may read and write is the same
on both layers (RawPacket metadata in, payload out, nothing below the application
layer). See docs/pyfilter.md.
Frames are length-prefixed binary on the worker's stdin/stdout:
[u32 len][u8 kind][u64 connection][payload], big-endian, len covering everything after
itself.
| kind | meaning |
|---|---|
0 / 1 |
a chunk, client→server / server→client |
2 |
the connection is over — so the worker can drop its module globals |
3 |
the connection's metadata, one JSON object, once |
The reply is [u32 len][verdict][name len][name][payload]. The name is whichever
@pyfilter function decided, so a block is attributed as <filter>/<function> — the same
token the NFQUEUE binaries report, which is what lets the backend have one way to
attribute one.
Three things are load-bearing:
- The worker announces itself (
READY) once it has executed the user's file. Waiting for it is what lets a ruleset carrying code that does not load be refused when it is applied, rather than discovered later as traffic that quietly stopped being filtered. - The connection id is in every frame, because the documented promise is that each stream gets its own module globals. One client's state deciding another client's verdict is both a false positive and a way to smuggle a pattern past a filter.
stdoutis the protocol channel, so the worker reassignssys.stdout = sys.stderrbefore running user code: aprint()landing in the middle of a frame gets the worker killed on every packet with nothing to explain it.tests/pyworker.rshas a regression test for exactly that.
Tracebacks go to stderr, where the backend already reads them; the worker also marks them
so the backend can say the traffic was forwarded unfiltered alongside the traceback,
which is the sentence the NFQUEUE side produces from an EXCEPTION.
One worker per rule, one conversation at a time. Serialising is the honest simple thing while a frame is a request and a response; a pool is the next step if Python filtering ever becomes the bottleneck.
TLS
The engine terminates it: it decrypts from the client, the chain sees plaintext that never
leaves the process, and it re-encrypts on the way out — rustls with the ring provider,
so there is no OpenSSL in the build and the Docker stage stays a plain cargo build.
What that replaced was nginx: one listener terminating the public connection and
forwarding plaintext to a second loopback port, which re-encrypted towards the real
service, with the filter engine attached to the leg in between. It cost a generated config,
a pair of ports derived from hashing ip:port — chosen rather than assigned, so they
could collide with something real — a tls_streams table, 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. A TLS service now occupies no port a plain one does not, and
the blast radius is structural: each service carries its certificate into its own process.
ALPN is mirrored, and the ordering 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. 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.
Before this, ALPN was dropped in both directions and every client silently fell back to
HTTP/1.1.
The upstream certificate is not verified, mirroring the proxy_ssl_verify off nginx
was configured with. That is the right call rather than laziness: the service on the other
side is the thing firegex is defending, it is behind loopback or a private link, and in a
competition it is invariably self-signed. Verifying it would only ever refuse to protect it.
Certificate and key reach the engine as paths, never as environment values: a private key in an env dump is a private key in a log.
What cannot be carried, and is a limit rather than a missing setting: TLS 1.0 and 1.1,
which rustls does not implement; the cipher list, which is fixed and forward-secret only;
and client certificates, since the certificate a client would present is one only the
service can check. The key must be one ring will sign with — RSA ≥ 2048, ECDSA or
Ed25519; nginx could be told to take a 1024-bit key with @SECLEVEL=1 and rustls has no
equivalent, so a smaller one is refused at startup with the reason on stderr.
QUIC, and HTTP/3
QUIC is terminated, and unlike TLS there was never a second option. TLS over TCP can be carried past unopened — the bytes are framed by a transport the kernel understands — so filtering the ciphertext is a thing an operator could choose. Past its Initial packet QUIC encrypts the frames, the stream boundaries and the packet number along with the payload, so a packet queued to NFQUEUE is a datagram of noise.
To the kernel it is UDP, so the addresses, the rules and the relay map are the code that
was already there for datagrams; what changes is that the port is bound by something that
terminates (quic::QuicManager) instead of something that forwards (udp::UdpManager).
relays::Relays is the two-armed enum both the startup list and the control channel go
through.
- The ALPN is asked of the service, which is the one thing that could not be kept from
the TLS path: a QUIC ClientHello arrives inside an encrypted Initial whose processing
is the handshake, and quinn offers nothing to hold it at. So the order is reversed —
the service is dialled first with the candidate list (
FGEX_PROXY_QUIC_ALPN, defaulth3) and the client is advertised exactly the one it chose. The invariant survives; what is lost is knowing in advance whether the client would take it, which shows up as a failed handshake with a log line naming the protocol. - One stream is one connection to the chain, with its own sessions and its own module globals, released when the stream ends. A filter's state follows a stream of bytes from start to end, which is what a QUIC stream is and what a QUIC connection is not.
- A refusal closes the connection, not the stream.
- Datagrams are carried, and one connection's are one flow.
L4::QuicDatagramis its own value rather thanQuicwith a flag, because the two answer the stream question differently: a datagram getsRawPacketand nothing else, which is the same answer the plain UDP relay gives. The flow opens on the first datagram, not on the connection, so a connection that negotiates the extension and never uses it costs no filter state. A refused datagram is dropped and the connection carries on — a datagram is complete by the time it is judged.DATAGRAM_BUFFERbounds what one connection may have waiting. - They are not carried on HTTP/3, and the handshake says so rather than the peer
finding out: an HTTP/3 datagram names the stream it belongs to, and the request streams
this proxy opens towards the service are not the ones the client opened. So the
h3accept configuration is built with a transport that does not advertise the extension. - WebTransport is not carried, and it is a bigger question than its datagrams: its streams are bound to a session by a varint every one of them carries, which breaks both "one stream is one connection to the chain" and the HTTP/1.1 rendering. It needs a session model, not a switch.
- 0-RTT is refused (
max_early_data_size = 0): early data is replayable and a refused request cannot be un-delivered. And every unvalidated client gets a Retry, so nothing is opened towards the service on the word of a source address nobody has checked.
HTTP/3 is rendered to the chain as the HTTP/1.1 it would have been (src/h3.rs, on
hyperium's h3 — a binding, not a second implementation). That is the point of the whole
exercise: QPACK would otherwise be what a filter is shown, and every pattern written for
the TCP service beside it would silently stop matching.
HTTP/2, and one rendering for every version
src/http1.rs is the rendering, and h2.rs/h3.rs are what is left of each protocol
once it is taken out. The HTTP/1.1 view a filter is shown — the framing decision, the
head hold, render_request/render_response, judge — lives in one place, and both
protocols are adapters to somebody else's parser. Two copies would be two things a filter
can be shown, and the day they drift the symptom is a pattern that matches over one
version and not another with nothing to say so.
An HTTP/2 client can be put in front of a service that speaks HTTP/1.1: h2.rs takes its
upstream as an Outbound, so it is either another HTTP/2 connection or h1up::H1Upstream
where the address said the service is cleartext — exactly as on the QUIC edge.
Three things differ from h3 and each is a way to get this wrong:
- Flow control is ours to run.
h2hands received bytes over and waits for them to be released. Releasing on read means no backpressure and this process buying memory for whichever side is faster; never releasing stalls the stream after the first window. So capacity is released after the piece has been forwarded, and outbound writes reserve capacity before writing. - The end of a message rides on its last frame. A head that is the whole message has
to be sent with
end_of_streamon the head; sending it open and closing with an empty DATA frame turns a trailers-only answer into one with a body — which is exactly a gRPC status-only reply, and gRPC clients refuse the mangled version.head_is_the_messageasks the sender rather than inferring. - One stream is one connection to the chain, unlike HTTP/1.1 where a keep-alive connection carries many requests through one set of filter state. HTTP/2 interleaves its streams, and sharing state between two of them is a way to smuggle a pattern past a filter by splitting it across them.
Also:
h2c— HTTP/2 in the clear — is recognised from the connection preface, not from a setting. Most gRPC is deployed without TLS behind a load balancer, so leaving it out would have made "gRPC is filtered" half true.sniff()inproxy.rspeeks rather than reads, so a connection that turns out to be something else is handed to the byte pumps exactly as it arrived and there is no buffer to replay. It races the service, because a protocol where the server speaks first (SMTP, SSH, most game protocols) would otherwise be held waiting for a client that is correctly waiting for a banner — and it is bounded (SNIFF_WAIT), so two silent peers cannot hold two descriptors. The same peek answers the TLS question for anhttpservice, which is why there is oneOpeningand not two sniffs.- Server push is refused in the handshake, not by dropping what arrives, and
extended CONNECT is not advertised; a plain
CONNECTstream is reset with a log line rather than rendered, because a tunnel is opaque bytes with no HTTP/1.1 message in it. - A refusal closes the connection with
Reason::CANCEL, deliberately notREFUSED_STREAM, which tells a client the request was never acted on and may be retried. - A bypassed chain is not terminated. Rendering for a chain with nothing to say is work
paid for no answer, so a service with no filters, one whose filters have all been
disabled, and a connection admitted past the limit with
over_limit_forwardsall take the byte pump. The cost is that such a connection stays a byte pump for its whole life even if a filter is pushed a moment later. - The HTTP/1.1
Upgrade: h2chandshake is not carried, and that is a limit rather than an unconfigured setting: the upgrade request is filtered as the HTTP/1.1 request it is, but if the service answers101everything after it is HTTP/2 nobody rendered. RFC 9113 deprecated the mechanism and nginx dropped it in 1.25.1.
UDP
The TCP path recovers where a connection was headed with SO_ORIGINAL_DST, which the
kernel implements for TCP and SCTP only. 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 the backend points each address's rule at its own port.
Per-flow filter state is 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. Both directions are
inspected. Verdict::Reject drops the datagram rather than closing anything. 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 the self-mark, or the redirect rule would catch it and
loop.
New relays can be added to a running engine over the control channel, so an address added to a running service costs nobody their connection.
Holding up under load
A service carries at most FGEX_PROXY_MAX_CONNECTIONS at once, and the operator says
what happens to the rest. It exists because the alternative was measured: 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.
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. 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.
- One number for both halves, because TCP connections and UDP flows spend the same
descriptors.
0is no limit, kept as a value rather than an absence. Slotis a guard with aDrop, not a decrement at the end ofhandle_connection: that function has a dozen ways out, 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. 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. - On QUIC,
over_limit_forwardsis honoured but is not free: the connection is still terminated, decrypted and re-encrypted, because there is no such thing as forwarding a QUIC connection unopened.
FGEX_PROXY_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:
- Either direction counts. The flag is set by whichever pump moves a byte first, so a service that greets its client satisfies it with its banner. Requiring the client to speak would hang every server-speaks-first protocol.
- 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.
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 signal a refusal uses, so both directions shut down cleanly — a dropped peer reads a truncation, and this connection has done nothing to deserve one.
Preserving the client address
The service must keep seeing the real client, or everything that logs, rate-limits or bans
by address starts seeing one address for the whole internet. Source preservation is
unconditional: the engine always dials the service from the client's address
(IP_TRANSPARENT, with a kernel-chosen port). CAP_NET_ADMIN is probed at startup,
because a missing capability would otherwise surface only as every service quietly seeing
the proxy's address. If a transparent dial fails at runtime the engine falls back to its
own address — traffic beats identity — but counts it and says so once, loudly.
The work is in getting the reply back. With the service on this same host, two flows look almost identical:
| Flow | src | dst | must |
|---|---|---|---|
| Engine answering the client | service:port |
client:client_port |
leave the box |
| Service answering the engine | service:port |
client:<ephemeral> |
stay local |
No address tells them apart — only an ephemeral port chosen at connect time, which nothing
can be written against. Conntrack can: the first belongs to the intercepted, DNATed
connection, the second is a separate entry the engine opened itself. So the output chain
reads ct status dnat accept for the first and marks everything else from a protected
service home, and the marked packets are delivered locally by an fwmark rule plus a
local default route. modules/services/nftables.py installs all of it.
Only the redirect intercept exists. A tproxy mode was implemented and removed: the
intercept it creates makes no NAT entry, so there is no marker to separate those two flows,
and a divert rule broad enough to catch the service's reply also reroutes the engine's own
SYN-ACK away from the client — the connection then hangs rather than fails. That was
measured. Since the topology firegex actually runs in has the service on the same host or
behind a private link, the mode that cannot serve it was not worth keeping.
The engine stamps SO_MARK (SELF_MARK, 0x133A) on every connection it opens, and the
intercept rules skip it — otherwise, where both ends are local, an intercept in nat output
would catch the engine's own dial and loop it back into itself.
Where the plaintext goes
Terminating in-process means the decrypted traffic is never a packet, so there is nothing
for a capture tool to read. src/capture.rs writes it out: each connection's plaintext
framed as the TCP stream it was, sent over AF_PACKET to a dummy interface (firegex0)
that carries every decrypted service's traffic and nothing else.
They are reconstructed packets. The bytes are real — what the filters saw and what was forwarded — but the framing is built here, because the framing on the wire was encrypted: sequence numbers from zero, no retransmissions, segmentation from this engine's read sizes. A SYN and a FIN are emitted so a capture tool 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.
On QUIC each stream is written as its own TCP conversation, with a synthetic client port from a cycling counter — every stream of one connection shares the connection's four-tuple, and writing them out as they are would interleave a hundred of them into one conversation whose bytes decode as nothing. On HTTP/3 and HTTP/2 what is written is the rendering, which is the only way this interface can keep the promise it makes everywhere else: what is on it is what the filters saw. It is not what left the process, and the two cannot disagree because they are the same bytes.
Every failure is silent and local. No interface, no socket (it needs CAP_NET_RAW), a send
that fails — the traffic goes on exactly as it would have. A capture aid must never be able
to interrupt the service it is watching. The interface itself belongs to the instance and
is created by modules/services/mirror.py before any engine starts.
Talking to it
Two channels, both line-oriented, and the datapath never waits on either.
stdout — what the engine says. The handshake first, then events:
| line | meaning |
|---|---|
PORT <n> |
the port it bound. Pass :0 and read this back; nothing keeps a registry |
UDP <upstream> <port> |
one per relay, in the order they were asked for |
BLOCKED <id> |
a rule refused something; <filter>/<function> for Python |
ACK OK / ACK FAIL <why> |
the answer to one control line |
STATS seen=… refused=… live=… over_limit=… no_first_byte=… |
every 2 s, first one immediately |
Everything descriptive — [info], [warn], [fatal] — goes to stderr, which the
backend captures and turns into the service's log. _died_because keeps the last [fatal]
line, so a service that will not start reports the engine's own reason rather than "the
proxy engine did not report a listening port".
stdin — what it is told. One command per line:
| line | meaning |
|---|---|
[{…}, {…}] |
the whole ruleset, replacing the previous one |
ADD_UDP <addr> [same|plain|tls] |
bind one more relay; answers UDP … then ACK |
PUBLISH <dialled> <edge> <onward> [<service>|-] |
what is spoken at one address and where the service behind it is |
WITHDRAW <dialled> |
forget one published address |
stdin closing is not a shutdown: the backend being gone is exactly when the datapath outliving its control channel matters.
Environment
| variable | meaning |
|---|---|
FGEX_PROXY_LISTEN |
required. [::]:0 for dual-stack, which is what the backend passes |
FGEX_PROXY_UPSTREAM |
required. original (recover it per connection) or a literal address |
FGEX_PROXY_SPOOF_SOURCE |
dial the service as the client. The backend always sets it |
FGEX_PROXY_TARGETS |
<addr>[|<edge>[|<onward>]][=<service>], comma-separated; the addresses that are not simply the service |
FGEX_PROXY_UDP |
<addr>[|<onward>], comma-separated; one relay each |
FGEX_PROXY_TLS / _TLS_OPTIONAL |
terminate TLS on the TCP listener; _OPTIONAL means only for the connections that start a handshake |
FGEX_PROXY_TLS_CERT / _TLS_KEY |
paths, never the material |
FGEX_PROXY_QUIC |
bind the relays with a QUIC endpoint instead of a datagram socket |
FGEX_PROXY_QUIC_ALPN |
what to offer the service, in order. Default h3 |
FGEX_PROXY_MAX_CONNECTIONS |
0 is no limit |
FGEX_PROXY_OVER_LIMIT_FORWARD |
forward what does not fit, unfiltered, instead of refusing it |
FGEX_PROXY_FIRST_BYTE_TIMEOUT |
seconds; 0 is off |
FGEX_PROXY_FILTER_TIMEOUT_MS |
how long a blocking filter gets. Default 2000 |
FGEX_PROXY_CONNECT_TIMEOUT_MS |
upstream dial and handshakes. Default 5000 |
FGEX_PROXY_SELF_MARK |
hex; default 133A. Must match PROXY_SELF_MARK in the backend |
FGEX_PROXY_PYWORKER |
fallback path to the worker, for a Python rule that carries no command |
FGEX_PROXY_FILTERS |
panic, hang, block:<needle> — test scaffolding only |
NTHREADS |
tokio worker threads. What run.py --threads sets, on both layers |
FGEX_PROXY_BINARY is read by the backend, not by this, and points at the binary — which
is what a dev build overrides.
There is one more mode: --debug-regex reads one JSON request on stdin, answers on
stdout and exits, starting no listener and touching no rules. It is what
POST /api/services/debug-regex shells out to, so the in-app pattern tester is answered by
the engine that would enforce the answer rather than by an approximation that would bless
patterns hyperscan rejects. It reports unscannable for patterns that are valid, will run,
and cannot be highlighted — validity is judged in stream mode, which is the mode a
pattern actually runs in, while highlighting needs a block-mode scan that does not accept
quite the same language.
Layout
| file | what is in it |
|---|---|
src/main.rs |
environment, startup handshake, the runtime, the stats ticker, --debug-regex |
src/filter.rs |
verdicts, the Filter trait, the chain and its isolation policy. ChainHandle swaps the chain under live connections |
src/rules.rs |
the filters the backend configures, and the JSON they arrive in |
src/hyperscan.rs |
the libhs binding: stream and block scanning, compilation, validation |
src/proxy.rs |
accept loop, the opening peek, TLS and ALPN, the limit, the byte pumps |
src/http1.rs |
the HTTP/1.1 rendering, and the traits both HTTP protocols implement |
src/h2.rs |
HTTP/2, on hyperium's h2 |
src/h3.rs |
HTTP/3, on hyperium's h3 |
src/h1up.rs |
HTTP/1.1 towards the service, on hyper's client |
src/quic.rs |
QUIC termination, streams, datagrams, the relay manager |
src/udp.rs |
datagram relays and per-flow state |
src/relays.rs |
the two-armed enum both relay kinds go through |
src/tls.rs |
termination, re-encryption, ALPN mirroring, the deliberately permissive upstream verifier |
src/transparent.rs |
IP_TRANSPARENT, SO_ORIGINAL_DST, the self-mark, the startup capability probe |
src/capture.rs |
the reconstructed packets written to firegex0 |
src/pyworker.rs |
the child process running the user's Python, and its deadline |
src/control.rs |
the stdin channel, running alongside the datapath |
src/debug.rs |
--debug-regex |
src/spec.rs |
filters built from a text spec. Test scaffolding only |
Tests: tests/fail_open.rs (the guarantees above, over real sockets), tests/rules.rs
(matching, direction, grouping, order), tests/pyworker.rs (a hung worker killed, a
crashed one recovering), tests/tls.rs, tests/quic.rs, tests/h2.rs, tests/h3.rs, and
tests/throughput.rs (the matcher benchmark, --ignored).
Building and testing
Built in the Dockerfile's compiler stage rather than in 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.
docker build --target compiler -t firegex-compiler .
docker run --rm -v "$PWD/backend:/execute" -w /execute/proxysrc firegex-compiler cargo test
cd backend/proxysrc && cargo test # on a host with libhs + fgex installed
cargo test --release --test throughput -- --ignored --nocapture
Mount backend/, not backend/proxysrc/: tests/pyworker.rs launches
../modules/services/pyworker.py, which imports firegex, which imports brotli. Without
both on PYTHONPATH the pyworker tests fail with a ModuleNotFoundError buried in captured
output and read as real failures. The repo's own fgex-lib has to come first on the path,
or a stale image's firegex wins.
The product-level suite is tests/, driven by pytest against a running instance;
tests/integration/test_http_versions.py and tests/integration/test_grpc.py are the two
that say whether the rendering in here really works.
Measured against NFQUEUE
The proxy is not the slow one, and the docs used to say it was. Moving bytes it is about 2.2× NFQUEUE at one thread and 4.7× at eight: NFQUEUE pays a userspace round trip per packet, while this pays once per connection and then the kernel moves the bytes. On short connections the two are indistinguishable.
The numbers, the method and the corrections that produced them live in
tests/bench/README.md and tests/bench/results/, and that
is deliberately the only place they are written down — a table copied into a second file is
a table that will still be quoting a figure nobody can reproduce a year from now.
The trade between the two layers is fail-open and transparency, not speed. Keep
docs/services.md, LayerChoice.tsx and transportSummary() saying that.
Backend side
backend/modules/services/ holds the other half:
transports.py—ProxyTransportstarts this binary, reads the handshake, keeps the relay map and pushes the ruleset.NfqueueTransportandExternalTransportare the other two layers, behind the same interface.nftables.py— the redirect, the divert rule and the return path, all intable inet fgex.NAT_PRIORITYis-120, belowdstnat, so a container runtime's published port cannot rewrite the destination before firegex's rule matches it.firewall.py— oneServiceManagerper service: the engine is started first because it is what picks the listening port, and the rules are written afterwards. Stopping goes the other way round.pyworker.py— the child process this engine spawns for a Python rule, and--check, which is what decides whether a filter can be saved at all.mirror.py— thefiregex0device, created before any engine starts.