From 0c52014606ab361a7024e92d777611ecb2915208 Mon Sep 17 00:00:00 2001 From: Domingo Dirutigliano Date: Wed, 23 Sep 2026 11:21:51 +0200 Subject: [PATCH] minor changes and bug fixes --- .github/workflows/release.yml | 19 +- AGENTS.md | 41 +- Dockerfile | 13 +- backend/app.py | 82 ++- backend/docker-entrypoint.sh | 11 + backend/modules/firewall/firewall.py | 12 +- backend/modules/firewall/models.py | 7 +- backend/modules/firewall/nftables.py | 51 +- backend/modules/services/firewall.py | 22 +- backend/modules/services/logs.py | 4 + backend/modules/services/nftables.py | 25 +- backend/modules/services/pyapi.py | 12 +- backend/modules/services/pyworker.py | 40 +- backend/modules/services/transports.py | 155 +++-- backend/proxysrc/src/control.rs | 15 +- backend/proxysrc/src/h1up.rs | 9 +- backend/proxysrc/src/h2.rs | 17 +- backend/proxysrc/src/h3.rs | 16 +- backend/proxysrc/src/http1.rs | 65 +- backend/proxysrc/src/main.rs | 19 +- backend/proxysrc/src/proxy.rs | 148 ++++- backend/proxysrc/src/quic.rs | 25 +- backend/proxysrc/src/udp.rs | 135 ++-- backend/proxysrc/tests/h2.rs | 51 ++ backend/proxysrc/tests/h3.rs | 59 ++ backend/proxysrc/tests/tls.rs | 130 +++- backend/proxysrc/tests/udp.rs | 163 +++++ backend/routers/services.py | 383 ++++++++--- backend/utils/__init__.py | 16 + backend/utils/sqlite.py | 14 +- docs/firewall.md | 5 +- docs/pyfilter.md | 36 +- docs/services.md | 23 +- .../firegex/pyfilters/internals/__init__.py | 15 +- fgex-lib/firegex/pyfilters/internals/data.py | 7 +- .../firegex/pyfilters/proxysim/__init__.py | 2 +- fgex-lib/llhttp/llhttp_module.c | 36 +- frontend/src/components/Firewall/utils.ts | 3 +- frontend/src/components/MainLayout.tsx | 5 +- .../components/Services/AddEditService.tsx | 32 +- .../src/components/Services/AddressList.tsx | 20 +- .../components/Services/AddressOptions.tsx | 22 +- .../src/components/Services/AddressRow.tsx | 2 +- .../src/components/Services/pyfilterHints.ts | 19 +- frontend/src/js/demo.ts | 19 +- frontend/src/js/utils.tsx | 617 +++++++++--------- frontend/src/pages/Firewall/SettingsModal.tsx | 3 + run.py | 18 +- tests/helpers/host.py | 47 ++ tests/helpers/quicserver.py | 3 +- tests/integration/test_addresses.py | 28 + tests/integration/test_editor_hints.py | 24 +- tests/integration/test_engine.py | 92 +++ tests/integration/test_grpc.py | 2 +- tests/integration/test_nftables.py | 21 +- tests/integration/test_quic.py | 31 + tests/integration/test_service_edit.py | 72 ++ tests/integration/test_stats.py | 21 + tests/unit/test_backup_guards.py | 38 +- tests/unit/test_chain_payloads.py | 36 + tests/unit/test_live_log.py | 42 ++ tests/unit/test_llhttp_binding.py | 39 ++ tests/unit/test_pyfilter_inference.py | 2 +- tests/unit/test_service_lifecycle_lock.py | 75 +++ tests/unit/test_service_validation.py | 67 +- 65 files changed, 2480 insertions(+), 803 deletions(-) create mode 100644 backend/proxysrc/tests/udp.rs create mode 100644 tests/helpers/host.py create mode 100644 tests/integration/test_engine.py create mode 100644 tests/integration/test_service_edit.py create mode 100644 tests/unit/test_live_log.py create mode 100644 tests/unit/test_service_lifecycle_lock.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21829c7..8b46e8a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -240,8 +240,11 @@ jobs: include: - os: ubuntu-latest # manylinux + musllinux, x86_64 - os: ubuntu-24.04-arm # and the same for aarch64 - - os: macos-latest # arm64 - - os: macos-13 # x86_64, while GitHub still offers it + # arm64 and x86_64 both, from one runner: the Intel macOS image this used to ask + # for (`macos-13`) has been retired, and a matrix entry naming a runner that no + # longer exists fails — which, with the publish job needing this one, blocked + # every PyPI release. The x86_64 wheel is cross-compiled; see CIBW_TEST_SKIP. + - os: macos-latest - os: windows-latest steps: @@ -256,15 +259,25 @@ jobs: sed -i.bak "s/{{VERSION_PLACEHOLDER}}/${{ steps.tag.outputs.TAG_NAME }}/g" fgex-lib/setup.py; sed -i.bak "s/{{VERSION_PLACEHOLDER}}/${{ steps.tag.outputs.TAG_NAME }}/g" fgex-lib/firegex/__init__.py; - name: Build wheels - uses: pypa/cibuildwheel@v2.21.3 + # 3.x, because 2.x predates CPython 3.14 and quietly built nothing for it: the + # selector below matched no identifier it knew, which is not an error. + uses: pypa/cibuildwheel@v3.2.0 with: package-dir: fgex-lib output-dir: wheelhouse env: # The library declares `python_requires>=3.10`, so build what it claims to # support and nothing else. PyPy is excluded: the datapath embeds CPython. + # 3.10 and 3.11 build because the binding declares its 3.12-only module slot + # only where it exists (`llhttp_module.c`) — unguarded, it failed to compile + # there, and the whole job with it. CIBW_BUILD: "cp310-* cp311-* cp312-* cp313-* cp314-*" CIBW_SKIP: "*-musllinux_i686 *_i686 pp*" + CIBW_ARCHS_MACOS: "arm64 x86_64" + # An x86_64 wheel built on an arm64 runner cannot be imported there without + # Rosetta, which the runner is not promised to have; the arm64 wheels, built + # from the same sources in the same job, are what the smoke test checks. + CIBW_TEST_SKIP: "*-macosx_x86_64" # A wheel that imports is the only kind worth publishing, and the extension is # the whole reason this job exists. The checks live in a file rather than in a # `-c` string because the one that matters needs a `try`: the parser has to diff --git a/AGENTS.md b/AGENTS.md index 17b5969..e1ff0ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,10 +128,12 @@ 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. -**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 ` line per relay after `PORT`, and `_add_proxy` points each address's rule at its own port. +**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 | ` 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. +**A relay is named by where it sends *and* what it speaks there** — `udp_relay_slot()`, `|`, and the engine's `QuicManager` keys the same pair. Keyed on the upstream alone, two addresses reaching one service port with different answers (HTTP/3 relayed as QUIC to `udp/443` beside HTTP/3 turned into HTTPS on `tcp/443`, both real) got one relay between them, and an address whose `upstream` was **edited** on a running service was handed the relay it already had: the edit was saved, shown back as a tag, and reached nothing until a restart. Now the edit opens the relay for the new answer and repoints the rule; the old relay stays bound and idle until the service restarts, the same as a removed address's. + Client address transparency is preserved via `transparent::connect_as_udp` using `IP_TRANSPARENT` with mark `0x1339` and policy routing `table 1339`. New UDP addresses can be added to running services dynamically via `ADD_UDP` over the control channel without restarting the proxy. What UDP keeps: per-flow filter state keyed by client address and released after `IDLE` — UDP has no close to observe, so a timeout is the only thing that ends a flow — and both directions inspected. `Verdict::Reject` drops the datagram rather than closing anything, because there is nothing to close. Replies leave through the **listener** socket so conntrack rewrites them to appear from the address the client dialled; sending from the upstream socket would reach a client not expecting that source. The relay's own dial carries `SELF_MARK`, or the redirect rule would catch it and loop. @@ -161,7 +163,7 @@ The TCP side needs telling because one listener fronts every address and recover **An ordered chain works on every layer, and gets there differently.** The proxy walks a list inside one process. NFQUEUE cannot — its binaries fuse transport and filter — so there a chain is a chain of *processes*: one binary per filter, each with its own queue and its own pair of base chains, at `-307 + position` (prerouting) and `107 + position` (postrouting). A packet a filter accepts carries on to the next base chain in the same hook, so **filter order is base-chain priority order**. This was established by experiment, not assumed: two `cppregex` processes at adjacent priorities, where the second refuses traffic the first passed. The cost is a process and a reassembly pass per filter, which is why `MAX_CHAIN_POSITIONS` exists at all. -Changing the *shape* of an NFQUEUE chain (adding, removing, reordering) cannot be pushed: the shape is the arrangement of processes and rules. `NfqueueTransport.reload` raises `ChainShapeChanged` and `ServiceManager.update_chain` rebuilds, logging that it did. Editing a pattern inside an existing filter still costs nobody their connection. +Changing the *shape* of an NFQUEUE chain (adding, removing, reordering) cannot be pushed: the shape is the arrangement of processes and rules. `NfqueueTransport.reload` raises `ChainShapeChanged` and `ServiceManager.update_chain` rebuilds, logging that it did. Editing a pattern inside an existing filter still costs nobody their connection. A service with no active filter runs one stand-in stage (`_EmptyFilter`), and "nothing active, only the stand-in running" is not a change of shape — compared as ids it always looked like one, so every edit to a service whose filters were all switched off rebuilt its datapath. ### One engine per concern, never two Two implementations of the same concept is the mistake this module was restructured to remove, and it is worth restating because it keeps being tempting: @@ -176,16 +178,16 @@ Two implementations of the same concept is the mistake this module was restructu - **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>` 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 `Transport.check()` catches the case where the service was switched to UDP afterwards. Python filters *do* work on UDP on both layers: `cpproxy` gained `filter_action_udp` (no stream follower, no ack fixing — a datagram is complete in itself), and its per-flow contexts are bounded by `enforce_limit` because a datagram has no close to observe and a spoofed-source flood would otherwise accumulate one set of module globals per forged address. `handle_next_packet` used to throw for anything but TCP, with a message claiming UDP was supported. -- **A filter file is validated by the process that will run it.** `pyworker.py --check ` 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. +- **`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. +- **A filter file is validated by the process that will run it.** `pyworker.py --check ` 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 (`` 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. - The default for `FGEX_STREAM_MAX_SIZE` is 1 MiB, as documented. It read `1*8e20` under a comment saying "1MB", so `FGEX_FULL_STREAM_ACTION` never fired unless a size was set by hand, and the float it produced would have been refused by the setter beside it. -- **The editor's hints are introspected, never written down.** `modules/services/pyapi.py` reads the models out of `type_annotations_associations` and their members out of the classes — including `writable`, from `property.fset`, which is how the interface can say that the payload is the only thing a filter may assign to. `GET /api/services/pyfilter-api` serves it and `pyfilterHints.ts` feeds Monaco's completion and hover from it. A second, hand-written description would be wrong the first time a model gained a property, and autocompletion that lies is worse than none. The route is declared **before** `/{service_id}`, or that one matches it and looks for a service by that name. +- **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 `/`: the NFQUEUE binaries already reported the function name, and the proxy worker now returns it in the verdict frame (`[verdict][name len][name][payload]`). `ServiceManager._on_block` splits that one token in one place. A file with ten functions blocking 900 connections is not an answer to "which of them is doing it". -- **Nothing below the application layer crosses into a filter.** `RawPacket` exposes addresses, ports, family and direction as read-only metadata, and `data` — the payload — as the only writable thing; `mangled_data` comes back, never a packet. That boundary exists because the two layers cannot honestly offer the same thing underneath it: NFQUEUE has a real header whose rewriting desynchronises the stream, while the proxy terminated the connection and writes its own. The old model papered over that by handing the proxy a literal `FAKE:IP:TCP:HEADERS:` prefix as `raw_packet`, so the same filter did different things depending on where it was attached. On the proxy the metadata is *better* than a header would be — the engine knows both ends and sends them in a `KIND_OPEN` frame (`ConnectionMeta` → `Filter::connection_opened`), once per connection rather than on every chunk. +- **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. - **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. @@ -209,12 +211,14 @@ The firewall module is the one that had to move to get there, and the reason is - **`iptables` stopped working on the host.** `iptables-nft` refuses an entire table as soon as one rule in it cannot be expressed in iptables' own format, and it stores conntrack matches as an `xt` blob where nft writes a native `ct state`. Bisecting the rules firegex writes, `ct state` was the only one that tripped it — which is to say `allow_established` and `drop_invalid`, on in any sane configuration. From then on `iptables -L` and `iptables-save` answer ``table `filter' is incompatible, use 'nft' tool``, on a box where Docker, ufw or the organisers' scripts are the ones asking. - **Stopping firegex opened the host.** `reset()` re-added `filter INPUT` with `policy accept`, and in nftables `add chain` on an existing chain *updates* its policy. That chain was the administrator's: a host carrying default-deny had it silently removed when the firegex firewall was switched off. -A base chain carries its own policy wherever it lives, so default-deny is expressed exactly as before and a `drop` is terminal whichever table's chain reached it — nothing about what gets blocked changed. What changed is that firegex now adds a layer instead of taking one over: an `accept` in `fgex_rules_in` means "firegex does not block this", never "nothing else will", which is all it ever honestly meant. `reset()` is now deleting two tables per family (`add` before `delete`, because a batch is atomic and deleting a table that is not there fails all of it). +A base chain carries its own policy wherever it lives, so default-deny is expressed exactly as before and a `drop` is terminal whichever table's chain reached it. **One thing about what gets blocked did change, and `allow_dnat` puts it back**: in iptables' own `FORWARD` chain, Docker's or podman's accept for a published port came before or after firegex's jump in the same chain, and either way traffic to a published container port never met firegex's policy; a base chain of our own is evaluated on its own, so a drop policy dropped every connection to a published container port — how every service on a CTF box is reached. `dnat_rules()` appends `ct status dnat accept` to `fgex_forward` after the jump (on by default, a switch in the firewall settings): the operator's forward rules still apply first, and the runtime's own chain still decides for itself. What changed is that firegex now adds a layer instead of taking one over: an `accept` in `fgex_rules_in` means "firegex does not block this", never "nothing else will", which is all it ever honestly meant. `reset()` is now deleting two tables per family (`add` before `delete`, because a batch is atomic and deleting a table that is not there fails all of it). `Rule.table` is still `filter` or `mangle` in the database, the API and the interface — it names the hook a rule is evaluated at. `NFT_TABLES` maps it to the table it lands in; the two were one string only because that string used to be somebody else's table.<> A refused edit must change nothing: `_apply_chain(service_id, undo)` in `routers/services.py` rolls the write back when the transport rejects the resulting chain. Without it an operator is told their filter was rejected and then finds it in the list, and the next start fails for the reason they thought they had avoided. +`edit_service` holds itself to the same rule, and did not. It now checks the service it would become **before writing** — `Transport.check()` against the chain, whether or not the service is running (a stopped one used to take any combination and fail on its next start), a published port refused on a layer that does not dial, and the addresses' `edge`/`proto`/`upstream` recomputed for the new protocol (`_address_under`) — then writes the service and its addresses in **one transaction** (`db.queries`), because a protocol change that collided with another service's address used to leave the service rewritten and its addresses not. A running service whose new definition will not start has the old one written back and is started again on it, rather than being left stopped on the definition that failed. What an address can still be given afterwards — the port of the operator's own proxy on a service moved to the hand-off layer — is not checked there, exactly as at creation: `ExternalTransport.check` names it at start. + Order is the load-bearing detail in `services/firewall.py`, because getting it wrong is not an error anybody sees — it is a window during which traffic goes unfiltered. Starting: the capture interface if the service decrypts (the engine opens its socket to it at startup, so one that appears afterwards is one this process will not find), then the datapath, then the nft rules that point at it. Stopping is the exact reverse. ### Block statistics (`modules/services/stats.py`) @@ -252,7 +256,10 @@ A Rust binary, built in the Dockerfile's `compiler` stage rather than a `rust` i - `filter.rs` — the chain, and everything that keeps a filter from taking the datapath down: `catch_unwind`, a deadline on `spawn_blocking`, and a filter that misbehaves losing its say rather than the traffic being held. `panic = "unwind"` in `Cargo.toml` is load-bearing, not tuning. - Filters that keep state do so **per connection, per direction** (`ChainSessions`), because one client's bytes deciding another client's verdict is both a false positive and a way to smuggle a pattern past a filter by splitting it across two connections. - `pyworker.rs` frames carry a connection id and a close frame, so the Python side can honour the documented promise that each stream gets its own module globals. -- Source preservation is unconditional: the engine always dials the service from the client's address. `ct status dnat` is what separates the engine answering a client from the service answering the engine, which is why redirect (not tproxy) works even when the service is on this host. +- 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 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. ### The `external` hand-off Three nft chains, not two: prerouting for traffic from outside, **output for traffic this host generates** (which never reaches prerouting — the same reason the proxy transport has `fgex_nat_out`), and postrouting to undo the rewrite on the way back. A `mangle` target is one host, so `one_address()` strips the prefix that service addresses carry; @@ -265,8 +272,8 @@ Note for tests: Linux refuses a loopback connection to a port with no listener * Say what it buys, and no more. **A cap does not save the service being attacked** — it cannot tell a connection that is silent because it is an attack from one that is silent because the client is slow, so an attacker who fills the limit still fills it. What it buys is that one service's attacker stops being everyone's: measured, 400 silent connections against a limited service left the service beside it answering. Do not let the interface or the docs imply otherwise. -- One number for both halves, because TCP connections and UDP flows spend the same descriptors and two limits for that would be two things to keep in step. `0` is no limit, kept as a value rather than an absence, because "unlimited" is a choice. -- `over_limit_forwards` is the same trade as fail-open at a different moment, and off by default for the same reason: a filter that quietly stops filtering is the worse surprise. +- 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. - **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. @@ -305,7 +312,7 @@ What cannot be carried, and is a limit rather than a missing setting: **TLS 1.0 The key must be one `ring` will sign with — RSA ≥ 2048 bits, ECDSA, or Ed25519. nginx could be told to take a 1024-bit key with `@SECLEVEL=1`; rustls has no equivalent, so a smaller key is refused when the service starts. That refusal is worth having land well: `_pump_stderr` keeps the engine's last `[fatal]` line and `_died_because` puts it in the exception, because "the proxy engine did not report a listening port" is the symptom and the engine already printed the reason. -**What the service behind speaks is its own question, and it belongs to the *address*.** `upstream` on `service_addresses` — `same`, `tcp`, `tls` — because that is where it has an answer: one daemon reached over TLS on one port and in the clear on another is re-encrypted for the first and handed the plaintext for the second, and what leaves is a property of the way in. It was a service column first, which forced one answer on every edge of an `http` service. It reaches the engine **with the address**: `FGEX_PROXY_TARGETS` and `PUBLISH` for a TCP one (`
||[=]`), `FGEX_PROXY_UDP` and `ADD_UDP` for a relay — never as a variable of its own, since there is no longer a per-process answer. `Onward` in `proxy.rs` is the one spelling of the three values (a second enum, `UpstreamProtocol`, existed in `quic.rs` for an hour and is exactly the drift this file forbids); `TlsSetup.upstream` is built whenever the engine terminates anything and *used* per address. +**What the service behind speaks is its own question, and it belongs to the *address*.** `upstream` on `service_addresses` — `same`, `tcp`, `tls` — because that is where it has an answer: one daemon reached over TLS on one port and in the clear on another is re-encrypted for the first and handed the plaintext for the second, and what leaves is a property of the way in. It was a service column first, which forced one answer on every edge of an `http` service. It reaches the engine **with the address**: `FGEX_PROXY_TARGETS` and `PUBLISH` for a TCP one (`
||[=]`), `FGEX_PROXY_UDP` and `ADD_UDP` for a relay — never as a variable of its own, since there is no longer a per-process answer. `Onward` in `proxy.rs` is the one spelling of the three values (a second enum, `UpstreamProtocol`, existed in `quic.rs` for an hour and is exactly the drift this file forbids); `TlsSetup.upstream` is built whenever the engine terminates anything and *used* per address. **It is only asked where firegex decrypts** (`upstream_applies`): the cleartext address of an `http` service is carried as it arrived — the engine's plaintext path, h2c included, never reads `published.upstream` — so a choice there is refused rather than stored, the form does not offer it (`addressCapabilities` takes the edge), and moving an address to the clear without mentioning the upstream resets it to `same` instead of refusing the move. It used to be offered, saved and shown back as a tag there, and read by nothing. `same` is what every address did before there was a choice: a connection this engine terminated goes back out encrypted, one it never terminated was never encrypted to begin with, so a plain TCP service is untouched by this and always was. The other two say the service speaks HTTP/1.1 — in the clear or under its own TLS — whatever arrived at the front. @@ -363,11 +370,13 @@ Three things differ from h3 and each is a way to get this wrong: - **The end of a message rides on its last frame.** A head that *is* the whole message has to be sent with `end_of_stream` on the head; sending it open and closing with an empty DATA frame turns a **trailers-only** answer into one with a body — which is exactly a gRPC status-only reply, and gRPC clients refuse the mangled version. `head_is_the_message` is the one place that is decided, and it asks the sender (`RecvStream::is_end_stream`) rather than inferring, which is also what keeps a `content-length: 0` request headers-only through the proxy. - **One stream is one connection to the chain**, as on h3 — and unlike HTTP/1.1, where a keep-alive connection carries many requests through one set of filter state. HTTP/2 interleaves its streams, and sharing state between two of them is "one client's bytes deciding another client's verdict", which is what `ChainSessions` exists to prevent and also a way to smuggle a pattern past a filter by splitting it across two streams. Say this in the docs: it is a real difference an operator writing a stateful filter can be caught by. +**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. + **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. -**A bypassed chain is not terminated.** Rendering for a chain with nothing to say is work paid for no answer, so a service with no filters, one whose filters have all been disabled, and a connection admitted past the limit with `over_limit_forwards` all take the byte pump. The cost is that a connection admitted while the chain was empty stays a byte pump for its whole life even if a filter is pushed a moment later — the same trade `over_limit_forwards` itself makes. Note what this does to tests: `tests/h2.rs` spells its no-op chain `LIVE` rather than `""`, because an empty one passed every assertion while exercising none of this, which is the class of false green that found itself immediately (a trailer section that should have been dropped was carried, because nothing had been terminated). +**A bypassed chain is not terminated.** Rendering for a chain with nothing to say is work paid for no answer, so a service with no filters, one whose filters have all been disabled, and a connection admitted past the limit with `over_limit_forwards` all take the byte pump. **What the client opened with is still asked on an optional-TLS listener** (an `http` service), because there the answer decides more than rendering: whether the connection is decrypted, which is what keeps an `Edge::Tls` address's promise and what lets a service answering in the clear be reached over TLS at all. Skipping the peek for a bypassed chain made `opening` always `Other`, and every connection to a TLS address was refused as "not TLS" — a new HTTPS service answered nobody until its first filter, and one went dark the moment its filters were all switched off or had all lost their say, which is the opposite of failing open. `tests/tls.rs` pins it with an empty chain. The cost is that a connection admitted while the chain was empty stays a byte pump for its whole life even if a filter is pushed a moment later — the same trade `over_limit_forwards` itself makes. Note what this does to tests: `tests/h2.rs` spells its no-op chain `LIVE` rather than `""`, because an empty one passed every assertion while exercising none of this, which is the class of false green that found itself immediately (a trailer section that should have been dropped was carried, because nothing had been terminated). **`h2c` — HTTP/2 in the clear — is recognised from the connection preface, not from a setting.** Most gRPC is deployed without TLS behind a load balancer, so leaving it out would have made "gRPC is filtered" half true. A prior-knowledge client opens with a fixed 24 bytes that no other protocol begins with, and `sniff()` in `proxy.rs` **peeks** rather than reads — nothing is consumed, so a connection that turns out to be something else is handed to the byte pumps exactly as it arrived and there is no buffer to replay. Two properties are load-bearing: it **races the service**, because a protocol where the server speaks first (SMTP, SSH, most game protocols) would otherwise be held waiting for a client that is correctly waiting for a banner, and firegex would look like it broke them at random; and it is **bounded** (`SNIFF_WAIT`), so two silent peers cannot hold the two descriptors open. The same peek answers the TLS question for an `http` service, which is why there is one `Opening` and not two sniffs. @@ -395,16 +404,16 @@ Every module router is mounted onto `api = APIRouter(prefix="/api", dependencies `--unsafe-disable-auth` (env `UNSAFE_DISABLE_AUTH`) hands access control to a reverse proxy: `check_login` returns `True`, the socket.io handler skips its check, and `APP_STATUS()` reports `run` even with no password stored. The password endpoints (`/api/login`, `/api/set-password`, `/api/change-password`) answer `403` in this mode on purpose — otherwise an anonymous caller could plant a credential that keeps working once authentication is turned back on. `run.py config --password` still works, writing the hash straight into the DB from the host, and an explicit `-P` at first start is kept so that re-enabling authentication lands on a known password instead of the open initial-setup state. -**The flag is state, not a constant.** It lives in `keys_values["auth_disabled"]` and every check reads it (`auth_disabled()`); the environment is only the seed, written once per boot in `startup_main`. It used to be a module-level constant read at import, which is why `run.py config --password` could print "it will take effect immediately" at a process that had already decided every caller was an administrator — the hash landed in the database and nothing ever asked for it. `run.py`'s `put_runtime_settings()`/`get_runtime_setting()` are the one way in and out of a running instance's `keys_values` (Docker exec or the standalone rootfs, same function), used by both `--password` and `--[no-]unsafe-disable-auth`, and `config --show` says when the running instance disagrees with the persisted config. **`config --password` re-enables authentication when it was off**, live and persisted: setting a password is asking for one to be asked for, and a stored-but-unchecked password is worse than none because it reads like one. It stands down when the same command line said otherwise — an explicit `--unsafe-disable-auth`, or `--keep-auth-disabled` for an instance deliberately held open behind a proxy. Note the password block runs *after* the auth block in `handle_config_command`, which is why the auth block's "there is no password set" warning checks whether one is about to be set. +**The flag is state, not a constant.** It lives in `keys_values["auth_disabled"]` and every check reads it (`auth_disabled()`); the environment is only the seed, decided once per boot by `seed_auth_mode`. **Which boot matters.** A container's environment is fixed when it is created, and Docker starting it again by itself — a reboot, a daemon restart, a crash under `restart: unless-stopped` — brings back that environment, older than anything `run.py config` said since: authentication re-enabled with `run.py config --password` was off again after the next reboot, while `config --show` said it was on. So `run.py config` also writes `auth_disabled_host`, and a boot that is not fresh takes it over the environment; `docker-entrypoint.sh` marks the first boot of each container (`/execute/.firegex-booted`, in the container's own layer, gone from a container `run.py` recreates) and exports `FIREGEX_FRESH_BOOT=1`, which `run.py` also sets for every standalone start. A fresh boot takes the environment — which already says what `.firegex-conf.json` says — and clears the key. What `/api/auth-mode` sets writes neither, so it lasts as long as the process, as the interface says. It used to be a module-level constant read at import, which is why `run.py config --password` could print "it will take effect immediately" at a process that had already decided every caller was an administrator — the hash landed in the database and nothing ever asked for it. `run.py`'s `put_runtime_settings()`/`get_runtime_setting()` are the one way in and out of a running instance's `keys_values` (Docker exec or the standalone rootfs, same function), used by both `--password` and `--[no-]unsafe-disable-auth`, and `config --show` says when the running instance disagrees with the persisted config. **`config --password` re-enables authentication when it was off**, live and persisted: setting a password is asking for one to be asked for, and a stored-but-unchecked password is worse than none because it reads like one. It stands down when the same command line said otherwise — an explicit `--unsafe-disable-auth`, or `--keep-auth-disabled` for an instance deliberately held open behind a proxy. Note the password block runs *after* the auth block in `handle_config_command`, which is why the auth block's "there is no password set" warning checks whether one is about to be set. -`POST /api/auth-mode` turns it off and back on at runtime. **The two directions are not symmetrical, and that asymmetry is the design:** off is an administrator's deployment choice, but on, asked while it is off, would let any passer-by set a password and keep the real operator out for good — a lasting foothold, unlike the vandalism the mode already permits. So re-enabling takes a token this instance signed *before* it was turned off. Nothing new is signed while it is off (login and set-password both 403), so holding one is exactly that proof, and the operator who turned it off still has theirs. `token_is_valid()` is split out of `check_login` for this single caller: it needs the question without the "authentication is off, so yes" that `check_login` answers first. Either direction lasts only as long as the process, because the environment is what firegex comes up with — `run.py config` writes both the live database and `.firegex-conf.json`. +`POST /api/auth-mode` turns it off and back on at runtime. **The two directions are not symmetrical, and that asymmetry is the design:** off is an administrator's deployment choice, but on, asked while it is off, would let any passer-by set a password and keep the real operator out for good — a lasting foothold, unlike the vandalism the mode already permits. So re-enabling takes a token this instance signed *before* it was turned off. Nothing new is signed while it is off (login and set-password both 403), so holding one is exactly that proof, and the operator who turned it off still has theirs. `token_is_valid()` is split out of `check_login` for this single caller: it needs the question without the "authentication is off, so yes" that `check_login` answers first. Either direction lasts only as long as the process, because the environment is what firegex comes up with — `run.py config` writes the live database, `auth_disabled_host` and `.firegex-conf.json`. Frontend note: a 401 used to call `window.location.reload()`. A token left over from a database that has since been recreated (a new `secret`) 401s on the first query, the page reloads itself, and what comes back is whatever `APP_STATUS()` calls for — "choose a password", if none is set. That is the "sometimes a password screen appears on refresh" report. The token is now dropped and the app re-renders from the next `/api/status`, and no `Authorization: Bearer null` is sent when there is no token at all. Independently of that, `IPFilterMiddleware` restricts access by CIDR (`--allowed-ips`, optionally reading the client IP from `--proxy-ip-header`). It **fails closed**: a missing, unparseable, or non-matching IP is denied. Note the header variant trusts a client-supplied value, so it only holds if the proxy overwrites it. ### SQLite wrapper (`backend/utils/sqlite.py`) -Each DB file's schema is versioned by hashing the Python schema dict; on mismatch the **entire file is deleted and recreated** (not migrated) — any schema dict change wipes that DB file on next startup, including `db/firegex.db`'s `keys_values` table (password/JWT secret). `dump()`/`load()` back the export/import backup feature; `dump()` deliberately excludes `password`, `secret` **and `auth_disabled`** from exported `keys_values` rows, so `/api/import` explicitly re-preserves all three around the import. The third one is the newest and the easiest to lose: it is not a credential, it is *whether credentials are asked for*, which belongs to the deployment and not to the configuration being restored — a backup able to carry it is a backup able to turn a firewall's authentication off. `auth_disabled` is also put back **after** `startup_main()`, not before like the other two, because `startup_main` seeds it from the environment: right when a process is starting, wrong during an import where nothing restarted and the environment did not change. `tests/integration/test_backup.py` turns authentication off *around* the import on purpose — asserting it is still on afterwards passes either way on a container whose environment agrees, and the bug only shows where the running state and the boot-time environment differ. +Each DB file's schema is versioned by hashing the Python schema dict; on mismatch the **entire file is deleted and recreated** (not migrated) — any schema dict change wipes that DB file on next startup, including `db/firegex.db`'s `keys_values` table (password/JWT secret). `dump()`/`load()` back the export/import backup feature; `dump()` deliberately excludes `password`, `secret`, **`auth_disabled` and `auth_disabled_host`** from exported `keys_values` rows, so `/api/import` explicitly re-preserves all four around the import. The last two are the easiest to lose: they are not credentials, they are *whether credentials are asked for*, which belongs to the deployment and not to the configuration being restored — a backup able to carry them is a backup able to turn a firewall's authentication off. The import restarts the application state with `startup_main(seed_auth=False)`: re-deciding authentication from the environment is what a boot does, and doing it during an import opened a window — the length of every service's start — in which a container booted without authentication answered everybody as an administrator before the preserved value was written back. `tests/integration/test_backup.py` turns authentication off *around* the import on purpose — asserting it is still on afterwards passes either way on a container whose environment agrees, and the bug only shows where the running state and the boot-time environment differ. ### Real-time frontend updates One Socket.IO event, `"update"`, whose payload is a list of strings treated as a react-query `queryKey` prefix (e.g. `["services"]`, `["firewall"]`). `App.tsx` does `queryClient.invalidateQueries({ queryKey: data })` — prefix matching invalidates any query whose key starts with that array. Convention: each module's mutating endpoints emit the same tag string that module's frontend queries use as the first `queryKey` element. `staleTime: Infinity` is set globally, so this event is the **only** automatic refresh mechanism — a missing/mismatched tag means silent staleness for other clients/tabs. @@ -423,7 +432,7 @@ Do not reintroduce the alias as a file of re-exports if it ever comes back. `typ **Deploying is `run.py`, and only `run.py`.** The package used to carry a `fgex deploy` passthrough that exec'd a copy of it; it is gone, along with the build-time copy `setup.py` made. The package is for writing and testing filters — `firegex.pyfilters`, `firegex.regex`, the two simulators — and starting a firegex is a separate job done from a clone. Do not reintroduce a deployment command here: the reason the old one was a verbatim passthrough was that any second description of `run.py`'s argument surface drifts, and not shipping one at all is the same argument taken one step further. -**The HTTP parser ships as source, and nobody upstream will bump it.** `fgex-lib/llhttp/` carries llhttp itself (`lib/`, verbatim from a `release/*` tag) and our binding beside it, because `pyllhttp` is archived, every binding with prebuilt wheels fails to load in the subinterpreters `cpproxy` runs filters in, and a ctypes binding against the system `libllhttp` would make `pip install firegex` assume a package installed beforehand. `PROVENANCE.md` there is what makes that maintainable: which tag, which checksums, what is ours and what is theirs, and how to move it. Each release changes what the parser *accepts* — going from 9.2.1 to 9.4.3, an empty `Transfer-Encoding` stopped being accepted and tabs around `Content-Length` started being — so `tests/unit/test_http_parsing.py` pins both through the public models: a change there is a decision about what filters are shown, never a test to fix. A parser more lenient than the service behind it is how a filter gets bypassed while still looking switched on. **The binding names itself and nothing else**: `pyllhttp` is where it was forked from, not something it uses, and the error path that imported it at runtime raised `SystemError: ... returned NULL without setting an exception` on every malformed request anywhere that archived package was not installed — which is everywhere except the machines it was written on. `wheel_smoke.py` is the check that could have caught it, and the reason it is a file rather than a `-c` string: `cibuildwheel` runs it against each wheel in an environment with nothing else in it, parsing something malformed as well as something valid. +**The HTTP parser ships as source, and nobody upstream will bump it.** `fgex-lib/llhttp/` carries llhttp itself (`lib/`, verbatim from a `release/*` tag) and our binding beside it, because `pyllhttp` is archived, every binding with prebuilt wheels fails to load in the subinterpreters `cpproxy` runs filters in, and a ctypes binding against the system `libllhttp` would make `pip install firegex` assume a package installed beforehand. `PROVENANCE.md` there is what makes that maintainable: which tag, which checksums, what is ours and what is theirs, and how to move it. Each release changes what the parser *accepts* — going from 9.2.1 to 9.4.3, an empty `Transfer-Encoding` stopped being accepted and tabs around `Content-Length` started being — so `tests/unit/test_http_parsing.py` pins both through the public models: a change there is a decision about what filters are shown, never a test to fix. A parser more lenient than the service behind it is how a filter gets bypassed while still looking switched on. **The binding names itself and nothing else**: `pyllhttp` is where it was forked from, not something it uses, and the error path that imported it at runtime raised `SystemError: ... returned NULL without setting an exception` on every malformed request anywhere that archived package was not installed — which is everywhere except the machines it was written on. `wheel_smoke.py` is the check that could have caught it, and the reason it is a file rather than a `-c` string: `cibuildwheel` runs it against each wheel in an environment with nothing else in it, parsing something malformed as well as something valid. **A method is named by a switch over `HTTP_ALL_METHOD_MAP`** (`method_name`), never by indexing a table: the table the binding inherited was built from `HTTP_METHOD_MAP`, which runs 0–33 and jumps to QUERY at 46 without PRI or the RTSP methods — all of which a request parser accepts — so `QUERY` and `PLAY` read past its end and `FLUSH rtsp://x/ RTSP/1.0`, one line from any client, crashed the process parsing it (on NFQUEUE that is `cpproxy`, and after five crashes a minute the service is stopped). `tests/unit/test_llhttp_binding.py` asks for every such method in a process of its own. The module's per-interpreter-GIL slot is declared only from 3.12, where it exists: unguarded, the binding did not compile for the 3.10 and 3.11 wheels the release builds, and the publish job needed all of them. `firegex/regex/` binds `libhs` through ctypes rather than depending on a wheel: no build step, nothing to go stale, and by construction the same library the product loads. It is a third binding to one engine, not a third engine — the distinction that matters is that there is still only one thing deciding what matches. Where libhs is absent the tools raise `HyperscanMissing` and stop; adding an `re` fallback would reintroduce exactly the lie the in-app tester was built to avoid. `tests/unit/test_regex_library.py` pins the agreement: validity in the mode a pattern will run in, direction, the shared file format, and that a ruleset asking to `rewrite` is refused rather than quietly treated as a block. Not part of the backend/frontend build. `fgex-lib/README.md` is a short index (PyPI-facing) linking out to `docs/*.md`; it isn't rendered anywhere itself. diff --git a/Dockerfile b/Dockerfile index 3f5341a..160f1b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,14 +16,13 @@ RUN bun run build # Base fedora container FROM --platform=$TARGETARCH quay.io/fedora/fedora:44 AS base # iproute: the proxy engine needs `ip rule`/`ip route` to bring the return traffic -# home when a service preserves the client's source address. -# iproute-tc: `tc` is a separate package from `ip` on Fedora, and it is what mirrors the -# decrypted traffic of every TLS service onto one capture interface. The kernel's own -# `dup to` in the netdev family would do the same without it, and is not used: it needs -# `nft_dup_netdev`, which plenty of kernels are built without, and the failure is a rule -# that will not load rather than a feature that degrades. +# home when a service preserves the client's source address, and `ip link` is what puts +# the `firegex0` capture interface there. Nothing else is needed for the capture: the +# engine writes the decrypted traffic onto it itself, through a packet socket +# (`proxysrc/src/capture.rs`). `tc` used to be installed for mirroring it there, and +# nothing has used it since. RUN dnf -y update && dnf install -y python3.14 libnetfilter_queue \ - libnfnetlink libmnl libcap-ng-utils nftables iproute iproute-tc \ + libnfnetlink libmnl libcap-ng-utils nftables iproute \ vectorscan libtins python3-nftables libpcap && dnf clean all RUN mkdir -p /execute/modules diff --git a/backend/app.py b/backend/app.py index 644edf4..25510f6 100644 --- a/backend/app.py +++ b/backend/app.py @@ -9,7 +9,7 @@ from fastapi import FastAPI, HTTPException, Depends, APIRouter from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from jose import jwt from utils.sqlite import SQLite -from utils import SAFE_DB_NAME, SAFE_PY_NAME, API_VERSION, FIREGEX_PORT, FIREGEX_HOST, FIREGEX_SOCKET, JWT_ALGORITHM, get_interfaces, socketio_emit, DEBUG, SysctlManager, NORELOAD, safe_join +from utils import boot_auth_mode, SAFE_DB_NAME, SAFE_PY_NAME, API_VERSION, FIREGEX_PORT, FIREGEX_HOST, FIREGEX_SOCKET, JWT_ALGORITHM, get_interfaces, socketio_emit, DEBUG, SysctlManager, NORELOAD, safe_join from utils.loader import frontend_deploy, load_routers from utils.models import AuthModeForm, ChangePasswordModel, IpInterface, PasswordChangeForm, PasswordForm, ResetRequest, StatusModel, StatusMessageModel from contextlib import asynccontextmanager @@ -46,6 +46,18 @@ PROXY_IP_HEADER = os.getenv("PROXY_IP_HEADER", "") #: decided that every caller was an administrator. UNSAFE_DISABLE_AUTH_ENV = os.getenv("UNSAFE_DISABLE_AUTH", "").strip().lower() in {"1", "true", "yes", "on"} +#: Whether this boot's environment was written just now, from the host's configuration: +#: set by `docker-entrypoint.sh` on the first boot of a container, and by `run.py` for +#: every standalone start. Absent means the container is being started **again** by +#: Docker — after a reboot, a restart of the daemon, a crash under `restart: +#: unless-stopped` — with the environment it was created with, which is older than +#: anything `run.py config` has said since. +FRESH_BOOT = os.getenv("FIREGEX_FRESH_BOOT", "") == "1" + +#: What the host last decided about authentication with `run.py config`, kept where a +#: restart can read it. See `seed_auth_mode`. +AUTH_HOST_KEY = "auth_disabled_host" + def auth_disabled() -> bool: """Is authentication off right now? @@ -343,14 +355,38 @@ async def get_ip_interfaces(): #Routers Loader reset, startup, shutdown = load_routers(api) -async def startup_main(): +def seed_auth_mode(): + """Decide at boot whether authentication is on. + + Three writers, and a boot has to take the right one. `run.py start/restart` says it + in the environment of a container it has just created. `run.py config` says it on the + running instance and in `.firegex-conf.json` — but a container's environment is fixed + when it is created, so a container Docker started again by itself (a reboot, a daemon + restart, a crash under `restart: unless-stopped`) came back up with the value it was + created with. Authentication re-enabled with `run.py config --password` was off again + after the next reboot, while `config --show` went on saying it was on. + + So `run.py config` also writes what it decided under `AUTH_HOST_KEY`, and a boot that + is not fresh takes that over its stale environment. A fresh one — a container `run.py` + has just created from the current configuration — takes the environment, which already + says the same thing, and clears the key. What the interface sets (`/api/auth-mode`) + writes neither, so it lasts as long as the process, as it says it does. + """ + disabled, keep_held = boot_auth_mode(db.get(AUTH_HOST_KEY), FRESH_BOOT, + UNSAFE_DISABLE_AUTH_ENV) + db.put("auth_disabled", "1" if disabled else "0") + if not keep_held: + db.query("DELETE FROM keys_values WHERE key = ?;", AUTH_HOST_KEY) + + +async def startup_main(seed_auth: bool = True): db.init() - # The environment is the boot-time answer: `run.py start/restart --[no-]unsafe-disable-auth` - # says what this instance should come up as, and writing it here is what makes the flag - # readable at runtime without making it unreadable at boot. Changes made afterwards — - # from the interface, or with `run.py config` — outlive nothing but the process, which - # is why `run.py config` also persists them to .firegex-conf.json. - db.put("auth_disabled", "1" if UNSAFE_DISABLE_AUTH_ENV else "0") + # Only when a process is starting. An import restarts the application state without + # anything about the deployment having changed, and re-deciding here opened a window — + # the length of every service's start — in which a container booted without + # authentication answered everybody as an administrator, whatever it had been set to. + if seed_auth: + seed_auth_mode() if os.getenv("PSW_HASH_SET"): db.put("password", os.getenv("PSW_HASH_SET")) try: @@ -442,15 +478,8 @@ async def import_db(data: dict): os.makedirs('db') # Backups never contain the password, the secret or the auth mode (export_db strips - # all three), so preserve this instance's own values instead of losing them on import. - current_password = db.get("password") - current_secret = db.get("secret") - # This one has to go back *after* the restart below: `startup_main` seeds it from the - # environment, which is the right answer when the process is starting and the wrong - # one here — nothing restarted, the environment did not change, and an import that - # quietly reverted authentication to whatever the container was booted with would - # undo a `run.py config` nobody remembers making. - current_auth_disabled = db.get("auth_disabled") + # them), so preserve this instance's own values instead of losing them on import. + kept = {key: db.get(key) for key in ("password", "secret", "auth_disabled", AUTH_HOST_KEY)} for db_path, db_data in db_imports: temp_db = SQLite(str(db_path)) @@ -464,16 +493,19 @@ async def import_db(data: dict): with open(filter_path, 'wb') as script_file: script_file.write(decoded) - if current_password is not None: - db.put("password", current_password) - if current_secret is not None: - db.put("secret", current_secret) + for key, value in kept.items(): + if value is not None: + db.put(key, value) - # Restart the application state + # Restart the application state, without re-deciding authentication: nothing about the + # deployment changed, and deciding again from the environment is what a boot does. await shutdown_main() - await startup_main() - if current_auth_disabled is not None: - db.put("auth_disabled", current_auth_disabled) + await startup_main(seed_auth=False) + # And once more after it, in case the restart recreated the database under an older + # schema and took the values with it. + for key, value in kept.items(): + if value is not None and db.get(key) != value: + db.put(key, value) return {'status': 'ok'} diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh index ee51872..4221675 100644 --- a/backend/docker-entrypoint.sh +++ b/backend/docker-entrypoint.sh @@ -2,6 +2,17 @@ chown nobody -R /execute/ +# Whether this is the first boot of this container, told to the backend. A container's +# environment is fixed when it is created, and Docker starting it again by itself — after +# a reboot, a restart of the daemon, a crash under `restart: unless-stopped` — brings back +# that environment, which may be older than what `run.py config` has said since. The +# marker lives in the container's own layer: it survives a restart and is gone from a +# container `run.py` creates anew. +if [ ! -e /execute/.firegex-booted ]; then + touch /execute/.firegex-booted + export FIREGEX_FRESH_BOOT=1 +fi + # Create socket directory if SOCKET_DIR is set if [ -n "$SOCKET_DIR" ]; then mkdir -p "$SOCKET_DIR" diff --git a/backend/modules/firewall/firewall.py b/backend/modules/firewall/firewall.py index 171e34f..8864c7e 100644 --- a/backend/modules/firewall/firewall.py +++ b/backend/modules/firewall/firewall.py @@ -40,7 +40,8 @@ class FirewallManager: multicast_dns=self.multicast_dns, allow_upnp=self.allow_upnp, drop_invalid=self.drop_invalid, - allow_dhcp=self.allow_dhcp + allow_dhcp=self.allow_dhcp, + allow_dnat=self.allow_dnat, ) @settings.setter @@ -53,6 +54,7 @@ class FirewallManager: self.allow_upnp=value.allow_upnp self.drop_invalid=value.drop_invalid self.allow_dhcp=value.allow_dhcp + self.allow_dnat=value.allow_dnat @property def policy(self): @@ -134,3 +136,11 @@ class FirewallManager: def allow_dhcp(self, value): self.db.set("allow_dhcp", "1" if value else "0") + @property + def allow_dnat(self): + return self.db.get("allow_dnat", "1") == "1" + + @allow_dnat.setter + def allow_dnat(self, value): + self.db.set("allow_dnat", "1" if value else "0") + diff --git a/backend/modules/firewall/models.py b/backend/modules/firewall/models.py index 97d2d01..5fda03d 100644 --- a/backend/modules/firewall/models.py +++ b/backend/modules/firewall/models.py @@ -76,4 +76,9 @@ class FirewallSettings(BaseModel): multicast_dns: bool allow_upnp: bool drop_invalid: bool - allow_dhcp: bool \ No newline at end of file + allow_dhcp: bool + #: Forwarded traffic whose destination another NAT rewrote — a port Docker or podman + #: published, a router's port forward — is left to the rules that published it when no + #: firegex rule matched it, instead of meeting the forward policy. Defaulted, so a + #: client or a backup that predates it still validates. + allow_dnat: bool = True \ No newline at end of file diff --git a/backend/modules/firewall/nftables.py b/backend/modules/firewall/nftables.py index 57b7967..17b9a0d 100644 --- a/backend/modules/firewall/nftables.py +++ b/backend/modules/firewall/nftables.py @@ -18,13 +18,19 @@ policy. Two things went wrong with that, and both were reported from real hosts: default-deny policy had it quietly removed the moment the firegex firewall was disabled. -Owning the tables fixes both by construction, and costs nothing: a base chain carries -its own policy wherever it lives, so default-deny is expressed exactly as before, and a -`drop` is terminal no matter which table's chain reached it. What changes is that -firegex now *adds* a layer instead of taking over somebody else's — an `accept` here -means "firegex does not block this", never "nothing else will", which is what it already -meant in practice. Everything installed is visible under one prefix and removed by -deleting two tables per family. +Owning the tables fixes both by construction: a base chain carries its own policy +wherever it lives, so default-deny is expressed exactly as before, and a `drop` is +terminal no matter which table's chain reached it. What changes is that firegex now +*adds* a layer instead of taking over somebody else's — an `accept` here means "firegex +does not block this", never "nothing else will", which is what it already meant in +practice. Everything installed is visible under one prefix and removed by deleting two +tables per family. + +One thing did change, and `dnat_rules` is what puts it back: sharing iptables' own +`FORWARD` chain, traffic Docker or podman accepted for a published port never met +firegex's policy, while a chain of our own is evaluated on its own — so a drop policy +dropped every connection to a published container port. `allow_dnat` (on by default) +leaves destination-NATed traffic no rule of ours matched to the rules that published it. """ from modules.firewall.models import FirewallSettings, Action, Rule, Protocol, Mode, Table @@ -271,9 +277,38 @@ class FiregexTables(NFTableManager): # No hooking step any more: the base chains are firegex's own and `_skeleton` # already pointed each of them at its rules chain. - rules = self.init_comands(policy, opt) + self.get_rules(*srvs) + rules = self.init_comands(policy, opt) + self.get_rules(*srvs) + self.dnat_rules(opt) self.cmd(*rules) + def dnat_rules(self, opt: FirewallSettings | None) -> list[dict]: + """Leave port-forwarded traffic to the rules that forwarded it, when no rule of + ours has matched it. + + A consequence of owning the base chains that the move to them did not keep. In + iptables' own `FORWARD` chain, which is where firegex's rules used to be, Docker's + and podman's accept for a published port came first or came after firegex's jump + in the same chain — either way, traffic to a container's published port was + accepted without ever meeting firegex's policy. A base chain of our own is + evaluated on its own, so with the policy at drop every connection to a published + container port was dropped, on the CTF box where that is how every service is + reached. The rules chain still runs first, so a forward rule the operator wrote + applies to that traffic exactly as before; this only decides what the *policy* + does with what no rule matched, and the runtime's own chain still decides on its + own whether it accepts it. + """ + if opt is None or not opt.allow_dnat: + return [] + return [ + {"add": {"rule": { + "family": family, "table": self.filter_table, "chain": "fgex_forward", + "expr": [ + {"match": {"op": "in", "left": {"ct": {"key": "status"}}, "right": "dnat"}}, + {"accept": None}, + ], + }}} + for family in ("ip", "ip6") + ] + def get_rules(self,*srvs:Rule): rules = [] final_srvs:list[Rule] = [] diff --git a/backend/modules/services/firewall.py b/backend/modules/services/firewall.py index c2f9533..b435d5d 100644 --- a/backend/modules/services/firewall.py +++ b/backend/modules/services/firewall.py @@ -138,7 +138,7 @@ class ServiceManager: code_path=code_path(flt.id), functions=self.db.query( "SELECT name, active FROM pyfilters WHERE filter_id = ? " - "ORDER BY name ASC;", + "ORDER BY position ASC, name ASC;", flt.id, ), )) @@ -398,9 +398,13 @@ class ServiceManager: # --- lifecycle ------------------------------------------------------------ async def enable(self): - if self.active: - return + # Asked under the lock, not before it. Asked first, two starts arriving together — + # a double click, or an operator's start racing the watchdog's restart — both saw + # a stopped service, and the second built a datapath of its own over the first: + # the first engine was left running with nobody holding it. async with self.lock: + if self.active: + return self.transport = transports.build( self.srv, on_block=self._on_block, @@ -430,9 +434,12 @@ class ServiceManager: ) async def disable(self, persist: bool = True): - if not self.active: - return + # Under the lock for the same reason: a stop arriving while a start was still in + # progress saw a service that was not active yet, returned at once, and the start + # then finished — so the stop the operator asked for was simply lost. async with self.lock: + if not self.active: + return nft.delete(self.srv) self._steer = {} if self.transport: @@ -502,6 +509,7 @@ class ServiceManager: and self.srv.carries(L4.TCP) and self.srv.has_ipv6_tcp and not was_dual + and self.transport is not None and not self.transport.is_dual_stack ): self.log.add( @@ -514,6 +522,10 @@ class ServiceManager: if not added: return async with self.lock: + # Asked again under the lock: the service may have been stopped while this + # was waiting for it, and there is then no datapath to point anything at. + if not self.active or self.transport is None: + return # UDP is relayed by one socket per address — and QUIC by one endpoint per # address, for the same reason — so a new address is a new relay, opened on # the engine that is already running, before the rule that will point traffic diff --git a/backend/modules/services/logs.py b/backend/modules/services/logs.py index e2c751f..745dbc8 100644 --- a/backend/modules/services/logs.py +++ b/backend/modules/services/logs.py @@ -105,6 +105,10 @@ class ServiceLog: if not entries: return if dropped: + # A number of its own. It used to reuse the last line's, which the browser + # dedupes on: whichever of the two arrived second was thrown away, and it was + # usually this one — the only line saying how much had not been shown. + self.seq += 1 entries.append({ "at": int(time.time() * 1000), "level": LEVEL.WARN, diff --git a/backend/modules/services/nftables.py b/backend/modules/services/nftables.py index 4702975..4a7a4ce 100644 --- a/backend/modules/services/nftables.py +++ b/backend/modules/services/nftables.py @@ -26,7 +26,7 @@ NAT entry to key off and so could never reach a local service at all. import subprocess -from modules.services.models import TRANSPORT, Address, Service +from modules.services.models import TRANSPORT, UPSTREAM, Address, Service from utils import ( NFTableManager, addr_parse, @@ -223,6 +223,20 @@ def udp_relay_key(ip: str, port: int) -> str: return f"[{host}]:{port}" if ip_family(host) == "ip6" else f"{host}:{port}" +def udp_relay_slot(ip: str, port: int, onward: str) -> str: + """Which relay carries one address: where it sends, and what is spoken there. + + The engine names each relay this way on its `UDP ` line, and it is what + the transport's map, the rule that points at a relay and the manager adding an address + are keyed on. Where it sends alone was not enough: two addresses reaching one service + port can want different answers — HTTP/3 relayed as QUIC to `udp/443` beside HTTP/3 + turned into HTTPS on `tcp/443` — and an address whose answer was edited on a running + service was handed the relay it already had, so the new choice reached nothing until a + restart. `onward` is `UPSTREAM.env(...)`'s word. + """ + return f"{udp_relay_key(ip, port)}|{onward}" + + class InstalledRule: """A rule already in the ruleset, matched back to the service that owns it. @@ -587,11 +601,12 @@ class FiregexTables(NFTableManager): # at that address's port rather than at the one shared TCP listener. port = proxy_port if l4 == "udp": - # Keyed by where the relay *sends*, which is how the engine reported - # it: a published address and the service behind it are two ports, - # and the relay is filed under the second. + # Keyed by where the relay *sends* and what it speaks there, which is + # how the engine reported it: a published address and the service + # behind it are two ports, and the relay is filed under the second. service_ip, service_port = service_at(srv, addr) - key = udp_relay_key(udp_relay_host(service_ip), service_port) + key = udp_relay_slot(udp_relay_host(service_ip), service_port, + UPSTREAM.env(addr.upstream)) port = (udp_ports or {}).get(key) if port is None: raise Exception( diff --git a/backend/modules/services/pyapi.py b/backend/modules/services/pyapi.py index 9413b98..ff55735 100644 --- a/backend/modules/services/pyapi.py +++ b/backend/modules/services/pyapi.py @@ -24,9 +24,10 @@ def _clean(doc: str | None) -> str: def _members(cls) -> list[dict]: """The public properties of a model, with what they are for and whether they change. - Writability is worth reporting on its own: the whole boundary the filter API rests - on is that metadata is read and only the payload is written, and an editor that says - so while you type teaches it better than a paragraph in the documentation. + Writability is reported rather than assumed: the boundary the filter API rests on is + that a filter reads and answers with a verdict, and an editor that says so while you + type teaches it better than a paragraph in the documentation. Nothing is writable + today — the payload was, for `UNSTABLE_MANGLE`, and stopped being with it. """ out = [] for name, member in inspect.getmembers(cls, lambda m: isinstance(m, property)): @@ -75,8 +76,9 @@ def describe() -> dict: {"name": "REJECT", "value": REJECT.value, "doc": "Refuse the connection. Everything still in the stream goes with it."}, {"name": "DROP", "value": DROP.value, - "doc": "Silently drop this chunk and every one after it, without closing."}, - + "doc": "Stop the connection's traffic. On NFQUEUE this chunk and every one after " + "it are dropped without closing anything; on the proxy layer, where a " + "stream cannot skip bytes, the connection is closed as REJECT does."}, ] settings = [ diff --git a/backend/modules/services/pyworker.py b/backend/modules/services/pyworker.py index 48d2d5e..aa122f9 100644 --- a/backend/modules/services/pyworker.py +++ b/backend/modules/services/pyworker.py @@ -23,8 +23,8 @@ Frames are length-prefixed and binary: in [u32 len][u8 kind][u64 connection][payload] kind: 0 client->server, 1 server->client, 2 connection closed, 3 connection opened - out [u32 len][u8 verdict][u8 name len][name][payload] - verdict: 0 accept, 1 reject, 2 replace + out [u32 len][u8 verdict][u8 name len][name] + verdict: 0 accept, 1 reject The name is the `@pyfilter` function that decided, so a block is attributed to one function of a file rather than to the file as a whole — a file routinely holds several, @@ -39,8 +39,10 @@ Nothing below the application layer crosses in the other direction. There used t `FAKE:IP:TCP:HEADERS:` prefix here, handed to the library as `raw_packet` so that a filter written against real headers would find something there. It found a lie: the proxy terminates the connection and writes its own headers, so editing them changed -nothing, while the same filter on NFQUEUE edited a real packet. Metadata in, payload -out — that is the one contract both network layers can honestly keep. +nothing, while the same filter on NFQUEUE edited a real packet. Metadata in, a verdict +out — that is the one contract both network layers can honestly keep. There is no +payload coming back either: rewriting went with `UNSTABLE_MANGLE`, for the reason the +library's `Action` gives. """ import json @@ -52,7 +54,6 @@ import traceback VERDICT_ACCEPT = 0 VERDICT_REJECT = 1 -VERDICT_REPLACE = 2 # Sent once, after the user's file has been compiled. It is what lets the engine refuse # a ruleset whose code does not load, instead of discovering it later as traffic that # quietly stopped being filtered. @@ -285,8 +286,8 @@ class Filters: self.contexts.pop(connection, None) self.endpoints.pop(connection, None) - def run(self, connection: int, data: bytes, is_input: bool) -> tuple[int, str, bytes]: - """One chunk through the chain. Returns the verdict, who decided, and the payload.""" + def run(self, connection: int, data: bytes, is_input: bool) -> tuple[int, str]: + """One chunk through the chain. Returns the verdict, and which function decided.""" from firegex.pyfilters import ACCEPT, DROP, REJECT ctx = self.context(connection) @@ -328,23 +329,23 @@ class Filters: # The library did not answer. Saying nothing about a chunk is not a reason # to stop carrying it. print("[warn] [pyworker] no verdict from the filter chain", file=sys.stderr) - return VERDICT_ACCEPT, "", b"" + return VERDICT_ACCEPT, "" action = result.get("action") # Which function decided, so the block lands on it rather than on the whole file. matched = result.get("matched_by") matched = matched if isinstance(matched, str) else "" if action == ACCEPT.value: - return VERDICT_ACCEPT, "", b"" + return VERDICT_ACCEPT, "" if action == REJECT.value: - return VERDICT_REJECT, matched, b"" + return VERDICT_REJECT, matched if action == DROP.value: # A proxy has no way to swallow one chunk and keep the stream coherent — # the application on the other side would be reading a hole. Closing is the # honest equivalent, and it is what the operator meant by dropping. - return VERDICT_REJECT, matched, b"" + return VERDICT_REJECT, matched print(f"[warn] [pyworker] unknown action {action!r}", file=sys.stderr) - return VERDICT_ACCEPT, "", b"" + return VERDICT_ACCEPT, "" def _read_exactly(stream, count: int) -> bytes | None: @@ -357,16 +358,11 @@ def _read_exactly(stream, count: int) -> bytes | None: return buf -def _write(stream, verdict: int, matched: str, payload: bytes) -> None: +def _write(stream, verdict: int, matched: str) -> None: # The name is length-prefixed with a single byte: a Python identifier fits, and a # separator would have to be one that cannot appear in one. name = matched.encode()[:255] - stream.write( - struct.pack(">I", len(payload) + len(name) + 2) - + bytes([verdict, len(name)]) - + name - + payload - ) + stream.write(struct.pack(">I", len(name) + 2) + bytes([verdict, len(name)]) + name) stream.flush() @@ -453,7 +449,7 @@ def main() -> int: continue # deliberately unanswered try: - verdict, matched, out = filters.run(connection, payload, kind == KIND_C2S) + verdict, matched = filters.run(connection, payload, kind == KIND_C2S) except Exception: # One connection's filter blowing up must not take the process down: the # other connections it is serving would lose their filtering with it. The @@ -464,8 +460,8 @@ def main() -> int: last_traceback = now traceback.print_exc() print(EXCEPTION_MARK, file=sys.stderr, flush=True) - verdict, matched, out = VERDICT_ACCEPT, "", b"" - _write(stdout, verdict, matched, out) + verdict, matched = VERDICT_ACCEPT, "" + _write(stdout, verdict, matched) if __name__ == "__main__": diff --git a/backend/modules/services/transports.py b/backend/modules/services/transports.py index 5594f7d..650c742 100644 --- a/backend/modules/services/transports.py +++ b/backend/modules/services/transports.py @@ -28,8 +28,8 @@ from typing import NamedTuple from modules.services.models import (KIND, L4, PROTO, TRANSPORT, UPSTREAM, Filter, Regex, quic_alpn, upstream_refusal) from modules.services.nftables import (MAX_CHAIN_POSITIONS, NoRelayAddress, - interface_addresses, one_address, service_at, - udp_relay_host, udp_relay_key) + interface_addresses, service_at, udp_relay_host, + udp_relay_key, udp_relay_slot) from utils import DEBUG, nicenessify MODULES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") @@ -345,7 +345,8 @@ class _QueueStage: self._sock_reader: asyncio.StreamReader | None = None self._sock_writer: asyncio.StreamWriter | None = None self._sock_ready = asyncio.Event() - #: Which rule id each filter code stands for, for block attribution. + #: Which rule id each pattern code stands for, for block attribution. A Python + #: stage reports the function's own name instead, which needs no table. self._codes: dict[str, str] = {} #: Set by `stop()`, so the watchdog can tell a shutdown we asked for from one we #: did not. Without the distinction every ordinary stop looks like a crash. @@ -453,8 +454,16 @@ class _QueueStage: data = await self.process.stdout.read(10 * 1024) if not data: return + text = data.decode(errors="replace") + # The binary's own diagnostics share this pipe with the user's output, so + # the reason it gives for dying is read here or not at all — and without + # it a stage that would not start was reported as merely "would not + # start", with the sentence that said why sitting in the log. + for line in text.splitlines(): + if "[fatal]" in line: + self.owner._last_fatal = line.split("[fatal]", 1)[1].strip() if self.owner.on_output: - self.owner.on_output(self.srv.id, data.decode(errors="replace")) + self.owner.on_output(self.srv.id, text) except (asyncio.CancelledError, asyncio.IncompleteReadError): pass except Exception: @@ -480,9 +489,14 @@ class _QueueStage: line = (await reader.readuntil()).decode().strip() if DEBUG: print(f"[{self.srv.name}/{self.link.filter.name}] {line}") - if line.startswith("BLOCKED ") or line.startswith("MANGLED "): + if line.startswith("BLOCKED "): code = line.split()[1] - self.owner._emit_block(self._codes.get(code, self.link.id)) + if self.link.kind == KIND.PYFILTER: + # The function that decided, as `/`: the one + # token both network layers report. + self.owner._emit_block(f"{self.link.id}/{code}") + else: + self.owner._emit_block(self._codes.get(code, self.link.id)) elif line.startswith("EXCEPTION") and self.owner.on_exception: self.owner.on_exception(self.srv.id) elif line.startswith("ACK "): @@ -522,38 +536,27 @@ class _QueueStage: return (" ".join(codes) + "\n").encode() def _python_payload(self, link: ChainLink) -> bytes: - """cpproxy takes the user's module and compiles it in its own interpreter.""" - from firegex.pyfilters.internals import get_filter_names + """cpproxy takes the user's module and compiles it in its own interpreter. + **Nothing of the user's code runs here.** Which functions the file defines used to + be asked of the library in this process, which meant `exec`ing the module body + inside the backend's own event loop on every start and every reload — the thing + `pyworker.py --check` exists to keep out of it. The binary is told which functions + the operator left switched on, by name, and the library in there works out the rest + against the module it has just compiled: `None` means every one the file defines, + and a name the file no longer defines is passed over rather than refusing the file. + """ code = "" if link.code_path and os.path.exists(link.code_path): with open(link.code_path) as f: code = f.read() - # Which filters the file defines, asked of the library rather than assumed: it - # decides what counts as one, and it refuses to compile a module that does not - # tell it. Every block the binary reports names one of these, so they are also - # what attributes a refusal to a rule. - try: - names = get_filter_names(code) if code.strip() else [] - except Exception: - # A file that will not even parse is the binary's refusal to make, with the - # error the operator needs; guessing an empty list here would hide it. - names = [] - # Only what the operator left switched on, intersected with what the file - # actually defines: a stale selection naming a function that has since been - # deleted must not stop the module from loading. + self._codes = {} enabled = link.enabled_functions - if enabled is not None: - names = [name for name in names if name in set(enabled)] - # The binary reports a block by the function name; `/` is the - # one token both network layers send, so the backend attributes a block the same - # way whichever one produced it. - self._codes = {name: f"{link.id}/{name}" for name in names} body = ( code - + "\n\n__firegex_pyfilter_enabled = [" - + ", ".join(repr(name) for name in names) - + "]\n" + + "\n\n__firegex_pyfilter_enabled = " + + ("None" if enabled is None else repr(list(enabled))) + + "\n" # No protocol is written in. The file shows which one it speaks by what its # filters ask for, so a file cannot disagree with its own declaration — it # used to be passed in from here, and a filter asking for an HttpRequest @@ -561,8 +564,12 @@ class _QueueStage: # annotation. + "import firegex.pyfilters.internals\n" + "firegex.pyfilters.internals.compile(globals())\n" - ) - return len(body).to_bytes(4, byteorder="big") + body.encode() + ).encode() + # The length of what is sent, in bytes: counted in characters, a file with one + # accented letter in a comment announced fewer bytes than followed, and the rest of + # it was read as the next length prefix — the binary refused the code, then took the + # leftover as an absurd size and exited. + return len(body).to_bytes(4, byteorder="big") + body async def _push(self, payload: bytes): self._ack = asyncio.get_running_loop().create_future() @@ -710,10 +717,17 @@ class NfqueueTransport(Transport): async def reload(self, chain: list[ChainLink]) -> None: self.check(self.srv, chain) active = [link for link in chain if link.filter.active] + running = [stage.link.id for stage in self.stages] + # Nothing active, and nothing but the stand-in running: the shape has not changed. + # Compared as it was, the stand-in's id never matched an empty list, so every edit + # to a service whose filters were all switched off — renaming one, adding a + # pattern to one — tore the whole datapath down and built it again. + if not active and running == [_EmptyFilter(self.srv.id).id]: + return # A filter appearing, disappearing or changing places changes which process sits # at which priority, and that is the chain itself. Pushing new rules into the # processes that happen to be running would enforce the old order. - if [link.id for link in active] != [stage.link.id for stage in self.stages]: + if [link.id for link in active] != running: raise ChainShapeChanged() for stage, link in zip(self.stages, active): await stage.reload(link) @@ -804,10 +818,15 @@ class ProxyTransport(Transport): self._ack: asyncio.Future | None = None self._cmd_lock = asyncio.Lock() self.port: int | None = None - #: One relay port per protected address, for UDP. TCP needs none of this: a - #: single listener fronts every address and recovers where each connection was - #: headed from conntrack, which UDP cannot do — see `udp.rs`. + #: One relay port per protected address, for UDP, keyed by `udp_relay_slot`: where + #: the relay sends and what it speaks there. TCP needs none of this: a single + #: listener fronts every address and recovers where each connection was headed + #: from conntrack, which UDP cannot do — see `udp.rs`. self.udp_ports: dict[str, int] = {} + #: Set by `stop()`, so the watchdog can tell a shutdown we asked for from one we + #: did not — the same distinction the NFQUEUE stages make. + self._stopped = False + self._watchdog: asyncio.Task | None = None def _published(self) -> list[str]: """What this engine is told about its TCP addresses, one entry each. @@ -954,6 +973,9 @@ class ProxyTransport(Transport): # This allows adding new IPv6 addresses to a running service without any restart. self.is_dual_stack = self.srv.has_ipv6 or supports_ipv6() listen = "[::]:0" if self.is_dual_stack else "0.0.0.0:0" + # Resolved once: the list the engine is launched with and the number of `UDP` + # lines read back afterwards have to be the same answer. + relays = self._udp_targets() self.process = await asyncio.create_subprocess_exec( PROXY_ENGINE, stdout=asyncio.subprocess.PIPE, @@ -978,8 +1000,7 @@ class ProxyTransport(Transport): # which is UDP as far as the rules are concerned — what changes is # what binds the port, and `_crypto_env` is where that is said. "FGEX_PROXY_UDP": ",".join( - f"{udp_relay_key(ip, port)}|{onward}" - for ip, port, onward in self._udp_targets() + udp_relay_slot(ip, port, onward) for ip, port, onward in relays ), # The addresses this service is *published* on rather than # intercepted at: `dialled=service`, one pair per address that says @@ -1018,7 +1039,7 @@ class ProxyTransport(Transport): # One `UDP ` line per relay, before anything else. Read here # rather than in the event loop because the rules cannot be installed until # every one of them is known. - for _ in self._udp_targets(): + for _ in relays: try: line = ( await asyncio.wait_for(self.process.stdout.readuntil(), timeout=ACK_TIMEOUT) @@ -1031,11 +1052,17 @@ class ProxyTransport(Transport): if not line.startswith("UDP "): await self.stop() raise Exception(f"unexpected output from the proxy engine: {line.strip()!r}") - _, upstream, port = line.split() - self.udp_ports[upstream] = int(port) + _, slot, port = line.split() + self.udp_ports[slot] = int(port) # Only now: the handshake line was read directly above, and everything after # it belongs to the event reader. self._reader_task = asyncio.create_task(self._read_events()) + # And someone to notice if it goes. The nft rules outlive the process: without + # this an engine that died — killed by the OOM killer under a flood, say — left + # every connection redirected at a port nobody listened on, so each client got a + # reset while the interface went on reporting the service as active, and nothing + # ever brought it back. The NFQUEUE stages always had one. + self._watchdog = asyncio.create_task(self._watch()) # Rules before traffic. The engine must already be enforcing when the nft rule # starts sending it connections, or the first ones through a freshly started # service would go unfiltered. @@ -1086,9 +1113,24 @@ class ProxyTransport(Transport): ) return json.dumps(out) + async def _watch(self): + """Notice the engine dying, which nothing else here does. See `start`.""" + process = self.process + try: + returncode = await process.wait() + except asyncio.CancelledError: + return + if self._stopped or process is not self.process: + return # we stopped it ourselves + if self.on_died: + self.on_died("the proxy engine", returncode) + async def reload(self, chain: list[ChainLink]) -> None: if not self.process or self.process.returncode is not None: - return + # Said, rather than passed over: returning quietly made an edit look applied + # to an engine that was not there to apply it. The watchdog restarts a dead + # engine from what the database holds. + raise Exception("the proxy engine is not running") async with self._cmd_lock: self._ack = asyncio.get_running_loop().create_future() self.process.stdin.write((self._payload(chain) + "\n").encode()) @@ -1115,15 +1157,20 @@ class ProxyTransport(Transport): object's, so a caller that has just added an address asks for the relay and is handed the port, rather than keeping a second map of its own beside this one. """ - target = udp_relay_key(udp_relay_host(ip), port) - if target in self.udp_ports: - return self.udp_ports[target] + host = udp_relay_host(ip) + target = udp_relay_key(host, port) + # What names a relay is where it sends *and* what it speaks there: keyed on the + # first alone, an address whose answer was edited on a running service was handed + # the relay it already had, and the new choice reached nothing until a restart. + slot = udp_relay_slot(host, port, onward) + if slot in self.udp_ports: + return self.udp_ports[slot] if not self.process or self.process.returncode is not None: raise Exception("the proxy engine is not running") async with self._cmd_lock: - if target in self.udp_ports: - return self.udp_ports[target] + if slot in self.udp_ports: + return self.udp_ports[slot] self._ack = asyncio.get_running_loop().create_future() self.process.stdin.write(f"ADD_UDP {target} {onward}\n".encode()) await self.process.stdin.drain() @@ -1135,10 +1182,10 @@ class ProxyTransport(Transport): self._ack = None if not ok: raise Exception(f"the proxy engine rejected ADD_UDP {target}: {detail}") - relay_port = self.udp_ports.get(target) + relay_port = self.udp_ports.get(slot) if relay_port is None: raise Exception( - f"the proxy engine acknowledged ADD_UDP but did not report port for {target}" + f"the proxy engine acknowledged ADD_UDP but did not report port for {slot}" ) return relay_port @@ -1189,8 +1236,8 @@ class ProxyTransport(Transport): elif line.startswith("UDP "): parts = line.split() if len(parts) == 3: - _, upstream, port_str = parts - self.udp_ports[upstream] = int(port_str) + _, slot, port_str = parts + self.udp_ports[slot] = int(port_str) elif line.startswith("BLOCKED "): self._emit_block(line.split()[1]) elif line.startswith("STATS "): @@ -1214,6 +1261,12 @@ class ProxyTransport(Transport): traceback.print_exc() async def stop(self) -> None: + # Said before anything is torn down, or the watchdog wakes on the kill below and + # reports a crash we asked for. + self._stopped = True + if self._watchdog and self._watchdog is not asyncio.current_task(): + self._watchdog.cancel() + self._watchdog = None for task in (self._reader_task, self._stderr_pump): if task: task.cancel() diff --git a/backend/proxysrc/src/control.rs b/backend/proxysrc/src/control.rs index 16b7e54..82fa91c 100644 --- a/backend/proxysrc/src/control.rs +++ b/backend/proxysrc/src/control.rs @@ -66,14 +66,21 @@ pub async fn serve_stdin( let mut parts = rest.split_whitespace(); let target_str = parts.next().unwrap_or("").trim(); let onward = match parts.next() { - Some("plain") => Onward::Plain, - Some("tls") => Onward::Tls, - _ => Onward::Same, + None => Onward::Same, + Some(word) => match Onward::from_word(word) { + Some(onward) => onward, + None => { + let err = format!("unknown upstream {word:?}"); + eprintln!("[warn] [control] {err}"); + ack(false, &err); + continue; + } + }, }; match target_str.parse::() { Ok(upstream) => match relays.add_relay(upstream, onward).await { Ok(port) => { - println!("UDP {upstream} {port}"); + println!("UDP {upstream}|{} {port}", onward.word()); ack(true, ""); } Err(e) => { diff --git a/backend/proxysrc/src/h1up.rs b/backend/proxysrc/src/h1up.rs index 8e04bc7..9ad971c 100644 --- a/backend/proxysrc/src/h1up.rs +++ b/backend/proxysrc/src/h1up.rs @@ -72,6 +72,10 @@ pub(crate) struct H1Upstream { pub(crate) client: SocketAddr, /// Present when the service speaks TLS: the client half of the handshake. pub(crate) tls: Option>, + /// The name the client asked for, presented to the service in turn. A service that + /// picks its certificate or its virtual host by SNI would otherwise be handed the bare + /// address — which TLS does not even send — and answer as its default host. + pub(crate) server_name: Option, pub(crate) connect_timeout: Duration, pub(crate) self_mark: Option, /// Dial as the client. Off is the fallback the TCP path also has, and it is here for @@ -167,7 +171,10 @@ impl Outbound for H1Upstream { let answer = match &self.tls { Some(config) => { - let name = crate::tls::server_name(&self.upstream.ip().to_string())?; + let name = match self.server_name.as_deref().map(crate::tls::server_name) { + Some(Ok(name)) => name, + _ => crate::tls::server_name(&self.upstream.ip().to_string())?, + }; let stream = tokio::time::timeout( self.connect_timeout, crate::tls::connector(config.clone()).connect(name, socket), diff --git a/backend/proxysrc/src/h2.rs b/backend/proxysrc/src/h2.rs index 91cbfb9..ec196a3 100644 --- a/backend/proxysrc/src/h2.rs +++ b/backend/proxysrc/src/h2.rs @@ -59,7 +59,8 @@ use crate::filter::{ next_connection_id, ChainHandle, ChainSessions, ConnectionId, ConnectionMeta, Direction, L4, }; use crate::http1::{ - carried, close_body, head_framing, judge, push_body, render_request, render_response, Answer, + carried, close_body, conflicting_authority, head_framing, judge, push_body, render_request, + render_response, Answer, Framing, Incoming, Outbound, OutboundBody, Rendered, }; use crate::proxy::ProxyStats; @@ -524,6 +525,20 @@ async fn exchange( return Ok(()); } + // One authority, or none: the chain is shown one `Host` and the service is handed the + // request as it came, so two that disagree would be a filter reading one host while + // the service answers for another. Malformed by RFC 9113 §8.3.1, and treated so. + if let Some(why) = conflicting_authority(&head) { + eprintln!( + "[warn] [h2] {}: {why}, so the filters would be shown one host while the \ + service routes on the other. The stream is reset as malformed.", + ctx.client + ); + let mut respond = respond; + respond.send_reset(h2::Reason::PROTOCOL_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 smuggling, // and one invented by the proxy in front of it is the worst possible answer. So the diff --git a/backend/proxysrc/src/h3.rs b/backend/proxysrc/src/h3.rs index 45ff05a..b614e83 100644 --- a/backend/proxysrc/src/h3.rs +++ b/backend/proxysrc/src/h3.rs @@ -29,7 +29,8 @@ use h3::error::StreamError; use crate::capture::Tap; use crate::filter::{ChainSessions, ConnectionId, Direction}; use crate::http1::{ - carried, close_body, head_framing, judge, push_body, render_request, render_response, Answer, + carried, close_body, conflicting_authority, head_framing, judge, push_body, render_request, + render_response, Answer, Framing, Incoming, Outbound, OutboundBody, Rendered, }; use crate::quic::{Behind, Carrier}; @@ -279,6 +280,19 @@ async fn exchange( let (request, mut from_client) = resolver.resolve_request().await.map_err(io::Error::other)?; carrier.spoken.store(true, Ordering::Relaxed); + // One authority, or none: the chain is shown one `Host` and the service is handed the + // request as it came, so two that disagree would be a filter reading one host while + // the service answers for another. Malformed, and the stream is stopped as such. + if let Some(why) = conflicting_authority(&request) { + eprintln!( + "[warn] [h3] {}: {why}, so the filters would be shown one host while the \ + service routes on the other. 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 // smuggling, and one invented by the proxy in front of it is the worst possible diff --git a/backend/proxysrc/src/http1.rs b/backend/proxysrc/src/http1.rs index dd18ff9..b5c0994 100644 --- a/backend/proxysrc/src/http1.rs +++ b/backend/proxysrc/src/http1.rs @@ -318,7 +318,39 @@ pub(crate) async fn judge( } } +/// Why a request names its authority twice and disagrees with itself, or `None`. +/// +/// HTTP/2 and HTTP/3 carry the authority in `:authority`, and a client may send a `host` +/// header beside it. The rendering shows the chain one `Host` line, while the request goes +/// on to the service with both — and a service reading HTTP/2 or HTTP/3 routes on +/// `:authority`, which RFC 9113 §8.3.1 says wins. So a request whose two disagree would +/// show a filter one host and deliver itself to another: `:authority: admin.internal` with +/// `host: public.example` walks past every rule written against the admin host. The RFC +/// calls such a request malformed, and so does this. A second `host` header is the same +/// question asked twice, and is answered the same way. +pub(crate) fn conflicting_authority(request: &http::Request<()>) -> Option { + let mut hosts = request.headers().get_all(http::header::HOST).iter(); + let host = hosts.next(); + if hosts.next().is_some() { + return Some("the request carries more than one host header".to_string()); + } + match (request.uri().authority(), host) { + (Some(authority), Some(host)) + if !host.as_bytes().eq_ignore_ascii_case(authority.as_str().as_bytes()) => + { + Some(format!( + ":authority says {authority} and the host header says {}", + String::from_utf8_lossy(host.as_bytes()) + )) + } + _ => None, + } +} + /// 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 +/// and `:authority`, where both are present, say the same thing. pub(crate) fn render_request(request: &http::Request<()>, framing: &Framing) -> Vec { let target = request .uri() @@ -327,7 +359,7 @@ pub(crate) fn render_request(request: &http::Request<()>, framing: &Framing) -> .unwrap_or_else(|| "/".to_string()); let mut view = format!("{} {} HTTP/1.1\r\n", request.method(), target).into_bytes(); // Both versions carry it as `:authority`, and an HTTP/1.1 parser wants a `Host` line. - // A request that also sent `host` outright keeps its own. + // A request that also sent `host` outright keeps its own, which is the same value. if !request.headers().contains_key(http::header::HOST) { if let Some(authority) = request.uri().authority() { view.extend_from_slice(format!("host: {authority}\r\n").as_bytes()); @@ -391,3 +423,34 @@ pub(crate) fn close_body(view: &mut Vec, framing: &Framing, trailers: Option } view.extend_from_slice(b"\r\n"); } + +#[cfg(test)] +mod tests { + use super::conflicting_authority; + + fn request(authority: Option<&str>, hosts: &[&str]) -> http::Request<()> { + let uri = match authority { + Some(authority) => format!("https://{authority}/path"), + None => "/path".to_string(), + }; + let mut builder = http::Request::builder().uri(uri); + for host in hosts { + builder = builder.header(http::header::HOST, *host); + } + builder.body(()).unwrap() + } + + #[test] + fn one_authority_said_once_or_twice_alike_is_fine() { + assert_eq!(conflicting_authority(&request(Some("example.com"), &[])), None); + assert_eq!(conflicting_authority(&request(None, &["example.com"])), None); + assert_eq!(conflicting_authority(&request(Some("example.com"), &["example.com"])), None); + assert_eq!(conflicting_authority(&request(Some("Example.com"), &["example.COM"])), None); + } + + #[test] + fn two_authorities_that_disagree_are_malformed() { + assert!(conflicting_authority(&request(Some("admin.internal"), &["public.example"])).is_some()); + assert!(conflicting_authority(&request(None, &["a.example", "b.example"])).is_some()); + } +} diff --git a/backend/proxysrc/src/main.rs b/backend/proxysrc/src/main.rs index d703d67..4d0084e 100644 --- a/backend/proxysrc/src/main.rs +++ b/backend/proxysrc/src/main.rs @@ -459,19 +459,22 @@ async fn run() { // relay speaks. Per relay because a relay is one protected address, and // two ports of one service can be reached differently. let (target, onward) = match target.trim().split_once('|') { - Some((target, "plain")) => (target, fgex_proxy::proxy::Onward::Plain), - Some((target, "tls")) => (target, fgex_proxy::proxy::Onward::Tls), - Some((target, "same")) => (target, fgex_proxy::proxy::Onward::Same), - Some((_, other)) => { - eprintln!("[fatal] [main] FGEX_PROXY_UDP: unknown upstream '{other}'"); - exit(2); - } + Some((target, word)) => match fgex_proxy::proxy::Onward::from_word(word) { + Some(onward) => (target, onward), + None => { + eprintln!("[fatal] [main] FGEX_PROXY_UDP: unknown upstream '{word}'"); + exit(2); + } + }, None => (target.trim(), fgex_proxy::proxy::Onward::Same), }; let upstream = parse_addr("FGEX_PROXY_UDP", target.trim()); match relays.add_relay(upstream, onward).await { Ok(port) => { - println!("UDP {upstream} {port}"); + // With what it speaks onward, because that is half of what names a + // relay: two addresses sending to one service port can want different + // answers, and each gets a relay of its own. + println!("UDP {upstream}|{} {port}", onward.word()); use std::io::Write; let _ = std::io::stdout().flush(); } diff --git a/backend/proxysrc/src/proxy.rs b/backend/proxysrc/src/proxy.rs index 4c264ad..6dba95c 100644 --- a/backend/proxysrc/src/proxy.rs +++ b/backend/proxysrc/src/proxy.rs @@ -135,7 +135,7 @@ pub struct Published { /// /// Not to be confused with [`Upstream`] beside it, which is *where* to forward. This is /// what is spoken when it gets there. -#[derive(Clone, Copy, Default, PartialEq, Eq)] +#[derive(Clone, Copy, Default, PartialEq, Eq, Hash, Debug)] pub enum Onward { /// Whatever arrived: a connection terminated here goes back out encrypted, one that /// arrived in the clear is forwarded in the clear. The default, and what every @@ -148,6 +148,29 @@ pub enum Onward { Tls, } +impl Onward { + /// The word the backend uses for it, on `FGEX_PROXY_UDP`, `ADD_UDP` and `PUBLISH`, and + /// the one this engine answers with on a `UDP` line. + pub fn word(self) -> &'static str { + match self { + Onward::Same => "same", + Onward::Plain => "plain", + Onward::Tls => "tls", + } + } + + /// The other way round. `None` for a word nobody defined, so each caller can decide + /// whether that is fatal (at startup) or a refused command (on the control channel). + pub fn from_word(word: &str) -> Option { + match word { + "same" => Some(Onward::Same), + "plain" => Some(Onward::Plain), + "tls" => Some(Onward::Tls), + _ => None, + } + } +} + #[derive(Default)] pub struct Targets(std::sync::RwLock>); @@ -427,6 +450,11 @@ async fn dial( .await { Ok(Ok(stream)) => Ok(stream), + // The service answered, and the answer was no: nothing is listening there. That + // says nothing about impersonating the client, so it is not a reason to try again + // as ourselves — the second attempt would be refused the same way, and the warning + // below would blame source preservation for a service that is simply down. + Ok(Err(e)) if e.kind() == io::ErrorKind::ConnectionRefused => Err(e), // Losing the client's address is bad; losing the connection is worse. Fall // back, but make the degradation loud — a service silently seeing one source // address for everyone is exactly the kind of thing nobody notices in time. @@ -452,6 +480,24 @@ async fn dial( } } +/// Whether a connection was dialled straight at this engine instead of being steered here. +/// +/// Every connection the engine should carry arrives through a `redirect`, so conntrack +/// remembers a destination that is somebody else's: the service's own address and port. +/// A client that dials the listener itself — its port is an ephemeral one on every +/// address of the host, and a port scan finds it — leaves conntrack nothing to translate, +/// and `SO_ORIGINAL_DST` then answers with the very address the connection was accepted +/// on. Forwarding there is dialling ourselves, and the new connection asks the same +/// question and gets the same answer: measured, one such client grew into as many +/// connections as the limit allowed within two seconds, and with no limit (the default) it +/// grows until the process runs out of descriptors and the service stops answering anyone. +/// +/// The configured listen address could not catch it, because that is the wildcard the +/// listener was bound to (`[::]:0`) and never the address a connection is accepted on. +fn dialled_at_ourselves(destination: SocketAddr, accepted_on: SocketAddr, listen: SocketAddr) -> bool { + destination == accepted_on || destination == listen +} + async fn handle_connection( client: TcpStream, peer: SocketAddr, @@ -459,7 +505,7 @@ async fn handle_connection( chain: ChainHandle, stats: Arc, ) -> io::Result<()> { - let upstream = match cfg.upstream { + let original = match cfg.upstream { Upstream::Fixed(addr) => addr, Upstream::Original => match original_destination(&client) { Ok(addr) => addr, @@ -471,15 +517,23 @@ async fn handle_connection( }; // What the operator said about the address this was dialled at: nothing, for every // address that is simply the service, which is the transparent case and the default. - let published = cfg.targets.resolve(upstream); - let upstream = published.target.unwrap_or(upstream); + let published = cfg.targets.resolve(original); + let upstream = published.target.unwrap_or(original); - // A rule that steers our own outbound traffic back at us would spin forever. - if upstream == cfg.listen { + // Something that would have this engine dial itself: a client connecting straight to + // the listener, or a rule or a publication that points back at it. Either would spin + // for as long as there are descriptors to spend — see `dialled_at_ourselves`. + let accepted_on = unmap(client.local_addr()?); + if dialled_at_ourselves(original, accepted_on, cfg.listen) + || dialled_at_ourselves(upstream, accepted_on, cfg.listen) + { stats.origin_lookup_failures.fetch_add(1, Ordering::Relaxed); return Err(io::Error::new( io::ErrorKind::InvalidInput, - format!("refusing to forward {upstream} to ourselves: check the intercept rules"), + format!( + "refusing to forward {upstream} to ourselves: this connection was dialled \ + at the engine's own port rather than steered here by a rule" + ), )); } @@ -493,14 +547,27 @@ async fn handle_connection( let _ = client.set_nodelay(true); let _ = server.set_nodelay(true); - // Nothing is sniffed for a chain that has nothing to say: a bypassed service is a - // byte pump whatever its clients speak, so the question has no consequence and the - // peek would be work paid for no answer. An `http` service in that state carries the - // client's TLS through untouched, which is exactly what "not filtering" means. - let opening = if chain.current().is_bypassed() || !(cfg.tls.is_off() || cfg.tls.optional) { - Opening::Other - } else { + // What the client opened with, asked only where the answer decides something. + // + // On a listener that terminates TLS **optionally** — an `http` service — it always + // does, whatever the chain: whether this connection is decrypted is what makes an + // address declared as the encrypted one keep its promise, and what lets a service + // that answers in the clear be reached over TLS at all (`Onward::Plain`). This was + // skipped for a chain with nothing to say, on the reasoning that a bypassed service + // is a byte pump either way — and every connection to a TLS address of such a service + // was then refused as "not TLS", which is what a brand new HTTPS service with no + // filter yet did, and what one did the moment its filters were switched off or lost + // their say. The opposite of failing open. + // + // On a plain listener the only question is HTTP/2 in the clear, which matters only to + // a chain that renders it — so there, and only there, a bypassed chain skips the peek. + // A listener that always terminates TLS has no question to ask. + let opening = if cfg.tls.optional + || (cfg.tls.is_off() && !chain.current().is_bypassed()) + { sniff(&client, &server).await + } else { + Opening::Other }; // An address declared to be the encrypted one keeps that promise: a client opening @@ -517,19 +584,24 @@ async fn handle_connection( // the cleartext site declared as the encrypted one. Naming the address and what // was expected turns an afternoon into a sentence. eprintln!( - "[warn] [proxy] {peer} spoke something that is not TLS to {upstream}, which this service declares as its encrypted address. The connection is refused rather than carried to a service expecting HTTPS. If that address is meant to carry the site in the clear, it is the one to change." + "[warn] [proxy] {peer} spoke something that is not TLS to {original}, which \ + this service declares as its encrypted address. The connection is refused \ + rather than carried to a service expecting HTTPS. If that address is meant \ + to carry the site in the clear, it is the one to change." ); return Err(io::Error::new( io::ErrorKind::InvalidData, - format!("{upstream} is published as TLS and this connection did not start one"), + format!("{original} is published as TLS and this connection did not start one"), )); } if cfg.tls.is_off() || (cfg.tls.optional && opening != Opening::Tls) { // HTTP/2 in the clear, which a client announces by opening with a fixed 24-byte // preface. It is worth catching for the same reason the TLS path catches `h2`: // without it a gRPC service that speaks plaintext — which is most of them behind - // a load balancer — is carried as HPACK nobody can read. - if opening == Opening::Http2 { + // a load balancer — is carried as HPACK nobody can read. Not for a chain with + // nothing to say, which is a byte pump whatever the client speaks: the peek is + // only taken on its behalf here because an optional TLS listener needed it anyway. + if opening == Opening::Http2 && !chain.current().is_bypassed() { return crate::h2::carry( client, server, @@ -576,7 +648,7 @@ async fn handle_connection( // exactly what the service chose. What changes is what firegex then *does* with a // connection that agreed on `h2` — it speaks it, on both sides, instead of // forwarding frames no filter can read. - let (started, wanted) = match &cfg.tls.server { + let (started, wanted, sni) = match &cfg.tls.server { Some(_) => { let start = tokio::time::timeout( cfg.connect_timeout, @@ -584,14 +656,19 @@ async fn handle_connection( ) .await .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "TLS handshake timed out"))??; - let wanted: Vec> = start - .client_hello() + let hello = start.client_hello(); + let wanted: Vec> = hello .alpn() .map(|it| it.map(|p| p.to_vec()).collect()) .unwrap_or_default(); - (Some(start), wanted) + // The name the client asked for, carried on for the same reason the protocol + // list is: a service choosing its certificate or its virtual host by SNI would + // otherwise be handed this engine's idea of a name — the bare address, which + // TLS does not even send — and answer as its default host. + let sni = hello.server_name().map(str::to_owned); + (Some(start), wanted, sni) } - None => (None, Vec::new()), + None => (None, Vec::new(), None), }; // Per address: the configuration is built once, and whether this connection uses it @@ -608,7 +685,10 @@ async fn handle_connection( }; let (server, agreed): (Duplex, Option>) = match upstream_tls { Some(config) => { - let name = tls::server_name(&upstream.ip().to_string())?; + let name = match sni.as_deref().map(tls::server_name) { + Some(Ok(name)) => name, + _ => tls::server_name(&upstream.ip().to_string())?, + }; let connected = tokio::time::timeout( cfg.connect_timeout, tls::connector(tls::with_alpn(config, &wanted)).connect(name, server), @@ -706,6 +786,7 @@ async fn handle_connection( upstream, client: peer, tls: None, + server_name: None, connect_timeout: cfg.connect_timeout, self_mark: cfg.self_mark, spoof: cfg.spoof_source, @@ -1093,3 +1174,22 @@ where let _ = wr.shutdown().await; Ok(PumpOutcome::Finished) } + +#[cfg(test)] +mod tests { + use super::dialled_at_ourselves; + + /// What `SO_ORIGINAL_DST` hands back for a connection dialled straight at the listener + /// is the address it was accepted on, and forwarding there is dialling ourselves. + #[test] + fn a_connection_nothing_redirected_is_recognised() { + let listen = "[::]:0".parse().unwrap(); + let accepted_on = "10.0.0.5:41234".parse().unwrap(); + assert!(dialled_at_ourselves(accepted_on, accepted_on, listen)); + // The configured address is the wildcard, which is why comparing with it alone + // never caught this. + assert!(!dialled_at_ourselves(accepted_on, "10.0.0.6:41234".parse().unwrap(), listen)); + // A redirected connection remembers the service it was headed for. + assert!(!dialled_at_ourselves("10.0.0.5:80".parse().unwrap(), accepted_on, listen)); + } +} diff --git a/backend/proxysrc/src/quic.rs b/backend/proxysrc/src/quic.rs index 5c38b8c..cf44d06 100644 --- a/backend/proxysrc/src/quic.rs +++ b/backend/proxysrc/src/quic.rs @@ -356,6 +356,8 @@ impl QuicRelay { ), _ => None, }, + // Known only once the client's handshake is done, below. + server_name: None, connect_timeout: self.cfg.connect_timeout, self_mark: self.cfg.self_mark, spoof: self.cfg.spoof_source, @@ -402,6 +404,17 @@ impl QuicRelay { } }; + // The name the client asked for, now that its handshake has said it, for an + // upstream that re-encrypts: a service choosing its certificate or virtual host by + // SNI must not be handed the bare address instead. A QUIC upstream is dialled + // before the client's handshake (see `dial_upstream`) and cannot be told. + let mut behind = behind; + if let Behind::Http1(upstream) = &mut behind { + upstream.server_name = peer + .handshake_data() + .and_then(|data| data.downcast::().ok()) + .and_then(|data| data.server_name); + } self.relay(peer, behind, client, chain, agreed.as_deref()).await; drop(endpoint); Ok(()) @@ -952,7 +965,13 @@ pub struct QuicManager { chain: ChainHandle, cfg: QuicConfig, stats: Arc, - relays: Arc>>, + /// One relay per service **and** per what that service speaks. Keyed on the service + /// alone, a second address sending to the same port with a different answer — HTTP/3 + /// relayed as QUIC to `udp/443` beside HTTP/3 turned into HTTPS on `tcp/443`, both + /// perfectly real — was handed the first one's relay, and so was an address whose + /// answer was edited on a running service: the new choice reached nothing until a + /// restart, while the interface said it was in force. + relays: Arc>>, } impl QuicManager { @@ -967,7 +986,7 @@ impl QuicManager { pub async fn add_relay(&self, upstream: SocketAddr, onward: Onward) -> io::Result { let mut map = self.relays.lock().await; - if let Some(&port) = map.get(&upstream) { + if let Some(&port) = map.get(&(upstream, onward)) { return Ok(port); } let bind: SocketAddr = if upstream.is_ipv6() { @@ -987,7 +1006,7 @@ impl QuicManager { Arc::clone(&self.stats), )?; let port = relay.local_addr()?.port(); - map.insert(upstream, port); + map.insert((upstream, onward), port); tokio::spawn(Arc::new(relay).serve()); Ok(port) } diff --git a/backend/proxysrc/src/udp.rs b/backend/proxysrc/src/udp.rs index 73f8dc9..c1acb7b 100644 --- a/backend/proxysrc/src/udp.rs +++ b/backend/proxysrc/src/udp.rs @@ -20,16 +20,16 @@ use std::collections::HashMap; use std::io; use std::net::SocketAddr; -use std::sync::atomic::Ordering; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::net::UdpSocket; -use crate::proxy::ProxyStats; +use crate::proxy::{ProxyStats, Slot}; use crate::filter::{ next_connection_id, ChainHandle, ChainSessions, ConnectionId, ConnectionMeta, Direction, - Verdict, L4, + FilterChain, Verdict, L4, }; /// The largest datagram this relay will carry. Comfortably past the practical MTU and @@ -47,15 +47,27 @@ const IDLE: Duration = Duration::from_secs(60); const SWEEP: Duration = Duration::from_secs(10); struct Flow { - /// The socket this relay dials the service from. Not the client's address: source - /// preservation is what UDP gives up here. + /// The socket this relay dials the service from, bound to the client's own address + /// where the kernel allows it. upstream: Arc, connection: ConnectionId, /// Filter state for the client→service direction. Owned by the receive loop, which /// is single-threaded, so it needs no lock. sessions: ChainSessions, + /// What this flow is judged by: the service's chain, or no chain at all for a flow + /// admitted past the limit because the operator chose to forward what does not fit. + /// Decided once, when the flow opens — the same trade a TCP connection admitted past + /// the limit makes for its whole life. + chain: ChainHandle, + /// Whether anything of this flow has been refused yet, in either direction. A refusal + /// is counted once per flow, because the number it goes into is compared with flows + /// seen, and one flow refusing a thousand datagrams is still one flow refused. + refused: Arc, reply_task: tokio::task::JoinHandle<()>, last_seen: Instant, + /// This flow's place in the service's count of what it is carrying, released when the + /// flow is. Held for the side effect of dropping it. + _slot: Slot, } /// One protected address, relayed. @@ -68,12 +80,14 @@ pub struct UdpRelay { chain: ChainHandle, self_mark: Option, pub spoof_source: bool, - /// How many flows may exist at once. `0` means no limit. + /// How many connections and flows the service may carry at once, counted together + /// with its TCP connections in `stats.live`. `0` means no limit. max_flows: usize, - /// Whether a datagram from a new source past the limit is forwarded unfiltered - /// rather than dropped. The operator's choice, the same one the TCP side offers. + /// Whether a flow from a new source past the limit is carried unfiltered rather than + /// dropped. The operator's choice, the same one the TCP side offers. over_limit_forwards: bool, - /// Shared with the TCP side, so one service reports one pair of numbers. + /// Shared with the TCP side, so one service reports one pair of numbers and spends one + /// budget. stats: Arc, } @@ -104,26 +118,6 @@ impl UdpRelay { }) } - /// Send one datagram straight through, with no flow and therefore no filter state. - /// - /// This is what "forwarded unfiltered" has to mean for UDP: a flow *is* the state, so - /// admitting a datagram without creating one is admitting it without inspection — - /// which is exactly what the operator chose when they picked it over dropping. - async fn forward_unfiltered(&self, client: SocketAddr, data: &[u8]) -> io::Result<()> { - let socket = if self.spoof_source { - match crate::transparent::connect_as_udp(client.ip(), self.upstream, self.self_mark).await { - Ok(s) => s, - Err(_) => { - crate::transparent::connect_plain_udp(self.upstream, self.self_mark).await? - } - } - } else { - crate::transparent::connect_plain_udp(self.upstream, self.self_mark).await? - }; - socket.send(data).await?; - Ok(()) - } - pub fn local_addr(&self) -> io::Result { self.listener.local_addr() } @@ -167,31 +161,46 @@ impl UdpRelay { // hundredths of a second, took four hundred descriptors and held // them for a minute after the sender had gone. // - // The same cap on the same shape already existed on the NFQUEUE side - // (`MAX_UDP_FLOWS` in `pyproxy.cpp`); it had simply never been - // carried here. + // Counted in the **same** number as the service's TCP connections, + // because they spend the same descriptors and the limit is one budget: + // it was a count of this relay's own flows, so a service with three UDP + // addresses could hold three times its limit in flows and every one of + // them beside a full complement of TCP connections. + let live = self.stats.live.fetch_add(1, Ordering::Relaxed) + 1; + let slot = Slot(Arc::clone(&self.stats)); let limit = self.max_flows; - if limit > 0 && flows.len() >= limit { + let over = limit > 0 && live > limit as u64; + let chain = if over { self.stats.over_limit.fetch_add(1, Ordering::Relaxed); if !self.stats.warned_limit.swap(true, Ordering::Relaxed) { eprintln!( - "[warn] [udp] {limit} concurrent flows reached; datagrams \ - from new sources are being {} until it clears", - if self.over_limit_forwards { "forwarded unfiltered" } else { "dropped" }, + "[warn] [udp] {limit} concurrent connections and flows \ + reached; new flows are being {} until it clears", + if self.over_limit_forwards { "carried unfiltered" } else { "dropped" }, ); } if !self.over_limit_forwards { + drop(slot); continue; } - // Forwarding without a flow means without filter state, which is - // what "unfiltered" has to mean here: a flow is the state. - if let Err(e) = self.forward_unfiltered(client, &buf[..len]).await { - eprintln!("[warn] [udp] cannot forward past the limit: {e}"); + // Carried as a flow of its own, with no chain in front of it. It + // used to be a datagram sent from a socket closed straight after, + // so the service's answer reached a port nobody was listening on: + // the request went through and the client never heard back, which + // is not what forwarding is for. + ChainHandle::new(FilterChain::empty()) + } else { + if live * 2 <= limit as u64 { + // Armed again once there is real room, as on the TCP side. + self.stats.warned_limit.store(false, Ordering::Relaxed); + } + self.chain.clone() + }; + match self.open(client, chain, slot).await { + Ok(flow) => { + self.stats.accepted.fetch_add(1, Ordering::Relaxed); + flows.entry(client).or_insert(flow) } - continue; - } - match self.open(client).await { - Ok(flow) => flows.entry(client).or_insert(flow), Err(e) => { eprintln!("[warn] [udp] cannot reach {} for {client}: {e}", self.upstream); continue; @@ -203,14 +212,19 @@ impl UdpRelay { // Re-read the handle every datagram, so a chain swapped in mid-flow takes // effect without anyone losing their session. - let verdict = self + let verdict = flow .chain .current() .run(Direction::ClientToServer, &buf[..len], &mut flow.sessions) .await; let payload: &[u8] = match &verdict { Verdict::Accept => &buf[..len], - Verdict::Reject(_) => continue, + Verdict::Reject(_) => { + if !flow.refused.swap(true, Ordering::Relaxed) { + self.stats.closed_by_filter.fetch_add(1, Ordering::Relaxed); + } + continue; + } }; if let Err(e) = flow.upstream.send(payload).await { eprintln!("[warn] [udp] cannot forward to {}: {e}", self.upstream); @@ -219,8 +233,8 @@ impl UdpRelay { } /// Start relaying one client's flow: a socket towards the service, and a task - /// carrying the answers back. - async fn open(&self, client: SocketAddr) -> io::Result { + /// carrying the answers back, both judged by `chain`. + async fn open(&self, client: SocketAddr, chain: ChainHandle, slot: Slot) -> io::Result { let upstream = if self.spoof_source { match crate::transparent::connect_as_udp(client.ip(), self.upstream, self.self_mark).await { Ok(s) => s, @@ -244,9 +258,9 @@ impl UdpRelay { let connection = next_connection_id(); // Told once, before any datagram of this flow is judged. `client` is the real - // peer even though the service will not see it: a filter reads who is talking, - // and that stays true whatever address the relay dials from. - self.chain.current().connection_opened( + // peer: a filter reads who is talking, and that stays true whatever address the + // relay dials from. + chain.current().connection_opened( connection, &ConnectionMeta { client, @@ -255,20 +269,26 @@ impl UdpRelay { }, ); + let refused = Arc::new(AtomicBool::new(false)); let reply_task = tokio::spawn(replies( Arc::clone(&upstream), Arc::clone(&self.listener), client, - self.chain.clone(), + chain.clone(), connection, + Arc::clone(&refused), + Arc::clone(&self.stats), )); Ok(Flow { upstream, connection, sessions: ChainSessions::new(connection), + chain, + refused, reply_task, last_seen: Instant::now(), + _slot: slot, }) } @@ -287,7 +307,7 @@ impl UdpRelay { for addr in done { if let Some(flow) = flows.remove(&addr) { flow.reply_task.abort(); - self.chain.current().connection_closed(flow.connection); + flow.chain.current().connection_closed(flow.connection); } } } @@ -304,6 +324,8 @@ async fn replies( client: SocketAddr, chain: ChainHandle, connection: ConnectionId, + refused: Arc, + stats: Arc, ) { // This direction's own filter state, exactly as the TCP pumps keep theirs. let mut sessions = ChainSessions::new(connection); @@ -324,7 +346,12 @@ async fn replies( Verdict::Accept => &buf[..len], // There is no connection to close, so refusing the datagram is the whole of // what refusing can mean here. The client simply never receives it. - Verdict::Reject(_) => continue, + Verdict::Reject(_) => { + if !refused.swap(true, Ordering::Relaxed) { + stats.closed_by_filter.fetch_add(1, Ordering::Relaxed); + } + continue; + } }; if let Err(e) = listener.send_to(payload, client).await { eprintln!("[warn] [udp] cannot answer {client}: {e}"); diff --git a/backend/proxysrc/tests/h2.rs b/backend/proxysrc/tests/h2.rs index 1d03b90..881f29d 100644 --- a/backend/proxysrc/tests/h2.rs +++ b/backend/proxysrc/tests/h2.rs @@ -957,3 +957,54 @@ async fn a_pattern_blocks_over_http2_in_front_of_http1() { "the refused request reached the service anyway" ); } + +/// One request with an explicit `host` header beside its `:authority`, and what came back. +async fn request_with_host(addr: SocketAddr, host: &str) -> Result { + let (mut sender, driving) = connect(addr).await?; + let answer = async { + let outgoing = http::Request::builder() + .method("GET") + .uri("https://localhost/who") + .header("host", host) + .body(()) + .map_err(|e| e.to_string())?; + let (response, _) = sender.send_request(outgoing, true).map_err(|e| e.to_string())?; + let response = response.await.map_err(|e| e.to_string())?; + let mut got = Vec::new(); + let mut incoming = response.into_body(); + while let Some(chunk) = incoming.data().await { + let chunk = chunk.map_err(|e| e.to_string())?; + let len = chunk.len(); + got.extend_from_slice(&chunk); + let _ = incoming.flow_control().release_capacity(len); + } + Ok(String::from_utf8_lossy(&got).into_owned()) + } + .await; + driving.abort(); + answer +} + +/// A `host` header that disagrees with `:authority` is malformed, and never reaches the +/// service. +/// +/// The chain is shown one `Host` line and the service is handed the request as it came, +/// and an HTTP/2 service routes on `:authority`. So `:authority: admin.internal` beside +/// `host: public.example` showed every filter the public host while the request went to +/// the admin one — a rule written against the admin host walked straight past. +#[tokio::test] +async fn a_host_that_disagrees_with_the_authority_is_refused() { + let (addr, seen) = relay(Service::Echo, LIVE).await; + + let agreeing = request_with_host(addr, "localhost").await; + assert!(agreeing.is_ok(), "a host agreeing with :authority was refused: {agreeing:?}"); + let answered = seen.requests.load(Ordering::Relaxed); + + let disagreeing = request_with_host(addr, "admin.internal").await; + assert!(disagreeing.is_err(), "a disagreeing host was carried: {disagreeing:?}"); + assert_eq!( + seen.requests.load(Ordering::Relaxed), + answered, + "the service answered a request the filters were shown under another host" + ); +} diff --git a/backend/proxysrc/tests/h3.rs b/backend/proxysrc/tests/h3.rs index 0e6d940..17b64b9 100644 --- a/backend/proxysrc/tests/h3.rs +++ b/backend/proxysrc/tests/h3.rs @@ -701,3 +701,62 @@ async fn a_pattern_matches_with_an_http1_service_behind() { let answer = request(addr, "GET", "/public", None, false).await.unwrap(); assert_eq!(answer, "you asked for /public and sent 0 bytes: "); } + +/// A `host` header that disagrees with `:authority` is malformed, and never reaches the +/// service — the HTTP/3 half of the same case in `h2.rs`. +#[tokio::test] +async fn a_host_that_disagrees_with_the_authority_is_refused() { + let (cert, key) = self_signed(); + let upstream = spawn_h3_echo(&cert, &key).await; + let addr = spawn_relay(upstream, "").await; + + let ask = |host: &'static str| async move { + 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 outgoing = http::Request::builder() + .method("GET") + .uri("https://localhost/who") + .header("host", host) + .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())?; + 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::from_utf8_lossy(&got).into_owned()) + } + .await; + driving.abort(); + answer + }; + + let agreeing = ask("localhost").await; + assert!( + agreeing.as_deref().is_ok_and(|a| a.contains("/who")), + "a host agreeing with :authority was not carried: {agreeing:?}" + ); + let disagreeing = tokio::time::timeout(Duration::from_secs(5), ask("admin.internal")) + .await + .expect("a malformed request was left hanging"); + assert!(disagreeing.is_err(), "a disagreeing host was carried: {disagreeing:?}"); +} diff --git a/backend/proxysrc/tests/tls.rs b/backend/proxysrc/tests/tls.rs index 6c51f6a..8a145c9 100644 --- a/backend/proxysrc/tests/tls.rs +++ b/backend/proxysrc/tests/tls.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use std::time::Duration; use fgex_proxy::filter::{ChainHandle, FilterChain}; -use fgex_proxy::proxy::{Proxy, ProxyConfig, TlsSetup}; +use fgex_proxy::proxy::{Edge, Onward, Proxy, ProxyConfig, Published, TlsSetup}; use fgex_proxy::spec::parse_filters; use fgex_proxy::tls; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -212,3 +212,131 @@ async fn tls_off_leaves_the_plain_path_alone() { assert_eq!(&buf[..n], b"plain as ever"); let _ = Arc::new(()); } + +/// A TLS service that answers with the server name its client asked for, and nothing else. +async fn spawn_sni_reporter(cert: &str, key: &str) -> std::net::SocketAddr { + let config = tls::server_config(cert, key).unwrap(); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let acceptor = tls::acceptor(config); + loop { + let Ok((sock, _)) = listener.accept().await else { + continue; + }; + let acceptor = acceptor.clone(); + tokio::spawn(async move { + let Ok(mut stream) = acceptor.accept(sock).await else { + return; + }; + let mut buf = vec![0u8; 1024]; + let _ = stream.read(&mut buf).await; + let name = stream.get_ref().1.server_name().unwrap_or("").to_string(); + let _ = stream.write_all(name.as_bytes()).await; + let _ = stream.shutdown().await; + }); + } + }); + addr +} + +/// The engine as an `http` service runs it: TLS terminated only for the connections that +/// start a handshake, with the service's address declared as the encrypted one and the +/// service behind it speaking `upstream_speaks`. +async fn spawn_http_edge( + upstream: std::net::SocketAddr, + filters: &str, + cert: &str, + key: &str, + upstream_speaks: Onward, +) -> std::net::SocketAddr { + let chain = ChainHandle::new(FilterChain::new( + parse_filters(filters).unwrap(), + Duration::from_millis(500), + )); + let mut cfg = ProxyConfig::fixed("127.0.0.1:0".parse().unwrap(), upstream); + cfg.tls = TlsSetup { + server: Some(tls::server_config(cert, key).unwrap()), + upstream: Some(tls::client_config().unwrap()), + optional: true, + }; + cfg.targets.publish( + upstream, + Published { target: None, edge: Edge::Tls, upstream: upstream_speaks }, + ); + let proxy = Proxy::bind(cfg, chain).await.unwrap(); + let addr = proxy.local_addr().unwrap(); + tokio::spawn(proxy.serve()); + addr +} + +/// An HTTPS address answers whether or not a filter is attached yet. +/// +/// A chain with nothing to say used to skip the look at what the client opened with, so +/// every connection to an address declared as the encrypted one was refused as "not TLS": +/// a brand new `http` service answered nobody until its first filter, and one whose +/// filters were all switched off — or had all lost their say by panicking — went dark +/// the same way. The opposite of failing open. +#[tokio::test] +async fn a_tls_address_answers_with_no_filter_attached() { + let (cert, key) = self_signed(); + let upstream = spawn_plain_echo().await; + for filters in ["", "block:ZZ-NO-SUCH-BYTES-ZZ"] { + let addr = spawn_http_edge(upstream, filters, &cert, &key, Onward::Plain).await; + let got = tls_roundtrip(addr, b"hello").await; + assert_eq!( + got.as_deref().ok(), + Some(&b"hello"[..]), + "the TLS address did not answer with the chain {filters:?}: {got:?}" + ); + } +} + +/// The same for a service that speaks TLS itself: re-encrypted, chain or no chain. +#[tokio::test] +async fn a_tls_address_re_encrypts_with_no_filter_attached() { + let (cert, key) = self_signed(); + let upstream = spawn_tls_echo(&cert, &key).await; + let addr = spawn_http_edge(upstream, "", &cert, &key, Onward::Same).await; + let got = tls_roundtrip(addr, b"end to end").await.unwrap(); + assert_eq!(got, b"end to end"); +} + +/// And the promise the declaration makes still holds without a filter: a client that +/// opens the encrypted address in the clear is refused rather than carried. +#[tokio::test] +async fn a_tls_address_refuses_the_clear_with_no_filter_attached() { + let (cert, key) = self_signed(); + let upstream = spawn_plain_echo().await; + let addr = spawn_http_edge(upstream, "", &cert, &key, Onward::Plain).await; + let mut sock = TcpStream::connect(addr).await.unwrap(); + sock.write_all(b"GET / HTTP/1.1\r\n\r\n").await.unwrap(); + let mut buf = vec![0u8; 1024]; + let n = tokio::time::timeout(Duration::from_secs(5), sock.read(&mut buf)) + .await + .expect("the refused connection was left hanging") + .unwrap_or(0); + assert_eq!(n, 0, "a cleartext client was carried: {:?}", &buf[..n]); +} + +/// The name the client asked for is the name the service is asked for. +/// +/// A service choosing its certificate or its virtual host by SNI was handed the bare +/// address instead — which TLS does not even send — and answered as its default host. +#[tokio::test] +async fn the_client_s_server_name_reaches_the_service() { + let (cert, key) = self_signed(); + let upstream = spawn_sni_reporter(&cert, &key).await; + let addr = spawn_proxy( + upstream, + "", + TlsSetup { + server: Some(tls::server_config(&cert, &key).unwrap()), + upstream: Some(tls::client_config().unwrap()), + optional: false, + }, + ) + .await; + let got = tls_roundtrip(addr, b"who am I talking to").await.unwrap(); + assert_eq!(String::from_utf8_lossy(&got), "localhost"); +} diff --git a/backend/proxysrc/tests/udp.rs b/backend/proxysrc/tests/udp.rs new file mode 100644 index 0000000..250054d --- /dev/null +++ b/backend/proxysrc/tests/udp.rs @@ -0,0 +1,163 @@ +//! The datagram relay: what the connection limit means for it, and what it counts. +//! +//! Everything here dials the service plainly (`spoof_source` off), because a test has no +//! policy route to bring a transparent reply home. What is being asked is independent of +//! it: whether a flow past the limit hears back, whether flows spend the same budget TCP +//! connections do, and whether the counters the backend divides are in the same unit. + +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; + +use fgex_proxy::filter::{ChainHandle, FilterChain}; +use fgex_proxy::proxy::ProxyStats; +use fgex_proxy::spec::parse_filters; +use fgex_proxy::udp::UdpRelay; +use tokio::net::UdpSocket; + +/// A UDP service answering every datagram with the same bytes. +async fn spawn_echo() -> std::net::SocketAddr { + let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let addr = socket.local_addr().unwrap(); + tokio::spawn(async move { + let mut buf = vec![0u8; 2048]; + loop { + let Ok((n, from)) = socket.recv_from(&mut buf).await else { + continue; + }; + let _ = socket.send_to(&buf[..n], from).await; + } + }); + addr +} + +async fn spawn_relay( + upstream: std::net::SocketAddr, + filters: &str, + limit: usize, + over_limit_forwards: bool, + stats: Arc, +) -> std::net::SocketAddr { + let chain = ChainHandle::new(FilterChain::new( + parse_filters(filters).unwrap(), + Duration::from_millis(500), + )); + let relay = UdpRelay::bind( + "127.0.0.1:0".parse().unwrap(), + upstream, + chain, + None, + false, + limit, + over_limit_forwards, + stats, + ) + .await + .unwrap(); + let addr = relay.local_addr().unwrap(); + tokio::spawn(relay.serve()); + addr +} + +/// Send one datagram from a fresh source and wait a moment for an answer. +async fn ask(relay: std::net::SocketAddr, payload: &[u8]) -> Option> { + let client = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + client.send_to(payload, relay).await.unwrap(); + let mut buf = vec![0u8; 2048]; + match tokio::time::timeout(Duration::from_millis(700), client.recv_from(&mut buf)).await { + Ok(Ok((n, _))) => Some(buf[..n].to_vec()), + _ => None, + } +} + +/// "Forward what does not fit" has to mean the client is answered. +/// +/// A flow past the limit used to be one datagram sent from a socket closed straight after, +/// so the request reached the service and its answer went to a port nobody held any more: +/// forwarded, and useless to every request/response protocol UDP carries. +#[tokio::test] +async fn a_flow_past_the_limit_is_answered_when_forwarding() { + let upstream = spawn_echo().await; + let stats = Arc::new(ProxyStats::default()); + let relay = spawn_relay(upstream, "", 1, true, Arc::clone(&stats)).await; + + // The first source takes the one place and keeps it: flows are kept until idle. + let first = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + first.send_to(b"first", relay).await.unwrap(); + let mut buf = vec![0u8; 64]; + let n = tokio::time::timeout(Duration::from_secs(2), first.recv(&mut buf)) + .await + .expect("the first flow was not answered") + .unwrap(); + assert_eq!(&buf[..n], b"first"); + + assert_eq!(ask(relay, b"second").await.as_deref(), Some(&b"second"[..])); + assert_eq!(stats.over_limit.load(Ordering::Relaxed), 1); +} + +/// Without forwarding, what does not fit is dropped, and the limit says so. +#[tokio::test] +async fn a_flow_past_the_limit_is_dropped_otherwise() { + let upstream = spawn_echo().await; + let stats = Arc::new(ProxyStats::default()); + let relay = spawn_relay(upstream, "", 1, false, Arc::clone(&stats)).await; + + let first = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + first.send_to(b"first", relay).await.unwrap(); + let mut buf = vec![0u8; 64]; + tokio::time::timeout(Duration::from_secs(2), first.recv(&mut buf)) + .await + .expect("the first flow was not answered") + .unwrap(); + + assert_eq!(ask(relay, b"second").await, None); + assert_eq!(stats.over_limit.load(Ordering::Relaxed), 1); +} + +/// One budget for TCP connections and UDP flows, because they spend the same descriptors. +/// +/// Each relay used to count only its own flows, so a service with several UDP addresses +/// could hold several times its limit beside a full complement of TCP connections. +#[tokio::test] +async fn flows_spend_the_same_budget_as_connections() { + let upstream = spawn_echo().await; + let stats = Arc::new(ProxyStats::default()); + // As if the TCP side of the service were already carrying one connection. + stats.live.store(1, Ordering::Relaxed); + let relay = spawn_relay(upstream, "", 1, false, Arc::clone(&stats)).await; + + assert_eq!(ask(relay, b"no room").await, None, "a flow was admitted past the limit"); + assert_eq!(stats.live.load(Ordering::Relaxed), 1, "a refused flow kept its place"); +} + +/// Flows are counted in the unit the backend divides by: flows seen, flows refused. +/// +/// UDP counted neither, so a service refusing datagrams all day reported no connections +/// and a refused share of zero. +#[tokio::test] +async fn flows_and_refusals_are_counted_once_each() { + let upstream = spawn_echo().await; + let stats = Arc::new(ProxyStats::default()); + let relay = spawn_relay(upstream, "block:DENYME", 0, false, Arc::clone(&stats)).await; + + let client = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let mut buf = vec![0u8; 64]; + client.send_to(b"hello", relay).await.unwrap(); + let n = tokio::time::timeout(Duration::from_secs(2), client.recv(&mut buf)) + .await + .expect("an innocent datagram was not answered") + .unwrap(); + assert_eq!(&buf[..n], b"hello"); + for _ in 0..3 { + client.send_to(b"DENYME please", relay).await.unwrap(); + } + tokio::time::sleep(Duration::from_millis(300)).await; + + assert_eq!(stats.accepted.load(Ordering::Relaxed), 1, "one flow, counted once"); + assert_eq!(stats.live.load(Ordering::Relaxed), 1, "the flow holds one place"); + assert_eq!( + stats.closed_by_filter.load(Ordering::Relaxed), + 1, + "three refused datagrams of one flow are one refused flow" + ); +} diff --git a/backend/routers/services.py b/backend/routers/services.py index 3c61aa6..79aa26d 100644 --- a/backend/routers/services.py +++ b/backend/routers/services.py @@ -10,15 +10,16 @@ to it, in an order the operator chooses, and can be added, reordered, switched o switched on without the service being recreated or a single connection being dropped. """ +import asyncio import base64 import binascii import json import os import secrets import sqlite3 -import subprocess import sys import time +import traceback from fastapi import APIRouter, HTTPException from fastapi.responses import PlainTextResponse @@ -687,8 +688,8 @@ SERVICE_QUERY = """ s.over_limit_last over_limit_last, (s.tls_cert IS NOT NULL AND TRIM(s.tls_cert) != '' AND s.tls_key IS NOT NULL AND TRIM(s.tls_key) != '') has_tls_material, - COUNT(DISTINCT f.filter_id) n_filters, - COALESCE(SUM(DISTINCT f.blocked), 0) n_blocked + COUNT(f.filter_id) n_filters, + COALESCE(SUM(f.blocked), 0) n_blocked FROM services s LEFT JOIN filters f ON s.service_id = f.service_id """ @@ -787,7 +788,7 @@ def _insert_address(service_id: str, proto: str, transport: str, form: AddressFo raise HTTPException(status_code=400, detail=str(e)) edge = _address_edge(proto, form) target_port = _address_target(transport, form) - upstream = _address_upstream(proto, form) + upstream = _address_upstream(proto, edge, form) proxy_ip = _hijack_ip(transport, parsed_ip, form) address_id = gen_id() db.query( @@ -803,7 +804,7 @@ def _insert_address(service_id: str, proto: str, transport: str, form: AddressFo target_port, upstream, proxy_ip, - form.proxy_port, + _hijack_port(transport, form), ) return address_id @@ -822,11 +823,23 @@ def _hijack_ip(transport: str, service_ip: str, form: AddressForm) -> str | None the single thing that index was added to forbid. The return rule tells them apart by address and port, so it could not. """ - if str(transport) != TRANSPORT.EXTERNAL or not form.proxy_port: + if str(transport) != TRANSPORT.EXTERNAL: + return None + if not form.proxy_port: return form.proxy_ip or None return hijack_endpoint(form.proxy_ip, service_ip) +def _hijack_port(transport: str, form: AddressForm) -> int | None: + """The port half of the same endpoint: the hand-off layer's alone. + + Stored for any layer, it was a value read by nothing that still sat in the partial + unique index — so a proxy address that happened to carry one could refuse a real + hand-off its endpoint. + """ + return form.proxy_port if str(transport) == TRANSPORT.EXTERNAL else None + + def _address_edge(proto: str, form: AddressForm) -> str: """What clients speak at one address of a service. @@ -886,9 +899,9 @@ def _address_target(transport: str, form: AddressForm) -> int | None: if str(transport) != TRANSPORT.PROXY: raise HTTPException( status_code=400, - detail=f"Only the proxy layer can send an address's traffic to a port other " - f"than the one it arrived on: it terminates the connection and opens " - f"the one to the service, so it is free to open it elsewhere. " + detail="Only the proxy layer can send an address's traffic to a port other " + "than the one it arrived on: it terminates the connection and opens " + "the one to the service, so it is free to open it elsewhere. " + ("NFQUEUE passes judgement on packets already on their way and opens " "nothing." if str(transport) == TRANSPORT.NFQUEUE else @@ -900,13 +913,27 @@ def _address_target(transport: str, form: AddressForm) -> int | None: return form.target_port -def _address_upstream(proto: str, form: AddressForm) -> str: +def upstream_applies(proto: str, edge: str) -> bool: + """Whether one address has an upstream leg of its own to decide. + + Only where firegex decrypts what arrives: on a cleartext service it terminates + nothing, and on the cleartext address of an HTTPS service the engine carries what + arrived as it arrived — so an answer stored there is read by nothing. It was accepted + there, and the form offered it: "send it to the service over TLS" on a port clients + reach in the clear was saved, shown back as a tag, and ignored by the engine, whose + cleartext path never asks. + """ + if str(proto) not in (L4.TLS, L4.QUIC, L4.HTTP): + return False + return str(edge) != L4.TCP + + +def _address_upstream(proto: str, edge: str, form: AddressForm) -> str: """What the service behind one address speaks. - Only a service that is decrypted has an upstream leg of its own to decide: on - anything else firegex terminates nothing, so there is nothing to put back or leave - off, and a stored answer would be a setting that does nothing. Refused rather than - ignored for that reason. + Only an address whose traffic is decrypted has an upstream leg of its own to decide + (`upstream_applies`). Anywhere else a stored answer would be a setting that does + nothing, and it is refused rather than ignored for that reason. """ asked = str(form.upstream) if form.upstream else UPSTREAM.SAME if asked == UPSTREAM.SAME: @@ -920,6 +947,13 @@ def _address_upstream(proto: str, form: AddressForm) -> str: f"terminates nothing there, so there is no upstream leg to decide. " f"Only a service firegex decrypts has one.", ) + if not upstream_applies(proto, edge): + raise HTTPException( + status_code=400, + detail="This address is reached in the clear, and firegex carries what arrives " + "there as it arrived — there is no decrypted leg for it to hand the " + "service differently. Only an address reached over TLS or HTTP/3 has one.", + ) return asked @@ -1018,10 +1052,11 @@ async def add_service(form: ServiceAddForm): # nobody asked for. for address in form.addresses: _address_target(form.transport, address) + edge = _address_edge(form.proto, address) refusal = upstream_refusal( form.proto, - _address_upstream(form.proto, address), - L4.l4_of(_address_edge(form.proto, address)) == L4.UDP, + _address_upstream(form.proto, edge, address), + L4.l4_of(edge) == L4.UDP, ) if refusal: raise HTTPException(status_code=400, detail=refusal) @@ -1094,6 +1129,63 @@ async def add_service(form: ServiceAddForm): return {"status": "ok", "service_id": service_id} +def _shown(ip_int: str) -> str: + """An address as an operator typed it: the host-length prefix it is stored with is + dropped, a real range keeps its own.""" + addr, _, prefix = str(ip_int).partition("/") + if prefix in ("32", "128"): + return addr + return str(ip_int) + + +def _address_under(address: dict, proto: str) -> dict: + """What one address becomes when its service changes protocol. + + The addresses carry the transport the kernel matches on, so that `(ip, port, proto)` + can be unique, and what is spoken at them; both follow the service rather than being + a second thing the operator sets. `http` is the one protocol whose addresses keep + their own answer — rewriting them all to `tcp` would quietly turn an HTTP/3 address + into a TCP one — except that an address reached over plain UDP is, on an HTTPS + service, its HTTP/3 edge. Switching *away* from `http` flattens them, which is honest: + the service now speaks one thing, and a collision that follows from it is refused. + + An upstream choice survives only where the address still has a decrypted leg for it + to apply to (`upstream_applies`); anywhere else it would be a stored answer read by + nothing. + """ + if str(proto) == L4.HTTP: + edge = str(address["edge"]) + if edge not in (L4.TCP, L4.TLS, L4.QUIC): + edge = L4.QUIC if L4.l4_of(edge) == L4.UDP else L4.TCP + else: + edge = str(proto) + upstream = str(address["upstream"] or UPSTREAM.SAME) + if not upstream_applies(proto, edge): + upstream = UPSTREAM.SAME + return {**address, "edge": edge, "proto": L4.l4_of(edge), "upstream": upstream} + + +async def _filters_needing_a_stream(service_id: str) -> list[str]: + """The Python filters of a service that could not run on datagrams, by name. + + Asked of the checker, which reports the models a file asks for: the protocol stored + on the row says only whether a file parses HTTP, and a file asking for a TCP stream is + stored as `tcp` and would sit on a UDP service never being called. + """ + names = [] + for row in db.query( + "SELECT filter_id, name FROM filters WHERE service_id = ? AND kind = ?;", + service_id, KIND.PYFILTER, + ): + code = read_code(row["filter_id"]) + if not code.strip(): + continue + checked = await validate_code(code) + if checked.get("ok") and needs_a_stream(checked.get("models", [])): + names.append(row["name"]) + return names + + @app.put("/{service_id}", response_model=StatusMessageModel) async def edit_service(service_id: str, form: ServiceSettingsForm): """Change a service's definition. @@ -1102,6 +1194,16 @@ async def edit_service(service_id: str, form: ServiceSettingsForm): service is stopped and started around it. That is a visible interruption, which is why filters and addresses are edited through their own endpoints instead — those cost at most the connections on the address that changed. + + **A refused edit changes nothing**, and three things make that true. The service it + would become is checked before anything is written — the layer against the protocol + and the chain, the way a start would check it, whether or not the service is running; + this used to happen only by starting it, so a stopped service took any combination + and failed on its next start instead. It is written in one transaction, the service + and its addresses together: a protocol change that collided with another service's + address used to leave the service rewritten and its addresses not. And a running + service whose new definition will not start is put back, and started again as it was, + instead of being left stopped on the definition that failed. """ row = _service_or_404(service_id) fields = {} @@ -1154,46 +1256,97 @@ async def edit_service(service_id: str, form: ServiceSettingsForm): fields["tls_key"] = form.tls_key if not fields: return {"status": "ok"} - try: - db.query( - f"UPDATE services SET {', '.join(f'{k} = ?' for k in fields)} WHERE service_id = ?;", - *fields.values(), - service_id, - ) - except sqlite3.IntegrityError: - raise HTTPException(status_code=400, detail="That name is already taken") - if "proto" in fields and fields["proto"] != L4.HTTP: - # The addresses carry the transport the kernel matches on, so that - # `(ip, port, proto)` can be unique. Kept in step here rather than being a second - # thing the operator sets — and derived, because `tls` is TCP on the wire. - # - # Not for `http`, which is the one protocol whose addresses already hold the - # answer: rewriting them all to `tcp` would quietly turn its HTTP/3 address into a - # TCP one and leave the QUIC relay pointing at nothing. Switching *away* from - # `http` does flatten them, which is honest — the service now speaks one - # transport — and can legitimately collide, which is the 400 below. - try: - db.query( - # Both, because they are one answer: a service that now speaks one thing - # everywhere has addresses that speak it too, and an `edge` left behind - # would be a row claiming to be reached a way the service no longer is. - "UPDATE service_addresses SET proto = ?, edge = ? WHERE service_id = ?;", - L4.l4_of(fields["proto"]), - fields["proto"], - service_id, - ) - except sqlite3.IntegrityError: + + proto = str(fields.get("proto", row["proto"])) + transport = str(fields.get("transport", row["transport"])) + old_addresses = _addresses(service_id) + new_addresses = [_address_under(address, proto) for address in old_addresses] + + # What the addresses say has to mean something on the layer they land on. A port the + # service is published on takes a layer that dials; on the others it would be read by + # nothing, and the address would look published while being merely intercepted. + if transport != TRANSPORT.PROXY: + published = [a for a in new_addresses if a["target_port"]] + if published: raise HTTPException( status_code=400, - detail="Another service already protects one of these addresses on " - f"{fields['proto']}", + detail="Only the proxy layer can send an address's traffic to a port other " + "than the one it arrived on, and " + + ", ".join(f"{_shown(a['ip_int'])}:{a['port']}" for a in published) + + " is published on another port. Take that off the address first.", ) + for address in new_addresses: + refusal = upstream_refusal(proto, address["upstream"], + L4.l4_of(address["edge"]) == L4.UDP) + if refusal: + raise HTTPException(status_code=400, detail=refusal) - srv = load_service(service_id) + # The service it would become, asked what a start would ask of the layer and the + # chain. Without its addresses, the way a service is checked when it is created: what + # an address still lacks on the new layer — the port of the operator's own proxy, say + # — is filled in on the address afterwards, and a start says so if it is not. + manager = firewall.get(service_id) + candidate = Service.from_dict({**row, **fields}) try: - await firewall.get(service_id).refresh(srv) + transports.build_class(transport).check(candidate, manager.chain()) except UnsupportedChain as e: raise HTTPException(status_code=400, detail=str(e)) + if proto == L4.UDP: + needing = await _filters_needing_a_stream(service_id) + if needing: + raise HTTPException( + status_code=400, + detail=f"{', '.join(needing)} {'ask' if len(needing) > 1 else 'asks'} for a " + f"stream — an assembled TCP stream or a parsed HTTP message — and a " + f"UDP service carries datagrams, so on it the library would never " + f"call {'them' if len(needing) > 1 else 'it'}. Rewrite them against " + f"RawPacket, or keep the service on a stream.", + ) + + def rewrite(values: dict, addresses: list[dict]) -> list[tuple]: + queries = [( + f"UPDATE services SET {', '.join(f'{k} = ?' for k in values)} WHERE service_id = ?;", + *values.values(), + service_id, + )] + for address in addresses: + queries.append(( + "UPDATE service_addresses SET proto = ?, edge = ?, upstream = ? " + "WHERE address_id = ?;", + address["proto"], address["edge"], address["upstream"], address["address_id"], + )) + return queries + + # All of it or none: a protocol change that collided with another service's address + # used to leave the service rewritten and its addresses as they were. + try: + db.queries(rewrite(fields, new_addresses)) + except sqlite3.IntegrityError as e: + if "services.name" in str(e): + raise HTTPException(status_code=400, detail="That name is already taken") + raise HTTPException( + status_code=400, + detail=f"Another service already protects one of these addresses on {proto}", + ) + + was_active = manager.active + try: + await manager.refresh(load_service(service_id)) + except Exception as e: + # The new definition would not start. Put the old one back, and the service with + # it, rather than leaving it stopped on the definition that failed — which is what + # happened, with a 500, to a service handed a key its engine would not sign with. + db.queries(rewrite({k: row[k] for k in fields}, old_addresses)) + manager.srv = load_service(service_id) + if was_active: + try: + await manager.enable() + except Exception: + traceback.print_exc() + raise HTTPException( + status_code=400 if isinstance(e, UnsupportedChain) else 500, + detail=f"The service would not start with that, so nothing was changed: {e}", + ) await refresh_frontend() return {"status": "ok"} @@ -1233,10 +1386,11 @@ async def add_address(service_id: str, form: AddressForm): # Before the row exists, because this is the address's own answer and it can be one # this instance cannot honour. `ProxyTransport.check` says the same thing at start, # and would leave the address behind to be undone. + edge = _address_edge(row["proto"], form) refusal = upstream_refusal( row["proto"], - _address_upstream(row["proto"], form), - L4.l4_of(_address_edge(row["proto"], form)) == L4.UDP, + _address_upstream(row["proto"], edge, form), + L4.l4_of(edge) == L4.UDP, ) if refusal: raise HTTPException(status_code=400, detail=refusal) @@ -1297,6 +1451,7 @@ async def edit_address(service_id: str, address_id: str, form: AddressForm): # a move sending only an address and a port would otherwise quietly unpublish it. # Taking one off is a value, not an absence: `0` for the port, `same` for the # upstream — which is what the interface sends when it is emptied. + upstream_mentioned = form.upstream is not None if form.edge is None: form.edge = was["edge"] if form.target_port is None: @@ -1307,8 +1462,13 @@ async def edit_address(service_id: str, address_id: str, form: AddressForm): # Everything the row says is worked out *before* the old rules come back, so a # refusal leaves an address that is still steered exactly as it was. edge = _address_edge(row["proto"], form) + if not upstream_mentioned and not upstream_applies(row["proto"], edge): + # Moved to the clear without a word about the upstream: what the row had was an + # answer for the decrypted leg this address no longer has, and keeping it would + # refuse the very edit that made it meaningless. + form.upstream = UPSTREAM.SAME target_port = _address_target(row["transport"], form) - upstream = _address_upstream(row["proto"], form) + upstream = _address_upstream(row["proto"], edge, form) refusal = upstream_refusal(row["proto"], upstream, L4.l4_of(edge) == L4.UDP) if refusal: raise HTTPException(status_code=400, detail=refusal) @@ -1346,7 +1506,7 @@ async def edit_address(service_id: str, address_id: str, form: AddressForm): await manager.address_removed(address_id) try: write(parsed_ip, form.port, L4.l4_of(edge), edge, target_port, upstream, - proxy_ip, form.proxy_port) + proxy_ip, _hijack_port(row["transport"], form)) except sqlite3.IntegrityError as e: await restore() raise HTTPException(status_code=400, detail=_address_taken(e)) @@ -1612,7 +1772,7 @@ async def check_filter_code(service_id: str, filter_id: str, form: CodeForm): row = _filter_or_404(service_id, filter_id) if row["kind"] != KIND.PYFILTER: raise HTTPException(status_code=400, detail="This filter is not a pyfilter") - return validate_code(form.code) + return await validate_code(form.code) @app.get("/{service_id}/filters/{filter_id}/code", response_class=PlainTextResponse) @@ -1643,7 +1803,7 @@ async def set_filter_code(service_id: str, filter_id: str, form: CodeForm): # Refused here, with the reason and the line, rather than by the datapath a moment # later with "the filter code did not load: worker exited" — which is true, useless, # and leaves the operator reading a traceback out of the service log. - checked = validate_code(form.code) + checked = await validate_code(form.code) if not checked.get("ok"): raise HTTPException(status_code=400, detail=describe_error(checked["error"])) service = _service_or_404(service_id) @@ -1664,20 +1824,23 @@ async def set_filter_code(service_id: str, filter_id: str, form: CodeForm): proto = checked.get("proto", PROTO.TCP) was = read_code(filter_id) was_functions = db.query( - "SELECT name, active, blocked FROM pyfilters WHERE filter_id = ?;", filter_id + "SELECT name, active, blocked, position FROM pyfilters WHERE filter_id = ?;", filter_id ) write_code(filter_id, form.code) db.query("UPDATE filters SET proto = ? WHERE filter_id = ?;", proto, filter_id) - _reconcile_functions(filter_id, form.code) + _reconcile_functions(filter_id, checked.get("filters") or []) def undo(): write_code(filter_id, was) db.query("UPDATE filters SET proto = ? WHERE filter_id = ?;", row["proto"], filter_id) db.query("DELETE FROM pyfilters WHERE filter_id = ?;", filter_id) for fn in was_functions: + # With its place in the file, or a refused edit would hand back the list in + # an order the file does not have. db.query( - "INSERT INTO pyfilters (filter_id, name, active, blocked) VALUES (?, ?, ?, ?);", - filter_id, fn["name"], fn["active"], fn["blocked"], + "INSERT INTO pyfilters (filter_id, name, active, blocked, position) " + "VALUES (?, ?, ?, ?, ?);", + filter_id, fn["name"], fn["active"], fn["blocked"], fn["position"], ) await _apply_chain(service_id, undo) @@ -1685,7 +1848,7 @@ async def set_filter_code(service_id: str, filter_id: str, form: CodeForm): return {"status": "ok"} -def _reconcile_functions(filter_id: str, code: str) -> None: +def _reconcile_functions(filter_id: str, defined: list[str]) -> None: """Line the stored function list up with what the code now defines. The code decides which functions exist; the operator decides which of them run. So a @@ -1693,16 +1856,12 @@ def _reconcile_functions(filter_id: str, code: str) -> None: switched on, and one that is still there keeps whatever the operator had set — an edit elsewhere in the file must not silently switch a function back on. - A file that will not load leaves the list alone rather than emptying it: the - datapath refuses the ruleset with the real error a moment later, and wiping the - operator's choices on the way to that would be a second failure. + `defined` is what `pyworker.py --check` found, in definition order: the file is not + run again here to ask. It used to be — `exec`ed a second time inside the backend's + own event loop, after the checker had already answered the same question in a process + of its own, which is exactly the place the checker exists to keep the operator's + module body out of. """ - from firegex.pyfilters.internals import get_filter_names - - try: - defined = get_filter_names(code) if code.strip() else [] - except Exception: - return existing = { row["name"] for row in db.query( "SELECT name FROM pyfilters WHERE filter_id = ?;", filter_id @@ -1730,7 +1889,36 @@ def _reconcile_functions(filter_id: str, code: str) -> None: CHECK_TIMEOUT = 8 -def validate_code(code: str) -> dict: +async def _run_helper(argv: list[str], stdin: bytes | None, + timeout: float) -> tuple[int, bytes, bytes] | None: + """Run one of the helper processes without holding up everything else. + + The checker and the regex tester are separate processes on purpose, and they were + waited for with `subprocess.run` from inside `async` endpoints — which blocks the one + event loop the whole backend runs on. The editor asks for a check 700 ms after every + pause in typing, and a check is a Python start and an import at the very least, up to + `CHECK_TIMEOUT` for slow module code: for that long no acknowledgement from a datapath + was read (so a ruleset push or a service start could time out), and no `BLOCKED` line + either, until the engine's pipe filled and its writes stalled the traffic itself. + + `None` when it ran out of time, and it is killed rather than left behind. + """ + proc = await asyncio.create_subprocess_exec( + *argv, + stdin=asyncio.subprocess.PIPE if stdin is not None else asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(stdin), timeout) + except (asyncio.TimeoutError, TimeoutError): + proc.kill() + await proc.wait() + return None + return proc.returncode, stdout, stderr + + +async def validate_code(code: str) -> dict: """Would this filter load, and if not, exactly where does it go wrong? Asked of `pyworker.py --check`, which is the process that will run the filter — the @@ -1741,7 +1929,7 @@ def validate_code(code: str) -> dict: In a subprocess, and not only for the answer: the module body of a filter is arbitrary code, and this used to be `exec`ed inside the backend's own event loop. A `while True:` at module level would have taken the whole interface down with it. - Here it costs a timeout. + Here it costs a timeout — the check's own, not the backend's (see `_run_helper`). """ import tempfile @@ -1749,12 +1937,14 @@ def validate_code(code: str) -> dict: f.write(code) probe = f.name try: - proc = subprocess.run( - [sys.executable or "python3", PYWORKER, "--check", probe], - capture_output=True, - timeout=CHECK_TIMEOUT, - ) - except subprocess.TimeoutExpired: + ran = await _run_helper([sys.executable or "python3", PYWORKER, "--check", probe], + None, CHECK_TIMEOUT) + finally: + try: + os.remove(probe) + except OSError: + pass + if ran is None: return { "ok": False, "error": { @@ -1765,16 +1955,12 @@ def validate_code(code: str) -> dict: "traceback": "", }, } - finally: - try: - os.remove(probe) - except OSError: - pass + _, stdout, stderr = ran try: - return json.loads(proc.stdout.decode()) + return json.loads(stdout.decode()) except (ValueError, UnicodeDecodeError): # The checker itself failed, which is ours to explain rather than theirs. - detail = proc.stderr.decode(errors="replace").strip() or "the check produced no answer" + detail = stderr.decode(errors="replace").strip() or "the check produced no answer" return {"ok": False, "error": {"type": "CheckFailed", "message": detail, "line": 0, "column": 0, "text": "", "traceback": ""}} @@ -1898,7 +2084,7 @@ async def add_regex(service_id: str, filter_id: str, form: RegexAddForm): # Checked by the engine that will run it, not by a stand-in. A pattern accepted # here and refused at start time would leave the operator with a service that # will not come up and no idea which rule is at fault. - ok, why = check_pattern(pattern, form.case_sensitive) + ok, why = await check_pattern(pattern, form.case_sensitive) if not ok: raise HTTPException(status_code=400, detail=f"Invalid pattern: {why}") try: @@ -1953,7 +2139,7 @@ async def edit_regex(service_id: str, filter_id: str, regex_id: str, form: Regex # Whatever the row will hold once this is applied, checked as a whole rather than # field by field: what the engine has to accept is the resulting pattern. pattern = _decoded_pattern(fields.get("regex", was["regex"])) - ok, why = check_pattern(pattern, fields.get("case_sensitive", was["case_sensitive"])) + ok, why = await check_pattern(pattern, fields.get("case_sensitive", was["case_sensitive"])) if not ok: raise HTTPException(status_code=400, detail=f"Invalid pattern: {why}") @@ -2176,26 +2362,23 @@ async def clear_logs(service_id: str): # --- the debugger ------------------------------------------------------------ -def _ask_engine(request: dict) -> dict: +async def _ask_engine(request: dict) -> dict: """Put the question to the binary that would enforce the answer.""" try: - proc = subprocess.run( - [PROXY_ENGINE, "--debug-regex"], - input=json.dumps(request).encode(), - capture_output=True, - timeout=10, - ) + ran = await _run_helper([PROXY_ENGINE, "--debug-regex"], + json.dumps(request).encode(), 10) except FileNotFoundError: raise HTTPException(status_code=500, detail="The matching engine is not installed") - except subprocess.TimeoutExpired: + if ran is None: raise HTTPException(status_code=400, detail="The pattern took too long to evaluate") - if proc.returncode != 0: - detail = proc.stderr.decode(errors="replace").strip() or "the engine refused the request" + returncode, stdout, stderr = ran + if returncode != 0: + detail = stderr.decode(errors="replace").strip() or "the engine refused the request" raise HTTPException(status_code=400, detail=detail) - return json.loads(proc.stdout.decode()) + return json.loads(stdout.decode()) -def check_pattern(pattern: str, case_sensitive: bool = True) -> tuple[bool, str]: +async def check_pattern(pattern: str, case_sensitive: bool = True) -> tuple[bool, str]: """Would the engine accept this pattern? Asked of the engine that will run it rather than of a stand-in, and compiled for @@ -2210,7 +2393,7 @@ def check_pattern(pattern: str, case_sensitive: bool = True) -> tuple[bool, str] a pattern that was perfectly fine. """ try: - res = _ask_engine( + res = await _ask_engine( { "patterns": [ { @@ -2240,7 +2423,7 @@ async def debug_regexes(form: DebugForm): be saved and disagree about what matches — a tester the operator would learn not to trust, which is worse than none. """ - res = _ask_engine( + res = await _ask_engine( { "patterns": [ { diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py index edac6ca..91117da 100644 --- a/backend/utils/__init__.py +++ b/backend/utils/__init__.py @@ -123,6 +123,22 @@ SAFE_DB_NAME = re.compile(r'[A-Za-z0-9_-]+\.db') SAFE_PY_NAME = re.compile(r'[A-Za-z0-9_-]+\.py') +def boot_auth_mode(held: str | None, fresh_boot: bool, env_disabled: bool) -> tuple[bool, bool]: + """Whether authentication starts off disabled, and whether the host's stored decision + is kept — `(disabled, keep_held)`. + + `held` is what `run.py config` last stored for the host (`"1"`, `"0"` or `None`). A + fresh boot — a container or a standalone start `run.py` has just written the + environment for — takes the environment, which already says what the configuration + says, and the stored decision is dropped. Any other boot is Docker starting an existing + container again with the environment it was created with, which may be older than the + stored decision, and the stored decision wins. + """ + if fresh_boot or held is None: + return env_disabled, False + return held == "1", True + + def safe_join(base_dir: Union[str, Path], *paths: str) -> Path: """ Safely join a base directory with one or more path components. diff --git a/backend/utils/sqlite.py b/backend/utils/sqlite.py index 1f9d680..a07ff12 100644 --- a/backend/utils/sqlite.py +++ b/backend/utils/sqlite.py @@ -130,13 +130,15 @@ class SQLite(): for table in tables: tname = table["name"] if tname == 'keys_values': - # Three keys never travel in a backup. `password` and `secret` are this - # instance's credentials, and `auth_disabled` is *whether it asks for - # them* — a property of where this firegex is deployed, not of the - # configuration being backed up. A backup that could carry it would be a - # backup that can turn a firewall's authentication off on restore. + # Four keys never travel in a backup. `password` and `secret` are this + # instance's credentials, and `auth_disabled` with `auth_disabled_host` + # are *whether it asks for them* — a property of where this firegex is + # deployed, not of the configuration being backed up. A backup that could + # carry them would be a backup that can turn a firewall's authentication + # off on restore. res[tname] = self.query( - f"SELECT * FROM {tname} WHERE key NOT IN ('password', 'secret', 'auth_disabled');" + f"SELECT * FROM {tname} WHERE key NOT IN " + f"('password', 'secret', 'auth_disabled', 'auth_disabled_host');" ) else: res[tname] = self.query(f"SELECT * FROM {tname};") diff --git a/docs/firewall.md b/docs/firewall.md index 684f5ed..78573f6 100644 --- a/docs/firewall.md +++ b/docs/firewall.md @@ -27,8 +27,11 @@ Beyond the rule list and policy, a few toggles affect the whole module: - **allow_dhcp**: always accept DHCP traffic. - **multicast_dns**: always accept mDNS (multicast DNS) traffic. - **allow_upnp**: always accept UPnP traffic. +- **allow_dnat** (on by default): forwarded traffic whose destination another NAT rewrote — a port **Docker or podman published** for a container, a router's port forward — is left to the rules that published it when none of *your* rules matched it, instead of meeting the forward policy. Your `forward` rules still apply to it first, so a rule that drops it drops it. -Each of these, when enabled, inserts a small accept rule ahead of your own rules — they're conveniences for common cases you'd otherwise have to write by hand. +Each of the others, when enabled, inserts a small accept rule ahead of your own rules — they're conveniences for common cases you'd otherwise have to write by hand. **allow_dnat** is the exception: it sits after your rules, because it only decides what the *policy* does with what they did not match. + +Why it exists: a published container port is reached by *forwarding* — Docker rewrites the destination to the container's address, and the traffic is routed to it rather than delivered to this host — so an `in` rule for that port never matches it, and with the policy at `drop` it would be dropped at the forward hook. When Firegex's rules lived in the tables `iptables` uses, Docker's own accept in that same chain let such traffic through; in tables of Firegex's own it has to be said. Turn it off to have the forward policy apply to published ports too, and allow them with `forward` rules. ## How it works diff --git a/docs/pyfilter.md b/docs/pyfilter.md index 72cc09c..6454793 100644 --- a/docs/pyfilter.md +++ b/docs/pyfilter.md @@ -12,7 +12,7 @@ per-stream history, and decides in code. 2. Attach a **Python filter** to it and write or upload the code below. One file can define as many `@pyfilter` functions as you like. 3. From then on every matching packet reaches your filters, which return an action - (accept / drop / reject / mangle). + (accept / drop / reject). Code can be edited while the service is running: it takes effect on the next packet, and no connection is dropped. On the proxy layer the filter runs in a process of its own, so code @@ -53,9 +53,7 @@ Filter names (the function name) must be unique within a filter file. **They run in the order the file defines them**, top to bottom, and the first one to return anything but `ACCEPT` ends the packet — so a filter placed above another decides -before it, and the interface lists them in that same order. (`UNSTABLE_MANGLE` is the -exception: the rewrite is remembered and the walk carries on, because a filter below may -still want to refuse the rewritten bytes.) +before it, and the interface lists them in that same order. Each TCP stream (i.e. each connection) gets its own isolated set of global variables: code at module level runs once per stream, and the same globals are reused across every packet of that stream. Don't store state in another module's globals — that memory is shared across every stream handled by the same thread and will cause data to leak/interfere between unrelated connections. Global variable names starting with `__firegex` are reserved for internal use. @@ -79,7 +77,12 @@ A filter must return one of these values (importable from `firegex.pyfilters`): |---|---| | `ACCEPT` | The packet is accepted and forwarded to the destination. This is also the default behavior if `None` is returned. | | `REJECT` | The connection is closed and every packet in the stream is dropped. | -| `DROP` | This packet, and every subsequent packet in the stream, is silently dropped (unlike `REJECT`, this doesn't simulate a connection closure). | +| `DROP` | On NFQUEUE, this packet and every subsequent packet in the stream are silently dropped (unlike `REJECT`, this doesn't simulate a connection closure). On the proxy layer a stream cannot skip bytes, so `DROP` closes the connection exactly as `REJECT` does. | + +There is no statement that rewrites the traffic. There used to be one, `UNSTABLE_MANGLE`, +and it was removed for the reason regex rewriting was: a filter only ever sees one chunk, +so a pattern split across two of them was never rewritten, and nothing said so. A filter +that should keep something from leaving refuses the connection carrying it. ### Data structures @@ -90,8 +93,8 @@ These are the types you can use as filter parameter annotations. Some need a con Firegex's code editor is not a plain text box: - **Completion** offers the models, the verdicts and the module-level settings, and — once - a parameter is annotated — the members of whatever it was annotated with. Each one says - whether it is **read-only or writable**, which is the boundary the whole API rests on. + a parameter is annotated — the members of whatever it was annotated with. Everything a + filter is shown is read-only: it answers with a verdict and changes nothing. - **Hover** any model, member or verdict for what it is and what it does. - **The file is checked as you type**, by the process that will actually run it. A file that will not load is marked **on the line it fails on**, with the reason and the @@ -118,9 +121,9 @@ defines, keeping your choices for the functions that are still there. #### What a filter can see, and what it can change -Everything below the application layer is **metadata, and read-only**: the addresses, the ports, the address family, which way the chunk is going. There is no way to read an IP or TCP header as bytes and no way to write one. The payload is the only thing a filter can change. +Everything below the application layer is **metadata, and read-only**: the addresses, the ports, the address family, which way the chunk is going. There is no way to read an IP or TCP header as bytes and no way to write one. The payload is read-only too: a filter answers with a verdict, and nothing it assigns reaches either end. -That is deliberate. The two network layers cannot honestly offer the same thing down there: on NFQUEUE a real header exists and rewriting it desynchronises the connection in ways that surface minutes later somewhere else, while the proxy terminated the connection and reopened it, so the headers on the wire are firegex's own and a filter editing them would be editing nothing. An earlier version papered over that by handing the proxy a literal `FAKE:IP:TCP:HEADERS:` prefix — so the same filter did different things depending on which layer it happened to be attached to. Metadata in, payload out is the one contract both layers keep. +That is deliberate. The two network layers cannot honestly offer the same thing down there: on NFQUEUE a real header exists and rewriting it desynchronises the connection in ways that surface minutes later somewhere else, while the proxy terminated the connection and reopened it, so the headers on the wire are firegex's own and a filter editing them would be editing nothing. An earlier version papered over that by handing the proxy a literal `FAKE:IP:TCP:HEADERS:` prefix — so the same filter did different things depending on which layer it happened to be attached to. Metadata in, a verdict out is the one contract both layers keep. #### Which models work on UDP @@ -159,9 +162,6 @@ before the chain sees it, so `HttpRequest` and the rest mean what they always me that rendering does and does not promise. The question a model asks is therefore "is there a stream", not "is this TCP", and `RawPacket` answers both separately. -Rewriting a datagram is **exact**, on both layers: there are no sequence numbers for a -different length to desynchronise. - `REJECT` means something narrower here. There is no connection to close, so the datagram is simply not delivered and the next one from the same flow is judged afresh. @@ -171,7 +171,7 @@ is simply not delivered and the next one from the same flow is judged afresh. from firegex.pyfilters.models import RawPacket ``` -One chunk of the connection, and what is known about it. The **only** data structure that can be mutated. +One chunk of the connection, and what is known about it. - `data: bytes` — the application payload. Read-only. - `data_size: int` — how many bytes of payload this chunk carries. @@ -188,7 +188,6 @@ One chunk of the connection, and what is known about it. The **only** data struc so one file can carry both. - `src_ip: str`, `dst_ip: str`, `src_port: int`, `dst_port: int` — where this chunk came from and where it is going (read-only). - `client_ip`, `client_port`, `server_ip`, `server_port` — the same two endpoints named by role instead of by direction, so a filter does not have to branch on `is_input` to find out who the client is (read-only). -- `was_mangled: bool` — whether an earlier filter in the chain already rewrote this chunk. @@ -443,12 +442,11 @@ There is no protocol to pass: the file says which one it speaks by what its filt from firegex.pyfilters.models import RawPacket, HttpRequest, HttpHistory from firegex.pyfilters import pyfilter, ACCEPT, REJECT, DROP, FullStreamAction -# Lowest level of abstraction: only RawPacket can be mangled. +# Lowest level of abstraction: the raw payload of each chunk. @pyfilter -def mangle_example(packet: RawPacket): - if b"TEST_MANGLING" in packet.data: - packet.data = packet.data.replace(b"TEST", b"UNSTABLE") - return UNSTABLE_MANGLE +def raw_example(packet: RawPacket): + if not packet.is_input and b"FLAG{" in packet.data: + return REJECT if b"BAD DATA" in packet.data: return DROP return ACCEPT diff --git a/docs/services.md b/docs/services.md index 3d294dc..88c5492 100644 --- a/docs/services.md +++ b/docs/services.md @@ -40,7 +40,7 @@ the service being configured is offered: |---|---|---| | **What clients speak here** — clear · TLS · HTTP/3 | HTTPS services, on the row itself | Not an extra: it is what the address *is* | | **The service is on another port** | the Proxy layer | Clients arrive at this address; firegex dials the service on that port instead | -| **Send to the service** — what arrived · plaintext · TLS | any service firegex decrypts | What firegex speaks to the service, which need not be what the client spoke. *Plaintext* is what puts an ordinary cleartext site behind HTTPS, HTTP/2 and HTTP/3 with only firegex holding a certificate | +| **Send to the service** — what arrived · plaintext · TLS | an address whose traffic firegex decrypts: any address of a TLS or QUIC service, and the TLS and HTTP/3 addresses of an HTTPS one | What firegex speaks to the service, which need not be what the client spoke. *Plaintext* is what puts an ordinary cleartext site behind HTTPS, HTTP/2 and HTTP/3 with only firegex holding a certificate. Not on a cleartext address: firegex carries what arrives there as it arrived, so there is no decrypted leg to hand over differently, and the choice is refused rather than stored | | **Hand it to your proxy at** | the hand-off layer | Where your own proxy is listening for this address | What is set is repeated back under the address as a **tag**, and clicking a tag opens the @@ -239,10 +239,10 @@ Nothing is terminated. which caps bulk throughput. - **Reassembly in userspace.** Done via libtins, with memory caps and timeout heuristics. - **One process per filter.** Chaining requires multiple processes and netfilter queues. -- **A Python filter's rewriting is unstable on TCP here.** Changing a payload's length - desynchronises the stream, which is what `UNSTABLE_MANGLE` is named after. On **UDP** - it is exact, because a datagram carries no sequence numbers to desynchronise. - Patterns do not rewrite on either layer. +- **Nothing rewrites traffic, on either layer.** A Python filter answers with a verdict, + and patterns block; `UNSTABLE_MANGLE` is gone, because a chunk at a time could never + rewrite a pattern split across two of them — see the [Python filter + documentation](pyfilter.md#packet-statements). ### UDP on the proxy layer @@ -288,6 +288,12 @@ hundred of them from a single host were enough to exhaust firegex entirely. No d to be sent, and every other service on the instance went down with the one being attacked. +The limit is **one number for everything the service carries**: its TCP connections, its +QUIC connections and its UDP flows are counted together, because they spend the same +descriptors. A UDP flow — one client address talking to one address of the service — +holds its place until it has been quiet for a minute, since a datagram has no close to +wait for. + A limit does **not** save the service under attack. A cap 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 does is contain the damage to that @@ -299,7 +305,8 @@ What happens past the limit is yours to choose: - **Refuse** (the default). Nothing reaches the service that was not inspected, and clients are turned away while the limit holds. - **Forward unfiltered.** The service stays reachable past the limit, and that traffic - gets there with nothing having looked at it. This is the same trade as fail-open, made + gets there with nothing having looked at it. On UDP that is a flow of its own, answers + included, carried with no filter in front of it for as long as it lasts. This is the same trade as fail-open, made at a different moment, and it is off by default for the same reason: a filter that quietly stops filtering is the worse surprise. @@ -379,7 +386,9 @@ refused there and then. **It is asked of each address**, under its ⚙ button, and not of the service: what leaves towards the service is a property of the way in. One daemon reached over TLS on one port and in the clear on another is re-encrypted for the first and handed the plaintext for the -second. +second. It is only asked where firegex decrypts: the cleartext address of an HTTPS service +is carried as it arrived, so moving an address to the clear takes its answer back to *as +it arrives*. | | What firegex does | |---|---| diff --git a/fgex-lib/firegex/pyfilters/internals/__init__.py b/fgex-lib/firegex/pyfilters/internals/__init__.py index 96e6525..5978bca 100644 --- a/fgex-lib/firegex/pyfilters/internals/__init__.py +++ b/fgex-lib/firegex/pyfilters/internals/__init__.py @@ -265,11 +265,24 @@ def compile(glob: dict) -> None: glob["print"] = functools.partial(print, flush=True) + # Which functions run: `None` for every one the file defines, a list for exactly + # those of them. A name the file no longer defines is passed over rather than + # refusing the whole file — a switched-off function deleted from the code afterwards + # would otherwise stop every other filter in it from loading. Worked out here, in the + # process that has just run the module, so the caller never has to run it to ask. + from firegex.pyfilters import collect_pyfilters + defined = collect_pyfilters(glob) + enabled = glob.get("__firegex_pyfilter_enabled") + if enabled is None: + names = defined + else: + wanted = set(enabled) + names = [name for name in defined if name in wanted] # Absent means "read it off the code", which is the normal case: the file shows # which protocol it speaks by what its filters ask for, so nothing has to be kept # in step with it by hand. internal_data.filter_call_info = generate_filter_structure( - glob["__firegex_pyfilter_enabled"], glob.get("__firegex_proto"), glob + names, glob.get("__firegex_proto"), glob ) for name, (attribute, kind) in _SETTINGS.items(): diff --git a/fgex-lib/firegex/pyfilters/internals/data.py b/fgex-lib/firegex/pyfilters/internals/data.py index e627b04..419ee9d 100644 --- a/fgex-lib/firegex/pyfilters/internals/data.py +++ b/fgex-lib/firegex/pyfilters/internals/data.py @@ -134,13 +134,10 @@ class RawPacket: @property def data(self) -> bytes: - """The application payload: the only part a filter can change.""" + """The application payload, as it arrived. Read-only: a filter answers with a + verdict, and nothing it writes here would reach the other end.""" return self.__data - @data.setter - def data(self, value: bytes): - self.__data = bytes(value) - @property def data_size(self) -> int: "How many bytes of application payload this chunk carries" diff --git a/fgex-lib/firegex/pyfilters/proxysim/__init__.py b/fgex-lib/firegex/pyfilters/proxysim/__init__.py index 3e94d48..ea8abde 100644 --- a/fgex-lib/firegex/pyfilters/proxysim/__init__.py +++ b/fgex-lib/firegex/pyfilters/proxysim/__init__.py @@ -134,7 +134,7 @@ async def _forward_and_filter(filter_ctx: dict, continue if action == DROP.value: - log_print("drop-action", "Dropping connection caused by {escape(filter_name)} pyfilter") + log_print("drop-action", f"Dropping connection caused by {escape(filter_name)} pyfilter") has_to_drop = True continue diff --git a/fgex-lib/llhttp/llhttp_module.c b/fgex-lib/llhttp/llhttp_module.c index c7c8b84..698d25c 100644 --- a/fgex-lib/llhttp/llhttp_module.c +++ b/fgex-lib/llhttp/llhttp_module.c @@ -57,11 +57,29 @@ static void error_class_name(const char *snake, char *out, size_t out_len) { out[written] = '\0'; } -static const char* methods[] = { - #define HTTP_METHOD_GEN(NUMBER, NAME, STRING) #STRING, - HTTP_METHOD_MAP(HTTP_METHOD_GEN) +/* + * The name of a method, by the number llhttp gives it, or NULL for a number it never + * produces. + * + * A switch over `HTTP_ALL_METHOD_MAP` with the numbers as the case labels. This used to + * be an array built from `HTTP_METHOD_MAP` and indexed with the method's number — but + * that map is not the numbers in order: it runs 0 to 33 and then jumps to QUERY (46), + * and PRI (34) and the RTSP methods (35 to 45) are not in it at all, although a request + * parser accepts every one of them. So `QUERY / HTTP/1.1` read a name from past the end + * of the array, PRI was reported as QUERY, and `FLUSH rtsp://x/ RTSP/1.0` — one line from + * any client, sent to a service with an HTTP filter — crashed the process parsing it. + * `llhttp_method_name` would give the same answers and `abort()` on anything else; this + * answers NULL instead, because nothing a client sends may take the process down. + */ +static const char *method_name(uint8_t method) { + switch (method) { + #define HTTP_METHOD_GEN(NUMBER, NAME, STRING) case NUMBER: return #STRING; + HTTP_ALL_METHOD_MAP(HTTP_METHOD_GEN) #undef HTTP_METHOD_GEN -}; + default: + return NULL; + } +} /* New public callback helper that uses method names as C strings */ static int parser_callback(const char *name, llhttp_t *llhttp) { @@ -322,7 +340,10 @@ static PyObject *parser_method(PyObject *self, void *closure) { if (!llhttp->http_major && !llhttp->http_minor) Py_RETURN_NONE; - return PyUnicode_FromString(methods[llhttp->method]); + const char *name = method_name(llhttp->method); + if (!name) + Py_RETURN_NONE; + return PyUnicode_FromString(name); } static PyObject *parser_major(PyObject *self, void *closure) { @@ -610,7 +631,12 @@ fail: static PyModuleDef_Slot llhttp_slots[] = { {Py_mod_exec, init_llhttp_module}, + /* Declared where it exists — the slot arrived with per-interpreter GILs in 3.12, and + * the package supports interpreters older than that. Before 3.12 there is nothing to + * declare: every subinterpreter shares the one GIL. */ +#if PY_VERSION_HEX >= 0x030C0000 {Py_mod_multiple_interpreters, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED}, +#endif {0, NULL} }; diff --git a/frontend/src/components/Firewall/utils.ts b/frontend/src/components/Firewall/utils.ts index 54fc4cc..21ae223 100644 --- a/frontend/src/components/Firewall/utils.ts +++ b/frontend/src/components/Firewall/utils.ts @@ -60,7 +60,8 @@ export type FirewallSettings = { multicast_dns: boolean, allow_upnp: boolean, drop_invalid: boolean, - allow_dhcp: boolean + allow_dhcp: boolean, + allow_dnat: boolean, } diff --git a/frontend/src/components/MainLayout.tsx b/frontend/src/components/MainLayout.tsx index dd592d8..d142f91 100644 --- a/frontend/src/components/MainLayout.tsx +++ b/frontend/src/components/MainLayout.tsx @@ -3,7 +3,7 @@ import { ActionIcon, Container, Menu, Space, Tooltip } from '@mantine/core'; import { AppShell } from '@mantine/core'; import NavBar from './NavBar'; import HeaderPage from './Header'; -import { getMainPath } from '../js/utils'; +import { getMainPath, HOME_SECTIONS } from '../js/utils'; import { useLocation } from 'react-router'; import { useNavbarStore, useSessionStore } from '../js/store'; import { HiMenu } from "react-icons/hi"; @@ -14,7 +14,8 @@ function MainLayout({ children }:{ children:any }) { const { setHomeSection } = useSessionStore() const location = useLocation() useEffect(()=>{ - if (location.pathname !== "/"){ + // Only a page that exists is worth coming back to: "/" redirects to it. + if (HOME_SECTIONS.includes(getMainPath())){ setHomeSection(getMainPath()) } },[location.pathname]) diff --git a/frontend/src/components/Services/AddEditService.tsx b/frontend/src/components/Services/AddEditService.tsx index 677895d..7b73f49 100644 --- a/frontend/src/components/Services/AddEditService.tsx +++ b/frontend/src/components/Services/AddEditService.tsx @@ -139,20 +139,24 @@ export default function AddEditService({ opened, onClose, edit }: { const submit = async (values: FormValues) => { setSubmitting(true) const isExternal = values.transport === Transport.EXTERNAL - const caps = addressCapabilities(values.proto, values.transport) - const addresses: AddressForm[] = values.addresses.map(a => ({ - ip_int: a.ip_int, - port: a.port, - // Sent only where it means something — the same rules the ⚙ on each row - // offers them by. Everywhere else the backend refuses them rather than - // letting a stored value sit there being read by nothing. - ...(values.proto === L4.HTTP ? { edge: a.edge } : {}), - ...(caps.canPublish && Number(a.target_port) && Number(a.target_port) !== a.port - ? { target_port: Number(a.target_port) } : {}), - ...(caps.canChooseUpstream && a.upstream !== Upstream.SAME - ? { upstream: a.upstream } : {}), - ...(isExternal ? { proxy_ip: a.proxy_ip, proxy_port: a.proxy_port } : {}), - })) + const addresses: AddressForm[] = values.addresses.map(a => { + // Per address, because on an HTTPS service what an address can say depends on + // what it is reached over. + const caps = addressCapabilities(values.proto, values.transport, a.edge) + return { + ip_int: a.ip_int, + port: a.port, + // Sent only where it means something — the same rules the ⚙ on each row + // offers them by. Everywhere else the backend refuses them rather than + // letting a stored value sit there being read by nothing. + ...(values.proto === L4.HTTP ? { edge: a.edge } : {}), + ...(caps.canPublish && Number(a.target_port) && Number(a.target_port) !== a.port + ? { target_port: Number(a.target_port) } : {}), + ...(caps.canChooseUpstream && a.upstream !== Upstream.SAME + ? { upstream: a.upstream } : {}), + ...(isExternal ? { proxy_ip: a.proxy_ip, proxy_port: a.proxy_port } : {}), + } + }) try { if (edit) { diff --git a/frontend/src/components/Services/AddressList.tsx b/frontend/src/components/Services/AddressList.tsx index 0553312..2c25f0e 100644 --- a/frontend/src/components/Services/AddressList.tsx +++ b/frontend/src/components/Services/AddressList.tsx @@ -45,8 +45,6 @@ function AddressModal({ opened, onClose, service, edit }: { const [error, setError] = useState(null) const isExternal = service.transport === Transport.EXTERNAL const isHttp = service.proto === L4.HTTP - const caps = addressCapabilities(service.proto, service.transport) - const anyOption = caps.canPublish || caps.canChooseUpstream || caps.isExternal const form = useForm({ initialValues: { @@ -80,6 +78,11 @@ function AddressModal({ opened, onClose, service, edit }: { else form.reset() }, [opened, edit?.address_id]) + // What this address can say depends on what it is reached over, which on an HTTPS + // service is being chosen in this very form. + const caps = addressCapabilities(service.proto, service.transport, form.values.edge) + const anyOption = caps.canPublish || caps.canChooseUpstream || caps.isExternal + const submit = async (values: Values) => { setBusy(true) // Sent wherever it means something, and sent even when it is empty: this is a @@ -92,7 +95,11 @@ function AddressModal({ opened, onClose, service, edit }: { target_port: Number(values.target_port) !== values.port ? Number(values.target_port) || 0 : 0, } : {}), - ...(caps.canChooseUpstream ? { upstream: values.upstream } : {}), + // Where the address has no decrypted leg the choice is taken back rather than + // left out: left out, the row keeps what it had, and an address moved to the + // clear would keep an answer that no longer applies to anything. + ...(isHttp || caps.canChooseUpstream + ? { upstream: caps.canChooseUpstream ? values.upstream : Upstream.SAME } : {}), ...(isExternal ? { proxy_ip: values.proxy_ip, proxy_port: values.proxy_port } : {}), } try { @@ -173,7 +180,6 @@ export default function AddressList({ service }: { service: Service }) { const [removing, setRemoving] = useState
(null) const addresses = service.addresses ?? [] const only = addresses.length === 1 - const caps = addressCapabilities(service.proto, service.transport) //: What each interface is carrying at the moment, so a row named `wg0` can say // where that actually is. An interface with nothing on it is worth seeing too: // on the proxy layer a UDP relay has to bind one of these. @@ -276,7 +282,8 @@ export default function AddressList({ service }: { service: Service }) { them uses: this is where an operator comes back to check what they chose, and a setting named differently here reads as a different setting. Clicking one opens that panel. */} - {addressTags(address, caps).map(tag => + {addressTags(address, + addressCapabilities(service.proto, service.transport, address.edge)).map(tag => a.edge === L4.QUIC)) return diff --git a/frontend/src/components/Services/AddressOptions.tsx b/frontend/src/components/Services/AddressOptions.tsx index a6c8826..8cb3927 100644 --- a/frontend/src/components/Services/AddressOptions.tsx +++ b/frontend/src/components/Services/AddressOptions.tsx @@ -16,8 +16,12 @@ import { decrypts, L4, Transport, Upstream } from './utils'; * the operator to work out the effect for themselves. */ -/** Which of these options exist at all for the service being configured. */ -export function addressCapabilities(proto: string, transport: string) { +/** + * Which of these options exist at all for one address of the service being configured. + * + * `edge` is what that address is reached over, which only an HTTPS service lets vary. + */ +export function addressCapabilities(proto: string, transport: string, edge?: string | null) { const isExternal = transport === Transport.EXTERNAL return { isExternal, @@ -28,9 +32,13 @@ export function addressCapabilities(proto: string, transport: string) { // connection to the service, NFQUEUE opens nothing, and the hand-off layer // leaves the question to the proxy you run yourself. canPublish: transport === Transport.PROXY, - //: Only a service firegex decrypts has a leg of its own towards the service. On - // a cleartext one it terminates nothing, so there is nothing to put back. - canChooseUpstream: decrypts({ proto }) && transport === Transport.PROXY, + //: Only an address whose traffic firegex decrypts has a leg of its own towards the + // service. On a cleartext service it terminates nothing, and on the cleartext + // address of an HTTPS service it carries what arrived as it arrived — so there is + // nothing to put back or leave off. It used to be offered there anyway, and the + // choice was saved, shown back as a tag and read by nothing. + canChooseUpstream: decrypts({ proto }) && transport === Transport.PROXY + && !(proto === L4.HTTP && (edge ?? L4.TCP) === L4.TCP), } } @@ -58,6 +66,8 @@ export const upstreamHint = (upstream: string) => type Extras = { port: number, + /** `http` only: what this address is reached over. */ + edge?: string | null, target_port?: number | string | null, upstream?: string | null, proxy_ip?: string | null, @@ -107,7 +117,7 @@ export default function AddressOptions({ form, field, values, proto, transport } proto: string, transport: string, }) { - const caps = addressCapabilities(proto, transport) + const caps = addressCapabilities(proto, transport, values.edge) const port = Number(values.port) const target = Number(values.target_port) const upstream = String(values.upstream ?? Upstream.SAME) diff --git a/frontend/src/components/Services/AddressRow.tsx b/frontend/src/components/Services/AddressRow.tsx index 8c38054..9236795 100644 --- a/frontend/src/components/Services/AddressRow.tsx +++ b/frontend/src/components/Services/AddressRow.tsx @@ -33,7 +33,7 @@ export default function AddressRow({ form, index, proto, transport, canRemove }: }) { const [open, setOpen] = useState(false) const address = form.values.addresses[index] - const caps = addressCapabilities(proto, transport) + const caps = addressCapabilities(proto, transport, address.edge) const tags = addressTags(address, caps) const anything = caps.canPublish || caps.canChooseUpstream || caps.isExternal diff --git a/frontend/src/components/Services/pyfilterHints.ts b/frontend/src/components/Services/pyfilterHints.ts index 6ce3047..44b3355 100644 --- a/frontend/src/components/Services/pyfilterHints.ts +++ b/frontend/src/components/Services/pyfilterHints.ts @@ -45,19 +45,6 @@ const SNIPPETS = [ ].join("\n"), doc: "Asking for an HttpRequest is what makes this file an HTTP filter.", }, - { - label: "pyfilter-rewrite", - detail: "a filter that rewrites the payload", - insert: [ - "@pyfilter", - "def ${1:redact}(packet: RawPacket):", - "\tif b\"${2:secret}\" not in packet.data:", - "\t\treturn ACCEPT", - "\tpacket.data = packet.data.replace(b\"${2:secret}\", b\"${3:[redacted]}\")", - "\treturn UNSTABLE_MANGLE", - ].join("\n"), - doc: "`data` is the only thing a filter can change. Exact on the proxy layer.", - }, { label: "imports", detail: "the usual imports", @@ -81,14 +68,14 @@ function annotationsInScope(lines: string[], upTo: number): Map return found } +// Whether a member can be assigned to is reported by the library rather than assumed. A +// filter answers with a verdict and changes nothing — the payload used to be writable, +// for `UNSTABLE_MANGLE`, and stopped being with it — so no member claims to be today. const memberDoc = (model: string, m: { name: string, doc: string, writable: boolean }) => ({ value: [ `\`${model}.${m.name}\`${m.writable ? " — **writable**" : ""}`, "", m.doc || "_No description._", - ...(m.writable - ? ["", "Assign to it and return `UNSTABLE_MANGLE` to forward the new bytes."] - : []), ].join("\n"), }) diff --git a/frontend/src/js/demo.ts b/frontend/src/js/demo.ts index de05451..d48725e 100644 --- a/frontend/src/js/demo.ts +++ b/frontend/src/js/demo.ts @@ -21,7 +21,7 @@ const DEMO_CERT = "-----BEGIN CERTIFICATE-----\n(demo placeholder, no real key m // per-function switches on the filter card are for. Nothing here declares a protocol: // asking for an HttpRequest is what makes this an HTTP filter, and the RawPacket one // sits beside it because HTTP provides both. -const SAMPLE_FILTER = `from firegex.pyfilters import pyfilter, ACCEPT, REJECT, UNSTABLE_MANGLE +const SAMPLE_FILTER = `from firegex.pyfilters import pyfilter, ACCEPT, REJECT from firegex.pyfilters.models import HttpRequest, RawPacket @@ -34,12 +34,11 @@ def block_path_traversal(http_request: HttpRequest): @pyfilter -def redact_flag(packet: RawPacket): +def refuse_flag_leaks(packet: RawPacket): """Never let a flag leave, whatever the request looked like.""" - if packet.is_input or b"FLAG{" not in packet.data: - return ACCEPT - packet.data = packet.data.replace(b"FLAG{", b"REDACTED{") - return UNSTABLE_MANGLE + if not packet.is_input and b"FLAG{" in packet.data: + return REJECT + return ACCEPT @pyfilter @@ -62,7 +61,7 @@ const DEMO_PYFILTER_API = { members: [ { name: "client_ip", doc: "The client's address, whichever way this chunk is going", writable: false }, { name: "client_port", doc: "The client's port, whichever way this chunk is going", writable: false }, - { name: "data", doc: "The application payload: the only part a filter can change", writable: true }, + { name: "data", doc: "The application payload, as it arrived. Read-only: a filter answers with a verdict", writable: false }, { name: "data_size", doc: "How many bytes of application payload this chunk carries", writable: false }, { name: "dst_ip", doc: "Where this chunk is going", writable: false }, { name: "dst_port", doc: "The port this chunk is going to", writable: false }, @@ -99,8 +98,7 @@ const DEMO_PYFILTER_API = { verdicts: [ { name: "ACCEPT", value: 0, doc: "Forward this chunk. Returning None means the same thing.", values: [] }, { name: "REJECT", value: 2, doc: "Refuse the connection. Everything still in the stream goes with it.", values: [] }, - { name: "DROP", value: 1, doc: "Silently drop this chunk and every one after it, without closing.", values: [] }, - { name: "UNSTABLE_MANGLE", value: 3, doc: "Forward the payload you assigned to `data`.", values: [] }, + { name: "DROP", value: 1, doc: "Stop the connection's traffic. On NFQUEUE this chunk and every one after it are dropped without closing anything; on the proxy layer, where a stream cannot skip bytes, the connection is closed as REJECT does.", values: [] }, ], settings: [ { name: "FGEX_STREAM_MAX_SIZE", doc: "Bytes of one stream a model may accumulate.", values: [] }, @@ -199,7 +197,7 @@ const state = { functions: [ { filter_id: fltShopPy, name: "block_path_traversal", active: true, blocked: 641 }, { filter_id: fltWebPy, name: "block_path_traversal", active: true, blocked: 1190 }, - { filter_id: fltShopPy, name: "redact_flag", active: true, blocked: 0 }, + { filter_id: fltShopPy, name: "refuse_flag_leaks", active: true, blocked: 0 }, // Switched off rather than deleted: the code is still there to turn back on. { filter_id: fltShopPy, name: "log_client", active: false, blocked: 0 }, ] as Json[], @@ -215,6 +213,7 @@ const state = { settings: { keep_rules: false, allow_loopback: true, allow_established: true, allow_icmp: true, multicast_dns: false, allow_upnp: false, drop_invalid: true, allow_dhcp: true, + allow_dnat: true, } as Json, }, } diff --git a/frontend/src/js/utils.tsx b/frontend/src/js/utils.tsx index e4047a8..4e26ff2 100644 --- a/frontend/src/js/utils.tsx +++ b/frontend/src/js/utils.tsx @@ -1,305 +1,314 @@ -import { showNotification } from "@mantine/notifications"; -import { ImCross } from "react-icons/im"; -import { TiTick } from "react-icons/ti" -import { Navigate } from "react-router"; -import { ChangePassword, IpInterface, LoginResponse, PasswordSend, ServerResponse, ServerResponseToken, ServerStatusResponse } from "./models"; -import { Buffer } from "buffer" -import { QueryClient, useQuery } from "@tanstack/react-query"; -import { useMediaQuery } from "@mantine/hooks"; -import { io, type Socket } from "socket.io-client"; -import { useAuthStore, useSessionStore } from "./store"; -import { demoApi, demoSocket } from "./demo"; - -export const IS_DEV = import.meta.env.DEV -// The public demo build (`bun run build:demo`) answers every request from an in-memory -// backend instead of the network. Vite inlines this as a literal, so a normal build drops -// ./demo and its seed data entirely. -export const IS_DEMO = import.meta.env.VITE_DEMO === "true" - -export const regex_ipv6 = "^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$"; -export const regex_ipv6_no_cidr = "^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*$"; -export const regex_ipv4 = "^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\\/(3[0-2]|[1-2][0-9]|[0-9]))?$" -export const regex_ipv4_no_cidr = "^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$" -export const regex_port = "^([1-9]|[1-9][0-9]{1,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])?$" -export const regex_range_port = "^(([1-9]|[1-9][0-9]{1,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])(-([1-9]|[1-9][0-9]{1,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])?)?)?$" -/** - * What an operator may type where an address is asked for. - * - * One rule, read by the pickers, the badges and every form that validates one, because - * the backend has exactly one too (`utils.is_interface_name` / `parse_ip_or_int`): an - * interface name is 1-15 characters of `[a-zA-Z0-9_.:-]` that is not already an address. - * The order matters — `::1` fits the charset as happily as it fits IPv6, and IPv6 is - * what it means. - */ -export const isIpAddress = (v: string, { cidr = false } = {}) => - !!(v.match(cidr ? regex_ipv4 : regex_ipv4_no_cidr) || v.match(cidr ? regex_ipv6 : regex_ipv6_no_cidr)) - -export const isInterfaceName = (v: string) => - !isIpAddress(v, { cidr: true }) && !!v.trim().match(/^[a-zA-Z0-9_.:-]{1,15}$/) - -/** - * Either of them: what a service address accepts, and what the picker offers. - * - * A range is an address here too — `10.0.0.0/24:8080` is in the documented set and the - * backend parses it — except where one address is the whole point, which is the layer - * that hands traffic to a proxy of the operator's own. - */ -export const isAddressOrInterface = (v: string, { cidr = true } = {}) => - isIpAddress(v, { cidr }) || isInterfaceName(v) - -/** - * An address as the operator typed it. - * - * The backend parses what it is given and stores it back with a prefix, so `127.0.0.1` - * comes out of the database as `127.0.0.1/32` — the same address, spelled in a way - * nobody types and that reads like a range on a row beside real ones. A host-length - * prefix is dropped for display; a prefix that actually covers more than one address is - * left exactly as it is, because there it is the whole point. - */ -export const bareAddress = (v: string) => { - const [addr, prefix] = v.split("/") - if (prefix === undefined) return v - return (addr.includes(":") ? prefix === "128" : prefix === "32") ? addr : v -} - -// Where `bun run dev` looks for the backend. Overridable because the backend needs -// Linux and NET_ADMIN, so it is often not on the same machine as the dev server. -export const DEV_IP_BACKEND = import.meta.env.VITE_BACKEND ?? "127.0.0.1:4444" - -export const WARNING_NFPROXY_TIME_LIMIT = 1000*60*10 // 10 minutes - -export type EnumToPrimitiveUnion = `${T & string}` | ParseNumber<`${T & number}`>; -type ParseNumber = T extends `${infer U extends number}` ? U : never; - -export function typeCastEnum(value: EnumToPrimitiveUnion): E { - return value as E; -} - -// demoSocket implements the handful of members App.tsx uses; the cast keeps every -// call site typed against the real client. -export const socketio: Socket = IS_DEMO? (demoSocket as unknown as Socket) : - import.meta.env.DEV? - io("ws://"+DEV_IP_BACKEND, { - path:"/sock/socket.io", - transports: ['websocket'], - autoConnect: false, - auth: { - token: useAuthStore.getState().getAccessToken() - } - }): - io({ - path:"/sock/socket.io", - transports: ['websocket'], - autoConnect: false, - auth: { - token: useAuthStore.getState().getAccessToken() - } - }) - -export const queryClient = new QueryClient({ defaultOptions: { queries: { - staleTime: Infinity -} }}) - -export function getErrorMessage(e: any) { - let error = "Unknown error"; - if(typeof e == "string") return e - if (e.response) { - // The request was made and the server responded with a status code - // that falls out of the range of 2xx - error = e.response.data.error; - } else { - // Something happened in setting up the request that triggered an Error - error = e.message || e.error; - } - return error; -} - -export function getErrorMessageFromServerResponse(e: any, def:string = "Unknown error") { - if (e.status){ - return e.status - } - if (e.detail){ - if (typeof e.detail == "string") - return e.detail - if (e.detail[0] && e.detail[0].msg) - return e.detail[0].msg - } - if (e.error){ - return e.error - } - return def -} - - -export async function genericapi(method:string,path:string,data:any = undefined, is_form:boolean=false):Promise{ - if (IS_DEMO) return await demoApi(method, path, data) - return await new Promise((resolve, reject) => { - fetch(`${IS_DEV?`http://${DEV_IP_BACKEND}`:""}/api/${path}`, { - method: method, - credentials: "same-origin", - cache: 'no-cache', - headers: { - ...(data?{'Content-Type': is_form ? 'application/x-www-form-urlencoded' : 'application/json'}:{}), - // Omitted rather than sent as the string "null": an unauthenticated app was - // otherwise offering a credential on every request, which is a 401 the server - // had to be asked for and the client had to recover from. - ...(useAuthStore.getState().getAccessToken() - ? { "Authorization": "Bearer " + useAuthStore.getState().getAccessToken() } - : {}) - }, - body: data? (is_form ? (new URLSearchParams(data)).toString() : JSON.stringify(data)) : undefined - }).then(res => { - if(res.status === 401) { - // Drop the token and let the app re-render from it. Reloading the document - // here is what made a password screen appear "sometimes on refresh": a token - // left over from a database that has since been recreated 401s on the first - // query, the page reloads itself, and what comes back is whatever screen the - // server's status calls for — "choose a password", if none is set. The app - // already asks /api/status again when the token changes; that is the same - // recovery without throwing away the page the operator was looking at. - useAuthStore.getState().clearAccessToken(); - return reject("Session expired") - } - if(res.status === 406) resolve({status:"Wrong Password"}) - if(!res.ok){ - const errorDefault = res.statusText - return res.json().then( res => reject(getErrorMessageFromServerResponse(res, errorDefault)) ).catch( _err => reject(errorDefault)) - } - // text/plain bodies (a filter's Python source) stay strings: a source file that - // happens to be valid JSON must not be parsed into a number/array/object - const isPlainText = (res.headers.get("content-type") ?? "").startsWith("text/plain") - res.text().then(t => { - if (isPlainText) return resolve(t) - try{ - resolve(JSON.parse(t)) - }catch(e){ - resolve(t) - } - }).catch( err => reject(err)) - }).catch(err => { - reject(err) - }) - }); -} - -export async function getapi(path:string):Promise{ - return await genericapi("GET",path) -} - -export async function postapi(path:string,data:any=undefined,is_form:boolean=false):Promise{ - return await genericapi("POST",path,data,is_form) -} - -export async function deleteapi(path:string):Promise{ - return await genericapi("DELETE",path) -} - -export async function putapi(path:string,data:any):Promise{ - return await genericapi("PUT",path,data) -} - -export function getMainPath(){ - const paths = window.location.pathname.split("/") - if (paths.length > 1) return paths[1] - return "" -} - -export function HomeRedirector(){ - const section = useSessionStore.getState().getHomeSection(); - const path = section?`/${section}`:`/services` - return -} - -export async function resetfiregex(delete_data:boolean = false){ - const { status } = await postapi("reset",{delete:delete_data}) as ServerResponse; - return (status === "ok"?undefined:status) -} - -export const ipInterfacesQuery = () => useQuery({ queryKey: ["ipinterfaces"], queryFn: getipinterfaces }) - -export async function getipinterfaces(){ - return await getapi("interfaces") as IpInterface[]; -} - -export async function getstatus(){ - return await getapi(`status`) as ServerStatusResponse; -} - -export async function logout(){ - useAuthStore.getState().clearAccessToken(); -} - -export async function setpassword(data:PasswordSend) { - const { status, access_token } = await postapi("set-password",data) as ServerResponseToken; - if (access_token) - useAuthStore.getState().setAccessToken(access_token); - return status === "ok"?undefined:status -} - -/** Hand access control to a reverse proxy. Off only — see the backend's `set_auth_mode`. */ -export async function setAuthMode(disabled: boolean) { - const { status } = await postapi("auth-mode", { disabled }) as ServerResponse; - return status === "ok" ? undefined : status -} - -export async function changepassword(data:ChangePassword) { - const { status, access_token } = await postapi("change-password",data) as ServerResponseToken; - if (access_token) - useAuthStore.getState().setAccessToken(access_token); - return status === "ok"?undefined:status -} - -export async function login(data:PasswordSend) { - const from = {username: "login", password: data.password}; - const { status, access_token } = await postapi("login",from,true) as LoginResponse; - useAuthStore.getState().setAccessToken(access_token); - return status; -} - -export function errorNotify(title:string, description:string ){ - showNotification({ - autoClose: 2000, - title: title, - message: description, - color: 'red', - icon: , - }); -} - -export function okNotify(title:string, description:string ){ - showNotification({ - autoClose: 2000, - title: title, - message: description, - color: 'teal', - icon: , - }); -} - -export const makeid = (length:number) => { - let result = ''; - const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - const charactersLength = characters.length; - let counter = 0; - while (counter < length) { - result += characters.charAt(Math.floor(Math.random() * charactersLength)); - counter += 1; - } - return result; -} - -export function b64encode(data:number[]|string){ - return Buffer.from(data).toString('base64') -} - -export function b64decode(regexB64:string){ - return Buffer.from(regexB64, "base64").toString() -} - -export function isMediumScreen(){ - return useMediaQuery('(min-width: 600px)'); -} - -export function isLargeScreen(){ - return useMediaQuery('(min-width: 992px)'); +import { showNotification } from "@mantine/notifications"; +import { ImCross } from "react-icons/im"; +import { TiTick } from "react-icons/ti" +import { Navigate } from "react-router"; +import { ChangePassword, IpInterface, LoginResponse, PasswordSend, ServerResponse, ServerResponseToken, ServerStatusResponse } from "./models"; +import { Buffer } from "buffer" +import { QueryClient, useQuery } from "@tanstack/react-query"; +import { useMediaQuery } from "@mantine/hooks"; +import { io, type Socket } from "socket.io-client"; +import { useAuthStore, useSessionStore } from "./store"; +import { demoApi, demoSocket } from "./demo"; + +export const IS_DEV = import.meta.env.DEV +// The public demo build (`bun run build:demo`) answers every request from an in-memory +// backend instead of the network. Vite inlines this as a literal, so a normal build drops +// ./demo and its seed data entirely. +export const IS_DEMO = import.meta.env.VITE_DEMO === "true" + +export const regex_ipv6 = "^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$"; +export const regex_ipv6_no_cidr = "^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*$"; +export const regex_ipv4 = "^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\\/(3[0-2]|[1-2][0-9]|[0-9]))?$" +export const regex_ipv4_no_cidr = "^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$" +export const regex_port = "^([1-9]|[1-9][0-9]{1,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])?$" +export const regex_range_port = "^(([1-9]|[1-9][0-9]{1,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])(-([1-9]|[1-9][0-9]{1,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])?)?)?$" +/** + * What an operator may type where an address is asked for. + * + * One rule, read by the pickers, the badges and every form that validates one, because + * the backend has exactly one too (`utils.is_interface_name` / `parse_ip_or_int`): an + * interface name is 1-15 characters of `[a-zA-Z0-9_.:-]` that is not already an address. + * The order matters — `::1` fits the charset as happily as it fits IPv6, and IPv6 is + * what it means. + */ +export const isIpAddress = (v: string, { cidr = false } = {}) => + !!(v.match(cidr ? regex_ipv4 : regex_ipv4_no_cidr) || v.match(cidr ? regex_ipv6 : regex_ipv6_no_cidr)) + +export const isInterfaceName = (v: string) => + !isIpAddress(v, { cidr: true }) && !!v.trim().match(/^[a-zA-Z0-9_.:-]{1,15}$/) + +/** + * Either of them: what a service address accepts, and what the picker offers. + * + * A range is an address here too — `10.0.0.0/24:8080` is in the documented set and the + * backend parses it — except where one address is the whole point, which is the layer + * that hands traffic to a proxy of the operator's own. + */ +export const isAddressOrInterface = (v: string, { cidr = true } = {}) => + isIpAddress(v, { cidr }) || isInterfaceName(v) + +/** + * An address as the operator typed it. + * + * The backend parses what it is given and stores it back with a prefix, so `127.0.0.1` + * comes out of the database as `127.0.0.1/32` — the same address, spelled in a way + * nobody types and that reads like a range on a row beside real ones. A host-length + * prefix is dropped for display; a prefix that actually covers more than one address is + * left exactly as it is, because there it is the whole point. + */ +export const bareAddress = (v: string) => { + const [addr, prefix] = v.split("/") + if (prefix === undefined) return v + return (addr.includes(":") ? prefix === "128" : prefix === "32") ? addr : v +} + +// Where `bun run dev` looks for the backend. Overridable because the backend needs +// Linux and NET_ADMIN, so it is often not on the same machine as the dev server. +export const DEV_IP_BACKEND = import.meta.env.VITE_BACKEND ?? "127.0.0.1:4444" + +export const WARNING_NFPROXY_TIME_LIMIT = 1000*60*10 // 10 minutes + +export type EnumToPrimitiveUnion = `${T & string}` | ParseNumber<`${T & number}`>; +type ParseNumber = T extends `${infer U extends number}` ? U : never; + +export function typeCastEnum(value: EnumToPrimitiveUnion): E { + return value as E; +} + +// demoSocket implements the handful of members App.tsx uses; the cast keeps every +// call site typed against the real client. +export const socketio: Socket = IS_DEMO? (demoSocket as unknown as Socket) : + import.meta.env.DEV? + io("ws://"+DEV_IP_BACKEND, { + path:"/sock/socket.io", + transports: ['websocket'], + autoConnect: false, + auth: { + token: useAuthStore.getState().getAccessToken() + } + }): + io({ + path:"/sock/socket.io", + transports: ['websocket'], + autoConnect: false, + auth: { + token: useAuthStore.getState().getAccessToken() + } + }) + +export const queryClient = new QueryClient({ defaultOptions: { queries: { + staleTime: Infinity +} }}) + +export function getErrorMessage(e: any) { + let error = "Unknown error"; + if(typeof e == "string") return e + if (e.response) { + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + error = e.response.data.error; + } else { + // Something happened in setting up the request that triggered an Error + error = e.message || e.error; + } + return error; +} + +export function getErrorMessageFromServerResponse(e: any, def:string = "Unknown error") { + if (e.status){ + return e.status + } + if (e.detail){ + if (typeof e.detail == "string") + return e.detail + if (e.detail[0] && e.detail[0].msg) + return e.detail[0].msg + } + if (e.error){ + return e.error + } + return def +} + + +export async function genericapi(method:string,path:string,data:any = undefined, is_form:boolean=false):Promise{ + if (IS_DEMO) return await demoApi(method, path, data) + return await new Promise((resolve, reject) => { + fetch(`${IS_DEV?`http://${DEV_IP_BACKEND}`:""}/api/${path}`, { + method: method, + credentials: "same-origin", + cache: 'no-cache', + headers: { + ...(data?{'Content-Type': is_form ? 'application/x-www-form-urlencoded' : 'application/json'}:{}), + // Omitted rather than sent as the string "null": an unauthenticated app was + // otherwise offering a credential on every request, which is a 401 the server + // had to be asked for and the client had to recover from. + ...(useAuthStore.getState().getAccessToken() + ? { "Authorization": "Bearer " + useAuthStore.getState().getAccessToken() } + : {}) + }, + body: data? (is_form ? (new URLSearchParams(data)).toString() : JSON.stringify(data)) : undefined + }).then(res => { + if(res.status === 401) { + // Drop the token and let the app re-render from it. Reloading the document + // here is what made a password screen appear "sometimes on refresh": a token + // left over from a database that has since been recreated 401s on the first + // query, the page reloads itself, and what comes back is whatever screen the + // server's status calls for — "choose a password", if none is set. The app + // already asks /api/status again when the token changes; that is the same + // recovery without throwing away the page the operator was looking at. + useAuthStore.getState().clearAccessToken(); + return reject("Session expired") + } + if(res.status === 406) resolve({status:"Wrong Password"}) + if(!res.ok){ + const errorDefault = res.statusText + return res.json().then( res => reject(getErrorMessageFromServerResponse(res, errorDefault)) ).catch( _err => reject(errorDefault)) + } + // text/plain bodies (a filter's Python source) stay strings: a source file that + // happens to be valid JSON must not be parsed into a number/array/object + const isPlainText = (res.headers.get("content-type") ?? "").startsWith("text/plain") + res.text().then(t => { + if (isPlainText) return resolve(t) + try{ + resolve(JSON.parse(t)) + }catch(e){ + resolve(t) + } + }).catch( err => reject(err)) + }).catch(err => { + reject(err) + }) + }); +} + +export async function getapi(path:string):Promise{ + return await genericapi("GET",path) +} + +export async function postapi(path:string,data:any=undefined,is_form:boolean=false):Promise{ + return await genericapi("POST",path,data,is_form) +} + +export async function deleteapi(path:string):Promise{ + return await genericapi("DELETE",path) +} + +export async function putapi(path:string,data:any):Promise{ + return await genericapi("PUT",path,data) +} + +export function getMainPath(){ + const paths = window.location.pathname.split("/") + if (paths.length > 1) return paths[1] + return "" +} + +/** + * The top-level pages that exist. The last one visited is remembered for the tab and sent + * back to on "/" — so it has to be one of these: a tab open across the upgrade that folded + * `nfregex`, `nfproxy`, `porthijack` and `tls-decrypt` into `services` remembered one of + * those, and redirecting to a route that no longer exists landed back here and redirected + * again, for ever. + */ +export const HOME_SECTIONS = ["services", "firewall"] + +export function HomeRedirector(){ + const section = useSessionStore.getState().getHomeSection(); + const path = section && HOME_SECTIONS.includes(section) ? `/${section}` : `/services` + return +} + +export async function resetfiregex(delete_data:boolean = false){ + const { status } = await postapi("reset",{delete:delete_data}) as ServerResponse; + return (status === "ok"?undefined:status) +} + +export const ipInterfacesQuery = () => useQuery({ queryKey: ["ipinterfaces"], queryFn: getipinterfaces }) + +export async function getipinterfaces(){ + return await getapi("interfaces") as IpInterface[]; +} + +export async function getstatus(){ + return await getapi(`status`) as ServerStatusResponse; +} + +export async function logout(){ + useAuthStore.getState().clearAccessToken(); +} + +export async function setpassword(data:PasswordSend) { + const { status, access_token } = await postapi("set-password",data) as ServerResponseToken; + if (access_token) + useAuthStore.getState().setAccessToken(access_token); + return status === "ok"?undefined:status +} + +/** Hand access control to a reverse proxy. Off only — see the backend's `set_auth_mode`. */ +export async function setAuthMode(disabled: boolean) { + const { status } = await postapi("auth-mode", { disabled }) as ServerResponse; + return status === "ok" ? undefined : status +} + +export async function changepassword(data:ChangePassword) { + const { status, access_token } = await postapi("change-password",data) as ServerResponseToken; + if (access_token) + useAuthStore.getState().setAccessToken(access_token); + return status === "ok"?undefined:status +} + +export async function login(data:PasswordSend) { + const from = {username: "login", password: data.password}; + const { status, access_token } = await postapi("login",from,true) as LoginResponse; + useAuthStore.getState().setAccessToken(access_token); + return status; +} + +export function errorNotify(title:string, description:string ){ + showNotification({ + autoClose: 2000, + title: title, + message: description, + color: 'red', + icon: , + }); +} + +export function okNotify(title:string, description:string ){ + showNotification({ + autoClose: 2000, + title: title, + message: description, + color: 'teal', + icon: , + }); +} + +export const makeid = (length:number) => { + let result = ''; + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const charactersLength = characters.length; + let counter = 0; + while (counter < length) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + counter += 1; + } + return result; +} + +export function b64encode(data:number[]|string){ + return Buffer.from(data).toString('base64') +} + +export function b64decode(regexB64:string){ + return Buffer.from(regexB64, "base64").toString() +} + +export function isMediumScreen(){ + return useMediaQuery('(min-width: 600px)'); +} + +export function isLargeScreen(){ + return useMediaQuery('(min-width: 992px)'); } \ No newline at end of file diff --git a/frontend/src/pages/Firewall/SettingsModal.tsx b/frontend/src/pages/Firewall/SettingsModal.tsx index d808957..9fe3a33 100644 --- a/frontend/src/pages/Firewall/SettingsModal.tsx +++ b/frontend/src/pages/Firewall/SettingsModal.tsx @@ -43,6 +43,9 @@ export function SettingsModal({ opened, onClose }:{ opened:boolean, onClose:()=> setSettings({...settings, allow_upnp:v.target.checked})}/> setSettings({...settings, drop_invalid:v.target.checked})}/> setSettings({...settings, allow_dhcp:v.target.checked})}/> + setSettings({...settings, allow_dnat:v.target.checked})}/> diff --git a/run.py b/run.py index 17f8f23..96d1e69 100755 --- a/run.py +++ b/run.py @@ -105,7 +105,7 @@ def check_already_running(): #: option that owns the setting is the one to use. MANAGED_ENV = { "PORT", "HOST", "NTHREADS", "PSW_HASH_SET", "SOCKET_DIR", "FIREGEX_VERSION", - "ALLOWED_IPS", "PROXY_IP_HEADER", "UNSAFE_DISABLE_AUTH", + "ALLOWED_IPS", "PROXY_IP_HEADER", "UNSAFE_DISABLE_AUTH", "FIREGEX_FRESH_BOOT", } @@ -1010,6 +1010,11 @@ def run_standalone(): if psw_set: env_vars.append(f"PSW_HASH_SET={hash_psw(psw_set)}") + # Every standalone start is a boot whose environment was just written from the + # configuration, which is what the backend needs to know to let it decide + # authentication over what `run.py config` last stored (see `seed_auth_mode`). + env_vars.append("FIREGEX_FRESH_BOOT=1") + # Add socket dir if set (use path inside chroot) if args.socket_dir: env_vars.append("SOCKET_DIR=/run/firegex") @@ -1228,9 +1233,12 @@ def handle_config_command(args): puts("Authentication disabled: every request reaching firegex becomes a full administrator", color=colors.red, is_bold=True) else: puts("Authentication enabled", color=colors.green) - # Both halves, or the two disagree until the next restart: the database is what - # the running process reads, the config file is what the next one comes up with. - applied = put_runtime_settings({"auth_disabled": "1" if args.unsafe_disable_auth else "0"}) + # Every half, or they disagree: the database is what the running process reads, + # the config file is what the next `run.py start` comes up with, and the host key + # is what a container Docker starts again by itself comes up with — its own + # environment being the one it was created with, older than this. + value = "1" if args.unsafe_disable_auth else "0" + applied = put_runtime_settings({"auth_disabled": value, "auth_disabled_host": value}) if applied: puts("Applied to the running instance immediately, and kept for the next start.", color=colors.green) else: @@ -1293,7 +1301,7 @@ def handle_config_command(args): puts("Put it in force with 'python3 run.py config --no-unsafe-disable-auth'.", color=colors.yellow) else: config["unsafe_disable_auth"] = False - put_runtime_settings({"auth_disabled": "0"}) + put_runtime_settings({"auth_disabled": "0", "auth_disabled_host": "0"}) puts("Authentication was disabled on this instance: turned back on, so the new " "password is asked for.", color=colors.green, is_bold=True) puts("Pass --keep-auth-disabled to store a password without putting it in force.", diff --git a/tests/helpers/host.py b/tests/helpers/host.py new file mode 100644 index 0000000..64e3fe6 --- /dev/null +++ b/tests/helpers/host.py @@ -0,0 +1,47 @@ +"""What the host firegex runs on says about it, read as root. + +Reading a ruleset or naming the process behind a socket needs root, and the suite does +not: each command goes through `sudo -n` unless it already is root, so a developer with +passwordless sudo gets these checks and one without gets `None` — a skip — rather than a +failure about a permission the tests never asked anyone for. +""" + +import os +import re +import subprocess + + +def as_root(*argv: str) -> str | None: + """Run one command as root: its output, or `None` where that cannot be done here.""" + command = list(argv) if os.geteuid() == 0 else ["sudo", "-n", *argv] + try: + done = subprocess.run(command, capture_output=True, text=True) + except FileNotFoundError: + return None + return done.stdout if done.returncode == 0 else None + + +def ruleset() -> str | None: + """The services module's table, or `None` where it cannot be read from here.""" + 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. + + 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. + """ + 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): + found = re.search(r"redirect to :(\d+)", line) + if found: + return int(found.group(1)) + return None + + +def listening_pid(port: int) -> int | None: + """The process listening on a TCP port, as the host sees it.""" + found = re.search(r"pid=(\d+)", as_root("ss", "-Hltnp", f"sport = :{port}") or "") + return int(found.group(1)) if found else None diff --git a/tests/helpers/quicserver.py b/tests/helpers/quicserver.py index fb54867..be4dd87 100644 --- a/tests/helpers/quicserver.py +++ b/tests/helpers/quicserver.py @@ -78,7 +78,8 @@ class QuicEcho: self._loop.run_forever() async def _serve(self): - import tempfile, os + import tempfile + import os # aioquic reads them from disk, and a key written world-readable is a key # somebody else on the machine can have. paths = [] diff --git a/tests/integration/test_addresses.py b/tests/integration/test_addresses.py index a22b95e..0296ab4 100644 --- a/tests/integration/test_addresses.py +++ b/tests/integration/test_addresses.py @@ -384,3 +384,31 @@ def test_a_container_runtimes_rewrite_does_not_take_the_traffic( "the service stopped answering once a rewrite was staged beside it" assert _echo(real.port, b"carrying BLOCKME") != b"carrying BLOCKME", \ "the rewrite took the traffic and the chain never ran" + + +def test_an_upstream_is_refused_where_nothing_is_decrypted(api, service, stand_in, + certificate): + """The cleartext address of an HTTPS service is carried as it arrived. + + So "send it to the service over TLS" has nothing to act on there. It was accepted, + shown back as a tag, and read by nothing — the engine's cleartext path never asks. + """ + cert, key = certificate() + server = stand_in() + name = f"clear-up-{server.port}" + why = api.services_add_error( + name=name, transport="proxy", proto="http", tls_cert=cert, tls_key=key, + addresses=[{"ip_int": "127.0.0.1", "port": server.port, "edge": "tcp", + "upstream": "tls"}], + ) + assert why is not None, "an upstream was accepted on a cleartext address" + assert "in the clear" in why, why + + service_id = service(name, "127.0.0.1", server.port, "proxy", proto="http", + tls_cert=cert, tls_key=key, + addresses=[{"ip_int": "127.0.0.1", "port": server.port, + "edge": "tcp"}]) + address = api.services_addresses(service_id)[0] + why = api.services_edit_address_error(service_id, address["address_id"], "127.0.0.1", + server.port, edge="tcp", upstream="tls") + assert why is not None and "in the clear" in why, why diff --git a/tests/integration/test_editor_hints.py b/tests/integration/test_editor_hints.py index da37a5c..46c5c36 100644 --- a/tests/integration/test_editor_hints.py +++ b/tests/integration/test_editor_hints.py @@ -26,22 +26,22 @@ def test_a_raw_packet_offers_the_metadata_a_filter_may_read(described): assert {"client_ip", "server_port", "is_tcp", "is_input"} <= members, str(sorted(members)) -def test_only_the_payload_is_writable(described): - """Nothing below the application layer crosses into a filter. +def test_nothing_a_filter_is_shown_is_writable(described): + """A filter reads, and answers with a verdict. - 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 — so metadata in, payload out is the one contract both - keep. The old model papered over that by handing the proxy a literal - `FAKE:IP:TCP:HEADERS:` prefix, so the same filter did different things depending on - where it was attached. + Nothing below the application layer ever crossed into one: 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 payload was the one exception, for `UNSTABLE_MANGLE`, and stopped + being one with it: assignable and read by nothing afterwards, it was an editor + promising a rewrite that never reached the other end. """ raw = [m for m in described["models"] if m["name"] == "RawPacket"][0] members = {m["name"]: m for m in raw["members"]} - assert members["data"]["writable"] is True - assert members["client_ip"]["writable"] is False - writable = [name for name, member in members.items() if member["writable"]] - assert writable == ["data"], str(writable) + assert members["data"]["writable"] is False + writable = [f"{model['name']}.{member['name']}" for model in described["models"] + for member in model["members"] if member["writable"]] + assert writable == [], str(writable) def test_each_model_says_which_protocols_it_belongs_to(described): diff --git a/tests/integration/test_engine.py b/tests/integration/test_engine.py new file mode 100644 index 0000000..a9b2dee --- /dev/null +++ b/tests/integration/test_engine.py @@ -0,0 +1,92 @@ +"""The proxy engine as a process: a way in nobody configured, and a death nobody asked for. + +Both are about what surrounds the datapath rather than what it does to traffic. The +engine listens on an ephemeral port every local process can dial, and it runs as a +process that can die while the rules steering traffic at it stay where they are. +""" + +import socket +import time + +import pytest + +from integration.conftest import add_regex_filter, start_and_settle +from helpers.host import as_root, engine_port, listening_pid +from helpers.traffic import Channel + +pytestmark = [pytest.mark.instance, pytest.mark.root] + + +def _listener(port: int) -> int: + found = engine_port(port) + 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") + return found + + +def test_a_connection_dialled_straight_at_the_engine_is_turned_away(api, protected, + proxy_layer): + """Nothing redirected it, so it has nowhere to go but back into the engine. + + `SO_ORIGINAL_DST` on a connection no rule rewrote is the address it was accepted on — + the engine's own port. The check against dialling ourselves compared with the address + 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`. + """ + 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: + sock.settimeout(5) + try: + closed = sock.recv(1) == b"" + except ConnectionResetError: + closed = True + except socket.timeout: + closed = False + assert closed, "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_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. + + Nothing watched the engine. Killed or crashed, it left its redirect in place, pointing + at a port nobody listened on any more: every client of the service was refused, while + the interface went on saying the service was active. The rules outlive the process on + both layers, which is why the recovery restarts the service rather than the process. + """ + service_id, server, port = protected(proxy_layer, name="enginedies") + add_regex_filter(api, service_id, "BLOCK_ME_ENGINE") + start_and_settle(api, service_id) + channel = Channel(server, port, proxy_layer.ipv6) + assert channel.gets_through(b"harmless"), "the service was not working to begin with" + + pid = listening_pid(_listener(port)) + if pid is None: + pytest.skip("cannot name the process behind the engine's port from here") + as_root("kill", "-9", str(pid)) + + deadline = time.monotonic() + 10 + said: list[str] = [] + while time.monotonic() < deadline: + said = [e["text"] for e in api.services_logs(service_id)] + restarted = [i for i, text in enumerate(said) if "restarting it" in text] + if restarted and any("started on the" in text for text in said[restarted[-1]:]): + break + time.sleep(0.3) + else: + pytest.fail(f"the engine was not brought back: {said[-5:]}") + + assert any("exited on its own" in text for text in said), said[-5:] + assert api.services_get(service_id)["status"] == "active" + assert channel.gets_through(b"after the engine was killed"), \ + "the service was reported restarted and still refused its clients" + assert channel.is_blocked(b"carrying BLOCK_ME_ENGINE"), \ + "the engine came back without the filters it had" diff --git a/tests/integration/test_grpc.py b/tests/integration/test_grpc.py index 45f453f..80a9c5a 100644 --- a/tests/integration/test_grpc.py +++ b/tests/integration/test_grpc.py @@ -18,7 +18,7 @@ import pytest from integration import filter_code from integration.conftest import (add_python_filter, add_regex_filter, start_and_settle) -from helpers.grpcserver import (BIDI, FORBIDDEN, SECRET, TRAILERS_ONLY, UNARY, +from helpers.grpcserver import (FORBIDDEN, SECRET, TRAILERS_ONLY, UNARY, GrpcService, bidi, call, channel, needs_grpc, server_stream) from helpers.net import free_port diff --git a/tests/integration/test_nftables.py b/tests/integration/test_nftables.py index a5e02b8..7a6a8e5 100644 --- a/tests/integration/test_nftables.py +++ b/tests/integration/test_nftables.py @@ -8,35 +8,16 @@ and whose `reset()` re-added `filter INPUT` with `policy accept`, silently remov administrator's default-deny. """ -import subprocess import time import pytest from integration.conftest import start_and_settle +from helpers.host import ruleset as _ruleset pytestmark = [pytest.mark.instance, pytest.mark.root] -def _ruleset() -> str | None: - """The services module's table, or `None` where it cannot be read from here. - - Tried directly and then through `sudo -n`, because reading the ruleset needs root and - the suite does not: a developer with passwordless sudo gets these checks, and one - without gets a skip rather than a failure about a permission the tests never asked - anyone for. - """ - for command in (["nft"], ["sudo", "-n", "nft"]): - try: - shown = subprocess.run(command + ["list", "table", "inet", "fgex"], - capture_output=True, text=True) - except FileNotFoundError: - return None - if shown.returncode == 0: - return shown.stdout - return None - - @pytest.fixture(autouse=True) def needs_the_ruleset(): if _ruleset() is None: diff --git a/tests/integration/test_quic.py b/tests/integration/test_quic.py index 1904f78..635a83d 100644 --- a/tests/integration/test_quic.py +++ b/tests/integration/test_quic.py @@ -245,3 +245,34 @@ def test_it_works_over_ipv6_too(api, service, quic_stand_in): assert "/public" in h3_request(server.host, server.port, "/public") assert h3_is_blocked(server.host, server.port, "/admin"), \ "the filters are not seeing the decrypted request over IPv6" + + +@needs_quic +def test_what_the_service_speaks_can_be_changed_while_it_runs(api, service, quic_stand_in, + http_stand_in): + """The relay is keyed by where it sends *and* what it speaks there. + + It used to be keyed by where alone, so the address came back to the relay it already + had — still speaking HTTP/3 to a service now answering HTTP/1.1 — and the edit was + saved, shown, and did nothing until the service was restarted. + + Two stand-ins on one port number, one on UDP and one on TCP, tell the two legs apart + by their answers: the QUIC one echoes what it was sent after a colon, the HTTP/1.1 + one does not. + """ + quic = quic_stand_in() + http_stand_in(port=quic.port) + cert, key = quic.material + service_id = service(f"quic-onward-{quic.port}", "127.0.0.1", quic.port, "proxy", + proto="quic", tls_cert=cert, tls_key=key) + start_and_settle(api, service_id) + assert h3_request(quic.host, quic.port, "/which").endswith("bytes: "), \ + "the exchange did not reach the QUIC service to begin with" + + address = api.services_addresses(service_id)[0] + assert api.services_edit_address(service_id, address["address_id"], "127.0.0.1", + quic.port, upstream="tcp") + time.sleep(SETTLE) + answer = h3_request(quic.host, quic.port, "/which") + assert answer.endswith("bytes"), \ + f"the edit was saved and the relay went on speaking HTTP/3: {answer!r}" diff --git a/tests/integration/test_service_edit.py b/tests/integration/test_service_edit.py new file mode 100644 index 0000000..01cad7f --- /dev/null +++ b/tests/integration/test_service_edit.py @@ -0,0 +1,72 @@ +"""Editing a service, and what a refused edit leaves behind: nothing. + +An edit changes what the datapath *is*, so it is checked the way a start would check it +and written all at once. Each test here is a way it used to leave the service somewhere +between the old definition and the new one. +""" + +import pytest + +from integration.conftest import start_and_settle +from helpers.certs import UNPARSEABLE_CERT, UNPARSEABLE_KEY +from helpers.traffic import Channel + +pytestmark = pytest.mark.instance + + +def test_a_stopped_service_is_refused_what_it_could_not_start_with(api, service, + stand_in, certificate): + """The layer is asked whether or not the service is running. + + It used to be asked only by starting the service, so a stopped one took any + combination — TLS on the queued layer, here — and failed on its next start instead, + long after the operator had moved on. + """ + cert, key = certificate() + server = stand_in() + service_id = service(f"edit-nfq-{server.port}", "127.0.0.1", server.port, "nfqueue") + + why = api.services_edit_error(service_id, proto="tls", tls_cert=cert, tls_key=key) + assert why is not None, "a stopped queued service was switched to TLS" + assert "proxy layer" in why, why + assert api.services_get(service_id)["proto"] == "tcp", "the refused edit was written" + + +def test_an_edit_that_collides_leaves_the_service_as_it_was(api, service, stand_in): + """The service and its addresses are one write. + + Moving a UDP service to TCP rewrites its addresses too, and one of them is another + service's TCP address. The service row used to be written first, on its own, so the + collision left a TCP service whose addresses still said UDP. + """ + server = stand_in() + service(f"edit-tcp-{server.port}", "127.0.0.1", server.port, "proxy") + udp = service(f"edit-udp-{server.port}", "127.0.0.1", server.port, "proxy", proto="udp") + + why = api.services_edit_error(udp, proto="tcp") + assert why is not None, "two services were allowed the same TCP address" + assert api.services_get(udp)["proto"] == "udp", "the service was rewritten anyway" + assert [a["proto"] for a in api.services_addresses(udp)] == ["udp"] + + +def test_a_running_service_whose_edit_will_not_start_is_put_back(api, protected, + proxy_layer): + """Refused by the engine, not by the form: nothing short of starting it can tell. + + The material is shaped like a certificate and is not one. The service used to be left + stopped, on the definition that failed, with a 500 — so a typo in a certificate took + a working service off the air. + """ + service_id, server, port = protected(proxy_layer, name="edit-back") + start_and_settle(api, service_id) + channel = Channel(server, port, proxy_layer.ipv6) + assert channel.gets_through(b"before"), "the service was not working to begin with" + + why = api.services_edit_error(service_id, proto="tls", tls_cert=UNPARSEABLE_CERT, + tls_key=UNPARSEABLE_KEY) + assert why is not None, "the engine accepted material that is not a certificate" + assert "nothing was changed" in why, why + + now = api.services_get(service_id) + assert (now["status"], now["proto"]) == ("active", "tcp"), str(now) + assert channel.gets_through(b"after"), "the service was not started again as it was" diff --git a/tests/integration/test_stats.py b/tests/integration/test_stats.py index a2aed0e..5f0e1ab 100644 --- a/tests/integration/test_stats.py +++ b/tests/integration/test_stats.py @@ -269,3 +269,24 @@ def test_the_per_packet_layer_reports_no_connection_share_rather_than_a_made_up_ service_id, _, _, _ = with_blocks traffic = api.services_stats(service_id)["traffic"] assert traffic["connections"] is None and traffic["refused_share"] is None, str(traffic) + + +def test_the_service_total_is_the_sum_of_its_filters(api, protected, proxy_layer): + """What the service list shows beside each service. + + It added up the *distinct* counts, so two filters that had refused one connection + each made a service that had refused one. The proxy layer, because a connection is + refused exactly once there, and equal counts are what the old sum lost. + """ + service_id, server, port = protected(proxy_layer, name="sum") + add_regex_filter(api, service_id, "FIRST_BLOCK", name="first") + add_regex_filter(api, service_id, "SECOND_BLOCK", name="second") + start_and_settle(api, service_id) + channel = Channel(server, port, proxy_layer.ipv6) + assert channel.is_blocked(b"carrying FIRST_BLOCK") + assert channel.is_blocked(b"carrying SECOND_BLOCK") + time.sleep(1.0) + + per_filter = [f["blocked"] for f in api.services_filters(service_id)] + assert per_filter == [1, 1], f"each filter was meant to refuse once: {per_filter}" + assert api.services_get(service_id)["n_blocked"] == 2 diff --git a/tests/unit/test_backup_guards.py b/tests/unit/test_backup_guards.py index 9ffd6a6..ac81ed5 100644 --- a/tests/unit/test_backup_guards.py +++ b/tests/unit/test_backup_guards.py @@ -11,7 +11,6 @@ and covers the cases nobody would stage against a running firewall. """ import re -import sqlite3 from pathlib import Path import pytest @@ -92,7 +91,8 @@ def test_a_backup_carries_neither_credentials_nor_the_auth_mode(tmp_path): db.connect() db.create_schema({}) for key, value in (("password", "$pbkdf2$secret"), ("secret", "jwt-signing-key"), - ("auth_disabled", "1"), ("something_else", "kept")): + ("auth_disabled", "1"), ("auth_disabled_host", "1"), + ("something_else", "kept")): db.query("INSERT INTO keys_values (key, value) VALUES (?, ?);", key, value) carried = {row["key"] for row in db.dump()["keys_values"]} @@ -101,7 +101,7 @@ def test_a_backup_carries_neither_credentials_nor_the_auth_mode(tmp_path): def test_everything_else_in_a_table_is_carried(tmp_path): - """The exclusion is three named keys, not a table nobody exports.""" + """The exclusion is four named keys, not a table nobody exports.""" db = SQLite(str(tmp_path / "other.db"), {}) db.connect() db.create_schema({"things": {"id": "VARCHAR(10) PRIMARY KEY", "v": "TEXT"}}) @@ -110,3 +110,35 @@ def test_everything_else_in_a_table_is_carried(tmp_path): dumped = db.dump() assert dumped["things"] == [{"id": "a", "v": "kept"}] db.disconnect() + + +# --- whether authentication starts off ---------------------------------------- +# A container's environment is fixed when it is created. Docker starting it again by +# itself — a reboot, a daemon restart, a crash under `restart: unless-stopped` — brings that +# environment back, older than what `run.py config` has said since: authentication turned +# back on with `run.py config --password` was off again after the next reboot. + + +def test_a_fresh_boot_takes_the_environment(): + """`run.py` has just written it from the current configuration.""" + from utils import boot_auth_mode + + assert boot_auth_mode(None, True, True) == (True, False) + assert boot_auth_mode("0", True, True) == (True, False), \ + "a container run.py has just created is told the current answer by its environment" + assert boot_auth_mode("1", True, False) == (False, False) + + +def test_a_restarted_container_keeps_what_the_host_decided_since(): + from utils import boot_auth_mode + + assert boot_auth_mode("0", False, True) == (False, True), \ + "authentication re-enabled with run.py config came back off after a reboot" + assert boot_auth_mode("1", False, False) == (True, True) + + +def test_with_nothing_decided_since_the_environment_stands(): + from utils import boot_auth_mode + + assert boot_auth_mode(None, False, False) == (False, False) + assert boot_auth_mode(None, False, True) == (True, False) diff --git a/tests/unit/test_chain_payloads.py b/tests/unit/test_chain_payloads.py index ed8f98c..f6ddbec 100644 --- a/tests/unit/test_chain_payloads.py +++ b/tests/unit/test_chain_payloads.py @@ -110,6 +110,42 @@ def test_every_function_switched_off_is_an_empty_list_not_an_absence(): assert link.enabled_functions is not None +# --- what cpproxy is told ------------------------------------------------------ + + +def _python_stage(tmp_path, code: str, functions=None): + path = tmp_path / "filter.py" + path.write_text(code, encoding="utf-8") + srv = Service(service_id="s", name="n", status="stop", proto="tcp", + transport=TRANSPORT.NFQUEUE) + link = ChainLink(a_filter(KIND.PYFILTER), code_path=str(path), functions=functions) + return _QueueStage(srv, link, owner=None), link + + +def _body(payload: bytes) -> bytes: + size = int.from_bytes(payload[:4], "big") + assert size == len(payload) - 4, "the length prefix disagrees with what follows" + return payload[4:] + + +def test_the_length_prefix_counts_bytes_not_characters(tmp_path): + """Counted in characters, one accented letter in a comment announced fewer bytes than + followed; the binary read the rest as the next length prefix and exited.""" + stage, link = _python_stage(tmp_path, "# perché\nX = 'é'\n") + _body(stage._python_payload(link)) + + +def test_the_binary_is_told_names_and_never_runs_the_file_here(tmp_path): + """Which functions exist is the library's to work out, in the process that compiles + the module: nothing of the operator's code runs in the backend to ask.""" + stage, link = _python_stage(tmp_path, "raise SystemExit('ran in the backend')\n") + assert b"__firegex_pyfilter_enabled = None" in _body(stage._python_payload(link)) + + stage, link = _python_stage(tmp_path, "X = 1\n", functions=[{"name": "a", "active": 1}, + {"name": "b", "active": 0}]) + assert b"__firegex_pyfilter_enabled = ['a']" in _body(stage._python_payload(link)) + + # --- what the proxy engine is told about an address --------------------------- diff --git a/tests/unit/test_live_log.py b/tests/unit/test_live_log.py new file mode 100644 index 0000000..ecd62ef --- /dev/null +++ b/tests/unit/test_live_log.py @@ -0,0 +1,42 @@ +"""The live log's batches, as the browser receives them. + +The browser dedupes lines on `seq` across reconnects, so every line it is sent needs a +number of its own — including the one the flush writes itself to say how many lines did +not fit. +""" + +import asyncio + +from modules.services import logs + + +class _Socket: + def __init__(self): + self.sent = [] + + async def emit(self, event, payload): + self.sent.append((event, payload)) + + +def test_the_line_saying_what_was_dropped_has_a_number_of_its_own(monkeypatch): + """It used to reuse the last line's, and the browser kept whichever arrived first.""" + import utils + + socket = _Socket() + monkeypatch.setattr(utils, "socketio", socket) + monkeypatch.setattr(logs, "FLUSH_INTERVAL", 0) + + async def run(): + log = logs.ServiceLog("s") + for n in range(logs.FLUSH_LIMIT + 5): + log.add(logs.LEVEL.OUTPUT, f"line {n}") + await log._flush_task + return log + + log = asyncio.run(run()) + entries = [e for event, payload in socket.sent if event == "log" + for e in payload["entries"]] + seqs = [e["seq"] for e in entries] + [e["seq"] for e in log.entries()] + summary = [e for e in entries if "more line(s)" in e["text"]] + assert summary, "nothing said how many lines were dropped" + assert seqs.count(summary[0]["seq"]) == 1, "the summary shares its number with a line" diff --git a/tests/unit/test_llhttp_binding.py b/tests/unit/test_llhttp_binding.py index fc8e9eb..a848ce8 100644 --- a/tests/unit/test_llhttp_binding.py +++ b/tests/unit/test_llhttp_binding.py @@ -94,3 +94,42 @@ def test_a_valid_exchange_is_unaffected(without_pyllhttp): response = _llhttp.Response() response.execute(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n") assert response.status_code == 404 + + +# --- naming the method -------------------------------------------------------- +# The name used to be read out of an array built from `HTTP_METHOD_MAP` and indexed with +# the method's number. That map is not the numbers in order — it runs 0 to 33 and jumps to +# QUERY at 46, with PRI and the RTSP methods not in it at all — so a request parser handed +# any of those read past the end of the array: QUERY and PLAY came back named after +# whatever lay beyond it, and FLUSH crashed the process. A client decides which method it +# sends, so each case runs in a process of its own: a crash has to fail one test, not end +# the run. + +_METHOD_OF = """ +import sys +from firegex import _llhttp + +class Parser(_llhttp.Request): + def on_headers_complete(self): + print(self.method, flush=True) + +Parser().execute(sys.argv[1].encode()) +""" + + +@pytest.mark.parametrize("method, line", [ + ("GET", "GET / HTTP/1.1"), + ("QUERY", "QUERY / HTTP/1.1"), + ("PLAY", "PLAY rtsp://example/ RTSP/1.0"), + ("FLUSH", "FLUSH rtsp://example/ RTSP/1.0"), + ("DESCRIBE", "DESCRIBE rtsp://example/ RTSP/1.0"), +]) +def test_every_method_the_parser_accepts_is_named(method, line): + import subprocess + + ran = subprocess.run( + [sys.executable, "-c", _METHOD_OF, f"{line}\r\nHost: a\r\nCSeq: 1\r\n\r\n"], + capture_output=True, text=True, timeout=30, + ) + assert ran.returncode == 0, f"parsing {line!r} ended the process: {ran.stderr[-300:]}" + assert ran.stdout.strip() == method diff --git a/tests/unit/test_pyfilter_inference.py b/tests/unit/test_pyfilter_inference.py index 87b610d..6c3b733 100644 --- a/tests/unit/test_pyfilter_inference.py +++ b/tests/unit/test_pyfilter_inference.py @@ -16,7 +16,7 @@ import pytest from firegex.pyfilters import collect_pyfilters, pyfilter from firegex.pyfilters.internals import (compile as fgex_compile, get_code_proto, - get_filter_names, infer_proto, simplest_proto) + get_filter_names, simplest_proto) from firegex.pyfilters.internals.data import DataStreamCtx from firegex.pyfilters.internals.models import ExceptionAction, FullStreamAction from firegex.pyfilters.models import (HttpRequest, RawPacket, TCPClientStream, diff --git a/tests/unit/test_service_lifecycle_lock.py b/tests/unit/test_service_lifecycle_lock.py new file mode 100644 index 0000000..2352a6e --- /dev/null +++ b/tests/unit/test_service_lifecycle_lock.py @@ -0,0 +1,75 @@ +"""Two starts, or two stops, arriving at one service together. + +A double click does it, and so does an operator's start racing the watchdog's restart. +`active` used to be asked before the lock was taken, so both calls saw a stopped service +and the second built a datapath of its own over the first — whose engine was left running +with nobody holding it. Two stops, likewise, both saw it running. +""" + +import asyncio + +import pytest + +from modules.services import firewall +from modules.services.models import TRANSPORT, Service + + +class _Transport: + def __init__(self, seen: dict): + self.seen = seen + + async def start(self, chain): + self.seen["start"] += 1 + await asyncio.sleep(0.05) + return {} + + async def stop(self): + self.seen["stop"] += 1 + await asyncio.sleep(0.05) + + +class _Quiet: + """Stands in for the rules, the log and the database: none of them is the question.""" + + def add(self, *args, **kwargs): + pass + + def delete(self, *args, **kwargs): + pass + + def query(self, *args, **kwargs): + return [] + + +@pytest.fixture +def manager(monkeypatch): + seen = {"start": 0, "stop": 0} + monkeypatch.setattr(firewall, "nft", _Quiet()) + monkeypatch.setattr(firewall, "log_for", lambda service_id: _Quiet()) + monkeypatch.setattr(firewall.transports, "build", lambda srv, **kw: _Transport(seen)) + srv = Service(service_id="s", name="n", status="stop", proto="tcp", + transport=TRANSPORT.PROXY) + return firewall.ServiceManager(srv, _Quiet()), seen + + +def test_two_starts_at_once_build_one_datapath(manager): + service, seen = manager + + async def run(): + await asyncio.gather(service.enable(), service.enable()) + + asyncio.run(run()) + assert seen["start"] == 1, "the second start built a datapath over the first" + assert service.active + + +def test_two_stops_at_once_take_it_down_once(manager): + service, seen = manager + + async def run(): + await service.enable() + await asyncio.gather(service.disable(), service.disable()) + + asyncio.run(run()) + assert seen["stop"] == 1, "the second stop tore down a datapath that was already gone" + assert not service.active diff --git a/tests/unit/test_service_validation.py b/tests/unit/test_service_validation.py index 56abc87..c3bd9d2 100644 --- a/tests/unit/test_service_validation.py +++ b/tests/unit/test_service_validation.py @@ -17,8 +17,9 @@ import pytest from fastapi import HTTPException from routers.services import (AddressForm, _address_edge, _address_target, - _address_upstream, _check_tls_material, _decoded_pattern, - _hijack_ip, describe_error, needs_a_stream) + _address_under, _address_upstream, _check_tls_material, + _decoded_pattern, _hijack_ip, _hijack_port, describe_error, + needs_a_stream, upstream_applies) from modules.services.models import L4, TRANSPORT, UPSTREAM @@ -92,21 +93,64 @@ def test_a_published_port_has_to_be_a_port(): def test_only_a_decrypted_service_has_an_upstream_leg(): """Firegex terminates nothing elsewhere, so there is nothing to put back or leave off.""" - assert _address_upstream(L4.TLS, form(upstream=UPSTREAM.TCP)) == UPSTREAM.TCP - assert _address_upstream(L4.HTTP, form(upstream=UPSTREAM.TLS)) == UPSTREAM.TLS - assert "carried as it arrives" in refusal(_address_upstream, L4.TCP, + assert _address_upstream(L4.TLS, L4.TLS, form(upstream=UPSTREAM.TCP)) == UPSTREAM.TCP + assert _address_upstream(L4.HTTP, L4.TLS, form(upstream=UPSTREAM.TLS)) == UPSTREAM.TLS + assert _address_upstream(L4.HTTP, L4.QUIC, form(upstream=UPSTREAM.TCP)) == UPSTREAM.TCP + assert "carried as it arrives" in refusal(_address_upstream, L4.TCP, L4.TCP, form(upstream=UPSTREAM.TCP)) +def test_a_cleartext_address_has_no_upstream_leg_either(): + """Firegex carries what arrives at the cleartext address of an HTTPS service as it + arrived: the engine's plaintext path never asks. The choice used to be accepted there, + shown back as a tag, and read by nothing.""" + assert not upstream_applies(L4.HTTP, L4.TCP) + assert upstream_applies(L4.HTTP, L4.TLS) and upstream_applies(L4.HTTP, L4.QUIC) + assert "reached in the clear" in refusal(_address_upstream, L4.HTTP, L4.TCP, + form(upstream=UPSTREAM.TLS)) + + def test_the_default_upstream_is_allowed_everywhere(): """`same` is what every address did before there was a choice.""" for proto in L4.ALL: - assert _address_upstream(proto, form()) == UPSTREAM.SAME - assert _address_upstream(proto, form(upstream=UPSTREAM.SAME)) == UPSTREAM.SAME + for edge in L4.edges_of(proto) + (proto,): + assert _address_upstream(proto, edge, form()) == UPSTREAM.SAME + assert _address_upstream(proto, edge, form(upstream=UPSTREAM.SAME)) == UPSTREAM.SAME def test_an_unknown_upstream_is_named(): - assert "Unknown upstream" in refusal(_address_upstream, L4.TLS, form(upstream="carrier-pigeon")) + assert "Unknown upstream" in refusal(_address_upstream, L4.TLS, L4.TLS, + form(upstream="carrier-pigeon")) + + +# --- an address, when its service changes protocol ------------------------------ + + +def _row(**kw) -> dict: + return {"address_id": "a", "ip_int": "10.0.0.1/32", "port": 443, "proto": "tcp", + "edge": "tls", "upstream": "tcp", "target_port": 80, **kw} + + +def test_an_address_follows_its_service_to_one_protocol(): + moved = _address_under(_row(), L4.QUIC) + assert (moved["edge"], moved["proto"]) == (L4.QUIC, L4.UDP) + assert moved["upstream"] == UPSTREAM.TCP, "still decrypted, so the answer still applies" + assert moved["target_port"] == 80, "where the service is is not the protocol's to change" + + +def test_an_answer_with_nothing_left_to_apply_to_goes(): + """A service that stops being decrypted has no leg to hand over differently.""" + assert _address_under(_row(), L4.TCP)["upstream"] == UPSTREAM.SAME + + +def test_an_https_service_keeps_what_each_address_said(): + assert _address_under(_row(edge="tls"), L4.HTTP)["edge"] == L4.TLS + assert _address_under(_row(edge="quic", proto="udp"), L4.HTTP)["edge"] == L4.QUIC + # A plain UDP address of a service becoming HTTPS is that service's HTTP/3 edge. + became = _address_under(_row(edge="udp", proto="udp", upstream="same"), L4.HTTP) + assert (became["edge"], became["proto"]) == (L4.QUIC, L4.UDP) + cleartext = _address_under(_row(edge="tcp", upstream="tls"), L4.HTTP) + assert cleartext["upstream"] == UPSTREAM.SAME # --- where the operator's own proxy is --------------------------------------- @@ -130,9 +174,14 @@ def test_a_stated_endpoint_is_kept(): def test_nothing_else_has_an_endpoint(): - """A value on a layer that hands nothing off would be a setting that does nothing.""" + """A value on a layer that hands nothing off would be a setting that does nothing — + and one that still sat in the unique index, able to refuse a real hand-off its port.""" assert _hijack_ip(TRANSPORT.PROXY, "10.0.0.1/32", form()) is None assert _hijack_ip(TRANSPORT.NFQUEUE, "10.0.0.1/32", form()) is None + assert _hijack_ip(TRANSPORT.PROXY, "10.0.0.1/32", + form(proxy_ip="127.0.0.1", proxy_port=8080)) is None + assert _hijack_port(TRANSPORT.PROXY, form(proxy_port=8080)) is None + assert _hijack_port(TRANSPORT.EXTERNAL, form(proxy_port=8080)) == 8080 # --- the TLS material ---------------------------------------------------------