minor adjustments

This commit is contained in:
Domingo Dirutigliano 2026-09-24 09:19:00 +02:00
parent 0c52014606
commit 70373eb4f6
No known key found for this signature in database
36 changed files with 1322 additions and 321 deletions

View File

@ -128,6 +128,8 @@ The central idea, and the thing to preserve when changing anything here: **how t
`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.
**Telling the Python worker about a connection never waits on it.** `connection_opened`/`connection_closed` are called from the relay itself, on the async runtime; they used to take the worker's lock there — held by whatever exchange was in flight, up to its whole deadline — and start the worker if it was not running, so one connection feeding a hanging filter held every new connection of the service on the threads carrying all the others. They queue their frames now (`PyWorkerRule::pending`, capped) and the next exchange, which runs on a blocking thread, writes them ahead of its chunk. A ruleset reload prepares its filters on a blocking thread for the same reason: starting a worker is an interpreter start-up.
**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>|<onward> <port>` line per relay after `PORT`, and `_add_proxy` points each address's rule at its own port.
**One spelling of an upstream, and one owner of the map.** `udp_relay_key()` in `nftables.py` is the only place the `host:port` / `[host]:port` token is built, and `udp_relay_host()` the only place an interface name is resolved to something a relay can bind — IPv4 first and link-local last, because an interface almost always carries an `fe80::` address it was never configured with and binding there puts the service where nobody dials. Both were written out by hand in four and three places respectively, and the engine's map, the rule that points at a relay and the manager adding an address to a running service all have to agree on the token exactly; when they stop, the symptom is a service whose UDP silently goes nowhere. For the same reason `ProxyTransport.udp_ports` is the *only* map of relays: `start()` returns that dict as `_steer["udp_ports"]`, so it is the same object the rules read, and `ServiceManager.address_added` asks `add_udp_target()` for a relay rather than keeping a second copy of the answer beside it.
@ -172,22 +174,24 @@ Two implementations of the same concept is the mistake this module was restructu
- **Regex matching is hyperscan, everywhere — there is no second regex engine in the tree.** `binsrc/regex/` on the NFQUEUE side, `proxysrc/src/hyperscan.rs` on the proxy side, a binding rather than a reimplementation. Adding `regex` back to `Cargo.toml` would undo this.
- **A pattern blocks. There is no second action, and `rewrite` is not coming back.** It existed, and was withdrawn because it could not keep the promise it made: rewriting scanned one chunk at a time, since bytes already forwarded cannot be taken back, so a match straddling two chunks was never rewritten. Measured — a pattern sent whole arrived redacted, the same pattern split across two TCP segments arrived intact, and blocking (which scans in stream mode) caught both. The miss was not the problem; its silence was — no block, no log, no counter, just a rule the operator trusted that had quietly not applied. The removal has to be complete to mean anything, and it was half-done for a while: an `action` column that was gone from the datapath still had a `rewritten` field travelling from `debug.rs` through the backend model to a panel in `RegexDebugger.tsx` that could never render, a single-member `Action` enum in `firegex.regex`, and a `replace_with` on the frontend `Regex` type. If any of those spellings reappears, it is residue, not a feature.
- **A pattern is validated against the mode it will run in**, which is streaming, because that is how a pattern follows a connection across chunk boundaries. hyperscan does not accept quite the same patterns in every mode (`a{1,1000}b` streams but does not block-scan), and judging by the wrong one refuses rules that work — the same class of lie as a tester that disagrees with the engine. Block-mode scanning survives for one job only: reporting *where* a match starts, so the tester can highlight it. `debug.rs` reports `unscannable` for patterns that are valid, will run, and cannot be highlighted.
- **The C++ binaries swap their configuration through `std::atomic<shared_ptr<…>>`** (`regex_config`, `config`). The updater thread replaces it while every queue thread copies it per packet or per stream, and a plain `shared_ptr` read and reset from two threads at once is a data race: the copy can take a reference on a control block the reset has just freed, so an operator editing a rule under load could crash the binary with it. Neither binary defines `DEBUG` any more — `cpproxy` did, which printed the operator's whole filter source to its stderr, and so into the service log, on every reload.
- **The Python API is `firegex.pyfilters`, everywhere.** `cpproxy` embeds it; `modules/services/pyworker.py` runs it out of process. An earlier version of that worker defined a look-alike `@pyfilter` taking `(data, direction)`, which meant a filter written from the docs loaded on one transport and failed on the other.
- **A filter file's protocol is read off the file, never declared.** The library already decides when to call a filter from what its parameters are annotated with, so `infer_proto` in `firegex/pyfilters/internals` reads the same table back the other way: asking for an `HttpRequest` makes a file an HTTP filter, asking only for a `RawPacket` makes one run on anything, and asking for two *different* application protocols is refused naming both functions. `type_annotations_associations` is the single place a protocol is defined; a second list of names would be one more thing to keep in step, and the day it fell behind the symptom would be a filter that silently never runs. The backend stores the detected value on the row so the interface can show it, and `PUT .../code` is what recomputes it. Passing one in from outside is exactly what used to make an HTTP filter on a TCP service a 500: the *service's* protocol was sent to the datapath, so the filter was refused for asking for an `HttpRequest`, which is the only thing an HTTP filter does.
- **A filter file's functions run in the order the file defines them**, and the first to answer anything but ACCEPT ends the packet. `@pyfilter` sets a mark on the function and hands it back; `collect_pyfilters` reads a module's globals, which are in definition order. What that replaced was a *set of names* on a module-global attribute of the decorator, so the order was whatever hashing produced — which decided which function got credited with a block and whether an earlier `UNSTABLE_MANGLE` survived, with nothing in the file able to tell you which. The `position` column on `pyfilters` carries the same order to the interface, which used to list them `ORDER BY name`. Three consequences of that registry are also gone with it: `get_filters_info` had to clear it before and after (and reached it by `exec`ing import strings), two compilations could race, and a function registered from somewhere the module globals could not reach was registered and then not found.
- **A pyfilter file's functions are switched on and off individually, and the code is never edited to do it.** `pyfilters` holds one row per `@pyfilter` the code defines, reconciled with the file on every `PUT .../code`: functions that have gone are dropped, new ones arrive active, survivors keep the operator's `active` and `blocked`. Only the enabled names reach `__firegex_pyfilter_enabled`, which is already what decides whether the library ever calls a function. Three states are distinct all the way down (`Option<Vec<String>>` in the spec, `*` / `=a,b` / `=` on the worker's argv): **absent** means every function the file defines — the case of a filter whose list was never derived, where defaulting to "none" would silently stop every filter running — while an **empty list** means the operator switched them all off. `ChainLink.enabled_functions` returns `None` for no rows and `[]` for all-inactive, which is the same distinction.
- **gRPC is a body format inside HTTP, not a protocol beside it, and that is what keeps it free.** `GrpcMessage`/`GrpcRequest`/`GrpcResponse` live under `"http"` in `type_annotations_associations` and nowhere else. The alternative — a `"grpc"` value in `filters.proto`'s CHECK and in `PROTO` — would have been a schema change, and changing that dict **deletes and recreates `db/services.db`**, so the price of a model would have been every service on the instance. Everything *around* a call already arrives through `HttpRequest`: the method is the path, the metadata are headers, the status is a trailer section. What did not was the body, which is a sequence of `[flag][4-byte length][protobuf]`.
- **The parser learned to release body chunks, and only the gRPC models turn it on.** A server-streaming or bidirectional RPC has a body that does not finish until the stream closes, so a model shown only finished bodies is called once, at the end — a log entry rather than a block. `release_body_chunks` on `InternalCallbackHandler` hands the message over as its body grows; it is off by default, and each gRPC model has its own `_parser_class()`, so `HttpRequest` keeps its contract exactly (twice: once with the headers, once with the whole message). Two details are load-bearing: the partial body is **undecoded**, because `content-encoding` is undone once at message-complete and half a gzip stream is not half a body (the models decline while one is in the way, which for real gRPC never happens — it compresses per message instead); and the cursor lives on the `InternalHTTPMessage`, not beside it, because a keep-alive connection carries many messages through one parser and a per-connection cursor would start the second one part-way in. Separate parser keys are not tidiness either: taking a frame *consumes* it, so two models sharing one parser would leave the second silently never running.
- **`RawPacket` is the only model that survives a datagram**, and that fact is stated once, as `DATAGRAM_SAFE_MODELS` in `routers/services.py`. Every other model declines to be built when `is_tcp` is false, so a filter asking for one on a UDP service would sit in the chain and never be called. `--check` reports the models a file asks for, saving refuses the combination naming them, and `edit_service` asks the same of every Python filter of a service being switched to UDP (`_filters_needing_a_stream`) — `Transport.check()` can only see the stored protocol, which says whether a file parses HTTP and not whether it wants a TCP stream. Python filters *do* work on UDP on both layers: `cpproxy` gained `filter_action_udp` (no stream follower, no ack fixing — a datagram is complete in itself), and its per-flow contexts are bounded by `enforce_limit` because a datagram has no close to observe and a spoofed-source flood would otherwise accumulate one set of module globals per forged address. `handle_next_packet` used to throw for anything but TCP, with a message claiming UDP was supported.
- **`RawPacket` is the only model that survives a datagram**, and that fact is stated once, as `DATAGRAM_SAFE_MODELS` in `routers/services.py`. Every other model declines to be built when `is_tcp` is false, so a filter asking for one on a UDP service would sit in the chain and never be called. `--check` reports the models a file asks for, saving refuses the combination naming them, and `edit_service` asks the same of every Python filter of a service being switched to UDP (`_filters_needing_a_stream`) — `Transport.check()` can only see the stored protocol, which says whether a file parses HTTP and not whether it wants a TCP stream. Python filters *do* work on UDP on both layers: `cpproxy` gained `filter_action_udp` (no stream follower, no ack fixing — a datagram is complete in itself), and its per-flow contexts are bounded because a datagram has no close to observe and a spoofed-source flood would otherwise accumulate one set of module globals per forged address: a UDP flow's context goes after `UDP_IDLE_SECONDS` (60) of silence, and past the service's own `max_connections` (`FIREGEX_MAX_FLOWS`; 0, the default, is no limit) the least recently used one goes to make room. It used to be a fixed 4096 applied to **every** context, TCP included, in flow-id order — so a burst of datagrams could take the state of a TCP stream in the middle of a request, whose next packet was then parsed from a clean slate. Only UDP flows are counted and evicted now (`udp_recent`/`udp_where`). `handle_next_packet` used to throw for anything but TCP, with a message claiming UDP was supported.
- **A filter file is validated by the process that will run it.** `pyworker.py --check <file>` builds the module exactly as the worker does, compiles it, and prints one JSON object: `{ok, proto, filters}` or an error carrying **type, message, line, column, the source line and the traceback**. `validate_code()` runs it in a **subprocess with a timeout** — user code used to be `exec`ed inside the backend's own event loop by `detect_proto`, so a `while True:` at module level would have taken the interface down. `POST .../check` answers 200 even when the answer is no: a refusal is the result, and the editor wants a position to mark, not a string to print. Saving goes through the same check, so the operator is never handed "the filter code did not load: worker exited" and left to find the traceback in the service log. **Nothing else runs the operator's module in the backend either**: `_reconcile_functions` takes the function names from the check's answer rather than `exec`ing the file a second time, and the NFQUEUE payload (`_python_payload`) hands the binary the enabled names — `None` for all of them — and lets `firegex.pyfilters.internals.compile()` resolve them against the module it has just compiled, passing over a name the file no longer defines. And the check is awaited, not waited for: `validate_code`, `check_pattern` and `_ask_engine` run their helper processes through `_run_helper` (asyncio). They used `subprocess.run` inside `async` endpoints, which blocks the one event loop the whole backend runs on — for a Python start per keystroke-pause in the editor, and up to `CHECK_TIMEOUT` for slow module code — while no ACK and no `BLOCKED` line was read, until an engine's stdout pipe filled and its synchronous `println!` stalled the datapath itself.
- `_where_in_source` finds the line three ways: a `SyntaxError`'s own position; a traceback frame belonging to the file (`<string>` counts as theirs — the library execs the source to find its filters, and everything the library runs from has a real path); otherwise any identifier in the message looked back up among the file's `def`s, **last definition wins**, because that is the one Python is left holding.
- `Filters` compiles once under the operator's file name instead of re-parsing per connection, so runtime tracebacks in the log name their file and their line too.
- A filter is called directly. `context_call` used to write the arguments into the module globals, `eval` a call expression built from them, and delete the three keys again — per filter, per packet — which is exactly `func(*args)`, because the operator's code was `exec`ed into those globals and so the function's `__globals__` *is* that dict.
- **`FGEX_STREAM_MAX_SIZE` bounds what a stream costs in memory, and three things had got past it.** A compressed body was decoded whole (`on_message_complete`): a megabyte of gzip under the cap is a gigabyte after it, so every step of the decoding is bounded by the cap now, and a body that would go past it meets `FGEX_FULL_STREAM_ACTION` like any other stream past the cap (with `FLUSH`, handed over still encoded). A WebSocket message is bounded the same way (`max_size` to `Frame.parse`). The flush itself cleared the *view* of a body and subtracted it from the count while the buffer it is read from kept growing — so on a gRPC stream, whose body is handed over as it grows, memory grew past every flush and the count went negative (`_flush_body`); framed gRPC bytes are also dropped as they are handed over (`_forget_framed`), and a frame past the cap is skipped to its end so the next one is still found where it starts. And `HttpHistory` counted messages but not bytes, a hundred bodies of a megabyte per direction per connection; each direction's history is held under the cap too (`_trim_history`). Separately, the flush raised on a message still in its headers (`len(None)`), so headers past the cap were an exception and the packet failed open.
- The default for `FGEX_STREAM_MAX_SIZE` is 1 MiB, as documented. It read `1*8e20` under a comment saying "1MB", so `FGEX_FULL_STREAM_ACTION` never fired unless a size was set by hand, and the float it produced would have been refused by the setter beside it.
- **The editor's hints are introspected, never written down.** `modules/services/pyapi.py` reads the models out of `type_annotations_associations` and their members out of the classes — including `writable`, from `property.fset`, which is how the interface can say whether a member can be assigned to — none can, since the payload stopped being writable with `UNSTABLE_MANGLE`. `GET /api/services/pyfilter-api` serves it and `pyfilterHints.ts` feeds Monaco's completion and hover from it. A second, hand-written description would be wrong the first time a model gained a property, and autocompletion that lies is worse than none. The route is declared **before** `/{service_id}`, or that one matches it and looks for a service by that name.
- **A filter that raises says so the same way on both layers.** `_on_exception` writes one sentence — the traffic was forwarded unfiltered — and it has to be reachable from either. NFQUEUE gets there directly: the interpreter is embedded in the binary, which sends `EXCEPTION`. The proxy cannot, because `pyworker.py` catches the exception in a separate process and answers with an ordinary ACCEPT, so nothing crosses the frame boundary that the engine could tell apart from a filter agreeing. It marks stderr with `EXCEPTION_MARK` instead and `Transport._pump_stderr` turns that back into the same call. The traceback is printed *beside* it, not instead of it: the traceback says which filter and which line, and the sentence says what happened to the connection — for a while the proxy printed only the first, which tells an operator a filter broke and not that traffic went through unchecked. The marker is spelled in both files because the worker is launched as a script and imports nothing from the backend, which is what keeps the user's code out of this process's imports; `tests/integration/test_resilience.py` asks for the sentence on both layers and so keeps them in step.
- **A block names the function, not just the file.** Both layers report `<filter_id>/<function>`: the NFQUEUE binaries already reported the function name, and the proxy worker now returns it in the verdict frame (`[verdict][name len][name][payload]`). `ServiceManager._on_block` splits that one token in one place. A file with ten functions blocking 900 connections is not an answer to "which of them is doing it".
- **Nothing below the application layer crosses into a filter.** `RawPacket` exposes addresses, ports, family and direction as read-only metadata, and `data` — the payload — read-only as well: a filter answers with a verdict, and nothing comes back but that verdict and the function that gave it. The payload was writable for `UNSTABLE_MANGLE`, and stayed writable after it was removed, which made `packet.data = ...` a silent no-op on both layers while the editor still offered a "rewrite" snippet ending in `return UNSTABLE_MANGLE` — a name that no longer exists, so the snippet raised on its first match and the traffic went through unfiltered. The setter, the snippet, the hover text and the docs' example are gone together; the C++ `MANGLE` branch is unreachable and harmless. That boundary exists because the two layers cannot honestly offer the same thing underneath it: NFQUEUE has a real header whose rewriting desynchronises the stream, while the proxy terminated the connection and writes its own. The old model papered over that by handing the proxy a literal `FAKE:IP:TCP:HEADERS:` prefix as `raw_packet`, so the same filter did different things depending on where it was attached. On the proxy the metadata is *better* than a header would be — the engine knows both ends and sends them in a `KIND_OPEN` frame (`ConnectionMeta` → `Filter::connection_opened`), once per connection rather than on every chunk.
- **Nothing below the application layer crosses into a filter.** `RawPacket` exposes addresses, ports, family and direction as read-only metadata, and `data` — the payload — read-only as well: a filter answers with a verdict, and nothing comes back but that verdict and the function that gave it. The payload was writable for `UNSTABLE_MANGLE`, and stayed writable after it was removed, which made `packet.data = ...` a silent no-op on both layers while the editor still offered a "rewrite" snippet ending in `return UNSTABLE_MANGLE` — a name that no longer exists, so the snippet raised on its first match and the traffic went through unfiltered. The setter, the snippet, the hover text and the docs' example are gone together, and so is the C++ path that applied a rewrite: `cpproxy` treats action `3` (the retired `MANGLE`) as `INVALID`, which fails open and is reported as an exception. The number stays unused rather than being given to anything new. `FilterAction::MANGLE` in `nfqueue.cpp` is **not** that: it is how `reject()` sends the RST with the payload stripped, and it stays. That boundary exists because the two layers cannot honestly offer the same thing underneath it: NFQUEUE has a real header whose rewriting desynchronises the stream, while the proxy terminated the connection and writes its own. The old model papered over that by handing the proxy a literal `FAKE:IP:TCP:HEADERS:` prefix as `raw_packet`, so the same filter did different things depending on where it was attached. On the proxy the metadata is *better* than a header would be — the engine knows both ends and sends them in a `KIND_OPEN` frame (`ConnectionMeta` → `Filter::connection_opened`), once per connection rather than on every chunk.
- **The order the operator set is the order that runs.** `parse_ruleset` groups patterns by their `filter` id and keeps the positions the backend sent. An earlier version grouped every block, then the Python — which turned the chain into a set, so a filter placed after another could run before it, and nothing said so. Within one filter the operator expressed no order, so blocking goes before rewriting there; across filters, never reorder. `tests/rules.rs` pins both.
- **The regex tester runs the engine.** `POST /api/services/debug-regex` shells out to `fgex-proxy --debug-regex`, so what it says is what will happen. A `re`-based approximation would bless patterns hyperscan rejects.
@ -198,7 +202,7 @@ Each module (`services`, `firewall`) follows the same shape:
- `backend/modules/<mod>/firewall.py` — `FirewallManager`/`ServiceManager` pair: the manager tracks one `ServiceManager` per DB row, which starts/stops that one service.
- `backend/modules/<mod>/nftables.py` — builds/tears down the module's nftables rules; extends `utils.NFTableManager` (a `Singleton`), so there is exactly one rule-set owner per module.
**Every nftables object firegex creates is named `fgex_`, and lives in a table firegex owns.** The services module holds `table inet fgex` with `fgex_queue_in_<n>`, `fgex_queue_out_<n>`, `fgex_hijack_*`, `fgex_divert`, `fgex_nat`, `fgex_nat_out`, `fgex_route`; the firewall module holds `fgex_filter` and `fgex_mangle` per family, with base chains `fgex_input`/`fgex_forward`/`fgex_output`/`fgex_prerouting`/`fgex_postrouting` jumping to `fgex_rules_in`/`_out`/`_fwd`. One `grep fgex_` over the ruleset is the whole footprint.
**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`, `fgex_guard` and the set `fgex_datapath` — and its `reset()` deletes **the whole table**, because flushing it chain by chain was one batch that nft refuses outright if any chain named in it is missing, so a table left by a version with other chains (an upgrade after a crash) was never cleared and went on steering traffic at dead queues and ports; 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.
**An interface rule says where traffic *arrived*, not where it was going, and that is deliberate.** `iifname docker0 tcp dport 443` matches every container's **outbound** HTTPS as well, because that traffic arrives on `docker0` too, and a `redirect` then takes it into the engine, which terminates it with this service's certificate — measured: `pip install` inside any container failed with `CERTIFICATE_VERIFY_FAILED: hostname mismatch` while such a service was running, and worked the moment it stopped. Pinning the destination to the addresses the interface carries was written, tested and **reverted**, because it removes the one case interfaces exist for: an operator names a link when **the address is not theirs to know**, which includes firegex in front of a service on another machine reached through it, and a rule pinned to this host's own addresses silently stops protecting that. Protecting a link protects what crosses it. Naming one that carries other people's traffic is a choice, and the answer to not wanting it is a narrower address, not a narrower rule — `docs/services.md` says so where the choice is made, because it is surprising the first time. Do not re-narrow this without a way to tell the two deployments apart.
@ -257,7 +261,8 @@ A Rust binary, built in the Dockerfile's `compiler` stage rather than a `rust` i
- Filters that keep state do so **per connection, per direction** (`ChainSessions`), because one client's bytes deciding another client's verdict is both a false positive and a way to smuggle a pattern past a filter by splitting it across two connections.
- `pyworker.rs` frames carry a connection id and a close frame, so the Python side can honour the documented promise that each stream gets its own module globals.
- Source preservation is unconditional: the engine always dials the service from the client's address. `ct status dnat` is what separates the engine answering a client from the service answering the engine, which is why redirect (not tproxy) works even when the service is on this host. A transparent dial the service **refuses** is not retried as ourselves: nothing is listening, and the retry used to blame source preservation for a service that was simply down.
- **A connection nothing redirected is refused** (`dialled_at_ourselves`). The listener is on an ephemeral port of every address (`[::]:0`), so anybody can dial it straight; `SO_ORIGINAL_DST` then answers with the address the connection was accepted on, and forwarding there is the engine dialling itself — measured, one such client became as many connections as the limit allowed in two seconds, and with no limit it grows until the process runs out of descriptors. The old guard compared with the configured listen address, which is the wildcard and never matches.
- **The engine's own ports answer only what was redirected to them** (`fgex_guard`). The TCP listener and every UDP/QUIC relay are bound to the wildcard, because a redirect delivers to whichever address the packet arrived on — so a relay, which forwards to its service whoever sent the datagram, put a service protected on `127.0.0.1:53` on the network. `fgex_datapath` holds `l4proto . port` of every one of them and `fgex_guard` (input hook) drops what reaches one without conntrack's DNAT status; `fgex_divert`'s mark exempts replies to the engine's transparent sockets, whose destination is a client's port and can be any number. A port is added when its redirect is installed and released only when the **whole service** comes down (`FiregexTables._guarded`), because a relay stays bound after its address is removed and would otherwise be reachable directly again — and an element left behind after a stop would drop traffic for whatever process is given that port next.
- **A connection nothing redirected is refused** (`dialled_at_ourselves`), behind the guard as well as in front of it. The listener is on an ephemeral port of every address (`[::]:0`), so anybody can dial it straight; `SO_ORIGINAL_DST` then answers with the address the connection was accepted on, and forwarding there is the engine dialling itself — measured, one such client became as many connections as the limit allowed in two seconds, and with no limit it grows until the process runs out of descriptors. The old guard compared with the configured listen address, which is the wildcard and never matches.
- **The backend watches the engine** (`ProxyTransport._watch`, the same `on_died` → `_recover_datapath` the NFQUEUE stages always had). The nft rules outlive the process, so a dead engine used to leave every connection redirected at a closed port — a total outage while the service read `ACTIVE`, and nothing ever restarted it. `reload()` on a dead engine raises rather than returning quietly, so an edit is not reported applied to nothing.
- **The client's SNI goes on to a TLS upstream**, beside its ALPN list (and on the QUIC → HTTPS path once the client's handshake has said it). A service choosing its certificate or virtual host by SNI was handed the bare address — which TLS does not even send — and answered as its default host. A QUIC upstream is dialled before the client's handshake and cannot be told.
@ -268,13 +273,13 @@ Note for tests: Linux refuses a loopback connection to a port with no listener *
### 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.
**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. A cap on the same shape existed on the NFQUEUE side (a fixed `MAX_UDP_FLOWS` of 4096 in `pyproxy.cpp`) and had never been carried to the Rust engine; TCP had none anywhere. That fixed cap is gone: on NFQUEUE the same `max_connections`, set in the form's advanced settings for a UDP service, bounds how many UDP flows keep their Python state, and idle flows are let go after a minute whether or not one is set.
Say what it buys, and no more. **A cap does not save the service being attacked** — it cannot tell a connection that is silent because it is an attack from one that is silent because the client is slow, so an attacker who fills the limit still fills it. What it buys is that one service's attacker stops being everyone's: measured, 400 silent connections against a limited service left the service beside it answering. Do not let the interface or the docs imply otherwise.
- One number for both halves, because TCP connections and UDP flows spend the same descriptors and two limits for that would be two things to keep in step. `0` is no limit, kept as a value rather than an absence, because "unlimited" is a choice. It **is** one number: `udp.rs` counts its flows in the same `stats.live` the TCP and QUIC paths do, with a `Slot` held by the flow. It used to compare each relay's own flow count with the limit, so a service with several UDP addresses held several times its limit beside a full complement of TCP connections — and since UDP flows were never counted in `accepted` or `closed_by_filter` either, a UDP service reported no connections and a refused share of zero whatever it refused. A refusal is counted once per flow, because the number it goes into is compared with flows seen.
- `over_limit_forwards` is the same trade as fail-open at a different moment, and off by default for the same reason: a filter that quietly stops filtering is the worse surprise. On UDP a flow past the limit is carried as a flow of its own with an empty chain, answers included. It used to be a datagram sent from a socket closed straight after, so the service's answer reached a port nobody held: forwarded, and useless to every request/response protocol UDP carries.
- 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.
- 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 — with, for a UDP service only, the same number read as how many UDP flows keep their Python state (`limitsQueuedFlows`) — and the hand-off layer has none of them, so it gets no accordion at all.
- **Proxy layer only**, and absent from the form elsewhere. NFQUEUE hands the kernel a verdict on packets in flight; it accepts nothing and dials nothing. The form also resets it to `0` when the layer changes, because a stored setting that does nothing is the trap this whole file keeps arguing against.
- `Slot` is a guard, not a decrement at the end of `handle_connection`: that function has a dozen ways out — a refused chain, a failed dial, a timed-out handshake, a panic — and a counter that leaks on any one of them is a limit that tightens until it refuses everything.
- The accept loop **backs off** after a failed accept. That one is not a policy question: out of descriptors, the loop spun on `accept` → `EMFILE` → print → `accept`, measured at ~40% of a core with four clients knocking, writing a log line per attempt into the pipe the backend reads.
@ -372,6 +377,10 @@ Three things differ from h3 and each is a way to get this wrong:
**One authority, or the request is malformed** (`conflicting_authority`, asked by `h2.rs` and `h3.rs` before anything is rendered). HTTP/2 and HTTP/3 carry the authority in `:authority`, a client may add a `host` header beside it, and the rendering shows the chain one `Host` line while the request goes on to the service with both — where an HTTP/2 or HTTP/3 service routes on `:authority` (RFC 9113 §8.3.1). Two that disagree showed every filter one host and delivered the request to the other, so a rule written against an admin vhost was walked past with `host: public` beside `:authority: admin`. They are reset (`PROTOCOL_ERROR` / `H3_MESSAGE_ERROR`) with a log line, as are two `host` headers.
**What belongs to a connection does not cross into another version** (`http1::connection_specific`, `http1::drop_connection_headers`). HTTP/2 and HTTP/3 have no hop-by-hop headers: the framing and the connection's lifetime are the protocol's own. An HTTP/1.1 service says things about its connection in almost every answer — `transfer-encoding: chunked` for a body of unknown length, `connection: keep-alive` — and those were passed on as they came: the h2 crate refused to send such a response at all, so an HTTP/2 client in front of an ordinary HTTP/1.1 service got a stream error for any chunked answer, and an HTTP/3 client is required to reject one. `h1up` takes them out, with whatever `connection` names, before the answer is rendered or sent. The other direction: h2 refuses a request carrying one itself, but the h3 crate does not, so `h3.rs` stops such a stream as malformed (RFC 9114 §4.2) rather than hand an HTTP/1.1 service a `connection` naming headers to drop or a `te` that becomes a transfer coding; `te: trailers` is the one allowed. The test stand-ins had never sent either header, which is how the first half went unnoticed.
**One client connection is bounded like one connection.** `max_connections` counts a TCP or QUIC connection once, and on the HTTP edges each stream is an exchange of its own — towards an HTTP/1.1 service, a connection of its own. The h2 crate sets no limit on concurrent streams by default, so one HTTP/2 client could open the protected service as many times as it liked; `h2.rs` advertises `MAX_CONCURRENT_STREAMS` (100, what quinn already allows per QUIC connection). A client's head is bounded the same way on both (`http1::MAX_HEAD_BYTES`, 1 MiB — the budget the filter library holds a stream to by default, and far past what any service accepts, so a large cookie or heavy gRPC metadata is never what it refuses): h2 allowed 16 MiB and h3 had no limit at all. Both crates answer an oversized head with `431` themselves. And the connection dialled before the client's protocol is known is dropped on the HTTP/2 → HTTP/1.1 path, where every exchange dials its own; held, it sat idle towards the service for as long as the client stayed.
**Server push is refused in the handshake, not by dropping what arrives** (`enable_push(false)` towards the service) — the same courtesy the QUIC edge pays about datagrams, and for the same reason: a peer that learns from SETTINGS is better off than one that watches its pushes vanish. **Extended CONNECT is not advertised**, and a plain `CONNECT` stream is reset with a log line rather than rendered: a tunnel is opaque bytes with no HTTP/1.1 message in it, and inventing one would be the fabrication everything here avoids. Both are the WebTransport answer from the QUIC section, arriving at the same place.
**A refusal closes the connection, not the stream**, with `Reason::CANCEL` — deliberately not `REFUSED_STREAM`, which tells a client the request was never acted on and may be retried, and a block a client retries past is not a block. The GOAWAY is polled onto the wire before the socket goes (`GOAWAY_GRACE`), for the same reason the byte pumps shut down rather than abort.

View File

@ -19,13 +19,6 @@ using namespace std;
namespace Firegex{
namespace NfQueue{
/* Largest packet we are willing to hand back to the kernel in a verdict.
NFQUEUE payloads are carried in a netlink attribute whose length field is
16 bit wide, so anything above this can never be re-injected anyway; the
cap exists to stop a python filter from making us allocate an arbitrary
amount of memory through the payload it returns. */
const size_t MAX_VERDICT_PACKET_SIZE = 0xffff;
/* Reads the fail-open policy once, tolerating a missing variable.
`strcmp(getenv(...), "1")` was the previous spelling, which dereferences a
null pointer the moment the binary is run without the variable set — by
@ -323,40 +316,6 @@ class PktRequest {
}
}
// Rewrite the application payload, keeping the headers the packet arrived with.
//
// A filter can no longer see or write anything below the application layer, so
// there is no packet to take from it — only the payload it produced. That is also
// the honest shape: the proxy layer terminates the connection and writes its own
// headers, so a filter that could rewrite an IP header here and not there would be
// a filter that means two different things depending on where it was attached.
void mangle_custom_data(const char* data_ptr, size_t data_len){
if (action == FilterAction::NOACTION){
try{
/* The payload comes from the operator's python, and what is
rebuilt from it is what goes back to the kernel in a verdict.
A netlink attribute carries its length in 16 bits, so a
bigger packet could never be re-injected anyway — refusing it
here is what stops a filter from making this process
allocate an arbitrary amount of memory for nothing. */
if (data_len + _header_size > MAX_VERDICT_PACKET_SIZE){
throw invalid_argument("Mangled packet is too big to be re-injected");
}
set_data(data_ptr, data_len);
reserialize();
action = FilterAction::MANGLE;
}catch(const std::exception& e){
#ifdef DEBUG
cerr << "[DEBUG] [PktRequest.mangle_custom_data] " << e.what() << endl;
#endif
action = FilterAction::DROP;
}
perform_action(false);
}else{
throw invalid_argument("Cannot mangle a packet that has already been accepted or dropped");
}
}
FilterAction get_action(){
return action;
}

View File

@ -38,8 +38,9 @@ void config_updater (){
}
}
try{
regex_config.reset(new RegexRules(raw_rules, regex_config->stream_mode()));
cerr << "[info] [updater] Config update done to ver "<< regex_config->ver() << endl;
auto next = make_shared<RegexRules>(raw_rules, regex_config.load()->stream_mode());
regex_config.store(next);
cerr << "[info] [updater] Config update done to ver "<< next->ver() << endl;
osyncstream(cout) << "ACK OK" << endl;
}catch(const std::exception& e){
cerr << "[error] [updater] Failed to build new configuration!" << endl;
@ -81,7 +82,7 @@ int main(int argc, char *argv[]){
bool fail_open = Firegex::NfQueue::nfqueue_fail_open();
regex_config.reset(new RegexRules(stream_mode));
regex_config.store(make_shared<RegexRules>(stream_mode));
MultiThreadQueue<RegexNfQueue> queue_manager(n_of_threads);
osyncstream(cout) << "QUEUE " << queue_manager.queue_num() << endl;

View File

@ -1,4 +1,3 @@
#define DEBUG
#define PY_SSIZE_T_CLEAN
#include <Python.h>
@ -56,9 +55,9 @@ __firegex_packet_info = {
}
Nothing below the application layer crosses this boundary as bytes: the addresses and
ports are metadata a filter reads, and the payload is the only thing it can write. The
proxy datapath terminates the connection and writes its own headers, so a filter that
could rewrite one here and not there would mean two different things depending on which
ports are metadata a filter reads, and nothing comes back but a verdict. The proxy
datapath terminates the connection and writes its own headers, so a filter that could
rewrite one here and not there would mean two different things depending on which
network layer it happened to be attached to.
The protocol is not passed in either: the library reads it off the filters' parameter
@ -68,15 +67,14 @@ only for a RawPacket runs on anything.
As result the packet handler is responsible to return a dictionary in the global context with the following dictionary:
__firegex_pyfilter_result = {
"action": REJECT, # One of PyFilterResponse
"matched_by": "invalid_curl_agent", # The function that matched the packet (used if action = DROP or REJECT or MANGLE)
"mangled_data": b"new payload" # The rewritten application payload (used if action = MANGLE)
"matched_by": "invalid_curl_agent", # The function that matched the packet (used if action = DROP or REJECT)
}
PyFilterResponse {
ACCEPT = 0,
DROP = 1,
REJECT = 2,
MANGLE = 3,
// 3 was MANGLE, a payload rewrite; retired, and refused as INVALID
EXCEPTION = 4,
INVALID = 5
};
@ -120,13 +118,10 @@ void config_updater (){
cerr << "[fatal] [updater] Control socket error: " << e.what() << endl;
exit(EXIT_FAILURE);
}
#ifdef DEBUG
cerr << "[DEBUG] [updater] Received code: " << code << endl;
#endif
cerr << "[info] [updater] Updating configuration" << endl;
PyEval_AcquireThread(state); //Restore GIL before executing python code
try{
config.reset(new PyCodeConfig(code));
config.store(make_shared<PyCodeConfig>(code));
cerr << "[info] [updater] Config update done" << endl;
control_socket << "ACK OK" << endl;
}catch(const std::exception& e){
@ -155,7 +150,7 @@ int main(int argc, char *argv[]) {
if (n_threads_str != nullptr) n_of_threads = ::atoi(n_threads_str);
if(n_of_threads <= 0) n_of_threads = 1;
config.reset(new PyCodeConfig());
config.store(make_shared<PyCodeConfig>());
MultiThreadQueue<PyProxyQueue> queue(n_of_threads);

View File

@ -80,10 +80,6 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue<PyProxyQueue> {
control_socket << "BLOCKED " << func_name << endl;
}
inline void print_mangle_reason(const string& func_name){
control_socket << "MANGLED " << func_name << endl;
}
inline void print_exception_reason(){
control_socket << "EXCEPTION" << endl;
}
@ -97,20 +93,18 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue<PyProxyQueue> {
pyq->pkt->drop();// This is needed because the callback has to take the updated pkt pointer!
}
//: How many UDP flows may hold a filter context at once. See `enforce_limit`.
static const size_t MAX_UDP_FLOWS = 4096;
// One datagram, judged on its own.
//
// No stream to follow and no sequence numbers to fix: a datagram is complete in
// itself, which is also why a rewrite here is *exact* rather than the unstable
// thing it is over TCP — there is nothing for a different length to desynchronise.
// Each flow still gets its own module globals, keyed the same way a connection is.
// itself. Each flow still gets its own module globals, keyed the same way a
// connection is.
void filter_action_udp(NfQueue::PktRequest<PyProxyQueue>* pkt, const string& data){
// Idle flows first, so a limit is never met by flows that have already gone.
sctx.udp_expire(UDP_IDLE_SECONDS);
auto stream_search = sctx.streams_ctx.find(pkt->sid);
pyfilter_ctx* stream_match;
if (stream_search == sctx.streams_ctx.end()){
shared_ptr<PyCodeConfig> conf = config;
shared_ptr<PyCodeConfig> conf = config.load();
PyObject* compiled_code = conf->compiled_code();
if (compiled_code == nullptr){
return pkt->accept(); // no filter configured; nothing to ask
@ -122,11 +116,12 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue<PyProxyQueue> {
print_exception_reason();
return pkt->accept();
}
sctx.enforce_limit(MAX_UDP_FLOWS);
sctx.udp_make_room(max_udp_flows());
sctx.streams_ctx.insert_or_assign(pkt->sid, stream_match);
}else{
stream_match = stream_search->second;
}
sctx.udp_touch(pkt->sid);
auto result = stream_match->handle_packet(pkt, data, pkt->is_input);
switch(result.action){
@ -139,16 +134,6 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue<PyProxyQueue> {
case PyFilterResponse::REJECT:
print_blocked_reason(*result.filter_match_by);
return pkt->drop();
case PyFilterResponse::MANGLE:
pkt->mangle_custom_data(result.mangled_data->c_str(), result.mangled_data->size());
if (pkt->get_action() == NfQueue::FilterAction::DROP){
cerr << "[ERROR] [filter_action_udp] Failed to mangle: Malformed Packet... the packet was dropped" << endl;
print_blocked_reason(*result.filter_match_by);
print_exception_reason();
}else{
print_mangle_reason(*result.filter_match_by);
}
return;
case PyFilterResponse::EXCEPTION:
case PyFilterResponse::INVALID:
print_exception_reason();
@ -161,7 +146,7 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue<PyProxyQueue> {
auto stream_search = sctx.streams_ctx.find(pkt->sid);
pyfilter_ctx* stream_match;
if (stream_search == sctx.streams_ctx.end()){
shared_ptr<PyCodeConfig> conf = config;
shared_ptr<PyCodeConfig> conf = config.load();
//If config is not set, ignore the stream
PyObject* compiled_code = conf->compiled_code();
if (compiled_code == nullptr){
@ -205,16 +190,6 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue<PyProxyQueue> {
stream.client_data_callback(bind(keep_fin_packet, this));
stream.server_data_callback(bind(keep_fin_packet, this));
return pkt->reject();
case PyFilterResponse::MANGLE:
pkt->mangle_custom_data(result.mangled_data->c_str(), result.mangled_data->size());
if (pkt->get_action() == NfQueue::FilterAction::DROP){
cerr << "[ERROR] [filter_action] Failed to mangle: Malformed Packet... the packet was dropped" << endl;
print_blocked_reason(*result.filter_match_by);
print_exception_reason();
}else{
print_mangle_reason(*result.filter_match_by);
}
return;
case PyFilterResponse::EXCEPTION:
case PyFilterResponse::INVALID:
print_exception_reason();

View File

@ -5,6 +5,7 @@
#include <marshal.h>
#include <vector>
#include <memory>
#include <atomic>
#include <iostream>
#include "../utils.cpp"
@ -15,7 +16,9 @@ namespace PyProxy {
class PyCodeConfig;
shared_ptr<PyCodeConfig> config;
// Atomic for the reason `regex_config` is: the updater swaps it while the queue threads
// copy it for every new stream.
std::atomic<shared_ptr<PyCodeConfig>> config;
UnixClientConnection control_socket;
PyObject* unmarshal_code(string encoded_code){

View File

@ -5,6 +5,8 @@
#include <iostream>
#include <tins/tcp_ip/stream_identifier.h>
#include <map>
#include <list>
#include <cstdlib>
#include <atomic>
#include <chrono>
#include <Python.h>
@ -22,34 +24,32 @@ namespace PyProxy {
class PyCodeConfig;
class PyProxyQueue;
// The library's `Action` values, which are the wire format between it and this binary.
// 3 was `MANGLE`, a rewrite of the payload; it is gone from the library, and the number
// stays unused so that nothing old can ever mean something new. Anything outside the
// valid set is `INVALID`, which fails open and is reported like an exception.
enum PyFilterResponse {
ACCEPT = 0,
DROP = 1,
REJECT = 2,
MANGLE = 3,
EXCEPTION = 4,
INVALID = 5
};
const PyFilterResponse VALID_PYTHON_RESPONSE[4] = {
const PyFilterResponse VALID_PYTHON_RESPONSE[3] = {
PyFilterResponse::ACCEPT,
PyFilterResponse::DROP,
PyFilterResponse::REJECT,
PyFilterResponse::MANGLE
};
struct py_filter_response {
PyFilterResponse action;
string* filter_match_by = nullptr;
// The rewritten application payload, never a packet: nothing below the
// application layer crosses into the filter, so nothing below it comes back.
string* mangled_data = nullptr;
py_filter_response(PyFilterResponse action, string* filter_match_by = nullptr, string* mangled_data = nullptr):
action(action), filter_match_by(filter_match_by), mangled_data(mangled_data){}
py_filter_response(PyFilterResponse action, string* filter_match_by = nullptr):
action(action), filter_match_by(filter_match_by){}
~py_filter_response(){
delete mangled_data;
delete filter_match_by;
}
};
@ -256,28 +256,8 @@ struct pyfilter_ctx {
del_item_from_glob("__firegex_pyfilter_result");
return py_filter_response(action_enum, func_name);
}
if (action_enum == PyFilterResponse::MANGLE){
PyObject* mangled_data = PyDict_GetItemString(result, "mangled_data");
if (mangled_data == nullptr){
del_item_from_glob("__firegex_pyfilter_result");
#ifdef DEBUG
cerr << "[DEBUG] [handle_packet] No result mangled_data found" << endl;
#endif
return py_filter_response(PyFilterResponse::INVALID);
}
if (!PyBytes_Check(mangled_data)){
#ifdef DEBUG
cerr << "[DEBUG] [handle_packet] mangled_data is not a bytes" << endl;
#endif
del_item_from_glob("__firegex_pyfilter_result");
return py_filter_response(PyFilterResponse::INVALID);
}
string* pkt_str = new string(PyBytes_AsString(mangled_data), PyBytes_Size(mangled_data));
del_item_from_glob("__firegex_pyfilter_result");
return py_filter_response(PyFilterResponse::MANGLE, func_name, pkt_str);
}
//Should never reach this point, but just in case of new action not managed...
delete func_name;
del_item_from_glob("__firegex_pyfilter_result");
return py_filter_response(PyFilterResponse::INVALID);
}
@ -287,12 +267,54 @@ struct pyfilter_ctx {
typedef map<stream_id, pyfilter_ctx*> matching_map;
// How long a UDP flow keeps its filter's state with nothing arriving. A datagram has no
// close to observe, so this is the only thing that ends a flow — the same minute the
// proxy layer's relay gives one.
constexpr int64_t UDP_IDLE_SECONDS = 60;
inline long long env_number(const char* name){
const char* env = getenv(name);
if (env == nullptr) return 0;
char* end = nullptr;
long long parsed = strtoll(env, &end, 10);
return end != env ? parsed : 0;
}
// How many UDP flows one queue thread may hold a filter's state for at once: the
// service's own "most connections at once", shared out between the threads — each holds
// the contexts of the flows hashed to it, so a limit applied per thread would be the
// operator's number times the thread count. 0, missing or unreadable means no limit, and
// idle flows still go after `UDP_IDLE_SECONDS`, which is what keeps that from growing
// without end.
inline size_t max_udp_flows(){
static const size_t value = [](){
const long long limit = env_number("FIREGEX_MAX_FLOWS");
if (limit <= 0) return (size_t)0;
const long long threads = env_number("NTHREADS") > 0 ? env_number("NTHREADS") : 1;
return (size_t)((limit + threads - 1) / threads);
}();
return value;
}
inline int64_t steady_seconds(){
return std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
}
struct stream_ctx {
matching_map streams_ctx;
NfQueue::tcp_ack_map tcp_ack_ctx;
// The UDP flows holding a context, most recently used first, with when each was
// last seen — so the one to let go of, whether for being idle or to make room, is
// always at the back. TCP contexts are not in here and are never evicted: a TCP
// stream is released when libtins sees it close, and dropping its state while it
// is still open would have its next packet parsed from a clean slate mid-message.
list<pair<stream_id, int64_t>> udp_recent;
map<stream_id, list<pair<stream_id, int64_t>>::iterator> udp_where;
void clean_stream_by_id(stream_id sid){
auto stream_search = streams_ctx.find(sid);
if (stream_search != streams_ctx.end()){
@ -300,6 +322,41 @@ struct stream_ctx {
delete stream_match;
streams_ctx.erase(stream_search->first);
}
auto flow = udp_where.find(sid);
if (flow != udp_where.end()){
udp_recent.erase(flow->second);
udp_where.erase(flow);
}
}
// This UDP flow was just used.
void udp_touch(const stream_id& sid){
auto flow = udp_where.find(sid);
if (flow != udp_where.end()){
udp_recent.erase(flow->second);
}
udp_recent.emplace_front(sid, steady_seconds());
udp_where[sid] = udp_recent.begin();
}
// Let go of the UDP flows nothing has arrived on for `idle` seconds.
void udp_expire(int64_t idle){
const int64_t now = steady_seconds();
while (!udp_recent.empty() && now - udp_recent.back().second >= idle){
stream_id sid = udp_recent.back().first;
clean_stream_by_id(sid);
}
}
// Make room for one more UDP flow under `limit`, letting the least recently used go;
// a limit of zero is none. Only UDP flows are counted and only UDP flows go: the
// ceiling used to be applied to every context, so a burst of datagrams could take the
// state of a TCP connection still in the middle of a request.
void udp_make_room(size_t limit){
while (limit > 0 && udp_where.size() >= limit && !udp_recent.empty()){
stream_id sid = udp_recent.back().first;
clean_stream_by_id(sid);
}
}
void clean_tcp_ack_by_id(stream_id sid){
@ -311,23 +368,6 @@ struct stream_ctx {
}
}
// Keep the number of live filter contexts under a ceiling.
//
// A TCP flow is released when libtins sees the connection close. A datagram has no
// close to observe, so a UDP service under a spoofed-source flood would otherwise
// accumulate one set of Python module globals per forged address until the process
// died. Which context is dropped is arbitrary — the map is ordered by flow id, not
// by age — and that is the honest trade: the bound is the point, and a flow that
// loses its globals starts again from a clean state rather than taking the service
// with it.
void enforce_limit(size_t limit){
while (streams_ctx.size() >= limit && !streams_ctx.empty()){
auto victim = streams_ctx.begin();
delete victim->second;
streams_ctx.erase(victim);
}
}
void clean(){
for (auto ele: streams_ctx){
delete ele.second;
@ -337,6 +377,8 @@ struct stream_ctx {
}
tcp_ack_ctx.clear();
streams_ctx.clear();
udp_recent.clear();
udp_where.clear();
}
};

View File

@ -8,6 +8,7 @@
#include <vector>
#include <hs.h>
#include <memory>
#include <atomic>
using namespace std;
@ -194,7 +195,11 @@ class RegexRules{
}
};
shared_ptr<RegexRules> regex_config;
// Atomic, because it is swapped by the updater thread while every queue thread copies
// it per packet. A plain shared_ptr read and reset from two threads at once is a data
// race: the copy can take a reference on a control block the reset has just freed, and
// an operator editing a pattern under load could crash the binary with it.
std::atomic<shared_ptr<RegexRules>> regex_config;
void inline scratch_setup(regex_ruleset &conf, hs_scratch_t* & scratch){
if (scratch == nullptr && conf.hs_db != nullptr){

View File

@ -39,7 +39,7 @@ public:
NfQueue::PktRequest<RegexNfQueue>* pkt;
bool filter_action(NfQueue::PktRequest<RegexNfQueue>* pkt, const string& data){
shared_ptr<RegexRules> conf = regex_config;
shared_ptr<RegexRules> conf = regex_config.load();
auto current_version = conf->ver();
if (current_version != latest_config_ver){

View File

@ -314,13 +314,6 @@ def _chain(name: str, ctype: str, hook: str, prio: int) -> dict:
}
def _drop_chain(name: str) -> list[dict]:
return [
{"flush": {"chain": {"table": FiregexTables.table_name, "family": "inet", "name": name}}},
{"delete": {"chain": {"table": FiregexTables.table_name, "family": "inet", "name": name}}},
]
class FiregexTables(NFTableManager):
# NFQUEUE transport: one pair of chains per position in the filter chain.
@staticmethod
@ -340,6 +333,9 @@ class FiregexTables(NFTableManager):
nat_chain = "fgex_nat"
nat_output_chain = "fgex_nat_out"
route_chain = "fgex_route"
guard_chain = "fgex_guard"
#: `l4proto . port` of every listener and relay a proxy-layer redirect points at.
datapath_set = "fgex_datapath"
def __init__(self):
super().__init__(
@ -400,6 +396,64 @@ class FiregexTables(NFTableManager):
_chain(self.nat_output_chain, "nat", "output", NAT_PRIORITY),
# `route`, so changing the mark forces the packet to be re-routed.
_chain(self.route_chain, "route", "output", -150),
# **The engine's own ports are reached through a redirect or not at all.**
# Its TCP listener and every UDP and QUIC relay are bound to the wildcard,
# because a redirect delivers to whichever address the packet arrived on,
# so each of them could be dialled straight from anywhere that reaches the
# host. A relay forwards to its service whoever sent the datagram: a
# service protected on `127.0.0.1:53` was on the network through the
# relay's port. What arrived by the redirect carries conntrack's DNAT
# status; anything else aimed at one of these ports is dropped. Replies
# to the engine's transparent sockets are marked by `fgex_divert` and
# left alone, since a client's source port can be any number at all.
{
"add": {
"set": {
"family": "inet",
"table": self.table_name,
"name": self.datapath_set,
"type": ["inet_proto", "inet_service"],
}
}
},
_chain(self.guard_chain, "filter", "input", 0),
{
"add": {
"rule": {
"family": "inet",
"table": self.table_name,
"chain": self.guard_chain,
"expr": [
{
"match": {
"op": "!=",
"left": {"meta": {"key": "mark"}},
"right": PROXY_MARK,
}
},
{
"match": {
"op": "==",
"left": {"&": [{"ct": {"key": "status"}}, "dnat"]},
"right": 0,
}
},
{
"match": {
"op": "==",
"left": {"concat": [
{"meta": {"key": "l4proto"}},
{"payload": {"protocol": "th", "field": "dport"}},
]},
"right": f"@{self.datapath_set}",
}
},
self.COUNTER,
{"drop": None},
],
}
}
},
# Installed once. A packet on the intercepted (redirected) connection
# is the engine answering a client and has to leave normally; anything
# else from a protected service is a reply to the engine's own dial,
@ -424,23 +478,12 @@ class FiregexTables(NFTableManager):
}
},
],
[
*[
cmd
for position in range(MAX_CHAIN_POSITIONS)
for cmd in (
*_drop_chain(self.queue_input_chain(position)),
*_drop_chain(self.queue_output_chain(position)),
)
],
*_drop_chain(self.hijack_in_chain),
*_drop_chain(self.hijack_local_chain),
*_drop_chain(self.hijack_out_chain),
*_drop_chain(self.divert_chain),
*_drop_chain(self.nat_chain),
*_drop_chain(self.nat_output_chain),
*_drop_chain(self.route_chain),
],
# The whole table, which is firegex's alone. It used to be flushed chain by
# chain in one batch, and nft refuses a batch outright if any object in it is
# missing — so a table left by a version with a different set of chains (an
# upgrade after a crash, say) was never cleared at all: its rules went on
# steering traffic at queues and ports nobody listened on any more.
[{"delete": {"table": {"family": "inet", "name": self.table_name}}}],
)
#: How the mark and the routing table are spelled to `ip`, derived from the constants
@ -476,6 +519,8 @@ class FiregexTables(NFTableManager):
def reset(self):
super().reset()
# The set went with the table.
self._guarded.clear()
try:
self._policy_route("del")
except Exception:
@ -613,6 +658,7 @@ class FiregexTables(NFTableManager):
f"the proxy transport has no UDP relay listening for {key}"
)
self._add_proxy(srv, target_ip, target_port, family, l4, port)
self._guard(srv, l4, port)
def _add_queue(self, srv: Service, ip: str, port: int, family: str, l4: str,
queue_nums: list[int]):
@ -900,3 +946,39 @@ class FiregexTables(NFTableManager):
]
if cmds:
self.cmd(*cmds)
# Only when the whole service comes down. An address taken off a running service
# leaves its relay bound in the engine — relays live as long as the engine does —
# so its port stays guarded until the engine goes; released earlier, the relay
# would be reachable directly again with nothing steering traffic at it.
if addresses is None:
for l4, port in self._guarded.pop(str(srv.id), set()):
self._unguard(l4, port)
def _element(self, verb: str, l4: str, port: int) -> dict:
return {
verb: {
"element": {
"family": "inet",
"table": self.table_name,
"name": self.datapath_set,
"elem": [{"concat": [l4, int(port)]}],
}
}
}
#: Which engine ports each running service has had guarded, so that all of them are
#: released when it stops — including a relay whose address was removed while it ran.
#: Kept here rather than read back from the rules for exactly that relay: nothing
#: points at it any more, and an element left in the set would drop traffic for
#: whatever process is given that port next.
_guarded: dict[str, set[tuple[str, int]]] = {}
def _guard(self, srv: Service, l4: str, port: int) -> None:
"""Refuse whatever reaches `port` without having been redirected there."""
self.cmd(self._element("add", l4, port))
self._guarded.setdefault(str(srv.id), set()).add((l4, int(port)))
def _unguard(self, l4: str, port: int) -> None:
# Separately, and tolerated: an element that is not there makes nft refuse the
# command, and the rules it belonged to are already gone either way.
self.raw_cmd(self._element("delete", l4, port))

View File

@ -422,6 +422,10 @@ class _QueueStage:
"NTHREADS": os.getenv("NTHREADS", "1"),
"FIREGEX_NFQUEUE_FAIL_OPEN": "1" if self.srv.fail_open else "0",
"FIREGEX_NFPROXY_SOCK": self._sock_path,
# How many UDP flows keep a filter's state at once: the service's own
# limit, 0 for none. Only datagrams need it — a TCP stream's state
# goes when the stream closes.
"FIREGEX_MAX_FLOWS": str(self.srv.max_connections),
},
),
)

View File

@ -159,14 +159,23 @@ pub async fn serve_stdin(
continue;
}
match parse_ruleset(trimmed).and_then(|filters| {
// Everything that has to be started is started here, while the old chain
// is still the one enforcing.
// Everything that has to be started is started here, while the old chain is
// still the one enforcing — on a blocking thread, because starting a Python
// worker takes an interpreter's start-up and an import, and this task shares the
// runtime with every relay: with one worker thread, which is what a one-core
// host is given, the service carried nothing while a reload was loading a file.
let line = trimmed.to_string();
let prepared = tokio::task::spawn_blocking(move || {
parse_ruleset(&line).and_then(|filters| {
for filter in &filters {
filter.prepare()?;
}
Ok(filters)
}) {
})
})
.await
.unwrap_or_else(|e| Err(format!("preparing the ruleset failed: {e}")));
match prepared {
Ok(filters) => {
let n = filters.len();
chain.replace(FilterChain::new(filters, deadline));

View File

@ -289,7 +289,11 @@ impl Answer for H1Answer {
.take()
.ok_or_else(|| io::Error::other("the answer was already taken"))?;
let response = pending.await.map_err(io::Error::other)?;
let (parts, body) = response.into_parts();
let (mut parts, body) = response.into_parts();
// This answer goes on in HTTP/2 or HTTP/3, where what an HTTP/1.1 connection says
// about itself has no place — and the rendering the chain is shown settles its own
// framing from what is left.
crate::http1::drop_connection_headers(&mut parts.headers);
self.body = Some(body);
Ok(http::Response::from_parts(parts, ()))
}

View File

@ -65,6 +65,16 @@ use crate::http1::{
};
use crate::proxy::ProxyStats;
/// How many streams one client connection may have open at once.
///
/// The h2 crate's default is none at all, and on this edge every stream is an exchange of
/// its own — towards an HTTP/1.1 service, a connection of its own to the service. So one
/// client connection, which `max_connections` counts once, could open as many connections
/// to the protected service as it liked, and a Python filter's state with each. A hundred
/// is what the QUIC edge already allows (quinn's default), so the two versions of one
/// service answer the same, and it is what RFC 9113 recommends as a floor.
const MAX_CONCURRENT_STREAMS: u32 = 100;
/// How long a refused connection is given to put its GOAWAY on the wire.
///
/// A refusal that simply dropped the socket would reach the client as a truncated TLS
@ -344,6 +354,8 @@ where
// advertising it would invite streams whose rendering would be a fabrication. Push is
// turned off towards the service above, in the same spirit.
let mut server = h2::server::Builder::new()
.max_concurrent_streams(MAX_CONCURRENT_STREAMS)
.max_header_list_size(crate::http1::MAX_HEAD_BYTES)
.handshake::<_, Bytes>(client_io)
.await
.map_err(io::Error::other)?;

View File

@ -29,7 +29,8 @@ use h3::error::StreamError;
use crate::capture::Tap;
use crate::filter::{ChainSessions, ConnectionId, Direction};
use crate::http1::{
carried, close_body, conflicting_authority, head_framing, judge, push_body, render_request,
carried, close_body, conflicting_authority, connection_specific, head_framing, judge,
push_body, render_request,
render_response, Answer,
Framing, Incoming, Outbound, OutboundBody, Rendered,
};
@ -157,6 +158,7 @@ pub(crate) async fn carry(carrier: &Carrier) {
// handling has its priorities backwards.
let mut server = match h3::server::builder()
.send_grease(false)
.max_field_section_size(u64::from(crate::http1::MAX_HEAD_BYTES))
.build(h3_quinn::Connection::new(carrier.peer.clone()))
.await
{
@ -292,6 +294,16 @@ async fn exchange<O: Outbound>(
from_client.stop_stream(h3::error::Code::H3_MESSAGE_ERROR);
return Ok(());
}
// Malformed as well (RFC 9114 §4.2), and not refused by the h3 crate: see
// `connection_specific`. HTTP/2 needs no such line, because h2 refuses them itself.
if let Some(why) = connection_specific(request.headers()) {
eprintln!(
"[warn] [h3] {}: {why}. The stream is stopped as malformed.",
carrier.client
);
from_client.stop_stream(h3::error::Code::H3_MESSAGE_ERROR);
return Ok(());
}
// A request that carries no body must not be shown `transfer-encoding: chunked`,
// which it never had — a filter looking for that header is usually looking for

View File

@ -30,7 +30,10 @@
//! than forwarded past a filter that never saw them;
//! * `host` is rendered from the `:authority` pseudo-header, which is where both HTTP/2
//! and HTTP/3 put it;
//! * hop-by-hop headers do not appear, because both forbid sending them.
//! * hop-by-hop headers do not appear: both versions forbid them, a request carrying one
//! is refused as malformed ([`connection_specific`]), and an answer from a service
//! reached over HTTP/1.1 has its own taken out before it goes on
//! ([`drop_connection_headers`]).
//!
//! A message whose declared `content-length` disagrees with the body it then sends is
//! malformed in HTTP/2 and HTTP/3 as well, and nothing here tries to repair it: the frames
@ -347,6 +350,70 @@ pub(crate) fn conflicting_authority(request: &http::Request<()>) -> Option<Strin
}
}
/// The most a client's head may take — the header section of one request, decoded.
///
/// Neither crate bounds it usefully by default: h2 allows 16 MiB, and h3 sets no limit at
/// all, so an HTTP/3 request could keep one header block growing for as long as flow
/// control kept crediting it, a hundred streams to a connection. A mebibyte is the budget
/// the filter library holds a stream to by default (`FGEX_STREAM_MAX_SIZE`), well past
/// what any HTTP/1.1 server accepts in a head, and far enough from the usual range that
/// a large cookie or a heavy set of gRPC metadata is never what it refuses.
pub(crate) const MAX_HEAD_BYTES: u32 = 1024 * 1024;
/// Headers about one connection rather than about the message it carries.
///
/// HTTP/2 and HTTP/3 have no such thing (RFC 9113 §8.2.2, RFC 9114 §4.2): the framing and
/// the connection's lifetime are the protocol's own business, so a message carrying one
/// is malformed. `te` is the exception, and only as `trailers`.
const CONNECTION_SPECIFIC: [&str; 6] =
["connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "te"];
/// Why a request that arrived over HTTP/2 or HTTP/3 carries a header only a connection
/// can, or `None`.
///
/// The h2 crate refuses these itself; h3 does not, so an HTTP/3 client could hand a
/// service reached over HTTP/1.1 a `connection` naming headers for it to drop, or a `te`
/// the upstream then extends into a transfer coding the service is told to undo — none of
/// which a filter shown the rendering would recognise as the request the service got.
pub(crate) fn connection_specific(headers: &http::HeaderMap) -> Option<String> {
for name in CONNECTION_SPECIFIC {
for value in headers.get_all(name) {
if name == "te" && value.as_bytes().eq_ignore_ascii_case(b"trailers") {
continue;
}
return Some(format!(
"the request carries `{name}`, which belongs to a connection rather than to \
a message"
));
}
}
None
}
/// Take out of an HTTP/1.1 answer what belongs to that connection, before it goes on in
/// a version that forbids it.
///
/// Every HTTP/1.1 service says something about its connection — a body of unknown length
/// comes `transfer-encoding: chunked`, a connection it will keep open says `keep-alive` —
/// and those were passed on as they came: HTTP/2 refused to send the response at all, and
/// an HTTP/3 client is required to reject one. What `connection` itself names goes too,
/// since that is what the header is for (RFC 9110 §7.6.1).
pub(crate) fn drop_connection_headers(headers: &mut http::HeaderMap) {
let named: Vec<http::HeaderName> = headers
.get_all(http::header::CONNECTION)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.filter_map(|name| http::HeaderName::from_bytes(name.trim().as_bytes()).ok())
.collect();
for name in named {
headers.remove(name);
}
for name in CONNECTION_SPECIFIC {
headers.remove(name);
}
}
/// The request, as the HTTP/1.1 it would have been.
///
/// Called only for a request that has passed [`conflicting_authority`], so a `host` header

View File

@ -780,6 +780,10 @@ async fn handle_connection(
// connection of its own kind.
return match published.upstream {
Onward::Plain => {
// Each exchange dials a connection of its own, so the one opened before
// anything was known about the client goes now — held, it sat idle
// towards the service for as long as the client stayed.
drop(server);
crate::h2::carry_to_h1(
client,
crate::h1up::H1Upstream {

View File

@ -78,6 +78,13 @@ const STARTUP_GRACE: Duration = Duration::from_secs(10);
/// Cap on one frame, so a confused worker cannot ask us to allocate the machine away.
const MAX_FRAME: u32 = 64 * 1024 * 1024;
/// Cap on the open and close frames waiting for the next exchange. Reached only when
/// nothing has asked the worker anything for thousands of connections — a filter that
/// has lost its say is still told about every connection — and past it they are
/// dropped: a connection then reaches the worker without its addresses, or leaves state
/// behind in a worker that was not going to be asked anything anyway.
const MAX_PENDING: usize = 1024 * 1024;
#[derive(Debug, Default)]
pub struct WorkerStats {
/// Times the worker was killed for missing its deadline or dying on its own.
@ -98,6 +105,14 @@ pub struct PyWorkerRule {
/// thing: a frame is a request and a response, and interleaving them would need
/// request ids and a worker able to answer out of order. A pool comes later.
child: Mutex<Option<Child>>,
/// Open and close frames not yet written, in order. They need no answer, and they
/// are told from the relay itself — on the async runtime — where taking `child`
/// meant waiting out whatever exchange held it, up to its whole deadline, and where
/// a worker that had to be started first was started right there. A filter hanging
/// on one connection held every new connection of the service in that queue, on the
/// threads that carry all the others. The next exchange writes them ahead of its own
/// chunk, which runs where blocking is allowed and keeps an open ahead of the data.
pending: Mutex<Vec<u8>>,
pub stats: std::sync::Arc<WorkerStats>,
}
@ -116,6 +131,7 @@ impl PyWorkerRule {
enabled,
deadline,
child: Mutex::new(None),
pending: Mutex::new(Vec::new()),
stats: std::sync::Arc::new(WorkerStats::default()),
}
}
@ -180,6 +196,15 @@ impl PyWorkerRule {
/// Deciding here rather than in the file is the point: a function is turned off
/// without editing the code that defines it, and turned back on without the
/// operator having to remember what they deleted.
/// Hold a frame that needs no answer for the next exchange. See `pending`.
fn queue(&self, frame: Vec<u8>) {
let Ok(mut pending) = self.pending.lock() else { return };
if pending.len() + frame.len() > MAX_PENDING {
pending.clear();
}
pending.extend_from_slice(&frame);
}
pub fn enabled(&self) -> Option<&[String]> {
self.enabled.as_deref()
}
@ -234,6 +259,12 @@ impl PyWorkerRule {
chunk,
);
let queued = std::mem::take(
&mut *self.pending.lock().map_err(|_| "worker queue poisoned".to_string())?,
);
if !queued.is_empty() {
write_before(stdin, &queued, deadline_at)?;
}
write_before(stdin, &frame, deadline_at)?;
let stdout = child.stdout.as_mut().ok_or("worker has no stdout")?;
let mut header = [0u8; 4];
@ -301,41 +332,13 @@ impl Filter for PyWorkerRule {
"l4": meta.l4.name(),
})
.to_string();
let mut slot = match self.child.lock() {
Ok(slot) => slot,
Err(_) => return,
};
if slot.is_none() {
match self.spawn() {
Ok(child) => *slot = Some(child),
Err(_) => return,
}
}
let Some(child) = slot.as_mut() else { return };
let Some(stdin) = child.stdin.as_mut() else {
return;
};
let frame = build_frame(connection, KIND_OPEN, payload.as_bytes());
let deadline_at = Instant::now() + self.deadline;
if write_before(stdin, &frame, deadline_at).is_err() {
self.discard(&mut slot, "could not report a new connection");
}
self.queue(build_frame(connection, KIND_OPEN, payload.as_bytes()));
}
fn connection_closed(&self, connection: ConnectionId) {
// Best effort, and deliberately not on the datapath's critical path: the
// connection is already over, so nothing is waiting on this. A worker that
// has died in the meantime simply has no state left to free.
let mut slot = match self.child.lock() {
Ok(slot) => slot,
Err(_) => return,
};
let Some(child) = slot.as_mut() else { return };
let Some(stdin) = child.stdin.as_mut() else { return };
let frame = build_frame(connection, KIND_CLOSE, &[]);
let deadline_at = Instant::now() + self.deadline;
if write_before(stdin, &frame, deadline_at).is_err() {
self.discard(&mut slot, "could not report a closed connection");
}
self.queue(build_frame(connection, KIND_CLOSE, &[]));
}
fn prepare(&self) -> Result<(), String> {
self.warm_up()

View File

@ -858,6 +858,18 @@ fn edge(tls: bool) -> &'static str {
/// A cleartext **HTTP/1.1** service, which is what most of the web is.
async fn spawn_http1_service(seen: Arc<Mutex<Vec<String>>>) -> SocketAddr {
spawn_http1_service_answering(
seen,
b"HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ncontent-length: 5\r\n\r\nhello",
)
.await
}
/// The same, answering with whatever head and body it is given.
async fn spawn_http1_service_answering(
seen: Arc<Mutex<Vec<String>>>,
answer: &'static [u8],
) -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
@ -873,12 +885,7 @@ async fn spawn_http1_service(seen: Arc<Mutex<Vec<String>>>) -> SocketAddr {
seen.lock()
.unwrap()
.push(String::from_utf8_lossy(&buf[..read]).to_string());
let _ = socket
.write_all(
b"HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\n\
content-length: 5\r\n\r\nhello",
)
.await;
let _ = socket.write_all(answer).await;
});
}
});
@ -941,6 +948,124 @@ async fn http2_is_carried_to_a_service_that_speaks_http1() {
);
}
/// What an HTTP/1.1 service actually answers with: a body of unknown length sent chunked,
/// and a connection it offers to keep open. Both are headers about *that* connection,
/// which HTTP/2 forbids outright — the h2 crate refuses to send a response carrying one —
/// so they were passed through and every such answer failed on its way to the client.
#[tokio::test]
async fn an_http1_answer_is_relayed_without_its_connection_headers() {
let (cert, key) = self_signed();
let seen = Arc::new(Mutex::new(Vec::new()));
let service = spawn_http1_service_answering(
Arc::clone(&seen),
b"HTTP/1.1 200 OK\r\nconnection: keep-alive\r\nkeep-alive: timeout=5\r\n\
content-type: text/plain\r\ntransfer-encoding: chunked\r\n\r\n\
5\r\nhello\r\n0\r\n\r\n",
)
.await;
let proxy = spawn_proxy_to_http1(service, &cert, &key, LIVE).await;
let answer = request(proxy, "GET", "/files/ok", Body::None).await;
assert!(
answer.as_deref().is_ok_and(|a| a.contains("hello")),
"a chunked keep-alive answer did not reach an HTTP/2 client: {answer:?}"
);
}
/// One client connection is one connection to the limit, and each of its streams is an
/// exchange of its own — towards an HTTP/1.1 service, a connection of its own. With no cap
/// on concurrent streams, one client could open as many connections to the service as it
/// liked past `max_connections`.
#[tokio::test]
async fn one_client_connection_cannot_open_the_service_a_thousand_times() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let service = listener.local_addr().unwrap();
let open = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let most = Arc::new(std::sync::atomic::AtomicUsize::new(0));
{
let (open, most) = (Arc::clone(&open), Arc::clone(&most));
tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
let (open, most) = (Arc::clone(&open), Arc::clone(&most));
tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
// Counted from the request, not from the accept: a connection that
// closes without asking anything is not one the service is serving.
let mut buf = [0u8; 4096];
if !matches!(socket.read(&mut buf).await, Ok(n) if n > 0) {
return;
}
let now = open.fetch_add(1, Ordering::SeqCst) + 1;
most.fetch_max(now, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(300)).await;
// Counted down before answering: once the answer is out the stream
// can end and the client open the next, before this task resumes.
open.fetch_sub(1, Ordering::SeqCst);
let _ = socket
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok")
.await;
});
}
});
}
let (cert, key) = self_signed();
let proxy = spawn_proxy_to_http1(service, &cert, &key, LIVE).await;
let (sender, driving) = connect(proxy).await.unwrap();
let mut asked = Vec::new();
for _ in 0..250 {
let sender = sender.clone();
asked.push(tokio::spawn(async move {
let mut sender = sender.ready().await.map_err(|e| e.to_string())?;
let outgoing = http::Request::builder()
.method("GET")
.uri("https://localhost/")
.body(())
.map_err(|e| e.to_string())?;
let (response, _) = sender.send_request(outgoing, true).map_err(|e| e.to_string())?;
response.await.map_err(|e| e.to_string()).map(|_| ())
}));
}
for one in asked {
let _ = tokio::time::timeout(Duration::from_secs(20), one).await;
}
driving.abort();
let most = most.load(Ordering::SeqCst);
assert!(most > 0, "nothing reached the service at all");
assert!(most <= 100, "one client connection held {most} connections to the service open");
}
/// A head is bounded: the h2 crate's own limit is 16 MiB, a hundred streams to a
/// connection. Well past what services accept, though — a large head is still carried.
#[tokio::test]
async fn a_head_past_the_limit_is_refused_over_http2() {
let (addr, seen) = relay(Service::Echo, LIVE).await;
let ask = |size: usize| async move {
let (mut sender, driving) = connect(addr).await?;
let answer = async {
let outgoing = http::Request::builder()
.method("GET")
.uri("https://localhost/who")
.header("x-big", "a".repeat(size))
.body(())
.map_err(|e| e.to_string())?;
let (response, _) = sender.send_request(outgoing, true).map_err(|e| e.to_string())?;
response.await.map_err(|e| e.to_string()).map(|r| r.status().as_u16())
}
.await;
driving.abort();
answer
};
assert_eq!(ask(256 * 1024).await, Ok(200), "a 256 KiB head was refused");
let answered = seen.requests.load(Ordering::Relaxed);
let refused = tokio::time::timeout(Duration::from_secs(10), ask(2 * 1024 * 1024))
.await
.expect("an oversized head was left hanging");
// The crate answers `431 Request Header Fields Too Large` itself, as RFC 9113 suggests.
assert!(!matches!(refused, Ok(200)), "a 2 MiB head was carried: {refused:?}");
assert_eq!(seen.requests.load(Ordering::Relaxed), answered, "it reached the service");
}
#[tokio::test]
async fn a_pattern_blocks_over_http2_in_front_of_http1() {
// The promise the rendering exists for, on the edge that just gained it: one filter,

View File

@ -760,3 +760,126 @@ async fn a_host_that_disagrees_with_the_authority_is_refused() {
.expect("a malformed request was left hanging");
assert!(disagreeing.is_err(), "a disagreeing host was carried: {disagreeing:?}");
}
/// One GET through the relay carrying `headers`, and the answer's status, head and body.
async fn ask_with(
addr: SocketAddr,
headers: &[(&'static str, &'static str)],
) -> Result<(u16, http::HeaderMap, String), String> {
let mut config = tls::quic_client_config().unwrap();
config.alpn_protocols = vec![b"h3".to_vec()];
let mut endpoint = quinn::Endpoint::client("127.0.0.1:0".parse().unwrap()).unwrap();
endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new(
QuicClientConfig::try_from(config).unwrap(),
)));
let connection = endpoint
.connect(addr, "localhost")
.map_err(|e| e.to_string())?
.await
.map_err(|e| e.to_string())?;
let (mut driver, mut sender) = h3::client::new(h3_quinn::Connection::new(connection))
.await
.map_err(|e| e.to_string())?;
let driving = tokio::spawn(async move {
let _ = driver.wait_idle().await;
});
let answer = async {
let mut builder = http::Request::builder().method("GET").uri("https://localhost/who");
for (name, value) in headers {
builder = builder.header(*name, *value);
}
let outgoing = builder.body(()).map_err(|e| e.to_string())?;
let mut stream = sender.send_request(outgoing).await.map_err(|e| e.to_string())?;
stream.finish().await.map_err(|e| e.to_string())?;
let response = stream.recv_response().await.map_err(|e| e.to_string())?;
let mut got = Vec::new();
while let Some(mut chunk) = stream.recv_data().await.map_err(|e| e.to_string())? {
let len = chunk.remaining();
got.extend_from_slice(&chunk.copy_to_bytes(len));
}
Ok::<_, String>((
response.status().as_u16(),
response.headers().clone(),
String::from_utf8_lossy(&got).into_owned(),
))
}
.await;
driving.abort();
answer
}
/// A header about a connection is malformed in HTTP/3, and the h3 crate does not refuse
/// it. Carried to a service reached over HTTP/1.1 it would be read there as a statement
/// about *that* connection — which headers to drop, which transfer coding to undo — and a
/// filter shown the rendering would not recognise the request the service got.
#[tokio::test]
async fn a_connection_header_is_refused_over_http3() {
let upstream = spawn_h1_echo().await;
let addr = spawn_relay_to(upstream, "", Onward::Plain).await;
let trailers = ask_with(addr, &[("te", "trailers")]).await;
assert!(trailers.is_ok(), "`te: trailers`, the one allowed, was refused: {trailers:?}");
for header in [("transfer-encoding", "chunked"), ("connection", "x-forwarded-for"),
("keep-alive", "timeout=5"), ("upgrade", "websocket"), ("te", "gzip")] {
let refused = tokio::time::timeout(Duration::from_secs(5), ask_with(addr, &[header]))
.await
.expect("a malformed request was left hanging");
assert!(refused.is_err(), "a request carrying {header:?} was carried: {refused:?}");
}
}
/// What an HTTP/1.1 service answers with says things about its own connection — a body
/// sent chunked, a connection offered for reuse — that an HTTP/3 client is required to
/// reject a response for. They are the service's, and they stay behind.
#[tokio::test]
async fn an_http1_answer_reaches_an_http3_client_without_its_connection_headers() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let upstream = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut socket, _)) = listener.accept().await {
tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = [0u8; 4096];
let _ = socket.read(&mut buf).await;
let _ = socket
.write_all(
b"HTTP/1.1 200 OK\r\nconnection: keep-alive, x-internal\r\n\
keep-alive: timeout=5\r\nx-internal: secret\r\n\
content-type: text/plain\r\ntransfer-encoding: chunked\r\n\r\n\
5\r\nhello\r\n0\r\n\r\n",
)
.await;
});
}
});
let addr = spawn_relay_to(upstream, "", Onward::Plain).await;
let (_, headers, body) = ask_with(addr, &[]).await.unwrap();
assert_eq!(body, "hello");
for name in ["connection", "keep-alive", "transfer-encoding", "x-internal"] {
assert!(!headers.contains_key(name), "`{name}` reached the HTTP/3 client: {headers:?}");
}
assert_eq!(headers.get("content-type").map(|v| v.as_bytes()), Some(&b"text/plain"[..]));
}
/// A head is bounded, which the h3 crate does not do by default at all.
#[tokio::test]
async fn a_head_past_the_limit_is_refused_over_http3() {
let upstream = spawn_h1_echo().await;
let addr = spawn_relay_to(upstream, "", Onward::Plain).await;
let big: &'static str = Box::leak("a".repeat(2 * 1024 * 1024).into_boxed_str());
let refused = tokio::time::timeout(Duration::from_secs(10), ask_with(addr, &[("x-big", big)]))
.await
.expect("an oversized head was left hanging");
// The crate answers `431 Request Header Fields Too Large` itself, and nothing is sent on.
assert!(
!matches!(refused, Ok((200, _, _))),
"a 2 MiB head was carried: {:?}",
refused.map(|r| r.2)
);
let large: &'static str = Box::leak("a".repeat(256 * 1024).into_boxed_str());
assert!(
matches!(ask_with(addr, &[("x-large", large)]).await, Ok((200, _, _))),
"a 256 KiB head was refused"
);
}

View File

@ -160,6 +160,56 @@ def sometimes_hangs(packet: RawPacket):
assert!(c.disabled_filters().is_empty());
}
/// Telling the worker about a connection is not allowed to wait on it.
///
/// The open and close frames are sent from the relay itself, on the async runtime. They
/// used to take the worker's lock there — held by whatever exchange was in progress, up
/// to its whole deadline — so one connection feeding a hanging filter held every new
/// connection of the service on the threads that carry all the others.
#[tokio::test]
async fn a_connection_opening_does_not_wait_on_a_hanging_filter() {
let path = filter_file(
"hang-open",
r#"
import time
from firegex.pyfilters import pyfilter, ACCEPT
from firegex.pyfilters.models import RawPacket
@pyfilter
def hangs_on_request(packet: RawPacket):
if b"HANG" in packet.data:
time.sleep(3600)
return ACCEPT
"#,
);
let c = std::sync::Arc::new(chain_for(&path, 1500));
// Started first, so the worker is up and the only thing held is the exchange.
assert_eq!(feed(&c, &[b"warm"]).await, vec![Verdict::Accept]);
let stuck = {
let c = std::sync::Arc::clone(&c);
tokio::spawn(async move { feed(&c, &[b"HANG"]).await })
};
tokio::time::sleep(Duration::from_millis(300)).await;
let meta = ConnectionMeta {
client: "10.0.0.9:51000".parse().unwrap(),
server: "10.0.0.1:8080".parse().unwrap(),
l4: L4::Tcp,
};
let started = Instant::now();
let other = next_connection_id();
c.connection_opened(other, &meta);
c.connection_closed(other);
assert!(
started.elapsed() < Duration::from_millis(200),
"a new connection waited {:?} on another connection's hanging filter",
started.elapsed()
);
assert_eq!(stuck.await.unwrap(), vec![Verdict::Accept]);
}
/// A filter that crashes on one chunk must not cost the ones after it.
#[tokio::test]
async fn a_crashing_python_filter_recovers() {

View File

@ -315,7 +315,7 @@ def check_previous_requests(resp: HttpResponse):
return REJECT
```
The number of entries kept per stream is capped by the `FGEX_MAX_HISTORY_SIZE` global (default `100`) — see [Other global options](#other-global-options) below; once the cap is reached, the oldest entry is dropped as a new one is added.
The number of entries kept per stream is capped by the `FGEX_MAX_HISTORY_SIZE` global (default `100`) — see [Other global options](#other-global-options) below; once the cap is reached, the oldest entry is dropped as a new one is added. What each direction's history holds is also kept under `FGEX_STREAM_MAX_SIZE` bytes, the same budget as the stream itself, so on a connection carrying large bodies the oldest entries go sooner; the newest one is always kept.
### gRPC messages
@ -391,6 +391,13 @@ Then set these in the filter file's globals:
| `REJECT` | Reject the stream and close the connection, like a `REJECT` filter statement. |
| `ACCEPT` | Stop calling pyfilters and accept the rest of the traffic as-is. |
A compressed body is held to the same limit **decoded**: a megabyte of gzip can be a
gigabyte of anything that compresses well, so decoding stops at `FGEX_STREAM_MAX_SIZE` and
a body that would go past it meets the action above — refused with `REJECT` or `DROP`,
passed without calling the filter with `ACCEPT`, and with `FLUSH` handed over as it
arrived, still encoded, as a body in an encoding the library cannot undo would be. A
compressed WebSocket message is bounded the same way.
## Other global options
```python
@ -398,7 +405,7 @@ from firegex.pyfilters import ExceptionAction
```
- `FGEX_INVALID_ENCODING_ACTION: ExceptionAction` — action taken when parsing hits an invalid/unsupported encoding (a parser-level failure). Default: `ExceptionAction.REJECT`. Values: `ACCEPT` (accept the packet that caused the error), `DROP` (drop the connection), `REJECT` (reject the connection), `NOACTION` (do nothing — the error is signaled and the stream is accepted without calling any more pyfilters on it).
- `FGEX_MAX_HISTORY_SIZE: int` — max number of requests/responses kept per stream by [`HttpHistory`](#httphistory-alias-httpstreamhistory--http-only). Default: `100`.
- `FGEX_MAX_HISTORY_SIZE: int` — max number of requests/responses kept per stream by [`HttpHistory`](#httphistory-alias-httpstreamhistory--http-only). Default: `100`. The entries of one direction together are also held under `FGEX_STREAM_MAX_SIZE` bytes.
## Testing a filter locally

View File

@ -179,10 +179,6 @@ halves.
**What that buys**
- **Exact rewriting, for a Python filter.** Two independent connections means a
replacement can be any length: there are no sequence numbers shared with the client to
desynchronise. Patterns do not rewrite on any layer — see **Regex** under *Filters* below
for why that was withdrawn. This is
- **Reassembly is the kernel's problem.** Both sides are ordinary sockets, so
out-of-order and retransmitted segments are sorted out before a filter ever sees them
— rather than being rebuilt in userspace with the caveats that come with it.
@ -261,10 +257,10 @@ What UDP on the proxy layer provides:
- **Zero-downtime dynamic address addition.** Adding new addresses to a running UDP service
dynamically binds new relay sockets through the control channel without restarting the
engine or dropping existing connections.
- **Exact rewriting, at any length, for a Python filter.** A datagram is
self-contained, so there are no sequence numbers for a longer or shorter payload to
desynchronise. The caveat that makes rewriting unstable on the NFQUEUE layer simply
does not apply.
- **Relays answer only what was redirected to them.** Each relay is bound on an
ephemeral port of every address, because a redirect delivers to whichever address the
datagram arrived on; a datagram sent straight to that port, from anywhere, is dropped
by the rules rather than forwarded to your service.
- **Per-flow filter state.** Each client address is a flow with its own filter state and
its own Python module globals, released after a minute of silence — UDP has no close
to observe, so a timeout is the only thing that can end one.
@ -280,6 +276,14 @@ A service can be given a **limit on how many connections it carries at once**, a
what to do with what does not fit. Both are on the proxy layer, which is the one that
accepts a connection and dials the service; NFQUEUE hands the kernel a verdict on packets
already in flight, and what it can run out of is queue — that is what fail-open is for.
There is no limit unless you set one.
On NFQUEUE the same setting, under **Advanced settings**, means something narrower, and
only for a UDP service: **how many UDP flows keep their Python filters' state at once**.
A datagram has no close to observe, so a flow's state is otherwise kept until it has been
quiet for a minute. Past the limit nothing is refused — the flow quiet the longest loses
what its filters were keeping, and starts over from a clean state when it next sends. A
TCP stream lets go of its state when it closes, so the setting does not apply to one.
Without a limit, connections that are opened and then say **nothing** are the cheapest
attack there is. Each costs two descriptors — one from the client, one to the service,

View File

@ -17,9 +17,9 @@ class RawPacket:
depending on the transport it happened to be attached to — the exact failure the
unified model exists to prevent.
So: read the metadata, edit `data`. `data` is the application payload and the only
thing a filter can change, which is also the only change that means the same thing
on both layers.
So: read the metadata and the payload, and answer with a verdict. Nothing a filter is
shown can be written — the payload was, for `UNSTABLE_MANGLE`, and stopped being
with it.
"""
def __init__(self,

View File

@ -15,15 +15,20 @@ same class of lie as a tester that disagrees with the engine, so this normalises
more capable behaviour rather than to whichever backend is present.
"""
import io
try: # Python 3.14 and later
from compression.zstd import ZstdFile as _ZstdFile
from compression.zstd import decompress as _decompress
except ImportError: # Python 3.10 - 3.13
import io
def _read(data: bytes, size: int) -> bytes:
with _ZstdFile(io.BytesIO(data)) as f:
return f.read(size)
except ImportError: # Python 3.10 - 3.13
import zstandard
def _decompress(data: bytes) -> bytes:
def _reader(data: bytes):
"""Every frame in `data`, streamed.
`stream_reader` rather than `ZstdDecompressor.decompress`: the latter needs the
@ -31,11 +36,31 @@ except ImportError: # Python 3.10 - 3.13
matches the standard library, which decompresses a concatenation of frames as
one body — an encoder that flushed mid-response produces exactly that.
"""
reader = zstandard.ZstdDecompressor().stream_reader(
return zstandard.ZstdDecompressor().stream_reader(
io.BytesIO(data), read_across_frames=True
)
return reader.read()
def _decompress(data: bytes) -> bytes:
return _reader(data).read()
def _read(data: bytes, size: int) -> bytes:
reader = _reader(data)
out = bytearray()
while len(out) < size:
piece = reader.read(size - len(out))
if not piece:
break
out += piece
return bytes(out)
def decompress(data: bytes) -> bytes:
def decompress(data: bytes, max_length: int | None = None) -> bytes:
"""`data` decompressed, or its first `max_length` bytes when that is given.
Bounded by reading rather than by decompressing and measuring: a frame a few hundred
bytes long can declare gigabytes, and what arrives in an HTTP body is whatever the
other end chose to send.
"""
if max_length is None:
return _decompress(data)
return _read(data, max_length)

View File

@ -1,6 +1,6 @@
from firegex import _llhttp
from firegex.pyfilters.internals.exceptions import NotReadyToRun
from firegex.pyfilters.internals.data import DataStreamCtx
from firegex.pyfilters.internals.data import DEFAULT_STREAM_MAX_SIZE, DataStreamCtx
from firegex.pyfilters.internals.exceptions import (
StreamFullDrop,
StreamFullReject,
@ -43,12 +43,12 @@ class InternalHTTPMessage:
status_code: int | None = field(default=None)
total_size: int = field(default=0)
user_agent: str = field(default_factory=str)
content_encoding: str = field(default=str)
content_type: str = field(default=str)
content_encoding: str = field(default="")
content_type: str = field(default="")
keep_alive: bool = field(default=False)
should_upgrade: bool = field(default=False)
http_version: str = field(default=str)
method: str = field(default=str)
http_version: str = field(default="")
method: str = field(default="")
content_length: int = field(default=0)
stream: bytes = field(default_factory=bytes)
ws_stream: list[Frame] = field(default_factory=list) # Decoded websocket stream
@ -61,6 +61,13 @@ class InternalHTTPMessage:
#: keep-alive connection carries many messages through one parser, and a cursor kept
#: per connection would start the second message part-way in.
grpc_consumed: int = field(default=0)
#: How much of a gRPC frame that was flushed for being larger than the cap is still
#: to arrive. Passed over when it does, so the frame after it is found where it
#: starts rather than read out of the middle of the one before.
grpc_skip: int = field(default=0)
#: The body would have decoded to more than a filter may hold (`stream_max_size`),
#: so it was left as it arrived and the full-stream action decides what happens.
decoded_too_large: bool = field(default=False)
@dataclass
@ -77,6 +84,78 @@ class InternalHttpBuffer:
_ws_packet_stream: bytes = field(default_factory=bytes)
class _TooLarge(Exception):
"""A body that would decode to more than the limit it was decoded under."""
def _inflate(data: bytes, wbits: int, limit: int) -> bytes:
decoder = zlib.decompressobj(wbits)
out = decoder.decompress(data, limit + 1)
if len(out) > limit or decoder.unconsumed_tail:
raise _TooLarge()
out += decoder.flush(limit + 1 - len(out))
if len(out) > limit:
raise _TooLarge()
return out
def _gunzip(data: bytes, limit: int) -> bytes:
with gzip.GzipFile(fileobj=io.BytesIO(data)) as f:
out = f.read(limit + 1)
if len(out) > limit:
raise _TooLarge()
return out
def _unbrotli(data: bytes, limit: int) -> bytes:
decoder = brotli.Decompressor()
out = decoder.process(data, output_buffer_limit=limit + 1)
if len(out) > limit:
raise _TooLarge()
if not decoder.is_finished():
# What `brotli.decompress` says about a truncated stream, said here too: the
# incremental decoder just stops, and half a body is not the body.
raise brotli.error("the brotli stream ends before it is finished")
return out
def _unzstd(data: bytes, limit: int) -> bytes:
out = zstd_compat.decompress(data, max_length=limit + 1)
if len(out) > limit:
raise _TooLarge()
return out
def _flush_body(parser: "InternalCallbackHandler") -> None:
"""Throw away the body in hand: the buffer it grows in, and not only the view of it.
Only the view used to be cleared, and the size it counted taken off the total — so on
a gRPC stream, whose body is handed over as it grows, the buffer went on growing past
every flush while the count went negative and stopped triggering one at all.
"""
msg = parser.msg
buffered = parser.buffers._body_buffer
if parser.release_body_chunks:
# What has been framed is gone already. Of the rest, a header that has fully
# arrived says how long its frame is, and what is still to come of that frame is
# skipped; a partial header is a few bytes, and kept.
tail = buffered[msg.grpc_consumed:]
keep = tail
if len(tail) >= GRPC_HEADER_SIZE:
length = int.from_bytes(tail[1:GRPC_HEADER_SIZE], "big")
msg.grpc_skip = max(0, GRPC_HEADER_SIZE + length - len(tail))
keep = b""
msg.total_size -= len(buffered) - len(keep)
parser.buffers._body_buffer = keep
msg.body = keep
msg.grpc_consumed = 0
return
msg.total_size -= len(buffered) + len(msg.body or b"")
parser.buffers._body_buffer = b""
if msg.body is not None:
msg.body = b""
class InternalCallbackHandler:
buffers = InternalHttpBuffer()
msg = InternalHTTPMessage()
@ -99,6 +178,11 @@ class InternalCallbackHandler:
release_body_chunks = False
#: Whether a chunk has arrived that has not been handed over yet.
_body_chunk_pending = False
#: The most a body may decode to, and a WebSocket message decompress to. Set from
#: `stream_max_size` before every parse, because what a filter may hold is the
#: operator's number: a body arrives under that cap compressed, and a megabyte of
#: gzip is a gigabyte of zeroes — or of anything else that compresses as well.
max_decoded_size = DEFAULT_STREAM_MAX_SIZE
def reset_data(self):
self.msg = InternalHTTPMessage()
@ -202,24 +286,24 @@ class InternalCallbackHandler:
encodings = [ele.strip() for ele in self.content_encoding.lower().split(",")]
decode_success = True
decoding_body = self.msg.body
# Every step under the same limit, so a stack of encodings cannot multiply past it.
limit = self.max_decoded_size
try:
for enc in reversed(encodings):
if not enc:
continue
if enc == "deflate":
# Both spellings, because both are on the wire. RFC 7230 defines
# `deflate` as the zlib format of RFC 1950, and plenty of servers send
# the raw stream instead — browsers accept either, and a decoder that
# takes only one lets half the compressed traffic reach a filter still
# compressed, where a pattern finds nothing and the only trace is a line
# on stdout saying it skipped.
# `deflate` as the zlib format of RFC 1950, and plenty of servers
# send the raw stream instead — browsers accept either, and a decoder
# that takes only one lets half the compressed traffic reach a filter
# still compressed, where a pattern finds nothing and the only trace
# is a line on stdout saying it skipped.
for wbits in (-zlib.MAX_WBITS, zlib.MAX_WBITS):
try:
decompress = zlib.decompressobj(wbits)
decoded = decompress.decompress(decoding_body)
decoded += decompress.flush()
except Exception:
decoding_body = _inflate(decoding_body, wbits, limit)
except zlib.error:
continue
decoding_body = decoded
break
else:
print("Error decompressing deflate: neither raw nor zlib-wrapped: "
@ -228,7 +312,9 @@ class InternalCallbackHandler:
break
elif enc == "br":
try:
decoding_body = brotli.decompress(decoding_body)
decoding_body = _unbrotli(decoding_body, limit)
except _TooLarge:
raise
except Exception as e:
print(f"Error decompressing brotli: {e}: skipping", flush=True)
decode_success = False
@ -237,16 +323,18 @@ class InternalCallbackHandler:
enc == "gzip" or enc == "x-gzip"
): # https://datatracker.ietf.org/doc/html/rfc2616#section-3.5
try:
if "gzip" in self.content_encoding.lower():
with gzip.GzipFile(fileobj=io.BytesIO(decoding_body)) as f:
decoding_body = f.read()
decoding_body = _gunzip(decoding_body, limit)
except _TooLarge:
raise
except Exception as e:
print(f"Error decompressing gzip: {e}: skipping", flush=True)
decode_success = False
break
elif enc == "zstd":
try:
decoding_body = zstd_compat.decompress(decoding_body)
decoding_body = _unzstd(decoding_body, limit)
except _TooLarge:
raise
except Exception as e:
print(f"Error decompressing zstd: {e}: skipping", flush=True)
decode_success = False
@ -256,6 +344,11 @@ class InternalCallbackHandler:
else:
decode_success = False
break
except _TooLarge:
print(f"[WARNING] The body decodes to more than {limit} bytes "
f"(FGEX_STREAM_MAX_SIZE): left as it arrived", flush=True)
self.msg.decoded_too_large = True
decode_success = False
if decode_success:
self.msg.body = decoding_body
@ -386,7 +479,12 @@ class InternalCallbackHandler:
return new_data
parsing = Frame.parse(
read_exact, extensions=self._ws_extentions, mask=self._is_input()
read_exact, extensions=self._ws_extentions, mask=self._is_input(),
# A compressed message is inflated by the extension, and without a bound on
# it a frame of a few kilobytes is as many gigabytes as it says it is. Past
# the bound the frame is refused, and the connection's data is carried on as
# a plain stream — the same as any frame that will not parse.
max_size=self.max_decoded_size,
)
parsing.send(None)
try:
@ -499,6 +597,24 @@ class HttpHistory:
HttpStreamHistory = HttpHistory
def _held(entry: "InternalBasicHttpMetaClass") -> int:
msg = entry._message
return max(msg.total_size, len(msg.body or b"")) + len(msg.stream)
def _trim_history(history: deque, budget: int) -> None:
"""Keep a direction's history under the byte budget a stream is held to.
`FGEX_MAX_HISTORY_SIZE` counts messages, and each of them can be as large as the cap
on the stream — so a keep-alive connection could hold a hundred bodies of a megabyte
each, per direction, for as long as it stayed open. The oldest go first; the newest
is always kept, since it fitted under the cap on its own.
"""
total = sum(_held(entry) for entry in history)
while len(history) > 1 and total > budget:
total -= _held(history.popleft())
class InternalBasicHttpMetaClass:
"""Internal class to handle HTTP requests and responses"""
@ -648,6 +764,7 @@ class InternalBasicHttpMetaClass:
# above is: parsing happens before any instance exists, and a flag applied
# afterwards would take effect one packet late.
parser.release_body_chunks = cls._should_release_body_chunks()
parser.max_decoded_size = internal_data.stream_max_size
if not internal_data.call_mem.get(
@ -675,8 +792,10 @@ class InternalBasicHttpMetaClass:
parser.messages.clear()
parser.msg.total_size -= len(parser.msg.stream)
parser.msg.stream = b""
parser.msg.total_size -= len(parser.msg.body)
parser.msg.body = b""
# Whatever the body is at: none yet for a message still in its
# headers — where `len(body)` used to raise, so headers past the cap
# were an exception rather than a flush and the packet failed open.
_flush_body(parser)
print("[WARNING] Flushing stream", flush=True)
if (
parser.total_size + len(internal_data.current_pkt.data)
@ -708,6 +827,19 @@ class InternalBasicHttpMetaClass:
case ExceptionAction.ACCEPT:
raise NotReadyToRun()
if any(msg.decoded_too_large for msg in parser.messages):
# Too large once decoded is too large: the operator's answer to a stream
# past the cap is the answer here too, rather than a second policy nobody
# chose. Flushing has nothing left to throw away, so the message goes on
# as it arrived — still encoded, as an encoding this cannot undo would.
match internal_data.full_stream_action:
case FullStreamAction.REJECT:
raise StreamFullReject()
case FullStreamAction.DROP:
raise StreamFullDrop()
case FullStreamAction.ACCEPT:
raise NotReadyToRun()
if parser.should_upgrade and not internal_data.current_pkt.is_input:
# Creating ws_option for the client
if not internal_data.data_handler_context.get(
@ -808,8 +940,10 @@ class InternalBasicHttpMetaClass:
msg.added_to_history = True
if internal_data.current_pkt.is_input:
req_history_deque.append(HttpFullRequest(parser, msg))
_trim_history(req_history_deque, internal_data.stream_max_size)
else:
resp_history_deque.append(HttpFullResponse(parser, msg))
_trim_history(resp_history_deque, internal_data.stream_max_size)
if len(built_instances) == 1:
res = built_instances[0]
@ -981,6 +1115,10 @@ def _grpc_frames(msg: InternalHTTPMessage) -> list[GrpcFrame]:
body = msg.body or b""
taken: list[GrpcFrame] = []
at = msg.grpc_consumed
if msg.grpc_skip:
passed = min(msg.grpc_skip, len(body) - at)
at += passed
msg.grpc_skip -= passed
while len(body) - at >= GRPC_HEADER_SIZE:
length = int.from_bytes(body[at + 1 : at + GRPC_HEADER_SIZE], "big")
end = at + GRPC_HEADER_SIZE + length
@ -994,6 +1132,22 @@ def _grpc_frames(msg: InternalHTTPMessage) -> list[GrpcFrame]:
return taken
def _forget_framed(parser: InternalCallbackHandler, msg: InternalHTTPMessage) -> None:
"""Drop what has been framed from a body that is still arriving.
A streaming RPC's body ends only when the stream does, and every frame in it had been
handed over already — so keeping it bought nothing but memory, one frame at a time,
for as long as the stream stayed open.
"""
if msg is not parser.msg or msg.message_complete or not msg.grpc_consumed:
return
done = msg.grpc_consumed
parser.buffers._body_buffer = parser.buffers._body_buffer[done:]
msg.body = parser.buffers._body_buffer
msg.total_size -= done
msg.grpc_consumed = 0
def _speaks_grpc(msg: InternalHTTPMessage) -> bool:
return (msg.content_type or "").lower().startswith("application/grpc")
@ -1079,6 +1233,7 @@ class GrpcMessage(InternalBasicHttpMetaClass):
piece._frame = frame
piece._is_input = internal_data.current_pkt.is_input
pieces.append(piece)
_forget_framed(one._parser, msg)
if not pieces:
# Not "nothing matched" but "there is nothing to show yet" — a body half-way

View File

@ -3,9 +3,10 @@ pydantic>=2
typing-extensions>=4.7.1
pycryptodome
watchfiles
fgex
websockets
brotli
# 1.2 is the first with `output_buffer_limit`, which is what lets a body be decoded only
# as far as a filter may hold it. See `_unbrotli` in pyfilters/models/http.py.
brotli>=1.2.0
# Python 3.14 has `compression.zstd` in the standard library; this fills the gap
# below it. See firegex/pyfilters/internals/zstd_compat.py.
zstandard; python_version < '3.14'

View File

@ -202,6 +202,12 @@ export default function AddEditService({ opened, onClose, edit }: {
}
const isProxy = form.values.transport === Transport.PROXY
//: On NFQUEUE the same number means something narrower: how many UDP flows keep what
// their Python filters hold. A datagram has no close to observe, so that is the one
// thing there with nothing but a count and an idle timeout to bound it; a TCP stream
// lets go of its state when it closes, and a limit on it would do nothing.
const limitsQueuedFlows = form.values.transport === Transport.NFQUEUE
&& form.values.proto === L4.UDP
//: Whether the engine terminates and decrypts this service — TLS or QUIC. What the
// certificate fields, their validation and the payload all key off, because the two
// protocols need exactly the same thing from the operator.
@ -216,6 +222,9 @@ export default function AddEditService({ opened, onClose, edit }: {
isProxy && form.values.max_connections > 0 && form.values.over_limit_forwards
? "the excess forwarded unfiltered"
: null,
limitsQueuedFlows && form.values.max_connections > 0
? `at most ${form.values.max_connections} UDP flows held`
: null,
isProxy && form.values.first_byte_timeout > 0
? `silent for ${form.values.first_byte_timeout}s is closed`
: null,
@ -235,9 +244,12 @@ export default function AddEditService({ opened, onClose, edit }: {
// Neither survives leaving the layer that honours it. A stored limit that does
// nothing is the same trap as a stored protocol nothing can decrypt.
if (isEncrypted) form.setFieldValue('proto', L4.TCP)
if (form.values.max_connections !== 0) form.setFieldValue('max_connections', 0)
if (form.values.first_byte_timeout !== 0) form.setFieldValue('first_byte_timeout', 0)
}, [form.values.transport])
useEffect(() => {
if (!isProxy && !limitsQueuedFlows && form.values.max_connections !== 0)
form.setFieldValue('max_connections', 0)
}, [form.values.transport, form.values.proto])
const isExternal = form.values.transport === Transport.EXTERNAL
//: `http` is the one protocol whose addresses are not all on the same transport, so
// it is the one where each of them has to say which it is.
@ -396,11 +408,24 @@ export default function AddEditService({ opened, onClose, edit }: {
{...form.getInputProps('over_limit_forwards', { type: 'checkbox' })}
/>
</> : null}
</> : <Switch
</> : <>
<Switch
label="Keep forwarding if the filter stops answering"
description="The kernel's fail-open backstop. Turning it off means traffic stops when the filter does."
{...form.getInputProps('fail_open', { type: 'checkbox' })}
/>}
/>
{limitsQueuedFlows ? <>
<Space h="sm" />
<NumberInput
label="Most UDP flows at once"
description={form.values.max_connections > 0
? "Past it, the flow quiet the longest loses what its Python filters were keeping, and starts over."
: "0 means no limit: a flow keeps its Python filters' state until it has been quiet for a minute."}
min={0} step={64} allowDecimal={false}
{...form.getInputProps('max_connections')}
/>
</> : null}
</>}
</Accordion.Panel>
</Accordion.Item>
</Accordion>

View File

@ -130,8 +130,8 @@ const state = {
],
// One shape for every service: a network layer, and a chain of filters on it.
services: [
{ service_id: svcShop, name: "shop-api", status: "active", proto: "tcp", transport: "proxy", fail_open: true, max_connections: 0, over_limit_forwards: false, first_byte_timeout: 0, over_limit_hits: 0, over_limit_first: null, over_limit_last: null, filtering_since: demoStart },
{ service_id: svcScore, name: "scoreboard", status: "active", proto: "tcp", transport: "nfqueue", fail_open: true, max_connections: 512, over_limit_forwards: false, first_byte_timeout: 0, over_limit_hits: 1847, over_limit_first: demoStart, over_limit_last: demoStart + 900, filtering_since: demoStart },
{ service_id: svcShop, name: "shop-api", status: "active", proto: "tcp", transport: "proxy", fail_open: true, max_connections: 512, over_limit_forwards: false, first_byte_timeout: 0, over_limit_hits: 1847, over_limit_first: demoStart, over_limit_last: demoStart + 900, filtering_since: demoStart },
{ service_id: svcScore, name: "scoreboard", status: "active", proto: "tcp", transport: "nfqueue", fail_open: true, max_connections: 0, over_limit_forwards: false, first_byte_timeout: 0, over_limit_hits: 0, over_limit_first: null, over_limit_last: null, filtering_since: demoStart },
{ service_id: svcVault, name: "vault", status: "stop", proto: "tls", transport: "proxy", fail_open: true, max_connections: 0, over_limit_forwards: false, first_byte_timeout: 0, over_limit_hits: 0, over_limit_first: null, over_limit_last: null, filtering_since: null },
// QUIC: UDP on the wire, terminated here because nothing else can see inside it.
// Its filters are written against HTTP because its traffic is HTTP/3, which the

View File

@ -26,15 +26,16 @@ def ruleset() -> str | None:
return as_root("nft", "list", "table", "inet", "fgex")
def engine_port(service_port: int) -> int | None:
"""The proxy engine's listener, read off the rule that redirects a service's port.
def engine_port(service_port: int, l4: str = "tcp") -> int | None:
"""Where the proxy layer's redirect sends a service's port: the engine's listener,
or with `l4="udp"` the relay bound for that address.
The engine picks an ephemeral port when it starts and tells only the backend, so the
redirect is the one place outside those two that says which it is.
The engine picks ephemeral ports when it starts and tells only the backend, so the
redirect is the one place outside those two that says which they are.
"""
for line in (ruleset() or "").splitlines():
# TCP only: a UDP address on the same port is redirected to its own relay.
if re.search(rf"\btcp dport {service_port}\b", line):
# By protocol: a UDP address on the same port is redirected to its own relay.
if re.search(rf"\b{l4} dport {service_port}\b", line):
found = re.search(r"redirect to :(\d+)", line)
if found:
return int(found.group(1))

View File

@ -17,8 +17,8 @@ from helpers.traffic import Channel
pytestmark = [pytest.mark.instance, pytest.mark.root]
def _listener(port: int) -> int:
found = engine_port(port)
def _listener(port: int, l4: str = "tcp") -> int:
found = engine_port(port, l4)
if found is None:
pytest.skip("cannot read the nftables ruleset from here: not Linux, no root and no "
"passwordless sudo, or the instance under test is on another host")
@ -34,26 +34,64 @@ def test_a_connection_dialled_straight_at_the_engine_is_turned_away(api, protect
the listener was *bound* to, the wildcard, which no connection is ever accepted on:
so it dialled itself, and that connection dialled itself, for as long as there were
descriptors to spend. Any local process could set it off with one `connect`.
Two things stop it now, and either is enough: the rules drop what reaches one of the
engine's ports without having been redirected there, and the engine refuses a
connection whose destination is itself. So it is either never answered or closed at
once — what it may not be is held.
"""
service_id, server, port = protected(proxy_layer, name="selfdial")
start_and_settle(api, service_id)
listener = _listener(port)
with socket.create_connection((proxy_layer.ip, listener), timeout=5) as sock:
try:
with socket.create_connection((proxy_layer.ip, listener), timeout=3) as sock:
sock.settimeout(5)
try:
closed = sock.recv(1) == b""
turned_away = sock.recv(1) == b""
except ConnectionResetError:
closed = True
turned_away = True
except socket.timeout:
closed = False
assert closed, "the engine held a connection that could only have gone back into itself"
turned_away = False
except (TimeoutError, ConnectionRefusedError):
turned_away = True
assert turned_away, \
"the engine held a connection that could only have gone back into itself"
channel = Channel(server, port, proxy_layer.ipv6)
assert channel.gets_through(b"still answering"), \
"the service stopped working after somebody dialled the engine directly"
def test_a_relay_takes_only_what_was_redirected_to_it(api, service, udp_stand_in,
proxy_layer):
"""A relay forwards to its service whoever sent the datagram.
Every relay is bound to the wildcard, because a redirect delivers to whichever
address the packet arrived on — so a service protected on a loopback address was
reachable from the network through its relay's port, filters or no filters. Only
what arrived through the redirect is carried now.
"""
echo = udp_stand_in(proxy_layer.ipv6)
service_id = service(f"relay-{echo.port}", proxy_layer.ip, echo.port, "proxy",
proto="udp")
start_and_settle(api, service_id, wait=1.2)
assert echo.exchange(b"through the service") == b"through the service", \
"the service was not reachable to begin with"
relay = _listener(echo.port, "udp")
family = socket.AF_INET6 if proxy_layer.ipv6 else socket.AF_INET
with socket.socket(family, socket.SOCK_DGRAM) as sock:
sock.settimeout(1.5)
sock.sendto(b"straight at the relay", (proxy_layer.ip, relay))
try:
answered = sock.recvfrom(65535)[0]
except (TimeoutError, OSError):
answered = None
assert answered is None, "a datagram sent straight to the relay reached the service"
assert echo.exchange(b"still carried") == b"still carried"
def test_the_engine_killed_is_brought_back_with_its_filters(api, protected, proxy_layer):
"""The proxy layer's half of the watchdog the queued layer already had.

View File

@ -183,3 +183,67 @@ def test_udp_on_an_interface_needs_an_address_to_bind_a_relay_to(api, service,
pytest.skip("this host has no `lo` to resolve, or would not start the service")
time.sleep(1.2)
assert echo.exchange(b"through the interface") == b"through the interface"
# --- how many flows keep their state, on NFQUEUE -------------------------------------
REMEMBERS = """
from firegex.pyfilters import pyfilter, ACCEPT, REJECT
from firegex.pyfilters.models import RawPacket
# Module level, so it is per flow: each one gets globals of its own.
marked = False
@pyfilter
def remembers(packet: RawPacket):
global marked
if b"MARK" in packet.data:
marked = True
return ACCEPT
return REJECT if marked else ACCEPT
"""
def _from(port: int, echo, payload: bytes) -> bytes | None:
"""One datagram from a fixed source port, so several exchanges are one flow."""
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.settimeout(1.5)
try:
sock.bind(("127.0.0.1", port))
sock.sendto(payload, (echo.host, echo.port))
return sock.recvfrom(65535)[0]
except (TimeoutError, OSError):
return None
finally:
sock.close()
@pytest.mark.parametrize("limit,kept", [(0, True), (2, False)], ids=["no-limit", "limit-2"])
def test_a_queued_udp_service_keeps_flows_up_to_its_own_limit(api, service, udp_stand_in,
limit, kept):
"""`cpproxy` held at most 4096 flows' state, whatever the operator wanted, and let go
of an arbitrary one past that — a TCP stream's included. The number is the service's
own now, none by default, and past it the flow quiet the longest starts over."""
from helpers.net import free_port
echo = udp_stand_in()
service_id = service(f"flows-{limit}-{echo.port}", "127.0.0.1", echo.port, "nfqueue",
proto="udp", max_connections=limit)
add_python_filter(api, service_id, REMEMBERS, name="remembers")
start_and_settle(api, service_id, wait=1.2)
first = free_port(udp=True)
assert _from(first, echo, b"MARK") == b"MARK"
assert _from(first, echo, b"again") is None, "the flow did not keep its state at all"
# Enough of them that some land on the first flow's thread: flows are shared out
# between the queue threads by a hash, and so is the limit.
for _ in range(200):
assert echo.exchange(b"another flow") == b"another flow"
answered = _from(first, echo, b"once more")
if kept:
assert answered is None, "with no limit, a flow lost its state to two others"
else:
assert answered == b"once more", "past the limit, the oldest flow kept its state"

View File

@ -178,3 +178,47 @@ def test_a_trailers_only_reply_carries_its_status():
b"grpc-status: 7\r\ncontent-length: 0\r\n\r\n", False),
)
assert [s["payload"] for s in seen if not s["is_request"]] == []
# --- a stream that stays open --------------------------------------------------------
# A streaming RPC's body ends only when the stream does. Every frame in it had already
# been handed over, and the body went on holding all of them: under the default flush the
# buffer grew past every flush while the size it was counted by went negative.
def _parser_after(cap: int, *chunks: tuple[bytes, bool]):
code = FILTER_CODE.replace(
"seen = []",
"from firegex.pyfilters import FullStreamAction\n"
f"FGEX_STREAM_MAX_SIZE = {cap}\n"
"FGEX_FULL_STREAM_ACTION = FullStreamAction.FLUSH\n"
"seen = []",
)
glob = {"__firegex_pyfilter_enabled": ["watch"]}
exec(code, glob, glob)
compile(glob)
for payload, is_input in chunks:
glob["__firegex_packet_info"] = packet(payload, is_input)
handle_packet(glob)
from firegex.pyfilters.internals.data import DataStreamCtx
return glob["seen"], DataStreamCtx(glob).data_handler_context["http_grpc_in"]
def test_a_long_stream_holds_only_what_it_has_not_framed():
frames = [chunk(frame(b"x" * 295)) for _ in range(60)]
seen, parser = _parser_after(1000, (request_head(), True),
*[(f, True) for f in frames])
assert len(seen) == 60
assert len(parser.buffers._body_buffer) < 1000, "every frame handed over is still held"
assert parser.msg.total_size >= 0
def test_a_frame_past_the_cap_is_skipped_and_the_next_one_still_found():
big = frame(b"z" * 3000)
pieces = [chunk(big[i:i + 400]) for i in range(0, len(big), 400)]
seen, parser = _parser_after(1000, (request_head(), True),
*[(p, True) for p in pieces],
(chunk(frame(b"after")), True))
assert [s["payload"] for s in seen] == [b"after"], \
"the frame after the flushed one was read out of the middle of it"
assert len(parser.buffers._body_buffer) < 1000

View File

@ -268,6 +268,76 @@ def test_a_zstd_body_is_decompressed_before_the_filter_sees_it():
assert final(run((raw, False)), "response")["body"] == b"FLAG{secret}"
# --- a body larger decoded than the cap it arrived under -----------------------------
# `FGEX_STREAM_MAX_SIZE` bounds what a filter holds, but a body arrives under it
# compressed: a megabyte of gzip is a gigabyte of zeroes. It was decoded whole, so one
# response could take the worker's memory with it.
def _capped(cap: int, action: str, *chunks: tuple[bytes, bool]) -> tuple[list[dict], int]:
code = FILTER_CODE.replace(
"seen = []",
"from firegex.pyfilters import FullStreamAction\n"
f"FGEX_STREAM_MAX_SIZE = {cap}\n"
f"FGEX_FULL_STREAM_ACTION = FullStreamAction.{action}\n"
"seen = []",
)
glob = {"__firegex_pyfilter_enabled": ["watch_request", "watch_response"]}
exec(code, glob, glob)
compile(glob)
for payload, is_input in chunks:
glob["__firegex_packet_info"] = packet(payload, is_input)
handle_packet(glob)
return glob["seen"], glob.get("__firegex_pyfilter_result", {}).get("action")
def _response(encoding: str, payload: bytes) -> bytes:
return (b"HTTP/1.1 200 OK\r\nContent-Encoding: " + encoding.encode()
+ b"\r\nContent-Length: " + str(len(payload)).encode() + b"\r\n\r\n" + payload)
def _bombs():
import brotli
zeroes = b"\0" * (4 * 1024 * 1024)
deflate = zlib.compressobj(wbits=-zlib.MAX_WBITS)
yield "gzip", gzip.compress(zeroes)
yield "deflate", deflate.compress(zeroes) + deflate.flush()
yield "br", brotli.compress(zeroes)
# Two layers, each under the cap on its own: the limit holds for every step.
yield "gzip, gzip", gzip.compress(gzip.compress(zeroes))
@pytest.mark.parametrize("encoding,payload", list(_bombs()), ids=lambda v: str(v)[:12])
def test_a_body_that_decodes_past_the_cap_meets_the_full_stream_action(encoding, payload):
seen, action = _capped(1024 * 1024, "REJECT", (_response(encoding, payload), False))
assert action == 2, "a body decoding to four megabytes under a one-megabyte cap passed"
assert seen == [], "the filter was handed a body past the cap"
def test_flushing_hands_the_body_over_as_it_arrived():
"""Nothing is buffered to throw away, so the message goes on still encoded — what
an encoding this cannot undo already does."""
payload = gzip.compress(b"\0" * (4 * 1024 * 1024))
seen, action = _capped(1024 * 1024, "FLUSH", (_response("gzip", payload), False))
assert action == 0
assert final(seen, "response")["body"] == payload
def test_a_body_under_the_cap_is_still_decoded():
seen, _ = _capped(1024 * 1024, "REJECT", (_response("gzip", gzip.compress(b"FLAG")), False))
assert final(seen, "response")["body"] == b"FLAG"
def test_headers_past_the_cap_are_flushed_not_raised():
"""The flush subtracted `len(body)` from a message still in its headers, whose body is
`None`: headers past the cap raised instead of flushing, and the packet failed open."""
head = b"POST / HTTP/1.1\r\nHost: a\r\nX-Pad: " + b"A" * 150
seen, action = _capped(100, "FLUSH", (head[:60], True), (head[60:], True),
(b"\r\nContent-Length: 4\r\n\r\nbody", True))
assert action == 0
assert final(seen, "request")["body"] == b"body"
def test_an_identity_encoding_is_left_alone():
raw = b"HTTP/1.1 200 OK\r\nContent-Encoding: identity\r\nContent-Length: 5\r\n\r\nplain"
assert final(run((raw, False)), "response")["body"] == b"plain"
@ -307,3 +377,29 @@ def test_tabs_around_a_content_length_are_tolerated():
message = final(run((raw, False)), "response")
assert message["body"] == b"plain"
assert message["content_length"] == 5
def test_the_history_is_held_to_the_same_bytes_as_the_stream():
"""`FGEX_MAX_HISTORY_SIZE` counts messages, and each can be as large as the stream
cap: a keep-alive connection could hold a hundred bodies of a megabyte, per
direction, for as long as it stayed open."""
code = """
from firegex.pyfilters import pyfilter, ACCEPT
from firegex.pyfilters.models import HttpFullRequest
FGEX_STREAM_MAX_SIZE = 1000
held = []
@pyfilter
def watch(req: HttpFullRequest):
held.append(sum(len(r.body or b"") for r in req.history.requests))
return ACCEPT
"""
glob = {"__firegex_pyfilter_enabled": ["watch"]}
exec(code, glob, glob)
compile(glob)
request = b"POST / HTTP/1.1\r\nHost: a\r\nContent-Length: 400\r\n\r\n" + b"b" * 400
for _ in range(8):
glob["__firegex_packet_info"] = packet(request, True)
handle_packet(glob)
assert len(glob["held"]) == 8
assert max(glob["held"]) <= 1000, glob["held"]
assert glob["held"][-1] > 0, "the history was emptied rather than trimmed"

View File

@ -535,3 +535,44 @@ def test_something_that_is_neither_is_refused_by_name():
parse_ip_or_int("")
with pytest.raises(ValueError, match="neither a valid IP address nor"):
parse_ip_or_int("not a name, and not an address")
# --- the engine's own ports ---------------------------------------------------------
# Every listener and relay the proxy engine binds is on the wildcard, so each is guarded:
# what reaches one without having been redirected there is dropped. A relay stays bound
# for as long as the engine runs, even after its address is taken off the service.
def _guard_elements(commands: list, verb: str) -> list:
return [c[verb]["element"]["elem"][0]["concat"] for c in commands
if verb in c and "element" in c[verb]]
def test_an_engine_port_is_guarded_until_its_service_stops(monkeypatch):
table = detached_table()
sent: list = []
table.cmd = lambda *cmds: sent.extend(cmds)
table.raw_cmd = lambda *cmds: sent.extend(cmds)
table.get = lambda: []
monkeypatch.setattr(FiregexTables, "_guarded", {})
first = Address(address_id=new_id(), service_id="dns", ip_int="10.0.0.1/32", port=53,
proto=L4.UDP)
second = Address(address_id=new_id(), service_id="dns", ip_int="10.0.0.2/32", port=53,
proto=L4.UDP)
srv = Service(service_id="dns", name="dns", status="active", proto=L4.UDP,
transport=TRANSPORT.PROXY, addresses=[first, second])
relays = {nft.udp_relay_slot(nft.udp_relay_host(a.ip_int), 53, "same"): port
for a, port in ((first, 41000), (second, 41001))}
table.add(srv, udp_ports=relays)
assert sorted(_guard_elements(sent, "add")) == [["udp", 41000], ["udp", 41001]]
sent.clear()
table.delete(srv, [first])
assert _guard_elements(sent, "delete") == [], \
"a relay still bound in the engine was left reachable directly"
sent.clear()
table.delete(srv)
assert sorted(_guard_elements(sent, "delete")) == [["udp", 41000], ["udp", 41001]], \
"a port the service no longer holds was left guarded for whoever binds it next"

View File

@ -92,3 +92,19 @@ def test_the_fallback_is_reachable_at_all():
# shim itself. That is what tells the two apart.
assert fallback._decompress.__module__ == "zstd_compat_probe", \
"hiding `compression` did not select the zstandard fallback"
@pytest.mark.parametrize("hide_stdlib", BACKENDS)
@pytest.mark.parametrize("label,blob,expected",
[pytest.param(n, b, e, id=n) for n, b, e in _corpus()])
def test_both_backends_stop_where_they_are_told_to(hide_stdlib, label, blob, expected):
"""A bounded read, so a body can be decoded only as far as a filter may hold it."""
shim = _load(hide_stdlib)
assert shim.decompress(blob, max_length=7) == expected[:7]
assert shim.decompress(blob, max_length=len(expected) + 1) == expected
@pytest.mark.parametrize("hide_stdlib", BACKENDS)
def test_a_frame_declaring_far_more_is_not_decoded_whole(hide_stdlib):
blob = zstandard.ZstdCompressor().compress(b"\0" * (64 * 1024 * 1024))
assert len(_load(hide_stdlib).decompress(blob, max_length=1024)) == 1024