# Proxy datapath engine An alternative engine to the NFQUEUE one in `backend/binsrc/`. Instead of lifting packets to userspace and handing a verdict back to the kernel, it terminates the connection and owns both halves. Status: **working, and not yet the default anywhere.** A service can select it from the Proxy Engine page, with regex and rewrite rules that apply to live connections, Python filters run in a process of their own, and it has been measured against nfregex. What it does not have is the C++ engine's parsed HTTP models. The two questions that could have killed the approach outright — does it still fail open, and does the service still see the real client — are answered below, and by tests rather than by argument. ## 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 has to be 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` on a blocking task | Chunk forwarded, filter disabled at once | | Filter never returns | Deadline on the blocking task | Chunk forwarded, filter disabled after N 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 | 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. ## What the proxy buys - **Exact rewriting.** `Verdict::Replace` changes the payload length and the stream stays correct, because there are two independent TCP connections. On NFQUEUE the same operation desynchronises sequence numbers, which is why its action is still called `UNSTABLE_MANGLE` there — see `docs/pyfilter.md`. - **Real connection metadata.** A filter is told the client's and the service's address and port once per connection (`ConnectionMeta`, sent as a `KIND_OPEN` frame), and can read nothing else below the application layer. That is more than a header could honestly say here: this engine terminated the connection, so the headers on the wire are its own. - **Reassembly from the kernel**, instead of `StreamFollower` and the out-of-order caveat that comes with it. - **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 rejected connection is shut down properly instead of leaving the peer to infer something from missing packets. ## 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; `hanging_filter_fails_open_then_loses_its_say` asserts the leak stops growing), 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. Nothing shipped now relies on the abandoned thread path — regex rules run inline, the Python worker enforces its own deadline — but the machinery stays, because it is what a future in-process filter would need. ## Rules One JSON line on stdin carries the whole ruleset, one `ACK OK` / `ACK FAIL ` line comes back — the same shape `cppregex` uses for its filter codes. Blocks are reported as `BLOCKED ` so the backend can attribute them. ```json [{"kind":"regex","id":"r1","pattern":"FLAG\\{[a-z]+\\}","direction":"s2c"}, {"kind":"replace","id":"r2","pattern":"secret","with":"[redacted]"}] ``` Two decisions worth knowing: - **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. The backend mirrors this: a rule the engine will not compile is removed again rather than left sitting in the list looking active. - **`regex` matches the window, `replace` matches the chunk.** Matching the chunk alone would miss a pattern split across two reads, which is the evasion nfregex reassembles the stream to prevent. Rewriting the window instead of the chunk would resend bytes that already left. The per-direction window defaults to 1MB (`FGEX_PROXY_STREAM_WINDOW`) — that is the longest pattern that can still be caught across a boundary, and every byte of it is rescanned per chunk, so it is a throughput knob rather than a buffer. It belongs to one connection: one client's traffic can never complete another's pattern. 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 — found by the suite, not by reading. ## Python filters The thing the C++ engine has and this did not. It runs in a **child process**, and that is the whole point rather than an implementation detail: the engine's own deadline can only walk away from a blocking thread, which then holds a pool slot until the process restarts. A child that misses its deadline is killed and respawned on the next chunk, and the chunk it was holding is forwarded. `tests/pyworker.rs` asserts exactly that. The API is the one a pyfilters user already knows: ```python from firegex.pyfilters import pyfilter, ACCEPT, REJECT @pyfilter def block_path_traversal(data: bytes, direction: str): return REJECT if b"../" in data else ACCEPT ``` What a filter is handed is different, and deliberately so: a chunk of the stream and a direction, not one of the C++ engine's parsed models. `HttpRequest` and friends are the C++ engine's, and they are not here. The import is shimmed to resolve to the worker rather than to an installed `fgex`, because the decorator has to register into this worker and borrowing the names without the models would be the confusing option. Frames are length-prefixed binary on the worker's stdin/stdout, tracebacks go to stderr where the backend already reads them. The worker announces itself once it has executed the user's file, which is what lets a ruleset carrying code that does not load be **refused when it is applied** instead of discovered later as traffic that quietly stopped being filtered — the backend then puts the previous file back. 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 rules see plaintext that never leaves the process, and it re-encrypts on the way out — `rustls`, so there is no OpenSSL in the build either. 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 worked, and it cost a config generated per stream, **a pair of ports derived from hashing `ip:port`**, and one hop where the traffic was in the clear. The ports are what settled it. They were chosen rather than assigned, so they could collide with something the operator was actually running, and a service was protected on them rather than on the address the world dialled. Terminating here means a TLS service occupies no port that a plain one does not. The argument that this removes the hop in the clear is *not* the argument, and was worth retiring: reading loopback on the box means already having code on the box. 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. The API reports whether TLS is on and never returns the material. The key has to be one `ring` will sign with — RSA of at least 2048 bits, ECDSA, or Ed25519. nginx could be told to accept a smaller RSA key with `@SECLEVEL=1`; rustls has no equivalent, and a service carrying one is refused at startup with the reason. One thing the NFQUEUE binaries never need, because they never open a second connection: on a path where both ends are local, `nat prerouting` is never consulted, and an intercept in `nat output` would catch the engine's own dial and loop it back into itself. So the engine stamps `SO_MARK` on every connection it opens and the intercept rules skip it. ### 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 — which would have been a real loss along with the ports. So `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 TLS service's plaintext and nothing else. They are **reconstructed** packets. The bytes are real — what the rules saw, and what was forwarded after any rewrite — 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. 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. ## Measured against nfregex `tests/proxy_benchmark.py` runs both engines on the same host with the same iperf3 workload and the same patterns, adding rules one at a time — the method `benchmark.py` already uses, with both binaries driven directly so it needs no firegex instance. Figures below are MB/s on one machine; the interesting part is the ratio, not the absolute number. | Rules | Proxy engine | nfregex (NFQUEUE) | Proxy + a Python filter | | --- | --- | --- | --- | | none | ~7600 | ~2450 | ~1310 | | 1 | ~4300 | ~2100–2700 | ~1200 | | 5 | ~2900 | ~2300–2800 | ~1140 | | 10 | ~2880 | ~2400–3300 | ~1200 | A Python filter costs roughly another halving and then stops mattering: every chunk is a round trip to a child process, and the regex rules that run alongside it are free by comparison. It is the price of being able to kill a filter that hangs, and at ~1.2 GB/s it is far above what any service in a competition will offer. TLS is measured apart, with its own client, because iperf3 does not speak it — only the ratio between the two rows means anything: | Through the engine | MB/s | | --- | --- | | plaintext | ~6900 | | TLS terminated and re-encrypted | ~1450 | Both handshakes and AES on both legs, for about a fifth of the plaintext figure. That is the cost of the engine doing the crypto rather than nginx — worth knowing before choosing which of the two TLS paths to take, though it is not what should decide it. Moving bytes, the proxy is about **3x faster**: the kernel does the reassembly and there is no per-packet round trip to userspace. With rules the two are **comparable** — nfregex's readings swing widely enough that claiming a winner at 10 rules would be reading noise. Getting there took two corrections that the benchmark, not review, made obvious: - The first version rescanned a 1MB rolling window with every rule on every chunk. Throughput fell with each rule added — 100 MB/s at ten rules, against nfregex's 2400 — while hyperscan on the other side did not move. Fixed by scanning only the new bytes plus a bounded overlap, and by compiling all the patterns that share a direction and a case setting into **one** `RegexSet`. - After that, one rule still cost 4x the throughput of none. The cost was not the matching: it was the thread hop and the copy that every filter paid so that user code which hangs cannot stall the datapath. A compiled regex cannot hang, so it now declares `may_block() == false` and runs inline against the relay's own buffers. That took ten rules from 1264 to ~2950 MB/s. The deadline machinery is still there, and still the default: it is what Python filters will need. It is simply no longer charged to filters that cannot use it. ## Layout - `src/filter.rs` — verdicts, the `Filter` trait, the chain and its isolation policy. `ChainHandle` swaps the chain under live connections so reconfiguring costs nobody. - `src/proxy.rs` — accept loop, upstream selection and the two relay pumps. - `src/transparent.rs` — `IP_TRANSPARENT`, original-destination recovery, and the startup capability probe. - `src/rules.rs` — the filters the backend configures, and the JSON they arrive in. - `src/pyworker.rs` — the child process running the user's Python, and its deadline. - `src/tls.rs` — termination and re-encryption, and the verifier that deliberately accepts what the protected service presents. - `src/control.rs` — the stdin/ACK control channel, running alongside the datapath so a rule change never gates traffic. - `src/spec.rs` — filters built from a text spec: `panic`, `hang` and friends. Test scaffolding only, so the Python suite can ask for a filter that misbehaves without linking against the crate. - `tests/fail_open.rs` — the guarantees above, over real sockets. - `tests/rules.rs` — matching, direction, rewriting, grouping, and the boundary cases. - `tests/pyworker.rs` — a hung worker being killed, a crashed one recovering. - `tests/tls.rs` — decrypt, inspect, re-encrypt, and rules seeing the plaintext. - `../../tests/proxy_benchmark.py` — the comparison above. - `../../tests/proxy_engine_test.py` — the same guarantees from outside the process. - `../../tests/proxy_transparent_test.py` — transparency, in three network namespaces. ## Source-IP transparency The service has to keep seeing the real client, or everything that logs, rate-limits or bans by address starts seeing one address for the whole internet. `transparent.rs` covers both halves: - **Where was the client going?** Under `tproxy` the kernel leaves the original destination as the accepted socket's own local address. Under dnat/redirect — the shape `porthijack` already uses — it survives only in conntrack, so it is fetched with `SO_ORIGINAL_DST`. Both modes are implemented; `FGEX_PROXY_INTERCEPT` picks one. - **Dial the service as the client.** `IP_TRANSPARENT` on the outbound socket, bound to the client's address with a kernel-chosen port. `CAP_NET_ADMIN` is checked 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 proxy falls back to its own address — traffic beats identity — but counts it and says so once, loudly. ### The ruleset tproxy needs Proven by `tests/proxy_transparent_test.py`, for a service outside this namespace. Rule order is not cosmetic: replies addressed to a client we are impersonating belong to an existing transparent socket and must be diverted **before** the forwarding decision sends them back out. The next section covers the on-host case, which the redirect intercept handles differently. ``` nft add rule ip pre socket transparent 1 meta mark set 0x1 accept nft add rule ip pre iif tcp dport tproxy to : meta mark set 0x1 ip rule add fwmark 0x1 lookup 100 ip route add local 0.0.0.0/0 dev lo table 100 ``` The `local` route is what makes marked packets be delivered here instead of routed by destination. Without it a spoofed connection does not fail, it hangs — which is why the test asserts a completed round trip and not just the address the service reports. The same route serves the redirect intercept; only the divert rule differs. ## 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. The proxy dials the service from the client's address; the work is in getting the reply back. With the service on this same host, two flows look almost identical: | Flow | src | dst | must | | --- | --- | --- | --- | | Proxy answering the client | `service:port` | `client:client_port` | leave the box | | Service answering the proxy | `service:port` | `client:` | 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 proxy opened itself. So the output chain reads ``` ct status dnat accept # the proxy answering a client: let it out ip saddr tcp sport mark set # anything else from the service: bring it home ``` and the marked packets are delivered locally by the usual `fwmark` rule plus a `local` default route. That is what makes source preservation work in the topology firegex actually runs in, with no namespace of its own and no tproxy. The tproxy intercept creates no NAT entry and so has no such marker: there, a divert rule broad enough to catch the service's reply also reroutes the proxy's own SYN-ACK away from the client, and the connection hangs rather than fails. That was measured. `modules/proxyengine/routing.check_topology` refuses the combination up front, so it is an error at start instead of a stall under load, and points at the mode that does work. | | intercept reaches proxy via | preserves client IP | needs `ip` | service on this host | | --- | --- | --- | --- | --- | | `redirect` | conntrack NAT + `SO_ORIGINAL_DST` | optional | only with preserve | yes | | `tproxy` | `tproxy to :port` | optional | always | no | ## Running it No Rust on the host? Build and test in a container: ```bash docker run --rm -v "$PWD/backend/proxysrc":/w -w /w rust:1-slim cargo test ``` End-to-end against the built binary, reusing the existing test helpers: ```bash docker run --rm -v "$PWD":/repo -w /repo/tests python:3-slim python3 proxy_engine_test.py ``` Transparency needs a real kernel, root and nftables, so it runs privileged: ```bash docker run --rm --privileged -v "$PWD":/repo -w /repo/tests fedora:44 sh -c \ "dnf install -y python3 nftables iproute >/dev/null && python3 proxy_transparent_test.py" ``` Neither test is in `run_tests.sh` yet: they need no running firegex instance, CI does not build this binary, and the transparency one rewrites the host's routing rules — the same reason `ipfilter_test.py` is run on its own. They join the suite when the Dockerfile builds the engine and a service can select it. ## Backend side `backend/modules/proxyengine/` follows the per-module shape the other modules use: - `models.py` — `Service`, with `intercept` selecting the trade-off above. - `nftables.py` — the ruleset, in the shared `firegex` inet table. One `filter` prerouting chain (divert + tproxy intercepts) and one `nat` prerouting chain (redirect intercepts). The divert rule is installed once by `init()`, never per service, and per-service rules append after it so it stays first. - `routing.py` — the policy routing tproxy needs, plus the two guards: `check()` for a missing `ip` binary, `check_topology()` for the namespace constraint. - `firewall.py` — the `ServiceManager`/`FirewallManager` pair. One engine process per service: it is started first because it is what picks the listening port (bind to 0, read `PORT ` back, the same handshake `cppregex` uses for `QUEUE `), and the rules are written afterwards. Stopping goes the other way round. `routers/proxyengine.py` mounts it at `/api/proxyengine` through the usual auto-discovery, with its own `db/proxy-engine.db`. Its own module and its own DB file on purpose: adding a field to an existing module's schema would wipe that module's database, since `utils/sqlite.py` recreates rather than migrates. The Dockerfile builds the engine in a `proxyengine` stage and ships it next to `cppregex`/`cpproxy`, and the runtime image now installs `iproute` for the return-path rules. `FGEX_PROXY_BINARY` overrides the path for a dev build. ## Next 1. The C++ engine's parsed models (`HttpRequest` and the rest), so a filter can work above raw bytes. 2. A worker pool, if Python filtering turns out to be a bottleneck. 3. Deciding whether the TLS module keeps its nginx. Everything it does is now possible here; retiring `modules/tls/nginx.py` would be the first change in this whole line of work that removes more code than it adds, and it is a product decision rather than a technical one.