diff --git a/.dockerignore b/.dockerignore index 7d88c0b..63f3302 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,37 +1,41 @@ -Dockerfile - -**/*.pyc -**/__pycache__/ -**/.vscode/** -**/.vscode/ -**/.mypy_cache/** -**/.mypy_cache/ - -**/node_modules -**/.pnp -**/.pnp.js - -# testing -/frontend/coverage - -/backend/db/ -/backend/db/** -/frontend/build/ -/frontend/build/** -/frontend/node_modules/ -/backend/modules/cppregex -/backend/modules/proxy -compose.yml -.firegex-compose.yml - -# misc -**/.DS_Store -**/.env.local -**/.env.development.local -**/.env.test.local -**/.env.production.local - -**/npm-debug.log* -**/yarn-debug.log* -**/yarn-error.log* - +Dockerfile + +**/*.pyc +**/__pycache__/ +**/.vscode/** +**/.vscode/ +**/.mypy_cache/** +**/.mypy_cache/ + +**/node_modules +**/.pnp +**/.pnp.js + +# testing +/frontend/coverage + +/backend/db/ +/backend/db/** +/frontend/build/ +/frontend/build/** +/frontend/node_modules/ +/backend/modules/cppregex +/backend/modules/cpproxy +/backend/modules/fgex-proxy +# Built inside the compiler stage; a host build tree would only be copied in to be +# overwritten, and it is larger than the rest of the context put together. +/backend/proxysrc/target/ +compose.yml +.firegex-compose.yml + +# misc +**/.DS_Store +**/.env.local +**/.env.development.local +**/.env.test.local +**/.env.production.local + +**/npm-debug.log* +**/yarn-debug.log* +**/yarn-error.log* + diff --git a/.github/workflows/demo-pages.yml b/.github/workflows/demo-pages.yml index b3c10f0..118a9b5 100644 --- a/.github/workflows/demo-pages.yml +++ b/.github/workflows/demo-pages.yml @@ -41,7 +41,7 @@ jobs: working-directory: frontend/dist run: | # GitHub Pages serves 404.html for any path it has no file for. Handing it the - # SPA entrypoint is what makes deep links like /nfproxy/ resolve. + # SPA entrypoint is what makes deep links like /services/ resolve. cp index.html 404.html echo 'demo.firegex.pwnzer0tt1.it' > CNAME diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b785580..c94d618 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,11 +24,42 @@ jobs: - name: Build and run firegex run: python3 run.py start -P testpassword + # No apt-get: the tests' dependencies are pure python. `iperf3`, matplotlib and + # numpy belong to the benchmarks, which CI does not run — a measurement has no + # pass or fail to gate a release on. + # + # As root, because what needs root **skips** otherwise — and a case that always + # skips is not a case this gate is checking. Through the script rather than `sudo + # pytest`, because the script installs the dependencies into the environment it is + # running in; a root process does not read the invoking user's site-packages. - name: Run tests - run: sudo apt-get install -y iperf3 && cd tests && ./run_tests.sh + run: cd tests && sudo ./run_tests.sh + + # The proxy engine's own suite: HTTP/2, HTTP/3 and QUIC termination, the Python worker's + # framing and deadlines, the UDP relay — much of it reachable from no integration test, + # and none of it was run before a release. In the image stage built for it, which is + # the compiler plus the filter library the worker tests start. `backend/` is mounted + # rather than baked in because the worker tests launch `../modules/services/pyworker.py`. + engine_tests: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Build the engine's test image + run: docker build --target enginetest -t firegex-enginetest . + + - name: Run the engine's tests + run: >- + docker run --rm -v "$PWD/backend:/execute" -w /execute/proxysrc + firegex-enginetest cargo test docker_build: - needs: run_tests + needs: [run_tests, engine_tests] runs-on: ${{ matrix.os }} strategy: matrix: @@ -217,8 +248,121 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # The README's picture of the interface, retaken for every release so it never shows a + # version that no longer exists. From the demo build served locally rather than from the + # published demo: that deploys on its own schedule, and could still be the old one. + update_readme_screenshot: + needs: [run_tests, engine_tests] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: main + + - name: Set up bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Playwright + run: | + python -m pip install --upgrade pip + pip install playwright + playwright install --with-deps chromium + + - name: Build the demo + working-directory: frontend + run: bun install --frozen-lockfile && VITE_VERSION="${{ github.ref_name }}" bun run build:demo + + - name: Screenshot it + working-directory: frontend + run: | + bunx vite preview --port 4173 --strictPort & + timeout 60 bash -c 'until curl -sf http://localhost:4173/ > /dev/null; do sleep 0.5; done' + python3 ../docs/screenshot_demo.py http://localhost:4173/ + + - name: Commit the new screenshot + uses: stefanzweifel/git-auto-commit-action@v5 + with: + commit_message: "Update README screenshot for ${{ github.ref_name }}" + file_pattern: 'docs/Firegex_Screenshot.png' + branch: main + + # `firegex` carries a C extension — the llhttp binding in `fgex-lib/llhttp/` — so it + # is no longer one universal wheel. A wheel is built per platform and per interpreter + # here, and the sdist goes up beside them: where no wheel matches, pip compiles from + # that. Compiling is a thing pip knows how to do; assuming a system library is already + # installed is not, which is why the parser ships as source rather than as a `ctypes` + # binding against the distribution's `libllhttp`. + build_firegex_wheels: + needs: [run_tests, engine_tests] + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest # manylinux + musllinux, x86_64 + - os: ubuntu-24.04-arm # and the same for aarch64 + # 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: + - uses: actions/checkout@v4 + - name: Extract tag name + id: tag + shell: bash + run: echo TAG_NAME=$(echo $GITHUB_REF | cut -d / -f 3) >> $GITHUB_OUTPUT + - name: Update version + shell: bash + run: >- + 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 + # 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 + # raise its *own* exception on malformed traffic, and this clean environment — + # no `pyllhttp`, no developer's leftovers — is the only place that is tested. + CIBW_TEST_COMMAND: python {package}/wheel_smoke.py + - uses: actions/upload-artifact@v4 + with: + name: wheels-${{ matrix.os }} + path: wheelhouse/*.whl + pypi_publish_firegex: - needs: run_tests + needs: build_firegex_wheels runs-on: ubuntu-latest permissions: contents: read @@ -240,16 +384,27 @@ jobs: run: >- sed -i "s/{{VERSION_PLACEHOLDER}}/${{ steps.tag.outputs.TAG_NAME }}/g" fgex-lib/setup.py; sed -i "s/{{VERSION_PLACEHOLDER}}/${{ steps.tag.outputs.TAG_NAME }}/g" fgex-lib/firegex/__init__.py; - - name: Build package - run: cd fgex-lib && python -m build && mv ./dist ../ + # The sdist is what pip falls back to where no wheel matches, so it has to carry + # the parser's C sources — `MANIFEST.in` is what puts them in. + - name: Build the sdist + run: cd fgex-lib && python -m build --sdist && mv ./dist ../ + - name: Collect the wheels built for every platform + uses: actions/download-artifact@v4 + with: + pattern: wheels-* + merge-multiple: true + path: dist - name: Publish package uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} + # After `firegex`, never beside it: this package is nothing but `firegex==`, so + # published while a wheel job failed it is an alias for a version that does not exist, + # and `pip install fgex` fails for everybody until somebody notices. pypi_publish_fgex_alias: - needs: run_tests + needs: pypi_publish_firegex runs-on: ubuntu-latest permissions: contents: read diff --git a/.gitignore b/.gitignore index 82c5647..017b096 100644 --- a/.gitignore +++ b/.gitignore @@ -1,53 +1,69 @@ -**/*.pyc -**/__pycache__/ -**/.vscode/** -**/.vscode/ -**/.mypy_cache/** -**/.mypy_cache/ - -**/node_modules -**/.pnp -**/.pnp.js - -# testing -/frontend/coverage -/fgex-lib/firegex.egg-info -/fgex-lib/dist -/fgex-lib/build -/fgex-lib/fgex-pip/fgex.egg-info -/fgex-lib/fgex-pip/dist -/backend/db/ -/backend/db/** -/frontend/build/ -/frontend/build/** -/frontend/dist/ -/frontend/dist/** -/backend/modules/cppregex -/backend/binsrc/cppregex -/backend/modules/cpproxy -/backend/binsrc/cpproxy -/backend/modules/proxy -/docker-compose.yml -/firegex-compose.yml -/.firegex-compose.yml -/.firegex-conf.json - -/.firegex-standalone.pid - -/firegexfs/** -/firegexfs - -/firegex.py -/tests/benchmark.csv -/tests/comparemark.csv -# misc -**/.DS_Store -**/.env.local -**/.env.development.local -**/.env.test.local -**/.env.production.local - -**/npm-debug.log* -**/yarn-debug.log* -**/yarn-error.log* -.claude \ No newline at end of file +**/*.pyc +**/__pycache__/ +**/.vscode/** +**/.vscode/ +**/.mypy_cache/** +**/.mypy_cache/ + +**/node_modules +**/.pnp +**/.pnp.js + +# testing +/frontend/coverage +/fgex-lib/firegex.egg-info +/fgex-lib/dist +/fgex-lib/build +/fgex-lib/fgex-pip/fgex.egg-info +/fgex-lib/fgex-pip/dist +/backend/db/ +/backend/db/** +/frontend/build/ +/frontend/build/** +/frontend/dist/ +/frontend/dist/** +/backend/modules/cppregex +/backend/binsrc/cppregex +/backend/modules/cpproxy +/backend/binsrc/cpproxy +/backend/modules/fgex-proxy +/docker-compose.yml +/firegex-compose.yml +/.firegex-compose.yml +/.firegex-conf.json + +/.firegex-standalone.pid + +/firegexfs/** +/firegexfs + +/firegex.py + +# A benchmark run's own output. `bench/results/` holds the ones that were kept on +# purpose, so only the default filename a run drops beside the script is ignored — +# the three paths here before named scripts that moved into bench/ or were deleted. +/tests/bench/benchmark.csv +/tests/bench/*.csv +!/tests/bench/results/*.csv +# misc +**/.DS_Store +**/.env.local +**/.env.development.local +**/.env.test.local +**/.env.production.local + +**/npm-debug.log* +**/yarn-debug.log* +**/yarn-error.log* +.claude + +# Rust proxy engine build artifacts +/backend/proxysrc/target/ + +# pytest +**/.pytest_cache/ + +# The vendored HTTP parser, built in place by an editable install. +/fgex-lib/firegex/_llhttp*.so +/fgex-lib/firegex/_llhttp*.pyd +/fgex-lib/build/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..81b6835 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,469 @@ +# AGENTS.md + +Guidance for any coding agent working in this repository, and the one copy of it. Tool-specific +files — `CLAUDE.md` and anything like it — point here rather than repeating this, because two +copies of a document this long drift the first time one of them is edited, and the drift is +invisible: each agent reads its own file and neither is told the other now says something else. +That already happened once, and the stale copy went on describing rules that had been replaced. + +## What this is + +Firegex is a firewall built for CTF Attack-Defense competitions: it sits in front of a vulnerable service and blocks/mangles malicious traffic before it reaches it. Behind a single FastAPI backend and React frontend it ships **services** (a network layer plus an ordered chain of filters) and plain nftables rules (`firewall`). That is the whole product surface: `nfregex`, `nfproxy`, `tls` and `porthijack` were folded into `services` as choices rather than modules. + +## Commands + +### Running the whole app (Docker or standalone) +```bash +python3 run.py # build+start (Docker if available, else standalone chroot on Linux) +python3 run.py --standalone # force standalone mode (no Docker) +python3 run.py stop [--clear] # stop (optionally wipe the DB volume/rootfs) +python3 run.py restart # recreate the container WITHOUT rebuilding (stale image if source changed) +python3 run.py status +python3 run.py config --password [newpass] # change the running instance's password; omit the value to be prompted interactively +python3 run.py config --show # print the persisted settings (port, host, socket dir, auth) +python3 run.py start --unsafe-disable-auth # no authentication at all — see "Authentication" below +python3 run.py restart --env FGEX_PROXY_FILTER_TIMEOUT_MS=5000 # anything run.py has no opinion about +``` +`--env KEY=VALUE` (repeatable, `KEY=` removes one) is the *only* supported way to set what the engine and the backend read straight out of their environment — `FGEX_PROXY_FILTER_TIMEOUT_MS`, `FGEX_STREAM_MAX_SIZE`. (`FGEX_PROXY_QUIC_ALPN` was the example here; it is gone — the engine reads each client's list off its first packet, see QUIC below.) It exists because `run.py` **rewrites** `.firegex-compose.yml` on every start, so a variable hand-edited into it is gone by the next one, which is what the QUIC documentation's "set it in the container's environment" silently ran into. It is stored in `.firegex-conf.json` like the other flags, reaches the standalone chroot as well as the container, and refuses the names `run.py` sets itself (`MANAGED_ENV`) rather than letting two writers of one variable decide by list order. +`run.py` has zero external dependencies (stdlib only) by design. `restart` runs `docker compose up -d --force-recreate`, so it re-reads the generated compose file and re-applies env vars (`ALLOWED_IPS`, `PROXY_IP_HEADER`, `UNSAFE_DISABLE_AUTH`) — a plain `docker compose restart` would keep the old container environment and silently ignore those. It still does **not** rebuild the image; after a source change use `stop` then the bare start command to force `docker compose up -d --build`. + +Most `start`/`restart` flags are persisted to `.firegex-conf.json` and become the default for later runs, so a bare `python3 run.py start` inherits whatever was last passed. + +### Frontend (`frontend/`) +```bash +bun install # bun.lock is canonical +bun run dev # vite dev server +bun run build # tsc && vite build +npx tsc --noEmit -p . # typecheck only (no separate lint script is configured) +``` + +### Backend (`backend/`) +Needs Linux with `NET_ADMIN` (nftables/nfqueue) and root, so it's normally only run via `run.py` (Docker or standalone). Direct iteration on a Linux box/VM: +```bash +cd backend && DEBUG=1 python3 app.py DEBUG +``` + +### Tests (`tests/`) +**One suite, one runner: `pytest`.** Everything drives a **live, already-running** firegex (`http://127.0.0.1:4444` by default), root/Linux, except `unit/` which needs nothing running at all. + +```bash +cd tests +sudo ./run_tests.sh # installs deps, waits for the instance, runs pytest +sudo ./run_tests.sh --set-pass # against one still in its initial-setup state +sudo ./run_tests.sh unit # the ones needing no instance +pytest -k udp # one subject +pytest --layer proxy --no-ipv6 -x # narrow, stop at the first failure +pytest standalone # restarts the instance; opt-in, see below +``` + +**Run it as root, through the script.** Most of the suite only drives the API over HTTP and +works fine without, which is why it was run unprivileged for a long time — but what needs +root then *skips*, and a case that always skips proves nothing. `sudo ./run_tests.sh` is +what makes those run, and going through the script rather than `sudo pytest` is what makes +it work at all: `pip install --user` put every dependency in the invoking user's +site-packages, which a root process does not read, and the editable `firegex` is a `.pth` +there — a file only processed for *site* directories, so putting that path in `PYTHONPATH` +imports pytest and still leaves `pyworker.py --check` unable to find the filter library. +The script installs into the environment it is running in, which resolves all of it. Two +things that trip a root run were found by trying it, and both are fixed rather than worked +around: the `test` group in `tests/pyproject.toml` was missing `fastapi` (see the file for why one unit test +needs the backend's own dependency, and why its absence stops the *whole* run rather than +one file), and `./run_tests.sh unit` — advertised in the script's own help — took `unit` as +a **password** and ran everything against an instance it could not authenticate to. + +| Directory | Needs an instance | What it holds | +|---|---|---| +| `unit/` | no | `firegex.regex` against libhs, the `firegex.pyfilters` models, the address→nftables translation, and `fgex pyfilters` run as its own process in front of a local echo (`test_proxysim.py` — nothing else ran the simulator) | +| `integration/` | yes | the product, parametrised over the combinations | +| `standalone/` | yes, and restarts it | settings read once at process startup | +| `helpers/` | — | the API client, stand-in services, certificates, the traffic `Channel` | +| `bench/` | yes | measurement, not testing; `pytest` does not collect it | + +**The combinations are parameters, not a shell loop.** `integration/conftest.py` holds `Layer(transport, ipv6, tls)` and the fixtures that fan out over it — `inspecting_layer`, `filtering_layer`, `proxy_layer`, `tls_layer`, `external_layer`, `any_layer`. Running both network layers with the same filters is the point: a filter that only works on one of them is exactly the failure the unified model exists to prevent. This was a shell script invoking one 1100-line program eight times, which meant the first failure ended the run and the other seven combinations were never reached, and a host without IPv6 on loopback failed rather than skipped. + +Three things that have to stay true of this layout: + +- **`helpers/`, never `utils/`.** The backend's own package is called `utils`, and this directory's used to be too — so whichever imported first won, and `unit/test_nftables_addresses.py` (which imports the backend) and everything importing `helpers.firegexapi` could not be collected in one pytest process. That name collision is why the suite *could not* be unified before. +- **The interception canary.** It is perfectly possible to reach the API of an instance whose datapath protects a different machine — a container without host networking, a VM with the port forwarded. Every call returns `ok` and not one packet goes near any of it, which without a check reads as dozens of "the filter did not block" failures rather than as a suite pointed somewhere it cannot reach. `datapath_in_our_path` asks once, plainly, and everything needing interception is skipped with the reason. +- **Skip with a reason rather than fail.** A laptop with no IPv6 loopback, a non-root run, an instance elsewhere — none of those are firegex bugs, and `-ra` is in `addopts` so the summary says which half of the suite the machine declined. + +`Channel` (`helpers/traffic.py`) is the one place that knows whether TLS is in the way. The old suite branched on it at every traffic check, about twenty times. + +`helpers/quicserver.py`'s `h3_is_blocked` counts **only a closed connection** as a block, because that is what a refusal on that layer does; a malformed request, an unreachable port and a timeout are failures with nothing to do with a filter, and catching them too let "the filter blocked it" pass for reasons that are not a block. It found one immediately: `authority()` brackets an IPv6 literal, and without that every IPv6 exchange was answered `H3_MESSAGE_ERROR` by the far end and read here as a refusal. + +Run the suite **twice in a row** when changing anything about addresses or TLS: state left behind by the first pass only shows up on the second. + +`standalone/` is opt-in because it drives `run.py stop`/`start` itself — the IP allowlist and the boot-time auth mode are read once at startup, so there is no way to test them against a running instance. It restores an unrestricted one at the end, which every other module depends on. + +`tests/integration/test_grpc.py` and `tests/integration/test_http_versions.py` are the two that say whether HTTP/2 is really filtered. The second is **the** test of the whole rendering: one filter file, unchanged, in front of an HTTP/1.1 service, an HTTP/2 one and an HTTP/3 one, blocking the same request on all three — it can only pass because each version is shown to the chain as the same HTTP/1.1. `grpcio` is an optional dependency alongside `aioquic`, and everything needing it skips with a reason, because a gRPC service is the honest way to stand up a real HTTP/2 service with an HPACK-compressed path and a trailer-section status. + +The Rust datapath has its own suite, which needs no running instance but does need the production toolchain (vectorscan and Python 3.14 — the filter library imports `compression.zstd`): +```bash +docker build --target compiler -t firegex-compiler . +docker run --rm -v "$PWD/backend:/execute" -w /execute/proxysrc firegex-compiler cargo test +cd backend/proxysrc && cargo test # on a host with libhs + fgex installed +cargo test --release --test throughput -- --ignored --nocapture # the matcher benchmark +``` +Mount `backend/`, not `backend/proxysrc/`: `tests/pyworker.rs` launches `../modules/services/pyworker.py`. That file imports `firegex` and the library imports `brotli`, neither of which the compiler stage installs — without them on `PYTHONPATH` eight pyworker tests fail with a `ModuleNotFoundError` buried in captured output and read as real failures. The runtime image has both (`/usr/local/lib/python3.14/site-packages` and `/usr/local/lib64/...`), and the repo's own `fgex-lib` has to come *first* on the path or a stale image's `firegex` wins. + +The suite runs against an instance with authentication turned off as well: `login()` reads a 403 there as "there is nothing to log in to" rather than as a failure, since every request is already allowed. `integration/test_auth.py` is the exception and skips itself — it is about passwords, and those endpoints answer 403 in that mode by design. Note `BearerSession.post` copies the header dict rather than writing into it: the form `Content-Type` belongs to the login request alone, and it used to stick, which went unnoticed only because a successful login replaces the whole dict a moment later. + +CI (`.github/workflows/release.yml`) runs **only on `release: published`**, not on pushes or pull requests — nothing validates a PR automatically. Two jobs gate every publish job below them, both on `ubuntu-latest` and `ubuntu-24.04-arm`: `run_tests` does `python3 run.py start -P testpassword` then `cd tests && sudo ./run_tests.sh`, and `engine_tests` runs the Rust suite (`cargo test` in `proxysrc`) in the Dockerfile's `enginetest` stage — the compiler stage plus the filter library the pyworker tests start. That suite was not run before a release at all, though much of what it covers (HTTP/2, HTTP/3 and QUIC termination, the worker's framing, the UDP relay) no integration test reaches. `enginetest` sits **before** `final` on purpose: a build with no target, which is what compose does, takes the last stage. The tests' dependencies are pure python; `iperf3`, matplotlib and numpy belong to the `bench` group of `tests/pyproject.toml` and CI installs none of them. + +## Architecture + +### Three tiers +React (Vite + Mantine + react-router + TanStack Query + socket.io-client) → FastAPI backend → per-transport datapath binaries that do the actual interception. The backend mostly orchestrates: SQLite-backed config, spawning/talking to those processes, and deploying the built React app. + +### The layers are compared in the interface, not only in the docs +`components/Services/LayerChoice.tsx` puts the trade in front of the operator where the choice is made: a table of what each layer costs, plus the paragraph for the one selected. `transportSummary()` carries the same sentence onto every service row and detail page. Keep them in step with `docs/services.md` — the point is that an operator never has to read documentation to find out that one layer reassembles in the kernel and the other has a kernel-guaranteed fail-open. There used to be a `losesClientAddress()` beside them, marking the one combination that gave the client's address up; source transparency is unconditional on every layer now, including UDP, so it answered `false` for every input and the badge it gated could not render. Do not reintroduce it without a combination that actually loses something. + +### The two layers (`backend/modules/services/`) +The central idea, and the thing to preserve when changing anything here: **how traffic is intercepted and what is done with it are independent choices.** + +- **Network layer** (`transports.py`): `nfqueue` — packets queued to userspace, verdict handed back, nothing terminated, kernel fail-open via `NFQA_CFG_F_FAIL_OPEN` plus `bypass` on the nft rule. `proxy` — the connection is terminated and reopened, which buys exact rewriting, kernel reassembly, backpressure and an ordered chain, and costs the kernel backstop (rebuilt by hand in `proxysrc/src/filter.rs`). + + **The proxy is not the slow one, and the docs used to say it was.** Measured: 2.2× NFQUEUE's bulk throughput at one thread and 4.7× at eight, because NFQUEUE pays a userspace round trip *per packet* while the proxy pays once per connection and then the kernel moves the bytes. `NTHREADS` reaches the Rust engine too since 5.0.0 — `main.rs` builds its tokio runtime with that many workers rather than `#[tokio::main]` taking every core, so `run.py --threads` means one thing on both layers instead of one thing on one and nothing on the other. On short connections the two are indistinguishable — that claim cost three contradictory measurements before `tests/bench/connbench.py` started interleaving the layers within each pass instead of measuring one and then the other, which was handing the machine's drift to whichever went second. The trade between the layers is fail-open and transparency, not speed; keep `docs/services.md`, `LayerChoice.tsx` and `transportSummary()` saying that. `external` — a stateless destination rewrite that puts a proxy the operator wrote in the path (this is what `porthijack` used to be); no firegex process is involved, so `check()` refuses any filter, since a filter attached there would be one that never runs. +- **Filter layer**: `regex` (patterns compiled into one hyperscan database) or `pyfilter` (the user's Python). A service holds an ordered list of them. + +`Transport.check()` is where an impossible combination is refused **before** anything starts: more than `MAX_CHAIN_POSITIONS` filters on `nfqueue`, any filter at all on `external`, and the `tls` protocol anywhere but the proxy layer. It is asked twice: at creation with an empty chain, for what the service alone makes impossible, and again whenever a chain is pushed. UDP was never removed from the datapath: `cppregex` runs in block mode for it and `cpproxy` handles it, and the only thing that had gone missing was the choice in the UI. + +**Telling the Python worker about a connection never waits on it.** `connection_opened`/`connection_closed` are called from the relay itself, on the async runtime; they used to take the worker's lock there — held by whatever exchange was in flight, up to its whole deadline — and start the worker if it was not running, so one connection feeding a hanging filter held every new connection of the service on the threads carrying all the others. They queue their frames now (`PyWorkerRule::pending`, capped) and the next exchange, which runs on a blocking thread, writes them ahead of its chunk. A ruleset reload prepares its filters on a blocking thread for the same reason: starting a worker is an interpreter start-up. + +**UDP on the proxy layer (`proxysrc/src/udp.rs`).** The TCP path recovers where a connection was headed with `SO_ORIGINAL_DST`, which the kernel implements **for TCP and SCTP only** (`ENOPROTOOPT` otherwise). So UDP is relayed with **one socket per protected address**, each with its upstream fixed — nothing has to be recovered because nothing was lost. The engine prints one `UDP | ` 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. A flow outlives its service restarting: a datagram sent while the service was down comes back as ICMP "port unreachable", which the flow's connected socket reports once as `ConnectionRefused`, and `replies` carries on past it. It used to end the task reading the service's answers while the flow stayed in the table, and a client that kept talking kept the flow from ever going idle — so it never heard from the service again, while a new client did (`tests/udp.rs::a_flow_outlives_its_service_restarting`). + +**Each flow's datagrams are judged by a task of the flow's own** (`forward` in `udp.rs`, fed through a channel of `FLOW_QUEUE`). The receive loop used to run the chain itself, one datagram after another for every client of the address, so a filter taking a second over one client's datagram was a second in which no other client's was read, and the socket buffer dropped theirs behind it. The loop only dispatches now; within one flow the order is kept, and a flow sending faster than it is judged has its own datagrams dropped past the queue, as the socket buffer would. Pinned by `tests/udp.rs::a_slow_verdict_for_one_client_does_not_hold_up_another` and `one_flows_datagrams_keep_their_order`. + +**UDP flows are forgotten in conntrack whenever the rules that steer them change** (`modules/services/conntrack.py`, ctnetlink over `NETLINK_NETFILTER` — no `conntrack` binary, no dependency). Conntrack translates a flow at its first packet and a UDP flow lasts for as long as datagrams keep arriving, so on this layer the entry decided where each datagram went: a flow older than the service went on straight to it, **unfiltered, for as long as it kept talking** (anybody could open one before the protection went up); one carried across a restart stayed with the engine being retired, under its old filters, keeping that engine alive until the drain gave up; and once that engine was gone the entry sent every datagram to a relay port nobody held, refreshing itself each time, until the client happened to fall silent for two minutes. `ServiceManager._claim_udp` runs after the redirect is installed (start, address added, rules put back), `_release_udp` after it is taken off on a stop (not on a restart, whose start half claims them a moment later), and `_engine_gone` once an engine has exited, for the flows still pointed at its relay ports. A datagram address forgets every flow headed for it (`moved`): changing engines between two datagrams costs a flow nothing but the filter's own state. A **QUIC** address forgets only what nothing is carrying (`unsteered`: never redirected, or redirected to a port nobody is bound to, read from `/proc/net/udp{,6}`), because a QUIC connection moved to another engine ends. The engine's own dials (`SELF_MARK` on the conntrack entry) are never touched, nor is anything TCP. A failure is logged and changes nothing else. `tests/integration/test_udp.py` holds a flow open across a start, a restart and a killed engine. + +`ConnectionMeta.l4` carries the transport to the filter, so the library's stream and HTTP models key off what the engine says rather than a hardcoded `True` in the worker. It is an enum and not a boolean because QUIC broke the question in two: a QUIC stream is ordered and reliable, so every model built on a stream applies to it, and it rides on UDP, so every rule that matches the wire matches UDP — one flag answering both got one of them wrong whichever way it was set. The worker is sent both words (`is_tcp` for the wire, `l4` for what can be built on it) and `RawPacket` exposes `l4` and `is_stream` beside `is_tcp`; `is_stream` is what `models/tcp.py` and `models/http.py` gate on. Absent, `l4` derives from `is_tcp`, because the NFQUEUE binaries carry TCP and UDP and have nothing else to say. + +### A service is a list of addresses +`service_addresses` holds `(ip, port, proto)` rows; the service itself holds the name, transport, protocol and TLS material. One daemon routinely answers on more than one address — a v4 and a v6 one, a public and an internal one — and all of them deserve the same chain. Two services with hand-copied chains is how one of them silently stops being protected. + +Consequences worth keeping: +- **Adding an address to a running service drops nothing.** `ServiceManager.address_added` installs only that address's rules against the datapath already running (`self._steer` holds what to point at). On TCP and TLS the proxy listener is dual-stack from the start, and on UDP new relays are bound dynamically on the fly, so adding any address (IPv4, IPv6, UDP, TCP, TLS) never drops connections or restarts the service. +- **`address_removed` drops the address from `self.srv.addresses` itself** rather than re-reading the table, because the caller takes the rules back *before* deleting the row. Re-reading would put it straight back, and the next restart would re-protect an address that no longer exists — with TLS, by claiming a port for a stream nobody owns, which then refuses the next service that wants it. +- **`proto` is denormalised onto each address** and rewritten when the service's changes. That is what makes `(ip, port, proto)` a usable uniqueness key, so a TCP service and a UDP one can share an address exactly as the kernel allows. +- **The `external` proxy endpoint belongs to the address, not the service**, and a partial unique index forbids two addresses sharing one: the return rule recognises the operator's proxy by address and port to put the original port back, so two addresses behind one endpoint could not be told apart on the way out. The endpoint is the hand-off layer's alone: `edit_service` clears it from every address of a service moving to another layer, or it stayed in the index read by nothing and refused another service's hand-off. `_hijack_ip` refuses one that is not a single address in the family of the address it stands in for, at the moment the row is written. +- **The proxy listener is dual-stack whenever the host supports IPv6** (`[::]:0`), and `transparent::unmap` turns the v4-mapped peer and local addresses back into IPv4 before anything else in the engine touches them — conntrack, the source-preserving dial and the metadata a filter reads all want the address the packet actually had. +- **Addresses accept network interface names as well as IPs.** An entry in `service_addresses` can be an IP/CIDR or a network interface name (1-15 chars, e.g. `eth0`, `wg0`, `tun0`, `lo`), checked via `utils.is_interface_name` and marked by `Address.is_interface`. The interface is not a second spelling of an address, and the interface is what the operator is choosing when the address is somebody else's to hand out. The frontend says which of the two it understood *while it is being typed* — `addressKind` in `components/InterfaceInput.tsx` puts it on a badge in the field, because `eth-0` is a perfectly good interface name and a perfectly wrong address, and the two are matched by completely different rules. That rule is written once on the frontend too, as `isInterfaceName`/`isIpAddress` in `js/utils.tsx`, reading the same charset the backend does — it used to be pasted into `AddEditService.tsx` and `AddressList.tsx`, two copies of a rule the picker beside them did not consult at all, which is how the hand-off layer came to offer interface names in the list and refuse them on submit. `InterfaceInput` lists interfaces and addresses as two groups, each interface carrying what it holds right now (one question decides between them: "and what is on it"), and stays a combobox rather than a select because an address a service will answer on need not exist on this host yet. **Link-local is not offered** — every interface carries an `fe80::` it was never configured with, which doubled the list with addresses that would put a service where no client looks for it, the same reason `udp_relay_host()` takes them last; one typed by hand is still accepted, and an interface holding nothing else says "link-local only" rather than listing it. A row is **the badge as a column, and everything else in the one beside it** — value, then the note, wrapping inside that column and never under the badge. Neither of those two is given a fixed share, because which one is long changes with the group (the addresses a link carries, or the address itself): fixing it took the whole width from a full-length IPv6 and left the other wrapping a character per line, a row a screen tall beside an address cut off mid-digit; and three items in one wrapping row put that same address at the container's left edge, under the badge, three lines tall. The list's own width is **computed from the longest value in it**, between a floor and a ceiling, and three simpler rules were wrong first: inherited from the content it collapsed to the field's width, because content allowed to wrap asks for less than it needs to stay on one line; pinned to the widest row any host could produce, every list was that wide beside fields half the size; and a floor alone put the one long address back over three lines. A host answering on `10.60.3.1` gets the floor, one carrying a full-length IPv6 gets the room to show it with its note beside it, and monospace is what makes the arithmetic honest. **The row beside them is laid out by the same arithmetic, and got it wrong the same way.** Its note (`now 10.10.0.3`, `on eth0`) is a flex item with a basis wide enough for a full-length IPv6, because a basis is what decides whether an item shares a line or drops to one of its own — at 80px it shared, and the first row with a couple of tags on it squeezed the note into the gap that was left and broke an IPv6 across three lines mid-digit. A min-width beside that basis is not the fix and was tried: on a phone the column is narrower than any floor worth setting, so the floor pushed the address past the card's edge, and it buys nothing anyway — an item only shares a line when its basis fitted, and on a line of its own it is already as wide as it can be. The row's own container grows to what the buttons leave rather than to its contents, or each row is as wide as its own longest line and two rows of one list wrap opposite ways: the one carrying a full IPv6 kept its note inline while the one saying `now 127.0.0.1` pushed it down. `overflowWrap: anywhere` stays as the last resort, which is what a 375px screen needs and nothing wider should reach. **`AddressList` reads in the same words it was chosen in** — the same badge at the same `BADGE_WIDTH`, then the value, then `on eth0` for an address and `now 10.10.0.3` for an interface — because a row an operator comes back to check, described differently from the list it came out of, is a reason to wonder whether it is the same thing. What is *shown* is `bareAddress`: the backend stores what it parsed, so `127.0.0.1` comes back as `127.0.0.1/32`, a spelling nobody types that reads like a range beside real ones; a host-length prefix is dropped for display and a real one is kept, which is also what tells `addressKind` apart. A real range survives an edit for the same reason — the modal seeded itself by cutting at the first slash, so opening "move this address" on a `/16` and saving narrowed a service to one host of it. Where interfaces are not an answer — the `external` proxy endpoint, and the service address on that layer — they are not listed at all and a name typed there is badged red, rather than offered and then refused on submit. Nftables matches use `meta iifname ` for inbound/prerouting and `meta oifname ` for outbound/postrouting/route. On `proxy` (TCP/TLS), redirect rules match `iifname`; the return path needs no per-interface rule, because it follows the conntrack mark (below). **The output hook cannot match either of those, and matches the interface's addresses instead.** `iifname` means nothing for a packet this host generates, and `oifname` is the wrong question twice over: `oifname eth0 dport 80` says *we* are calling somebody else's port 80, which is the host's own outbound traffic and none of firegex's business — and a connection from this host to one of its **own** addresses is routed through `lo` anyway, so it would never match. So `_add_proxy` resolves what the interface carries (`interface_addresses`, link-local dropped for the reason `udp_relay_host` takes it last) and writes one `daddr` rule per address. For a while it wrote nothing there at all, on the correct observation that `oifname` would hijack the wrong traffic, and the consequence was a service intercepted for the outside world and silently not for anything on the box — reported by an operator curling their own service and watching a blocked payload sail through. The cost is that an address the interface gains later is not covered *for traffic this host generates* until the service restarts; inbound keeps following the interface, and `udp_relay_host` pays the same price for the same reason. Each of those rules carries the interface's name as a **comment**, because nothing else in it does: `delete()` finds rules by what they match, and without it every one would outlive the service that installed it as a redirect to a port nobody is listening on. On UDP `proxy`, the primary IP assigned to the interface is resolved dynamically via `psutil.net_if_addrs()` to bind the relay. On `nfqueue`, interface traffic is queued natively in prerouting/postrouting without needing an IP. On `external`, interfaces are disallowed because return rewrites require a concrete IP. + +**An address says what is spoken at it and where the service is, and those are two different questions from the one the rules ask.** `service_addresses` carries `edge` — `tcp`, `tls`, `udp` or `quic`, the same words a service speaks minus `http`, which is the one that means "ask the addresses" — and `target_port`, which is where the traffic goes when that is not the port it arrived on. The two are scoped by different things, and confusing the two scopes is what the first version of this did. `edge` is only ever *chosen* on an `http` service, because that is the only protocol whose addresses are not all the same; elsewhere it is the service's own and a value disagreeing with it is refused, while `proto` (what the kernel matches) stays `L4.l4_of(edge)`. `target_port` is a question for the **layer**: publishing is being reachable one more way than the service is, and what that takes is something that *dials*. The proxy terminates the connection and opens the one to the service, so it can open it on another port, whatever the protocol — a plain `tcp` service published on a second port is exactly as real as an HTTPS one, and `_address_target()` was keyed on `http` only because `http` was the protocol the case was first noticed on. NFQUEUE hands the kernel a verdict on packets already on their way and opens nothing; `external` gives the traffic to the operator's own proxy, which decides for itself where the service is. On those two it is **refused** rather than stored, because a port nothing reads would tell an operator their service is published while it is merely intercepted. **And every one of them is editable after the fact.** `edit_address` rewrites `ip_int`, `port`, `proto`, `edge`, `target_port` and `upstream` — it took the first two and the proxy endpoint alone for a while, which made the rest add-only and left an operator who had set one while creating the service with no way back to it but deleting the address. The edit already takes that address's rules back and reinstalls them, which is the whole cost of changing any of it, and only that address stops being steered meanwhile. Two properties hold it together: everything the row will say is worked out **before** the old rules come back, so a refusal changes nothing; and a field the caller did not mention **keeps what the row had**, because this is also the endpoint that merely moves an address, and a move sending only an address and a port would otherwise quietly unpublish it. Taking one off is a value (`0`, `same`), not an absence. The interface asks for all of it in one place, `AddressOptions.tsx`, used by the ⚙ on each row of the creation form and by the modal on the service's page: the capability rules and the wording were written twice before, which is how the form came to offer what the backend then refused, and how one setting came to be described in two sets of words on two screens. The wording rule there took two corrections to settle. It began as field names — "The service is on port", "The service behind speaks" — which were true and left the operator to work out the effect. Rewritten as pure effect, "Send it to another port" was **read backwards**: with a port at each end, an operator with a cleartext service on `:80` who wanted it reached over TLS on `:443` typed `80` as the address and `443` here, and got a service intercepted where it already listened and published nowhere. So the label names the **fact the operator supplies** and the description says **what firegex does with it**, naming both ends: "The service is on another port" over "Clients arrive on :443 — firegex dials the service on :80". And because the misreading is really about the model rather than the sentence, the empty state says where a second way in comes from: another address, not another port in this box. + +`target_port` is what makes firegex a **publisher** rather than only a transparent interceptor, on the addresses that use it: one daemon answering HTTP/1.1 in the clear on `:80` is reached over TLS on `:443` and over HTTP/3 on a UDP port, all three pointed back at `:80`, with one chain over them. It is per address and not per service because that is the shape of the thing — the service has one place it listens and several places it is published. + +**`resolve_target()` and `service_at()` are the two halves and must not be merged again.** The first is where the world knocks, which is what a *rule matches*; the second is where the service is, which is what the engine *dials*. They were one function for as long as those were one answer, and making it return the target was tried first: the rules then redirected the service's own port twice and left the published port unprotected, which is a service that looks configured and answers nothing. Everything that matches asks the first (`FiregexTables.add`, `Rule.matches`); everything that dials asks the second (`_udp_targets`, the relay key in `add`, `_published`). + +The TCP side needs telling because one listener fronts every address and recovers each connection's destination from conntrack: `FGEX_PROXY_TARGETS` at startup and `PUBLISH`/`WITHDRAW` on the control channel while it runs, both carrying `
|[=]`. UDP and QUIC need none of it — a relay's upstream is fixed when it is bound, so `ADD_UDP` already answered both questions. **Only `tls` is sent as an edge**, because only it is a promise the engine keeps (a cleartext client on that port is refused); "in the clear" is the permissive default that still terminates a client bringing TLS, and making it strict was tried and broke every existing `http` service, whose single TCP address has always carried both and decided per connection. + +**The service form asks three questions and the address row asks one, and everything else is behind a button.** It had grown into a wall: a five-answer protocol picker, a service-wide upstream control, a layer picker greyed out to one option, and two ports plus an edge on every address row — all of it in front of an operator who wanted a TCP service on one port. The shape now is **is it encrypted → which protocol → its certificate**, then the addresses, then the layer; and `AddressRow.tsx` puts a service published elsewhere, a service answering in the clear, and the hand-off endpoint behind one ⚙ per address, offering only what applies to the service being configured. What is set comes back as a **tag under the address that opens the panel it came from** — folded is not hidden, and an option an operator cannot see they set is one that will surprise them at the worst moment. **The layer picker is not shown at all for a service that is decrypted**, because decrypting means terminating and one layer does that: a control with two disabled options and one answer is a question nobody is being asked. It is replaced by the sentence, not dropped, since the layer is a real property of the service — and an effect pins `transport` to `proxy`, because a form that hides a control still submits its value. + +**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. 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: + +- **There is one HTTP/1.1 rendering, and every terminated HTTP protocol is shown it.** `proxysrc/src/http1.rs` holds it; `h2.rs` and `h3.rs` are adapters to hyperium's `h2` and `h3` crates and nothing more. Two copies of the rendering would be two things a filter can be shown, and the day they drift the symptom is a pattern that matches over one version and not another with nothing to say so. For the same reason neither protocol is reimplemented here: HPACK, QPACK, multiplexing and flow control are the crates' problem. +- **Regex matching is hyperscan, everywhere — there is no second regex engine in the tree.** `binsrc/regex/` on the NFQUEUE side, `proxysrc/src/hyperscan.rs` on the proxy side, a binding rather than a reimplementation. Adding `regex` back to `Cargo.toml` would undo this. +- **A pattern blocks. There is no second action, and `rewrite` is not coming back.** It existed, and was withdrawn because it could not keep the promise it made: rewriting scanned one chunk at a time, since bytes already forwarded cannot be taken back, so a match straddling two chunks was never rewritten. Measured — a pattern sent whole arrived redacted, the same pattern split across two TCP segments arrived intact, and blocking (which scans in stream mode) caught both. The miss was not the problem; its silence was — no block, no log, no counter, just a rule the operator trusted that had quietly not applied. The removal has to be complete to mean anything, and it was half-done for a while: an `action` column that was gone from the datapath still had a `rewritten` field travelling from `debug.rs` through the backend model to a panel in `RegexDebugger.tsx` that could never render, a single-member `Action` enum in `firegex.regex`, and a `replace_with` on the frontend `Regex` type. If any of those spellings reappears, it is residue, not a feature. +- **A pattern is validated against the mode it will run in**, which is streaming, because that is how a pattern follows a connection across chunk boundaries. hyperscan does not accept quite the same patterns in every mode (`a{1,1000}b` streams but does not block-scan), and judging by the wrong one refuses rules that work — the same class of lie as a tester that disagrees with the engine. Block-mode scanning survives for one job only: reporting *where* a match starts, so the tester can highlight it. `debug.rs` reports `unscannable` for patterns that are valid, will run, and cannot be highlighted. +- **The C++ binaries swap their configuration through `std::atomic>`** (`regex_config`, `config`). The updater thread replaces it while every queue thread copies it per packet or per stream, and a plain `shared_ptr` read and reset from two threads at once is a data race: the copy can take a reference on a control block the reset has just freed, so an operator editing a rule under load could crash the binary with it. Neither binary defines `DEBUG` any more — `cpproxy` did, which printed the operator's whole filter source to its stderr, and so into the service log, on every reload. +- **A new configuration reaches the connections already open, on every engine.** The proxy layer hands a connection's next chunk to the new chain, `cppregex` resets its matchers when the version changes, and `cpproxy` now does the same with its filter contexts (`PyCodeConfig::version`, `follow_config`, `stream_ctx::clean_filters`, which leaves the TCP sequence bookkeeping alone). It kept `latest_config_ver` and never read it, so a stream ran the code it started with for as long as it stayed open: a check added against the attack in progress did not reach the connection carrying it. `cpproxy` also no longer runs a full `PyGC_Collect()` per packet, and runs one per `COLLECT_EVERY` (64) dropped contexts rather than per context. A full pass cost ~0.9 ms with the library loaded, and the subinterpreter's own collector is on (`before_loop`), so the per-packet one bought nothing. The per-context one did buy something, which is why it is batched rather than dropped: a context is a cycle (the module's functions hold its globals), and left to the automatic schedule a promoted one waits for a full pass triggered by object counts rather than bytes. Measured with a filter building a table at module level, 1500 short connections: ~8 s with a pass per context and memory flat at 95 MB; ~0.8 s with none, but memory settling around 300 MB; ~0.8 s and 135–160 MB batched. +- **A connection the NFQUEUE binary did not see open is still inspected.** Both binaries call `follower.follow_partial_streams(true)` in `before_loop`. libtins' default drops any stream whose SYN it missed, and on this layer that is the ordinary case, not an edge one: a new filter is a new process, a chain that changes shape is rebuilt, and a service can be started on a port that already has connections. Each time, every open connection went uninspected until it closed, and nothing said so. It was found by holding a connection open across adding a filter and watching the pattern go through (`test_a_filter_added_to_a_running_service_reaches_a_connection_already_open`, and `test_a_connection_open_before_the_service_started_is_inspected`). A stream met halfway has no handshake to say which end is the client, so `cpproxy` takes the direction from the packet (`pkt->is_input`, from the queue mark) instead of from which libtins callback fired; `cppregex` already did. A stream met halfway can be met mid-message, and so can one whose filter state was replaced (below): see the next bullet for what the HTTP model does about it. The proxy layer has nothing equivalent to fix: a connection made before its redirect existed was never NATed to it. +- **A filter that takes over a connection does not judge a message by its second half.** Three things give a Python filter a context that begins after its connection did: a queued stage meeting a stream halfway (above), `cpproxy` replacing its contexts for new code (`clean_filters`, which records the TCP streams in `stream_ctx::taken_over`), and a proxy-layer worker that never got the connection's open frame (new code, or respawned after a crash). Each sets `__firegex_joined_late` in that context's globals, and the HTTP model then treats a parse failure *before the direction has parsed anything cleanly* as having come in mid-message: that direction is carried without its HTTP filters until the connection closes, with one `[info]` line per process (`_met_mid_message`). It used to go to `invalid_encoding_action`: with REJECT a valid upload in flight was refused whenever a filter changed, and without it a working client was logged as "not HTTP". Measured on both layers before the fix, pinned by `test_a_request_in_flight_when_the_filter_changes_is_not_judged_by_its_second_half`. A connection met between two messages parses, is in step (`_in_step`), and answers to the usual rules from then on; a connection seen from its first byte never gets the benefit. The trade is deliberate — never break a working client over firegex's own change of state: an attacker holding a message open across a filter change keeps that direction out of the HTTP filters for the connection's life, where before the partial-stream fix every open connection was out of every filter. +- **An edit reaches only the filter it was made to.** Every edit reloads the whole chain, and a reload used to reset every Python filter's per-connection state — module globals, the HTTP parser's place, on the proxy the addresses told at open — over a pattern changed in some other filter. `_QueueStage.reload` no longer sends a stage the payload it last acknowledged (`_in_force`), which also keeps `cppregex`'s matchers mid-stream; the proxy carries a `PyWorkerRule` over into the new chain when `Filter::reuse_key` is unchanged (`parse_ruleset_reusing`: id, command, enabled functions, deadline and a hash of the code itself, since saving rewrites the same path). Pinned by `an_unchanged_worker_is_carried_into_the_next_ruleset_with_its_state` and `test_an_edit_to_another_filter_leaves_a_python_filters_connections_alone`. +- **The Python API is `firegex.pyfilters`, everywhere.** `cpproxy` embeds it; `modules/services/pyworker.py` runs it out of process. An earlier version of that worker defined a look-alike `@pyfilter` taking `(data, direction)`, which meant a filter written from the docs loaded on one transport and failed on the other. +- **A filter file's protocol is read off the file, never declared.** The library already decides when to call a filter from what its parameters are annotated with, so `infer_proto` in `firegex/pyfilters/internals` reads the same table back the other way: asking for an `HttpRequest` makes a file an HTTP filter, asking only for a `RawPacket` makes one run on anything, and asking for two *different* application protocols is refused naming both functions. `type_annotations_associations` is the single place a protocol is defined; a second list of names would be one more thing to keep in step, and the day it fell behind the symptom would be a filter that silently never runs. The backend stores the detected value on the row so the interface can show it, and `PUT .../code` is what recomputes it. Passing one in from outside is exactly what used to make an HTTP filter on a TCP service a 500: the *service's* protocol was sent to the datapath, so the filter was refused for asking for an `HttpRequest`, which is the only thing an HTTP filter does. +- **A filter file's functions run in the order the file defines them**, and the first to answer anything but ACCEPT ends the packet. `@pyfilter` sets a mark on the function and hands it back; `collect_pyfilters` reads a module's globals, which are in definition order. What that replaced was a *set of names* on a module-global attribute of the decorator, so the order was whatever hashing produced — which decided which function got credited with a block and whether an earlier `UNSTABLE_MANGLE` survived, with nothing in the file able to tell you which. The `position` column on `pyfilters` carries the same order to the interface, which used to list them `ORDER BY name`. Three consequences of that registry are also gone with it: `get_filters_info` had to clear it before and after (and reached it by `exec`ing import strings), two compilations could race, and a function registered from somewhere the module globals could not reach was registered and then not found. +- **A pyfilter file's functions are switched on and off individually, and the code is never edited to do it.** `pyfilters` holds one row per `@pyfilter` the code defines, reconciled with the file on every `PUT .../code`: functions that have gone are dropped, new ones arrive active, survivors keep the operator's `active` and `blocked`. Only the enabled names reach `__firegex_pyfilter_enabled`, which is already what decides whether the library ever calls a function. Three states are distinct all the way down (`Option>` in the spec, `*` / `=a,b` / `=` on the worker's argv): **absent** means every function the file defines — the case of a filter whose list was never derived, where defaulting to "none" would silently stop every filter running — while an **empty list** means the operator switched them all off. `ChainLink.enabled_functions` returns `None` for no rows and `[]` for all-inactive, which is the same distinction. +- **gRPC is a body format inside HTTP, not a protocol beside it, and that is what keeps it free.** `GrpcMessage`/`GrpcRequest`/`GrpcResponse` live under `"http"` in `type_annotations_associations` and nowhere else. The alternative — a `"grpc"` value in `filters.proto`'s CHECK and in `PROTO` — would have been a schema change, and changing that dict **deletes and recreates `db/services.db`**, so the price of a model would have been every service on the instance. Everything *around* a call already arrives through `HttpRequest`: the method is the path, the metadata are headers, the status is a trailer section. What did not was the body, which is a sequence of `[flag][4-byte length][protobuf]`. +- **The parser learned to release body chunks, and only the gRPC models turn it on.** A server-streaming or bidirectional RPC has a body that does not finish until the stream closes, so a model shown only finished bodies is called once, at the end — a log entry rather than a block. `release_body_chunks` on `InternalCallbackHandler` hands the message over as its body grows; it is off by default, and each gRPC model has its own `_parser_class()`, so `HttpRequest` keeps its contract exactly (twice: once with the headers, once with the whole message). Two details are load-bearing: the partial body is **undecoded**, because `content-encoding` is undone once at message-complete and half a gzip stream is not half a body (the models decline while one is in the way, which for real gRPC never happens — it compresses per message instead); and the cursor lives on the `InternalHTTPMessage`, not beside it, because a keep-alive connection carries many messages through one parser and a per-connection cursor would start the second one part-way in. Separate parser keys are not tidiness either: taking a frame *consumes* it, so two models sharing one parser would leave the second silently never running. +- **`RawPacket` is the only model that survives a datagram**, and that fact is stated once, as `DATAGRAM_SAFE_MODELS` in `routers/services.py`. Every other model declines to be built when `is_tcp` is false, so a filter asking for one on a UDP service would sit in the chain and never be called. `--check` reports the models a file asks for, saving refuses the combination naming them, and `edit_service` asks the same of every Python filter of a service being switched to UDP (`_filters_needing_a_stream`) — `Transport.check()` can only see the stored protocol, which says whether a file parses HTTP and not whether it wants a TCP stream. Python filters *do* work on UDP on both layers: `cpproxy` gained `filter_action_udp` (no stream follower, no ack fixing — a datagram is complete in itself), and its per-flow contexts are bounded because a datagram has no close to observe and a spoofed-source flood would otherwise accumulate one set of module globals per forged address: a UDP flow's context goes after `UDP_IDLE_SECONDS` (60) of silence, and past the service's own `max_connections` (`FIREGEX_MAX_FLOWS`; 0, the default, is no limit) the least recently used one goes to make room. It used to be a fixed 4096 applied to **every** context, TCP included, in flow-id order — so a burst of datagrams could take the state of a TCP stream in the middle of a request, whose next packet was then parsed from a clean slate. Only UDP flows are counted and evicted now (`udp_recent`/`udp_where`). `handle_next_packet` used to throw for anything but TCP, with a message claiming UDP was supported. +- **A filter file is validated by the process that will run it.** `pyworker.py --check ` 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. + - **`FGEX_STREAM_MAX_SIZE` bounds what a stream costs in memory, and three things had got past it.** A compressed body was decoded whole (`on_message_complete`): a megabyte of gzip under the cap is a gigabyte after it, so every step of the decoding is bounded by the cap now, and a body that would go past it meets `FGEX_FULL_STREAM_ACTION` like any other stream past the cap (with `FLUSH`, handed over still encoded). A WebSocket message is bounded the same way (`max_size` to `Frame.parse`). The flush itself cleared the *view* of a body and subtracted it from the count while the buffer it is read from kept growing — so on a gRPC stream, whose body is handed over as it grows, memory grew past every flush and the count went negative (`_flush_body`); framed gRPC bytes are also dropped as they are handed over (`_forget_framed`), and a frame past the cap is skipped to its end so the next one is still found where it starts. And `HttpHistory` counted messages but not bytes, a hundred bodies of a megabyte per direction per connection; each direction's history is held under the cap too (`_trim_history`). Separately, the flush raised on a message still in its headers (`len(None)`), so headers past the cap were an exception and the packet failed open. + - **Traffic the HTTP parser cannot read is carried by default, and said so** (`invalid_encoding_action` defaults to `ACCEPT`). It was refused, so a request the service would have taken and llhttp would not — one client's quirk, a protocol that only resembles HTTP — closed the connection with nobody having chosen that, credited to the filter function as if it had decided it. Refusing is one line in the filter file for an operator who wants it against request smuggling; when it refuses, the block is credited to `@INVALID_ENCODING`, and the warning is one line per connection and direction rather than a traceback per packet. + - The default for `FGEX_STREAM_MAX_SIZE` is 1 MiB, as documented. It read `1*8e20` under a comment saying "1MB", so `FGEX_FULL_STREAM_ACTION` never fired unless a size was set by hand, and the float it produced would have been refused by the setter beside it. +- **The editor's hints are introspected, never written down.** `modules/services/pyapi.py` reads the models out of `type_annotations_associations` and their members out of the classes — including `writable`, from `property.fset`, which is how the interface can say whether a member can be assigned to — none can, since the payload stopped being writable with `UNSTABLE_MANGLE`. `GET /api/services/pyfilter-api` serves it and `pyfilterHints.ts` feeds Monaco's completion and hover from it. A second, hand-written description would be wrong the first time a model gained a property, and autocompletion that lies is worse than none. The route is declared **before** `/{service_id}`, or that one matches it and looks for a service by that name. +- **A filter that raises says so the same way on both layers.** `_on_exception` writes one sentence — the traffic was forwarded unfiltered — and it has to be reachable from either. NFQUEUE gets there directly: the interpreter is embedded in the binary, which sends `EXCEPTION`. The proxy cannot, because `pyworker.py` catches the exception in a separate process and answers with an ordinary ACCEPT, so nothing crosses the frame boundary that the engine could tell apart from a filter agreeing. It marks stderr with `EXCEPTION_MARK` instead and `Transport._pump_stderr` turns that back into the same call. The traceback is printed *beside* it, not instead of it: the traceback says which filter and which line, and the sentence says what happened to the connection — for a while the proxy printed only the first, which tells an operator a filter broke and not that traffic went through unchecked. The marker is spelled in both files because the worker is launched as a script and imports nothing from the backend, which is what keeps the user's code out of this process's imports; `tests/integration/test_resilience.py` asks for the sentence on both layers and so keeps them in step. +- **A block names the function, not just the file.** Both layers report `/`: the NFQUEUE binaries already reported the function name, and the proxy worker now returns it in the verdict frame (`[verdict][name len][name][payload]`). `ServiceManager._on_block` splits that one token in one place. A file with ten functions blocking 900 connections is not an answer to "which of them is doing it". +- **Nothing below the application layer crosses into a filter.** `RawPacket` exposes addresses, ports, family and direction as read-only metadata, and `data` — the payload — read-only as well: a filter answers with a verdict, and nothing comes back but that verdict and the function that gave it. The payload was writable for `UNSTABLE_MANGLE`, and stayed writable after it was removed, which made `packet.data = ...` a silent no-op on both layers while the editor still offered a "rewrite" snippet ending in `return UNSTABLE_MANGLE` — a name that no longer exists, so the snippet raised on its first match and the traffic went through unfiltered. The setter, the snippet, the hover text and the docs' example are gone together, and so is the C++ path that applied a rewrite: `cpproxy` treats action `3` (the retired `MANGLE`) as `INVALID`, which fails open and is reported as an exception. The number stays unused rather than being given to anything new. `FilterAction::MANGLE` in `nfqueue.cpp` is **not** that: it is how `reject()` sends the RST with the payload stripped, and it stays. That boundary exists because the two layers cannot honestly offer the same thing underneath it: NFQUEUE has a real header whose rewriting desynchronises the stream, while the proxy terminated the connection and writes its own. The old model papered over that by handing the proxy a literal `FAKE:IP:TCP:HEADERS:` prefix as `raw_packet`, so the same filter did different things depending on where it was attached. On the proxy the metadata is *better* than a header would be — the engine knows both ends and sends them in a `KIND_OPEN` frame (`ConnectionMeta` → `Filter::connection_opened`), once per connection rather than on every chunk. +- **The order the operator set is the order that runs.** `parse_ruleset` groups patterns by their `filter` id and keeps the positions the backend sent. An earlier version grouped every block, then the Python — which turned the chain into a set, so a filter placed after another could run before it, and nothing said so. Within one filter the operator expressed no order, so blocking goes before rewriting there; across filters, never reorder. `tests/rules.rs` pins both. +- **The regex tester runs the engine.** `POST /api/services/debug-regex` shells out to `fgex-proxy --debug-regex`, so what it says is what will happen. A `re`-based approximation would bless patterns hyperscan rejects. + +### Per-module pattern +Each module (`services`, `firewall`) follows the same shape: +- `backend/routers/.py` — FastAPI endpoints + its own SQLite DB, via `utils.sqlite.SQLite`. +- `backend/modules//models.py` — plain model classes, `from_dict`. +- `backend/modules//firewall.py` — `FirewallManager`/`ServiceManager` pair: the manager tracks one `ServiceManager` per DB row, which starts/stops that one service. +- `backend/modules//nftables.py` — builds/tears down the module's nftables rules; extends `utils.NFTableManager` (a `Singleton`), so there is exactly one rule-set owner per module. + +**Every nftables object firegex creates is named `fgex_`, and lives in a table firegex owns.** The services module holds `table inet fgex` with `fgex_queue_in_`, `fgex_queue_out_`, `fgex_hijack_*`, `fgex_divert`, `fgex_nat`, `fgex_nat_out`, `fgex_route`, `fgex_guard` and the set `fgex_datapath` — and its `reset()` deletes **the whole table**, because flushing it chain by chain was one batch that nft refuses outright if any chain named in it is missing, so a table left by a version with other chains (an upgrade after a crash) was never cleared and went on steering traffic at dead queues and ports; the firewall module holds `fgex_filter` and `fgex_mangle` per family, with base chains `fgex_input`/`fgex_forward`/`fgex_output`/`fgex_prerouting`/`fgex_postrouting` jumping to `fgex_rules_in`/`_out`/`_fwd`. One `grep fgex_` over the ruleset is the whole footprint. + +**Something else removing those tables is noticed, said and undone, within `TABLE_WATCH` (3 s).** `nft -f /etc/nftables.conf` begins with `flush ruleset` on Debian, and so does restarting its unit; before this, every service went on reading ACTIVE with nothing reaching a filter, and a default-deny firewall became no firewall, with nothing said. Each module's `FirewallManager` runs a `_watch_table` task from `init()` to `close()`/`stop_watching()`, asking `nft.intact()` — one small chain that `init()` always leaves rules in, so a deleted table and an emptied one both fail it. The services module rebuilds the table and calls `ServiceManager.put_rules_back()`, which is the second half of `enable()` alone: the datapath never stopped, so nothing restarts and no connection is dropped. It runs under the service's lock, like `disable()`, or rules put back while a stop is waiting on its datapath would outlive the stop (`test_rules_are_not_put_back_behind_a_stop`). Each service logs it as an error, at most once per `TABLE_TOLD_QUIET`, because traffic went through unfiltered in between. Partial surgery — one rule of ours deleted by hand — is not detected; that is somebody editing firegex's table, not somebody else's tool wiping the ruleset. + +**An interface rule says where traffic *arrived*, not where it was going, and that is deliberate.** `iifname docker0 tcp dport 443` matches every container's **outbound** HTTPS as well, because that traffic arrives on `docker0` too, and a `redirect` then takes it into the engine, which terminates it with this service's certificate — measured: `pip install` inside any container failed with `CERTIFICATE_VERIFY_FAILED: hostname mismatch` while such a service was running, and worked the moment it stopped. Pinning the destination to the addresses the interface carries was written, tested and **reverted**, because it removes the one case interfaces exist for: an operator names a link when **the address is not theirs to know**, which includes firegex in front of a service on another machine reached through it, and a rule pinned to this host's own addresses silently stops protecting that. Protecting a link protects what crosses it. Naming one that carries other people's traffic is a choice, and the answer to not wanting it is a narrower address, not a narrower rule — `docs/services.md` says so where the choice is made, because it is surprising the first time. Do not re-narrow this without a way to tell the two deployments apart. + +**An interface stands for the addresses it carries, and every place that needs an address has to resolve it — three of them gave up instead.** `udp_relay_host()` got this right from the start; the others did not, and each failure was silent in its own way. `_add_proxy` wrote no output-hook rule at all, so a service protected on `lo` filtered the outside world and not a client on the box. `_published()` and `ServiceManager.address_added` skipped the announcement, because the engine keys that map on what `SO_ORIGINAL_DST` hands back and an interface name is not an address — so an HTTPS edge declared on `lo:443` was never announced, the engine met the connection knowing nothing, terminated it as an ordinary one and dialled `:443` where nothing listens. HTTPS on that address simply did not answer while the same address written `127.0.0.1:443` worked, which is how it was found and is as confusing a symptom as this module has produced. All three now go through `interface_addresses()`, which returns the concrete addresses (link-local dropped) and hands back a single-element list for an address, so the two spellings take one path. The cost is the one `udp_relay_host` already pays: the answer is resolved when the rules are installed, so an address the interface gains later is not covered until the service restarts. When adding anything that needs *an address* rather than *a match*, the question to ask is what it does when handed `eth0` — and the answer is never "skip it". + +**The proxy layer's redirect runs before anybody else's destination rewrite, and that is not tuning.** `NAT_PRIORITY` is -120: below `dstnat` (-100), above conntrack (-200). Docker and podman publish a container's port by appending their own chain to `nat PREROUTING` and `nat OUTPUT`, which `iptables-nft` registers at exactly `dstnat` — the priority these chains used to share. Two base chains at one priority in two tables are ordered by neither of them, and it was measured going the wrong way: the DNAT rewrote the destination to the container's address, firegex's rule (which matches the *published* address) stopped matching, its counter stayed at zero, and a request carrying a blocked pattern was answered by the container. The service still read `ACTIVE`. Running first, the published port is captured before anyone can rewrite it — the property the NFQUEUE layer always had for free by living at `raw`, three hundred below all of this, which is why that layer filtered a containerised service correctly the whole time. Nothing downstream is lost: the engine's own dial carries `SELF_MARK` and is skipped by the rule it would otherwise match, so the container runtime's DNAT still does its job on the way out. `tests/integration/test_addresses.py::test_a_container_runtimes_rewrite_does_not_take_the_traffic` stages a competing rewrite at `dstnat` and asks for the block; it was confirmed to fail with the old priority before being kept. A CTF service is almost always in a container, so this is not an edge case — it is the deployment. + +The firewall module is the one that had to move to get there, and the reason is worth keeping. It used to write into the tables called `filter` and `mangle` — the ones `iptables-nft` claims — adding base chains named INPUT and FORWARD beside iptables' own and driving their policy. That broke two things, both reproduced before being fixed: + +- **`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. **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. **And what the containers send**, which the first version of it left out: with the policy at drop, a web container could not reach its database on the same bridge and no container reached the network — measured with two containers on a Docker network — because Docker accepts that in `FORWARD` too, beside where firegex's rules used to be. So traffic arriving from a container runtime's bridge (`CONTAINER_BRIDGES`: `docker0`, `br-*`, `docker_gwbridge`, `podman*`, `cni-podman*`; br_netfilter shows bridged packets with the bridge as their interface) is left to the runtime the same way. `get_rules` also had the family of every rule narrowed by the first one naming an address (`families` set once, outside the loop): an "accept port 80" listed below an IPv4 rule did not exist for IPv6. 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). **`set()` sends that teardown in the same batch as the firewall replacing it.** It was a batch of its own sent first, so when nft refused a rule — an interface name longer than the kernel takes was enough, and the router accepted anything without a `/` — the old tables were gone and the new ones never arrived: a default-deny firewall became none, and the watcher failed the same way every three seconds. Now a refusal leaves the previous firewall in force, the router puts the database back (`apply_changes(undo)`) and answers 400, and an address field is an IP or an interface name the kernel takes (`INTERFACE`, a trailing `*` allowed). + +`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. + +**And a refused rebuild does not leave the service stopped.** On NFQUEUE a new shape is rebuilt (`ChainShapeChanged` → `restart()`), and the rebuild stops the service before it finds out the new chain cannot start — a filter that loads in the checker's interpreter and not in `cpproxy`'s subinterpreter, a push that times out. Undoing the database afterwards answered "refused", which reads as "nothing happened", while the service was down. `update_chain(undo)` and `address_added(address_id, undo)` now take the undo, run it, and start the service again on what it restored (`_back_as_it_was`); `_once` makes the undo safe to run from both the manager and the router's own `except`. + +**The status the API reports is what is running** (`_running_status`), not the stored one, which is what the operator asked for and what the next boot starts. A service that could not come back at boot kept `active` in the database and the list showed it active with nothing filtering it. Stopping such a service still writes `stop`, or the next boot would try it again. + +`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. **Only what differs from the row counts as an edit.** The form sends every setting on every save, and each save used to stop and start a running service — resetting every connection it carried for opening the dialog and pressing save. A change of name alone is written and nothing is restarted, because nothing running reads it. + +Order is the load-bearing detail in `services/firewall.py`, because getting it wrong is not an error anybody sees — it is a window during which traffic goes unfiltered. Starting: the capture interface if the service decrypts (the engine opens its socket to it at startup, so one that appears afterwards is one this process will not find), then the datapath, then the nft rules that point at it. Stopping is the exact reverse. + +### Block statistics (`modules/services/stats.py`) +A cumulative counter per filter, per pattern and per `@pyfilter` function lives in the database; the bucketed history answers the other question — *when* did this start. Blocks are counted per filter into one-minute buckets, kept for `HISTORY_BUCKETS`, and flushed at most every `FLUSH_INTERVAL` seconds. Both properties are load-bearing for the same reason the live log's are: a service refusing thousands of connections a minute must not be able to take the backend down through its own bookkeeping, which is what one row per block would do. There is deliberately **no background task** — flushing happens on the next write once the interval has passed, and always on a read — so nothing has to be started, cancelled or waited for at shutdown. The frontend draws it as hand-written SVG (`StatsPanel.tsx`) rather than pulling in a charting library several times the size of the page it sits on. + +**Per-block counters are written once a second, in one transaction** (`ServiceManager.flush_blocks`, `BLOCK_FLUSH`), and the names a block is logged under are looked up once per rule until the chain changes. They were three commits per refused connection, about 17 ms a block, so the backend fell behind any flood of refusals — and that was not only its own problem: the engine wrote `BLOCKED` to a pipe the backend was not reading fast enough, the write blocked the runtime thread that made it, and the flood slowed every other client of the service (measured: 5000 refusals took 74 s and a legitimate client waited a median 4.5 s, failing half the time; now 4 s and 1 ms). Every services API request writes what is waiting first (the router's `_counters_written` dependency), so a read is exact and an edit resetting a pattern's counter cannot have the old pattern's last blocks written onto it afterwards. On the engine's side **nothing on the datapath writes to stdout** (`report.rs`): lines go through a bounded queue and a thread of their own; `BLOCKED` and `STATS` are dropped and counted when the backend is that far behind, and replies the backend waits for (`PORT`, `UDP`, `ACK`) wait for room, in order. **Nor does anything on the datapath write to stderr directly**: `crate::diag!` goes through a queue and a thread of its own and is dropped and counted when full, and `crate::diag_throttled!` says at most a few lines per window from one call site and counts the rest into one line (`report::Throttle`). Every line a client can make the engine write at will — a connection ending with an error, a cleartext client at a TLS address, a malformed request — was an `eprintln!` from the thread carrying traffic, which takes one lock for the whole process and blocks once the pipe fills, and at `[warn]` it flooded the 500-line log and the problem badge. `main.rs` and `control.rs` keep `eprintln!`: a `[fatal]` line has to be written before the `exit` after it. + +`share` is computed in the backend, not the browser, so a chart and the table beside it cannot disagree about what a rule accounts for. + +**History is stored per *rule*, not per filter, and that is what makes a time range mean one thing.** `block_history` keys on the token the datapath reported — a pattern id, `/`, or a filter id — so `GET .../stats?range_from=&range_to=` can answer the chart, the per-filter totals, the per-pattern totals and the shares over the *same* window. Storing only per filter would have left the chart honouring the range and the tables beside it reporting all of time. A filter's total is the **sum of its rules**, not a lookup of its own id: that token appears only when nothing named itself. + +`bucket_width` takes either a `target` bar count (the default, which is what makes the bar widen with the range) or an explicit `want` in seconds from the operator, and both are floored at `MAX_STEPS` bars — a step fine enough to melt the chart is widened, never refused, and `bucket_seconds` reports what was used rather than what was asked. A range never grows the number of buckets, only their width (`bucket_width`): everything is an aggregate of the one-minute rows, so twelve hours costs the same rows as one and is drawn in the same handful of bars. Ranges are clamped to **two** floors — `retention_start()` and the service's `filtering_since` — and the served range is reported back, so the interface can say what it actually showed. `filtering_since` is written by `_note_filtering()`, which is called from `start_service` and from `_apply_chain` (the two places the two halves of "running, with an active filter" change) and once per service at `startup()`, which is what covers services restored from a backup or predating the column. It is written once and never moved: a service stopped and started again has a real gap in its chart, and moving the mark would hide it. Note the clamp happens *after* the caller's ends are put in order, and the `filtering_since` floor applies **only to a window that reaches into the service's life**: a window that ended before it began is answered exactly as asked and comes back empty, because it is. Dragging its start forward instead would have reported the current minute's blocks under a range an hour old — buckets are minute-aligned, so a zero-width range is not an empty one. `all_time` is reported alongside `total` so a narrow window showing nothing is not read as a contradiction against the cards. + +`StatsCharts.tsx` draws bars, lines, stacked area and 100%-share from the same series — four views, one dataset, still hand-written SVG for the reason the first chart was. Hover is tracked once on the container and turned into a step index, not per shape: a line and an area have nothing under the cursor to attach a handler to, and one handler means the four charts cannot disagree about which step is being pointed at. The readout itself is HTML positioned over the plot, for the same reason the axis labels are — `preserveAspectRatio="none"` stretches rectangles correctly and distorts glyphs. `clock()` is exported from there and used by the axis labels, so a bar's tick and its readout cannot disagree about what a step is called. + +**Editing a pattern keeps the row, resets its counters, and hands its history to the filter.** `PUT .../regexes/{id}` validates the *resulting* row through the engine (changing the action alone can invalidate a stored pattern, since blocking compiles for streaming and rewriting for block scanning), and when the pattern text itself changed it resets `blocked` and calls `stats.disown_rule` — counts belong to the text that produced them, so the new text must not inherit them. That call happens **after** `_apply_chain` accepts, never before: a refused edit has to change nothing, and history moved ahead of the verdict cannot be put back by `undo()`. The rule's *pending* batch moves with it, or the last few blocks of the old pattern would be flushed under the new one. + +What `disown_rule` must not do is **delete** those rows, which is what it used to do. Those connections were refused, by that filter, in those minutes, and the chart is answering *when this service was under attack* — a question a later typo fix has nothing to say about. Deleting took a bite out of the timeline on every correction, and on a filter holding one pattern it emptied the chart. So the rows are re-keyed to the filter's own id, already the token the datapath reports when nothing finer named itself: `window()` and `totals_by_filter()` group by `filter_id` and do not change at all, while `totals()` groups by `rule_id` and correctly credits the new text with nothing. + +The cost is that some blocks in a range then belong to no rule still present — which was already true of a *deleted* pattern, whose rows are left behind on purpose. The stats route closes that by adding one **residual** entry per filter for the difference between what the filter was credited with and what its current rules account for (`residual: true`, prose rather than a pattern, so `StatsPanel` prints it as text and not in a `Code` box). Without it the per-pattern list quietly adds up to less than the chart above it, and the operator is left hunting for the difference. Note `check_pattern` coerces `case_sensitive`: a caller checking a row it read back from SQLite hands over `1`, and the engine's request format refuses an integer where a boolean belongs. + +**Traffic totals are reported in the unit each layer can honestly produce, and the two are never divided by one another.** This is the part to preserve: + +- **Packets and bytes** come from `counter` statements on the intercept rules — free, kernel-side, and unskewable because no userspace filter is involved in producing them. `FiregexTables.traffic()` reads them back, from **one inbound chain per transport**: the nfqueue layer installs a rule pair per filter position all seeing the same packets, so summing them would multiply the answer by the length of the chain. +- **Proxy services report no packet count at all.** Their rule lives in a `nat` chain, and conntrack translates a connection once — every packet after the first is handled without the rule being walked again. That counter is *new connections wearing a packet label*, so it is dropped rather than shown. +- **Connections** come from the engine's periodic `STATS seen=… refused=…` line. They live on `ProxyStats`, not on `ChainStats`: a chain is replaced wholesale on every ruleset push, and counters that reset on a rule edit would make the ratio jump for a reason nobody could see. Because both numbers are connections, `refused_share` there is exact. +- NFQUEUE has no connection to count, so it reports `null` for the share rather than dividing refused connections by a packet count. **The asymmetry is the difference between the layers showing through; do not "fix" it by inventing a denominator.** The engine's counters arrive on a timer, so a test that reads them has to wait for them. + +### The live log (`modules/services/logs.py`) +A bounded ring per service plus a coalescing flush over a `log` Socket.IO event (distinct from `update`, which only invalidates queries). **A warning or an error does not wait for the service's page to be opened**: `ProblemNotifier` (mounted in `MainLayout`) turns `warn`/`error` lines into a notification wherever the operator is — one per service per 20 s, the rest counted into it, except that an error is never folded into a warning ("restarting it" followed by "could not restart it" hid the second until a third came, which it need not) — and the service list carries the newest one (`ServiceLog.last_problem`, `problem` on `ServiceModel`) as a badge for an hour; clearing the log dismisses it. The log panel was the only place these went, so an engine that died and came back, a filter raising on every packet, or a fallback that cost the service its clients' addresses waited for somebody to look. `cpproxy`'s merged stdout/stderr goes through `_on_engine` like every other datapath's (`_QueueStage._forward_output`, whole lines only): it went in as the user's output, so its `[error]` lines and the traceback of a filter that raised were grey text among the prints. Two properties are load-bearing and easy to lose: the ring is capped, and the flush is on a timer with a per-batch cap that reports a dropped count rather than carrying it — a service refusing thousands of connections a minute must not be able to take the backend or the browser down through its own logging. + +Sources: block attribution (resolved to the filter name and pattern, one query per block, on a path that only runs when something was already refused), the user's `print()`, and the datapath's own stderr. That last one is why `pyworker.py` reassigns `sys.stdout = sys.stderr` before running user code: stdout is the length-prefixed protocol channel, and a `print()` landing in the middle of a frame gets the worker killed on every packet with nothing to explain it. `tests/pyworker.rs` has a regression test for exactly that. + +### The proxy datapath (`backend/proxysrc/`) +A Rust binary, built in the Dockerfile's `compiler` stage rather than a `rust` image on purpose: it links the same `libhs` the C++ binaries do, and a binary built against one distro's vectorscan and run against another's is a bad afternoon. + +- `filter.rs` — the chain, and everything that keeps a filter from taking the datapath down: `catch_unwind`, a deadline 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. **Losing its say for missing the deadline is a pause, not a sentence** (`REARM_AFTER`, 30 s): it used to last for the life of the chain, and a Python filter misses its deadline when exchanges queue behind one another — which is what a flood does, the one moment it is wanted — so the protection stayed off until somebody next edited a rule. A filter that is still slow loses its say again after the same few misses, which bounds what a genuinely hung one strands to a few threads per pause. A panic is broken code and stays out until the chain is replaced; the warning says how. +- Filters that keep state do so **per connection, per direction** (`ChainSessions`), because one client's bytes deciding another client's verdict is both a false positive and a way to smuggle a pattern past a filter by splitting it across two connections. +- `pyworker.rs` frames carry a connection id and a close frame, so the Python side can honour the documented promise that each stream gets its own module globals. +- Source preservation is unconditional: the engine always dials the service from the client's address. `ct status dnat` is what separates the engine answering a client from the service answering the engine, which is why redirect (not tproxy) works even when the service is on this host. A transparent dial the service **refuses** is not retried as ourselves: nothing is listening, and the retry used to blame source preservation for a service that was simply down. +- **A service behind a container runtime's rewrite works from anywhere, on both layers** — it is the ordinary CTF deployment. On the proxy layer the engine dials the published address as the client, the runtime's DNAT sends it to the container, and the container answers **from its own address**: `socket transparent` in `fgex_divert` found no socket talking to that address, so the answer went on to the real client, which reset it, and every connection from another host waited out the dial and was retried as this host — slow, and the client's address lost. `fgex_divert` now also marks home replies carrying the engine's conntrack mark (the one `fgex_route` copies onto every connection the engine opens). On NFQUEUE, a client **on this host** was never inspected: the runtime rewrites that connection in `nat OUTPUT`, so it leaves towards the container without passing prerouting, and the container's answer arrives for a local socket without passing postrouting. `fgex_queue_lreq_` (output) and `fgex_queue_lrep_` (input), at `LOCAL_QUEUE_PRIORITY` (-90: after the rewrite on the way out, before it is undone on the way in, so both halves show the container's address and a stream follower sees one stream), match such connections by conntrack's **original** destination with `ct status dnat`, and are found again by `get()` through their `address ` comment. Measured with a real Docker container; `tests/integration/test_remote_clients.py` stages the same rewrite into a network namespace so no Docker is needed. +- **The service's answers are brought home by who opened the connection, not by the port they come from.** `fgex_route` copies the engine's `SELF_MARK` onto the conntrack entry of every connection it opens and marks whatever answers one of those for local delivery — and that is all it holds. The per-address rule that used to sit there matched the service by the address it is protected on, which is where the engine dials it only while the address *is* the service: one published on another port, or an HTTP/3 edge in front of an HTTP/1.1 service (dialled over TCP), answered from a port nothing matched, and the answer was routed out to the client instead of home. A client on this host never noticed — the reply is local either way — so every test passed while a client on another host timed out. `tests/integration/test_remote_clients.py` puts the client in a network namespace to ask. Matching every reply *from* the service's port instead was not the fix: that port can be reachable directly, and its own clients' answers would have been diverted. The per-address rule was exactly that, for the protected port itself, and it was removed for exactly that reason: a connection open before the service started is never redirected (conntrack translates a connection at its first packet), so its answers belong to the client, and a client on another host stopped hearing back the moment protection began. Measured with a connection held open from a network namespace across the start, TCP and UDP (`test_a_connection_already_open_survives_the_service_starting`); NFQUEUE never had the problem. +- **The engine's own ports answer only what was redirected to them** (`fgex_guard`). The TCP listener and every UDP/QUIC relay are bound to the wildcard, because a redirect delivers to whichever address the packet arrived on — so a relay, which forwards to its service whoever sent the datagram, put a service protected on `127.0.0.1:53` on the network. `fgex_datapath` holds `l4proto . port` of every one of them and `fgex_guard` (input hook) drops what reaches one without conntrack's DNAT status; `fgex_divert`'s mark exempts replies to the engine's transparent sockets, whose destination is a client's port and can be any number. A port is added when its redirect is installed and released only when the **whole service** comes down (`FiregexTables._guarded`), because a relay stays bound after its address is removed and would otherwise be reachable directly again — and an element left behind after a stop would drop traffic for whatever process is given that port next. +- **A connection nothing redirected is refused** (`dialled_at_ourselves`), behind the guard as well as in front of it. The listener is on an ephemeral port of every address (`[::]:0`), so anybody can dial it straight; `SO_ORIGINAL_DST` then answers with the address the connection was accepted on, and forwarding there is the engine dialling itself — measured, one such client became as many connections as the limit allowed in two seconds, and with no limit it grows until the process runs out of descriptors. The old guard compared with the configured listen address, which is the wildcard and never matches. +- **Stopping or restarting a proxied service does not cut what the engine carries.** The engine terminates every connection, so `stop()` — killing it — cut them all, measured: a connection through the engine read EOF on the next write after a stop and after a restart, while the same test on NFQUEUE carried on. `ServiceManager.disable` takes the rules off and calls `Transport.retire` instead: conntrack keeps delivering what it already translated to the old engine's ports, new connections go to the service (a stop) or to the new engine (a restart — `handing_over`, from `restart()` and `refresh()`), and the old engine exits once a `STATS` line counted after it was taken out (`DRAIN_FRESH_AFTER`) says `live=0` — which counts TCP connections, UDP flows and QUIC connections alike. A stop pushes it an empty chain first, because stopping is how an operator takes a hurting filter out of the way; a restart keeps the old chain. Bounded by `DRAIN_LIMIT` (10 min) and `MAX_DRAINING` engines per service, both logged when they close something. Its ports stay guarded until it is gone (`nft.delete(keep_guards=True)` → `release_guards`, which leaves alone a port another service has since been given), and its TLS files are removed at once — it read them at startup, and removed later they would be the new engine's copies. `FirewallManager.close()` still kills: the table that brings the answers home goes with it (`stop_draining`). Pinned by `test_stopping_a_proxied_service_does_not_cut_its_connections`, `test_a_restart_hands_over_without_cutting_connections` and `unit/test_proxy_drain.py`. +- **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. **A datapath that does not acknowledge a command is killed, and one that refuses it is left running** (`Transport._abandon`). A refusal leaves the previous configuration in force on all three binaries; the NFQUEUE stages used to be stopped on one anyway — and stopped *on purpose*, so the watchdog said nothing and the service read active with no process behind its queue. A missing answer is different: what was applied cannot be known, and the answer arriving late was read as the answer to the next command, putting every command after it one answer out of step. So the process is killed without being marked stopped, and the watchdog rebuilds the service from the saved configuration. The wait for a configuration is `LOAD_TIMEOUT` per Python filter rather than `ACK_TIMEOUT`, because loading one runs its module body and the code check allows that eight seconds. +- **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; + +Note for tests: Linux refuses a loopback connection to a port with no listener *without emitting a packet*, so no rule can rescue it. A hand-off test has to keep the real service listening and tell the two apart by their replies, not by which one answers. + +### Holding up under load + +**A service carries at most `max_connections` at once, and the operator says what happens to the rest.** It exists because the alternative was measured, not argued: silent connections cost two descriptors each — one from the client, one to the service, since the upstream is dialled on accept — and ~505 of them from one host exhausted the container's 1024 and took **every** service down. UDP was cheaper still: 600 datagrams from 600 forged sources in 0.03 s took 400 descriptors and held them a minute after the sender left. A cap on the same shape existed on the NFQUEUE side (a fixed `MAX_UDP_FLOWS` of 4096 in `pyproxy.cpp`) and had never been carried to the Rust engine; TCP had none anywhere. That fixed cap is gone: on NFQUEUE the same `max_connections`, set in the form's advanced settings for a UDP service, bounds how many UDP flows keep their Python state, and idle flows are let go after a minute whether or not one is set. + +Say what it buys, and no more. **A cap does not save the service being attacked** — it cannot tell a connection that is silent because it is an attack from one that is silent because the client is slow, so an attacker who fills the limit still fills it. What it buys is that one service's attacker stops being everyone's: measured, 400 silent connections against a limited service left the service beside it answering. Do not let the interface or the docs imply otherwise. + +- One number for both halves, because TCP connections and UDP flows spend the same descriptors and two limits for that would be two things to keep in step. `0` is no limit, kept as a value rather than an absence, because "unlimited" is a choice. It **is** one number: `udp.rs` counts its flows in the same `stats.live` the TCP and QUIC paths do, with a `Slot` held by the flow. It used to compare each relay's own flow count with the limit, so a service with several UDP addresses held several times its limit beside a full complement of TCP connections — and since UDP flows were never counted in `accepted` or `closed_by_filter` either, a UDP service reported no connections and a refused share of zero whatever it refused. A refusal is counted once per flow, because the number it goes into is compared with flows seen. +- `over_limit_forwards` is the same trade as fail-open at a different moment, and off by default for the same reason: a filter that quietly stops filtering is the worse surprise. On UDP a flow past the limit is carried as a flow of its own with an empty chain, answers included. It used to be a datagram sent from a socket closed straight after, so the service's answer reached a port nobody held: forwarded, and useless to every request/response protocol UDP carries. +- Folded into the form's one **Advanced settings** accordion, not dropped: these are settings an operator reaches for once — after something went wrong, or before a competition — and never again, and left in the open they are more things to read past on every service. One fold rather than one per topic, and its summary is built from what *differs from the defaults* (`changed`), so an untuned service says "Everything at its default" and a tuned one says exactly how. Folded is not hidden. What is inside depends on the layer, because the settings belong to different ones: the connection limits are the proxy's, fail-open is NFQUEUE's — with, for a UDP service only, the same number read as how many UDP flows keep their Python state (`limitsQueuedFlows`) — and the hand-off layer has none of them, so it gets no accordion at all. +- **Proxy layer only**, and absent from the form elsewhere. NFQUEUE hands the kernel a verdict on packets in flight; it accepts nothing and dials nothing. The form also resets it to `0` when the layer changes, because a stored setting that does nothing is the trap this whole file keeps arguing against. +- `Slot` is a guard, not a decrement at the end of `handle_connection`: that function has a dozen ways out — a refused chain, a failed dial, a timed-out handshake, a panic — and a counter that leaks on any one of them is a limit that tightens until it refuses everything. +- The accept loop **backs off** after a failed accept. That one is not a policy question: out of descriptors, the loop spun on `accept` → `EMFILE` → print → `accept`, measured at ~40% of a core with four clients knocking, writing a log line per attempt into the pipe the backend reads. + +**`first_byte_timeout` is what the cap cannot be.** A cap contains a phantom flood; a deadline ends it — measured, with the limit full and the attacker still holding every socket, clients got back in once it passed. Two properties are load-bearing and easy to break: + +- **Either direction counts.** The flag is set by whichever pump moves a byte first, so a service that greets its client (SMTP, SSH, most game protocols) satisfies it with its banner. Requiring the *client* to speak would hang every server-speaks-first protocol, and the failure would look like firegex breaking them at random. +- **Only until the first byte, never again.** A connection that has spoken and gone quiet is a session, and sessions think. This is not an idle timeout; making it one would close long-lived connections that are legitimately waiting. + +The flag is set *before* the chain runs, because a chunk a filter goes on to refuse is still a connection that said something. The watchdog fires through the same `stop_tx` a refusal uses, so both directions shut down cleanly rather than being dropped — a dropped peer reads a truncation, and this connection has done nothing to deserve one. Zero is off, and there is no default worth picking: how long a legitimate client may stay silent is a fact about the service, not about firegex. + +**The trace is deliberately in two places.** `_on_over_limit` writes `over_limit_hits`/`_first`/`_last` to the database *and* logs at warning level, rate-limited by `OVER_LIMIT_QUIET`. The log answers "why are clients failing right now" and is a bounded ring, so a burst at three in the morning is gone by breakfast; the columns answer "did we ever hit the wall", which is asked the next day. The engine's counter is cumulative *per process*, so the transport hands up only the **increase** — passing the absolute number would reset the durable count on every restart of the service. + +### TLS + +**The two controls that constrain each other read one rule.** `carries(transport, proto)` in `components/Services/utils.ts` is the only place the constraint lives: the protocol picker hides what the chosen layer cannot carry, the layer picker disables what cannot carry the chosen protocol, and `Transport.check()` refuses it on the server. Written twice, the two halves drift and the form ends up offering a combination the backend then refuses — being told no after filling in a form is a worse way to learn it than not being offered the option. Both pickers say no the same way: the option stays where it is, **disabled**, with a sentence underneath saying why and what would make it available. An option that vanishes leaves nothing to ask about — the operator is left wondering whether it exists at all or whether they misremembered seeing it — and two controls side by side that refuse in two different ways are a confusion of their own. + +**`ProtocolChoice.tsx` is the other half of that pair, and it exists for a confusion that was reported rather than guessed at.** The picker used to be a bare row of five buttons headed "Transport protocol" — five alternatives on one axis, which they are not. An operator with a plain HTTP/1.1 service in the clear had nothing to tell them whether `TCP` or `HTTP` was meant for them (both are true sentences about their service), and picking `HTTP` then asked for a certificate for something with nothing encrypted about it. It is now two questions, the first being the one that actually divides them and the one the certificate hangs off — **is the traffic encrypted?** — with the second asked *inside* that answer and offering only protocols of that kind: `tcp`/`udp` in the clear, `tls`/`quic`/`https` encrypted. Every option is a protocol under its own name, which matters: an intermediate version asked "on what transport?" and computed the value from the pair, showing the name only in a badge underneath, and that reads as the form having decided something on your behalf. `http` is labelled **HTTPS** in the interface — `protoLabel()` in `components/Services/utils.ts`, the one place a protocol is named, used by the picker, the service row and the detail page alike, because being offered `HTTPS` and then shown `HTTP` on the row reads as two different settings. It is still `http` in the database, the API and the docs; the rename is only about where it is chosen, since beside TLS and QUIC an option called "HTTP" reads as the cleartext web protocol. Two more things are load-bearing: the lock on the encrypted answer, which says before the click which branch will want a certificate, and the certificate fields sitting directly under the choice that asks for them rather than below the advanced settings — cause and request a screenful apart is most of why the request looked unmotivated. **And the inline text is one clause per option, deliberately.** What is needed while choosing is which option is yours; what a protocol costs and what filters attach to it is documentation, and it lives in the popover and in `docs/services.md`. Three successive versions of this component grew paragraphs explaining the trade inline and each one had to be cut back — the same lesson `LayerChoice` already records one section above. + +**TLS is one of `L4`'s values, beside `tcp`, `udp` and `quic` — not a boolean on top of one.** It is what the service speaks, and as a flag it made three combinations expressible that are not real: TLS on a UDP service, on NFQUEUE, and on a hand-off, each caught by a check written by hand. Two of those are now unrepresentable, and the third (`NfqueueTransport.check()`) is asked at **creation** as well as at start — `add_service` calls `transports.build_class(...).check(srv, [])` with an empty chain, which catches what the *service* makes impossible while leaving what the *chain* makes impossible to the moment there is one. A row that is created happily and then refuses to start every time is a trap. + +The second answer that split off from it: an address carries the transport **the kernel matches on** (`L4.l4_of`), not the service's own, because `tls` is TCP on the wire and `quic` is UDP. Without that, `(ip, port, proto)` would let a TCP service and a TLS one claim one `ip:port` between them, which is the same port twice. `l4_proto()` in `nftables.py` is gone with it — the address already holds the answer it used to compute. + +It belongs to the **proxy** layer: decrypting means terminating the connection, and terminating is what that layer does. That is not a lost feature so much as an honest one — TLS on NFQUEUE worked by putting nginx in front to terminate and re-encrypt, so the connection *was* terminated and the layer's one distinguishing property was already gone. + +**The engine terminates it, and a TLS service therefore occupies no port at all.** `proxysrc/src/tls.rs` decrypts from the client and re-encrypts towards the service inside the process that filters, so `resolve_target()` points the rules at the address the world dials, exactly as for a plain service. `ProxyTransport._tls_env()` hands the engine `FGEX_PROXY_TLS_CERT`/`_KEY` — **paths**, because a certificate on a command line or in an environment dump is a certificate in a log — written `0600` under `TLS_DIR` and removed after the engine is gone, never before. + +What that replaced: nginx terminated on one derived loopback port and re-encrypted from a second, with the filters attached to the leg in between. Protecting one address cost a pair of ports produced by hashing `ip:port` — *chosen* ports, which could collide with something real — plus a generated config, a `tls_streams` table keyed by address id, an nft chain of its own, and a failure mode where one unloadable certificate stopped nginx and took **every** TLS service on the instance down with it. All of that is gone: `modules/tls/` no longer exists, and the blast radius is now structural, since each service carries its certificate into its own engine process. + +**ALPN is mirrored, and the ordering that makes it possible is the design.** The client's handshake is *started* and held at the ClientHello (`tokio_rustls::LazyConfigAcceptor`), because that message carries the protocols the client will speak; the upstream handshake goes next offering exactly that list, and whatever the service picked is the single protocol the client is then told (`tls::with_alpn` / `tls::answering_with`, both cloning the config per connection — the list comes from the client, so there is no fixed set to precompute, and a config clone is nothing beside a handshake). Answering something the service did not say is how a proxy breaks a protocol it was only carrying: a client told `h2` against an HTTP/1.1 service sends frames nothing can read. When the service picks nothing the client is told nothing — no agreement is invented. `tests/integration/test_tls.py` pins all five cases, including both mismatches and a client that offers only one protocol. Before this, ALPN was dropped in both directions and every client silently fell back to HTTP/1.1. + +**Mirroring ALPN means `h2` can be agreed, and `h2.rs` renders it.** For a while nothing did, and the symptom is worth keeping because it is the failure this whole file is arranged against: HTTP/2 compresses its headers with HPACK exactly as HTTP/3 does with QPACK, so on an `h2` connection a pattern written against a request line matched nothing and a filter asking for an `HttpRequest` was handed the `PRI * HTTP/2.0` preface — parsed as one fictitious request — and then frames it could not read, so it was never called again. No block, no log, no counter. Only a body travelled in the clear, and since gRPC *is* HTTP/2, gRPC could not be filtered at all. The engine named it at `warn` once per process (`warned_h2`) and the docs said to turn HTTP/2 off on the service; both are gone, because the real fix is done. See the HTTP/2 section below. + +What cannot be carried, and is a limit rather than a missing setting: **TLS 1.0 and 1.1**, which rustls does not implement at all; the cipher list, which is fixed and forward-secret only; and **client certificates**, since `with_no_client_auth()` is the only honest answer when the certificate a client would present is one only the service can check. Say these are impossible rather than unconfigured — an operator who needs TLS 1.0 needs the `external` layer, not a flag. + +The key must be one `ring` will sign with — RSA ≥ 2048 bits, ECDSA, or Ed25519. nginx could be told to take a 1024-bit key with `@SECLEVEL=1`; rustls has no equivalent, so a smaller key is refused when the service starts. That refusal is worth having land well: `_pump_stderr` keeps the engine's last `[fatal]` line and `_died_because` puts it in the exception, because "the proxy engine did not report a listening port" is the symptom and the engine already printed the reason. + +**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. + +On the TCP edge that is nothing but a config: `build_tls` hands back no upstream client configuration and the dial is an ordinary socket. **On the QUIC edge it is the one place in this engine where what leaves is not the version that arrived**, and it was the point of the whole exercise: most of the web is a service that speaks HTTP/1.1 and never will speak QUIC, so firegex terminates HTTP/3 in front of it. What makes that honest rather than a translation invented here is that the exchange is *already* rendered as HTTP/1.1 for the chain — `judge` sees those bytes, `firegex0` carries those bytes, and now the service receives those same bytes. Three things are load-bearing: + +- **`http1.rs` grew the sending mirror of `Incoming`** — `Outbound`, `OutboundBody`, `Answer` — and `h3.rs`'s `exchange`/`carry_request`/`carry_response` are generic over it. The alternative was a second copy of the orchestration for the HTTP/1.1 case, which is the drift the `Incoming` trait already exists to prevent one level down. `h1up.rs` implements them over hyper's HTTP/1.1 client (client+http1 features only): the request direction is free because the rendering already built it, and the *answer* is what needed a crate, since HTTP/1.1 is the one version this engine has never parsed — an HTTP/1.1 connection elsewhere is a byte pump. Writing a response parser beside hyper's is what "one engine per concern" forbids. +- **The request target has to be reduced to origin form.** HTTP/2 and HTTP/3 carry it split across `:scheme`, `:authority` and `:path`, which `http::Uri` holds as one absolute URI; written to a socket unchanged that is `GET https://host/path`, the *proxy* form, which an origin server may refuse and which every service reading the path back gets wrong. `h1up` reduces it and moves the authority to `host` — the same decision `render_request` already makes for the chain, so what the filters saw and what the service receives stay the same message. The tests caught this immediately; it is not theoretical. +- **One connection per exchange**, because a QUIC stream is one request and HTTP/1.1 has no multiplexing. Pooling would put two clients' requests on one socket and with it the thing `ChainSessions` exists to prevent, at a layer nobody would think to look at. Its body is a channel into hyper, and `finish()` **drops the sender** — waiting on the channel instead (`Sender::closed()`) waits for the service to answer a request this end has not finished sending, which is a deadlock the chunked test found at once. + +One combination cannot work, and it is impossible rather than unimplemented: a QUIC edge carrying something that is **not HTTP/3** to a service that speaks HTTP/1.1, because opaque stream bytes have no HTTP/1.1 form. Nothing refuses it at configuration time any more — there is no list to check it against, since the protocol is each client's (see below) — so on such an address the engine answers `h3` itself and a client offering only something else is refused by that handshake. `upstream_refusal()` used to refuse it up front against a configured list, and went with the list. + +**The decrypted traffic is written to an interface of its own, and those are reconstructed packets.** Terminating in-process means the plaintext is never a packet, so `proxysrc/src/capture.rs` frames it as the TCP stream it was and sends it to `firegex0` over `AF_PACKET`: one interface carrying every decrypted service's plaintext — TLS and QUIC both, see the QUIC section for what a stream costs there — and nothing else, which is what a capture tool wants pointed at it. The bytes are real — what the filters saw and what was forwarded — but the framing is invented, because the framing on the wire was encrypted. Sequence numbers start at zero, there are no retransmissions, and the segmentation is the engine's read sizes; a SYN and a FIN are emitted so Wireshark has a stream to follow rather than orphan segments. Say so wherever it is offered: a capture from here is not evidence of what was on the wire. + +Three things keep it from being able to hurt anything. `modules/services/mirror.py` creates the device in `FirewallManager.init()`, before any engine starts (a socket opened before the interface exists finds nothing), and `release_capture()` removes it from the process's `shutdown()` hook and nowhere else — deliberately not from `close()`, which `reset()` also calls, or the device would vanish and reappear underneath whatever was capturing from it. It is the **instance's** device, not each TLS service's: it used to be created and released per service, on the argument that an interface present while nothing is decrypting is one somebody points a capture at and watches stay empty — but a capture tool is attached once, for a round, and Zeek and Suricata exit rather than wait when the interface under them disappears, which restarting a single TLS service was enough to cause. Watching it stay empty is the better failure. `release_capture_if_idle()` is gone; do not add a per-service release back. Every failure is silent and local — no interface, no socket, a failed send — and the traffic goes on exactly as it would have. And the `AF_PACKET` socket needs `CAP_NET_RAW`, which `docker-entrypoint.sh` asks for **and starts without**: it tries the full capability set, falls back to the set without `cap_net_raw` while saying what that costs, and only then runs as root. A host that will not grant it loses the capture, not the firewall. + +Note for tests: a client now dials the service's own address, because there is no second port to be pointed at. The old note about the DNAT skipping loopback belonged to nginx's public-port redirect and went with it. + +### QUIC + +**QUIC is terminated, and unlike TLS there was never a second option.** TLS over TCP can be carried past unopened — the bytes are framed by a transport the kernel understands, so filtering the ciphertext is a thing an operator can choose. Past its Initial packet QUIC encrypts the frames, the stream boundaries and the packet number along with the payload, so a packet queued to NFQUEUE is a datagram of noise. `NfqueueTransport.check()` and `ExternalTransport.check()` refuse it saying that, rather than the sentence they say about TLS. + +It is UDP to the kernel, and that is the whole of what the backend had to learn: `L4.l4_of(quic) == udp`, so the addresses, the uniqueness key, the nft rules, `udp_relay_key`/`udp_relay_host`, `ProxyTransport.udp_ports` and `ADD_UDP` are the code that was already there. What changes is what binds the port — `FGEX_PROXY_QUIC=1` beside the certificate makes the engine build `quic::QuicManager` instead of `udp::UdpManager`, and `relays::Relays` is the two-armed enum the control channel and the startup list both go through. `Service.decrypts` (once `terminates_tls`) is what gates the certificate, on both protocols. + +**The ALPN is mirrored, as on the TLS path, and nothing is configured.** The ClientHello travels inside an Initial packet, and that was taken for a reason it could not be read before answering — so the engine offered the service a candidate list instead, `FGEX_PROXY_QUIC_ALPN` for the whole instance and then a per-service field, and a QUIC service speaking anything but HTTP/3 was unreachable until somebody found the setting. But Initial keys are derived from the Destination Connection ID the client sends in the clear (RFC 9001 §5.2), exactly so a middlebox can read this. `quic_hello.rs` does: `SniffingSocket` wraps the endpoint's socket, every long-header Initial is decrypted with `rustls::quic::Suite::keys`, CRYPTO frames are reassembled by offset across datagrams (a ClientHello with post-quantum key shares does not fit one packet), and the ALPN list is parked per client address for `QuicRelay::offered`, which waits `HELLO_WAIT` for the rest of a split hello. `dial_upstream` then offers the service exactly that list and `QuicSetup::answering_with` tells the client the one protocol the service chose — both built per connection, as the TLS path does. The map is bounded (`MAX_PENDING`, `KEPT_FOR`, `MAX_FRAGMENTS`, `MAX_HELLO`) because forged Initials cost the sender nothing — and it was not bounded enough: empty CRYPTO frames counted towards nothing, so a peer grew one hello without end while every packet sorted all its pieces again inside the endpoint's receive path. Empty pieces and pieces past `MAX_HELLO` are not kept, a hello holds at most `MAX_FRAGMENTS`, the table is swept by age every `SWEEP_EVERY` rather than only when full, and a full table evicts its oldest entry instead of refusing newcomers — refusing meant a thousand forged Initials every ten seconds stopped every real client's hello from being read. A hello that cannot be read falls back to `h3`, said once per relay. The parser is tested against packets protected with rustls's own client-side keys, and `tests/quic.rs` drives real quinn clients through it, including one whose hello spans packets. + +**One stream is one connection to the chain** (`Carrier::open`), with its own `ChainSessions` and its own module globals, released when the stream ends. A filter's state follows a stream of bytes from start to end, which is what a QUIC stream is and what a QUIC connection is not; on HTTP/3 it also lands where an operator expects, one request to a stream. The raw path reuses `proxy.rs`'s pumps unchanged — `spawn_pumps`, `pump` and `join_pumps` are `pub(crate)` for exactly this, and `spawn_pumps` takes the `spoken` flag rather than making one, because the first-byte deadline is the *connection's* question and a flag per stream would never answer it for a connection that opened a hundred and spoke on none. + +**A refusal closes the connection, not the stream.** Resetting one stream would leave the client free to ask again on the next, which is not what a block means anywhere else; it is what keep-alive already does on HTTP/1.1. Both ends are closed with the same reason through `Carrier::refuse`. + +**HTTP/3 is terminated too, and rendered to the chain as the HTTP/1.1 it would have been** (`proxysrc/src/h3.rs`, on hyperium's `h3` — a binding, not a second implementation). This is the point of the whole exercise: HTTP/3 puts the method, path and headers in a QPACK-compressed HEADERS frame, so a filter shown the stream would be shown a compression format and every pattern written for the TCP service beside it would silently stop matching. Rendering it means `HttpRequest`, `HttpResponse` and a hyperscan pattern mean one thing on both. The same bargain the capture interface makes — the bytes are real, the framing is reconstructed — and the details are load-bearing: the request line says `HTTP/1.1`, `Host` comes from `:authority`, a declared length is passed through, and **the head is held back until it is known whether a body is coming**, so a bodiless request is never shown a `Transfer-Encoding: chunked` it never carried. A filter looking for that header is looking for smuggling, and one invented by the proxy in front of it is the worst possible answer; `tests/h3.rs` pins both halves of that. What goes on to the service is HTTP/3 again, re-encoded — the rendering is a view, never a translation the service sees. + +Two things about that hold, both of which were wrong first and are pinned now. **It is bounded** (`HEAD_HOLD`, 100 ms), because holding it indefinitely deadlocks every exchange the service is supposed to speak first in — a bidirectional RPC sends a head and waits to be answered, and a proxy that will not forward the head until a body arrives is waiting for something that is waiting for it. `head_framing` is what makes the hold cheap enough to bound: a declared `content-length` frames the body by itself and waits for nothing at all, a message with no body has already ended its stream, and what is left over is a sender holding the stream open — which is a message of undeclared length, which is what chunked means, so the timeout renders rather than refuses. **And a trailer section is always shown to the chain**, which is what decides the framing of a message carrying one and no body: in HTTP/1.1 trailers belong to a chunked message and nowhere else, so that message is rendered chunked with an empty body — the shape of a gRPC trailers-only answer, which used to reach the far side with nothing having read it. The one rendering that cannot carry trailers is a declared length, whose body ends where the header said; there `carried()` **drops** them with a log line, because forwarding a piece of a message no filter saw is the thing nothing else here does. `Incoming` exists so both directions decide all of this in one place rather than in two copies that can drift. + +**The plaintext is written to `firegex0`, one TCP stream per QUIC stream.** A QUIC stream is ordered, reliable and starts at zero, which is exactly what a `Tap` rebuilds; what it has not got is an identity a capture tool can use, because every stream of one connection shares the connection's four-tuple and writing them out as they are would interleave a hundred of them into one conversation whose bytes decode as nothing. So `Tap::open_stream` gives each a **synthetic client port** from a cycling counter — a counter and not a hash of the stream id, since ids restart at zero on every connection and two clients' first streams would land in one conversation. That is one more piece of invented framing on top of the invented framing this file already is, and it has to be said wherever the capture is offered, which `AddressList`'s panel now does: the port names a stream, not a socket. On HTTP/3 what is written is the **rendering** — the same HTTP/1.1 the filters were shown, since `judge` is the funnel every rendered byte already passes through — which is the only way `firegex0` can keep the promise it makes everywhere else, that what is on it is what the filters saw. It is not what left this process (that went out as HTTP/3 again), and the two cannot disagree because they are the same bytes. Written only once the chain has agreed, as the TCP pumps do it: a refused piece is one the traffic never got past this process, so the conversation ends where it did. + +**QUIC datagrams are carried, and one connection's are one flow.** `L4::QuicDatagram` is its own value rather than `Quic` with a flag, because the two answer the stream question differently and `is_stream` is what every model built on a stream turns on — a datagram gets `RawPacket` and nothing else, which is the same answer the plain UDP relay gives. `Carrier::carry_datagrams` opens the flow **on the first datagram**, not on the connection, so a connection that negotiates the extension and never uses it costs no filter state; both directions share the one id, and the sessions persist across datagrams exactly as the UDP relay's do — a pattern split over two datagrams sent a millisecond apart is otherwise a way straight through, and `tests/quic.rs` pins it. A refused datagram is **dropped, and the connection carries on**: that is a block meaning something different from what it means one function up, and the difference belongs to the traffic rather than to a preference — a stream is a conversation that can be ended, a datagram is complete by the time it is judged. `DATAGRAM_BUFFER` bounds what one connection may have waiting, because unbounded it is memory bought in this process by whoever sends fastest. + +**They are not carried on HTTP/3, and the handshake says so rather than the peer finding out.** An HTTP/3 datagram names the stream it belongs to, and the request streams this proxy opens towards the service are not the ones the client opened, so the name would point somewhere else on the far side; `QuicSetup::build` therefore gives the `h3` accept configuration a transport that does not advertise the extension, and the upstream edge — dialled before the service has said what it speaks — is decided by the candidate list instead. **WebTransport is not carried for a further reason and is a bigger question than its datagrams**: its streams are bound to a session by a varint every one of them carries, which breaks both of the rules the rest of this rests on — "one stream is one connection to the chain" would hand a filter a routing header it does not know to skip, and the HTTP/1.1 rendering has nothing to say for "this stream belongs to that request". It needs a session model, not a switch. + +Two things are refused rather than left unconfigured, and should be said as limits: **0-RTT** (`max_early_data_size = 0`), because early data is replayable and a refused request cannot be un-delivered; and a **Retry for every unvalidated client**, so that nothing is opened towards the service on the word of a source address nobody has checked. `over_limit_forwards` is honoured but is not free here: a QUIC connection past the limit is still terminated, decrypted and re-encrypted, because there is no such thing as forwarding a QUIC connection unopened. + +### HTTP/2, and one rendering for every version + +**`src/http1.rs` is the rendering, and `h2.rs`/`h3.rs` are what is left of each protocol once it is taken out.** The HTTP/1.1 view a filter is shown — `Framing`, `HEAD_HOLD`, `head_framing`, `carried`, `render_request`/`render_response`, `push_body`/`close_body`, `judge` — lived inside `h3.rs` and had nothing QUIC about it. Copying it into `h2.rs` would have been two renderings that can drift, which is the argument the `Incoming` trait already makes one level down, and the day they drift the symptom is a pattern that matches over HTTP/3 and not over HTTP/2, silently. So it lives in one place and both protocols are adapters to somebody else's parser: hyperium's `h3`, and `h2` for HTTP/2 — a binding, never a second implementation, for the reason stated under "one engine per concern". + +**An HTTP/2 client can be put in front of a service that speaks HTTP/1.1, and for a while it could not.** `h2.rs` takes its upstream as an `Outbound` — `H2Upstream` where the service speaks HTTP/2, `h1up::H1Upstream` where the address said `upstream = plaintext` — so the rendering the chain is shown is also what the service receives, exactly as on the QUIC edge. Before that, `h2::carry` opened an HTTP/2 client handshake and nothing else, which is why the ALPN could only ever be *mirrored*: with a cleartext service there was no upstream handshake to ask, so the client was told nothing and fell back to HTTP/1.1. The asymmetry is what gave it away — the same cleartext service was reachable over HTTP/3 and not over HTTP/2, one rendering underneath, opposite answers. `proxy.rs` now answers `h2` itself when the address says the service is cleartext, **and only while the chain will render it**: a bypassed chain is a byte pump, and a byte pump between an HTTP/2 client and an HTTP/1.1 service carries frames nothing at the far end can read. + +Two things the trait had to grow for it, both because HTTP/2 states on the head what HTTP/1.1 frames for itself. `Outbound::open` takes **`ends_it`**: a head that is the whole message has to go out as one, or a trailers-only gRPC answer becomes an answer with a body, which is the thing `head_is_the_message` exists to prevent — HTTP/1.1 just closes the body early and HTTP/3 ignores it. And `Incoming` grew **`release`** and **`ended`**: flow control is HTTP/2's alone, and releasing the window only *after* a piece has been forwarded is what turns the far end's window into backpressure on the near one, so it could not stay inside a loop that no longer knows which protocol it is reading. + +**Terminating HTTP/2 was never forced the way terminating QUIC is.** The bytes are framed by a transport the kernel understands, so firegex could have gone on forwarding them. What forces it is the promise: a filter written once has to work everywhere, and there is no way to keep that over HPACK without decompressing it, which is what parsing HTTP/2 means. + +Three things differ from h3 and each is a way to get this wrong: + +- **Flow control is ours to run.** `h2` hands received bytes over and waits for them to be released. Releasing on read means no backpressure and this process buying memory for whichever side is faster; never releasing stalls the stream after the first window. So capacity is released **after the piece has been forwarded**, and outbound writes reserve capacity before writing rather than queueing in the connection. +- **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. + +**What belongs to a connection does not cross into another version** (`http1::connection_specific`, `http1::drop_connection_headers`). HTTP/2 and HTTP/3 have no hop-by-hop headers: the framing and the connection's lifetime are the protocol's own. An HTTP/1.1 service says things about its connection in almost every answer — `transfer-encoding: chunked` for a body of unknown length, `connection: keep-alive` — and those were passed on as they came: the h2 crate refused to send such a response at all, so an HTTP/2 client in front of an ordinary HTTP/1.1 service got a stream error for any chunked answer, and an HTTP/3 client is required to reject one. `h1up` takes them out, with whatever `connection` names, before the answer is rendered or sent. The other direction: h2 refuses a request carrying one itself, but the h3 crate does not, so `h3.rs` stops such a stream as malformed (RFC 9114 §4.2) rather than hand an HTTP/1.1 service a `connection` naming headers to drop or a `te` that becomes a transfer coding; `te: trailers` is the one allowed. The test stand-ins had never sent either header, which is how the first half went unnoticed. + +**One client connection is bounded like one connection.** `max_connections` counts a TCP or QUIC connection once, and on the HTTP edges each stream is an exchange of its own — towards an HTTP/1.1 service, a connection of its own. The h2 crate sets no limit on concurrent streams by default, so one HTTP/2 client could open the protected service as many times as it liked; `h2.rs` advertises `MAX_CONCURRENT_STREAMS` (100, what quinn already allows per QUIC connection). A client's head is bounded the same way on both (`http1::MAX_HEAD_BYTES`, 1 MiB — the budget the filter library holds a stream to by default, and far past what any service accepts, so a large cookie or heavy gRPC metadata is never what it refuses): h2 allowed 16 MiB and h3 had no limit at all. Both crates answer an oversized head with `431` themselves. And the connection dialled before the client's protocol is known is dropped on the HTTP/2 → HTTP/1.1 path, where every exchange dials its own; held, it sat idle towards the service for as long as the client stayed. + +**Server push is refused in the handshake, not by dropping what arrives** (`enable_push(false)` towards the service) — the same courtesy the QUIC edge pays about datagrams, and for the same reason: a peer that learns from SETTINGS is better off than one that watches its pushes vanish. **Extended CONNECT is not advertised**, and a plain `CONNECT` stream is reset with a log line rather than rendered: a tunnel is opaque bytes with no HTTP/1.1 message in it, and inventing one would be the fabrication everything here avoids. Both are the WebTransport answer from the QUIC section, arriving at the same place. + +**A refusal closes the connection, not the stream**, with `Reason::CANCEL` — deliberately not `REFUSED_STREAM`, which tells a client the request was never acted on and may be retried, and a block a client retries past is not a block. The GOAWAY is polled onto the wire before the socket goes (`GOAWAY_GRACE`), for the same reason the byte pumps shut down rather than abort. + +**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. + +What is *not* carried and is a limit rather than an unconfigured setting: the **HTTP/1.1 `Upgrade: h2c`** handshake. The upgrade request itself is filtered as the HTTP/1.1 request it is, but if the service answers `101` everything after it is HTTP/2 nobody rendered. RFC 9113 deprecated the mechanism and nginx dropped it in 1.25.1, so in practice no service accepts it; say so rather than implying it is handled. + +### A service can speak every version of HTTP at once (`http`) + +`L4.HTTP` is the odd value in that enum and deliberately so: the other four say what is on the wire, and this one says what the *service* is, letting each address say how it is reached — `:80` in the clear, `:443` under TLS, `:443/udp` under QUIC. One service, one chain, one certificate. + +It exists because the alternative was measured against the same rule that put several addresses under one service: HTTP/1.1 and HTTP/2 live on `tls` (TCP) and HTTP/3 on `quic` (UDP), so covering the three took two or three services with the same filter chain copied between them by hand — and a chain kept in step by hand is a chain that stops protecting one of them silently. + +**Nothing about the kernel became ambiguous**, because nothing here is asked of the kernel. Each address still carries its own `tcp` or `udp` in `service_addresses.proto`, which is what the rules match and what `(ip, port, proto)` is keyed on; `nftables.py` needed no change at all, because it was already asking the address. What changed is that the questions which used to be asked of the *service* — "does this need a UDP relay", "does the TCP listener need to accept IPv6" — now go through `Service.carries(l4)` and `Service.udp_addresses`, since `Service.l4` no longer has one answer. `L4.l4_of("http")` returns `tcp`, which is the right default for an address that did not say and never a claim about all of them. + +**What the connection turns out to be is decided per connection, from what the client sent.** `TlsSetup.optional` is the whole of the mechanism: the TCP listener terminates TLS only for the connections that start a handshake, so the cleartext port and the encrypted one are the same listener, the same chain and the same certificate. The upstream leg mirrors it — a connection that arrived in the clear is forwarded in the clear, one that arrived under TLS is re-encrypted — because the service is being dialled on the port the client chose, and that port's edge is the one the client has just demonstrated. This is the ALPN mirroring rule applied one layer down: firegex carries what the two ends are doing rather than deciding it for them. + +Two consequences worth keeping: + +- **TLS on TCP and QUIC on UDP now live in one engine process**, which they could not before: `main.rs` built one or the other on the reasoning that a service speaks one or the other. `FGEX_PROXY_TLS` says so explicitly now, and the old implicit rule (a certificate means terminate, unless this is a QUIC service) still applies to everything that does not. +- **A certificate is required**, even for the cleartext addresses beside it. A service that only answers in the clear is a `tcp` one — where HTTP/1.1 is filtered as it always was and HTTP/2 is now recognised from its preface — so the TLS and HTTP/3 edges are the whole of what `http` adds, and neither exists without one. + +`http` is **proxy-layer only**, refused at creation by `NfqueueTransport.check` and `ExternalTransport.check` with its own sentence rather than the TLS one or the QUIC one: the reason is neither, it is that an `http` service is reached over TLS *and* QUIC *and* in the clear and that layer can read none of it — not even the cleartext edge, because HTTP/2 in the clear is HPACK and rendering it means terminating the connection. + +### Authentication (`backend/app.py`) +Every module router is mounted onto `api = APIRouter(prefix="/api", dependencies=[Depends(is_loggined)])`, so a new file in `routers/` is authenticated by construction. The whole auth surface is four places: that dependency, the Socket.IO `connect` handler (reads the token out of `auth`), and the three routes declared on `app` directly rather than on `api` — `/api/status`, `/api/login`, `/api/set-password`. Adding an unauthenticated route means declaring it on `app`, which should stay a deliberate, rare act. + +`--unsafe-disable-auth` (env `UNSAFE_DISABLE_AUTH`) hands access control to a reverse proxy: `check_login` returns `True`, the socket.io handler skips its check, and `APP_STATUS()` reports `run` even with no password stored. The password endpoints (`/api/login`, `/api/set-password`, `/api/change-password`) answer `403` in this mode on purpose — otherwise an anonymous caller could plant a credential that keeps working once authentication is turned back on. `run.py config --password` still works, writing the hash straight into the DB from the host, and an explicit `-P` at first start is kept so that re-enabling authentication lands on a known password instead of the open initial-setup state. + +**The flag is state, not a constant.** It lives in `keys_values["auth_disabled"]` and every check reads it (`auth_disabled()`); the environment is only the seed, 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 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`, **`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. **An import is all or nothing.** Every database is first loaded with `load(dry_run=True)`, which does the writes and rolls them back, and only when all of them would load is any loaded for real; the preserved keys are written back in a `finally`. They used to be loaded one after another, so a refusal from one left the ones before it replaced and the rest of the import never ran — with `firegex.db` first, as an export lists it, the instance came back with no password and no secret, asking the next visitor to choose one. What refused was the firewall's own `table` column, which `load` wrote unquoted: no backup holding a single firewall rule could be imported. `load` quotes every identifier now (they come from `PRAGMA table_info`, never from the backup). **And the filter files follow the database**: `_prune_orphan_code` runs at every services start — which is what an import ends with — and removes each `.py` no Python filter refers to. An import used to write the backup's files and leave every other one, so the code of each filter created since the backup stayed on disk for a filter that no longer existed. `tests/integration/test_backup.py` restores a deleted service — chain, patterns, code, switched-off functions, certificate — and checks it is running and filtering, restores the firewall's rules and policy, and imports a backup that cannot load to check nothing changed. + +### Real-time frontend updates +One Socket.IO event, `"update"`, whose payload is a list of strings treated as a react-query `queryKey` prefix (e.g. `["services"]`, `["firewall"]`). `App.tsx` does `queryClient.invalidateQueries({ queryKey: data })` — prefix matching invalidates any query whose key starts with that array. Convention: each module's mutating endpoints emit the same tag string that module's frontend queries use as the first `queryKey` element. `staleTime: Infinity` is set globally, so this event is the **only** automatic refresh mechanism — a missing/mismatched tag means silent staleness for other clients/tabs. + +### Frontend structure +`pages//` (routes) + `components//` (ServiceRow, AddEditService, `utils.ts` with query key + API wrapper) per module, mirroring the backend split. Detail drill-down lives at `pages//ServiceDetails.tsx` under a nested `:srv` route (see `App.tsx`'s `PageRouting`). Theme tokens are CSS variables in `src/index.css`; Mantine `Card` defaults are set globally in `index.tsx`. Prefer Mantine `Group`/`Stack` with `gap` over the legacy `Box className="center-flex"` utility classes still present in older parts of the codebase. + +### `fgex-lib/` +Separate, independently-published PyPI package (`pip install fgex`/`firegex`) — the library users write Python filters against (`@pyfilter`, `RawPacket`/`HttpRequest`/etc.), plus the `fgex` CLI, a local `proxysim` for testing filters, and `firegex.regex` for testing *pattern* rulesets, without a real firegex instance. + +The library module is **`firegex.pyfilters`**, and the CLI command is `fgex pyfilters`. Both were `nfproxy` when that was one of several modules. A compatibility alias carried the old spellings through 4.x and was **removed in 5.0.0**, which is a breaking change and the reason the version is a major one: `from firegex.nfproxy import pyfilter` is the first line of every filter written before it, including the ones already sitting in a running firegex's database. + +Two things about that removal are worth keeping. The directory has to go, not just its contents — a `firegex/nfproxy/` left behind with only a `__pycache__` in it is a *namespace package*, so the import succeeds and hands back something empty, and `from firegex.nfproxy import pyfilter` then fails with an `ImportError` about a name rather than a `ModuleNotFoundError` about a module. And the failure is made to explain itself where the operator meets it: `pyworker.py --check` recognises this one import and says what to write instead, because the alternative is a filter that stops loading at the first start after an upgrade, which at a competition is the start of a round. + +Do not reintroduce the alias as a file of re-exports if it ever comes back. `type_annotations_associations` maps model **classes** to protocols, and both the library and the datapath decide when to call a filter by looking an annotation up in that table, so a shim building second copies of `HttpRequest` would leave a filter annotating a class the table has never heard of — importing cleanly and then never being called. The old one aliased submodules into `sys.modules` for exactly that reason: one class per model, two spellings of one package. + +**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. **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. + +### Module documentation (`docs/*.md`) +Each module's user-facing docs (`docs/services.md`, `docs/pyfilter.md`, `docs/firewall.md`) are single markdown sources, each imported directly with Vite's `?raw` suffix by a matching `Docs.tsx` component (e.g. `components/Services/ServicesDocs.tsx`) and rendered through the shared `components/MarkdownDocs.tsx` (React Markdown + `remark-gfm`, code fences routed through `CodeHighlight`, relative `.md` cross-links rewritten to the equivalent GitHub blob URL since they'd otherwise resolve against the app's own origin). `DocsButton.tsx`'s `DocType` enum wires each module to its component. Every language a fence uses has to be in `index.tsx`'s `createHighlighter({ langs })` — Shiki throws for one it was not given, and that throw is uncaught, so it takes the whole page down rather than the block it could not colour. Editing a module's docs means editing its one `docs/*.md` file — never the `*Docs.tsx` component directly. Reading these files across the `frontend/`↔repo-root boundary requires `vite.config.ts`'s `server.fs.allow: ['..']` (dev) and the `Dockerfile` frontend build stage's `COPY ./docs/*.md /docs/` (prod/standalone rootfs) to stay in sync with any path changes. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index edc70f4..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,103 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## What this is - -Firegex is a firewall built for CTF Attack-Defense competitions: it sits in front of a vulnerable service and blocks/mangles malicious traffic before it reaches it. It ships several independent filtering modules (regex-based, Python-filter-based, port hijacking, plain nftables rules, TLS decrypt-and-reinspect) behind a single FastAPI backend and React frontend. - -## Commands - -### Running the whole app (Docker or standalone) -```bash -python3 run.py # build+start (Docker if available, else standalone chroot on Linux) -python3 run.py --standalone # force standalone mode (no Docker) -python3 run.py stop [--clear] # stop (optionally wipe the DB volume/rootfs) -python3 run.py restart # recreate the container WITHOUT rebuilding (stale image if source changed) -python3 run.py status -python3 run.py config --password [newpass] # change the running instance's password; omit the value to be prompted interactively -python3 run.py config --show # print the persisted settings (port, host, socket dir, auth) -python3 run.py start --unsafe-disable-auth # no authentication at all — see "Authentication" below -``` -`run.py` has zero external dependencies (stdlib only) by design. `restart` runs `docker compose up -d --force-recreate`, so it re-reads the generated compose file and re-applies env vars (`ALLOWED_IPS`, `PROXY_IP_HEADER`, `UNSAFE_DISABLE_AUTH`) — a plain `docker compose restart` would keep the old container environment and silently ignore those. It still does **not** rebuild the image; after a source change use `stop` then the bare start command to force `docker compose up -d --build`. - -Most `start`/`restart` flags are persisted to `.firegex-conf.json` and become the default for later runs, so a bare `python3 run.py start` inherits whatever was last passed. - -### Frontend (`frontend/`) -```bash -bun install # bun.lock is canonical -bun run dev # vite dev server -bun run build # tsc && vite build -npx tsc --noEmit -p . # typecheck only (no separate lint script is configured) -``` - -### Backend (`backend/`) -Needs Linux with `NET_ADMIN` (nftables/nfqueue) and root, so it's normally only run via `run.py` (Docker or standalone). Direct iteration on a Linux box/VM: -```bash -cd backend && DEBUG=1 python3 app.py DEBUG -``` - -### Tests (`tests/`) -No pytest — plain scripts driven against a **live, already-running** firegex instance (`http://127.0.0.1:4444` by default), root/Linux required. -```bash -cd tests -./run_tests.sh [password] # full suite (defaults to "testpassword") -python3 nfregex_test.py -p -m tcp [--ipv6] [--tls] -python3 nfproxy_test.py -p [--ipv6] [--tls] -python3 ph_test.py -p -m tcp|udp [--ipv6] -python3 tls_test.py -p [--ipv6] -python3 api_test.py -p -python3 ipfilter_test.py # access control (--allowed-ips/--proxy-ip-header/--unsafe-disable-auth) -``` -`ipfilter_test.py` is the odd one out: those settings are only read at process startup, so it drives `run.py stop`/`start` itself for each scenario and restores an unrestricted instance at the end. It is **not** part of `run_tests.sh` — run it on its own, and expect it to bounce the instance. - -CI (`.github/workflows/release.yml`) runs **only on `release: published`**, not on pushes or pull requests — nothing validates a PR automatically. Its `run_tests` job (`ubuntu-latest` + `ubuntu-24.04-arm`) does `python3 run.py start -P testpassword` then `cd tests && ./run_tests.sh`, and gates every publish job below it. - -## Architecture - -### Three tiers -React (Vite + Mantine + react-router + TanStack Query + socket.io-client) → FastAPI backend → per-module C++ binaries that do the actual packet interception via nftables/NFQUEUE. The backend mostly orchestrates: SQLite-backed config, spawning/talking to the C++ processes over unix sockets, and deploying the built React app. - -### Backend router auto-discovery (`backend/utils/loader.py`) -Every `.py` file in `backend/routers/` is auto-imported and mounted at `/api/` if it exposes a module-level `app` (a `fastapi.APIRouter`). Optional module-level `startup()`, `shutdown()`, `reset(params)` async functions are collected and called by the app's lifespan/`/api/reset` handlers. **Adding a new module = adding a new file here** — nothing else needs to register it. - -### Per-module pattern -Each filtering module (`nfproxy`, `nfregex`, `porthijack`, `firewall`, `tls`) follows the same shape: -- `backend/routers/.py` — FastAPI endpoints + its own SQLite DB, via `utils.sqlite.SQLite`. -- `backend/modules//models.py` — plain `Service`/filter model, `from_dict`. -- `backend/modules//firewall.py` — `FirewallManager`/`ServiceManager` pair: `FirewallManager` tracks one `ServiceManager` per DB row (in-memory), `ServiceManager.next(status)` starts/stops the interceptor/nft rules for that one service. -- `backend/modules//nftables.py` — builds/tears down the module's nftables rules; the manager class extends `utils.NFTableManager` (a `Singleton`), so there's exactly one nft-rule-set owner per module. - -### nfproxy vs nfregex -Both intercept via NFQUEUE, but differently: -- **nfregex** (`backend/binsrc/nfregex.cpp` → `cppregex`): pure C++, matches PCRE2/hyperscan regexes against the stream entirely in the binary. Faster, but rules are just regex+mode. -- **nfproxy** (`backend/binsrc/nfproxy.cpp`, `binsrc/pyproxy/*`, embeds libpython → `cpproxy`): user-supplied Python filter code (via the `firegex` pip package, `fgex-lib/`) runs inside the C++ process through the embedded interpreter, talking back to the backend over a unix socket for stats/exceptions. Much more flexible, slower than nfregex. - -### TLS Decrypt bridge (`backend/modules/tls/`) -A `tls_streams` DB row describes one public `ip:port` to decrypt. `ssl_port`/`clear_port` are two loopback ports **deterministically derived from a hash of `ip:port`** (`get_tls_ports()`), not stored arbitrarily. nginx (config generated by `modules/tls/nginx.py`) does the actual TLS work per active stream: -1. `listen loopback:ssl_port ssl` → terminates the public TLS connection, forwards **plaintext** to `loopback:clear_port`. -2. `listen loopback:clear_port` → re-encrypts (`proxy_ssl on`) and forwards to the real `ip:port`. The real backend is expected to also speak TLS (mirrors a CTF challenge service that natively does TLS) — this is not a generic "terminate TLS in front of a plaintext service" reverse proxy. - -The only unencrypted hop is the loopback leg between `ssl_port` and `clear_port` — an nfproxy/nfregex service must attach there (`target_type="tls"`, `tls_stream_id=...`) to see decrypted content. Both modules' `nftables.py` has a `resolve_target(srv)` helper that swaps in `(loopback_ip, clear_port)` for `target_type="tls"` services instead of the service's own `ip_int`/`port` (which mirror the stream's real destination for display only). Stopping a TLS stream cascades to stop dependent filter services (and vice versa on start); deleting a stream still referenced by a service is rejected. This cross-module logic lives in `routers/tls.py`, which imports `routers.nfproxy`/`routers.nfregex` directly (one-directional — those modules only import `modules.tls.*`, never `routers.tls`). - -### Authentication (`backend/app.py`) -Every module router is mounted onto `api = APIRouter(prefix="/api", dependencies=[Depends(is_loggined)])`, so a new file in `routers/` is authenticated by construction. The whole auth surface is four places: that dependency, the Socket.IO `connect` handler (reads the token out of `auth`), and the three routes declared on `app` directly rather than on `api` — `/api/status`, `/api/login`, `/api/set-password`. Adding an unauthenticated route means declaring it on `app`, which should stay a deliberate, rare act. - -`--unsafe-disable-auth` (env `UNSAFE_DISABLE_AUTH`) hands access control to a reverse proxy: `check_login` returns `True`, the socket.io handler skips its check, and `APP_STATUS()` reports `run` even with no password stored. The password endpoints (`/api/login`, `/api/set-password`, `/api/change-password`) answer `403` in this mode on purpose — otherwise an anonymous caller could plant a credential that keeps working once authentication is turned back on. `run.py config --password` still works, writing the hash straight into the DB from the host, and an explicit `-P` at first start is kept so that re-enabling authentication lands on a known password instead of the open initial-setup state. - -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` from exported `keys_values` rows, so `/api/import` explicitly re-preserves the current password/secret around the import. - -### 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. `["nfproxy"]`, `["tls_streams"]`). `App.tsx` does `queryClient.invalidateQueries({ queryKey: data })` — prefix matching invalidates any query whose key starts with that array. Convention: each module's mutating endpoints emit the same tag string that module's frontend queries use as the first `queryKey` element. `staleTime: Infinity` is set globally, so this event is the **only** automatic refresh mechanism — a missing/mismatched tag means silent staleness for other clients/tabs. - -### Frontend structure -`pages//` (routes) + `components//` (ServiceRow, AddEditService, `utils.ts` with query key + API wrapper) per module, mirroring the backend split. Detail drill-down lives at `pages//ServiceDetails.tsx` under a nested `:srv` route (see `App.tsx`'s `PageRouting`). Theme tokens are CSS variables in `src/index.css`; Mantine `Card` defaults are set globally in `index.tsx`. Prefer Mantine `Group`/`Stack` with `gap` over the legacy `Box className="center-flex"` utility classes still present in older parts of the codebase. - -### `fgex-lib/` -Separate, independently-published PyPI package (`pip install fgex`/`firegex`) — the library users write nfproxy filters against (`@pyfilter`, `RawPacket`/`HttpRequest`/etc.), plus the `fgex` CLI and a local `proxysim` for testing filters without a real firegex instance. Not part of the backend/frontend build. `fgex-lib/README.md` is a short index (PyPI-facing) linking out to `docs/*.md`; it isn't rendered anywhere itself. - -### Module documentation (`docs/*.md`) -Each module's user-facing docs (`docs/nfproxy.md`, `docs/nfregex.md`, `docs/porthijack.md`, `docs/firewall.md`, `docs/tls.md`) are single markdown sources, each imported directly with Vite's `?raw` suffix by a matching `Docs.tsx` component (e.g. `components/NFProxy/NFProxyDocs.tsx`) and rendered through the shared `components/MarkdownDocs.tsx` (React Markdown + `remark-gfm`, code fences routed through `CodeHighlight`, relative `.md` cross-links rewritten to the equivalent GitHub blob URL since they'd otherwise resolve against the app's own origin). `DocsButton.tsx`'s `DocType` enum wires each module to its component. Editing a module's docs means editing its one `docs/*.md` file — never the `*Docs.tsx` component directly. Reading these files across the `frontend/`↔repo-root boundary requires `vite.config.ts`'s `server.fs.allow: ['..']` (dev) and the `Dockerfile` frontend build stage's `COPY ./docs/*.md /docs/` (prod/standalone rootfs) to stay in sync with any path changes. diff --git a/Dockerfile b/Dockerfile index 4d68095..2318ede 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,50 +1,73 @@ - -# Firegex Dockerfile UUID signature -# cf1795af-3284-4183-a888-81ad3590ad84 -# Needed for run.py to detect the Dockerfile - - -FROM --platform=$BUILDPLATFORM oven/bun AS frontend -WORKDIR /app -ADD ./frontend/package.json . -ADD ./frontend/bun.lock . -RUN bun i -COPY ./frontend/ . -COPY ./docs/*.md /docs/ -RUN bun run build - -# Base fedora container -FROM --platform=$TARGETARCH quay.io/fedora/fedora:44 AS base -RUN dnf -y update && dnf install -y python3.14 libnetfilter_queue \ - libnfnetlink libmnl libcap-ng-utils nftables \ - vectorscan libtins python3-nftables libpcap nginx nginx-mod-stream && dnf clean all - -RUN mkdir -p /execute/modules -WORKDIR /execute - -FROM --platform=$TARGETARCH base AS compiler - -RUN dnf -y update && dnf install -y python3.14-devel @development-tools gcc-c++ \ - libnetfilter_queue-devel libnfnetlink-devel libmnl-devel \ - vectorscan-devel libtins-devel libpcap-devel boost-devel - -COPY ./backend/binsrc /execute/binsrc -RUN g++ binsrc/nfregex.cpp -o cppregex -std=c++23 -O3 -Wall -lnetfilter_queue -pthread -lnfnetlink $(pkg-config --cflags --libs libtins libhs libmnl) -RUN g++ binsrc/nfproxy.cpp -o cpproxy -std=c++23 -O3 -Wall -lnetfilter_queue -lpython3.14 -pthread -lnfnetlink $(pkg-config --cflags --libs libtins libmnl python3) - -#Building main conteiner -FROM --platform=$TARGETARCH base AS final - -COPY ./backend/requirements.txt /execute/requirements.txt -COPY ./fgex-lib /execute/fgex-lib - -RUN dnf -y update && dnf install -y gcc-c++ python3.14-devel uv git &&\ - uv pip install --no-cache --system ./fgex-lib &&\ - uv pip install --no-cache --system -r /execute/requirements.txt &&\ - uv cache clean && dnf remove -y gcc-c++ python3.14-devel uv git && dnf clean all - -COPY ./backend/ /execute/ -COPY --from=compiler /execute/cppregex /execute/cpproxy /execute/modules/ -COPY --from=frontend /app/dist/ ./frontend/ - -CMD ["/bin/sh", "/execute/docker-entrypoint.sh"] + +# Firegex Dockerfile UUID signature +# cf1795af-3284-4183-a888-81ad3590ad84 +# Needed for run.py to detect the Dockerfile + + +FROM --platform=$BUILDPLATFORM oven/bun AS frontend +WORKDIR /app +ADD ./frontend/package.json . +ADD ./frontend/bun.lock . +RUN bun i +COPY ./frontend/ . +COPY ./docs/*.md /docs/ +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, 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 \ + vectorscan libtins python3-nftables libpcap && dnf clean all + +RUN mkdir -p /execute/modules +WORKDIR /execute + +FROM --platform=$TARGETARCH base AS compiler + +RUN dnf -y update && dnf install -y python3.14-devel @development-tools gcc-c++ \ + libnetfilter_queue-devel libnfnetlink-devel libmnl-devel \ + vectorscan-devel libtins-devel libpcap-devel boost-devel cargo + +COPY ./backend/binsrc /execute/binsrc +RUN g++ binsrc/nfregex.cpp -o cppregex -std=c++23 -O3 -Wall -lnetfilter_queue -pthread -lnfnetlink $(pkg-config --cflags --libs libtins libhs libmnl) +RUN g++ binsrc/pyfilter.cpp -o cpproxy -std=c++23 -O3 -Wall -lnetfilter_queue -lpython3.14 -pthread -lnfnetlink $(pkg-config --cflags --libs libtins libmnl python3) + +# The proxy datapath engine, built here rather than in a rust image on purpose: it +# links the same libhs the C++ binaries do, and a binary built against one distro's +# vectorscan and run against another's is a class of failure nobody would enjoy +# debugging at a competition. +COPY ./backend/proxysrc /execute/proxysrc +RUN cd /execute/proxysrc && cargo build --release + +# Where the engine's own suite runs (`cargo test` in proxysrc). It starts real Python +# workers through the filter library, so it needs the compiler stage *and* that library +# with what it imports — without them the pyworker tests fail with a ModuleNotFoundError +# buried in captured output. Nothing below copies from it, and it sits before `final` so a +# build with no target (what compose does) is not handed this as the image. +FROM --platform=$TARGETARCH compiler AS enginetest +COPY ./fgex-lib /fgex-lib +RUN python3.14 -m pip install --no-cache-dir --break-system-packages /fgex-lib + +#Building main conteiner +FROM --platform=$TARGETARCH base AS final + +COPY ./backend/pyproject.toml /execute/pyproject.toml +COPY ./fgex-lib /execute/fgex-lib + +RUN dnf -y update && dnf install -y gcc-c++ python3.14-devel uv git &&\ + uv pip install --no-cache --system ./fgex-lib &&\ + uv pip install --no-cache --system -r /execute/pyproject.toml &&\ + uv cache clean && dnf remove -y gcc-c++ python3.14-devel uv git && dnf clean all + +COPY ./backend/ /execute/ +COPY --from=compiler /execute/cppregex /execute/cpproxy /execute/modules/ +COPY --from=compiler /execute/proxysrc/target/release/fgex-proxy /execute/modules/ +COPY --from=frontend /app/dist/ ./frontend/ + +CMD ["/bin/sh", "/execute/docker-entrypoint.sh"] diff --git a/README.md b/README.md index 82fd540..9041a7f 100644 --- a/README.md +++ b/README.md @@ -84,11 +84,24 @@ This is especially useful if you lost the current password: it's applied directl ## Functionalities -- **[Netfilter Regex](docs/nfregex.md)**: filtering using [NFQUEUE](https://netfilter.org/projects/libnetfilter_queue/) with [nftables](https://netfilter.org/projects/nftables/) uses a c++ file that handle the regexes and the requests, blocking the malicius requests. PCRE2 regexes are used. The requests are intercepted kernel side, so this filter works immediatly (IPv4/6 and TCP/UDP supported) +- **[Services](docs/services.md)**: the main module. A service is one protected endpoint described in two independent parts — a **network layer** that says how its traffic is intercepted, and an ordered chain of **filters** that says what happens to it. + - A service protects a **list of addresses or network interfaces**, mixing IPv4, IPv6 and network interfaces (`eth0`, `wg0`, etc.) freely: intercept traffic on specific IPs or directly across an entire interface. Adding an address or interface to a running service drops no connections. + - Network layer: **NFQUEUE** (packets lifted to userspace with [nfqueue](https://netfilter.org/projects/libnetfilter_queue/) and [nftables](https://netfilter.org/projects/nftables/), nothing terminated, the kernel keeps forwarding if a filter dies) or **proxy** (the connection is terminated and reopened, which buys kernel reassembly, an unlimited ordered chain of filters, TLS and QUIC termination and real backpressure — while still dialling your service from the client's own address, so it never stops seeing who is talking to it). + - Filters: **regex**, matched by [hyperscan](https://github.com/VectorCamp/vectorscan) — the same engine on either layer, so a pattern means the same thing wherever it runs — or **Python**, your own code written against the [`firegex`](https://pypi.org/project/firegex/) library, with built-in models for HTTP and TCP streams. You never declare which protocol a Python filter speaks: asking for an `HttpRequest` is what makes it an HTTP filter, and a filter reads the addresses and ports as metadata while the payload is the only thing it can change. + - A regex **blocks** the connection, and that is deliberately all it does: rewriting had to scan one chunk at a time, so a pattern split across two TCP segments went through unredacted with nothing to say it had — a rule you trust and that quietly does not apply is worse than no rule. To change bytes rather than refuse them, use a Python filter, which sees the reassembled stream. + - **TLS** is one of the protocols a service can speak, not a separate thing to create: the proxy engine terminates the connection itself, the filters inspect the plaintext, and it is re-encrypted towards your service — with the client's ALPN mirrored rather than invented, and the decrypted stream written to a `firegex0` interface you can point a capture at. A TLS service occupies no extra port. + - Or **your own proxy** as the network layer: firegex rewrites the destination on the way in and changes it back on the way out, so a proxy you wrote yourself sits in the path invisibly. The escape hatch for a protocol nothing built in understands. + - **QUIC**, terminated: it is the fourth protocol a service can speak, and the only one with no alternative — past its first packet QUIC encrypts the frames and the stream boundaries as well as the payload, so nothing that merely forwards can be shown to a filter. Each stream inside reaches the chain as its own connection, and **HTTP/3 is rendered as the HTTP/1.1 it would have been**, so an `HttpRequest` filter or a hyperscan pattern written for a TCP service means exactly the same thing on QUIC without being rewritten. + - **HTTP/2 is rendered the same way**, and with it **gRPC**. HTTP/2 puts the method, the path and the headers in an HPACK-compressed frame, so a firewall that only forwarded it showed a filter a compression format: patterns matched nothing and an `HttpRequest` filter was never called, silently. The engine terminates it now — under TLS, or in the clear from the connection preface — so one pattern and one Python file cover HTTP/1.1, HTTP/2 and HTTP/3. + - **HTTP** is a protocol a service can speak in its own right, meaning *every* version at once: its TCP addresses carry HTTP/1.1 and HTTP/2, encrypted or not as each client chooses, and its UDP addresses carry HTTP/3 — one service, one chain, one certificate, instead of two or three services with the same filters copied between them by hand. + - And a Python filter can read **gRPC messages** rather than the framing they arrive in: one message at a time, as each completes, so a streaming or bidirectional call is filtered while it is still open rather than logged after it ends. + - **TCP or UDP**, on every layer — with full source IP transparency on both. On the proxy layer, UDP datagrams are relayed with one dedicated socket per address using transparent IP spoofing (`IP_TRANSPARENT`), with per-flow filter state and zero-downtime dynamic address additions. NFQUEUE filters packets in place with kernel-level fail-open. + - **The two inspecting layers are opposite trades, [compared side by side in the interface itself](docs/services.md#network-layers) while you choose**: the proxy owns both halves of a connection, so the kernel reassembles for it and a Python filter can rewrite a payload exactly, at the cost of rebuilding fail-open by hand; NFQUEUE is per packet and keeps forwarding by kernel guarantee if a filter dies, at the cost of userspace reassembly and a process per filter. + - **Charts of what each filter has refused** — per filter, per pattern and per `@pyfilter` function, each with **its share of the blocking**. Pick the window (last 15 minutes, hour, 6h, 24h, everything kept, or two exact instants) and the shape (bars, lines, stacked area, or share); **everything on the page counts the same window**, so the chart and the table beside it can never tell different stories. Alongside it, how much traffic arrived, in the unit each layer can honestly report. + - **The Python editor knows the library**: completion and hover for every model and its members — marking which of them you may write to — and the file is **checked as you type by the process that will run it**, so a mistake is flagged on its own line with the reason instead of surfacing later as "worker exited". The hints are introspected from the installed library, so they cannot drift from it. + - A **live log** per service shows what it is doing as it does it: what refused each connection (by name and pattern, not an opaque id), whatever your Python prints, and what the datapath says about its own health. + - A **pattern tester** is built in, and it runs the very engine that will enforce the answer — so a pattern it accepts is one you can save, and one it rejects tells you why in the engine's own words. - **[Firewall Rules](docs/firewall.md)**: create basic firewall rules to allow and deny specific traffic, like ufw or iptables but using firegex graphic interface (by using [nftable](https://netfilter.org/projects/nftables/)) -- **[Hijack Port to Proxy](docs/porthijack.md)**: redirect the traffic on a specific port to another port. Thanks to this you can start your own proxy, connecting to the real service using the loopback interface. Firegex will be resposable about the routing of the packets using internally [nftables](https://netfilter.org/projects/nftables/) -- **[Netfilter Proxy](docs/nfproxy.md)**: uses [nfqueue](https://netfilter.org/projects/libnetfilter_queue/) to simulate a python proxy, you can write your own filter in python and use it to filter the traffic. There are built-in some data handler to parse protocols like HTTP, and before apply the filter you can test it with fgex command (you need to install firegex lib from pypi). -- **[TLS Decryption](docs/tls.md)**: a decrypt-and-reinspect bridge for services that speak TLS natively. It transparently terminates the public TLS connection, exposes the decrypted traffic on a loopback port so a Netfilter Regex/Proxy service can inspect it, then re-encrypts before forwarding to the real backend. Firegex can also be restricted to accept connections only from a set of trusted CIDR ranges (`--allowed-ips`, optionally combined with `--proxy-ip-header` when running behind a reverse proxy) — see `python3 run.py start -h`. @@ -102,11 +115,17 @@ python3 run.py start --host 127.0.0.1 --unsafe-disable-auth -P 'a-password-for-l This disables Firegex's password, JWT, and Socket.IO authentication: **every request that reaches Firegex is a full administrator**, so bind it to loopback, a Unix socket, or otherwise make sure nothing but the proxy can reach it. Requests forwarded by the proxy, including headers such as `X-Forwarded-For`, reach Firegex normally. Use `--no-unsafe-disable-auth` to turn the built-in authentication back on. +It can also be switched **while Firegex is running**, from either end: + +- **From the interface**, under the menu's *Firewall Access* section: *Turn Authentication Off* takes effect on the next request. The browser that did it keeps its session, so the same menu offers *Turn Authentication On* to undo it. A browser that arrives afterwards does not get that option — while authentication is off, anyone who can reach Firegex could otherwise set their own password and keep you out for good. +- **From the host**, with `python3 run.py config --unsafe-disable-auth` / `--no-unsafe-disable-auth`. This is the one that sticks: it writes to the running instance *and* persists the choice for the next start. A change made from the interface lasts only until Firegex restarts, since the start-up flag is what it comes up with. `config --password` re-enables it on its own — see below. + A few things worth knowing before using it: -- **The setting is persisted** in `.firegex-conf.json`, like `--port` or `--allowed-ips`: a later plain `python3 run.py start` keeps authentication disabled until you pass `--no-unsafe-disable-auth`. Every start prints a banner while it is active, and `python3 run.py config --show` reports it. -- **Set a password anyway** (`-P`, or later `python3 run.py config --password`). It is unused while authentication is disabled, but it is what you fall back on if you re-enable it. Without one, `--no-unsafe-disable-auth` brings Firegex up in its initial-setup state, where anyone who can reach it chooses the password. -- **The password cannot be changed over the API** while authentication is disabled (`/api/login`, `/api/set-password` and `/api/change-password` answer `403`) — otherwise an anonymous caller could plant a credential that keeps working once authentication is back on. `python3 run.py config --password` still works, since it writes to the database from the host. +- **The setting is persisted** in `.firegex-conf.json`, like `--port` or `--allowed-ips`: a later plain `python3 run.py start` keeps authentication disabled until you pass `--no-unsafe-disable-auth`. Every start prints a banner while it is active, and `python3 run.py config --show` reports it — including when the running instance currently disagrees with it. +- **Set a password anyway** (`-P` at first start). Without one, `--no-unsafe-disable-auth` brings Firegex up in its initial-setup state, where anyone who can reach it chooses the password. +- **`config --password` turns authentication back on**, if it was off — setting a password is asking for one to be asked for, and a password that is stored but never checked is worse than no password because it reads like one. It applies to the running instance and to the next start, both. Pass `--keep-auth-disabled` for the one case where that is not what you want: an instance held open behind a proxy that authenticates, with a password kept ready for the day it is not. +- **The password cannot be changed over the API** while authentication is disabled (`/api/login`, `/api/set-password` and `/api/change-password` answer `403`) — otherwise an anonymous caller could plant a credential that keeps working once authentication is back on. `python3 run.py config --password` still works, since it writes to the database from the host, and it takes effect on the next request rather than on the next restart. - **`--allowed-ips` is not a substitute for the proxy's access control.** Combined with `--proxy-ip-header` it trusts a client-supplied header, so it only holds up if the proxy overwrites that header and nothing else can reach the port directly. ## Documentation @@ -116,10 +135,10 @@ Each module above has its own markdown guide under [`docs/`](docs/), covering ho Heres a brief description about the firegex structure: - [Frontend (React)](frontend/README.md) -- [Backend (FastAPI + C++)](backend/README.md) -- [Netfilter Proxy Python library (`firegex`/`fgex` pip package)](fgex-lib/README.md) +- [Backend (FastAPI + C++ and Rust datapaths)](backend/README.md) +- [Python filter library (`firegex`/`fgex` pip package)](fgex-lib/README.md) -More specific information about how Firegex works, and in particular about the nfproxy module, are available here (in italian only): [https://github.com/domysh/engineering-thesis](https://github.com/domysh/engineering-thesis) (PDF in the release attachments) +More specific information about how Firegex works, and in particular about the Python filter engine (called `nfproxy` at the time), are available here (in italian only): [https://github.com/domysh/engineering-thesis](https://github.com/domysh/engineering-thesis) (PDF in the release attachments) ![Firegex Working Scheme](docs/FiregexInternals.png) diff --git a/backend/app.py b/backend/app.py index 66778e4..36defdc 100644 --- a/backend/app.py +++ b/backend/app.py @@ -9,16 +9,15 @@ 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 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 ChangePasswordModel, IpInterface, PasswordChangeForm, PasswordForm, ResetRequest, StatusModel, StatusMessageModel +from utils.models import AuthModeForm, ChangePasswordModel, IpInterface, PasswordChangeForm, PasswordForm, ResetRequest, StatusModel, StatusMessageModel from contextlib import asynccontextmanager from fastapi.middleware.cors import CORSMiddleware import socketio from socketio.exceptions import ConnectionRefusedError import hashlib import hmac -import re from ipaddress import ip_network, ip_address # DB init db = SQLite('db/firegex.db') @@ -39,12 +38,47 @@ async def lifespan(app): ALLOWED_NETWORKS = [ip_network(ip.strip(), strict=False) for ip in os.getenv("ALLOWED_IPS", "").split(",") if ip.strip()] PROXY_IP_HEADER = os.getenv("PROXY_IP_HEADER", "") -UNSAFE_DISABLE_AUTH = os.getenv("UNSAFE_DISABLE_AUTH", "").strip().lower() in {"1", "true", "yes", "on"} -# With UNSAFE_DISABLE_AUTH every request is already a full administrator, so the password -# endpoints would let an anonymous caller plant a credential that keeps working once -# authentication is turned back on. The password can only be set out of band, from the -# host, with "python3 run.py config --password". -AUTH_DISABLED_DETAIL = "Firegex authentication is disabled: the password can only be changed from the host with 'python3 run.py config --password'" +#: What the environment asked for at boot. Only the seed: the answer lives in the +#: database from here on, so that turning authentication off — or handing the running +#: instance a password — takes effect on the next request instead of on the next restart. +#: A flag that could only be read at startup meant `run.py config --password` printing +#: "it will take effect immediately" while the process it was talking to had already +#: 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? + + Read per request rather than cached. It is one indexed lookup in a SQLite file the + process already holds open, on a path that is about to do a PBKDF2 or a JWT decode, + and the alternative is a copy in memory that can disagree with the one on disk — + which is the whole bug this replaced. + """ + stored = db.get("auth_disabled") + if stored is None: + return UNSAFE_DISABLE_AUTH_ENV + return stored == "1" + + +# With authentication off every request is already a full administrator, so the password +# endpoints would let an anonymous caller plant a credential that keeps working once it +# is turned back on. Both are set out of band, from the host, with "python3 run.py +# config --password" and "--no-unsafe-disable-auth". +AUTH_DISABLED_DETAIL = ("Firegex authentication is disabled: the password can only be changed from " + "the host with 'python3 run.py config --password'") class IPFilterMiddleware: def __init__(self, app): @@ -122,7 +156,7 @@ utils.socketio = socketio.AsyncServer( sio_app = socketio.ASGIApp(utils.socketio, socketio_path="/sock/socket.io", other_asgi_app=app) app.mount("/sock", sio_app) -def APP_STATUS(): return "run" if UNSAFE_DISABLE_AUTH or db.get("password") is not None else "init" +def APP_STATUS(): return "run" if auth_disabled() or db.get("password") is not None else "init" def JWT_SECRET(): return db.get("secret") def _hash_psw_sync(psw: str) -> str: @@ -155,21 +189,32 @@ def create_access_token(data: dict): async def refresh_frontend(additional:list[str]=[]): await socketio_emit([]+additional) -async def check_login(token: str = Depends(oauth2_scheme)): - if UNSAFE_DISABLE_AUTH: - return True +def token_is_valid(token: str | None) -> bool: + """Was this token issued by this instance to somebody who logged in? + + Separate from `check_login` because one caller needs the question without the + "authentication is off, so yes" that `check_login` answers first: turning + authentication back on has to be asked by somebody who was an administrator *before* + it was turned off, and while it is off nothing new is signed — `/api/login` and + `/api/set-password` both refuse — so a valid token is exactly that proof. + """ if not token: return False try: payload = jwt.decode(token, JWT_SECRET(), algorithms=[JWT_ALGORITHM]) - logged_in: bool = payload.get("logged_in") except Exception: return False - return logged_in + return bool(payload.get("logged_in")) + + +async def check_login(token: str = Depends(oauth2_scheme)): + if auth_disabled(): + return True + return token_is_valid(token) @utils.socketio.on("connect") async def sio_connect(sid, environ, auth): - if not UNSAFE_DISABLE_AUTH and (not auth or not await check_login(auth.get("token"))): + if not auth_disabled() and (not auth or not await check_login(auth.get("token"))): raise ConnectionRefusedError("Unauthorized") utils.sid_list.add(sid) @@ -207,13 +252,13 @@ async def get_app_status(auth: bool = Depends(check_login)): "status": APP_STATUS(), "loggined": auth, "version": API_VERSION, - "auth_disabled": UNSAFE_DISABLE_AUTH + "auth_disabled": auth_disabled() } @app.post("/api/login") async def login_api(form: OAuth2PasswordRequestForm = Depends()): """Get a login token to use the firegex api""" - if UNSAFE_DISABLE_AUTH: + if auth_disabled(): raise HTTPException(status_code=403, detail=AUTH_DISABLED_DETAIL) if APP_STATUS() != "run": raise HTTPException(status_code=400) @@ -228,7 +273,7 @@ async def login_api(form: OAuth2PasswordRequestForm = Depends()): @app.post('/api/set-password', response_model=ChangePasswordModel) async def set_password(form: PasswordForm): """Set the password of firegex""" - if UNSAFE_DISABLE_AUTH: + if auth_disabled(): raise HTTPException(status_code=403, detail=AUTH_DISABLED_DETAIL) if APP_STATUS() != "init": raise HTTPException(status_code=400) @@ -241,7 +286,7 @@ async def set_password(form: PasswordForm): @api.post('/change-password', response_model=ChangePasswordModel) async def change_password(form: PasswordChangeForm): """Change the password of firegex""" - if UNSAFE_DISABLE_AUTH: + if auth_disabled(): raise HTTPException(status_code=403, detail=AUTH_DISABLED_DETAIL) if APP_STATUS() != "run": raise HTTPException(status_code=400) @@ -257,6 +302,51 @@ async def change_password(form: PasswordChangeForm): return {"status":"ok", "access_token": create_access_token({"logged_in": True})} +@api.post('/auth-mode', response_model=StatusMessageModel) +async def set_auth_mode(form: AuthModeForm, token: str = Depends(oauth2_scheme)): + """Turn authentication off on a running instance, and back on. + + **Off** is a thing an administrator can decide: it hands access control to whatever + sits in front of firegex, which is a deployment choice rather than an escalation, and + the caller has just proved they are the administrator. + + **On** is asked for differently, because while authentication is off every request + reaching firegex is already a full administrator — an anonymous caller could turn it + back on with a password of their own and keep the real operator out for good, which + is a lasting foothold and not merely the vandalism the mode already allows. So it + takes a token this instance signed *before* it was turned off. Nothing new is signed + while it is off (`/api/login` and `/api/set-password` both refuse), so holding one is + exactly the proof that is wanted, and the operator who turned it off still has theirs. + Whoever does not can do it from the host, where being able to ask is its own proof: + `run.py config --no-unsafe-disable-auth`. + + Either way it lasts as long as the process, because the environment is what firegex + comes up with. `run.py config --[no-]unsafe-disable-auth` writes here *and* persists + the choice, which is how it survives a restart. + """ + if form.disabled == auth_disabled(): + return {"status": "ok"} + if not form.disabled and not token_is_valid(token): + raise HTTPException( + status_code=403, + detail="Turning authentication back on takes a session from before it was " + "turned off, or the host: 'python3 run.py config --no-unsafe-disable-auth'", + ) + if not form.disabled and db.get("password") is None: + # Otherwise the instance comes back up asking any passer-by to choose the password. + raise HTTPException( + status_code=400, + detail="There is no password to ask for. Set one first, from the host, with " + "'python3 run.py config --password'", + ) + db.put("auth_disabled", "1" if form.disabled else "0") + # The sockets were authorised under the rule that has just changed. Dropping them is + # what makes every browser ask again, which is the only way they find out. + await disconnect_all() + await refresh_frontend() + return {"status": "ok"} + + @api.get('/interfaces', response_model=list[IpInterface]) async def get_ip_interfaces(): """Get a list of ip and ip6 interfaces""" @@ -265,8 +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() + # 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: @@ -309,20 +429,17 @@ async def export_db(): temp_db = SQLite(os.path.join('db', f)) dbs[f] = temp_db.dump() - # Export nfproxy filters - if os.path.exists('db/nfproxy_filters'): - dbs['nfproxy_filters'] = {} - for f in os.listdir('db/nfproxy_filters'): + # Export the user's own filter code + if os.path.exists('db/service_filters'): + dbs['service_filters'] = {} + for f in os.listdir('db/service_filters'): if f.endswith('.py'): - with open(os.path.join('db/nfproxy_filters', f), 'rb') as script_file: - dbs['nfproxy_filters'][f] = base64.b64encode(script_file.read()).decode('utf-8') + with open(os.path.join('db/service_filters', f), 'rb') as script_file: + dbs['service_filters'][f] = base64.b64encode(script_file.read()).decode('utf-8') return dbs -# Backup entries are limited to plain basenames of the two shapes export_db() -# produces: ".db" databases and ".py" nfproxy filter files. The charset -# forbids path separators and "..", so a crafted key can't escape the target dir. -_SAFE_DB_NAME = re.compile(r'^[A-Za-z0-9_-]+\.db$') -_SAFE_PY_NAME = re.compile(r'^[A-Za-z0-9_-]+\.py$') +# The charset a backup entry's name has to satisfy lives in `utils`, beside `safe_join`: +# the two are one defence in two halves — what a name may contain, and where it may land. @api.post('/import', response_model=StatusMessageModel) async def import_db(data: dict): @@ -335,17 +452,17 @@ async def import_db(data: dict): for key, value in data.items(): if not isinstance(key, str): raise HTTPException(status_code=400, detail="Invalid backup: keys must be strings") - if _SAFE_DB_NAME.match(key): + if SAFE_DB_NAME.fullmatch(key): if not isinstance(value, dict): raise HTTPException(status_code=400, detail=f"Invalid backup: '{key}' must be an object") # safe_join is defense-in-depth on top of the regex: it rejects any # path that would resolve outside the db/ directory. db_imports.append((safe_join('db', key), value)) - elif key == 'nfproxy_filters': + elif key == 'service_filters': if not isinstance(value, dict): - raise HTTPException(status_code=400, detail="Invalid backup: 'nfproxy_filters' must be an object") + raise HTTPException(status_code=400, detail="Invalid backup: 'service_filters' must be an object") for fname, script_content in value.items(): - if not isinstance(fname, str) or not _SAFE_PY_NAME.match(fname): + if not isinstance(fname, str) or not SAFE_PY_NAME.fullmatch(fname): raise HTTPException(status_code=400, detail=f"Invalid backup: illegal filter filename '{fname}'") if not isinstance(script_content, str): raise HTTPException(status_code=400, detail=f"Invalid backup: filter '{fname}' must be a base64 string") @@ -353,38 +470,62 @@ async def import_db(data: dict): decoded = base64.b64decode(script_content, validate=True) except ValueError: raise HTTPException(status_code=400, detail=f"Invalid backup: filter '{fname}' is not valid base64") - filter_imports.append((safe_join('db/nfproxy_filters', fname), decoded)) + filter_imports.append((safe_join('db/service_filters', fname), decoded)) else: raise HTTPException(status_code=400, detail=f"Invalid backup: unexpected entry '{key}'") if not os.path.exists('db'): os.makedirs('db') - # Backups never contain the password/secret (export_db strips them), so preserve - # the current session's own values instead of losing them on import. - current_password = db.get("password") - current_secret = db.get("secret") + # Backups never contain the password, the secret or the auth mode (export_db strips + # 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)) - # Load into the existing schema; SQLite.load only writes to tables/columns - # that actually exist, so unknown fields in the backup are ignored. - temp_db.load(db_data) + # Every database is loaded once for nothing first, and only if all of them would load + # is any of them loaded for real. They used to go one after another: a refusal from + # one left the ones before it replaced and the rest of the import never ran — so a + # backup listing `firegex.db` first wiped its `keys_values` and never put this + # instance's password and secret back, leaving it asking the next visitor to choose a + # password. Load into the existing schema: SQLite.load only writes to tables and + # columns that actually exist, so unknown fields in the backup are ignored. + try: + for dry_run in (True, False): + for db_path, db_data in db_imports: + temp_db = SQLite(str(db_path)) + try: + temp_db.load(db_data, dry_run=dry_run) + except Exception as e: + if dry_run: + raise HTTPException( + status_code=400, + detail=f"Invalid backup: '{os.path.basename(db_path)}' cannot be " + f"loaded, so nothing was imported: {e}", + ) + raise + finally: + temp_db.disconnect() - if filter_imports: - os.makedirs('db/nfproxy_filters', exist_ok=True) - for filter_path, decoded in filter_imports: - with open(filter_path, 'wb') as script_file: - script_file.write(decoded) + if filter_imports: + os.makedirs('db/service_filters', exist_ok=True) + for filter_path, decoded in filter_imports: + with open(filter_path, 'wb') as script_file: + script_file.write(decoded) + finally: + # Whatever happened above: these are this instance's, and an import that stopped + # half way must not be the end of them. + for key, value in kept.items(): + if value is not None and db.get(key) != value: + db.put(key, value) - if current_password is not None: - db.put("password", current_password) - if current_secret is not None: - db.put("secret", current_secret) - - # 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() + 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'} @@ -403,6 +544,4 @@ if __name__ == '__main__': reload=DEBUG and not NORELOAD, access_log=True, workers=1, # Firewall module can't be replicated in multiple workers - # Later the firewall module will be moved to a separate process - # The webserver will communicate using redis (redis is also needed for websockets) ) diff --git a/backend/binsrc/classes/netfilter.cpp b/backend/binsrc/classes/netfilter.cpp index d887eef..0224221 100644 --- a/backend/binsrc/classes/netfilter.cpp +++ b/backend/binsrc/classes/netfilter.cpp @@ -1,6 +1,5 @@ #include #include -#include #include #include #include "../utils.cpp" @@ -9,154 +8,123 @@ #ifndef NETFILTER_CLASS_CPP #define NETFILTER_CLASS_CPP -namespace Firegex -{ - namespace NfQueue - { +namespace Firegex { +namespace NfQueue { - template - class ThreadNfQueue - { - public: - ThreadNfQueue() = default; - virtual ~ThreadNfQueue() = default; +template +class ThreadNfQueue { +public: + ThreadNfQueue() = default; + virtual ~ThreadNfQueue() = default; - std::thread thr; - BlockingQueue *> queue; + std::thread thr; + BlockingQueue*> queue; - virtual void before_loop() {} - virtual void handle_next_packet(PktRequest *pkt) {} - - void loop() - { - static_cast(this)->before_loop(); - PktRequest *pkt; - for (;;) - { - queue.take(pkt); - try - { - static_cast(this)->handle_next_packet(pkt); - } - catch (const std::exception &e) - { - std::cerr << "[error] Exception handling packet: " << e.what() << std::endl; - if (pkt->get_action() == FilterAction::NOACTION) - { - try - { - pkt->accept(); - } - catch (...) - { - } - } - } - catch (...) - { - std::cerr << "[error] Unknown exception handling packet" << std::endl; - if (pkt->get_action() == FilterAction::NOACTION) - { - try - { - pkt->accept(); - } - catch (...) - { - } - } - } - delete pkt; + virtual void before_loop() {} + virtual void handle_next_packet(PktRequest* pkt){} + + void loop() { + static_cast(this)->before_loop(); + PktRequest* pkt; + for(;;) { + queue.take(pkt); + try { + static_cast(this)->handle_next_packet(pkt); + } catch (const std::exception& e) { + std::cerr << "[error] Exception handling packet: " << e.what() << std::endl; + if (pkt->get_action() == FilterAction::NOACTION) { + try { + pkt->accept(); + } catch (...) {} + } + } catch (...) { + std::cerr << "[error] Unknown exception handling packet" << std::endl; + if (pkt->get_action() == FilterAction::NOACTION) { + try { + pkt->accept(); + } catch (...) {} } } - - void run_thread_loop() - { - thr = std::thread([this]() - { this->loop(); }); - } - }; - - template , Worker>> - void __real_handler(PktRequest> *pkt) - { - const size_t idx = hash_stream_id(pkt->sid) % pkt->ctx->size(); - - auto *converted_pkt = reinterpret_cast *>(pkt); - converted_pkt->ctx = &((*pkt->ctx)[idx]); - - converted_pkt->ctx->queue.put(converted_pkt); + delete pkt; } - - template , Worker>> - class MultiThreadQueue - { - static_assert(std::is_base_of_v, Worker>, - "Worker must inherit from ThreadNfQueue"); - - private: - std::vector workers; - NfQueue, __real_handler> *nfq; - uint16_t queue_num_; - - public: - const size_t n_threads; - static constexpr int QUEUE_BASE_NUM = 1000; - - // Listed in declaration order (that is the order they are really built in). - explicit MultiThreadQueue(size_t n_threads) - : workers(n_threads), n_threads(n_threads) - { - if (n_threads == 0) - throw std::invalid_argument("At least 1 thread required"); - - for (uint16_t qnum = QUEUE_BASE_NUM;; qnum++) - { - try - { - nfq = new NfQueue, __real_handler>(qnum); - queue_num_ = qnum; - break; - } - catch (const std::invalid_argument &) - { - if (qnum == std::numeric_limits::max()) - throw std::runtime_error("No available queue numbers"); - } - } - } - - ~MultiThreadQueue() - { - delete nfq; - } - - void start() - { - for (auto &worker : workers) - { - worker.run_thread_loop(); - } - for (;;) - { - try - { - nfq->handle_next_packet(&workers); - } - catch (const std::exception &e) - { - std::cerr << "[error] [MultiThreadQueue.start] " << e.what() << std::endl; - } - catch (...) - { - std::cerr << "[error] [MultiThreadQueue.start] unknown error while handling a packet" << std::endl; - } - } - } - - uint16_t queue_num() const { return queue_num_; } - }; - } -} // namespace Firegex::NfQueue + + void run_thread_loop() { + thr = std::thread([this]() { this->loop(); }); + } +}; + +template , Worker>> +void __real_handler(PktRequest>* pkt) { + const size_t idx = hash_stream_id(pkt->sid) % pkt->ctx->size(); + + auto* converted_pkt = reinterpret_cast*>(pkt); + converted_pkt->ctx = &((*pkt->ctx)[idx]); + + converted_pkt->ctx->queue.put(converted_pkt); +} + + +template , Worker>> +class MultiThreadQueue { + static_assert(std::is_base_of_v, Worker>, + "Worker must inherit from ThreadNfQueue"); + +private: + std::vector workers; + NfQueue, __real_handler> * nfq; + uint16_t queue_num_; + + +public: + const size_t n_threads; + static constexpr int QUEUE_BASE_NUM = 1000; + + explicit MultiThreadQueue(size_t n_threads) + // Listed in declaration order (that is the order they are really built in). + : workers(n_threads), n_threads(n_threads) + { + if(n_threads == 0) throw std::invalid_argument("At least 1 thread required"); + + for(uint16_t qnum = QUEUE_BASE_NUM; ; qnum++) { + try { + nfq = new NfQueue, __real_handler>(qnum); + queue_num_ = qnum; + break; + } + catch(const std::invalid_argument&) { + if(qnum == std::numeric_limits::max()) + throw std::runtime_error("No available queue numbers"); + } + } + } + + ~MultiThreadQueue() { + delete nfq; + } + + void start() { + for(auto& worker : workers) { + worker.run_thread_loop(); + } + /* One unexpected message must not end the interception. Whatever + `handle_next_packet` throws — a netlink read that failed, a packet + libtins refuses — the nftables rules stay installed either way, so + leaving this loop means a service that still claims to be filtering + and is not. */ + for (;;){ + try { + nfq->handle_next_packet(&workers); + } catch (const std::exception& e) { + std::cerr << "[error] [MultiThreadQueue.start] " << e.what() << std::endl; + } catch (...) { + std::cerr << "[error] [MultiThreadQueue.start] unknown error while handling a packet" << std::endl; + } + } + } + + uint16_t queue_num() const { return queue_num_; } +}; + +}} // namespace Firegex::NfQueue #endif // NETFILTER_CLASS_CPP \ No newline at end of file diff --git a/backend/binsrc/classes/nfqueue.cpp b/backend/binsrc/classes/nfqueue.cpp index 9a70310..c017b6b 100644 --- a/backend/binsrc/classes/nfqueue.cpp +++ b/backend/binsrc/classes/nfqueue.cpp @@ -19,14 +19,10 @@ using namespace std; namespace Firegex{ namespace NfQueue{ -/* Largest packet we are willing to hand back to the kernel in a verdict. - NFQUEUE payloads are carried in a netlink attribute whose length field is - 16 bit wide, so anything above this can never be re-injected anyway; the - cap exists to stop a python filter from making us allocate an arbitrary - amount of memory through mangle_custom_pkt(). */ -const size_t MAX_VERDICT_PACKET_SIZE = 0xffff; - /* Reads the fail-open policy once, tolerating a missing variable. + `strcmp(getenv(...), "1")` was the previous spelling, which dereferences a + null pointer the moment the binary is run without the variable set — by + hand, or by anything that is not the backend. Note that this only drives NFQA_CFG_F_FAIL_OPEN (what the kernel does when the queue is full) and the fallback verdict used when a packet cannot be parsed; the "what happens when this process is not running at all" half of @@ -174,6 +170,35 @@ class PktRequest { return packet.data()+_header_size; } + // The metadata a filter is allowed to know about the layers below it. Read-only by + // construction: these are copies pulled out of the headers, and there is no way + // back from them to the bytes they came from. + string src_ip(){ + if (is_ipv6){ + return ipv6 ? ipv6->src_addr().to_string() : string(); + } + return ipv4 ? ipv4->src_addr().to_string() : string(); + } + + string dst_ip(){ + if (is_ipv6){ + return ipv6 ? ipv6->dst_addr().to_string() : string(); + } + return ipv4 ? ipv4->dst_addr().to_string() : string(); + } + + uint16_t src_port(){ + if (tcp) return tcp->sport(); + if (udp) return udp->sport(); + return 0; + } + + uint16_t dst_port(){ + if (tcp) return tcp->dport(); + if (udp) return udp->dport(); + return 0; + } + size_t data_size(){ return packet.size()-_header_size; } @@ -209,58 +234,6 @@ class PktRequest { return nullptr; } - void set_packet(const char* data, size_t data_size){ - // Parsing only the header with libtins - Tins::PDU *data_pdu = nullptr; - size_t total_size; - // The ipv6 branch has to patch the payload length before handing the - // buffer to libtins, and for a mangled packet `data` points straight at - // the bytes object owned by the python filter: work on a private copy - // instead of mutating the caller's memory. - string raw_packet(data, data_size); - char* raw = raw_packet.data(); - if (is_ipv6){ - delete ipv6; - ipv6 = nullptr; - if (data_size >= 40){ // 40 == fixed size of ipv6 header - // Resetting payload length before parsing to libtins - uint16_t payload_len = htons(data_size-40); - memcpy(((uint8_t *)raw)+4, &payload_len, 2); - } - ipv6 = new Tins::IPv6((uint8_t*)raw, data_size); - if (tcp){ - tcp = ipv6->find_pdu(); - data_pdu = tcp; - }else if (udp){ - udp = ipv6->find_pdu(); - data_pdu = udp; - }else{ - data_pdu = ipv6; - } - total_size = ipv6->size(); - }else{ - delete ipv4; - ipv4 = nullptr; - ipv4 = new Tins::IP((uint8_t*)raw, data_size); - if (tcp){ - tcp = ipv4->find_pdu(); - data_pdu = tcp; - }else if(udp){ - udp = ipv4->find_pdu(); - data_pdu = udp; - }else{ - data_pdu = ipv4; - } - total_size = ipv4->size(); - } - _header_size = total_size - inner_data_size(data_pdu); - if (_header_size > data_size){ - throw invalid_argument("Parsed header is larger than the packet itself"); - } - // Libtins can skip data if the lenght is changed to a bigger len (due to ip header total lenght), so we need to specify the data section manually - set_data(raw+_header_size, data_size-_header_size); - } - void fix_tcp_ack(){ need_tcp_fixing = need_tcp_fix(); if(!need_tcp_fixing){ @@ -343,27 +316,6 @@ class PktRequest { } } - void mangle_custom_pkt(const char* raw_pkt, size_t raw_pkt_size){ - if (action == FilterAction::NOACTION){ - try{ - if (raw_pkt_size > MAX_VERDICT_PACKET_SIZE){ - throw invalid_argument("Mangled packet is too big to be re-injected"); - } - set_packet(raw_pkt, raw_pkt_size); - reserialize(); - action = FilterAction::MANGLE; - }catch(const std::exception& e){ - #ifdef DEBUG - cerr << "[DEBUG] [PktRequest.mangle_custom_pkt] " << e.what() << endl; - #endif - action = FilterAction::DROP; - } - perform_action(false); - }else{ - throw invalid_argument("Cannot mangle a packet that has already been accepted or dropped"); - } - } - FilterAction get_action(){ return action; } diff --git a/backend/binsrc/nfregex.cpp b/backend/binsrc/nfregex.cpp index ec76b62..a771e12 100644 --- a/backend/binsrc/nfregex.cpp +++ b/backend/binsrc/nfregex.cpp @@ -38,8 +38,9 @@ void config_updater (){ } } try{ - regex_config.reset(new RegexRules(raw_rules, regex_config->stream_mode())); - cerr << "[info] [updater] Config update done to ver "<< regex_config->ver() << endl; + auto next = make_shared(raw_rules, regex_config.load()->stream_mode()); + regex_config.store(next); + cerr << "[info] [updater] Config update done to ver "<< next->ver() << endl; osyncstream(cout) << "ACK OK" << endl; }catch(const std::exception& e){ cerr << "[error] [updater] Failed to build new configuration!" << endl; @@ -81,7 +82,7 @@ int main(int argc, char *argv[]){ bool fail_open = Firegex::NfQueue::nfqueue_fail_open(); - regex_config.reset(new RegexRules(stream_mode)); + regex_config.store(make_shared(stream_mode)); MultiThreadQueue queue_manager(n_of_threads); osyncstream(cout) << "QUEUE " << queue_manager.queue_num() << endl; diff --git a/backend/binsrc/nfproxy.cpp b/backend/binsrc/pyfilter.cpp similarity index 78% rename from backend/binsrc/nfproxy.cpp rename to backend/binsrc/pyfilter.cpp index a8a83e3..3dfd287 100644 --- a/backend/binsrc/nfproxy.cpp +++ b/backend/binsrc/pyfilter.cpp @@ -1,4 +1,3 @@ -#define DEBUG #define PY_SSIZE_T_CLEAN #include @@ -22,7 +21,7 @@ How python code is handles: User code example: ```python -from firegex.nfproxy import DROP, ACCEPT, pyfilter +from firegex.pyfilters import DROP, ACCEPT, pyfilter @pyfilter def invalid_curl_agent(http): @@ -36,9 +35,8 @@ The code is now edited adding an intestation and a end statement: ```python __firegex_pyfilter_enabled = ["invalid_curl_agent", "func3"] # This list is dynamically generated by firegex backend -__firegex_proto = "http" -import firegex.nfproxy.internals -firegex.nfproxy.internals.compile(globals(), locals()) # This function can save other global variables, to use by the packet handler and is used generally to check and optimize the code +import firegex.pyfilters.internals +firegex.pyfilters.internals.compile(globals(), locals()) # This function can save other global variables, to use by the packet handler and is used generally to check and optimize the code ```` (First lines are the same to keep line of code consistent on exceptions messages) @@ -48,39 +46,49 @@ The globals and locals generated here are copied for each connection, and are us Using C API will be injected in global context the following informations: __firegex_packet_info = { - "data" = b"raw data found on L4", - "raw_packet" = b"raw packet", + "data" = b"the application payload", "is_input" = True, # If the packet is incoming from a client "is_ipv6" = False, # If the packet is ipv6 "is_tcp" = True, # If the packet is tcp + "src_ip" = "10.0.0.7", "dst_ip" = "10.0.0.1", + "src_port" = 51234, "dst_port" = 80, } +Nothing below the application layer crosses this boundary as bytes: the addresses and +ports are metadata a filter reads, and nothing comes back but a verdict. The proxy +datapath terminates the connection and writes its own headers, so a filter that could +rewrite one here and not there would mean two different things depending on which +network layer it happened to be attached to. + +The protocol is not passed in either: the library reads it off the filters' parameter +annotations, so a file that asks for an HttpRequest is an HTTP filter and one that asks +only for a RawPacket runs on anything. + As result the packet handler is responsible to return a dictionary in the global context with the following dictionary: __firegex_pyfilter_result = { "action": REJECT, # One of PyFilterResponse - "matched_by": "invalid_curl_agent", # The function that matched the packet (used if action = DROP or REJECT or MANGLE) - "mangled_packet": b"new packet" # The new packet to send to the kernel (used if action = MANGLE) + "matched_by": "invalid_curl_agent", # The function that matched the packet (used if action = DROP or REJECT) } PyFilterResponse { ACCEPT = 0, DROP = 1, REJECT = 2, - MANGLE = 3, + // 3 was MANGLE, a payload rewrite; retired, and refused as INVALID EXCEPTION = 4, INVALID = 5 }; Every time a packet is received, the packet handler will execute the following code: ```python -firegex.nfproxy.internals.handle_packet(globals()) +firegex.pyfilters.internals.handle_packet(globals()) ```` The TCP stream is sorted by libtins using c++ code, but the c++ code is not responsabile di buffer the stream, but only to sort those So firegex handle_packet has to implement a way to limit memory usage, this dipends on what methods you choose to use to filter packets firegex lib will give you all the needed possibilities to do this is many ways -Final note: is not raccomanded to use variables that starts with __firegex_ in your code, because they may break the nfproxy +Final note: is not raccomanded to use variables that starts with __firegex_ in your code, because they may break the pyfilter */ @@ -110,13 +118,10 @@ void config_updater (){ cerr << "[fatal] [updater] Control socket error: " << e.what() << endl; exit(EXIT_FAILURE); } - #ifdef DEBUG - cerr << "[DEBUG] [updater] Received code: " << code << endl; - #endif cerr << "[info] [updater] Updating configuration" << endl; PyEval_AcquireThread(state); //Restore GIL before executing python code try{ - config.reset(new PyCodeConfig(code)); + config.store(make_shared(code)); cerr << "[info] [updater] Config update done" << endl; control_socket << "ACK OK" << endl; }catch(const std::exception& e){ @@ -145,7 +150,7 @@ int main(int argc, char *argv[]) { if (n_threads_str != nullptr) n_of_threads = ::atoi(n_threads_str); if(n_of_threads <= 0) n_of_threads = 1; - config.reset(new PyCodeConfig()); + config.store(make_shared()); MultiThreadQueue queue(n_of_threads); diff --git a/backend/binsrc/pyproxy/pyproxy.cpp b/backend/binsrc/pyproxy/pyproxy.cpp index 8169f66..9150aaa 100644 --- a/backend/binsrc/pyproxy/pyproxy.cpp +++ b/backend/binsrc/pyproxy/pyproxy.cpp @@ -30,7 +30,22 @@ namespace PyProxy { class PyProxyQueue: public NfQueue::ThreadNfQueue { private: - u_int16_t latest_config_ver = 0; + // The configuration this thread's contexts were built from. + uint32_t latest_config_ver = 0; + + // A new configuration reaches the connections already open, the way it does on the + // other two engines: `cppregex` resets its matchers on a new version and the proxy + // layer hands the next chunk to the new chain. This was declared and never read, so + // here a stream kept the code it had started with for as long as it stayed open — a + // function switched off, or a check added against the attack in progress, went on not + // reaching the one connection it was meant for. Its state starts again, as it does + // there. + void follow_config(){ + const uint32_t current = config.load()->version; + if (current == latest_config_ver) return; + sctx.clean_filters(); + latest_config_ver = current; + } public: stream_ctx sctx; StreamFollower follower; @@ -71,6 +86,14 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue { } handle_packet_code = unmarshal_code(py_handle_packet_code); + // Streams whose start this process did not see are followed from the packet it + // does see. They are otherwise not followed at all, and a packet of one was simply + // accepted: every connection open before the service started, or before its chain + // was rebuilt — which adding a filter does — and every one quiet for longer than + // the follower keeps a stream, carried whatever it said afterwards. A persistent + // connection was a way past any filter added after it opened. Recovery mode is + // switched on for them in `on_new_stream`. + follower.follow_partial_streams(true); // Setting callbacks for the stream follower follower.new_stream_callback(bind(on_new_stream, placeholders::_1, this)); follower.stream_termination_callback(bind(on_stream_close, placeholders::_1, this)); @@ -80,10 +103,6 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue { control_socket << "BLOCKED " << func_name << endl; } - inline void print_mangle_reason(const string& func_name){ - control_socket << "MANGLED " << func_name << endl; - } - inline void print_exception_reason(){ control_socket << "EXCEPTION" << endl; } @@ -97,11 +116,60 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue { pyq->pkt->drop();// This is needed because the callback has to take the updated pkt pointer! } + // One datagram, judged on its own. + // + // No stream to follow and no sequence numbers to fix: a datagram is complete in + // itself. Each flow still gets its own module globals, keyed the same way a + // connection is. + void filter_action_udp(NfQueue::PktRequest* pkt, const string& data){ + // Idle flows first, so a limit is never met by flows that have already gone. + sctx.udp_expire(UDP_IDLE_SECONDS); + auto stream_search = sctx.streams_ctx.find(pkt->sid); + pyfilter_ctx* stream_match; + if (stream_search == sctx.streams_ctx.end()){ + shared_ptr conf = config.load(); + PyObject* compiled_code = conf->compiled_code(); + if (compiled_code == nullptr){ + return pkt->accept(); // no filter configured; nothing to ask + } + try{ + stream_match = new pyfilter_ctx(compiled_code, handle_packet_code); + }catch(invalid_argument& e){ + cerr << "[error] [filter_action_udp] Failed to create the filter context" << endl; + print_exception_reason(); + return pkt->accept(); + } + sctx.udp_make_room(max_udp_flows()); + sctx.streams_ctx.insert_or_assign(pkt->sid, stream_match); + }else{ + stream_match = stream_search->second; + } + sctx.udp_touch(pkt->sid); + + auto result = stream_match->handle_packet(pkt, data, pkt->is_input); + switch(result.action){ + case PyFilterResponse::ACCEPT: + return pkt->accept(); + // There is no connection to close, so refusing means this datagram is not + // delivered. The next one from the same flow is judged afresh, which is the + // only thing "refuse" can mean without a connection to refuse. + case PyFilterResponse::DROP: + case PyFilterResponse::REJECT: + print_blocked_reason(*result.filter_match_by); + return pkt->drop(); + case PyFilterResponse::EXCEPTION: + case PyFilterResponse::INVALID: + print_exception_reason(); + sctx.clean_stream_by_id(pkt->sid); + return pkt->accept(); + } + } + void filter_action(NfQueue::PktRequest* pkt, Stream& stream, const string& data, bool is_client){ auto stream_search = sctx.streams_ctx.find(pkt->sid); pyfilter_ctx* stream_match; if (stream_search == sctx.streams_ctx.end()){ - shared_ptr conf = config; + shared_ptr conf = config.load(); //If config is not set, ignore the stream PyObject* compiled_code = conf->compiled_code(); if (compiled_code == nullptr){ @@ -123,6 +191,13 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue { stream.ignore_server_data(); return pkt->accept(); } + // This context starts after its connection did: the process met the + // stream halfway, or new code replaced the context it had. What arrives + // first can be the middle of a message, and the library must not judge + // a message by its second half — see `_met_mid_message` there. + if (stream.is_partial_stream() || sctx.taken_over.erase(pkt->sid) > 0){ + stream_match->set_item_to_glob("__firegex_joined_late", PyBool_FromLong(1)); + } sctx.streams_ctx.insert_or_assign(pkt->sid, stream_match); } }else{ @@ -145,16 +220,6 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue { stream.client_data_callback(bind(keep_fin_packet, this)); stream.server_data_callback(bind(keep_fin_packet, this)); return pkt->reject(); - case PyFilterResponse::MANGLE: - pkt->mangle_custom_pkt(result.mangled_packet->c_str(), result.mangled_packet->size()); - if (pkt->get_action() == NfQueue::FilterAction::DROP){ - cerr << "[ERROR] [filter_action] Failed to mangle: Malformed Packet... the packet was dropped" << endl; - print_blocked_reason(*result.filter_match_by); - print_exception_reason(); - }else{ - print_mangle_reason(*result.filter_match_by); - } - return; case PyFilterResponse::EXCEPTION: case PyFilterResponse::INVALID: print_exception_reason(); @@ -169,7 +234,12 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue { } - static void on_data_recv(Stream& stream, PyProxyQueue* pyq, const string& data, bool is_client) { + // The direction is the packet's own, from the mark the rules put on it, rather than + // which side of the stream the follower calls the client: on a stream followed from + // the middle that is whoever it happened to see first, and the service answering + // before the client speaks again made the filter read the answer as the request. + static void on_data_recv(Stream& stream, PyProxyQueue* pyq, const string& data, bool) { + const bool is_client = pyq->pkt->is_input; pyq->pkt->fix_data_payload(); pyq->filter_action(pyq->pkt, stream, data, is_client); //Only here the rebuilt_tcp_data is set } @@ -220,6 +290,18 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue { void handle_next_packet(NfQueue::PktRequest* _pkt) override{ pkt = _pkt; // Setting packet context + follow_config(); + + if (pkt->l4_proto == NfQueue::L4Proto::UDP){ + // Straight to the filter: the stream follower is TCP's, and so is every + // piece of machinery above it. This used to fall into the check below and + // throw — with a message claiming UDP was supported. + filter_action_udp(pkt, string(pkt->data(), pkt->data_size())); + if (pkt->get_action() == NfQueue::FilterAction::NOACTION){ + return pkt->accept(); + } + return; + } if (pkt->l4_proto != NfQueue::L4Proto::TCP){ throw invalid_argument("Only TCP and UDP are supported"); diff --git a/backend/binsrc/pyproxy/settings.cpp b/backend/binsrc/pyproxy/settings.cpp index 6648994..bfca866 100644 --- a/backend/binsrc/pyproxy/settings.cpp +++ b/backend/binsrc/pyproxy/settings.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include "../utils.cpp" @@ -15,7 +16,9 @@ namespace PyProxy { class PyCodeConfig; -shared_ptr config; +// Atomic for the reason `regex_config` is: the updater swaps it while the queue threads +// copy it for every new stream. +std::atomic> config; UnixClientConnection control_socket; PyObject* unmarshal_code(string encoded_code){ @@ -24,8 +27,14 @@ PyObject* unmarshal_code(string encoded_code){ } class PyCodeConfig{ + private: + // Only the updater thread builds configurations, so this needs no lock. + static inline uint32_t glob_seq = 0; public: string encoded_code; + // Which configuration this is, so a queue thread can tell that the contexts it + // holds were built from code no longer in force. 0 is the empty one it starts on. + uint32_t version = 0; PyCodeConfig(const string& pycode){ PyObject* compiled_code = Py_CompileStringExFlags(pycode.c_str(), "", Py_file_input, NULL, 2); @@ -58,6 +67,7 @@ class PyCodeConfig{ } encoded_code = string(PyBytes_AsString(code_dump), PyBytes_Size(code_dump)); Py_DECREF(code_dump); + version = ++glob_seq; } PyObject* compiled_code(){ @@ -78,7 +88,7 @@ string py_handle_packet_code; void init_handle_packet_code(){ PyObject* compiled_code = Py_CompileStringExFlags( - "firegex.nfproxy.internals.handle_packet(globals())\n", "", + "firegex.pyfilters.internals.handle_packet(globals())\n", "", Py_file_input, NULL, 2); PyObject* code_dump = PyMarshal_WriteObjectToString(compiled_code, 4); Py_DECREF(compiled_code); diff --git a/backend/binsrc/pyproxy/stream_ctx.cpp b/backend/binsrc/pyproxy/stream_ctx.cpp index aac781d..542d9e7 100644 --- a/backend/binsrc/pyproxy/stream_ctx.cpp +++ b/backend/binsrc/pyproxy/stream_ctx.cpp @@ -5,6 +5,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include "../classes/netfilter.cpp" #include "../classes/nfqueue.cpp" @@ -20,38 +25,72 @@ namespace PyProxy { class PyCodeConfig; class PyProxyQueue; +// The library's `Action` values, which are the wire format between it and this binary. +// 3 was `MANGLE`, a rewrite of the payload; it is gone from the library, and the number +// stays unused so that nothing old can ever mean something new. Anything outside the +// valid set is `INVALID`, which fails open and is reported like an exception. enum PyFilterResponse { ACCEPT = 0, DROP = 1, REJECT = 2, - MANGLE = 3, EXCEPTION = 4, INVALID = 5 }; -const PyFilterResponse VALID_PYTHON_RESPONSE[4] = { +const PyFilterResponse VALID_PYTHON_RESPONSE[3] = { PyFilterResponse::ACCEPT, PyFilterResponse::DROP, PyFilterResponse::REJECT, - PyFilterResponse::MANGLE }; struct py_filter_response { PyFilterResponse action; string* filter_match_by = nullptr; - string* mangled_packet = nullptr; - py_filter_response(PyFilterResponse action, string* filter_match_by = nullptr, string* mangled_packet = nullptr): - action(action), filter_match_by(filter_match_by), mangled_packet(mangled_packet){} + py_filter_response(PyFilterResponse action, string* filter_match_by = nullptr): + action(action), filter_match_by(filter_match_by){} ~py_filter_response(){ - delete mangled_packet; delete filter_match_by; } }; typedef Tins::TCPIP::StreamIdentifier stream_id; +// How long between two tracebacks out of this process, in seconds. +// +// Filter code that throws throws on every packet — a typo does not fire once — and a +// traceback is a dozen lines. Unthrottled, one broken filter writes thousands of lines a +// minute into a log that is a bounded ring, so the flood does not merely repeat itself: +// it pushes out the first traceback, which was the one worth reading, along with +// everything that was in the log before the filter broke. +// +// The `EXCEPTION` sent to the backend is deliberately *not* throttled with it. That is +// one token, it costs nothing, and it is what the backend counts to say "still raising, +// N more since" — so the operator still learns how often this is happening while being +// shown the traceback once. +constexpr int64_t TRACEBACK_QUIET_SECONDS = 30; + +// Process-wide rather than per stream: the flood is one broken filter, not one broken +// connection, and throttling per connection would let a hundred connections print a +// hundred copies of the same fault. Relaxed ordering is enough — two threads racing here +// print one traceback each, which is not a problem worth a lock. +static std::atomic last_traceback_at{-TRACEBACK_QUIET_SECONDS * 2}; + +inline bool traceback_is_due() { + const int64_t now = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); + int64_t previous = last_traceback_at.load(std::memory_order_relaxed); + if (now - previous < TRACEBACK_QUIET_SECONDS) return false; + return last_traceback_at.compare_exchange_strong( + previous, now, std::memory_order_relaxed); +} + +// How many dropped filter contexts may wait for a full collection, and how many have on +// this queue thread — each thread runs an interpreter of its own, so each counts its own. +constexpr unsigned COLLECT_EVERY = 64; +static thread_local unsigned dropped_since_collect = 0; + struct pyfilter_ctx { PyObject * glob = nullptr; @@ -75,7 +114,18 @@ struct pyfilter_ctx { ~pyfilter_ctx(){ Py_DECREF(glob); Py_DECREF(py_handle_packet); - PyGC_Collect(); + // The module's functions hold these globals and the globals hold the functions, + // so a context is a cycle only the collector frees. Left to the interpreter's + // own schedule, one that lived long enough to be promoted waits for a full pass, + // which is triggered by object counts rather than by bytes — measured, a filter + // building a table at module level held ~200 MB more across a stream of short + // connections. A full pass per context held memory flat and cost ~1 ms each, a + // ceiling of about two hundred connections a second per queue thread. One pass + // every `COLLECT_EVERY` contexts keeps both. + if (++dropped_since_collect >= COLLECT_EVERY){ + dropped_since_collect = 0; + PyGC_Collect(); + } } inline void set_item_to_glob(const char* key, PyObject* value){ @@ -111,22 +161,38 @@ struct pyfilter_ctx { PyObject * packet_info = PyDict_New(); pkt->reserialize(); + // The application payload, and metadata about everything under it. No header + // bytes cross this boundary in either direction: a filter reads where the + // traffic came from and edits what it carries, which is the one contract both + // network layers can honestly offer. + string src_ip = pkt->src_ip(), dst_ip = pkt->dst_ip(); set_item_to_dict(packet_info, "data", PyBytes_FromStringAndSize(data.c_str(), data.size())); - set_item_to_dict(packet_info, "l4_size", PyLong_FromLong(pkt->data_size())); - set_item_to_dict(packet_info, "raw_packet", PyBytes_FromStringAndSize(pkt->packet.c_str(), pkt->packet.size())); set_item_to_dict(packet_info, "is_input", PyBool_FromLong(is_client)); set_item_to_dict(packet_info, "is_ipv6", PyBool_FromLong(pkt->is_ipv6)); set_item_to_dict(packet_info, "is_tcp", PyBool_FromLong(pkt->l4_proto == NfQueue::L4Proto::TCP)); + set_item_to_dict(packet_info, "src_ip", PyUnicode_FromStringAndSize(src_ip.c_str(), src_ip.size())); + set_item_to_dict(packet_info, "dst_ip", PyUnicode_FromStringAndSize(dst_ip.c_str(), dst_ip.size())); + set_item_to_dict(packet_info, "src_port", PyLong_FromLong(pkt->src_port())); + set_item_to_dict(packet_info, "dst_port", PyLong_FromLong(pkt->dst_port())); // Set packet info to the global context set_item_to_glob("__firegex_packet_info", packet_info); + // No collection here. The interpreter's own collector is on (`before_loop` + // makes sure), and a full pass per packet cost about a millisecond with the + // library loaded — a ceiling of a thousand packets a second per queue thread, + // spent finding nothing. PyObject * result = PyEval_EvalCode(py_handle_packet, glob, glob); - PyGC_Collect(); del_item_from_glob("__firegex_packet_info"); if (PyErr_Occurred()){ - cerr << "[error] [handle_packet] Failed to execute the code " << result << endl; - PyErr_Print(); + // Shown at most once every TRACEBACK_QUIET_SECONDS; the EXCEPTION below goes + // every time, and is what carries the count. + if (traceback_is_due()){ + cerr << "[error] [handle_packet] Failed to execute the code " << result << endl; + PyErr_Print(); + } else { + PyErr_Clear(); + } #ifdef DEBUG cerr << "[DEBUG] [handle_packet] Exception raised" << endl; #endif @@ -210,28 +276,8 @@ struct pyfilter_ctx { del_item_from_glob("__firegex_pyfilter_result"); return py_filter_response(action_enum, func_name); } - if (action_enum == PyFilterResponse::MANGLE){ - PyObject* mangled_packet = PyDict_GetItemString(result, "mangled_packet"); - if (mangled_packet == nullptr){ - del_item_from_glob("__firegex_pyfilter_result"); - #ifdef DEBUG - cerr << "[DEBUG] [handle_packet] No result mangled_packet found" << endl; - #endif - return py_filter_response(PyFilterResponse::INVALID); - } - if (!PyBytes_Check(mangled_packet)){ - #ifdef DEBUG - cerr << "[DEBUG] [handle_packet] mangled_packet is not a bytes" << endl; - #endif - del_item_from_glob("__firegex_pyfilter_result"); - return py_filter_response(PyFilterResponse::INVALID); - } - string* pkt_str = new string(PyBytes_AsString(mangled_packet), PyBytes_Size(mangled_packet)); - del_item_from_glob("__firegex_pyfilter_result"); - return py_filter_response(PyFilterResponse::MANGLE, func_name, pkt_str); - } - //Should never reach this point, but just in case of new action not managed... + delete func_name; del_item_from_glob("__firegex_pyfilter_result"); return py_filter_response(PyFilterResponse::INVALID); } @@ -241,19 +287,118 @@ struct pyfilter_ctx { typedef map matching_map; +// How long a UDP flow keeps its filter's state with nothing arriving. A datagram has no +// close to observe, so this is the only thing that ends a flow — the same minute the +// proxy layer's relay gives one. +constexpr int64_t UDP_IDLE_SECONDS = 60; + +inline long long env_number(const char* name){ + const char* env = getenv(name); + if (env == nullptr) return 0; + char* end = nullptr; + long long parsed = strtoll(env, &end, 10); + return end != env ? parsed : 0; +} + +// How many UDP flows one queue thread may hold a filter's state for at once: the +// service's own "most connections at once", shared out between the threads — each holds +// the contexts of the flows hashed to it, so a limit applied per thread would be the +// operator's number times the thread count. 0, missing or unreadable means no limit, and +// idle flows still go after `UDP_IDLE_SECONDS`, which is what keeps that from growing +// without end. +inline size_t max_udp_flows(){ + static const size_t value = [](){ + const long long limit = env_number("FIREGEX_MAX_FLOWS"); + if (limit <= 0) return (size_t)0; + const long long threads = env_number("NTHREADS") > 0 ? env_number("NTHREADS") : 1; + return (size_t)((limit + threads - 1) / threads); + }(); + return value; +} + +inline int64_t steady_seconds(){ + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); +} + struct stream_ctx { matching_map streams_ctx; NfQueue::tcp_ack_map tcp_ack_ctx; + // The UDP flows holding a context, most recently used first, with when each was + // last seen — so the one to let go of, whether for being idle or to make room, is + // always at the back. TCP contexts are not in here and are never evicted: a TCP + // stream is released when libtins sees it close, and dropping its state while it + // is still open would have its next packet parsed from a clean slate mid-message. + list> udp_recent; + map>::iterator> udp_where; + + // The TCP streams whose filter context was thrown away while they were open — new + // code arrived — so the context built for their next packet knows it is taking over + // halfway, possibly in the middle of a message. + set taken_over; + void clean_stream_by_id(stream_id sid){ + taken_over.erase(sid); auto stream_search = streams_ctx.find(sid); if (stream_search != streams_ctx.end()){ auto stream_match = stream_search->second; delete stream_match; streams_ctx.erase(stream_search->first); } + auto flow = udp_where.find(sid); + if (flow != udp_where.end()){ + udp_recent.erase(flow->second); + udp_where.erase(flow); + } + } + + // This UDP flow was just used. + void udp_touch(const stream_id& sid){ + auto flow = udp_where.find(sid); + if (flow != udp_where.end()){ + udp_recent.erase(flow->second); + } + udp_recent.emplace_front(sid, steady_seconds()); + udp_where[sid] = udp_recent.begin(); + } + + // Let go of the UDP flows nothing has arrived on for `idle` seconds. + void udp_expire(int64_t idle){ + const int64_t now = steady_seconds(); + while (!udp_recent.empty() && now - udp_recent.back().second >= idle){ + stream_id sid = udp_recent.back().first; + clean_stream_by_id(sid); + } + } + + // Make room for one more UDP flow under `limit`, letting the least recently used go; + // a limit of zero is none. Only UDP flows are counted and only UDP flows go: the + // ceiling used to be applied to every context, so a burst of datagrams could take the + // state of a TCP connection still in the middle of a request. + void udp_make_room(size_t limit){ + while (limit > 0 && udp_where.size() >= limit && !udp_recent.empty()){ + stream_id sid = udp_recent.back().first; + clean_stream_by_id(sid); + } + } + + // Every filter context, TCP and UDP alike, for when the code they were built from is + // no longer the code in force. The sequence bookkeeping stays: it belongs to the + // connection, not to the filter, and a stream whose payload was already cut would + // have its acknowledgements go wrong without it. + void clean_filters(){ + for (auto ele: streams_ctx){ + if (udp_where.find(ele.first) == udp_where.end()){ + taken_over.insert(ele.first); + } + delete ele.second; + } + streams_ctx.clear(); + udp_recent.clear(); + udp_where.clear(); } void clean_tcp_ack_by_id(stream_id sid){ @@ -266,14 +411,12 @@ struct stream_ctx { } void clean(){ - for (auto ele: streams_ctx){ - delete ele.second; - } + clean_filters(); + taken_over.clear(); for (auto ele: tcp_ack_ctx){ delete ele.second; } tcp_ack_ctx.clear(); - streams_ctx.clear(); } }; diff --git a/backend/binsrc/regex/regex_rules.cpp b/backend/binsrc/regex/regex_rules.cpp index 6d1d698..2afb273 100644 --- a/backend/binsrc/regex/regex_rules.cpp +++ b/backend/binsrc/regex/regex_rules.cpp @@ -8,6 +8,7 @@ #include #include #include +#include using namespace std; @@ -194,7 +195,11 @@ class RegexRules{ } }; -shared_ptr regex_config; +// Atomic, because it is swapped by the updater thread while every queue thread copies +// it per packet. A plain shared_ptr read and reset from two threads at once is a data +// race: the copy can take a reference on a control block the reset has just freed, and +// an operator editing a pattern under load could crash the binary with it. +std::atomic> regex_config; void inline scratch_setup(regex_ruleset &conf, hs_scratch_t* & scratch){ if (scratch == nullptr && conf.hs_db != nullptr){ diff --git a/backend/binsrc/regex/regexfilter.cpp b/backend/binsrc/regex/regexfilter.cpp index 2aef9ff..e6e15c7 100644 --- a/backend/binsrc/regex/regexfilter.cpp +++ b/backend/binsrc/regex/regexfilter.cpp @@ -39,7 +39,7 @@ public: NfQueue::PktRequest* pkt; bool filter_action(NfQueue::PktRequest* pkt, const string& data){ - shared_ptr conf = regex_config; + shared_ptr conf = regex_config.load(); auto current_version = conf->ver(); if (current_version != latest_config_ver){ @@ -181,6 +181,14 @@ public: } void before_loop() override{ + // Streams whose start this process did not see are followed from the packet it + // does see. They are otherwise not followed at all, and a packet of one was simply + // accepted: every connection open before the service started, or before its chain + // was rebuilt — which adding a filter does — and every one quiet for longer than + // the follower keeps a stream, carried whatever it said afterwards. A persistent + // connection was a way past any filter added after it opened. Recovery mode is + // switched on for them in `on_new_stream`. + follower.follow_partial_streams(true); follower.new_stream_callback(bind(on_new_stream, placeholders::_1, this)); follower.stream_termination_callback(bind(on_stream_close, placeholders::_1, this)); } diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh index 9cad0e3..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" @@ -20,18 +31,34 @@ fi echo "[*] Attempting to start with capabilities..." -if capsh --caps="cap_net_admin,cap_setpcap,cap_setuid,cap_setgid,cap_sys_nice+eip" \ - --keep=1 \ - --user=nobody \ - --addamb=cap_net_admin,cap_sys_nice \ - -- -c "exit 0" -then - exec capsh --caps="cap_net_admin,cap_setpcap,cap_setuid,cap_setgid,cap_sys_nice+eip" \ - --keep=1 \ - --user=nobody \ - --addamb=cap_net_admin,cap_sys_nice \ - --shell=/usr/bin/python3 \ - -- /execute/app.py DOCKER +# Two sets, tried in order, because they are not equally important. +# +# `cap_net_raw` is what lets the proxy engine open the packet socket it writes decrypted +# traffic to, for an operator capturing `firegex0`. It is a capture aid: a host that will +# not grant it should lose the capture, not the firewall. So the set without it is tried +# next, and only if *that* fails does anything run as root. +try_caps() { + caps="$1" + ambient="$2" + capsh --caps="$caps" --keep=1 --user=nobody --addamb="$ambient" -- -c "exit 0" 2>/dev/null +} + +run_caps() { + exec capsh --caps="$1" --keep=1 --user=nobody --addamb="$2" \ + --shell=/usr/bin/python3 -- /execute/app.py DOCKER +} + +FULL="cap_net_admin,cap_net_raw,cap_setpcap,cap_setuid,cap_setgid,cap_sys_nice+eip" +FULL_AMB="cap_net_admin,cap_net_raw,cap_sys_nice" +BASE="cap_net_admin,cap_setpcap,cap_setuid,cap_setgid,cap_sys_nice+eip" +BASE_AMB="cap_net_admin,cap_sys_nice" + +if try_caps "$FULL" "$FULL_AMB"; then + run_caps "$FULL" "$FULL_AMB" +elif try_caps "$BASE" "$BASE_AMB"; then + echo "[!] no cap_net_raw: TLS services will run, but their decrypted traffic" + echo " cannot be written to the capture interface" + run_caps "$BASE" "$BASE_AMB" else echo "[!] capsh failed, running with root user" exec python3 /execute/app.py DOCKER diff --git a/backend/modules/firewall/firewall.py b/backend/modules/firewall/firewall.py index 171e34f..077a9ff 100644 --- a/backend/modules/firewall/firewall.py +++ b/backend/modules/firewall/firewall.py @@ -1,4 +1,5 @@ import asyncio +import traceback from modules.firewall.nftables import FiregexTables from modules.firewall.models import Rule, FirewallSettings from utils.sqlite import SQLite @@ -6,18 +7,44 @@ from modules.firewall.models import Action nft = FiregexTables() +#: How often the kernel is asked whether the firewall is still there. The services +#: module asks the same of its own table, for the same reason: `nft flush ruleset` — +#: the first line of a Debian `/etc/nftables.conf` — takes this with it, and a +#: default-deny firewall silently became no firewall at all. +TABLE_WATCH = 3.0 + class FirewallManager: def __init__(self, db:SQLite): self.db = db self.lock = asyncio.Lock() + self._watch: asyncio.Task | None = None async def close(self): + self.stop_watching() async with self.lock: nft.reset() async def init(self): nft.init() await self.reload() + if self._watch is None or self._watch.done(): + self._watch = asyncio.create_task(self._watch_table()) + + async def _watch_table(self): + while True: + await asyncio.sleep(TABLE_WATCH) + try: + if self.enabled and not nft.intact(): + print("[error] [firewall] the firewall's nftables tables were removed " + "by something else; putting them back", flush=True) + await self.reload() + except Exception: + traceback.print_exc() + + def stop_watching(self) -> None: + if self._watch is not None: + self._watch.cancel() + self._watch = None async def reload(self): async with self.lock: @@ -40,7 +67,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 +81,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 +163,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 6822fca..69654a5 100644 --- a/backend/modules/firewall/nftables.py +++ b/backend/modules/firewall/nftables.py @@ -1,47 +1,121 @@ +"""The plain-nftables firewall, in tables of firegex's own. + +**Everything here is named `fgex_`, and that is not only cosmetic.** This module used to +write into the tables called `filter` and `mangle` — the ones `iptables-nft` claims — +putting base chains named INPUT and FORWARD beside iptables' own and setting their +policy. Two things went wrong with that, and both were reported from real hosts: + +* **iptables stops working.** `iptables-nft` refuses a whole table as soon as it holds + one rule it cannot express in its own format, and the first thing this module writes + is a native `ct state` match — iptables stores that as an `xt` match blob instead. From + then on `iptables -L`, `iptables-save` and everything built on them answer + ``table `filter' is incompatible, use 'nft' tool``, on a box where Docker, ufw, + fail2ban or the organisers' own scripts are the ones asking. It took nothing exotic to + trigger: `allow_established` and `drop_invalid` are the two settings that write `ct + state`, and both are on in any sane configuration. +* **Stopping firegex opened the host.** `reset()` set the policy of `filter INPUT` back + to `accept`, and that chain belonged to the *administrator*: a host carrying a + default-deny policy had it quietly removed the moment the firegex firewall was + disabled. + +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 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, every connection between two containers and every one a +container made to the outside. `allow_dnat` (on by default) leaves that traffic, when no +rule of ours matched it, to the container runtime's rules as it always was. +""" + from modules.firewall.models import FirewallSettings, Action, Rule, Protocol, Mode, Table from utils import nftables_int_to_json, ip_family, NFTableManager, is_ip_parse import copy +#: The bridges a container runtime forwards through: Docker's default one and its user +#: networks (`br-`), and podman's. Traffic arriving from one of them is a container +#: talking — to another container, or out — and iptables' `FORWARD`, where Docker and +#: podman accept it, is where firegex's rules used to sit too. +CONTAINER_BRIDGES = ("docker0", "br-*", "docker_gwbridge", "podman*", "cni-podman*") + +#: Where a rule's `table` — which is the operator's word, stored in the database and +#: shown in the interface — actually lands. The two are deliberately not the same string +#: any more: "filter" and "mangle" describe what a rule *does*, and used to double as the +#: name of somebody else's table. +NFT_TABLES = { + Table.FILTER: "fgex_filter", + Table.MANGLE: "fgex_mangle", +} + + class FiregexTables(NFTableManager): - rules_chain_in = "firegex_firewall_rules_in" - rules_chain_out = "firegex_firewall_rules_out" - rules_chain_fwd = "firegex_firewall_rules_fwd" - filter_table = "filter" - mangle_table = "mangle" - + rules_chain_in = "fgex_rules_in" + rules_chain_out = "fgex_rules_out" + rules_chain_fwd = "fgex_rules_fwd" + filter_table = NFT_TABLES[Table.FILTER] + mangle_table = NFT_TABLES[Table.MANGLE] + + #: The base chains, and the one rules chain each of them hands over to. Hook and + #: priority are the same ones the rules used to be evaluated at, so what reaches a + #: rule is unchanged; only the table around it is firegex's. + base_chains = [ + # (table, chain, hook, priority, follows the configured policy) + ("filter", "fgex_input", "input", 0, True, rules_chain_in), + ("filter", "fgex_forward", "forward", 0, True, rules_chain_fwd), + ("filter", "fgex_output", "output", 0, False, rules_chain_out), + ("mangle", "fgex_prerouting", "prerouting", -150, False, rules_chain_in), + ("mangle", "fgex_postrouting", "postrouting", -150, False, rules_chain_out), + ] + + #: The rules chains, and which table each lives in. `fgex_rules_fwd` is filter-only: + #: mangle has no forward hook in this layout, which is why a mangle rule in forward + #: mode is refused by the router before it gets here. + rules_chains = [ + ("filter", rules_chain_in), ("filter", rules_chain_out), ("filter", rules_chain_fwd), + ("mangle", rules_chain_in), ("mangle", rules_chain_out), + ] + + def _table(self, which: str) -> str: + return self.filter_table if which == "filter" else self.mangle_table + + def _skeleton(self, policy: str): + """Everything that has to exist before a single rule can be added. + + The base chains are flushed and re-pointed rather than inspected for a jump that + may already be there. Each holds exactly one rule and nothing else, so rebuilding + is shorter than checking and cannot leave a duplicate behind; the version that + checked had to, because the chain belonged to iptables and emptying it was not + firegex's to do. + """ + for family in ("ip", "ip6"): + for table in (self.filter_table, self.mangle_table): + yield {"add": {"table": {"name": table, "family": family}}} + for which, chain in self.rules_chains: + yield {"add": {"chain": { + "family": family, "table": self._table(which), "name": chain, + }}} + for which, chain, hook, prio, follows_policy, target in self.base_chains: + table = self._table(which) + yield {"add": {"chain": { + "family": family, "table": table, "name": chain, + "type": "filter", "hook": hook, "prio": prio, + "policy": policy if follows_policy else Action.ACCEPT, + }}} + yield {"flush": {"chain": {"family": family, "table": table, "name": chain}}} + yield {"add": {"rule": { + "family": family, "table": table, "chain": chain, + "expr": [{"jump": {"target": target}}], + }}} + def init_comands(self, policy:str=Action.ACCEPT, opt: FirewallSettings|None = None): - rules = [ - {"add":{"table":{"name":self.filter_table,"family":"ip"}}}, - {"add":{"table":{"name":self.filter_table,"family":"ip6"}}}, - - {"add":{"table":{"name":self.mangle_table,"family":"ip"}}}, - {"add":{"table":{"name":self.mangle_table,"family":"ip6"}}}, - - {"add":{"chain":{"family":"ip","table":self.filter_table, "name":"INPUT","type":"filter","hook":"input","prio":0,"policy":policy}}}, - {"add":{"chain":{"family":"ip6","table":self.filter_table,"name":"INPUT","type":"filter","hook":"input","prio":0,"policy":policy}}}, - {"add":{"chain":{"family":"ip","table":self.filter_table,"name":"FORWARD","type":"filter","hook":"forward","prio":0,"policy":policy}}}, - {"add":{"chain":{"family":"ip6","table":self.filter_table,"name":"FORWARD","type":"filter","hook":"forward","prio":0,"policy":policy}}}, - {"add":{"chain":{"family":"ip","table":self.filter_table,"name":"OUTPUT","type":"filter","hook":"output","prio":0,"policy":Action.ACCEPT}}}, - {"add":{"chain":{"family":"ip6","table":self.filter_table,"name":"OUTPUT","type":"filter","hook":"output","prio":0,"policy":Action.ACCEPT}}}, - - {"add":{"chain":{"family":"ip","table":self.mangle_table, "name":"PREROUTING","type":"filter","hook":"prerouting","prio":-150,"policy":Action.ACCEPT}}}, - {"add":{"chain":{"family":"ip6","table":self.mangle_table,"name":"PREROUTING","type":"filter","hook":"prerouting","prio":-150,"policy":Action.ACCEPT}}}, - {"add":{"chain":{"family":"ip","table":self.mangle_table, "name":"POSTROUTING","type":"filter","hook":"postrouting","prio":-150,"policy":Action.ACCEPT}}}, - {"add":{"chain":{"family":"ip6","table":self.mangle_table,"name":"POSTROUTING","type":"filter","hook":"postrouting","prio":-150,"policy":Action.ACCEPT}}}, - - {"add":{"chain":{"family":"ip","table":self.filter_table,"name":self.rules_chain_in}}}, - {"add":{"chain":{"family":"ip6","table":self.filter_table,"name":self.rules_chain_in}}}, - {"add":{"chain":{"family":"ip","table":self.filter_table,"name":self.rules_chain_out}}}, - {"add":{"chain":{"family":"ip6","table":self.filter_table,"name":self.rules_chain_out}}}, - {"add":{"chain":{"family":"ip","table":self.filter_table,"name":self.rules_chain_fwd}}}, - {"add":{"chain":{"family":"ip6","table":self.filter_table,"name":self.rules_chain_fwd}}}, - - {"add":{"chain":{"family":"ip","table":self.mangle_table,"name":self.rules_chain_in}}}, - {"add":{"chain":{"family":"ip6","table":self.mangle_table,"name":self.rules_chain_in}}}, - {"add":{"chain":{"family":"ip","table":self.mangle_table,"name":self.rules_chain_out}}}, - {"add":{"chain":{"family":"ip6","table":self.mangle_table,"name":self.rules_chain_out}}}, - ] + rules = list(self._skeleton(policy)) if opt is None: return rules @@ -173,66 +247,46 @@ class FiregexTables(NFTableManager): return rules def __init__(self): - super().__init__(self.init_comands(),[ - #Needed to reset to ALLOW when fireall is disabled (DO NOT REMOVE) - {"add":{"chain":{"family":"ip","table":self.filter_table, "name":"INPUT","type":"filter","hook":"input","prio":0,"policy":Action.ACCEPT}}}, - {"add":{"chain":{"family":"ip6","table":self.filter_table,"name":"INPUT","type":"filter","hook":"input","prio":0,"policy":Action.ACCEPT}}}, - {"add":{"chain":{"family":"ip","table":self.filter_table,"name":"FORWARD","type":"filter","hook":"forward","prio":0,"policy":Action.ACCEPT}}}, - {"add":{"chain":{"family":"ip6","table":self.filter_table,"name":"FORWARD","type":"filter","hook":"forward","prio":0,"policy":Action.ACCEPT}}}, - - {"flush":{"chain":{"table":self.filter_table,"family":"ip", "name":self.rules_chain_in}}}, - {"flush":{"chain":{"table":self.filter_table,"family":"ip", "name":self.rules_chain_out}}}, - {"flush":{"chain":{"table":self.filter_table,"family":"ip", "name":self.rules_chain_fwd}}}, - {"flush":{"chain":{"table":self.filter_table,"family":"ip6", "name":self.rules_chain_in}}}, - {"flush":{"chain":{"table":self.filter_table,"family":"ip6", "name":self.rules_chain_out}}}, - {"flush":{"chain":{"table":self.filter_table,"family":"ip6", "name":self.rules_chain_fwd}}}, - - {"flush":{"chain":{"table":self.mangle_table,"family":"ip", "name":self.rules_chain_in}}}, - {"flush":{"chain":{"table":self.mangle_table,"family":"ip", "name":self.rules_chain_out}}}, - {"flush":{"chain":{"table":self.mangle_table,"family":"ip6", "name":self.rules_chain_in}}}, - {"flush":{"chain":{"table":self.mangle_table,"family":"ip6", "name":self.rules_chain_out}}} - ]) - - def chain_to_firegex(self, chain:str, table:str): - if table == self.filter_table: - match chain: - case "INPUT": - return self.rules_chain_in - case "OUTPUT": - return self.rules_chain_out - case "FORWARD": - return self.rules_chain_fwd - elif table == self.mangle_table: - match chain: - case "PREROUTING": - return self.rules_chain_in - case "POSTROUTING": - return self.rules_chain_out - return None - - def insert_firegex_chains(self): - rules:list[dict] = list(self.list_rules(tables=[self.filter_table, self.mangle_table], chains=["INPUT", "OUTPUT", "FORWARD", "PREROUTING", "POSTROUTING"])) - for table in [self.filter_table, self.mangle_table]: - for family in ["ip", "ip6"]: - for chain in ["INPUT", "OUTPUT", "FORWARD"] if table == self.filter_table else ["PREROUTING", "POSTROUTING"]: - found = False - rule_to_add = [{ "jump": { "target": self.chain_to_firegex(chain, table) }}] - for r in rules: - if r.get("family") == family and r.get("table") == table and r.get("chain") == chain and r.get("expr") == rule_to_add: - found = True - break - if found: - continue - yield { "add":{ "rule": { - "family": family, - "table": table, - "chain": chain, - "expr": rule_to_add - }}} - + # Taking the firewall down is now deleting what firegex owns, nothing more. + # This used to have to put things *back*: the base chains were the host's, so + # stopping meant re-adding `filter INPUT` with an accept policy — which silently + # undid an administrator's default-deny — and flushing the rules chains one by + # one because deleting the table around them was not an option. `add` before + # `delete` is there because a batch is atomic and deleting a table that is not + # there fails the whole thing; adding one that already exists does nothing. + self._teardown = [ + command + for family in ("ip", "ip6") + for table in (self.filter_table, self.mangle_table) + for command in ( + {"add": {"table": {"name": table, "family": family}}}, + {"delete": {"table": {"name": table, "family": family}}}, + ) + ] + super().__init__(self.init_comands(), self._teardown) + + def intact(self) -> bool: + """Whether the firewall firegex installed is still in the kernel. + + The input chain of the IPv4 filter table always holds its jump, so it is the one + place a deleted table and an emptied one both show. + """ + code, listed, _ = self.raw_cmd({"list": {"chain": { + "family": "ip", "table": self.filter_table, "name": "fgex_input", + }}}) + return code == 0 and any("rule" in item for item in listed["nftables"]) + def set(self, srvs:list[Rule], policy:str=Action.ACCEPT, opt:FirewallSettings = None): + """Replace the whole firewall with this one, or leave the one in force alone. + + **One batch, teardown included.** nft applies a batch whole or not at all, and the + teardown used to be a batch of its own sent first: when nft then refused a rule — + an interface name longer than the kernel allows was enough — the old tables were + already gone and the new ones never arrived. A default-deny firewall became no + firewall at all, and the watcher putting it back failed the same way every few + seconds. Sent together, a refusal leaves the previous firewall exactly as it was. + """ srvs = list(srvs) - self.reset() if policy == Action.REJECT: policy = Action.DROP srvs.append(Rule( @@ -248,23 +302,70 @@ class FiregexTables(NFTableManager): table=Table.FILTER )) - rules = self.init_comands(policy, opt) + list(self.insert_firegex_chains()) + self.get_rules(*srvs) - self.cmd(*rules) + # 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) + self.dnat_rules(opt) + self.cmd(*self._teardown, *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 [] + matches = [ + {"match": {"op": "in", "left": {"ct": {"key": "status"}}, "right": "dnat"}}, + # What the containers send, to each other and out. Leaving only the published + # ports to the runtime was the first version, and with the policy at drop it + # cut a web container off from its database on the same bridge, and every + # container off from the network — neither of which met firegex's policy + # while its rules lived in iptables' `FORWARD` beside Docker's own accepts. + *({"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": bridge}} + for bridge in CONTAINER_BRIDGES), + ] + return [ + {"add": {"rule": { + "family": family, "table": self.filter_table, "chain": "fgex_forward", + "expr": [match, {"accept": None}], + }}} + for family in ("ip", "ip6") + for match in matches + ] def get_rules(self,*srvs:Rule): rules = [] final_srvs:list[Rule] = [] for ele in srvs: if ele.proto == Protocol.BOTH: + # Copies both ways: the rule handed in is the caller's, and rewriting its + # protocol in place turned a stored "both" into "tcp" for whoever read it + # next. udp_rule = copy.deepcopy(ele) udp_rule.proto = Protocol.UDP.value - ele.proto = Protocol.TCP.value - final_srvs.append(udp_rule) + tcp_rule = copy.deepcopy(ele) + tcp_rule.proto = Protocol.TCP.value + final_srvs.extend((udp_rule, tcp_rule)) + continue final_srvs.append(ele) - - families = ["ip", "ip6"] - + for srv in final_srvs: + # Per rule. It was set once, before the loop, and narrowed by the first rule + # naming an address — so every rule after that one was installed for that + # family only: an "accept port 80" written below an IPv4 rule did not exist + # for IPv6, and with the policy at drop every IPv6 client was refused. + families = ["ip", "ip6"] ip_filters = [] if srv.src != "": @@ -297,7 +398,10 @@ class FiregexTables(NFTableManager): for fam in families: rules.append({ "add":{ "rule": { "family": fam, - "table": srv.table, + # `srv.table` is the operator's word for what the rule does, kept in + # the database and in the API; the table it lands in is firegex's. + # The two were one string until that string was iptables' table. + "table": NFT_TABLES.get(srv.table, self.filter_table), "chain": self.rules_chain_out if srv.output_mode else self.rules_chain_in if srv.input_mode else self.rules_chain_fwd, "expr": ip_filters + port_filters + end_rules }}}) diff --git a/backend/modules/nfproxy/firegex.py b/backend/modules/nfproxy/firegex.py deleted file mode 100644 index d3d5f10..0000000 --- a/backend/modules/nfproxy/firegex.py +++ /dev/null @@ -1,248 +0,0 @@ -from modules.nfproxy.nftables import FiregexTables -from modules.nfproxy.models import Service, PyFilter -import os -import asyncio -import traceback -from fastapi import HTTPException -import time -from utils import run_func -from utils import nicenessify - -nft = FiregexTables() - -MODULE = "nfproxy" - -OUTSTREAM_BUFFER_SIZE = 1024*10 - -class FiregexInterceptor: - - def __init__(self): - self.srv:Service - self.filter_map_lock:asyncio.Lock - self.filter_map: dict[str, PyFilter] - self.update_config_lock:asyncio.Lock - self.process:asyncio.subprocess.Process - self.update_task: asyncio.Task - self.server_task: asyncio.Task - self.sock_path: str - self.unix_sock: asyncio.Server - self.ack_arrived = False - self.ack_status = None - self.ack_fail_what = "Queue response timed-out" - self.ack_lock = asyncio.Lock() - self.sock_reader:asyncio.StreamReader = None - self.sock_writer:asyncio.StreamWriter = None - self.sock_conn_lock:asyncio.Lock - self.last_time_exception = 0 - self.outstrem_function = None - self.expection_function = None - self.outstrem_task: asyncio.Task - self.outstrem_buffer = "" - self.watchdog_task: asyncio.Task = None - self.exit_callback = None - self.stopped = False - - @classmethod - async def start(cls, srv: Service, outstream_func=None, exception_func=None, on_exit=None): - self = cls() - self.srv = srv - self.exit_callback = on_exit - self.filter_map_lock = asyncio.Lock() - self.update_config_lock = asyncio.Lock() - self.sock_conn_lock = asyncio.Lock() - self.outstrem_function = outstream_func - self.expection_function = exception_func - if not self.sock_conn_lock.locked(): - await self.sock_conn_lock.acquire() - self.sock_path = f"/tmp/firegex_nfproxy_{srv.id}.sock" - if os.path.exists(self.sock_path): - os.remove(self.sock_path) - self.unix_sock = await asyncio.start_unix_server(self._server_listener,path=self.sock_path) - self.server_task = asyncio.create_task(self.unix_sock.serve_forever()) - queue_range = await self._start_binary() - self.update_task = asyncio.create_task(self.update_stats()) - self.watchdog_task = asyncio.create_task(self._watch_process()) - nft.add(self.srv, queue_range) - if not self.ack_lock.locked(): - await self.ack_lock.acquire() - return self - - async def _stream_handler(self): - while True: - try: - out_data = (await self.process.stdout.read(1024*10)).decode(errors="ignore") - except asyncio.exceptions.LimitOverrunError: - self.outstrem_buffer = "" - continue - except Exception as e: - self.ack_arrived = False - self.ack_status = False - self.ack_fail_what = "Can't read from nfq client" - self.ack_lock.release() - await self.stop(expected=False) - traceback.print_exc() # Python can't print it alone? nope it's python... wasted 1 day :) - raise HTTPException(status_code=500, detail="Can't read from nfq client") from e - self.outstrem_buffer+=out_data - if len(self.outstrem_buffer) > OUTSTREAM_BUFFER_SIZE: - self.outstrem_buffer = self.outstrem_buffer[-OUTSTREAM_BUFFER_SIZE:]+"\n" - if self.outstrem_function: - await run_func(self.outstrem_function, self.srv.id, out_data) - - async def _start_binary(self): - proxy_binary_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../cpproxy")) - self.process = await asyncio.create_subprocess_exec( - proxy_binary_path, stdin=asyncio.subprocess.DEVNULL, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - env=dict(os.environ, **{ - "NTHREADS": os.getenv("NTHREADS","1"), - "FIREGEX_NFQUEUE_FAIL_OPEN": "1" if self.srv.fail_open else "0", - "FIREGEX_NFPROXY_SOCK": self.sock_path - }), - ) - nicenessify(-10, self.process.pid) - self.outstrem_task = asyncio.create_task(self._stream_handler()) - try: - async with asyncio.timeout(3): - await self.sock_conn_lock.acquire() - line_fut = await self.sock_reader.readuntil() - except asyncio.TimeoutError: - self.process.kill() - raise Exception("Binary don't returned queue number until timeout") - line = line_fut.decode() - if line.startswith("QUEUE "): - params = line.split() - return (int(params[1]), int(params[1])) - else: - self.process.kill() - raise Exception("Invalid binary output") - - async def _server_listener(self, reader:asyncio.StreamReader, writer:asyncio.StreamWriter): - if self.sock_reader or self.sock_writer: - writer.write_eof() # Technically never reached - writer.close() - reader.feed_eof() - return - self.sock_reader = reader - self.sock_writer = writer - self.sock_conn_lock.release() - - async def update_stats(self): - try: - while True: - try: - line = (await self.sock_reader.readuntil()).decode() - except Exception as e: - self.ack_arrived = False - self.ack_status = False - self.ack_fail_what = "Can't read from nfq client" - self.ack_lock.release() - await self.stop(expected=False) - raise HTTPException(status_code=500, detail="Can't read from nfq client") from e - if line.startswith("BLOCKED "): - filter_name = line.split()[1] - async with self.filter_map_lock: - if filter_name in self.filter_map: - self.filter_map[filter_name].blocked_packets+=1 - await self.filter_map[filter_name].update() - if line.startswith("MANGLED "): - filter_name = line.split()[1] - async with self.filter_map_lock: - if filter_name in self.filter_map: - self.filter_map[filter_name].edited_packets+=1 - await self.filter_map[filter_name].update() - if line.startswith("EXCEPTION"): - self.last_time_exception = int(time.time()*1000) #ms timestamp - if self.expection_function: - await run_func(self.expection_function, self.srv.id, self.last_time_exception) - if line.startswith("ACK "): - self.ack_arrived = True - self.ack_status = line.split()[1].upper() == "OK" - if not self.ack_status: - self.ack_fail_what = " ".join(line.split()[2:]) - self.ack_lock.release() - except asyncio.CancelledError: - pass - except asyncio.IncompleteReadError: - pass - except Exception: - traceback.print_exc() - - - async def _watch_process(self): - """Detects an unexpected death of the interceptor binary. - - The nftables rules are installed independently of this process and - survive it, so a crashed interceptor leaves the service completely - unfiltered (silently accepted when the service is fail_open, dropped - otherwise) while the UI keeps reporting it as active. Nothing used to - notice: the stdout reader just saw EOF and returned. - """ - try: - returncode = await self.process.wait() - except asyncio.CancelledError: - return - if self.stopped: - return # We killed it ourselves - print(f"[error] [{MODULE}] The interceptor of service {self.srv.id} exited unexpectedly with code {returncode}") - if self.exit_callback: - try: - await run_func(self.exit_callback, returncode) - except asyncio.CancelledError: - raise - except Exception: - traceback.print_exc() - - async def stop(self, expected: bool = True): - """Tears the interceptor down. - - `expected` tells the watchdog whether this shutdown was asked for: an - internal failure passes False so that the crash is still reported and - the service can be recovered, instead of looking like a clean stop. - """ - self.stopped = expected - # The watchdog itself calls stop() through the exit callback: cancelling - # the running task from inside would abort the recovery. - if self.watchdog_task and self.watchdog_task is not asyncio.current_task(): - self.watchdog_task.cancel() - self.server_task.cancel() - self.update_task.cancel() - self.unix_sock.close() - self.outstrem_task.cancel() - if os.path.exists(self.sock_path): - os.remove(self.sock_path) - if self.process and self.process.returncode is None: - self.process.kill() - - async def _update_config(self, code): - async with self.update_config_lock: - if self.sock_writer: - self.sock_writer.write(len(code).to_bytes(4, byteorder='big')+code.encode()) - await self.sock_writer.drain() - try: - async with asyncio.timeout(3): - await self.ack_lock.acquire() - except TimeoutError: - self.ack_fail_what = "Queue response timed-out" - if not self.ack_arrived or not self.ack_status: - await self.stop() - raise HTTPException(status_code=500, detail=f"NFQ error: {self.ack_fail_what}") - else: - raise HTTPException(status_code=400, detail="Socket not ready") - - async def reload(self, filters:list[PyFilter]): - async with self.filter_map_lock: - if os.path.exists(f"db/nfproxy_filters/{self.srv.id}.py"): - with open(f"db/nfproxy_filters/{self.srv.id}.py") as f: - filter_file = f.read() - else: - filter_file = "" - self.filter_map = {ele.name: ele for ele in filters} - await self._update_config( - filter_file + "\n\n" + - "__firegex_pyfilter_enabled = [" + ", ".join([repr(f.name) for f in filters]) + "]\n" + - "__firegex_proto = " + repr(self.srv.proto) + "\n" + - "import firegex.nfproxy.internals\n" + - "firegex.nfproxy.internals.compile(globals())\n" - ) - diff --git a/backend/modules/nfproxy/firewall.py b/backend/modules/nfproxy/firewall.py deleted file mode 100644 index 89bea36..0000000 --- a/backend/modules/nfproxy/firewall.py +++ /dev/null @@ -1,185 +0,0 @@ -import asyncio -import time -import traceback -from modules.nfproxy.firegex import FiregexInterceptor -from modules.nfproxy.nftables import FiregexTables, FiregexFilter -from modules.nfproxy.models import Service, PyFilter -from utils.sqlite import SQLite -from utils import run_func, socketio_emit - -class STATUS: - STOP = "stop" - ACTIVE = "active" - -nft = FiregexTables() - -MODULE = "nfproxy" -# How many times a service may be brought back up before it is declared broken, -# and after how long without a crash the counter goes back to zero. -MAX_RESTART_ATTEMPTS = 5 -CRASH_COUNTER_RESET_SECONDS = 60 - -class ServiceManager: - def __init__(self, srv: Service, db, outstream_func=None, exception_func=None): - self.srv = srv - self.db = db - self.status = STATUS.STOP - self.filters: dict[str, FiregexFilter] = {} - self.lock = asyncio.Lock() - self.interceptor = None - self._crash_count = 0 - self._last_crash_time = 0.0 - self.outstream_function = outstream_func - self.last_exception_time = 0 - async def excep_internal_handler(srv, exc_time): - self.last_exception_time = exc_time - if exception_func: - await run_func(exception_func, srv, exc_time) - self.exception_function = excep_internal_handler - - async def _update_filters_from_db(self): - pyfilters = [ - PyFilter.from_dict(ele, self.db) for ele in - self.db.query("SELECT * FROM pyfilter WHERE service_id = ? AND active=1;", self.srv.id) - ] - #Filter check - old_filters = set(self.filters.keys()) - new_filters = set([f.name for f in pyfilters]) - #remove old filters - for f in old_filters: - if f not in new_filters: - del self.filters[f] - #add new filters - for f in new_filters: - if f not in old_filters: - self.filters[f] = [ele for ele in pyfilters if ele.name == f][0] - if self.interceptor: - await self.interceptor.reload(self.filters.values()) - - def __update_status_db(self, status): - self.db.query("UPDATE services SET status = ? WHERE service_id = ?;", status, self.srv.id) - - async def next(self,to,persist:bool=True): - async with self.lock: - if to == STATUS.STOP: - await self.stop(persist=persist) - if to == STATUS.ACTIVE: - await self.restart() - - def _set_status(self,status,persist:bool=True): - self.status = status - if persist: - self.__update_status_db(status) - - def read_outstrem_buffer(self): - if self.interceptor: - return self.interceptor.outstrem_buffer - else: - return "" - - - async def _on_interceptor_exit(self, returncode: int): - """Recovers from an unexpected death of the interceptor binary. - - Restarting rebuilds both the process and its nftables rules; it is - attempted a bounded number of times so that a systematically crashing - interceptor does not turn into a restart loop. When the budget is - exhausted the service is stopped for good, which at least removes the - rules and makes the failure visible instead of leaving a service that - claims to be active while filtering nothing. - """ - async with self.lock: - if self.interceptor is None or self.status != STATUS.ACTIVE: - return # Already being stopped on purpose - # The process is already gone, but its sockets and reader tasks are - # not: release them before building a replacement. - await self.interceptor.stop() - self.interceptor = None - now = time.monotonic() - if now - self._last_crash_time > CRASH_COUNTER_RESET_SECONDS: - self._crash_count = 0 - self._last_crash_time = now - self._crash_count += 1 - if self._crash_count > MAX_RESTART_ATTEMPTS: - print(f"[error] [{MODULE}] Service {self.srv.id} crashed {self._crash_count} times (last exit code {returncode}), giving up and stopping it") - await self.stop() - else: - print(f"[warning] [{MODULE}] Restarting the interceptor of service {self.srv.id} after exit code {returncode} ({self._crash_count}/{MAX_RESTART_ATTEMPTS})") - try: - await self.start() - except Exception: - traceback.print_exc() - await self.stop() - await socketio_emit([MODULE]) - - async def start(self): - if not self.interceptor: - nft.delete(self.srv) - self.interceptor = await FiregexInterceptor.start(self.srv, outstream_func=self.outstream_function, exception_func=self.exception_function, on_exit=self._on_interceptor_exit) - await self._update_filters_from_db() - self._set_status(STATUS.ACTIVE) - - async def stop(self,persist:bool=True): - nft.delete(self.srv) - if self.interceptor: - await self.interceptor.stop() - self.interceptor = None - self._set_status(STATUS.STOP,persist=persist) - - async def restart(self): - await self.stop() - await self.start() - - async def update_filters(self): - async with self.lock: - await self._update_filters_from_db() - - - -class FirewallManager: - def __init__(self, db:SQLite, outstream_func=None, exception_func=None): - self.db = db - self.service_table: dict[str, ServiceManager] = {} - self.lock = asyncio.Lock() - self.outstream_function = outstream_func - self.exception_function = exception_func - - async def close(self): - for key in list(self.service_table.keys()): - try: - await self.remove(key, persist=False) - except Exception: - # Don't let one broken service block shutdown of the others - self.service_table.pop(key, None) - - async def remove(self,srv_id,persist:bool=True): - async with self.lock: - if srv_id in self.service_table: - await self.service_table[srv_id].next(STATUS.STOP,persist=persist) - del self.service_table[srv_id] - - async def init(self): - nft.init() - await self.reload() - - async def reload(self): - async with self.lock: - services = self.db.query('SELECT * FROM services;') - - for srv in services: - if srv["service_id"] in self.service_table: - continue - srv_obj = Service.from_dict(srv) - self.service_table[srv_obj.id] = ServiceManager(srv_obj, self.db, outstream_func=self.outstream_function, exception_func=self.exception_function) - await self.service_table[srv_obj.id].next(srv_obj.status) - - def get(self,srv_id) -> ServiceManager: - if srv_id in self.service_table: - return self.service_table[srv_id] - else: - raise ServiceNotFoundException() - -class ServiceNotFoundException(Exception): - pass - - diff --git a/backend/modules/nfproxy/models.py b/backend/modules/nfproxy/models.py deleted file mode 100644 index 19880db..0000000 --- a/backend/modules/nfproxy/models.py +++ /dev/null @@ -1,66 +0,0 @@ -class Service: - def __init__( - self, - service_id: str, - status: str, - port: int | None, - name: str, - proto: str, - ip_int: str | None, - fail_open: bool, - target_type: str = "flow", - tls_stream_id: str | None = None, - tls_cert: str | None = None, - tls_key: str | None = None, - **other, - ): - self.id = service_id - self.status = status - self.port = port - self.name = name - self.proto = proto - self.ip_int = ip_int - self.fail_open = fail_open - self.target_type = target_type - self.tls_stream_id = tls_stream_id - self.tls_cert = tls_cert - self.tls_key = tls_key - - @classmethod - def from_dict(cls, var: dict): - return cls(**var) - - -class PyFilter: - def __init__( - self, - name: str, - service_id: str, - blocked_packets: int, - edited_packets: int, - active: bool, - db, - **other, - ): - self.name = name - self.blocked_packets = blocked_packets - self.edited_packets = edited_packets - self.active = active - self.__db = db - self.srv_id = service_id - - async def update(self): - self.__db.query( - "UPDATE pyfilter SET blocked_packets = ?, edited_packets = ? WHERE name = ? AND service_id = ?;", - self.blocked_packets, - self.edited_packets, - self.name, - self.srv_id, - ) - - def __repr__(self): - return f"" - - @classmethod - def from_dict(cls, var: dict, db): - return cls(**var, db=db) diff --git a/backend/modules/nfproxy/nftables.py b/backend/modules/nfproxy/nftables.py deleted file mode 100644 index 711e879..0000000 --- a/backend/modules/nfproxy/nftables.py +++ /dev/null @@ -1,169 +0,0 @@ -from modules.nfproxy.models import Service -from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json -from modules.tls.manager import get_tls_ports, TLSManager - -def convert_protocol_to_l4(proto:str): - if proto == "tcp": - return "tcp" - elif proto == "http": - return "tcp" - else: - raise Exception("Invalid protocol") - -def resolve_target(srv: Service) -> tuple[str, int] | None: - """Returns the (ip_int, port) a service's intercept rule should actually match. - For target_type=='tls' this is the loopback clear_port where nginx exposes the - decrypted plaintext, not the service's own (real backend) ip_int/port. Returns - None if the linked TLS stream can't be resolved (e.g. it was deleted).""" - if srv.target_type == "tls": - tls_stream = TLSManager().get_stream(srv.tls_stream_id) - if not tls_stream: - return None - _, clear_port = get_tls_ports(tls_stream["ip_int"], tls_stream["port"]) - loopback_ip = "::1" if ip_family(tls_stream["ip_int"]) == "ip6" else "127.0.0.1" - return loopback_ip, clear_port - return srv.ip_int, srv.port - - -def build_queue_statement(srv: Service, init: int, end: int) -> dict: - """Builds the nftables `queue` statement for a service. - - The `bypass` flag tells the kernel to accept packets when no process is - bound to the queue, which is what happens after the interceptor binary - dies. Tying it to the service's fail_open setting keeps the two halves of - the policy consistent: without fail_open a dead interceptor must not - silently let the traffic through unfiltered. - """ - statement = {"num": str(init) if init == end else {"range": [init, end]}} - if srv.fail_open: - statement["flags"] = ["bypass"] - return {"queue": statement} - - -class FiregexFilter: - def __init__(self, proto:str, port:int, ip_int:str, target:str, id:int): - self.id = id - self.target = target - self.proto = proto - self.port = int(port) - self.ip_int = str(ip_int) - - def __eq__(self, o: object) -> bool: - if isinstance(o, FiregexFilter): - return self.port == o.port and self.proto == o.proto and ip_parse(self.ip_int) == ip_parse(o.ip_int) - if isinstance(o, Service): - return self.matches(o) - return False - - def matches(self, srv: Service) -> bool: - if self.proto != convert_protocol_to_l4(srv.proto): - return False - target = resolve_target(srv) - if target is None: - return False - target_ip, target_port = target - return self.port == target_port and ip_parse(self.ip_int) == ip_parse(target_ip) - -class FiregexTables(NFTableManager): - input_chain = "nfproxy_input" - output_chain = "nfproxy_output" - nat_chain = "nfproxy_nat" - - def __init__(self): - super().__init__([ - {"add":{"chain":{ #Input chain attached before conntrack see it - "family":"inet", - "table":self.table_name, - "name":self.input_chain, - "type":"filter", - "hook":"prerouting", - "prio":-308, - "policy":"accept" - }}}, - {"add":{"chain":{ #Output chain attached after conntrack saw it - "family":"inet", - "table":self.table_name, - "name":self.output_chain, - "type":"filter", - "hook":"postrouting", - "prio":108, - "policy":"accept" - }}} - ],[ - {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}}, - {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}}, - {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}}, - {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}}, - ]) - - def add(self, srv:Service, queue_range): - - for ele in self.get(): - if ele.matches(srv): - return - - target = resolve_target(srv) - if target is None: - return - target_ip, target_port = target - - init, end = queue_range - if init > end: - init, end = end, init - - self.cmd( - { "insert":{ "rule": { # Send non-TLS outbound traffic to NFQUEUE (Client -> Server) - "family": "inet", - "table": self.table_name, - "chain": self.output_chain, - "expr": [ - {'match': {'left': {'payload': {'protocol': ip_family(target_ip), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(target_ip)}}, - {'match': {"left": { "payload": {"protocol": convert_protocol_to_l4(str(srv.proto)), "field": "dport"}}, "op": "==", "right": int(target_port)}}, - {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}}, - build_queue_statement(srv, init, end) - ] - }}}, - {"insert":{"rule":{ # Send non-TLS inbound traffic to NFQUEUE (Server -> Client) - "family": "inet", - "table": self.table_name, - "chain": self.input_chain, - "expr": [ - {'match': {'left': {'payload': {'protocol': ip_family(target_ip), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(target_ip)}}, - {'match': {"left": { "payload": {"protocol": convert_protocol_to_l4(str(srv.proto)), "field": "sport"}}, "op": "==", "right": int(target_port)}}, - {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}}, - build_queue_statement(srv, init, end) - ] - }}} - ) - - - def get(self) -> list[FiregexFilter]: - res = [] - for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain]): - try: - ip_int = None - if isinstance(filter["expr"][0]["match"]["right"],str): - ip_int = str(ip_parse(filter["expr"][0]["match"]["right"])) - else: - ip_int = f'{filter["expr"][0]["match"]["right"]["prefix"]["addr"]}/{filter["expr"][0]["match"]["right"]["prefix"]["len"]}' - res.append(FiregexFilter( - target=filter["chain"], - id=int(filter["handle"]), - proto=filter["expr"][1]["match"]["left"]["payload"]["protocol"], - port=filter["expr"][1]["match"]["right"], - ip_int=ip_int - )) - except (KeyError, TypeError, IndexError): - pass # Rule has unexpected structure (e.g., intermediate NAT rule), skip - return res - - def delete(self, srv:Service): - for filter in self.get(): - if filter.matches(srv): - self.cmd({ "delete":{ "rule": { - "family": "inet", - "table": self.table_name, - "chain": filter.target, - "handle": filter.id - }}}) - \ No newline at end of file diff --git a/backend/modules/nfregex/firegex.py b/backend/modules/nfregex/firegex.py deleted file mode 100644 index 9dbd7d3..0000000 --- a/backend/modules/nfregex/firegex.py +++ /dev/null @@ -1,233 +0,0 @@ -from modules.nfregex.nftables import FiregexTables -from utils import run_func -from modules.nfregex.models import Service, Regex -import os -import asyncio -import traceback -from utils import DEBUG -from fastapi import HTTPException -from utils import nicenessify - -nft = FiregexTables() - -MODULE = "nfregex" - -async def test_regex_validity(regex: str) -> bool: - proxy_binary_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),"../cppregex") - process = await asyncio.create_subprocess_exec( - proxy_binary_path, - stdout=asyncio.subprocess.PIPE, - stdin=asyncio.subprocess.DEVNULL, - env={"FIREGEX_TEST_REGEX": regex}, - ) - await process.wait() - if process.returncode != 0: - message = (await process.stdout.read()).decode() - return False, message - return True, "ok" - -class RegexFilter: - def __init__( - self, regex, - is_case_sensitive=True, - input_mode=False, - output_mode=False, - blocked_packets=0, - id=None, - update_func = None - ): - self.regex = regex - self.is_case_sensitive = is_case_sensitive - if input_mode == output_mode: - input_mode = output_mode = True # (False, False) == (True, True) - self.input_mode = input_mode - self.output_mode = output_mode - self.blocked = blocked_packets - self.id = id - self.update_func = update_func - self.compiled_regex = self.compile() - - @classmethod - def from_regex(cls, regex:Regex, update_func = None): - return cls( - id=regex.id, regex=regex.regex, is_case_sensitive=regex.is_case_sensitive, - blocked_packets=regex.blocked_packets, - input_mode = regex.mode in ["C","B"], output_mode=regex.mode in ["S","B"], - update_func = update_func - ) - def compile(self): - if isinstance(self.regex, str): - self.regex = self.regex.encode() - if not isinstance(self.regex, bytes): - raise Exception("Invalid Regex Paramether") - case_sensitive = "1" if self.is_case_sensitive else "0" - if self.input_mode: - yield case_sensitive + "C" + self.regex.hex() - if self.output_mode: - yield case_sensitive + "S" + self.regex.hex() - - async def update(self): - if self.update_func: - await run_func(self.update_func, self) - -class FiregexInterceptor: - - def __init__(self): - self.srv:Service - self.filter_map_lock:asyncio.Lock - self.filter_map: dict[str, RegexFilter] - self.regex_filters: set[RegexFilter] - self.update_config_lock:asyncio.Lock - self.process:asyncio.subprocess.Process - self.update_task: asyncio.Task - self.ack_arrived = False - self.ack_status = None - self.ack_fail_what = "Queue response timed-out" - self.ack_lock = asyncio.Lock() - self.watchdog_task: asyncio.Task = None - self.exit_callback = None - self.stopped = False - - @classmethod - async def start(cls, srv: Service, on_exit=None): - self = cls() - self.srv = srv - self.exit_callback = on_exit - self.filter_map_lock = asyncio.Lock() - self.update_config_lock = asyncio.Lock() - queue_range = await self._start_binary() - self.update_task = asyncio.create_task(self.update_blocked()) - self.watchdog_task = asyncio.create_task(self._watch_process()) - nft.add(self.srv, queue_range) - if not self.ack_lock.locked(): - await self.ack_lock.acquire() - return self - - async def _start_binary(self): - proxy_binary_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),"../cppregex") - self.process = await asyncio.create_subprocess_exec( - proxy_binary_path, - stdout=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE, - env=dict(os.environ, **{ - "MATCH_MODE": "stream" if self.srv.proto == "tcp" else "block", - "NTHREADS": os.getenv("NTHREADS","1"), - "FIREGEX_NFQUEUE_FAIL_OPEN": "1" if self.srv.fail_open else "0", - }), - ) - nicenessify(-10, self.process.pid) - line_fut = self.process.stdout.readuntil() - try: - line_fut = await asyncio.wait_for(line_fut, timeout=3) - except asyncio.TimeoutError: - self.process.kill() - raise Exception("Invalid binary output") - line = line_fut.decode() - if line.startswith("QUEUE "): - params = line.split() - return (int(params[1]), int(params[1])) - else: - self.process.kill() - raise Exception("Invalid binary output") - - async def update_blocked(self): - try: - while True: - line = (await self.process.stdout.readuntil()).decode() - if DEBUG: - print(line) - if line.startswith("BLOCKED "): - regex_id = line.split()[1] - async with self.filter_map_lock: - if regex_id in self.filter_map: - self.filter_map[regex_id].blocked+=1 - await self.filter_map[regex_id].update() - if line.startswith("ACK "): - self.ack_arrived = True - self.ack_status = line.split()[1].upper() == "OK" - if not self.ack_status: - self.ack_fail_what = " ".join(line.split()[2:]) - self.ack_lock.release() - except asyncio.CancelledError: - pass - except asyncio.IncompleteReadError: - pass - except Exception: - traceback.print_exc() - - - async def _watch_process(self): - """Detects an unexpected death of the interceptor binary. - - The nftables rules are installed independently of this process and - survive it, so a crashed interceptor leaves the service completely - unfiltered (silently accepted when the service is fail_open, dropped - otherwise) while the UI keeps reporting it as active. Nothing used to - notice: the stdout reader just saw EOF and returned. - """ - try: - returncode = await self.process.wait() - except asyncio.CancelledError: - return - if self.stopped: - return # We killed it ourselves - print(f"[error] [{MODULE}] The interceptor of service {self.srv.id} exited unexpectedly with code {returncode}") - if self.exit_callback: - try: - await run_func(self.exit_callback, returncode) - except asyncio.CancelledError: - raise - except Exception: - traceback.print_exc() - - async def stop(self, expected: bool = True): - """Tears the interceptor down. - - `expected` tells the watchdog whether this shutdown was asked for: an - internal failure passes False so that the crash is still reported and - the service can be recovered, instead of looking like a clean stop. - """ - self.stopped = expected - # The watchdog itself calls stop() through the exit callback: cancelling - # the running task from inside would abort the recovery. - if self.watchdog_task and self.watchdog_task is not asyncio.current_task(): - self.watchdog_task.cancel() - self.update_task.cancel() - if self.process and self.process.returncode is None: - self.process.kill() - - async def _update_config(self, filters_codes): - async with self.update_config_lock: - self.process.stdin.write((" ".join(filters_codes)+"\n").encode()) - await self.process.stdin.drain() - try: - async with asyncio.timeout(3): - await self.ack_lock.acquire() - except TimeoutError: - self.ack_fail_what = "Queue response timed-out" - if not self.ack_arrived or not self.ack_status: - await self.stop() - raise HTTPException(status_code=500, detail=f"NFQ error: {self.ack_fail_what}") - - - async def reload(self, filters:list[RegexFilter]): - async with self.filter_map_lock: - self.filter_map = self.compile_filters(filters) - filters_codes = self.get_filter_codes() - await self._update_config(filters_codes) - - def get_filter_codes(self): - filters_codes = list(self.filter_map.keys()) - filters_codes.sort(key=lambda a: self.filter_map[a].blocked, reverse=True) - return filters_codes - - def compile_filters(self, filters:list[RegexFilter]): - res = {} - for filter_obj in filters: - try: - raw_filters = filter_obj.compile() - for filter in raw_filters: - res[filter] = filter_obj - except Exception: - pass - return res - diff --git a/backend/modules/nfregex/firewall.py b/backend/modules/nfregex/firewall.py deleted file mode 100644 index 08a0344..0000000 --- a/backend/modules/nfregex/firewall.py +++ /dev/null @@ -1,173 +0,0 @@ -import asyncio -import time -import traceback -from modules.nfregex.firegex import FiregexInterceptor, RegexFilter -from modules.nfregex.nftables import FiregexTables, FiregexFilter -from modules.nfregex.models import Regex, Service -from utils.sqlite import SQLite -from utils import socketio_emit - -class STATUS: - STOP = "stop" - ACTIVE = "active" - -nft = FiregexTables() - -MODULE = "nfregex" -# How many times a service may be brought back up before it is declared broken, -# and after how long without a crash the counter goes back to zero. -MAX_RESTART_ATTEMPTS = 5 -CRASH_COUNTER_RESET_SECONDS = 60 - - -class ServiceManager: - def __init__(self, srv: Service, db): - self.srv = srv - self.db = db - self.status = STATUS.STOP - self.filters: dict[int, FiregexFilter] = {} - self.lock = asyncio.Lock() - self.interceptor = None - self._crash_count = 0 - self._last_crash_time = 0.0 - - async def _update_filters_from_db(self): - regexes = [ - Regex.from_dict(ele) for ele in - self.db.query("SELECT * FROM regexes WHERE service_id = ? AND active=1;", self.srv.id) - ] - #Filter check - old_filters = set(self.filters.keys()) - new_filters = set([f.id for f in regexes]) - #remove old filters - for f in old_filters: - if f not in new_filters: - del self.filters[f] - #add new filters - for f in new_filters: - if f not in old_filters: - filter = [ele for ele in regexes if ele.id == f][0] - self.filters[f] = RegexFilter.from_regex(filter, self._stats_updater) - if self.interceptor: - await self.interceptor.reload(self.filters.values()) - - def __update_status_db(self, status): - self.db.query("UPDATE services SET status = ? WHERE service_id = ?;", status, self.srv.id) - - async def next(self,to,persist:bool=True): - async with self.lock: - if to == STATUS.STOP: - await self.stop(persist=persist) - if to == STATUS.ACTIVE: - await self.restart() - - def _stats_updater(self,filter:RegexFilter): - self.db.query("UPDATE regexes SET blocked_packets = ? WHERE regex_id = ?;", filter.blocked, filter.id) - - def _set_status(self,status,persist:bool=True): - self.status = status - if persist: - self.__update_status_db(status) - - - async def _on_interceptor_exit(self, returncode: int): - """Recovers from an unexpected death of the interceptor binary. - - Restarting rebuilds both the process and its nftables rules; it is - attempted a bounded number of times so that a systematically crashing - interceptor does not turn into a restart loop. When the budget is - exhausted the service is stopped for good, which at least removes the - rules and makes the failure visible instead of leaving a service that - claims to be active while filtering nothing. - """ - async with self.lock: - if self.interceptor is None or self.status != STATUS.ACTIVE: - return # Already being stopped on purpose - # The process is already gone, but its sockets and reader tasks are - # not: release them before building a replacement. - await self.interceptor.stop() - self.interceptor = None - now = time.monotonic() - if now - self._last_crash_time > CRASH_COUNTER_RESET_SECONDS: - self._crash_count = 0 - self._last_crash_time = now - self._crash_count += 1 - if self._crash_count > MAX_RESTART_ATTEMPTS: - print(f"[error] [{MODULE}] Service {self.srv.id} crashed {self._crash_count} times (last exit code {returncode}), giving up and stopping it") - await self.stop() - else: - print(f"[warning] [{MODULE}] Restarting the interceptor of service {self.srv.id} after exit code {returncode} ({self._crash_count}/{MAX_RESTART_ATTEMPTS})") - try: - await self.start() - except Exception: - traceback.print_exc() - await self.stop() - await socketio_emit([MODULE]) - - async def start(self): - if not self.interceptor: - nft.delete(self.srv) - self.interceptor = await FiregexInterceptor.start(self.srv, on_exit=self._on_interceptor_exit) - await self._update_filters_from_db() - self._set_status(STATUS.ACTIVE) - - async def stop(self,persist:bool=True): - nft.delete(self.srv) - if self.interceptor: - await self.interceptor.stop() - self.interceptor = None - self._set_status(STATUS.STOP,persist=persist) - - async def restart(self): - await self.stop() - await self.start() - - async def update_filters(self): - async with self.lock: - await self._update_filters_from_db() - - - -class FirewallManager: - def __init__(self, db:SQLite): - self.db = db - self.service_table: dict[str, ServiceManager] = {} - self.lock = asyncio.Lock() - - async def close(self): - for key in list(self.service_table.keys()): - try: - await self.remove(key, persist=False) - except Exception: - # Don't let one broken service block shutdown of the others - self.service_table.pop(key, None) - - async def remove(self,srv_id,persist:bool=True): - async with self.lock: - if srv_id in self.service_table: - await self.service_table[srv_id].next(STATUS.STOP,persist=persist) - del self.service_table[srv_id] - - async def init(self): - nft.init() - await self.reload() - - async def reload(self): - async with self.lock: - services = self.db.query('SELECT * FROM services;') - - for srv in services: - if srv["service_id"] in self.service_table: - continue - srv_obj = Service.from_dict(srv) - self.service_table[srv_obj.id] = ServiceManager(srv_obj, self.db) - await self.service_table[srv_obj.id].next(srv_obj.status) - - def get(self,srv_id) -> ServiceManager: - if srv_id in self.service_table: - return self.service_table[srv_id] - else: - raise ServiceNotFoundException() - -class ServiceNotFoundException(Exception): - pass diff --git a/backend/modules/nfregex/models.py b/backend/modules/nfregex/models.py deleted file mode 100644 index d2fa3d2..0000000 --- a/backend/modules/nfregex/models.py +++ /dev/null @@ -1,35 +0,0 @@ -import base64 - -class Service: - def __init__(self, service_id: str, status: str, port: int | None, name: str, proto: str, ip_int: str | None, fail_open: bool, target_type: str = "flow", tls_stream_id: str | None = None, tls_cert: str | None = None, tls_key: str | None = None, **other): - self.id = service_id - self.status = status - self.port = port - self.name = name - self.proto = proto - self.ip_int = ip_int - self.fail_open = fail_open - self.target_type = target_type - self.tls_stream_id = tls_stream_id - self.tls_cert = tls_cert - self.tls_key = tls_key - - @classmethod - def from_dict(cls, var: dict): - return cls(**var) - - -class Regex: - def __init__(self, regex_id: int, regex: bytes, mode: str, service_id: str, blocked_packets: int, is_case_sensitive: bool, active: bool, **other): - self.regex = regex - self.mode = mode - self.service_id = service_id - self.blocked_packets = blocked_packets - self.id = regex_id - self.is_case_sensitive = is_case_sensitive - self.active = active - - @classmethod - def from_dict(cls, var: dict): - var['regex'] = base64.b64decode(var['regex']) - return cls(**var) \ No newline at end of file diff --git a/backend/modules/nfregex/nftables.py b/backend/modules/nfregex/nftables.py deleted file mode 100644 index 4653f23..0000000 --- a/backend/modules/nfregex/nftables.py +++ /dev/null @@ -1,164 +0,0 @@ -from modules.nfregex.models import Service -from utils import ip_parse, ip_family, NFTableManager, nftables_int_to_json -from modules.tls.manager import get_tls_ports, TLSManager - -def resolve_target(srv: Service) -> tuple[str, int] | None: - """Returns the (ip_int, port) a service's intercept rule should actually match. - For target_type=='tls' this is the loopback clear_port where nginx exposes the - decrypted plaintext, not the service's own (real backend) ip_int/port. Returns - None if the linked TLS stream can't be resolved (e.g. it was deleted).""" - if srv.target_type == "tls": - tls_stream = TLSManager().get_stream(srv.tls_stream_id) - if not tls_stream: - return None - _, clear_port = get_tls_ports(tls_stream["ip_int"], tls_stream["port"]) - loopback_ip = "::1" if ip_family(tls_stream["ip_int"]) == "ip6" else "127.0.0.1" - return loopback_ip, clear_port - return srv.ip_int, srv.port - - -def build_queue_statement(srv: Service, init: int, end: int) -> dict: - """Builds the nftables `queue` statement for a service. - - The `bypass` flag tells the kernel to accept packets when no process is - bound to the queue, which is what happens after the interceptor binary - dies. Tying it to the service's fail_open setting keeps the two halves of - the policy consistent: without fail_open a dead interceptor must not - silently let the traffic through unfiltered. - """ - statement = {"num": str(init) if init == end else {"range": [init, end]}} - if srv.fail_open: - statement["flags"] = ["bypass"] - return {"queue": statement} - - -class FiregexFilter: - def __init__(self, proto:str, port:int, ip_int:str, target:str, id:int): - self.id = id - self.target = target - self.proto = proto - self.port = int(port) - self.ip_int = str(ip_int) - - def __eq__(self, o: object) -> bool: - if isinstance(o, FiregexFilter): - return self.port == o.port and self.proto == o.proto and ip_parse(self.ip_int) == ip_parse(o.ip_int) - if isinstance(o, Service): - return self.matches(o) - return False - - def matches(self, srv: Service) -> bool: - if self.proto != str(srv.proto): - return False - target = resolve_target(srv) - if target is None: - return False - target_ip, target_port = target - return self.port == target_port and ip_parse(self.ip_int) == ip_parse(target_ip) - -class FiregexTables(NFTableManager): - input_chain = "nfregex_input" - output_chain = "nfregex_output" - nat_chain = "nfregex_nat" - - def __init__(self): - super().__init__([ - {"add":{"chain":{ - "family":"inet", - "table":self.table_name, - "name":self.input_chain, - "type":"filter", - "hook":"prerouting", - "prio":-307, - "policy":"accept" - }}}, - {"add":{"chain":{ - "family":"inet", - "table":self.table_name, - "name":self.output_chain, - "type":"filter", - "hook":"postrouting", - "prio":107, - "policy":"accept" - }}} - ],[ - {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}}, - {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.input_chain}}}, - {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}}, - {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.output_chain}}}, - ]) - - def add(self, srv:Service, queue_range): - - for ele in self.get(): - if ele.matches(srv): - return - - target = resolve_target(srv) - if target is None: - return - target_ip, target_port = target - - init, end = queue_range - if init > end: - init, end = end, init - - self.cmd( - { "insert":{ "rule": { - "family": "inet", - "table": self.table_name, - "chain": self.output_chain, - "expr": [ - {'match': {'left': {'payload': {'protocol': ip_family(target_ip), 'field': 'saddr'}}, 'op': '==', 'right': nftables_int_to_json(target_ip)}}, - {'match': {"left": { "payload": {"protocol": str(srv.proto), "field": "sport"}}, "op": "==", "right": int(target_port)}}, - {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1338}}, - build_queue_statement(srv, init, end) - ] - }}}, - {"insert":{"rule":{ - "family": "inet", - "table": self.table_name, - "chain": self.input_chain, - "expr": [ - {'match': {'left': {'payload': {'protocol': ip_family(target_ip), 'field': 'daddr'}}, 'op': '==', 'right': nftables_int_to_json(target_ip)}}, - {'match': {"left": { "payload": {"protocol": str(srv.proto), "field": "dport"}}, "op": "==", "right": int(target_port)}}, - {"mangle": {"key": {"meta": {"key": "mark"}},"value": 0x1337}}, - build_queue_statement(srv, init, end) - ] - }}} - ) - - - def get(self) -> list[FiregexFilter]: - res = [] - for filter in self.list_rules(tables=[self.table_name], chains=[self.input_chain,self.output_chain]): - try: - ip_int = None - if isinstance(filter["expr"][0]["match"]["right"],str): - ip_int = str(ip_parse(filter["expr"][0]["match"]["right"])) - else: - ip_int = f'{filter["expr"][0]["match"]["right"]["prefix"]["addr"]}/{filter["expr"][0]["match"]["right"]["prefix"]["len"]}' - res.append(FiregexFilter( - target=filter["chain"], - id=int(filter["handle"]), - proto=filter["expr"][1]["match"]["left"]["payload"]["protocol"], - port=filter["expr"][1]["match"]["right"], - ip_int=ip_int - )) - except (KeyError, TypeError, IndexError): - pass # Rule has unexpected structure (e.g., intermediate NAT rule), skip - return res - - def delete(self, srv:Service): - delete_cmds = [] - for filter in self.get(): - if filter.matches(srv): - delete_cmds.append({ "delete":{ "rule": { - "family": "inet", - "table": self.table_name, - "chain": filter.target, - "handle": filter.id - }}}) - if delete_cmds: - self.cmd(*delete_cmds) - \ No newline at end of file diff --git a/backend/modules/porthijack/__init__.py b/backend/modules/porthijack/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/modules/porthijack/firewall.py b/backend/modules/porthijack/firewall.py deleted file mode 100644 index 23c59a3..0000000 --- a/backend/modules/porthijack/firewall.py +++ /dev/null @@ -1,84 +0,0 @@ -import asyncio -from modules.porthijack.nftables import FiregexTables -from modules.porthijack.models import Service -from utils.sqlite import SQLite - -nft = FiregexTables() - -class ServiceNotFoundException(Exception): - pass - -class ServiceManager: - def __init__(self, srv: Service, db): - self.srv = srv - self.db = db - self.active = False - self.lock = asyncio.Lock() - - async def enable(self): - if not self.active: - async with self.lock: - nft.delete(self.srv) - nft.add(self.srv) - self._set_status(True) - - async def disable(self,persist:bool=True): - if self.active: - async with self.lock: - nft.delete(self.srv) - self._set_status(False,persist=persist) - - async def refresh(self, srv:Service): - self.srv = srv - if self.active: - await self.restart() - - def _set_status(self,active,persist:bool=True): - self.active = active - if persist: - self.db.query("UPDATE services SET active = ? WHERE service_id = ?;", active, self.srv.service_id) - - async def restart(self): - await self.disable() - await self.enable() - -class FirewallManager: - def __init__(self, db:SQLite): - self.db = db - self.service_table: dict[str, ServiceManager] = {} - self.lock = asyncio.Lock() - - async def close(self): - for key in list(self.service_table.keys()): - try: - await self.remove(key, persist=False) - except Exception: - # Don't let one broken service block shutdown of the others - self.service_table.pop(key, None) - - async def remove(self,srv_id,persist:bool=True): - async with self.lock: - if srv_id in self.service_table: - await self.service_table[srv_id].disable(persist=persist) - del self.service_table[srv_id] - - async def init(self): - nft.init() - await self.reload() - - async def reload(self): - async with self.lock: - for srv in self.db.query('SELECT * FROM services;'): - srv = Service.from_dict(srv) - if srv.service_id in self.service_table: - continue - self.service_table[srv.service_id] = ServiceManager(srv, self.db) - if srv.active: - await self.service_table[srv.service_id].enable() - - def get(self,srv_id) -> ServiceManager: - if srv_id in self.service_table: - return self.service_table[srv_id] - else: - raise ServiceNotFoundException() - diff --git a/backend/modules/porthijack/models.py b/backend/modules/porthijack/models.py deleted file mode 100644 index 7b1e352..0000000 --- a/backend/modules/porthijack/models.py +++ /dev/null @@ -1,14 +0,0 @@ -class Service: - def __init__(self, service_id: str, active: bool, public_port: int, proxy_port: int, name: str, proto: str, ip_src: str, ip_dst:str, **other): - self.service_id = service_id - self.active = active - self.public_port = public_port - self.proxy_port = proxy_port - self.name = name - self.proto = proto - self.ip_src = ip_src - self.ip_dst = ip_dst - - @classmethod - def from_dict(cls, var: dict): - return cls(**var) diff --git a/backend/modules/porthijack/nftables.py b/backend/modules/porthijack/nftables.py deleted file mode 100644 index 05d77ff..0000000 --- a/backend/modules/porthijack/nftables.py +++ /dev/null @@ -1,102 +0,0 @@ -from modules.porthijack.models import Service -from utils import addr_parse, ip_parse, ip_family, NFTableManager, nftables_json_to_int - -class FiregexHijackRule(): - def __init__(self, proto:str, public_port:int,proxy_port:int, ip_src:str, ip_dst:str, target:str, id:int): - self.id = id - self.target = target - self.proto = proto - self.public_port = public_port - self.proxy_port = proxy_port - self.ip_src = str(ip_src) - self.ip_dst = str(ip_dst) - - def __eq__(self, o: object) -> bool: - if isinstance(o, FiregexHijackRule) or isinstance(o, Service): - return self.public_port == o.public_port and self.proto == o.proto and ip_parse(self.ip_src) == ip_parse(o.ip_src) - return False - -class FiregexTables(NFTableManager): - prerouting_porthijack = "prerouting_porthijack" - postrouting_porthijack = "postrouting_porthijack" - - def __init__(self): - super().__init__([ - {"add":{"chain":{ - "family":"inet", - "table":self.table_name, - "name":self.prerouting_porthijack, - "type":"filter", - "hook":"prerouting", - "prio":-310, - "policy":"accept" - }}}, - {"add":{"chain":{ - "family":"inet", - "table":self.table_name, - "name":self.postrouting_porthijack, - "type":"filter", - "hook":"postrouting", - "prio":110, - "policy":"accept" - }}} - ],[ - {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.prerouting_porthijack}}}, - {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.prerouting_porthijack}}}, - {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.postrouting_porthijack}}}, - {"delete":{"chain":{"table":self.table_name,"family":"inet", "name":self.postrouting_porthijack}}} - ]) - - def add(self, srv:Service): - - for ele in self.get(): - if ele.__eq__(srv): - return - - self.cmd({ "insert":{ "rule": { - "family": "inet", - "table": self.table_name, - "chain": self.prerouting_porthijack, - "expr": [ - {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_src), 'field': 'daddr'}}, 'op': '==', 'right': addr_parse(srv.ip_src)}}, - {'match': {'left': {'payload': {'protocol': str(srv.proto), 'field': 'dport'}}, 'op': '==', 'right': int(srv.public_port)}}, - {'mangle': {'key': {'payload': {'protocol': str(srv.proto), 'field': 'dport'}}, 'value': int(srv.proxy_port)}}, - {'mangle': {'key': {'payload': {'protocol': ip_family(srv.ip_src), 'field': 'daddr'}}, 'value': addr_parse(srv.ip_dst)}} - ] - }}}) - self.cmd({ "insert":{ "rule": { - "family": "inet", - "table": self.table_name, - "chain": self.postrouting_porthijack, - "expr": [ - {'match': {'left': {'payload': {'protocol': ip_family(srv.ip_dst), 'field': 'saddr'}}, 'op': '==', 'right': addr_parse(srv.ip_dst)}}, - {'match': {'left': { "payload": {"protocol": str(srv.proto), "field": "sport"}}, "op": "==", "right": int(srv.proxy_port)}}, - {'mangle': {'key': {'payload': {'protocol': str(srv.proto), 'field': 'sport'}}, 'value': int(srv.public_port)}}, - {'mangle': {'key': {'payload': {'protocol': ip_family(srv.ip_dst), 'field': 'saddr'}}, 'value': addr_parse(srv.ip_src)}} - ] - }}}) - - - def get(self) -> list[FiregexHijackRule]: - res = [] - for filter in self.list_rules(tables=[self.table_name], chains=[self.prerouting_porthijack,self.postrouting_porthijack]): - res.append(FiregexHijackRule( - target=filter["chain"], - id=int(filter["handle"]), - proto=filter["expr"][1]["match"]["left"]["payload"]["protocol"], - public_port=filter["expr"][1]["match"]["right"] if filter["chain"] == self.prerouting_porthijack else filter["expr"][2]["mangle"]["value"], - proxy_port=filter["expr"][1]["match"]["right"] if filter["chain"] == self.postrouting_porthijack else filter["expr"][2]["mangle"]["value"], - ip_src=nftables_json_to_int(filter["expr"][0]["match"]["right"]) if filter["chain"] == self.prerouting_porthijack else nftables_json_to_int(filter["expr"][3]["mangle"]["value"]), - ip_dst=nftables_json_to_int(filter["expr"][0]["match"]["right"]) if filter["chain"] == self.postrouting_porthijack else nftables_json_to_int(filter["expr"][3]["mangle"]["value"]), - )) - return res - - def delete(self, srv:Service): - for filter in self.get(): - if filter.__eq__(srv): - self.cmd({ "delete":{ "rule": { - "family": "inet", - "table": self.table_name, - "chain": filter.target, - "handle": filter.id - }}}) \ No newline at end of file diff --git a/backend/modules/nfproxy/__init__.py b/backend/modules/services/__init__.py similarity index 100% rename from backend/modules/nfproxy/__init__.py rename to backend/modules/services/__init__.py diff --git a/backend/modules/services/conntrack.py b/backend/modules/services/conntrack.py new file mode 100644 index 0000000..fbcf5ca --- /dev/null +++ b/backend/modules/services/conntrack.py @@ -0,0 +1,358 @@ +"""Forgetting UDP flows, so the next datagram of each is steered by the rules in force now. + +Conntrack translates a flow once, at its first packet, and the proxy layer's `redirect` +is a translation. TCP lives with that: a connection is one thing from its handshake to +its close, and one opened before the service started is simply carried to the end. UDP +has no close — a flow lasts for as long as datagrams keep arriving inside the timeout — +and that turned the same rule into three failures: + +* **a flow older than the service was never filtered**, for as long as it kept talking: + nothing redirected it, and nothing ever would. A client could open one before the + protection went up and keep it open indefinitely; +* **a flow carried across a restart stayed with the engine being retired**, under the + filters it started with, and kept that engine alive until the drain gave up on it; +* **and once that engine was gone, the flow went nowhere.** Conntrack went on sending it + to a relay port nobody listened on, every datagram refreshing the entry that did so, + until the client happened to fall silent for two minutes. + +A UDP flow can simply be forgotten: its next datagram is a new flow, judged by whatever +rules are there now, and neither end had any state that depended on the old entry. So +this deletes the entries, over ctnetlink — the kernel's own interface, the one the +`conntrack` tool speaks, with nothing to install and nothing to spawn. + +TCP is never touched here: forgetting a connection breaks it. +""" + +import ipaddress +import socket +import struct +from dataclasses import dataclass +from typing import Callable, Iterable + +NETLINK_NETFILTER = 12 + +NLM_F_REQUEST = 0x1 +NLM_F_ACK = 0x4 +NLM_F_DUMP = 0x300 +NLMSG_ERROR = 2 +NLMSG_DONE = 3 + +NFNL_SUBSYS_CTNETLINK = 1 +IPCTNL_MSG_CT_GET = 1 +IPCTNL_MSG_CT_DELETE = 2 + +NLA_TYPE_MASK = 0x3FFF + +CTA_TUPLE_ORIG = 1 +CTA_TUPLE_REPLY = 2 +CTA_STATUS = 3 +CTA_MARK = 8 +CTA_ZONE = 18 + +CTA_TUPLE_IP = 1 +CTA_TUPLE_PROTO = 2 + +CTA_IP_V4_SRC = 1 +CTA_IP_V4_DST = 2 +CTA_IP_V6_SRC = 3 +CTA_IP_V6_DST = 4 + +CTA_PROTO_NUM = 1 +CTA_PROTO_SRC_PORT = 2 +CTA_PROTO_DST_PORT = 3 + +#: `IPS_DST_NAT`: the flow's destination was rewritten — by a `redirect`, among others. +IPS_DST_NAT = 1 << 5 + +IPPROTO_UDP = 17 + +_HEADER = struct.Struct("=IHHII") +_NFGEN = struct.Struct("=BBH") +_ATTR = struct.Struct("=HH") + + +@dataclass +class Flow: + """One conntrack entry, as much of it as deciding and deleting need.""" + + family: int + l4proto: int + orig_src: ipaddress._BaseAddress | None + orig_dst: ipaddress._BaseAddress | None + orig_sport: int | None + orig_dport: int | None + reply_src: ipaddress._BaseAddress | None + reply_sport: int | None + status: int + mark: int + #: The original tuple and the zone exactly as the kernel sent them, which is what a + #: delete names the entry by. + raw_orig: bytes + raw_zone: bytes = b"" + + @property + def redirected(self) -> bool: + return bool(self.status & IPS_DST_NAT) + + +# --- the wire ------------------------------------------------------------------- + + +def _align(n: int) -> int: + return (n + 3) & ~3 + + +def attr(kind: int, payload: bytes) -> bytes: + """One netlink attribute, padded.""" + raw = _ATTR.pack(_ATTR.size + len(payload), kind) + payload + return raw + b"\0" * (_align(len(raw)) - len(raw)) + + +def attrs(data: bytes) -> Iterable[tuple[int, bytes, bytes]]: + """`(type, payload, the whole attribute)` for each attribute in `data`.""" + pos = 0 + while pos + _ATTR.size <= len(data): + length, kind = _ATTR.unpack_from(data, pos) + if length < _ATTR.size or pos + length > len(data): + return + yield kind & NLA_TYPE_MASK, data[pos + _ATTR.size:pos + length], data[pos:pos + length] + pos += _align(length) + + +def message(kind: int, flags: int, seq: int, family: int, payload: bytes = b"") -> bytes: + body = _NFGEN.pack(family, 0, 0) + payload + return _HEADER.pack(_HEADER.size + len(body), (NFNL_SUBSYS_CTNETLINK << 8) | kind, + flags, seq, 0) + body + + +def messages(data: bytes) -> Iterable[tuple[int, bytes]]: + """`(type, payload)` for each netlink message in one read.""" + pos = 0 + while pos + _HEADER.size <= len(data): + length, kind, _flags, _seq, _pid = _HEADER.unpack_from(data, pos) + if length < _HEADER.size or pos + length > len(data): + return + yield kind, data[pos + _HEADER.size:pos + length] + pos += _align(length) + + +def _tuple(data: bytes) -> tuple: + src = dst = sport = dport = None + proto = 0 + for kind, payload, _ in attrs(data): + if kind == CTA_TUPLE_IP: + for ip_kind, value, _ in attrs(payload): + if ip_kind in (CTA_IP_V4_SRC, CTA_IP_V6_SRC): + src = ipaddress.ip_address(value) + elif ip_kind in (CTA_IP_V4_DST, CTA_IP_V6_DST): + dst = ipaddress.ip_address(value) + elif kind == CTA_TUPLE_PROTO: + for proto_kind, value, _ in attrs(payload): + if proto_kind == CTA_PROTO_NUM: + proto = value[0] + elif proto_kind == CTA_PROTO_SRC_PORT: + sport = struct.unpack("!H", value[:2])[0] + elif proto_kind == CTA_PROTO_DST_PORT: + dport = struct.unpack("!H", value[:2])[0] + return src, dst, sport, dport, proto + + +def parse_flow(payload: bytes) -> Flow | None: + """A conntrack entry out of one `IPCTNL_MSG_CT_NEW` message's payload.""" + if len(payload) < _NFGEN.size: + return None + family = payload[0] + orig = reply = None + raw_orig = raw_zone = b"" + status = mark = 0 + for kind, value, whole in attrs(payload[_NFGEN.size:]): + if kind == CTA_TUPLE_ORIG: + orig, raw_orig = _tuple(value), whole + elif kind == CTA_TUPLE_REPLY: + reply = _tuple(value) + elif kind == CTA_STATUS: + status = struct.unpack("!I", value[:4])[0] + elif kind == CTA_MARK: + mark = struct.unpack("!I", value[:4])[0] + elif kind == CTA_ZONE: + raw_zone = whole + if orig is None: + return None + reply_src, _, reply_sport, _, _ = reply if reply else (None, None, None, None, 0) + return Flow( + family=family, l4proto=orig[4], + orig_src=orig[0], orig_dst=orig[1], orig_sport=orig[2], orig_dport=orig[3], + reply_src=reply_src, reply_sport=reply_sport, + status=status, mark=mark, raw_orig=raw_orig, raw_zone=raw_zone, + ) + + +def delete_request(flow: Flow, seq: int) -> bytes: + return message(IPCTNL_MSG_CT_DELETE, NLM_F_REQUEST | NLM_F_ACK, seq, flow.family, + flow.raw_orig + flow.raw_zone) + + +# --- talking to the kernel ------------------------------------------------------ + + +def _read_until_done(sock: socket.socket) -> Iterable[bytes]: + """The payload of every entry a dump answers with.""" + while True: + data = sock.recv(1 << 18) + if not data: + return + for kind, payload in messages(data): + if kind == NLMSG_DONE: + return + if kind == NLMSG_ERROR: + error = struct.unpack_from("=i", payload)[0] if len(payload) >= 4 else 0 + if error: + raise OSError(-error, "conntrack dump refused") + return + yield payload + + +def _acknowledged(sock: socket.socket) -> int: + """The error a request was answered with, 0 for none.""" + while True: + for kind, payload in messages(sock.recv(1 << 16)): + if kind == NLMSG_ERROR: + return -struct.unpack_from("=i", payload)[0] if len(payload) >= 4 else 0 + + +def delete_where(chosen: Callable[[Flow], bool]) -> int: + """Delete every UDP entry `chosen` picks. Returns how many went. + + The table is read once and the deletes follow: a flow that ended in between is + answered `ENOENT`, which is the state being asked for. + """ + with socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, NETLINK_NETFILTER) as sock: + sock.settimeout(5) + sock.bind((0, 0)) + sock.send(message(IPCTNL_MSG_CT_GET, NLM_F_REQUEST | NLM_F_DUMP, 1, socket.AF_UNSPEC)) + doomed = [] + for payload in _read_until_done(sock): + flow = parse_flow(payload) + if flow is not None and flow.l4proto == IPPROTO_UDP and chosen(flow): + doomed.append(flow) + gone = 0 + for seq, flow in enumerate(doomed, start=2): + sock.send(delete_request(flow, seq)) + error = _acknowledged(sock) + if error == 0: + gone += 1 + elif error != 2: # ENOENT: it ended by itself meanwhile + raise OSError(error, "conntrack refused a delete") + return gone + + +# --- what to forget ------------------------------------------------------------- + + +Target = tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, int] + + +def targets(pairs: Iterable[tuple[str, int]]) -> list[Target]: + """`(address or network, port)` pairs as something a flow can be compared with.""" + return [(ipaddress.ip_network(str(ip), strict=False), int(port)) for ip, port in pairs] + + +def headed_for(flow: Flow, wanted: list[Target]) -> bool: + """Whether a flow was going to one of `wanted` before anything rewrote it.""" + if flow.orig_dst is None or flow.orig_dport is None: + return False + return any( + flow.orig_dport == port and flow.orig_dst.version == network.version + and flow.orig_dst in network + for network, port in wanted + ) + + +def chooser(*, moved: list[Target], unsteered: list[Target], spare_mark: int, + bound: set[int]) -> Callable[[Flow], bool]: + """Which flows `forget_udp` lets go of. Split out so the decision can be tested + without a kernel.""" + + def chosen(flow: Flow) -> bool: + if flow.mark == spare_mark: + return False + if headed_for(flow, moved): + return True + if headed_for(flow, unsteered): + return not flow.redirected or flow.reply_sport not in bound + return False + + return chosen + + +def forget_udp(*, moved: Iterable[tuple[str, int]] = (), + unsteered: Iterable[tuple[str, int]] = (), spare_mark: int) -> int: + """Forget UDP flows headed for these addresses, so their next datagram is steered by + the rules in force now. + + * `moved`: every flow headed there, including the ones a redirect already took to an + engine — a datagram flow can change engines between two datagrams and lose nothing + but the filter's own state for it, which is what a new configuration costs anyway. + * `unsteered`: only the ones nothing is carrying — never redirected at all, or + redirected to a port nobody listens on any more. What a QUIC edge needs: its + connections are terminated by an engine, and moved to another one mid-connection + they would end, while one still draining is carrying them. + + `spare_mark` is the engine's own: its dials to the service are flows headed for the + same address, and they are its to keep. + """ + moved_to, unsteered_to = targets(moved), targets(unsteered) + if not moved_to and not unsteered_to: + return 0 + return delete_where(chooser( + moved=moved_to, unsteered=unsteered_to, spare_mark=spare_mark, + bound=bound_udp_ports() if unsteered_to else set(), + )) + + +def bound_udp_ports() -> set[int]: + """Every UDP port some socket on this host is bound to, from `/proc/net/udp{,6}`.""" + found = set() + for path in ("/proc/net/udp", "/proc/net/udp6"): + try: + with open(path) as table: + next(table, None) + for line in table: + fields = line.split() + if len(fields) > 1 and ":" in fields[1]: + found.add(int(fields[1].rsplit(":", 1)[1], 16)) + except OSError: + continue + return found + + +def forget_udp_redirected_to(ports: Iterable[int], local: set) -> int: + """Forget the UDP flows a redirect sent to one of these ports on this host. + + What is left of an engine once it has gone: every such flow points at a relay nobody + listens on any more. Recognised by the reply coming from that port on one of this + host's own addresses — which is what a redirect writes into the reply tuple, and what + no flow forwarded to a container or another host can have. + """ + wanted = {int(port) for port in ports} + if not wanted: + return 0 + return delete_where( + lambda flow: flow.redirected and flow.reply_sport in wanted + and flow.reply_src is not None and flow.reply_src in local + ) + + +def local_addresses() -> set: + """Every address this host holds right now.""" + import psutil + + found = set() + for entries in psutil.net_if_addrs().values(): + for entry in entries: + if entry.family in (socket.AF_INET, socket.AF_INET6): + try: + found.add(ipaddress.ip_address(entry.address.split("%")[0])) + except ValueError: + pass + return found diff --git a/backend/modules/services/firewall.py b/backend/modules/services/firewall.py new file mode 100644 index 0000000..3346092 --- /dev/null +++ b/backend/modules/services/firewall.py @@ -0,0 +1,1026 @@ +"""Running services: the transport, the chain on top of it, and the rules that meet. + +One `ServiceManager` per row in `services`, holding whichever transport that row asked +for. Everything order-dependent lives here, because getting the order wrong is not an +error anybody sees — it is a window during which traffic goes unfiltered. + +Starting, outside in: the datapath first, which is what decides the queue number or the +port, then the nftables rules that point at it. Stopping is the exact reverse, so nothing +is ever steered at something that has already gone. There used to be a step before both +— nginx, brought up for a service behind TLS so that the plaintext port existed before +anything attached to it. The engine terminates TLS itself now, so a certificate is +something it is started with rather than something arranged around it; what is left +before the datapath is the capture interface, which has to exist before the process that +opens a socket to it. +""" + +import asyncio +import base64 +import os +import time +import traceback + +from modules.services import conntrack +from modules.services import mirror +from modules.services import transports +from modules.services import stats +from modules.services.logs import LEVEL, forget as forget_log, log_for +from modules.services.models import (KIND, L4, STATUS, UPSTREAM, Address, Filter, + Regex, Service) +from modules.services.nftables import (PROXY_SELF_MARK, FiregexTables, NoRelayAddress, + interface_addresses, udp_relay_host) +from utils import is_ip_parse +from utils.sqlite import SQLite + +nft = FiregexTables() + +#: The user's Python, one file per filter. +CODE_DIR = "db/service_filters" + +#: How many times a datapath may be brought back before it is declared broken, and how +#: long without a crash puts the counter back to zero. +#: +#: Bounded, because the alternative to a budget is a restart loop: a binary that dies on +#: the first packet of every attempt would be restarted forever, and each attempt tears +#: the rules down and builds them again. Spending the budget stops the service outright, +#: which is the honest end state — the rules come off and the interface says stopped, +#: instead of a service that reports itself active and is filtering nothing. +MAX_RESTART_ATTEMPTS = 5 +CRASH_COUNTER_RESET = 60 + +#: How long between two log lines about the connection limit. Being at the limit means +#: connections are arriving faster than they leave, so a line each would be the flood +#: arriving twice — once at the service and once in the operator's log. +OVER_LIMIT_QUIET = 30 + +#: How long between two reports that a filter is raising. Same reasoning as the line +#: above, for the same shape of problem: user code that throws usually throws on every +#: packet, so a line each is the fault arriving once per packet in a log that holds a +#: few hundred lines. What the operator needs is that it is happening, how often, and one +#: traceback — not the same traceback several hundred times, with everything else that +#: was in the log pushed out behind it. +RAISED_QUIET = 30 + +#: How long a block's counters may wait in memory before being written. They used to be +#: three writes per refused connection, each its own commit — about 17 ms a block, so the +#: backend fell behind any flood of them, the engine's pipe filled, and the flood slowed +#: every other client of the service. Once a second, in one transaction, whatever the +#: rate. Anything reading the counters through the API writes what is waiting first. +BLOCK_FLUSH = 1.0 + +#: How often the kernel is asked whether firegex's rules are still there. +#: +#: Something else can remove them: `nft -f /etc/nftables.conf` begins with `flush +#: ruleset` on Debian, and so does restarting the `nftables` unit. Every service then +#: went on reading `ACTIVE` while nothing reached a filter, and nothing said so. Every few +#: seconds is a short window and a cheap question — one small chain. +TABLE_WATCH = 3.0 +#: And how long between two log lines about it, per service. Something removing them on a +#: timer would otherwise write one every `TABLE_WATCH`. +TABLE_TOLD_QUIET = 60 + + +def code_path(filter_id: str) -> str: + return os.path.join(CODE_DIR, f"{filter_id}.py") + + +def read_code(filter_id: str) -> str: + try: + with open(code_path(filter_id)) as f: + return f.read() + except FileNotFoundError: + return "" + + +def write_code(filter_id: str, code: str) -> None: + os.makedirs(CODE_DIR, exist_ok=True) + with open(code_path(filter_id), "w") as f: + f.write(code) + + +def clear_code(filter_id: str) -> None: + try: + os.remove(code_path(filter_id)) + except FileNotFoundError: + pass + + +class ServiceNotFoundException(Exception): + pass + + +class ServiceManager: + def __init__(self, srv: Service, db: SQLite): + self.srv = srv + self.db = db + self.active = False + self.transport: transports.Transport | None = None + self.lock = asyncio.Lock() + self.log = log_for(srv.id) + #: When the limit was last mentioned in the log, so a flood is reported rather + #: than reproduced. + self._over_limit_told = 0.0 + self._raised_told = 0.0 + self._raised_since = 0 + #: What the running datapath needs the rules to point at — a queue number per + #: filter, or the port the proxy is listening on. Kept so an address added later + #: can be steered at the same datapath instead of restarting it. + self._steer: dict = {} + #: How many times this service's datapath has died on its own recently, and + #: when the last one was. + self._crash_count = 0 + self._last_crash = 0.0 + #: Carried between lines so a multi-line traceback keeps its severity. + self._in_traceback = False + self._engine_level = LEVEL.INFO + #: Blocks counted and not yet written, by the token the datapath reported. + self._unwritten_blocks: dict[str, int] = {} + #: Rules put back since the log last said so, and when it did. + self._rules_lost = 0 + self._rules_lost_told = 0.0 + self._block_flush: asyncio.TimerHandle | None = None + #: What a reported token is called and which filter owns it, looked up once per + #: token rather than once per block, and forgotten whenever the chain changes. + self._named: dict[tuple[str, str], str] = {} + self._owners: dict[str, str] = {} + #: Work started after an engine has gone, kept referenced until it is done. + self._background: set[asyncio.Task] = set() + + # --- reading the configuration ------------------------------------------- + + def chain(self) -> list[transports.ChainLink]: + """The filter chain as configured, in the operator's order.""" + links = [] + rows = self.db.query( + "SELECT * FROM filters WHERE service_id = ? ORDER BY position ASC;", self.srv.id + ) + for row in rows: + flt = Filter.from_dict(row) + if flt.kind == KIND.REGEX: + regexes = [ + Regex.from_dict(r) + for r in self.db.query( + "SELECT * FROM regexes WHERE filter_id = ?;", flt.id + ) + ] + links.append(transports.ChainLink(flt, regexes=regexes)) + else: + links.append(transports.ChainLink( + flt, + code_path=code_path(flt.id), + functions=self.db.query( + "SELECT name, active FROM pyfilters WHERE filter_id = ? " + "ORDER BY position ASC, name ASC;", + flt.id, + ), + )) + return links + + def check(self) -> None: + """Would this service start? Raises `UnsupportedChain` with the reason.""" + transports.TRANSPORTS[self.srv.transport].check(self.srv, self.chain()) + + def reload_addresses(self) -> None: + """Re-read where this service is reachable, without touching anything else.""" + self.srv.addresses = [ + Address.from_dict(row) + for row in self.db.query( + "SELECT * FROM service_addresses WHERE service_id = ? ORDER BY ip_int, port;", + self.srv.id, + ) + ] + + # --- callbacks from the datapath ------------------------------------------ + + def _on_block(self, reported: str): + # Either a regex rule id, or `/` from a pyfilter — the one + # token both network layers report. Split once, here, so there is a single place + # that knows the format. + rule_id, _, function = reported.partition("/") + self._unwritten_blocks[reported] = self._unwritten_blocks.get(reported, 0) + 1 + if self._block_flush is None: + try: + self._block_flush = asyncio.get_running_loop().call_later( + BLOCK_FLUSH, self.flush_blocks) + except RuntimeError: + self.flush_blocks() + # The chart is attributed to the filter, not to the pattern or the function: it + # is about which link in the chain is carrying the traffic, and a chain of twenty + # patterns would be a chart nobody can read. The finer totals are listed + # alongside it, and named in the log line below. + # The whole token, not just the filter: keeping the finer key is what lets a + # time range mean the same thing for the chart and for the tables beside it. + stats.record(self.srv.id, self._owning_filter(rule_id), reported) + stats.flush(self.db) + self.log.add(LEVEL.BLOCK, f"connection refused by {self._describe(rule_id, function)}") + + def flush_blocks(self) -> None: + """Write the counters of every block reported since the last time.""" + if self._block_flush is not None: + self._block_flush.cancel() + self._block_flush = None + pending, self._unwritten_blocks = self._unwritten_blocks, {} + if not pending: + return + queries = [] + for reported, count in pending.items(): + rule_id, _, function = reported.partition("/") + if function: + queries.append(( + "UPDATE pyfilters SET blocked = blocked + ? " + "WHERE filter_id = ? AND name = ?;", + count, rule_id, function, + )) + # What is left is a regex rule or a whole filter, depending on which refused. + # Only one of these matches, and a blind update is cheaper than asking first. + queries.append(( + "UPDATE regexes SET blocked = blocked + ? WHERE regex_id = ?;", + count, rule_id, + )) + queries.append(( + "UPDATE filters SET blocked = blocked + ? WHERE filter_id = ? OR " + "filter_id = (SELECT filter_id FROM regexes WHERE regex_id = ?);", + count, rule_id, rule_id, + )) + try: + self.db.queries(queries) + except Exception: + traceback.print_exc() + + def _owning_filter(self, rule_id: str) -> str: + """Which filter a block belongs to. The id is a filter's or a pattern's.""" + owner = self._owners.get(rule_id) + if owner is None: + found = self.db.query( + "SELECT filter_id FROM regexes WHERE regex_id = ?;", rule_id + ) + owner = self._owners[rule_id] = found[0]["filter_id"] if found else rule_id + return owner + + def _describe(self, rule_id: str, function: str = "") -> str: + """Name what refused a connection, rather than echoing an opaque id. + + Looked up once per rule and kept until the chain changes: a flood of refusals is + one rule refusing thousands of times, and an operator reading `refused by + 4f2a91c8` learns nothing. + """ + named = self._named.get((rule_id, function)) + if named is None: + named = self._named[(rule_id, function)] = self._look_up_name(rule_id, function) + return named + + def _look_up_name(self, rule_id: str, function: str) -> str: + if function: + # A file holds several functions, so naming the file alone would leave the + # operator to guess which of them refused the connection. + found = self.db.query("SELECT name FROM filters WHERE filter_id = ?;", rule_id) + return f"{found[0]['name']}: {function}()" if found else f"{function}()" + found = self.db.query( + "SELECT f.name fname, r.regex pattern FROM regexes r " + "JOIN filters f ON r.filter_id = f.filter_id WHERE r.regex_id = ?;", + rule_id, + ) + if found: + try: + pattern = base64.b64decode(found[0]["pattern"]).decode(errors="replace") + except Exception: + pattern = "?" + return f"{found[0]['fname']}: /{pattern}/" + found = self.db.query("SELECT name FROM filters WHERE filter_id = ?;", rule_id) + if found: + return found[0]["name"] + return rule_id + + def _on_output(self, _service_id: str, text: str): + self.log.add(LEVEL.OUTPUT, text) + + def _on_exception(self, _service_id: str): + """A filter threw, and the chunk went through unfiltered. + + Rate-limited, because code that throws throws on every packet: unthrottled this + is the same sentence a few hundred times, and the log is a bounded ring — so the + flood does not merely repeat itself, it evicts everything else, including the + first report and whatever was there before the filter broke. + + The suppressed ones are counted rather than dropped. "it is still happening, 412 + times since" is the number that says whether this is one odd request or every + request, which is the question the operator actually has. + """ + now = time.time() + self._raised_since += 1 + if now - self._raised_told < RAISED_QUIET: + return + first = self._raised_told == 0.0 + count, self._raised_since = self._raised_since, 0 + self._raised_told = now + self.log.add( + LEVEL.ERROR, + "a filter raised; the traffic was forwarded unfiltered" + if first and count == 1 else + f"a filter is still raising: {count} more since, all forwarded unfiltered", + ) + + def _on_engine(self, text: str): + """Whatever the datapath itself says about its own health. + + Worth surfacing rather than leaving on the backend's stderr: these are the lines + that say a filter was disabled, or that source preservation fell back — things + the operator would otherwise only notice from the outside, much later. + """ + for line in text.splitlines(): + stripped = line.strip() + if not stripped: + continue + if stripped.startswith("["): + # An engine line carries its own tag, and ends any traceback before it. + self._engine_level = LEVEL.INFO + if "[warn]" in stripped: + self._engine_level = LEVEL.WARN + elif "[error]" in stripped or "[fatal]" in stripped: + self._engine_level = LEVEL.ERROR + level = self._engine_level + self._in_traceback = False + elif stripped.startswith("Traceback (most recent call last)") or stripped.startswith( + "thread '" + ): + # A traceback arrives one line at a time and only the first says what it + # is. Reporting the rest as ordinary output would bury the failure in the + # middle of a wall of grey. + self._in_traceback = True + level = LEVEL.ERROR + elif self._in_traceback: + level = LEVEL.ERROR + else: + # Everything the datapath says about itself is tagged, so an untagged + # line came from the user's own code — their `print()`, which the worker + # sends here precisely so it can be read. + level = LEVEL.OUTPUT + self.log.add(level, stripped) + + def _on_datapath_died(self, what: str, returncode: int) -> None: + """A datapath process is gone and nobody asked it to go. + + Scheduled rather than awaited: this is called from the task that was watching the + process, and the recovery below stops and starts the service — which cancels that + very task. Doing it inline would have the watchdog cancel itself half way through. + """ + #: A bare `create_task` swallows whatever the coroutine raises, and the first + #: version of this recovery did exactly that: a wrong constant name meant the + #: restart never ran, while the line reporting the crash still appeared — a + #: watchdog that says the right thing and does nothing. Anything that goes wrong + #: in here is the operator's business too. + task = asyncio.create_task(self._recover_datapath(what, returncode)) + + def _complain(done: asyncio.Task): + if done.cancelled(): + return + if done.exception(): + self.log.add( + LEVEL.ERROR, + f"could not recover the datapath: {done.exception()!r}", + ) + + task.add_done_callback(_complain) + + async def _recover_datapath(self, what: str, returncode: int) -> None: + """Bring the datapath back, a bounded number of times. + + **The nftables rules outlive the process**, which is what makes this worth doing + at all: when the binary dies the rules stay, so traffic keeps being steered at a + queue nobody is reading and the interface goes on saying the service is active. + What actually happens to that traffic is then decided by the `bypass` flag alone — + accepted where the service is fail-open, dropped where it is not — and either way + nothing filters it and nothing says so. + + Restarting rebuilds the processes *and* their rules. It is attempted a bounded + number of times so that a binary dying on the first packet of every attempt does + not become a restart loop; when the budget is spent the service is stopped for + good, which takes the rules off and makes the failure visible. + """ + if not self.active or self.transport is None: + return # already being stopped on purpose + now = time.monotonic() + if now - self._last_crash > CRASH_COUNTER_RESET: + self._crash_count = 0 + self._last_crash = now + self._crash_count += 1 + self.log.add( + LEVEL.ERROR, + f"{what} exited on its own with code {returncode} — its rules are still in " + f"place, so nothing was filtering this service", + ) + if self._crash_count > MAX_RESTART_ATTEMPTS: + self.log.add( + LEVEL.ERROR, + f"it has done that {self._crash_count} times; stopping the service " + f"instead of restarting it again", + ) + await self.disable() + return + self.log.add( + LEVEL.WARN, + f"restarting it ({self._crash_count}/{MAX_RESTART_ATTEMPTS})", + ) + try: + await self.restart() + except Exception as e: + self.log.add(LEVEL.ERROR, f"could not restart it: {e}") + await self.disable() + + def _on_over_limit(self, refused: int) -> None: + """The connection limit turned something away. Say so, and keep the count. + + Two records, because they answer different questions and one cannot do both. The + log says it *now*, at warning level, so an operator watching a service under + attack sees the reason its clients are failing rather than guessing. The database + says it *later*: the live log is a bounded ring on purpose, so a burst at three + in the morning would be gone by breakfast, and "did we ever hit the wall" is + exactly the question asked the next day. + + The engine's own line is what explains the policy; this one carries the number, + and is rate-limited for the same reason the engine's is — being at the limit + means connections are arriving faster than they leave. + """ + now = int(time.time()) + self.db.query( + "UPDATE services SET over_limit_hits = over_limit_hits + ?, " + "over_limit_first = COALESCE(over_limit_first, ?), over_limit_last = ? " + "WHERE service_id = ?;", + refused, now, now, self.srv.id, + ) + if now - self._over_limit_told >= OVER_LIMIT_QUIET: + self._over_limit_told = now + self.log.add( + LEVEL.WARN, + f"the {self.srv.max_connections} connection limit turned away {refused} " + f"more: they were " + + ("forwarded with no filter in front of them" + if self.srv.over_limit_forwards else "refused") + + ". Raise the limit, or find out who is holding connections open.", + ) + + # --- lifecycle ------------------------------------------------------------ + + async def enable(self): + # 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, + on_output=self._on_output, + on_exception=self._on_exception, + on_engine=self._on_engine, + on_over_limit=self._on_over_limit, + on_died=self._on_datapath_died, + ) + try: + steer = await self.transport.start(self.chain()) + self._steer = steer + # Clear first: a stale rule from a crashed run would otherwise sit in + # front of the new one and send traffic nowhere. + nft.delete(self.srv) + nft.add(self.srv, **steer) + except Exception as e: + self.log.add(LEVEL.ERROR, f"could not start: {e}") + await self.transport.stop() + self.transport = None + raise + self._set_status(True) + await self._claim_udp(self.srv.addresses) + self.log.add( + LEVEL.INFO, + f"started on the {self.srv.transport} layer, " + f"{len([link for link in self.chain() if link.filter.active])} filter(s) active", + ) + + async def disable(self, persist: bool = True, handing_over: bool = False, + drain: bool = True): + """Stop filtering this service. + + The connections the datapath is carrying are not cut (`drain`): the rules come + off first, so nothing new reaches it, and the transport carries what it already + has until it closes — see `ProxyTransport.retire`. `handing_over` is a restart, + whose connections carry on under the filters they started with; a stop carries + them unfiltered. Only shutting firegex down cuts them, since the rules that bring + their answers home go with it. + """ + # 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: + self.flush_blocks() + if not self.active: + # One that was meant to run and could not — at boot, or after a failed + # rebuild — is not running, and stopping it is still a decision: kept as + # it was, the next boot would try to start it again. + if persist and self.srv.status == STATUS.ACTIVE: + self._set_status(False) + return + guarded = nft.delete(self.srv, keep_guards=True) + self._steer = {} + # A restart claims them again in a moment, from the engine replacing this one. + if not handing_over: + await self._release_udp(self.srv.addresses) + if self.transport: + if drain: + await self.transport.retire( + keep_filtering=handing_over, + then=lambda: self._in_background(self._engine_gone(guarded)), + ) + else: + await self.transport.stop() + await self._engine_gone(guarded) + self.transport = None + else: + await self._engine_gone(guarded) + self._set_status(False, persist=persist) + self.log.add(LEVEL.INFO, "stopped") + + async def restart(self): + await self.disable(handing_over=True) + await self.enable() + + async def put_rules_back(self) -> None: + """Steer this service at its datapath again, after something else removed the rules. + + The datapath never went anywhere — the engine or the queue binaries are still + running and still listening where `_steer` says — so this is the second half of + `enable()` alone. Under the lock, like `disable()`, so a stop in progress cannot + have its rules put back behind it. + """ + async with self.lock: + if not self.active or self.transport is None: + return + nft.delete(self.srv) + nft.add(self.srv, **self._steer) + # Whatever arrived while the rules were gone went straight to the service, and + # a datagram flow that did so would go on doing it. + await self._claim_udp(self.srv.addresses) + self._rules_lost += 1 + now = time.time() + if now - self._rules_lost_told < TABLE_TOLD_QUIET: + return + times = f" ({self._rules_lost} times since the last report)" \ + if self._rules_lost > 1 else "" + self._rules_lost, self._rules_lost_told = 0, now + self.log.add( + LEVEL.ERROR, + "firegex's rules were removed from the kernel by something else — an " + "`nft flush ruleset`, or a firewall being reloaded — and traffic reached " + f"this service unfiltered until they were put back{times}. Make whatever " + "manages this host's nftables leave the `fgex` tables alone.", + ) + + async def _back_as_it_was(self, undo) -> None: + """A rebuild the new configuration could not survive: put the old one back, and + the service with it. + + The rebuild stopped the service before it found out, so undoing the edit in the + database alone left it stopped — the operator was told their change had been + refused, which reads as "nothing happened", while the service they had been + editing was no longer protecting anything. + """ + if undo: + undo() + self.reload_addresses() + if self.active: + return + try: + await self.enable() + except Exception as e: + self.log.add(LEVEL.ERROR, f"could not start it again as it was either: {e}") + return + self.log.add(LEVEL.WARN, "the change was refused, so the service was started again " + "as it was before it") + + async def update_chain(self, undo=None): + """Push the current chain to a running datapath. + + Editing a rule costs nobody their connection: the datapath swaps its + configuration underneath the traffic. A chain the transport cannot host is + refused here, before it is pushed anywhere. + + Adding, removing or reordering a filter on the NFQUEUE layer is different, and + the transport says so by raising: there, each filter is its own process at its + own chain priority, so the shape of the chain *is* the arrangement of processes + and rules. That has to be rebuilt, which is a visible interruption — better than + silently enforcing the old order. + + `undo` puts the database back as it was before the edit. A rebuild that fails has + already stopped the service, so it is not enough for the caller to undo the edit + afterwards: the service is started again on what `undo` restored — see + `_back_as_it_was`. + """ + restart = False + # What was refused before this edit is written under the names it had, and what + # is refused after it is looked up again. + self.flush_blocks() + self._named.clear() + self._owners.clear() + async with self.lock: + if not self.active or not self.transport: + return + try: + await self.transport.reload(self.chain()) + except transports.ChainShapeChanged: + restart = True + if restart: + self.log.add(LEVEL.INFO, "the chain changed shape; rebuilding it") + try: + await self.restart() + except Exception: + await self._back_as_it_was(undo) + raise + return + self.log.add( + LEVEL.INFO, + f"filters reloaded, " + f"{len([link for link in self.chain() if link.filter.active])} active " + f"(no connection was dropped)", + ) + + async def address_added(self, address_id: str, undo=None): + """Steer one more address at the datapath that is already running. + + Deliberately not a restart. The chain is unchanged and the datapath is already + enforcing it, so all that is missing are the rules that point the new address at + it — and the connections on every other address stay up. + + The exception is a proxy service that had no IPv6 address until now: its + listener was opened in a family that cannot accept one, so it has to be + reopened. Saying so and rebuilding beats installing a rule that redirects + traffic at a socket which will refuse it. + """ + was_dual = self.srv.has_ipv6_tcp + self.reload_addresses() + if not self.active: + return + # Asked of the transport the kernel sees, not of what the service speaks: the + # one listener that has a family to get wrong is the TCP one. Anything relayed + # per address — datagrams, and QUIC — binds a socket for the new address when it + # arrives, in whatever family that address is, so there is nothing to reopen. + if ( + self.srv.transport == transports.TRANSPORT.PROXY + 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( + LEVEL.INFO, + "the first IPv6 address needs a listener that can accept one; rebuilding", + ) + try: + await self.restart() + except Exception: + await self._back_as_it_was(undo) + raise + return + added = [addr for addr in self.srv.addresses if addr.id == address_id] + 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 + # at it exists. The engine owns the map of them: `_steer` + # carries that same dict, so what is opened here is what the rule finds. + if self.srv.transport == transports.TRANSPORT.PROXY: + for addr in added: + if transports.fronted_by_the_listener(self.srv, addr): + # A TCP address is fronted by the one shared listener, which + # recovers where each connection was headed from conntrack — so + # the only thing it can need told is that this address fronts a + # service somewhere else. An address that is simply the service + # needs nothing at all, which is every address until one says so. + # + # Which of the two it is, and in exactly which words, is + # `transports.announcement`'s to decide: the startup list the + # engine is launched with (`FGEX_PROXY_TARGETS`) is built from the + # same function, so an address that was there when the service + # started and one added a minute later cannot mean two things. + said = transports.announcement(self.srv, addr) + if said is not None: + # One announcement per address, and an interface stands for + # every address it carries: the engine keys this map on what + # `SO_ORIGINAL_DST` hands back, which is never an interface + # name. Skipped instead, an HTTPS edge added on `eth0:443` + # was never announced and the engine dialled :443 instead of + # the service. + for host in interface_addresses(addr.ip_int): + await self.transport.publish( + (host, addr.port), + said.word, + said.onward, + (host, said.target_port) if said.target_port else None, + ) + continue + try: + host = udp_relay_host(addr.ip_int) + except NoRelayAddress as e: + raise transports.UnsupportedChain(str(e)) from e + # The relay's upstream *is* the answer to the same question: what is + # bound here forwards to where the service is, which is the target + # when there is one and the address itself when there is not. + await self.transport.add_udp_target( + host, addr.target_port or addr.port, UPSTREAM.env(addr.upstream) + ) + nft.add(self.srv, added, **self._steer) + await self._claim_udp(added) + self.log.add( + LEVEL.INFO, + f"also protecting {added[0].ip_int}:{added[0].port} (no connection was dropped)", + ) + + async def address_removed(self, address_id: str): + """Stop steering one address, leaving the rest of the service running.""" + gone = [addr for addr in self.srv.addresses if addr.id == address_id] + if gone: + async with self.lock: + if self.active: + nft.delete(self.srv, gone) + await self._release_udp(gone) + # And the engine's own note of where this address fronted, so its + # map cannot come to disagree with the rules that feed it. + for addr in gone: + if (self.srv.transport == transports.TRANSPORT.PROXY + and transports.fronted_by_the_listener(self.srv, addr)): + # Every address it was announced under, or the engine keeps + # fronting one this service no longer protects. + for host in interface_addresses(addr.ip_int): + try: + await self.transport.withdraw((host, addr.port)) + except Exception as e: + self.log.add( + LEVEL.WARN, + f"could not withdraw {addr.ip_int}: {e}") + if self.active: + self.log.add(LEVEL.INFO, f"no longer protecting {gone[0].ip_int}:{gone[0].port}") + # Dropped from the list here rather than re-read from the database, because the + # row is still there: the caller takes the rules back before deleting it, so + # that nothing is deleted while traffic is still being steered at it. Re-reading + # would put the address straight back, and the next restart would re-protect an + # address that no longer exists. + self.srv.addresses = [addr for addr in self.srv.addresses if addr.id != address_id] + + # --- UDP flows conntrack is still carrying ------------------------------------- + + def _udp_edges(self, addresses: list[Address]) -> tuple[list, list]: + """The datagram addresses and the QUIC ones among these, as `(address, port)` + pairs a flow can be matched against — an interface by what it carries.""" + plain, quic = [], [] + for addr in addresses: + if L4.l4_of(addr.proto or self.srv.proto) != L4.UDP: + continue + hosts = ([addr.ip_int] if is_ip_parse(addr.ip_int) + else interface_addresses(addr.ip_int)) + (quic if str(addr.edge) == L4.QUIC else plain).extend( + (host, addr.port) for host in hosts) + return plain, quic + + async def _forget_flows(self, work, said: str) -> None: + """Run a conntrack sweep off the event loop, and say what it did. + + Never fatal: at worst a flow goes on where it was going, which is what happened + before anything here existed. + """ + try: + gone = await asyncio.to_thread(work) + except Exception as e: + self.log.add(LEVEL.WARN, f"could not re-steer the UDP flows already open: {e}") + return + if gone: + self.log.add(LEVEL.INFO, said.format(gone=gone)) + + async def _claim_udp(self, addresses: list[Address]) -> None: + """Make the UDP flows already open to these addresses meet the rules just installed. + + Conntrack translates a flow at its first packet, so a datagram flow older than the + redirect went on straight to the service — unfiltered, for as long as it kept + talking — and one a previous engine carried stayed with it. Forgotten, each one's + next datagram is a new flow and is redirected like any other. A QUIC edge keeps + the connections an engine is still carrying, since moving one would end it. + """ + if self.srv.transport != transports.TRANSPORT.PROXY: + return + plain, quic = self._udp_edges(addresses) + if not plain and not quic: + return + await self._forget_flows( + lambda: conntrack.forget_udp(moved=plain, unsteered=quic, + spare_mark=PROXY_SELF_MARK), + "{gone} UDP flow(s) already open reach the filters from their next datagram", + ) + + async def _release_udp(self, addresses: list[Address]) -> None: + """Send the datagram flows the engine carried straight to the service. + + What stopping means for them. Left alone, each went on to the engine being retired + for as long as it kept talking — kept the engine alive until the drain gave up, and + then went nowhere at all. QUIC connections are the engine's, and are carried until + they close. + """ + if self.srv.transport != transports.TRANSPORT.PROXY: + return + plain, _ = self._udp_edges(addresses) + if not plain: + return + await self._forget_flows( + lambda: conntrack.forget_udp(moved=plain, spare_mark=PROXY_SELF_MARK), + "{gone} UDP flow(s) go straight to the service from their next datagram", + ) + + async def _engine_gone(self, guarded: set[tuple[str, int]]) -> None: + """What is left to do once an engine has exited: its ports stop being guarded, and + the UDP flows still pointed at them are let go. + + Those flows were headed for a relay nobody listens on now, and every datagram + refreshed the entry that sent it there — a client that kept talking was cut off + from the service until it happened to fall silent for two minutes. + """ + nft.release_guards(guarded) + ports = [port for l4, port in guarded if l4 == L4.UDP] + if not ports: + return + await self._forget_flows( + lambda: conntrack.forget_udp_redirected_to(ports, conntrack.local_addresses()), + "{gone} UDP flow(s) left pointing at a stopped engine were let go", + ) + + def _in_background(self, work) -> None: + task = asyncio.get_running_loop().create_task(work) + self._background.add(task) + task.add_done_callback(self._background.discard) + + def traffic(self) -> dict: + """How much has arrived, and how much of it was refused. + + Two measures rather than one ratio, because they are in different units and + dividing them would invent a number. The kernel counts **packets** on the rules + that intercept this service — free, and nothing in userspace can skew it. The + proxy engine counts **connections**, which is the same unit a block is in, so + there the share is exact; the nfqueue layer works per packet and has no + connection to count, which is a real difference between the two and is reported + as one rather than papered over. + """ + seen = nft.traffic(self.srv) if self.active else {"packets": 0, "bytes": 0} + counters = self.transport.counters if self.transport else {} + return { + # Zero on the proxy layer, where no rule is walked per packet — see + # `FiregexTables.traffic`. The engine's connection counters answer instead. + "packets": seen["packets"], + "bytes": seen["bytes"], + "connections": counters.get("seen"), + "connections_refused": counters.get("refused"), + } + + async def refresh(self, srv: Service): + """The service's own definition changed, which the datapath cannot absorb.""" + was_active = self.active + if was_active: + await self.disable(handing_over=True) + self.srv = srv + if was_active: + await self.enable() + + async def next(self, status: str): + if status == STATUS.ACTIVE: + await self.enable() + else: + await self.disable() + + def _set_status(self, active: bool, persist: bool = True): + self.active = active + self.srv.status = STATUS.ACTIVE if active else STATUS.STOP + if persist: + self.db.query( + "UPDATE services SET status = ? WHERE service_id = ?;", + self.srv.status, + self.srv.id, + ) + + +class FirewallManager: + def __init__(self, db: SQLite): + self.db = db + self.services: dict[str, ServiceManager] = {} + self.lock = asyncio.Lock() + self._watch: asyncio.Task | None = None + + async def init(self): + nft.init() + # Before any engine, which opens its socket to it at startup: an interface that + # appears afterwards is one that process will not find until it is restarted. + # Once, here, rather than per service: a capture tool is attached to it for the + # length of a round, and an interface that comes and goes with the services + # takes the tool with it. + mirror.ensure() + await self.reload() + if self._watch is None or self._watch.done(): + self._watch = asyncio.create_task(self._watch_table()) + + async def _watch_table(self): + """Put the rules back whenever something outside firegex takes them away.""" + while True: + await asyncio.sleep(TABLE_WATCH) + try: + if not nft.intact(): + await self._put_rules_back() + except Exception: + traceback.print_exc() + + async def _put_rules_back(self): + async with self.lock: + # Asked again under the lock, which may have been held by something that + # rebuilt the table while this waited for it. + if nft.intact(): + return + print("[error] [backend] firegex's nftables table was removed by something " + "else; putting it back", flush=True) + nft.init() + for manager in list(self.services.values()): + try: + await manager.put_rules_back() + except Exception as e: + manager.log.add(LEVEL.ERROR, f"could not put its rules back: {e}") + + def stop_watching(self) -> None: + if self._watch is not None: + self._watch.cancel() + self._watch = None + + async def reload(self): + async with self.lock: + for row in self.db.query("SELECT * FROM services;"): + srv = Service.from_dict(row) + if srv.id in self.services: + continue + self.services[srv.id] = ServiceManager(srv, self.db) + # Where it is reachable, read here rather than in the constructor so + # there is one place that knows how, and one that calls it. + self.services[srv.id].reload_addresses() + if srv.status == STATUS.ACTIVE: + try: + await self.services[srv.id].enable() + except Exception: + # One service that cannot come back must not stop the others: + # at a competition, most of the protection working beats none + # of it working. + traceback.print_exc() + + async def remove(self, srv_id: str, persist: bool = True, drain: bool = True): + async with self.lock: + if srv_id in self.services: + await self.services[srv_id].disable(persist=persist, drain=drain) + del self.services[srv_id] + if persist: + # A deleted service's log has nothing left to be about. + forget_log(srv_id) + + async def close(self): + """Stop every service. Deliberately *not* the end of the capture interface. + + `reset()` closes and re-initialises without the process going anywhere, so + releasing here would make the device disappear and come back underneath whatever + was capturing from it — the exact failure it was made persistent to avoid. + """ + # First, or a table deleted on the way down is one it would put back. + self.stop_watching() + # Cut, not drained: what brings a drained connection's answers home is this + # table's, and it is about to go — as is the process that would end the drain. + for key in list(self.services.keys()): + try: + await self.remove(key, persist=False, drain=False) + except Exception: + self.services.pop(key, None) + await transports.stop_draining() + stats.flush(self.db, force=True) + + def release_capture(self) -> None: + """Take the capture interface away, at process shutdown and nowhere else.""" + mirror.release() + + def flush_blocks(self) -> None: + """Write every service's waiting block counters, before anything reads them. + + The history behind the chart too: it waits up to `stats.FLUSH_INTERVAL` for the + next block, and at shutdown there is no next block. + """ + for manager in self.services.values(): + manager.flush_blocks() + stats.flush(self.db, force=True) + + def get(self, srv_id: str) -> ServiceManager: + if srv_id not in self.services: + raise ServiceNotFoundException() + return self.services[srv_id] diff --git a/backend/modules/services/logs.py b/backend/modules/services/logs.py new file mode 100644 index 0000000..e2e454d --- /dev/null +++ b/backend/modules/services/logs.py @@ -0,0 +1,207 @@ +"""What a running service is actually doing, as it does it. + +Everything interesting a datapath has to say — a connection refused and by which rule, +a filter that printed something, a filter that raised, a warning about falling back — +used to go to the backend's own stderr, where nobody looks during a competition. This +keeps a bounded tail of it per service and pushes new lines to the browser. + +Two properties matter more than the feature itself: + +**It cannot grow.** A service under attack blocks thousands of connections a minute and +a filter in a print loop produces output as fast as the traffic arrives. The ring is +capped and the oldest lines fall off; nothing here can turn logging into an outage. + +**It cannot flood the browser.** Lines are coalesced and flushed on a timer rather than +emitted one by one, and a flush that would carry more than it should says how many it +dropped instead of carrying them. + +The same flush is also what tells the browser that the **counters** moved. A block bumps +a filter's total, a pattern's, a function's and the bucketed history, and the interface +holds all of those with `staleTime: Infinity` — nothing refetches on its own, so without +a signal the numbers and the chart sit still while the log scrolls past underneath them. +Riding the flush that already exists beats a second timer, and it is exactly true: the +lines that carry a block are the events that change a counter. It is throttled harder +than the log, because invalidating every query of a service is a heavier thing to ask of +the browser than appending a line. +""" + +import asyncio +import time +from collections import deque + +import utils + +#: Lines kept per service. Enough to see what just happened, small enough that a +#: hundred services cost a few megabytes between them. +HISTORY = 500 + +#: How long lines are allowed to pile up before the browser is told. Long enough that a +#: burst becomes one message, short enough to still read as live. +FLUSH_INTERVAL = 0.25 + +#: Most lines carried in a single flush. Beyond this the browser is told the count +#: rather than the content — a thousand identical block lines inform nobody. +FLUSH_LIMIT = 50 + +#: How often a service may tell the browser its counters are stale. Much slower than the +#: log's own flush: this one invalidates every query of the service — its filters, its +#: patterns, its functions, its statistics — and doing that four times a second under +#: attack would be its own kind of flood. +COUNTER_INTERVAL = 2.0 + + +class LEVEL: + INFO = "info" + BLOCK = "block" + OUTPUT = "output" + WARN = "warn" + ERROR = "error" + + +class ServiceLog: + """One service's rolling tail, and the pending batch on its way to the browser.""" + + def __init__(self, service_id: str): + self.service_id = service_id + self.history: deque[dict] = deque(maxlen=HISTORY) + self._pending: list[dict] = [] + self._dropped = 0 + self._flush_task: asyncio.Task | None = None + #: When the browser was last told that this service's counters had moved, and + #: the emit waiting for the current window to close. + self._counters_told = 0.0 + self._counters_task: asyncio.Task | None = None + #: Counts every line ever added, so the frontend can tell "nothing new" from + #: "the tail happens to look the same". + self.seq = 0 + + def add(self, level: str, text: str) -> None: + for line in text.splitlines() or [""]: + line = line.rstrip() + if not line: + continue + self.seq += 1 + entry = {"at": int(time.time() * 1000), "level": level, "text": line, "seq": self.seq} + self.history.append(entry) + if len(self._pending) < FLUSH_LIMIT: + self._pending.append(entry) + else: + self._dropped += 1 + self._schedule() + + def _schedule(self) -> None: + if self._flush_task and not self._flush_task.done(): + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return # no loop to emit on; the history still has the lines + self._flush_task = loop.create_task(self._flush_later()) + + async def _flush_later(self) -> None: + await asyncio.sleep(FLUSH_INTERVAL) + entries, dropped = self._pending, self._dropped + self._pending, self._dropped = [], 0 + 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, + "text": f"... and {dropped} more line(s) in the same instant", + "seq": self.seq, + }) + # Reached through the module rather than imported by value: `utils.socketio` is + # assigned when the app starts, so a direct import would capture the `None` it + # holds at import time and silently never emit anything. + try: + if utils.socketio is not None: + await utils.socketio.emit( + "log", {"service_id": self.service_id, "entries": entries} + ) + await self._tell_counters_moved(entries) + except Exception: + pass # a browser that is not listening is not a reason to disturb the datapath + + async def _tell_counters_moved(self, entries: list[dict]) -> None: + """Say that the totals and the chart are out of date, at most every so often. + + Only for blocks: they are the lines that come with a counter having changed. A + filter's `print()` or a warning moves nothing, and invalidating every query of + the service for one would be work done for no reason. + + Rate-limited with a **trailing** emit rather than by dropping what falls inside + the window. Dropping was the first version and it left the interface frozen on + stale numbers: a burst emitted once at its start, every flush after that was + discarded, and when the traffic stopped there was no further flush to carry the + final count — so the chart sat at whatever it happened to reach two seconds in. + The last block of a burst is precisely the one worth hearing about. + """ + # A warning or an error changes the service list too: it carries the latest one + # (`last_problem`). + if not any(entry["level"] in (LEVEL.BLOCK, LEVEL.WARN, LEVEL.ERROR) + for entry in entries): + return + waited = time.monotonic() - self._counters_told + if waited >= COUNTER_INTERVAL: + await self._emit_counters() + return + # Inside the window: one emit is scheduled for when it closes, and further + # blocks arriving before then fold into it. + if self._counters_task and not self._counters_task.done(): + return + self._counters_task = asyncio.get_running_loop().create_task( + self._emit_counters_after(COUNTER_INTERVAL - waited) + ) + + async def _emit_counters_after(self, delay: float) -> None: + await asyncio.sleep(delay) + try: + await self._emit_counters() + except Exception: + pass # a browser that is not listening is not a reason to raise here + + async def _emit_counters(self) -> None: + self._counters_told = time.monotonic() + if utils.socketio is not None: + await utils.socketio_emit(["services"]) + + def entries(self) -> list[dict]: + return list(self.history) + + def last_problem(self) -> dict | None: + """The newest warning or error still in the tail, for the service list to show. + + The live log is on the service's own page, and a warning nobody has that page open + for is a warning nobody reads: a datapath that died and came back, a filter that + raised, a fallback that lost the client's address. The list carries the latest one + beside the service, and clearing the log is how it is acknowledged. + """ + for entry in reversed(self.history): + if entry["level"] in (LEVEL.WARN, LEVEL.ERROR): + return entry + return None + + def clear(self) -> None: + self.history.clear() + self._pending.clear() + self._dropped = 0 + + +_logs: dict[str, ServiceLog] = {} + + +def log_for(service_id: str) -> ServiceLog: + log = _logs.get(service_id) + if log is None: + log = _logs[service_id] = ServiceLog(service_id) + return log + + +def forget(service_id: str) -> None: + """Drop a deleted service's tail, so nothing outlives what it was about.""" + _logs.pop(service_id, None) diff --git a/backend/modules/services/mirror.py b/backend/modules/services/mirror.py new file mode 100644 index 0000000..9ea3e90 --- /dev/null +++ b/backend/modules/services/mirror.py @@ -0,0 +1,55 @@ +"""The interface the engine writes decrypted traffic to. + +TLS is terminated inside the engine, which is what lets a protected service occupy no +port beyond the one it already answered on — and it means the plaintext is never a packet +on any interface, so there would be nothing for a capture tool to read. The engine writes +it out itself (`proxysrc/src/capture.rs`), framed as the TCP stream it was before +encryption, onto the device this module puts there. + +All that is needed here is the device: a `dummy` interface, which is a sink that carries +whatever is sent to it and goes nowhere. One for the whole instance, so every TLS +service's plaintext arrives on the same interface and a single capture covers the lot. + +It exists for as long as firegex does, put there by `FirewallManager.init` and taken +away by `close`. It used to come and go with the TLS services themselves, on the argument +that an interface present while nothing is decrypting is one somebody points a capture at +and watches stay empty. Watching it stay empty turned out to be the better failure: a +capture tool is attached once, at the start of a round, and an interface that disappears +underneath it takes the tool with it — Zeek and Suricata exit rather than wait, and the +restart of a single TLS service was enough to do it. So the device now outlives any one +service, and the thing an operator points at in the morning is still there in the +afternoon. +""" + +import subprocess + +#: What an operator types into a capture tool. Fixed, and the same name the engine uses. +DEVICE = "firegex0" + + +def _run(*args: str) -> subprocess.CompletedProcess: + return subprocess.run(args, capture_output=True, text=True) + + +def present() -> bool: + return _run("ip", "link", "show", DEVICE).returncode == 0 + + +def ensure() -> bool: + """Put the device there, if it is not already. Returns whether it is now. + + A failure is not an error anybody hears about: a capture aid that could stop a + service from starting would be worse than no capture aid. The engine finds no + interface, writes nothing, and the traffic is filtered and forwarded exactly as it + would have been. + """ + if present(): + return True + if _run("ip", "link", "add", DEVICE, "type", "dummy").returncode != 0: + return False + return _run("ip", "link", "set", DEVICE, "up").returncode == 0 + + +def release() -> None: + """Take it away again, at shutdown and nowhere else.""" + _run("ip", "link", "del", DEVICE) diff --git a/backend/modules/services/models.py b/backend/modules/services/models.py new file mode 100644 index 0000000..4abbb4d --- /dev/null +++ b/backend/modules/services/models.py @@ -0,0 +1,547 @@ +"""The one service model: a network layer, and an ordered chain of filters on it. + +Firegex used to have a module per combination — `nfregex` was NFQUEUE plus regexes, +`nfproxy` was NFQUEUE plus Python, `porthijack` was a redirect, `tls` was its own module — and +each one owned its own services, its own page and its own database. Choosing how to +inspect traffic therefore also chose how to intercept it, and the two have nothing to +do with each other. + +Here they are separate. A [`Service`] says only where the traffic is and how to get +hold of it; a [`Filter`] says what to do with it. Any filter runs on any transport +that can host it, and the service does not have to be recreated to change either. +""" + +import base64 +import uuid + +from utils import is_ip_parse + + +class TRANSPORT: + """How the traffic is intercepted.""" + + #: Packets are queued to userspace and a verdict is handed back to the kernel. + #: Nothing is terminated, so the service sees the original connection, and the + #: kernel keeps forwarding if the filter dies. It cannot rewrite a payload + #: without desynchronising the stream. + NFQUEUE = "nfqueue" + #: The connection is terminated and reopened towards the service. That buys exact + #: rewriting, kernel-side reassembly, backpressure and TLS; it costs the kernel's + #: fail-open backstop, which the engine rebuilds itself. + PROXY = "proxy" + #: The traffic is handed to a proxy the operator runs themselves, by rewriting the + #: destination on the way in and undoing it on the way out. Firegex inspects + #: nothing here — it only arranges for their proxy to be in the path — so no filter + #: can be attached. It is the escape hatch for a protocol nothing built in + #: understands. + EXTERNAL = "external" + + ALL = (NFQUEUE, PROXY, EXTERNAL) + + +class L4: + """What a service speaks on the wire. A property of the service. + + `tls` sits beside `tcp` and `udp` rather than being a flag on top of one of them, + because that is the shape of the thing: a service speaks TLS or it does not, and + "TLS" is what an operator would answer if asked what is on the wire. It used to be a + boolean, which made three combinations representable that are not real — TLS on a + UDP service, on the nfqueue layer, and on a hand-off — and each of them had to be + caught by a check written by hand. Two of those are now unrepresentable instead. + + What the *kernel* matches on is a different question with a smaller answer, which is + what `l4_of` gives: an address carries that, so `(ip, port, proto)` stays a key that + means what it says. Without it a TCP service and a TLS service could both claim one + `ip:port`, which on the wire is the same port twice. + """ + + TCP = "tcp" + UDP = "udp" + #: TLS over TCP, terminated by the engine. + TLS = "tls" + #: QUIC, terminated by the engine. UDP on the wire, TLS 1.3 inside it, and streams + #: inside that — which is the only reason a filter has anything to look at: once past + #: the Initial packet, QUIC encrypts the frames and the stream boundaries along with + #: the payload, so a layer that forwards has nothing to show anybody. + QUIC = "quic" + #: Every version of HTTP, on whichever transport each address carries it. + #: + #: The odd one out, and deliberately so. The other four say what is on the wire; + #: this one says what the *service* is, and lets each address say how it is reached + #: — `:80` in the clear, `:443` under TLS, `:443/udp` under QUIC. One service, one + #: chain, one certificate, and HTTP/1.1, HTTP/2 and HTTP/3 all rendered to the + #: filters as the same HTTP/1.1. + #: + #: It exists because the alternative was two or three services with hand-copied + #: filter chains: HTTP/1.1 and HTTP/2 live on `tls` (TCP) and HTTP/3 on `quic` + #: (UDP), and a chain that has to be kept in step by hand is a chain that stops + #: protecting one of them silently — the same argument that put several addresses + #: under one service in the first place. + #: + #: Nothing about the *kernel* becomes ambiguous, because nothing here is asked of + #: the kernel: each address still carries its own `tcp` or `udp`, which is what the + #: rules match and what `(ip, port, proto)` is keyed on. What the connection turns + #: out to be — TLS or not, HTTP/2 or not — is decided by the engine per connection, + #: from what the client actually sent. + HTTP = "http" + + ALL = (TCP, UDP, TLS, QUIC, HTTP) + + @staticmethod + def l4_of(proto: str) -> str: + """The transport a rule has to match to catch this service's traffic. + + Two of the four are the other two wearing a hat: `tls` is TCP on the wire and + `quic` is UDP. That is what keeps `(ip, port, proto)` a key meaning what it says + — without it a TLS service and a TCP one, or a QUIC service and a UDP one, could + each claim one `ip:port` between them, which on the wire is the same port twice. + + `http` answers `tcp` here, which is the right *default* for an address that does + not say otherwise and never the whole answer: an `http` service's addresses each + carry their own, because it is the one protocol whose addresses are not all on + the same transport. Ask `Service.carries` rather than the service's protocol + whenever the question is about the kernel. + """ + return L4.UDP if str(proto) in (L4.UDP, L4.QUIC) else L4.TCP + + @staticmethod + def edges_of(proto: str) -> tuple[str, ...]: + """Which transports an address of this service may be reached on. + + One for every protocol but `http`, which is the point of `http`. + """ + if str(proto) == L4.HTTP: + return (L4.TCP, L4.UDP) + return (L4.l4_of(proto),) + + +class PROTO: + """Which application protocol a Python filter is written against. + + Never chosen: **read off the filter's own code**. The library decides when a filter + is called from what its parameters are annotated with, so asking for an + `HttpRequest` is what makes a file an HTTP filter and asking only for a `RawPacket` + is what makes one protocol-agnostic. Stored here so the interface can show it and + so a chain can be described without executing anything; the code remains the only + thing that decides it. + + A file that asks for two *different* application protocols is refused when it is + saved, naming both functions — a connection is only ever one of them. + """ + + TCP = "tcp" + HTTP = "http" + + ALL = (TCP, HTTP) + + +class KIND: + """What a filter is.""" + + #: Patterns matched by hyperscan. Both transports match with the same library, so + #: a pattern means the same thing wherever it runs. + REGEX = "regex" + #: The user's own Python, running out of process. + PYFILTER = "pyfilter" + + ALL = (REGEX, PYFILTER) + + +class STATUS: + STOP = "stop" + ACTIVE = "active" + + +# A matching pattern closes the connection. There is no second action, and there is no +# `action` column any more. +# +# There used to be `rewrite`: replace the matched bytes and let the traffic continue. It +# was removed because it could not keep the promise it made. Rewriting scanned one chunk +# at a time — bytes already forwarded cannot be taken back, so a match straddling two +# chunks was never rewritable — and the result was measured: a pattern sent whole reached +# the service rewritten, and the same pattern split across two TCP segments reached it +# *intact*. Blocking, which scans in hyperscan's stream mode, caught the split version. +# +# So the failure was silent and the operator had no way to see it: no block, no log, no +# counter, just a rewrite rule that had quietly not applied. A filter that works on the +# sender's segmentation is worse than no filter, because it is trusted. Blocking is the +# verdict this can honestly offer on a stream, so it is the only one offered. + + +class MODE: + """Which half of the traffic a pattern is matched against.""" + + CLIENT_TO_SERVER = "C" + SERVER_TO_CLIENT = "S" + BOTH = "B" + + ALL = (CLIENT_TO_SERVER, SERVER_TO_CLIENT, BOTH) + + +class UPSTREAM: + """What the service *behind one address* speaks. + + The engine used to have one answer and never asked: whatever the client spoke, the + service was assumed to speak too — a TLS connection was re-encrypted on the way out, + a QUIC one re-encoded as QUIC. That is right whenever firegex is carrying somebody + else's encryption, and wrong in the case an operator most often has, which is a + service that speaks neither and never will. + + So it is three answers now, and they are about the *service*, not about the client: + + * `same` — it speaks what the client spoke. The default, and what every service did + before there was a choice; + * `tcp` — it answers in the clear. Firegex terminates the client's TLS or QUIC and + forwards the plaintext, which makes firegex the thing that *adds* the encryption + rather than the thing that carries it; + * `tls` — it speaks TLS, whatever the client used to get here. On a TLS service that + is `same` by another name; under QUIC it is the one that matters, because it is + how an HTTPS-only service is reached from an HTTP/3 client. + + Under QUIC the last two are not a pass-through: HTTP/3 has no cleartext form, so what + reaches the service is the HTTP/1.1 the filters were already being shown. That is the + one place in the engine where what leaves is not the version that arrived, and it is + only ever the operator's explicit choice. + """ + + SAME = "same" + TCP = "tcp" + TLS = "tls" + + ALL = (SAME, TCP, TLS) + + @staticmethod + def env(upstream: str) -> str: + """What the engine is told. One variable, because it is one question.""" + return {UPSTREAM.TCP: "plain", UPSTREAM.TLS: "tls"}.get(str(upstream), "same") + + +class Address: + """One address a service is protected on. + + A service is one thing to protect and one way of intercepting it; *where* it is + reachable is a list, because the same service routinely answers on more than one + address — a v4 and a v6 one, a public and an internal one, two ports of the same + daemon. Making the operator create one service per address meant keeping their + filter chains in step by hand, and a chain that drifted was a chain that stopped + protecting one of them silently. + + The transport protocol is carried here as well as on the service. Denormalised on + purpose: it is what makes `(ip, port, proto)` a usable uniqueness key, so a TCP + service and a UDP one can share an address the way the kernel lets them. It is + derived — `L4.l4_of(edge)` — because `tls` is TCP on the wire and `quic` is UDP, and + what the kernel matches is a smaller question than what the address carries. + + Two things an address says for itself, and both are why it says anything at all: + **what is spoken at it** (`edge`) and **where that traffic goes** (`target_port`). + Firegex was transparent and only transparent — the client dialled the service's own + address and the engine dialled it back as the client — which meant a service could + only be protected where it already listened. One service answering in the clear on + `:80`, reached over TLS on `:443` and over HTTP/3 on a UDP port, is three rows + pointing at one port. + """ + + def __init__( + self, + address_id: str, + service_id: str, + ip_int: str, + port: int, + proto: str = L4.TCP, + edge: str | None = None, + target_port: int | None = None, + upstream: str = UPSTREAM.SAME, + proxy_ip: str | None = None, + proxy_port: int | None = None, + **other, + ): + self.id = address_id + self.service_id = service_id + self.ip_int = ip_int + self.port = port + self.proto = proto + #: What clients speak *at this address*: one of `tcp`, `tls`, `udp`, `quic` — + #: the same words a service speaks, minus `http`, which is the one that means + #: "ask the addresses". For every protocol but that one it is the service's own + #: and the operator never sees it; on an `http` service it is the choice, and it + #: is what decides whether this address needs a certificate behind it and which + #: transport the kernel matches. + self.edge = str(edge or proto) + #: Where the traffic that arrives here is actually sent, when that is not the + #: port it arrived on. `None` is the transparent case and the default: firegex + #: dials the address the client dialled, as the client, and the service sees + #: exactly what it would have seen without any of this. + #: + #: Set, this address is a *publication*: one service answered on `:80` in the + #: clear and reached on `:443` under TLS, or on a QUIC port, without moving. The + #: address is where the world knocks; this is where the service is. + self.target_port = int(target_port) if target_port else None + #: What the service *behind this address* speaks. See [`UPSTREAM`]. It belongs to + #: the address rather than to the service because that is where the question has + #: an answer: one daemon is reached over TLS on one port and in the clear on + #: another, and what firegex does on the way out is a property of the way in. + self.upstream = str(upstream or UPSTREAM.SAME) + #: Where the operator's own proxy is listening for *this* address. Only for + #: `external`, and per address rather than per service: the return rule rewrites + #: the source port back to the original one, so two addresses handed to the same + #: proxy endpoint could not be told apart on the way out. + self.proxy_ip = proxy_ip + self.proxy_port = proxy_port + + @classmethod + def from_dict(cls, var: dict): + return cls(**var) + + def to_dict(self) -> dict: + return { + "address_id": self.id, + "service_id": self.service_id, + "ip_int": self.ip_int, + "port": self.port, + "proto": self.proto, + "edge": self.edge, + "target_port": self.target_port, + "upstream": self.upstream, + "proxy_ip": self.proxy_ip, + "proxy_port": self.proxy_port, + } + + @property + def is_interface(self) -> bool: + return not is_ip_parse(self.ip_int) + + @property + def is_ipv6(self) -> bool: + return ":" in str(self.ip_int) if not self.is_interface else False + + def __repr__(self): + return f"
" + + +class Service: + """One protected endpoint and the transport in front of it.""" + + def __init__( + self, + service_id: str, + name: str, + status: str, + proto: str, + transport: str, + fail_open: bool = True, + max_connections: int = 0, + over_limit_forwards: bool = False, + first_byte_timeout: int = 0, + tls_cert: str | None = None, + tls_key: str | None = None, + addresses: list | None = None, + **other, + ): + self.id = service_id + self.name = name + self.status = status + self.proto = proto + self.transport = transport + self.fail_open = bool(fail_open) + #: How many connections or UDP flows may be carried at once; 0 means no limit. + self.max_connections = max(0, int(max_connections or 0)) + #: Whether what does not fit is forwarded with no filter in front of it. + self.over_limit_forwards = bool(over_limit_forwards) + #: Seconds a connection may carry nothing at all before it is closed; 0 is never. + self.first_byte_timeout = max(0, int(first_byte_timeout or 0)) + self.tls_cert = tls_cert + self.tls_key = tls_key + #: Every address this service is protected on. Filled by whoever read the + #: service out of the database; a service with none is configured but + #: unreachable, and starting it installs no rules. + self.addresses: list[Address] = list(addresses or []) + + @classmethod + def from_dict(cls, var: dict): + return cls(**var) + + @property + def decrypts(self) -> bool: + """Whether the engine decrypts this service's traffic before filtering it. + + Read off what the service speaks rather than held beside it. Only the proxy layer + can do it: decrypting means terminating the connection, and that is what that + layer does. It was once orthogonal to the layer, which took an nginx in front of + NFQUEUE to arrange — so the connection was terminated anyway, and the one + property that layer has over the other was already gone. + + Named for what it does rather than for TLS, because two protocols do it now and + QUIC is the one with no alternative: TLS over TCP can at least be carried past + unopened, while a QUIC packet past the handshake has its frames encrypted too. + Both need the same thing from the operator, which is a certificate. + + `http` is in the list because a certificate is the whole of what it adds: a + cleartext HTTP service is already `tcp`, and HTTP/2 in the clear is recognised + there from the connection preface whatever the service is called. What `http` + buys over that is the TLS and the QUIC edges, and neither exists without one. + """ + return str(self.proto) in (L4.TLS, L4.QUIC, L4.HTTP) + + @property + def l4(self) -> str: + """The transport the kernel sees, which is what a rule has to match. + + Only meaningful where a service has one. An `http` service is reached on both, + and everything that used to ask this of such a service asks `carries` instead: + the answer here is its addresses' default, not a claim about all of them. + """ + return L4.l4_of(self.proto) + + def carries(self, l4: str) -> bool: + """Whether any of this service's addresses is reached over `l4`. + + The question every rule, listener and relay actually has, and the one that + stopped having a single answer when `http` arrived. A service with no addresses + yet is answered from its protocol, so a check made at creation — before there is + an address to look at — still means something. + """ + if not self.addresses: + return l4 in L4.edges_of(self.proto) + return any(L4.l4_of(addr.proto or self.proto) == l4 for addr in self.addresses) + + @property + def udp_addresses(self) -> list: + """The addresses that need a relay of their own: datagrams, and QUIC.""" + return [ + addr + for addr in self.addresses + if L4.l4_of(addr.proto or self.proto) == L4.UDP + ] + + @property + def has_ipv6(self) -> bool: + """Whether any of its addresses is IPv6. + + Decides the family a proxy listener has to be opened in, which is why it is a + property of the service and not of one address: one listener serves them all. + """ + return any(addr.is_ipv6 for addr in self.addresses) + + @property + def has_ipv6_tcp(self) -> bool: + """Whether the *TCP* listener needs to be able to accept IPv6. + + The narrower question, and the one worth asking before rebuilding a service: the + only listener that can have its family wrong is the TCP one. Everything relayed + per address — datagrams, and QUIC — binds a socket in the family of the address + when it arrives. On an `http` service the two sit side by side, so an IPv6 HTTP/3 + address must not cost every TCP connection on the service a restart it did not + need. + """ + udp = {addr.id for addr in self.udp_addresses} + return any(addr.is_ipv6 for addr in self.addresses if addr.id not in udp) + + def to_dict(self) -> dict: + # The certificate goes out, the key never does: it is write-only from the + # API's point of view, and a service listing is the easiest place to leak it. + return { + "service_id": self.id, + "name": self.name, + "status": self.status, + "proto": self.proto, + "transport": self.transport, + "fail_open": self.fail_open, + "addresses": [addr.to_dict() for addr in self.addresses], + } + + +class Filter: + """One link in a service's chain.""" + + def __init__( + self, + filter_id: str, + service_id: str, + position: int, + kind: str, + name: str, + active: bool, + proto: str = PROTO.TCP, + blocked: int = 0, + **other, + ): + self.id = filter_id + self.service_id = service_id + self.position = position + self.kind = kind + self.name = name + self.active = bool(active) + self.proto = proto + self.blocked = blocked + + @classmethod + def from_dict(cls, var: dict): + return cls(**var) + + def to_dict(self) -> dict: + return { + "filter_id": self.id, + "service_id": self.service_id, + "position": self.position, + "kind": self.kind, + "name": self.name, + "active": self.active, + "proto": self.proto, + "blocked": self.blocked, + } + + +class Regex: + """One pattern inside a regex filter. + + Stored base64-encoded because a pattern is bytes, not text: matching a raw byte + sequence is a normal thing to want, and JSON has no way to carry one. + """ + + def __init__( + self, + regex_id: str, + filter_id: str, + regex: bytes, + mode: str, + case_sensitive: bool, + active: bool, + blocked: int = 0, + **other, + ): + self.id = regex_id + self.filter_id = filter_id + self.regex = regex + self.mode = mode + self.case_sensitive = bool(case_sensitive) + self.active = bool(active) + self.blocked = blocked + + @classmethod + def from_dict(cls, var: dict): + var = dict(var) + var["regex"] = base64.b64decode(var["regex"]) + return cls(**var) + + def to_dict(self) -> dict: + return { + "regex_id": self.id, + "filter_id": self.filter_id, + "regex": base64.b64encode(self.regex).decode(), + "mode": self.mode, + "case_sensitive": self.case_sensitive, + "active": self.active, + "blocked": self.blocked, + } + + @property + def is_input(self) -> bool: + return self.mode in (MODE.CLIENT_TO_SERVER, MODE.BOTH) + + @property + def is_output(self) -> bool: + return self.mode in (MODE.SERVER_TO_CLIENT, MODE.BOTH) + + +def new_id() -> str: + return str(uuid.uuid4()) diff --git a/backend/modules/services/nftables.py b/backend/modules/services/nftables.py new file mode 100644 index 0000000..24cffd5 --- /dev/null +++ b/backend/modules/services/nftables.py @@ -0,0 +1,1200 @@ +"""One nftables owner for both transports. + +The rules a service needs depend entirely on how its traffic is intercepted, and +nothing else — not on what filters it runs. That is the whole point of separating the +two layers, and it is why this file dispatches on `srv.transport` and never looks at +a filter. + +**NFQUEUE** is the simple half: match the service's address in prerouting and +postrouting, mark the direction, and queue with `bypass` so the kernel keeps +forwarding if userspace stops answering. + +**Proxy** has to arrange the return path by hand, because it terminates connections. +The subtle part is that the service must still see the client's address. When the +engine dials the service as the client, two flows look almost identical: + + engine -> client src service:port dst client:client_port must leave the box + service -> engine src service:port dst client: must stay local + +No address distinguishes them. Conntrack does: the first belongs to the intercepted, +redirected connection, the second is a separate entry the engine opened itself. So +`ct status dnat` lets the first out, and everything else from a protected service — and +anything answering a connection the engine opened, from whichever port — is marked home. That is what makes source preservation work even with the service on +this very host — which is also the case that ruled tproxy out, since tproxy leaves no +NAT entry to key off and so could never reach a local service at all. +""" + +import subprocess + +from modules.services.models import TRANSPORT, UPSTREAM, Address, Service +from utils import ( + NFTableManager, + addr_parse, + get_interface_ips, + ip_family, + ip_parse, + is_ip_parse, + nftables_int_to_json, +) + +# Direction marks the NFQUEUE binaries read back off a packet. +QUEUE_MARK_INPUT = 0x1337 +QUEUE_MARK_OUTPUT = 0x1338 + +#: How many filters an NFQUEUE service can chain. +#: +#: Each position is its own pair of base chains, because that is what makes a chain +#: possible there at all: a packet a filter accepts carries on to the next base chain in +#: the same hook, so filter order is chain priority order. Eight is not a technical +#: limit, it is a number of always-present chains worth paying for; nothing stops it +#: growing except that every position costs two chains whether or not anyone uses it. +MAX_CHAIN_POSITIONS = 8 + +#: Where the NFQUEUE layer meets a connection this host opened to one of its own addresses +#: that somebody rewrote on the way — a port a container runtime publishes. Docker's DNAT +#: for this host's own traffic sits in `nat OUTPUT` at `dstnat` (-100) and sends it out +#: towards the container, so it never comes back in through prerouting where the queue +#: chains are, and neither does the container's answer: it arrives for a local socket +#: and goes to `input`. These run after that rewrite on the way out and before it is +#: undone on the way in (`srcnat`, 100), so both halves show the container's address and +#: a stream follower sees one stream. +LOCAL_QUEUE_PRIORITY = -90 + +#: Where the proxy layer's redirect sits in the prerouting and output hooks. +#: +#: **Before `dstnat` (-100), and that is the whole point.** Docker and podman publish a +#: container's port by appending their own chain to `nat PREROUTING`, which `iptables-nft` +#: registers at exactly `dstnat` — the priority this used to share. Two base chains at one +#: priority in two tables are ordered by neither of them, and measured on a host where the +#: DNAT went first: it rewrote the destination to the container's address, firegex's rule +#: (which matches the *published* address) stopped matching, its counter stayed at zero, +#: and traffic carrying a blocked pattern was answered by the container. The service still +#: read `ACTIVE`. Protection that disappears without saying so is the worst failure this +#: module has, and a race nobody arbitrates is not a thing to leave to luck. +#: +#: So the redirect runs first and the published port is captured before anybody can +#: rewrite it — which is the property the NFQUEUE layer already had for free by living at +#: `raw`, three hundred below any of this. The engine's own dial is not caught by the +#: rule it would otherwise match (`SELF_MARK`), so whatever DNAT the container runtime +#: installs still does its job on the way out, exactly as before. +#: +#: The floor is conntrack at -200: a `nat` chain registered before it has no connection to +#: attach a translation to. Anything between the two behaves identically; this one is far +#: enough from both to leave room. +NAT_PRIORITY = -120 + +# Shared by the divert rule and the return rule; policy routing keys off it. +PROXY_MARK = 0x1339 +# Routing table holding the `local` default that makes marked packets be delivered +# here instead of routed by destination. +PROXY_ROUTE_TABLE = 1339 +# Stamped by the engine on the connections it opens. Without excluding it, an +# intercept rule cannot tell the engine's own dial from the traffic it should +# intercept — and where both ends are local, the engine would redirect itself in a +# loop. Deliberately not PROXY_MARK: +# that one means "deliver this here", which would strand an outbound connection. +# Matches `transparent::SELF_MARK` in the engine. +PROXY_SELF_MARK = 0x133A + + +def resolve_target(srv: Service, addr: Address) -> tuple[str, int] | None: + """Where one address's rules should point: at the address itself. + + This is the address the **world knocks on**, which is what a rule has to match, and + it is not always where the service is — see [`service_at`]. The two were one answer + for as long as firegex was transparent and only transparent, and making this one + return the other quietly redirected the service's own port twice and left the + published one unprotected, which is a service that looks configured and answers + nothing. + + It used to be somewhere else for a TLS service. nginx terminated the connection on + one derived loopback port and re-encrypted from a second, and the rules were pointed + at the plaintext leg in between — which is why protecting one address cost two ports + chosen by hashing `ip:port`, and why those ports could collide with something real. + The engine terminates TLS itself now, so there is no leg to point at and no port to + choose: the ciphertext arrives at the address the world dials, and is decrypted + inside the process that filters it. + + The return type keeps its `None`, because a caller that installs nothing is still + the right answer for an address that cannot be resolved. + """ + return addr.ip_int, addr.port + + +def service_at(srv: Service, addr: Address) -> tuple[str, int] | None: + """Where the service reached through one address actually is. + + The address itself, unless that address is a **publication**: one daemon answering + HTTP/1.1 in the clear on `:80` can be reached over TLS on `:443` and over HTTP/3 on a + UDP port, all three sent back to the port it already listens on. Firegex is then not + transparent on those addresses, which is the point of them and the reason it is said + per address rather than per service. + + Everything that *dials* asks this — the UDP and QUIC relays, whose upstream is fixed + when they are bound, and the TCP listener, which is told through `FGEX_PROXY_TARGETS` + because one listener fronts every address and recovers each connection's destination + from conntrack. Everything that *matches* asks [`resolve_target`]. + """ + return addr.ip_int, (addr.target_port or addr.port) + + +def one_address(ip: str) -> str: + """Strip any prefix: a rewrite target is one host, never a network. + + Service addresses are normalised to network form (`127.0.0.1/32`) because that is + what a *match* wants. A `mangle` writes a single address into the packet and refuses + anything else, so the two cannot share a representation. + """ + return str(ip).split("/")[0] + + +def hijack_endpoint(proxy_ip: str | None, service_ip: str) -> str: + """Where the operator's own proxy listens for one address, as a single host. + + The one spelling of the default, because it is asked in three places that must agree: + the rule that sends traffic to that proxy, the rule that undoes the rewrite on the way + back, and the uniqueness check that stops two addresses sharing one endpoint. Loopback + is where such a proxy normally is, in the family of the address it fronts. + + It is resolved when an address is **written**, not only when its rules are built. The + column used to be left NULL and defaulted here — and SQLite's UNIQUE treats every NULL + as distinct, so the partial index meant to forbid two addresses behind one endpoint + happily accepted any number of them as long as nobody typed the address out. They then + all resolved to the same loopback port, which is exactly the collision the index exists + to prevent: the return rule recognises the proxy by address and port, so it could not + tell them apart on the way out. + """ + return one_address(proxy_ip or "") or ( + "::1" if ip_family(one_address(service_ip)) == "ip6" else "127.0.0.1" + ) + + +class NoRelayAddress(Exception): + """An address a UDP relay cannot be bound for.""" + + +def udp_relay_host(ip: str) -> str: + """The concrete address a UDP relay binds for `ip`, which may be an interface name. + + A relay has to bind somewhere, and an interface name is not somewhere — so the + interface's own address stands in for it, resolved when it is needed rather than + stored, because an interface's address is the host's to change. IPv4 first and + link-local last: an interface almost always carries a link-local IPv6 address it was + never configured with, and binding the relay to that one would put the service on an + address nobody dials. + """ + if is_ip_parse(ip): + return one_address(ip) + candidates = [one_address(addr.split("%")[0]) for addr in get_interface_ips(ip)] + ordered = sorted( + candidates, + key=lambda a: (ip_family(a) == "ip6", a.lower().startswith(("fe80:", "169.254."))), + ) + if not ordered: + raise NoRelayAddress( + f"interface '{ip}' has no IP address assigned, so there is nothing for a " + f"UDP relay to bind to" + ) + return ordered[0] + + +#: Written on the output-hook rules an interface address installs, so they can be found +#: again. There is nothing in the packet that names the interface there — see +#: `interface_addresses` — so the rule says which interface it belongs to itself. +IFACE_COMMENT = "iface " + +#: Written on a rule that matches its address through conntrack rather than in the packet +#: — `
` — for the same reason: `get()` reads what a rule matches to +#: know whose it is, and there is nothing in these for it to read. +ADDRESS_COMMENT = "address " + + +def interface_addresses(ip: str) -> list[str]: + """Every address an interface carries right now, as a rule can match them. + + Link-local is left out for the reason `udp_relay_host` takes it last: an interface + almost always carries an `fe80::` it was never configured with, and nothing dials a + service there. + """ + if is_ip_parse(ip): + return [one_address(ip)] + return [ + one_address(addr.split("%")[0]) + for addr in get_interface_ips(ip) + if not addr.lower().startswith(("fe80:", "169.254.")) + ] + + +def udp_relay_key(ip: str, port: int) -> str: + """How a UDP relay's upstream is spelled, everywhere it is spelled. + + The engine reports one `UDP ` line per relay and is asked for a new + one by the same token, so the transport's map of them, the rule that points traffic + at one, and the manager adding an address to a running service all have to agree on + it exactly. Written out at each of those it stops agreeing the first time one of + them is changed, and the symptom is a service whose UDP silently goes nowhere. + """ + host = one_address(ip) + 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. + + Rules are found by what they match rather than by a handle we remember, so a + restart that lost its bookkeeping still cleans up after itself. + """ + + def __init__(self, chain: str, handle: int, proto: str, port: int, ip_int: str, + packets: int = 0, bytes_: int = 0): + self.chain = chain + self.handle = handle + self.proto = proto + self.port = int(port) + self.ip_int = str(ip_int) + #: What the kernel has counted on this rule. + self.packets = int(packets) + self.bytes = int(bytes_) + + def _is(self, ip: str, port: int) -> bool: + """Whether this rule matches one particular address and port. + + Both halves together, because an installed rule is identified by the pair: the + two legs of a hand-off match different addresses on **different ports**, and + comparing the port against only one of them is what made the outbound one + invisible. + """ + if self.port != int(port): + return False + if is_ip_parse(self.ip_int) and is_ip_parse(ip): + return ip_parse(self.ip_int) == ip_parse(ip) + if not is_ip_parse(self.ip_int) and not is_ip_parse(ip): + return self.ip_int == ip + return False + + def matches(self, srv: Service, addr: Address) -> bool: + """Whether this installed rule is one of `addr`'s.""" + # The address already carries the transport the kernel matches on, derived + # from the service's: `tls` is TCP on the wire. + if self.proto != str(addr.proto): + return False + target = resolve_target(srv, addr) + if target is None: + return False + target_ip, target_port = target + if self._is(target_ip, target_port): + return True + # The return leg of an external hand-off matches the operator's proxy, not the + # service — **and their port, not the service's**. The port was compared against + # the service's before either identity was considered, so this branch was only + # ever reached when the two happened to be the same number: every other hand-off + # left its outbound rule behind on stop, still rewriting the source of anything + # from that proxy endpoint, and installed a second copy on the next start. + if srv.transport == TRANSPORT.EXTERNAL and addr.proxy_port and is_ip_parse(target_ip): + proxy_ip = hijack_endpoint(addr.proxy_ip, target_ip) + return self._is(proxy_ip, addr.proxy_port) + return False + + def matches_any(self, srv: Service, addresses: list[Address]) -> bool: + return any(self.matches(srv, addr) for addr in addresses) + + +def _chain(name: str, ctype: str, hook: str, prio: int) -> dict: + return { + "add": { + "chain": { + "family": "inet", + "table": FiregexTables.table_name, + "name": name, + "type": ctype, + "hook": hook, + "prio": prio, + "policy": "accept", + } + } + } + + +class FiregexTables(NFTableManager): + # NFQUEUE transport: one pair of chains per position in the filter chain. + @staticmethod + def queue_input_chain(position: int) -> str: + return f"fgex_queue_in_{position}" + + @staticmethod + def queue_output_chain(position: int) -> str: + return f"fgex_queue_out_{position}" + + # And the connections this host opens to a rewritten address: see LOCAL_QUEUE_PRIORITY. + @staticmethod + def queue_local_request_chain(position: int) -> str: + return f"fgex_queue_lreq_{position}" + + @staticmethod + def queue_local_reply_chain(position: int) -> str: + return f"fgex_queue_lrep_{position}" + + # External transport: hand the traffic to a proxy the operator runs + hijack_in_chain = "fgex_hijack_in" + hijack_local_chain = "fgex_hijack_local" + hijack_out_chain = "fgex_hijack_out" + # Proxy transport + divert_chain = "fgex_divert" + nat_chain = "fgex_nat" + nat_output_chain = "fgex_nat_out" + route_chain = "fgex_route" + guard_chain = "fgex_guard" + #: `l4proto . port` of every listener and relay a proxy-layer redirect points at. + datapath_set = "fgex_datapath" + + def __init__(self): + super().__init__( + [ + *[ + chain + for position in range(MAX_CHAIN_POSITIONS) + # Increasing priority means increasing position: netfilter walks the + # base chains of a hook in priority order, so this *is* the chain. + for chain in ( + _chain(self.queue_input_chain(position), "filter", "prerouting", + -307 + position), + _chain(self.queue_output_chain(position), "filter", "postrouting", + 107 + position), + _chain(self.queue_local_request_chain(position), "filter", "output", + LOCAL_QUEUE_PRIORITY + position), + _chain(self.queue_local_reply_chain(position), "filter", "input", + LOCAL_QUEUE_PRIORITY + position), + ) + ], + # Earlier than the queue chains, so traffic destined for somebody else's + # proxy is redirected before anything here tries to inspect it. + _chain(self.hijack_in_chain, "filter", "prerouting", -310), + # Locally generated traffic never reaches prerouting: it goes straight + # from output to postrouting. Without this chain a hand-off would work + # for the outside world and silently not for anything on this host — + # including firegex's own tests, which is how the gap was found. + _chain(self.hijack_local_chain, "filter", "output", -310), + _chain(self.hijack_out_chain, "filter", "postrouting", 110), + # Before conntrack, so a reply to one of the engine's transparent + # sockets is recognised as ours before anything rewrites it. + _chain(self.divert_chain, "filter", "prerouting", -150), + # Installed once, never per service: a packet that already belongs to + # one of the engine's transparent sockets is a reply to a connection + # it opened while impersonating a client, and has to be delivered + # locally rather than forwarded on towards that client. + { + "add": { + "rule": { + "family": "inet", + "table": self.table_name, + "chain": self.divert_chain, + "expr": [ + { + "match": { + "op": "==", + "left": {"socket": {"key": "transparent"}}, + "right": 1, + } + }, + {"mangle": {"key": {"meta": {"key": "mark"}}, "value": PROXY_MARK}}, + {"accept": None}, + ], + } + } + }, + # The same, for a reply the socket lookup cannot place: one to a connection + # the engine opened towards an address somebody rewrote — a port a container + # runtime publishes, above all. The engine dials the published address as + # the client, Docker's DNAT sends it on to the container, and the container + # answers from its own address: `socket transparent` looks for a socket + # talking to *that* and finds none, so the answer was routed on to the real + # client, which reset it. A client on another host reached a Docker service + # through the proxy layer only after the dial timed out and was retried as + # this host — slowly, and with the client's address lost. Conntrack still + # knows whose connection it is, by the mark `fgex_route` copied onto it. + { + "add": { + "rule": { + "family": "inet", + "table": self.table_name, + "chain": self.divert_chain, + "expr": [ + { + "match": { + "op": "==", + "left": {"ct": {"key": "direction"}}, + "right": "reply", + } + }, + { + "match": { + "op": "==", + "left": {"ct": {"key": "mark"}}, + "right": PROXY_SELF_MARK, + } + }, + {"mangle": {"key": {"meta": {"key": "mark"}}, "value": PROXY_MARK}}, + {"accept": None}, + ], + } + } + }, + _chain(self.nat_chain, "nat", "prerouting", NAT_PRIORITY), + # Locally generated traffic never reaches the nat prerouting hook, and + # a client on this host protecting a service on this host is exactly that. + # Same priority for the same reason: a container runtime publishes a port + # in `nat OUTPUT` as well, so the race is the same one on this hook. + _chain(self.nat_output_chain, "nat", "output", NAT_PRIORITY), + # `route`, so changing the mark forces the packet to be re-routed. + _chain(self.route_chain, "route", "output", -150), + # **The engine's own ports are reached through a redirect or not at all.** + # Its TCP listener and every UDP and QUIC relay are bound to the wildcard, + # because a redirect delivers to whichever address the packet arrived on, + # so each of them could be dialled straight from anywhere that reaches the + # host. A relay forwards to its service whoever sent the datagram: a + # service protected on `127.0.0.1:53` was on the network through the + # relay's port. What arrived by the redirect carries conntrack's DNAT + # status; anything else aimed at one of these ports is dropped. Replies + # to the engine's transparent sockets are marked by `fgex_divert` and + # left alone, since a client's source port can be any number at all. + { + "add": { + "set": { + "family": "inet", + "table": self.table_name, + "name": self.datapath_set, + "type": ["inet_proto", "inet_service"], + } + } + }, + _chain(self.guard_chain, "filter", "input", 0), + { + "add": { + "rule": { + "family": "inet", + "table": self.table_name, + "chain": self.guard_chain, + "expr": [ + { + "match": { + "op": "!=", + "left": {"meta": {"key": "mark"}}, + "right": PROXY_MARK, + } + }, + { + "match": { + "op": "==", + "left": {"&": [{"ct": {"key": "status"}}, "dnat"]}, + "right": 0, + } + }, + { + "match": { + "op": "==", + "left": {"concat": [ + {"meta": {"key": "l4proto"}}, + {"payload": {"protocol": "th", "field": "dport"}}, + ]}, + "right": f"@{self.datapath_set}", + } + }, + self.COUNTER, + {"drop": None}, + ], + } + } + }, + # Installed once. A packet on the intercepted (redirected) connection + # is the engine answering a client and has to leave normally; a reply to + # the engine's own dial is marked home by the two rules after this one. + { + "add": { + "rule": { + "family": "inet", + "table": self.table_name, + "chain": self.route_chain, + "expr": [ + { + "match": { + "op": "in", + "left": {"ct": {"key": "status"}}, + "right": "dnat", + } + }, + {"accept": None}, + ], + } + } + }, + # **A reply to a connection the engine opened, from whatever port it + # comes — and nothing else.** A per-address rule used to do this, knowing + # the service by the address it is protected on. That is where the engine + # dials it only while the address *is* the service: one published on + # another port is dialled there, and an HTTP/3 edge in front of an + # HTTP/1.1 service is dialled over TCP, and their answers were routed out + # towards the client instead of home. It also matched the answers on + # connections the engine never saw, the ones open before the service + # started, and brought those home to nobody. Conntrack knows who opened a + # connection: the engine's mark is copied onto it on the way out and read + # back off everything that answers it. + { + "add": { + "rule": { + "family": "inet", + "table": self.table_name, + "chain": self.route_chain, + "expr": [ + { + "match": { + "op": "==", + "left": {"meta": {"key": "mark"}}, + "right": PROXY_SELF_MARK, + } + }, + {"mangle": {"key": {"ct": {"key": "mark"}}, + "value": PROXY_SELF_MARK}}, + ], + } + } + }, + { + "add": { + "rule": { + "family": "inet", + "table": self.table_name, + "chain": self.route_chain, + "expr": [ + { + "match": { + "op": "==", + "left": {"ct": {"key": "direction"}}, + "right": "reply", + } + }, + { + "match": { + "op": "==", + "left": {"ct": {"key": "mark"}}, + "right": PROXY_SELF_MARK, + } + }, + {"mangle": {"key": {"meta": {"key": "mark"}}, + "value": PROXY_MARK}}, + ], + } + } + }, + ], + # The whole table, which is firegex's alone. It used to be flushed chain by + # chain in one batch, and nft refuses a batch outright if any object in it is + # missing — so a table left by a version with a different set of chains (an + # upgrade after a crash, say) was never cleared at all: its rules went on + # steering traffic at queues and ports nobody listened on any more. + [{"delete": {"table": {"family": "inet", "name": self.table_name}}}], + ) + + #: How the mark and the routing table are spelled to `ip`, derived from the constants + #: above rather than written out. They were four literal `"0x1339"` and `"1339"` + #: strings beside the two names that already held those numbers — the shape of drift + #: this file argues against everywhere else, and the one place where it would be + #: silent: the policy route would simply point at a table nothing marks. + PROXY_MARK_ARG = f"{PROXY_MARK:#x}" + PROXY_TABLE_ARG = str(PROXY_ROUTE_TABLE) + + def _policy_route(self, action: str) -> None: + """Add or delete the rule and route that bring the engine's return traffic home. + + `check=False` throughout: adding one that is already there and deleting one that + is not are both the state being asked for, and neither is worth an exception. + """ + for family in ([], ["-6"]): + subprocess.run( + ["ip", *family, "rule", action, "fwmark", self.PROXY_MARK_ARG, + "lookup", self.PROXY_TABLE_ARG], + check=False, stderr=subprocess.DEVNULL, + ) + if action != "add": + # The route goes with the rule that reached it, so deleting it is belt and + # braces rather than a step of its own. + return + for family in ([], ["-6"]): + subprocess.run( + ["ip", *family, "route", "add", "local", "default", "dev", "lo", + "table", self.PROXY_TABLE_ARG], + check=False, stderr=subprocess.DEVNULL, + ) + + def intact(self) -> bool: + """Whether what `init()` put in the kernel is still there. + + One small chain rather than the whole table, because this is asked every few + seconds: `init()` always leaves rules in the route chain, so a table that was + deleted (`nft flush ruleset`, which is how a Debian `/etc/nftables.conf` begins) + and one that was only emptied (`nft flush table`) both fail it. + """ + code, listed, _ = self.raw_cmd({"list": {"chain": { + "family": "inet", "table": self.table_name, "name": self.route_chain, + }}}) + return code == 0 and any("rule" in item for item in listed["nftables"]) + + def reset(self): + super().reset() + # The set went with the table. + self._guarded.clear() + try: + self._policy_route("del") + except Exception: + pass + + def init(self): + super().init() + # The `local` default route is what makes marked packets be delivered here + # instead of routed by destination; without it a preserved-source connection + # does not fail, it hangs. + try: + self._policy_route("add") + except Exception as e: + print("Failed to configure ip rules for transparent proxy:", e) + + # --- rule fragments ------------------------------------------------------- + + def _not_ours(self) -> list: + """Skip the connections the engine opened itself.""" + return [ + { + "match": { + "op": "!=", + "left": {"meta": {"key": "mark"}}, + "right": PROXY_SELF_MARK, + } + } + ] + + #: Counted by the kernel on the rules that intercept a service's traffic. + #: + #: Free — netfilter already evaluates these rules — and it is the only measure of + #: "how much arrived" that no filter can skew, because nothing in userspace is + #: involved in producing it. It counts packets, which is what the wire carries; a + #: block is a refused connection, so the two are never divided by one another. + COUNTER = {"counter": {"packets": 0, "bytes": 0}} + + def _match(self, ip: str, port: int, family: str, l4: str, addr: str, field: str) -> list: + if not is_ip_parse(ip): + iface_key = "iifname" if addr == "daddr" else "oifname" + return [ + { + "match": { + "op": "==", + "left": {"meta": {"key": iface_key}}, + "right": ip, + } + }, + { + "match": { + "op": "==", + "left": {"payload": {"protocol": l4, "field": field}}, + "right": int(port), + } + }, + ] + return [ + { + "match": { + "op": "==", + "left": {"payload": {"protocol": family, "field": addr}}, + "right": nftables_int_to_json(ip), + } + }, + { + "match": { + "op": "==", + "left": {"payload": {"protocol": l4, "field": field}}, + "right": int(port), + } + }, + ] + + def _rule(self, chain: str, expr: list, insert: bool = False, + comment: str | None = None) -> dict: + verb = "insert" if insert else "add" + return { + verb: { + "rule": { + "family": "inet", + "table": self.table_name, + "chain": chain, + "expr": expr, + **({"comment": comment} if comment else {}), + } + } + } + + # --- installing ----------------------------------------------------------- + + def add(self, srv: Service, addresses: list[Address] | None = None, *, + queue_nums: list[int] | None = None, proxy_port: int | None = None, + udp_ports: dict | None = None): + """Steer a started service's traffic into whatever is now listening for it. + + Always called after the process is up: a rule pointing at a queue nobody is + reading, or a port nobody is bound to, is worse than no rule at all. + + One address at a time, each skipped if it is already steered. Passing a subset + is how an address is added to a running service without the others losing their + connections — the datapath is the same one, only the rules that point at it are + new. + """ + installed = self.get() + for addr in addresses if addresses is not None else srv.addresses: + if any(rule.matches(srv, addr) for rule in installed): + continue # already steered; nothing to do and nothing to duplicate + + target = resolve_target(srv, addr) + if target is None: + continue + target_ip, target_port = target + is_iface = not is_ip_parse(target_ip) + family = "ip" if is_iface else ip_family(target_ip) + l4 = str(addr.proto) + + if srv.transport == TRANSPORT.NFQUEUE: + self._add_queue(srv, target_ip, target_port, family, l4, queue_nums or []) + elif srv.transport == TRANSPORT.EXTERNAL: + self._add_external(srv, addr, target_ip, target_port, family, l4) + else: + # UDP is relayed by a socket of its own per address, so the rule points + # 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* 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_slot(udp_relay_host(service_ip), service_port, + UPSTREAM.env(addr.upstream)) + port = (udp_ports or {}).get(key) + if port is None: + raise Exception( + f"the proxy transport has no UDP relay listening for {key}" + ) + self._add_proxy(srv, target_ip, target_port, family, l4, port) + self._guard(srv, l4, port) + + def _add_queue(self, srv: Service, ip: str, port: int, family: str, l4: str, + queue_nums: list[int]): + """One rule pair per filter, each in the chain that runs at its position. + + A packet a filter accepts goes on to the next base chain in the same hook, so + the filters see it in position order and the first one to refuse it is the last + one that sees it. That is the whole mechanism: the chain is the priorities. + """ + if not queue_nums: + raise Exception("the nfqueue transport needs at least one queue number") + if len(queue_nums) > MAX_CHAIN_POSITIONS: + raise Exception( + f"the nfqueue transport chains at most {MAX_CHAIN_POSITIONS} filters" + ) + cmds = [] + for position, queue_num in enumerate(queue_nums): + # **`bypass` is the service's own fail-open policy, not a constant.** It tells + # the kernel to accept a packet when nothing is bound to the queue, which is + # exactly what happens when the interceptor dies — and it was set on every + # rule regardless. So a service with fail_open *off*, whose whole point is + # that traffic stops rather than passes when filtering stops, went wide open + # the moment its binary crashed: the one case the setting exists for. The + # other half of the same policy is `FIREGEX_NFQUEUE_FAIL_OPEN`, which decides + # what the process does while it is alive; the two have to agree or the + # answer depends on how the filtering stopped. + queue = {"queue": { + "num": str(queue_num), + **({"flags": ["bypass"]} if srv.fail_open else {}), + }} + cmds.append(self._rule( + self.queue_output_chain(position), + self._match(ip, port, family, l4, "saddr", "sport") + + [ + self.COUNTER, + {"mangle": {"key": {"meta": {"key": "mark"}}, "value": QUEUE_MARK_OUTPUT}}, + queue, + ], + insert=True, + )) + cmds.append(self._rule( + self.queue_input_chain(position), + self._match(ip, port, family, l4, "daddr", "dport") + + [ + self.COUNTER, + {"mangle": {"key": {"meta": {"key": "mark"}}, "value": QUEUE_MARK_INPUT}}, + queue, + ], + insert=True, + )) + if not is_ip_parse(ip): + # A connection this host opens is not matched by interface, for the reason + # the proxy layer's output-hook rules give — and what these catch is one + # to an address, rewritten. + continue + # This host dialling one of these addresses when something rewrote it on the + # way out: a port Docker publishes, which is how a CTF service is usually + # reached. Recognised by where the connection was *going*, which conntrack + # keeps, because by now the packet itself says the container's address. + # Without these the request and the answer both went round every queue here, + # and a payload the filter refuses from anywhere else reached the service + # from this host untouched. + original = self._rewritten_to(ip, port, family, l4) + note = f"{ADDRESS_COMMENT}{l4} {int(port)} {ip}" + cmds.append(self._rule( + self.queue_local_request_chain(position), + original + self._ct_direction("original") + [ + self.COUNTER, + {"mangle": {"key": {"meta": {"key": "mark"}}, "value": QUEUE_MARK_INPUT}}, + queue, + ], + insert=True, comment=note, + )) + cmds.append(self._rule( + self.queue_local_reply_chain(position), + original + self._ct_direction("reply") + [ + {"mangle": {"key": {"meta": {"key": "mark"}}, "value": QUEUE_MARK_OUTPUT}}, + queue, + ], + insert=True, comment=note, + )) + self.cmd(*cmds) + + @staticmethod + def _rewritten_to(ip: str, port: int, family: str, l4: str) -> list: + """A connection that was headed for `ip:port` and had its destination rewritten.""" + return [ + {"match": {"op": "in", "left": {"ct": {"key": "status"}}, "right": "dnat"}}, + {"match": {"op": "==", "left": {"meta": {"key": "l4proto"}}, "right": l4}}, + {"match": {"op": "==", + "left": {"ct": {"key": f"{family} daddr", "dir": "original"}}, + "right": nftables_int_to_json(ip)}}, + {"match": {"op": "==", + "left": {"ct": {"key": "proto-dst", "dir": "original"}}, + "right": int(port)}}, + ] + + @staticmethod + def _ct_direction(direction: str) -> list: + return [{"match": {"op": "==", "left": {"ct": {"key": "direction"}}, + "right": direction}}] + + def _add_external(self, srv: Service, addr: Address, ip: str, port: int, family: str, + l4: str): + """Put somebody else's proxy in the path, invisibly. + + A stateless rewrite rather than NAT: the destination is changed on the way in + and changed back on the way out, so the operator's proxy sees the connection + arrive and the client sees answers from the address it dialled. Conntrack is + not involved, which is what lets this coexist with a service that is also being + proxied or queued elsewhere. + + The proxy endpoint belongs to the address, not to the service: the return rule + recognises traffic by the proxy's own address and port and rewrites the source + port back, so two addresses sharing one proxy endpoint could not be told apart + on the way out. Every address gets its own. + """ + if not is_ip_parse(ip): + raise Exception("the external transport requires a concrete IP address, not an interface") + if not addr.proxy_port: + raise Exception("the external transport needs the port your proxy listens on") + # One host, not a network: a hand-off points at the single address the + # operator's proxy is listening on, so any prefix a normaliser added is dropped. + proxy_ip = hijack_endpoint(addr.proxy_ip, ip) + inbound = self._match(ip, port, family, l4, "daddr", "dport") + [ + self.COUNTER, + {"mangle": {"key": {"payload": {"protocol": l4, "field": "dport"}}, + "value": int(addr.proxy_port)}}, + {"mangle": {"key": {"payload": {"protocol": family, "field": "daddr"}}, + "value": addr_parse(one_address(proxy_ip))}}, + ] + self.cmd( + self._rule(self.hijack_in_chain, inbound, insert=True), + self._rule(self.hijack_local_chain, inbound, insert=True), + self._rule( + self.hijack_out_chain, + self._match(proxy_ip, addr.proxy_port, ip_family(proxy_ip), l4, "saddr", "sport") + + [ + {"mangle": {"key": {"payload": {"protocol": l4, "field": "sport"}}, + "value": int(port)}}, + {"mangle": {"key": {"payload": {"protocol": ip_family(proxy_ip), "field": "saddr"}}, + "value": addr_parse(one_address(ip))}}, + ], + insert=True, + ), + ) + + def _add_proxy(self, srv: Service, ip: str, port: int, family: str, l4: str, proxy_port: int): + if proxy_port is None: + raise Exception("the proxy transport needs a listening port") + # Conntrack rewrites the destination and remembers the original, which the + # engine reads back with SO_ORIGINAL_DST. It is also what tells the return + # rule which packets are the engine answering a client. + # + # Both hooks: prerouting catches what arrives from outside, output catches + # what this host generates, which is the only way a client on this host reaches + # a service on it. Conntrack NATs a connection once, so a rule in each is not a + # double translation. + is_iface = not is_ip_parse(ip) + # **The match is the interface, and nothing narrower.** Pinning the destination to + # the addresses the interface carries was tried, to stop a service protecting + # `docker0:443` from also taking every container's outbound HTTPS — which it does, + # measured, terminating it with the service's certificate. It was reverted: an + # interface name exists precisely for the case where **the address is not the + # operator's to know**, which includes firegex sitting in front of a service on + # another machine reached through that link, and pinning to this host's own + # addresses quietly removes that. Protecting an interface protects what crosses + # it; naming a link that carries other people's traffic is a choice, and the + # answer to not wanting it is a narrower address, not a narrower rule. + redirect = ( + self._not_ours() + + self._match(ip, port, family, l4, "daddr", "dport") + + [self.COUNTER, {"redirect": {"port": int(proxy_port)}}] + ) + # Nothing per service on the way back. The engine's answers are brought home by + # the conntrack mark on the connections it opened (`init()`), whatever address + # and port they come from. A rule here used to mark everything leaving the + # service's address and port as well, which added nothing for the engine and + # caught the connections that were open before the service started: nothing + # redirected those, so their answers belonged to the client, and a client on + # another host stopped hearing back the moment protection began. + cmds = [self._rule(self.nat_chain, redirect)] + if not is_iface: + cmds.append(self._rule(self.nat_output_chain, redirect)) + else: + # An interface address has to be caught in the output hook too, and cannot be + # caught the same way. `iifname` means nothing for a packet this host is + # generating, and `oifname` is the wrong question twice over: `oifname eth0 + # dport 80` would mean *we* are calling somebody else's port 80, which is the + # host's own outbound traffic and none of firegex's business — and a + # connection from this host to one of its **own** addresses is routed through + # `lo` anyway, so it would never match. + # + # So the destination is matched instead, against the addresses that interface + # carries. That is exactly "this host calling a service it protects", and it + # is what an operator means when they protect `lo` and then curl `127.0.0.1`. + # Without it the service was intercepted for the outside world and silently + # not for anything on the box, which is the shape of an interception canary + # failing and is how this was reported. + # + # The addresses are resolved now rather than stored, and that is the cost: + # one the interface gains later is not covered *for traffic this host + # generates* until the service restarts. Inbound keeps matching `iifname`, so + # it follows the interface as it always did. `udp_relay_host` pays the same + # price for the same reason. + # + # The rule carries the interface's name as a comment because nothing else in + # it does. `delete()` finds a rule by what it matches, and these match an + # address; without the comment they would be left behind on every stop — a + # redirect to a port nobody is listening on any more. + for addr in interface_addresses(ip): + cmds.append(self._rule( + self.nat_output_chain, + self._not_ours() + + self._match(addr, port, ip_family(addr), l4, "daddr", "dport") + + [self.COUNTER, {"redirect": {"port": int(proxy_port)}}], + comment=f"{IFACE_COMMENT}{ip}", + )) + self.cmd(*cmds) + + # --- reading back --------------------------------------------------------- + + def get(self) -> list[InstalledRule]: + res = [] + chains = [ + *[self.queue_input_chain(p) for p in range(MAX_CHAIN_POSITIONS)], + *[self.queue_output_chain(p) for p in range(MAX_CHAIN_POSITIONS)], + *[self.queue_local_request_chain(p) for p in range(MAX_CHAIN_POSITIONS)], + *[self.queue_local_reply_chain(p) for p in range(MAX_CHAIN_POSITIONS)], + self.hijack_in_chain, + self.hijack_local_chain, + self.hijack_out_chain, + self.divert_chain, + self.nat_chain, + self.nat_output_chain, + self.route_chain, + ] + for rule in self.list_rules(tables=[self.table_name], chains=chains, family="inet"): + try: + expr = rule["expr"] + note = str(rule.get("comment") or "") + counter = next( + (e["counter"] for e in expr if isinstance(e, dict) and "counter" in e), + {}, + ) + if note.startswith(ADDRESS_COMMENT): + proto, port, ip_int = note[len(ADDRESS_COMMENT):].split(" ", 2) + res.append(InstalledRule( + chain=rule["chain"], handle=int(rule["handle"]), proto=proto, + port=int(port), ip_int=ip_int, + packets=counter.get("packets", 0), bytes_=counter.get("bytes", 0), + )) + continue + # An intercept rule starts with the self-mark exclusion; every other + # per-service rule starts with the address. The two rules init() + # installs match on `socket` or on `ct`, and fall out here. + if "meta" in expr[0].get("match", {}).get("left", {}): + if expr[0].get("match", {}).get("left", {}).get("meta", {}).get("key") == "mark": + expr = expr[1:] + match_left = expr[0].get("match", {}).get("left", {}) + if "payload" in match_left: + right = expr[0]["match"]["right"] + if isinstance(right, str): + ip_int = str(ip_parse(right)) + else: + ip_int = f'{right["prefix"]["addr"]}/{right["prefix"]["len"]}' + elif "meta" in match_left and match_left["meta"].get("key") in ("iifname", "oifname"): + ip_int = str(expr[0]["match"]["right"]) + else: + continue + # An output-hook rule installed for an interface matches one of that + # interface's addresses, so what it matches does not say whose it is. + # The comment does, and reading it back here is what lets the rest of + # this file go on treating the rule as the interface's — including + # `delete()`, which would otherwise never find it. + if note.startswith(IFACE_COMMENT): + ip_int = note[len(IFACE_COMMENT):] + res.append( + InstalledRule( + chain=rule["chain"], + handle=int(rule["handle"]), + proto=expr[1]["match"]["left"]["payload"]["protocol"], + port=expr[1]["match"]["right"], + ip_int=ip_int, + packets=counter.get("packets", 0), + bytes_=counter.get("bytes", 0), + ) + ) + except (KeyError, TypeError, IndexError, ValueError): + continue # a rule of a shape we did not write; not ours to touch + return res + + def traffic(self, srv: Service, addresses: list[Address] | None = None) -> dict: + """How much has arrived for this service, counted by the kernel. + + Only the *inbound* rules, and only one of them per address. The nfqueue layer + installs a rule pair per filter, all seeing the same packets, so summing them + would multiply the answer by the length of the chain; and the return-path rules + count traffic going the other way, which is a different question. Position zero + is the first filter in the chain, so it sees everything. + + Packets, never connections: this is what the wire carried. A block is a refused + connection, so the two are reported side by side and never divided. + """ + # Only the layers whose rules the kernel evaluates for **every** packet. + # + # The proxy layer's rule lives in a `nat` chain, and conntrack translates a + # connection once: every packet after the first is handled from the conntrack + # entry without the rule being walked again. Its counter therefore counts new + # connections, not packets — a perfectly good number, and not the one the label + # says. The engine already reports connections properly, so this reports nothing + # there rather than a packet count that is off by the length of every flow. + inbound = { + # Two chains, but never both for one packet: from elsewhere a request passes + # prerouting, and from this host to a rewritten address it passes only output. + TRANSPORT.NFQUEUE: {self.queue_input_chain(0), self.queue_local_request_chain(0)}, + TRANSPORT.EXTERNAL: {self.hijack_in_chain, self.hijack_local_chain}, + }.get(srv.transport, set()) + targets = addresses if addresses is not None else srv.addresses + packets = total_bytes = 0 + for rule in self.get(): + if rule.chain in inbound and rule.matches_any(srv, targets): + packets += rule.packets + total_bytes += rule.bytes + return {"packets": packets, "bytes": total_bytes} + + def delete(self, srv: Service, addresses: list[Address] | None = None, + keep_guards: bool = False) -> set[tuple[str, int]]: + """Take back the rules for these addresses, or for all of the service's. + + Passing a subset is how one address is removed from a running service: the + others keep their rules, and the datapath keeps their connections. + + `keep_guards` leaves the engine's ports guarded and hands them back instead, for + `release_guards` once the engine has gone: one draining the connections it still + carries is listening on them until then. + """ + targets = addresses if addresses is not None else srv.addresses + cmds = [ + { + "delete": { + "rule": { + "family": "inet", + "table": self.table_name, + "chain": rule.chain, + "handle": rule.handle, + } + } + } + for rule in self.get() + if rule.matches_any(srv, targets) + ] + if cmds: + self.cmd(*cmds) + # Only when the whole service comes down. An address taken off a running service + # leaves its relay bound in the engine — relays live as long as the engine does — + # so its port stays guarded until the engine goes; released earlier, the relay + # would be reachable directly again with nothing steering traffic at it. + if addresses is None: + guarded = self._guarded.pop(str(srv.id), set()) + if keep_guards: + return guarded + self.release_guards(guarded) + return set() + + def release_guards(self, ports: set[tuple[str, int]]) -> None: + """Stop guarding ports an engine no longer listens on. + + Except one some running service is guarding now: an engine that drained for + minutes gave its port back, and the kernel is free to have handed it to another. + """ + owned = set().union(*self._guarded.values()) if self._guarded else set() + for l4, port in ports: + if (l4, port) not in owned: + self._unguard(l4, port) + + def _element(self, verb: str, l4: str, port: int) -> dict: + return { + verb: { + "element": { + "family": "inet", + "table": self.table_name, + "name": self.datapath_set, + "elem": [{"concat": [l4, int(port)]}], + } + } + } + + #: Which engine ports each running service has had guarded, so that all of them are + #: released when it stops — including a relay whose address was removed while it ran. + #: Kept here rather than read back from the rules for exactly that relay: nothing + #: points at it any more, and an element left in the set would drop traffic for + #: whatever process is given that port next. + _guarded: dict[str, set[tuple[str, int]]] = {} + + def _guard(self, srv: Service, l4: str, port: int) -> None: + """Refuse whatever reaches `port` without having been redirected there.""" + self.cmd(self._element("add", l4, port)) + self._guarded.setdefault(str(srv.id), set()).add((l4, int(port))) + + def _unguard(self, l4: str, port: int) -> None: + # Separately, and tolerated: an element that is not there makes nft refuse the + # command, and the rules it belonged to are already gone either way. + self.raw_cmd(self._element("delete", l4, port)) diff --git a/backend/modules/services/pyapi.py b/backend/modules/services/pyapi.py new file mode 100644 index 0000000..ff55735 --- /dev/null +++ b/backend/modules/services/pyapi.py @@ -0,0 +1,101 @@ +"""What the filter library offers, read out of the library itself. + +The editor needs to know which models exist, what each one can be asked for, and which +of those can be written to. All of that is already stated once — in the classes the +library exposes and in `type_annotations_associations`, the table that decides when a +filter is called — so it is introspected rather than described a second time. + +A hand-written list would have been quicker and would be wrong within a release: a +property added to `HttpRequest` would be missing from the hints, and one renamed would +be offered under a name that no longer resolves. Autocompletion that lies is worse than +none, for the same reason a regex tester that disagrees with the engine is. +""" + +import inspect + +#: Members every object has and nobody wants offered. +_SKIP = {"count", "index"} + + +def _clean(doc: str | None) -> str: + return inspect.cleandoc(doc or "").strip() + + +def _members(cls) -> list[dict]: + """The public properties of a model, with what they are for and whether they change. + + 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)): + if name.startswith("_") or name in _SKIP: + continue + out.append({ + "name": name, + "doc": _clean(member.__doc__), + "writable": member.fset is not None, + }) + for name, member in inspect.getmembers(cls, inspect.isfunction): + if name.startswith("_") or name in _SKIP: + continue + try: + signature = f"{name}{inspect.signature(member)}" + except (TypeError, ValueError): + signature = f"{name}(...)" + out.append({"name": name, "doc": _clean(member.__doc__), + "writable": False, "signature": signature}) + return sorted(out, key=lambda m: m["name"]) + + +def describe() -> dict: + """Everything the editor is allowed to suggest, built from the live library.""" + from firegex.pyfilters import ( + ACCEPT, DROP, REJECT, ExceptionAction, FullStreamAction, + ) + from firegex.pyfilters.models import type_annotations_associations + + models: dict[str, dict] = {} + for proto, types in type_annotations_associations.items(): + for cls in types: + entry = models.setdefault(cls.__name__, { + "name": cls.__name__, + "doc": _clean(cls.__doc__), + "members": _members(cls), + "protocols": [], + }) + entry["protocols"].append(proto) + for entry in models.values(): + entry["protocols"].sort() + + verdicts = [ + {"name": "ACCEPT", "value": ACCEPT.value, + "doc": "Forward this chunk. Returning None means the same thing."}, + {"name": "REJECT", "value": REJECT.value, + "doc": "Refuse the connection. Everything still in the stream goes with it."}, + {"name": "DROP", "value": DROP.value, + "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 = [ + {"name": "FGEX_STREAM_MAX_SIZE", + "doc": "Bytes of one stream a model may accumulate before " + "FGEX_FULL_STREAM_ACTION decides what to do."}, + {"name": "FGEX_FULL_STREAM_ACTION", + "doc": "What happens when a stream reaches that size.", + "values": [f"FullStreamAction.{name}" for name in FullStreamAction.__members__]}, + {"name": "FGEX_INVALID_ENCODING_ACTION", + "doc": "What happens when a parser cannot read the traffic — a reply that is " + "not valid HTTP, for instance.", + "values": [f"ExceptionAction.{name}" for name in ExceptionAction.__members__]}, + ] + + return { + "models": sorted(models.values(), key=lambda m: m["name"]), + "verdicts": verdicts, + "settings": settings, + } diff --git a/backend/modules/services/pyworker.py b/backend/modules/services/pyworker.py new file mode 100644 index 0000000..74c4932 --- /dev/null +++ b/backend/modules/services/pyworker.py @@ -0,0 +1,475 @@ +#!/usr/bin/env python3 +"""Runs a user's Python filters, one process away from the datapath. + +Started by the engine, never by the backend: the engine is the one that has to kill it +when it stops answering, and something else owning its lifetime would defeat the point +of moving the code out of process at all. + +The filters are run through the **real** `firegex.pyfilters` library — the same one the +NFQUEUE binary embeds, the same one `fgex pyfilters` simulates against, the same one the +documentation describes. An earlier version of this file defined its own look-alike +`@pyfilter` that took `(data, direction)`, and that was a mistake of exactly the kind a +regex engine written twice would be: a filter written from the docs loaded on one +transport and failed on the other, and the docs were right about neither. + +Each connection gets its own set of module globals, which is what the API promises — +code at module level runs once per stream and the same globals are reused for every +packet of that stream. That is why every frame carries a connection id, and why the +engine sends a close frame: without one, a service would accumulate one set of globals +per connection for as long as it ran. + +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] + 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, +and "this filter blocked 900 connections" is not an answer when you are trying to find +out which of them is doing it. + +Neither an open nor a close frame is answered. A close has no verdict to give about a +connection that is already over; an open carries that connection's metadata — the +addresses and ports, as JSON — which the filter reads and cannot change. + +Nothing below the application layer crosses in the other direction. There used to be a +`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, 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 +import re +import struct +import sys +import time +import traceback + +VERDICT_ACCEPT = 0 +VERDICT_REJECT = 1 +# 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. +READY = 0xFF + +#: Written to stderr when the user's code raises, beside the traceback itself. +#: +#: The traceback says a filter broke and names its file and line, which is what an +#: operator needs to fix it. It does not say what happened to the *connection*, and that +#: is the part they have to know: it went through unfiltered. The engine cannot report it +#: because it never learns of it — the exception is caught here and answered with ACCEPT, +#: so nothing crosses the frame boundary to tell it apart from a filter that simply +#: agreed. So it is marked on the diagnostics channel instead, and `Transport._pump_stderr` +#: turns the marker back into the one sentence `_on_exception` writes for either network +#: layer. A wire token like `BLOCKED` or `[fatal]`, and spelled the same in both places. +EXCEPTION_MARK = "[exception] [filter]" + +#: How long between two tracebacks out of the same worker. +#: +#: A filter that throws usually throws on every packet, and a traceback is a dozen lines. +#: Unthrottled, one broken filter writes thousands of lines a minute into a log that +#: holds a few hundred — so the flood does not merely repeat itself, it pushes out the +#: first traceback, which was the one worth reading, along with everything that was in +#: the log before the filter broke. +#: +#: The mark is *not* throttled with it: it is one short token the engine turns into a +#: single rate-limited sentence on the other side, and it is what carries the count. So +#: the operator still learns how often this is happening, and gets one traceback rather +#: than the same one several hundred times. +TRACEBACK_QUIET = 30 + +KIND_C2S = 0 +KIND_S2C = 1 +KIND_CLOSE = 2 +KIND_OPEN = 3 + + +def _build_filter_code(path: str, enabled: list[str] | None = None) -> str: + """Turn the user's file into the module the library expects to execute. + + Identical in shape to what `firegex.pyfilters.proxysim` builds, because it has to be: + the library decides which filters exist and when each one is callable from these + trailing lines, and a different preamble would mean a different set of filters. + + `enabled` is the subset the operator left switched on; `None` means all of them. It + is intersected with what the file actually defines rather than trusted, so a stale + selection naming a function that has since been deleted cannot stop the file from + loading — the library would refuse the whole module over one missing name. + + No protocol is written in. The file shows which one it speaks by what its filters + ask for, so there is nothing here that could disagree with the code — an earlier + version passed one in from the outside, and a filter asking for an `HttpRequest` + under a file declared `tcp` was refused with an error about its own annotation. + """ + from firegex.pyfilters.internals import get_filter_names + + with open(path) as f: + source = f.read() + + names = get_filter_names(source) + if enabled is not None: + wanted = set(enabled) + names = [name for name in names if name in wanted] + return ( + source + + "\n\n__firegex_pyfilter_enabled = [" + + ", ".join(repr(name) for name in names) + + "]\n" + "import firegex.pyfilters.internals\n" + "firegex.pyfilters.internals.compile(globals())\n" + ) + + +def _where_in_source(source: str, exc: BaseException, filename: str) -> dict: + """Point at the line of the operator's own file, when there is one to point at. + + Three ways, in order of how directly they know: + + * a `SyntaxError` carries its own position, and the code never ran; + * a traceback frame belonging to the file — the module body raised while loading. + `` counts as theirs: the library execs the source to find its filters + before the module is built under its real name, and everything the library itself + runs from has a real path, so an anonymous frame is the user's; + * neither, which is the interesting case. The library refuses a filter by *name* + ("Invalid type annotation X for function Y", "Parameter 'p' of Z has none"), from + frames that are all its own, so any identifier in the message is looked back up + among the file's own `def`s. Without this the operator is told which function is + wrong and left to go and find it. + """ + lines = source.splitlines() + + def at(line: int) -> dict: + return {"line": line, "column": 0, + "text": lines[line - 1].rstrip() if 0 < line <= len(lines) else ""} + + if isinstance(exc, SyntaxError) and exc.lineno: + return {"line": exc.lineno, "column": exc.offset or 0, + "text": (exc.text or "").rstrip()} + + mine = {filename, ""} + frames = [f for f in traceback.extract_tb(exc.__traceback__) if f.filename in mine] + if frames: + return at(frames[-1].lineno or 0) + + # The *last* definition of a name, because that is the one Python is left holding: + # a file that defines `check` twice runs the second, and pointing at the first would + # send the operator to a function that is not the one being complained about. + defs = {} + for number, text in enumerate(lines, start=1): + found = re.match(r"\s*(?:async\s+)?def\s+(\w+)\s*\(", text) + if found: + defs[found.group(1)] = number + for word in re.findall(r"\w+", str(exc)): + if word in defs: + return at(defs[word]) + return {"line": 0, "column": 0, "text": ""} + + +#: The rename this package went through, and the one message worth saying about it. +#: +#: `firegex.nfproxy` was the old spelling of `firegex.pyfilters`, aliased through 4.x and +#: removed in 5.0.0. `from firegex.nfproxy import pyfilter` is the first line of every +#: filter written before that, and the error Python raises for it — "No module named +#: 'firegex.nfproxy'" — is accurate and tells the operator nothing about what to write +#: instead. They meet it here, in the editor, with the line already marked; the +#: alternative is meeting it at the first start after an upgrade, which at a competition +#: is the start of a round. +RENAMED_IMPORT = "firegex.nfproxy" +RENAMED_TO = "firegex.pyfilters" + + +def _explain(exc: BaseException, message: str) -> str: + """Add what to do about it, where there is something to say.""" + if isinstance(exc, ImportError) and RENAMED_IMPORT in (str(exc) or ""): + return (f"{message}. `{RENAMED_IMPORT}` was renamed to `{RENAMED_TO}` in 5.0.0 " + f"and the old name no longer resolves: change the import at the top of " + f"this file, and the rest of the filter works unchanged") + return message + + +def check(path: str) -> dict: + """Would this file load, and what does it define? + + Run by the backend before a save is accepted, in a process of its own — which is + both why the answer can be trusted and why a filter with a `while True:` at module + level cannot take the interface down with it. It is the *same* build and the same + compile the datapath performs, so what is accepted here is what will run. + """ + from firegex.pyfilters.internals import get_filters_info + + try: + with open(path) as f: + source = f.read() + except OSError as e: + return {"ok": False, "error": {"type": "OSError", "message": str(e), + "line": 0, "column": 0, "text": "", + "traceback": ""}} + try: + infos = get_filters_info(source) if source.strip() else [] + # Not just "does it parse": the whole module, exactly as the worker builds it, + # so an error the library only raises at compile time is raised here too. + Filters(_build_filter_code(path), path) + except BaseException as e: # noqa: BLE001 - anything the user's code can raise + return { + "ok": False, + "error": { + "type": type(e).__name__, + # A SyntaxError's text carries "(, line N)", which names a file + # the operator never wrote and repeats a position reported separately. + "message": _explain(e, re.sub( + r"\s*\((?:<[^>]*>|[^()]*\.py), line \d+\)$", "", + str(e) or type(e).__name__)), + **_where_in_source(source, e, path), + "traceback": traceback.format_exc(), + }, + } + return { + "ok": True, + "proto": infos[0].proto if infos else "tcp", + "filters": [info.name for info in infos], + # Which models the file actually asks for. The protocol alone does not say: a + # `tcp` file wanting only a RawPacket runs on datagrams too, while one wanting a + # TCP stream cannot — and the difference decides whether attaching it to a UDP + # service gives you a filter or a filter that never fires. + "models": sorted({ + annotation.__name__ + for info in infos + for annotation in info.params + }), + } + + +class Filters: + """The compiled filter module, plus one set of globals per live connection.""" + + def __init__(self, code: str, filename: str = ""): + # Compiled once, under the operator's own file name: every connection reuses the + # code object instead of re-parsing the source, and a traceback names the file + # they wrote rather than ``, with line numbers that match what they see + # in the editor. The preamble is appended *after* their source precisely so + # those numbers line up. + self.code = compile(code, filename, "exec") + self.contexts: dict[int, dict] = {} + #: What the engine said about each live connection, kept so every chunk of it + #: carries the same addresses without them being repeated on the wire. + self.endpoints: dict[int, dict] = {} + # Run once here so a file that cannot load is a startup failure, which the + # engine turns into a refused ruleset rather than a service that silently stops + # filtering. + probe: dict = {} + exec(self.code, probe, probe) # noqa: S102 - running the user's filter is the feature + + def context(self, connection: int) -> dict: + ctx = self.contexts.get(connection) + if ctx is None: + ctx = {} + exec(self.code, ctx, ctx) # noqa: S102 + # No open frame came for it, so this worker is taking over a connection + # already under way — new code, or one restarted after the last died — and + # its first chunk can be the middle of a message. The library then carries + # what it cannot read instead of judging half a message; see + # `_met_mid_message` in the HTTP model. + if connection not in self.endpoints: + ctx["__firegex_joined_late"] = True + self.contexts[connection] = ctx + return ctx + + def opened(self, connection: int, payload: bytes) -> None: + try: + meta = json.loads(payload.decode()) + except (ValueError, UnicodeDecodeError): + return + if isinstance(meta, dict): + self.endpoints[connection] = meta + + def close(self, connection: int) -> None: + self.contexts.pop(connection, None) + self.endpoints.pop(connection, None) + + 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) + # Missing endpoints are empty strings and zeros rather than an error: the engine + # sends them best-effort, and a filter losing the client's address is a worse + # outcome than a filter that sees "" for it, but neither is a reason to stop + # filtering the traffic. + meta = self.endpoints.get(connection, {}) + client_ip = meta.get("client_ip", "") + client_port = int(meta.get("client_port", 0)) + server_ip = meta.get("server_ip", "") + server_port = int(meta.get("server_port", 0)) + ctx["__firegex_packet_info"] = { + "data": data, + "is_input": is_input, + "is_ipv6": bool(meta.get("is_ipv6", False)), + # Said by the engine rather than assumed: this worker serves TCP connections, + # UDP flows and QUIC streams alike. Two words rather than one, because they + # answer different questions — `is_tcp` is what is on the wire, `l4` is what + # the library's stream and HTTP models are built on, and QUIC answers those + # two differently. Absent, `l4` derives from the other: the NFQUEUE engines + # carry TCP and UDP and have nothing else to say. + "is_tcp": bool(meta.get("is_tcp", True)), + "l4": meta.get("l4"), + "src_ip": client_ip if is_input else server_ip, + "src_port": client_port if is_input else server_port, + "dst_ip": server_ip if is_input else client_ip, + "dst_port": server_port if is_input else client_port, + } + try: + exec( # noqa: S102 + "firegex.pyfilters.internals.handle_packet(globals())", ctx, ctx + ) + finally: + ctx.pop("__firegex_packet_info", None) + + result = ctx.pop("__firegex_pyfilter_result", None) + if not isinstance(result, dict): + # 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, "" + + 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, "" + if action == REJECT.value: + 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 + print(f"[warn] [pyworker] unknown action {action!r}", file=sys.stderr) + return VERDICT_ACCEPT, "" + + +def _read_exactly(stream, count: int) -> bytes | None: + buf = b"" + while len(buf) < count: + chunk = stream.read(count - len(buf)) + if not chunk: + return None + buf += chunk + return buf + + +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(name) + 2) + bytes([verdict, len(name)]) + name) + stream.flush() + + +def main() -> int: + if len(sys.argv) < 2: + print("usage: pyworker.py [* | =fn,fn] | --check ", + file=sys.stderr) + return 2 + if sys.argv[1] == "--check": + # One JSON object on stdout and nothing else, so the caller never has to guess + # which part of the output was the answer. + if len(sys.argv) < 3: + print("usage: pyworker.py --check ", file=sys.stderr) + return 2 + # Checking a file means *running* its module body, and `print()` is the most + # natural thing in the world to reach for while writing a filter. On stdout it + # would land in the middle of the answer: the backend parses this output as JSON, + # a stray line makes it unparseable, and the operator is told `the check produced + # no answer` about a file whose only sin was printing while it loaded. + # + # So the real stdout is put aside before the user's code can reach it, and + # anything the file prints goes to the diagnostics channel — the same swap the + # worker itself makes below, for the same reason. + answer = sys.stdout + sys.stdout = sys.stderr + try: + result = check(sys.argv[2]) + finally: + sys.stdout = answer + answer.write(json.dumps(result)) + answer.flush() + return 0 + # `*` (or nothing at all) means every function the file defines, which is what a + # hand-run worker wants; `=a,b` means exactly those, and a bare `=` means none. + # A marker rather than a bare list because "all" and "none" are both real answers + # and an empty string cannot stand for both. + enabled = None + if len(sys.argv) > 2 and sys.argv[2].startswith("="): + enabled = [name.strip() for name in sys.argv[2][1:].split(",") if name.strip()] + + try: + filters = Filters(_build_filter_code(sys.argv[1], enabled), sys.argv[1]) + except Exception: + traceback.print_exc() + return 1 + + stdin = sys.stdin.buffer + stdout = sys.stdout.buffer + + # The user's code shares this process, and `print()` is the most natural thing in + # the world to reach for while debugging a filter. On stdout it would land in the + # middle of a length-prefixed frame: the engine reads the text as a frame header, + # sees an absurd length, and kills the worker — so the filter would be restarted on + # every packet and the operator would be told nothing except that it stopped + # working. Their output belongs on the diagnostics channel, which the backend now + # captures into the service log, so it goes there and stdout stays the protocol. + sys.stdout = sys.stderr + + stdout.write(struct.pack(">I", 2) + bytes([READY, 0])) + stdout.flush() + + # Monotonic rather than wall clock: the machine's clock moving is not a reason to + # start printing tracebacks again, or to stop. + last_traceback = -TRACEBACK_QUIET + + while True: + header = _read_exactly(stdin, 4) + if header is None: + return 0 # the engine went away; so do we + (length,) = struct.unpack(">I", header) + body = _read_exactly(stdin, length) + if body is None or len(body) < 9: + return 0 + kind = body[0] + (connection,) = struct.unpack(">Q", body[1:9]) + payload = body[9:] + + if kind == KIND_OPEN: + filters.opened(connection, payload) + continue # deliberately unanswered + + if kind == KIND_CLOSE: + filters.close(connection) + continue # deliberately unanswered + + try: + 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 + # chunk is accepted, which is the fail-open half — and the mark is what makes + # that visible rather than silent. + now = time.monotonic() + if now - last_traceback >= TRACEBACK_QUIET: + last_traceback = now + traceback.print_exc() + print(EXCEPTION_MARK, file=sys.stderr, flush=True) + verdict, matched = VERDICT_ACCEPT, "" + _write(stdout, verdict, matched) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/modules/services/stats.py b/backend/modules/services/stats.py new file mode 100644 index 0000000..fd37ed0 --- /dev/null +++ b/backend/modules/services/stats.py @@ -0,0 +1,254 @@ +"""What each rule has actually refused, and when. + +The service list carries a running total per filter, per pattern and per function, which +answers "is this rule doing anything at all". It does not answer the question an operator +asks during a competition — *when* did this start, and which rule is carrying it right +now — because a cumulative counter looks the same whether a filter blocked a thousand +connections an hour ago or is blocking them this minute. + +So blocks are also counted into fixed time buckets, **per rule** rather than per filter. +Keeping the finer key is what lets every number on the page answer the same question: ask +for the last fifteen minutes and the chart, the totals and the shares all mean the last +fifteen minutes. Storing only per filter would have left the chart honouring the range +and the table beside it quietly reporting all of time. + +Two properties keep that from becoming a liability of its own, and they are the same two +the live log has: + +* **Bounded.** Only the last `HISTORY_BUCKETS` are kept, and older rows are pruned as new + ones are written. A service that has been refusing traffic for a week stores the same + amount as one that started an hour ago. +* **Coalesced.** A block bumps a counter in memory, and memory is written out at most + every `FLUSH_INTERVAL` seconds. A service refusing thousands of connections a minute + must not be able to take the backend down through its own bookkeeping — which is + exactly what one row per block would do. + +There is deliberately no background task. Flushing happens on the next write once the +interval has passed, and always on a read, so what the interface shows is current without +anything having to be started, cancelled or waited for at shutdown. +""" + +import math +import time + +#: The finest resolution anything is stored at. Every range the interface offers is an +#: aggregate of these, so a wider window costs no more rows — only wider bars. +BUCKET_SECONDS = 60 + +#: How far back the history goes. Two days at a minute each: long enough to cover a +#: competition and the night before it, small enough to stay a few thousand rows. +HISTORY_BUCKETS = 48 * 60 + +#: How long counts may sit in memory before being written out. +FLUSH_INTERVAL = 5 + +_pending: dict[tuple[str, str, str, int], int] = {} +_last_flush = 0.0 + + +def bucket_of(when: float | None = None) -> int: + return int((when if when is not None else time.time()) // BUCKET_SECONDS) * BUCKET_SECONDS + + +def retention_start() -> int: + """The oldest instant anything is still known about.""" + return bucket_of() - (HISTORY_BUCKETS - 1) * BUCKET_SECONDS + + +def record(service_id: str, filter_id: str, rule_id: str, when: float | None = None) -> None: + """Count one refusal. Cheap on purpose: this runs on the block path. + + `rule_id` is whatever the datapath reported — a pattern, or `/`, + or the filter itself. `filter_id` is carried alongside it so the chart can group by + filter without a join back to a table the rule may have been deleted from. + """ + key = (service_id, filter_id, rule_id, bucket_of(when)) + _pending[key] = _pending.get(key, 0) + 1 + + +def flush(db, force: bool = False) -> None: + """Write the counts out, and drop whatever has aged out of the window.""" + global _last_flush + now = time.time() + if not force and now - _last_flush < FLUSH_INTERVAL: + return + _last_flush = now + if not _pending: + return + batch = list(_pending.items()) + _pending.clear() + queries = [ + ( + "INSERT INTO block_history (service_id, filter_id, rule_id, bucket, blocked) " + "VALUES (?, ?, ?, ?, ?) ON CONFLICT(rule_id, bucket) " + "DO UPDATE SET blocked = blocked + excluded.blocked;", + service_id, + filter_id, + rule_id, + bucket, + count, + ) + for (service_id, filter_id, rule_id, bucket), count in batch + ] + # Pruning rides along with the write rather than being scheduled: there is no moment + # at which rows can accumulate without something also having written one. + queries.append(("DELETE FROM block_history WHERE bucket < ?;", retention_start())) + db.queries(queries) + + +def forget(db, service_id: str) -> None: + """A deleted service's history has nothing left to be about.""" + for key in [k for k in _pending if k[0] == service_id]: + del _pending[key] + db.query("DELETE FROM block_history WHERE service_id = ?;", service_id) + + +def forget_filter(db, filter_id: str) -> None: + for key in [k for k in _pending if k[1] == filter_id]: + del _pending[key] + db.query("DELETE FROM block_history WHERE filter_id = ?;", filter_id) + + +def disown_rule(db, filter_id: str, rule_id: str) -> None: + """Take a rule's history away from it without taking it off the chart. + + Editing a pattern keeps the row — its place in the filter, and whatever else was set + on it — but the counts belonged to the text that used to be there, so the new text + must not inherit them: a per-pattern table that carried them over would credit blocks + to a matcher that never made them. + + What it must *not* do is delete them. Those connections were refused, by this filter, + in those minutes, and the chart is answering *when this service was under attack* — + a question a later typo fix has nothing to say about. Dropping the rows took a bite + out of the timeline every time a pattern was corrected, which on a filter holding one + pattern emptied it. So they are re-keyed to the filter itself, which is already the + token the datapath reports when nothing finer named itself: the chart and the + per-filter totals group by filter and do not change at all, while the per-pattern + table correctly credits the new text with nothing. + + The pending batch moves with them, or the old pattern's last few hits would be + flushed under the new one. + """ + if filter_id == rule_id: + return # already the filter's own; there is nothing to take it away from + for key in [k for k in _pending if k[2] == rule_id]: + service_id, held_by, _, bucket = key + moved = (service_id, held_by, filter_id, bucket) + _pending[moved] = _pending.get(moved, 0) + _pending.pop(key) + db.queries([ + ( + "INSERT INTO block_history (service_id, filter_id, rule_id, bucket, blocked) " + "SELECT service_id, filter_id, ?, bucket, blocked FROM block_history " + "WHERE rule_id = ? ON CONFLICT(rule_id, bucket) " + "DO UPDATE SET blocked = blocked + excluded.blocked;", + filter_id, + rule_id, + ), + ("DELETE FROM block_history WHERE rule_id = ?;", rule_id), + ]) + + +#: Most bars any chart may hold, whatever step is asked for. Past this a plot stops +#: being a shape and becomes a texture, and the browser is drawing a rectangle per bar. +MAX_STEPS = 400 + + +def bucket_width(start: int, end: int, target: int, want: int | None = None) -> int: + """How wide one bar has to be for a range to stay readable. + + Two ways to say it, and both end at the same place. Without `want`, the window is + shown in roughly `target` bars whatever it spans: twelve hours at one-minute + resolution is seven hundred bars in a few hundred pixels. With `want`, the operator + has picked the step themselves — five minutes is five minutes whether they are + looking at an hour or at a day, which is what makes two ranges comparable by eye. + + Either way the answer is a whole number of stored buckets, so widening is addition + and never interpolation, and either way it is floored at `MAX_STEPS` bars: a step + fine enough to melt the chart is refused by widening it, not by refusing the request, + and the width actually used is reported back so the interface can say what it drew. + """ + span = max(BUCKET_SECONDS, end - start + BUCKET_SECONDS) + least = math.ceil(span / BUCKET_SECONDS / MAX_STEPS) + if want: + steps = max(1, round(want / BUCKET_SECONDS)) + else: + steps = max(1, math.ceil(span / BUCKET_SECONDS / max(1, target))) + return BUCKET_SECONDS * max(1, steps, least) + + +def window(db, service_id: str, start: int, end: int, target: int = 60, + want: int | None = None) -> dict: + """What was refused between two instants, per filter, in even steps. + + Every step is present whether or not anything happened in it, because a chart with + the quiet minutes missing is a chart that lies about the shape of a burst. + """ + flush(db, force=True) + start, end = bucket_of(start), bucket_of(end) + if end < start: + start, end = end, start + width = bucket_width(start, end, target, want) + # Aligned to the step, so the same range always lands on the same edges and a + # refresh does not shuffle every bar sideways. + first = (start // width) * width + edges = list(range(first, end + width, width)) + index = {edge: i for i, edge in enumerate(edges)} + + series: dict[str, list[int]] = {} + rows = db.query( + "SELECT filter_id, bucket, SUM(blocked) blocked FROM block_history " + "WHERE service_id = ? AND bucket >= ? AND bucket <= ? " + "GROUP BY filter_id, bucket;", + service_id, + first, + end, + ) + for row in rows: + slot = index.get((int(row["bucket"]) // width) * width) + if slot is None: + continue + counts = series.setdefault(row["filter_id"], [0] * len(edges)) + counts[slot] += int(row["blocked"]) + return {"edges": edges, "width": width, "series": series} + + +def totals_by_filter(db, service_id: str, start: int, end: int) -> dict[str, int]: + """What each filter refused in the range, summed over the rules inside it. + + Not a lookup of the filter's own id: that token only appears when a whole filter + refused something without naming a rule. A regex block is reported by its pattern + and a Python one by `/`, so a filter's total is the sum of what + its rules did — which is also what makes the per-filter figures add up to the total. + """ + flush(db, force=True) + start, end = bucket_of(start), bucket_of(end) + if end < start: + start, end = end, start + return { + row["filter_id"]: int(row["blocked"]) + for row in db.query( + "SELECT filter_id, SUM(blocked) blocked FROM block_history " + "WHERE service_id = ? AND bucket >= ? AND bucket <= ? GROUP BY filter_id;", + service_id, + start, + end, + ) + } + + +def totals(db, service_id: str, start: int, end: int) -> dict[str, int]: + """What each rule refused in the range, keyed by whatever the datapath reported.""" + flush(db, force=True) + start, end = bucket_of(start), bucket_of(end) + if end < start: + start, end = end, start + return { + row["rule_id"]: int(row["blocked"]) + for row in db.query( + "SELECT rule_id, SUM(blocked) blocked FROM block_history " + "WHERE service_id = ? AND bucket >= ? AND bucket <= ? GROUP BY rule_id;", + service_id, + start, + end, + ) + } diff --git a/backend/modules/services/transports.py b/backend/modules/services/transports.py new file mode 100644 index 0000000..6840f1a --- /dev/null +++ b/backend/modules/services/transports.py @@ -0,0 +1,1568 @@ +"""The network layer: three ways to get hold of a service's traffic. + +A transport knows how to intercept, and nothing about what the filters do. A filter +chain knows what to do, and nothing about how the bytes arrived. The service model +combines them, and this file is the seam. + +The seam is not perfectly clean, and pretending otherwise would cost the operator +later. The NFQUEUE binaries fuse the two layers: `cppregex` is NFQUEUE *plus* +hyperscan, `cpproxy` is NFQUEUE *plus* an embedded interpreter, and neither can host +the other's filter. So a chain there is a chain of *processes*, one per filter, each at +its own base-chain priority — a packet a filter accepts carries on to the next chain in +the same hook, which is what makes order mean something. The proxy walks the same chain +as a list inside one process. + +What is left of the asymmetry is priced and refused up front, in `check()`: at most +`MAX_CHAIN_POSITIONS` filters on NFQUEUE, because each one costs a process and a +reassembly pass; nothing at all on `external`, because firegex is not in that path and a +filter attached to it would never run. +""" + +import asyncio +import json +import os +import sys +import time +import traceback +from typing import NamedTuple + +from modules.services.models import (KIND, L4, PROTO, TRANSPORT, UPSTREAM, Filter, + Regex) +from modules.services.nftables import (MAX_CHAIN_POSITIONS, NoRelayAddress, + 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__)), "..") + +CPPREGEX = os.path.join(MODULES_DIR, "cppregex") +CPPROXY = os.path.join(MODULES_DIR, "cpproxy") +PROXY_ENGINE = os.getenv("FGEX_PROXY_BINARY", os.path.join(MODULES_DIR, "fgex-proxy")) + +#: Where a TLS service's certificate and key are put for the engine to read. Not under +#: `db/`: the material is already in the database, and a second durable copy of a private +#: key is a second thing to remember to delete. +TLS_DIR = "/tmp/firegex_tls" +# The out-of-process worker the proxy engine runs the user's Python in. The engine +# spawns it, not us: it is the one that has to kill it when it stops answering. +PYWORKER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "pyworker.py") +#: What `pyworker.py` writes to stderr when the user's code raises. Spelled here as well +#: as there on purpose: the worker is launched as a script and imports nothing from the +#: backend, which is what keeps the user's code from sharing this process's imports. +#: Kept in step by `tests/integration/test_resilience.py`, which asks for the sentence on +#: both network layers. +PYWORKER_EXCEPTION_MARK = "[exception] [filter]" + +#: How long to wait for a binary to acknowledge a control command, or to say it started. +ACK_TIMEOUT = 3 + +#: How long a binary may take over one filter's worth of a configuration push. Loading a +#: Python filter runs its module body, which the code check lets take up to eight seconds +#: (`CHECK_TIMEOUT` in the router) — so a push held to `ACK_TIMEOUT` gave up on a file the +#: check had just accepted, and the answer that came afterwards was read as the answer to +#: whichever command was sent next. +LOAD_TIMEOUT = 10 + +#: How long a proxy engine taken out of service may go on carrying the connections it +#: already had. It is taken out of the rules at once — new connections go straight to the +#: service, or to the engine that replaced it — and it exits as soon as it reports none +#: left. Killed instead, which is what a stop used to do, every connection it carried +#: was cut: a download, a websocket, a request half answered. The ceiling is for the ones +#: that never close. +DRAIN_LIMIT = 600 +#: The engine reports its connections every two seconds (`STATS_INTERVAL` in `main.rs`); +#: a report sent before it was taken out of the rules cannot say it has none left. +DRAIN_FRESH_AFTER = 2.5 +#: How many engines of one service may be draining at once. A service restarted over and +#: over while clients hold connections open would otherwise keep one process per restart. +MAX_DRAINING = 3 + +#: Every engine taken out of service and still carrying connections, by service id. +_draining: dict[str, list["ProxyTransport"]] = {} + + +async def stop_draining() -> None: + """End every drain now: the rules that carry their connections home are going too.""" + for engines in list(_draining.values()): + for engine in list(engines): + await engine._end_drain(None) + + +class UnsupportedChain(Exception): + """The chosen transport cannot host the chain that was asked of it.""" + + +class ChainShapeChanged(Exception): + """The chain is no longer the one the running datapath was started for. + + Not an error: it means the change cannot be pushed and has to be rebuilt. Only the + NFQUEUE layer raises it, because only there is the chain made of separate processes + at separate priorities rather than a list inside one. + """ + + +class ChainLink: + """One filter, with everything needed to run it already resolved. + + The transports never touch the database: whoever builds the chain has already + read the patterns and the code out of it, so a transport cannot accidentally + enforce something other than what the caller decided to enforce. + """ + + def __init__(self, flt: Filter, regexes: list[Regex] | None = None, + code_path: str | None = None, functions: list | None = None): + self.filter = flt + self.regexes = regexes or [] + self.code_path = code_path + #: The `@pyfilter` functions the operator left switched on, by name. `None` + #: means every one the file defines — which is what a filter whose code has + #: never been saved has, and what a hand-run worker assumes. + self.functions = functions + + @property + def enabled_functions(self) -> list[str] | None: + """The names to hand the library, or `None` for "everything the file defines". + + No rows at all means the list was never derived — a filter whose code predates + the per-function switches, or one whose code did not parse when it was saved. + The safe reading there is to run what the file defines: the alternative is a + file whose filters all silently stop running, which is the exact failure this + whole module is arranged to prevent. + + Rows that all say `active = 0` are a different thing entirely — the operator + switched every function off — and that stays representable as an empty list. + """ + if not self.functions: + return None + return [fn["name"] for fn in self.functions if fn["active"]] + + @property + def kind(self) -> str: + return self.filter.kind + + @property + def id(self) -> str: + return self.filter.id + + +#: What an address's edge is called on the wire to the engine. Only `tls` has a name of +#: its own, because only it is a promise: this port is the encrypted one, and a client +#: opening it in the clear is refused. Everything else is "whatever the client turns out +#: to be speaking", which is what one listener fronting both a cleartext port and an +#: encrypted one has always had to do — and refusing TLS at a port nobody promised would +#: be a rule with nothing behind it. +_EDGE_WORD = {L4.TLS: "tls"} + + +class Announcement(NamedTuple): + """What the engine is told about one TCP address, beyond that it exists.""" + + #: `tls` where the address was declared as the encrypted one, `any` otherwise. + word: str + #: What the service behind it speaks: `same`, `plain` or `tls`. + onward: str + #: Where that service is, when it is not on the port the address names. + target_port: int | None + + +def fronted_by_the_listener(srv, addr) -> bool: + """Whether this address is one the single TCP listener answers for. + + Asked of the transport the **kernel** matches on, which is what decides whether an + address gets a relay of its own: a UDP or QUIC address is bound to the service it + fronts when the relay is opened, so there is nothing left to tell the engine about + it. Written as one question because it was two — an edge test here and a `proto` + test there — and two spellings of one rule are what this module spends most of its + comments arguing against. + """ + return L4.l4_of(addr.proto or srv.proto) == L4.TCP + + +def announcement(srv, addr) -> Announcement | None: + """What the engine has to be told about one TCP address, or `None` for nothing. + + `None` is the ordinary answer and the important one: an address that is simply the + service — dialled where it listens, carried as it arrives — is one the engine already + handles correctly by knowing nothing about it, because that is what transparent + means. Announcing it anyway would be a map entry restating the default. + + One function rather than two because there are two callers and they have to agree + exactly: `ProxyTransport._published` builds the startup list (`FGEX_PROXY_TARGETS`) + and `ServiceManager.address_added` sends `PUBLISH` for an address added to a service + already running. They were the same three lines written twice, and the day they + disagree the symptom is an address that behaves one way when the service starts with + it and another way when it is added afterwards — which is as hard a bug to see as + this module has produced. + """ + if not fronted_by_the_listener(srv, addr): + return None + # Only ever *chosen* on an `http` service, because that is the only protocol whose + # addresses are not all reached the same way; elsewhere it is the service's own. + word = (_EDGE_WORD.get(str(addr.edge), "any") + if str(srv.proto) == L4.HTTP else "any") + onward = UPSTREAM.env(addr.upstream) + moved = addr.target_port if addr.target_port and addr.target_port != addr.port else None + if word == "any" and onward == "same" and moved is None: + return None + return Announcement(word=word, onward=onward, target_port=moved) + + +class Transport: + """What a network layer has to be able to do.""" + + def __init__(self, srv, on_block=None, on_output=None, on_exception=None, on_engine=None, + on_over_limit=None, on_died=None): + self.srv = srv + # Called with the id of whatever refused a connection — a regex rule id, or a + # filter id for a pyfilter. Counters live in the database, which is the + # manager's business, not ours. + self.on_block = on_block + #: Whatever the user's own code printed. + self.on_output = on_output + self.on_exception = on_exception + #: What the datapath has seen and refused, in connections. Only the layers that + #: work in connections report it; the rest leave it empty and the kernel's + #: packet counters answer instead. + self.counters: dict = {} + #: Whatever the datapath says about itself. Forwarded rather than swallowed: + #: these are the lines that say a filter lost its say or a fallback happened. + self.on_engine = on_engine + #: Called with how many *more* connections the limit has turned away since the + #: last report. The engine's own counter resets when it restarts, so somebody + #: above has to add them up if the trace is to outlive the process. + self.on_over_limit = on_over_limit + #: Called when a datapath process dies without being asked to. **The nftables + #: rules outlive the process**, so nobody notices on their own: the traffic keeps + #: being steered at a queue nobody is reading, the interface goes on reporting the + #: service as active, and what it is doing is decided by the `bypass` flag alone. + #: Whoever is above decides whether to bring it back. + self.on_died = on_died + #: Whether whatever this layer listens on can accept IPv6. Declared here rather + #: than on the one layer that has a listener, so a caller asking a transport + #: what it can accept gets an answer instead of an AttributeError. + self.is_dual_stack: bool = False + self.process: asyncio.subprocess.Process | None = None + self._stderr_pump: asyncio.Task | None = None + + @classmethod + def check(cls, srv, chain: list[ChainLink]) -> None: + """Refuse an impossible combination before anything is started. + + Whatever the network layer is: a filter that cannot fire on this service's + traffic is refused here, once, rather than by each transport separately. + """ + if str(srv.proto) != L4.UDP: + return + # A file that asks for parsed HTTP needs a stream to parse. On datagrams the + # library declines to build the model, so the filter is never called — and a + # filter that silently never runs is worse than one that is refused. Saving such + # code against a UDP service is refused too; this catches the case where the + # service was switched to UDP afterwards. + http = [ + link for link in chain + if link.filter.active and link.kind == KIND.PYFILTER + and link.filter.proto == PROTO.HTTP + ] + if http: + raise UnsupportedChain( + f"{', '.join(link.filter.name for link in http)} " + f"{'ask' if len(http) > 1 else 'asks'} for parsed HTTP, which needs a " + f"TCP stream underneath, and this service speaks UDP. On datagrams " + f"those filters would never be called. Rewrite them against RawPacket, " + f"or move the service back to TCP." + ) + + async def start(self, chain: list[ChainLink]) -> dict: + """Bring the datapath up. Returns what `nftables.add` needs to steer traffic.""" + raise NotImplementedError + + async def reload(self, chain: list[ChainLink]) -> None: + raise NotImplementedError + + async def add_udp_target(self, ip: str, port: int, + onward: str = "same") -> int: + raise NotImplementedError + + async def stop(self) -> None: + raise NotImplementedError + + async def retire(self, keep_filtering: bool, then=None) -> None: + """Stop, sparing whatever connections this layer can spare. See `ProxyTransport`. + + Only the proxy layer has any to spare: it is the one that terminates them. A + queued service's connections never belonged to its processes, and a handed-off + one's belong to the operator's proxy — both go on when the rules come off. + """ + await self.stop() + if then: + then() + + async def _kill(self): + if self.process and self.process.returncode is None: + self.process.kill() + await self.process.wait() + self.process = None + + def _emit_block(self, rule_id: str): + if self.on_block: + self.on_block(rule_id) + + def _pump_stderr(self, stream) -> asyncio.Task | None: + """Forward the datapath's own diagnostics line by line. + + The last `[fatal]` is kept as well as forwarded, because a datapath that dies + during startup is reported by whatever was waiting for its handshake — and + "did not report a listening port" is the symptom, while the line it printed on + the way down is the reason. Kept on the *owner* rather than per process, which is + what lets the NFQUEUE stages use it too: they pump their stderr through here and + had the identical problem, a timeout reported as a missing queue number while the + binary had already said why it was going. + """ + if stream is None or not self.on_engine: + return None + + async def run(): + try: + while True: + line = await stream.readline() + if not line: + return + text = line.decode(errors="replace") + if "[fatal]" in text: + self._last_fatal = text.split("[fatal]", 1)[1].strip() + # The worker marks a filter that raised, because the verdict it sends + # back is an ordinary ACCEPT and the engine cannot tell that apart + # from a filter that agreed. The traceback beside this line says which + # filter and where; this is what says the traffic went through + # unfiltered, and it is the same sentence the queued layer's + # `EXCEPTION` produces. + if PYWORKER_EXCEPTION_MARK in text: + # A protocol token, like `BLOCKED ` on the other channel: + # it is turned into the sentence an operator reads and is not + # forwarded raw, or the log would carry both. + if self.on_exception: + self.on_exception(self.srv.id) + continue + self.on_engine(text) + except (asyncio.CancelledError, asyncio.IncompleteReadError): + pass + except Exception: + traceback.print_exc() + + return asyncio.create_task(run()) + + def _abandon(self, process, what: str, command: str, waited: float) -> None: + """Give up on a datapath process that stopped answering its control channel. + + Killed rather than waited on further, because its answer may still come — and it + would then be read as the answer to the next command, and every command after that + would be told what the one before it got. Killed **without** being marked stopped, + so its watchdog reports it and the service is rebuilt from the saved configuration: + what was applied, if anything, cannot be known from here. + """ + if self.on_engine: + self.on_engine(f"[error] [backend] {what} did not acknowledge {command} within " + f"{waited:.0f}s; restarting it") + if process is not None and process.returncode is None: + process.kill() + + async def _died_because(self, fallback: str) -> str: + """What to tell the operator when the datapath would not come up. + + A moment first: stdout closing is what wakes the caller, and the reason was + written to stderr, so the two arrive in that order often enough to matter. + """ + for _ in range(10): + if getattr(self, "_last_fatal", None): + break + await asyncio.sleep(0.05) + reason = getattr(self, "_last_fatal", None) + return f"{fallback}: {reason}" if reason else fallback + + +class _QueueStage: + """One filter, in one NFQUEUE binary, at one position in the chain. + + The two binaries fuse transport and filter — `cppregex` is NFQUEUE plus hyperscan, + `cpproxy` is NFQUEUE plus an embedded interpreter — so a chain of them is a chain of + processes rather than a chain inside one. They also speak different control + protocols, which is the only reason this class knows about both. + """ + + def __init__(self, srv, link: ChainLink, owner: "NfqueueTransport"): + self.srv = srv + self.link = link + self.owner = owner + self.process: asyncio.subprocess.Process | None = None + self.queue_num: int | None = None + self._reader_task: asyncio.Task | None = None + self._output_task: asyncio.Task | None = None + self._ack: asyncio.Future | None = None + self._sock_path: str | None = None + self._sock_server: asyncio.Server | None = None + self._sock_reader: asyncio.StreamReader | None = None + self._sock_writer: asyncio.StreamWriter | None = None + self._sock_ready = asyncio.Event() + #: 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. + self._stopped = False + self._watchdog: asyncio.Task | None = None + #: What this process last acknowledged, so an edit elsewhere in the chain does + #: not send it the same filters again. See `reload`. + self._in_force: bytes | None = None + + async def start(self) -> int: + if self.link.kind == KIND.PYFILTER: + self.queue_num = await self._start_cpproxy() + else: + self.queue_num = await self._start_cppregex() + self._watchdog = asyncio.create_task(self._watch()) + await self.reload(self.link) + return self.queue_num + + async def _watch(self): + """Notice the binary dying, which nothing else here does. + + The reader task sees EOF on stdout and returns, which is indistinguishable from a + clean shutdown, so a crashed interceptor used to leave a service that reports + itself as active and filters nothing — its rules still pointing traffic at a queue + with no reader behind it. + """ + try: + returncode = await self.process.wait() + except asyncio.CancelledError: + return + if self._stopped: + return # we killed it ourselves + self.owner._stage_died(self.link.filter.name, returncode) + + # --- cppregex: config on stdin, events on stdout -------------------------- + + async def _start_cppregex(self) -> int: + self.process = await asyncio.create_subprocess_exec( + CPPREGEX, + stdout=asyncio.subprocess.PIPE, + stdin=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=dict( + os.environ, + **{ + "MATCH_MODE": "stream" if self.srv.proto == "tcp" else "block", + "NTHREADS": os.getenv("NTHREADS", "1"), + "FIREGEX_NFQUEUE_FAIL_OPEN": "1" if self.srv.fail_open else "0", + }, + ), + ) + nicenessify(-10, self.process.pid) + self._output_task = self.owner._pump_stderr(self.process.stderr) + queue = await self._read_queue_number(self.process.stdout) + self._reader_task = asyncio.create_task(self._read_events(self.process.stdout)) + return queue + + # --- cpproxy: config and events over a unix socket ------------------------ + + async def _start_cpproxy(self) -> int: + self._sock_path = f"/tmp/firegex_service_{self.srv.id}_{self.link.id}.sock" + if os.path.exists(self._sock_path): + os.remove(self._sock_path) + self._sock_ready.clear() + self._sock_server = await asyncio.start_unix_server( + self._accept_worker, path=self._sock_path + ) + self.process = await asyncio.create_subprocess_exec( + CPPROXY, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + env=dict( + os.environ, + **{ + "NTHREADS": os.getenv("NTHREADS", "1"), + "FIREGEX_NFQUEUE_FAIL_OPEN": "1" if self.srv.fail_open else "0", + "FIREGEX_NFPROXY_SOCK": self._sock_path, + # How many UDP flows keep a filter's state at once: the service's own + # limit, 0 for none. Only datagrams need it — a TCP stream's state + # goes when the stream closes. + "FIREGEX_MAX_FLOWS": str(self.srv.max_connections), + }, + ), + ) + nicenessify(-10, self.process.pid) + # Whatever the user's Python prints is theirs to see, so it is forwarded rather + # than swallowed — a filter that is silently doing nothing is the hardest kind + # to debug during a competition. + self._output_task = asyncio.create_task(self._pump_output()) + try: + async with asyncio.timeout(ACK_TIMEOUT): + await self._sock_ready.wait() + queue = await self._read_queue_number(self._sock_reader) + except (asyncio.TimeoutError, TimeoutError) as e: + reason = await self.owner._died_because("the nfqueue binary would not start") + await self.stop() + raise Exception(reason) from e + self._reader_task = asyncio.create_task(self._read_events(self._sock_reader)) + return queue + + async def _accept_worker(self, reader, writer): + if self._sock_reader or self._sock_writer: + writer.close() # only ever one connection; a second is not ours + return + self._sock_reader, self._sock_writer = reader, writer + self._sock_ready.set() + + async def _pump_output(self): + import codecs + + # Whole lines only, decoded across reads: a line cut at a read boundary was two + # log entries, and a character cut there was two replacement marks. + decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + pending = "" + try: + while True: + data = await self.process.stdout.read(10 * 1024) + if not data: + break + *lines, pending = (pending + decoder.decode(data)).split("\n") + self._forward_output(lines) + except (asyncio.CancelledError, asyncio.IncompleteReadError): + pass + except Exception: + traceback.print_exc() + if pending: + self._forward_output([pending]) + + def _forward_output(self, lines: list[str]) -> None: + """The binary's own diagnostics share this pipe with whatever the user's code prints. + + So both go through the classifier the other datapaths' stderr goes through, which + reads a line's tag for its level, a traceback as an error, and an untagged line as + the user's own output. They used to go in as the user's output, all of them: + `[error]` lines and the traceback of a filter that raised were grey text among the + prints, where a warning is looked for and not found. + """ + for line in lines: + # 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. + if "[fatal]" in line: + self.owner._last_fatal = line.split("[fatal]", 1)[1].strip() + text = "\n".join(lines) + if not text.strip(): + return + if self.owner.on_engine: + self.owner.on_engine(text) + elif self.owner.on_output: + self.owner.on_output(self.srv.id, text) + + # --- shared --------------------------------------------------------------- + + async def _read_queue_number(self, reader: asyncio.StreamReader) -> int: + try: + line = (await asyncio.wait_for(reader.readuntil(), timeout=ACK_TIMEOUT)).decode() + except (asyncio.TimeoutError, asyncio.IncompleteReadError) as e: + reason = await self.owner._died_because("the nfqueue binary would not start") + await self.stop() + raise Exception(reason) from e + if not line.startswith("QUEUE "): + await self.stop() + raise Exception(f"unexpected output from the nfqueue binary: {line.strip()!r}") + return int(line.split()[1]) + + async def _read_events(self, reader: asyncio.StreamReader): + try: + while True: + line = (await reader.readuntil()).decode().strip() + if DEBUG: + print(f"[{self.srv.name}/{self.link.filter.name}] {line}") + if line.startswith("BLOCKED "): + code = line.split()[1] + 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 "): + rest = line[4:].strip() + if self._ack and not self._ack.done(): + self._ack.set_result((rest.upper().startswith("OK"), rest)) + except (asyncio.CancelledError, asyncio.IncompleteReadError): + pass + except Exception: + traceback.print_exc() + + async def reload(self, link: ChainLink) -> None: + self.link = link + if link.kind == KIND.REGEX: + payload = self._regex_payload(link.regexes) + else: + payload = self._python_payload(link) + # Every stage is reloaded on every edit to the chain, and a new configuration is + # a clean slate for the connections under way: `cppregex` resets its matchers + # and `cpproxy` its filters' state. For the filter that was edited that is the + # point. For the others it cost every open connection its place — a request in + # the middle of an upload was read again from its second half — over a pattern + # changed in a filter they have nothing to do with. + if payload == self._in_force: + return + await self._push(payload) + self._in_force = payload + + def _regex_payload(self, regexes: list[Regex]) -> bytes: + """cppregex takes its patterns as hex codes, one per direction. + + ``: a pattern is bytes, and the only encoding both ends + agree on without quoting rules is hex. + """ + self._codes = {} + codes = [] + for rx in regexes: + if not rx.active: + continue + case = "1" if rx.case_sensitive else "0" + for direction, wanted in (("C", rx.is_input), ("S", rx.is_output)): + if wanted: + code = case + direction + rx.regex.hex() + self._codes[code] = rx.id + codes.append(code) + 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. + + **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() + self._codes = {} + enabled = link.enabled_functions + body = ( + code + + "\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 + # under a service that said `tcp` was refused with an error naming its own + # annotation. + + "import firegex.pyfilters.internals\n" + + "firegex.pyfilters.internals.compile(globals())\n" + ).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() + if self._sock_writer: + self._sock_writer.write(payload) + await self._sock_writer.drain() + elif self.process and self.process.stdin: + self.process.stdin.write(payload) + await self.process.stdin.drain() + else: + raise Exception("the nfqueue binary is not running") + try: + ok, detail = await asyncio.wait_for(self._ack, timeout=LOAD_TIMEOUT) + except asyncio.TimeoutError: + # Whether it took the new filters or is still at it cannot be known now, and + # an answer arriving later would be read as the answer to the next push. So + # it is killed *without* being marked stopped: the watchdog reports it and + # the service is rebuilt from what the database says — which, after an edit + # that was refused, is what it said before. At start the caller stops every + # stage anyway. + self.owner._abandon(self.process, f"the {self.link.filter.name} interceptor", + "the filters", LOAD_TIMEOUT) + raise Exception("the nfqueue binary did not acknowledge the filters") + finally: + self._ack = None + if not ok: + # Not fatal, and it must not be made so: both binaries answer `ACK FAIL` with + # the filters they had still in force. This used to stop the stage, which + # during an edit left a service reported active with nothing reading its + # queue — unfiltered where it fails open, silent where it does not — and + # stopped on purpose, so the watchdog had nothing to say about it. + raise Exception(f"the nfqueue binary rejected the filters: {detail}") + + 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._output_task): + if task: + task.cancel() + self._reader_task = self._output_task = None + if self._sock_server: + self._sock_server.close() + self._sock_server = None + self._sock_reader = self._sock_writer = None + if self._sock_path and os.path.exists(self._sock_path): + os.remove(self._sock_path) + self._sock_path = None + if self.process and self.process.returncode is None: + self.process.kill() + await self.process.wait() + self.process = None + self.queue_num = None + + +class NfqueueTransport(Transport): + """Packets are queued to userspace; the kernel keeps a verdict-shaped hole open. + + Nothing is terminated, so the protected service sees the original connection with no + help from us, and `NFQA_CFG_F_FAIL_OPEN` plus the `bypass` flag on the nft rule mean + traffic keeps flowing if a filter dies. The price is that a payload cannot be + rewritten without desynchronising the stream. + + A chain here is a chain of *processes*, one per filter, each with its own queue and + its own pair of base chains. A packet a filter accepts carries on to the next base + chain in the same hook, so filter order is chain priority order and the first filter + to refuse a packet is the last one that sees it. It costs a process and a reassembly + pass per filter, which is the price of the two binaries fusing transport and filter; + what it buys is that the model means the same thing on every network layer. + """ + + name = TRANSPORT.NFQUEUE + + @classmethod + def check(cls, srv, chain: list[ChainLink]) -> None: + super().check(srv, chain) + if str(srv.proto) == L4.HTTP: + # Not the TLS sentence and not the QUIC one, because the reason is neither: + # an `http` service is reached over TLS and over QUIC *and* in the clear, and + # this layer can decrypt none of it. Even the cleartext edge would be half an + # answer — HTTP/2 in the clear is HPACK, and rendering it means terminating + # the connection, which is the proxy layer's job by definition. + raise UnsupportedChain( + "An `http` service is reached over every version of HTTP at once, which " + "means TLS on one port and QUIC on another — and this layer inspects " + "packets on their way past rather than terminating them, so it can read " + "none of it. Move the service to the proxy layer, or set its protocol to " + "TCP and filter the HTTP/1.1 going by." + ) + if srv.decrypts: + # Decrypting means terminating the connection, which is what the proxy layer + # does; this one inspects packets on their way past and hands the kernel a + # verdict, which is the whole reason it can fail open. It was once offered + # here by putting nginx in front to terminate and re-encrypt, with the + # filters attached to the plaintext leg in between — so the connection *was* + # terminated, the layer's one distinguishing property was already gone, and + # it cost two loopback ports chosen by hashing the address. + if str(srv.proto) == L4.QUIC: + # And for QUIC there is not even that: nginx could terminate TLS and hand + # over a TCP stream, but a QUIC connection past its first packet has its + # frames and its stream boundaries encrypted as well as its payload. + # Queued to userspace it is a UDP datagram of noise. + raise UnsupportedChain( + "QUIC encrypts its frames and its stream boundaries, not just the " + "payload, so a packet queued to this layer carries nothing a filter " + "could read. It has to be terminated to be inspected at all: move " + "the service to the proxy layer, or set its protocol to UDP and " + "accept that the rules are matching the encrypted datagrams." + ) + raise UnsupportedChain( + "A service that speaks TLS has to be decrypted, and decrypting means " + "terminating the connection — which is what the proxy layer does and " + "this one deliberately does not. Move the service to the proxy layer, or " + "set its protocol to TCP and filter the ciphertext as it goes by." + ) + active = [link for link in chain if link.filter.active] + if len(active) > MAX_CHAIN_POSITIONS: + raise UnsupportedChain( + f"the nfqueue transport chains at most {MAX_CHAIN_POSITIONS} filters, " + f"and this service has {len(active)} active. Deactivate some, or move it " + f"to the proxy transport, which walks the chain inside one process." + ) + # There used to be a third refusal here, for patterns that rewrote the traffic + # rather than blocking it. Nothing rewrites any more — on either layer — so + # there is nothing left to refuse: see the note on ACTION in `models.py`. + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.stages: list[_QueueStage] = [] + #: One report per datapath, not one per process: a chain is several binaries and + #: they usually die together, which would otherwise ask for four restarts of one + #: service. + self._reported_death = False + + async def start(self, chain: list[ChainLink]) -> dict: + self.check(self.srv, chain) + active = [link for link in chain if link.filter.active] + # No active filter still gets a datapath: the operator turned the rules off, not + # the service, and turning one back on must not need a restart. An empty regex + # stage matches nothing and costs one process. + if not active: + active = [ChainLink(_EmptyFilter(self.srv.id), regexes=[])] + try: + for link in active: + stage = _QueueStage(self.srv, link, self) + self.stages.append(stage) + await stage.start() + except Exception: + await self.stop() + raise + return {"queue_nums": [stage.queue_num for stage in self.stages]} + + 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] != running: + raise ChainShapeChanged() + for stage, link in zip(self.stages, active): + await stage.reload(link) + + async def stop(self) -> None: + for stage in self.stages: + await stage.stop() + self.stages = [] + + def _stage_died(self, filter_name: str, returncode: int): + """One of the chain's processes is gone and nobody asked it to go. + + Reported once per shutdown: the stages share a service, so a chain of four that + dies together would otherwise ask for four restarts of the same thing. + """ + if self._reported_death: + return + self._reported_death = True + if self.on_died: + self.on_died(f"the {filter_name} interceptor", returncode) + + +class _EmptyFilter: + """Stands in for a service with nothing active, so it still has a datapath.""" + + def __init__(self, service_id: str): + self.id = f"empty-{service_id}" + self.name = "no active filter" + self.kind = KIND.REGEX + self.proto = PROTO.TCP + self.active = True + + +def supports_ipv6() -> bool: + try: + import socket + with socket.socket(socket.AF_INET6, socket.SOCK_STREAM) as s: + s.bind(("::", 0)) + return True + except Exception: + return False + + +class ProxyTransport(Transport): + """The connection is terminated and reopened towards the service. + + That buys the whole chain: filters run in the order the operator set, a rewrite is + exact because we own both halves, reassembly is the kernel's problem, and a slow + filter slows the sender instead of dropping packets. What it costs is the kernel's + fail-open backstop, which the engine rebuilds by hand — a filter that panics, hangs + or crashes loses its say and the traffic keeps moving. + + The proxy is meant to be invisible: it always dials the service from the client's + own address, and there is no setting for that. A service that suddenly saw one + address for the whole internet would be a regression nobody would attribute to us. + """ + + name = TRANSPORT.PROXY + + @classmethod + def check(cls, srv, chain: list[ChainLink]) -> None: + super().check(srv, chain) + if srv.carries(L4.UDP): + from utils import get_interface_ips, is_ip_parse + # Only the addresses that need a relay of their own. On an `http` service + # those are its QUIC ones, sitting beside TCP addresses that need nothing of + # the sort — which is why this asks the address rather than the service. + for addr in srv.udp_addresses: + if not is_ip_parse(addr.ip_int): + ips = get_interface_ips(addr.ip_int) + if not ips: + raise UnsupportedChain( + f"interface '{addr.ip_int}' has no IP assigned for UDP proxy relay. " + f"Use an IP address or NFQUEUE transport." + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._reader_task: asyncio.Task | None = None + self._ack: asyncio.Future | None = None + self._cmd_lock = asyncio.Lock() + self.port: int | None = None + #: 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 + #: When the engine last reported its counters, for a drain to know that `live` + #: was counted after the engine was taken out of the rules. + self._stats_at = 0.0 + self._drain: asyncio.Task | None = None + self._after_drain = None + + def _published(self) -> list[str]: + """What this engine is told about its TCP addresses, one entry each. + + `10.0.0.1:443|tls|plain=10.0.0.1:80` reads as: what arrives at the first is + spoken under TLS, the service behind it answers in the clear, and it is at the + second. Every part after the address is optional and an address that needs none + of them is not listed at all — which is every address of every service until an + operator says otherwise, and is what keeps the transparent case exactly as it + was. + + Only TCP: a UDP or QUIC address has a relay of its own, bound to the service it + fronts and terminating what it was built to terminate, so both questions are + already answered by `FGEX_PROXY_UDP`. + + **An interface is resolved to the addresses it carries, one entry each**, rather + than skipped. The engine keys this map on what `SO_ORIGINAL_DST` hands back, + which is an address and never an interface — so there was nothing to key on and + the row was dropped. What that cost: an HTTPS edge declared on `lo:443` was never + announced, so the engine met the connection with no idea it was a TLS edge or + that the service was on `:80`, terminated it as an ordinary one and dialled + `:443`, where nothing listens. HTTPS on that address simply did not answer, and + the same address written `127.0.0.1:443` worked — which is how it was found. + """ + out = [] + for addr in self.srv.addresses: + # An address with nothing to say is not listed: the engine's answer for one + # it has never heard of is the transparent case, which is exactly what it + # would be told. `announcement` is where that is decided, and it is shared + # with `ServiceManager.address_added` so that an address present at startup + # and one added afterwards cannot come to mean different things. + said = announcement(self.srv, addr) + if said is None: + continue + for host in interface_addresses(addr.ip_int): + entry = f"{udp_relay_key(host, addr.port)}|{said.word}|{said.onward}" + if said.target_port: + entry += f"={udp_relay_key(host, said.target_port)}" + out.append(entry) + return out + + def _udp_targets(self) -> list[tuple[str, int, str]]: + """The addresses that each need a relay of their own, resolved. Empty for TCP. + + Resolved here and nowhere else, so the relay the engine is asked to open, the + rule that points at it and the key both are filed under are derived from one + answer rather than from three lookups that could each land somewhere different. + + The third item is what the service behind *that relay* speaks, because a relay is + one protected address and the question belongs to the address. + """ + out = [] + for addr in self.srv.udp_addresses: + target = service_at(self.srv, addr) + if not target: + continue + target_ip, target_port = target + try: + out.append((udp_relay_host(target_ip), target_port, + UPSTREAM.env(addr.upstream))) + except NoRelayAddress as e: + raise UnsupportedChain( + f"{e}. Give the service an IP address, or put it on the NFQUEUE " + f"layer, which needs none." + ) from e + return out + + def _crypto_env(self) -> dict: + """Hand the engine its certificate, or nothing at all. + + The engine reads **paths**, not the material: a certificate on a command line or + in an environment dump is a certificate in a log somewhere. They are written + `0600` under a directory of the service's own, and removed when it stops. + + What the service *behind* speaks does not travel here: it is the address's + answer, not the service's, so it rides with the address — on `FGEX_PROXY_TARGETS` + and `PUBLISH` for a TCP one, and on `FGEX_PROXY_UDP` and `ADD_UDP` for a relay. + `same` re-encrypts on the way out the way nginx's `proxy_ssl on` did; the other + two say the service speaks HTTP/1.1, in the clear or under its own TLS. + + QUIC takes the same pair and one more word. It needs no `_UPSTREAM` flag because + there is no unencrypted QUIC to choose between — the handshake is part of the + transport — and it needs `FGEX_PROXY_QUIC` because what binds a protected + address is then an endpoint that terminates rather than a socket that forwards. + """ + if not self.srv.decrypts: + return {} + os.makedirs(TLS_DIR, exist_ok=True) + cert_path = os.path.join(TLS_DIR, f"{self.srv.id}.crt") + key_path = os.path.join(TLS_DIR, f"{self.srv.id}.key") + for path, material in ((cert_path, self.srv.tls_cert), (key_path, self.srv.tls_key)): + # Opened restricted rather than written and then chmod'ed: between the two + # there is a moment when the key is readable, and it only takes one. + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as handle: + handle.write(material or "") + material = { + "FGEX_PROXY_TLS_CERT": cert_path, + "FGEX_PROXY_TLS_KEY": key_path, + } + if str(self.srv.proto) == L4.HTTP: + # Every edge at once, in one process, with one certificate and one chain. + # `TLS_OPTIONAL` is the whole of what makes that possible: the TCP listener + # terminates TLS only for the connections that start a handshake, so the + # cleartext port and the encrypted one are the same listener and the same + # filters. `QUIC` is set whether or not the service has a UDP address yet, + # so that adding one later opens a relay on the engine already running rather + # than needing it restarted. + return { + **material, + "FGEX_PROXY_TLS": "1", + "FGEX_PROXY_TLS_OPTIONAL": "1", + "FGEX_PROXY_QUIC": "1", + } + if str(self.srv.proto) == L4.QUIC: + return { + **material, + "FGEX_PROXY_QUIC": "1", + # No protocol list: the engine offers the service what each client + # offered, read off the client's first packet — see `quic_hello.rs`. + } + return material + + def _clear_crypto_material(self) -> None: + for suffix in (".crt", ".key"): + try: + os.remove(os.path.join(TLS_DIR, f"{self.srv.id}{suffix}")) + except OSError: + pass + + async def start(self, chain: list[ChainLink]) -> dict: + self.check(self.srv, chain) + # The listener has to be able to accept every family the service answers in. A + # dual-stack listener ([::]:0) is used whenever IPv6 is supported on the host, + # so it accepts both IPv4 (as mapped addresses) and IPv6 connections from the start. + # 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, + stdin=asyncio.subprocess.PIPE, + # Captured rather than inherited: the engine writes its warnings here, and + # on the backend's own stderr nobody reads them during a competition. + stderr=asyncio.subprocess.PIPE, + env=dict( + os.environ, + **{ + # Port 0: the kernel picks and the engine reports back, so nothing + # has to keep a registry of which ports are already taken. + "FGEX_PROXY_LISTEN": listen, + "FGEX_PROXY_UPSTREAM": "original", + "FGEX_PROXY_SPOOF_SOURCE": "1", + # The same knob the NFQUEUE binaries get. `run.py --threads` used to + # reach them and not this engine, so one flag meant two things. + "NTHREADS": os.getenv("NTHREADS", "1"), + # One relay per address, each with its upstream already known: + # `SO_ORIGINAL_DST` is TCP-only, so there is nothing to recover per + # datagram and nothing to recover it from. The same list serves QUIC, + # 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( + 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 + # where the service really is. Only TCP needs telling — a UDP or + # QUIC address has a relay of its own whose upstream is already the + # answer, which is the same list above. Empty is the transparent + # case, which is every address that did not say otherwise. + "FGEX_PROXY_TARGETS": ",".join(self._published()), + # One number for both halves: TCP connections and UDP flows spend + # the same descriptors, and two limits meaning "how much of that may + # go" would be two things to keep in step. + "FGEX_PROXY_MAX_CONNECTIONS": str(self.srv.max_connections), + **({"FGEX_PROXY_OVER_LIMIT_FORWARD": "1"} + if self.srv.over_limit_forwards else {}), + "FGEX_PROXY_FIRST_BYTE_TIMEOUT": str(self.srv.first_byte_timeout), + "FGEX_PROXY_FILTER_TIMEOUT_MS": os.getenv("FGEX_PROXY_FILTER_TIMEOUT_MS", "2000"), + **self._crypto_env(), + }, + ), + ) + nicenessify(-10, self.process.pid) + self._stderr_pump = self._pump_stderr(self.process.stderr) + try: + line = ( + await asyncio.wait_for(self.process.stdout.readuntil(), timeout=ACK_TIMEOUT) + ).decode() + except (asyncio.TimeoutError, asyncio.IncompleteReadError) as e: + # Read before `stop()` clears it: the pump is cancelled there. + reason = await self._died_because("the proxy engine would not start") + await self.stop() + raise Exception(reason) from e + if not line.startswith("PORT "): + await self.stop() + raise Exception(f"unexpected output from the proxy engine: {line.strip()!r}") + self.port = int(line.split()[1]) + # 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 relays: + try: + line = ( + await asyncio.wait_for(self.process.stdout.readuntil(), timeout=ACK_TIMEOUT) + ).decode() + except (asyncio.TimeoutError, asyncio.IncompleteReadError) as e: + reason = await self._died_because( + "the proxy engine would not open a UDP relay") + await self.stop() + raise Exception(reason) from e + if not line.startswith("UDP "): + await self.stop() + raise Exception(f"unexpected output from the proxy engine: {line.strip()!r}") + _, 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. + await self.reload(chain) + return {"proxy_port": self.port, "udp_ports": self.udp_ports} + + def _payload(self, chain: list[ChainLink]) -> str: + """The chain as the engine's one-line JSON, in the operator's order.""" + out = [] + for link in chain: + if not link.filter.active: + continue + if link.kind == KIND.REGEX: + for rx in link.regexes: + if not rx.active: + continue + out.append( + { + "kind": "regex", + "id": rx.id, + # Which filter it belongs to, so the engine knows where one + # thing the operator placed ends and the next begins. + "filter": link.id, + "pattern": rx.regex.decode(errors="replace"), + "direction": {"C": "c2s", "S": "s2c"}.get(rx.mode, "both"), + "case_sensitive": rx.case_sensitive, + } + ) + elif link.kind == KIND.PYFILTER and link.code_path: + out.append( + { + "kind": "python", + "id": link.id, + "code_path": os.path.abspath(link.code_path), + # Which of the file's functions are switched on. `None` — the + # case of a filter whose list was never derived — means all of + # them, and is not the same as an empty list, which means the + # operator switched every one of them off. + "enabled": link.enabled_functions, + # No protocol: the worker reads it off the file, the same way + # the NFQUEUE binary does. This used to send the *service's* + # protocol, which is a different thing entirely — so an HTTP + # filter on a TCP service was refused for asking for an + # HttpRequest, which is the only thing an HTTP filter does. + "command": [sys.executable or "python3", PYWORKER], + "timeout_ms": 1000, + } + ) + 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: + # 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") + # The engine answers once every Python filter of the chain has loaded, one after + # the other, so the wait grows with them. + python = sum(1 for link in chain + if link.filter.active and link.kind == KIND.PYFILTER and link.code_path) + waited = LOAD_TIMEOUT * (1 + python) + async with self._cmd_lock: + self._ack = asyncio.get_running_loop().create_future() + self.process.stdin.write((self._payload(chain) + "\n").encode()) + await self.process.stdin.drain() + try: + ok, detail = await asyncio.wait_for(self._ack, timeout=waited) + except asyncio.TimeoutError: + self._abandon(self.process, "the proxy engine", "the chain", waited) + raise Exception("the proxy engine did not acknowledge the chain") + finally: + self._ack = None + if not ok: + # Deliberately not fatal to the datapath: the engine keeps enforcing the + # chain it already had, so a rejected edit costs the operator an error + # message rather than their protection. + raise Exception(f"the proxy engine rejected the chain: {detail}") + + async def add_udp_target(self, ip: str, port: int, onward: str = "same") -> int: + """Bind a dedicated UDP relay for a new address without restarting the engine. + + `onward` is what the service behind *this* relay speaks, because a relay is one + protected address and that question belongs to the address. + + Idempotent, and deliberately the only way in: the map of relays is this + 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. + """ + 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 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() + try: + ok, detail = await asyncio.wait_for(self._ack, timeout=ACK_TIMEOUT) + except asyncio.TimeoutError: + self._abandon(self.process, "the proxy engine", "ADD_UDP", ACK_TIMEOUT) + raise Exception(f"the proxy engine did not acknowledge ADD_UDP for {target}") + finally: + self._ack = None + if not ok: + raise Exception(f"the proxy engine rejected ADD_UDP {target}: {detail}") + 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 {slot}" + ) + return relay_port + + async def publish(self, public: tuple[str, int], edge: str, onward: str, + target: tuple[str, int] | None) -> None: + """Tell the running engine about one TCP address. + + The same thing `FGEX_PROXY_TARGETS` says at startup, said to an engine that is + already carrying traffic — because an address can be added to a running service + and the point of that is that nothing is dropped. + + UDP and QUIC addresses are not here: theirs is a relay with its upstream fixed + when it is bound, so `add_udp_target` has already answered both questions. + """ + where = f" {udp_relay_key(*target)}" if target else "" + await self._command(f"PUBLISH {udp_relay_key(*public)} {edge} {onward}{where}") + + async def withdraw(self, public: tuple[str, int]) -> None: + await self._command(f"WITHDRAW {udp_relay_key(*public)}") + + async def _command(self, line: str) -> None: + """One control line, and the acknowledgement it is owed.""" + if not self.process or self.process.returncode is not None: + 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(f"{line}\n".encode()) + await self.process.stdin.drain() + try: + ok, detail = await asyncio.wait_for(self._ack, timeout=ACK_TIMEOUT) + except asyncio.TimeoutError: + self._abandon(self.process, "the proxy engine", line.split()[0], ACK_TIMEOUT) + raise Exception(f"the proxy engine did not acknowledge {line.split()[0]}") + finally: + self._ack = None + if not ok: + raise Exception(f"the proxy engine rejected {line}: {detail}") + + async def _read_events(self): + try: + while True: + line = (await self.process.stdout.readuntil()).decode().strip() + if DEBUG: + print(f"[{self.srv.name}] {line}") + if line.startswith("ACK "): + rest = line[4:].strip() + if self._ack and not self._ack.done(): + self._ack.set_result((rest.upper().startswith("OK"), rest)) + elif line.startswith("UDP "): + parts = line.split() + if len(parts) == 3: + _, 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 "): + # `seen` and `refused`, both connections, both cumulative since the + # engine started. Reported on a timer rather than one line per + # connection: this is a denominator, not a live feed. + was = self.counters.get("over_limit", 0) + for token in line[6:].split(): + key, _, value = token.partition("=") + if value.isdigit(): + self.counters[key] = int(value) + self._stats_at = time.monotonic() + # Only the increase, and only when there is one: the engine counts + # from zero every time it starts, so handing the absolute number up + # would reset the durable trace on every restart of the service. + grew = self.counters.get("over_limit", 0) - was + if grew > 0 and self.on_over_limit: + self.on_over_limit(grew) + except (asyncio.CancelledError, asyncio.IncompleteReadError): + pass + except Exception: + 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() + self._reader_task = self._stderr_pump = None + self.port = None + await self._kill() + # After the engine is gone, never before: a key removed while the process that + # reads it is still running would be a key removed from under a restart. + self._clear_crypto_material() + + async def retire(self, keep_filtering: bool, then=None) -> None: + """Take the engine out of service without cutting the connections it carries. + + The caller has already taken it out of the rules, so nothing new reaches it; what + conntrack already sent here keeps coming here, and is carried until it closes. + `keep_filtering` is a restart's answer — the connections go on under the filters + they started with — and a stop's is to carry them unfiltered: stopping is how an + operator takes a filter that is hurting the service out of the way, and a filter + that went on judging the connections already open would not be out of the way. + + `then` runs once the engine has gone, which is when its ports can stop being + guarded: until then a relay would be reachable directly with nothing steering + traffic at it. + """ + self._stopped = True + if self._watchdog and self._watchdog is not asyncio.current_task(): + self._watchdog.cancel() + self._watchdog = None + self.port = None + self._after_drain = then + # The engine read these when it started and never again, and a restart writes + # its own copies for the engine replacing this one: removed later, they would be + # the new engine's. + self._clear_crypto_material() + if not self.process or self.process.returncode is not None: + await self._end_drain(None) + return + if not keep_filtering: + try: + await self.reload([]) + except Exception: + # Not answering is `_abandon`'s to deal with, and it already has. + pass + engines = _draining.setdefault(self.srv.id, []) + engines.append(self) + while len(engines) > MAX_DRAINING: + oldest = engines[0] + await oldest._end_drain( + f"[warn] [drain] {oldest.counters.get('live', 0)} connection(s) carried " + f"from before an earlier restart were closed: {MAX_DRAINING} engines were " + f"already draining") + self._drain = asyncio.create_task(self._wait_out(keep_filtering, time.monotonic())) + + async def _wait_out(self, kept_filtering: bool, began: float) -> None: + told = False + while self.process and self.process.returncode is None: + await asyncio.sleep(0.5) + fresh = self._stats_at >= began + DRAIN_FRESH_AFTER + live = self.counters.get("live", 0) + if fresh and live == 0: + if told: + await self._end_drain("[info] [drain] the connections from before " + "have all closed") + else: + await self._end_drain(None) + return + if fresh and not told: + told = True + self._say( + f"[info] [drain] {live} connection(s) were open through the engine; " + f"they carry on " + f"{'under the filters they started with' if kept_filtering else 'unfiltered'}" + f" until they close, for up to {DRAIN_LIMIT // 60} minutes") + if time.monotonic() - began >= DRAIN_LIMIT: + await self._end_drain( + f"[warn] [drain] {live} connection(s) from before were still open after " + f"{DRAIN_LIMIT // 60} minutes and were closed") + return + await self._end_drain(None) + + async def _end_drain(self, said: str | None) -> None: + engines = _draining.get(self.srv.id, []) + if self in engines: + engines.remove(self) + if not engines: + _draining.pop(self.srv.id, None) + if self._drain and self._drain is not asyncio.current_task(): + self._drain.cancel() + self._drain = None + if said: + self._say(said) + for task in (self._reader_task, self._stderr_pump): + if task: + task.cancel() + self._reader_task = self._stderr_pump = None + await self._kill() + then, self._after_drain = self._after_drain, None + if then: + try: + then() + except Exception: + traceback.print_exc() + + def _say(self, line: str) -> None: + if self.on_engine: + self.on_engine(line) + + +class ExternalTransport(Transport): + """Hand the traffic to a proxy the operator runs themselves. + + Nothing of firegex is in the path: the rules rewrite the destination on the way in + and change it back on the way out, so their proxy sees the connection arrive and the + client sees answers from the address it dialled. It is the escape hatch for a + protocol none of the built-in filters understand — write the proxy, point a service + at it, and firegex arranges the rest. + + Because nothing here inspects anything, a filter attached to such a service would be + a filter that never runs. Saying so beats letting an operator believe their patterns + are in force. + """ + + name = TRANSPORT.EXTERNAL + + @classmethod + def check(cls, srv, chain: list[ChainLink]) -> None: + super().check(srv, chain) + if any(addr.is_interface for addr in srv.addresses): + raise UnsupportedChain( + "the external transport hands traffic to an external proxy and rewrites the " + "source IP on return, which requires a concrete IP address rather than an interface." + ) + missing = [addr for addr in srv.addresses if not addr.proxy_port] + if missing: + raise UnsupportedChain( + f"this service hands its traffic to a proxy you run, so every address " + f"needs the port that proxy listens on for it — " + f"{', '.join(f'{a.ip_int}:{a.port}' for a in missing)} has none. They " + f"cannot share one: the return rule puts the original port back by " + f"recognising your proxy's, so two addresses behind the same endpoint " + f"could not be told apart on the way out." + ) + active = [link for link in chain if link.filter.active] + if active: + raise UnsupportedChain( + f"this service hands its traffic to your own proxy, so firegex inspects " + f"nothing and the {len(active)} filter(s) attached to it would never run. " + f"Deactivate them, or move the service to the proxy transport, which " + f"filters and can still forward." + ) + if srv.decrypts: + raise UnsupportedChain( + f"Decrypting exists so that filters can see the plaintext, and this " + f"service has no filters — nothing of firegex is in its path. Let your own " + f"proxy speak " + f"{'QUIC' if str(srv.proto) == L4.QUIC else 'HTTP itself' if str(srv.proto) == L4.HTTP else 'TLS'}" + f", or move " + f"the service to the proxy layer." + ) + + async def start(self, chain: list[ChainLink]) -> dict: + self.check(self.srv, chain) + # Deliberately nothing to start. The rules are the whole mechanism, and the + # manager installs them right after this returns. + return {} + + async def reload(self, chain: list[ChainLink]) -> None: + self.check(self.srv, chain) + + async def stop(self) -> None: + pass + + +TRANSPORTS = { + TRANSPORT.NFQUEUE: NfqueueTransport, + TRANSPORT.PROXY: ProxyTransport, + TRANSPORT.EXTERNAL: ExternalTransport, +} + + +def build(srv, **kwargs) -> Transport: + try: + return TRANSPORTS[srv.transport](srv, **kwargs) + except KeyError: + raise Exception(f"unknown transport {srv.transport!r}") + + +def build_class(transport: str) -> type[Transport]: + """The transport a name stands for, for asking `check()` before anything exists.""" + try: + return TRANSPORTS[transport] + except KeyError: + raise Exception(f"unknown transport {transport!r}") diff --git a/backend/modules/tls/__init__.py b/backend/modules/tls/__init__.py deleted file mode 100644 index c85f177..0000000 --- a/backend/modules/tls/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# TLS Decrypt Module diff --git a/backend/modules/tls/manager.py b/backend/modules/tls/manager.py deleted file mode 100644 index 6d9d087..0000000 --- a/backend/modules/tls/manager.py +++ /dev/null @@ -1,92 +0,0 @@ -import hashlib -from ipaddress import ip_interface -from utils.sqlite import SQLite - -def ip_parse(ip: str) -> str: - try: - return str(ip_interface(ip).network) - except Exception: - return ip - -def get_tls_ports(ip: str, port: int) -> tuple[int, int]: - ip_addr = ip.split("/")[0] - key = f"{ip_addr}:{port}" - h = int.from_bytes(hashlib.sha256(key.encode('utf-8')).digest()[:4], 'big') - ssl_port = 10000 + (h % 10000) - clear_port = 20000 + (h % 10000) - return ssl_port, clear_port - -TLS_SCHEMA = { - 'tls_streams': { - 'id': 'VARCHAR(100) PRIMARY KEY', - 'name': 'VARCHAR(100) NOT NULL', - 'ip_int': 'VARCHAR(100) NOT NULL', - 'port': 'INT NOT NULL', - 'cert': 'TEXT NOT NULL', - 'key': 'TEXT NOT NULL', - 'status': 'VARCHAR(50) NOT NULL', # 'active' or 'stop' - 'ssl_port': 'INT NOT NULL', - 'clear_port': 'INT NOT NULL', - 'UNIQUE': '(ip_int, port)' - } -} - -class TLSManager: - def __init__(self, db_path='db/nft-tls.db'): - self.db = SQLite(db_path, schema=TLS_SCHEMA) - self.db.init() - - def add_stream(self, stream_id: str, name: str, ip_int: str, port: int, cert: str, key: str, status: str = 'stop') -> None: - ip_int = ip_parse(ip_int) - ssl_port, clear_port = get_tls_ports(ip_int, port) - self.db.query( - "INSERT INTO tls_streams (id, name, ip_int, port, cert, key, status, ssl_port, clear_port) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);", - stream_id, name, ip_int, port, cert, key, status, ssl_port, clear_port - ) - - def get_streams(self) -> list[dict]: - return self.db.query("SELECT * FROM tls_streams;") - - def get_stream(self, stream_id: str) -> dict | None: - res = self.db.query("SELECT * FROM tls_streams WHERE id = ?;", stream_id) - return res[0] if res else None - - def get_stream_by_ip_port(self, ip_int: str, port: int) -> dict | None: - ip_int = ip_parse(ip_int) - res = self.db.query("SELECT * FROM tls_streams WHERE ip_int = ? AND port = ?;", ip_int, port) - return res[0] if res else None - - def update_status(self, stream_id: str, status: str) -> None: - self.db.query("UPDATE tls_streams SET status = ? WHERE id = ?;", status, stream_id) - - def update_stream(self, stream_id: str, name: str | None = None, ip_int: str | None = None, port: int | None = None, cert: str | None = None, key: str | None = None) -> None: - stream = self.get_stream(stream_id) - if not stream: - return - fields: dict = {} - if name is not None: - fields["name"] = name - if cert is not None: - fields["cert"] = cert - if key is not None: - fields["key"] = key - new_ip = ip_parse(ip_int) if ip_int is not None else stream["ip_int"] - new_port = port if port is not None else stream["port"] - if ip_int is not None: - fields["ip_int"] = new_ip - if port is not None: - fields["port"] = new_port - if ip_int is not None or port is not None: - ssl_port, clear_port = get_tls_ports(new_ip, new_port) - fields["ssl_port"] = ssl_port - fields["clear_port"] = clear_port - if not fields: - return - set_clause = ", ".join(f"{k} = ?" for k in fields) - self.db.query(f"UPDATE tls_streams SET {set_clause} WHERE id = ?;", *fields.values(), stream_id) - - def delete_stream(self, stream_id: str) -> None: - self.db.query("DELETE FROM tls_streams WHERE id = ?;", stream_id) - - def get_active_streams(self) -> list[dict]: - return self.db.query("SELECT * FROM tls_streams WHERE status = 'active';") diff --git a/backend/modules/tls/nftables.py b/backend/modules/tls/nftables.py deleted file mode 100644 index 3a307a5..0000000 --- a/backend/modules/tls/nftables.py +++ /dev/null @@ -1,43 +0,0 @@ -from utils import NFTableManager, nftables_int_to_json, ip_family -from modules.tls.manager import TLSManager - -class TLSTables(NFTableManager): - nat_chain = "tls_nat" - - def __init__(self): - super().__init__([ - {"add":{"chain":{ #NAT chain for DNAT - "family":"inet", - "table":self.table_name, - "name":self.nat_chain, - "type":"nat", - "hook":"prerouting", - "prio":-100, - "policy":"accept" - }}} - ],[ - {"flush":{"chain":{"table":self.table_name,"family":"inet", "name":self.nat_chain}}}, - ]) - - def reload(self): - self.init() - manager = TLSManager() - streams = manager.get_active_streams() - cmds = [] - for srv in streams: - loopback_ip = "::1" if ip_family(srv["ip_int"]) == "ip6" else "127.0.0.1" - cmds.append({"insert":{ "rule": { # Redirect TLS traffic to local Nginx SSL proxy - "family": "inet", - "table": self.table_name, - "chain": self.nat_chain, - "expr": [ - {'match': {'left': {'payload': {'protocol': ip_family(srv["ip_int"]), 'field': 'daddr'}}, 'op': '==', 'right': nftables_int_to_json(srv["ip_int"])}}, - {'match': {'left': {'payload': {'protocol': 'tcp', 'field': 'dport'}}, 'op': '==', 'right': int(srv["port"])}}, - {'match': {'left': {'meta': {'key': 'iifname'}}, 'op': '!=', 'right': 'lo'}}, - {'dnat': {'family': ip_family(srv["ip_int"]), 'addr': loopback_ip, 'port': int(srv["ssl_port"])}} - ] - }}}) - if cmds: - self.cmd(*cmds) - -tls_firewall = TLSTables() diff --git a/backend/modules/tls/nginx.py b/backend/modules/tls/nginx.py deleted file mode 100644 index 5c210e4..0000000 --- a/backend/modules/tls/nginx.py +++ /dev/null @@ -1,83 +0,0 @@ -import subprocess -import os -import glob -from modules.tls.manager import TLSManager - -NGINX_CONF_PATH = "/tmp/firegex_nginx.conf" -NGINX_PID_PATH = "/tmp/firegex_nginx.pid" -NGINX_LOG_PATH = "/tmp/firegex_nginx_error.log" - -def update_nginx() -> None: - manager = TLSManager() - streams = manager.get_active_streams() - - if not streams: - subprocess.run(["nginx", "-c", NGINX_CONF_PATH, "-s", "stop", "-e", NGINX_LOG_PATH], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - - # Clean up certificates, configuration, and pid - for pattern in ("/tmp/firegex_cert_*", "/tmp/firegex_key_*", NGINX_CONF_PATH, NGINX_PID_PATH, NGINX_LOG_PATH): - for path in (glob.glob(pattern) if "*" in pattern else [pattern]): - try: - os.remove(path) - except OSError: - pass - return - - servers_config = [] - for srv in streams: - srv_id = srv["id"] - cert_path = f"/tmp/firegex_cert_{srv_id}.crt" - key_path = f"/tmp/firegex_key_{srv_id}.key" - - with open(cert_path, "w") as f: - f.write(srv.get("cert") or "") - with open(key_path, "w") as f: - f.write(srv.get("key") or "") - - ssl_port = srv["ssl_port"] - clear_port = srv["clear_port"] - - ip_addr = srv["ip_int"].split("/")[0] - is_ipv6 = ":" in ip_addr - loopback_ip = "[::1]" if is_ipv6 else "127.0.0.1" - dest_ip = f"[{ip_addr}]" if is_ipv6 else ip_addr - - servers_config.append( - f" server {{\n" - f" listen {loopback_ip}:{ssl_port} ssl;\n" - f" proxy_pass {loopback_ip}:{clear_port};\n" - f" ssl_certificate {cert_path};\n" - f" ssl_certificate_key {key_path};\n" - f" ssl_protocols TLSv1.2 TLSv1.3;\n" - f" }}\n" - f" server {{\n" - f" listen {loopback_ip}:{clear_port};\n" - f" proxy_pass {dest_ip}:{srv['port']};\n" - f" proxy_ssl on;\n" - f" proxy_ssl_verify off;\n" - f" proxy_ssl_protocols TLSv1.2 TLSv1.3;\n" - f" }}" - ) - - config_content = "" - if os.path.isdir("/usr/share/nginx/modules"): - config_content += "include /usr/share/nginx/modules/*.conf;\n" - - config_content += ( - f"pid {NGINX_PID_PATH};\n" - f"error_log {NGINX_LOG_PATH} info;\n\n" - f"events {{\n" - f" worker_connections 1024;\n" - f"}}\n\n" - f"stream {{\n" - + "\n".join(servers_config) + "\n" - f"}}\n" - ) - - with open(NGINX_CONF_PATH, "w") as f: - f.write(config_content) - - # Try to reload. If Nginx is not running, start it. - res = subprocess.run(["nginx", "-c", NGINX_CONF_PATH, "-s", "reload", "-e", NGINX_LOG_PATH], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - if res.returncode != 0: - subprocess.run(["nginx", "-c", NGINX_CONF_PATH, "-e", NGINX_LOG_PATH], check=True) diff --git a/backend/modules/tls/service.py b/backend/modules/tls/service.py deleted file mode 100644 index 1ad73fb..0000000 --- a/backend/modules/tls/service.py +++ /dev/null @@ -1,22 +0,0 @@ -from modules.tls.manager import TLSManager -from modules.tls.nginx import update_nginx -from modules.tls.nftables import tls_firewall -from utils import socketio_emit - -async def reload_tls(): - update_nginx() - tls_firewall.reload() - await socketio_emit(["tls_streams"]) - -async def activate_stream(stream_id: str | None) -> bool: - """Activate a TLS stream if it exists and isn't already active. Returns False if the stream doesn't exist.""" - if not stream_id: - return False - manager = TLSManager() - stream = manager.get_stream(stream_id) - if not stream: - return False - if stream["status"] != "active": - manager.update_status(stream_id, "active") - await reload_tls() - return True diff --git a/backend/proxysrc/Cargo.lock b/backend/proxysrc/Cargo.lock new file mode 100644 index 0000000..585e9b7 --- /dev/null +++ b/backend/proxysrc/Cargo.lock @@ -0,0 +1,1061 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fgex-proxy" +version = "0.1.0" +dependencies = [ + "bytes", + "h2", + "h3", + "h3-quinn", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "libc", + "quinn", + "rcgen", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "socket2", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h3" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10872b55cfb02a821b69dc7cf8dc6a71d6af25eb9a79662bec4a9d016056b3be" +dependencies = [ + "bytes", + "fastrand", + "futures-util", + "http", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "h3-quinn" +version = "0.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2e732c8d91a74731663ac8479ab505042fbf547b9a207213ab7fbcbfc4f8b4" +dependencies = [ + "bytes", + "futures", + "h3", + "quinn", + "tokio", + "tokio-util", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "futures-io", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/backend/proxysrc/Cargo.toml b/backend/proxysrc/Cargo.toml new file mode 100644 index 0000000..e97884d --- /dev/null +++ b/backend/proxysrc/Cargo.toml @@ -0,0 +1,78 @@ +[package] +name = "fgex-proxy" +version = "0.1.0" +edition = "2021" +description = "Proxy datapath engine for firegex (fail-open spike)" +license = "GPL-3.0-or-later" + +[dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "io-std", "macros", "time", "sync"] } +# Raw setsockopt: IP_TRANSPARENT and SO_ORIGINAL_DST have no safe wrapper. +libc = "0.2" +socket2 = "0.6" +# Filter rules arrive as one JSON line on stdin +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# TLS terminated by the engine, so the plaintext never leaves the process. +# `ring` rather than the default aws-lc-rs: it builds without cmake or nasm, which +# keeps the Docker stage a plain `cargo build`. +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "tls12", "std", "logging"] } +rustls-pemfile = "2" +# QUIC, terminated by the engine for the same reason TLS is: after the Initial packet +# everything — frames, stream boundaries, even the packet number — is encrypted, so a +# layer that only forwards has nothing a filter could be shown. `ring` again, and the +# same rustls the TCP side already links. +quinn = { version = "0.11", default-features = false, features = ["runtime-tokio", "rustls-ring", "log"] } +# HTTP/3, terminated on top of QUIC. A binding rather than a second implementation: the +# frames and QPACK are somebody else's problem, and what this engine does with them — +# presenting each exchange to the chain as the HTTP/1.1 it would have been — is the part +# that belongs here. +h3 = "0.0.8" +h3-quinn = "0.0.10" +# HTTP/2, terminated on TCP for the reason HTTP/3 is terminated on QUIC: its method, path +# and headers are HPACK-compressed, so a layer that only forwards shows a filter a +# compression format. A binding again — HPACK, multiplexing and flow control are the +# crate's problem, and a second implementation of HTTP/2 in this tree is exactly what +# `AGENTS.md` forbids. +h2 = "0.4" +# HTTP/1.1 towards the service, for the one thing this engine could not do: speak an +# older version to the service than the client used. A client edge that is terminated — +# HTTP/3 over QUIC — is already rendered to the chain as HTTP/1.1, so putting those same +# bytes on a socket is not a translation invented here, it is the rendering being sent +# where the service can read it. The *response* is what needs the crate: HTTP/1.1 is the +# one version this engine never parsed, and inventing a parser for it beside hyper's is +# the thing `AGENTS.md` forbids. Client and http1 only — nothing here serves HTTP/1.1, +# and the h2 client above stays h2's. +hyper = { version = "1", default-features = false, features = ["client", "http1"] } +hyper-util = { version = "0.1", default-features = false, features = ["tokio"] } +http-body-util = "0.1" +http-body = "1" +bytes = "1" +http = "1" + +[dev-dependencies] +# Test certificates are generated, never committed. +rcgen = "0.13" +# The QUIC tests stand a real client and a real service either side of the relay: the +# same crates the engine links, so what they prove is what will run. +quinn = { version = "0.11", default-features = false, features = ["runtime-tokio", "rustls-ring"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +# And a real HTTP/3 service and client either side of it, for the same reason. +h3 = "0.0.8" +h3-quinn = "0.0.10" +# The same, for HTTP/2: the tests stand a real h2 client and a real h2 service either +# side of the proxy, so what they prove is what will run. +h2 = "0.4" +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] } +bytes = "1" +http = "1" + +# Catching a panicking filter is what keeps the datapath alive, so the binary +# must unwind rather than abort. This is not a tuning knob, it is load-bearing. +[profile.release] +panic = "unwind" +opt-level = 3 + +[profile.dev] +panic = "unwind" diff --git a/backend/proxysrc/README.md b/backend/proxysrc/README.md new file mode 100644 index 0000000..e39910e --- /dev/null +++ b/backend/proxysrc/README.md @@ -0,0 +1,592 @@ +# The proxy datapath engine + +`fgex-proxy`: one of the two network layers a service can be put on. Instead of lifting +packets to userspace and handing a verdict back to the kernel — which is what +`backend/binsrc/` does — it terminates the connection and owns both halves. + +It is what a service selects as its **proxy** layer in `modules/services/transports.py`, +and it is what makes TLS, QUIC, HTTP/2 and HTTP/3 filterable at all. + +> This file is the engine's own documentation: what it does, and why it is shaped this +> way. The product-level view — which layer to choose and what each one costs — is +> [`docs/services.md`](../../docs/services.md), and the rules a change here has to keep +> are in [`AGENTS.md`](../../AGENTS.md). Where any of the three disagree, `AGENTS.md` +> wins; this file has been wrong before by being left behind while the engine moved. + +## What it has to prove first + +The NFQUEUE engine fails open in the kernel: the nft rule carries `flags: ["bypass"]` and +the binary asks for `NFQA_CFG_F_FAIL_OPEN`. If firegex dies, stalls, or falls behind, +packets flow anyway. In attack/defense that is the difference between "the filter didn't +fire" and "I lost the SLA". + +A userspace proxy has no such backstop: if the process dies, the socket dies with it. So +the property is rebuilt by hand, and it has to hold against user code that is assumed to +be broken: + +| Failure | Handled by | Outcome | +| --- | --- | --- | +| Filter panics | `catch_unwind` around every call | Chunk forwarded, filter disabled at once | +| Filter never returns | Deadline on the blocking task | Chunk forwarded, filter disabled after 3 in a row | +| Every filter disabled | `degraded` flag | Chain skipped entirely — no copy, no thread hop | +| Connection handler panics | One task per connection | That connection only; the listener lives | +| Python worker hangs or dies | Killed and respawned on the next chunk | That chunk forwarded | +| Backend goes away | stdin closing is not a shutdown | The current ruleset keeps being enforced | + +The degraded path is deliberately the cheapest one. Failing open must not cost more than +working normally, or the failure mode becomes a second failure. `panic = "unwind"` in +`Cargo.toml` is load-bearing rather than tuning: an aborting binary cannot catch anything. + +`tests/fail_open.rs` asserts each row over real sockets. + +## What the proxy buys + +- **Every version of HTTP, filtered the same way.** HTTP/2 and HTTP/3 compress their + method, path and headers (HPACK, QPACK), so a layer that only forwards shows a filter a + compression format. This one terminates them and renders each exchange as the HTTP/1.1 + it would have been, so one pattern and one Python file cover all three. +- **TLS and QUIC.** The plaintext never leaves the process. +- **Reassembly from the kernel**, instead of a stream follower and the out-of-order caveat + that comes with it. +- **An ordered chain inside one process**, rather than one process and one reassembly pass + per filter. +- **Real connection metadata.** A filter is told the client's and the service's address + and port once per connection (`ConnectionMeta`, a `KIND_OPEN` frame), and nothing below + the application layer beyond that. That is more than a header could honestly say here: + this engine terminated the connection, so the headers on the wire are its own. +- **Backpressure for free.** A slow filter slows the sender through TCP flow control + rather than overflowing a fixed-size queue and dropping packets. +- **Clean closes.** A refused connection is shut down properly instead of leaving the peer + to infer something from missing packets. + +What it costs is the kernel backstop above, and being in the path at all: the connection +the service sees is one this process opened. + +## The limitation that decided the design + +A blocking filter thread **cannot be cancelled**. When one blows the deadline the engine +walks away, but the thread lives until its own code decides to stop — it holds a +blocking-pool slot until the process restarts. The damage is bounded (a filter that times +out repeatedly is disabled and never called again) but bounded is not zero. + +That is why user Python runs **out of process**: a hung worker can be killed, a hung +thread cannot. It also keeps `libpython` out of the datapath binary, which is where a good +share of the C++ engine's complexity lives. + +Compiled patterns cannot hang, so they declare `may_block() == false` and run **inline** +against the relay's own buffers — no thread hop, no copy. That is not a micro-optimisation: +it took ten rules from ~1260 to ~2950 MB/s in the benchmark that found it. The deadline +machinery is still there and still the default; it is simply no longer charged to filters +that cannot use it. + +## Rules + +One JSON line on stdin carries the whole ruleset, one `ACK OK` / `ACK FAIL ` line +comes back — the same shape `cppregex` uses for its filter codes. Blocks are reported as +`BLOCKED ` so the backend can attribute them. + +```json +[{"kind":"regex","id":"r1","filter":"f1","pattern":"FLAG\\{[a-z]+\\}","direction":"s2c","case_sensitive":true}, + {"kind":"python","id":"f2","code_path":"/execute/db/service_filters/f2.py", + "enabled":["block_traversal"],"command":["python3","/execute/modules/services/pyworker.py"], + "timeout_ms":1000}] +``` + +Four decisions worth knowing: + +- **A pattern blocks. There is no second action.** There used to be a `replace` rule, and + it was withdrawn because it could not keep its promise: rewriting scanned one chunk at a + time — bytes already forwarded cannot be taken back — so a match straddling two chunks + was never rewritten. Measured: a pattern sent whole arrived redacted, the same pattern + split across two TCP segments arrived intact, and blocking (which scans in hyperscan's + stream mode) caught both. The miss was not the problem; its silence was. +- **The order the backend sent is the order that runs.** `filter` groups the patterns of + one link so they share a compiled database, which is what makes fifty patterns cost + about what one costs — and a group is exactly a filter, so grouping never crosses a + boundary the operator can see. An earlier version grouped every regex and then every + Python rule, which turned the chain into a set. `tests/rules.rs` pins both. +- **A rejected ruleset changes nothing.** One bad pattern refuses the whole update and the + previous rules keep enforcing. Failing open covers a filter that breaks at runtime; it + is not a licence to quietly drop rules the operator asked for. Each pattern is compiled + alone first and thrown away, only so a failure can name *which* rule is wrong — + hyperscan reports the first one and stops. +- **A block closes the connection, not the direction.** Half-closing would let a service + that answers without waiting for the request still reach a client whose request was + refused. On QUIC and HTTP/2 the refusal closes the *connection* rather than the stream, + for the same reason: a block a client can simply retry on the next stream is not a block. + +Matching is hyperscan (`src/hyperscan.rs`, a binding to `libhs`), the same library +`cppregex` uses on the other layer. There is no second regex engine in this tree, and +adding `regex` to `Cargo.toml` would undo that. + +## Python filters + +The API is the one a `firegex` user already knows, and it is the **same library** the +NFQUEUE binary embeds — `firegex.pyfilters`, with all of its models: + +```python +from firegex.pyfilters import pyfilter, ACCEPT, REJECT, HttpRequest + +@pyfilter +def block_path_traversal(req: HttpRequest): + return REJECT if "../" in req.url else ACCEPT +``` + +`modules/services/pyworker.py` is the child process that runs it. A filter file's protocol +is read off the annotations, never declared; what a filter may read and write is the same +on both layers (`RawPacket` metadata in, payload out, nothing below the application +layer). See [`docs/pyfilter.md`](../../docs/pyfilter.md). + +Frames are length-prefixed binary on the worker's stdin/stdout: +`[u32 len][u8 kind][u64 connection][payload]`, big-endian, `len` covering everything after +itself. + +| kind | meaning | +| --- | --- | +| `0` / `1` | a chunk, client→server / server→client | +| `2` | the connection is over — so the worker can drop its module globals | +| `3` | the connection's metadata, one JSON object, once | + +The reply is `[u32 len][verdict][name len][name][payload]`. The name is whichever +`@pyfilter` function decided, so a block is attributed as `/` — the same +token the NFQUEUE binaries report, which is what lets the backend have one way to +attribute one. + +Three things are load-bearing: + +- **The worker announces itself** (`READY`) once it has executed the user's file. Waiting + for it is what lets a ruleset carrying code that does not load be refused *when it is + applied*, rather than discovered later as traffic that quietly stopped being filtered. +- **The connection id is in every frame**, because the documented promise is that each + stream gets its own module globals. One client's state deciding another client's verdict + is both a false positive and a way to smuggle a pattern past a filter. +- **`stdout` is the protocol channel**, so the worker reassigns `sys.stdout = sys.stderr` + before running user code: a `print()` landing in the middle of a frame gets the worker + killed on every packet with nothing to explain it. `tests/pyworker.rs` has a regression + test for exactly that. + +Tracebacks go to stderr, where the backend already reads them; the worker also marks them +so the backend can say *the traffic was forwarded unfiltered* alongside the traceback, +which is the sentence the NFQUEUE side produces from an `EXCEPTION`. + +One worker per rule, one conversation at a time. Serialising is the honest simple thing +while a frame is a request and a response; a pool is the next step if Python filtering +ever becomes the bottleneck. + +## TLS + +The engine terminates it: it decrypts from the client, the chain sees plaintext that never +leaves the process, and it re-encrypts on the way out — `rustls` with the `ring` provider, +so there is no OpenSSL in the build and the Docker stage stays a plain `cargo build`. + +What that replaced was **nginx**: one listener terminating the public connection and +forwarding plaintext to a second loopback port, which re-encrypted towards the real +service, with the filter engine attached to the leg in between. It cost a generated config, +**a pair of ports derived from hashing `ip:port`** — chosen rather than assigned, so they +could collide with something real — a `tls_streams` table, an nft chain of its own, and a +failure mode where one unloadable certificate stopped nginx and took every TLS service on +the instance down with it. A TLS service now occupies no port a plain one does not, and +the blast radius is structural: each service carries its certificate into its own process. + +**ALPN is mirrored, and the ordering is the design.** The client's handshake is *started* +and held at the ClientHello (`tokio_rustls::LazyConfigAcceptor`), because that message +carries the protocols the client will speak; the upstream handshake goes next offering +exactly that list; and whatever the service picked is the single protocol the client is +then told. Answering something the service did not say is how a proxy breaks a protocol it +was only carrying — a client told `h2` against an HTTP/1.1 service sends frames nothing can +read. When the service picks nothing, the client is told nothing: no agreement is invented. +Before this, ALPN was dropped in both directions and every client silently fell back to +HTTP/1.1. + +**The upstream certificate is not verified**, mirroring the `proxy_ssl_verify off` nginx +was configured with. That is the right call rather than laziness: the service on the other +side is the thing firegex is defending, it is behind loopback or a private link, and in a +competition it is invariably self-signed. Verifying it would only ever refuse to protect it. + +Certificate and key reach the engine as **paths**, never as environment values: a private +key in an env dump is a private key in a log. + +What cannot be carried, and is a limit rather than a missing setting: **TLS 1.0 and 1.1**, +which rustls does not implement; the cipher list, which is fixed and forward-secret only; +and **client certificates**, since the certificate a client would present is one only the +service can check. The key must be one `ring` will sign with — RSA ≥ 2048, ECDSA or +Ed25519; nginx could be told to take a 1024-bit key with `@SECLEVEL=1` and rustls has no +equivalent, so a smaller one is refused at startup with the reason on stderr. + +## QUIC, and HTTP/3 + +**QUIC is terminated, and unlike TLS there was never a second option.** TLS over TCP can +be carried past unopened — the bytes are framed by a transport the kernel understands — so +filtering the ciphertext is a thing an operator could choose. Past its Initial packet QUIC +encrypts the frames, the stream boundaries and the packet number along with the payload, +so a packet queued to NFQUEUE is a datagram of noise. + +To the kernel it is UDP, so the addresses, the rules and the relay map are the code that +was already there for datagrams; what changes is that the port is bound by something that +terminates (`quic::QuicManager`) instead of something that forwards (`udp::UdpManager`). +`relays::Relays` is the two-armed enum both the startup list and the control channel go +through. + +- **The ALPN is mirrored, as on the TLS path.** quinn offers nothing to hold a ClientHello + at, but an Initial packet's keys come from the connection ID it carries in the clear, so + `quic_hello.rs` reads the client's list off the socket before the handshake is answered. + The service is dialled first offering exactly that list, and the client is advertised + the one protocol the service chose — nothing to configure, whatever the service speaks. + A hello that cannot be read falls back to offering `h3`, and says so. +- **One stream is one connection to the chain**, with its own sessions and its own module + globals, released when the stream ends. A filter's state follows a stream of bytes from + start to end, which is what a QUIC stream is and what a QUIC connection is not. +- **A refusal closes the connection**, not the stream. +- **Datagrams are carried, and one connection's are one flow.** `L4::QuicDatagram` is its + own value rather than `Quic` with a flag, because the two answer the stream question + differently: a datagram gets `RawPacket` and nothing else, which is the same answer the + plain UDP relay gives. The flow opens on the first datagram, not on the connection, so a + connection that negotiates the extension and never uses it costs no filter state. A + refused datagram is **dropped and the connection carries on** — a datagram is complete by + the time it is judged. `DATAGRAM_BUFFER` bounds what one connection may have waiting. +- **They are not carried on HTTP/3, and the handshake says so** rather than the peer + finding out: an HTTP/3 datagram names the stream it belongs to, and the request streams + this proxy opens towards the service are not the ones the client opened. So the `h3` + accept configuration is built with a transport that does not advertise the extension. +- **WebTransport is not carried**, and it is a bigger question than its datagrams: its + streams are bound to a session by a varint every one of them carries, which breaks both + "one stream is one connection to the chain" and the HTTP/1.1 rendering. It needs a + session model, not a switch. +- **0-RTT is refused** (`max_early_data_size = 0`): early data is replayable and a refused + request cannot be un-delivered. And **every unvalidated client gets a Retry**, so nothing + is opened towards the service on the word of a source address nobody has checked. + +**HTTP/3 is rendered to the chain as the HTTP/1.1 it would have been** (`src/h3.rs`, on +hyperium's `h3` — a binding, not a second implementation). That is the point of the whole +exercise: QPACK would otherwise be what a filter is shown, and every pattern written for +the TCP service beside it would silently stop matching. + +## HTTP/2, and one rendering for every version + +**`src/http1.rs` is the rendering, and `h2.rs`/`h3.rs` are what is left of each protocol +once it is taken out.** The HTTP/1.1 view a filter is shown — the framing decision, the +head hold, `render_request`/`render_response`, `judge` — lives in one place, and both +protocols are adapters to somebody else's parser. Two copies would be two things a filter +can be shown, and the day they drift the symptom is a pattern that matches over one +version and not another with nothing to say so. + +An HTTP/2 client can be put in front of a service that speaks HTTP/1.1: `h2.rs` takes its +upstream as an `Outbound`, so it is either another HTTP/2 connection or `h1up::H1Upstream` +where the address said the service is cleartext — exactly as on the QUIC edge. + +Three things differ from h3 and each is a way to get this wrong: + +- **Flow control is ours to run.** `h2` hands received bytes over and waits for them to be + released. Releasing on read means no backpressure and this process buying memory for + whichever side is faster; never releasing stalls the stream after the first window. So + capacity is released **after the piece has been forwarded**, and outbound writes reserve + capacity before writing. +- **The end of a message rides on its last frame.** A head that *is* the whole message has + to be sent with `end_of_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` + asks the sender rather than inferring. +- **One stream is one connection to the chain**, unlike HTTP/1.1 where a keep-alive + connection carries many requests through one set of filter state. HTTP/2 interleaves its + streams, and sharing state between two of them is a way to smuggle a pattern past a + filter by splitting it across them. + +Also: + +- **`h2c` — HTTP/2 in the clear — is recognised from the connection preface, not from a + setting.** Most gRPC is deployed without TLS behind a load balancer, so leaving it out + would have made "gRPC is filtered" half true. `sniff()` in `proxy.rs` **peeks** rather + than reads, so a connection that turns out to be something else is handed to the byte + pumps exactly as it arrived and there is no buffer to replay. It **races the service**, + because a protocol where the server speaks first (SMTP, SSH, most game protocols) would + otherwise be held waiting for a client that is correctly waiting for a banner — and it is + bounded (`SNIFF_WAIT`), so two silent peers cannot hold two descriptors. The same peek + answers the TLS question for an `http` service, which is why there is one `Opening` and + not two sniffs. +- **Server push is refused in the handshake**, not by dropping what arrives, and + **extended CONNECT is not advertised**; a plain `CONNECT` stream is reset with a log line + rather than rendered, because a tunnel is opaque bytes with no HTTP/1.1 message in it. +- **A refusal closes the connection with `Reason::CANCEL`**, deliberately not + `REFUSED_STREAM`, which tells a client the request was never acted on and may be retried. +- **A bypassed chain is not terminated.** Rendering for a chain with nothing to say is work + paid for no answer, so a service with no filters, one whose filters have all been + disabled, and a connection admitted past the limit with `over_limit_forwards` all take + the byte pump. The cost is that such a connection stays a byte pump for its whole life + even if a filter is pushed a moment later. +- **The HTTP/1.1 `Upgrade: h2c` handshake is not carried**, and that is a limit rather than + an unconfigured setting: the upgrade request is filtered as the HTTP/1.1 request it is, + but if the service answers `101` everything after it is HTTP/2 nobody rendered. RFC 9113 + deprecated the mechanism and nginx dropped it in 1.25.1. + +## UDP + +The TCP path recovers where a connection was headed with `SO_ORIGINAL_DST`, which the +kernel implements **for TCP and SCTP only**. So UDP is relayed with **one socket per +protected address**, each with its upstream fixed — nothing has to be recovered because +nothing was lost. The engine prints one `UDP ` line per relay after +`PORT`, and the backend points each address's rule at its own port. + +Per-flow filter state is keyed by client address and released after `IDLE`: UDP has no +close to observe, so a timeout is the only thing that ends a flow. Both directions are +inspected. `Verdict::Reject` drops the datagram rather than closing anything. Replies leave +through the **listener** socket so conntrack rewrites them to appear from the address the +client dialled; sending from the upstream socket would reach a client not expecting that +source. The relay's own dial carries the self-mark, or the redirect rule would catch it and +loop. + +New relays can be added to a running engine over the control channel, so an address added +to a running service costs nobody their connection. + +## Holding up under load + +**A service carries at most `FGEX_PROXY_MAX_CONNECTIONS` at once**, and the operator says +what happens to the rest. It exists because the alternative was measured: silent +connections cost two descriptors each — one from the client, one to the service, since the +upstream is dialled on accept — and ~505 of them from one host exhausted the container's +1024 and took **every** service down. UDP was cheaper still: 600 datagrams from 600 forged +sources in 0.03 s took 400 descriptors and held them a minute after the sender left. + +Say what it buys, and no more. **A cap does not save the service being attacked** — it +cannot tell a connection that is silent because it is an attack from one that is silent +because the client is slow. What it buys is that one service's attacker stops being +everyone's: measured, 400 silent connections against a limited service left the service +beside it answering. + +- One number for both halves, because TCP connections and UDP flows spend the same + descriptors. `0` is no limit, kept as a value rather than an absence. +- `Slot` is a guard with a `Drop`, not a decrement at the end of `handle_connection`: that + function has a dozen ways out, and a counter that leaks on any one of them is a limit + that tightens until it refuses everything. +- The accept loop **backs off** after a failed accept. Out of descriptors, the loop spun on + `accept` → `EMFILE` → print → `accept`, measured at ~40% of a core with four clients + knocking, writing a log line per attempt into the pipe the backend reads. +- On QUIC, `over_limit_forwards` is honoured but is not free: the connection is still + terminated, decrypted and re-encrypted, because there is no such thing as forwarding a + QUIC connection unopened. + +**`FGEX_PROXY_FIRST_BYTE_TIMEOUT` is what the cap cannot be.** A cap contains a phantom +flood; a deadline ends it — measured, with the limit full and the attacker still holding +every socket, clients got back in once it passed. Two properties are load-bearing: + +- **Either direction counts.** The flag is set by whichever pump moves a byte first, so a + service that greets its client satisfies it with its banner. Requiring the *client* to + speak would hang every server-speaks-first protocol. +- **Only until the first byte, never again.** A connection that has spoken and gone quiet + is a session, and sessions think. This is not an idle timeout. + +The flag is set *before* the chain runs, because a chunk a filter goes on to refuse is +still a connection that said something. The watchdog fires through the same stop signal a +refusal uses, so both directions shut down cleanly — a dropped peer reads a truncation, and +this connection has done nothing to deserve one. + +## Preserving the client address + +The service must keep seeing the real client, or everything that logs, rate-limits or bans +by address starts seeing one address for the whole internet. Source preservation is +**unconditional**: the engine always dials the service from the client's address +(`IP_TRANSPARENT`, with a kernel-chosen port). `CAP_NET_ADMIN` is probed at startup, +because a missing capability would otherwise surface only as every service quietly seeing +the proxy's address. If a transparent dial fails at runtime the engine falls back to its +own address — traffic beats identity — but counts it and says so once, loudly. + +The work is in getting the reply back. With the service on this same host, two flows look +almost identical: + +| Flow | src | dst | must | +| --- | --- | --- | --- | +| Engine answering the client | `service:port` | `client:client_port` | leave the box | +| Service answering the engine | `service:port` | `client:` | stay local | + +No address tells them apart — only an ephemeral port chosen at connect time, which nothing +can be written against. **Conntrack can**: the first belongs to the intercepted, DNATed +connection, the second is a separate entry the engine opened itself. So the output chain +reads `ct status dnat accept` for the first and marks everything else from a protected +service home, and the marked packets are delivered locally by an `fwmark` rule plus a +`local` default route. `modules/services/nftables.py` installs all of it. + +**Only the redirect intercept exists.** A `tproxy` mode was implemented and removed: the +intercept it creates makes no NAT entry, so there is no marker to separate those two flows, +and a divert rule broad enough to catch the service's reply also reroutes the engine's own +SYN-ACK away from the client — the connection then hangs rather than fails. That was +measured. Since the topology firegex actually runs in has the service on the same host or +behind a private link, the mode that cannot serve it was not worth keeping. + +The engine stamps `SO_MARK` (`SELF_MARK`, `0x133A`) on every connection it opens, and the +intercept rules skip it — otherwise, where both ends are local, an intercept in `nat output` +would catch the engine's own dial and loop it back into itself. + +## Where the plaintext goes + +Terminating in-process means the decrypted traffic is never a packet, so there is nothing +for a capture tool to read. `src/capture.rs` writes it out: each connection's plaintext +framed as the TCP stream it was, sent over `AF_PACKET` to a dummy interface (`firegex0`) +that carries every decrypted service's traffic and nothing else. + +They are **reconstructed** packets. The bytes are real — what the filters saw and what was +forwarded — but the framing is built here, because the framing on the wire was encrypted: +sequence numbers from zero, no retransmissions, segmentation from this engine's read sizes. +A SYN and a FIN are emitted so a capture tool has a stream to follow rather than orphan +segments. **Say so wherever it is offered: a capture from here is not evidence of what was +on the wire.** + +On QUIC each *stream* is written as its own TCP conversation, with a synthetic client port +from a cycling counter — every stream of one connection shares the connection's four-tuple, +and writing them out as they are would interleave a hundred of them into one conversation +whose bytes decode as nothing. On HTTP/3 and HTTP/2 what is written is the **rendering**, +which is the only way this interface can keep the promise it makes everywhere else: what is +on it is what the filters saw. It is not what left the process, and the two cannot disagree +because they are the same bytes. + +Every failure is silent and local. No interface, no socket (it needs `CAP_NET_RAW`), a send +that fails — the traffic goes on exactly as it would have. A capture aid must never be able +to interrupt the service it is watching. The interface itself belongs to the instance and +is created by `modules/services/mirror.py` before any engine starts. + +## Talking to it + +Two channels, both line-oriented, and the datapath never waits on either. + +**stdout — what the engine says.** The handshake first, then events: + +| line | meaning | +| --- | --- | +| `PORT ` | the port it bound. Pass `:0` and read this back; nothing keeps a registry | +| `UDP ` | one per relay, in the order they were asked for | +| `BLOCKED ` | a rule refused something; `/` for Python | +| `ACK OK` / `ACK FAIL ` | the answer to one control line | +| `STATS seen=… refused=… live=… over_limit=… no_first_byte=…` | every 2 s, first one immediately | + +Everything descriptive — `[info]`, `[warn]`, `[fatal]` — goes to **stderr**, which the +backend captures and turns into the service's log. `_died_because` keeps the last `[fatal]` +line, so a service that will not start reports the engine's own reason rather than "the +proxy engine did not report a listening port". + +**stdin — what it is told.** One command per line: + +| line | meaning | +| --- | --- | +| `[{…}, {…}]` | the whole ruleset, replacing the previous one | +| `ADD_UDP [same\|plain\|tls]` | bind one more relay; answers `UDP …` then `ACK` | +| `PUBLISH [\|-]` | what is spoken at one address and where the service behind it is | +| `WITHDRAW ` | forget one published address | + +stdin closing is **not** a shutdown: the backend being gone is exactly when the datapath +outliving its control channel matters. + +## Environment + +| variable | meaning | +| --- | --- | +| `FGEX_PROXY_LISTEN` | **required.** `[::]:0` for dual-stack, which is what the backend passes | +| `FGEX_PROXY_UPSTREAM` | **required.** `original` (recover it per connection) or a literal address | +| `FGEX_PROXY_SPOOF_SOURCE` | dial the service as the client. The backend always sets it | +| `FGEX_PROXY_TARGETS` | `[\|[\|]][=]`, comma-separated; the addresses that are not simply the service | +| `FGEX_PROXY_UDP` | `[\|]`, comma-separated; one relay each | +| `FGEX_PROXY_TLS` / `_TLS_OPTIONAL` | terminate TLS on the TCP listener; `_OPTIONAL` means only for the connections that start a handshake | +| `FGEX_PROXY_TLS_CERT` / `_TLS_KEY` | **paths**, never the material | +| `FGEX_PROXY_QUIC` | bind the relays with a QUIC endpoint instead of a datagram socket | +| `FGEX_PROXY_MAX_CONNECTIONS` | 0 is no limit | +| `FGEX_PROXY_OVER_LIMIT_FORWARD` | forward what does not fit, unfiltered, instead of refusing it | +| `FGEX_PROXY_FIRST_BYTE_TIMEOUT` | seconds; 0 is off | +| `FGEX_PROXY_FILTER_TIMEOUT_MS` | how long a blocking filter gets. Default 2000 | +| `FGEX_PROXY_CONNECT_TIMEOUT_MS` | upstream dial and handshakes. Default 5000 | +| `FGEX_PROXY_SELF_MARK` | hex; default `133A`. Must match `PROXY_SELF_MARK` in the backend | +| `FGEX_PROXY_PYWORKER` | fallback path to the worker, for a Python rule that carries no `command` | +| `FGEX_PROXY_FILTERS` | `panic`, `hang`, `block:` — **test scaffolding only** | +| `NTHREADS` | tokio worker threads. What `run.py --threads` sets, on both layers | + +`FGEX_PROXY_BINARY` is read by the *backend*, not by this, and points at the binary — which +is what a dev build overrides. + +There is one more mode: **`--debug-regex`** reads one JSON request on stdin, answers on +stdout and exits, starting no listener and touching no rules. It is what +`POST /api/services/debug-regex` shells out to, so the in-app pattern tester is answered by +the engine that would enforce the answer rather than by an approximation that would bless +patterns hyperscan rejects. It reports `unscannable` for patterns that are valid, will run, +and cannot be highlighted — validity is judged in **stream** mode, which is the mode a +pattern actually runs in, while highlighting needs a block-mode scan that does not accept +quite the same language. + +## Layout + +| file | what is in it | +| --- | --- | +| `src/main.rs` | environment, startup handshake, the runtime, the stats ticker, `--debug-regex` | +| `src/filter.rs` | verdicts, the `Filter` trait, the chain and its isolation policy. `ChainHandle` swaps the chain under live connections | +| `src/rules.rs` | the filters the backend configures, and the JSON they arrive in | +| `src/hyperscan.rs` | the `libhs` binding: stream and block scanning, compilation, validation | +| `src/proxy.rs` | accept loop, the opening peek, TLS and ALPN, the limit, the byte pumps | +| `src/http1.rs` | **the** HTTP/1.1 rendering, and the traits both HTTP protocols implement | +| `src/h2.rs` | HTTP/2, on hyperium's `h2` | +| `src/h3.rs` | HTTP/3, on hyperium's `h3` | +| `src/h1up.rs` | HTTP/1.1 *towards the service*, on hyper's client | +| `src/quic.rs` | QUIC termination, streams, datagrams, the relay manager | +| `src/udp.rs` | datagram relays and per-flow state | +| `src/relays.rs` | the two-armed enum both relay kinds go through | +| `src/tls.rs` | termination, re-encryption, ALPN mirroring, the deliberately permissive upstream verifier | +| `src/transparent.rs` | `IP_TRANSPARENT`, `SO_ORIGINAL_DST`, the self-mark, the startup capability probe | +| `src/capture.rs` | the reconstructed packets written to `firegex0` | +| `src/pyworker.rs` | the child process running the user's Python, and its deadline | +| `src/control.rs` | the stdin channel, running alongside the datapath | +| `src/debug.rs` | `--debug-regex` | +| `src/spec.rs` | filters built from a text spec. Test scaffolding only | + +Tests: `tests/fail_open.rs` (the guarantees above, over real sockets), `tests/rules.rs` +(matching, direction, grouping, order), `tests/pyworker.rs` (a hung worker killed, a +crashed one recovering), `tests/tls.rs`, `tests/quic.rs`, `tests/h2.rs`, `tests/h3.rs`, and +`tests/throughput.rs` (the matcher benchmark, `--ignored`). + +## Building and testing + +Built in the Dockerfile's `compiler` stage rather than in a `rust` image on purpose: it +links the same `libhs` the C++ binaries do, and a binary built against one distro's +vectorscan and run against another's is a bad afternoon. + +```bash +docker build --target compiler -t firegex-compiler . +docker run --rm -v "$PWD/backend:/execute" -w /execute/proxysrc firegex-compiler cargo test +cd backend/proxysrc && cargo test # on a host with libhs + fgex installed +cargo test --release --test throughput -- --ignored --nocapture +``` + +Mount `backend/`, **not** `backend/proxysrc/`: `tests/pyworker.rs` launches +`../modules/services/pyworker.py`, which imports `firegex`, which imports `brotli`. Without +both on `PYTHONPATH` the pyworker tests fail with a `ModuleNotFoundError` buried in captured +output and read as real failures. The repo's own `fgex-lib` has to come *first* on the path, +or a stale image's `firegex` wins. + +The product-level suite is `tests/`, driven by pytest against a running instance; +`tests/integration/test_http_versions.py` and `tests/integration/test_grpc.py` are the two +that say whether the rendering in here really works. + +## Measured against NFQUEUE + +**The proxy is not the slow one, and the docs used to say it was.** Moving bytes it is +about **2.2× NFQUEUE at one thread and 4.7× at eight**: NFQUEUE pays a userspace round trip +*per packet*, while this pays once per connection and then the kernel moves the bytes. On +short connections the two are indistinguishable. + +The numbers, the method and the corrections that produced them live in +[`tests/bench/README.md`](../../tests/bench/README.md) and `tests/bench/results/`, and that +is deliberately the only place they are written down — a table copied into a second file is +a table that will still be quoting a figure nobody can reproduce a year from now. + +The trade between the two layers is **fail-open and transparency, not speed**. Keep +`docs/services.md`, `LayerChoice.tsx` and `transportSummary()` saying that. + +## Backend side + +`backend/modules/services/` holds the other half: + +- `transports.py` — `ProxyTransport` starts this binary, reads the handshake, keeps the + relay map and pushes the ruleset. `NfqueueTransport` and `ExternalTransport` are the + other two layers, behind the same interface. +- `nftables.py` — the redirect, the divert rule and the return path, all in `table inet + fgex`. `NAT_PRIORITY` is `-120`, below `dstnat`, so a container runtime's published port + cannot rewrite the destination before firegex's rule matches it. +- `firewall.py` — one `ServiceManager` per service: the engine is started first because it + is what picks the listening port, and the rules are written afterwards. Stopping goes the + other way round. +- `pyworker.py` — the child process this engine spawns for a Python rule, and + `--check`, which is what decides whether a filter can be saved at all. +- `mirror.py` — the `firegex0` device, created before any engine starts. diff --git a/backend/proxysrc/src/capture.rs b/backend/proxysrc/src/capture.rs new file mode 100644 index 0000000..bf6d972 --- /dev/null +++ b/backend/proxysrc/src/capture.rs @@ -0,0 +1,406 @@ +//! The decrypted stream, written out as the packets it never was. +//! +//! TLS is terminated in this process, which is what lets a protected service occupy no +//! port beyond the one it already answered on — and it means the plaintext is never a +//! packet on any interface, so there is nothing for a capture tool to see. Everything an +//! operator wants to do with the decrypted traffic (read it in Wireshark, keep a pcap of +//! a round, grep a flow after the fact) would have gone with the ports. +//! +//! So the engine writes it out itself. Each connection's plaintext is framed as the TCP +//! stream it was before encryption and sent to a dummy interface, where tcpdump and +//! Wireshark read it like anything else. One interface for the whole instance: every TLS +//! service's plaintext arrives there and nothing else does. +//! +//! **These are reconstructed packets.** The bytes are real — they are exactly what the +//! filters saw and exactly what was forwarded — but the framing around them is made up +//! here, because the framing that actually crossed the wire was encrypted. Sequence +//! numbers start at zero, there are no retransmissions, and the segmentation is this +//! engine's read sizes rather than the peer's. What that buys is a stream Wireshark can +//! follow; what it costs is that this is not evidence of what was on the wire, and the +//! documentation says so. +//! +//! **A QUIC stream is reconstructed the same way, one TCP stream each.** A QUIC stream +//! is ordered, reliable and starts at zero, which is precisely what a [`Tap`] rebuilds — +//! but every stream of one connection shares the connection's four-tuple, so writing +//! them out as they are would interleave a hundred of them into a single conversation +//! that no tool could take apart. So each is given a **synthetic client port** by +//! [`Tap::open_stream`], and that is one more piece of invented framing on top of the +//! invented framing this whole file is: the port identifies a stream and was never a +//! port anybody bound. It has to be said wherever a capture from here is offered, next +//! to the sentence about sequence numbers, because an operator reading a port back out +//! of Wireshark will otherwise go looking for a socket that does not exist. +//! +//! Failure is always silent and always local. If the interface is not there, or the +//! socket cannot be opened, or a send fails, the traffic goes on exactly as it would +//! have: a capture aid must never be able to interrupt a service it is only watching. + +use std::ffi::CString; +use std::net::{IpAddr, SocketAddr}; +use std::sync::atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering}; +use std::sync::Arc; + +/// The interface the decrypted traffic is written to. A fixed name on purpose: it is +/// something an operator types into a capture tool. +pub const DEVICE: &str = "firegex0"; + +/// How much payload goes in one segment. Under the dummy device's 1500-byte MTU with +/// room for the largest headers this writes (Ethernet + IPv6 + TCP). +const SEGMENT: usize = 1400; + +const ETH_HEADER: usize = 14; +const IPV4_HEADER: usize = 20; +const IPV6_HEADER: usize = 40; +const TCP_HEADER: usize = 20; + +const FIN: u8 = 0x01; +const SYN: u8 = 0x02; +const PSH: u8 = 0x08; +const ACK: u8 = 0x10; + +/// A raw socket bound to the capture interface, shared by every connection. +pub struct Capture { + fd: libc::c_int, + ifindex: libc::c_int, +} + +// The fd is used only for `sendto`, which is thread-safe on a packet socket. +unsafe impl Send for Capture {} +unsafe impl Sync for Capture {} + +impl Drop for Capture { + fn drop(&mut self) { + unsafe { libc::close(self.fd) }; + } +} + +impl Capture { + /// Open the capture interface, or `None` if there is nothing to write to. + /// + /// `None` is the ordinary case, not an error: the interface exists only while some + /// TLS service is running, and a host that cannot have one still runs services. + pub fn open() -> Option> { + let name = CString::new(DEVICE).ok()?; + let ifindex = unsafe { libc::if_nametoindex(name.as_ptr()) }; + if ifindex == 0 { + return None; + } + // `ETH_P_ALL` in the socket's protocol would also make it *receive* every frame + // on every interface, which is a firehose nobody reads. Zero sends only. + let fd = unsafe { libc::socket(libc::AF_PACKET, libc::SOCK_RAW | libc::SOCK_CLOEXEC, 0) }; + if fd < 0 { + return None; + } + Some(Arc::new(Capture { fd, ifindex: ifindex as libc::c_int })) + } + + fn send(&self, frame: &[u8]) { + let mut addr: libc::sockaddr_ll = unsafe { std::mem::zeroed() }; + addr.sll_family = libc::AF_PACKET as u16; + addr.sll_ifindex = self.ifindex; + addr.sll_halen = 6; + unsafe { + // The return value is deliberately ignored: a capture that cannot keep up, + // or an interface that went away, must not become an error on the path of + // the traffic it is watching. + libc::sendto( + self.fd, + frame.as_ptr() as *const libc::c_void, + frame.len(), + libc::MSG_DONTWAIT, + &addr as *const libc::sockaddr_ll as *const libc::sockaddr, + std::mem::size_of::() as libc::socklen_t, + ); + } + } +} + +/// One connection's reconstruction: the two ends, and where each direction has got to. +pub struct Tap { + capture: Arc, + client: SocketAddr, + server: SocketAddr, + /// Next sequence number for client → service. + up: AtomicU32, + /// Next sequence number for service → client. + down: AtomicU32, + /// Whether the end of the stream has been written. + ended: AtomicBool, +} + +/// A reconstruction always gets an end, whichever way its owner left. +/// +/// The TCP path closes its tap where the connection is torn down, and could; the paths +/// that carry one stream of many have a dozen ways out — a refused request returns before +/// anything is joined — and a stream left without a FIN is one Wireshark holds open +/// waiting for bytes that are never coming. So the last holder emits it, and +/// [`Tap::closed`] stays callable where the end is known and worth saying explicitly. +impl Drop for Tap { + fn drop(&mut self) { + self.closed(); + } +} + +/// Hands out the ports that tell one multiplexed stream from another. +/// +/// A counter and not a hash of the stream id: ids restart at zero on every QUIC +/// connection, so deriving the port from one would drop two clients' first streams into +/// the same conversation. Cycling the ephemeral range instead gives the same answer a +/// NAT gives, and reuse only becomes possible after 16384 live streams — at which point +/// a new SYN is what separates them, exactly as it does for a real port that came round +/// again. +static NEXT_STREAM_PORT: AtomicU16 = AtomicU16::new(0); + +const EPHEMERAL_BASE: u16 = 49152; +const EPHEMERAL_SPAN: u16 = 65535 - EPHEMERAL_BASE + 1; + +fn stream_port() -> u16 { + EPHEMERAL_BASE + NEXT_STREAM_PORT.fetch_add(1, Ordering::Relaxed) % EPHEMERAL_SPAN +} + +impl Tap { + /// Start a reconstruction for one stream of a multiplexed connection. + /// + /// The client's address keeps its IP and loses its port, because the port is the only + /// field left that can tell two streams of one connection apart: the service's end is + /// the service's and changing it would misattribute the traffic, and the client's IP + /// is what an operator filters on. What comes back is therefore a conversation + /// between a real host and a real service on a port that never existed. + pub fn open_stream( + capture: Option>, + client: SocketAddr, + server: SocketAddr, + ) -> Option> { + let mut client = client; + client.set_port(stream_port()); + Tap::open(capture, client, server) + } + + /// Start a reconstruction, and emit the handshake that opens it. + /// + /// The handshake is not decoration: without a SYN, Wireshark has no beginning for + /// the stream and cannot reassemble it, so the payloads show up as unrelated + /// segments of a conversation it never saw start. + pub fn open(capture: Option>, client: SocketAddr, server: SocketAddr) -> Option> { + // Mixed families would need a translation nobody asked for; in practice both + // ends of a connection the engine terminates are the same family. + if (client.is_ipv4()) != (server.is_ipv4()) { + return None; + } + let tap = Arc::new(Tap { + capture: capture?, + client, + server, + up: AtomicU32::new(0), + down: AtomicU32::new(0), + ended: AtomicBool::new(false), + }); + // Interleaved with the increments, not emitted and then counted: each of these + // reads the two counters as they stand, so a SYN-ACK sent before the client's + // SYN has been counted acknowledges nothing and reads as a broken handshake in + // any tool that checks. SYN consumes a sequence number; so does SYN-ACK. + tap.emit(true, SYN, &[]); + tap.up.fetch_add(1, Ordering::Relaxed); + tap.emit(false, SYN | ACK, &[]); + tap.down.fetch_add(1, Ordering::Relaxed); + tap.emit(true, ACK, &[]); + Some(tap) + } + + /// One chunk of plaintext, in the direction it was forwarded. + /// + /// What is written here is what was *forwarded*, after any rewriting: a capture that + /// showed the bytes a filter replaced would disagree with what the two ends actually + /// received, which is the one thing it is for. + pub fn wrote(&self, from_client: bool, data: &[u8]) { + for chunk in data.chunks(SEGMENT) { + self.emit(from_client, PSH | ACK, chunk); + let counter = if from_client { &self.up } else { &self.down }; + counter.fetch_add(chunk.len() as u32, Ordering::Relaxed); + } + } + + /// Close the reconstruction, so the stream has an end as well as a beginning. + /// + /// Idempotent: [`Drop`] calls it too, and a stream that ended twice would read as one + /// that was closed and then closed again. + pub fn closed(&self) { + if self.ended.swap(true, Ordering::Relaxed) { + return; + } + self.emit(true, FIN | ACK, &[]); + self.emit(false, FIN | ACK, &[]); + } + + fn emit(&self, from_client: bool, flags: u8, payload: &[u8]) { + let (src, dst) = if from_client { + (self.client, self.server) + } else { + (self.server, self.client) + }; + let (seq, ack) = if from_client { + (self.up.load(Ordering::Relaxed), self.down.load(Ordering::Relaxed)) + } else { + (self.down.load(Ordering::Relaxed), self.up.load(Ordering::Relaxed)) + }; + let frame = match (src.ip(), dst.ip()) { + (IpAddr::V4(s), IpAddr::V4(d)) => { + build_v4(s, d, src.port(), dst.port(), seq, ack, flags, payload) + } + (IpAddr::V6(s), IpAddr::V6(d)) => { + build_v6(s, d, src.port(), dst.port(), seq, ack, flags, payload) + } + _ => return, + }; + self.capture.send(&frame); + } +} + +/// Ethernet header. The addresses are locally administered and made up: a dummy device +/// has no peer to address, and a capture tool only needs the ethertype to know what +/// follows. +fn ethernet(out: &mut Vec, ethertype: u16) { + out.extend_from_slice(&[0x02, 0, 0, 0, 0, 0x02]); // destination + out.extend_from_slice(&[0x02, 0, 0, 0, 0, 0x01]); // source + out.extend_from_slice(ðertype.to_be_bytes()); +} + +fn ones_complement(sum: u32) -> u16 { + let mut sum = sum; + while sum >> 16 != 0 { + sum = (sum & 0xffff) + (sum >> 16); + } + !(sum as u16) +} + +fn sum_bytes(bytes: &[u8]) -> u32 { + let mut sum = 0u32; + let mut i = 0; + while i + 1 < bytes.len() { + sum += u16::from_be_bytes([bytes[i], bytes[i + 1]]) as u32; + i += 2; + } + if i < bytes.len() { + sum += (bytes[i] as u32) << 8; + } + sum +} + +#[allow(clippy::too_many_arguments)] +fn tcp_header( + src_port: u16, + dst_port: u16, + seq: u32, + ack: u32, + flags: u8, + payload: &[u8], + pseudo: u32, +) -> Vec { + let mut tcp = Vec::with_capacity(TCP_HEADER); + tcp.extend_from_slice(&src_port.to_be_bytes()); + tcp.extend_from_slice(&dst_port.to_be_bytes()); + tcp.extend_from_slice(&seq.to_be_bytes()); + tcp.extend_from_slice(&ack.to_be_bytes()); + tcp.push(5 << 4); // data offset: five 32-bit words, no options + tcp.push(flags); + tcp.extend_from_slice(&65535u16.to_be_bytes()); // window + tcp.extend_from_slice(&[0, 0]); // checksum, filled in below + tcp.extend_from_slice(&[0, 0]); // urgent pointer + let checksum = ones_complement(pseudo + sum_bytes(&tcp) + sum_bytes(payload)); + tcp[16..18].copy_from_slice(&checksum.to_be_bytes()); + tcp +} + +#[allow(clippy::too_many_arguments)] +fn build_v4( + src: std::net::Ipv4Addr, + dst: std::net::Ipv4Addr, + src_port: u16, + dst_port: u16, + seq: u32, + ack: u32, + flags: u8, + payload: &[u8], +) -> Vec { + let tcp_len = TCP_HEADER + payload.len(); + let pseudo = sum_bytes(&src.octets()) + + sum_bytes(&dst.octets()) + + libc::IPPROTO_TCP as u32 + + tcp_len as u32; + let tcp = tcp_header(src_port, dst_port, seq, ack, flags, payload, pseudo); + + let mut out = Vec::with_capacity(ETH_HEADER + IPV4_HEADER + tcp_len); + ethernet(&mut out, 0x0800); + let start = out.len(); + out.push(0x45); // version 4, five-word header + out.push(0); // dscp/ecn + out.extend_from_slice(&((IPV4_HEADER + tcp_len) as u16).to_be_bytes()); + out.extend_from_slice(&[0, 0]); // identification + out.extend_from_slice(&0x4000u16.to_be_bytes()); // don't fragment + out.push(64); // ttl + out.push(libc::IPPROTO_TCP as u8); + out.extend_from_slice(&[0, 0]); // checksum, filled in below + out.extend_from_slice(&src.octets()); + out.extend_from_slice(&dst.octets()); + let checksum = ones_complement(sum_bytes(&out[start..])); + out[start + 10..start + 12].copy_from_slice(&checksum.to_be_bytes()); + + out.extend_from_slice(&tcp); + out.extend_from_slice(payload); + out +} + +#[allow(clippy::too_many_arguments)] +fn build_v6( + src: std::net::Ipv6Addr, + dst: std::net::Ipv6Addr, + src_port: u16, + dst_port: u16, + seq: u32, + ack: u32, + flags: u8, + payload: &[u8], +) -> Vec { + let tcp_len = TCP_HEADER + payload.len(); + let pseudo = sum_bytes(&src.octets()) + + sum_bytes(&dst.octets()) + + libc::IPPROTO_TCP as u32 + + tcp_len as u32; + let tcp = tcp_header(src_port, dst_port, seq, ack, flags, payload, pseudo); + + let mut out = Vec::with_capacity(ETH_HEADER + IPV6_HEADER + tcp_len); + ethernet(&mut out, 0x86DD); + out.extend_from_slice(&0x6000_0000u32.to_be_bytes()); // version 6, no traffic class + out.extend_from_slice(&(tcp_len as u16).to_be_bytes()); // payload length + out.push(libc::IPPROTO_TCP as u8); // next header + out.push(64); // hop limit + out.extend_from_slice(&src.octets()); + out.extend_from_slice(&dst.octets()); + out.extend_from_slice(&tcp); + out.extend_from_slice(payload); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The one property that makes multiplexed streams separable in a capture. + /// + /// Two streams of one connection differ in nothing a capture tool looks at — same + /// addresses, same service port — so if this stopped handing out distinct ports they + /// would arrive as one interleaved conversation, which is worse than not capturing + /// them at all: it reads as a single stream whose bytes make no sense. + #[test] + fn every_stream_gets_a_port_of_its_own() { + let mut seen = std::collections::HashSet::new(); + for _ in 0..EPHEMERAL_SPAN as usize { + let port = stream_port(); + assert!(port >= EPHEMERAL_BASE, "{port} is not an ephemeral port"); + assert!(seen.insert(port), "{port} came round before the range was spent"); + } + // And then it does come round, rather than running out: a long-lived engine has + // more streams than there are ports, and a new SYN is what separates the reuse — + // exactly as it does for a real port the kernel hands out again. + assert!(!seen.insert(stream_port())); + } +} diff --git a/backend/proxysrc/src/control.rs b/backend/proxysrc/src/control.rs new file mode 100644 index 0000000..71f4802 --- /dev/null +++ b/backend/proxysrc/src/control.rs @@ -0,0 +1,186 @@ +//! The control channel: one JSON ruleset per line on stdin, one ACK per line out. +//! +//! Same shape the NFQUEUE engines use, so the backend side is the code it already +//! has: write the whole ruleset, wait for `ACK OK` or `ACK FAIL `. + +use std::net::SocketAddr; +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, BufReader}; + +use crate::filter::{ChainHandle, FilterChain}; +use crate::proxy::{Edge, Onward, Published, Targets}; +use crate::relays::Relays; +use crate::rules::parse_ruleset_reusing; + +fn ack(ok: bool, detail: &str) { + crate::report::reply(if ok { "ACK OK".to_string() } else { format!("ACK FAIL {detail}") }); +} + +/// Apply rulesets and handle control commands until stdin closes. +/// +/// A rejected update leaves the running chain in place: the engine keeps enforcing +/// what it already had rather than falling back to enforcing nothing, which is the +/// opposite of the fail-open rule and deliberately so. Failing open covers a filter +/// that breaks at runtime; it is not a licence to silently drop a ruleset the +/// operator asked for. +pub async fn serve_stdin( + chain: ChainHandle, + deadline: Duration, + relays: Relays, + targets: std::sync::Arc, +) { + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + loop { + let line = match lines.next_line().await { + Ok(Some(line)) => line, + // stdin closed: the backend is gone. Keep serving traffic with the + // ruleset we have — the datapath outliving its control channel is the + // whole point of failing open. + Ok(None) => { + eprintln!("[info] [control] stdin closed, keeping the current ruleset"); + return; + } + Err(e) => { + eprintln!("[warn] [control] cannot read stdin: {e}"); + return; + } + }; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + // `ADD_UDP` whatever is behind it: the command names what the *rules* match on, + // and a QUIC service is UDP to the kernel. The backend asks for a port to point + // an address at, and that question has one answer whichever relay gives it. + if let Some(rest) = trimmed.strip_prefix("ADD_UDP ") { + // `
` or `
` — what the service behind this relay + // speaks, which is the relay's own answer and not the process's. + let mut parts = rest.split_whitespace(); + let target_str = parts.next().unwrap_or("").trim(); + let onward = match parts.next() { + 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) => { + crate::report::reply(format!("UDP {upstream}|{} {port}", onward.word())); + ack(true, ""); + } + Err(e) => { + eprintln!("[warn] [control] failed to add UDP relay for {upstream}: {e}"); + ack(false, &e.to_string()); + } + }, + Err(e) => { + let err = format!("invalid target address {target_str}: {e}"); + eprintln!("[warn] [control] {err}"); + ack(false, &err); + } + } + continue; + } + + // `PUBLISH ` and `WITHDRAW `: one address is being + // served on a port the service does not listen on. Sent while the engine runs + // because an address can be added to a running service, and the startup list + // (`FGEX_PROXY_TARGETS`) says the same thing for the ones it began with. + if let Some(rest) = trimmed.strip_prefix("PUBLISH ") { + // `PUBLISH []`: what is spoken at one + // address, what is spoken to the service behind it, and — where it is not + // simply the service — where that service is. A dash for the last says the + // address *is* the service, which is every address until an operator says + // otherwise. + let mut parts = rest.split_whitespace(); + let public = parts.next().map(str::parse::); + let edge = match parts.next() { + Some("tls") => Some(Edge::Tls), + Some("any") | Some("clear") | None => Some(Edge::Whatever), + Some(_) => None, + }; + let onward = match parts.next() { + Some("plain") => Some(Onward::Plain), + Some("tls") => Some(Onward::Tls), + Some("same") | None => Some(Onward::Same), + Some(_) => None, + }; + let target = match parts.next() { + None | Some("-") => Ok(None), + Some(addr) => addr.parse::().map(Some), + }; + match (public, edge, onward, target) { + (Some(Ok(public)), Some(edge), Some(upstream), Ok(target)) => { + targets.publish(public, Published { target, edge, upstream }); + match target { + Some(target) => { + eprintln!("[info] [control] {public} now fronts {target}") + } + None => eprintln!("[info] [control] {public} declared"), + } + ack(true, ""); + } + _ => { + let err = + format!("PUBLISH wants `
[]`, got {rest:?}"); + eprintln!("[warn] [control] {err}"); + ack(false, &err); + } + } + continue; + } + if let Some(rest) = trimmed.strip_prefix("WITHDRAW ") { + match rest.trim().parse::() { + Ok(public) => { + targets.withdraw(&public); + ack(true, ""); + } + Err(e) => { + let err = format!("invalid address {rest:?}: {e}"); + eprintln!("[warn] [control] {err}"); + ack(false, &err); + } + } + continue; + } + + // Everything that has to be started is started here, while the old chain is + // still the one enforcing — on a blocking thread, because starting a Python + // worker takes an interpreter's start-up and an import, and this task shares the + // runtime with every relay: with one worker thread, which is what a one-core + // host is given, the service carried nothing while a reload was loading a file. + let line = trimmed.to_string(); + let previous = chain.current().filters(); + let prepared = tokio::task::spawn_blocking(move || { + parse_ruleset_reusing(&line, &previous).and_then(|filters| { + for filter in &filters { + filter.prepare()?; + } + Ok(filters) + }) + }) + .await + .unwrap_or_else(|e| Err(format!("preparing the ruleset failed: {e}"))); + match prepared { + Ok(filters) => { + let n = filters.len(); + chain.replace(FilterChain::new(filters, deadline)); + eprintln!("[info] [control] ruleset replaced, {n} rule(s) active"); + ack(true, ""); + } + Err(e) => { + eprintln!("[warn] [control] ruleset rejected, keeping the previous one: {e}"); + ack(false, &e); + } + } + } +} diff --git a/backend/proxysrc/src/debug.rs b/backend/proxysrc/src/debug.rs new file mode 100644 index 0000000..56c7c26 --- /dev/null +++ b/backend/proxysrc/src/debug.rs @@ -0,0 +1,313 @@ +//! Answering "what would this pattern actually do?" with the engine that would do it. +//! +//! An in-app regex tester is only worth having if it is the same matcher the datapath +//! runs. A tester built on Python's `re` would accept backreferences hyperscan rejects, +//! disagree about what `.` matches, and quietly bless a pattern that will be refused +//! the moment it is saved — which is worse than having no tester, because the operator +//! would trust it. +//! +//! So the backend asks this binary instead. One JSON request in, one JSON response +//! out, block mode with start-of-match offsets so the answer can be highlighted. + +use serde::{Deserialize, Serialize}; + +use crate::hyperscan::{self, Mode, Pattern}; + +/// Enough matches to show, few enough to render. A pattern like `.*` over a sizeable +/// sample matches at almost every offset, and an operator learns nothing from the +/// ten thousandth one. +const MATCH_LIMIT: usize = 1000; + +#[derive(Deserialize)] +pub struct DebugRequest { + pub patterns: Vec, + /// The sample to scan, base64-encoded: a pattern is matched against bytes, and + /// testing one against bytes that are not valid text is a normal thing to want. + pub sample: String, +} + +#[derive(Deserialize)] +pub struct DebugPattern { + pub id: String, + pub expr: String, + #[serde(default = "yes")] + pub case_sensitive: bool, +} + +fn yes() -> bool { + true +} + +#[derive(Serialize)] +pub struct DebugResponse { + pub matches: Vec, + /// Per pattern, the compile error if there was one. A ruleset with one broken + /// pattern still reports the others, because the operator is usually editing one + /// pattern and should not lose the answers about the rest. + pub errors: Vec, + /// Set when the whole request failed rather than one pattern. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Patterns that are valid and will run, but that this tester cannot show matches + /// for. Highlighting needs block-mode scanning, and block mode rejects a few + /// patterns streaming accepts. Saying so beats either refusing them or silently + /// showing no matches. + pub unscannable: Vec, + + pub truncated: bool, +} + +#[derive(Serialize)] +pub struct DebugMatch { + pub id: String, + pub from: u64, + pub to: u64, +} + +#[derive(Serialize, Debug)] +pub struct DebugError { + pub id: String, + pub error: String, +} + +fn b64_decode(input: &str) -> Result, String> { + // A dependency for this would be a dependency in the datapath, and the datapath's + // dependency list is a thing worth keeping short. + let mut out = Vec::with_capacity(input.len() * 3 / 4); + let mut acc: u32 = 0; + let mut bits = 0u32; + for ch in input.bytes() { + let value = match ch { + b'A'..=b'Z' => ch - b'A', + b'a'..=b'z' => ch - b'a' + 26, + b'0'..=b'9' => ch - b'0' + 52, + b'+' => 62, + b'/' => 63, + b'=' | b'\n' | b'\r' => continue, + _ => return Err("the sample is not valid base64".to_string()), + } as u32; + acc = (acc << 6) | value; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + } + } + Ok(out) +} + +/// Scan one sample with one set of patterns. +pub fn run(request: &str) -> DebugResponse { + let req: DebugRequest = match serde_json::from_str(request) { + Ok(r) => r, + Err(e) => { + return DebugResponse { + matches: Vec::new(), + errors: Vec::new(), + error: Some(format!("malformed request: {e}")), + unscannable: Vec::new(), + truncated: false, + } + } + }; + let sample = match b64_decode(&req.sample) { + Ok(s) => s, + Err(e) => { + return DebugResponse { + matches: Vec::new(), + errors: Vec::new(), + error: Some(e), + unscannable: Vec::new(), + truncated: false, + } + } + }; + + // Compiled one at a time rather than as a set: a set reports only the first + // pattern that failed, and the point of a tester is to say which of the operator's + // patterns is wrong and why. + let mut errors = Vec::new(); + let mut unscannable = Vec::new(); + let mut good = Vec::new(); + for p in &req.patterns { + let pattern = Pattern { + expr: p.expr.clone(), + case_sensitive: p.case_sensitive, + }; + // Validity is judged against the mode this pattern will actually run in, so the + // tester's verdict is the same one the datapath will reach. + let runtime_mode = Mode::Stream; + if let Err(e) = hyperscan::Database::compile(std::slice::from_ref(&pattern), runtime_mode) + { + errors.push(DebugError { id: p.id.clone(), error: e }); + continue; + } + // Showing where it matches is a separate question: only block mode reports + // offsets, and it accepts slightly less. + match hyperscan::Database::compile(std::slice::from_ref(&pattern), Mode::Block) { + Ok(_) => good.push((p.id.clone(), pattern)), + Err(e) => unscannable.push(DebugError { id: p.id.clone(), error: e }), + } + } + + if good.is_empty() { + return DebugResponse { + matches: Vec::new(), + errors, + error: None, + unscannable, + truncated: false, + }; + } + + let (ids, patterns): (Vec, Vec) = good.into_iter().unzip(); + let db = match hyperscan::Database::compile(&patterns, Mode::Block) { + Ok(db) => db, + Err(e) => { + return DebugResponse { + matches: Vec::new(), + errors, + error: Some(e), + unscannable, + truncated: false, + } + } + }; + let hits = match hyperscan::scan_block(&db, &sample, MATCH_LIMIT) { + Ok(h) => h, + Err(e) => { + return DebugResponse { + matches: Vec::new(), + errors, + error: Some(e), + unscannable, + truncated: false, + } + } + }; + let truncated = hits.len() >= MATCH_LIMIT; + + + + DebugResponse { + matches: hits + .into_iter() + .map(|m| DebugMatch { + id: ids + .get(m.id as usize) + .cloned() + .unwrap_or_else(|| "unknown".to_string()), + from: m.from, + to: m.to, + }) + .collect(), + errors, + error: None, + unscannable, + truncated, + } +} + + +#[cfg(test)] +mod tests { + use super::*; + + fn ask(patterns: &str, sample: &[u8]) -> DebugResponse { + // base64 by hand, to keep the encoder and the decoder honest about each other + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut encoded = String::new(); + for block in sample.chunks(3) { + let b = [ + block[0], + *block.get(1).unwrap_or(&0), + *block.get(2).unwrap_or(&0), + ]; + let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32; + for i in 0..4 { + if i <= block.len() { + encoded.push(ALPHABET[((n >> (18 - i * 6)) & 63) as usize] as char); + } else { + encoded.push('='); + } + } + } + run(&format!(r#"{{"patterns":{patterns},"sample":"{encoded}"}}"#)) + } + + + + #[test] + fn reports_where_a_pattern_matched() { + let res = ask(r#"[{"id":"a","expr":"FLAG\\{[a-z]+\\}"}]"#, b"xx FLAG{abc} yy"); + assert!(res.error.is_none(), "{:?}", res.error); + assert_eq!(res.matches.len(), 1); + assert_eq!(res.matches[0].id, "a"); + assert_eq!((res.matches[0].from, res.matches[0].to), (3, 12)); + } + + #[test] + fn reports_every_match_not_just_the_first() { + let res = ask(r#"[{"id":"a","expr":"ab"}]"#, b"ab_ab_ab"); + assert_eq!(res.matches.len(), 3); + } + + #[test] + fn names_the_pattern_that_failed_and_keeps_the_rest() { + let res = ask( + r#"[{"id":"bad","expr":"(unclosed"},{"id":"good","expr":"ok"}]"#, + b"this is ok", + ); + assert_eq!(res.errors.len(), 1); + assert_eq!(res.errors[0].id, "bad"); + assert!(!res.errors[0].error.is_empty()); + assert_eq!(res.matches.len(), 1, "the valid pattern still reported"); + assert_eq!(res.matches[0].id, "good"); + } + + #[test] + fn honours_case_sensitivity_per_pattern() { + let res = ask( + r#"[{"id":"s","expr":"ABC"},{"id":"i","expr":"ABC","case_sensitive":false}]"#, + b"abc", + ); + let ids: Vec<&str> = res.matches.iter().map(|m| m.id.as_str()).collect(); + assert_eq!(ids, vec!["i"]); + } + + #[test] + fn matches_bytes_that_are_not_text() { + let res = ask(r#"[{"id":"a","expr":"\\xff\\xfe"}]"#, &[0x41, 0xff, 0xfe]); + assert!(res.error.is_none(), "{:?}", res.error); + assert_eq!(res.matches.len(), 1); + assert_eq!((res.matches[0].from, res.matches[0].to), (1, 3)); + } + + /// The two modes do not accept exactly the same patterns, and judging a pattern by + /// the wrong one refuses something that works. A bounded repeat compiles for + /// streaming — which is how a blocking pattern runs — but not for the block-mode + /// scanning the tester highlights with. + #[test] + fn a_pattern_is_judged_by_the_mode_it_will_run_in() { + let res = ask(r#"[{"id":"a","expr":"a{1,1000}b"}]"#, b"aab"); + assert!(res.errors.is_empty(), "a valid blocking pattern was refused: {:?}", res.errors); + assert_eq!( + res.unscannable.len(), + 1, + "it is valid but cannot be highlighted, and that has to be said" + ); + } + + #[test] + fn a_malformed_request_is_an_error_not_a_panic() { + let res = run("{not json"); + assert!(res.error.is_some()); + } + + #[test] + fn an_empty_sample_matches_nothing_and_does_not_fail() { + let res = ask(r#"[{"id":"a","expr":"x"}]"#, b""); + assert!(res.error.is_none()); + assert!(res.matches.is_empty()); + } +} diff --git a/backend/proxysrc/src/filter.rs b/backend/proxysrc/src/filter.rs new file mode 100644 index 0000000..21a6cc1 --- /dev/null +++ b/backend/proxysrc/src/filter.rs @@ -0,0 +1,618 @@ +//! The filter layer, and the isolation that keeps it from taking the datapath down. +//! +//! Everything here exists to uphold one rule: **a filter may fail, the traffic may +//! not**. The NFQUEUE engine gets that property from the kernel (`NFQA_CFG_F_FAIL_OPEN` +//! plus the `bypass` flag on the nft rule): if userspace stops answering, packets flow +//! anyway. A userspace proxy has no such backstop, so it has to be built here. + +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +/// Which half of the connection a chunk came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Direction { + /// Data travelling from the client towards the protected service. + ClientToServer, + /// Data travelling from the protected service back to the client. + ServerToClient, +} + +impl Direction { + pub fn as_str(self) -> &'static str { + match self { + Direction::ClientToServer => "c->s", + Direction::ServerToClient => "s->c", + } + } +} + +/// What a filter decided about a chunk of stream data. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Verdict { + /// Forward the chunk unchanged. + Accept, + /// Close the connection. Carries the id of the rule that refused it, so a block + /// is attributed without a shared slot two connections could race over. + Reject(Option), +} + +/// What a filter is shown about one chunk of traffic. Borrowed, so a filter that +/// cannot block is handed the relay's own buffers with nothing copied. +/// +/// Only the new bytes. There is deliberately no re-scan region: a filter that has to +/// find a pattern straddling two chunks keeps the state to do so in its own session, +/// which costs nothing per chunk. An overlap window was the previous answer and it +/// was both slower and bounded — it could only find patterns shorter than itself. +pub struct FilterCtx<'a> { + pub direction: Direction, + /// The bytes that just arrived. + pub chunk: &'a [u8], + /// Which connection these bytes belong to. + /// + /// Both directions of one connection share it, because that is what a stream is + /// to a filter. A filter that keeps state — the user's Python keeps a whole set of + /// module globals — needs this to keep one client's state away from another's, and + /// nothing else in the datapath can tell it for them. + pub connection: ConnectionId, +} + +/// What a filter is allowed to know about the layers below the application one. +/// +/// Addresses and ports, and nothing else — no header bytes in either direction. The +/// two network layers cannot honestly offer the same thing below this line: the proxy +/// terminated the connection and writes its own headers, so a filter rewriting one +/// here would be rewriting nothing, while on NFQUEUE the same edit desynchronises the +/// stream. Metadata is the part that means the same thing on both, so metadata is what +/// crosses. +#[derive(Clone, Copy, Debug)] +pub struct ConnectionMeta { + /// The peer that opened the connection. + pub client: std::net::SocketAddr, + /// Where it was actually headed — the protected service, not this proxy. + pub server: std::net::SocketAddr, + /// What is underneath. A filter is shown it because the library's stream and HTTP + /// models need a stream to be built on, and a filter written against `RawPacket` may + /// legitimately want to know what it is looking at. + pub l4: L4, +} + +/// What carries a connection. +/// +/// A boolean until QUIC arrived, which broke the question in two: a QUIC stream is +/// ordered and reliable, so every model that needs a stream applies to it, and it is +/// carried by UDP, so every rule that matches the wire matches UDP. Answering either of +/// those with "is it TCP" gets the other one wrong — as a boolean this either hid the +/// stream models from QUIC or told a filter it was looking at TCP. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum L4 { + Tcp, + Udp, + /// A stream inside a QUIC connection. + Quic, + /// A QUIC DATAGRAM frame: inside a QUIC connection, and outside its streams. + /// + /// Its own value rather than [`L4::Quic`] with a flag, because the two answer the + /// stream question differently and everything the library builds on a stream turns + /// on that answer. Telling a filter `udp` instead would answer both questions + /// correctly — unreliable, and UDP on the wire — and lose the only thing left worth + /// knowing, which is that this arrived inside a connection that is also carrying + /// streams. + QuicDatagram, +} + +impl L4 { + /// Whether the bytes arrive as an ordered, reliable stream — which is what the + /// library's stream and HTTP models are built on. + pub fn is_stream(self) -> bool { + matches!(self, L4::Tcp | L4::Quic) + } + + /// The name the filter library knows it by. + pub fn name(self) -> &'static str { + match self { + L4::Tcp => "tcp", + L4::Udp => "udp", + L4::Quic => "quic", + L4::QuicDatagram => "quic-datagram", + } + } +} + +/// Identifies one connection for the lifetime of the process. +pub type ConnectionId = u64; + +/// Hands out connection ids. Wrapping would take 585 years at a million per second. +static NEXT_CONNECTION: AtomicU64 = AtomicU64::new(1); + +pub fn next_connection_id() -> ConnectionId { + NEXT_CONNECTION.fetch_add(1, Ordering::Relaxed) +} + +/// A unit of inspection. Deliberately synchronous and blocking: filters are user +/// code (today C++-embedded Python, tomorrow whatever we choose), and the engine +/// must stay correct when that code misbehaves rather than assume it will not. +pub trait Filter: Send + Sync + 'static { + fn name(&self) -> &str; + fn inspect(&self, ctx: &FilterCtx<'_>) -> Verdict; + + /// Whether this filter might not return. + /// + /// User code has to be assumed capable of hanging, so it runs on a blocking + /// thread under a deadline — which costs a thread hop and a copy of every chunk. + /// A compiled regular expression cannot hang (the engine has no backtracking), + /// so it says so and is run inline instead. A benchmark put that overhead at + /// most of the difference between filtering and not filtering. + fn may_block(&self) -> bool { + true + } + + /// Do whatever has to succeed before this filter can be trusted to run. + /// + /// Called when a ruleset is applied, never on the datapath. A filter that cannot + /// get ready refuses the whole update, which is deliberately not fail-open: that + /// covers a rule breaking under traffic, not a rule the operator asked for and + /// never got. + fn prepare(&self) -> Result<(), String> { + Ok(()) + } + + /// Open this filter's state for one direction of one connection. + /// + /// `None` means the filter is stateless for that direction and [`Filter::inspect`] + /// answers instead. Matching state must never be shared between connections — + /// one client's bytes deciding another client's verdict is both a false positive + /// and a way to smuggle a pattern past the filter by splitting it across two + /// connections. + /// + /// A stateful filter has to be non-blocking. The blocking path abandons its + /// thread when a filter blows the deadline, and a thread that owns connection + /// state cannot be abandoned without losing it; [`FilterChain::with_policy`] + /// refuses the combination rather than leaving it to be discovered under load. + fn open_session(&self, _dir: Direction) -> Option> { + None + } + + /// A connection has started, and here is what is known about it. + /// + /// Called once per connection before any of its chunks, so a filter that reports + /// metadata to something outside this process has it before the first verdict is + /// asked for. Most filters do not care and take the default. + fn connection_opened(&self, _connection: ConnectionId, _meta: &ConnectionMeta) {} + + /// This connection is over; release whatever was being kept for it. + /// + /// Sessions are dropped by the connection that owned them, so a filter whose state + /// is entirely in its session need not implement this. A filter holding state + /// somewhere else — the Python worker keeps a set of globals per connection, in a + /// process of its own — has no other way to learn that it can let go, and would + /// otherwise grow for as long as the service runs. + fn connection_closed(&self, _connection: ConnectionId) {} + + /// What makes this filter the same filter in the next ruleset, if anything does. + /// + /// A ruleset is rebuilt whole on every edit, and a filter that keeps per-connection + /// state outside its sessions loses all of it when it is rebuilt: the Python worker + /// is a process, and a new one meets every open connection halfway. A filter that + /// answers here, with a key that changes whenever its configuration does, is carried + /// over into the new chain instead when the key matches. `None` — the default — is + /// always rebuilt. + fn reuse_key(&self) -> Option<&str> { + None + } +} + +/// One filter's state for one direction of one connection. +/// +/// Split from [`Filter`] because the two have different lifetimes: the filter is the +/// compiled configuration, shared and swapped wholesale, while the session is the +/// per-connection position in the stream. +pub trait FilterSession: Send + 'static { + fn inspect(&mut self, ctx: &FilterCtx<'_>) -> Verdict; +} + +/// Every filter's state for one direction of one connection. +/// +/// Held by the relay and handed to [`FilterChain::run`], so the sessions live and die +/// with the connection they belong to. Rebuilt when the chain underneath is replaced: +/// the sessions belong to the old chain's filters, and a swap means the operator +/// changed the rules — matching a new ruleset against old partial state would report +/// matches for patterns that were never in force. +pub struct ChainSessions { + /// The connection these belong to, handed to every filter that is consulted. + connection: ConnectionId, + /// Which chain these sessions were opened against. Zero means "none yet"; live + /// chains number from one. + generation: u64, + slots: Vec>>, +} + +impl ChainSessions { + pub fn new(connection: ConnectionId) -> Self { + Self { + connection, + generation: 0, + slots: Vec::new(), + } + } +} + +/// Why a filter stopped being consulted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DisableReason { + Panicked, + TimedOut, +} + +/// Counters worth reporting back to the backend. Kept plain so the spike does not +/// grow a metrics dependency before it has earned one. +#[derive(Debug, Default)] +pub struct ChainStats { + pub inspected: AtomicU64, + pub accepted: AtomicU64, + pub rejected: AtomicU64, + pub panics: AtomicU64, + pub timeouts: AtomicU64, + pub filters_disabled: AtomicU64, + pub bypassed_chunks: AtomicU64, +} + +/// Numbers chains so a connection can notice its sessions are stale. Starts at one +/// because zero means "no sessions opened yet". +static CHAIN_GENERATION: AtomicU64 = AtomicU64::new(1); + +/// How long a filter that kept missing its deadline sits out before it is asked again. +/// +/// Slow is not broken. A Python filter misses its deadline when exchanges queue behind one +/// another, which is what a flood does — the one moment its protection is wanted — and +/// disabled for the life of the chain it stayed off until somebody next edited a rule, +/// with one log line to say so. So it is tried again after a pause, and if it is still +/// slow it loses its say again after the same few misses: a filter that genuinely never +/// returns strands a few threads per pause rather than one per chunk. A panic is broken +/// code, and stays out until the chain is replaced. +pub const REARM_AFTER: Duration = Duration::from_secs(30); + +/// Milliseconds on a monotonic clock, never zero, so zero can mean "not scheduled". +fn monotonic_ms() -> u64 { + static START: OnceLock = OnceLock::new(); + START.get_or_init(Instant::now).elapsed().as_millis() as u64 + 1 +} + +struct Slot { + filter: Arc, + /// Set once the filter has misbehaved enough to lose its say. + disabled: AtomicBool, + consecutive_timeouts: AtomicU64, + /// When a filter that timed out is asked again (`monotonic_ms`), or 0 for never. + rearm_at_ms: AtomicU64, +} + +/// An immutable set of filters. Swapping the whole chain is how configuration +/// changes reach live connections, so a chain is never mutated in place. +pub struct FilterChain { + slots: Vec, + /// Identifies this chain, so a connection can tell that the one it opened its + /// sessions against is no longer the live one. + generation: u64, + deadline: Duration, + max_consecutive_timeouts: u64, + /// How long a filter that timed out sits out. `REARM_AFTER` but for tests. + rearm_after: Duration, + /// The earliest `rearm_at_ms` of any slot, or 0: what lets the hot path skip the clock. + next_rearm_ms: AtomicU64, + /// Trips when no filter is left to consult, making the chain a pure relay. + degraded: AtomicBool, + pub stats: Arc, +} + +impl FilterChain { + pub fn new(filters: Vec>, deadline: Duration) -> Self { + Self::with_policy(filters, deadline, 3) + } + + /// The filters this chain consults, in order — for the next ruleset to carry over + /// the ones it has not changed (`Filter::reuse_key`). + pub fn filters(&self) -> Vec> { + self.slots.iter().map(|slot| slot.filter.clone()).collect() + } + + pub fn with_policy( + filters: Vec>, + deadline: Duration, + max_consecutive_timeouts: u64, + ) -> Self { + let degraded = AtomicBool::new(filters.is_empty()); + // A stateful filter on the blocking path would lose its state the first time + // it blew the deadline, silently and only under load. Refusing it here makes + // that a programming error instead of an incident. + debug_assert!( + filters + .iter() + .all(|f| !f.may_block() + || f.open_session(Direction::ClientToServer).is_none()), + "a filter that may block cannot carry per-connection state" + ); + Self { + slots: filters + .into_iter() + .map(|filter| Slot { + filter, + disabled: AtomicBool::new(false), + consecutive_timeouts: AtomicU64::new(0), + rearm_at_ms: AtomicU64::new(0), + }) + .collect(), + generation: CHAIN_GENERATION.fetch_add(1, Ordering::Relaxed), + deadline, + max_consecutive_timeouts, + rearm_after: REARM_AFTER, + next_rearm_ms: AtomicU64::new(0), + degraded, + stats: Arc::new(ChainStats::default()), + } + } + + /// The same chain, with filters that time out asked again after `after` rather than + /// `REARM_AFTER`. + pub fn rearming_after(mut self, after: Duration) -> Self { + self.rearm_after = after; + self + } + + pub fn empty() -> Self { + Self::new(Vec::new(), Duration::from_millis(50)) + } + + /// True when the chain has nothing left to say and the relay can skip it + /// entirely — no copy, no thread hop. The degraded path has to be the cheap + /// one, otherwise failing open costs more than working normally. + pub fn is_bypassed(&self) -> bool { + self.degraded.load(Ordering::Relaxed) + } + + pub fn disabled_filters(&self) -> Vec<&str> { + self.slots + .iter() + .filter(|s| s.disabled.load(Ordering::Relaxed)) + .map(|s| s.filter.name()) + .collect() + } + + fn disable(&self, slot: &Slot, reason: DisableReason) { + if slot.disabled.swap(true, Ordering::Relaxed) { + return; // already out of the loop, do not double count + } + self.stats.filters_disabled.fetch_add(1, Ordering::Relaxed); + match reason { + DisableReason::TimedOut => { + let at = monotonic_ms() + self.rearm_after.as_millis() as u64; + slot.rearm_at_ms.store(at, Ordering::Relaxed); + let _ = self.next_rearm_ms.fetch_update(Ordering::Relaxed, Ordering::Relaxed, + |next| (next == 0 || at < next).then_some(at)); + crate::diag!( + "[warn] [filter] '{}' kept missing its {:?} deadline: traffic keeps \ + flowing without it, and it is asked again in {}s", + slot.filter.name(), + self.deadline, + self.rearm_after.as_secs(), + ); + } + DisableReason::Panicked => crate::diag!( + "[warn] [filter] '{}' panicked: traffic keeps flowing without it until the \ + filters are next applied — save a change to them, or restart the service", + slot.filter.name(), + ), + } + if self + .slots + .iter() + .all(|s| s.disabled.load(Ordering::Relaxed)) + { + self.degraded.store(true, Ordering::Relaxed); + crate::diag!("[warn] [filter] every filter is disabled, chain is now a plain relay"); + } + } + + /// Tell every filter that a connection has started, and what it is. + pub fn connection_opened(&self, connection: ConnectionId, meta: &ConnectionMeta) { + for slot in &self.slots { + slot.filter.connection_opened(connection, meta); + } + } + + /// Tell every filter that a connection is over. + /// + /// Called once per connection, not once per direction: a stream is one thing to a + /// filter, and telling it twice would free state the other direction still needs. + pub fn connection_closed(&self, connection: ConnectionId) { + for slot in &self.slots { + slot.filter.connection_closed(connection); + } + } + + /// Run the chain over one chunk. + /// + /// Put back the filters whose pause is over. See `REARM_AFTER`. + fn rearm(&self) { + let now = monotonic_ms(); + let mut next = 0; + for slot in &self.slots { + let at = slot.rearm_at_ms.load(Ordering::Relaxed); + if at == 0 { + continue; + } + if now < at { + next = if next == 0 { at } else { next.min(at) }; + continue; + } + // Whoever wins this puts the filter back; everybody else sees it done. + if slot + .rearm_at_ms + .compare_exchange(at, 0, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + slot.consecutive_timeouts.store(0, Ordering::Relaxed); + slot.disabled.store(false, Ordering::Relaxed); + self.degraded.store(false, Ordering::Relaxed); + crate::diag!( + "[info] [filter] '{}' is back in the chain after sitting out {}s", + slot.filter.name(), + self.rearm_after.as_secs(), + ); + } + } + self.next_rearm_ms.store(next, Ordering::Relaxed); + } + + /// Any way a filter can fail — panic, or simply never returning — resolves to + /// `Accept` for the chunk in flight. The chunk is never held hostage by the + /// filter's failure. + pub async fn run( + &self, + dir: Direction, + data: &[u8], + sessions: &mut ChainSessions, + ) -> Verdict { + let due = self.next_rearm_ms.load(Ordering::Relaxed); + if due != 0 && monotonic_ms() >= due { + self.rearm(); + } + if self.is_bypassed() { + self.stats.bypassed_chunks.fetch_add(1, Ordering::Relaxed); + return Verdict::Accept; + } + + // First chunk of this connection, or the chain was swapped underneath it. + if sessions.generation != self.generation { + sessions.slots = self + .slots + .iter() + .map(|slot| slot.filter.open_session(dir)) + .collect(); + sessions.generation = self.generation; + } + + self.stats.inspected.fetch_add(1, Ordering::Relaxed); + + for (index, slot) in self.slots.iter().enumerate() { + if slot.disabled.load(Ordering::Relaxed) { + continue; + } + + // A filter that cannot hang is run right here: no thread hop, no copy. + // The deadline exists for code that might never return, and paying for + // it on code that cannot is what the benchmark showed we were doing. + if !slot.filter.may_block() { + let ctx = FilterCtx { + direction: dir, + chunk: data, + connection: sessions.connection, + }; + let outcome = match sessions.slots.get_mut(index).and_then(Option::as_mut) { + Some(session) => { + catch_unwind(AssertUnwindSafe(|| session.inspect(&ctx))) + } + None => catch_unwind(AssertUnwindSafe(|| slot.filter.inspect(&ctx))), + }; + match outcome { + Ok(Verdict::Accept) => {} + Ok(Verdict::Reject(by)) => { + self.stats.rejected.fetch_add(1, Ordering::Relaxed); + let id = by.clone().unwrap_or_else(|| slot.filter.name().to_string()); + crate::report::blocked(&id); + return Verdict::Reject(by); + } + Err(_) => { + self.stats.panics.fetch_add(1, Ordering::Relaxed); + self.disable(slot, DisableReason::Panicked); + } + } + continue; + } + + // The filter needs owned data: it runs on a blocking thread that may + // outlive this call if it hangs, so it cannot borrow the relay buffer. + let owned_chunk = data.to_vec(); + let filter = Arc::clone(&slot.filter); + let connection = sessions.connection; + + let handle = tokio::task::spawn_blocking(move || { + let ctx = FilterCtx { + direction: dir, + chunk: &owned_chunk, + connection, + }; + catch_unwind(AssertUnwindSafe(|| filter.inspect(&ctx))) + }); + + match tokio::time::timeout(self.deadline, handle).await { + Ok(Ok(Ok(verdict))) => { + slot.consecutive_timeouts.store(0, Ordering::Relaxed); + match verdict { + Verdict::Accept => {} + Verdict::Reject(by) => { + self.stats.rejected.fetch_add(1, Ordering::Relaxed); + // The backend attributes the block to a rule by this id, + // the same way cppregex reports `BLOCKED `. + let id = by.clone().unwrap_or_else(|| slot.filter.name().to_string()); + crate::report::blocked(&id); + return Verdict::Reject(by); + } + } + } + // The filter panicked: it is broken code, not a transient hiccup, + // so it loses its say immediately. + Ok(Ok(Err(_))) | Ok(Err(_)) => { + self.stats.panics.fetch_add(1, Ordering::Relaxed); + self.disable(slot, DisableReason::Panicked); + } + // The filter blew the deadline. One slow call can be load, so it + // takes a few in a row to lose the filter. The blocking thread is + // abandoned; it cannot be cancelled, and waiting for it is exactly + // the stall we refuse to have. + Err(_) => { + self.stats.timeouts.fetch_add(1, Ordering::Relaxed); + let seen = slot.consecutive_timeouts.fetch_add(1, Ordering::Relaxed) + 1; + if seen >= self.max_consecutive_timeouts { + self.disable(slot, DisableReason::TimedOut); + } + } + } + } + + self.stats.accepted.fetch_add(1, Ordering::Relaxed); + Verdict::Accept + } +} + +/// Hands the live chain to every connection, and lets it be swapped underneath +/// them. Reconfiguring must not cost established connections: dropping them on a +/// filter edit would be a regression against the NFQUEUE engine, which reloads +/// its rules in place. +#[derive(Clone)] +pub struct ChainHandle { + rx: tokio::sync::watch::Receiver>, + tx: Arc>>, +} + +impl ChainHandle { + pub fn new(chain: FilterChain) -> Self { + let (tx, rx) = tokio::sync::watch::channel(Arc::new(chain)); + Self { + rx, + tx: Arc::new(tx), + } + } + + /// The chain to use for the next chunk. Cheap enough to call per chunk. + pub fn current(&self) -> Arc { + self.rx.borrow().clone() + } + + /// Install a new chain. Connections in flight pick it up on their next chunk. + pub fn replace(&self, chain: FilterChain) { + let _ = self.tx.send(Arc::new(chain)); + } +} diff --git a/backend/proxysrc/src/h1up.rs b/backend/proxysrc/src/h1up.rs new file mode 100644 index 0000000..47d63ff --- /dev/null +++ b/backend/proxysrc/src/h1up.rs @@ -0,0 +1,335 @@ +//! Speaking HTTP/1.1 to the service, under an edge that is not HTTP/1.1. +//! +//! Everything else in this engine forwards to the service in the version the client +//! arrived in: the rendering is a view shown to the filters, never a translation the +//! service sees. This module is the one exception, and it is the operator's to ask for +//! (`FGEX_PROXY_UPSTREAM`): an ordinary web service that speaks HTTP/1.1 and nothing else +//! can be put behind an HTTP/3 edge, with firegex terminating QUIC in front of it. +//! +//! **Half of it is free and half of it needed a crate.** The request direction is already +//! done by the time it gets here — [`crate::http1`] renders every exchange as the HTTP/1.1 +//! it would have been, because that is what the chain has to be shown, so putting those +//! same bytes on a socket invents nothing. The *answer* is what could not be done: HTTP/1.1 +//! is the one version this engine has never parsed. An HTTP/1.1 connection elsewhere is a +//! byte pump — the filters look at bytes, and what parses them is the library on the Python +//! side — so there was no response parser to reach for, and writing one beside hyper's is +//! exactly what `AGENTS.md` forbids. So the answer comes back through hyper's client, and +//! this module is a binding: framing, chunked encoding and keep-alive are the crate's +//! problem. +//! +//! **One connection per exchange.** HTTP/1.1 has no multiplexing, and the edge above does: +//! a QUIC stream is one request, so it gets one upstream connection of its own, dialled +//! from the client's own address like every other dial this engine makes. Pooling would +//! put two clients' requests on one socket, and with it the thing `ChainSessions` exists +//! to prevent — one client's bytes deciding another's verdict — at a layer where nobody +//! would think to look for it. + +use std::io; +use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::Duration; + +use bytes::Bytes; +use http_body_util::BodyExt; + +use crate::http1::{Answer, Incoming, Outbound, OutboundBody}; + +/// The body of a request being written piece by piece into hyper. +/// +/// hyper takes a body as one value and pulls from it; the chain produces pieces as it +/// accepts them. A channel is the join between the two, and its bound is what keeps a +/// fast client from buying memory in this process while a slow service reads. +struct Streamed { + rx: tokio::sync::mpsc::Receiver>, +} + +impl http_body::Body for Streamed { + type Data = Bytes; + type Error = io::Error; + + fn poll_frame( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + self.get_mut().rx.poll_recv(cx).map(|frame| frame.map(Ok)) + } +} + +/// How many pieces of one request may be waiting for the service at once. +const IN_FLIGHT: usize = 8; + +/// How this engine reaches a service that speaks HTTP/1.1. +/// +/// Dial parameters and nothing else: there is no connection here, because each exchange +/// opens its own. Cloned per exchange the way the HTTP/3 sender above it is. +#[derive(Clone)] +pub(crate) struct H1Upstream { + pub(crate) upstream: SocketAddr, + /// Dialled as this address, so the service sees the real client. The same + /// unconditional source preservation every other dial here makes. + 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 + /// the same reason: a service seeing one source address for everyone is a regression + /// nobody would attribute to us. + pub(crate) spoof: bool, +} + +impl H1Upstream { + async fn connect(&self) -> io::Result { + if !self.spoof { + return tokio::time::timeout( + self.connect_timeout, + crate::transparent::connect_plain(self.upstream, self.self_mark), + ) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "upstream connect timed out"))?; + } + // Losing the client's address is bad, losing the connection is worse — the same + // trade, and just as loud about it, as the TCP and QUIC dials make. + match tokio::time::timeout( + self.connect_timeout, + crate::transparent::connect_as(self.client.ip(), self.upstream, self.self_mark), + ) + .await + { + Ok(Ok(stream)) => Ok(stream), + failed => { + let why = match failed { + Ok(Err(e)) => e.to_string(), + _ => "timed out (is the return path diverted?)".to_string(), + }; + crate::diag_throttled!("[warn] [h1] dials that could not keep the client's address", 1, 60, + "[warn] [h1] cannot reach {} as {}: {why}. Falling back to our own \ + address — the service will not see real client IPs.", + self.upstream, + self.client.ip(), + ); + tokio::time::timeout( + self.connect_timeout, + crate::transparent::connect_plain(self.upstream, self.self_mark), + ) + .await + .map_err(|_| { + io::Error::new(io::ErrorKind::TimedOut, "upstream connect timed out") + })? + } + } + } +} + +impl Outbound for H1Upstream { + type Error = io::Error; + type Body = H1Body; + type Answer = H1Answer; + + async fn open( + &mut self, + request: http::Request<()>, + ends_it: bool, + ) -> io::Result<(Self::Body, Self::Answer)> { + let socket = self.connect().await?; + let _ = socket.set_nodelay(true); + let (tx, rx) = tokio::sync::mpsc::channel(IN_FLIGHT); + let body = Streamed { rx }; + // The head goes out as HTTP/1.1 whatever it arrived as, and that is more than + // restating the version. HTTP/2 and HTTP/3 carry the target split across + // `:scheme`, `:authority` and `:path`, which `http::Uri` holds as one absolute + // URI — and written to a socket unchanged that is the *proxy* form of a request + // line, `GET https://host/path`, which an origin server is entitled to refuse and + // which every service reading the path back gets wrong. So the target is reduced + // to its origin form and the authority moves to `host`, which is exactly the + // decision `http1::render_request` already makes for the chain: what the filters + // were shown and what the service receives are the same message, and the two + // deciding it differently is the drift this engine is arranged against. + let (mut parts, _) = request.into_parts(); + parts.version = http::Version::HTTP_11; + if let Some(authority) = parts.uri.authority().map(|a| a.to_string()) { + if !parts.headers.contains_key(http::header::HOST) { + if let Ok(value) = http::HeaderValue::from_str(&authority) { + parts.headers.insert(http::header::HOST, value); + } + } + } + parts.uri = parts + .uri + .path_and_query() + .map(|target| target.as_str()) + .unwrap_or("/") + .parse() + .map_err(|e| io::Error::other(format!("cannot render the request target: {e}")))?; + let request = http::Request::from_parts(parts, body); + + let answer = match &self.tls { + Some(config) => { + 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), + ) + .await + .map_err(|_| { + io::Error::new(io::ErrorKind::TimedOut, "upstream TLS handshake timed out") + })??; + Self::handshake(stream, request).await? + } + None => Self::handshake(socket, request).await?, + }; + // A head that is the whole message gets a body that is already over: hyper then + // frames the request with no body at all, which is what HTTP/1.1 does for one. + // The flag exists for HTTP/2, where it has to ride on the head; here it only + // saves a channel nobody will write to. + Ok((H1Body { tx: (!ends_it).then_some(tx) }, answer)) + } +} + +impl H1Upstream { + /// The hyper side of opening one exchange, shared by the two kinds of socket. + async fn handshake(io: S, request: http::Request) -> io::Result + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, + { + let (mut sender, connection) = + hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(io)) + .await + .map_err(io::Error::other)?; + // hyper drives the socket from here; without this task nothing is ever written. + // Its end is this exchange's end, so it is dropped with the answer rather than + // detached — one connection per exchange is what makes that safe to say. + let driving = tokio::spawn(async move { + if let Err(e) = connection.await { + crate::diag_throttled!("[info] [h1] connections to the service that ended with an error", 20, 10, "[info] [h1] the connection to the service ended: {e}"); + } + }); + // Not awaited here: the service is allowed to answer before it has read the whole + // request — a refusal, a redirect, a `413` — and the body is still being written + // into the channel above by the other half of this exchange. Awaiting it now is a + // deadlock with a name. + let pending = Box::pin(sender.send_request(request)); + Ok(H1Answer { + pending: Some(pending), + body: None, + trailers: None, + _driving: driving, + }) + } +} + +/// The request body's writing half. +pub(crate) struct H1Body { + /// Taken by `finish`, because dropping the sender is what ends the body: hyper reads + /// the close as the last frame and writes the terminating chunk. Waiting on the + /// channel instead — for the *receiver* to go — is waiting for the service to answer + /// a request this end has not finished sending, which is a deadlock with a name. + tx: Option>>, +} + +impl H1Body { + async fn send(&mut self, frame: http_body::Frame) -> io::Result<()> { + let Some(tx) = self.tx.as_ref() else { + return Err(io::Error::other("this request body has already ended")); + }; + tx.send(frame) + .await + .map_err(|_| io::Error::other("the service stopped reading the request")) + } +} + +impl OutboundBody for H1Body { + type Error = io::Error; + + async fn data(&mut self, chunk: Bytes) -> io::Result<()> { + self.send(http_body::Frame::data(chunk)).await + } + + async fn trailers(&mut self, trailers: http::HeaderMap) -> io::Result<()> { + self.send(http_body::Frame::trailers(trailers)).await + } + + async fn finish(&mut self) -> io::Result<()> { + self.tx.take(); + Ok(()) + } +} + +type Pending = Pin< + Box>> + Send>, +>; + +/// The service's answer, read the way every other answer in this engine is. +pub(crate) struct H1Answer { + /// Taken by `response()`, which is the first thing the reading half does. + pending: Option, + body: Option, + /// A trailer section arrives as a frame among the body's, so it is kept here until + /// the body is done and somebody asks for it — which is the order the rendering + /// wants it in. + trailers: Option, + /// Dropped with the answer, which cancels the connection task: one connection per + /// exchange means its life is exactly this one's. + _driving: tokio::task::JoinHandle<()>, +} + +impl Answer for H1Answer { + async fn response(&mut self) -> io::Result> { + let pending = self + .pending + .take() + .ok_or_else(|| io::Error::other("the answer was already taken"))?; + let response = pending.await.map_err(io::Error::other)?; + let (mut parts, body) = response.into_parts(); + // This answer goes on in HTTP/2 or HTTP/3, where what an HTTP/1.1 connection says + // about itself has no place — and the rendering the chain is shown settles its own + // framing from what is left. + crate::http1::drop_connection_headers(&mut parts.headers); + self.body = Some(body); + Ok(http::Response::from_parts(parts, ())) + } +} + +impl Incoming for H1Answer { + type Error = io::Error; + + fn ended(&self) -> bool { + // hyper knows: a response framed with no body at all — `204`, `304`, a declared + // length of zero — is over before anybody reads it. + use http_body::Body; + self.body.as_ref().is_none_or(|body| body.is_end_stream()) + } + + async fn data(&mut self) -> io::Result> { + let Some(body) = self.body.as_mut() else { + return Ok(None); + }; + while let Some(frame) = body.frame().await { + let frame = frame.map_err(io::Error::other)?; + match frame.into_data() { + Ok(data) => return Ok(Some(data)), + // Not data, so it is the trailer section, and it is the last thing the + // body will produce. Kept for whoever asks next rather than dropped. + Err(frame) => { + if let Ok(trailers) = frame.into_trailers() { + self.trailers = Some(trailers); + } + } + } + } + Ok(None) + } + + async fn trailers(&mut self) -> io::Result> { + Ok(self.trailers.take()) + } +} diff --git a/backend/proxysrc/src/h2.rs b/backend/proxysrc/src/h2.rs new file mode 100644 index 0000000..f0edc60 --- /dev/null +++ b/backend/proxysrc/src/h2.rs @@ -0,0 +1,827 @@ +//! HTTP/2, terminated by the engine — and shown to the filters as HTTP/1.1. +//! +//! The rendering lives in [`crate::http1`]; what is here is the adapter, and the reason it +//! exists is the same one that put [`crate::h3`] there. HTTP/2 puts the method, the path +//! and the headers in an **HPACK-compressed** HEADERS frame, so a connection that +//! negotiated `h2` and was only forwarded showed a filter a compression format: a pattern +//! written against a request line matched nothing, and a filter asking for an +//! `HttpRequest` was handed the `PRI * HTTP/2.0` preface — parsed as one fictitious +//! request — and then frames it could not read, so it was never called again. No block, no +//! log, no counter: the exact failure everything else in this engine is arranged against. +//! Only a body travelled in the clear. Since gRPC *is* HTTP/2, that also meant gRPC could +//! not be filtered at all. +//! +//! Unlike QUIC, terminating was never forced here — the bytes are framed by a transport +//! the kernel understands, and firegex could have gone on forwarding them. It is forced by +//! the promise instead: a filter written once has to work everywhere, and there is no way +//! to keep that promise over HPACK without decompressing it, which is what parsing HTTP/2 +//! means. +//! +//! Three things differ from h3 and are worth stating, because each of them is a way to get +//! this wrong: +//! +//! * **Flow control is ours to run.** `h2` hands received bytes over and waits for them to +//! be released; a proxy that releases on read has no backpressure and buffers whatever +//! the faster side sends, and one that never releases stalls after the first window. So +//! capacity is released **after the piece has been forwarded**, which is what makes the +//! window follow the service rather than this process, and outbound writes reserve +//! capacity first rather than queueing in memory. +//! * **The end of a message rides on its last frame**, not on a separate close. A response +//! of head-and-nothing-else must be sent with `end_of_stream` **on the head**: send it +//! open and then close it with an empty DATA frame and a *trailers-only* answer stops +//! being one — which is precisely the shape of a gRPC status-only reply, and gRPC +//! clients refuse it. [`head_is_the_message`] is the one place that decision is made, +//! for both directions. +//! * **A stream is a connection to the chain**, which on HTTP/1.1 it is not. There a +//! keep-alive connection carries many requests through one set of filter state; here the +//! streams are concurrent and interleaved, and sharing state between them is exactly +//! "one client's bytes deciding another client's verdict" — the thing [`ChainSessions`] +//! exists to prevent. So an h2 stream gets its own, as an h3 request stream does. +//! +//! **Server push is not carried, and the service is told so in the handshake** +//! (`enable_push(false)`) rather than having its pushes dropped — the same courtesy the +//! QUIC edge pays about datagrams. **Extended CONNECT is not advertised**, and a plain +//! `CONNECT` stream is refused rather than rendered: a tunnel is opaque bytes, and +//! inventing an HTTP/1.1 request for one would be the fabrication this module exists to +//! avoid. + +use std::io; +use std::net::SocketAddr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +use tokio::io::{AsyncRead, AsyncWrite}; + +use crate::capture::{Capture, Tap}; +use crate::filter::{ + next_connection_id, ChainHandle, ChainSessions, ConnectionId, ConnectionMeta, Direction, L4, +}; +use crate::http1::{ + carried, close_body, conflicting_authority, head_framing, judge, push_body, render_request, + render_response, Answer, + Framing, Incoming, Outbound, OutboundBody, Rendered, +}; +use crate::proxy::ProxyStats; + +/// How many streams one client connection may have open at once. +/// +/// The h2 crate's default is none at all, and on this edge every stream is an exchange of +/// its own — towards an HTTP/1.1 service, a connection of its own to the service. So one +/// client connection, which `max_connections` counts once, could open as many connections +/// to the protected service as it liked, and a Python filter's state with each. A hundred +/// is what the QUIC edge already allows (quinn's default), so the two versions of one +/// service answer the same, and it is what RFC 9113 recommends as a floor. +const MAX_CONCURRENT_STREAMS: u32 = 100; + +/// How long a refused connection is given to put its GOAWAY on the wire. +/// +/// A refusal that simply dropped the socket would reach the client as a truncated TLS +/// stream, which is the peer having to guess — the same reason the byte pumps shut down +/// rather than abort. Bounded, because a peer that has stopped reading must not be able to +/// hold the task open. +const GOAWAY_GRACE: Duration = Duration::from_secs(2); + +impl Incoming for h2::RecvStream { + type Error = h2::Error; + + fn data(&mut self) -> impl std::future::Future, h2::Error>> + Send { + // Named through the trait's own path rather than `self.data()`: the inherent + // method has the same name, and which one a bare call resolves to is not something + // this file should depend on. + async move { h2::RecvStream::data(self).await.transpose() } + } + + fn trailers( + &mut self, + ) -> impl std::future::Future, h2::Error>> + Send { + h2::RecvStream::trailers(self) + } + + fn release(&mut self, taken: usize) -> Result<(), h2::Error> { + self.flow_control().release_capacity(taken) + } + + fn ended(&self) -> bool { + self.is_end_stream() + } +} + +/// HTTP/2 towards the service, which is what an HTTP/2 client edge used to always get. +/// +/// A handle to the connection `carry` opened, cloned per stream — which is what HTTP/2 +/// multiplexing is, and why this is a clone rather than a dial. +#[derive(Clone)] +pub(crate) struct H2Upstream(h2::client::SendRequest); + +impl Outbound for H2Upstream { + type Error = io::Error; + type Body = H2Send; + type Answer = H2Recv; + + async fn open( + &mut self, + request: http::Request<()>, + ends_it: bool, + ) -> io::Result<(Self::Body, Self::Answer)> { + // The parts travel as they arrived; only the version is restated, because what + // goes out is HTTP/2 again. + let (mut parts, _) = request.into_parts(); + parts.version = http::Version::HTTP_2; + let (response, to_service) = self + .0 + .send_request(http::Request::from_parts(parts, ()), ends_it) + .map_err(io::Error::other)?; + Ok(( + H2Send { inner: to_service, done: ends_it }, + H2Recv { pending: Some(response), body: None, trailers: None }, + )) + } +} + +/// The request body on its way to the service, over HTTP/2. +pub(crate) struct H2Send { + inner: h2::SendStream, + /// Whether the end of the stream has already gone out. Writing past it is a user + /// error `h2` reports rather than ignores, and the end can leave on the head, on a + /// trailer section, or on an empty final frame — three ways to get here once. + done: bool, +} + +impl OutboundBody for H2Send { + type Error = io::Error; + + async fn data(&mut self, chunk: Bytes) -> io::Result<()> { + send_body(&mut self.inner, chunk, false).await.map_err(io::Error::other) + } + + async fn trailers(&mut self, trailers: http::HeaderMap) -> io::Result<()> { + self.done = true; + self.inner.send_trailers(trailers).map_err(io::Error::other) + } + + async fn finish(&mut self) -> io::Result<()> { + if self.done { + return Ok(()); + } + self.done = true; + self.inner.send_data(Bytes::new(), true).map_err(io::Error::other) + } +} + +/// The service's answer over HTTP/2, read the way every other answer in this engine is. +pub(crate) struct H2Recv { + pending: Option, + body: Option, + trailers: Option, +} + +impl Answer for H2Recv { + async fn response(&mut self) -> io::Result> { + let pending = self + .pending + .take() + .ok_or_else(|| io::Error::other("the answer was already taken"))?; + let response = pending.await.map_err(io::Error::other)?; + let (parts, body) = response.into_parts(); + self.body = Some(body); + Ok(http::Response::from_parts(parts, ())) + } +} + +impl Incoming for H2Recv { + type Error = io::Error; + + async fn data(&mut self) -> io::Result> { + let Some(body) = self.body.as_mut() else { + return Ok(None); + }; + Incoming::data(body).await.map_err(io::Error::other) + } + + async fn trailers(&mut self) -> io::Result> { + if let Some(trailers) = self.trailers.take() { + return Ok(Some(trailers)); + } + let Some(body) = self.body.as_mut() else { + return Ok(None); + }; + Incoming::trailers(body).await.map_err(io::Error::other) + } + + fn release(&mut self, taken: usize) -> io::Result<()> { + match self.body.as_mut() { + Some(body) => body.release(taken).map_err(io::Error::other), + None => Ok(()), + } + } + + fn ended(&self) -> bool { + self.body.as_ref().is_some_and(|body| body.is_end_stream()) + } +} + +/// Everything one terminated HTTP/2 connection is carried with. +/// +/// Cloned per stream: every field is a handle already, so a clone is a few reference +/// counts and the streams need nothing from each other. +#[derive(Clone)] +pub struct Carriage { + pub client: SocketAddr, + pub upstream: SocketAddr, + pub chain: ChainHandle, + pub stats: Arc, + /// Where a reconstruction of each stream is written, when anything is listening. + pub capture: Option>, + /// How long this connection may say nothing at all. The question is the + /// *connection's*, exactly as it is on QUIC: a peer that opens twenty streams and + /// speaks on none is the shape it is there to catch. + pub first_byte_timeout: Option, +} + +impl Carriage { + fn rendered(&self) -> Rendered<'_> { + Rendered { + chain: &self.chain, + stats: &self.stats, + client: self.client, + layer: "h2", + } + } + + /// Tell the chain about a stream, in the words the rest of the engine uses. + /// + /// `L4::Tcp`, with nothing to distinguish it from HTTP/1.1 on the same service — which + /// is the whole point. An h2 stream is a stream, and it is TCP on the wire, so both + /// questions the enum answers have the same answer they have one layer up. + fn open(&self) -> ConnectionId { + let connection = next_connection_id(); + self.chain.current().connection_opened( + connection, + &ConnectionMeta { + client: self.client, + server: self.upstream, + l4: L4::Tcp, + }, + ); + connection + } + + /// A reconstruction of one stream, for whoever is watching the capture interface. + /// + /// Per stream and not per connection: a stream is what has a beginning, an order and + /// an end. What it has not got is a port of its own — every stream of one connection + /// shares the four-tuple — so [`Tap::open_stream`] invents one, and the documentation + /// says so wherever this interface is offered. + fn tap(&self) -> Option> { + Tap::open_stream(self.capture.clone(), self.client, self.upstream) + } +} + +/// Ends the whole connection because a rule refused something on one of its streams. +/// +/// A refusal ends the connection, not the stream. Resetting one stream of many would leave +/// the client free to ask again on the next, which is not what a block means anywhere else +/// in firegex — and on HTTP/1.1 keep-alive a refused request already takes the connection +/// with it. +#[derive(Clone)] +struct Refusal(Arc>); + +impl Refusal { + fn new() -> (Self, tokio::sync::watch::Receiver) { + let (tx, rx) = tokio::sync::watch::channel(false); + (Self(Arc::new(tx)), rx) + } + + fn refuse(&self) { + let _ = self.0.send(true); + } +} + +/// Carry one connection that both ends agreed to speak HTTP/2 on. +pub async fn carry(client_io: C, service_io: S, ctx: Carriage) -> io::Result<()> +where + C: AsyncRead + AsyncWrite + Unpin + Send + 'static, + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let (sender, driver) = h2::client::Builder::new() + // Said in the handshake rather than by dropping what arrives: a pushed response + // this proxy cannot render is one the service should not spend a stream on, and a + // peer that learns from SETTINGS is better off than one that watches pushes + // vanish. The same answer the QUIC edge gives about datagrams on h3. + .enable_push(false) + .handshake::<_, Bytes>(service_io) + .await + .map_err(io::Error::other)?; + carry_with(client_io, H2Upstream(sender), Some(tokio::spawn(driver)), ctx).await +} + +/// Carry an HTTP/2 client in front of a service that speaks **HTTP/1.1**. +/// +/// The edge the QUIC side has had all along, arriving here: what the filters are shown is +/// the same HTTP/1.1 rendering either way, so sending that rendering on to the service +/// invents nothing. It is what lets an ordinary cleartext web service be reached over +/// HTTP/2 with only firegex holding a certificate — the deployment that could already be +/// reached over HTTP/3 and, until this existed, not over HTTP/2, for no reason an operator +/// could see. +/// +/// No connection driver, because there is no connection: `H1Upstream` dials once per +/// exchange, which is also what keeps two clients' requests off one socket. +pub(crate) async fn carry_to_h1( + client_io: C, + upstream: crate::h1up::H1Upstream, + ctx: Carriage, +) -> io::Result<()> +where + C: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + carry_with(client_io, upstream, None, ctx).await +} + +async fn carry_with( + client_io: C, + upstream: U, + mut driving: Option>>, + ctx: Carriage, +) -> io::Result<()> +where + C: AsyncRead + AsyncWrite + Unpin + Send + 'static, + U: Outbound + Clone + Send + 'static, +{ + // Extended CONNECT is left unadvertised, which is the default: a service that wants + // WebTransport over HTTP/2 needs a session model this proxy has not got, and + // advertising it would invite streams whose rendering would be a fabrication. Push is + // turned off towards the service above, in the same spirit. + let mut server = h2::server::Builder::new() + .max_concurrent_streams(MAX_CONCURRENT_STREAMS) + .max_header_list_size(crate::http1::MAX_HEAD_BYTES) + .handshake::<_, Bytes>(client_io) + .await + .map_err(io::Error::other)?; + + let (refusal, mut refused) = Refusal::new(); + + // Set by the first head that crosses in either direction. A service that answers + // before its client has spoken satisfies it, exactly as a request would — the + // deadline is about silence, not about who speaks first. + let spoken = Arc::new(AtomicBool::new(false)); + let watchdog = ctx.first_byte_timeout.map(|deadline| { + let spoken = Arc::clone(&spoken); + let refusal = refusal.clone(); + let stats = Arc::clone(&ctx.stats); + tokio::spawn(async move { + tokio::time::sleep(deadline).await; + if !spoken.load(Ordering::Relaxed) { + stats.no_first_byte.fetch_add(1, Ordering::Relaxed); + // Through the same channel a refusal uses, so the connection is wound + // down with a GOAWAY rather than dropped: a peer dropped mid-stream reads + // a truncation, and this connection has done nothing to deserve one. + refusal.refuse(); + } + }) + }); + + // The exchanges already in flight, kept rather than detached. A client winding the + // connection down leaves `accept()` answering `None` while answers are still owed, and + // walking away there would cut off the last request of every well-behaved client. + let mut inflight = tokio::task::JoinSet::new(); + // A refusal and a first-byte deadline both end the connection here rather than + // letting it run out; the difference between them is in the log, not in what is + // done about it. + let mut ended_early = false; + + loop { + tokio::select! { + accepted = server.accept() => match accepted { + Some(Ok((request, respond))) => { + let ctx = ctx.clone(); + let refusal = refusal.clone(); + let mut sender = upstream.clone(); + let spoken = Arc::clone(&spoken); + inflight.spawn(async move { + let connection = ctx.open(); + let outcome = + exchange(&ctx, &refusal, &spoken, connection, request, respond, &mut sender) + .await; + // Whatever happened, the filter's state for this stream goes — a + // refused request and a completed one both end it. + ctx.chain.current().connection_closed(connection); + if let Err(e) = outcome { + crate::diag_throttled!("[info] [h2] streams that ended with an error", 20, 10, "[info] [h2] stream ended: {e}"); + } + }); + } + Some(Err(e)) => { + crate::diag_throttled!("[info] [h2] client connections that ended with an error", 20, 10, "[info] [h2] the client's connection ended: {e}"); + break; + } + // The client is done with the connection, which is the ordinary way out. + None => break, + }, + // The service ended its side. Waiting for the client to notice would mean + // holding a connection that has nowhere to go. Only where there *is* a + // connection: an HTTP/1.1 upstream opens one per exchange, so there is no + // shared driver to outlive and nothing to watch here. + reason = async { driving.as_mut().unwrap().await }, if driving.is_some() => { + if let Ok(Err(e)) = reason { + crate::diag_throttled!("[info] [h2] connections the service ended", 20, 10, "[info] [h2] the service ended the connection: {e}"); + } + break; + } + _ = refused.changed() => { + ended_early = true; + break; + } + } + } + + if ended_early { + // CANCEL rather than REFUSED_STREAM: the latter 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 reason reaches the operator through the service log, which is where + // the filter that refused it is already named. + server.abrupt_shutdown(h2::Reason::CANCEL); + // Polled rather than dropped, so the GOAWAY is actually written before the socket + // goes. Bounded: a peer that has stopped reading must not hold this open. + let _ = tokio::time::timeout( + GOAWAY_GRACE, + std::future::poll_fn(|cx| server.poll_closed(cx)), + ) + .await; + // Drained even here, and this is the part that is easy to get wrong: the + // exchanges still in flight have to *end* rather than be dropped, because ending + // is what releases the filter state each of them holds — for Python, a whole set + // of module globals, living in the worker under a connection id that only + // `connection_closed` frees. Aborting them by dropping the set leaked one of + // those per stream, on the path an attacker triggers on purpose. They finish + // immediately: the connection under them is already shut down. + let _ = tokio::time::timeout(GOAWAY_GRACE, drain(&mut inflight)).await; + } else { + // Nothing here is unbounded: every filter an exchange consults runs under the + // chain's own deadline, and the connection has the socket's. Unbounded on + // purpose, though — a client winding the connection down leaves answers still + // owed, and walking away would cut off its last exchange. + drain(&mut inflight).await; + } + drop(upstream); + if let Some(driving) = driving { + driving.abort(); + } + if let Some(watchdog) = watchdog { + watchdog.abort(); + } + Ok(()) +} + +/// Wait for every exchange still in flight to end. +/// +/// Ending is what releases what a filter is holding for that stream, so this is not +/// tidiness: a set of exchanges dropped rather than joined leaves their state behind. +async fn drain(inflight: &mut tokio::task::JoinSet<()>) { + while inflight.join_next().await.is_some() {} +} + +/// Whether a message's head is the whole of the message. +/// +/// Its own function because getting it wrong is invisible on HTTP/1.1 and fatal on +/// HTTP/2: the end of a message rides on its last frame, so a head that *is* the entire +/// message has to be sent as one. Sending it open and closing with an empty DATA frame +/// turns a trailers-only answer — a gRPC status-only reply is exactly that — into a +/// message with a body, which gRPC clients refuse. +/// +/// Three things have to hold together, and the sender is the authority on the third: no +/// first chunk was read, no trailer section is still owed, and the peer really did end its +/// half. The last one is why a `content-length: 0` request stays headers-only through this +/// proxy instead of growing an empty DATA frame it did not have. +fn head_is_the_message( + first: &Option, + early: &Option>, + from: &I, +) -> bool { + first.is_none() && !matches!(early, Some(Some(_))) && from.ended() +} + +/// One request and its answer. +/// +/// The two directions are carried **at the same time**, not one after the other, which is +/// the same shape the byte pumps have and for the same reason: a service is allowed to +/// answer before it has read the whole request — a refusal, a redirect, a `413` — and a +/// proxy that insists on finishing the upload first turns that into a stall neither end +/// can explain. +#[allow(clippy::too_many_arguments)] +async fn exchange( + ctx: &Carriage, + refusal: &Refusal, + spoken: &AtomicBool, + connection: ConnectionId, + request: http::Request, + respond: h2::server::SendResponse, + sender: &mut O, +) -> io::Result<()> { + spoken.store(true, Ordering::Relaxed); + let (parts, mut from_client) = request.into_parts(); + let head = http::Request::from_parts(parts, ()); + + // A tunnel is opaque bytes with no HTTP/1.1 message to render, so it is refused rather + // than shown to the chain as something it is not. Naming it beats a stream that hangs. + if head.method() == http::Method::CONNECT { + crate::diag_throttled!("[warn] [h2] CONNECT streams refused", 5, 30, + "[warn] [h2] {}: a CONNECT stream arrived. firegex terminates HTTP/2 to show \ + the filters each exchange as HTTP/1.1, and a tunnel has no such exchange in \ + it, so the stream is refused rather than carried unread.", + ctx.client + ); + let mut respond = respond; + respond.send_reset(h2::Reason::REFUSED_STREAM); + 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) { + crate::diag_throttled!("[warn] [h2] requests with two authorities", 5, 30, + "[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 + // framing is settled before the head is rendered, and settled from as little as will + // do it. + let (framing, first, early) = head_framing(&mut from_client, head.headers()) + .await + .map_err(io::Error::other)?; + let head_ends_it = head_is_the_message(&first, &early, &from_client); + + // One reconstruction for the exchange, not one per direction: a request and its + // response are one conversation, which is what they would have been on HTTP/1.1. + let tap = ctx.tap(); + let rendered = ctx.rendered(); + + let mut sessions = ChainSessions::new(connection); + let mut view = render_request(&head, &framing); + if let Some(chunk) = &first { + push_body(&mut view, chunk, &framing); + } + // A message that ended with a trailer section and no body is rendered whole here, + // terminator and trailers included: there is no later chunk to hang them off, and a + // trailer section shown to nobody is a piece of the message that travelled unread. + if let Some(trailers) = early.as_ref() { + close_body(&mut view, &framing, trailers.as_ref()); + } + if !judge(&rendered, Direction::ClientToServer, &view, &mut sessions, tap.as_ref()).await { + refusal.refuse(); + return Ok(()); + } + + // Only now does the service hear about it, in whatever version it speaks — HTTP/2 + // again where the service speaks it, HTTP/1.1 where the address said the service is a + // cleartext one. The rendering above is the same either way, which is what makes the + // second case a forward rather than a translation invented here. + let (parts, _) = head.into_parts(); + let (mut to_service, from_service) = sender + .open(http::Request::from_parts(parts, ()), head_ends_it) + .await + .map_err(io::Error::other)?; + + if let Some(chunk) = first { + let len = chunk.len(); + to_service.data(chunk).await.map_err(io::Error::other)?; + // After the forward, never before: the window that reopens is the one the service + // has actually taken the bytes off, which is what backpressure means here. + from_client.release(len).map_err(io::Error::other)?; + } + + let (asked, answered) = tokio::join!( + carry_request( + ctx, refusal, from_client, to_service, framing, sessions, tap.clone(), early, + head_ends_it, + ), + // Its own sessions, the same id: both halves of one exchange are one connection to + // a filter that keeps state, and each direction keeps its own position in it. + carry_response(ctx, refusal, spoken, connection, from_service, respond, tap.clone()), + ); + if let Some(tap) = tap { + tap.closed(); + } + asked?; + answered +} + +/// The rest of the request body, judged chunk by chunk and forwarded. +#[allow(clippy::too_many_arguments)] +async fn carry_request( + ctx: &Carriage, + refusal: &Refusal, + mut from_client: h2::RecvStream, + mut to_service: B, + framing: Framing, + mut sessions: ChainSessions, + tap: Option>, + early: Option>, + head_ended_it: bool, +) -> io::Result<()> +where + B::Error: std::error::Error + Send + Sync + 'static, +{ + // The head carried the end of the stream, so this half is already finished. Touching + // it again would be writing past the end, which `h2` reports as a user error rather + // than ignoring. + if head_ended_it { + return Ok(()); + } + let rendered = ctx.rendered(); + let trailers = match early { + // The body was over before the head was rendered, so both were shown to the chain + // together. Reading either again would be reading past the end of the stream. + Some(trailers) => trailers, + None => { + // A pattern split across two DATA frames is still a pattern, so the session + // carries on from the head. + while let Some(chunk) = Incoming::data(&mut from_client) + .await + .map_err(io::Error::other)? + { + let mut view = Vec::new(); + push_body(&mut view, &chunk, &framing); + if !judge(&rendered, Direction::ClientToServer, &view, &mut sessions, tap.as_ref()) + .await + { + refusal.refuse(); + return Ok(()); + } + let len = chunk.len(); + to_service.data(chunk).await.map_err(io::Error::other)?; + from_client.release(len).map_err(io::Error::other)?; + } + let trailers = Incoming::trailers(&mut from_client) + .await + .map_err(io::Error::other)?; + let mut tail = Vec::new(); + close_body(&mut tail, &framing, trailers.as_ref()); + if !tail.is_empty() + && !judge(&rendered, Direction::ClientToServer, &tail, &mut sessions, tap.as_ref()) + .await + { + refusal.refuse(); + return Ok(()); + } + carried(&rendered, framing, trailers) + } + }; + if let Some(trailers) = trailers { + to_service.trailers(trailers).await.map_err(io::Error::other)?; + } + to_service.finish().await.map_err(io::Error::other) +} + +/// The answer, rendered and judged the same way. +#[allow(clippy::too_many_arguments)] +async fn carry_response( + ctx: &Carriage, + refusal: &Refusal, + spoken: &AtomicBool, + connection: ConnectionId, + mut from_service: A, + mut respond: h2::server::SendResponse, + tap: Option>, +) -> io::Result<()> +where + ::Error: std::error::Error + Send + Sync + 'static, +{ + let rendered = ctx.rendered(); + let head = from_service.response().await.map_err(io::Error::other)?; + spoken.store(true, Ordering::Relaxed); + + let mut sessions = ChainSessions::new(connection); + // The answer's framing is settled the same way the request's was, and for the second + // half of the same reason: a service that sends its head and then waits — a streaming + // RPC, a long poll — must not have that head held here, or the client is waiting for + // something that is waiting for the client. + let (framing, first, early) = head_framing(&mut from_service, head.headers()) + .await + .map_err(io::Error::other)?; + let head_ends_it = head_is_the_message(&first, &early, &from_service); + + let mut view = render_response(&head, &framing); + if let Some(chunk) = &first { + push_body(&mut view, chunk, &framing); + } + if let Some(trailers) = early.as_ref() { + close_body(&mut view, &framing, trailers.as_ref()); + } + if !judge(&rendered, Direction::ServerToClient, &view, &mut sessions, tap.as_ref()).await { + refusal.refuse(); + return Ok(()); + } + + let (mut parts, _) = head.into_parts(); + parts.version = http::Version::HTTP_2; + // `end_of_stream` on the head where the head is the message: a status-only gRPC answer + // is HEADERS and nothing else, and closing it with an empty DATA frame instead would + // make it a message with a body that gRPC clients then refuse. + let mut to_client = respond + .send_response(http::Response::from_parts(parts, ()), head_ends_it) + .map_err(io::Error::other)?; + + if let Some(chunk) = first { + let len = chunk.len(); + send_body(&mut to_client, chunk, false).await.map_err(io::Error::other)?; + from_service.release(len).map_err(io::Error::other)?; + } + + if head_ends_it { + return Ok(()); + } + + let trailers = match early { + Some(trailers) => trailers, + None => { + while let Some(chunk) = Incoming::data(&mut from_service) + .await + .map_err(io::Error::other)? + { + let mut view = Vec::new(); + push_body(&mut view, &chunk, &framing); + if !judge(&rendered, Direction::ServerToClient, &view, &mut sessions, tap.as_ref()) + .await + { + refusal.refuse(); + return Ok(()); + } + let len = chunk.len(); + send_body(&mut to_client, chunk, false).await.map_err(io::Error::other)?; + from_service.release(len).map_err(io::Error::other)?; + } + let trailers = Incoming::trailers(&mut from_service) + .await + .map_err(io::Error::other)?; + let mut tail = Vec::new(); + close_body(&mut tail, &framing, trailers.as_ref()); + if !tail.is_empty() + && !judge(&rendered, Direction::ServerToClient, &tail, &mut sessions, tap.as_ref()) + .await + { + refusal.refuse(); + return Ok(()); + } + carried(&rendered, framing, trailers) + } + }; + finish(&mut to_client, trailers).map_err(io::Error::other) +} + +/// End a half of an exchange, with the trailer section it is allowed to carry. +/// +/// A message whose head already carried `end_of_stream` is finished, and touching the +/// stream again would be writing past the end of it; `h2` reports that as a user error +/// rather than ignoring it, so the emptiness is checked here rather than at each call. +fn finish( + to: &mut h2::SendStream, + trailers: Option, +) -> Result<(), h2::Error> { + match trailers { + Some(trailers) => to.send_trailers(trailers), + None => to.send_data(Bytes::new(), true), + } +} + +/// Write one piece of a body, taking the window as it opens. +/// +/// `send_data` on its own queues whatever it is given inside the connection, so a client +/// that uploads faster than the service reads would be buying memory in this process — +/// which is the resource the connection limit exists to defend. Reserving first and +/// writing what is granted is what turns the far end's window into backpressure on the +/// near one, and it is the other half of releasing capacity only after a forward. +async fn send_body( + to: &mut h2::SendStream, + mut data: Bytes, + end: bool, +) -> Result<(), h2::Error> { + if data.is_empty() { + return to.send_data(data, end); + } + while !data.is_empty() { + to.reserve_capacity(data.len()); + let granted = match std::future::poll_fn(|cx| to.poll_capacity(cx)).await { + Some(granted) => granted?, + // The far end is gone. Saying so as an error lets the caller wind the + // exchange down the way every other failure here does. + None => return to.send_data(data, end), + }; + if granted == 0 { + continue; + } + let piece = data.split_to(granted.min(data.len())); + to.send_data(piece, end && data.is_empty())?; + } + Ok(()) +} diff --git a/backend/proxysrc/src/h3.rs b/backend/proxysrc/src/h3.rs new file mode 100644 index 0000000..eddabc4 --- /dev/null +++ b/backend/proxysrc/src/h3.rs @@ -0,0 +1,512 @@ +//! HTTP/3, terminated on top of QUIC — and shown to the filters as HTTP/1.1. +//! +//! The rendering itself lives in [`crate::http1`], which is where the reasoning about it +//! belongs now that HTTP/2 is shown to the chain the same way. What is left here is the +//! adapter: hyperium's `h3` parses and re-encodes the frames, this module decides when to +//! ask the chain and what to do with the answer. +//! +//! Terminating is not a choice on this layer the way it is on TCP. Past its Initial packet +//! QUIC encrypts the frames, the stream boundaries and the packet number, so a layer that +//! only forwards has nothing a filter could be shown. What goes on to the service is +//! HTTP/3 again, re-encoded — the rendering is a view, never a translation the service +//! sees. Unless the operator says the service speaks HTTP/1.1 and nothing else, which is +//! the one place that sentence stops being true and is written down where it is chosen: +//! see [`crate::h1up`]. +//! +//! One request is one connection to the chain, which is [`Carrier::open`]'s rule applied +//! to h3: a request stream carries exactly one exchange, so a filter's per-connection +//! state — for Python, a whole set of module globals — lives exactly as long as the +//! request it belongs to. + +use std::future::Future; +use std::io; +use std::sync::atomic::Ordering; +use std::sync::Arc; + +use bytes::{Buf, Bytes}; +use h3::error::StreamError; + +use crate::capture::Tap; +use crate::filter::{ChainSessions, ConnectionId, Direction}; +use crate::http1::{ + carried, close_body, conflicting_authority, connection_specific, head_framing, judge, + push_body, render_request, + render_response, Answer, + Framing, Incoming, Outbound, OutboundBody, Rendered, +}; +use crate::quic::{Behind, Carrier}; + +impl Incoming for h3::server::RequestStream +where + S: h3::quic::RecvStream + Send, + B: Buf + Send, +{ + type Error = StreamError; + + fn data(&mut self) -> impl Future, StreamError>> + Send { + async move { Ok(self.recv_data().await?.map(collect)) } + } + + fn trailers( + &mut self, + ) -> impl Future, StreamError>> + Send { + self.recv_trailers() + } +} + +impl Incoming for h3::client::RequestStream +where + S: h3::quic::RecvStream + Send, + B: Buf + Send, +{ + type Error = StreamError; + + fn data(&mut self) -> impl Future, StreamError>> + Send { + async move { Ok(self.recv_data().await?.map(collect)) } + } + + fn trailers( + &mut self, + ) -> impl Future, StreamError>> + Send { + self.recv_trailers() + } +} + +/// How this connection's exchanges are described to the shared rendering. +fn rendered<'a>(carrier: &'a Carrier) -> Rendered<'a> { + Rendered { + chain: &carrier.chain, + stats: &carrier.stats, + client: carrier.client, + layer: "h3", + } +} + +/// The two halves of one h3 request stream, as the shared rendering wants them. +type H3Send = h3::client::RequestStream< + as h3::quic::BidiStream>::SendStream, + Bytes, +>; +type H3Recv = h3::client::RequestStream< + as h3::quic::BidiStream>::RecvStream, + Bytes, +>; + +/// HTTP/3 towards the service, which is what every HTTP/3 client edge used to get. +impl Outbound for h3::client::SendRequest { + type Error = StreamError; + type Body = H3Send; + type Answer = H3Recv; + + async fn open( + &mut self, + request: http::Request<()>, + // HTTP/3 frames the end of a message on the stream, so nothing has to be said on + // the head: the body half is finished by the caller when there is none, exactly + // as it is when there is one. The flag is HTTP/2's problem. + _ends_it: bool, + ) -> Result<(Self::Body, Self::Answer), StreamError> { + // The parts travel as they arrived; only the version is restated, because what + // goes out is HTTP/3 again. + let (mut parts, _) = request.into_parts(); + parts.version = http::Version::HTTP_3; + let stream = self + .send_request(http::Request::from_parts(parts, ())) + .await?; + let (send, recv) = stream.split(); + Ok((send, recv)) + } +} + +impl OutboundBody for H3Send { + type Error = StreamError; + + async fn data(&mut self, chunk: Bytes) -> Result<(), StreamError> { + self.send_data(chunk).await + } + + async fn trailers(&mut self, trailers: http::HeaderMap) -> Result<(), StreamError> { + self.send_trailers(trailers).await + } + + async fn finish(&mut self) -> Result<(), StreamError> { + h3::client::RequestStream::finish(self).await + } +} + +impl Answer for H3Recv { + async fn response(&mut self) -> Result, StreamError> { + self.recv_response().await + } +} + +/// Carry one HTTP/3 connection: accept requests, and forward each one. +/// +/// Where they are forwarded *to* is the operator's choice, and the only thing that +/// changes with it is which sender the exchanges are handed. Everything the chain is +/// shown is built by [`crate::http1`] either way — which is the whole reason the choice +/// could be offered at all. +pub(crate) async fn carry(carrier: &Carrier) { + // **No GREASE, in either direction.** The reserved frame and stream types exist so + // that implementations keep their handling of unknown extensions honest, which is a + // worthy thing for a browser to do and is not firegex's to do on somebody's service. + // Sending them means putting a frame on the wire that the client never sent, and the + // first real server this was pointed at — aioquic — stopped answering requests + // entirely because of it: the trailing grease frame left its end-of-stream + // unreported, so the request was received and never completed. A firewall that + // breaks the service it is protecting in order to exercise that service's protocol + // handling has its priorities backwards. + let mut server = match h3::server::builder() + .send_grease(false) + .max_field_section_size(u64::from(crate::http1::MAX_HEAD_BYTES)) + .build(h3_quinn::Connection::new(carrier.peer.clone())) + .await + { + Ok(connection) => connection, + Err(e) => { + crate::diag_throttled!("[info] [h3] HTTP/3 connections that did not start", 20, 10, "[info] [h3] the client's HTTP/3 connection did not start: {e}"); + return; + } + }; + + match carrier.service.clone() { + Behind::Quic(connection) => { + let (mut driver, sender) = match h3::client::builder() + .send_grease(false) + .build(h3_quinn::Connection::new(connection)) + .await + { + Ok(pair) => pair, + Err(e) => { + crate::diag_throttled!("[warn] [h3] connections to a service that did not speak HTTP/3", 1, 30, "[warn] [h3] {} does not speak HTTP/3: {e}", carrier.upstream); + carrier.peer.close(0u32.into(), b"the service did not speak HTTP/3"); + return; + } + }; + // The client half of h3 needs something to run its connection: control + // streams, settings, and the QPACK streams underneath every header block. + // Without this task nothing upstream ever completes. + let driving = tokio::spawn(async move { driver.wait_idle().await.to_string() }); + accept_loop(carrier, &mut server, sender, Some(driving)).await; + } + // A service that speaks HTTP/1.1 has no connection to run and nothing to keep + // alive between exchanges: each one dials its own, so there is no driver here + // and nothing for the loop to watch but the client. + Behind::Http1(upstream) => accept_loop(carrier, &mut server, upstream, None).await, + } +} + +/// Take requests until the client is done, and let each one run on its own. +async fn accept_loop( + carrier: &Carrier, + server: &mut h3::server::Connection, + sender: O, + driving: Option>, +) where + O: Outbound + Clone + Send + 'static, +{ + // The requests already in flight, kept rather than detached. A client winding the + // connection down — a GOAWAY, or simply no more requests — leaves `accept()` + // answering `None` while answers are still owed, and walking away there would cut + // off the last exchange of every well-behaved client. + let mut inflight = tokio::task::JoinSet::new(); + let mut driving = driving; + + loop { + let accepted = tokio::select! { + accepted = server.accept() => accepted, + // The service ended its side. Waiting for the client to notice would mean + // waiting out the idle timeout with a connection that has nowhere to go. + // With an HTTP/1.1 service there is no such side to watch — its connections + // live and die with one exchange — so this arm simply never fires. + reason = async { + match driving.as_mut() { + Some(driver) => driver.await.ok(), + None => std::future::pending().await, + } + } => { + if let Some(reason) = reason { + crate::diag_throttled!("[info] [h3] connections the service ended", 20, 10, "[info] [h3] the service ended the connection: {reason}"); + } + carrier.peer.close(0u32.into(), b"the service closed the connection"); + break; + } + }; + match accepted { + Ok(Some(resolver)) => { + let carrier = carrier.clone(); + let mut sender = sender.clone(); + inflight.spawn(async move { + let connection = carrier.open(); + let outcome = exchange(&carrier, connection, resolver, &mut sender).await; + // Whatever happened, the filter's state for this request goes — a + // refused request and a completed one both end it. + carrier.chain.current().connection_closed(connection); + if let Err(e) = outcome { + crate::diag_throttled!("[info] [h3] requests that ended with an error", 20, 10, "[info] [h3] request ended: {e}"); + } + }); + } + // The client is done with the connection, which is the ordinary way out. + Ok(None) => break, + Err(e) => { + crate::diag_throttled!("[info] [h3] client connections that ended with an error", 20, 10, "[info] [h3] the client's connection ended: {e}"); + break; + } + } + } + // Nothing here is unbounded: each exchange rides on connections with an idle + // timeout, and every filter it consults runs under the chain's own deadline. + while inflight.join_next().await.is_some() {} + if let Some(driving) = driving { + driving.abort(); + } +} + +/// One request and its answer. +/// +/// The two directions are carried **at the same time**, not one after the other, which +/// is the same shape the TCP pumps have and for the same reason: a service is allowed to +/// answer before it has read the whole request — a refusal, a redirect, a `413` — and a +/// proxy that insists on finishing the upload first turns that into a stall neither end +/// can explain. +/// +/// Generic over where it is going, and over nothing else: what the chain is shown is the +/// same rendering whatever answers, which is the promise the whole module makes. +async fn exchange( + carrier: &Carrier, + connection: ConnectionId, + resolver: h3::server::RequestResolver, + sender: &mut O, +) -> io::Result<()> { + 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) { + crate::diag_throttled!("[warn] [h3] requests with two authorities", 5, 30, + "[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(()); + } + // Malformed as well (RFC 9114 §4.2), and not refused by the h3 crate: see + // `connection_specific`. HTTP/2 needs no such line, because h2 refuses them itself. + if let Some(why) = connection_specific(request.headers()) { + crate::diag_throttled!("[warn] [h3] requests carrying connection-specific headers", 5, 30, + "[warn] [h3] {}: {why}. The stream is stopped as malformed.", + carrier.client + ); + from_client.stop_stream(h3::error::Code::H3_MESSAGE_ERROR); + return Ok(()); + } + + // A request that carries no body must not be shown `transfer-encoding: chunked`, + // which it never had — a filter looking for that header is usually looking for + // smuggling, and one invented by the proxy in front of it is the worst possible + // answer. So the framing is settled before the head is rendered, and settled from + // as little as will do it. + let (framing, first, early) = head_framing(&mut from_client, request.headers()) + .await + .map_err(io::Error::other)?; + + // One reconstruction for the exchange, not one per direction: a request and its + // response are one conversation, which is what they would have been on TCP. + let tap = carrier.tap(); + let rendered = rendered(carrier); + + // This direction's filter state, and the head as the chain will see it. + let mut sessions = ChainSessions::new(connection); + let mut view = render_request(&request, &framing); + if let Some(chunk) = &first { + push_body(&mut view, chunk, &framing); + } + // A message that ended with a trailer section and no body is rendered whole here, + // terminator and trailers included: there is no later chunk to hang them off, and a + // trailer section shown to nobody is a piece of the message that travelled unread. + if let Some(trailers) = early.as_ref() { + close_body(&mut view, &framing, trailers.as_ref()); + } + if !judge(&rendered, Direction::ClientToServer, &view, &mut sessions, tap.as_ref()).await { + carrier.refuse(); + return Ok(()); + } + + // Only now does the service hear about it, in whatever version it speaks. + let (parts, _) = request.into_parts(); + // Whether this head is the whole message. On HTTP/3 the request stream says so the + // way HTTP/2 does, and the flag only matters to an upstream that has to state it on + // the head — but it is computed from the same three answers wherever it is asked. + let ends_it = first.is_none() && !matches!(early, Some(Some(_))) && from_client.ended(); + let (mut to_service, from_service) = sender + .open(http::Request::from_parts(parts, ()), ends_it) + .await + .map_err(io::Error::other)?; + if let Some(chunk) = first { + to_service.data(chunk).await.map_err(io::Error::other)?; + } + + let (to_client, from_client) = from_client.split(); + + let (asked, answered) = tokio::join!( + carry_request(carrier, from_client, to_service, framing, sessions, tap.clone(), early), + // Its own sessions, the same id: both halves of one exchange are one connection + // to a filter that keeps state, and each direction keeps its own position in it. + carry_response(carrier, connection, from_service, to_client, tap.clone()), + ); + if let Some(tap) = tap { + tap.closed(); + } + asked?; + answered +} + +/// The rest of the request body, judged chunk by chunk and forwarded. +#[allow(clippy::too_many_arguments)] +async fn carry_request( + carrier: &Carrier, + mut from_client: h3::server::RequestStream< + as h3::quic::BidiStream>::RecvStream, + Bytes, + >, + mut to_service: B, + framing: Framing, + mut sessions: ChainSessions, + tap: Option>, + early: Option>, +) -> io::Result<()> +where + B::Error: std::error::Error + Send + Sync + 'static, +{ + let rendered = rendered(carrier); + let trailers = match early { + // The body was over before the head was rendered, so both were shown to the chain + // together. Reading either again would be reading past the end of the stream. + Some(trailers) => trailers, + None => { + // A pattern split across two DATA frames is still a pattern, so the session + // carries on from the head. + while let Some(chunk) = from_client.data().await.map_err(io::Error::other)? { + let mut view = Vec::new(); + push_body(&mut view, &chunk, &framing); + if !judge(&rendered, Direction::ClientToServer, &view, &mut sessions, tap.as_ref()) + .await + { + carrier.refuse(); + return Ok(()); + } + to_service.data(chunk).await.map_err(io::Error::other)?; + } + let trailers = from_client.trailers().await.map_err(io::Error::other)?; + let mut tail = Vec::new(); + close_body(&mut tail, &framing, trailers.as_ref()); + if !tail.is_empty() + && !judge(&rendered, Direction::ClientToServer, &tail, &mut sessions, tap.as_ref()) + .await + { + carrier.refuse(); + return Ok(()); + } + carried(&rendered, framing, trailers) + } + }; + if let Some(trailers) = trailers { + to_service + .trailers(trailers) + .await + .map_err(io::Error::other)?; + } + to_service.finish().await.map_err(io::Error::other) +} + +/// The answer, rendered and judged the same way. +async fn carry_response( + carrier: &Carrier, + connection: ConnectionId, + mut from_service: A, + mut to_client: h3::server::RequestStream< + as h3::quic::BidiStream>::SendStream, + Bytes, + >, + tap: Option>, +) -> io::Result<()> +where + ::Error: std::error::Error + Send + Sync + 'static, +{ + let rendered = rendered(carrier); + let response = from_service.response().await.map_err(io::Error::other)?; + let mut sessions = ChainSessions::new(connection); + // The answer's framing is settled the same way the request's was, and for the second + // half of the same reason: a service that sends its head and then waits — a streaming + // RPC, a long poll — must not have that head held here, or the client is waiting for + // something that is waiting for the client. + let (framing, first, early) = head_framing(&mut from_service, response.headers()) + .await + .map_err(io::Error::other)?; + + let mut view = render_response(&response, &framing); + if let Some(chunk) = &first { + push_body(&mut view, chunk, &framing); + } + if let Some(trailers) = early.as_ref() { + close_body(&mut view, &framing, trailers.as_ref()); + } + if !judge(&rendered, Direction::ServerToClient, &view, &mut sessions, tap.as_ref()).await { + carrier.refuse(); + return Ok(()); + } + + let (mut parts, _) = response.into_parts(); + parts.version = http::Version::HTTP_3; + to_client + .send_response(http::Response::from_parts(parts, ())) + .await + .map_err(io::Error::other)?; + if let Some(chunk) = first { + to_client.send_data(chunk).await.map_err(io::Error::other)?; + } + let trailers = match early { + Some(trailers) => trailers, + None => { + while let Some(chunk) = from_service.data().await.map_err(io::Error::other)? { + let mut view = Vec::new(); + push_body(&mut view, &chunk, &framing); + if !judge(&rendered, Direction::ServerToClient, &view, &mut sessions, tap.as_ref()) + .await + { + carrier.refuse(); + return Ok(()); + } + to_client.send_data(chunk).await.map_err(io::Error::other)?; + } + let trailers = from_service.trailers().await.map_err(io::Error::other)?; + let mut tail = Vec::new(); + close_body(&mut tail, &framing, trailers.as_ref()); + if !tail.is_empty() + && !judge(&rendered, Direction::ServerToClient, &tail, &mut sessions, tap.as_ref()) + .await + { + carrier.refuse(); + return Ok(()); + } + carried(&rendered, framing, trailers) + } + }; + if let Some(trailers) = trailers { + to_client + .send_trailers(trailers) + .await + .map_err(io::Error::other)?; + } + to_client.finish().await.map_err(io::Error::other) +} + +/// One DATA frame's bytes, however many pieces the parser had them in. +fn collect(mut buf: impl Buf) -> Bytes { + let len = buf.remaining(); + buf.copy_to_bytes(len) +} diff --git a/backend/proxysrc/src/http1.rs b/backend/proxysrc/src/http1.rs new file mode 100644 index 0000000..ef98891 --- /dev/null +++ b/backend/proxysrc/src/http1.rs @@ -0,0 +1,523 @@ +//! The HTTP/1.1 view every terminated HTTP protocol is shown to the chain as. +//! +//! A filter written against `HttpRequest` is a filter written once. The same file is +//! supposed to work on every layer, and the library decides when to call it from what its +//! parameters are annotated with — not from what the service happens to speak. HTTP/2 +//! puts the method, the path and the headers in an HPACK-compressed HEADERS frame and +//! HTTP/3 in a QPACK one, so a filter handed the bytes of either would be handed a +//! compression format, and every pattern written for the HTTP/1.1 service beside it would +//! silently stop matching. +//! +//! So each exchange is rendered as the HTTP/1.1 message it would have been, and that is +//! what the chain is shown. `HttpRequest`, `HttpResponse` and a hyperscan pattern all mean +//! the same thing on every version, and nothing had to learn a new protocol. The same +//! bargain the capture interface makes: **the bytes are real and the framing is +//! reconstructed**. Concretely, and this belongs wherever this is documented — +//! +//! * the request line says `HTTP/1.1`, because there is no other version an HTTP/1 parser +//! will read, and the version on the wire was not it; +//! * a body with no `content-length` is shown chunked, which is what that message is in +//! HTTP/1.1 — and the head is held until it is known whether a body is coming, so that a +//! request without one is not shown a framing header it never had. Held *briefly*: a +//! declared length settles the question outright and waits for nothing, a message with +//! no body has already ended its stream, and what is left is a sender holding the stream +//! open, which [`HEAD_HOLD`] ends rather than deadlocking an exchange the service is +//! supposed to speak first in; +//! * a **trailer section is always shown to the chain**, which is what decides the framing +//! of a message that has one and no body: in HTTP/1.1 a trailer section belongs to a +//! chunked message and nowhere else. The one message that cannot carry one in the +//! rendering is one that declared its length, and there the trailers are dropped rather +//! than forwarded past a filter that never saw them; +//! * `host` is rendered from the `:authority` pseudo-header, which is where both HTTP/2 +//! and HTTP/3 put it; +//! * hop-by-hop headers do not appear: both versions forbid them, a request carrying one +//! is refused as malformed ([`connection_specific`]), and an answer from a service +//! reached over HTTP/1.1 has its own taken out before it goes on +//! ([`drop_connection_headers`]). +//! +//! A message whose declared `content-length` disagrees with the body it then sends is +//! malformed in HTTP/2 and HTTP/3 as well, and nothing here tries to repair it: the frames +//! are forwarded as they came, and the rendering carries the same disagreement — which an +//! HTTP/1 parser reads as a message that does not end where it said it would. That is what +//! the library's invalid-encoding action is for, and it refuses by default, which is the +//! same answer the same traffic gets over HTTP/1.1. +//! +//! What it is *not* is a translation the service ever sees: upstream, the message goes +//! back out in the version it arrived in, re-encoded by the same library that parsed it. +//! +//! **The capture interface is shown this same rendering**, one TCP conversation per +//! exchange, which is the only way `firegex0` can keep the promise it makes everywhere +//! else: what is written there is what the filters saw. It is not what left this process +//! towards the service, so the rendering is a view in the capture exactly as it is a view +//! to a filter, and the two cannot disagree because they are the same bytes. +//! +//! # Why this module exists at all +//! +//! It was inside `h3.rs`, and HTTP/2 wanted every line of it. Copying it would have been +//! two renderings that can come to disagree — the same argument the [`Incoming`] trait +//! already makes *within* one protocol, one level up. The day they diverge the symptom is +//! a pattern that matches over HTTP/3 and not over HTTP/2, silently, which is the failure +//! this whole engine is arranged against. So the rendering lives here once, and +//! [`crate::h2`] and [`crate::h3`] are what is left of each protocol after it is taken +//! out: an adapter to somebody else's parser. + +use std::future::Future; +use std::net::SocketAddr; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; + +use crate::capture::Tap; +use crate::filter::{ChainHandle, ChainSessions, Direction, Verdict}; +use crate::proxy::ProxyStats; + +/// How the body of one message is framed in the view the filters are shown. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Framing { + /// The message said how long it is, so the body is shown as it arrives. + Sized, + /// It did not, so it is shown chunked — the HTTP/1.1 spelling of that. + Chunked, + /// There is no body at all, and nothing is added to say so. + Empty, +} + +/// How long the head is held while it is still unknown whether a body follows. +/// +/// Only ever paid by a message that declared no length *and* has not yet said whether it +/// has a body — which is to say, one whose sender is holding the stream open. A message +/// with a `content-length` is rendered from its head alone and waits for nothing; one +/// without a body has already finished its stream, so the answer is there before this +/// timer starts. +/// +/// It exists because holding the head indefinitely deadlocks an exchange the service is +/// supposed to speak first in: a bidirectional RPC — gRPC's among them — sends a head and +/// then waits to be answered, and a proxy that will not forward the head until a body +/// arrives is waiting for something that is waiting for it. When it expires the message is +/// rendered chunked, which is what a message of undeclared length *is* in HTTP/1.1, so +/// nothing is invented that the sender did not already imply. +pub(crate) const HEAD_HOLD: Duration = Duration::from_millis(100); + +/// Everything the rendering needs about who is being carried, and no more. +/// +/// Neither protocol's connection type appears here on purpose: what this module does is +/// the same on both, and a parameter that differed would be the seam along which the two +/// renderings could start to drift again. +pub(crate) struct Rendered<'a> { + pub(crate) chain: &'a ChainHandle, + pub(crate) stats: &'a Arc, + pub(crate) client: SocketAddr, + /// `h2` or `h3`, for the one log line this module writes. + pub(crate) layer: &'static str, +} + +/// The receiving half of one direction of an exchange. +/// +/// Four types end up here — a server and a client request stream on each of two crates — +/// and nothing unifies them, while every question this module asks a message is the same +/// on all of them. One trait rather than the same framing decision written four times: the +/// copies are renderings that can come to disagree, and the whole point of the rendering +/// is that a filter sees one thing. +pub(crate) trait Incoming { + /// Whatever the underlying crate says went wrong. Never inspected here — the caller + /// owns the protocol and its errors, this module owns the framing. + type Error; + + fn data(&mut self) -> impl Future, Self::Error>> + Send; + fn trailers( + &mut self, + ) -> impl Future, Self::Error>> + Send; + + /// Give back the window one piece took, once it has been forwarded and not before. + /// + /// HTTP/2 is the only protocol here that asks: it hands received bytes over and waits + /// for them to be released, so releasing on *read* would mean no backpressure and + /// this process buying memory for whichever side is faster, while never releasing + /// stalls the stream after its first window. Everything else frames its own flow and + /// answers with the default. + fn release(&mut self, taken: usize) -> Result<(), Self::Error> { + let _ = taken; + Ok(()) + } + + /// Whether the sender has already said there is nothing more coming. + /// + /// Only meaningful beside a head that produced no body and no trailer section: the + /// three together are what decide whether the head **is** the message, which HTTP/2 + /// has to state on the head itself (see [`Outbound::open`]). A protocol whose sender + /// cannot say it in advance answers `false`, which is always safe — the message is + /// then framed the ordinary way and ends when its body does. + fn ended(&self) -> bool { + false + } +} + +/// Where a rendered message is sent once the chain has agreed to it. +/// +/// The mirror of [`Incoming`], and it exists for the same reason one level further out: +/// what leaves this engine towards the service used to be, always, the protocol the +/// client arrived in — so the sending half could be written against one crate's concrete +/// types. It is a choice now (`FGEX_PROXY_UPSTREAM`), because a service that speaks +/// HTTP/1.1 can be put behind an HTTP/3 edge, and the alternative to this trait was a +/// second copy of the orchestration in [`crate::h3`] — request in one direction, answer +/// in the other, framing decided on both — which is the drift this module exists to +/// prevent, one level down. +/// +/// Note what is *not* here: nothing about rendering. The bytes a filter sees are built by +/// this module whatever the service turns out to speak, so an implementation of this +/// trait only has to know how to put a head, a body and a trailer section on the wire. +pub(crate) trait Outbound: Send { + type Error: std::error::Error + Send + Sync + 'static; + /// Where this exchange's body is written. + type Body: OutboundBody + Send; + /// Where its answer comes back. + type Answer: Answer + Send; + + /// Send the head, and take the two halves the rest of the exchange runs on. + /// + /// `ends_it` says the head **is** the whole message. It is a parameter rather than a + /// later `finish()` because not every protocol can say it afterwards: on HTTP/2 the + /// end of a message rides on its last frame, so a head sent open and then closed with + /// an empty DATA frame is a message *with a body* — which is exactly what a + /// status-only gRPC answer is not, and what gRPC clients refuse. HTTP/1.1 frames it + /// from the headers and has nothing to do with the flag but close the body early. + fn open( + &mut self, + request: http::Request<()>, + ends_it: bool, + ) -> impl Future> + Send; +} + +/// The body of a message on its way to the service. +/// +/// Imperative rather than a stream handed over whole, because that is the shape both ends +/// of this already have: the chain judges one piece at a time and forwards what it +/// accepts, so a piece is written when it is allowed and not before. +pub(crate) trait OutboundBody: Send { + type Error; + + fn data(&mut self, chunk: Bytes) -> impl Future> + Send; + fn trailers( + &mut self, + trailers: http::HeaderMap, + ) -> impl Future> + Send; + /// No more of it is coming. Separate from dropping, because on some protocols the + /// end of a message is a frame and not the absence of one. + fn finish(&mut self) -> impl Future> + Send; +} + +/// The service's answer: its head first, then the body every [`Incoming`] gives. +pub(crate) trait Answer: Incoming + Send { + fn response( + &mut self, + ) -> impl Future, ::Error>> + Send; +} + +/// What has to be known before a message's head can be rendered, and no more than that. +/// +/// Three answers come back together because they are one decision: the framing, the first +/// body chunk if reading it is what settled the question, and — where the body was over +/// before it began — the trailer section, which is already in hand by then and which +/// decides the framing too. A message that ends with trailers and no body *is* a chunked +/// message in HTTP/1.1, and that is the only place a trailer section can be rendered, so +/// it has to be known here rather than discovered after the head has gone out. +/// +/// `Some(trailers)` in the third slot means the body stream is finished and its trailer +/// section has already been taken; the caller must not read either again. +#[allow(clippy::type_complexity)] +pub(crate) async fn head_framing( + body: &mut B, + headers: &http::HeaderMap, +) -> Result<(Framing, Option, Option>), B::Error> { + // A declared length frames the body by itself, so there is nothing to wait for and + // nothing a later frame could change. This is also what keeps an upload that the + // service answers early — a `413`, a redirect, a refusal — moving in both directions. + if headers.contains_key(http::header::CONTENT_LENGTH) { + return Ok((Framing::Sized, None, None)); + } + // Both crates keep a half-read frame's state in the stream rather than in the future, + // so letting the deadline drop it loses nothing. + match tokio::time::timeout(HEAD_HOLD, body.data()).await { + Ok(first) => match first? { + Some(chunk) => Ok((Framing::Chunked, Some(chunk), None)), + None => { + // The body is over. Whatever ended it — the end of the stream or a + // trailer section — has already been read into the stream, so this + // resolves without waiting for anything. + let trailers = body.trailers().await?; + let framing = if trailers.is_some() { + Framing::Chunked + } else { + Framing::Empty + }; + Ok((framing, None, Some(trailers))) + } + }, + Err(_) => Ok((Framing::Chunked, None, None)), + } +} + +/// The trailer section this engine is willing to forward. +/// +/// A trailer section the chain was never shown is a piece of the message that travelled +/// unfiltered, which is the one thing nothing else here does. Only one framing leaves +/// nowhere to render it: a message that declared its length, whose HTTP/1.1 view ends +/// where the header said it would — anything appended after that is not part of the +/// message any parser reading the rendering would see. So it is dropped, which is what an +/// intermediary is allowed to do with a trailer section it cannot carry, and said out loud +/// rather than done quietly. +pub(crate) fn carried( + rendered: &Rendered<'_>, + framing: Framing, + trailers: Option, +) -> Option { + if framing == Framing::Sized && trailers.is_some() { + crate::diag_throttled!("[warn] [http] trailer sections dropped from a message of declared length", 5, 30, + "[warn] [{}] {}: a trailer section arrived on a message that declared its \ + length. The HTTP/1.1 the filters are shown has nowhere to put one, so it is \ + dropped rather than forwarded unread.", + rendered.layer, rendered.client, + ); + return None; + } + trailers +} + +/// Ask the chain about one piece of the rendered message. +pub(crate) async fn judge( + rendered: &Rendered<'_>, + direction: Direction, + view: &[u8], + sessions: &mut ChainSessions, + tap: Option<&Arc>, +) -> bool { + // Re-read every time, so a ruleset pushed mid-request takes effect without anybody + // losing their connection — the same rule the byte pumps follow. + match rendered + .chain + .current() + .run(direction, view, sessions) + .await + { + Verdict::Accept => { + // Written on the way past, and only once the chain has agreed: what a capture + // shows has to be what was let through, or it is a record of a decision that + // was not taken. A refused piece leaves the conversation ending where the + // traffic did, which is the truth about it. + if let Some(tap) = tap { + tap.wrote(direction == Direction::ClientToServer, view); + } + true + } + Verdict::Reject(_) => { + rendered + .stats + .closed_by_filter + .fetch_add(1, Ordering::Relaxed); + false + } + } +} + +/// 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 most a client's head may take — the header section of one request, decoded. +/// +/// Neither crate bounds it usefully by default: h2 allows 16 MiB, and h3 sets no limit at +/// all, so an HTTP/3 request could keep one header block growing for as long as flow +/// control kept crediting it, a hundred streams to a connection. A mebibyte is the budget +/// the filter library holds a stream to by default (`FGEX_STREAM_MAX_SIZE`), well past +/// what any HTTP/1.1 server accepts in a head, and far enough from the usual range that +/// a large cookie or a heavy set of gRPC metadata is never what it refuses. +pub(crate) const MAX_HEAD_BYTES: u32 = 1024 * 1024; + +/// Headers about one connection rather than about the message it carries. +/// +/// HTTP/2 and HTTP/3 have no such thing (RFC 9113 §8.2.2, RFC 9114 §4.2): the framing and +/// the connection's lifetime are the protocol's own business, so a message carrying one +/// is malformed. `te` is the exception, and only as `trailers`. +const CONNECTION_SPECIFIC: [&str; 6] = + ["connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "te"]; + +/// Why a request that arrived over HTTP/2 or HTTP/3 carries a header only a connection +/// can, or `None`. +/// +/// The h2 crate refuses these itself; h3 does not, so an HTTP/3 client could hand a +/// service reached over HTTP/1.1 a `connection` naming headers for it to drop, or a `te` +/// the upstream then extends into a transfer coding the service is told to undo — none of +/// which a filter shown the rendering would recognise as the request the service got. +pub(crate) fn connection_specific(headers: &http::HeaderMap) -> Option { + for name in CONNECTION_SPECIFIC { + for value in headers.get_all(name) { + if name == "te" && value.as_bytes().eq_ignore_ascii_case(b"trailers") { + continue; + } + return Some(format!( + "the request carries `{name}`, which belongs to a connection rather than to \ + a message" + )); + } + } + None +} + +/// Take out of an HTTP/1.1 answer what belongs to that connection, before it goes on in +/// a version that forbids it. +/// +/// Every HTTP/1.1 service says something about its connection — a body of unknown length +/// comes `transfer-encoding: chunked`, a connection it will keep open says `keep-alive` — +/// and those were passed on as they came: HTTP/2 refused to send the response at all, and +/// an HTTP/3 client is required to reject one. What `connection` itself names goes too, +/// since that is what the header is for (RFC 9110 §7.6.1). +pub(crate) fn drop_connection_headers(headers: &mut http::HeaderMap) { + let named: Vec = headers + .get_all(http::header::CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .filter_map(|name| http::HeaderName::from_bytes(name.trim().as_bytes()).ok()) + .collect(); + for name in named { + headers.remove(name); + } + for name in CONNECTION_SPECIFIC { + headers.remove(name); + } +} + +/// The request, as the HTTP/1.1 it would have been. +/// +/// Called only for a request that has passed [`conflicting_authority`], so a `host` header +/// 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() + .path_and_query() + .map(|p| p.as_str().to_string()) + .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, 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()); + } + } + render_headers(&mut view, request.headers(), framing); + view +} + +/// And the response. +pub(crate) fn render_response(response: &http::Response<()>, framing: &Framing) -> Vec { + let status = response.status(); + let mut view = format!( + "HTTP/1.1 {} {}\r\n", + status.as_u16(), + status.canonical_reason().unwrap_or("") + ) + .into_bytes(); + render_headers(&mut view, response.headers(), framing); + view +} + +fn render_headers(view: &mut Vec, headers: &http::HeaderMap, framing: &Framing) { + for (name, value) in headers { + view.extend_from_slice(name.as_str().as_bytes()); + view.extend_from_slice(b": "); + view.extend_from_slice(value.as_bytes()); + view.extend_from_slice(b"\r\n"); + } + if matches!(framing, Framing::Chunked) { + view.extend_from_slice(b"transfer-encoding: chunked\r\n"); + } + view.extend_from_slice(b"\r\n"); +} + +/// One body frame, framed the way the head said it would be. +pub(crate) fn push_body(view: &mut Vec, chunk: &[u8], framing: &Framing) { + match framing { + Framing::Chunked => { + view.extend_from_slice(format!("{:x}\r\n", chunk.len()).as_bytes()); + view.extend_from_slice(chunk); + view.extend_from_slice(b"\r\n"); + } + _ => view.extend_from_slice(chunk), + } +} + +/// The end of the body, where the framing has one to show. +pub(crate) fn close_body(view: &mut Vec, framing: &Framing, trailers: Option<&http::HeaderMap>) { + if !matches!(framing, Framing::Chunked) { + return; + } + view.extend_from_slice(b"0\r\n"); + if let Some(trailers) = trailers { + for (name, value) in trailers { + view.extend_from_slice(name.as_str().as_bytes()); + view.extend_from_slice(b": "); + view.extend_from_slice(value.as_bytes()); + view.extend_from_slice(b"\r\n"); + } + } + 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/hyperscan.rs b/backend/proxysrc/src/hyperscan.rs new file mode 100644 index 0000000..5b4b478 --- /dev/null +++ b/backend/proxysrc/src/hyperscan.rs @@ -0,0 +1,578 @@ +//! Bindings to the hyperscan/vectorscan C API. +//! +//! Firegex already matches with hyperscan on the NFQUEUE side (`binsrc/regex/`), +//! and the proxy transport has to match with the *same* engine — not a similar one. +//! Two engines would mean a pattern that blocks on one transport and not the other, +//! and an in-app regex debugger that tests neither. So this is a binding rather than +//! a second implementation, and the flags below deliberately mirror +//! `regex_rules.cpp`: `SINGLEMATCH | ALLOWEMPTY`, plus `CASELESS` when asked. +//! +//! Stream mode is the other reason. A proxy sees a byte stream in arbitrary chunks, +//! and a pattern may straddle two of them. Rescanning an overlap window bounds the +//! cost but also bounds what can match. `hs_scan_stream` carries the automaton state +//! across chunks instead, so a match is found wherever it falls, at no extra scanning. + +use std::ffi::{c_char, c_int, c_uint, c_void, CStr, CString}; +use std::ptr; +use std::sync::Arc; + +#[allow(non_camel_case_types)] +type hs_database_t = c_void; +#[allow(non_camel_case_types)] +type hs_scratch_t = c_void; +#[allow(non_camel_case_types)] +type hs_stream_t = c_void; + +#[repr(C)] +struct hs_compile_error_t { + message: *mut c_char, + expression: c_int, +} + +type MatchEventHandler = extern "C" fn( + id: c_uint, + from: u64, + to: u64, + flags: c_uint, + context: *mut c_void, +) -> c_int; + +const HS_SUCCESS: c_int = 0; +const HS_SCAN_TERMINATED: c_int = -3; + +const HS_FLAG_CASELESS: c_uint = 1; +#[allow(dead_code)] +const HS_FLAG_SINGLEMATCH: c_uint = 8; +const HS_FLAG_ALLOWEMPTY: c_uint = 16; +const HS_FLAG_SOM_LEFTMOST: c_uint = 256; + +const HS_MODE_BLOCK: c_uint = 1; +const HS_MODE_STREAM: c_uint = 2; + +#[link(name = "hs")] +extern "C" { + fn hs_compile_multi( + expressions: *const *const c_char, + flags: *const c_uint, + ids: *const c_uint, + elements: c_uint, + mode: c_uint, + platform: *const c_void, + db: *mut *mut hs_database_t, + error: *mut *mut hs_compile_error_t, + ) -> c_int; + fn hs_free_database(db: *mut hs_database_t) -> c_int; + fn hs_free_compile_error(error: *mut hs_compile_error_t) -> c_int; + fn hs_alloc_scratch(db: *const hs_database_t, scratch: *mut *mut hs_scratch_t) -> c_int; + fn hs_free_scratch(scratch: *mut hs_scratch_t) -> c_int; + fn hs_open_stream( + db: *const hs_database_t, + flags: c_uint, + stream: *mut *mut hs_stream_t, + ) -> c_int; + fn hs_scan_stream( + id: *mut hs_stream_t, + data: *const c_char, + length: c_uint, + flags: c_uint, + scratch: *mut hs_scratch_t, + on_event: Option, + ctxt: *mut c_void, + ) -> c_int; + fn hs_close_stream( + id: *mut hs_stream_t, + scratch: *mut hs_scratch_t, + on_event: Option, + ctxt: *mut c_void, + ) -> c_int; + fn hs_reset_stream( + id: *mut hs_stream_t, + flags: c_uint, + scratch: *mut hs_scratch_t, + on_event: Option, + ctxt: *mut c_void, + ) -> c_int; + fn hs_scan( + db: *const hs_database_t, + data: *const c_char, + length: c_uint, + flags: c_uint, + scratch: *mut hs_scratch_t, + on_event: Option, + ctxt: *mut c_void, + ) -> c_int; +} + +/// How a compiled database will be used. The mode is baked into the database, so a +/// database compiled for one cannot be scanned with the other. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Mode { + /// Live traffic: state carries across chunks, first match per pattern wins. + Stream, + /// One self-contained buffer, every match reported with its start offset. + /// This is what the in-app debugger scans with. + Block, +} + +/// One pattern as the operator wrote it. +pub struct Pattern { + pub expr: String, + pub case_sensitive: bool, +} + +/// A compiled set of patterns. +/// +/// Immutable once built, which is what lets every connection share one `Arc` of it +/// instead of recompiling per connection — and what makes the `Sync` below sound. +pub struct Database { + db: *mut hs_database_t, + mode: Mode, + count: usize, +} + +// hyperscan states that a compiled database is read-only at scan time and may be +// shared by any number of concurrent scans, each with its own scratch. The scratch +// is the mutable part, and it is owned per-scanner below. +unsafe impl Send for Database {} +unsafe impl Sync for Database {} + +impl Drop for Database { + fn drop(&mut self) { + if !self.db.is_null() { + unsafe { hs_free_database(self.db) }; + } + } +} + +fn take_compile_error(err: *mut hs_compile_error_t) -> String { + if err.is_null() { + return "hyperscan failed to compile the pattern".to_string(); + } + let msg = unsafe { + if (*err).message.is_null() { + "hyperscan failed to compile the pattern".to_string() + } else { + CStr::from_ptr((*err).message).to_string_lossy().into_owned() + } + }; + unsafe { hs_free_compile_error(err) }; + msg +} + +impl Database { + /// Compile the patterns into one database, ids being their index in `patterns`. + /// + /// One database for many patterns is the whole point of hyperscan: the automata + /// are merged, so scanning for fifty patterns costs about what scanning for one + /// costs. Adding a pattern must therefore rebuild, never chain. + pub fn compile(patterns: &[Pattern], mode: Mode) -> Result { + if patterns.is_empty() { + return Err("no patterns to compile".to_string()); + } + let owned: Vec = patterns + .iter() + .map(|p| { + CString::new(p.expr.as_bytes()) + .map_err(|_| "a pattern contains a null byte".to_string()) + }) + .collect::>()?; + let ptrs: Vec<*const c_char> = owned.iter().map(|s| s.as_ptr()).collect(); + + let base = match mode { + Mode::Stream => HS_FLAG_ALLOWEMPTY, + // The debugger wants every match and where it started, so neither + // SINGLEMATCH (one report per pattern) nor the default no-start-offset. + Mode::Block => HS_FLAG_SOM_LEFTMOST | HS_FLAG_ALLOWEMPTY, + }; + let flags: Vec = patterns + .iter() + .map(|p| if p.case_sensitive { base } else { base | HS_FLAG_CASELESS }) + .collect(); + let ids: Vec = (0..patterns.len() as c_uint).collect(); + + let hs_mode = match mode { + Mode::Stream => HS_MODE_STREAM, + // No SOM horizon flag: those are a streaming-mode bound on how far + // back a start offset may be remembered. Block mode scans one buffer and + // always reports the true start. + Mode::Block => HS_MODE_BLOCK, + }; + + let mut db: *mut hs_database_t = ptr::null_mut(); + let mut err: *mut hs_compile_error_t = ptr::null_mut(); + let rc = unsafe { + hs_compile_multi( + ptrs.as_ptr(), + flags.as_ptr(), + ids.as_ptr(), + patterns.len() as c_uint, + hs_mode, + ptr::null(), + &mut db, + &mut err, + ) + }; + if rc != HS_SUCCESS { + return Err(take_compile_error(err)); + } + Ok(Database { db, mode, count: patterns.len() }) + } + + /// Compile one pattern on its own, purely to find out whether it is valid. + /// + /// Worth the extra compile: `hs_compile_multi` reports the first bad pattern and + /// stops, so a ruleset with one typo would otherwise reject every pattern in it + /// with a message about a different one. + pub fn validate(expr: &str, case_sensitive: bool, mode: Mode) -> Result<(), String> { + Database::compile( + &[Pattern { expr: expr.to_string(), case_sensitive }], + mode, + ) + .map(|_| ()) + } + + pub fn len(&self) -> usize { + self.count + } + + pub fn mode(&self) -> Mode { + self.mode + } +} + +struct Scratch { + scratch: *mut hs_scratch_t, +} + +impl Drop for Scratch { + fn drop(&mut self) { + if !self.scratch.is_null() { + unsafe { hs_free_scratch(self.scratch) }; + } + } +} + +impl Scratch { + fn alloc(db: &Database) -> Result { + let mut scratch: *mut hs_scratch_t = ptr::null_mut(); + let rc = unsafe { hs_alloc_scratch(db.db, &mut scratch) }; + if rc != HS_SUCCESS { + return Err(format!("hyperscan could not allocate scratch space ({rc})")); + } + Ok(Scratch { scratch }) + } +} + +/// Context handed to the C callback. `id` of the first match, if any. +struct FirstMatch { + hit: Option, +} + +extern "C" fn on_first_match( + id: c_uint, + _from: u64, + _to: u64, + _flags: c_uint, + context: *mut c_void, +) -> c_int { + // SAFETY: `context` is the `&mut FirstMatch` passed to hs_scan_stream, which + // outlives the scan and is not aliased — hyperscan calls this synchronously. + let res = unsafe { &mut *(context as *mut FirstMatch) }; + res.hit = Some(id as u32); + -1 // stop scanning: one match is enough to decide a verdict +} + +/// A scanner bound to one direction of one connection. +/// +/// Owns its scratch rather than borrowing a shared one: scratch is the mutable +/// working memory of a scan and must not be touched by two scans at once. Tying its +/// lifetime to the stream's makes that unrepresentable instead of a rule to remember. +pub struct StreamScanner { + db: Arc, + stream: *mut hs_stream_t, + scratch: Scratch, +} + +// Both pointers are owned exclusively by this struct and never shared; moving the +// whole scanner to another thread moves both together. +unsafe impl Send for StreamScanner {} + +impl Drop for StreamScanner { + fn drop(&mut self) { + if !self.stream.is_null() { + unsafe { hs_close_stream(self.stream, self.scratch.scratch, None, ptr::null_mut()) }; + } + } +} + +impl StreamScanner { + pub fn open(db: Arc) -> Result { + if db.mode != Mode::Stream { + return Err("this database was not compiled for stream scanning".to_string()); + } + let scratch = Scratch::alloc(&db)?; + let mut stream: *mut hs_stream_t = ptr::null_mut(); + let rc = unsafe { hs_open_stream(db.db, 0, &mut stream) }; + if rc != HS_SUCCESS { + return Err(format!("hyperscan could not open a stream ({rc})")); + } + Ok(StreamScanner { db, stream, scratch }) + } + + /// Feed the next chunk. Returns the id of the pattern that matched, if one did. + /// + /// The stream keeps its own state, so a caller must never re-feed bytes it has + /// already fed — that would match patterns that never actually appeared. + pub fn scan(&mut self, data: &[u8]) -> Result, String> { + if data.is_empty() { + return Ok(None); + } + let mut res = FirstMatch { hit: None }; + // The C API takes a 32-bit length. Real chunks are far below that, but a + // caller is not obliged to know it, so feed in pieces rather than truncate. + for piece in data.chunks(u32::MAX as usize) { + let rc = unsafe { + hs_scan_stream( + self.stream, + piece.as_ptr() as *const c_char, + piece.len() as c_uint, + 0, + self.scratch.scratch, + Some(on_first_match), + &mut res as *mut FirstMatch as *mut c_void, + ) + }; + if rc == HS_SCAN_TERMINATED { + break; + } + if rc != HS_SUCCESS { + return Err(format!("hyperscan stream scan failed ({rc})")); + } + } + Ok(res.hit) + } + + /// Reset the stream to its initial state, clearing any previous match or terminated status. + pub fn reset(&mut self) -> Result<(), String> { + if self.stream.is_null() { + return Ok(()); + } + let rc = unsafe { + hs_reset_stream( + self.stream, + 0, + self.scratch.scratch, + None, + ptr::null_mut(), + ) + }; + if rc != HS_SUCCESS { + // If reset failed, close and reopen the stream cleanly. + unsafe { hs_close_stream(self.stream, self.scratch.scratch, None, ptr::null_mut()) }; + self.stream = ptr::null_mut(); + let reopen_rc = unsafe { hs_open_stream(self.db.db, 0, &mut self.stream) }; + if reopen_rc != HS_SUCCESS { + return Err(format!("hyperscan reset stream failed ({rc}/{reopen_rc})")); + } + } + Ok(()) + } + + pub fn pattern_count(&self) -> usize { + self.db.count + } +} + +/// One match found by a block-mode scan. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Match { + pub id: u32, + pub from: u64, + pub to: u64, +} + +struct AllMatches { + hits: Vec, + limit: usize, +} + +extern "C" fn on_every_match( + id: c_uint, + from: u64, + to: u64, + _flags: c_uint, + context: *mut c_void, +) -> c_int { + // SAFETY: as above — synchronous, single-threaded, outlives the scan. + let res = unsafe { &mut *(context as *mut AllMatches) }; + res.hits.push(Match { id: id as u32, from, to }); + // A pathological pattern over a large sample can match at nearly every offset; + // the debugger wants a readable answer, not every one of them. + if res.hits.len() >= res.limit { + -1 + } else { + 0 + } +} + +/// Scan one self-contained buffer and report every match with its offsets. +/// +/// This is what the debugger runs, so what it shows is what the datapath would see. +pub fn scan_block(db: &Database, data: &[u8], limit: usize) -> Result, String> { + if db.mode != Mode::Block { + return Err("this database was not compiled for block scanning".to_string()); + } + let scratch = Scratch::alloc(db)?; + scan_block_with(db, scratch.scratch, data, limit) +} + +fn scan_block_with( + db: &Database, + scratch: *mut hs_scratch_t, + data: &[u8], + limit: usize, +) -> Result, String> { + let mut res = AllMatches { hits: Vec::new(), limit }; + let rc = unsafe { + hs_scan( + db.db, + data.as_ptr() as *const c_char, + data.len() as c_uint, + 0, + scratch, + Some(on_every_match), + &mut res as *mut AllMatches as *mut c_void, + ) + }; + if rc != HS_SUCCESS && rc != HS_SCAN_TERMINATED { + return Err(format!("hyperscan block scan failed ({rc})")); + } + Ok(res.hits) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stream_db(exprs: &[(&str, bool)]) -> Arc { + let pats: Vec = exprs + .iter() + .map(|(e, cs)| Pattern { expr: e.to_string(), case_sensitive: *cs }) + .collect(); + Arc::new(Database::compile(&pats, Mode::Stream).expect("compile")) + } + + #[test] + fn matches_a_pattern_in_one_chunk() { + let db = stream_db(&[("needle", true)]); + let mut sc = StreamScanner::open(db).unwrap(); + assert_eq!(sc.scan(b"a haystack with a needle in it").unwrap(), Some(0)); + } + + #[test] + fn reports_which_pattern_matched() { + let db = stream_db(&[("alpha", true), ("bravo", true), ("charlie", true)]); + let mut sc = StreamScanner::open(db).unwrap(); + assert_eq!(sc.scan(b"...bravo...").unwrap(), Some(1)); + } + + /// The reason stream mode exists: a match split across two writes. + #[test] + fn matches_across_a_chunk_boundary() { + let db = stream_db(&[("secret-token", true)]); + let mut sc = StreamScanner::open(db).unwrap(); + assert_eq!(sc.scan(b"GET /?k=secr").unwrap(), None); + assert_eq!(sc.scan(b"et-token HTTP/1.1").unwrap(), Some(0)); + } + + /// ...and it holds however small the pieces are, which no overlap window does. + #[test] + fn matches_across_many_one_byte_chunks() { + let db = stream_db(&[("abcdefghij", true)]); + let mut sc = StreamScanner::open(db).unwrap(); + let mut hit = None; + for b in b"xxxabcdefghijxxx" { + if let Some(id) = sc.scan(&[*b]).unwrap() { + hit = Some(id); + } + } + assert_eq!(hit, Some(0)); + } + + #[test] + fn honours_case_sensitivity() { + let sensitive = stream_db(&[("NeEdLe", true)]); + let mut sc = StreamScanner::open(sensitive).unwrap(); + assert_eq!(sc.scan(b"needle").unwrap(), None); + + let insensitive = stream_db(&[("NeEdLe", false)]); + let mut sc = StreamScanner::open(insensitive).unwrap(); + assert_eq!(sc.scan(b"needle").unwrap(), Some(0)); + } + + #[test] + fn scans_arbitrary_bytes_not_just_utf8() { + let db = stream_db(&[(r"\x00\xff\xfe", true)]); + let mut sc = StreamScanner::open(db).unwrap(); + assert_eq!(sc.scan(&[0x41, 0x00, 0xff, 0xfe, 0x42]).unwrap(), Some(0)); + } + + #[test] + fn two_scanners_of_one_database_are_independent() { + let db = stream_db(&[("split-me", true)]); + let mut a = StreamScanner::open(db.clone()).unwrap(); + let mut b = StreamScanner::open(db).unwrap(); + assert_eq!(a.scan(b"spl").unwrap(), None); + // b has seen nothing, so a's partial progress must not carry into it + assert_eq!(b.scan(b"it-me").unwrap(), None); + assert_eq!(a.scan(b"it-me").unwrap(), Some(0)); + } + + #[test] + fn a_bad_pattern_names_itself() { + let err = Database::validate("(unclosed", true, Mode::Stream).unwrap_err(); + assert!(!err.is_empty(), "the compile error should carry a message"); + assert!(Database::validate("(closed)", true, Mode::Stream).is_ok()); + } + + #[test] + fn block_mode_reports_every_match_with_offsets() { + let db = Database::compile( + &[Pattern { expr: "a.c".to_string(), case_sensitive: true }], + Mode::Block, + ) + .unwrap(); + let hits = scan_block(&db, b"abc___axc", 100).unwrap(); + assert_eq!(hits.len(), 2); + assert_eq!((hits[0].from, hits[0].to), (0, 3)); + assert_eq!((hits[1].from, hits[1].to), (6, 9)); + } + + #[test] + fn block_mode_stops_at_the_limit() { + let db = Database::compile( + &[Pattern { expr: "x".to_string(), case_sensitive: true }], + Mode::Block, + ) + .unwrap(); + let hits = scan_block(&db, &[b'x'; 1000], 10).unwrap(); + assert_eq!(hits.len(), 10); + } + + #[test] + fn a_database_refuses_the_wrong_scan_mode() { + let block = Database::compile( + &[Pattern { expr: "x".to_string(), case_sensitive: true }], + Mode::Block, + ) + .unwrap(); + assert!(StreamScanner::open(Arc::new(block)).is_err()); + + let stream = Database::compile( + &[Pattern { expr: "x".to_string(), case_sensitive: true }], + Mode::Stream, + ) + .unwrap(); + assert!(scan_block(&stream, b"x", 10).is_err()); + } +} diff --git a/backend/proxysrc/src/lib.rs b/backend/proxysrc/src/lib.rs new file mode 100644 index 0000000..0a6bdb5 --- /dev/null +++ b/backend/proxysrc/src/lib.rs @@ -0,0 +1,32 @@ +//! Proxy datapath engine for firegex. +//! +//! An alternative to the NFQUEUE engine: instead of lifting packets to userspace and +//! handing a verdict back to the kernel, terminate the connection and own both halves. +//! That buys exact rewriting, kernel-side reassembly and real backpressure — and costs +//! the kernel's fail-open backstop, which [`filter`] rebuilds by hand. + +pub mod control; +pub mod debug; +pub mod filter; +pub mod h1up; +pub mod h2; +pub mod h3; +pub mod http1; +pub mod hyperscan; +pub mod proxy; +pub mod quic; +pub mod quic_hello; +pub mod pyworker; +pub mod rules; +pub mod spec; +pub mod capture; +pub mod tls; +pub mod transparent; +pub mod relays; +pub mod report; +pub mod udp; + +pub use filter::{ + ChainHandle, ChainSessions, Direction, Filter, FilterChain, FilterCtx, FilterSession, Verdict, +}; +pub use proxy::{Proxy, ProxyConfig, Upstream}; diff --git a/backend/proxysrc/src/main.rs b/backend/proxysrc/src/main.rs new file mode 100644 index 0000000..4a3453e --- /dev/null +++ b/backend/proxysrc/src/main.rs @@ -0,0 +1,497 @@ +use std::net::SocketAddr; +use std::process::exit; +use std::sync::Arc; +use std::time::Duration; + +use fgex_proxy::control::serve_stdin; +use fgex_proxy::filter::{ChainHandle, FilterChain}; +use fgex_proxy::proxy::{Proxy, ProxyConfig, TlsSetup, Upstream}; +use fgex_proxy::quic::{QuicConfig, QuicManager, QuicSetup}; +use fgex_proxy::relays::Relays; +use fgex_proxy::spec::parse_filters; +use fgex_proxy::transparent::probe_capability; + +/// How often the engine reports its connection counters. One short line, so the cost is +/// nothing; the first is sent immediately so a freshly started service has a baseline +/// rather than an unknown for the first interval. +const STATS_INTERVAL: Duration = Duration::from_secs(2); + +fn env_or_exit(key: &str) -> String { + match std::env::var(key) { + Ok(v) => v, + Err(_) => { + eprintln!("[fatal] [main] {key} is required"); + exit(2); + } + } +} + +fn env_flag(key: &str) -> bool { + matches!( + std::env::var(key).unwrap_or_default().trim(), + "1" | "true" | "yes" + ) +} + +fn parse_addr(key: &str, raw: &str) -> SocketAddr { + match raw.parse() { + Ok(a) => a, + Err(e) => { + eprintln!("[fatal] [main] {key}='{raw}' is not a valid address: {e}"); + exit(2); + } + } +} + +/// `FGEX_PROXY_TLS_CERT` / `_KEY` are paths, not the material itself: a certificate +/// on a command line or in an env dump is a certificate in a log somewhere. +fn env_usize(key: &str) -> Option { + std::env::var(key).ok()?.trim().parse().ok() +} + +/// The certificate and key this service terminates with, read once. +/// +/// Read rather than passed: a certificate on a command line or in an environment dump is +/// a certificate in a log somewhere. Both edges that need it — TLS over TCP and QUIC — +/// ask for the same pair, so it is read here and handed to whichever wants it. +fn read_material() -> Result, String> { + let cert_path = std::env::var("FGEX_PROXY_TLS_CERT").ok(); + let key_path = std::env::var("FGEX_PROXY_TLS_KEY").ok(); + match (cert_path, key_path) { + (Some(cert), Some(key)) => { + let certificate = + std::fs::read_to_string(&cert).map_err(|e| format!("cannot read {cert}: {e}"))?; + let key = + std::fs::read_to_string(&key).map_err(|e| format!("cannot read the key: {e}"))?; + Ok(Some((certificate, key))) + } + (None, None) => Ok(None), + _ => Err("FGEX_PROXY_TLS_CERT and FGEX_PROXY_TLS_KEY go together".to_string()), + } +} + +fn build_tls(material: Option<&(String, String)>, optional: bool) -> Result { + let server = match material { + Some((cert, key)) => Some(fgex_proxy::tls::server_config(cert, key)?), + None => None, + }; + // Built whenever this engine terminates anything, and *used* per address: whether a + // given connection is re-encrypted on the way out is the address's answer + // (`Onward`), not the process's. A plain TCP service terminates nothing, so it has + // no client configuration and never did — which is why none of this reaches it. + let upstream = match server { + Some(_) => Some(fgex_proxy::tls::client_config()?), + None => None, + }; + Ok(TlsSetup { + server, + upstream, + optional, + }) +} + +/// Answer one regex-debug request and exit, instead of starting a datapath. +/// +/// Its own mode rather than a long-running service: the backend asks rarely, an +/// operator's half-written pattern must not be able to affect anything that is +/// carrying traffic, and a process that exits cannot leak scratch space or state +/// between requests. +fn debug_regex() -> ! { + use std::io::Read; + let mut request = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut request) { + eprintln!("[fatal] [debug] cannot read the request: {e}"); + exit(2); + } + let response = fgex_proxy::debug::run(&request); + match serde_json::to_string(&response) { + Ok(json) => println!("{json}"), + Err(e) => { + eprintln!("[fatal] [debug] cannot encode the response: {e}"); + exit(2); + } + } + exit(0); +} + +/// How many worker threads the runtime gets. +/// +/// `NTHREADS` is what `run.py --threads` sets, and it used to reach only the NFQUEUE +/// binaries — this engine took every core whatever the operator asked for, so the same +/// flag meant one thing on one layer and nothing on the other. It means the same thing +/// on both now. Absent or unparseable is "as many as there are cores", which is tokio's +/// own default and the right answer when nobody has said otherwise. +fn worker_threads() -> usize { + std::env::var("NTHREADS") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .filter(|n| *n > 0) + .unwrap_or_else(|| std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1)) +} + +fn main() { + // Built by hand rather than through `#[tokio::main]`, which offers no way to say how + // many workers it should have. + let runtime = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(worker_threads()) + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + eprintln!("[fatal] [main] cannot start the runtime: {e}"); + exit(1); + } + }; + runtime.block_on(run()); +} + +async fn run() { + // Checked before anything else reads the environment: this mode starts no + // listener, opens no sockets and touches no rules. + if std::env::args().any(|a| a == "--debug-regex") { + debug_regex(); + } + + let listen_raw = env_or_exit("FGEX_PROXY_LISTEN"); + let listen = parse_addr("FGEX_PROXY_LISTEN", &listen_raw); + + // "original" puts one listener in front of every intercepted service; a literal + // address keeps the simpler one-listener-one-service shape. + let upstream_raw = env_or_exit("FGEX_PROXY_UPSTREAM"); + let upstream = if matches!(upstream_raw.trim(), "original" | "auto") { + Upstream::Original + } else { + Upstream::Fixed(parse_addr("FGEX_PROXY_UPSTREAM", &upstream_raw)) + }; + + // Only the outbound dial needs IP_TRANSPARENT now: the listener is a plain one + // that traffic is redirected into. Binding a foreign address was a tproxy + // requirement, and tproxy is gone. + let spoof_source = env_flag("FGEX_PROXY_SPOOF_SOURCE"); + + // Fail here rather than per connection: a missing CAP_NET_ADMIN would otherwise + // show up only as every service seeing the proxy's address instead of the client's. + if spoof_source { + if let Err(e) = probe_capability(listen.is_ipv6()) { + eprintln!( + "[fatal] [main] transparent mode needs CAP_NET_ADMIN, IP_TRANSPARENT failed: {e}" + ); + exit(1); + } + } + + let deadline = std::env::var("FGEX_PROXY_FILTER_TIMEOUT_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(2000); + let connect_timeout = std::env::var("FGEX_PROXY_CONNECT_TIMEOUT_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(5000); + + let specs = std::env::var("FGEX_PROXY_FILTERS").unwrap_or_default(); + let filters = match parse_filters(&specs) { + Ok(f) => f, + Err(e) => { + eprintln!("[fatal] [main] bad FGEX_PROXY_FILTERS: {e}"); + exit(2); + } + }; + + // Read before either edge is built: TLS over TCP and QUIC terminate with the same + // certificate, and a service speaks one of them or the other. + let material = match read_material() { + Ok(material) => material, + Err(e) => { + eprintln!("[fatal] [main] {e}"); + exit(2); + } + }; + + // QUIC is UDP on the wire, so it takes the place of the datagram relays rather than + // sitting beside them: the same map of one port per protected address, bound by + // something that terminates instead of something that forwards. + let quic = env_flag("FGEX_PROXY_QUIC"); + + // Whether the TCP listener terminates TLS, and whether it insists on it. + // + // `FGEX_PROXY_TLS_OPTIONAL` is what an `http` service sets: one certificate, one + // chain, and each connection carried as whatever the client opened it with — in the + // clear on one port and under TLS on another. It also means TLS on TCP and QUIC on + // UDP live in **one** engine process, which they could not before: the two used to be + // exclusive here, on the reasoning that a service speaks one or the other, and an + // HTTP service speaks both. + let tls_optional = env_flag("FGEX_PROXY_TLS_OPTIONAL"); + // Absent, the old rule: a certificate means terminate, unless this is a QUIC service, + // where the certificate belongs to the QUIC edge and the TCP listener has nothing + // pointed at it. Said explicitly by anything that wants both. + let terminate_tls = env_flag("FGEX_PROXY_TLS") || tls_optional || !quic; + let tls = match build_tls( + if terminate_tls { material.as_ref() } else { None }, + tls_optional, + ) { + Ok(setup) => setup, + Err(e) => { + eprintln!("[fatal] [main] {e}"); + exit(2); + } + }; + + // Stamped on every socket this engine opens towards a service, so the rules that + // intercept that service can tell our own dial from the traffic they are meant to + // catch. Read once: the UDP relays need the same mark as the TCP path. + let cfg_self_mark = std::env::var("FGEX_PROXY_SELF_MARK") + .ok() + .and_then(|v| u32::from_str_radix(v.trim_start_matches("0x"), 16).ok()) + .or(Some(fgex_proxy::transparent::SELF_MARK)); + + // Read once and shared by both paths: TCP connections and UDP flows are the same + // resource being defended, and two numbers meaning "how much of it may be spent" + // would be two things to keep in step. + let max_connections = env_usize("FGEX_PROXY_MAX_CONNECTIONS").unwrap_or(0); + let over_limit_forwards = env_flag("FGEX_PROXY_OVER_LIMIT_FORWARD"); + // In seconds, because that is the unit an operator thinks in for "how long may a + // connection say nothing". Zero and absent both mean off. + let first_byte_timeout = match env_usize("FGEX_PROXY_FIRST_BYTE_TIMEOUT").unwrap_or(0) { + 0 => None, + secs => Some(Duration::from_secs(secs as u64)), + }; + + // Opened once for the process and shared by every path that reconstructs something: + // the interface belongs to the instance, not to a service or a connection. + let capture = fgex_proxy::capture::Capture::open(); + + // Where each published address sends its traffic, for the ones this engine starts + // with. `10.0.0.1:443=10.0.0.1:80` reads as "what arrives at the first is the + // service at the second" — an address published on a port the service does not + // listen on. Absent, and for every address not named, the answer is the address + // itself, which is what transparent means and what every service had before. + let targets = std::sync::Arc::new(fgex_proxy::proxy::Targets::default()); + if let Ok(spec) = std::env::var("FGEX_PROXY_TARGETS") { + for entry in spec.split(',').map(str::trim).filter(|s| !s.is_empty()) { + // `10.0.0.1:443|tls=10.0.0.1:80`, and every part after the address optional: + // the edge says what is spoken there and the target where the service is, + // and an address that says neither is one this engine is simply in front of. + let (address, target) = match entry.split_once('=') { + Some((address, target)) => ( + address, + Some(parse_addr("FGEX_PROXY_TARGETS", target.trim())), + ), + None => (entry, None), + }; + // `
||`, both words optional and both defaulting to + // the answer every address gave before there was a question. + let mut words = address.split('|'); + let address = words.next().unwrap_or(address); + let edge = match words.next() { + Some("tls") => fgex_proxy::proxy::Edge::Tls, + None | Some("clear") | Some("any") => fgex_proxy::proxy::Edge::Whatever, + Some(other) => { + eprintln!("[fatal] [main] FGEX_PROXY_TARGETS: unknown edge '{other}'"); + exit(2); + } + }; + let upstream = match words.next() { + Some("plain") => fgex_proxy::proxy::Onward::Plain, + Some("tls") => fgex_proxy::proxy::Onward::Tls, + None | Some("same") => fgex_proxy::proxy::Onward::Same, + Some(other) => { + eprintln!("[fatal] [main] FGEX_PROXY_TARGETS: unknown upstream '{other}'"); + exit(2); + } + }; + let public = parse_addr("FGEX_PROXY_TARGETS", address.trim()); + // Said out loud, because everything else about an address is visible in the + // rules and this is not: it lives in this process and nowhere else, so an + // operator wondering why a port behaves the way it does has nothing to read. + eprintln!( + "[info] [main] {public} carries {} to {}", + match edge { + fgex_proxy::proxy::Edge::Tls => "TLS only", + _ => "whatever arrives", + }, + match (target, upstream) { + (Some(target), fgex_proxy::proxy::Onward::Plain) => { + format!("{target} in the clear") + } + (Some(target), fgex_proxy::proxy::Onward::Tls) => format!("{target} on TLS"), + (Some(target), _) => format!("{target} as it arrived"), + (None, fgex_proxy::proxy::Onward::Plain) => "the service in the clear".into(), + (None, fgex_proxy::proxy::Onward::Tls) => "the service on TLS".into(), + (None, _) => "the service as it arrived".into(), + } + ); + targets.publish(public, fgex_proxy::proxy::Published { target, edge, upstream }); + } + } + + let cfg = ProxyConfig { + listen, + upstream, + spoof_source, + connect_timeout: Duration::from_millis(connect_timeout), + tls, + // `None` simply means nothing is watching, which is the ordinary case. + capture: capture.clone(), + // Zero means no limit, which is what it was before there was one. The backend + // always sends a value; the default here is for anyone running the engine by + // hand, where a surprise limit would be worse than none. + max_connections, + over_limit_forwards, + first_byte_timeout, + self_mark: cfg_self_mark, + targets: std::sync::Arc::clone(&targets), + }; + let chain = ChainHandle::new(FilterChain::new(filters, Duration::from_millis(deadline))); + let chain_handle = chain.clone(); + let proxy = match Proxy::bind(cfg, chain).await { + Ok(p) => p, + Err(e) => { + eprintln!("[fatal] [main] cannot bind {listen}: {e}"); + exit(1); + } + }; + + // Handshake, in the shape the other engines use (`QUEUE ` from cppregex): + // one machine-readable line on stdout, so the caller can pass port 0 and learn + // which port it actually got. Everything descriptive goes to stderr. + let bound = proxy.local_addr().unwrap(); + // Taken before the UDP relays are built: they share it, so one service reports one + // pair of numbers whichever half of it the traffic arrived on. + let counters = proxy.stats(); + + fgex_proxy::report::reply(format!("PORT {}", bound.port())); + eprintln!( + "[info] [main] listening on {bound} -> {upstream_raw} \ + (spoof_source={spoof_source})" + ); + + // The relays this service's addresses get, bound at startup and added to on demand. + // Which kind is not a per-address choice: a service speaks QUIC or it speaks + // datagrams, and the one it speaks is what every one of its addresses gets. + let relays = if quic { + let (cert, key) = match &material { + Some(pair) => pair, + None => { + eprintln!( + "[fatal] [main] a QUIC service needs a certificate: QUIC carries TLS 1.3 \ + inside it, and terminating it is the only way a filter sees anything" + ); + exit(2); + } + }; + let setup = match QuicSetup::build(cert, key) { + Ok(setup) => Arc::new(setup), + Err(e) => { + eprintln!("[fatal] [main] {e}"); + exit(2); + } + }; + eprintln!( + "[info] [main] QUIC terminated here; each client's own protocols are offered \ + to the service" + ); + Relays::Quic(QuicManager::new( + chain_handle.clone(), + QuicConfig { + setup, + capture: capture.clone(), + self_mark: cfg_self_mark, + spoof_source, + max_connections, + over_limit_forwards, + first_byte_timeout, + connect_timeout: Duration::from_millis(connect_timeout), + // The default for a relay that was added without saying; the backend + // says, per address, on `FGEX_PROXY_UDP` and on `ADD_UDP`. + upstream: fgex_proxy::proxy::Onward::Same, + }, + Arc::clone(&counters), + )) + } else { + Relays::Datagram(fgex_proxy::udp::UdpManager::new( + chain_handle.clone(), + cfg_self_mark, + spoof_source, + max_connections, + over_limit_forwards, + Arc::clone(&counters), + )) + }; + + // UDP, when the backend asks for it: a comma-separated list of protected + // addresses, one relay each. One socket per address rather than one for all of + // them, because `SO_ORIGINAL_DST` — which is how the TCP side learns where a + // connection was headed — is TCP and SCTP only. With the upstream fixed per socket + // there is nothing to recover. + if let Ok(spec) = std::env::var("FGEX_PROXY_UDP") { + for target in spec.split(',').filter(|s| !s.trim().is_empty()) { + // `
` or `
|`: what the service behind this one + // 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, 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) => { + // 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. + fgex_proxy::report::reply(format!("UDP {upstream}|{} {port}", onward.word())); + } + Err(e) => { + eprintln!("[fatal] [main] cannot bind a UDP relay for {upstream}: {e}"); + exit(1); + } + } + } + } + + // Rulesets and control commands (e.g. ADD_UDP) arrive on stdin from here on. + // It runs alongside the datapath rather than gating it: traffic must flow + // before, during and after a rule change or relay addition. + let deadline_dur = Duration::from_millis(deadline); + let control_chain = chain_handle.clone(); + let control_relays = relays.clone(); + tokio::spawn(async move { serve_stdin(control_chain, deadline_dur, control_relays, targets).await }); + + // Both counters live on the proxy, not on the chain: a chain is replaced wholesale + // every time a ruleset is pushed, and counters that reset on a rule edit would make + // the ratio jump for a reason nobody could see. + tokio::spawn(async move { + let mut tick = tokio::time::interval(STATS_INTERVAL); + loop { + // The first tick fires immediately, which is the point: zero out of zero is + // a baseline, and "no answer yet" is not. + tick.tick().await; + // `live` and `over_limit` ride along with the two that were already here: + // the backend parses this line as key/value pairs, so a new number costs no + // protocol. `over_limit` is cumulative on purpose — a service that hit the + // wall once an hour ago still says so, which is the whole point of a trace. + // Dropped rather than waited for when the backend is behind: the next one + // carries the same cumulative numbers two seconds later. + fgex_proxy::report::event(format!( + "STATS seen={} refused={} live={} over_limit={} no_first_byte={}", + counters.accepted.load(std::sync::atomic::Ordering::Relaxed), + counters.closed_by_filter.load(std::sync::atomic::Ordering::Relaxed), + counters.live.load(std::sync::atomic::Ordering::Relaxed), + counters.over_limit.load(std::sync::atomic::Ordering::Relaxed), + counters.no_first_byte.load(std::sync::atomic::Ordering::Relaxed), + )); + } + }); + + if let Err(e) = proxy.serve().await { + eprintln!("[fatal] [main] accept loop died: {e}"); + exit(1); + } +} diff --git a/backend/proxysrc/src/proxy.rs b/backend/proxysrc/src/proxy.rs new file mode 100644 index 0000000..bb46934 --- /dev/null +++ b/backend/proxysrc/src/proxy.rs @@ -0,0 +1,1199 @@ +//! The datapath: accept, dial the real service, relay both halves through the chain. + +use std::io; +use std::net::SocketAddr; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +use crate::filter::{ + next_connection_id, ChainHandle, ChainSessions, ConnectionId, ConnectionMeta, Direction, + FilterChain, Verdict, L4, +}; +use crate::capture::{Capture, Tap}; +use crate::tls; +use crate::transparent::{ + bind_listener, connect_as, connect_plain, original_destination, unmap, +}; + +/// Either half of a connection, whether or not TLS is in the way. +/// +/// Boxed only when TLS is on: a plain connection keeps the concrete types the +/// benchmark was run against, and a TLS one is paying for cryptography anyway, so a +/// virtual call per read is not what it will notice. +type Duplex = Box; + +pub trait AsyncReadWrite: AsyncRead + AsyncWrite {} +impl AsyncReadWrite for T {} + +/// What the engine does about TLS for one service. +#[derive(Clone, Default)] +pub struct TlsSetup { + /// Terminate the client's TLS here, with this certificate. + pub server: Option>, + /// Re-encrypt towards the service, the way nginx's `proxy_ssl on` does. + pub upstream: Option>, + /// Terminate it **only if the client actually starts a handshake**. + /// + /// What an `http` service is: one chain in front of a daemon that answers in the + /// clear on one port and under TLS on another, with one certificate and one set of + /// filters. The alternative was one service per edge with the chain copied between + /// them by hand, which is the failure several addresses under one service already + /// exist to prevent. + /// + /// It is a per-connection question because that is the only place it has an answer, + /// and answering it from what the client sent is the same rule the ALPN mirroring + /// follows: firegex carries what the two ends are doing rather than deciding it for + /// them. The upstream leg mirrors it too — a connection that arrived in the clear is + /// forwarded in the clear, one that arrived under TLS is re-encrypted — because the + /// service is being dialled on the port the client chose, and that port's edge is the + /// one the client just demonstrated. + pub optional: bool, +} + +impl TlsSetup { + pub fn is_off(&self) -> bool { + self.server.is_none() && self.upstream.is_none() + } +} + +/// What a connection turned out to be, from the bytes the client opened it with. +/// +/// **Peeked, never read.** Everything stays in the socket, so a connection this tells +/// apart is handed on exactly as it arrived — there is nothing buffered to replay, and no +/// way for the question to lose a byte. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Opening { + /// A TLS record: `0x16` handshake, major version 3. Nothing else begins that way. + Tls, + /// The HTTP/2 connection preface, which is what "prior knowledge" looks like. + Http2, + /// Anything else, including a service that spoke before its client did. + Other, +} + +const RELAY_BUF: usize = 64 * 1024; + +/// Backstop for a direction that will not wind down — one blocked writing to a peer +/// that has stopped reading, say. The normal path is the stop signal below, which is +/// prompt. +const REJECT_GRACE: Duration = Duration::from_secs(2); + +/// What is spoken at one published address, and where it goes. +/// +/// Firegex is transparent by default and was transparent only, for as long as there was +/// one answer to "where is the service": the address the client dialled. The engine +/// recovers that from conntrack and dials it as the client, so the service goes on +/// seeing connections to the port it listens on from the addresses they came from. An +/// address with no entry here behaves exactly that way, which is why the empty map is +/// the default and why every field below is optional. +/// +/// An entry is the operator saying something about one address in particular: +/// +/// * **where the service is**, when it is not on this port — one daemon answering on +/// `:80` reached over TLS on `:443`, without being moved or reconfigured; +/// * **what is spoken here**, when the service is reached in more than one way at once. +/// Only `http` services have that, and only there is the question asked. +/// +/// Only the TCP path needs any of it. A UDP or QUIC address already has a relay of its +/// own, bound to the service it fronts and terminating what it was built to terminate. +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub enum Edge { + /// Whatever the client turns out to be speaking. The default and the ordinary case: + /// one listener fronts a whole service, a cleartext port and an encrypted one are + /// the same listener, and which a connection is cannot be known before it starts. + /// + /// There is deliberately no "cleartext only" beside this. Refusing a client that + /// brings TLS to a port nobody promised would be a rule with nothing behind it, and + /// the permissive answer is what an `http` service has always given. + #[default] + Whatever, + /// This address is the encrypted one, and was said to be. A client that opens it in + /// the clear is refused rather than carried, because "this port is HTTPS" is a + /// promise about the port rather than a guess about the connection — and a service + /// expecting HTTPS being handed a cleartext request is the kind of surprise an + /// operator would rather have at the door. + Tls, +} + +#[derive(Clone, Copy, Default)] +pub struct Published { + pub target: Option, + pub edge: Edge, + /// What the service behind *this* address speaks. Per address rather than per + /// process 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 firegex does on the way out is a property + /// of the way in. + pub upstream: Onward, +} + +/// What this engine speaks onward, to the service behind one address. +/// +/// 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, 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 + /// address did before there was a choice. + #[default] + Same, + /// The service speaks in the clear, whatever the client used to get here. + Plain, + /// The service speaks TLS, whatever the client used to get here. + 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>); + +impl Targets { + /// Say something about one address. Replaces any previous answer for it. + pub fn publish(&self, public: SocketAddr, what: Published) { + if let Ok(mut map) = self.0.write() { + map.insert(public, what); + } + } + + /// Forget one published address. A stale entry could only ever be consulted by + /// traffic the rules no longer send here, but an engine whose map disagrees with the + /// rules is a thing somebody will one day read and believe. + pub fn withdraw(&self, public: &SocketAddr) { + if let Ok(mut map) = self.0.write() { + map.remove(public); + } + } + + /// What was said about the address this connection was dialled at, if anything. + pub fn resolve(&self, original: SocketAddr) -> Published { + self.0 + .read() + .ok() + .and_then(|map| map.get(&original).copied()) + .unwrap_or_default() + } +} + +/// Where a connection should be forwarded. +#[derive(Clone, Copy, Debug)] +pub enum Upstream { + /// One service behind one listener. + Fixed(SocketAddr), + /// Wherever the client was originally headed — one listener in front of many + /// services, which is the point of a central proxy. + Original, +} + +pub struct ProxyConfig { + pub listen: SocketAddr, + pub upstream: Upstream, + /// Dial the service as the client, so it sees the real source address. + /// + /// Not a setting the operator sees: the proxy is supposed to be invisible, and a + /// service that suddenly sees one address for every client is the opposite of + /// that. It is here only so the fallback below has something to turn off. + pub spoof_source: bool, + /// Cap on the upstream handshake. A spoofed connect whose return path is not + /// diverted does not fail, it hangs — this turns that into a visible fallback. + pub connect_timeout: Duration, + /// TLS on either edge. Rules always see plaintext. + pub tls: TlsSetup, + /// Where the decrypted stream is written out, when TLS is terminated here and the + /// capture interface exists. `None` is the ordinary case, not a failure. + pub capture: Option>, + /// How many connections may be in flight at once. `0` means no limit. + /// + /// There was none, and the cost of that was measured rather than argued: roughly 505 + /// connections that are opened and then say nothing take a service down, because each + /// one holds two descriptors — one from the client, one to the service, since the + /// upstream is dialled on accept — against a 1024 limit. No data has to be sent. + pub max_connections: usize, + /// What happens to a connection that arrives while the limit is reached. + /// + /// Refusing keeps the promise that everything reaching the service was inspected, + /// and costs reachability under attack. Forwarding keeps the service reachable and + /// admits traffic nothing looked at. Neither is right in general, which is why it is + /// the operator's to choose; refusing is the default because a filter that quietly + /// stops filtering is the worse surprise. + pub over_limit_forwards: bool, + /// Where published addresses send their traffic, when it is not where they were + /// dialled. Empty is the transparent case and the default. + pub targets: Arc, + /// How long a connection may carry no bytes at all before it is closed. `None` is + /// off, which is what it was before. + /// + /// Until the **first** byte moves, and never afterwards: this is not an idle + /// timeout. A connection that has said something and then goes quiet is a session, + /// and sessions are allowed to think. One that has said nothing in either direction + /// since it was accepted is the shape of the attack the limit only contains — it + /// holds a descriptor here and one on the service, having asked for nothing. + /// + /// Either direction counts, which is what makes it safe for the protocols where the + /// *server* speaks first. A banner from SMTP or SSH satisfies the deadline exactly as + /// a request would; requiring the client to speak would have hung every one of them. + pub first_byte_timeout: Option, + /// Stamped on the connections the engine opens, so the intercept rules can skip + /// them. `None` only where nothing could loop back. + pub self_mark: Option, +} + +impl ProxyConfig { + pub fn fixed(listen: SocketAddr, upstream: SocketAddr) -> Self { + Self { + listen, + upstream: Upstream::Fixed(upstream), + spoof_source: false, + connect_timeout: Duration::from_secs(5), + tls: TlsSetup::default(), + capture: None, + max_connections: 0, + over_limit_forwards: false, + first_byte_timeout: None, + self_mark: None, + targets: Arc::new(Targets::default()), + } + } +} + +#[derive(Debug, Default)] +pub struct ProxyStats { + pub accepted: AtomicU64, + pub upstream_failures: AtomicU64, + pub closed_by_filter: AtomicU64, + /// Connections that reached the service under the proxy's own address because + /// the transparent dial did not work. Traffic flows, identity is lost. + pub source_spoof_failures: AtomicU64, + /// Connections dropped because their original destination could not be recovered. + pub origin_lookup_failures: AtomicU64, + /// How many connections are being carried right now. The number the limit is about. + pub live: AtomicU64, + /// How many were closed for never saying anything. Cumulative, like the rest. + pub no_first_byte: AtomicU64, + /// How many arrived while the limit was reached — refused or forwarded unfiltered, + /// whichever the operator chose. Cumulative, so a service that hit the wall once an + /// hour ago still says so. + pub over_limit: AtomicU64, + pub warned_spoof: AtomicBool, + /// Whether the limit has already been announced. Shared by both paths, so a service + /// carrying TCP and UDP says it once rather than twice. + pub warned_limit: AtomicBool, +} + +/// Holds a slot in the connection count for as long as the connection lives. +/// +/// A guard rather than a decrement at the end of `handle_connection`: that function has +/// a dozen ways out — a refused chain, a dial that fails, a handshake that times out, a +/// panic — and a counter that leaks on any one of them is a limit that tightens until it +/// refuses everything. +pub(crate) struct Slot(pub(crate) Arc); + +impl Drop for Slot { + fn drop(&mut self) { + self.0.live.fetch_sub(1, Ordering::Relaxed); + } +} + +/// How long to wait after an accept that failed before trying again. +const ACCEPT_BACKOFF: Duration = Duration::from_millis(20); + +pub struct Proxy { + listener: TcpListener, + cfg: Arc, + chain: ChainHandle, + pub stats: Arc, +} + +impl Proxy { + pub async fn bind(cfg: ProxyConfig, chain: ChainHandle) -> io::Result { + let listener = bind_listener(cfg.listen)?; + Ok(Self { + listener, + cfg: Arc::new(cfg), + chain, + stats: Arc::new(ProxyStats::default()), + }) + } + + /// The address actually bound, so a caller passing port 0 can find it. + pub fn local_addr(&self) -> io::Result { + self.listener.local_addr() + } + + pub fn stats(&self) -> Arc { + Arc::clone(&self.stats) + } + + /// Accept forever. + /// + /// Nothing a single connection does may end this loop: each one is handled in + /// its own task, so even an outright panic in there costs that connection and + /// nothing else. The listener outliving its connections is the whole point. + pub async fn serve(self) -> io::Result<()> { + loop { + let (client, peer) = match self.listener.accept().await { + // Unmapped here and nowhere else: past this line the rest of the engine + // only ever sees the address the client really had, whether it arrived + // on a v4 listener or on the v4 half of a dual-stack one. + Ok((client, peer)) => (client, unmap(peer)), + // Per-connection accept errors (fd limits, a client gone between + // SYN and accept) must not be fatal. + Err(e) => { + crate::diag_throttled!("[warn] [proxy] failed accepts", 5, 10, "[warn] [proxy] accept failed: {e}"); + // Out of descriptors, the condition does not clear by trying again + // immediately: the loop spins on `accept` → `EMFILE` → print → + // `accept`, which was measured burning about 40% of a core with four + // clients knocking, and writing a log line per attempt into the pipe + // the backend reads. A pause costs a few milliseconds of latency when + // accepting was going to fail anyway. + tokio::time::sleep(ACCEPT_BACKOFF).await; + continue; + } + }; + self.stats.accepted.fetch_add(1, Ordering::Relaxed); + + // Claimed before the connection is spawned and released when it ends, + // whichever way it ends. + let live = self.stats.live.fetch_add(1, Ordering::Relaxed) + 1; + let slot = Slot(Arc::clone(&self.stats)); + let limit = self.cfg.max_connections; + let over = limit > 0 && live > limit as u64; + if over { + self.stats.over_limit.fetch_add(1, Ordering::Relaxed); + // Once, not once per connection: the whole point of being at the limit + // is that connections are arriving faster than they leave, and a line + // each would be the flood arriving twice. + if !self.stats.warned_limit.swap(true, Ordering::Relaxed) { + crate::diag!( + "[warn] [proxy] {limit} concurrent connections reached; further \ + connections are being {} until it clears", + if self.cfg.over_limit_forwards { "forwarded unfiltered" } else { "refused" }, + ); + } + if !self.cfg.over_limit_forwards { + // Dropped here, which closes it: refusing costs the client a + // connection and costs the service nothing. + drop(client); + drop(slot); + continue; + } + } else if live * 2 <= limit as u64 { + // Armed again once there is real room, so the *next* time the wall is + // hit is reported too. Halfway rather than at the limit, or a service + // sitting exactly at it would log on every connection. + self.stats.warned_limit.store(false, Ordering::Relaxed); + } + + let cfg = Arc::clone(&self.cfg); + let chain = self.chain.clone(); + let stats = Arc::clone(&self.stats); + tokio::spawn(async move { + let _slot = slot; + // Over the limit and told to forward: the connection is relayed with no + // chain at all. That is the operator's choice made literal — the traffic + // reaches the service, and nothing claims to have looked at it. + let result = if over { + handle_connection(client, peer, cfg, ChainHandle::new(FilterChain::empty()), stats).await + } else { + handle_connection(client, peer, cfg, chain, stats).await + }; + if let Err(e) = result { + crate::diag_throttled!("[info] [proxy] connections that ended with an error", 20, 10, "[info] [proxy] connection from {peer} ended: {e}"); + } + }); + } + } +} + +async fn dial( + peer: SocketAddr, + upstream: SocketAddr, + cfg: &ProxyConfig, + stats: &ProxyStats, +) -> io::Result { + if !cfg.spoof_source { + return tokio::time::timeout(cfg.connect_timeout, connect_plain(upstream, cfg.self_mark)) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "upstream connect timed out"))?; + } + + match tokio::time::timeout( + cfg.connect_timeout, + connect_as(peer.ip(), upstream, cfg.self_mark), + ) + .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. + failed => { + stats.source_spoof_failures.fetch_add(1, Ordering::Relaxed); + if !stats.warned_spoof.swap(true, Ordering::Relaxed) { + let why = match failed { + Ok(Err(e)) => e.to_string(), + _ => "timed out (is the return path diverted?)".to_string(), + }; + crate::diag!( + "[warn] [proxy] cannot reach {upstream} as {}: {why}. \ + Falling back to our own address — the service will not see real client IPs.", + peer.ip() + ); + } + tokio::time::timeout(cfg.connect_timeout, connect_plain(upstream, cfg.self_mark)) + .await + .map_err(|_| { + io::Error::new(io::ErrorKind::TimedOut, "upstream connect timed out") + })? + } + } +} + +/// 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, + cfg: Arc, + chain: ChainHandle, + stats: Arc, +) -> io::Result<()> { + let original = match cfg.upstream { + Upstream::Fixed(addr) => addr, + Upstream::Original => match original_destination(&client) { + Ok(addr) => addr, + Err(e) => { + stats.origin_lookup_failures.fetch_add(1, Ordering::Relaxed); + return Err(e); + } + }, + }; + // 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(original); + let upstream = published.target.unwrap_or(original); + + // 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: this connection was dialled \ + at the engine's own port rather than steered here by a rule" + ), + )); + } + + let server = match dial(peer, upstream, &cfg, &stats).await { + Ok(s) => s, + Err(e) => { + stats.upstream_failures.fetch_add(1, Ordering::Relaxed); + return Err(e); + } + }; + let _ = client.set_nodelay(true); + let _ = server.set_nodelay(true); + + // 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 + // it in the clear is refused rather than carried to a service expecting HTTPS. Every + // other address is `Whatever`, where the sniff is the only answer there can be — + // one listener fronts both a cleartext port and an encrypted one, and which a + // connection is cannot be known before it starts. + if published.edge == Edge::Tls && opening != Opening::Tls && !cfg.tls.is_off() { + stats.closed_by_filter.fetch_add(1, Ordering::Relaxed); + // Said out loud, and at `warn`, because the operator on the other end of this has + // no way to guess it. What a cleartext client gets back is either nothing or a + // TLS alert — seven bytes a browser offers to save as a file — and neither says + // which of the two ends is wrong. It is nearly always the address: a port carrying + // the cleartext site declared as the encrypted one. Naming the address and what + // was expected turns an afternoon into a sentence. + crate::diag_throttled!("[warn] [proxy] clients that spoke something other than TLS at an encrypted address", 1, 30, + "[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!("{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. 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, + crate::h2::Carriage { + client: peer, + upstream, + chain: chain.clone(), + stats: Arc::clone(&stats), + // Unlike every other plaintext connection, this one *is* worth + // reconstructing: what is on the wire is a compression format, so the + // capture interface is the only place the exchange can be read as the + // filters saw it. + capture: cfg.capture.clone(), + first_byte_timeout: cfg.first_byte_timeout, + }, + ) + .await; + } + // Only where there is something to reconstruct: with no TLS and no HTTP/2, the + // bytes on the wire *are* the plaintext, and anybody wanting them can capture the + // interface they are already crossing rather than a copy this process invents. + let (client_rd, client_wr) = tokio::io::split(client); + let (server_rd, server_wr) = tokio::io::split(server); + return relay( + client_rd, client_wr, server_rd, server_wr, peer, upstream, &cfg, &chain, &stats, None, + ) + .await; + } + + // Handshakes before anything is relayed, and all of them on the deadline: a peer + // that opens a connection and then says nothing must not tie up a task. + // + // The order is the interesting part. The client's handshake is *started* and + // then held open at the ClientHello, because that message carries the protocols + // the client is willing to speak and this proxy has no business deciding them. + // The upstream handshake goes next, offering the service exactly that list; what + // the service picks is what the client is then told. Terminating in the middle + // of a connection means answering for a service, and answering something the + // service did not say is how a proxy breaks a protocol it was only supposed to + // carry — a client told `h2` while the service speaks HTTP/1.1 sends frames to + // something that cannot read them. + // + // Terminating HTTP/2 below changes nothing about that: the client is still told + // 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, sni) = match &cfg.tls.server { + Some(_) => { + let start = tokio::time::timeout( + cfg.connect_timeout, + tokio_rustls::LazyConfigAcceptor::new(rustls::server::Acceptor::default(), client), + ) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "TLS handshake timed out"))??; + let hello = start.client_hello(); + let wanted: Vec> = hello + .alpn() + .map(|it| it.map(|p| p.to_vec()).collect()) + .unwrap_or_default(); + // 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), + }; + + // Per address: the configuration is built once, and whether this connection uses it + // is the address's answer. `Same` is the old rule — re-encrypt what was terminated — + // and is why a plain TCP service, which terminates nothing, is untouched by any of + // this and always was. + let upstream_tls = match published.upstream { + Onward::Plain => None, + // The two coincide here and that is not an oversight: this point is only + // reached for a connection whose TLS *was* terminated, so "what arrived" and + // "TLS" are the same answer. They differ on the QUIC edge, where there is no + // arriving TLS to mirror and the choice decides the whole upstream leg. + Onward::Tls | Onward::Same => cfg.tls.upstream.as_ref(), + }; + let (server, agreed): (Duplex, Option>) = match upstream_tls { + Some(config) => { + 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), + ) + .await + .map_err(|_| { + io::Error::new(io::ErrorKind::TimedOut, "upstream TLS handshake timed out") + })??; + let agreed = connected.get_ref().1.alpn_protocol().map(|p| p.to_vec()); + (Box::new(connected), agreed) + } + // Nothing upstream to *ask*, which is two situations wearing one shape. Where + // the connection was never terminated there is nothing to decide and `wanted` is + // empty anyway. Where it was terminated and the address says the service speaks + // HTTP/1.1 whatever arrives (`Onward::Plain`), firegex is not carrying somebody + // else's protocol — it **is** the far end — so it answers for itself, and saying + // `http/1.1` is the whole of what it can honestly promise today. + // + // **`h2` is promised only while the chain will render it.** A bypassed chain is a + // byte pump, and a byte pump between an HTTP/2 client and an HTTP/1.1 service + // carries frames nothing at the far end can read — the exact breakage the + // mirroring rule exists to prevent, arriving from the other direction. The trade + // is the one the bypass already documents: a connection admitted while the chain + // was empty keeps the protocol it was admitted with. + // + // This is the other half of an asymmetry that stood for a while and had no reason + // behind it: the same cleartext HTTP/1.1 service was reachable over HTTP/3, + // because the QUIC edge picks from its own candidate list and speaks HTTP/1.1 + // onwards through `h1up`, and not reachable over HTTP/2 at all, because this edge + // had nobody to copy and therefore said nothing. One rendering underneath, + // opposite answers. + None => { + let picked = if matches!(published.upstream, Onward::Plain) { + let renders = !chain.current().is_bypassed(); + let offers = |name: &[u8]| wanted.iter().any(|p| p.as_slice() == name); + if renders && offers(b"h2") { + Some(b"h2".to_vec()) + } else if offers(b"http/1.1") { + Some(b"http/1.1".to_vec()) + } else { + None + } + } else { + None + }; + (Box::new(server), picked) + } + }; + + let client: Duplex = match (started, &cfg.tls.server) { + (Some(start), Some(config)) => { + let accepted = tokio::time::timeout( + cfg.connect_timeout, + start.into_stream(tls::answering_with(config, agreed.as_deref())), + ) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "TLS handshake timed out"))??; + Box::new(accepted) + } + _ => unreachable!("a started handshake means a server config"), + }; + + // Both ends agreed to speak HTTP/2, so the engine speaks it: terminated here, each + // exchange rendered to the chain as the HTTP/1.1 it would have been, and re-encoded + // on the way to the service. Without this the connection was forwarded as frames, + // and a pattern written against a request line matched nothing while an + // `HttpRequest` filter was never called — switched on, reporting nothing, running + // never. + // + // Not when the chain is bypassed. Rendering for a chain that has nothing to say is + // work paid for no answer, and this is the path a service with no filters and the + // `over_limit_forwards` fallback both take. The cost is that a connection carried + // 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 the fallback itself makes, and the + // reason it is written down here. + if agreed.as_deref() == Some(b"h2") && !chain.current().is_bypassed() { + let carriage = crate::h2::Carriage { + client: peer, + upstream, + chain: chain.clone(), + stats: Arc::clone(&stats), + capture: cfg.capture.clone(), + first_byte_timeout: cfg.first_byte_timeout, + }; + // Which version leaves is the address's answer, not the client's. `Plain` is the + // operator saying the service speaks HTTP/1.1 whatever arrives, so what leaves is + // the HTTP/1.1 the chain was already shown — the same bargain the QUIC edge makes, + // and the reason `h2.rs` takes its upstream as an `Outbound` rather than a + // connection of its own kind. + return match published.upstream { + Onward::Plain => { + // Each exchange dials a connection of its own, so the one opened before + // anything was known about the client goes now — held, it sat idle + // towards the service for as long as the client stayed. + drop(server); + crate::h2::carry_to_h1( + client, + crate::h1up::H1Upstream { + upstream, + client: peer, + tls: None, + server_name: None, + connect_timeout: cfg.connect_timeout, + self_mark: cfg.self_mark, + spoof: cfg.spoof_source, + }, + carriage, + ) + .await + } + _ => crate::h2::carry(client, server, carriage).await, + }; + } + + let (client_rd, client_wr) = tokio::io::split(client); + let (server_rd, server_wr) = tokio::io::split(server); + relay( + client_rd, + client_wr, + server_rd, + server_wr, + peer, + upstream, + &cfg, + &chain, + &stats, + Tap::open(cfg.capture.clone(), peer, upstream), + ) + .await +} + +/// What a client opening an HTTP/2 connection in the clear sends before anything else. +/// +/// RFC 9113's connection preface: a fixed 24 bytes, written as one piece, that no other +/// protocol begins with. It is what "prior knowledge" means — the client has been told the +/// service speaks HTTP/2 and does not negotiate. +const H2C_PREFACE: &[u8] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"; + +/// How long the opening is waited for when neither end has said anything yet. +/// +/// Short, and paid only by a connection where *nobody* has spoken — which is the shape the +/// first-byte deadline already exists to close, measured in seconds, so this adds nothing +/// anybody will notice. It is here so that two silent peers cannot hold this function, and +/// the two descriptors behind it, open forever. +const SNIFF_WAIT: Duration = Duration::from_millis(250); + +/// What this connection opens with, without taking anything out of the socket. +/// +/// It races the service, and that is the part that matters: a protocol where the *server* +/// speaks first — SMTP, SSH, most game protocols — would otherwise be held here waiting for +/// a client that is correctly waiting for a banner, and firegex would look like it was +/// breaking them at random. Neither TLS nor HTTP/2 has a client that waits to be spoken to, +/// so the service having spoken is proof this is neither. +async fn sniff(client: &TcpStream, server: &TcpStream) -> Opening { + async fn opening(client: &TcpStream) -> Opening { + let mut buf = [0u8; H2C_PREFACE.len()]; + loop { + let n = match client.peek(&mut buf).await { + Ok(0) | Err(_) => return Opening::Other, + Ok(n) => n, + }; + // Decided from three bytes, which arrive in the first segment of every TLS + // connection there has ever been: a handshake record, major version 3. Not + // from one — an opening byte on its own says nothing, and guessing from it + // would send a connection down the wrong edge for good. + if buf[0] == 0x16 { + if n < 3 { + tokio::time::sleep(Duration::from_millis(1)).await; + continue; + } + return if buf[1] == 0x03 { + Opening::Tls + } else { + Opening::Other + }; + } + if n >= H2C_PREFACE.len() { + return if buf == H2C_PREFACE { + Opening::Http2 + } else { + Opening::Other + }; + } + // Fewer bytes than the preface, but so far they *are* the preface: the + // segment boundary fell inside it. Peeking again costs nothing and is bounded + // by the caller — and treating a short read as "not HTTP/2" would be a + // connection carried unfiltered for a reason nobody could see. + if H2C_PREFACE.starts_with(&buf[..n]) { + tokio::time::sleep(Duration::from_millis(1)).await; + } else { + return Opening::Other; + } + } + } + + let mut first = [0u8; 1]; + tokio::select! { + biased; + answer = opening(client) => answer, + _ = server.peek(&mut first) => Opening::Other, + _ = tokio::time::sleep(SNIFF_WAIT) => Opening::Other, + } +} + +/// Carry one connection as two streams of bytes, which is every case HTTP/2 is not. +/// +/// Its own function because the plaintext path and the TLS one reach it from different +/// places now: the h2 branch returns before this, and what is left has to open the +/// chain's connection, pump, and close it in one piece rather than in two copies. +#[allow(clippy::too_many_arguments)] +async fn relay( + client_rd: CR, + client_wr: CW, + server_rd: SR, + server_wr: SW, + peer: SocketAddr, + upstream: SocketAddr, + cfg: &Arc, + chain: &ChainHandle, + stats: &Arc, + tap: Option>, +) -> io::Result<()> +where + CR: AsyncReadExt + Unpin + Send + 'static, + CW: AsyncWriteExt + Unpin + Send + 'static, + SR: AsyncReadExt + Unpin + Send + 'static, + SW: AsyncWriteExt + Unpin + Send + 'static, +{ + // Both directions share it: to a filter this is one stream, and a filter keeping + // per-stream state must not see the two halves as unrelated clients. + let connection = next_connection_id(); + // Told once, up front. These are the real addresses — this proxy knows both ends, + // which is more than the header on the wire could say once the connection has been + // terminated and reopened. + chain.current().connection_opened( + connection, + &ConnectionMeta { + client: peer, + server: upstream, + l4: L4::Tcp, + }, + ); + + let (up, down) = spawn_pumps( + client_rd, + client_wr, + server_rd, + server_wr, + chain, + stats, + connection, + tap.clone(), + Arc::new(AtomicBool::new(false)), + cfg.first_byte_timeout, + ); + + join_pumps(up, down).await; + // Both directions are finished, so anything a filter was keeping for this stream + // can go. Filters whose state lives in a session were already freed by dropping + // it; this is for the ones whose state lives somewhere the session cannot reach. + chain.current().connection_closed(connection); + // The reconstruction gets an end as well as a beginning, or Wireshark holds the + // stream open waiting for bytes that are never coming. + if let Some(tap) = &tap { + tap.closed(); + } + Ok(()) +} + +/// Why a direction stopped moving bytes. +#[derive(Debug, PartialEq, Eq)] +pub enum PumpOutcome { + /// The peer closed, or the stream simply ended. + Finished, + /// A rule refused the traffic, and the connection goes with it. + Rejected, +} + +/// Wait for both directions to finish, and report whether a rule ended them. +/// +/// A rule blocks a connection, not a direction. Half-closing would leave the other side +/// free to answer anyway — a service that replies without waiting for the request would +/// then still reach a client whose request was refused. The refused direction is already +/// winding the other one down through the stop channel; the grace period is the backstop +/// for one blocked writing to a peer that has stopped reading. +/// +/// The answer comes back rather than being acted on here, because what a refusal ends +/// differs by layer: on TCP the connection *is* the stream, while a QUIC stream is one of +/// many and the connection carrying it has to be closed on purpose. +pub(crate) async fn join_pumps( + mut up: tokio::task::JoinHandle>, + mut down: tokio::task::JoinHandle>, +) -> bool { + fn rejected(r: &Result, tokio::task::JoinError>) -> bool { + matches!(r, Ok(Ok(PumpOutcome::Rejected))) + } + async fn wind_down( + other: &mut tokio::task::JoinHandle>, + rejected: bool, + ) -> bool { + if !rejected { + return matches!(other.await, Ok(Ok(PumpOutcome::Rejected))); + } + if tokio::time::timeout(REJECT_GRACE, &mut *other).await.is_err() { + other.abort(); + } + true + } + + tokio::select! { + r = &mut up => wind_down(&mut down, rejected(&r)).await, + r = &mut down => wind_down(&mut up, rejected(&r)).await, + } +} + +/// Start both directions. Split out so the plain and the TLS paths share it. +#[allow(clippy::type_complexity)] +#[allow(clippy::too_many_arguments)] +pub(crate) fn spawn_pumps( + client_rd: CR, + client_wr: CW, + server_rd: SR, + server_wr: SW, + chain: &ChainHandle, + stats: &Arc, + connection: ConnectionId, + tap: Option>, + spoken: Arc, + cfg_first_byte: Option, +) -> ( + tokio::task::JoinHandle>, + tokio::task::JoinHandle>, +) +where + CR: AsyncReadExt + Unpin + Send + 'static, + CW: AsyncWriteExt + Unpin + Send + 'static, + SR: AsyncReadExt + Unpin + Send + 'static, + SW: AsyncWriteExt + Unpin + Send + 'static, +{ + // A refusal has to stop the other direction too, but stopping it by aborting the + // task drops its socket mid-stream: over TLS that reaches the peer as a + // truncation error instead of a close. Telling it to stop lets it forward nothing + // more and still shut down properly. + let (stop_tx, stop_rx) = tokio::sync::watch::channel(false); + + // Set by whichever direction moves a byte first. Both pumps hold it, so a server + // that speaks before its client satisfies the deadline just as a request would. + // + // Passed in rather than made here: on QUIC one connection carries many streams, and + // the question the deadline asks — has this peer said anything at all — is about the + // connection. A flag per stream would answer it once per stream and never for the + // connection that opened a hundred of them and spoke on none. + if let Some(deadline) = cfg_first_byte { + let spoken = Arc::clone(&spoken); + let stop = stop_tx.clone(); + let stats = Arc::clone(stats); + tokio::spawn(async move { + // Raced against the connection ending, not just slept through. Both pumps + // hold a receiver, so `closed()` resolves the moment the connection is over + // — and without that the timer outlives it for the whole deadline. A service + // taking a few thousand short connections a second with a minute's deadline + // would hold hundreds of thousands of sleeping tasks, which is the resource + // exhaustion this feature exists to prevent, arriving by the front door. + tokio::select! { + _ = tokio::time::sleep(deadline) => { + if !spoken.load(Ordering::Relaxed) { + stats.no_first_byte.fetch_add(1, Ordering::Relaxed); + // Through the same channel a refusal uses, so both directions are + // shut down rather than dropped: a peer dropped mid-stream reads + // a truncation, and this connection has done nothing to deserve + // one. + let _ = stop.send(true); + } + } + _ = stop.closed() => {} + } + }); + } + + let up = tokio::spawn(pump( + client_rd, + server_wr, + Direction::ClientToServer, + chain.clone(), + Arc::clone(stats), + connection, + tap.clone(), + Arc::clone(&spoken), + stop_tx.clone(), + stop_rx.clone(), + )); + let down = tokio::spawn(pump( + server_rd, + client_wr, + Direction::ServerToClient, + chain.clone(), + Arc::clone(stats), + connection, + tap, + spoken, + stop_tx, + stop_rx, + )); + (up, down) +} + +/// Move one direction of the stream, asking the chain about every chunk. +// Seven arguments rather than a struct: every one of them is moved into the task and +// used exactly once, so a struct would be a wrapper around the argument list. +#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_arguments)] +pub(crate) async fn pump( + mut rd: R, + mut wr: W, + dir: Direction, + chain: ChainHandle, + stats: Arc, + connection: ConnectionId, + tap: Option>, + spoken: Arc, + stop_tx: tokio::sync::watch::Sender, + mut stop_rx: tokio::sync::watch::Receiver, +) -> io::Result +where + R: AsyncReadExt + Unpin, + W: AsyncWriteExt + Unpin, +{ + // This direction's filter state, opened on the first chunk and dropped with the + // connection. Nothing here is shared with any other connection. + let mut sessions = ChainSessions::new(connection); + let mut buf = vec![0u8; RELAY_BUF]; + loop { + let read = tokio::select! { + r = rd.read(&mut buf) => r, + // The other direction refused the connection. Nothing more is forwarded, + // and this side is closed rather than dropped. + _ = stop_rx.changed() => { + let _ = wr.shutdown().await; + return Ok(PumpOutcome::Finished); + } + }; + let n = match read { + Ok(0) => break, + Ok(n) => n, + // However badly the other end goes away, this end is closed properly. + // Returning here without shutting down leaves a TLS peer reading a + // truncated stream instead of a close, which is the peer having to guess + // again. + Err(e) => { + let _ = wr.shutdown().await; + return Err(e); + } + }; + + // Before the chain has a say: a chunk a filter goes on to refuse is still a + // connection that said something, and the deadline is about silence. + spoken.store(true, Ordering::Relaxed); + + // Re-read the handle every chunk so a chain swapped in mid-connection + // takes effect without dropping anyone. + let verdict = chain.current().run(dir, &buf[..n], &mut sessions).await; + + match verdict { + Verdict::Accept => { + if let Err(e) = wr.write_all(&buf[..n]).await { + let _ = wr.shutdown().await; + return Err(e); + } + // After the write, and only what was written: a reconstruction that + // showed bytes the peer never received would disagree with the one + // thing it exists to report. + if let Some(tap) = &tap { + tap.wrote(dir == Direction::ClientToServer, &buf[..n]); + } + } + + Verdict::Reject(_) => { + stats.closed_by_filter.fetch_add(1, Ordering::Relaxed); + // Owning the connection means we can close it cleanly, instead of + // dropping packets and leaving the peer to guess. + let _ = stop_tx.send(true); + let _ = wr.shutdown().await; + return Ok(PumpOutcome::Rejected); + } + } + } + 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/pyworker.rs b/backend/proxysrc/src/pyworker.rs new file mode 100644 index 0000000..054906f --- /dev/null +++ b/backend/proxysrc/src/pyworker.rs @@ -0,0 +1,470 @@ +//! User Python code, kept out of the datapath process. +//! +//! The C++ engine embeds CPython, which is where a good share of its +//! complexity lives: sub-interpreters with their own GIL, `check_multi_interp_extensions` +//! locking out most of the ecosystem, and `allow_threads = 0` locking out the user's own. +//! +//! Here the code runs in a child process instead, for one reason above all: **a hung +//! worker can be killed, a hung thread cannot**. The engine's own filter deadline can +//! only walk away from a blocking thread and leave it holding a pool slot until the +//! process restarts — measured and asserted in `tests/fail_open.rs`. A child that +//! misses its deadline is killed outright and respawned on the next chunk, and the +//! chunk it was holding is forwarded. +//! +//! Frames are length-prefixed and binary, in both directions: +//! +//! ```text +//! engine -> worker [u32 len][u8 kind][u64 connection][payload] +//! kind: 0 client->server, 1 server->client, +//! 2 connection closed, 3 connection opened +//! worker -> engine [u32 len][u8 verdict][u8 name len][name][payload] +//! verdict: 0 accept, 1 reject +//! ``` +//! +//! The name is the `@pyfilter` function that decided, which is what makes a block +//! attributable to one function of a file rather than to the file as a whole — a file +//! routinely holds several, and "this filter blocked 900 connections" is not an answer +//! when you are trying to find out which of them is doing it. +//! +//! Neither an open nor a close frame is answered. A close has no verdict to give about +//! a connection that is already over, and waiting for one would make tearing down a +//! connection depend on a process the engine is willing to kill; an open carries the +//! connection's metadata — the addresses and ports a filter is allowed to read — once, +//! rather than repeating it on every chunk. + +use std::io::{Read, Write}; +use std::os::fd::AsRawFd; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use crate::filter::{ConnectionId, ConnectionMeta, Direction, Filter, FilterCtx, Verdict, L4}; + +const VERDICT_ACCEPT: u8 = 0; +const VERDICT_REJECT: u8 = 1; +/// Sent by the worker once the user's file has run. Waiting for it is what turns +/// "this code does not load" into a refused ruleset instead of a service that +/// silently stopped filtering. +const READY: u8 = 0xFF; + +/// What a frame means. The worker keeps one set of the user's module globals per +/// connection, which is what the documented API promises, so every frame has to say +/// which connection it belongs to — and something has to say when one is over, or the +/// worker would grow for as long as the service runs. +const KIND_C2S: u8 = 0; +const KIND_S2C: u8 = 1; +const KIND_CLOSE: u8 = 2; +/// Carries the connection's metadata as one JSON object. Everything below the +/// application layer that a filter may see arrives here and nowhere else — there are no +/// header bytes in the data frames, and no way to ask for any. +const KIND_OPEN: u8 = 3; + +/// `[u32 len][u8 kind][u64 connection][payload]`, big-endian, len covering everything +/// after itself. +fn build_frame(connection: ConnectionId, kind: u8, payload: &[u8]) -> Vec { + let mut frame = Vec::with_capacity(payload.len() + 13); + frame.extend_from_slice(&(payload.len() as u32 + 9).to_be_bytes()); + frame.push(kind); + frame.extend_from_slice(&connection.to_be_bytes()); + frame.extend_from_slice(payload); + frame +} + +/// How long a worker gets to import and execute the user's file. Longer than a +/// per-chunk deadline on purpose: this one pays for interpreter start-up. +const STARTUP_GRACE: Duration = Duration::from_secs(10); + +/// Cap on one frame, so a confused worker cannot ask us to allocate the machine away. +const MAX_FRAME: u32 = 64 * 1024 * 1024; + +/// Cap on the open and close frames waiting for the next exchange. Reached only when +/// nothing has asked the worker anything for thousands of connections — a filter that +/// has lost its say is still told about every connection — and past it they are +/// dropped: a connection then reaches the worker without its addresses, or leaves state +/// behind in a worker that was not going to be asked anything anyway. +const MAX_PENDING: usize = 1024 * 1024; + +#[derive(Debug, Default)] +pub struct WorkerStats { + /// Times the worker was killed for missing its deadline or dying on its own. + pub restarts: AtomicU64, + /// Chunks forwarded because the worker could not answer for them. + pub failed_open: AtomicU64, +} + +/// A filter backed by a child process running the user's Python. +pub struct PyWorkerRule { + id: String, + command: Vec, + code_path: String, + /// The subset of the file's `@pyfilter` functions to run; `None` means all of them. + enabled: Option>, + deadline: Duration, + /// One worker, one conversation at a time. Serialising is the honest simple + /// thing: a frame is a request and a response, and interleaving them would need + /// request ids and a worker able to answer out of order. A pool comes later. + child: Mutex>, + /// Open and close frames not yet written, in order. They need no answer, and they + /// are told from the relay itself — on the async runtime — where taking `child` + /// meant waiting out whatever exchange held it, up to its whole deadline, and where + /// a worker that had to be started first was started right there. A filter hanging + /// on one connection held every new connection of the service in that queue, on the + /// threads that carry all the others. The next exchange writes them ahead of its own + /// chunk, which runs where blocking is allowed and keeps an open ahead of the data. + pending: Mutex>, + pub stats: std::sync::Arc, + /// Everything that would make a new worker behave differently from this one; see + /// `Filter::reuse_key`. `None` when the file could not be read to say so. + reuse_key: Option, +} + +impl PyWorkerRule { + pub fn new( + id: String, + command: Vec, + code_path: String, + enabled: Option>, + deadline: Duration, + ) -> Self { + Self { + id, + command, + code_path, + enabled, + deadline, + child: Mutex::new(None), + pending: Mutex::new(Vec::new()), + stats: std::sync::Arc::new(WorkerStats::default()), + reuse_key: None, + } + } + + /// What this rule would be built from, as one string: the id, the command, which + /// functions are on, the deadline and the code itself. The same string means a new + /// worker would run exactly what this one runs, so it is kept instead — with the + /// state it holds for every open connection. The code is read rather than trusted to + /// its path, because saving a filter rewrites the same file. + pub fn key_for( + id: &str, + command: &[String], + code_path: &str, + enabled: &Option>, + deadline: Duration, + ) -> Option { + use std::hash::{Hash, Hasher}; + let code = std::fs::read(code_path).ok()?; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + code.hash(&mut hasher); + Some(format!( + "{id}\0{command:?}\0{code_path}\0{enabled:?}\0{}\0{:016x}", + deadline.as_millis(), + hasher.finish() + )) + } + + /// Answer to `key` from `Filter::reuse_key`. + pub fn reusable_as(mut self, key: Option) -> Self { + self.reuse_key = key; + self + } + + fn spawn(&self) -> Result { + let (program, args) = self + .command + .split_first() + .ok_or_else(|| "no command for the worker".to_string())?; + let mut child = Command::new(program) + .args(args) + // The file, and which of its functions are switched on. Which protocol it + // speaks is the file's own business — the library reads it off what the + // filters ask for, so there is nothing here that could disagree with the + // code. + // + // The selection is a marker plus a comma-separated list, because "run + // everything" and "run nothing" are both real answers and an empty string + // cannot mean both: `*` is all of them, `=` followed by names is exactly + // those, and `=` alone is none. + .arg(&self.code_path) + .arg(match &self.enabled { + None => "*".to_string(), + Some(names) => format!("={}", names.join(",")), + }) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + // Tracebacks go to our stderr, where the backend already reads them. + .stderr(Stdio::inherit()) + .spawn() + .map_err(|e| format!("cannot start the worker: {e}"))?; + + // Wait for it to say it loaded. A file that raises on import never gets here. + let deadline_at = Instant::now() + STARTUP_GRACE; + let ready = (|| -> Result<(), String> { + let stdout = child.stdout.as_mut().ok_or("worker has no stdout")?; + let mut header = [0u8; 4]; + read_before(stdout, &mut header, deadline_at)?; + // The same shape as a verdict — marker plus an empty name — so there is one + // frame layout on this channel rather than one for the handshake and + // another for everything after it. + if u32::from_be_bytes(header) != 2 { + return Err("worker did not announce itself".to_string()); + } + let mut marker = [0u8; 2]; + read_before(stdout, &mut marker, deadline_at)?; + if marker[0] != READY { + return Err("worker did not announce itself".to_string()); + } + Ok(()) + })(); + if let Err(why) = ready { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("the filter code did not load: {why}")); + } + Ok(child) + } + + /// Which `@pyfilter` functions of the file are switched on, or `None` for all. + /// + /// Deciding here rather than in the file is the point: a function is turned off + /// without editing the code that defines it, and turned back on without the + /// operator having to remember what they deleted. + /// Hold a frame that needs no answer for the next exchange. See `pending`. + fn queue(&self, frame: Vec) { + let Ok(mut pending) = self.pending.lock() else { return }; + if pending.len() + frame.len() > MAX_PENDING { + pending.clear(); + } + pending.extend_from_slice(&frame); + } + + pub fn enabled(&self) -> Option<&[String]> { + self.enabled.as_deref() + } + + /// Start the worker now, so a ruleset carrying code that cannot load is refused + /// at the moment it is applied rather than discovered later as unfiltered traffic. + pub fn warm_up(&self) -> Result<(), String> { + let mut slot = self + .child + .lock() + .map_err(|_| "worker lock poisoned".to_string())?; + if slot.is_none() { + *slot = Some(self.spawn()?); + } + Ok(()) + } + + /// Kill and forget, so the next chunk starts a fresh one. + fn discard(&self, slot: &mut Option, why: &str) { + if let Some(mut child) = slot.take() { + let _ = child.kill(); + let _ = child.wait(); + self.stats.restarts.fetch_add(1, Ordering::Relaxed); + crate::diag!("[warn] [pyworker] '{}' killed: {why}", self.id); + } + } + + fn exchange( + &self, + connection: ConnectionId, + dir: Direction, + chunk: &[u8], + ) -> Result { + let mut slot = self + .child + .lock() + .map_err(|_| "worker lock poisoned".to_string())?; + if slot.is_none() { + *slot = Some(self.spawn()?); + } + + let deadline_at = Instant::now() + self.deadline; + let result = (|| -> Result { + let child = slot.as_mut().expect("just spawned"); + let stdin = child.stdin.as_mut().ok_or("worker has no stdin")?; + let frame = build_frame( + connection, + match dir { + Direction::ClientToServer => KIND_C2S, + Direction::ServerToClient => KIND_S2C, + }, + chunk, + ); + + let queued = std::mem::take( + &mut *self.pending.lock().map_err(|_| "worker queue poisoned".to_string())?, + ); + if !queued.is_empty() { + write_before(stdin, &queued, deadline_at)?; + } + write_before(stdin, &frame, deadline_at)?; + let stdout = child.stdout.as_mut().ok_or("worker has no stdout")?; + let mut header = [0u8; 4]; + read_before(stdout, &mut header, deadline_at)?; + let len = u32::from_be_bytes(header); + if len == 0 || len > MAX_FRAME { + return Err(format!("worker sent a {len} byte frame")); + } + let mut body = vec![0u8; len as usize]; + read_before(stdout, &mut body, deadline_at)?; + // `[verdict][name len][name][payload]`. The name is whichever function + // decided; it is what lets a block be counted against one function of the + // file instead of the whole file. + if body.len() < 2 { + return Err("worker sent a truncated verdict".to_string()); + } + let name_len = body[1] as usize; + if body.len() < 2 + name_len { + return Err("worker sent a verdict whose name runs past the frame".to_string()); + } + let name = String::from_utf8_lossy(&body[2..2 + name_len]).into_owned(); + match body[0] { + VERDICT_ACCEPT => Ok(Verdict::Accept), + VERDICT_REJECT => Ok(Verdict::Reject(Some(if name.is_empty() { + self.id.clone() + } else { + // `/`, the one token both network layers report, so + // the backend has a single way to attribute a block. + format!("{}/{}", self.id, name) + }))), + other => Err(format!("worker sent verdict {other}")), + } + })(); + + if let Err(ref why) = result { + // Anything that goes wrong here — a deadline, a crash, a protocol + // violation — costs the worker its life, not the traffic. + self.discard(&mut slot, why); + } + result + } +} + +impl Filter for PyWorkerRule { + fn name(&self) -> &str { + &self.id + } + fn connection_opened(&self, connection: ConnectionId, meta: &ConnectionMeta) { + // Best effort and unanswered, like the close frame: the metadata is a + // convenience for the filter, and a worker that has died will be respawned by + // the first chunk anyway — which then arrives without endpoints rather than not + // at all. Losing the traffic over it would be the wrong trade. + let payload = serde_json::json!({ + "client_ip": meta.client.ip().to_string(), + "client_port": meta.client.port(), + "server_ip": meta.server.ip().to_string(), + "server_port": meta.server.port(), + "is_ipv6": meta.server.is_ipv6(), + // Both, and they are not the same question. `is_tcp` is what the NFQUEUE + // side has always sent and what a filter reads to know the wire; `l4` is what + // decides whether a stream can be built, which is true of QUIC and not of a + // datagram. A library that only received the first would have to guess one + // of them. + "is_tcp": meta.l4 == L4::Tcp, + "l4": meta.l4.name(), + }) + .to_string(); + self.queue(build_frame(connection, KIND_OPEN, payload.as_bytes())); + } + fn connection_closed(&self, connection: ConnectionId) { + // Best effort, and deliberately not on the datapath's critical path: the + // connection is already over, so nothing is waiting on this. A worker that + // has died in the meantime simply has no state left to free. + self.queue(build_frame(connection, KIND_CLOSE, &[])); + } + fn prepare(&self) -> Result<(), String> { + self.warm_up() + } + fn reuse_key(&self) -> Option<&str> { + self.reuse_key.as_deref() + } + fn inspect(&self, ctx: &FilterCtx<'_>) -> Verdict { + match self.exchange(ctx.connection, ctx.direction, ctx.chunk) { + Ok(verdict) => verdict, + Err(_) => { + // The rule loses its say for this chunk; the chunk goes through. + self.stats.failed_open.fetch_add(1, Ordering::Relaxed); + Verdict::Accept + } + } + } +} + +/// Wait until the fd is ready, or the deadline passes. +fn wait_ready(fd: i32, writable: bool, deadline_at: Instant) -> Result<(), String> { + let remaining = deadline_at.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err("worker missed its deadline".to_string()); + } + let mut pfd = libc::pollfd { + fd, + events: if writable { + libc::POLLOUT + } else { + libc::POLLIN + }, + revents: 0, + }; + let timeout_ms = remaining.as_millis().min(i32::MAX as u128) as libc::c_int; + let rc = unsafe { libc::poll(&mut pfd, 1, timeout_ms) }; + match rc { + 0 => Err("worker missed its deadline".to_string()), + n if n < 0 => Err(format!("poll failed: {}", std::io::Error::last_os_error())), + _ => { + if pfd.revents & (libc::POLLERR | libc::POLLNVAL) != 0 { + return Err("worker pipe broke".to_string()); + } + Ok(()) + } + } +} + +/// Write it all, giving up when the deadline passes rather than blocking forever on +/// a worker that has stopped reading. +fn write_before( + out: &mut W, + mut buf: &[u8], + deadline_at: Instant, +) -> Result<(), String> { + while !buf.is_empty() { + wait_ready(out.as_raw_fd(), true, deadline_at)?; + match out.write(buf) { + Ok(0) => return Err("worker closed its input".to_string()), + Ok(n) => buf = &buf[n..], + Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(format!("cannot write to the worker: {e}")), + } + } + out.flush() + .map_err(|e| format!("cannot flush to the worker: {e}")) +} + +fn read_before( + input: &mut R, + buf: &mut [u8], + deadline_at: Instant, +) -> Result<(), String> { + let mut filled = 0; + while filled < buf.len() { + wait_ready(input.as_raw_fd(), false, deadline_at)?; + match input.read(&mut buf[filled..]) { + Ok(0) => return Err("worker exited".to_string()), + Ok(n) => filled += n, + Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(format!("cannot read from the worker: {e}")), + } + } + Ok(()) +} + +impl Drop for PyWorkerRule { + fn drop(&mut self) { + if let Ok(mut slot) = self.child.lock() { + if let Some(mut child) = slot.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } + } +} diff --git a/backend/proxysrc/src/quic.rs b/backend/proxysrc/src/quic.rs new file mode 100644 index 0000000..4998c3e --- /dev/null +++ b/backend/proxysrc/src/quic.rs @@ -0,0 +1,1057 @@ +//! QUIC, terminated by the engine. +//! +//! TLS over TCP could in principle be left alone: the bytes on the wire are framed by a +//! transport the kernel understands, and a layer that only forwards still has packets to +//! count. QUIC does not leave even that. After the Initial packet every frame, every +//! stream boundary and the packet number itself are encrypted, so a layer that forwards +//! has nothing a filter could be shown — the choice is to terminate it or to admit that +//! nothing is being inspected. That is why QUIC lives here and on no other layer. +//! +//! On the wire it is UDP, which is what the rules match and what the relay map is made +//! of: one endpoint per protected address, exactly as [`crate::udp`] binds one socket +//! per address and for the same reason — `SO_ORIGINAL_DST` is TCP and SCTP only, so a +//! single listener could not recover where a datagram was headed. +//! +//! **The ALPN is mirrored, exactly as over TLS.** The client's ClientHello travels inside +//! an Initial packet, but Initial keys come from a connection ID the client sends in the +//! clear, so [`crate::quic_hello`] reads the list the client offered off the socket before +//! the handshake is answered. The service is dialled offering exactly that list, and the +//! client is told the one protocol the service chose. It used to be a candidate list the +//! operator wrote down per service, `h3` unless told otherwise, because the Initial was +//! taken to be unreadable: a QUIC service speaking anything but HTTP/3 was unreachable +//! until somebody found the setting. When a hello cannot be read — a QUIC version this +//! does not know, say — `h3` is offered and the log says so. + +use std::collections::HashMap; +use std::io; +use std::net::SocketAddr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use quinn::crypto::rustls::{QuicClientConfig, QuicServerConfig}; +use quinn::{Connection, Endpoint, Incoming, VarInt}; + +use crate::capture::{Capture, Tap}; +use crate::quic_hello::{Hellos, SniffingSocket}; +use crate::filter::{ + next_connection_id, ChainHandle, ChainSessions, ConnectionId, ConnectionMeta, Direction, + FilterChain, Verdict, L4, +}; +use crate::proxy::{join_pumps, pump, spawn_pumps, Onward, ProxyStats, PumpOutcome, Slot}; +use crate::transparent::unmap; + +/// What the client is told when a rule refuses one of its streams. +/// +/// An application error code, because by then the connection is established and QUIC has +/// no other way to say anything: `0x1f3` is in the application range and firegex is the +/// application here. The reason travels with it, so a developer who blocks themselves out +/// of their own service reads why in their client rather than guessing from a reset. +const REFUSED_CODE: u32 = 0x1f3; + +/// How long an idle QUIC connection is kept. +/// +/// Longer than the datagram relay's flow timeout, and for the opposite reason: a QUIC +/// connection *does* have a close to observe, so this is a backstop for a peer that +/// vanished rather than the only thing that ends it. +const IDLE: Duration = Duration::from_secs(60); + +/// How much unread datagram traffic one connection may have waiting. +/// +/// Bounded on purpose, and small: datagrams are dropped when it is full, which is what +/// unreliable means and exactly what the sender already has to handle. Unbounded, a peer +/// that sends faster than the chain judges would be buying memory in this process. +const DATAGRAM_BUFFER: usize = 256 * 1024; + +/// How long a client's hello may take to arrive whole, when it spans more than one +/// datagram. Past it, `h3` is offered and the log says the hello could not be read. +const HELLO_WAIT: Duration = Duration::from_millis(1500); + +/// What is offered to a service when the client's own list could not be read. +const FALLBACK_ALPN: &[u8] = b"h3"; + +/// Everything a QUIC service needs decided once, before any client arrives. +/// +/// No protocols among it: those are each client's own, and each connection's accepting +/// and dialling configurations are built from them — a clone of a rustls config and a +/// cipher-suite lookup, which is nothing beside the two handshakes they are for, and the +/// same trade the TLS path makes per connection for the same reason. +pub struct QuicSetup { + /// The service's certificate, answering. Cloned per connection to advertise the one + /// protocol the service chose. + server: Arc, + /// Towards the service. Cloned per connection to offer what the client offered. + client: rustls::ClientConfig, + /// Without datagrams, for HTTP/3; with them, for everything else. See `build`. + streams_only: Arc, + with_datagrams: Arc, + /// What the endpoint answers with before a service has been asked: only ever used + /// for connections this relay turns away, which need a configuration to be turned + /// away *by*. It advertises nothing, so nothing is agreed on it. + silent: Arc, +} + +impl QuicSetup { + /// Build both edges from the service's certificate. + pub fn build(cert_pem: &str, key_pem: &str) -> Result { + // Two of them, differing in one advertisement. **HTTP/3 connections do not carry + // datagrams here**, and the honest way to say that is not to advertise the + // extension on them: a peer then knows from the handshake and falls back, instead + // of sending datagrams that vanish. The reason is the one thing a datagram would + // need that this engine cannot give it — an HTTP/3 datagram names the stream it + // belongs to, and the request streams this proxy opens towards the service are + // not the ones the client opened, so the name would point somewhere else on the + // far side. Everything that is not h3 has no such coupling and carries them. + let streams_only = transport_config(false); + let with_datagrams = transport_config(true); + let server = crate::tls::server_config(cert_pem, key_pem)?; + let silent = Arc::new(server_config(&server, &[], &streams_only)?); + Ok(Self { + server, + client: crate::tls::quic_client_config()?, + streams_only, + with_datagrams, + silent, + }) + } + + fn transport_for(&self, protocols: &[Vec]) -> &Arc { + if protocols.iter().all(|p| p == b"h3") { + &self.streams_only + } else { + &self.with_datagrams + } + } + + /// The configuration that advertises the one protocol the service agreed to — or + /// nothing, when it agreed to nothing, so the refusal is the handshake's with a reason. + fn answering_with(&self, agreed: Option<&[u8]>) -> Result, String> { + let alpn: Vec> = agreed.map(|p| vec![p.to_vec()]).unwrap_or_default(); + if alpn.is_empty() { + return Ok(Arc::clone(&self.silent)); + } + Ok(Arc::new(server_config(&self.server, &alpn, self.transport_for(&alpn))?)) + } + + /// Towards the service, offering exactly what the client offered. The service is + /// dialled before it has said which protocol it speaks, so the datagram extension is + /// decided by the list rather than by the answer: with nothing but h3 on it there is + /// nothing a datagram could be for, and advertising it upstream would invite what this + /// end would then have to drop. + fn client_for(&self, offered: &[Vec]) -> Result { + let mut rustls_client = self.client.clone(); + rustls_client.alpn_protocols = offered.to_vec(); + let crypto = QuicClientConfig::try_from(rustls_client) + .map_err(|e| format!("cannot configure QUIC towards the service: {e}"))?; + let mut client = quinn::ClientConfig::new(Arc::new(crypto)); + client.transport_config(Arc::clone(self.transport_for(offered))); + Ok(client) + } + + fn default_server(&self) -> Arc { + Arc::clone(&self.silent) + } +} + +fn server_config( + base: &Arc, + alpn: &[Vec], + transport: &Arc, +) -> Result { + let mut rustls_config = (**base).clone(); + rustls_config.alpn_protocols = alpn.to_vec(); + // Left at zero deliberately. 0-RTT data is replayable by anyone who watched it go + // past, and a filter that refused a request has no way to un-deliver the copy of it + // the service already acted on. A round trip is the price of that not being true. + rustls_config.max_early_data_size = 0; + let crypto = QuicServerConfig::try_from(rustls_config) + .map_err(|e| format!("the certificate cannot carry QUIC: {e}"))?; + let mut config = quinn::ServerConfig::with_crypto(Arc::new(crypto)); + config.transport_config(Arc::clone(transport)); + Ok(config) +} + +/// The transport parameters an edge is built with. +fn transport_config(datagrams: bool) -> Arc { + let mut transport = quinn::TransportConfig::default(); + transport.max_idle_timeout(Some( + IDLE.try_into().expect("the idle timeout fits in a VarInt"), + )); + // Advertised only where they are carried, because a peer that finds the extension + // announced and then watches its datagrams disappear is worse off than one that knows + // from the handshake to do without. What a filter is shown for one is a `RawPacket` + // and nothing more, which is the same answer the datagram relay gives on plain UDP — + // there is no stream, so there is nothing for the stream models to be built on. + transport.datagram_receive_buffer_size(datagrams.then_some(DATAGRAM_BUFFER)); + Arc::new(transport) +} + +/// The settings a relay is built with, shared by every address of one service. +#[derive(Clone)] +pub struct QuicConfig { + pub setup: Arc, + /// Where a reconstruction of each stream is written, when anything is listening. + /// `None` is the ordinary case — no capture interface — and never a failure. + pub capture: Option>, + pub self_mark: Option, + pub spoof_source: bool, + pub max_connections: usize, + pub over_limit_forwards: bool, + pub first_byte_timeout: Option, + pub connect_timeout: Duration, + /// What the service behind this *relay* speaks — QUIC unless it says otherwise. + /// + /// One per relay rather than one per process, because a relay is one protected + /// address and the question belongs to the address: two ports of one service can + /// be reached differently. The other two values put an ordinary HTTP/1.1 service + /// behind an HTTP/3 edge, with firegex terminating QUIC on its behalf. + pub upstream: Onward, +} + +/// One protected address, terminated. +pub struct QuicRelay { + endpoint: Endpoint, + upstream: SocketAddr, + chain: ChainHandle, + cfg: QuicConfig, + stats: Arc, + /// The hellos read off this endpoint's socket, for each client's own ALPN list. + hellos: Arc, + /// Whether a hello that could not be read has been said once already. + warned_hello: AtomicBool, +} + +impl QuicRelay { + pub fn bind( + listen: SocketAddr, + upstream: SocketAddr, + chain: ChainHandle, + cfg: QuicConfig, + stats: Arc, + ) -> io::Result { + // Unmarked, exactly as the datagram relay's listener is. The mark is what tells + // the intercept rules to leave *our own dial* alone; this socket is the one the + // rules are pointing at, and its answers go back the way conntrack sends them. + let socket = std::net::UdpSocket::bind(listen)?; + let runtime = quinn::default_runtime() + .ok_or_else(|| io::Error::other("no async runtime for the QUIC endpoint"))?; + // Everything quinn receives passes by the hello reader first. + let hellos = Arc::new(Hellos::new()); + let sniffing = SniffingSocket::new(runtime.wrap_udp_socket(socket)?, Arc::clone(&hellos)); + let endpoint = Endpoint::new_with_abstract_socket( + quinn::EndpointConfig::default(), + Some((*cfg.setup.default_server()).clone()), + Arc::new(sniffing), + runtime, + )?; + Ok(Self { + endpoint, + upstream, + chain, + cfg, + stats, + hellos, + warned_hello: AtomicBool::new(false), + }) + } + + pub fn local_addr(&self) -> io::Result { + self.endpoint.local_addr() + } + + /// Accept forever. Nothing one connection does may end this loop. + pub async fn serve(self: Arc) { + while let Some(incoming) = self.endpoint.accept().await { + let relay = Arc::clone(&self); + tokio::spawn(async move { relay.admit(incoming).await }); + } + crate::diag!("[warn] [quic] the endpoint for {} closed", self.upstream); + } + + /// Decide whether this connection is carried at all, and on what terms. + async fn admit(self: Arc, incoming: Incoming) { + let client = unmap(incoming.remote_address()); + + // Before anything at all is opened towards the service. An Initial packet is one + // datagram carrying no proof that its source address is real, and answering it by + // opening a connection to the protected service is how a spoofed source turns + // this relay into the amplifier — the same shape as the datagram flood the flow + // cap exists for, but with a handshake in front of it. A Retry costs the honest + // client one round trip and costs the forged one everything, because the token + // has to come back from an address that can receive it. + if !incoming.remote_address_validated() { + if let Err(e) = incoming.retry() { + // Only possible if it was validated after all, which this branch has + // just established it was not. Refuse rather than assume. + e.into_incoming().refuse(); + } + return; + } + + self.stats.accepted.fetch_add(1, Ordering::Relaxed); + let live = self.stats.live.fetch_add(1, Ordering::Relaxed) + 1; + let slot = Slot(Arc::clone(&self.stats)); + let limit = self.cfg.max_connections; + let over = limit > 0 && live > limit as u64; + if over { + self.stats.over_limit.fetch_add(1, Ordering::Relaxed); + if !self.stats.warned_limit.swap(true, Ordering::Relaxed) { + crate::diag!( + "[warn] [quic] {limit} concurrent connections reached; further \ + connections are being {} until it clears", + if self.cfg.over_limit_forwards { + "carried unfiltered" + } else { + "refused" + }, + ); + } + if !self.cfg.over_limit_forwards { + // A refusal the client can read, rather than a silence it has to time + // out on: this connection reached a working service that chose not to + // carry it. + incoming.refuse(); + drop(slot); + return; + } + } else if live * 2 <= limit as u64 { + self.stats.warned_limit.store(false, Ordering::Relaxed); + } + + // Past the limit and told to carry it anyway: the streams are relayed against an + // empty chain. The traffic reaches the service and nothing claims to have looked + // at it, which is the operator's choice made literal. It is not free here the way + // it is on TCP — the connection is still terminated, decrypted and re-encrypted, + // because there is no such thing as forwarding a QUIC connection unopened. + let chain = if over { + ChainHandle::new(FilterChain::empty()) + } else { + self.chain.clone() + }; + + let _slot = slot; + if let Err(e) = self.serve_connection(incoming, client, chain).await { + crate::diag_throttled!("[info] [quic] connections that ended with an error", 20, 10, "[info] [quic] connection from {client} ended: {e}"); + } + } + + /// Both handshakes, in the order that lets the service decide the protocol. + async fn serve_connection( + &self, + incoming: Incoming, + client: SocketAddr, + chain: ChainHandle, + ) -> io::Result<()> { + // Two shapes, decided by what the service speaks. With QUIC behind, the service + // is dialled *first* and asked which protocol it wants — that ordering is the + // whole reason the client can be told something the service actually chose. + // With HTTP/1.1 behind there is nobody to ask: firegex is the thing speaking + // HTTP/3, so it answers `h3` for itself and nothing is dialled until there is a + // request to send. + let (endpoint, behind, agreed) = match self.cfg.upstream { + Onward::Same => { + let offered = self.offered(&incoming, client).await; + // The endpoint is held for as long as the connection is: it owns the + // socket the connection speaks through, and dropping it would take the + // connection with it. + let (endpoint, service) = self.dial_upstream(client, &offered).await?; + let agreed = negotiated_protocol(&service); + (Some(endpoint), Behind::Quic(service), agreed) + } + protocol => ( + None, + Behind::Http1(crate::h1up::H1Upstream { + upstream: self.upstream, + client, + tls: match protocol { + Onward::Tls => Some( + crate::tls::client_config().map_err(io::Error::other)?, + ), + _ => 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, + }), + Some(b"h3".to_vec()), + ), + }; + + let answering = self + .cfg + .setup + .answering_with(agreed.as_deref()) + .map_err(io::Error::other)?; + let accepting = incoming.accept_with(answering).map_err(io::Error::other)?; + let peer = match tokio::time::timeout(self.cfg.connect_timeout, accepting).await { + Ok(Ok(connection)) => connection, + Ok(Err(e)) => { + // Nearly always the one mismatch this ordering can produce, so it is + // named rather than left as a handshake error the operator has to decode. + // Which end chose it differs: with QUIC behind it is the service's pick, + // and with HTTP/1.1 behind it is `h3`, which is what firegex itself is + // offering to speak on the service's behalf. + if let Some(protocol) = &agreed { + crate::diag_throttled!("[warn] [quic] clients that refused the protocol the service chose", 5, 30, + "[warn] [quic] {client} would not take `{}`, which is what {} chose: {e}", + String::from_utf8_lossy(protocol), + match behind { + Behind::Quic(_) => self.upstream.to_string(), + Behind::Http1(_) => "firegex, for a service that speaks HTTP/1.1" + .to_string(), + }, + ); + } + if let Some(service) = behind.quic() { + service.close(VarInt::from_u32(0), b"client handshake failed"); + } + return Err(io::Error::other(e)); + } + Err(_) => { + if let Some(service) = behind.quic() { + service.close(VarInt::from_u32(0), b"client handshake timed out"); + } + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "the client's QUIC handshake timed out", + )); + } + }; + + // 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(()) + } + + /// The protocols this client offered, read off its hello; `h3` if it could not be. + async fn offered(&self, incoming: &Incoming, client: SocketAddr) -> Vec> { + match self.hellos.offered(incoming.remote_address(), HELLO_WAIT).await { + Some(offered) if !offered.is_empty() => offered, + _ => { + if !self.warned_hello.swap(true, Ordering::Relaxed) { + crate::diag!( + "[warn] [quic] could not read which protocols {client} offered; \ + offering {} to {} instead. A client speaking something else is \ + refused by the handshake.", + String::from_utf8_lossy(FALLBACK_ALPN), + self.upstream, + ); + } + vec![FALLBACK_ALPN.to_vec()] + } + } + } + + /// Open the connection to the service, from the client's own address where we can, + /// offering what the client offered. + async fn dial_upstream( + &self, + client: SocketAddr, + offered: &[Vec], + ) -> io::Result<(Endpoint, Connection)> { + let endpoint = match self.endpoint_as(client) { + Ok(endpoint) => endpoint, + Err(e) => { + // Losing the client's address is bad; losing the connection is worse — + // the same trade the TCP dial makes, and just as loud about it. + self.stats + .source_spoof_failures + .fetch_add(1, Ordering::Relaxed); + if !self.stats.warned_spoof.swap(true, Ordering::Relaxed) { + crate::diag!( + "[warn] [quic] cannot reach {} as {}: {e}. Falling back to our own \ + address — the service will not see real client IPs.", + self.upstream, + client.ip() + ); + } + self.endpoint_plain()? + } + }; + + // An IP literal is what the TCP path presents too. Nothing verifies it — the + // service being protected is the thing we are defending, not a peer to be + // authenticated — but rustls needs a name to put in the handshake. + let name = self.upstream.ip().to_string(); + let connecting = endpoint + .connect_with( + self.cfg.setup.client_for(offered).map_err(io::Error::other)?, + self.upstream, + &name, + ) + .map_err(io::Error::other)?; + let connection = tokio::time::timeout(self.cfg.connect_timeout, connecting) + .await + .map_err(|_| { + io::Error::new( + io::ErrorKind::TimedOut, + format!("{} did not complete a QUIC handshake", self.upstream), + ) + })? + .map_err(io::Error::other)?; + Ok((endpoint, connection)) + } + + /// An endpoint wearing the client's address. + fn endpoint_as(&self, client: SocketAddr) -> io::Result { + if !self.cfg.spoof_source { + return self.endpoint_plain(); + } + let socket = + crate::transparent::bind_as_udp(client.ip(), self.upstream, self.cfg.self_mark)?; + let runtime = quinn::default_runtime() + .ok_or_else(|| io::Error::other("no async runtime for the QUIC endpoint"))?; + Endpoint::new(quinn::EndpointConfig::default(), None, socket, runtime) + } + + fn endpoint_plain(&self) -> io::Result { + let bind: SocketAddr = if self.upstream.is_ipv6() { + "[::]:0".parse().unwrap() + } else { + "0.0.0.0:0".parse().unwrap() + }; + let socket = std::net::UdpSocket::bind(bind)?; + if let Some(mark) = self.cfg.self_mark { + use std::os::fd::AsRawFd; + crate::transparent::set_self_mark(socket.as_raw_fd(), mark)?; + } + let runtime = quinn::default_runtime() + .ok_or_else(|| io::Error::other("no async runtime for the QUIC endpoint"))?; + Endpoint::new(quinn::EndpointConfig::default(), None, socket, runtime) + } + + /// Carry one connection until either end is done with it. + async fn relay( + &self, + peer: Connection, + service: Behind, + client: SocketAddr, + chain: ChainHandle, + agreed: Option<&[u8]>, + ) { + // One flag for the whole connection, not one per stream. The question the + // deadline asks is whether this peer has said anything at all, and a connection + // that opens a hundred streams and speaks on none is exactly the shape it is + // there to catch. + let spoken = Arc::new(AtomicBool::new(false)); + let watchdog = self.cfg.first_byte_timeout.map(|deadline| { + let spoken = Arc::clone(&spoken); + let peer = peer.clone(); + let service = service.quic().cloned(); + let stats = Arc::clone(&self.stats); + tokio::spawn(async move { + tokio::select! { + _ = tokio::time::sleep(deadline) => { + if !spoken.load(Ordering::Relaxed) { + stats.no_first_byte.fetch_add(1, Ordering::Relaxed); + peer.close(VarInt::from_u32(0), b"nothing was ever said"); + if let Some(service) = &service { + service.close(VarInt::from_u32(0), b"nothing was ever said"); + } + } + } + // Closed rather than slept through, or a service taking short + // connections would hold one sleeping task per connection for the + // whole deadline. + _ = peer.closed() => {} + } + }) + }); + + let carrier = Carrier { + peer: peer.clone(), + service, + client, + upstream: self.upstream, + capture: self.cfg.capture.clone(), + chain, + stats: Arc::clone(&self.stats), + spoken, + }; + + // HTTP/3 is carried by something that understands it, and everything else by + // something that does not have to. The difference is not an optimisation: h3 + // puts a request's method, path and headers in a QPACK-compressed HEADERS frame, + // so a filter shown the raw stream would be shown a compression format. Ending + // the protocol here is what lets the chain be shown a request. + if agreed == Some(b"h3") { + crate::h3::carry(&carrier).await; + } else if carrier.service.quic().is_none() { + // Everything that is not HTTP/3 is opaque bytes on a stream, and opaque bytes + // have no HTTP/1.1 form to be sent in. Refused here, once, with a reason the + // client can read — rather than carried into a translation nobody could + // describe, or dropped silently. + crate::diag_throttled!("[warn] [quic] connections that agreed on something other than HTTP/3", 5, 30, + "[warn] [quic] {client}: this service is reached over HTTP/1.1, so only \ + HTTP/3 can be carried to it — the connection agreed on something else.", + ); + peer.close( + VarInt::from_u32(REFUSED_CODE), + b"this endpoint carries HTTP/3 only", + ); + } else { + // Together, because a connection carrying both must not have one wait for the + // other: each ends when the connection does. + tokio::join!(carrier.carry_streams(), carrier.carry_datagrams()); + } + + if let Some(watchdog) = watchdog { + watchdog.abort(); + } + } +} + +/// What one established connection's streams are carried with. +/// +/// Cloned per stream rather than shared behind a lock: everything in it is a handle +/// already — two connection handles, the chain's watch receiver, two `Arc`s — so a clone +/// is a few reference counts and the streams need nothing from each other. +/// What is behind this connection, and therefore what its exchanges are forwarded to. +/// +/// It was always a QUIC connection: the engine dialled the service before accepting the +/// client, so that the protocol the client is told is the one the service chose. That is +/// still the default and still the only thing a *raw* QUIC stream can be relayed to — a +/// stream of opaque bytes has nowhere else to go. +/// +/// The second arm is for the service that does not speak QUIC at all. An HTTP/3 exchange +/// is already rendered to the chain as HTTP/1.1, so it can be sent to a service that +/// speaks HTTP/1.1 and nothing else, which is what puts firegex in front of an ordinary +/// web service as the thing that terminates QUIC for it. Two things follow and are worth +/// stating where they are decided: +/// +/// * **it is HTTP/3 or nothing.** A raw QUIC stream has no HTTP/1.1 form, so a connection +/// that agrees on anything else is refused rather than carried into a translation +/// nobody could describe; +/// * **the ALPN stops being mirrored**, because there is no longer a service to ask. The +/// invariant everywhere else — never tell the client something the service did not say +/// — changes meaning here rather than being broken: the service does not speak QUIC, +/// firegex does, and `h3` is what firegex is speaking. +#[derive(Clone)] +pub(crate) enum Behind { + /// The service speaks QUIC. Streams are opened on this connection. + Quic(Connection), + /// The service speaks HTTP/1.1, dialled once per exchange. + Http1(crate::h1up::H1Upstream), +} + +impl Behind { + /// The upstream QUIC connection, where there is one. + pub(crate) fn quic(&self) -> Option<&Connection> { + match self { + Behind::Quic(connection) => Some(connection), + Behind::Http1(_) => None, + } + } +} + +#[derive(Clone)] +pub(crate) struct Carrier { + pub(crate) peer: Connection, + pub(crate) service: Behind, + pub(crate) client: SocketAddr, + pub(crate) upstream: SocketAddr, + pub(crate) capture: Option>, + pub(crate) chain: ChainHandle, + pub(crate) stats: Arc, + pub(crate) spoken: Arc, +} + +impl Carrier { + /// Relay whatever streams either end opens, as byte streams and nothing more. + async fn carry_streams(&self) { + let peer = self.peer.clone(); + // Raw streams are opaque bytes, so the only thing they can be relayed to is + // another QUIC connection. A service that speaks HTTP/1.1 is refused before a + // client is ever accepted — there is no HTTP/1.1 form of "some bytes on a + // stream" — so this is unreachable rather than a case to handle. + let Some(service) = self.service.quic().cloned() else { + return; + }; + let client = self.client; + loop { + tokio::select! { + // A stream the client opened. Both halves exist already, so the only + // thing that can block is opening its twin upstream — which is done in + // the task, or one stream waiting on the service's flow control would + // stop this loop accepting anybody else's. + opened = peer.accept_bi() => match opened { + Ok((send, recv)) => self.spawn_bi(recv, send, Origin::Client), + Err(e) => { service.close(VarInt::from_u32(0), b"the client is gone"); break log_end(client, "client", e); } + }, + opened = peer.accept_uni() => match opened { + Ok(recv) => self.spawn_uni(recv, Origin::Client), + Err(e) => { service.close(VarInt::from_u32(0), b"the client is gone"); break log_end(client, "client", e); } + }, + // And one the service opened. Rare outside HTTP/3's control streams, but + // a protocol nobody here has heard of may do it at any time. + opened = service.accept_bi() => match opened { + Ok((send, recv)) => self.spawn_bi(recv, send, Origin::Service), + Err(e) => { peer.close(VarInt::from_u32(0), b"the service is gone"); break log_end(client, "service", e); } + }, + opened = service.accept_uni() => match opened { + Ok(recv) => self.spawn_uni(recv, Origin::Service), + Err(e) => { peer.close(VarInt::from_u32(0), b"the service is gone"); break log_end(client, "service", e); } + }, + } + } + } +} + +/// Close both ends because a rule refused something on one of them. +fn refuse(peer: &Connection, service: &Connection) { + peer.close(VarInt::from_u32(REFUSED_CODE), b"blocked by firegex"); + service.close(VarInt::from_u32(REFUSED_CODE), b"blocked by firegex"); +} + +fn log_end(client: SocketAddr, side: &str, e: quinn::ConnectionError) { + match e { + quinn::ConnectionError::ApplicationClosed(_) + | quinn::ConnectionError::LocallyClosed + | quinn::ConnectionError::ConnectionClosed(_) => {} + other => crate::diag_throttled!("[info] [quic] connections ended by one side", 20, 10, "[info] [quic] {client}: the {side} ended the connection: {other}"), + } +} + +/// Which end opened a stream. Not the same question as which direction its bytes go: +/// both ends can write to a bidirectional stream whoever opened it. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Origin { + Client, + Service, +} + +impl Carrier { + /// End this connection because a rule said so. + /// + /// Both ends, with the same reason, spelled once: a client reading why it was cut off + /// and a service reading why its peer went away are the same event, and the two + /// halves drifting apart is how a close comes to mean two things. + pub(crate) fn refuse(&self) { + match self.service.quic() { + Some(service) => refuse(&self.peer, service), + // An HTTP/1.1 service has no connection of its own to end: the socket under + // this exchange is dropped with it, which is what closing one means there. + None => self + .peer + .close(VarInt::from_u32(REFUSED_CODE), b"blocked by firegex"), + } + } + + /// Tell the chain about a stream, in the words the rest of the engine uses. + /// + /// **One stream is one connection to a filter.** That is the honest mapping rather + /// than a convenience: a filter's state — for Python, a whole set of module globals — + /// follows a stream of bytes from its start to its end, which is what a QUIC stream + /// is and what a QUIC *connection* is not. On HTTP/3 it also lands where an operator + /// would expect, one request to a stream. The addresses are the connection's, because + /// they are the same for every stream on it. + pub(crate) fn open(&self) -> ConnectionId { + // Neither of the two answers that existed before this: a QUIC stream is a stream, + // so everything the library builds on one applies, and it rides on UDP, which is + // what the rules matched to get here. + self.open_as(L4::Quic) + } + + /// Tell the chain about this connection's datagrams, which are one flow of their own. + /// + /// One for the connection and not one per datagram: a datagram carries no identity a + /// flow could be keyed on beyond the connection it arrived in, which is the same + /// answer the plain UDP relay reaches from the other direction, where the flow is the + /// client address. Both directions share it, as they do there. + fn open_datagrams(&self) -> ConnectionId { + self.open_as(L4::QuicDatagram) + } + + fn open_as(&self, l4: L4) -> ConnectionId { + let connection = next_connection_id(); + self.chain.current().connection_opened( + connection, + &ConnectionMeta { + client: self.client, + server: self.upstream, + l4, + }, + ); + connection + } + + /// This connection's datagrams, in both directions. + /// + /// A refused datagram is **dropped, and the connection carries on**. That is a block + /// meaning something different from what it means one function up, and the difference + /// belongs to the traffic rather than to a preference: a stream is a conversation that + /// can be ended, and a datagram is already complete by the time it is judged — ending + /// the connection over one would cost every stream riding on it. The plain UDP relay + /// answers the same way. + /// + /// What a datagram does *not* lose is the filter's memory of the ones before it: the + /// flow keeps its sessions, so a pattern split across two datagrams a millisecond + /// apart is still found, which is the relay's rule again. What it loses is everything + /// built on a stream, and that is [`L4::QuicDatagram`]'s doing rather than this + /// function's. + async fn carry_datagrams(&self) { + // Nothing is opened for a connection that cannot carry them. A flow costs a + // filter its state — for Python, a whole set of module globals — and the flow is + // built on the first datagram rather than here, so a connection that negotiates + // the extension and never uses it costs nothing at all. + // Datagrams go from one QUIC connection to another and nowhere else: there is no + // HTTP/1.1 spelling of one, so a service reached that way carries none. + let Some(service) = self.service.quic().cloned() else { + return; + }; + if self.peer.max_datagram_size().is_none() && service.max_datagram_size().is_none() { + return; + } + let flow = tokio::sync::OnceCell::new(); + tokio::join!( + self.datagrams(&service, Direction::ClientToServer, &flow), + self.datagrams(&service, Direction::ServerToClient, &flow), + ); + // Released once, by whichever direction opened it: the two share one flow, and a + // filter told twice that a connection ended would be asked to forget it twice. + if let Some(&connection) = flow.get() { + self.chain.current().connection_closed(connection); + } + } + + /// One direction of the datagram flow. + async fn datagrams( + &self, + service: &Connection, + direction: Direction, + flow: &tokio::sync::OnceCell, + ) { + let (from, to) = match direction { + Direction::ClientToServer => (&self.peer, service), + Direction::ServerToClient => (service, &self.peer), + }; + let mut sessions: Option = None; + let mut complained = false; + loop { + // The error is the connection ending, which the stream carrier is already + // watching and reporting; saying it twice would put two lines in the log for + // one event. + let Ok(datagram) = from.read_datagram().await else { + return; + }; + // A datagram is something said, exactly as a byte on a stream is. + self.spoken.store(true, Ordering::Relaxed); + let connection = *flow.get_or_init(|| async { self.open_datagrams() }).await; + let sessions = sessions.get_or_insert_with(|| ChainSessions::new(connection)); + // Re-read every time, so a ruleset pushed mid-flow takes effect without + // anybody losing a connection. + if let Verdict::Reject(_) = self + .chain + .current() + .run(direction, &datagram, sessions) + .await + { + continue; + } + if let Err(e) = to.send_datagram(datagram) { + // Once per direction per connection: a datagram too large for the far end + // is a property of the two peers, so the second one fails for the same + // reason as the first and the thousandth would too. + if !complained { + complained = true; + crate::diag_throttled!("[info] [quic] connections whose datagrams could not be carried", 20, 10, "[info] [quic] {}: cannot carry a datagram: {e}", self.client); + } + } + } + } + + /// A reconstruction of one stream, for whoever is watching the capture interface. + /// + /// Per stream and not per connection, because a stream is the thing that has a + /// beginning, an order and an end — everything a TCP conversation is made of. What it + /// does not have is a port of its own, so [`Tap::open_stream`] invents one; the + /// documentation says so wherever this interface is offered. + pub(crate) fn tap(&self) -> Option> { + Tap::open_stream(self.capture.clone(), self.client, self.upstream) + } + + /// A bidirectional stream: both directions, inspected independently. + fn spawn_bi(&self, near_recv: quinn::RecvStream, near_send: quinn::SendStream, origin: Origin) { + let connection = self.open(); + let tap = self.tap(); + let peer = self.peer.clone(); + // As in `carry_streams`: opaque bytes have nowhere but another QUIC connection + // to go, so this arm cannot be reached with an HTTP/1.1 service behind. + let Some(service) = self.service.quic().cloned() else { + return; + }; + let chain = self.chain.clone(); + let stats = Arc::clone(&self.stats); + let spoken = Arc::clone(&self.spoken); + tokio::spawn(async move { + // Opened here rather than in the accept loop: the far end's flow control can + // make this wait, and waiting must cost this stream and no other. + let far = match origin { + Origin::Client => service.open_bi().await, + Origin::Service => peer.open_bi().await, + }; + let (far_send, far_recv) = match far { + Ok(pair) => pair, + Err(e) => { + crate::diag_throttled!("[info] [quic] streams that could not be carried", 20, 10, "[info] [quic] cannot carry a stream through: {e}"); + chain.current().connection_closed(connection); + return; + } + }; + + // Which end the bytes came *from* is what names the direction, whoever opened + // the stream: the service answering on a stream it opened itself is still the + // service talking. + let (client_rd, client_wr, service_rd, service_wr) = match origin { + Origin::Client => (near_recv, near_send, far_recv, far_send), + Origin::Service => (far_recv, far_send, near_recv, near_send), + }; + + let (up, down) = spawn_pumps( + client_rd, client_wr, service_rd, service_wr, &chain, &stats, connection, + tap.clone(), spoken, + // The deadline is the connection's, and its watchdog is already running. + None, + ); + if join_pumps(up, down).await { + // A refusal ends the connection, not the stream. Killing one stream of + // many would leave the client free to ask again on the next one, which is + // not what a block means anywhere else in firegex — and on HTTP/3 it is + // the same thing keep-alive does on HTTP/1.1, where a refused request + // takes the connection with it. + peer.close(VarInt::from_u32(REFUSED_CODE), b"blocked by firegex"); + service.close(VarInt::from_u32(REFUSED_CODE), b"blocked by firegex"); + } + if let Some(tap) = tap { + tap.closed(); + } + chain.current().connection_closed(connection); + }); + } + + /// A unidirectional stream: one pump, and nothing coming back. + fn spawn_uni(&self, near_recv: quinn::RecvStream, origin: Origin) { + let connection = self.open(); + let tap = self.tap(); + let peer = self.peer.clone(); + // As in `carry_streams`: opaque bytes have nowhere but another QUIC connection + // to go, so this arm cannot be reached with an HTTP/1.1 service behind. + let Some(service) = self.service.quic().cloned() else { + return; + }; + let chain = self.chain.clone(); + let stats = Arc::clone(&self.stats); + let spoken = Arc::clone(&self.spoken); + tokio::spawn(async move { + let far = match origin { + Origin::Client => service.open_uni().await, + Origin::Service => peer.open_uni().await, + }; + let far_send = match far { + Ok(send) => send, + Err(e) => { + crate::diag_throttled!("[info] [quic] streams that could not be carried", 20, 10, "[info] [quic] cannot carry a stream through: {e}"); + chain.current().connection_closed(connection); + return; + } + }; + let direction = match origin { + Origin::Client => Direction::ClientToServer, + Origin::Service => Direction::ServerToClient, + }; + // Its own stop channel, which nothing else holds: there is no other direction + // of this stream to wind down, and the refusal below closes the connection + // that every other stream is riding on anyway. + let (stop_tx, stop_rx) = tokio::sync::watch::channel(false); + let outcome = pump( + near_recv, far_send, direction, chain.clone(), stats, connection, tap.clone(), + spoken, stop_tx, stop_rx, + ) + .await; + if matches!(outcome, Ok(PumpOutcome::Rejected)) { + refuse(&peer, &service); + } + if let Some(tap) = tap { + tap.closed(); + } + chain.current().connection_closed(connection); + }); + } +} + +/// What the service agreed to speak. +fn negotiated_protocol(connection: &Connection) -> Option> { + let data = connection.handshake_data()?; + let data = data.downcast::().ok()?; + data.protocol +} + +/// Binds and tracks the QUIC endpoints of one service, one per protected address. +/// +/// The same shape as [`crate::udp::UdpManager`] and answering the same question, because +/// the backend asks it in the same words: a QUIC service is UDP as far as the rules are +/// concerned, so what it wants back is a port per address to point them at. +#[derive(Clone)] +pub struct QuicManager { + chain: ChainHandle, + cfg: QuicConfig, + stats: 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 { + pub fn new(chain: ChainHandle, cfg: QuicConfig, stats: Arc) -> Self { + Self { + chain, + cfg, + stats, + relays: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + } + } + + 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, onward)) { + return Ok(port); + } + let bind: SocketAddr = if upstream.is_ipv6() { + "[::]:0".parse().unwrap() + } else { + "0.0.0.0:0".parse().unwrap() + }; + // The relay's own copy, because what the service behind it speaks is this + // address's answer and not the process's. + let mut cfg = self.cfg.clone(); + cfg.upstream = onward; + let relay = QuicRelay::bind( + bind, + upstream, + self.chain.clone(), + cfg, + Arc::clone(&self.stats), + )?; + let port = relay.local_addr()?.port(); + map.insert((upstream, onward), port); + tokio::spawn(Arc::new(relay).serve()); + Ok(port) + } +} diff --git a/backend/proxysrc/src/quic_hello.rs b/backend/proxysrc/src/quic_hello.rs new file mode 100644 index 0000000..daaab46 --- /dev/null +++ b/backend/proxysrc/src/quic_hello.rs @@ -0,0 +1,678 @@ +//! What a QUIC client offers, read off its first packets before anybody answers them. +//! +//! The TLS path mirrors ALPN by holding the ClientHello open, asking the service with the +//! client's own list and telling the client what came back. QUIC was thought to leave +//! nothing to hold: the ClientHello travels inside an Initial packet, encrypted. But the +//! Initial keys are derived from the Destination Connection ID the client put in the +//! clear (RFC 9001 §5.2) — deliberately, so a middlebox can read exactly this — and so +//! the list can be read before the handshake is answered. That is what this does. +//! +//! What it replaced was a list of candidates the operator had to write down per service, +//! `h3` unless told otherwise: a QUIC service speaking anything else was unreachable +//! through firegex until somebody found the setting. With the client's own list the +//! service chooses from what the client actually offered, exactly as over TLS, and there +//! is nothing to configure. +//! +//! It reads; it never answers. The socket the endpoint owns is wrapped +//! ([`SniffingSocket`]) so every datagram quinn is about to process passes by here first, +//! and only long-header Initial packets are looked at — one comparison for everything +//! else. A ClientHello too large for one packet (post-quantum key shares make that +//! ordinary) is reassembled from its CRYPTO frames across datagrams, in whatever order +//! they arrive. + +use std::collections::{HashMap, VecDeque}; +use std::fmt; +use std::io::{self, IoSliceMut}; +use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; +use std::time::{Duration, Instant}; + +use quinn::udp::{RecvMeta, Transmit}; +use quinn::{AsyncUdpSocket, UdpPoller}; +use rustls::quic::{Suite, Version}; +use rustls::Side; + +/// How many clients' hellos may be in progress at once. Past it the one that has been +/// waiting longest makes room — a hello is claimed within milliseconds of arriving, so the +/// oldest is the one least likely to still be wanted. Forged Initials cost the sender +/// nothing (the keys are public, so a valid one can be made from any address), and with +/// `MAX_HELLO` this bounds what a flood can make this process hold to 16 MiB. +/// +/// It used to refuse newcomers instead, so a thousand forged Initials every `KEPT_FOR` +/// were enough to stop every real client's hello from being read. +const MAX_PENDING: usize = 1024; + +/// How long an unfinished or unclaimed hello is kept. +const KEPT_FOR: Duration = Duration::from_secs(10); + +/// How often the table is swept for hellos older than `KEPT_FOR`. Not on every packet: a +/// sweep walks the whole table, and every packet of a flood would pay for it. +const SWEEP_EVERY: Duration = Duration::from_secs(1); + +/// Most CRYPTO bytes kept for one hello. A ClientHello is one or two kilobytes, a few with +/// post-quantum key shares and a resumption ticket; this is a cap on what a peer can make +/// this process hold, not a size anybody meets. +const MAX_HELLO: usize = 16 * 1024; + +/// Most CRYPTO fragments kept for one hello, whatever their size. Real clients split a +/// hello into a handful; the cap is what stops a peer sending pieces nobody counted — +/// empty ones, which `MAX_HELLO` did not see at all, grew one hello without end and had +/// every packet sort all of them again, inside the endpoint's receive path. +const MAX_FRAGMENTS: usize = 64; + +/// One client's hello as it arrives. +struct Pending { + /// The connection ID its Initials were protected with. A new one means a new attempt — + /// a Retry answered, or another connection from the same port. + dcid: Vec, + fragments: Vec<(u64, Vec)>, + held: usize, + alpn: Option>>, + since: Instant, +} + +/// The hellos in progress, and the order they began in. +struct Table { + by_client: HashMap, + /// When each entry began, oldest first. An entry replaced or claimed leaves its line + /// here behind it; eviction skips a line whose time no longer matches the entry's. + order: VecDeque<(SocketAddr, Instant)>, + swept: Instant, +} + +impl Table { + fn new() -> Self { + Self { by_client: HashMap::new(), order: VecDeque::new(), swept: Instant::now() } + } + + fn sweep(&mut self, now: Instant) { + if now.duration_since(self.swept) < SWEEP_EVERY && self.by_client.len() < MAX_PENDING { + return; + } + self.swept = now; + self.by_client.retain(|_, p| now.duration_since(p.since) < KEPT_FOR); + let by_client = &self.by_client; + self.order.retain(|(from, since)| by_client.get(from).is_some_and(|p| p.since == *since)); + } + + /// Make room for one more by letting go of the entry that began longest ago. + fn evict_oldest(&mut self) { + while let Some((from, since)) = self.order.pop_front() { + if self.by_client.get(&from).is_some_and(|p| p.since == since) { + self.by_client.remove(&from); + return; + } + } + } + + fn start(&mut self, from: SocketAddr, dcid: Vec, now: Instant) -> &mut Pending { + self.order.push_back((from, now)); + self.by_client.insert(from, Pending { + dcid, + fragments: Vec::new(), + held: 0, + alpn: None, + since: now, + }); + self.by_client.get_mut(&from).expect("just inserted") + } +} + +/// The hellos being read, by the address they came from. +pub struct Hellos { + pending: Mutex, + arrived: tokio::sync::Notify, + suite: Suite, +} + +impl fmt::Debug for Hellos { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Hellos").finish_non_exhaustive() + } +} + +impl Default for Hellos { + fn default() -> Self { + Self::new() + } +} + +impl Hellos { + pub fn new() -> Self { + // Every version's Initial packets are protected with this suite, whatever the + // handshake goes on to choose: RFC 9001 §5.2. + let suite = rustls::crypto::ring::cipher_suite::TLS13_AES_128_GCM_SHA256 + .tls13() + .and_then(|s| s.quic_suite()) + .expect("TLS13_AES_128_GCM_SHA256 carries QUIC"); + Self { + pending: Mutex::new(Table::new()), + arrived: tokio::sync::Notify::new(), + suite, + } + } + + /// Look at one datagram received from `from`. Cheap for anything but an Initial. + pub fn observe(&self, from: SocketAddr, datagram: &[u8]) { + let mut rest = datagram; + // An Initial can have other packets coalesced behind it in one datagram; only + // the Initials matter, and they come first. + while let Some(packet) = decrypt_initial(&self.suite, rest) { + rest = &rest[packet.length..]; + self.absorb(from, packet); + } + } + + fn absorb(&self, from: SocketAddr, packet: Initial) { + let Ok(mut table) = self.pending.lock() else { return }; + let now = Instant::now(); + table.sweep(now); + // A new attempt — a Retry answered, another connection from the same port — or one + // kept past its time starts again; an address seen for the first time needs room. + let fresh = match table.by_client.get(&from) { + Some(entry) => entry.dcid != packet.dcid || now.duration_since(entry.since) >= KEPT_FOR, + None => { + if table.by_client.len() >= MAX_PENDING { + table.evict_oldest(); + } + true + } + }; + let entry = if fresh { + table.start(from, packet.dcid.clone(), now) + } else { + table.by_client.get_mut(&from).expect("looked up above") + }; + if entry.alpn.is_some() { + return; + } + let mut grew = false; + for (offset, data) in packet.crypto { + // An empty piece says nothing, and one past where any hello could reach is not + // part of one; neither is worth keeping. + if data.is_empty() { + continue; + } + let reaches = offset.saturating_add(data.len() as u64); + if reaches > MAX_HELLO as u64 + || entry.fragments.len() >= MAX_FRAGMENTS + || entry.held + data.len() > MAX_HELLO + { + break; + } + entry.held += data.len(); + entry.fragments.push((offset, data)); + grew = true; + } + if !grew { + return; + } + if let Some(alpn) = client_hello_alpn(&entry.fragments) { + entry.alpn = Some(alpn); + entry.fragments.clear(); + self.arrived.notify_waiters(); + } + } + + fn take(&self, from: SocketAddr) -> Option>> { + let mut table = self.pending.lock().ok()?; + if table.by_client.get(&from)?.alpn.is_some() { + return table.by_client.remove(&from).and_then(|p| p.alpn); + } + None + } + + /// The protocols the client at `from` offered, waiting up to `wait` for the rest of a + /// hello split across datagrams. `None` when it could not be read. + pub async fn offered(&self, from: SocketAddr, wait: Duration) -> Option>> { + let deadline = tokio::time::Instant::now() + wait; + loop { + // Registered before looking, so a hello completed in between is not missed. + let notified = self.arrived.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if let Some(alpn) = self.take(from) { + return Some(alpn); + } + if tokio::time::timeout_at(deadline, notified).await.is_err() { + return self.take(from); + } + } + } +} + +/// The socket a relay's endpoint receives through, reading hellos on the way past. +#[derive(Debug)] +pub struct SniffingSocket { + inner: Arc, + hellos: Arc, +} + +impl SniffingSocket { + pub fn new(inner: Arc, hellos: Arc) -> Self { + Self { inner, hellos } + } +} + +impl AsyncUdpSocket for SniffingSocket { + fn create_io_poller(self: Arc) -> Pin> { + Arc::clone(&self.inner).create_io_poller() + } + + fn try_send(&self, transmit: &Transmit) -> io::Result<()> { + self.inner.try_send(transmit) + } + + fn poll_recv( + &self, + cx: &mut Context, + bufs: &mut [IoSliceMut<'_>], + meta: &mut [RecvMeta], + ) -> Poll> { + let polled = self.inner.poll_recv(cx, bufs, meta); + if let Poll::Ready(Ok(count)) = &polled { + for (buf, meta) in bufs.iter().zip(meta.iter()).take(*count) { + let received = &buf[..meta.len.min(buf.len())]; + // Several datagrams of `stride` bytes each where the kernel coalesced them. + let stride = if meta.stride == 0 { received.len() } else { meta.stride }; + for datagram in received.chunks(stride.max(1)) { + if is_long_header(datagram) { + self.hellos.observe(meta.addr, datagram); + } + } + } + } + polled + } + + fn local_addr(&self) -> io::Result { + self.inner.local_addr() + } + + fn max_transmit_segments(&self) -> usize { + self.inner.max_transmit_segments() + } + + fn max_receive_segments(&self) -> usize { + self.inner.max_receive_segments() + } + + fn may_fragment(&self) -> bool { + self.inner.may_fragment() + } +} + +fn is_long_header(datagram: &[u8]) -> bool { + datagram.first().is_some_and(|b| b & 0x80 != 0) +} + +/// One Initial packet, decrypted: what it was protected with, and its CRYPTO frames. +struct Initial { + /// How many bytes of the datagram it took, so a packet coalesced behind it is found. + length: usize, + dcid: Vec, + crypto: Vec<(u64, Vec)>, +} + +fn varint(buf: &[u8], pos: &mut usize) -> Option { + let first = *buf.get(*pos)?; + let len = 1usize << (first >> 6); + let bytes = buf.get(*pos..*pos + len)?; + let mut value = u64::from(first & 0x3f); + for byte in &bytes[1..] { + value = (value << 8) | u64::from(*byte); + } + *pos += len; + Some(value) +} + +/// Decrypt the Initial packet at the start of `packet`, if that is what it is. +fn decrypt_initial(suite: &Suite, packet: &[u8]) -> Option { + let first = *packet.first()?; + if first & 0x80 == 0 { + return None; + } + let version = u32::from_be_bytes(packet.get(1..5)?.try_into().ok()?); + let (version, initial_type) = match version { + 0x0000_0001 => (Version::V1, 0), + 0x6b33_43cf => (Version::V2, 1), + 0xff00_001d..=0xff00_0020 => (Version::V1Draft, 0), + _ => return None, + }; + if (first >> 4) & 0x03 != initial_type { + return None; + } + let mut pos = 5; + let dcid_len = usize::from(*packet.get(pos)?); + let dcid = packet.get(pos + 1..pos + 1 + dcid_len)?.to_vec(); + pos += 1 + dcid_len; + let scid_len = usize::from(*packet.get(pos)?); + pos += 1 + scid_len; + let token_len = usize::try_from(varint(packet, &mut pos)?).ok()?; + pos = pos.checked_add(token_len)?; + let length = usize::try_from(varint(packet, &mut pos)?).ok()?; + let pn_offset = pos; + let end = pn_offset.checked_add(length)?; + if end > packet.len() { + return None; + } + + let keys = suite.keys(&dcid, Side::Server, version); + let sample_len = keys.remote.header.sample_len(); + let sample = packet.get(pn_offset + 4..pn_offset + 4 + sample_len)?.to_vec(); + let mut buf = packet[..end].to_vec(); + let (head, tail) = buf.split_at_mut(1); + let pn_field = tail.get_mut(pn_offset - 1..pn_offset + 3)?; + keys.remote + .header + .decrypt_in_place(&sample, &mut head[0], pn_field) + .ok()?; + let pn_len = usize::from(buf[0] & 0x03) + 1; + let mut number = 0u64; + for byte in &buf[pn_offset..pn_offset + pn_len] { + number = (number << 8) | u64::from(*byte); + } + let (header, payload) = buf.split_at_mut(pn_offset + pn_len); + let plain = keys + .remote + .packet + .decrypt_in_place(number, header, payload) + .ok()?; + + Some(Initial { + length: end, + dcid, + crypto: crypto_frames(plain)?, + }) +} + +/// The CRYPTO frames of a decrypted Initial payload. `None` for a frame an Initial cannot +/// carry, which means this was not what it looked like. +fn crypto_frames(payload: &[u8]) -> Option)>> { + let mut frames = Vec::new(); + let mut pos = 0; + while pos < payload.len() { + match varint(payload, &mut pos)? { + 0x00 | 0x01 => {} // PADDING, PING + kind @ (0x02 | 0x03) => { + // ACK: largest, delay, range count, first range, the ranges, and ECN. + varint(payload, &mut pos)?; + varint(payload, &mut pos)?; + let ranges = varint(payload, &mut pos)?; + varint(payload, &mut pos)?; + for _ in 0..ranges { + varint(payload, &mut pos)?; + varint(payload, &mut pos)?; + } + if kind == 0x03 { + for _ in 0..3 { + varint(payload, &mut pos)?; + } + } + } + 0x06 => { + let offset = varint(payload, &mut pos)?; + let len = usize::try_from(varint(payload, &mut pos)?).ok()?; + let data = payload.get(pos..pos.checked_add(len)?)?; + frames.push((offset, data.to_vec())); + pos += len; + } + // CONNECTION_CLOSE ends what there is to read. + 0x1c => break, + _ => return None, + } + } + Some(frames) +} + +/// The ALPN list of a ClientHello, once enough of it has arrived to have one. +/// +/// `Some(vec![])` for a complete hello that offers none — which QUIC does not allow, and +/// which the handshake is left to refuse. +fn client_hello_alpn(fragments: &[(u64, Vec)]) -> Option>> { + // What has arrived contiguously from the start, in whatever order it came. + let mut sorted: Vec<&(u64, Vec)> = fragments.iter().collect(); + sorted.sort_by_key(|(offset, _)| *offset); + let mut hello: Vec = Vec::new(); + for (offset, data) in sorted { + let offset = usize::try_from(*offset).ok()?; + if offset > hello.len() { + break; + } + let overlap = hello.len() - offset; + if overlap < data.len() { + hello.extend_from_slice(&data[overlap..]); + } + } + if hello.len() < 4 || hello[0] != 0x01 { + return None; + } + let len = (usize::from(hello[1]) << 16) | (usize::from(hello[2]) << 8) | usize::from(hello[3]); + let body = hello.get(4..4 + len)?; + Some(alpn_of(body).unwrap_or_default()) +} + +fn alpn_of(body: &[u8]) -> Option>> { + let u16_at = |pos: usize| -> Option { + Some(usize::from(u16::from_be_bytes(body.get(pos..pos + 2)?.try_into().ok()?))) + }; + // Legacy version and random, then the session id, the suites and the compressions. + let mut pos = 2 + 32; + pos += 1 + usize::from(*body.get(pos)?); + pos += 2 + u16_at(pos)?; + pos += 1 + usize::from(*body.get(pos)?); + let end = pos.checked_add(2 + u16_at(pos)?)?; + pos += 2; + while pos + 4 <= end { + let kind = u16_at(pos)?; + let len = u16_at(pos + 2)?; + let data = body.get(pos + 4..pos + 4 + len)?; + pos += 4 + len; + if kind != 16 { + continue; + } + let mut names = Vec::new(); + let mut at = 2; + while at < data.len() { + let name_len = usize::from(data[at]); + names.push(data.get(at + 1..at + 1 + name_len)?.to_vec()); + at += 1 + name_len; + } + return Some(names); + } + Some(Vec::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn push_varint(out: &mut Vec, value: u64) { + if value < 64 { + out.push(value as u8); + } else if value < 16384 { + out.extend_from_slice(&(0x4000 | value as u16).to_be_bytes()); + } else { + out.extend_from_slice(&(0x8000_0000 | value as u32).to_be_bytes()); + } + } + + /// A ClientHello offering `alpn`, and nothing else a parser would need. + fn hello_with(alpn: &[&[u8]]) -> Vec { + let mut names = Vec::new(); + for name in alpn { + names.push(name.len() as u8); + names.extend_from_slice(name); + } + let mut ext = vec![0x00, 0x10]; + ext.extend_from_slice(&((names.len() + 2) as u16).to_be_bytes()); + ext.extend_from_slice(&(names.len() as u16).to_be_bytes()); + ext.extend_from_slice(&names); + let mut body = vec![0x03, 0x03]; + body.extend_from_slice(&[0u8; 32]); + body.push(0); // no session id + body.extend_from_slice(&[0x00, 0x02, 0x13, 0x01]); // one suite + body.extend_from_slice(&[0x01, 0x00]); // no compression + body.extend_from_slice(&(ext.len() as u16).to_be_bytes()); + body.extend_from_slice(&ext); + let mut hello = vec![0x01]; + hello.extend_from_slice(&(body.len() as u32).to_be_bytes()[1..]); + hello.extend_from_slice(&body); + hello + } + + /// A client Initial carrying these CRYPTO fragments, protected the way a client + /// protects one — with rustls's own client-side keys, so what is read back is checked + /// against the other half of the library rather than against itself. + fn client_initial(dcid: &[u8], number: u64, crypto: &[(u64, &[u8])]) -> Vec { + let keys = Hellos::new().suite.keys(dcid, Side::Client, Version::V1); + let mut payload = Vec::new(); + for (offset, data) in crypto { + payload.push(0x06); + push_varint(&mut payload, *offset); + push_varint(&mut payload, data.len() as u64); + payload.extend_from_slice(data); + } + payload.push(0x01); // a PING, as Chrome scatters among the fragments + payload.resize(payload.len().max(1100), 0); // PADDING, to the size a client sends + let mut packet = vec![0xc1, 0, 0, 0, 1, dcid.len() as u8]; + packet.extend_from_slice(dcid); + packet.extend_from_slice(&[0, 0]); // no source id, no token + packet.extend_from_slice(&(0x4000 | (2 + payload.len() + 16) as u16).to_be_bytes()); + let pn_offset = packet.len(); + packet.extend_from_slice(&(number as u16).to_be_bytes()); + let tag = keys.local.packet.encrypt_in_place(number, &packet, &mut payload).unwrap(); + packet.extend_from_slice(&payload); + packet.extend_from_slice(tag.as_ref()); + let sample = packet[pn_offset + 4..pn_offset + 20].to_vec(); + let (first, rest) = packet.split_at_mut(1); + keys.local + .header + .encrypt_in_place(&sample, &mut first[0], &mut rest[pn_offset - 1..pn_offset + 1]) + .unwrap(); + packet + } + + fn from() -> SocketAddr { + "192.0.2.1:4433".parse().unwrap() + } + + #[test] + fn a_protected_initial_is_read_for_what_it_offers() { + let hellos = Hellos::new(); + let hello = hello_with(&[b"h3", b"fgex-test"]); + hellos.observe(from(), &client_initial(b"\x83\x94\xc8\xf0\x3e\x51\x57\x08", 0, &[(0, &hello)])); + assert_eq!(hellos.take(from()), Some(vec![b"h3".to_vec(), b"fgex-test".to_vec()])); + } + + /// Post-quantum key shares make a ClientHello bigger than one packet, and clients + /// scatter its pieces: two Initials, the second one first. + #[test] + fn a_hello_across_two_packets_arriving_backwards_is_reassembled() { + let hellos = Hellos::new(); + let hello = hello_with(&[b"doq"]); + let (a, b) = hello.split_at(30); + let dcid = b"\x01\x02\x03\x04\x05\x06\x07\x08"; + hellos.observe(from(), &client_initial(dcid, 1, &[(30, b)])); + assert_eq!(hellos.take(from()), None, "half a hello was read as a whole one"); + hellos.observe(from(), &client_initial(dcid, 0, &[(0, a)])); + assert_eq!(hellos.take(from()), Some(vec![b"doq".to_vec()])); + } + + /// A Retry answered is a new Initial under a new connection ID, and what was gathered + /// under the old one is not mixed into it. + #[test] + fn a_new_connection_id_starts_again() { + let hellos = Hellos::new(); + let old = hello_with(&[b"old"]); + let new = hello_with(&[b"new"]); + hellos.observe(from(), &client_initial(b"\x11\x11\x11\x11\x11\x11\x11\x11", 0, &[(0, &old[..20])])); + hellos.observe(from(), &client_initial(b"\x22\x22\x22\x22\x22\x22\x22\x22", 0, &[(0, &new)])); + assert_eq!(hellos.take(from()), Some(vec![b"new".to_vec()])); + } + + #[test] + fn anything_but_an_initial_is_passed_over() { + let hellos = Hellos::new(); + hellos.observe(from(), &[0x40, 1, 2, 3, 4, 5, 6, 7]); + hellos.observe(from(), &[0xe0, 0, 0, 0, 1, 0, 0]); + let mut tampered = client_initial(b"\x09\x09\x09\x09\x09\x09\x09\x09", 0, &[(0, &hello_with(&[b"h3"]))]); + let last = tampered.len() - 1; + tampered[last] ^= 0xff; + hellos.observe(from(), &tampered); + assert_eq!(hellos.take(from()), None); + } + + fn kept_for(hellos: &Hellos, client: SocketAddr) -> Option { + hellos.pending.lock().unwrap().by_client.get(&client).map(|p| p.fragments.len()) + } + + /// Empty CRYPTO frames cost nothing to send and were counted by nothing: an Initial + /// full of them grew one client's hello by hundreds of pieces per packet, without end, + /// and every packet sorted all of them again inside the endpoint's receive path. + #[test] + fn empty_pieces_are_not_kept() { + let hellos = Hellos::new(); + let empties: Vec<(u64, &[u8])> = (0..300u64).map(|i| (i, &b""[..])).collect(); + let dcid = b"\x31\x32\x33\x34\x35\x36\x37\x38"; + for number in 0..50 { + hellos.observe(from(), &client_initial(dcid, number, &empties)); + } + assert_eq!(kept_for(&hellos, from()), Some(0)); + } + + /// Pieces that never join up from the start are held, but only so many of them. + #[test] + fn a_hello_is_kept_in_a_bounded_number_of_pieces() { + let hellos = Hellos::new(); + let dcid = b"\x41\x42\x43\x44\x45\x46\x47\x48"; + for number in 0..200u64 { + hellos.observe(from(), &client_initial(dcid, number, &[(100 + number * 2, b"x")])); + } + let kept = kept_for(&hellos, from()).unwrap(); + assert!(kept <= MAX_FRAGMENTS, "{kept} pieces kept for one hello"); + } + + #[test] + fn a_piece_beyond_any_hello_is_not_kept() { + let hellos = Hellos::new(); + let dcid = b"\x51\x52\x53\x54\x55\x56\x57\x58"; + hellos.observe(from(), &client_initial(dcid, 0, &[(MAX_HELLO as u64 + 1, b"far")])); + assert_eq!(kept_for(&hellos, from()), Some(0)); + } + + /// A table full of hellos nobody finished makes room rather than turning newcomers + /// away: refusing them meant a thousand forged Initials every few seconds stopped + /// every real client's hello from being read. + #[test] + fn a_full_table_makes_room_for_the_next_client() { + let hellos = Hellos::new(); + let unfinished = hello_with(&[b"h3"]); + for n in 0..MAX_PENDING as u32 { + let forged = SocketAddr::from(([10, (n >> 16) as u8, (n >> 8) as u8, n as u8], 443)); + let dcid = n.to_be_bytes().repeat(2); + hellos.observe(forged, &client_initial(&dcid, 0, &[(0, &unfinished[..10])])); + } + assert_eq!(hellos.pending.lock().unwrap().by_client.len(), MAX_PENDING); + let hello = hello_with(&[b"fgex-late"]); + hellos.observe(from(), &client_initial(b"\x61\x62\x63\x64\x65\x66\x67\x68", 0, &[(0, &hello)])); + assert_eq!(hellos.take(from()), Some(vec![b"fgex-late".to_vec()])); + assert!(hellos.pending.lock().unwrap().by_client.len() <= MAX_PENDING); + } + + #[tokio::test] + async fn a_hello_still_arriving_is_waited_for() { + let hellos = Arc::new(Hellos::new()); + let hello = hello_with(&[b"h3"]); + let packet = client_initial(b"\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11", 0, &[(0, &hello)]); + let later = Arc::clone(&hellos); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + later.observe(from(), &packet); + }); + assert_eq!(hellos.offered(from(), Duration::from_secs(2)).await, Some(vec![b"h3".to_vec()])); + assert_eq!(hellos.offered(from(), Duration::from_millis(50)).await, None); + } +} diff --git a/backend/proxysrc/src/relays.rs b/backend/proxysrc/src/relays.rs new file mode 100644 index 0000000..ce4d8ca --- /dev/null +++ b/backend/proxysrc/src/relays.rs @@ -0,0 +1,39 @@ +//! The per-address relays a UDP-family service is made of. +//! +//! Two kinds, one question. `SO_ORIGINAL_DST` is TCP and SCTP only, so neither a +//! datagram relay nor a QUIC endpoint can recover where traffic was headed: both are +//! bound one per protected address with the upstream already known, and both answer the +//! backend with the port it should point that address's rule at. Which of the two a +//! service gets is decided once, by what the service speaks. +//! +//! An enum rather than a trait: there are two, the set is not open, and the control +//! channel only ever asks them the one thing. + +use std::io; +use std::net::SocketAddr; + +use crate::quic::QuicManager; +use crate::udp::UdpManager; + +#[derive(Clone)] +pub enum Relays { + /// Datagrams relayed as they are, each client's flow filtered on its own. + Datagram(UdpManager), + /// QUIC, terminated: the streams inside are what the filters see. + Quic(QuicManager), +} + +impl Relays { + /// Bind a relay for one protected address, or report the one already bound. + pub async fn add_relay( + &self, + upstream: SocketAddr, + onward: crate::proxy::Onward, + ) -> io::Result { + match self { + // A plain datagram relay forwards bytes; there is nothing for it to speak. + Relays::Datagram(manager) => manager.add_relay(upstream).await, + Relays::Quic(manager) => manager.add_relay(upstream, onward).await, + } + } +} diff --git a/backend/proxysrc/src/report.rs b/backend/proxysrc/src/report.rs new file mode 100644 index 0000000..e8559f3 --- /dev/null +++ b/backend/proxysrc/src/report.rs @@ -0,0 +1,256 @@ +//! Every line this engine sends the backend, written by a thread of its own. +//! +//! stdout is a pipe the backend reads, and a pipe fills. Each refused connection used to +//! be a `println!("BLOCKED …")` from the thread that refused it — so when the backend fell +//! behind, which a flood of refused connections makes it do, the pipe filled and those +//! writes blocked: the runtime threads carrying every other connection sat waiting on it. +//! Measured, 5000 refused connections took 74 seconds to get through, and a legitimate +//! client meanwhile waited a median 4.5 seconds and half the time not at all — an attacker +//! slowing a service to a halt by sending it exactly what firegex was there to refuse. +//! +//! So nothing on the datapath writes to stdout any more. Lines go into a bounded queue and +//! a thread of their own writes them out; a report of what happened (`BLOCKED`, `STATS`) +//! is dropped and counted when the queue is full, and a reply the backend is waiting for +//! (`PORT`, `UDP`, `ACK`) waits for room, so it is never lost and never overtakes a line +//! sent before it. + +use std::io::Write; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{sync_channel, SyncSender, TrySendError}; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +/// How many lines may wait for the backend. At a few dozen bytes each this is a megabyte; +/// what it buys is that a burst of refusals is reported whole rather than cut off. +const QUEUE: usize = 32 * 1024; + +/// How often a count of reports that had to be dropped is written out. +const DROPS_SAID_EVERY: Duration = Duration::from_secs(5); + +static SENDER: OnceLock> = OnceLock::new(); +static DROPPED: AtomicU64 = AtomicU64::new(0); + +fn sender() -> &'static SyncSender { + SENDER.get_or_init(|| { + let (tx, rx) = sync_channel::(QUEUE); + std::thread::Builder::new() + .name("fgex-report".to_string()) + .spawn(move || { + let stdout = std::io::stdout(); + let mut said = Instant::now(); + while let Ok(first) = rx.recv() { + let mut out = stdout.lock(); + let _ = writeln!(out, "{first}"); + // Whatever else is already waiting goes in the same write. + while let Ok(line) = rx.try_recv() { + let _ = writeln!(out, "{line}"); + } + let _ = out.flush(); + drop(out); + if said.elapsed() >= DROPS_SAID_EVERY { + let dropped = DROPPED.swap(0, Ordering::Relaxed); + if dropped > 0 { + eprintln!( + "[warn] [report] {dropped} refusals were not reported to the \ + backend: it was not reading fast enough. They were refused all \ + the same; only the counters and the log are short of them." + ); + } + said = Instant::now(); + } + } + }) + .expect("cannot start the thread that talks to the backend"); + tx + }) +} + +/// A reply the backend is waiting for. Waits for room rather than being dropped. +pub fn reply(line: String) { + let _ = sender().send(line); +} + +/// A report of something that happened. Never waits: dropped and counted when the backend +/// is too far behind, so the datapath is never held up by it. +pub fn event(line: String) { + if let Err(TrySendError::Full(_)) = sender().try_send(line) { + DROPPED.fetch_add(1, Ordering::Relaxed); + } +} + +/// A rule refused a connection. +pub fn blocked(id: &str) { + event(format!("BLOCKED {id}")); +} + +// --- what the datapath says about itself ------------------------------------------------ +// +// stderr is a pipe the backend reads too, and it fills the same way stdout did. Worse, it +// is written straight from the threads carrying traffic: a line per connection that ended +// with an error, or per client that spoke the wrong thing at a TLS address, was a write +// every runtime thread queued up on — `eprintln!` takes one lock for the whole process — +// and once the backend fell behind, one that blocked them all. Several of those lines are +// the client's to trigger at will. So the datapath's diagnostics go through a queue and a +// thread of their own, exactly as its reports do, and the ones a client can cause as often +// as it likes are counted rather than written past a few. + +static DIAG: OnceLock> = OnceLock::new(); +static DIAG_DROPPED: AtomicU64 = AtomicU64::new(0); + +fn diag_sender() -> &'static SyncSender { + DIAG.get_or_init(|| { + let (tx, rx) = sync_channel::(QUEUE); + std::thread::Builder::new() + .name("fgex-diag".to_string()) + .spawn(move || { + let stderr = std::io::stderr(); + while let Ok(first) = rx.recv() { + let mut out = stderr.lock(); + let _ = writeln!(out, "{first}"); + while let Ok(line) = rx.try_recv() { + let _ = writeln!(out, "{line}"); + } + let dropped = DIAG_DROPPED.swap(0, Ordering::Relaxed); + if dropped > 0 { + let _ = writeln!( + out, + "[warn] [report] {dropped} diagnostic line(s) were dropped: the \ + backend was not reading them fast enough" + ); + } + let _ = out.flush(); + } + }) + .expect("cannot start the thread that writes the engine's diagnostics"); + tx + }) +} + +/// A line about the datapath's own health. Never waits: dropped and counted when the +/// backend is too far behind, so nothing carrying traffic is held up by it. +pub fn diag(line: String) { + if let Err(TrySendError::Full(_)) = diag_sender().try_send(line) { + DIAG_DROPPED.fetch_add(1, Ordering::Relaxed); + } +} + +/// Milliseconds on a monotonic clock, never zero. +fn now_ms() -> u64 { + static START: OnceLock = OnceLock::new(); + START.get_or_init(Instant::now).elapsed().as_millis() as u64 + 1 +} + +/// At most `per_window` lines per window from one place in the code; the rest counted and +/// said as one line when the next window opens. +/// +/// For the lines a client can make the engine write as often as it likes — a connection +/// ending badly, a cleartext client at a TLS address, a malformed request. The first few +/// of a window are what an operator needs to see what is happening; the count says how +/// much of it there is; the thousands in between would only push everything else out of +/// a log that holds a few hundred lines. +pub struct Throttle { + /// Says what was held back, tag included: `[warn] [proxy] cleartext clients at a TLS + /// address`, so the summary is classified like the lines it stands for. + what: &'static str, + per_window: u64, + window_ms: u64, + opened_ms: AtomicU64, + said: AtomicU64, + held: AtomicU64, +} + +impl Throttle { + pub const fn new(what: &'static str, per_window: u64, window_secs: u64) -> Self { + Self { + what, + per_window, + window_ms: window_secs * 1000, + opened_ms: AtomicU64::new(0), + said: AtomicU64::new(0), + held: AtomicU64::new(0), + } + } + + pub fn say(&self, line: impl FnOnce() -> String) { + self.say_at(now_ms(), line, &mut diag); + } + + fn say_at(&self, now: u64, line: impl FnOnce() -> String, out: &mut dyn FnMut(String)) { + let opened = self.opened_ms.load(Ordering::Relaxed); + if now.saturating_sub(opened) >= self.window_ms + && self + .opened_ms + .compare_exchange(opened, now, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + self.said.store(0, Ordering::Relaxed); + let held = self.held.swap(0, Ordering::Relaxed); + if held > 0 { + out(format!( + "{}: {held} more like this in the last {}s, not written", + self.what, + now.saturating_sub(opened) / 1000, + )); + } + } + if self.said.fetch_add(1, Ordering::Relaxed) < self.per_window { + out(line()); + } else { + self.held.fetch_add(1, Ordering::Relaxed); + } + } +} + +/// `eprintln!` for the datapath: through the queue, never blocking. See [`diag`]. +#[macro_export] +macro_rules! diag { + ($($arg:tt)*) => { + $crate::report::diag(format!($($arg)*)) + }; +} + +/// [`diag!`], at most `per` times every `secs` seconds from this call site. See [`Throttle`]. +#[macro_export] +macro_rules! diag_throttled { + ($what:expr, $per:expr, $secs:expr, $($arg:tt)*) => {{ + static THROTTLE: $crate::report::Throttle = + $crate::report::Throttle::new($what, $per, $secs); + THROTTLE.say(|| format!($($arg)*)); + }}; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(throttle: &Throttle, at: u64, n: usize) -> Vec { + let mut said = Vec::new(); + for i in 0..n { + throttle.say_at(at, || format!("line {i}"), &mut |line| said.push(line)); + } + said + } + + #[test] + fn a_window_says_its_first_lines_and_holds_the_rest() { + let throttle = Throttle::new("[info] [test] things", 3, 10); + assert_eq!(run(&throttle, 50_000, 100), vec!["line 0", "line 1", "line 2"]); + } + + #[test] + fn what_was_held_is_counted_when_the_next_window_opens() { + let throttle = Throttle::new("[warn] [test] things", 2, 10); + run(&throttle, 50_000, 7); + let said = run(&throttle, 61_000, 1); + assert_eq!(said.len(), 2, "{said:?}"); + assert!(said[0].starts_with("[warn] [test] things: 5 more"), "{said:?}"); + assert_eq!(said[1], "line 0"); + } + + #[test] + fn a_quiet_window_says_nothing_about_holding_anything_back() { + let throttle = Throttle::new("[info] [test] things", 2, 10); + run(&throttle, 50_000, 1); + assert_eq!(run(&throttle, 70_000, 1), vec!["line 0"]); + } +} diff --git a/backend/proxysrc/src/rules.rs b/backend/proxysrc/src/rules.rs new file mode 100644 index 0000000..dc1d72b --- /dev/null +++ b/backend/proxysrc/src/rules.rs @@ -0,0 +1,344 @@ +//! The filters the backend actually configures, and the wire format that carries them. +//! +//! One JSON line in, the whole ruleset at once — the same shape `cppregex` uses when +//! it takes its filter codes on stdin. Replacing the set wholesale means there is no +//! partial state to reason about, and [`crate::filter::ChainHandle`] swaps it under +//! live connections so a rule change costs nobody their connection. + +use std::sync::Arc; +use std::time::Duration; + +use serde::Deserialize; + +use crate::filter::{Direction, Filter, FilterCtx, FilterSession, Verdict}; +use crate::hyperscan; +use crate::pyworker::PyWorkerRule; + +/// Which half of the traffic a rule looks at. +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum RuleDirection { + #[default] + Both, + /// Client to server: requests. + C2s, + /// Server to client: responses, where a leaked flag would show up. + S2c, +} + +impl RuleDirection { + fn label(self) -> &'static str { + match self { + RuleDirection::Both => "both ways", + RuleDirection::C2s => "c->s", + RuleDirection::S2c => "s->c", + } + } + + fn covers(self, dir: Direction) -> bool { + match self { + RuleDirection::Both => true, + RuleDirection::C2s => dir == Direction::ClientToServer, + RuleDirection::S2c => dir == Direction::ServerToClient, + } + } +} + +// A matching pattern refuses the connection. There is no second action. +// +// could not keep its promise on a stream. Rewriting scanned one chunk at a time — bytes +// already forwarded cannot be taken back, so a match straddling two chunks was never +// rewritable — and the failure was silent: no block, no log, nothing to see. Measured, +// a pattern sent in one write reached the service rewritten and the same pattern split +// across two segments reached it untouched. Blocking scans in stream mode and caught +// both. Whether a rule fires should not depend on how the sender happened to segment. + +/// One rule as the backend sends it. +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum RuleSpec { + /// A pattern, and what to do where it matches. + Regex { + id: String, + /// Which filter this pattern belongs to. + /// + /// It decides what shares a compiled database, and therefore what the operator + /// ordered: patterns of one filter are one thing they placed, and two filters + /// are two. Grouping by "whatever arrived next" would silently merge two filters + /// the operator deliberately separated. + #[serde(default)] + filter: String, + pattern: String, + #[serde(default)] + direction: RuleDirection, + #[serde(default = "default_true")] + case_sensitive: bool, + }, + /// Hand the traffic to the user's Python, in a process of its own. + Python { + id: String, + /// File the worker executes. The backend writes it; the engine only runs it. + code_path: String, + /// Which of the file's `@pyfilter` functions are switched on. + /// + /// Absent means every one the file defines; a list means exactly those, and an + /// empty list means none. The three are deliberately distinct: "not configured" + /// and "the operator switched everything off" want opposite answers, and + /// collapsing them would either run filters that were turned off or stop + /// running filters nobody touched. + #[serde(default)] + enabled: Option>, + /// How to start the worker. Configured rather than hardcoded so a standalone + /// install can point at its own interpreter. + #[serde(default)] + command: Vec, + #[serde(default = "default_python_timeout_ms")] + timeout_ms: u64, + }, +} + +fn default_true() -> bool { + true +} + +/// Generous compared to the chain's own deadline: this one has a process start-up +/// behind it the first time round. +fn default_python_timeout_ms() -> u64 { + 1000 +} + +/// Every blocking pattern that applies to one direction, matched in a single pass. +/// +/// This is hyperscan, the same library `cppregex` matches with on the NFQUEUE side — +/// not a second engine that happens to accept similar syntax. One engine is what lets +/// a pattern mean the same thing whichever transport carries it, and what lets the +/// in-app debugger show the operator what will actually happen. +/// +/// One database per direction rather than one per rule: hyperscan merges the automata, +/// so scanning for fifty patterns costs about what scanning for one costs. That is the +/// property a per-rule loop gave away, and a benchmark against nfregex made the loss +/// impossible to miss. +pub struct HyperscanRule { + db: Arc, + /// Parallel to the database's pattern ids: which rule each index belongs to. + ids: Arc>, + label: String, + direction: RuleDirection, +} + +impl Filter for HyperscanRule { + fn name(&self) -> &str { + &self.label + } + fn may_block(&self) -> bool { + false + } + fn inspect(&self, _ctx: &FilterCtx<'_>) -> Verdict { + // Only reached for a direction this rule does not cover, or when opening the + // scanner failed. Both mean "this rule has nothing to say about this chunk", + // and a rule with nothing to say must never be the reason traffic stops. + Verdict::Accept + } + fn open_session(&self, dir: Direction) -> Option> { + if !self.direction.covers(dir) { + return None; + } + match hyperscan::StreamScanner::open(Arc::clone(&self.db)) { + Ok(scanner) => Some(Box::new(HyperscanSession { + scanner, + ids: Arc::clone(&self.ids), + })), + // Fail open, loudly. The alternative is refusing connections because the + // engine could not allocate scratch space, which is the traffic paying + // for our problem. + Err(e) => { + crate::diag_throttled!("[warn] [filter] connections a scanner could not be opened for", 5, 10, "[warn] [filter] '{}' could not open a scanner ({e}): this connection goes unfiltered", self.label); + None + } + } + } +} + +/// One connection's position in the stream, for one direction. +struct HyperscanSession { + scanner: hyperscan::StreamScanner, + ids: Arc>, +} + +impl FilterSession for HyperscanSession { + fn inspect(&mut self, ctx: &FilterCtx<'_>) -> Verdict { + match self.scanner.scan(ctx.chunk) { + Ok(Some(index)) => { + // The id travels with the verdict rather than being stashed on the + // rule, so two connections blocking at the same instant cannot be + // credited to each other's pattern. + let id = self + .ids + .get(index as usize) + .cloned() + .unwrap_or_else(|| "unknown".to_string()); + // Reset the stream so that subsequent scans on this session (e.g. further UDP datagrams + // or connections) don't fail due to HS_SCAN_TERMINATED. + let _ = self.scanner.reset(); + Verdict::Reject(Some(id)) + } + Ok(None) => Verdict::Accept, + // A scan that errors has told us nothing about the chunk, so the chunk + // goes through. Reset the scanner so future chunks/datagrams can still be scanned. + Err(e) => { + let _ = self.scanner.reset(); + crate::diag_throttled!("[warn] [filter] scans that failed", 5, 10, "[warn] [filter] scan failed ({e}): scanner reset, chunk forwarded"); + Verdict::Accept + } + } + } +} + +/// Parse a whole ruleset from one JSON line. +/// +/// All or nothing: a single bad rule rejects the update and leaves the running chain +/// untouched, because half a ruleset is worse than the previous one. +pub fn parse_ruleset(line: &str) -> Result>, String> { + parse_ruleset_reusing(line, &[]) +} + +/// [`parse_ruleset`], carrying over any filter of `previous` that the new ruleset +/// describes exactly as it was (`Filter::reuse_key`). +/// +/// A ruleset is sent whole on every edit, so without this a pattern changed in one +/// filter restarted the Python worker of another, and the new worker met every open +/// connection in the middle: its module globals were gone, its HTTP parser started on +/// the second half of whatever request was in flight, and the addresses it had been told +/// when each connection opened were never told again. +pub fn parse_ruleset_reusing( + line: &str, + previous: &[Arc], +) -> Result>, String> { + let specs: Vec = + serde_json::from_str(line).map_err(|e| format!("malformed ruleset: {e}"))?; + + // The order the backend sent is the order the operator set, and it is kept. An + // Python — which quietly turned the chain into a set, so a filter placed after + // another could run before it. Nothing visible said so until a test asked whether + // a pattern could catch what the filter before it had written. + // + // Patterns of one filter still share a database, because that is what makes matching + // cost the same at fifty rules as at one. A group is exactly a filter, so grouping + // never crosses a boundary the operator can see. + let mut out: Vec> = Vec::new(); + let mut pending: Vec<(String, RuleDirection, hyperscan::Pattern)> = Vec::new(); + let mut pending_filter: Option = None; + + fn flush( + pending: &mut Vec<(String, RuleDirection, hyperscan::Pattern)>, + out: &mut Vec>, + ) -> Result<(), String> { + if pending.is_empty() { + return Ok(()); + } + for direction in [RuleDirection::C2s, RuleDirection::S2c] { + let selected: Vec<_> = pending + .iter() + .filter(|(_, d, _)| { + d.covers(match direction { + RuleDirection::C2s => Direction::ClientToServer, + _ => Direction::ServerToClient, + }) + }) + .collect(); + if selected.is_empty() { + continue; + } + let patterns: Vec = selected + .iter() + .map(|(_, _, p)| hyperscan::Pattern { + expr: p.expr.clone(), + case_sensitive: p.case_sensitive, + }) + .collect(); + + let ids: Vec = + selected.iter().map(|(id, _, _)| id.clone()).collect(); + let db = hyperscan::Database::compile(&patterns, hyperscan::Mode::Stream) + .map_err(|e| format!("cannot build the rule set: {e}"))?; + out.push(Arc::new(HyperscanRule { + label: format!("{} blocking rule(s) {}", ids.len(), direction.label()), + db: Arc::new(db), + ids: Arc::new(ids), + direction, + })); + } + pending.clear(); + Ok(()) + } + + for spec in specs { + match spec { + RuleSpec::Regex { + id, + filter, + pattern, + direction, + case_sensitive, + } => { + // A different filter is a different thing the operator placed, so it + // ends the group before it and takes its own position. + if pending_filter.as_deref() != Some(filter.as_str()) { + flush(&mut pending, &mut out)?; + pending_filter = Some(filter.clone()); + } + // Compiled alone first, only to be thrown away: hyperscan reports the + // first pattern that failed and stops, so a set that fails to build says + // nothing about which of the others were fine. The operator needs to know + // which rule to fix. + hyperscan::Database::validate(&pattern, case_sensitive, hyperscan::Mode::Stream) + .map_err(|e| format!("invalid regex '{pattern}': {e}"))?; + pending.push(( + id, + direction, + hyperscan::Pattern { expr: pattern, case_sensitive }, + )); + } + RuleSpec::Python { + id, + code_path, + command, + enabled, + timeout_ms, + } => { + // A process is not a pattern, so it ends the run of patterns before it + // and takes its place in the order. + flush(&mut pending, &mut out)?; + pending_filter = None; + let command = if command.is_empty() { + // The engine has no idea where the backend installed the worker, so + // the backend says. The env var is the fallback for a hand-run + // engine; there is no sensible hardcoded path. + let worker = std::env::var("FGEX_PROXY_PYWORKER").map_err(|_| { + "a python rule needs `command`, or FGEX_PROXY_PYWORKER set".to_string() + })?; + vec!["python3".to_string(), worker] + } else { + command + }; + let deadline = Duration::from_millis(timeout_ms); + let key = PyWorkerRule::key_for(&id, &command, &code_path, &enabled, deadline); + let kept = key.as_deref().and_then(|key| { + previous + .iter() + .find(|filter| filter.reuse_key() == Some(key)) + .cloned() + }); + out.push(match kept { + Some(filter) => filter, + None => Arc::new( + PyWorkerRule::new(id, command, code_path, enabled, deadline) + .reusable_as(key), + ), + }) + } + } + } + flush(&mut pending, &mut out)?; + Ok(out) +} diff --git a/backend/proxysrc/src/spec.rs b/backend/proxysrc/src/spec.rs new file mode 100644 index 0000000..5cd2cf3 --- /dev/null +++ b/backend/proxysrc/src/spec.rs @@ -0,0 +1,156 @@ +//! Filters built from a text spec. +//! +//! Provisional: the real engine will take its filters from the backend. This exists +//! so the datapath's guarantees can be driven from outside the process — the Python +//! suite needs to be able to ask for a filter that panics, or one that never returns, +//! without linking against the crate. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use crate::filter::{Direction, Filter, FilterCtx, FilterSession, Verdict}; + +/// Panics on every chunk. Stands in for user code with a bug in it. +pub struct PanicFilter { + name: String, +} + +impl Filter for PanicFilter { + fn name(&self) -> &str { + &self.name + } + fn inspect(&self, _ctx: &FilterCtx<'_>) -> Verdict { + panic!("filter '{}' panicked on purpose", self.name); + } +} + +/// Set only by [`release_hangs`], so a test can let abandoned filter threads exit. +/// The binary never sets it: for the Python suite the hang has to be genuine. +static HANG_RELEASE: AtomicBool = AtomicBool::new(false); + +/// Let every [`HangFilter`] currently stuck in `inspect` return. +/// +/// Needed because a blocking filter thread cannot be cancelled: the engine walks +/// away from it, but the thread lives until its own code decides to stop. Tests +/// would otherwise hang on runtime shutdown, which waits for the blocking pool — +/// the same reason a hung filter costs a pool thread until the process restarts. +pub fn release_hangs() { + HANG_RELEASE.store(true, Ordering::Relaxed); +} + +/// Never returns on its own. Stands in for user code that deadlocks or waits on +/// something that will not happen — the failure mode `catch_unwind` cannot help with. +pub struct HangFilter { + name: String, +} + +impl Filter for HangFilter { + fn name(&self) -> &str { + &self.name + } + fn inspect(&self, _ctx: &FilterCtx<'_>) -> Verdict { + while !HANG_RELEASE.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(20)); + } + Verdict::Accept + } +} + +/// Closes the connection when the needle shows up. +/// +/// Stateful for the same reason the real regex filter is: a needle that arrives in +/// two writes is still a needle, and a test filter that missed it would let the +/// integration suite pass on a datapath that had lost the property. +pub struct BlockFilter { + name: String, + needle: Vec, +} + +impl Filter for BlockFilter { + fn name(&self) -> &str { + &self.name + } + fn may_block(&self) -> bool { + false + } + fn inspect(&self, _ctx: &FilterCtx<'_>) -> Verdict { + Verdict::Accept // never reached: this filter always opens a session + } + fn open_session(&self, _dir: Direction) -> Option> { + Some(Box::new(BlockSession { + name: self.name.clone(), + needle: self.needle.clone(), + tail: Vec::new(), + })) + } +} + +struct BlockSession { + name: String, + needle: Vec, + tail: Vec, +} + +impl FilterSession for BlockSession { + fn inspect(&mut self, ctx: &FilterCtx<'_>) -> Verdict { + let mut window = std::mem::take(&mut self.tail); + window.extend_from_slice(ctx.chunk); + let hit = contains(&window, &self.needle); + // Keep just enough of the tail that a needle straddling the boundary is + // found once, and never twice. + let keep = window.len().min(self.needle.len().saturating_sub(1)); + self.tail = window[window.len() - keep..].to_vec(); + if hit { + Verdict::Reject(Some(self.name.clone())) + } else { + Verdict::Accept + } + } +} + + +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + if needle.is_empty() || needle.len() > haystack.len() { + return false; + } + haystack.windows(needle.len()).any(|w| w == needle) +} + +/// Parse one spec: `panic`, `hang` or `block:`. +/// +/// There was a `replace::` here, and it went with the rewriting verdict rather +/// than being dropped from the scaffolding on its own — there is nothing left for a test +/// filter to ask the chain to do but accept or refuse. +pub fn parse_filter(spec: &str) -> Result, String> { + let spec = spec.trim(); + let (kind, rest) = match spec.split_once(':') { + Some((k, r)) => (k, Some(r)), + None => (spec, None), + }; + + match (kind, rest) { + ("panic", _) => Ok(Arc::new(PanicFilter { + name: "panic".to_string(), + })), + ("hang", _) => Ok(Arc::new(HangFilter { + name: "hang".to_string(), + })), + ("block", Some(needle)) if !needle.is_empty() => Ok(Arc::new(BlockFilter { + name: format!("block({needle})"), + needle: needle.as_bytes().to_vec(), + })), + + _ => Err(format!("unknown filter spec '{spec}'")), + } +} + +/// Parse a comma-separated list. An empty string yields no filters. +pub fn parse_filters(specs: &str) -> Result>, String> { + specs + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(parse_filter) + .collect() +} diff --git a/backend/proxysrc/src/tls.rs b/backend/proxysrc/src/tls.rs new file mode 100644 index 0000000..6266439 --- /dev/null +++ b/backend/proxysrc/src/tls.rs @@ -0,0 +1,182 @@ +//! TLS, terminated by the engine itself. +//! +//! The TLS module does this with nginx today: one listener terminates the public +//! connection and forwards plaintext to a second loopback port, which re-encrypts and +//! forwards to the real service. A filter engine attaches to the leg in between. It +//! works, and it costs an nginx config generated per stream, a pair of ports derived +//! from a hash, and one hop where the traffic is in the clear. +//! +//! Terminating here removes all three: the engine decrypts, the rules see plaintext +//! that never leaves the process, and it re-encrypts on the way out. + +use std::io; +use std::sync::Arc; + +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}; +use rustls::{ClientConfig, DigitallySignedStruct, ServerConfig, SignatureScheme}; +use tokio_rustls::{TlsAcceptor, TlsConnector}; + +/// Certificate and key for the connection the engine terminates. +pub fn server_config(cert_pem: &str, key_pem: &str) -> Result, String> { + let certs: Vec> = rustls_pemfile::certs(&mut cert_pem.as_bytes()) + .collect::>() + .map_err(|e| format!("cannot read the certificate: {e}"))?; + if certs.is_empty() { + return Err("the certificate is empty".to_string()); + } + let key: PrivateKeyDer<'static> = rustls_pemfile::private_key(&mut key_pem.as_bytes()) + .map_err(|e| format!("cannot read the private key: {e}"))? + .ok_or_else(|| "the private key is empty".to_string())?; + + ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key) + .map(Arc::new) + .map_err(|e| format!("certificate and key do not go together: {e}")) +} + +/// Accepts whatever the protected service presents. +/// +/// This mirrors nginx's `proxy_ssl_verify off`, which is what the TLS module already +/// configures, and it is the right call here rather than laziness: the service being +/// protected is on the other side of a loopback or a private link, it is the thing +/// firegex is defending, and in a competition it is invariably self-signed. Verifying +/// it would only ever refuse to protect it. +#[derive(Debug)] +struct AcceptAnyServer(Arc); + +impl ServerCertVerifier for AcceptAnyServer { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &self.0.signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &self.0.signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + self.0.signature_verification_algorithms.supported_schemes() + } +} + +/// Re-encrypting towards the service. +pub fn client_config() -> Result, String> { + let provider = provider(); + let config = ClientConfig::builder_with_provider(Arc::clone(&provider)) + .with_safe_default_protocol_versions() + .map_err(|e| format!("cannot configure TLS: {e}"))? + .dangerous() + .with_custom_certificate_verifier(Arc::new(AcceptAnyServer(provider))) + .with_no_client_auth(); + Ok(Arc::new(config)) +} + +/// The same thing for QUIC, where TLS 1.3 is not a preference. +/// +/// QUIC carries its handshake in 1.3 and only 1.3, and a config still offering 1.2 is +/// refused outright by `QuicClientConfig` rather than quietly negotiating down — so the +/// version list is narrowed here, where the reason can be written next to it, instead of +/// at the call site as a conversion error nobody can act on. +pub fn quic_client_config() -> Result { + let provider = provider(); + Ok( + ClientConfig::builder_with_provider(Arc::clone(&provider)) + .with_protocol_versions(&[&rustls::version::TLS13]) + .map_err(|e| format!("cannot configure QUIC: {e}"))? + .dangerous() + .with_custom_certificate_verifier(Arc::new(AcceptAnyServer(provider))) + .with_no_client_auth(), + ) +} + +/// The process-wide crypto provider, installed once. +fn provider() -> Arc { + if let Some(installed) = rustls::crypto::CryptoProvider::get_default() { + return Arc::clone(installed); + } + let provider = rustls::crypto::ring::default_provider(); + // Losing the race is fine: whoever won installed the same thing. + let _ = provider.clone().install_default(); + rustls::crypto::CryptoProvider::get_default() + .cloned() + .unwrap_or_else(|| Arc::new(provider)) +} + +/// The upstream config, offering the protocols the client asked for. +/// +/// Cloned per connection rather than kept in a table: the list comes from the client, so +/// there is no fixed set to precompute, and a config clone is nothing beside the +/// handshake it is about to do. An empty list means the client offered none, and then +/// neither do we — offering something on its behalf would be answering a question it did +/// not ask. +pub fn with_alpn(config: &Arc, wanted: &[Vec]) -> Arc { + if wanted.is_empty() { + return Arc::clone(config); + } + let mut copy = (**config).clone(); + copy.alpn_protocols = wanted.to_vec(); + Arc::new(copy) +} + +/// The server config, advertising the one protocol the service agreed to. +/// +/// One, not a list: the service has already chosen, and offering the client anything else +/// would let it pick something the other side of this proxy cannot speak. `None` means +/// the service chose nothing, so the client is told nothing and both ends fall back +/// together. +pub fn answering_with(config: &Arc, agreed: Option<&[u8]>) -> Arc { + match agreed { + Some(protocol) => { + let mut copy = (**config).clone(); + copy.alpn_protocols = vec![protocol.to_vec()]; + Arc::new(copy) + } + None => Arc::clone(config), + } +} + +pub fn acceptor(config: Arc) -> TlsAcceptor { + TlsAcceptor::from(config) +} + +pub fn connector(config: Arc) -> TlsConnector { + TlsConnector::from(config) +} + +/// The name to present upstream. Nothing verifies it here, but rustls needs one. +pub fn server_name(host: &str) -> Result, io::Error> { + ServerName::try_from(host.to_string()) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, format!("bad server name: {e}"))) +} diff --git a/backend/proxysrc/src/transparent.rs b/backend/proxysrc/src/transparent.rs new file mode 100644 index 0000000..b3d0793 --- /dev/null +++ b/backend/proxysrc/src/transparent.rs @@ -0,0 +1,278 @@ +//! Source-IP transparency: making the protected service see the real client. +//! +//! The NFQUEUE engine gets this for free — it never terminates anything, so the +//! service sees the original packets. A proxy has to work for it, and if it does not, +//! every service that logs, rate-limits or bans by client address quietly starts +//! seeing one address for the whole internet. That is a correctness regression the +//! operator would never notice, so the capability is checked at startup and the +//! per-connection fallback is counted and logged rather than silent. + +use std::io; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::os::fd::{AsRawFd, RawFd}; + +use socket2::{Domain, Protocol, Socket, Type}; +use tokio::net::{TcpListener, TcpSocket, TcpStream, UdpSocket}; + +// Not in libc: netfilter's own option numbers. +const SO_ORIGINAL_DST: libc::c_int = 80; +const IP6T_SO_ORIGINAL_DST: libc::c_int = 80; +const IP_TRANSPARENT: libc::c_int = 19; +const IPV6_TRANSPARENT: libc::c_int = 75; + +/// Stamped on every connection the engine opens itself. +/// +/// Without it an intercept rule cannot tell the engine's own dial apart from the +/// traffic it is supposed to intercept, and on a path where both ends are local — +/// nginx's clear leg in the TLS module is exactly that — the engine redirects itself +/// in a loop. Deliberately not the mark policy routing keys off: that one means +/// "deliver this here", and stamping it on an outbound connection would strand it. +pub const SELF_MARK: u32 = 0x133A; + +fn setsockopt_int( + fd: RawFd, + level: libc::c_int, + name: libc::c_int, + value: libc::c_int, +) -> io::Result<()> { + let rc = unsafe { + libc::setsockopt( + fd, + level, + name, + &value as *const libc::c_int as *const libc::c_void, + std::mem::size_of::() as libc::socklen_t, + ) + }; + if rc != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +/// Allow the socket to bind and receive on addresses that are not ours. +/// +/// Needs `CAP_NET_ADMIN`; without it this fails with `EPERM` and the caller has to +/// decide whether that is fatal. +pub fn set_transparent(fd: RawFd, ipv6: bool) -> io::Result<()> { + if ipv6 { + setsockopt_int(fd, libc::IPPROTO_IPV6, IPV6_TRANSPARENT, 1) + } else { + setsockopt_int(fd, libc::IPPROTO_IP, IP_TRANSPARENT, 1) + } +} + +/// Turn a v4-mapped IPv6 address back into the IPv4 address it is. +/// +/// A service answering on both stacks is served by one dual-stack listener, so an IPv4 +/// connection arrives on it wearing a `::ffff:a.b.c.d` costume. Everything downstream — +/// the conntrack lookup, the source-preserving dial, the metadata a filter reads — +/// wants the address the packet actually had, and a mapped address matches none of the +/// v4 machinery it needs to reach. +pub fn unmap(addr: SocketAddr) -> SocketAddr { + match addr { + SocketAddr::V6(v6) => match v6.ip().to_ipv4_mapped() { + Some(v4) => SocketAddr::new(IpAddr::V4(v4), v6.port()), + None => addr, + }, + v4 => v4, + } +} + +/// Where the client was actually trying to reach. +/// +/// Always from conntrack: the destination was rewritten before we saw it, and +/// conntrack is what still remembers the original. There used to be a second way +/// (tproxy, which preserves the addresses in place), but it forced the operator to +/// understand the difference and could not reach a service on this host at all. The +/// proxy should look like it is not there, and that is not a setting. +pub fn original_destination(stream: &TcpStream) -> io::Result { + original_dst_from_conntrack(stream) +} + +fn original_dst_from_conntrack(stream: &TcpStream) -> io::Result { + let fd = stream.as_raw_fd(); + // Unmapped first: on a dual-stack listener an IPv4 connection reports a v6 local + // address, and asking IPv6 conntrack about an IPv4 flow simply fails. + match unmap(stream.local_addr()?) { + SocketAddr::V4(_) => { + let mut addr: libc::sockaddr_in = unsafe { std::mem::zeroed() }; + let mut len = std::mem::size_of::() as libc::socklen_t; + let rc = unsafe { + libc::getsockopt( + fd, + libc::IPPROTO_IP, + SO_ORIGINAL_DST, + &mut addr as *mut libc::sockaddr_in as *mut libc::c_void, + &mut len, + ) + }; + if rc != 0 { + return Err(io::Error::last_os_error()); + } + Ok(SocketAddr::new( + IpAddr::V4(Ipv4Addr::from(u32::from_be(addr.sin_addr.s_addr))), + u16::from_be(addr.sin_port), + )) + } + SocketAddr::V6(_) => { + let mut addr: libc::sockaddr_in6 = unsafe { std::mem::zeroed() }; + let mut len = std::mem::size_of::() as libc::socklen_t; + let rc = unsafe { + libc::getsockopt( + fd, + libc::IPPROTO_IPV6, + IP6T_SO_ORIGINAL_DST, + &mut addr as *mut libc::sockaddr_in6 as *mut libc::c_void, + &mut len, + ) + }; + if rc != 0 { + return Err(io::Error::last_os_error()); + } + Ok(SocketAddr::new( + IpAddr::V6(Ipv6Addr::from(addr.sin6_addr.s6_addr)), + u16::from_be(addr.sin6_port), + )) + } + } +} + +pub fn bind_listener(addr: SocketAddr) -> io::Result { + let socket = if addr.is_ipv6() { + let sock = TcpSocket::new_v6()?; + let _ = setsockopt_int(sock.as_raw_fd(), libc::IPPROTO_IPV6, libc::IPV6_V6ONLY, 0); + sock + } else { + TcpSocket::new_v4()? + }; + socket.set_reuseaddr(true)?; + socket.bind(addr)?; + socket.listen(1024) +} + +/// Dial the service as the client: same source address, kernel-chosen source port. +/// +/// The reply comes back to an address that is not ours, so it only reaches this +/// socket if the ruleset diverts it — the `socket transparent` match in prerouting +/// plus a local route for marked packets. Without that the connection simply hangs, +/// which is why the integration test asserts a full round trip and not just the +/// address the service reports. +pub async fn connect_as( + client: IpAddr, + upstream: SocketAddr, + self_mark: Option, +) -> io::Result { + if client.is_ipv6() != upstream.is_ipv6() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "cannot spoof an IPv4 client towards an IPv6 service, or the reverse", + )); + } + let socket = if upstream.is_ipv6() { + TcpSocket::new_v6()? + } else { + TcpSocket::new_v4()? + }; + set_transparent(socket.as_raw_fd(), upstream.is_ipv6())?; + if let Some(mark) = self_mark { + set_self_mark(socket.as_raw_fd(), mark)?; + } + socket.set_reuseaddr(true)?; + socket.bind(SocketAddr::new(client, 0))?; + socket.connect(upstream).await +} + +/// Dial without impersonating anyone, but still marked as ours. +pub async fn connect_plain(upstream: SocketAddr, self_mark: Option) -> io::Result { + let socket = if upstream.is_ipv6() { + TcpSocket::new_v6()? + } else { + TcpSocket::new_v4()? + }; + if let Some(mark) = self_mark { + set_self_mark(socket.as_raw_fd(), mark)?; + } + socket.connect(upstream).await +} + +/// A UDP socket wearing the client's address, with nothing dialled yet. +/// +/// Split out from [`connect_as_udp`] for QUIC: quinn owns its socket and sends to an +/// address per packet, which a *connected* socket refuses (`EISCONN`). Everything that +/// makes the address someone else's — `IP_TRANSPARENT`, the mark the intercept rules +/// skip, and the bind to a foreign address — happens here; who it then talks to is the +/// caller's business. +pub fn bind_as_udp( + client: IpAddr, + upstream: SocketAddr, + self_mark: Option, +) -> io::Result { + if client.is_ipv6() != upstream.is_ipv6() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "cannot spoof an IPv4 client towards an IPv6 service, or the reverse", + )); + } + let domain = if upstream.is_ipv6() { + Domain::IPV6 + } else { + Domain::IPV4 + }; + let socket = Socket::new(domain, Type::DGRAM, Some(Protocol::UDP))?; + socket.set_nonblocking(true)?; + set_transparent(socket.as_raw_fd(), upstream.is_ipv6())?; + if let Some(mark) = self_mark { + set_self_mark(socket.as_raw_fd(), mark)?; + } + socket.set_reuse_address(true)?; + let bind_addr: socket2::SockAddr = SocketAddr::new(client, 0).into(); + socket.bind(&bind_addr)?; + Ok(socket.into()) +} + +/// Dial the service as the client over UDP: same source address, kernel-chosen source port. +pub async fn connect_as_udp( + client: IpAddr, + upstream: SocketAddr, + self_mark: Option, +) -> io::Result { + let std_sock = bind_as_udp(client, upstream, self_mark)?; + let tokio_sock = UdpSocket::from_std(std_sock)?; + tokio_sock.connect(upstream).await?; + Ok(tokio_sock) +} + +/// Dial UDP without impersonating anyone, but still marked as ours. +pub async fn connect_plain_udp( + upstream: SocketAddr, + self_mark: Option, +) -> io::Result { + let bind: SocketAddr = if upstream.is_ipv6() { + "[::]:0".parse().unwrap() + } else { + "0.0.0.0:0".parse().unwrap() + }; + let socket = UdpSocket::bind(bind).await?; + if let Some(mark) = self_mark { + set_self_mark(socket.as_raw_fd(), mark)?; + } + socket.connect(upstream).await?; + Ok(socket) +} + +/// Mark a socket as ours, so the intercept rules can skip it. +pub fn set_self_mark(fd: RawFd, mark: u32) -> io::Result<()> { + setsockopt_int(fd, libc::SOL_SOCKET, libc::SO_MARK, mark as libc::c_int) +} + +/// Check at startup that we can actually do this, so a missing `CAP_NET_ADMIN` is a +/// loud failure instead of a silent loss of the client's identity. +pub fn probe_capability(ipv6: bool) -> io::Result<()> { + let socket = if ipv6 { + TcpSocket::new_v6()? + } else { + TcpSocket::new_v4()? + }; + set_transparent(socket.as_raw_fd(), ipv6) +} diff --git a/backend/proxysrc/src/udp.rs b/backend/proxysrc/src/udp.rs new file mode 100644 index 0000000..6c74664 --- /dev/null +++ b/backend/proxysrc/src/udp.rs @@ -0,0 +1,481 @@ +//! The proxy layer for UDP: relays with transparent source IP preservation. +//! +//! The TCP side of this engine stays invisible in two ways at once: it recovers where +//! the client was originally headed with `SO_ORIGINAL_DST`, and it dials the service +//! *from the client's own address* via `IP_TRANSPARENT`. +//! +//! On UDP: +//! * **`SO_ORIGINAL_DST` is TCP and SCTP only.** The kernel answers `ENOPROTOOPT` for a +//! UDP socket, so a single listener cannot ask where a datagram was going. This relay +//! handles it by binding **one socket per protected address**, each with the +//! upstream already known. +//! * **The client's address is preserved.** The relay dials the upstream service from +//! the client's own address using `IP_TRANSPARENT` and mark-based policy routing, +//! keeping source IP transparency on both TCP and UDP. +//! +//! What is kept is everything the filter layer cares about: each client flow is a +//! connection with its own filter state and its own module globals, both directions are +//! inspected. + +use std::collections::HashMap; +use std::io; +use std::net::SocketAddr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio::net::UdpSocket; + +use crate::proxy::{ProxyStats, Slot}; +use crate::filter::{ + next_connection_id, ChainHandle, ChainSessions, ConnectionId, ConnectionMeta, Direction, + FilterChain, Verdict, L4, +}; + +/// The largest datagram this relay will carry. Comfortably past the practical MTU and +/// past anything a filter is going to be shown in one piece. +const MAX_DATAGRAM: usize = 65_535; + +/// How long a client flow with no traffic is kept before its filter state is released. +/// +/// UDP has no close to observe, so this is the only thing that ends a flow. Too short +/// and a filter keeping per-stream state forgets mid-conversation; too long and a +/// service under a spoofed-source flood accumulates one session per forged address. +const IDLE: Duration = Duration::from_secs(60); + +/// How often idle flows are swept. +const SWEEP: Duration = Duration::from_secs(10); + +/// How many of one flow's datagrams may wait for its verdicts. Past it they are dropped, +/// which is what the socket buffer would have done with them: a flow whose filter is slow +/// falls behind on its own, and the budget bounds what it can make this process hold. +const FLOW_QUEUE: usize = 32; + +struct Flow { + /// This flow's datagrams, on their way to be judged and sent on — by a task of the + /// flow's own, so a slow verdict holds up this client and nobody else. + /// + /// The receive loop used to judge every datagram itself, one after the other, for + /// every client of the address: a filter taking a second over one client's datagram + /// was a second in which no other client's was even read, and the socket buffer + /// overflowing behind it dropped theirs. + to_service: tokio::sync::mpsc::Sender>, + forward_task: tokio::task::JoinHandle<()>, + connection: ConnectionId, + /// 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, + 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. +pub struct UdpRelay { + listener: Arc, + /// Where this listener's traffic goes. Fixed, which is the whole trick: with the + /// upstream known up front there is nothing to recover per datagram, and the kernel + /// option that cannot answer for UDP is never asked. + upstream: SocketAddr, + chain: ChainHandle, + self_mark: Option, + pub spoof_source: bool, + /// 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 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 and spends one + /// budget. + stats: Arc, +} + +impl UdpRelay { + /// Bind a listener for one protected address. Port 0 lets the kernel choose, and + /// [`UdpRelay::local_addr`] reports what it chose. + #[allow(clippy::too_many_arguments)] + pub async fn bind( + listen: SocketAddr, + upstream: SocketAddr, + chain: ChainHandle, + self_mark: Option, + spoof_source: bool, + max_flows: usize, + over_limit_forwards: bool, + stats: Arc, + ) -> io::Result { + let listener = UdpSocket::bind(listen).await?; + Ok(Self { + listener: Arc::new(listener), + upstream, + chain, + self_mark, + spoof_source, + max_flows, + over_limit_forwards, + stats, + }) + } + + pub fn local_addr(&self) -> io::Result { + self.listener.local_addr() + } + + /// Relay forever. + /// + /// Nothing one flow does may end this loop: a datagram that cannot be forwarded + /// costs that datagram, and a filter that refuses one costs that one too. The + /// listener outliving every flow is the same property the TCP side has. + pub async fn serve(self) -> io::Result<()> { + let mut flows: HashMap = HashMap::new(); + let mut buf = vec![0u8; MAX_DATAGRAM]; + let mut sweep = tokio::time::interval(SWEEP); + sweep.tick().await; + + loop { + let (len, client) = tokio::select! { + received = self.listener.recv_from(&mut buf) => match received { + Ok(pair) => pair, + // A per-datagram error (an ICMP port-unreachable landing on the + // socket, most often) must not end the relay. + Err(e) => { + crate::diag_throttled!("[warn] [udp] failed receives", 5, 10, "[warn] [udp] receive failed: {e}"); + continue; + } + }, + _ = sweep.tick() => { + self.expire(&mut flows); + continue; + } + }; + + let flow = match flows.get_mut(&client) { + Some(flow) => flow, + None => { + // The cheapest attack there is, and it was unbounded: a flow is + // created per *source address*, a datagram's source is not verified + // by any handshake, and a flow holds a socket, a task and a set of + // filter sessions for `IDLE` after the last datagram. Six hundred + // datagrams from six hundred forged sources, sent in three + // hundredths of a second, took four hundred descriptors and held + // them for a minute after the sender had gone. + // + // 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; + 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) { + crate::diag!( + "[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; + } + // 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) + } + Err(e) => { + crate::diag_throttled!("[warn] [udp] flows that could not reach the service", 5, 10, "[warn] [udp] cannot reach {} for {client}: {e}", self.upstream); + continue; + } + } + } + }; + flow.last_seen = Instant::now(); + match flow.to_service.try_send(buf[..len].to_vec()) { + Ok(()) => {} + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + crate::diag_throttled!("[warn] [udp] datagrams dropped behind a slow verdict", 5, 10, "[warn] [udp] {client} is sending faster than its datagrams are judged; one was dropped"); + } + // Its task is gone — it only ends when the flow does — so the flow goes + // too, and the client's next datagram opens a new one. + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => { + if let Some(flow) = flows.remove(&client) { + flow.close(); + } + } + } + } + } + + /// Start relaying one client's flow: a socket towards the service, a task judging and + /// sending on what the client sends, and one carrying the answers back, all 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, + Err(e) => { + self.stats.source_spoof_failures.fetch_add(1, Ordering::Relaxed); + if !self.stats.warned_spoof.swap(true, Ordering::Relaxed) { + crate::diag!( + "[warn] [udp] cannot reach {} as {}: {e}. \ + Falling back to our own address — the service will not see real client IPs.", + self.upstream, + client.ip() + ); + } + crate::transparent::connect_plain_udp(self.upstream, self.self_mark).await? + } + } + } else { + crate::transparent::connect_plain_udp(self.upstream, self.self_mark).await? + }; + let upstream = Arc::new(upstream); + + let connection = next_connection_id(); + // Told once, before any datagram of this flow is judged. `client` is the real + // peer: a filter reads who is talking, and that stays true whatever address the + // relay dials from. + chain.current().connection_opened( + connection, + &ConnectionMeta { + client, + server: self.upstream, + l4: L4::Udp, + }, + ); + + let refused = Arc::new(AtomicBool::new(false)); + let (to_service, datagrams) = tokio::sync::mpsc::channel(FLOW_QUEUE); + let forward_task = tokio::spawn(forward( + datagrams, + Arc::clone(&upstream), + self.upstream, + chain.clone(), + connection, + Arc::clone(&refused), + Arc::clone(&self.stats), + )); + let reply_task = tokio::spawn(replies( + Arc::clone(&upstream), + Arc::clone(&self.listener), + client, + chain.clone(), + connection, + Arc::clone(&refused), + Arc::clone(&self.stats), + )); + + Ok(Flow { + to_service, + forward_task, + connection, + chain, + reply_task, + last_seen: Instant::now(), + _slot: slot, + }) + } + + /// Release the flows nothing has been heard from. + /// + /// The only thing that ends a UDP flow: there is no close to observe. Without it a + /// service would accumulate one set of filter state — and, for a Python filter, one + /// set of module globals — per client address for as long as it ran. + fn expire(&self, flows: &mut HashMap) { + let now = Instant::now(); + let done: Vec = flows + .iter() + .filter(|(_, flow)| now.duration_since(flow.last_seen) > IDLE) + .map(|(addr, _)| *addr) + .collect(); + for addr in done { + if let Some(flow) = flows.remove(&addr) { + flow.close(); + } + } + } +} + +impl Flow { + /// Let go of everything this flow holds, and tell the filters it is over. + fn close(self) { + self.forward_task.abort(); + self.reply_task.abort(); + self.chain.current().connection_closed(self.connection); + } +} + +/// Judge one flow's datagrams in the order they arrived, and send on what is accepted. +async fn forward( + mut datagrams: tokio::sync::mpsc::Receiver>, + upstream: Arc, + service: 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); + while let Some(datagram) = datagrams.recv().await { + // Re-read the handle every datagram, so a chain swapped in mid-flow takes effect + // without anyone losing their session. + let verdict = chain + .current() + .run(Direction::ClientToServer, &datagram, &mut sessions) + .await; + if let Verdict::Reject(_) = verdict { + // There is no connection to close, so refusing the datagram is the whole of + // what refusing can mean here. + if !refused.swap(true, Ordering::Relaxed) { + stats.closed_by_filter.fetch_add(1, Ordering::Relaxed); + } + continue; + } + if let Err(e) = upstream.send(&datagram).await { + crate::diag_throttled!("[warn] [udp] datagrams that could not be forwarded", 5, 10, "[warn] [udp] cannot forward to {service}: {e}"); + } + } +} + +/// Carry one flow's answers back, inspecting them on the way. +/// +/// Replies leave through the *listener* socket, so conntrack rewrites them to appear +/// from the address the client dialled. Sending them from the upstream socket would +/// reach a client that is not expecting that source and would be dropped by it. +async fn replies( + upstream: Arc, + listener: Arc, + 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); + let mut buf = vec![0u8; MAX_DATAGRAM]; + loop { + let len = match upstream.recv(&mut buf).await { + Ok(len) => len, + // The service's port answered a datagram with ICMP "unreachable", which a + // connected socket reports once, on its next call: the service was down for a + // moment — being restarted, most often. Nothing about the flow is over. This + // used to end the task, which left the flow in the relay's table with nobody + // reading the service's answers, so a client that went on talking never heard + // back again while its datagrams kept the flow from ever going idle. + Err(e) if e.kind() == io::ErrorKind::ConnectionRefused => continue, + Err(e) => { + crate::diag_throttled!("[info] [udp] flows that ended with an error", 20, 10, "[info] [udp] flow for {client} ended: {e}"); + return; + } + }; + let verdict = chain + .current() + .run(Direction::ServerToClient, &buf[..len], &mut sessions) + .await; + let payload: &[u8] = match &verdict { + 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(_) => { + 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 { + crate::diag_throttled!("[warn] [udp] answers that could not be delivered", 5, 10, "[warn] [udp] cannot answer {client}: {e}"); + } + } +} + +/// Manages running UDP relays and allows adding new relays dynamically at runtime. +#[derive(Clone)] +pub struct UdpManager { + chain: ChainHandle, + self_mark: Option, + spoof_source: bool, + max_flows: usize, + over_limit_forwards: bool, + stats: Arc, + relays: Arc>>, +} + +impl UdpManager { + pub fn new( + chain: ChainHandle, + self_mark: Option, + spoof_source: bool, + max_flows: usize, + over_limit_forwards: bool, + stats: Arc, + ) -> Self { + Self { + chain, + self_mark, + spoof_source, + max_flows, + over_limit_forwards, + stats, + relays: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + } + } + + pub async fn add_relay(&self, upstream: SocketAddr) -> io::Result { + let mut map = self.relays.lock().await; + if let Some(&port) = map.get(&upstream) { + return Ok(port); + } + let bind: SocketAddr = if upstream.is_ipv6() { + "[::]:0".parse().unwrap() + } else { + "0.0.0.0:0".parse().unwrap() + }; + let relay = UdpRelay::bind( + bind, + upstream, + self.chain.clone(), + self.self_mark, + self.spoof_source, + self.max_flows, + self.over_limit_forwards, + Arc::clone(&self.stats), + ) + .await?; + + let port = relay.local_addr()?.port(); + map.insert(upstream, port); + tokio::spawn(async move { + if let Err(e) = relay.serve().await { + crate::diag!("[fatal] [udp] relay for {upstream} died: {e}"); + } + }); + Ok(port) + } +} + diff --git a/backend/proxysrc/tests/fail_open.rs b/backend/proxysrc/tests/fail_open.rs new file mode 100644 index 0000000..e51ee32 --- /dev/null +++ b/backend/proxysrc/tests/fail_open.rs @@ -0,0 +1,334 @@ +//! The claims the proxy engine has to earn before it deserves to exist. +//! +//! Every test drives real sockets: an echo server standing in for the protected +//! service, the proxy in front of it, and a client that must keep working no matter +//! what the filters do. + +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use fgex_proxy::filter::{ChainHandle, Filter, FilterChain, FilterCtx, Verdict}; +use fgex_proxy::proxy::{Proxy, ProxyConfig}; +use fgex_proxy::spec::parse_filters; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +/// Echo server: whatever it receives, it sends straight back. +async fn spawn_echo() -> std::net::SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { + continue; + }; + tokio::spawn(async move { + let mut buf = vec![0u8; 16 * 1024]; + loop { + match sock.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if sock.write_all(&buf[..n]).await.is_err() { + break; + } + } + } + } + }); + } + }); + addr +} + +async fn spawn_proxy(filters: &str, deadline_ms: u64) -> (std::net::SocketAddr, ChainHandle) { + spawn_proxy_with(FilterChain::new( + parse_filters(filters).unwrap(), + Duration::from_millis(deadline_ms), + )) + .await +} + +async fn spawn_proxy_with(chain: FilterChain) -> (std::net::SocketAddr, ChainHandle) { + let upstream = spawn_echo().await; + let chain = ChainHandle::new(chain); + let proxy = Proxy::bind( + ProxyConfig::fixed("127.0.0.1:0".parse().unwrap(), upstream), + chain.clone(), + ) + .await + .unwrap(); + let addr = proxy.local_addr().unwrap(); + tokio::spawn(proxy.serve()); + (addr, chain) +} + +/// Send one payload, read one reply. +async fn roundtrip(addr: std::net::SocketAddr, payload: &[u8]) -> std::io::Result> { + let mut sock = TcpStream::connect(addr).await?; + sock.write_all(payload).await?; + let mut buf = vec![0u8; 64 * 1024]; + let n = tokio::time::timeout(Duration::from_secs(5), sock.read(&mut buf)).await??; + buf.truncate(n); + Ok(buf) +} + +#[tokio::test] +async fn relays_traffic_with_no_filters() { + let (addr, _chain) = spawn_proxy("", 200).await; + let got = roundtrip(addr, b"hello firegex").await.unwrap(); + assert_eq!(got, b"hello firegex"); +} + +/// The headline guarantee: user code that panics costs its own say, not the traffic. +#[tokio::test] +async fn panicking_filter_keeps_traffic_flowing() { + let (addr, chain) = spawn_proxy("panic", 200).await; + + let got = roundtrip(addr, b"payload one").await.unwrap(); + assert_eq!( + got, b"payload one", + "a panicking filter must not eat the data" + ); + + // And the next connection is served too: the failure is contained, not fatal. + let got = roundtrip(addr, b"payload two").await.unwrap(); + assert_eq!(got, b"payload two"); + + let live = chain.current(); + assert!(live.stats.panics.load(Ordering::Relaxed) >= 1); + assert_eq!(live.disabled_filters(), vec!["panic"]); + assert!( + live.is_bypassed(), + "with its only filter disabled the chain should degrade to a plain relay" + ); +} + +/// The failure `catch_unwind` cannot catch: a filter that simply never returns. +/// +/// One test rather than three because releasing the stuck threads at the end is +/// process-wide, and the harness runs tests in parallel within one process. +#[tokio::test] +async fn hanging_filter_fails_open_then_loses_its_say() { + let rearm = Duration::from_millis(1500); + let (addr, chain) = spawn_proxy_with( + FilterChain::new(parse_filters("hang").unwrap(), Duration::from_millis(50)) + .rearming_after(rearm), + ) + .await; + + // The deadline, not the filter, decides when the chunk moves. + let started = Instant::now(); + let got = roundtrip(addr, b"still moving").await.unwrap(); + assert_eq!(got, b"still moving"); + assert!( + started.elapsed() < Duration::from_secs(3), + "the chunk waited on the filter instead of failing open (took {:?})", + started.elapsed() + ); + assert!(chain.current().stats.timeouts.load(Ordering::Relaxed) >= 1); + + // A single slow call could be load, so it takes a few in a row to lose it. + for _ in 0..4 { + assert_eq!(roundtrip(addr, b"tick").await.unwrap(), b"tick"); + } + let live = chain.current(); + assert_eq!(live.disabled_filters(), vec!["hang"]); + assert!(live.is_bypassed()); + + // Each timeout abandons a blocking thread that cannot be cancelled, so the + // damage has to be bounded: once disabled, the filter is never called again + // and the leak stops growing however much traffic follows. + let leaked = live.stats.timeouts.load(Ordering::Relaxed); + for _ in 0..10 { + assert_eq!(roundtrip(addr, b"more").await.unwrap(), b"more"); + } + assert_eq!( + live.stats.timeouts.load(Ordering::Relaxed), + leaked, + "a disabled filter is still being called, so the thread leak is unbounded" + ); + + // Slow is not broken: after a pause it is asked again, rather than staying out until + // somebody next edits a rule. It used to stay out for the life of the chain — which + // for a Python filter meant a flood, the one moment it was wanted, switched it off. + tokio::time::sleep(rearm + Duration::from_millis(300)).await; + let answered = roundtrip(addr, b"again").await.unwrap(); + let asked = live.stats.timeouts.load(Ordering::Relaxed) - leaked; + let back_in = live.disabled_filters().is_empty(); + let bypassed = live.is_bypassed(); + + // Let the abandoned threads exit, otherwise dropping the runtime waits on them — and + // before asserting, so a failure below fails the test rather than hanging it. + fgex_proxy::spec::release_hangs(); + + assert_eq!(answered, b"again"); + // Once per direction: the echo comes back through the chain as well. + assert_eq!(asked, 2, "the filter was not asked again after its pause"); + assert!(back_in, "two misses after the pause took it out again"); + assert!(!bypassed); +} + +/// Failing open is only worth anything if the filters do something when healthy. +#[tokio::test] +async fn healthy_filter_still_blocks() { + let (addr, _chain) = spawn_proxy("block:FLAG{", 200).await; + + let got = roundtrip(addr, b"harmless request").await.unwrap(); + assert_eq!(got, b"harmless request"); + + // Rejected: the proxy closes cleanly, so the client reads EOF rather than + // hanging on a silently dropped packet. + let got = roundtrip(addr, b"give me FLAG{...}").await.unwrap(); + assert!(got.is_empty(), "blocked payload came through: {got:?}"); +} + +/// A rule refuses a connection, not a direction: a service that answers without +/// waiting for the request must not still reach a client whose request was blocked. +#[tokio::test] +async fn a_reject_closes_the_whole_connection() { + // A server that greets on connect, before it has read anything. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let upstream = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { + continue; + }; + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(80)).await; + let _ = sock.write_all(b"UNSOLICITED").await; + }); + } + }); + + let chain = ChainHandle::new(FilterChain::new( + parse_filters("block:FLAG{").unwrap(), + Duration::from_millis(200), + )); + let proxy = Proxy::bind( + ProxyConfig::fixed("127.0.0.1:0".parse().unwrap(), upstream), + chain, + ) + .await + .unwrap(); + let addr = proxy.local_addr().unwrap(); + tokio::spawn(proxy.serve()); + + let mut sock = TcpStream::connect(addr).await.unwrap(); + sock.write_all(b"give me FLAG{x}").await.unwrap(); + let mut buf = vec![0u8; 1024]; + let n = tokio::time::timeout(Duration::from_secs(3), sock.read(&mut buf)) + .await + .unwrap() + .unwrap(); + assert_eq!( + n, + 0, + "the server's greeting reached a client whose request was blocked: {:?}", + &buf[..n] + ); +} + + + +/// A broken filter must not take its healthy neighbours down with it. +#[tokio::test] +async fn one_broken_filter_does_not_disable_the_others() { + let (addr, chain) = spawn_proxy("panic,block:FLAG{", 200).await; + + // Trip the panicking filter. + let got = roundtrip(addr, b"warmup").await.unwrap(); + assert_eq!(got, b"warmup"); + + let live = chain.current(); + assert_eq!(live.disabled_filters(), vec!["panic"]); + assert!( + !live.is_bypassed(), + "the surviving filter should keep the chain active" + ); + + // The healthy one is still enforcing. + let got = roundtrip(addr, b"take the FLAG{x}").await.unwrap(); + assert!(got.is_empty(), "the surviving filter stopped enforcing"); +} + +/// Reconfiguration must not cost established connections — the NFQUEUE engine +/// reloads its rules in place, and this one may not be worse. +#[tokio::test] +async fn chain_swap_applies_to_live_connections() { + let (addr, chain) = spawn_proxy("", 200).await; + + let mut sock = TcpStream::connect(addr).await.unwrap(); + sock.write_all(b"before").await.unwrap(); + let mut buf = vec![0u8; 1024]; + let n = sock.read(&mut buf).await.unwrap(); + assert_eq!(&buf[..n], b"before"); + + // Swap in a rewriting chain while the connection is open. + chain.replace(FilterChain::new( + parse_filters("block:after").unwrap(), + Duration::from_millis(200), + )); + + sock.write_all(b"after").await.unwrap(); + let n = tokio::time::timeout(Duration::from_secs(5), sock.read(&mut buf)) + .await + .unwrap() + .unwrap(); + assert_eq!( + n, + 0, + "the swapped-in chain did not close the connection" + ); +} + +/// A filter that panics only sometimes still loses its say on the first panic, +/// and the chunk that tripped it is forwarded rather than dropped. +struct FlakyFilter { + seen: std::sync::atomic::AtomicU64, +} + +impl Filter for FlakyFilter { + fn name(&self) -> &str { + "flaky" + } + fn inspect(&self, _ctx: &FilterCtx<'_>) -> Verdict { + if self.seen.fetch_add(1, Ordering::Relaxed) == 1 { + panic!("second call blows up"); + } + Verdict::Accept + } +} + +#[tokio::test] +async fn chunk_that_trips_a_panic_is_still_delivered() { + let upstream = spawn_echo().await; + let chain = ChainHandle::new(FilterChain::new( + vec![Arc::new(FlakyFilter { + seen: std::sync::atomic::AtomicU64::new(0), + })], + Duration::from_millis(200), + )); + let proxy = Proxy::bind( + ProxyConfig::fixed("127.0.0.1:0".parse().unwrap(), upstream), + chain.clone(), + ) + .await + .unwrap(); + let addr = proxy.local_addr().unwrap(); + tokio::spawn(proxy.serve()); + + let mut sock = TcpStream::connect(addr).await.unwrap(); + let mut buf = vec![0u8; 1024]; + + for expected in [&b"first"[..], &b"second"[..], &b"third"[..]] { + sock.write_all(expected).await.unwrap(); + let n = tokio::time::timeout(Duration::from_secs(5), sock.read(&mut buf)) + .await + .unwrap() + .unwrap(); + assert_eq!(&buf[..n], expected, "a chunk was lost around the panic"); + } + assert_eq!(chain.current().disabled_filters(), vec!["flaky"]); +} diff --git a/backend/proxysrc/tests/h2.rs b/backend/proxysrc/tests/h2.rs new file mode 100644 index 0000000..14a1850 --- /dev/null +++ b/backend/proxysrc/tests/h2.rs @@ -0,0 +1,1135 @@ +//! HTTP/2 terminated by the engine, and shown to the filters as HTTP/1.1. +//! +//! The point of the rendering is that a filter written once works on every version: a +//! pattern that blocks a path over HTTP/1.1 has to block the same path over HTTP/2, where +//! on the wire that path was an HPACK-compressed header block. These tests put a real h2 +//! client and a real h2 service either side of the proxy and check what the chain was +//! shown — and, where the question is what reached the *service*, what arrived there. +//! +//! They are the h3 suite's cases, asked again of the other protocol, plus the two that +//! only HTTP/2 can get wrong: a head that is the whole message has to go out as one, and +//! concurrent streams on one connection must not share a filter's state. + +use std::net::SocketAddr; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bytes::Bytes; +use fgex_proxy::filter::{ChainHandle, FilterChain}; +use fgex_proxy::proxy::{Proxy, ProxyConfig, TlsSetup}; +use fgex_proxy::spec::parse_filters; +use fgex_proxy::tls; +use tokio::net::{TcpListener, TcpStream}; + +fn self_signed() -> (String, String) { + let cert = + rcgen::generate_simple_self_signed(vec!["localhost".to_string(), "127.0.0.1".to_string()]) + .unwrap(); + (cert.cert.pem(), cert.key_pair.serialize_pem()) +} + +/// What a service did with one exchange, as the tests need to see it. +#[derive(Default)] +struct Seen { + /// Every trailer the service was handed, as `name=value`. + trailers: Mutex>, + /// How many requests it answered. + requests: AtomicUsize, +} + +/// How the stand-in service behaves. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Service { + /// Answers with what it was asked, body and all. + Echo, + /// Answers the moment the head arrives, without waiting for a body — the shape of a + /// bidirectional RPC and of every service that replies before an upload finishes. + Eager, + /// Answers with a head and nothing else, the way a gRPC status-only reply does. + TrailersOnly, +} + +/// A real HTTP/2 service, over TLS, standing where the protected service would be. +async fn spawn_service(cert: &str, key: &str, how: Service) -> (SocketAddr, Arc) { + let seen = Arc::new(Seen::default()); + let mut config = (*tls::server_config(cert, key).unwrap()).clone(); + config.alpn_protocols = vec![b"h2".to_vec()]; + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let recorded = Arc::clone(&seen); + tokio::spawn(async move { + loop { + let Ok((sock, _)) = listener.accept().await else { + return; + }; + let acceptor = acceptor.clone(); + let recorded = Arc::clone(&recorded); + tokio::spawn(async move { + let Ok(tls) = acceptor.accept(sock).await else { + return; + }; + let Ok(connection) = h2::server::handshake(tls).await else { + return; + }; + serve(connection, how, recorded).await; + }); + } + }); + (addr, seen) +} + +/// What the stand-in service does with the exchanges on one connection. +/// +/// Shared by the TLS and the cleartext service so the two cannot answer differently: the +/// h2c tests are asking whether the *proxy* behaves the same without TLS in the way, and a +/// service that differed would make that unanswerable. +async fn serve(mut connection: h2::server::Connection, how: Service, seen: Arc) +where + T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + while let Some(Ok((request, mut respond))) = connection.accept().await { + let seen = Arc::clone(&seen); + tokio::spawn(async move { + seen.requests.fetch_add(1, Ordering::Relaxed); + let path = request.uri().path().to_string(); + let (_, mut body) = request.into_parts(); + + if how == Service::Eager { + let answer = "the service spoke first"; + let response = http::Response::builder() + .status(200) + .header("content-length", answer.len().to_string()) + .body(()) + .unwrap(); + let mut out = respond.send_response(response, false).unwrap(); + let _ = out.send_data(Bytes::from(answer), true); + return; + } + + let mut collected = Vec::new(); + while let Some(Ok(chunk)) = body.data().await { + let len = chunk.len(); + collected.extend_from_slice(&chunk); + let _ = body.flow_control().release_capacity(len); + } + if let Ok(Some(trailers)) = body.trailers().await { + for (name, value) in trailers.iter() { + seen.trailers + .lock() + .unwrap() + .push(format!("{name}={}", value.to_str().unwrap_or("?"))); + } + } + + if how == Service::TrailersOnly { + // HEADERS with END_STREAM and nothing else — what a gRPC status-only + // answer is on the wire. + let response = http::Response::builder() + .status(200) + .header("content-type", "application/grpc") + .header("grpc-status", "5") + .body(()) + .unwrap(); + let _ = respond.send_response(response, true); + return; + } + + // One path answers with something a rule is looking for, in bytes that appear + // nowhere in the request: that is what makes a test of the *answer* a test of + // the answer. + let answer = if path == "/secret" { + "here is FLAG{only-in-the-answer}".to_string() + } else { + format!( + "you asked for {path} and sent {} bytes: {}", + collected.len(), + String::from_utf8_lossy(&collected) + ) + }; + let response = http::Response::builder() + .status(200) + .header("content-length", answer.len().to_string()) + .body(()) + .unwrap(); + let mut out = respond.send_response(response, false).unwrap(); + let _ = out.send_data(Bytes::from(answer), true); + }); + } +} + +/// The same service, speaking HTTP/2 in the clear: no TLS, prior knowledge, which is how +/// most gRPC is actually deployed behind a load balancer. +async fn spawn_cleartext_service(how: Service) -> (SocketAddr, Arc) { + let seen = Arc::new(Seen::default()); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let recorded = Arc::clone(&seen); + tokio::spawn(async move { + loop { + let Ok((sock, _)) = listener.accept().await else { + return; + }; + let recorded = Arc::clone(&recorded); + tokio::spawn(async move { + let Ok(connection) = h2::server::handshake(sock).await else { + return; + }; + serve(connection, how, recorded).await; + }); + } + }); + (addr, seen) +} + +/// A plain relay with no TLS on either edge, which is what a `tcp` service is. +async fn spawn_plain_proxy(upstream: SocketAddr, filters: &str) -> SocketAddr { + let chain = ChainHandle::new(FilterChain::new( + parse_filters(filters).unwrap(), + Duration::from_millis(500), + )); + let cfg = ProxyConfig::fixed("127.0.0.1:0".parse().unwrap(), upstream); + let proxy = Proxy::bind(cfg, chain).await.unwrap(); + let addr = proxy.local_addr().unwrap(); + tokio::spawn(proxy.serve()); + addr +} + +/// An h2 connection in the clear, the way a prior-knowledge client opens one. +async fn connect_cleartext( + addr: SocketAddr, +) -> Result<(h2::client::SendRequest, tokio::task::JoinHandle<()>), String> { + let sock = TcpStream::connect(addr).await.map_err(|e| e.to_string())?; + let (sender, connection) = h2::client::handshake(sock).await.map_err(|e| e.to_string())?; + let driving = tokio::spawn(async move { + let _ = connection.await; + }); + Ok((sender, driving)) +} + +/// The engine in front of it, terminating TLS on both edges as a `tls` service does. +async fn spawn_proxy(upstream: SocketAddr, cert: &str, key: &str, filters: &str) -> 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: false, + }; + let proxy = Proxy::bind(cfg, chain).await.unwrap(); + let addr = proxy.local_addr().unwrap(); + tokio::spawn(proxy.serve()); + addr +} + +/// An h2 connection to the proxy, offering `h2` so the service's answer is `h2`. +async fn connect(addr: SocketAddr) -> Result<(h2::client::SendRequest, tokio::task::JoinHandle<()>), String> { + let mut config = (*tls::client_config().unwrap()).clone(); + config.alpn_protocols = vec![b"h2".to_vec()]; + let connector = tokio_rustls::TlsConnector::from(Arc::new(config)); + let sock = TcpStream::connect(addr).await.map_err(|e| e.to_string())?; + let name = tls::server_name("localhost").map_err(|e| e.to_string())?; + let tls = connector.connect(name, sock).await.map_err(|e| e.to_string())?; + let (sender, connection) = h2::client::handshake(tls).await.map_err(|e| e.to_string())?; + let driving = tokio::spawn(async move { + let _ = connection.await; + }); + Ok((sender, driving)) +} + +/// How a request's body is framed on the way out. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Body { + /// No body at all: HEADERS with END_STREAM. + None, + /// A body whose length is declared up front. + Declared(&'static [u8]), + /// A body whose length is not declared, so the rendering has to frame it. + Undeclared(&'static [u8]), +} + +/// One request through the proxy, and what came back. +async fn request(addr: SocketAddr, method: &str, path: &str, body: Body) -> Result { + request_with_trailer(addr, method, path, body, None).await +} + +/// The same, optionally ending with a trailer section. +async fn request_with_trailer( + addr: SocketAddr, + method: &str, + path: &str, + body: Body, + trailer: Option<&str>, +) -> Result { + let (mut sender, driving) = connect(addr).await?; + let answer = async { + let mut builder = http::Request::builder() + .method(method) + .uri(format!("https://localhost{path}")); + let payload = match body { + Body::None => None, + Body::Declared(bytes) => { + builder = builder.header("content-length", bytes.len().to_string()); + Some(bytes) + } + Body::Undeclared(bytes) => Some(bytes), + }; + let outgoing = builder.body(()).map_err(|e| e.to_string())?; + let ends_here = payload.is_none() && trailer.is_none(); + let (response, mut out) = sender + .send_request(outgoing, ends_here) + .map_err(|e| e.to_string())?; + if let Some(bytes) = payload { + out.send_data(Bytes::from_static(bytes), trailer.is_none()) + .map_err(|e| e.to_string())?; + } + if let Some(trailer) = trailer { + let mut trailers = http::HeaderMap::new(); + trailers.insert("x-note", http::HeaderValue::from_str(trailer).unwrap()); + out.send_trailers(trailers).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 chain with a rule in it that nothing will ever match. +/// +/// Not the same as no chain at all, and the difference is the whole of what these tests +/// are about: a service with no filters is **bypassed**, and a bypassed connection is +/// carried as bytes rather than terminated, so nothing below would be exercised. Spelling +/// it `""` here passed every assertion while proving nothing about HTTP/2 — which is the +/// class of false green this engine's tests exist to avoid, so it is spelled out. +const LIVE: &str = "block:ZZ-NO-SUCH-BYTES-ZZ"; + +/// The whole arrangement, for the cases that only need to ask one question. +async fn relay(how: Service, filters: &str) -> (SocketAddr, Arc) { + let (cert, key) = self_signed(); + let (upstream, seen) = spawn_service(&cert, &key, how).await; + let addr = spawn_proxy(upstream, &cert, &key, filters).await; + (addr, seen) +} + +#[tokio::test] +async fn an_exchange_goes_through() { + let (addr, _) = relay(Service::Echo, LIVE).await; + let answer = request(addr, "GET", "/hello", Body::None).await.unwrap(); + assert_eq!(answer, "you asked for /hello and sent 0 bytes: "); +} + +/// The whole reason for rendering the exchange: a pattern written against an HTTP request +/// matches one that arrived as an HPACK header block. +#[tokio::test] +async fn a_pattern_matches_the_request_line() { + let (addr, _) = relay(Service::Echo, "block:GET /admin").await; + + let allowed = request(addr, "GET", "/public", Body::None).await.unwrap(); + assert!(allowed.contains("/public"), "an unrelated request was affected: {allowed}"); + + let refused = request(addr, "GET", "/admin", Body::None).await; + assert!( + refused.is_err(), + "a request the chain refused was answered anyway: {refused:?}" + ); +} + +/// A header the request did not carry must not appear in what the filters are shown. +/// +/// A filter looking for `transfer-encoding` is usually looking for request smuggling, so +/// one invented by the proxy in front of it is the worst possible answer. +#[tokio::test] +async fn a_request_with_no_body_is_shown_no_framing_header() { + let (addr, _) = relay(Service::Echo, "block:transfer-encoding").await; + let answer = request(addr, "GET", "/plain", Body::None).await.unwrap(); + assert!(answer.contains("/plain"), "a framing header was invented: {answer}"); +} + +/// And a body whose length was never declared is shown chunked, because that is what that +/// message is in HTTP/1.1 — so a parser reading the rendering finds a body. +#[tokio::test] +async fn a_body_of_unknown_length_is_shown_chunked() { + let (addr, _) = relay(Service::Echo, "block:transfer-encoding").await; + let refused = request(addr, "POST", "/upload", Body::Undeclared(b"anything")).await; + assert!( + refused.is_err(), + "a body with no declared length was shown unframed: {refused:?}" + ); +} + +/// A declared length is passed through as it was, and the body shown raw beneath it. +#[tokio::test] +async fn a_declared_length_is_left_alone() { + let (addr, _) = relay(Service::Echo, "block:transfer-encoding").await; + let answer = request(addr, "POST", "/upload", Body::Declared(b"declared")) + .await + .unwrap(); + assert!(answer.contains("sent 8 bytes"), "the body did not arrive: {answer}"); +} + +/// Both directions are inspected, and a refused answer does not reach the client. +#[tokio::test] +async fn the_answer_is_inspected_too() { + let (addr, _) = relay(Service::Echo, "block:FLAG{").await; + + let answer = request(addr, "GET", "/harmless", Body::None).await; + assert!(answer.is_ok(), "an unrelated exchange was affected: {answer:?}"); + + // Nothing in this request carries the needle; everything that does is in the reply. + let refused = request(addr, "GET", "/secret", Body::None).await; + match refused { + Err(_) => {} + Ok(body) => panic!("the answer reached the client anyway: {body}"), + } +} + +/// A trailer section on a message the rendering can frame is shown to the chain, which is +/// the only reason it may then be forwarded. +/// +/// The bodiless case is the one that matters most: a message of HEADERS and TRAILERS and +/// nothing in between is exactly a gRPC trailers-only request, and without a chunked body +/// to hang a trailer section off the rendering would end at the head and the trailers +/// would travel to the far side unread. +#[tokio::test] +async fn a_trailer_section_is_shown_to_the_chain() { + for (body, what) in [ + (Body::None, "no body at all"), + (Body::Undeclared(b"harmless"), "a body of undeclared length"), + ] { + let (addr, _) = relay(Service::Echo, "block:SNEAKY").await; + let refused = request_with_trailer(addr, "POST", "/up", body, Some("SNEAKY")).await; + assert!( + refused.is_err(), + "a needle in the trailers of a message with {what} was not seen: {refused:?}" + ); + } +} + +/// And where the rendering cannot carry one, it is dropped rather than forwarded unread. +/// +/// A declared length means the HTTP/1.1 view of the message ends where the header said it +/// would, so there is nowhere to put a trailer section — and forwarding a piece of a +/// message no filter was shown is the one thing this engine does not do anywhere else. +#[tokio::test] +async fn a_trailer_the_rendering_cannot_carry_is_not_forwarded() { + let (addr, seen) = relay(Service::Echo, LIVE).await; + let answer = + request_with_trailer(addr, "POST", "/up", Body::Declared(b"harmless"), Some("SNEAKY")).await; + assert!(answer.is_ok(), "the exchange was disturbed: {answer:?}"); + assert!( + seen.trailers.lock().unwrap().is_empty(), + "a trailer section no filter was shown reached the service: {:?}", + seen.trailers.lock().unwrap() + ); + + // Without the declared length the same trailers are renderable, so they are shown and + // then carried: the rule is what the chain saw, not a dislike of trailers. + let (addr, seen) = relay(Service::Echo, LIVE).await; + let answer = request_with_trailer( + addr, + "POST", + "/up", + Body::Undeclared(b"harmless"), + Some("SNEAKY"), + ) + .await; + assert!(answer.is_ok(), "the exchange was disturbed: {answer:?}"); + assert_eq!( + seen.trailers.lock().unwrap().as_slice(), + ["x-note=SNEAKY"], + "a trailer section the chain accepted was dropped anyway" + ); +} + +/// An exchange the service is supposed to speak first in is not held waiting for a body. +/// +/// The head is held only until the framing is known, and a client that sends a head and +/// then waits to be answered — a bidirectional RPC, gRPC's among them — never sends one. +/// Holding it there deadlocks the exchange: the proxy waiting for a body, the client +/// waiting for the answer that would tell it what to send. +#[tokio::test] +async fn a_service_that_speaks_first_is_not_waited_out() { + let (addr, _) = relay(Service::Eager, LIVE).await; + let (mut sender, driving) = connect(addr).await.unwrap(); + + let outgoing = http::Request::builder() + .method("POST") + .uri("https://localhost/stream") + .body(()) + .unwrap(); + // Deliberately no body and no end of stream: this client is waiting to be spoken to. + let (response, _out) = sender.send_request(outgoing, false).unwrap(); + let answered = tokio::time::timeout(Duration::from_secs(5), response).await; + driving.abort(); + assert!( + answered.is_ok(), + "the head never reached a service that answers without waiting for a body" + ); + assert_eq!(answered.unwrap().unwrap().status(), 200); +} + +/// A head that is the whole message goes out as one, and does not grow a body on the way. +/// +/// This is the case HTTP/3 could not get wrong and HTTP/2 can: the end of a message rides +/// on its last frame, so sending the head open and closing it with an empty DATA frame +/// turns a *trailers-only* answer into a message with a body. That is precisely the shape +/// of a gRPC status-only reply, and gRPC clients refuse the mangled version. +#[tokio::test] +async fn a_head_that_ends_the_message_stays_that_way() { + let (addr, _) = relay(Service::TrailersOnly, LIVE).await; + let (mut sender, driving) = connect(addr).await.unwrap(); + + let outgoing = http::Request::builder() + .method("POST") + .uri("https://localhost/rpc") + .header("content-type", "application/grpc") + .body(()) + .unwrap(); + let (response, _out) = sender.send_request(outgoing, true).unwrap(); + let response = tokio::time::timeout(Duration::from_secs(5), response) + .await + .expect("the answer never arrived") + .expect("the answer was an error"); + assert_eq!(response.headers().get("grpc-status").unwrap(), "5"); + let mut body = response.into_body(); + assert!( + body.is_end_stream(), + "a head-only answer reached the client with a body attached to it" + ); + assert!( + body.data().await.is_none(), + "an empty DATA frame was invented on the way through" + ); + driving.abort(); +} + +/// Concurrent streams on one connection are separate connections to the chain. +/// +/// On HTTP/1.1 a keep-alive connection carries its requests one after another, so one set +/// of filter state is the honest answer. HTTP/2 interleaves them, and a filter whose state +/// was shared between two streams would be one client's bytes deciding another client's +/// verdict — which is also a way to smuggle a pattern past a filter by splitting it across +/// two streams. Here half a needle goes up each of two concurrent streams; neither may +/// block. +#[tokio::test] +async fn concurrent_streams_do_not_share_filter_state() { + let (addr, _) = relay(Service::Echo, "block:NEEDLEHALVES").await; + let (mut sender, driving) = connect(addr).await.unwrap(); + + let mut waiting = Vec::new(); + for half in ["NEEDLE", "HALVES"] { + let outgoing = http::Request::builder() + .method("POST") + .uri("https://localhost/split") + .body(()) + .unwrap(); + let (response, mut out) = sender.send_request(outgoing, false).unwrap(); + out.send_data(Bytes::from(half), true).unwrap(); + waiting.push(response); + } + for response in waiting { + let answered = tokio::time::timeout(Duration::from_secs(5), response).await; + assert!( + matches!(&answered, Ok(Ok(r)) if r.status() == 200), + "a pattern split across two streams was matched as if they were one: {answered:?}" + ); + } + driving.abort(); +} + +/// And one connection really does carry them at the same time. +#[tokio::test] +async fn one_connection_carries_many_streams() { + let (addr, seen) = relay(Service::Echo, LIVE).await; + let (mut sender, driving) = connect(addr).await.unwrap(); + + let mut waiting = Vec::new(); + for n in 0..8 { + let outgoing = http::Request::builder() + .method("GET") + .uri(format!("https://localhost/n{n}")) + .body(()) + .unwrap(); + let (response, _) = sender.send_request(outgoing, true).unwrap(); + waiting.push((n, response)); + } + for (n, response) in waiting { + let response = tokio::time::timeout(Duration::from_secs(5), response) + .await + .expect("an answer never arrived") + .expect("an answer was an error"); + let mut body = response.into_body(); + let mut got = Vec::new(); + while let Some(Ok(chunk)) = body.data().await { + let len = chunk.len(); + got.extend_from_slice(&chunk); + let _ = body.flow_control().release_capacity(len); + } + let got = String::from_utf8_lossy(&got).into_owned(); + assert!( + got.contains(&format!("/n{n}")), + "two streams of one connection were mixed up: {got}" + ); + } + driving.abort(); + assert_eq!(seen.requests.load(Ordering::Relaxed), 8); +} + +/// A refusal ends the connection carrying it, not just the stream. +/// +/// Resetting one stream would leave the client free to ask again on the next, which is not +/// what a block means anywhere else in firegex — and on HTTP/1.1 keep-alive a refused +/// request already takes the connection with it. +#[tokio::test] +async fn a_refusal_ends_the_connection() { + let (addr, _) = relay(Service::Echo, "block:GET /admin").await; + let (mut sender, driving) = connect(addr).await.unwrap(); + + let refused = http::Request::builder() + .method("GET") + .uri("https://localhost/admin") + .body(()) + .unwrap(); + let (response, _) = sender.send_request(refused, true).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(5), response).await; + + // The connection is gone, so a second request on it cannot be answered — whether it is + // refused outright or simply never answered. + let again = http::Request::builder() + .method("GET") + .uri("https://localhost/public") + .body(()) + .unwrap(); + let answered = match sender.send_request(again, true) { + Err(_) => None, + Ok((response, _)) => tokio::time::timeout(Duration::from_secs(2), response) + .await + .ok() + .and_then(|r| r.ok()), + }; + driving.abort(); + assert!( + answered.is_none(), + "a connection whose stream was refused went on answering: {answered:?}" + ); +} + +/// A service with no filters is carried as bytes, and that is deliberate. +/// +/// Rendering an exchange for a chain that has nothing to say is work paid for no answer, +/// so a bypassed chain — a service with no filters, or one whose filters have all been +/// disabled, or a connection admitted past the limit with `over_limit_forwards` — takes +/// the byte pump instead. The visible consequence is that nothing is dropped, filtered or +/// rendered, which this pins with the one case that can tell the two paths apart: a +/// trailer section on a message of declared length, which a terminated connection drops +/// and a forwarded one carries. +/// +/// The cost of the trade 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. +#[tokio::test] +async fn an_empty_chain_is_carried_as_bytes() { + let (addr, seen) = relay(Service::Echo, "").await; + let answer = + request_with_trailer(addr, "POST", "/up", Body::Declared(b"harmless"), Some("SNEAKY")).await; + assert!(answer.is_ok(), "the exchange was disturbed: {answer:?}"); + assert_eq!( + seen.trailers.lock().unwrap().as_slice(), + ["x-note=SNEAKY"], + "an unfiltered connection was terminated and rendered anyway" + ); +} + +/// HTTP/2 in the clear is rendered too, which is how most gRPC is actually deployed. +/// +/// A `tcp` service with no TLS anywhere: the client opens with the connection preface and +/// the proxy recognises it. Without this the exchange is HPACK on the wire and nothing in +/// the chain can read it — the same silence the TLS path had, on the layer where a gRPC +/// service behind a load balancer usually lives. +#[tokio::test] +async fn cleartext_http2_is_rendered_as_well() { + let (upstream, _) = spawn_cleartext_service(Service::Echo).await; + let addr = spawn_plain_proxy(upstream, "block:GET /admin").await; + + let (mut sender, driving) = connect_cleartext(addr).await.unwrap(); + let outgoing = http::Request::builder() + .method("GET") + .uri("http://localhost/public") + .body(()) + .unwrap(); + let (response, _) = sender.send_request(outgoing, true).unwrap(); + let response = tokio::time::timeout(Duration::from_secs(5), response) + .await + .expect("no answer came back") + .expect("the answer was an error"); + assert_eq!(response.status(), 200); + driving.abort(); + + // And the pattern matches a request line that was HPACK on the wire. + let (mut sender, driving) = connect_cleartext(addr).await.unwrap(); + let outgoing = http::Request::builder() + .method("GET") + .uri("http://localhost/admin") + .body(()) + .unwrap(); + let (response, _) = sender.send_request(outgoing, true).unwrap(); + let answered = tokio::time::timeout(Duration::from_secs(5), response).await; + driving.abort(); + assert!( + !matches!(&answered, Ok(Ok(_))), + "a cleartext HTTP/2 request the chain refused was answered anyway: {answered:?}" + ); +} + +/// Looking for the preface must not make a service that speaks first wait for it. +/// +/// This is the trap the whole sniff is arranged around: SMTP, SSH and most game protocols +/// send a banner before the client says anything, and a proxy that holds the connection +/// until the client speaks breaks all of them in a way that looks random. The service +/// having spoken is proof this is not a prior-knowledge HTTP/2 client, so the banner is +/// what ends the wait. +#[tokio::test] +async fn a_banner_service_is_not_held_by_the_preface_check() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let upstream = listener.local_addr().unwrap(); + tokio::spawn(async move { + while let Ok((mut sock, _)) = listener.accept().await { + tokio::spawn(async move { + use tokio::io::AsyncWriteExt; + let _ = sock.write_all(b"220 service ready\r\n").await; + let _ = sock.flush().await; + // Held open, as a real banner protocol would, so the test is about the + // banner arriving and not about the connection ending. + tokio::time::sleep(Duration::from_secs(30)).await; + }); + } + }); + let addr = spawn_plain_proxy(upstream, LIVE).await; + + let mut sock = TcpStream::connect(addr).await.unwrap(); + let mut buf = [0u8; 19]; + let banner = tokio::time::timeout(Duration::from_millis(200), async { + use tokio::io::AsyncReadExt; + sock.read_exact(&mut buf).await + }) + .await; + assert!( + matches!(banner, Ok(Ok(_))), + "a service that speaks first was held waiting for an HTTP/2 preface: {banner:?}" + ); + assert_eq!(&buf[..], b"220 service ready\r\n"); +} + +/// And a plain HTTP/1.1 client is not mistaken for one, nor delayed by the check. +#[tokio::test] +async fn cleartext_http1_is_not_mistaken_for_http2() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let upstream = listener.local_addr().unwrap(); + tokio::spawn(async move { + while let Ok((mut sock, _)) = listener.accept().await { + tokio::spawn(async move { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut buf = vec![0u8; 1024]; + let n = sock.read(&mut buf).await.unwrap_or(0); + let seen = String::from_utf8_lossy(&buf[..n]).into_owned(); + let body = format!("saw {} bytes", seen.len()); + let _ = sock + .write_all( + format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await; + }); + } + }); + let addr = spawn_plain_proxy(upstream, LIVE).await; + + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut sock = TcpStream::connect(addr).await.unwrap(); + sock.write_all(b"GET /plain HTTP/1.1\r\nhost: x\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("an HTTP/1.1 client was left waiting by the preface check") + .unwrap(); + let answer = String::from_utf8_lossy(&buf[..n]).into_owned(); + assert!( + answer.starts_with("HTTP/1.1 200"), + "a plain HTTP/1.1 exchange was disturbed: {answer}" + ); +} + +/// One relay, one certificate, one chain — carrying the clear and the encrypted alike. +/// +/// This is what an `http` service is: a daemon answering in the clear on one port and +/// under TLS on another used to need two firegex services with their filter chains copied +/// between them by hand, and a chain kept in step by hand is one that stops protecting one +/// of them silently. The edge is decided per connection, from what the client actually +/// sent, which is the same rule the ALPN mirroring follows — firegex carries what the two +/// ends are doing rather than deciding it for them. +#[tokio::test] +async fn optional_tls_carries_both_edges_through_one_chain() { + let (cert, key) = self_signed(); + // Two services, because a real daemon listens twice: one socket in the clear and one + // under TLS. One relay in front of both. + let (encrypted, _) = spawn_service(&cert, &key, Service::Echo).await; + let (cleartext, _) = spawn_cleartext_service(Service::Echo).await; + + for (upstream, tls_on_the_wire) in [(encrypted, true), (cleartext, false)] { + let chain = ChainHandle::new(FilterChain::new( + parse_filters("block:GET /admin").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, + }; + let proxy = Proxy::bind(cfg, chain).await.unwrap(); + let addr = proxy.local_addr().unwrap(); + tokio::spawn(proxy.serve()); + + let open = |addr| async move { + if tls_on_the_wire { + connect(addr).await + } else { + connect_cleartext(addr).await + } + }; + + let (mut sender, driving) = open(addr).await.unwrap(); + let outgoing = http::Request::builder() + .method("GET") + .uri("https://localhost/public") + .body(()) + .unwrap(); + let (response, _) = sender.send_request(outgoing, true).unwrap(); + let response = tokio::time::timeout(Duration::from_secs(5), response) + .await + .unwrap_or_else(|_| panic!("no answer on the {} edge", edge(tls_on_the_wire))) + .unwrap_or_else(|e| panic!("the {} edge answered an error: {e}", edge(tls_on_the_wire))); + assert_eq!(response.status(), 200); + driving.abort(); + + // And the one chain refuses on both, which is the whole point of one service. + let (mut sender, driving) = open(addr).await.unwrap(); + let outgoing = http::Request::builder() + .method("GET") + .uri("https://localhost/admin") + .body(()) + .unwrap(); + let (response, _) = sender.send_request(outgoing, true).unwrap(); + let answered = tokio::time::timeout(Duration::from_secs(5), response).await; + driving.abort(); + assert!( + !matches!(&answered, Ok(Ok(_))), + "the {} edge answered a request the chain refused: {answered:?}", + edge(tls_on_the_wire) + ); + } +} + +fn edge(tls: bool) -> &'static str { + if tls { + "TLS" + } else { + "cleartext" + } +} + +/// A cleartext **HTTP/1.1** service, which is what most of the web is. +async fn spawn_http1_service(seen: Arc>>) -> SocketAddr { + spawn_http1_service_answering( + seen, + b"HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ncontent-length: 5\r\n\r\nhello", + ) + .await +} + +/// The same, answering with whatever head and body it is given. +async fn spawn_http1_service_answering( + seen: Arc>>, + answer: &'static [u8], +) -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let seen = Arc::clone(&seen); + tokio::spawn(async move { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut buf = vec![0u8; 8192]; + let read = match socket.read(&mut buf).await { + Ok(0) | Err(_) => return, + Ok(n) => n, + }; + seen.lock() + .unwrap() + .push(String::from_utf8_lossy(&buf[..read]).to_string()); + let _ = socket.write_all(answer).await; + }); + } + }); + addr +} + +/// The engine terminating HTTP/2 from the client in front of an HTTP/1.1 service. +async fn spawn_proxy_to_http1( + upstream: SocketAddr, + cert: &str, + key: &str, + filters: &str, +) -> SocketAddr { + use fgex_proxy::proxy::{Edge, Onward, Published}; + 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, + }; + // What the address says: the service behind it speaks HTTP/1.1 in the clear, whatever + // the client brought. That is the whole of what makes this edge different. + cfg.targets.publish( + upstream, + Published { target: None, edge: Edge::Whatever, upstream: Onward::Plain }, + ); + let proxy = Proxy::bind(cfg, chain).await.unwrap(); + let addr = proxy.local_addr().unwrap(); + tokio::spawn(proxy.serve()); + addr +} + +#[tokio::test] +async fn http2_is_carried_to_a_service_that_speaks_http1() { + // The deployment an operator actually has: an ordinary cleartext web service, reached + // over HTTP/2 with only firegex holding a certificate. It was reachable over HTTP/3 + // long before it was reachable over HTTP/2 — same rendering underneath, and the TLS + // edge simply had nobody to copy an ALPN from, so it answered nothing and every + // client fell back to HTTP/1.1. + let (cert, key) = self_signed(); + let seen = Arc::new(Mutex::new(Vec::new())); + let service = spawn_http1_service(Arc::clone(&seen)).await; + let proxy = spawn_proxy_to_http1(service, &cert, &key, LIVE).await; + + let answer = request(proxy, "GET", "/files/ok", Body::None).await.unwrap(); + assert!(answer.contains("hello"), "the answer did not come back: {answer}"); + + // And what the service was handed is HTTP/1.1 — the same bytes the chain was shown, + // which is what makes this a forward rather than a translation invented here. + let arrived = seen.lock().unwrap().clone(); + assert_eq!(arrived.len(), 1, "the service saw {} requests", arrived.len()); + assert!( + arrived[0].starts_with("GET /files/ok HTTP/1.1\r\n"), + "the service was not spoken to in HTTP/1.1: {:?}", + arrived[0] + ); +} + +/// What an HTTP/1.1 service actually answers with: a body of unknown length sent chunked, +/// and a connection it offers to keep open. Both are headers about *that* connection, +/// which HTTP/2 forbids outright — the h2 crate refuses to send a response carrying one — +/// so they were passed through and every such answer failed on its way to the client. +#[tokio::test] +async fn an_http1_answer_is_relayed_without_its_connection_headers() { + let (cert, key) = self_signed(); + let seen = Arc::new(Mutex::new(Vec::new())); + let service = spawn_http1_service_answering( + Arc::clone(&seen), + b"HTTP/1.1 200 OK\r\nconnection: keep-alive\r\nkeep-alive: timeout=5\r\n\ + content-type: text/plain\r\ntransfer-encoding: chunked\r\n\r\n\ + 5\r\nhello\r\n0\r\n\r\n", + ) + .await; + let proxy = spawn_proxy_to_http1(service, &cert, &key, LIVE).await; + + let answer = request(proxy, "GET", "/files/ok", Body::None).await; + assert!( + answer.as_deref().is_ok_and(|a| a.contains("hello")), + "a chunked keep-alive answer did not reach an HTTP/2 client: {answer:?}" + ); +} + +/// One client connection is one connection to the limit, and each of its streams is an +/// exchange of its own — towards an HTTP/1.1 service, a connection of its own. With no cap +/// on concurrent streams, one client could open as many connections to the service as it +/// liked past `max_connections`. +#[tokio::test] +async fn one_client_connection_cannot_open_the_service_a_thousand_times() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let service = listener.local_addr().unwrap(); + let open = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let most = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + { + let (open, most) = (Arc::clone(&open), Arc::clone(&most)); + tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let (open, most) = (Arc::clone(&open), Arc::clone(&most)); + tokio::spawn(async move { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + // Counted from the request, not from the accept: a connection that + // closes without asking anything is not one the service is serving. + let mut buf = [0u8; 4096]; + if !matches!(socket.read(&mut buf).await, Ok(n) if n > 0) { + return; + } + let now = open.fetch_add(1, Ordering::SeqCst) + 1; + most.fetch_max(now, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(300)).await; + // Counted down before answering: once the answer is out the stream + // can end and the client open the next, before this task resumes. + open.fetch_sub(1, Ordering::SeqCst); + let _ = socket + .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok") + .await; + }); + } + }); + } + let (cert, key) = self_signed(); + let proxy = spawn_proxy_to_http1(service, &cert, &key, LIVE).await; + + let (sender, driving) = connect(proxy).await.unwrap(); + let mut asked = Vec::new(); + for _ in 0..250 { + let sender = sender.clone(); + asked.push(tokio::spawn(async move { + let mut sender = sender.ready().await.map_err(|e| e.to_string())?; + let outgoing = http::Request::builder() + .method("GET") + .uri("https://localhost/") + .body(()) + .map_err(|e| e.to_string())?; + let (response, _) = sender.send_request(outgoing, true).map_err(|e| e.to_string())?; + response.await.map_err(|e| e.to_string()).map(|_| ()) + })); + } + for one in asked { + let _ = tokio::time::timeout(Duration::from_secs(20), one).await; + } + driving.abort(); + let most = most.load(Ordering::SeqCst); + assert!(most > 0, "nothing reached the service at all"); + assert!(most <= 100, "one client connection held {most} connections to the service open"); +} + +/// A head is bounded: the h2 crate's own limit is 16 MiB, a hundred streams to a +/// connection. Well past what services accept, though — a large head is still carried. +#[tokio::test] +async fn a_head_past_the_limit_is_refused_over_http2() { + let (addr, seen) = relay(Service::Echo, LIVE).await; + let ask = |size: usize| async move { + let (mut sender, driving) = connect(addr).await?; + let answer = async { + let outgoing = http::Request::builder() + .method("GET") + .uri("https://localhost/who") + .header("x-big", "a".repeat(size)) + .body(()) + .map_err(|e| e.to_string())?; + let (response, _) = sender.send_request(outgoing, true).map_err(|e| e.to_string())?; + response.await.map_err(|e| e.to_string()).map(|r| r.status().as_u16()) + } + .await; + driving.abort(); + answer + }; + assert_eq!(ask(256 * 1024).await, Ok(200), "a 256 KiB head was refused"); + let answered = seen.requests.load(Ordering::Relaxed); + let refused = tokio::time::timeout(Duration::from_secs(10), ask(2 * 1024 * 1024)) + .await + .expect("an oversized head was left hanging"); + // The crate answers `431 Request Header Fields Too Large` itself, as RFC 9113 suggests. + assert!(!matches!(refused, Ok(200)), "a 2 MiB head was carried: {refused:?}"); + assert_eq!(seen.requests.load(Ordering::Relaxed), answered, "it reached the service"); +} + +#[tokio::test] +async fn a_pattern_blocks_over_http2_in_front_of_http1() { + // The promise the rendering exists for, on the edge that just gained it: one filter, + // written once, refusing the same request whatever version carried it. + let (cert, key) = self_signed(); + let seen = Arc::new(Mutex::new(Vec::new())); + let service = spawn_http1_service(Arc::clone(&seen)).await; + let proxy = spawn_proxy_to_http1(service, &cert, &key, "block:/secret").await; + + let refused = request(proxy, "GET", "/secret", Body::None).await; + assert!(refused.is_err(), "the request was carried: {refused:?}"); + assert!( + seen.lock().unwrap().is_empty(), + "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 new file mode 100644 index 0000000..f558e5d --- /dev/null +++ b/backend/proxysrc/tests/h3.rs @@ -0,0 +1,885 @@ +//! HTTP/3 carried by the engine, and shown to the filters as HTTP/1.1. +//! +//! The point of the translation is that a filter written once works on both: a pattern +//! that blocks a path over TCP has to block the same path over QUIC, where on the wire +//! that path was a QPACK-compressed header block. These tests put a real HTTP/3 client and +//! a real HTTP/3 service either side of the relay and check what the chain was shown. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use bytes::{Buf, Bytes}; +use fgex_proxy::filter::{ChainHandle, FilterChain}; +use fgex_proxy::proxy::ProxyStats; +use fgex_proxy::proxy::Onward; +use fgex_proxy::quic::{QuicConfig, QuicRelay, QuicSetup}; +use fgex_proxy::spec::parse_filters; +use fgex_proxy::tls; +use quinn::crypto::rustls::{QuicClientConfig, QuicServerConfig}; + +fn self_signed() -> (String, String) { + let cert = + rcgen::generate_simple_self_signed(vec!["localhost".to_string(), "127.0.0.1".to_string()]) + .unwrap(); + (cert.cert.pem(), cert.key_pair.serialize_pem()) +} + +/// An HTTP/3 service that answers with what it was asked, body and all. +async fn spawn_h3_echo(cert: &str, key: &str) -> SocketAddr { + let mut config = (*tls::server_config(cert, key).unwrap()).clone(); + config.alpn_protocols = vec![b"h3".to_vec()]; + let endpoint = quinn::Endpoint::server( + quinn::ServerConfig::with_crypto(Arc::new(QuicServerConfig::try_from(config).unwrap())), + "127.0.0.1:0".parse().unwrap(), + ) + .unwrap(); + let addr = endpoint.local_addr().unwrap(); + tokio::spawn(async move { + while let Some(incoming) = endpoint.accept().await { + tokio::spawn(async move { + let Ok(connection) = incoming.await else { + return; + }; + let Ok(mut h3) = h3::server::builder() + .build(h3_quinn::Connection::new(connection)) + .await + else { + return; + }; + while let Ok(Some(resolver)) = h3.accept().await { + tokio::spawn(async move { + let Ok((request, mut stream)) = resolver.resolve_request().await else { + return; + }; + let mut body = Vec::new(); + while let Ok(Some(mut chunk)) = stream.recv_data().await { + let len = chunk.remaining(); + body.extend_from_slice(&chunk.copy_to_bytes(len)); + } + // One path answers with something a rule is looking for, and + // says it in bytes that appear nowhere in the request: that is + // what makes a test of the *answer* a test of the answer. + let answer = if request.uri().path() == "/secret" { + "here is FLAG{only-in-the-answer}".to_string() + } else { + format!( + "you asked for {} and sent {} bytes: {}", + request.uri().path(), + body.len(), + String::from_utf8_lossy(&body) + ) + }; + let response = http::Response::builder() + .status(http::StatusCode::OK) + .header("content-length", answer.len().to_string()) + .body(()) + .unwrap(); + let _ = stream.send_response(response).await; + let _ = stream.send_data(Bytes::from(answer)).await; + let _ = stream.finish().await; + }); + } + }); + } + }); + addr +} + +/// A service that speaks HTTP/1.1 and nothing else, which is what most of the web is. +/// +/// Deliberately hand-written rather than a crate: what these tests are about is what +/// leaves this engine, so the far end has to be something that reads exactly what an +/// HTTP/1.1 service reads and nothing more forgiving. +async fn spawn_h1_echo() -> SocketAddr { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + tokio::spawn(async move { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut buf = Vec::new(); + let mut chunk = [0u8; 4096]; + // The head, then whatever the framing says the body is. + let head_end = loop { + let n = match socket.read(&mut chunk).await { + Ok(0) | Err(_) => return, + Ok(n) => n, + }; + buf.extend_from_slice(&chunk[..n]); + if let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") { + break at + 4; + } + }; + let head = String::from_utf8_lossy(&buf[..head_end]).to_lowercase(); + let path = head.split_whitespace().nth(1).unwrap_or("/").to_string(); + let declared = head + .split("content-length:") + .nth(1) + .and_then(|rest| rest.split("\r\n").next()) + .and_then(|v| v.trim().parse::().ok()); + let mut body = buf[head_end..].to_vec(); + match declared { + Some(want) => { + while body.len() < want { + let n = match socket.read(&mut chunk).await { + Ok(0) | Err(_) => break, + Ok(n) => n, + }; + body.extend_from_slice(&chunk[..n]); + } + body.truncate(want.min(body.len())); + } + // Chunked, which is what an undeclared body is in HTTP/1.1 — and what + // the rendering shows a filter, so it is what has to arrive here. + None if head.contains("transfer-encoding: chunked") => { + let mut decoded = Vec::new(); + loop { + while !body.windows(2).any(|w| w == b"\r\n") { + let n = match socket.read(&mut chunk).await { + Ok(0) | Err(_) => break, + Ok(n) => n, + }; + body.extend_from_slice(&chunk[..n]); + } + let Some(at) = body.windows(2).position(|w| w == b"\r\n") else { + break; + }; + let size = usize::from_str_radix( + String::from_utf8_lossy(&body[..at]).trim(), + 16, + ) + .unwrap_or(0); + if size == 0 { + break; + } + body.drain(..at + 2); + while body.len() < size + 2 { + let n = match socket.read(&mut chunk).await { + Ok(0) | Err(_) => break, + Ok(n) => n, + }; + body.extend_from_slice(&chunk[..n]); + } + decoded.extend_from_slice(&body[..size.min(body.len())]); + body.drain(..(size + 2).min(body.len())); + } + body = decoded; + } + None => body.clear(), + } + let answer = format!( + "you asked for {path} and sent {} bytes: {}", + body.len(), + String::from_utf8_lossy(&body) + ); + let _ = socket + .write_all( + format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{answer}", + answer.len() + ) + .as_bytes(), + ) + .await; + let _ = socket.flush().await; + }); + } + }); + addr +} + +async fn spawn_relay(upstream: SocketAddr, filters: &str) -> SocketAddr { + spawn_relay_to(upstream, filters, Onward::Same).await +} + +async fn spawn_relay_to( + upstream: SocketAddr, + filters: &str, + protocol: Onward, +) -> SocketAddr { + let (cert, key) = self_signed(); + let chain = ChainHandle::new(FilterChain::new( + parse_filters(filters).unwrap(), + Duration::from_millis(500), + )); + let cfg = QuicConfig { + setup: Arc::new(QuicSetup::build(&cert, &key).unwrap()), + self_mark: None, + spoof_source: false, + max_connections: 0, + over_limit_forwards: false, + first_byte_timeout: None, + connect_timeout: Duration::from_secs(5), + upstream: protocol, + // No interface to reconstruct anything onto, which is every host without the + // capture device — what it would carry is pinned by the integration suite, which + // has a real one. + capture: None, + }; + let relay = QuicRelay::bind( + "127.0.0.1:0".parse().unwrap(), + upstream, + chain, + cfg, + Arc::new(ProxyStats::default()), + ) + .unwrap(); + let addr = relay.local_addr().unwrap(); + tokio::spawn(Arc::new(relay).serve()); + addr +} + +/// One request through the relay, and what came back. +async fn request( + addr: SocketAddr, + method: &str, + path: &str, + body: Option<&[u8]>, + declare_length: bool, +) -> Result { + let mut config = tls::quic_client_config().unwrap(); + config.alpn_protocols = vec![b"h3".to_vec()]; + let mut endpoint = quinn::Endpoint::client("127.0.0.1:0".parse().unwrap()).unwrap(); + endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new( + QuicClientConfig::try_from(config).unwrap(), + ))); + let connection = endpoint + .connect(addr, "localhost") + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + let (mut driver, mut sender) = h3::client::new(h3_quinn::Connection::new(connection)) + .await + .map_err(|e| e.to_string())?; + let driving = tokio::spawn(async move { + let _ = driver.wait_idle().await; + }); + + let answer = async { + let mut builder = http::Request::builder() + .method(method) + .uri(format!("https://localhost{path}")); + if declare_length { + builder = builder.header("content-length", body.map(|b| b.len()).unwrap_or(0).to_string()); + } + let outgoing = builder.body(()).map_err(|e| e.to_string())?; + let mut stream = sender.send_request(outgoing).await.map_err(|e| e.to_string())?; + if let Some(body) = body { + stream + .send_data(Bytes::copy_from_slice(body)) + .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 +} + +#[tokio::test] +async fn an_exchange_goes_through() { + let (cert, key) = self_signed(); + let upstream = spawn_h3_echo(&cert, &key).await; + let addr = spawn_relay(upstream, "").await; + + let answer = request(addr, "GET", "/hello", None, false).await.unwrap(); + assert_eq!(answer, "you asked for /hello and sent 0 bytes: "); +} + +/// The whole reason for rendering the exchange: a pattern written against an HTTP +/// request matches one that arrived as a QPACK header block. +#[tokio::test] +async fn a_pattern_matches_the_request_line() { + let (cert, key) = self_signed(); + let upstream = spawn_h3_echo(&cert, &key).await; + let addr = spawn_relay(upstream, "block:GET /admin").await; + + let allowed = request(addr, "GET", "/public", None, false).await.unwrap(); + assert!(allowed.contains("/public"), "an unrelated request was affected: {allowed}"); + + let refused = request(addr, "GET", "/admin", None, false).await; + assert!( + refused.is_err(), + "a request the chain refused was answered anyway: {refused:?}" + ); +} + +/// A header the request did not carry must not appear in what the filters are shown. +/// +/// This is what the head is held back for. A bodiless request rendered as +/// `transfer-encoding: chunked` would be a lie in front of every filter looking for that +/// header — and a filter looking for it is usually looking for request smuggling, which +/// makes a fabricated one the worst possible answer. +#[tokio::test] +async fn a_request_with_no_body_is_shown_no_framing_header() { + let (cert, key) = self_signed(); + let upstream = spawn_h3_echo(&cert, &key).await; + let addr = spawn_relay(upstream, "block:transfer-encoding").await; + + let answer = request(addr, "GET", "/plain", None, false).await.unwrap(); + assert!(answer.contains("/plain"), "a framing header was invented: {answer}"); +} + +/// And a body whose length was never declared is shown chunked, because that is what +/// that message is in HTTP/1.1 — so a parser reading the rendering finds a body. +#[tokio::test] +async fn a_body_of_unknown_length_is_shown_chunked() { + let (cert, key) = self_signed(); + let upstream = spawn_h3_echo(&cert, &key).await; + let addr = spawn_relay(upstream, "block:transfer-encoding").await; + + let refused = request(addr, "POST", "/upload", Some(b"anything"), false).await; + assert!( + refused.is_err(), + "a body with no declared length was shown unframed: {refused:?}" + ); +} + +/// A declared length is passed through as it was, and the body shown raw beneath it. +#[tokio::test] +async fn a_declared_length_is_left_alone() { + let (cert, key) = self_signed(); + let upstream = spawn_h3_echo(&cert, &key).await; + let addr = spawn_relay(upstream, "block:transfer-encoding").await; + + let answer = request(addr, "POST", "/upload", Some(b"declared"), true) + .await + .unwrap(); + assert!(answer.contains("sent 8 bytes"), "the body did not arrive: {answer}"); +} + +/// Both directions are inspected, and a refused answer does not reach the client. +#[tokio::test] +async fn the_answer_is_inspected_too() { + let (cert, key) = self_signed(); + let upstream = spawn_h3_echo(&cert, &key).await; + let addr = spawn_relay(upstream, "block:FLAG{").await; + + let answer = request(addr, "GET", "/harmless", None, false).await; + assert!(answer.is_ok(), "an unrelated exchange was affected: {answer:?}"); + + // Nothing in this request carries the needle; everything that does is in the reply. + let refused = request(addr, "GET", "/secret", None, false).await; + match refused { + Err(_) => {} + Ok(body) => panic!("the answer reached the client anyway: {body}"), + } +} + +/// A service that answers a fixed body and remembers the trailer section it was sent. +/// +/// Separate from the echo above on purpose: what these tests are asking is what reached +/// the *service*, which an answer that reflects the request cannot tell them apart from +/// what reached the client. +async fn spawn_h3_recorder( + cert: &str, + key: &str, +) -> (SocketAddr, Arc>>) { + let seen: Arc>> = Arc::new(std::sync::Mutex::new(Vec::new())); + let mut config = (*tls::server_config(cert, key).unwrap()).clone(); + config.alpn_protocols = vec![b"h3".to_vec()]; + let endpoint = quinn::Endpoint::server( + quinn::ServerConfig::with_crypto(Arc::new(QuicServerConfig::try_from(config).unwrap())), + "127.0.0.1:0".parse().unwrap(), + ) + .unwrap(); + let addr = endpoint.local_addr().unwrap(); + let recorded = Arc::clone(&seen); + tokio::spawn(async move { + while let Some(incoming) = endpoint.accept().await { + let recorded = Arc::clone(&recorded); + tokio::spawn(async move { + let Ok(connection) = incoming.await else { return }; + let Ok(mut h3) = h3::server::builder() + .build(h3_quinn::Connection::new(connection)) + .await + else { + return; + }; + while let Ok(Some(resolver)) = h3.accept().await { + let recorded = Arc::clone(&recorded); + tokio::spawn(async move { + let Ok((_request, mut stream)) = resolver.resolve_request().await else { + return; + }; + while let Ok(Some(_)) = stream.recv_data().await {} + if let Ok(Some(trailers)) = stream.recv_trailers().await { + for (name, value) in trailers.iter() { + recorded.lock().unwrap().push(format!( + "{name}={}", + value.to_str().unwrap_or("?") + )); + } + } + let answer = "recorded"; + let response = http::Response::builder() + .status(http::StatusCode::OK) + .header("content-length", answer.len().to_string()) + .body(()) + .unwrap(); + let _ = stream.send_response(response).await; + let _ = stream.send_data(Bytes::from(answer)).await; + let _ = stream.finish().await; + }); + } + }); + } + }); + (addr, seen) +} + +/// A service that answers the moment the head arrives, without waiting for a body. +/// +/// The shape of a bidirectional RPC and of every service that replies before an upload +/// finishes — a `413`, a redirect, a refusal. +async fn spawn_h3_eager(cert: &str, key: &str) -> SocketAddr { + let mut config = (*tls::server_config(cert, key).unwrap()).clone(); + config.alpn_protocols = vec![b"h3".to_vec()]; + let endpoint = quinn::Endpoint::server( + quinn::ServerConfig::with_crypto(Arc::new(QuicServerConfig::try_from(config).unwrap())), + "127.0.0.1:0".parse().unwrap(), + ) + .unwrap(); + let addr = endpoint.local_addr().unwrap(); + tokio::spawn(async move { + while let Some(incoming) = endpoint.accept().await { + tokio::spawn(async move { + let Ok(connection) = incoming.await else { return }; + let Ok(mut h3) = h3::server::builder() + .build(h3_quinn::Connection::new(connection)) + .await + else { + return; + }; + while let Ok(Some(resolver)) = h3.accept().await { + tokio::spawn(async move { + let Ok((_request, mut stream)) = resolver.resolve_request().await else { + return; + }; + let answer = "the service spoke first"; + let response = http::Response::builder() + .status(http::StatusCode::OK) + .header("content-length", answer.len().to_string()) + .body(()) + .unwrap(); + let _ = stream.send_response(response).await; + let _ = stream.send_data(Bytes::from(answer)).await; + let _ = stream.finish().await; + }); + } + }); + } + }); + addr +} + +/// One request that ends with a trailer section, and what came back. +async fn request_with_trailer( + addr: SocketAddr, + body: &[u8], + declare_length: bool, + trailer: &str, +) -> Result { + let mut config = tls::quic_client_config().unwrap(); + config.alpn_protocols = vec![b"h3".to_vec()]; + let mut endpoint = quinn::Endpoint::client("127.0.0.1:0".parse().unwrap()).unwrap(); + endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new( + QuicClientConfig::try_from(config).unwrap(), + ))); + let connection = endpoint + .connect(addr, "localhost") + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + let (mut driver, mut sender) = h3::client::new(h3_quinn::Connection::new(connection)) + .await + .map_err(|e| e.to_string())?; + let driving = tokio::spawn(async move { + let _ = driver.wait_idle().await; + }); + + let answer = async { + let mut builder = http::Request::builder() + .method("POST") + .uri("https://localhost/up"); + if declare_length { + builder = builder.header("content-length", body.len().to_string()); + } + let outgoing = builder.body(()).map_err(|e| e.to_string())?; + let mut stream = sender.send_request(outgoing).await.map_err(|e| e.to_string())?; + if !body.is_empty() { + stream + .send_data(Bytes::copy_from_slice(body)) + .await + .map_err(|e| e.to_string())?; + } + let mut trailers = http::HeaderMap::new(); + trailers.insert("x-note", http::HeaderValue::from_str(trailer).unwrap()); + stream.send_trailers(trailers).await.map_err(|e| e.to_string())?; + let _ = stream.finish().await; + 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 +} + +/// A trailer section on a message whose body was never framed by a declared length is +/// shown to the chain, which is the only reason it may then be forwarded. +/// +/// The bodiless case is the one that matters most and the one that used to slip past: a +/// message of HEADERS and TRAILERS and nothing in between — which is exactly a gRPC +/// trailers-only answer — had no chunked body to hang a trailer section off, so the +/// rendering ended at the head and the trailers travelled to the far side unread. They +/// decide the framing now: in HTTP/1.1 a message with trailers *is* a chunked one. +#[tokio::test] +async fn a_trailer_section_is_shown_to_the_chain() { + let (cert, key) = self_signed(); + let upstream = spawn_h3_echo(&cert, &key).await; + let addr = spawn_relay(upstream, "block:SNEAKY").await; + + for (body, declared, what) in [ + (&b""[..], false, "no body at all"), + (&b"harmless"[..], false, "a body of undeclared length"), + ] { + let refused = request_with_trailer(addr, body, declared, "SNEAKY").await; + assert!( + refused.is_err(), + "a needle in the trailers of a message with {what} was not seen: {refused:?}" + ); + } +} + +/// And where the rendering cannot carry one, it is dropped rather than forwarded unread. +/// +/// A declared length means the HTTP/1.1 view of the message ends where the header said it +/// would, so there is nowhere to put a trailer section — and forwarding a piece of a +/// message no filter was shown is the one thing this engine does not do anywhere else. +/// Dropping is what an intermediary is allowed to do with a trailer section it cannot +/// carry; the exchange itself is untouched. +#[tokio::test] +async fn a_trailer_the_rendering_cannot_carry_is_not_forwarded() { + let (cert, key) = self_signed(); + let (upstream, seen) = spawn_h3_recorder(&cert, &key).await; + let addr = spawn_relay(upstream, "").await; + + let answer = request_with_trailer(addr, b"harmless", true, "SNEAKY").await; + assert_eq!(answer.as_deref(), Ok("recorded"), "the exchange was disturbed"); + assert!( + seen.lock().unwrap().is_empty(), + "a trailer section no filter was shown reached the service: {:?}", + seen.lock().unwrap() + ); + + // Without the declared length the same trailers are renderable, so they are shown and + // then carried: the rule is what the chain saw, not a dislike of trailers. + let (upstream, seen) = spawn_h3_recorder(&cert, &key).await; + let addr = spawn_relay(upstream, "").await; + let answer = request_with_trailer(addr, b"harmless", false, "SNEAKY").await; + assert_eq!(answer.as_deref(), Ok("recorded")); + assert_eq!( + seen.lock().unwrap().as_slice(), + ["x-note=SNEAKY"], + "a trailer section the chain accepted was dropped anyway" + ); +} + +/// An exchange the service is supposed to speak first in is not held waiting for a body. +/// +/// The head is held only until the framing is known, and a client that sends a head and +/// then waits to be answered — a bidirectional RPC, gRPC's among them — never sends one. +/// Holding it there deadlocked the exchange: the proxy waiting for a body, the client +/// waiting for the answer that would tell it what to send. +#[tokio::test] +async fn a_service_that_speaks_first_is_not_waited_out() { + let (cert, key) = self_signed(); + let upstream = spawn_h3_eager(&cert, &key).await; + let addr = spawn_relay(upstream, "").await; + + 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").unwrap().await.unwrap(); + let (mut driver, mut sender) = h3::client::new(h3_quinn::Connection::new(connection)) + .await + .unwrap(); + let driving = tokio::spawn(async move { + let _ = driver.wait_idle().await; + }); + + let outgoing = http::Request::builder() + .method("POST") + .uri("https://localhost/stream") + .body(()) + .unwrap(); + let mut stream = sender.send_request(outgoing).await.unwrap(); + // Deliberately no body and no finish: this client is waiting to be spoken to. + let answered = tokio::time::timeout(Duration::from_secs(5), stream.recv_response()).await; + driving.abort(); + assert!( + answered.is_ok(), + "the head never reached a service that answers without waiting for a body" + ); + assert_eq!(answered.unwrap().unwrap().status(), http::StatusCode::OK); +} + +// --- a service that speaks HTTP/1.1, behind an HTTP/3 edge ---------------------------- +// +// The one place in this engine where what leaves is not the version that arrived. It is +// the operator's choice (`FGEX_PROXY_UPSTREAM`) and it exists because most of the web is +// an HTTP/1.1 service that will never speak QUIC: firegex terminates HTTP/3 in front of +// it and sends on the rendering the filters were already being shown. + +#[tokio::test] +async fn an_http1_service_answers_an_http3_client() { + let upstream = spawn_h1_echo().await; + let addr = spawn_relay_to(upstream, "", Onward::Plain).await; + + let answer = request(addr, "GET", "/hello", None, false).await.unwrap(); + assert_eq!(answer, "you asked for /hello and sent 0 bytes: "); +} + +/// A body, declared — which is the framing that travels as a `content-length` and is +/// read back by the far end as one. +#[tokio::test] +async fn a_declared_body_reaches_an_http1_service() { + let upstream = spawn_h1_echo().await; + let addr = spawn_relay_to(upstream, "", Onward::Plain).await; + + let answer = request(addr, "POST", "/upload", Some(b"twelve bytes"), true) + .await + .unwrap(); + assert_eq!(answer, "you asked for /upload and sent 12 bytes: twelve bytes"); +} + +/// And one that declared nothing, which is chunked in HTTP/1.1 — the framing the chain +/// is shown, so it had better be the framing the service is sent. +#[tokio::test] +async fn an_undeclared_body_reaches_an_http1_service_chunked() { + let upstream = spawn_h1_echo().await; + let addr = spawn_relay_to(upstream, "", Onward::Plain).await; + + let answer = request(addr, "POST", "/upload", Some(b"twelve bytes"), false) + .await + .unwrap(); + assert_eq!(answer, "you asked for /upload and sent 12 bytes: twelve bytes"); +} + +/// The promise that makes the whole thing worth having: one filter file, and it does not +/// care which version either end speaks. The same pattern that blocks this request +/// against an HTTP/3 service blocks it against an HTTP/1.1 one. +#[tokio::test] +async fn a_pattern_matches_with_an_http1_service_behind() { + let upstream = spawn_h1_echo().await; + let addr = spawn_relay_to(upstream, "block:GET /admin", Onward::Plain).await; + + assert!( + request(addr, "GET", "/admin", None, false).await.is_err(), + "the request reached an HTTP/1.1 service that a pattern should have refused" + ); + 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:?}"); +} + +/// One GET through the relay carrying `headers`, and the answer's status, head and body. +async fn ask_with( + addr: SocketAddr, + headers: &[(&'static str, &'static str)], +) -> Result<(u16, http::HeaderMap, String), String> { + let mut config = tls::quic_client_config().unwrap(); + config.alpn_protocols = vec![b"h3".to_vec()]; + let mut endpoint = quinn::Endpoint::client("127.0.0.1:0".parse().unwrap()).unwrap(); + endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new( + QuicClientConfig::try_from(config).unwrap(), + ))); + let connection = endpoint + .connect(addr, "localhost") + .map_err(|e| e.to_string())? + .await + .map_err(|e| e.to_string())?; + let (mut driver, mut sender) = h3::client::new(h3_quinn::Connection::new(connection)) + .await + .map_err(|e| e.to_string())?; + let driving = tokio::spawn(async move { + let _ = driver.wait_idle().await; + }); + let answer = async { + let mut builder = http::Request::builder().method("GET").uri("https://localhost/who"); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + let outgoing = builder.body(()).map_err(|e| e.to_string())?; + let mut stream = sender.send_request(outgoing).await.map_err(|e| e.to_string())?; + stream.finish().await.map_err(|e| e.to_string())?; + let response = stream.recv_response().await.map_err(|e| e.to_string())?; + let mut got = Vec::new(); + while let Some(mut chunk) = stream.recv_data().await.map_err(|e| e.to_string())? { + let len = chunk.remaining(); + got.extend_from_slice(&chunk.copy_to_bytes(len)); + } + Ok::<_, String>(( + response.status().as_u16(), + response.headers().clone(), + String::from_utf8_lossy(&got).into_owned(), + )) + } + .await; + driving.abort(); + answer +} + +/// A header about a connection is malformed in HTTP/3, and the h3 crate does not refuse +/// it. Carried to a service reached over HTTP/1.1 it would be read there as a statement +/// about *that* connection — which headers to drop, which transfer coding to undo — and a +/// filter shown the rendering would not recognise the request the service got. +#[tokio::test] +async fn a_connection_header_is_refused_over_http3() { + let upstream = spawn_h1_echo().await; + let addr = spawn_relay_to(upstream, "", Onward::Plain).await; + + let trailers = ask_with(addr, &[("te", "trailers")]).await; + assert!(trailers.is_ok(), "`te: trailers`, the one allowed, was refused: {trailers:?}"); + for header in [("transfer-encoding", "chunked"), ("connection", "x-forwarded-for"), + ("keep-alive", "timeout=5"), ("upgrade", "websocket"), ("te", "gzip")] { + let refused = tokio::time::timeout(Duration::from_secs(5), ask_with(addr, &[header])) + .await + .expect("a malformed request was left hanging"); + assert!(refused.is_err(), "a request carrying {header:?} was carried: {refused:?}"); + } +} + +/// What an HTTP/1.1 service answers with says things about its own connection — a body +/// sent chunked, a connection offered for reuse — that an HTTP/3 client is required to +/// reject a response for. They are the service's, and they stay behind. +#[tokio::test] +async fn an_http1_answer_reaches_an_http3_client_without_its_connection_headers() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let upstream = listener.local_addr().unwrap(); + tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + tokio::spawn(async move { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut buf = [0u8; 4096]; + let _ = socket.read(&mut buf).await; + let _ = socket + .write_all( + b"HTTP/1.1 200 OK\r\nconnection: keep-alive, x-internal\r\n\ + keep-alive: timeout=5\r\nx-internal: secret\r\n\ + content-type: text/plain\r\ntransfer-encoding: chunked\r\n\r\n\ + 5\r\nhello\r\n0\r\n\r\n", + ) + .await; + }); + } + }); + let addr = spawn_relay_to(upstream, "", Onward::Plain).await; + + let (_, headers, body) = ask_with(addr, &[]).await.unwrap(); + assert_eq!(body, "hello"); + for name in ["connection", "keep-alive", "transfer-encoding", "x-internal"] { + assert!(!headers.contains_key(name), "`{name}` reached the HTTP/3 client: {headers:?}"); + } + assert_eq!(headers.get("content-type").map(|v| v.as_bytes()), Some(&b"text/plain"[..])); +} + +/// A head is bounded, which the h3 crate does not do by default at all. +#[tokio::test] +async fn a_head_past_the_limit_is_refused_over_http3() { + let upstream = spawn_h1_echo().await; + let addr = spawn_relay_to(upstream, "", Onward::Plain).await; + let big: &'static str = Box::leak("a".repeat(2 * 1024 * 1024).into_boxed_str()); + let refused = tokio::time::timeout(Duration::from_secs(10), ask_with(addr, &[("x-big", big)])) + .await + .expect("an oversized head was left hanging"); + // The crate answers `431 Request Header Fields Too Large` itself, and nothing is sent on. + assert!( + !matches!(refused, Ok((200, _, _))), + "a 2 MiB head was carried: {:?}", + refused.map(|r| r.2) + ); + let large: &'static str = Box::leak("a".repeat(256 * 1024).into_boxed_str()); + assert!( + matches!(ask_with(addr, &[("x-large", large)]).await, Ok((200, _, _))), + "a 256 KiB head was refused" + ); +} diff --git a/backend/proxysrc/tests/pyworker.rs b/backend/proxysrc/tests/pyworker.rs new file mode 100644 index 0000000..e917f46 --- /dev/null +++ b/backend/proxysrc/tests/pyworker.rs @@ -0,0 +1,609 @@ +//! User Python, and the promise that moving it out of process was worth it. +//! +//! The engine's own deadline can only walk away from a blocking thread; the thread +//! keeps its pool slot until the process restarts, which `fail_open.rs` asserts and +//! the README calls out. A child process is different: it can be killed. These tests +//! are the difference being real rather than claimed. +//! +//! Every filter here is written against the real `firegex.pyfilters` API — parameters +//! annotated with a model, verdicts from the library — because that is the only API +//! there is. These tests used to use a look-alike that took `(data, direction)`, which +//! meant they passed against something no user could have written from the docs. + +use std::io::Write; +use std::time::{Duration, Instant}; + +use fgex_proxy::filter::{ + next_connection_id, ChainSessions, ConnectionMeta, Direction, FilterChain, Verdict, L4, +}; +use fgex_proxy::rules::{parse_ruleset, parse_ruleset_reusing}; + +/// Write a filter file and give back its path, kept for the process's lifetime. +fn filter_file(name: &str, body: &str) -> String { + let path = std::env::temp_dir().join(format!("fgex-pyworker-{name}.py")); + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(body.as_bytes()).unwrap(); + path.to_string_lossy().into_owned() +} + +/// The worker the backend ships, found relative to this crate. +fn worker() -> String { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../modules/services/pyworker.py") + .to_string_lossy() + .into_owned() +} + +fn chain_for(path: &str, timeout_ms: u64) -> FilterChain { + chain_with_timeout(path, None, timeout_ms) +} + +/// A chain over one python file, saying which of its functions are switched on. +/// +/// `None` leaves the field out entirely, which means every function the file defines; +/// `Some(&[])` switches all of them off. The two are deliberately different answers. +fn chain_with(path: &str, enabled: Option<&[&str]>) -> FilterChain { + chain_with_timeout(path, Some(enabled), 2000) +} + +fn chain_with_timeout( + path: &str, + enabled: Option>, + timeout_ms: u64, +) -> FilterChain { + let selection = match enabled { + None | Some(None) => String::new(), + Some(Some(names)) => format!( + r#""enabled":[{}],"#, + names + .iter() + .map(|n| format!("\"{n}\"")) + .collect::>() + .join(",") + ), + }; + let json = format!( + r#"[{{"kind":"python","id":"py1","code_path":"{path}","timeout_ms":{timeout_ms}, + {selection}"command":["python3","{}"]}}]"#, + worker() + ); + FilterChain::new( + parse_ruleset(&json).unwrap(), + // Well past the worker's own, so the worker's deadline is what fires and the + // chain is not the thing masking it. + Duration::from_millis(timeout_ms * 4 + 2000), + ) +} + +async fn feed(c: &FilterChain, chunks: &[&[u8]]) -> Vec { + feed_from(c, chunks, None).await +} + +/// Feed chunks, optionally telling the chain who is talking to whom first. +async fn feed_from( + c: &FilterChain, + chunks: &[&[u8]], + meta: Option, +) -> Vec { + let connection = next_connection_id(); + if let Some(meta) = meta { + c.connection_opened(connection, &meta); + } + let mut sessions = ChainSessions::new(connection); + let mut out = Vec::new(); + for chunk in chunks { + out.push(c.run(Direction::ClientToServer, chunk, &mut sessions).await); + } + out +} + +#[tokio::test] +async fn a_python_filter_blocks_and_passes() { + let path = filter_file( + "block", + r#" +from firegex.pyfilters import pyfilter, ACCEPT, REJECT +from firegex.pyfilters.models import RawPacket + +@pyfilter +def block_traversal(packet: RawPacket): + return REJECT if b"../" in packet.data else ACCEPT +"#, + ); + let c = chain_for(&path, 1000); + + assert_eq!(feed(&c, &[b"GET /index"]).await, vec![Verdict::Accept]); + assert_eq!( + feed(&c, &[b"GET /../../etc/passwd"]).await, + vec![Verdict::Reject(Some("py1/block_traversal".to_string()))] + ); +} + +/// The headline. A filter that never returns is killed, the chunk it was holding +/// goes through, and the next chunk gets a fresh worker rather than a dead engine. +#[tokio::test] +async fn a_hanging_python_filter_is_killed_and_traffic_continues() { + let path = filter_file( + "hang", + r#" +import time +from firegex.pyfilters import pyfilter, ACCEPT +from firegex.pyfilters.models import RawPacket + +# Module level, so it is per connection: the library gives each stream its own globals. +seen = 0 + +@pyfilter +def sometimes_hangs(packet: RawPacket): + global seen + seen += 1 + if seen == 1: + time.sleep(3600) + return ACCEPT +"#, + ); + let c = chain_for(&path, 300); + + let started = Instant::now(); + assert_eq!(feed(&c, &[b"first"]).await, vec![Verdict::Accept]); + assert!( + started.elapsed() < Duration::from_secs(3), + "the chunk waited on the worker instead of failing open (took {:?})", + started.elapsed() + ); + + // A fresh worker takes over, so filtering resumes instead of being lost for good. + assert_eq!(feed(&c, &[b"second"]).await, vec![Verdict::Accept]); + assert_eq!(feed(&c, &[b"third"]).await, vec![Verdict::Accept]); + // The chain never had to disable anything: the worker absorbed the failure. + assert!(!c.is_bypassed()); + assert!(c.disabled_filters().is_empty()); +} + +/// Telling the worker about a connection is not allowed to wait on it. +/// +/// The open and close frames are sent from the relay itself, on the async runtime. They +/// used to take the worker's lock there — held by whatever exchange was in progress, up +/// to its whole deadline — so one connection feeding a hanging filter held every new +/// connection of the service on the threads that carry all the others. +#[tokio::test] +async fn a_connection_opening_does_not_wait_on_a_hanging_filter() { + let path = filter_file( + "hang-open", + r#" +import time +from firegex.pyfilters import pyfilter, ACCEPT +from firegex.pyfilters.models import RawPacket + +@pyfilter +def hangs_on_request(packet: RawPacket): + if b"HANG" in packet.data: + time.sleep(3600) + return ACCEPT +"#, + ); + let c = std::sync::Arc::new(chain_for(&path, 1500)); + // Started first, so the worker is up and the only thing held is the exchange. + assert_eq!(feed(&c, &[b"warm"]).await, vec![Verdict::Accept]); + + let stuck = { + let c = std::sync::Arc::clone(&c); + tokio::spawn(async move { feed(&c, &[b"HANG"]).await }) + }; + tokio::time::sleep(Duration::from_millis(300)).await; + + let meta = ConnectionMeta { + client: "10.0.0.9:51000".parse().unwrap(), + server: "10.0.0.1:8080".parse().unwrap(), + l4: L4::Tcp, + }; + let started = Instant::now(); + let other = next_connection_id(); + c.connection_opened(other, &meta); + c.connection_closed(other); + assert!( + started.elapsed() < Duration::from_millis(200), + "a new connection waited {:?} on another connection's hanging filter", + started.elapsed() + ); + + assert_eq!(stuck.await.unwrap(), vec![Verdict::Accept]); +} + +/// A filter that crashes on one chunk must not cost the ones after it. +#[tokio::test] +async fn a_crashing_python_filter_recovers() { + let path = filter_file( + "crash", + r#" +import os +from firegex.pyfilters import pyfilter, ACCEPT, REJECT +from firegex.pyfilters.models import RawPacket + +@pyfilter +def dies_once(packet: RawPacket): + if b"boom" in packet.data: + os._exit(1) + return REJECT if b"deny" in packet.data else ACCEPT +"#, + ); + let c = chain_for(&path, 500); + + assert_eq!(feed(&c, &[b"hello"]).await, vec![Verdict::Accept]); + // The worker dies mid-answer: the chunk is forwarded rather than held. + assert_eq!(feed(&c, &[b"boom"]).await, vec![Verdict::Accept]); + // And the replacement is enforcing again. + assert_eq!( + feed(&c, &[b"deny me"]).await, + vec![Verdict::Reject(Some("py1/dies_once".to_string()))] + ); +} + +/// A filter file that does not even import must not take the traffic with it. +#[tokio::test] +async fn a_worker_that_cannot_start_fails_open() { + let path = filter_file("broken", "this is not python at all(((\n"); + let c = chain_for(&path, 500); + assert_eq!(feed(&c, &[b"still moving"]).await, vec![Verdict::Accept]); +} + +/// A filter that raises costs the chunk its filtering, and nothing else. +/// +/// The library runs the chain in one pass and does not isolate a filter that raises: +/// the exception aborts the whole chain for that packet, which is also what happens on +/// NFQUEUE. So the honest promise is not "the other filters still get a say", it is +/// "the traffic keeps moving and the next chunk is filtered again". Asserting the +/// former would be asserting a behaviour no transport actually has. +#[tokio::test] +async fn a_raising_filter_fails_open_and_the_worker_survives() { + let path = filter_file( + "raises", + r#" +from firegex.pyfilters import pyfilter, ACCEPT, REJECT +from firegex.pyfilters.models import RawPacket + +@pyfilter +def sometimes_raises(packet: RawPacket): + if b"boom" in packet.data: + raise ValueError("boom") + return REJECT if b"deny" in packet.data else ACCEPT +"#, + ); + let c = chain_for(&path, 1000); + + assert_eq!(feed(&c, &[b"fine"]).await, vec![Verdict::Accept]); + // The chunk that made it raise goes through rather than being held. + assert_eq!(feed(&c, &[b"boom"]).await, vec![Verdict::Accept]); + // And the worker is still there, still enforcing. + assert_eq!( + feed(&c, &[b"deny me"]).await, + vec![Verdict::Reject(Some("py1/sometimes_raises".to_string()))] + ); + assert!(!c.is_bypassed()); + assert!(c.disabled_filters().is_empty()); +} + +/// A filter that prints must not break the protocol. +/// +/// `print()` is the first thing anybody reaches for while debugging a filter, and the +/// worker shares its process with the user's code. On stdout that text lands inside a +/// length-prefixed frame: the engine reads it as a frame header, sees an absurd length +/// and kills the worker — so the filter is restarted on every packet, filters nothing, +/// and says nothing about why. The worker sends the user's output to stderr instead. +#[tokio::test] +async fn a_filter_that_prints_keeps_working() { + let path = filter_file( + "chatty", + r#" +from firegex.pyfilters import pyfilter, ACCEPT, REJECT +from firegex.pyfilters.models import RawPacket + +@pyfilter +def noisy(packet: RawPacket): + print("saw", len(packet.data), "bytes") + return REJECT if b"deny" in packet.data else ACCEPT +"#, + ); + let c = chain_for(&path, 1000); + + assert_eq!(feed(&c, &[b"hello"]).await, vec![Verdict::Accept]); + // Still enforcing after having printed, which it would not be if the output had + // gone down the protocol channel and cost the worker its life. + assert_eq!( + feed(&c, &[b"deny me"]).await, + vec![Verdict::Reject(Some("py1/noisy".to_string()))] + ); + assert_eq!(feed(&c, &[b"and again"]).await, vec![Verdict::Accept]); + assert_eq!( + feed(&c, &[b"deny me too"]).await, + vec![Verdict::Reject(Some("py1/noisy".to_string()))] + ); +} + +/// The documented promise: each stream gets its own module globals. +/// +/// This is why every frame carries a connection id. Sharing one set of globals across +/// connections would let one client's history decide another client's verdict — a +/// false positive that only shows up under concurrent traffic, and a way to smuggle a +/// pattern past a stateful filter by splitting it over two connections. +#[tokio::test] +async fn each_connection_gets_its_own_globals() { + let path = filter_file( + "percall", + r#" +from firegex.pyfilters import pyfilter, ACCEPT, REJECT +from firegex.pyfilters.models import RawPacket + +seen = 0 + +@pyfilter +def second_chunk_is_refused(packet: RawPacket): + global seen + seen += 1 + return REJECT if seen > 1 else ACCEPT +"#, + ); + let c = chain_for(&path, 1000); + + let first = next_connection_id(); + let mut a = ChainSessions::new(first); + assert_eq!( + c.run(Direction::ClientToServer, b"one", &mut a).await, + Verdict::Accept + ); + assert_eq!( + c.run(Direction::ClientToServer, b"two", &mut a).await, + Verdict::Reject(Some("py1/second_chunk_is_refused".to_string())), + "the counter did not survive between chunks of one connection" + ); + + // A different connection starts from zero, whatever the first one did. + let mut b = ChainSessions::new(next_connection_id()); + assert_eq!( + c.run(Direction::ClientToServer, b"one", &mut b).await, + Verdict::Accept, + "one connection's globals leaked into another's" + ); + + // Closing releases them, so a long-lived service does not accumulate one set of + // globals per connection it has ever carried. + c.connection_closed(first); + let mut again = ChainSessions::new(first); + assert_eq!( + c.run(Direction::ClientToServer, b"one", &mut again).await, + Verdict::Accept, + "the closed connection's globals were still there" + ); +} + +/// Python and regex rules coexist: the compiled ones run inline, the worker does not. +#[tokio::test] +async fn python_and_regex_rules_run_together() { + let path = filter_file( + "coexist", + r#" +from firegex.pyfilters import pyfilter, ACCEPT, REJECT +from firegex.pyfilters.models import RawPacket + +@pyfilter +def block_py(packet: RawPacket): + return REJECT if b"by-python" in packet.data else ACCEPT +"#, + ); + let json = format!( + r#"[{{"kind":"regex","id":"re1","pattern":"by-regex"}}, + {{"kind":"python","id":"py1","code_path":"{path}","timeout_ms":1000, + "command":["python3","{}"]}}]"#, + worker() + ); + let c = FilterChain::new(parse_ruleset(&json).unwrap(), Duration::from_secs(5)); + + assert_eq!(feed(&c, &[b"harmless"]).await, vec![Verdict::Accept]); + assert_eq!( + feed(&c, &[b"blocked by-regex here"]).await, + vec![Verdict::Reject(Some("re1".to_string()))] + ); + assert_eq!( + feed(&c, &[b"blocked by-python here"]).await, + vec![Verdict::Reject(Some("py1/block_py".to_string()))] + ); +} + +/// A filter reads the addresses, and cannot read anything below the application layer. +/// +/// This is the boundary the whole model rests on: metadata in, payload out. It also has +/// to be *real* metadata — the proxy terminated the connection, so the headers on the +/// wire are the engine's own, and an earlier version handed the filter a literal +/// `FAKE:IP:TCP:HEADERS:` prefix instead. A filter written against that did one thing +/// here and a different thing on NFQUEUE. +#[tokio::test] +async fn a_filter_is_told_who_is_talking_to_whom() { + let path = filter_file( + "endpoints", + r#" +from firegex.pyfilters import pyfilter, ACCEPT, REJECT +from firegex.pyfilters.models import RawPacket + +@pyfilter +def only_from_that_client(packet: RawPacket): + if packet.client_ip != "10.0.0.9" or packet.client_port != 51000: + return REJECT + if packet.server_ip != "10.0.0.1" or packet.server_port != 8080: + return REJECT + # There is nothing below the application layer to read, and asking for it fails + # rather than answering with something invented. + if hasattr(packet, "raw_packet"): + return REJECT + return ACCEPT +"#, + ); + let c = chain_for(&path, 2000); + let meta = ConnectionMeta { + client: "10.0.0.9:51000".parse().unwrap(), + server: "10.0.0.1:8080".parse().unwrap(), + l4: L4::Tcp, + }; + assert_eq!( + feed_from(&c, &[b"hello"], Some(meta)).await, + vec![Verdict::Accept], + "the filter did not see the addresses it was told about" + ); +} + +/// The protocol a filter file speaks is read off the file, not passed to it. +/// +/// Nothing in the ruleset says `http`, and the worker is started with the path alone. +/// The file is an HTTP filter because it asks for an `HttpRequest`, and a `RawPacket` +/// filter sits beside it in the same file because `http` provides both. +#[tokio::test] +async fn the_protocol_is_read_off_the_code() { + let path = filter_file( + "detected", + r#" +from firegex.pyfilters import pyfilter, ACCEPT, REJECT +from firegex.pyfilters.models import HttpRequest, RawPacket + +@pyfilter +def refuse_traversal(request: HttpRequest): + return REJECT if "../" in (request.url or "") else ACCEPT + +@pyfilter +def look_at_the_bytes(packet: RawPacket): + return ACCEPT +"#, + ); + let c = chain_for(&path, 2000); + assert_eq!( + feed(&c, &[b"GET /shop HTTP/1.1\r\nHost: x\r\n\r\n"]).await, + vec![Verdict::Accept] + ); + assert_eq!( + feed(&c, &[b"GET /../../etc/passwd HTTP/1.1\r\nHost: x\r\n\r\n"]).await, + vec![Verdict::Reject(Some("py1/refuse_traversal".to_string()))] + ); +} + +/// A file holds several functions, and each one can be switched off on its own. +/// +/// The code is untouched: only the list of names the library is given changes, and that +/// list is what decides whether a function is ever called. Deleting the code to stop +/// consulting a filter, and pasting it back to resume, is what this replaces. +#[tokio::test] +async fn one_function_of_a_file_can_be_switched_off() { + let path = filter_file( + "selectable", + r#" +from firegex.pyfilters import pyfilter, ACCEPT, REJECT +from firegex.pyfilters.models import RawPacket + +@pyfilter +def refuse_a(packet: RawPacket): + return REJECT if b"AAA" in packet.data else ACCEPT + +@pyfilter +def refuse_b(packet: RawPacket): + return REJECT if b"BBB" in packet.data else ACCEPT +"#, + ); + + // Nothing said: every function the file defines runs. + let all = chain_with(&path, None); + assert_eq!( + feed(&all, &[b"carrying AAA"]).await, + vec![Verdict::Reject(Some("py1/refuse_a".to_string()))], + "a block has to name the function that made it, not just the file" + ); + assert_eq!( + feed(&all, &[b"carrying BBB"]).await, + vec![Verdict::Reject(Some("py1/refuse_b".to_string()))] + ); + + // One of the two: the other is still in the file, and no longer consulted. + let only_a = chain_with(&path, Some(&["refuse_a"])); + assert_eq!( + feed(&only_a, &[b"carrying AAA"]).await, + vec![Verdict::Reject(Some("py1/refuse_a".to_string()))] + ); + assert_eq!( + feed(&only_a, &[b"carrying BBB"]).await, + vec![Verdict::Accept], + "a switched-off function must not decide anything" + ); + + // An empty list is not the same as saying nothing: the operator switched every one + // of them off, and the file inspects nothing until one is switched back on. + let none: [&str; 0] = []; + let off = chain_with(&path, Some(&none)); + assert_eq!(feed(&off, &[b"carrying AAA"]).await, vec![Verdict::Accept]); + assert_eq!(feed(&off, &[b"carrying BBB"]).await, vec![Verdict::Accept]); +} + +/// An edit elsewhere in the chain must not restart a Python filter that did not change. +/// +/// Every edit sends the whole ruleset, and a new worker meets every open connection +/// halfway: its module globals gone, its HTTP parser starting on the second half of the +/// request in flight. The same filter, described the same way, is carried over with the +/// state it keeps for each connection; new code is a new worker, as it has to be. +#[tokio::test] +async fn an_unchanged_worker_is_carried_into_the_next_ruleset_with_its_state() { + const REMEMBERS: &str = r#"from firegex.pyfilters import pyfilter, ACCEPT, REJECT +from firegex.pyfilters.models import RawPacket + +# Module level, so it is per connection: the library gives each stream its own globals. +said_first = False + +@pyfilter +def second_after_first(packet: RawPacket): + global said_first + if b"FIRST" in packet.data: + said_first = True + return REJECT if said_first and b"SECOND" in packet.data else ACCEPT +"#; + let path = filter_file("carried", REMEMBERS); + let json = format!( + r#"[{{"kind":"python","id":"py1","code_path":"{path}","timeout_ms":2000, + "command":["python3","{}"]}}]"#, + worker() + ); + let deadline = Duration::from_secs(10); + let connection = next_connection_id(); + + let before = FilterChain::new(parse_ruleset(&json).unwrap(), deadline); + let mut sessions = ChainSessions::new(connection); + assert_eq!( + before.run(Direction::ClientToServer, b"FIRST", &mut sessions).await, + Verdict::Accept + ); + + // The same ruleset again, as an edit to some other filter would send it. + let after = FilterChain::new( + parse_ruleset_reusing(&json, &before.filters()).unwrap(), + deadline, + ); + assert!( + std::sync::Arc::ptr_eq(&before.filters()[0], &after.filters()[0]), + "an unchanged Python filter was rebuilt" + ); + let mut sessions = ChainSessions::new(connection); + assert_eq!( + after.run(Direction::ClientToServer, b"SECOND", &mut sessions).await, + Verdict::Reject(Some("py1/second_after_first".to_string())), + "the connection's state did not survive an edit that did not touch this filter" + ); + + // New code is a new worker, and starts every connection afresh. + filter_file("carried", &format!("{REMEMBERS}\n# edited\n")); + let edited = FilterChain::new( + parse_ruleset_reusing(&json, &after.filters()).unwrap(), + deadline, + ); + assert!( + !std::sync::Arc::ptr_eq(&after.filters()[0], &edited.filters()[0]), + "edited code was not given a worker of its own" + ); + let mut sessions = ChainSessions::new(connection); + assert_eq!( + edited.run(Direction::ClientToServer, b"SECOND", &mut sessions).await, + Verdict::Accept + ); +} diff --git a/backend/proxysrc/tests/quic.rs b/backend/proxysrc/tests/quic.rs new file mode 100644 index 0000000..d968529 --- /dev/null +++ b/backend/proxysrc/tests/quic.rs @@ -0,0 +1,402 @@ +//! QUIC terminated by the engine, and the three things that has to get right. +//! +//! A client and a service, both speaking real QUIC, either side of the relay. What is +//! being proved is not that bytes arrive — it is that a rule can see them at all, which +//! on this transport is only true because the connection is terminated: on the wire the +//! stream boundaries and the frames are encrypted along with the payload. +//! +//! The protocol negotiated here is deliberately **not** `h3`. These are the tests for a +//! QUIC service that speaks something of its own, where a stream is a stream of bytes and +//! nothing reads them but the chain; HTTP/3 is carried by a module that understands it, +//! and is tested in `h3.rs`. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use fgex_proxy::filter::{ChainHandle, FilterChain}; +use fgex_proxy::proxy::ProxyStats; +use fgex_proxy::proxy::Onward; +use fgex_proxy::quic::{QuicConfig, QuicRelay, QuicSetup}; +use fgex_proxy::spec::parse_filters; +use fgex_proxy::tls; +use quinn::crypto::rustls::{QuicClientConfig, QuicServerConfig}; + +/// Generated per run: no key material belongs in the repository. +fn self_signed() -> (String, String) { + let cert = + rcgen::generate_simple_self_signed(vec!["localhost".to_string(), "127.0.0.1".to_string()]) + .unwrap(); + (cert.cert.pem(), cert.key_pair.serialize_pem()) +} + +fn alpn(list: &[&str]) -> Vec> { + list.iter().map(|p| p.as_bytes().to_vec()).collect() +} + +/// A service that speaks QUIC and echoes every stream, standing in for the real one. +async fn spawn_quic_echo(cert: &str, key: &str, speaks: &[&str]) -> SocketAddr { + let mut config = (*tls::server_config(cert, key).unwrap()).clone(); + config.alpn_protocols = alpn(speaks); + let crypto = QuicServerConfig::try_from(config).unwrap(); + let endpoint = quinn::Endpoint::server( + quinn::ServerConfig::with_crypto(Arc::new(crypto)), + "127.0.0.1:0".parse().unwrap(), + ) + .unwrap(); + let addr = endpoint.local_addr().unwrap(); + tokio::spawn(async move { + while let Some(incoming) = endpoint.accept().await { + tokio::spawn(async move { + let Ok(connection) = incoming.await else { + return; + }; + // Datagrams, echoed the same way the streams are: their own task, so a + // service that is answering one is still accepting the other. + let datagrams = connection.clone(); + tokio::spawn(async move { + while let Ok(datagram) = datagrams.read_datagram().await { + if datagrams.send_datagram(datagram).is_err() { + return; + } + } + }); + while let Ok((mut send, mut recv)) = connection.accept_bi().await { + tokio::spawn(async move { + let mut buf = vec![0u8; 16 * 1024]; + while let Ok(Some(n)) = recv.read(&mut buf).await { + if send.write_all(&buf[..n]).await.is_err() { + return; + } + } + let _ = send.finish(); + }); + } + }); + } + }); + addr +} + +/// The relay, in front of that service. +async fn spawn_relay(upstream: SocketAddr, filters: &str) -> SocketAddr { + let (cert, key) = self_signed(); + let chain = ChainHandle::new(FilterChain::new( + parse_filters(filters).unwrap(), + Duration::from_millis(500), + )); + let cfg = QuicConfig { + setup: Arc::new(QuicSetup::build(&cert, &key).unwrap()), + // No mark and no impersonation: `SO_MARK` and `IP_TRANSPARENT` both need + // CAP_NET_ADMIN, which a test run has no business requiring. What they change is + // which address the service sees, and nothing here is asking about that. + self_mark: None, + spoof_source: false, + max_connections: 0, + over_limit_forwards: false, + first_byte_timeout: None, + connect_timeout: Duration::from_secs(5), + upstream: Onward::Same, + // Nothing to write a reconstruction to, which is the ordinary case on any host + // without the capture interface and the case every test here runs in. + capture: None, + }; + let relay = QuicRelay::bind( + "127.0.0.1:0".parse().unwrap(), + upstream, + chain, + cfg, + Arc::new(ProxyStats::default()), + ) + .unwrap(); + let addr = relay.local_addr().unwrap(); + tokio::spawn(Arc::new(relay).serve()); + addr +} + +/// One message, and every message underneath it. +/// +/// quinn nests the reason: a read failing because the connection went away says only +/// "connection lost", and *why* it was lost — the application close, with the reason +/// firegex put in it — is one `source()` further down. A test that looked at the top +/// line alone could not tell a block from a crash. +fn because(error: &dyn std::error::Error) -> String { + let mut parts = vec![error.to_string()]; + let mut current = error.source(); + while let Some(inner) = current { + parts.push(inner.to_string()); + current = inner.source(); + } + parts.join(": ") +} + +struct Exchange { + /// What the client ended up speaking — which is what the *service* chose. + protocol: Option, + echoed: Result, String>, +} + +/// One client, one stream, one answer. +async fn roundtrip(addr: SocketAddr, payload: &[u8], speaks: &[&str]) -> Exchange { + let mut config = tls::quic_client_config().unwrap(); + config.alpn_protocols = alpn(speaks); + 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 connecting = endpoint.connect(addr, "localhost").unwrap(); + let connection = match tokio::time::timeout(Duration::from_secs(5), connecting).await { + Ok(Ok(connection)) => connection, + Ok(Err(e)) => { + return Exchange { + protocol: None, + echoed: Err(e.to_string()), + } + } + Err(_) => { + return Exchange { + protocol: None, + echoed: Err("the handshake timed out".to_string()), + } + } + }; + let protocol = connection + .handshake_data() + .and_then(|d| d.downcast::().ok()) + .and_then(|d| d.protocol) + .map(|p| String::from_utf8_lossy(&p).into_owned()); + + let echoed = async { + let (mut send, mut recv) = connection.open_bi().await.map_err(|e| because(&e))?; + send.write_all(payload).await.map_err(|e| because(&e))?; + send.finish().map_err(|e| because(&e))?; + recv.read_to_end(64 * 1024).await.map_err(|e| because(&e)) + } + .await; + + Exchange { protocol, echoed } +} + +/// The whole point: a stream goes through, and it went through something that decrypted +/// it on the way. +/// +/// It also exercises the Retry this relay answers a new client with — every first +/// connection from an address arrives unvalidated, so a malformed token would show up +/// here as a handshake that never completes rather than as a subtle weakness. +#[tokio::test] +async fn carries_a_stream_through() { + let (cert, key) = self_signed(); + let upstream = spawn_quic_echo(&cert, &key, &["fgex-raw"]).await; + let addr = spawn_relay(upstream, "").await; + + let exchange = roundtrip(addr, b"hello over quic", &["fgex-raw"]).await; + assert_eq!(exchange.echoed.unwrap(), b"hello over quic"); +} + +/// The reason terminating it is worth the trouble: a rule matches bytes that on the wire +/// were inside an encrypted packet, with the frame headers encrypted too. +#[tokio::test] +async fn rules_see_the_plaintext_inside_the_streams() { + let (cert, key) = self_signed(); + let upstream = spawn_quic_echo(&cert, &key, &["fgex-raw"]).await; + let addr = spawn_relay(upstream, "block:FLAG{").await; + + let harmless = roundtrip(addr, b"harmless request", &["fgex-raw"]).await; + assert_eq!(harmless.echoed.unwrap(), b"harmless request"); + + let blocked = roundtrip(addr, b"give me FLAG{secret}", &["fgex-raw"]).await; + let error = blocked + .echoed + .expect_err("a rule let the payload through, or the connection was left open"); + // Closed on purpose, with a reason the client can read — not a stream reset and not + // a silence to time out on. + assert!( + error.contains("blocked by firegex"), + "the client was not told why: {error}" + ); +} + +/// A refusal ends the connection, not just the stream that carried it. +/// +/// The alternative — resetting one stream — would leave the client free to ask again on +/// the next one, which is not what a block means anywhere else in firegex. +#[tokio::test] +async fn a_refusal_takes_the_whole_connection() { + let (cert, key) = self_signed(); + let upstream = spawn_quic_echo(&cert, &key, &["fgex-raw"]).await; + let addr = spawn_relay(upstream, "block:FLAG{").await; + + let mut config = tls::quic_client_config().unwrap(); + config.alpn_protocols = alpn(&["fgex-raw"]); + 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").unwrap().await.unwrap(); + + // One stream says something forbidden... + let (mut send, mut recv) = connection.open_bi().await.unwrap(); + send.write_all(b"here is a FLAG{x}").await.unwrap(); + let _ = send.finish(); + let _ = recv.read_to_end(4096).await; + + // ...and a second one, on the same connection, has nowhere to go. + let opened = tokio::time::timeout(Duration::from_secs(2), connection.open_bi()).await; + assert!( + matches!(opened, Ok(Err(_))), + "the connection outlived the block" + ); +} + +/// The service picks from what the client offered, and the client is told that and +/// nothing else — the TLS path's mirroring, on QUIC. +/// +/// The relay used to offer the service a list of its own, `h3` unless the operator named +/// others, because the client's hello was taken to be unreadable. It is read off the +/// Initial instead (`quic_hello`), so a service speaking something nobody configured is +/// carried as it is. +#[tokio::test] +async fn the_service_chooses_from_what_the_client_offered() { + let (cert, key) = self_signed(); + let upstream = spawn_quic_echo(&cert, &key, &["fgex-test"]).await; + let addr = spawn_relay(upstream, "").await; + + let exchange = roundtrip(addr, b"ping", &["h3", "fgex-test"]).await; + assert_eq!(exchange.protocol.as_deref(), Some("fgex-test")); + assert_eq!(exchange.echoed.unwrap(), b"ping"); +} + +/// A hello too big for one packet — which post-quantum key shares make ordinary — is read +/// across the packets it came in. +#[tokio::test] +async fn a_hello_spanning_several_packets_is_read_whole() { + let (cert, key) = self_signed(); + let upstream = spawn_quic_echo(&cert, &key, &["fgex-last"]).await; + let addr = spawn_relay(upstream, "").await; + + let filler: Vec = (0..40).map(|i| format!("fgex-unspoken-{i:02}-{}", "x".repeat(40))).collect(); + let mut offers: Vec<&str> = filler.iter().map(String::as_str).collect(); + offers.push("fgex-last"); + let exchange = roundtrip(addr, b"ping", &offers).await; + assert_eq!(exchange.protocol.as_deref(), Some("fgex-last")); + assert_eq!(exchange.echoed.unwrap(), b"ping"); +} + +/// A client that speaks nothing the service does is refused by the service's handshake, +/// the way it would be with no firegex in the way. +#[tokio::test] +async fn a_client_that_speaks_something_else_is_refused() { + let (cert, key) = self_signed(); + let upstream = spawn_quic_echo(&cert, &key, &["fgex-test"]).await; + let addr = spawn_relay(upstream, "").await; + + let exchange = roundtrip(addr, b"ping", &["h3"]).await; + assert!( + exchange.echoed.is_err(), + "a client was told a protocol the service never agreed to" + ); +} + +/// One client connection, for the tests that need more of it than `roundtrip` gives. +async fn connect(addr: SocketAddr, speaks: &[&str]) -> quinn::Connection { + let mut config = tls::quic_client_config().unwrap(); + config.alpn_protocols = alpn(speaks); + 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(), + ))); + endpoint.connect(addr, "localhost").unwrap().await.unwrap() +} + +/// A datagram goes through, and comes back. +#[tokio::test] +async fn datagrams_are_carried_both_ways() { + let (cert, key) = self_signed(); + let upstream = spawn_quic_echo(&cert, &key, &["fgex-raw"]).await; + let addr = spawn_relay(upstream, "").await; + + let connection = connect(addr, &["fgex-raw"]).await; + assert!( + connection.max_datagram_size().is_some(), + "the extension was not advertised to a client that could have used it" + ); + connection + .send_datagram(bytes::Bytes::from_static(b"ping")) + .unwrap(); + let back = tokio::time::timeout(Duration::from_secs(5), connection.read_datagram()) + .await + .expect("the datagram never came back") + .unwrap(); + assert_eq!(&back[..], b"ping"); +} + +/// A rule refusing a datagram drops that datagram, and nothing else. +/// +/// The opposite of what a refusal does to a stream, on purpose: there is no conversation +/// to end, so ending the connection would take a hundred other streams with it over one +/// message that was already complete when it was judged. +#[tokio::test] +async fn a_refused_datagram_is_dropped_and_the_connection_lives() { + let (cert, key) = self_signed(); + let upstream = spawn_quic_echo(&cert, &key, &["fgex-raw"]).await; + let addr = spawn_relay(upstream, "block:forbidden").await; + + let connection = connect(addr, &["fgex-raw"]).await; + connection + .send_datagram(bytes::Bytes::from_static(b"forbidden")) + .unwrap(); + // Nothing comes back, because nothing was forwarded. + assert!( + tokio::time::timeout(Duration::from_millis(500), connection.read_datagram()) + .await + .is_err(), + "a refused datagram reached the service anyway" + ); + + // And the connection is still there to be used. + connection + .send_datagram(bytes::Bytes::from_static(b"allowed")) + .unwrap(); + let back = tokio::time::timeout(Duration::from_secs(5), connection.read_datagram()) + .await + .expect("the connection was closed by a datagram that should only have been dropped") + .unwrap(); + assert_eq!(&back[..], b"allowed"); +} + +/// The datagrams of one connection are one flow, and a filter keeps its state across +/// them. +/// +/// The same answer the plain UDP relay gives — its sessions live on the flow, not on the +/// datagram — and the reason is that the alternative is a way through: a pattern split +/// over two datagrams would otherwise be two halves that match nothing, sent a +/// millisecond apart. What a datagram does *not* get is the stream models built on top of +/// a session, which is `L4::QuicDatagram`'s job and not this one's. +#[tokio::test] +async fn a_datagram_flow_keeps_its_filter_state() { + let (cert, key) = self_signed(); + let upstream = spawn_quic_echo(&cert, &key, &["fgex-raw"]).await; + let addr = spawn_relay(upstream, "block:secret").await; + + let connection = connect(addr, &["fgex-raw"]).await; + connection + .send_datagram(bytes::Bytes::from_static(b"sec")) + .unwrap(); + let back = tokio::time::timeout(Duration::from_secs(5), connection.read_datagram()) + .await + .expect("half a needle is not a needle, and this one was blocked") + .unwrap(); + assert_eq!(&back[..], b"sec"); + + // The half that completes it is refused, though it arrived in a datagram of its own. + connection + .send_datagram(bytes::Bytes::from_static(b"ret")) + .unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(500), connection.read_datagram()) + .await + .is_err(), + "a needle split across two datagrams went through" + ); +} diff --git a/backend/proxysrc/tests/rules.rs b/backend/proxysrc/tests/rules.rs new file mode 100644 index 0000000..909e393 --- /dev/null +++ b/backend/proxysrc/tests/rules.rs @@ -0,0 +1,253 @@ +//! The filters the backend actually configures, and the boundary case that decides +//! whether they are worth anything. + +use std::sync::Arc; +use std::time::Duration; + +use fgex_proxy::filter::{ + next_connection_id, ChainHandle, ChainSessions, Direction, FilterChain, Verdict, +}; +use fgex_proxy::rules::parse_ruleset; + +fn chain(json: &str) -> FilterChain { + FilterChain::new(parse_ruleset(json).unwrap(), Duration::from_millis(500)) +} + +async fn feed(c: &FilterChain, dir: Direction, chunks: &[&[u8]]) -> Vec { + let mut sessions = ChainSessions::new(next_connection_id()); + let mut out = Vec::new(); + for chunk in chunks { + out.push(c.run(dir, chunk, &mut sessions).await); + } + out +} + +/// A block carries the id of the pattern that fired, so the tests can name it. +fn reject(id: &str) -> Verdict { + Verdict::Reject(Some(id.to_string())) +} + +#[tokio::test] +async fn regex_rule_blocks_on_match() { + let c = chain(r#"[{"kind":"regex","id":"r1","pattern":"FLAG\\{[a-z]+\\}"}]"#); + let v = feed(&c, Direction::ClientToServer, &[b"nothing here"]).await; + assert_eq!(v, vec![Verdict::Accept]); + + let v = feed(&c, Direction::ClientToServer, &[b"give me FLAG{abc} now"]).await; + assert_eq!(v, vec![reject("r1")]); +} + +/// The reason the chain keeps a per-direction sessions at all. Split the pattern +/// across two reads and a chunk-only matcher goes blind — which is exactly the +/// evasion nfregex has to reassemble the stream to prevent. +#[tokio::test] +async fn regex_rule_matches_across_a_segment_boundary() { + let c = chain(r#"[{"kind":"regex","id":"r1","pattern":"/etc/passwd"}]"#); + let v = feed( + &c, + Direction::ClientToServer, + &[b"GET /etc/pa", b"sswd HTTP/1.1"], + ) + .await; + assert_eq!( + v, + vec![Verdict::Accept, reject("r1")], + "a pattern split over two reads was missed" + ); +} + +/// The property an overlap window could not have at any size. +/// +/// The previous matcher kept the last 4096 bytes and rescanned them with each chunk, +/// so a match spanning more than that was invisible — and raising the number would +/// have cost every byte of every connection. hyperscan carries the automaton state +/// instead: the span is unbounded and costs nothing per chunk. +#[tokio::test] +async fn regex_rule_matches_across_more_than_the_old_window() { + let c = chain(r#"[{"kind":"regex","id":"r1","pattern":"BEGIN.{9000,}END"}]"#); + let filler = vec![b'.'; 3000]; + let v = feed( + &c, + Direction::ClientToServer, + &[b"BEGIN", &filler, &filler, &filler, &filler, b"END"], + ) + .await; + assert_eq!( + v.last(), + Some(&reject("r1")), + "a match spanning 12000 bytes was missed" + ); +} + +#[tokio::test] +async fn regex_rule_honours_direction() { + // Only the server's answers are inspected: a request mentioning it is fine. + let c = chain(r#"[{"kind":"regex","id":"r1","pattern":"FLAG","direction":"s2c"}]"#); + let v = feed(&c, Direction::ClientToServer, &[b"where is the FLAG"]).await; + assert_eq!(v, vec![Verdict::Accept]); + + let v = feed(&c, Direction::ServerToClient, &[b"here: FLAG{x}"]).await; + assert_eq!(v, vec![reject("r1")]); +} + +#[tokio::test] +async fn regex_rule_can_ignore_case() { + let c = + chain(r#"[{"kind":"regex","id":"r1","pattern":"union select","case_sensitive":false}]"#); + let v = feed(&c, Direction::ClientToServer, &[b"1 UNION SELECT 1"]).await; + assert_eq!(v, vec![reject("r1")]); +} + +#[tokio::test] +async fn a_bad_rule_rejects_the_whole_ruleset() { + // Half a ruleset is worse than the one already running, so nothing is applied. + let bad = + r#"[{"kind":"regex","id":"ok","pattern":"a"},{"kind":"regex","id":"bad","pattern":"("}]"#; + let err = match parse_ruleset(bad) { + Err(e) => e, + Ok(rules) => panic!( + "a broken pattern was accepted ({} rules built)", + rules.len() + ), + }; + assert!(err.contains("invalid regex"), "unhelpful error: {err}"); + + assert!(parse_ruleset("not json").is_err()); + assert!(parse_ruleset(r#"[{"kind":"nope","id":"x"}]"#).is_err()); + assert!(parse_ruleset("[]").unwrap().is_empty()); +} + +/// Rules reach connections that are already open, which is what makes editing them +/// during a competition safe. +#[tokio::test] +async fn a_new_ruleset_reaches_an_open_connection() { + let handle = ChainHandle::new(chain("[]")); + let mut sessions = ChainSessions::new(next_connection_id()); + + let v = handle + .current() + .run(Direction::ClientToServer, b"FLAG{x}", &mut sessions) + .await; + assert_eq!(v, Verdict::Accept); + + handle.replace(chain(r#"[{"kind":"regex","id":"r1","pattern":"FLAG"}]"#)); + + let v = handle + .current() + .run(Direction::ClientToServer, b"FLAG{x}", &mut sessions) + .await; + assert_eq!(v, reject("r1")); +} + +/// The sessions is per connection: one client's traffic must never complete another +/// client's pattern. +#[tokio::test] +async fn windows_do_not_leak_between_connections() { + let c = Arc::new(chain( + r#"[{"kind":"regex","id":"r1","pattern":"/etc/passwd"}]"#, + )); + + let mut first = ChainSessions::new(next_connection_id()); + assert_eq!( + c.run(Direction::ClientToServer, b"GET /etc/pa", &mut first) + .await, + Verdict::Accept + ); + + // A different connection sending the tail must not be blocked by the half a + // previous client left behind. + let mut second = ChainSessions::new(next_connection_id()); + assert_eq!( + c.run(Direction::ClientToServer, b"sswd HTTP/1.1", &mut second) + .await, + Verdict::Accept + ); +} + +/// Blocking rules that share a direction and a case setting are compiled into one +/// automaton, so adding rules stops costing a scan each. Measured against nfregex, +/// the per-rule scan was the difference between 100 MB/s and 2400. +#[tokio::test] +async fn blocking_rules_are_matched_in_one_pass() { + let many: Vec = (0..50) + // Suffixed so no pattern is a prefix of another: the set reports the first + // index that matched, and overlapping patterns would make that ambiguous. + .map(|i| format!(r#"{{"kind":"regex","id":"r{i}","pattern":"needle{i}z"}}"#)) + .collect(); + let filters = parse_ruleset(&format!("[{}]", many.join(","))).unwrap(); + // Two matchers, not fifty: one hyperscan database per direction, each holding + // every pattern that applies that way. A chunk is scanned by exactly one of + // them, so the cost of matching stops depending on how many rules there are — + // the property a per-rule loop gave away and a benchmark caught. + assert_eq!( + filters.len(), + 2, + "50 blocking rules should collapse into one matcher per direction, got {}", + filters.len() + ); + + let c = FilterChain::new(filters, Duration::from_millis(500)); + let v = feed(&c, Direction::ClientToServer, &[b"harmless"]).await; + assert_eq!(v, vec![Verdict::Accept]); + // Still attributed to the individual rule that fired. + let v = feed( + &c, + Direction::ClientToServer, + &[b"here is needle37z for you"], + ) + .await; + assert_eq!(v, vec![reject("r37")]); +} + +/// Rules that cannot share an automaton stay apart, and still work. +#[tokio::test] +async fn only_direction_splits_the_matchers() { + let filters = parse_ruleset( + r#"[{"kind":"regex","id":"a","pattern":"aaa","direction":"c2s"}, + {"kind":"regex","id":"b","pattern":"bbb","direction":"s2c"}, + {"kind":"regex","id":"c","pattern":"ccc","case_sensitive":false}]"#, + ) + .unwrap(); + // Two: one blocking matcher per direction. + // Case-insensitivity does not split anything — hyperscan takes it as a per-pattern flag. + assert_eq!( + filters.len(), + 2, + "one blocking matcher per direction" + ); + + let c = FilterChain::new(filters, Duration::from_millis(500)); + assert_eq!( + feed(&c, Direction::ClientToServer, &[b"aaa"]).await, + vec![reject("a")] + ); + assert_eq!( + feed(&c, Direction::ClientToServer, &[b"bbb"]).await, + vec![Verdict::Accept], + "an s2c rule fired on client traffic" + ); + assert_eq!( + feed(&c, Direction::ServerToClient, &[b"CCC"]).await, + vec![reject("c")] + ); +} + +/// Does the scan actually traverse the whole buffer? Plant the needle at the very +/// last byte of a 64 KiB chunk: a matcher that short-circuits would miss it. +#[tokio::test] +async fn a_needle_at_the_end_of_a_chunk_is_found() { + let rules: Vec = (0..50) + .map(|i| format!(r#"{{"kind":"regex","id":"r{i}","pattern":"needle{i}z"}}"#)) + .collect(); + let c = chain(&format!("[{}]", rules.join(","))); + let mut chunk = vec![b'x'; 64 * 1024]; + let needle = b"needle37z"; + let at = chunk.len() - needle.len(); + chunk[at..].copy_from_slice(needle); + + let mut s = ChainSessions::new(next_connection_id()); + assert_eq!( + c.run(Direction::ClientToServer, &chunk, &mut s).await, + reject("r37") + ); +} diff --git a/backend/proxysrc/tests/throughput.rs b/backend/proxysrc/tests/throughput.rs new file mode 100644 index 0000000..695a45c --- /dev/null +++ b/backend/proxysrc/tests/throughput.rs @@ -0,0 +1,63 @@ +//! What the filter layer costs, in bytes per second. +//! +//! Ignored by default: it measures rather than asserts, and a number that depends on +//! the machine has no business failing a test run. Run it deliberately: +//! +//! ```text +//! cargo test --release --test throughput -- --ignored --nocapture +//! ``` +//! +//! Read the slope, not the absolute number. The buffer is reused and stays in cache, +//! so the throughput here is an upper bound on the matcher alone — real traffic is +//! bounded by sockets long before it is bounded by this. What the benchmark is for is +//! the shape of the curve: matching should cost about the same at two hundred rules +//! as at one, because every pattern for a direction lives in one hyperscan database. +//! A per-rule loop made that curve steep enough to be the reason this file exists. + +use std::time::{Duration, Instant}; + +use fgex_proxy::filter::{next_connection_id, ChainSessions, Direction, FilterChain, Verdict}; +use fgex_proxy::rules::parse_ruleset; + +const CHUNK: usize = 64 * 1024; // what the relay actually reads at a time +const TOTAL: usize = 512 * 1024 * 1024; + +fn ruleset(n: usize) -> String { + let rules: Vec = (0..n) + .map(|i| format!(r#"{{"kind":"regex","id":"r{i}","pattern":"needle{i}z"}}"#)) + .collect(); + format!("[{}]", rules.join(",")) +} + +async fn measure(rules: usize) -> f64 { + let chain = FilterChain::new( + parse_ruleset(&ruleset(rules)).unwrap(), + Duration::from_millis(500), + ); + // Traffic that matches nothing, but is not one repeated byte either: a uniform + // buffer is not what a matcher meets in practice and can flatter it. + let chunk: Vec = (0..CHUNK).map(|i| b'a' + ((i * 7 + i / 31) % 26) as u8).collect(); + let mut sessions = ChainSessions::new(next_connection_id()); + + let start = Instant::now(); + let mut sent = 0usize; + while sent < TOTAL { + assert_eq!( + chain.run(Direction::ClientToServer, &chunk, &mut sessions).await, + Verdict::Accept + ); + sent += CHUNK; + } + let secs = start.elapsed().as_secs_f64(); + (sent as f64) / secs / (1024.0 * 1024.0) +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "a measurement, not an assertion"] +async fn filter_throughput_by_rule_count() { + println!("\n rules MB/s"); + for n in [0, 1, 10, 50, 200] { + let mbs = measure(n).await; + println!(" {n:>5} {mbs:>9.0}"); + } +} diff --git a/backend/proxysrc/tests/tls.rs b/backend/proxysrc/tests/tls.rs new file mode 100644 index 0000000..8a145c9 --- /dev/null +++ b/backend/proxysrc/tests/tls.rs @@ -0,0 +1,342 @@ +//! TLS terminated by the engine, and the reason that is worth doing. +//! +//! The TLS module gets here with nginx: terminate on one loopback port, forward +//! plaintext to a second, re-encrypt, forward on — and attach a filter engine to the +//! leg in between, which is the one hop where the traffic is in the clear. Doing it +//! in the engine means the plaintext exists only inside this process, and the rules +//! that need to see it are already there. + +use std::sync::Arc; +use std::time::Duration; + +use fgex_proxy::filter::{ChainHandle, FilterChain}; +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}; +use tokio::net::{TcpListener, TcpStream}; + +/// Generated per run: no key material belongs in the repository. +fn self_signed() -> (String, String) { + let cert = + rcgen::generate_simple_self_signed(vec!["localhost".to_string(), "127.0.0.1".to_string()]) + .unwrap(); + (cert.cert.pem(), cert.key_pair.serialize_pem()) +} + +/// Plain echo, standing in for a service that does not speak TLS itself. +async fn spawn_plain_echo() -> std::net::SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { + continue; + }; + tokio::spawn(async move { + let mut buf = vec![0u8; 16 * 1024]; + loop { + match sock.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if sock.write_all(&buf[..n]).await.is_err() { + break; + } + } + } + } + }); + } + }); + addr +} + +/// An echo that speaks TLS, standing in for the CTF service that natively does. +async fn spawn_tls_echo(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; 16 * 1024]; + loop { + match stream.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if stream.write_all(&buf[..n]).await.is_err() { + break; + } + } + } + } + }); + } + }); + addr +} + +async fn spawn_proxy( + upstream: std::net::SocketAddr, + filters: &str, + tls_setup: TlsSetup, +) -> 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 = tls_setup; + let proxy = Proxy::bind(cfg, chain).await.unwrap(); + let addr = proxy.local_addr().unwrap(); + tokio::spawn(proxy.serve()); + addr +} + +/// A client that speaks TLS to the proxy, accepting whatever it presents. +async fn tls_roundtrip(addr: std::net::SocketAddr, payload: &[u8]) -> std::io::Result> { + let connector = tls::connector(tls::client_config().unwrap()); + let sock = TcpStream::connect(addr).await?; + let name = tls::server_name("localhost").unwrap(); + let mut stream = connector.connect(name, sock).await?; + stream.write_all(payload).await?; + let mut buf = vec![0u8; 64 * 1024]; + let n = tokio::time::timeout(Duration::from_secs(5), stream.read(&mut buf)).await??; + buf.truncate(n); + Ok(buf) +} + +#[tokio::test] +async fn terminates_tls_and_relays_to_a_plain_service() { + let (cert, key) = self_signed(); + let upstream = spawn_plain_echo().await; + let addr = spawn_proxy( + upstream, + "", + TlsSetup { + server: Some(tls::server_config(&cert, &key).unwrap()), + upstream: None, + optional: false, + }, + ) + .await; + + let got = tls_roundtrip(addr, b"hello over tls").await.unwrap(); + assert_eq!(got, b"hello over tls"); +} + +/// The shape the TLS module is for: the service speaks TLS too, so the engine +/// decrypts, inspects, and re-encrypts. nginx needs two listeners and a loopback hop +/// in the clear to do this. +#[tokio::test] +async fn decrypts_inspects_and_re_encrypts() { + let (cert, key) = self_signed(); + let upstream = spawn_tls_echo(&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"end to end").await.unwrap(); + assert_eq!(got, b"end to end"); +} + +/// The point of decrypting at all: a rule that could never have matched the +/// ciphertext blocks the connection. +#[tokio::test] +async fn rules_see_the_plaintext() { + let (cert, key) = self_signed(); + let upstream = spawn_tls_echo(&cert, &key).await; + let addr = spawn_proxy( + upstream, + "block:FLAG{", + TlsSetup { + server: Some(tls::server_config(&cert, &key).unwrap()), + upstream: Some(tls::client_config().unwrap()), + optional: false, + }, + ) + .await; + + let got = tls_roundtrip(addr, b"harmless request").await.unwrap(); + assert_eq!(got, b"harmless request"); + + // Blocked, and closed properly: over TLS an abrupt drop reaches the peer as a + // truncation error, which is exactly the "left to guess" this engine avoids. + let got = tls_roundtrip(addr, b"give me FLAG{secret}") + .await + .expect("a blocked TLS connection was torn down instead of closed"); + assert!( + got.is_empty(), + "a rule missed the decrypted payload: {got:?}" + ); +} + +/// A certificate and key that do not go together is a configuration error, and has to +/// be one at startup rather than a listener that refuses every handshake. +#[tokio::test] +async fn a_mismatched_key_is_refused() { + let (cert, _) = self_signed(); + let (_, other_key) = self_signed(); + let err = tls::server_config(&cert, &other_key).unwrap_err(); + assert!(err.contains("do not go together"), "unhelpful error: {err}"); + assert!(tls::server_config("", "").is_err()); +} + +/// TLS is off by default, and off must cost nothing: the plain path is the one the +/// benchmark measured. +#[tokio::test] +async fn tls_off_leaves_the_plain_path_alone() { + let upstream = spawn_plain_echo().await; + let addr = spawn_proxy(upstream, "", TlsSetup::default()).await; + assert!(TlsSetup::default().is_off()); + + let mut sock = TcpStream::connect(addr).await.unwrap(); + sock.write_all(b"plain as ever").await.unwrap(); + let mut buf = vec![0u8; 1024]; + let n = sock.read(&mut buf).await.unwrap(); + 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..3a7aa97 --- /dev/null +++ b/backend/proxysrc/tests/udp.rs @@ -0,0 +1,319 @@ +//! 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, Direction, Filter, FilterChain, FilterCtx, Verdict}; +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"); +} + +/// A service that goes away for a moment and comes back is answering again, for the +/// client that kept talking through it. +/// +/// A datagram sent while it was down is answered with ICMP "port unreachable", which the +/// flow's connected socket reports as an error on its next call. That error ended the task +/// carrying the answers back while the flow stayed in the table — and a client that kept +/// sending kept it from ever going idle, so it never heard from the service again. +#[tokio::test] +async fn a_flow_outlives_its_service_restarting() { + let first = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let upstream = first.local_addr().unwrap(); + let echo = |socket: UdpSocket| { + 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; + } + }) + }; + let service = echo(first); + let relay = spawn_relay(upstream, "", 0, false, Arc::new(ProxyStats::default())).await; + + // One source throughout: this is about a flow that already exists. + let client = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + assert_eq!(exchange(&client, relay, b"before").await.as_deref(), Some(&b"before"[..])); + + service.abort(); + let _ = service.await; + assert_eq!(exchange(&client, relay, b"while down").await, None); + + echo(UdpSocket::bind(upstream).await.unwrap()); + assert_eq!( + exchange(&client, relay, b"after").await.as_deref(), + Some(&b"after"[..]), + "the service came back and the flow that kept talking was not answered" + ); +} + +/// One datagram from `client`, and the answer if one comes. +async fn exchange( + client: &UdpSocket, + relay: std::net::SocketAddr, + payload: &[u8], +) -> Option> { + client.send_to(payload, relay).await.unwrap(); + let mut buf = vec![0u8; 2048]; + match tokio::time::timeout(Duration::from_millis(700), client.recv(&mut buf)).await { + Ok(Ok(n)) => Some(buf[..n].to_vec()), + _ => None, + } +} + +/// 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" + ); +} + + +/// Takes its time over a client's datagram carrying `slow`, and none over anything else — +/// the echo coming back included, or each exchange would pay for it twice. +struct SlowOn; + +impl Filter for SlowOn { + fn name(&self) -> &str { + "slow-on" + } + fn inspect(&self, ctx: &FilterCtx<'_>) -> Verdict { + if ctx.direction == Direction::ClientToServer && ctx.chunk.windows(4).any(|w| w == b"slow") { + std::thread::sleep(Duration::from_millis(1500)); + } + Verdict::Accept + } +} + +/// One client's slow verdict holds up that client, and nobody else. +/// +/// The receive loop used to judge every datagram of every client itself, one after the +/// other: a filter taking a second and a half over one client's datagram was a second and +/// a half in which no other client of the address was even read. +#[tokio::test] +async fn a_slow_verdict_for_one_client_does_not_hold_up_another() { + let upstream = spawn_echo().await; + let chain = ChainHandle::new(FilterChain::new( + vec![Arc::new(SlowOn) as Arc], + Duration::from_secs(5), + )); + let relay = UdpRelay::bind( + "127.0.0.1:0".parse().unwrap(), + upstream, + chain, + None, + false, + 0, + false, + Arc::new(ProxyStats::default()), + ) + .await + .unwrap(); + let relay_addr = relay.local_addr().unwrap(); + tokio::spawn(relay.serve()); + + let slow = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + slow.send_to(b"slow please", relay_addr).await.unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + + let started = std::time::Instant::now(); + assert_eq!(ask(relay_addr, b"quick").await.as_deref(), Some(&b"quick"[..]), + "the other client was not answered while the first waited for its verdict"); + assert!(started.elapsed() < Duration::from_millis(600), + "the other client waited {:?} behind the slow verdict", started.elapsed()); + + let mut buf = vec![0u8; 64]; + let n = tokio::time::timeout(Duration::from_secs(3), slow.recv(&mut buf)) + .await + .expect("the slow client was never answered") + .unwrap(); + assert_eq!(&buf[..n], b"slow please"); +} + +/// Within one flow the order is kept: a datagram is not overtaken by the one after it +/// while its verdict is still being decided. +#[tokio::test] +async fn one_flows_datagrams_keep_their_order() { + let upstream = spawn_echo().await; + let chain = ChainHandle::new(FilterChain::new( + vec![Arc::new(SlowOn) as Arc], + Duration::from_secs(5), + )); + let relay = UdpRelay::bind( + "127.0.0.1:0".parse().unwrap(), + upstream, + chain, + None, + false, + 0, + false, + Arc::new(ProxyStats::default()), + ) + .await + .unwrap(); + let relay_addr = relay.local_addr().unwrap(); + tokio::spawn(relay.serve()); + + let client = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + client.send_to(b"slow first", relay_addr).await.unwrap(); + client.send_to(b"then second", relay_addr).await.unwrap(); + let mut buf = vec![0u8; 64]; + let mut got = Vec::new(); + for _ in 0..2 { + let n = tokio::time::timeout(Duration::from_secs(3), client.recv(&mut buf)) + .await + .expect("an answer never came") + .unwrap(); + got.push(buf[..n].to_vec()); + } + assert_eq!(got, vec![b"slow first".to_vec(), b"then second".to_vec()]); +} diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..bcf38e4 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,15 @@ +# Not a package: only read for its dependencies (`uv pip install -r pyproject.toml` in the +# Dockerfile). The version firegex reports is in utils/__init__.py. +[project] +name = "firegex-backend" +version = "0.0.0" +dependencies = [ + "fastapi[all]", + "httpx", + "uvicorn[standard]", + "psutil", + "python-jose[cryptography]", + "python-socketio", + "brotli", + # nftables comes from the distribution (python3-nftables), not from PyPI. +] diff --git a/backend/requirements.txt b/backend/requirements.txt deleted file mode 100644 index 55d6516..0000000 --- a/backend/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -fastapi[all] -httpx -uvicorn[standard] -psutil -python-jose[cryptography] -python-socketio -brotli -#git+https://salsa.debian.org/pkg-netfilter-team/pkg-nftables#egg=nftables&subdirectory=py diff --git a/backend/routers/firewall.py b/backend/routers/firewall.py index c463e87..fcfe534 100644 --- a/backend/routers/firewall.py +++ b/backend/routers/firewall.py @@ -1,4 +1,6 @@ +import re import sqlite3 +import traceback from fastapi import APIRouter, HTTPException from utils.sqlite import SQLite from utils import ip_parse, ip_family, socketio_emit @@ -50,6 +52,7 @@ async def startup(): await firewall.init() async def shutdown(): + firewall.stop_watching() if not firewall.keep_rules: await firewall.close() db.disconnect() @@ -57,8 +60,29 @@ async def shutdown(): async def refresh_frontend(additional:list[str]=[]): await socketio_emit(["firewall"]+additional) -async def apply_changes(): - await firewall.reload() +async def apply_changes(undo=None): + """Put what the database now says into the kernel, or put the database back. + + `undo` restores what was stored before the change. nft refusing the new firewall + leaves the old one in force (`FiregexTables.set` sends it as one batch), so the + database has to follow: kept, the refused configuration would be what the watcher + tries to put back every few seconds and what the next boot comes up with — failing + each time, with no firewall behind it. + """ + try: + await firewall.reload() + except Exception as e: + if undo is None: + raise + undo() + try: + await firewall.reload() + except Exception: + traceback.print_exc() + raise HTTPException( + status_code=400, + detail=f"nftables refused the new firewall, so nothing was changed: {e}", + ) await refresh_frontend() return {'status': 'ok'} @@ -71,8 +95,13 @@ async def get_settings(): @app.put("/settings", response_model=StatusMessageModel) async def set_settings(form: FirewallSettings): """Set the firewall settings""" + before = firewall.settings firewall.settings = form - return await apply_changes() + + def undo(): + firewall.settings = before + + return await apply_changes(undo) @app.get('/rules', response_model=RuleInfo) async def get_rule_list(): @@ -86,8 +115,13 @@ async def get_rule_list(): @app.post('/enable', response_model=StatusMessageModel) async def enable_firewall(): """Request enabling the firewall""" + was = firewall.enabled firewall.enabled = True - return await apply_changes() + + def undo(): + firewall.enabled = was + + return await apply_changes(undo) @app.post('/disable', response_model=StatusMessageModel) async def disable_firewall(): @@ -95,27 +129,40 @@ async def disable_firewall(): firewall.enabled = False return await apply_changes() +#: What an address field may hold when it is not an address: an interface name, as the +#: kernel takes one, or one ending in `*` to match every interface starting that way +#: (`br-*`). Fifteen characters at most, which is what `IFNAMSIZ` leaves for the name. +#: +#: Anything else used to be stored on the strength of containing no `/`, and nft then +#: refused the whole firewall over it — a name one character too long, or with a quote in +#: it, was enough to take every rule down, the policy with them. +INTERFACE = re.compile(r"[A-Za-z0-9_.:-]{1,15}|[A-Za-z0-9_.:-]{0,14}\*") + +def _address_or_interface(value: str, what: str) -> tuple[str, bool]: + """`value` normalised, and whether it is an IP address rather than an interface.""" + value = (value or "").strip() + if value == "": + return value, False + try: + return ip_parse(value), True + except ValueError: + pass + if not INTERFACE.fullmatch(value): + raise HTTPException( + status_code=400, + detail=f"Invalid {what} address {value!r}: it is neither an IP address nor an " + f"interface name (at most 15 letters, digits and '_.:-', optionally " + f"ending in '*')", + ) + return value, False + def parse_and_check_rule(rule:RuleModel): if rule.table == Table.MANGLE and rule.mode == Mode.FORWARD: raise HTTPException(status_code=400, detail="Mangle table does not support forward mode") - is_src_ip = is_dst_ip = True - - try: - rule.src = ip_parse(rule.src) - except ValueError: - is_src_ip = False - - try: - rule.dst = ip_parse(rule.dst) - except ValueError: - is_dst_ip = False - - if not is_src_ip and "/" in rule.src: # Slash is not allowed in ip interfaces names - raise HTTPException(status_code=400, detail="Invalid source address") - if not is_dst_ip and "/" in rule.dst: - raise HTTPException(status_code=400, detail="Invalid destination address") + rule.src, is_src_ip = _address_or_interface(rule.src, "source") + rule.dst, is_dst_ip = _address_or_interface(rule.dst, "destination") if is_src_ip and is_dst_ip and ip_family(rule.dst) != ip_family(rule.src): raise HTTPException(status_code=400, detail="Destination and source addresses must be of the same family") @@ -125,30 +172,43 @@ def parse_and_check_rule(rule:RuleModel): return rule +RULE_COLUMNS = ("active", "name", "proto", "src", "dst", "port_src_from", "port_dst_from", + "port_src_to", "port_dst_to", "action", "mode", "`table`") + +def _write_rules(rows: list[dict]) -> None: + """Replace every stored rule with `rows`, in order, in one transaction.""" + db.queries(["DELETE FROM rules"] + [ + ( + f"INSERT INTO rules (rule_id, {', '.join(RULE_COLUMNS)}) " + f"VALUES (?, {', '.join('?' for _ in RULE_COLUMNS)})", + rid, *(row[column.strip('`')] for column in RULE_COLUMNS), + ) + for rid, row in enumerate(rows) + ]) + @app.post('/rules', response_model=StatusMessageModel) async def add_new_service(form: RuleFormAdd): """Edit rule table""" rules = [parse_and_check_rule(ele) for ele in form.rules] + before = db.query(f"SELECT {', '.join(RULE_COLUMNS)} FROM rules ORDER BY rule_id;") + policy_before = firewall.policy try: - db.queries(["DELETE FROM rules"]+ - [(""" - INSERT INTO rules ( - rule_id, active, name, - proto, - src, dst, - port_src_from, port_dst_from, - port_src_to, port_dst_to, - action, mode, `table` - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ? ,?, ?, ?)""", - rid, ele.active, ele.name, - ele.proto, - ele.src, ele.dst, - ele.port_src_from, ele.port_dst_from, - ele.port_src_to, ele.port_dst_to, - ele.action, ele.mode, ele.table - ) for rid, ele in enumerate(rules)] - ) + _write_rules([ + { + "active": ele.active, "name": ele.name, "proto": ele.proto, + "src": ele.src, "dst": ele.dst, + "port_src_from": ele.port_src_from, "port_dst_from": ele.port_dst_from, + "port_src_to": ele.port_src_to, "port_dst_to": ele.port_dst_to, + "action": ele.action, "mode": ele.mode, "table": ele.table, + } + for ele in rules + ]) firewall.policy = form.policy.value except sqlite3.IntegrityError: raise HTTPException(status_code=400, detail="Error saving the rules: maybe there are duplicated rules") - return await apply_changes() + + def undo(): + _write_rules(before) + firewall.policy = policy_before + + return await apply_changes(undo) diff --git a/backend/routers/nfproxy.py b/backend/routers/nfproxy.py deleted file mode 100644 index 83763cc..0000000 --- a/backend/routers/nfproxy.py +++ /dev/null @@ -1,454 +0,0 @@ -import secrets -import sqlite3 -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel -from modules.nfproxy.nftables import FiregexTables -from modules.nfproxy.firewall import STATUS, FirewallManager, ServiceNotFoundException -from utils.sqlite import SQLite -from utils import ip_parse, refactor_name, socketio_emit, PortType -from modules.tls.service import activate_stream -from utils.models import ResetRequest, StatusMessageModel -import os -from firegex.nfproxy.internals import get_filter_names -from fastapi.responses import PlainTextResponse -from modules.nfproxy.nftables import convert_protocol_to_l4 -import asyncio -import traceback -from utils import DEBUG -import utils - -class ServiceModel(BaseModel): - service_id: str - status: str - port: PortType|None = None - name: str - proto: str - ip_int: str|None = None - n_filters: int - edited_packets: int - blocked_packets: int - fail_open: bool - target_type: str - tls_stream_id: str|None = None - -class RenameForm(BaseModel): - name:str - -class SettingsForm(BaseModel): - port: PortType|None = None - proto: str|None = None - ip_int: str|None = None - fail_open: bool|None = None - target_type: str|None = None - tls_stream_id: str|None = None - -class PyFilterModel(BaseModel): - name: str - service_id: str - blocked_packets: int - edited_packets: int - active: bool - -class ServiceAddForm(BaseModel): - name: str - port: PortType|None = None - proto: str - ip_int: str|None = None - fail_open: bool = True - target_type: str = "flow" - tls_stream_id: str|None = None - -class ServiceAddResponse(BaseModel): - status:str - service_id: str|None = None - -class SetPyFilterForm(BaseModel): - code: str - -app = APIRouter() - -db = SQLite('db/nft-pyfilters.db', { - 'services': { - 'service_id': 'VARCHAR(100) PRIMARY KEY', - 'status': 'VARCHAR(100) NOT NULL', - 'target_type': 'VARCHAR(10) NOT NULL CHECK(target_type IN ("flow", "tls")) DEFAULT "flow"', - 'tls_stream_id': 'VARCHAR(100)', - 'port': 'INT CHECK(port > 0 and port < 65536)', - 'name': 'VARCHAR(100) NOT NULL UNIQUE', - 'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "http"))', - 'l4_proto': 'VARCHAR(3) NOT NULL CHECK (l4_proto IN ("tcp", "udp"))', - 'ip_int': 'VARCHAR(100)', - 'fail_open': 'BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1', - }, - 'pyfilter': { - 'name': 'VARCHAR(100) NOT NULL', - 'service_id': 'VARCHAR(100) NOT NULL', - 'blocked_packets': 'INTEGER UNSIGNED NOT NULL DEFAULT 0', - 'edited_packets': 'INTEGER UNSIGNED NOT NULL DEFAULT 0', - 'active' : 'BOOLEAN NOT NULL CHECK (active IN (0, 1)) DEFAULT 1', - 'FOREIGN KEY (service_id)':'REFERENCES services (service_id)', - 'PRIMARY KEY': '(name, service_id)' - }, - 'QUERY':[ - "CREATE UNIQUE INDEX IF NOT EXISTS unique_services ON services (port, ip_int, l4_proto);", - "CREATE UNIQUE INDEX IF NOT EXISTS unique_pyfilter_service ON pyfilter (name, service_id);" - ] -}) - -async def refresh_frontend(additional:list[str]=[]): - await socketio_emit(["nfproxy"]+additional) - -async def reset(params: ResetRequest): - if not params.delete: - db.backup() - await firewall.close() - FiregexTables().reset() - if params.delete: - db.delete() - db.init() - else: - db.restore() - try: - await firewall.init() - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - -async def startup(): - db.init() - try: - await firewall.init() - except Exception as e: - print("WARNING cannot start firewall:", e) - utils.socketio.on("nfproxy-outstream-join", join_outstream) - utils.socketio.on("nfproxy-outstream-leave", leave_outstream) - utils.socketio.on("nfproxy-exception-join", join_exception) - utils.socketio.on("nfproxy-exception-leave", leave_exception) - -async def shutdown(): - await firewall.close() - db.disconnect() - -def gen_service_id(): - while True: - res = secrets.token_hex(8) - if len(db.query('SELECT 1 FROM services WHERE service_id = ?;', res)) == 0: - break - return res - -async def outstream_func(service_id, data): - await utils.socketio.emit(f"nfproxy-outstream-{service_id}", data, room=f"nfproxy-outstream-{service_id}") - -async def exception_func(service_id, timestamp): - await utils.socketio.emit(f"nfproxy-exception-{service_id}", timestamp, room=f"nfproxy-exception-{service_id}") - -firewall = FirewallManager(db, outstream_func=outstream_func, exception_func=exception_func) - -@app.get('/services', response_model=list[ServiceModel]) -async def get_service_list(): - """Get the list of existent firegex services""" - res = db.query(""" - SELECT - s.service_id service_id, - s.status status, - s.port port, - s.name name, - s.proto proto, - s.ip_int ip_int, - s.fail_open fail_open, - s.target_type target_type, - s.tls_stream_id tls_stream_id, - COUNT(f.name) n_filters, - COALESCE(SUM(f.blocked_packets),0) blocked_packets, - COALESCE(SUM(f.edited_packets),0) edited_packets - FROM services s LEFT JOIN pyfilter f ON s.service_id = f.service_id - GROUP BY s.service_id; - """) - return res - -@app.get('/services/{service_id}', response_model=ServiceModel) -async def get_service_by_id(service_id: str): - """Get info about a specific service using his id""" - res = db.query(""" - SELECT - s.service_id service_id, - s.status status, - s.port port, - s.name name, - s.proto proto, - s.ip_int ip_int, - s.fail_open fail_open, - s.target_type target_type, - s.tls_stream_id tls_stream_id, - COUNT(f.name) n_filters, - COALESCE(SUM(f.blocked_packets),0) blocked_packets, - COALESCE(SUM(f.edited_packets),0) edited_packets - FROM services s LEFT JOIN pyfilter f ON s.service_id = f.service_id - WHERE s.service_id = ? GROUP BY s.service_id; - """, service_id) - if len(res) == 0: - raise HTTPException(status_code=400, detail="This service does not exists!") - return res[0] - -@app.post('/services/{service_id}/stop', response_model=StatusMessageModel) -async def service_stop(service_id: str): - """Request the stop of a specific service""" - await firewall.get(service_id).next(STATUS.STOP) - await refresh_frontend() - return {'status': 'ok'} - -@app.post('/services/{service_id}/start', response_model=StatusMessageModel) -async def service_start(service_id: str): - """Request the start of a specific service""" - srv = db.query("SELECT target_type, tls_stream_id FROM services WHERE service_id = ?;", service_id) - if srv and srv[0]["target_type"] == "tls": - if not await activate_stream(srv[0]["tls_stream_id"]): - raise HTTPException(status_code=400, detail="Linked TLS stream not found") - await firewall.get(service_id).next(STATUS.ACTIVE) - await refresh_frontend() - return {'status': 'ok'} - -@app.delete('/services/{service_id}', response_model=StatusMessageModel) -async def service_delete(service_id: str): - """Request the deletion of a specific service""" - db.query('DELETE FROM services WHERE service_id = ?;', service_id) - db.query('DELETE FROM pyfilter WHERE service_id = ?;', service_id) - if os.path.exists(f"db/nfproxy_filters/{service_id}.py"): - os.remove(f"db/nfproxy_filters/{service_id}.py") - await firewall.remove(service_id) - await refresh_frontend() - return {'status': 'ok'} - -@app.put('/services/{service_id}/rename', response_model=StatusMessageModel) -async def service_rename(service_id: str, form: RenameForm): - """Request to change the name of a specific service""" - form.name = refactor_name(form.name) - if not form.name: - raise HTTPException(status_code=400, detail="The name cannot be empty!") - try: - db.query('UPDATE services SET name=? WHERE service_id = ?;', form.name, service_id) - except sqlite3.IntegrityError: - raise HTTPException(status_code=400, detail="This name is already used") - await refresh_frontend() - return {'status': 'ok'} - -@app.put('/services/{service_id}/settings', response_model=StatusMessageModel) -async def service_settings(service_id: str, form: SettingsForm): - """Request to change the settings of a specific service (will cause a restart)""" - - srv_check = db.query("SELECT ip_int, port, target_type, tls_stream_id FROM services WHERE service_id = ?;", service_id) - if len(srv_check) == 0: - raise HTTPException(status_code=404, detail="Service not found") - old_srv = srv_check[0] - - if form.port is not None and (form.port < 1 or form.port > 65535): - raise HTTPException(status_code=400, detail="Invalid port") - - if form.ip_int is not None: - try: - form.ip_int = ip_parse(form.ip_int) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid address") - - new_ip = form.ip_int if form.ip_int is not None else old_srv["ip_int"] - new_port = form.port if form.port is not None else old_srv["port"] - - new_target_type = form.target_type if form.target_type is not None else old_srv["target_type"] - new_tls_stream_id = form.tls_stream_id if form.tls_stream_id is not None else old_srv["tls_stream_id"] - - if new_target_type == "tls" and not new_tls_stream_id: - raise HTTPException(status_code=400, detail="TLS stream ID is required when target type is tls") - if new_target_type == "flow" and (new_ip is None or new_port is None): - raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow") - - keys = [] - values = [] - - for key, value in form.model_dump(exclude_none=True).items(): - keys.append(key) - values.append(value) - if key == "proto": - keys.append("l4_proto") - values.append(convert_protocol_to_l4(value)) - - if len(keys) == 0: - raise HTTPException(status_code=400, detail="No settings to change provided") - - try: - db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id) - except sqlite3.IntegrityError: - raise HTTPException(status_code=400, detail="A service with these settings already exists") - - old_status = firewall.get(service_id).status - await firewall.remove(service_id) - if old_status == STATUS.ACTIVE and new_target_type == "tls": - await activate_stream(new_tls_stream_id) - await firewall.reload() - await firewall.get(service_id).next(old_status) - - await refresh_frontend() - return {'status': 'ok'} - - -@app.get('/services/{service_id}/pyfilters', response_model=list[PyFilterModel]) -async def get_service_pyfilter_list(service_id: str): - """Get the list of the pyfilters of a service""" - if not db.query("SELECT 1 FROM services s WHERE s.service_id = ?;", service_id): - raise HTTPException(status_code=400, detail="This service does not exists!") - return db.query(""" - SELECT - name, blocked_packets, edited_packets, active, service_id - FROM pyfilter WHERE service_id = ?; - """, service_id) - -@app.get('/services/{service_id}/pyfilters/{filter_name}', response_model=PyFilterModel) -async def get_pyfilter_by_id(service_id: str, filter_name: str): - """Get pyfilter info using his id""" - res = db.query(""" - SELECT - name, blocked_packets, edited_packets, active, service_id - FROM pyfilter WHERE name = ? AND service_id = ?; - """, filter_name, service_id) - if len(res) == 0: - raise HTTPException(status_code=400, detail="This filter does not exists!") - return res[0] - -@app.post('/services/{service_id}/pyfilters/{filter_name}/enable', response_model=StatusMessageModel) -async def pyfilter_enable(service_id: str, filter_name: str): - """Request the enabling of a pyfilter""" - res = db.query('SELECT * FROM pyfilter WHERE name = ? AND service_id = ?;', filter_name, service_id) - if len(res) != 0: - db.query('UPDATE pyfilter SET active=1 WHERE name = ? AND service_id = ?;', filter_name, service_id) - await firewall.get(res[0]["service_id"]).update_filters() - await refresh_frontend() - return {'status': 'ok'} - -@app.post('/services/{service_id}/pyfilters/{filter_name}/disable', response_model=StatusMessageModel) -async def pyfilter_disable(service_id: str, filter_name: str): - """Request the deactivation of a pyfilter""" - res = db.query('SELECT * FROM pyfilter WHERE name = ? AND service_id = ?;', filter_name, service_id) - if len(res) != 0: - db.query('UPDATE pyfilter SET active=0 WHERE name = ? AND service_id = ?;', filter_name, service_id) - await firewall.get(res[0]["service_id"]).update_filters() - await refresh_frontend() - return {'status': 'ok'} - -@app.post('/services', response_model=ServiceAddResponse) -async def add_new_service(form: ServiceAddForm): - """Add a new service""" - if form.target_type == "flow": - if form.ip_int is None or form.port is None: - raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow") - try: - form.ip_int = ip_parse(form.ip_int) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid address") - elif form.target_type == "tls": - if not form.tls_stream_id: - raise HTTPException(status_code=400, detail="TLS stream ID is required when target type is tls") - - if form.proto not in ["tcp", "http"]: - raise HTTPException(status_code=400, detail="Invalid protocol") - - srv_id = None - try: - srv_id = gen_service_id() - db.query("INSERT INTO services (service_id, name, port, status, proto, ip_int, fail_open, l4_proto, target_type, tls_stream_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, convert_protocol_to_l4(form.proto), form.target_type, form.tls_stream_id) - except sqlite3.IntegrityError: - raise HTTPException(status_code=400, detail="This type of service already exists") - await firewall.reload() - await refresh_frontend() - return {'status': 'ok', 'service_id': srv_id} - -@app.put('/services/{service_id}/code', response_model=StatusMessageModel) -async def set_pyfilters_code(service_id: str, form: SetPyFilterForm): - """Set the python filter for a service""" - service = db.query("SELECT service_id, proto FROM services WHERE service_id = ?;", service_id) - if len(service) == 0: - raise HTTPException(status_code=400, detail="This service does not exists!") - service = service[0] - service_id = service["service_id"] - srv_proto = service["proto"] - - try: - async with asyncio.timeout(8): - try: - found_filters = get_filter_names(form.code, srv_proto) - except Exception as e: - if DEBUG: - traceback.print_exc() - raise HTTPException(status_code=400, detail="Compile error: "+str(e)) - - # Remove filters that are not in the new code - existing_filters = db.query("SELECT name FROM pyfilter WHERE service_id = ?;", service_id) - existing_filters = [ele["name"] for ele in existing_filters] - for filter in existing_filters: - if filter not in found_filters: - db.query("DELETE FROM pyfilter WHERE name = ?;", filter) - - # Add filters that are in the new code but not in the database - for filter in found_filters: - if not db.query("SELECT 1 FROM pyfilter WHERE service_id = ? AND name = ?;", service_id, filter): - db.query("INSERT INTO pyfilter (name, service_id) VALUES (?, ?);", filter, service["service_id"]) - - # Eventually edited filters will be reloaded - os.makedirs("db/nfproxy_filters", exist_ok=True) - with open(f"db/nfproxy_filters/{service_id}.py", "w") as f: - f.write(form.code) - await firewall.get(service_id).update_filters() - await refresh_frontend() - except asyncio.TimeoutError: - if DEBUG: - traceback.print_exc() - raise HTTPException(status_code=400, detail="The operation took too long") - - return {'status': 'ok'} - -@app.get('/services/{service_id}/code', response_class=PlainTextResponse) -async def get_pyfilters_code(service_id: str): - """Get the python filter for a service""" - if not db.query("SELECT 1 FROM services WHERE service_id = ?;", service_id): - raise HTTPException(status_code=400, detail="This service does not exists!") - try: - with open(f"db/nfproxy_filters/{service_id}.py") as f: - return f.read() - except FileNotFoundError: - return "" - -#Socket io events -async def join_outstream(sid, data): - """Client joins a room.""" - srv = data.get("service") - if srv: - try: - srv_manager = firewall.get(srv) - room = f"nfproxy-outstream-{srv}" - await utils.socketio.enter_room(sid, room) - await utils.socketio.emit(room, srv_manager.read_outstrem_buffer(), room=sid) - except ServiceNotFoundException: - pass - -async def leave_outstream(sid, data): - """Client leaves a room.""" - srv = data.get("service") - if srv: - await utils.socketio.leave_room(sid, f"nfproxy-outstream-{srv}") - -async def join_exception(sid, data): - """Client joins a room.""" - srv = data.get("service") - if srv: - try: - srv_manager = firewall.get(srv) - room = f"nfproxy-exception-{srv}" - await utils.socketio.enter_room(sid, room) - await utils.socketio.emit(room, srv_manager.last_exception_time, room=sid) - except ServiceNotFoundException: - pass - -async def leave_exception(sid, data): - """Client leaves a room.""" - srv = data.get("service") - if srv: - await utils.socketio.leave_room(sid, f"nfproxy-exception-{srv}") - diff --git a/backend/routers/nfregex.py b/backend/routers/nfregex.py deleted file mode 100644 index 25b0bf2..0000000 --- a/backend/routers/nfregex.py +++ /dev/null @@ -1,456 +0,0 @@ -from base64 import b64decode -import secrets -import sqlite3 -from fastapi import APIRouter, HTTPException -from fastapi.responses import PlainTextResponse -from pydantic import BaseModel -from modules.nfregex.nftables import FiregexTables -from modules.nfregex.firewall import STATUS, FirewallManager -from utils.sqlite import SQLite -from utils import ip_parse, refactor_name, socketio_emit, PortType -from modules.tls.service import activate_stream -from utils.models import ResetRequest, StatusMessageModel -from modules.nfregex.firegex import test_regex_validity - -class ServiceModel(BaseModel): - status: str - service_id: str - port: PortType|None = None - name: str - proto: str - ip_int: str|None = None - n_regex: int - n_packets: int - fail_open: bool - target_type: str - tls_stream_id: str|None = None - -class RenameForm(BaseModel): - name:str - -class SettingsForm(BaseModel): - port: PortType|None = None - proto: str|None = None - ip_int: str|None = None - fail_open: bool|None = None - target_type: str|None = None - tls_stream_id: str|None = None - -class RegexModel(BaseModel): - regex:str - mode:str - id:int - service_id:str - n_packets:int - is_case_sensitive:bool - active:bool - -class RegexAddForm(BaseModel): - service_id: str - regex: str - mode: str - active: bool|None = None - is_case_sensitive: bool - -class ServiceAddForm(BaseModel): - name: str - port: PortType|None = None - proto: str - ip_int: str|None = None - fail_open: bool = False - target_type: str = "flow" - tls_stream_id: str|None = None - -class ServiceAddResponse(BaseModel): - status:str - service_id: str|None = None - -app = APIRouter() - -db = SQLite('db/nft-regex.db', { - 'services': { - 'service_id': 'VARCHAR(100) PRIMARY KEY', - 'status': 'VARCHAR(100) NOT NULL', - 'target_type': 'VARCHAR(10) NOT NULL CHECK(target_type IN ("flow", "tls")) DEFAULT "flow"', - 'tls_stream_id': 'VARCHAR(100)', - 'port': 'INT CHECK(port > 0 and port < 65536)', - 'name': 'VARCHAR(100) NOT NULL UNIQUE', - 'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "udp"))', - 'ip_int': 'VARCHAR(100)', - 'fail_open': 'BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1', - }, - 'regexes': { - 'regex': 'TEXT NOT NULL', - 'mode': 'VARCHAR(1) NOT NULL CHECK (mode IN ("C", "S", "B"))', # C = to the client, S = to the server, B = both - 'service_id': 'VARCHAR(100) NOT NULL', - 'blocked_packets': 'INTEGER UNSIGNED NOT NULL DEFAULT 0', - 'regex_id': 'INTEGER PRIMARY KEY', - 'is_case_sensitive' : 'BOOLEAN NOT NULL CHECK (is_case_sensitive IN (0, 1))', - 'active' : 'BOOLEAN NOT NULL CHECK (active IN (0, 1)) DEFAULT 1', - 'FOREIGN KEY (service_id)':'REFERENCES services (service_id)', - }, - 'QUERY':[ - "CREATE UNIQUE INDEX IF NOT EXISTS unique_services ON services (port, ip_int, proto);", - "CREATE UNIQUE INDEX IF NOT EXISTS unique_regex_service ON regexes (regex,service_id,mode,is_case_sensitive);" - ] -}) - -async def refresh_frontend(additional:list[str]=[]): - await socketio_emit(["nfregex"]+additional) - -async def reset(params: ResetRequest): - if not params.delete: - db.backup() - await firewall.close() - FiregexTables().reset() - if params.delete: - db.delete() - db.init() - else: - db.restore() - try: - await firewall.init() - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -async def startup(): - db.init() - try: - await firewall.init() - except Exception as e: - print("WARNING cannot start firewall:", e) - -async def shutdown(): - await firewall.close() - db.disconnect() - -def gen_service_id(): - while True: - res = secrets.token_hex(8) - if len(db.query('SELECT 1 FROM services WHERE service_id = ?;', res)) == 0: - break - return res - -firewall = FirewallManager(db) - -@app.get('/services', response_model=list[ServiceModel]) -async def get_service_list(): - """Get the list of existent firegex services""" - res = db.query(""" - SELECT - s.service_id service_id, - s.status status, - s.port port, - s.name name, - s.proto proto, - s.ip_int ip_int, - s.fail_open fail_open, - s.target_type target_type, - s.tls_stream_id tls_stream_id, - COUNT(r.regex_id) n_regex, - COALESCE(SUM(r.blocked_packets),0) n_packets - FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id - GROUP BY s.service_id; - """) - return res - -@app.get('/services/{service_id}', response_model=ServiceModel) -async def get_service_by_id(service_id: str): - """Get info about a specific service using his id""" - res = db.query(""" - SELECT - s.service_id service_id, - s.status status, - s.port port, - s.name name, - s.proto proto, - s.ip_int ip_int, - s.fail_open fail_open, - s.target_type target_type, - s.tls_stream_id tls_stream_id, - COUNT(r.regex_id) n_regex, - COALESCE(SUM(r.blocked_packets),0) n_packets - FROM services s LEFT JOIN regexes r ON s.service_id = r.service_id - WHERE s.service_id = ? GROUP BY s.service_id; - """, service_id) - if len(res) == 0: - raise HTTPException(status_code=400, detail="This service does not exists!") - return res[0] - -@app.post('/services/{service_id}/stop', response_model=StatusMessageModel) -async def service_stop(service_id: str): - """Request the stop of a specific service""" - await firewall.get(service_id).next(STATUS.STOP) - await refresh_frontend() - return {'status': 'ok'} - -@app.post('/services/{service_id}/start', response_model=StatusMessageModel) -async def service_start(service_id: str): - """Request the start of a specific service""" - srv = db.query("SELECT target_type, tls_stream_id FROM services WHERE service_id = ?;", service_id) - if srv and srv[0]["target_type"] == "tls": - if not await activate_stream(srv[0]["tls_stream_id"]): - raise HTTPException(status_code=400, detail="Linked TLS stream not found") - await firewall.get(service_id).next(STATUS.ACTIVE) - await refresh_frontend() - return {'status': 'ok'} - -@app.delete('/services/{service_id}', response_model=StatusMessageModel) -async def service_delete(service_id: str): - """Request the deletion of a specific service""" - db.query('DELETE FROM services WHERE service_id = ?;', service_id) - db.query('DELETE FROM regexes WHERE service_id = ?;', service_id) - await firewall.remove(service_id) - await refresh_frontend() - return {'status': 'ok'} - -@app.put('/services/{service_id}/rename', response_model=StatusMessageModel) -async def service_rename(service_id: str, form: RenameForm): - """Request to change the name of a specific service""" - form.name = refactor_name(form.name) - if not form.name: - raise HTTPException(status_code=400, detail="The name cannot be empty!") - try: - db.query('UPDATE services SET name=? WHERE service_id = ?;', form.name, service_id) - except sqlite3.IntegrityError: - raise HTTPException(status_code=400, detail="This name is already used") - await refresh_frontend() - return {'status': 'ok'} - -@app.put('/services/{service_id}/settings', response_model=StatusMessageModel) -async def service_settings(service_id: str, form: SettingsForm): - """Request to change the settings of a specific service (will cause a restart)""" - - srv_check = db.query("SELECT proto, ip_int, port, target_type, tls_stream_id FROM services WHERE service_id = ?;", service_id) - if len(srv_check) == 0: - raise HTTPException(status_code=404, detail="Service not found") - old_srv = srv_check[0] - - if form.proto is not None and form.proto not in ["tcp", "udp"]: - raise HTTPException(status_code=400, detail="Invalid protocol") - - if form.port is not None and (form.port < 1 or form.port > 65535): - raise HTTPException(status_code=400, detail="Invalid port") - - if form.ip_int is not None: - try: - form.ip_int = ip_parse(form.ip_int) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid address") - - new_ip = form.ip_int if form.ip_int is not None else old_srv["ip_int"] - new_port = form.port if form.port is not None else old_srv["port"] - new_proto = form.proto if form.proto is not None else old_srv["proto"] - - new_target_type = form.target_type if form.target_type is not None else old_srv["target_type"] - new_tls_stream_id = form.tls_stream_id if form.tls_stream_id is not None else old_srv["tls_stream_id"] - - if new_target_type == "tls": - if new_proto != "tcp": - raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol") - if not new_tls_stream_id: - raise HTTPException(status_code=400, detail="TLS stream ID is required when target type is tls") - - if new_target_type == "flow" and (new_ip is None or new_port is None): - raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow") - - keys = [] - values = [] - - for key, value in form.model_dump(exclude_none=True).items(): - keys.append(key) - values.append(value) - - if len(keys) == 0: - raise HTTPException(status_code=400, detail="No settings to change provided") - - try: - db.query(f'UPDATE services SET {", ".join([f"{key}=?" for key in keys])} WHERE service_id = ?;', *values, service_id) - except sqlite3.IntegrityError: - raise HTTPException(status_code=400, detail="A service with these settings already exists") - - old_status = firewall.get(service_id).status - await firewall.remove(service_id) - if old_status == STATUS.ACTIVE and new_target_type == "tls": - await activate_stream(new_tls_stream_id) - await firewall.reload() - await firewall.get(service_id).next(old_status) - - await refresh_frontend() - return {'status': 'ok'} - -@app.get('/services/{service_id}/regexes', response_model=list[RegexModel]) -async def get_service_regexe_list(service_id: str): - """Get the list of the regexes of a service""" - if not db.query("SELECT 1 FROM services s WHERE s.service_id = ?;", service_id): - raise HTTPException(status_code=400, detail="This service does not exists!") - return db.query(""" - SELECT - regex, mode, regex_id `id`, service_id, - blocked_packets n_packets, is_case_sensitive, active - FROM regexes WHERE service_id = ?; - """, service_id) - -@app.get('/regexes/{regex_id}', response_model=RegexModel) -async def get_regex_by_id(regex_id: int): - """Get regex info using his id""" - res = db.query(""" - SELECT - regex, mode, regex_id `id`, service_id, - blocked_packets n_packets, is_case_sensitive, active - FROM regexes WHERE `id` = ?; - """, regex_id) - if len(res) == 0: - raise HTTPException(status_code=400, detail="This regex does not exists!") - return res[0] - -@app.delete('/regexes/{regex_id}', response_model=StatusMessageModel) -async def regex_delete(regex_id: int): - """Delete a regex using his id""" - res = db.query('SELECT * FROM regexes WHERE regex_id = ?;', regex_id) - if len(res) != 0: - db.query('DELETE FROM regexes WHERE regex_id = ?;', regex_id) - await firewall.get(res[0]["service_id"]).update_filters() - await refresh_frontend() - - return {'status': 'ok'} - -@app.post('/regexes/{regex_id}/enable', response_model=StatusMessageModel) -async def regex_enable(regex_id: int): - """Request the enabling of a regex""" - res = db.query('SELECT * FROM regexes WHERE regex_id = ?;', regex_id) - if len(res) != 0: - db.query('UPDATE regexes SET active=1 WHERE regex_id = ?;', regex_id) - await firewall.get(res[0]["service_id"]).update_filters() - await refresh_frontend() - return {'status': 'ok'} - -@app.post('/regexes/{regex_id}/disable', response_model=StatusMessageModel) -async def regex_disable(regex_id: int): - """Request the deactivation of a regex""" - res = db.query('SELECT * FROM regexes WHERE regex_id = ?;', regex_id) - if len(res) != 0: - db.query('UPDATE regexes SET active=0 WHERE regex_id = ?;', regex_id) - await firewall.get(res[0]["service_id"]).update_filters() - await refresh_frontend() - return {'status': 'ok'} - -@app.post('/regexes', response_model=StatusMessageModel) -async def add_new_regex(form: RegexAddForm): - """Add a new regex""" - regex_correct, message = await test_regex_validity(b64decode(form.regex)) - if not regex_correct: - raise HTTPException(status_code=400, detail=f"Invalid regex: {message}") - try: - db.query("INSERT INTO regexes (service_id, regex, mode, is_case_sensitive, active ) VALUES (?, ?, ?, ?, ?);", - form.service_id, form.regex, form.mode, form.is_case_sensitive, True if form.active is None else form.active ) - except sqlite3.IntegrityError: - raise HTTPException(status_code=400, detail="An identical regex already exists") - - await firewall.get(form.service_id).update_filters() - await refresh_frontend() - return {'status': 'ok'} - -@app.post('/services', response_model=ServiceAddResponse) -async def add_new_service(form: ServiceAddForm): - """Add a new service""" - if form.target_type == "flow": - if form.ip_int is None or form.port is None: - raise HTTPException(status_code=400, detail="IP and Port are required when target type is flow") - try: - form.ip_int = ip_parse(form.ip_int) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid address") - elif form.target_type == "tls": - if not form.tls_stream_id: - raise HTTPException(status_code=400, detail="TLS stream ID is required when target type is tls") - - if form.proto not in ["tcp", "udp"]: - raise HTTPException(status_code=400, detail="Invalid protocol") - if form.target_type == "tls" and form.proto != "tcp": - raise HTTPException(status_code=400, detail="TLS is only supported for TCP protocol") - - srv_id = None - try: - srv_id = gen_service_id() - db.query("INSERT INTO services (service_id ,name, port, status, proto, ip_int, fail_open, target_type, tls_stream_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - srv_id, refactor_name(form.name), form.port, STATUS.STOP, form.proto, form.ip_int, form.fail_open, form.target_type, form.tls_stream_id) - except sqlite3.IntegrityError: - raise HTTPException(status_code=400, detail="This type of service already exists") - await firewall.reload() - await refresh_frontend() - return {'status': 'ok', 'service_id': srv_id} - - -@app.get('/metrics', response_class = PlainTextResponse) -async def metrics(): - """Aggregate metrics""" - stats = db.query(""" - SELECT - s.name, - s.status, - r.regex, - r.mode, - r.is_case_sensitive, - r.blocked_packets, - r.active - FROM regexes r LEFT JOIN services s ON s.service_id = r.service_id; - """) - metrics = [] - def sanitize(s): - return s.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n') - for stat in stats: - props = f'service_name="{sanitize(stat["name"])}",regex="{sanitize(b64decode(stat["regex"]).decode())}",mode="{stat["mode"]}",is_case_sensitive="{stat["is_case_sensitive"]}"' - metrics.append(f'firegex_blocked_packets{{{props}}} {stat["blocked_packets"]}') - metrics.append(f'firegex_active{{{props}}} {int(stat["active"] and stat["status"] == "active")}') - return "\n".join(metrics) - -@app.get('/services/{service_id}/export') -async def export_service_regexes(service_id: str): - """Export all regexes for a specific service as JSON""" - if len(db.query('SELECT 1 FROM services WHERE service_id = ?;', service_id)) == 0: - raise HTTPException(status_code=404, detail="Service not found") - - regexes = db.query("SELECT regex, mode, is_case_sensitive, active FROM regexes WHERE service_id = ?;", service_id) - return {"regexes": regexes} - -@app.post('/services/{service_id}/import', response_model=StatusMessageModel) -async def import_service_regexes(service_id: str, data: dict): - """Import regexes for a specific service from JSON""" - if len(db.query('SELECT 1 FROM services WHERE service_id = ?;', service_id)) == 0: - raise HTTPException(status_code=404, detail="Service not found") - - regexes = data.get("regexes", []) - if not isinstance(regexes, list): - raise HTTPException(status_code=400, detail="Invalid format") - - for r in regexes: - regex = r.get("regex") - mode = r.get("mode") - is_case_sensitive = r.get("is_case_sensitive", False) - active = r.get("active", True) - - if not regex or not mode: - continue - - try: - db.query('INSERT INTO regexes (regex, mode, service_id, is_case_sensitive, active) VALUES (?, ?, ?, ?, ?);', - regex, mode, service_id, int(is_case_sensitive), int(active)) - - # Since we inserted it directly, we need to notify the firewall to update its rules if it's active - if active and firewall.get(service_id).status == STATUS.ACTIVE: - # Easiest way to sync is to restart the service rule engine - await firewall.get(service_id).next(STATUS.ACTIVE) - except sqlite3.IntegrityError: - # Skip duplicates - pass - - # Force reload of all rules for this service - old_status = firewall.get(service_id).status - await firewall.remove(service_id) - await firewall.reload() - await firewall.get(service_id).next(old_status) - - await refresh_frontend() - return {'status': 'ok'} diff --git a/backend/routers/porthijack.py b/backend/routers/porthijack.py deleted file mode 100644 index 1c434f5..0000000 --- a/backend/routers/porthijack.py +++ /dev/null @@ -1,184 +0,0 @@ -import secrets -import sqlite3 -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel -from modules.porthijack.models import Service -from utils.sqlite import SQLite -from utils import addr_parse, ip_family, refactor_name, socketio_emit, PortType -from utils.models import ResetRequest, StatusMessageModel -from modules.porthijack.nftables import FiregexTables -from modules.porthijack.firewall import FirewallManager - -class ServiceModel(BaseModel): - service_id: str - active: bool - public_port: PortType - proxy_port: PortType - name: str - proto: str - ip_src: str - ip_dst: str - -class RenameForm(BaseModel): - name:str - -class ServiceAddForm(BaseModel): - name: str - public_port: PortType - proxy_port: PortType - proto: str - ip_src: str - ip_dst: str - -class ServiceAddResponse(BaseModel): - status:str - service_id: str|None = None - -app = APIRouter() - -db = SQLite('db/port-hijacking.db', { - 'services': { - 'service_id': 'VARCHAR(100) PRIMARY KEY', - 'active' : 'BOOLEAN NOT NULL CHECK (active IN (0, 1))', - 'public_port': 'INT NOT NULL CHECK(public_port > 0 and public_port < 65536)', - 'proxy_port': 'INT NOT NULL CHECK(proxy_port > 0 and proxy_port < 65536)', - 'name': 'VARCHAR(100) NOT NULL UNIQUE', - 'proto': 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "udp"))', - 'ip_src': 'VARCHAR(100) NOT NULL', - 'ip_dst': 'VARCHAR(100) NOT NULL', - } -}) - -async def reset(params: ResetRequest): - if not params.delete: - db.backup() - await firewall.close() - FiregexTables().reset() - if params.delete: - db.delete() - db.init() - else: - db.restore() - await firewall.init() - - -async def startup(): - db.init() - await firewall.init() - -async def shutdown(): - await firewall.close() - db.disconnect() - -async def refresh_frontend(additional:list[str]=[]): - await socketio_emit(["porthijack"]+additional) - -def gen_service_id(): - while True: - res = secrets.token_hex(8) - if len(db.query('SELECT 1 FROM services WHERE service_id = ?;', res)) == 0: - break - return res - -firewall = FirewallManager(db) - -@app.get('/services', response_model=list[ServiceModel]) -async def get_service_list(): - """Get the list of existent firegex services""" - return db.query("SELECT service_id, active, public_port, proxy_port, name, proto, ip_src, ip_dst FROM services;") - -@app.get('/services/{service_id}', response_model=ServiceModel) -async def get_service_by_id(service_id: str): - """Get info about a specific service using his id""" - res = db.query("SELECT service_id, active, public_port, proxy_port, name, proto, ip_src, ip_dst FROM services WHERE service_id = ?;", service_id) - if len(res) == 0: - raise HTTPException(status_code=400, detail="This service does not exists!") - return res[0] - -@app.post('/services/{service_id}/stop', response_model=StatusMessageModel) -async def service_stop(service_id: str): - """Request the stop of a specific service""" - await firewall.get(service_id).disable() - await refresh_frontend() - return {'status': 'ok'} - -@app.post('/services/{service_id}/start', response_model=StatusMessageModel) -async def service_start(service_id: str): - """Request the start of a specific service""" - await firewall.get(service_id).enable() - await refresh_frontend() - return {'status': 'ok'} - -@app.delete('/services/{service_id}', response_model=StatusMessageModel) -async def service_delete(service_id: str): - """Request the deletion of a specific service""" - db.query('DELETE FROM services WHERE service_id = ?;', service_id) - await firewall.remove(service_id) - await refresh_frontend() - return {'status': 'ok'} - -@app.put('/services/{service_id}/rename', response_model=StatusMessageModel) -async def service_rename(service_id: str, form: RenameForm): - """Request to change the name of a specific service""" - form.name = refactor_name(form.name) - if not form.name: - raise HTTPException(status_code=400, detail="The name cannot be empty!") - try: - db.query('UPDATE services SET name=? WHERE service_id = ?;', form.name, service_id) - except sqlite3.IntegrityError: - raise HTTPException(status_code=400, detail="This name is already used") - await refresh_frontend() - return {'status': 'ok'} - -class ChangeDestination(BaseModel): - ip_dst: str - proxy_port: PortType - -@app.put('/services/{service_id}/change-destination', response_model=StatusMessageModel) -async def service_change_destination(service_id: str, form: ChangeDestination): - """Request to change the proxy destination of the service""" - - try: - form.ip_dst = addr_parse(form.ip_dst) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid address") - srv = Service.from_dict(db.query('SELECT * FROM services WHERE service_id = ?;', service_id)[0]) - if ip_family(form.ip_dst) != ip_family(srv.ip_src): - raise HTTPException(status_code=400, detail="The destination ip is not of the same family as the source ip") - try: - db.query('UPDATE services SET proxy_port=?, ip_dst=? WHERE service_id = ?;', form.proxy_port, form.ip_dst, service_id) - except sqlite3.IntegrityError: - raise HTTPException(status_code=400, detail="Invalid proxy port or service") - - srv.ip_dst = form.ip_dst - srv.proxy_port = form.proxy_port - await firewall.get(service_id).refresh(srv) - - await refresh_frontend() - return {'status': 'ok'} - -@app.post('/services', response_model=ServiceAddResponse) -async def add_new_service(form: ServiceAddForm): - """Add a new service""" - try: - form.ip_src = addr_parse(form.ip_src) - form.ip_dst = addr_parse(form.ip_dst) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid address") - - if ip_family(form.ip_dst) != ip_family(form.ip_src): - raise HTTPException(status_code=400, detail="Destination and source addresses must be of the same family") - if form.proto not in ["tcp", "udp"]: - raise HTTPException(status_code=400, detail="Invalid protocol") - - srv_id = None - try: - srv_id = gen_service_id() - db.query("INSERT INTO services (service_id, active, public_port, proxy_port, name, proto, ip_src, ip_dst) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - srv_id, False, form.public_port, form.proxy_port , form.name, form.proto, form.ip_src, form.ip_dst) - except sqlite3.IntegrityError: - raise HTTPException(status_code=400, detail="This type of service already exists") - - await firewall.reload() - await refresh_frontend() - return {'status': 'ok', 'service_id': srv_id} diff --git a/backend/routers/services.py b/backend/routers/services.py new file mode 100644 index 0000000..da3c926 --- /dev/null +++ b/backend/routers/services.py @@ -0,0 +1,2569 @@ +"""Services: one network layer, one ordered chain of filters on top of it. + +This is the module that replaced `nfregex`, `nfproxy` and `tls`. Those were three ways +of saying the same two things — how to intercept, and what to do — with the two welded +together, so choosing regexes also chose NFQUEUE, and choosing TLS meant creating a +second object and keeping its address in step with the first. + +Here a service says only where the traffic is and how to reach it. Filters are attached +to it, in an order the operator chooses, and can be added, reordered, switched off and +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 sys +import time +import traceback + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import PlainTextResponse +from pydantic import BaseModel + +from modules.services.firewall import ( + CODE_DIR, + FirewallManager, + clear_code, + read_code, + write_code, +) +from modules.services import stats +from modules.services.logs import log_for +from modules.services.models import (KIND, L4, MODE, PROTO, STATUS, Service, + TRANSPORT, UPSTREAM) +from modules.services.nftables import FiregexTables, hijack_endpoint, one_address +from modules.services import transports +from modules.services.transports import PROXY_ENGINE, PYWORKER, UnsupportedChain +from utils import ( + PortType, + ip_family, + ip_parse, + is_ip_parse, + parse_ip_or_int, + refactor_name, + socketio_emit, +) +from utils.models import ResetRequest, StatusMessageModel + +from utils.sqlite import SQLite + +def _counters_written() -> None: + """Every request sees the block counters as they are, and changes them from there. + + They are written once a second (`BLOCK_FLUSH` in the manager), not per block — so a + read would otherwise be up to a second behind, and an edit that resets a pattern's + counter would have the old pattern's last blocks written onto the new one after it. + """ + firewall.flush_blocks() + + +app = APIRouter(dependencies=[Depends(_counters_written)]) + +db = SQLite( + "db/services.db", + { + "services": { + "service_id": "VARCHAR(100) PRIMARY KEY", + "name": "VARCHAR(100) NOT NULL UNIQUE", + "status": 'VARCHAR(100) NOT NULL CHECK (status IN ("active", "stop"))', + # What the service speaks, which is not always what the kernel matches on: + # `tls` is TCP on the wire and `quic` is UDP. The address carries the second + # answer. + "proto": 'VARCHAR(4) NOT NULL CHECK (proto IN ("tcp", "udp", "tls", "quic", "http"))', + "transport": 'VARCHAR(20) NOT NULL CHECK (transport IN ("nfqueue", "proxy", "external"))', + "fail_open": "BOOLEAN NOT NULL CHECK (fail_open IN (0, 1)) DEFAULT 1", + # How many connections (or UDP flows) this service may carry at once, and + # what happens to the next one. `0` is no limit, which is what every service + # had before there was one — kept as a value rather than as an absence, + # because "unlimited" is a choice an operator can make and should have to. + "max_connections": "INTEGER NOT NULL DEFAULT 0", + # 0: refuse what does not fit. 1: forward it with no filter in front of it. + "over_limit_forwards": "BOOLEAN NOT NULL CHECK (over_limit_forwards IN (0, 1)) DEFAULT 0", + # Seconds a connection may carry nothing in either direction before it is + # closed. 0 is off. Only until the *first* byte: a session that has spoken + # and gone quiet is a session, and sessions are allowed to think. + "first_byte_timeout": "INTEGER NOT NULL DEFAULT 0", + # The trace. A counter in the log would be gone with the next thousand lines, + # and the ring the live log keeps is bounded on purpose; this survives a + # restart, so "did we ever hit the wall" has an answer tomorrow morning. + "over_limit_hits": "INTEGER NOT NULL DEFAULT 0", + #: When it first happened and when it last did, unix seconds. Two instants + #: rather than one, because "it started an hour ago and is still going" and + #: "it happened once at 3am" are different situations. + "over_limit_first": "INT", + "over_limit_last": "INT", + "tls_cert": "TEXT", + "tls_key": "TEXT", + # The first instant this service could refuse anything: running, with a + # filter attached. Statistics start there rather than at the beginning of + # retention, because the hours before a service existed are not quiet hours + # — they are hours the question was not being asked, and drawing them as an + # empty chart says the opposite. Never cleared once set: a service stopped + # and started again has a real gap in the middle, and that is worth seeing. + "filtering_since": "INT", + }, + # Where a service is reachable. A list, because one service routinely answers on + # more than one address — a v4 and a v6 one, a public and an internal one, two + # ports of the same daemon — and making the operator create one service per + # address meant keeping their filter chains in step by hand. + "service_addresses": { + "address_id": "VARCHAR(100) PRIMARY KEY", + "service_id": "VARCHAR(100) NOT NULL", + "ip_int": "VARCHAR(100) NOT NULL", + "port": "INT NOT NULL CHECK(port > 0 and port < 65536)", + # What clients speak at this address, in the same words a service speaks + # them: `tls` is TCP with TLS on it, `quic` is UDP with QUIC. For every + # protocol but `http` it is the service's own and nobody is asked; `http` is + # the one that means "each address says", which is what it has always meant. + "edge": 'VARCHAR(4) NOT NULL CHECK (edge IN ("tcp", "udp", "tls", "quic")) ' + "DEFAULT 'tcp'", + # Where this address's traffic actually goes, when that is not the port it + # arrived on. NULL is transparent — the address is the service — which is + # what firegex did and only did until an address could be a *publication*: + # a service answering in the clear on :80 reached over TLS on :443 without + # moving. Only the port: the same host, because what is being published is a + # service this instance is already in front of. + "target_port": "INT CHECK(target_port IS NULL OR " + "(target_port > 0 AND target_port < 65536))", + # What the service behind *this address* speaks, which used to be assumed to + # be whatever the client spoke. `same` is that assumption and the default; + # `tcp` and `tls` say the service speaks HTTP/1.1 — in the clear or under its + # own TLS — whatever arrived at the front, which is what puts an ordinary web + # service behind a TLS or HTTP/3 edge with firegex terminating on its behalf. + # + # On the address rather than on the service 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. + "upstream": 'VARCHAR(8) NOT NULL CHECK (upstream IN ("same", "tcp", "tls")) ' + "DEFAULT 'same'", + # The transport the *kernel* sees, derived from the service's own: `tls` is + # TCP on the wire. Kept here so `(ip, port, proto)` is a usable uniqueness + # key — a TCP service and a UDP one may share an address exactly as the + # kernel allows, while a TCP service and a TLS one may not, because that is + # the same port twice. + "proto": 'VARCHAR(3) NOT NULL CHECK (proto IN ("tcp", "udp"))', + # Where the operator's own proxy is listening for *this* address. Only for + # `external`; loopback when they do not say otherwise, which is where such a + # proxy normally is. + "proxy_ip": "VARCHAR(100)", + "proxy_port": "INT CHECK(proxy_port IS NULL OR (proxy_port > 0 AND proxy_port < 65536))", + "FOREIGN KEY (service_id)": "REFERENCES services (service_id)", + }, + # The chain. `position` is what makes it ordered, and the operator sets it. + "filters": { + "filter_id": "VARCHAR(100) PRIMARY KEY", + "service_id": "VARCHAR(100) NOT NULL", + "position": "INT NOT NULL", + "kind": 'VARCHAR(20) NOT NULL CHECK (kind IN ("regex", "pyfilter"))', + # Only meaningful for a pyfilter, and never chosen: read off the code when + # it is saved, and stored so the interface can show it without executing + # anything. The code stays the only thing that decides it. + "proto": 'VARCHAR(10) NOT NULL CHECK (proto IN ("tcp", "http")) DEFAULT "tcp"', + "name": "VARCHAR(100) NOT NULL", + "active": "BOOLEAN NOT NULL CHECK (active IN (0, 1)) DEFAULT 1", + "blocked": "INTEGER UNSIGNED NOT NULL DEFAULT 0", + "FOREIGN KEY (service_id)": "REFERENCES services (service_id)", + }, + # One row per `@pyfilter` function a filter's code defines. Derived from the + # code and reconciled with it every time the code is saved — the function list + # is the file's to decide — but `active` and `blocked` are the operator's, and + # survive an edit that leaves the function in place. Turning a function off is + # how you stop consulting it without deleting the code that defines it. + "pyfilters": { + "filter_id": "VARCHAR(100) NOT NULL", + "name": "VARCHAR(100) NOT NULL", + "active": "BOOLEAN NOT NULL CHECK (active IN (0, 1)) DEFAULT 1", + "blocked": "INTEGER UNSIGNED NOT NULL DEFAULT 0", + # Where the function sits in its file. The library runs them in definition + # order, so listing them in any other order — this was `ORDER BY name`, and + # before that the run order itself was a set's — shows the operator a + # sequence that is not the one deciding their traffic. + "position": "INTEGER NOT NULL DEFAULT 0", + "FOREIGN KEY (filter_id)": "REFERENCES filters (filter_id)", + }, + # The patterns inside one regex filter. They are compiled together into a + # single hyperscan database, which is why they belong to a filter rather than + # each being one. + "regexes": { + "regex_id": "VARCHAR(100) PRIMARY KEY", + "filter_id": "VARCHAR(100) NOT NULL", + "regex": "TEXT NOT NULL", + "mode": 'VARCHAR(1) NOT NULL CHECK (mode IN ("C", "S", "B"))', + "case_sensitive": "BOOLEAN NOT NULL CHECK (case_sensitive IN (0, 1)) DEFAULT 1", + "active": "BOOLEAN NOT NULL CHECK (active IN (0, 1)) DEFAULT 1", + "blocked": "INTEGER UNSIGNED NOT NULL DEFAULT 0", + "FOREIGN KEY (filter_id)": "REFERENCES filters (filter_id)", + }, + # How much each filter refused, in fixed time buckets. Bounded and coalesced — + # see `modules/services/stats.py`, where both properties are the point. + "block_history": { + "service_id": "VARCHAR(100) NOT NULL", + "filter_id": "VARCHAR(100) NOT NULL", + # Whatever the datapath reported: a pattern id, `/`, or the + # filter itself. Kept at this resolution so that asking for a time range + # answers the same question everywhere on the page — the chart, the totals + # and the shares — instead of the chart honouring it and the table beside it + # quietly reporting all of time. + "rule_id": "VARCHAR(200) NOT NULL", + "bucket": "INTEGER NOT NULL", + "blocked": "INTEGER UNSIGNED NOT NULL DEFAULT 0", + }, + "QUERY": [ + "CREATE UNIQUE INDEX IF NOT EXISTS unique_block_bucket " + "ON block_history (rule_id, bucket);", + "CREATE INDEX IF NOT EXISTS block_history_by_service " + "ON block_history (service_id, bucket);", + "CREATE UNIQUE INDEX IF NOT EXISTS unique_service_address " + "ON service_addresses (ip_int, port, proto);", + # Two addresses handed to the same proxy endpoint could not be told apart on + # the way back: the return rule recognises the operator's proxy by address + # and port, and puts the original port back. Refusing the collision here is + # cheaper than an intermittently wrong source port in production. + "CREATE UNIQUE INDEX IF NOT EXISTS unique_hijack_target " + "ON service_addresses (proxy_ip, proxy_port) WHERE proxy_port IS NOT NULL;", + "CREATE UNIQUE INDEX IF NOT EXISTS unique_filter_position ON filters (service_id, position);", + "CREATE UNIQUE INDEX IF NOT EXISTS unique_pyfilter ON pyfilters (filter_id, name);", + "CREATE UNIQUE INDEX IF NOT EXISTS unique_regex ON regexes (filter_id, regex, mode, case_sensitive);", + ], + }, +) + +firewall = FirewallManager(db) + + +class AddressModel(BaseModel): + address_id: str + service_id: str + ip_int: str + port: PortType + proto: str + #: What clients speak here, which is what the operator chose. `proto` is what the + #: kernel matches, and is this with the hat taken off. + edge: str = L4.TCP + #: Where the service is, when this address is a publication rather than the service. + target_port: int | None = None + #: What the service behind this address speaks: `same`, `tcp` or `tls`. + upstream: str = UPSTREAM.SAME + proxy_ip: str | None = None + proxy_port: int | None = None + + +class AddressForm(BaseModel): + ip_int: str + port: PortType + #: What clients speak here — `tcp`, `tls`, `udp` or `quic`. Only ever set on an + #: `http` service, which is the one whose addresses are not all the same; elsewhere + #: the service's own protocol decides and a value here is refused rather than + #: allowed to disagree with it. + edge: str | None = None + #: Where the service actually is, when it is not on this port. Absent is the + #: transparent case: the address *is* the service. + target_port: int | None = None + #: What the service behind this address speaks. Absent is `same` — whatever arrived + #: at the front goes back out the same way, which is what every address did before + #: there was a choice. + upstream: str | None = None + #: `external` only: where your own proxy listens for this address. Two addresses + #: cannot share one — the return rule tells them apart by it. + proxy_ip: str | None = None + proxy_port: PortType | None = None + + +class ProblemModel(BaseModel): + """The newest warning or error in a service's log. See `ServiceLog.last_problem`.""" + + #: Unix milliseconds, like every log line. + at: int + level: str + text: str + + +class ServiceModel(BaseModel): + service_id: str + name: str + status: str + proto: str + transport: str + fail_open: bool + #: How many connections or UDP flows may be in flight at once; 0 means no limit. + max_connections: int = 0 + #: Whether what does not fit is forwarded unfiltered rather than refused. + over_limit_forwards: bool = False + #: Seconds a connection may say nothing at all before it is closed; 0 means never. + first_byte_timeout: int = 0 + #: How many times the limit turned something away, ever. Survives a restart, which + #: is what makes it a trace rather than a reading. + over_limit_hits: int = 0 + over_limit_first: int | None = None + over_limit_last: int | None = None + #: Whether a certificate *and* a key are stored for it. The material itself never + #: comes back — the key deliberately, the certificate for symmetry — so this is what + #: lets the form know whether an empty field means "unchanged" or "never set", and + #: ask for one before the service is started rather than after the engine has refused it. + has_tls_material: bool = False + addresses: list[AddressModel] = [] + n_filters: int + n_blocked: int + #: The newest warning or error still in its log, so the list can say something is + #: wrong without anybody having the service's page open. Cleared with the log. + problem: ProblemModel | None = None + + +class ServiceAddForm(BaseModel): + name: str + proto: str = L4.TCP + #: Every address to protect. At least one, and they may mix address families — + #: one service, one chain, however many places it answers. + addresses: list[AddressForm] + transport: str = TRANSPORT.PROXY + fail_open: bool = True + max_connections: int = 0 + over_limit_forwards: bool = False + first_byte_timeout: int = 0 + tls_cert: str | None = None + tls_key: str | None = None + + +class ServiceSettingsForm(BaseModel): + name: str | None = None + proto: str | None = None + transport: str | None = None + fail_open: bool | None = None + max_connections: int | None = None + over_limit_forwards: bool | None = None + first_byte_timeout: int | None = None + tls_cert: str | None = None + tls_key: str | None = None + + +class ServiceAddResponse(BaseModel): + status: str + service_id: str | None = None + + +class FilterModel(BaseModel): + filter_id: str + service_id: str + position: int + kind: str + proto: str + name: str + active: bool + blocked: int + n_regexes: int + #: How many `@pyfilter` functions the code defines, and how many are switched on. + n_functions: int = 0 + n_functions_active: int = 0 + + +class FilterAddForm(BaseModel): + kind: str + name: str | None = None + active: bool = True + + +class FilterEditForm(BaseModel): + name: str | None = None + active: bool | None = None + + +class PyFunctionModel(BaseModel): + filter_id: str + #: The `@pyfilter` function's own name, as written in the code. + name: str + active: bool + blocked: int + + +class PyFunctionEditForm(BaseModel): + active: bool + + +class ReorderForm(BaseModel): + #: Every filter of the service, in the order they should run. + filters: list[str] + + +class RegexModel(BaseModel): + regex_id: str + filter_id: str + regex: str + mode: str + case_sensitive: bool + active: bool + blocked: int + + +class RegexAddForm(BaseModel): + regex: str + mode: str = MODE.BOTH + case_sensitive: bool = True + active: bool = True + + +class RegexEditForm(BaseModel): + active: bool | None = None + #: base64, like the one on the add form: a pattern is bytes. + regex: str | None = None + mode: str | None = None + case_sensitive: bool | None = None + + +class CodeForm(BaseModel): + code: str + + +class StatsEntryModel(BaseModel): + id: str + name: str + kind: str + filter_id: str | None = None + #: True for the one entry per filter that stands for blocks no rule still in it + #: accounts for — a pattern deleted, or one whose text was edited and so gave its + #: history back to the filter. `name` describes it rather than naming a rule, which + #: is why the interface prints it as prose instead of as a pattern. + residual: bool = False + #: Refused in the selected range. + blocked: int + #: Refused since it was created. Only reported for filters, which are the only + #: things carrying a lifetime counter of their own. + all_time: int | None = None + #: What share of everything this service refused is this one's doing, 0–100. + #: Computed against the total rather than left to the browser, so every view of it + #: agrees and a chart cannot disagree with a table beside it. + share: float = 0.0 + + +class TrafficModel(BaseModel): + """How much arrived, in the units each layer can honestly report. + + Packets come from the kernel's own counters on the intercept rules, and are + available on every network layer. Connections come from the proxy engine, which is + the only layer that works in them — and is therefore the only one where the share + of refused traffic can be computed without dividing two different things. + """ + + packets: int = 0 + bytes: int = 0 + connections: int | None = None + connections_refused: int | None = None + #: Percentage of connections refused, 0–100. Null where the layer counts packets. + refused_share: float | None = None + + +class StatsSeriesModel(BaseModel): + id: str + name: str + counts: list[int] + + +class StatsModel(BaseModel): + filters: list[StatsEntryModel] + patterns: list[StatsEntryModel] + #: One entry per `@pyfilter` function, across every pyfilter in the chain. + functions: list[StatsEntryModel] + buckets: list[int] + #: How wide one bucket is. Widens with the range, so a chart stays readable. + bucket_seconds: int + series: list[StatsSeriesModel] + #: Refused in the selected range. Every list above counts the same range. + total: int + #: Refused since each rule was created, which is what the cards show. Reported + #: alongside so a narrow range showing nothing is not read as a contradiction. + all_time: int + #: The range actually served, after clamping to what is still kept. + range_from: int + range_to: int + #: The oldest instant anything is known about, so "everything" has a meaning. + kept_from: int + #: When this service first became able to refuse anything, if it ever did. The + #: effective floor of every range, the same way `kept_from` is. + filtering_since: int | None = None + traffic: TrafficModel + + +class LogEntryModel(BaseModel): + at: int + level: str + text: str + seq: int + + +class DebugPatternForm(BaseModel): + id: str + expr: str + case_sensitive: bool = True + + +class DebugForm(BaseModel): + patterns: list[DebugPatternForm] + #: base64, because a pattern is matched against bytes and a useful sample is not + #: always valid text. + sample: str + + +class DebugMatchModel(BaseModel): + id: str + start: int + end: int + + +class DebugErrorModel(BaseModel): + id: str + error: str + + +class DebugResponse(BaseModel): + matches: list[DebugMatchModel] + errors: list[DebugErrorModel] + error: str | None = None + #: Valid, and will run, but cannot be highlighted here. + unscannable: list[DebugErrorModel] = [] + truncated: bool + + +# --- lifecycle hooks the loader calls ---------------------------------------- + + +async def refresh_frontend(extra: list[str] | None = None): + await socketio_emit(["services"] + (extra or [])) + + +async def startup(): + db.init() + _prune_orphan_code() + try: + await firewall.init() + except Exception as e: + print("WARNING cannot start the services firewall:", e) + # Whatever came back up is filtering from now, if it was not already marked. This is + # what covers the services that existed before the mark did, and the ones restored + # from a backup: both are running with a chain the moment `init()` returns, and + # neither passes through a start or an edit to say so. + for row in db.query("SELECT service_id FROM services;"): + _note_filtering(row["service_id"]) + + +def _prune_orphan_code() -> None: + """Remove the filter files no filter refers to. + + The code lives beside the database rather than in it, so the two can drift apart, and + an import is where they did: it replaced the database and wrote the backup's files, + but left every file the backup did not have — the code of every Python filter created + since, kept on disk for filters that no longer existed. Done at every start, which is + also what an import ends with, so the directory holds exactly what the database uses. + Only `.py` files are touched; anything else in there is not ours to judge. + """ + if not os.path.isdir(CODE_DIR): + return + used = { + row["filter_id"] + for row in db.query("SELECT filter_id FROM filters WHERE kind = ?;", KIND.PYFILTER) + } + for name in os.listdir(CODE_DIR): + stem, ext = os.path.splitext(name) + if ext != ".py" or stem in used: + continue + try: + os.remove(os.path.join(CODE_DIR, name)) + except OSError: + traceback.print_exc() + + +async def shutdown(): + await firewall.close() + # The capture interface outlives any one service but not the process: this is the + # only place it goes away, so a `reset()` — which also closes — leaves it alone. + firewall.release_capture() + db.disconnect() + + +async def reset(params: ResetRequest): + if not params.delete: + # Into the copy that is put back, or the last second of counters is written + # after it was taken and then overwritten by it. + firewall.flush_blocks() + db.backup() + await firewall.close() + FiregexTables().reset() + if params.delete: + db.delete() + db.init() + # The user's Python and the certificates are not in the database, so wiping + # the database alone would leave them behind to be inherited by whatever + # reuses their id. + for name in os.listdir(CODE_DIR) if os.path.isdir(CODE_DIR) else []: + os.remove(os.path.join(CODE_DIR, name)) + else: + db.restore() + try: + await firewall.init() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +def gen_id() -> str: + return secrets.token_hex(8) + + +def _service_or_404(service_id: str) -> dict: + res = db.query("SELECT * FROM services WHERE service_id = ?;", service_id) + if not res: + raise HTTPException(status_code=404, detail="This service does not exist") + return res[0] + + +def _addresses(service_id: str) -> list[dict]: + return db.query( + "SELECT * FROM service_addresses WHERE service_id = ? ORDER BY ip_int, port;", + service_id, + ) + + +def _address_or_404(service_id: str, address_id: str) -> dict: + """An address is addressed through its service, and has to actually be in it.""" + res = db.query( + "SELECT * FROM service_addresses WHERE address_id = ? AND service_id = ?;", + address_id, + service_id, + ) + if not res: + raise HTTPException(status_code=404, detail="This address does not exist") + return res[0] + + +def load_service(service_id: str): + """The service and everywhere it is reachable, as the datapath sees it.""" + from modules.services.models import Address + + row = _service_or_404(service_id) + srv = Service.from_dict(row) + srv.addresses = [Address.from_dict(a) for a in _addresses(service_id)] + return srv + + +def _filter_or_404(service_id: str, filter_id: str) -> dict: + """A filter is addressed through its service, and has to actually be in it. + + Without the second half of that check the nesting would be decoration: any filter + id would work under any service, and the chain the caller thought it was editing + would not be the one that changed. + """ + res = db.query( + "SELECT * FROM filters WHERE filter_id = ? AND service_id = ?;", filter_id, service_id + ) + if not res: + raise HTTPException(status_code=404, detail="This filter does not exist") + return res[0] + + +def _regex_or_404(filter_id: str, regex_id: str) -> dict: + res = db.query( + "SELECT * FROM regexes WHERE regex_id = ? AND filter_id = ?;", regex_id, filter_id + ) + if not res: + raise HTTPException(status_code=404, detail="This pattern does not exist") + return res[0] + + +def _note_filtering(service_id: str) -> None: + """Remember the first moment this service could have refused something. + + Two things have to be true — it is running, and there is at least one active filter + in its chain — and neither of them is where the other is changed, so this is called + from both: starting a service, and every edit that goes through `_apply_chain`. + Asking the database is cheaper than keeping a third copy of the answer in step. + + Written once and never rewritten. A service that stops filtering and starts again + has a real gap in the middle of its history, and moving the mark forward would hide + it; clearing it would make the chart claim the earlier hours never happened. + """ + manager = firewall.services.get(service_id) + if manager is not None and not manager.active: + # Meant to run and not running — one that could not start at boot, say — has not + # begun filtering, whatever the stored status says. + return + row = db.query( + "SELECT s.filtering_since since, s.status status, " + "(SELECT COUNT(*) FROM filters f WHERE f.service_id = s.service_id AND f.active = 1) n " + "FROM services s WHERE s.service_id = ?;", + service_id, + ) + if not row or row[0]["since"] is not None: + return + if row[0]["status"] != STATUS.ACTIVE or int(row[0]["n"]) < 1: + return + # Stored aligned to its bucket, like everything else the statistics work in: the + # mark names the first bucket that can hold one of this service's blocks. An + # unaligned mark is a few seconds *after* the minute-aligned "now" the stats endpoint + # compares it against, which silently switched the floor off for the first minute of + # every service's life — exactly the minute it matters most. + db.query( + "UPDATE services SET filtering_since = ? WHERE service_id = ?;", + stats.bucket_of(), + service_id, + ) + + +async def _apply_chain(service_id: str, undo=None): + """Push the chain to a running service, translating a refusal into a 400. + + A chain the transport cannot host is the operator's mistake, not the server's, and + the message has to say which combination was impossible — an unqualified 500 would + leave them guessing. + + `undo` puts the database back. Without it a refused edit would still have been + written: the operator would be told their filter was rejected and then find it in + the list, and the next start would fail for a reason they thought they had avoided. + A refusal has to mean nothing changed. + """ + undo = _once(undo) + try: + await firewall.get(service_id).update_chain(undo) + except UnsupportedChain as e: + undo() + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + undo() + raise HTTPException(status_code=500, detail=str(e)) + _note_filtering(service_id) + + +def _once(undo): + """`undo`, or nothing, made safe to call from both the manager and the caller. + + A rebuild that fails has the manager put the database back before it starts the + service again, and the caller then reaches its own `except` — where the undo it has + always run is now the second one. + """ + done = [] + + def run(): + if undo is not None and not done: + done.append(True) + undo() + + return run + + +# --- services ---------------------------------------------------------------- + +SERVICE_QUERY = """ + SELECT + s.service_id service_id, s.name name, s.status status, s.proto proto, + s.transport transport, s.fail_open fail_open, + s.max_connections max_connections, s.over_limit_forwards over_limit_forwards, + s.first_byte_timeout first_byte_timeout, + s.over_limit_hits over_limit_hits, s.over_limit_first over_limit_first, + 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(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 +""" + + +def _address_row(row: dict) -> dict: + """One address, as it is stored. + + It used to grow two loopback ports here, because a TLS service was protected on a + pair of them rather than on the address itself. Nothing is derived any more: the + engine decrypts at the address the world dials. + """ + return dict(row) + + +def _with_addresses(row: dict) -> dict: + row = dict(row) + row["status"] = _running_status(row) + row["addresses"] = [ + _address_row(a) for a in _addresses(row["service_id"]) + ] + row["problem"] = log_for(row["service_id"]).last_problem() + return row + + +def _running_status(row: dict) -> str: + """Whether the service is running, not whether it is meant to. + + The stored status is what the operator asked for, and what the next boot starts: a + service that could not come back up at boot kept `active` there, and the interface + showed it as active while nothing was filtering it — the one failure this whole module + is arranged to avoid. The reason is in its log, and on the list as its problem. + """ + manager = firewall.services.get(row["service_id"]) + if manager is None: + return row["status"] + return STATUS.ACTIVE if manager.active else STATUS.STOP + + +@app.get("", response_model=list[ServiceModel]) +async def get_services(): + """Every service, with how many filters it runs and how much they have blocked.""" + return [_with_addresses(row) for row in db.query(SERVICE_QUERY + " GROUP BY s.service_id;")] + + +class ApiMemberModel(BaseModel): + name: str + doc: str = "" + #: True for the few things a filter may assign to — the payload, and nothing else. + writable: bool = False + signature: str | None = None + + +class ApiModelModel(BaseModel): + name: str + doc: str = "" + members: list[ApiMemberModel] = [] + #: Which application protocols provide it; a file asking for one of these is one of + #: those. `tcp` and `http` both means it runs on anything. + protocols: list[str] = [] + + +class ApiEntryModel(BaseModel): + name: str + doc: str = "" + value: int | None = None + values: list[str] = [] + + +class PyFilterApiModel(BaseModel): + models: list[ApiModelModel] + verdicts: list[ApiEntryModel] + settings: list[ApiEntryModel] + + +# Declared before `/{service_id}`, or that route matches this path and looks for a +# service called "pyfilter-api". +@app.get("/pyfilter-api", response_model=PyFilterApiModel) +async def get_pyfilter_api(): + """What the filter library offers, for the editor to suggest. + + Introspected from the library rather than written down: hints that disagree with the + thing they describe are worse than none, and a second description would disagree the + first time a model gained a property. + """ + from modules.services.pyapi import describe + + return describe() + + +@app.get("/{service_id}", response_model=ServiceModel) +async def get_service(service_id: str): + res = db.query(SERVICE_QUERY + " WHERE s.service_id = ? GROUP BY s.service_id;", service_id) + if not res: + raise HTTPException(status_code=404, detail="This service does not exist") + return _with_addresses(res[0]) + + +def _insert_address(service_id: str, proto: str, transport: str, form: AddressForm) -> str: + """Add one address, carrying the transport the kernel will match on. + + Not the service's own protocol: `tls` is TCP on the wire, and an address storing it + verbatim would let a TCP service and a TLS one claim one `ip:port` between them. + + On an `http` service the address says what is spoken at it, because that service is + reached in more than one way at once — `:80` in the clear, `:443` under TLS, a UDP + port over HTTP/3. What it says is kept beside the transport the kernel matches: the + two are the same answer with the hat on and off. + """ + try: + parsed_ip = parse_ip_or_int(form.ip_int) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + edge = _address_edge(proto, form) + target_port = _address_target(transport, form) + upstream = _address_upstream(proto, edge, form) + proxy_ip = _hijack_ip(transport, parsed_ip, form) + address_id = gen_id() + db.query( + "INSERT INTO service_addresses (address_id, service_id, ip_int, port, proto, " + "edge, target_port, upstream, proxy_ip, proxy_port) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", + address_id, + service_id, + parsed_ip, + form.port, + L4.l4_of(edge), + edge, + target_port, + upstream, + proxy_ip, + _hijack_port(transport, form), + ) + return address_id + + +def _hijack_ip(transport: str, service_ip: str, form: AddressForm) -> str | None: + """Where this address's traffic is handed off, stored rather than defaulted later. + + Only the hand-off layer has one; everywhere else it stays NULL, because there is no + endpoint and a value would be a setting that does nothing. + + Resolved **here**, at the point the row is written, so that the partial unique index + on `(proxy_ip, proxy_port)` can do its job. It could not before: the column was left + NULL whenever the operator did not type an address, SQLite counts every NULL as + distinct in a UNIQUE index, and the rules then defaulted all of them to the same + loopback address — so any number of addresses could sit behind one endpoint, which is + 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: + return None + if form.proxy_ip and str(form.proxy_ip).strip(): + # Checked here, where the row is written: it used to be stored as typed and only + # parsed when the rules were built, so a mistyped endpoint was a service that + # would not start, with the reason in its log rather than in this answer. + proxy = str(form.proxy_ip).strip() + if not is_ip_parse(proxy) or _shown(ip_parse(proxy)) != one_address(ip_parse(proxy)): + raise HTTPException( + status_code=400, + detail=f"Your proxy's address has to be one IP address, not {proxy!r}", + ) + if is_ip_parse(service_ip) and ip_family(proxy) != ip_family(service_ip): + raise HTTPException( + status_code=400, + detail="Your proxy's address has to be in the same family as the address " + "it stands in for: the rewrite changes the destination and nothing " + "else, so an IPv4 packet cannot be sent to an IPv6 proxy.", + ) + form.proxy_ip = proxy + 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. + + Every protocol but `http` has one answer for the whole service, so an address of one + is not asked and a value sent anyway is refused rather than quietly kept: a row whose + edge disagrees with its service is a row that would be intercepted on one transport + and served on another. + + `http` is the protocol that means "each address says", which is what it meant when + the choice was TCP or UDP and what it still means now that the same address can say + whether it is encrypted. The default is `tcp`, which is what an HTTP address is + unless somebody says otherwise. + """ + asked = str(form.edge) if form.edge else None + if str(proto) != L4.HTTP: + if asked and asked != str(proto): + raise HTTPException( + status_code=400, + detail=f"A {str(proto).upper()} service speaks {str(proto).upper()} at " + f"every address, so this one cannot be {asked!r}. Only an HTTPS " + f"service is reached in more than one way at once.", + ) + return str(proto) + if asked is None: + return L4.TCP + if asked not in (L4.TCP, L4.TLS, L4.QUIC): + raise HTTPException( + status_code=400, + detail=f"An address of an HTTPS service is reached in the clear, under TLS " + f"or over QUIC — not {asked!r}.", + ) + return asked + + +def _address_target(transport: str, form: AddressForm) -> int | None: + """Where the service reached through one address is, when it is not on that port. + + Every protocol can say it, because **publishing is being reachable one more way than + the service is** and nothing about that is HTTP's. One daemon on `:8080` is reached + at `:80` in the clear, at `:443` under TLS and at a UDP port over HTTP/3 — and also, + with nothing HTTP about it, at whatever port the round's scoreboard was told about. + It was refused outside `http` while `http` was the only protocol whose addresses were + not all the same, which confused the shape of one protocol with the shape of the + feature. + + What it does need is something that **dials**, and that is the layer rather than the + protocol. The proxy terminates the connection and opens the one to the service, so it + is free to open it somewhere else. NFQUEUE hands the kernel a verdict on packets + already on their way and opens nothing; the hand-off layer rewrites the destination to + your own proxy, which then decides for itself where the service is. On both, a stored + port would be read by nothing at all — refused rather than ignored, because an + operator told their service is published and finding it merely intercepted is exactly + the trap this file keeps arguing against. + """ + if not form.target_port: + return None + if str(transport) != TRANSPORT.PROXY: + raise HTTPException( + status_code=400, + 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 + "This layer hands the traffic to your own proxy, which decides for " + "itself where the service is."), + ) + if not 0 < form.target_port < 65536: + raise HTTPException(status_code=400, detail="Invalid port for the service") + return form.target_port + + +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 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: + return UPSTREAM.SAME + if asked not in UPSTREAM.ALL: + raise HTTPException(status_code=400, detail=f"Unknown upstream {asked!r}") + if str(proto) not in (L4.TLS, L4.QUIC, L4.HTTP): + raise HTTPException( + status_code=400, + detail=f"A {str(proto).upper()} service is carried as it arrives: firegex " + 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 + + +def _address_taken(e: sqlite3.IntegrityError) -> str: + """Say which uniqueness rule was broken, because they mean different things. + + **Told apart by the columns, not by the index name.** SQLite reports a violated + unique index as `UNIQUE constraint failed:
., ...` — it names the + index only for one on an expression — so keying on `unique_hijack_target` matched + nothing, and the sentence below has never once been shown. What an operator saw + instead was "one of these addresses is already protected", which points at the + service address while the thing actually colliding is their proxy's port: they go + looking for a service that does not exist. + + The index name is still accepted, because it costs nothing and is what a future + SQLite, or an index written as an expression, might report instead. + """ + said = str(e) + if "proxy_port" in said or "unique_hijack_target" in said: + return ( + "another address already hands its traffic to that proxy endpoint. Give " + "this one its own port: the return rule recognises your proxy by address " + "and port to put the original port back, so two addresses behind the same " + "endpoint could not be told apart on the way out." + ) + return "one of these addresses is already protected by a service" + + +def _check_tls_material(cert: str | None, key: str | None) -> None: + """Refuse TLS material the engine would refuse, at the point it is handed over. + + Only the PEM envelope is checked, not the key itself: the aim is to catch the two + mistakes that are made by hand and cost a restart to discover — a DER or PKCS#12 + file pasted in whole, and the certificate and the key swapped between the two + fields. Both used to surface as a service that simply would not start, with the + reason in the service log rather than in the answer to the request that caused it. + + Anything that carries the right header is passed through untouched: judging a + certificate here by more than its envelope would be a second opinion on what the + engine accepts, and the second opinion is the one that is wrong. + """ + if cert is not None and cert.strip(): + if "-----BEGIN CERTIFICATE-----" not in cert: + raise HTTPException( + status_code=400, + detail="That does not look like a PEM certificate: no " + "'BEGIN CERTIFICATE' block in it." + + (" It contains a private key — the two fields are the other way " + "round." if "PRIVATE KEY-----" in cert else ""), + ) + if key is not None and key.strip(): + if "PRIVATE KEY-----" not in key: + raise HTTPException( + status_code=400, + detail="That does not look like a PEM private key: no 'BEGIN PRIVATE " + "KEY' block in it." + + (" It contains a certificate — the two fields are the other way " + "round." if "-----BEGIN CERTIFICATE-----" in key else ""), + ) + if "ENCRYPTED PRIVATE KEY-----" in key: + raise HTTPException( + status_code=400, + detail="That private key is passphrase-protected, and nothing here can " + "be asked for the passphrase. Decrypt it first: " + "openssl pkey -in key.pem -out plain.pem", + ) + + +@app.post("", response_model=ServiceAddResponse) +async def add_service(form: ServiceAddForm): + if form.transport not in TRANSPORT.ALL: + raise HTTPException(status_code=400, detail=f"Unknown transport {form.transport!r}") + if form.proto not in L4.ALL: + raise HTTPException(status_code=400, detail=f"Unknown protocol {form.proto!r}") + if not form.addresses: + raise HTTPException( + status_code=400, detail="A service needs at least one address to protect" + ) + if form.proto in (L4.TLS, L4.QUIC, L4.HTTP): + if not (form.tls_cert and form.tls_key): + raise HTTPException( + status_code=400, + detail=f"A service that speaks {form.proto.upper()} needs both a " + f"certificate and a private key" + + (". An HTTP service covers the TLS and HTTP/3 edges as well as " + "the cleartext one, and neither exists without a certificate — " + "a service that only answers in the clear is a `tcp` one, where " + "HTTP/1.1 and HTTP/2 are already filtered." + if form.proto == L4.HTTP else ""), + ) + _check_tls_material(form.tls_cert, form.tls_key) + # Each address answers for itself what the service behind it speaks, and is refused + # here rather than at the first start if it cannot. Everything else an address says + # is checked in the same breath, and for a second reason: the rows are written after + # the service's own, so a refusal arriving then would leave a service behind that + # nobody asked for. + for address in form.addresses: + _address_target(form.transport, address) + _address_upstream(form.proto, _address_edge(form.proto, address), address) + # The transport gets a look before the row exists. A chain it could not host is + # caught later, when there is one; what is caught here is what the *service* makes + # impossible on its own — a protocol this layer cannot carry — and it is worth + # catching now, because the alternative is a row that is created happily and then + # refuses to start every time, with the reason arriving one action too late. + try: + transports.build_class(form.transport).check( + Service.from_dict({ + "service_id": "", "name": form.name, "status": STATUS.STOP, + "proto": form.proto, "transport": form.transport, + "fail_open": form.fail_open, + }), + [], + ) + except UnsupportedChain as e: + raise HTTPException(status_code=400, detail=str(e)) + if form.transport == TRANSPORT.EXTERNAL and any(not a.proxy_port for a in form.addresses): + raise HTTPException( + status_code=400, + detail="A service handing its traffic to your own proxy needs that proxy's " + "port for every address", + ) + if form.transport == TRANSPORT.EXTERNAL and any(not is_ip_parse(a.ip_int) for a in form.addresses): + raise HTTPException( + status_code=400, + detail="The external transport hands traffic to your own proxy and rewrites the " + "source address on return, which requires a concrete IP address rather than an interface.", + ) + # What the rows will hold, worked out before the first one is written: the addresses + # go in after the service, so a refusal half way through left the service and the + # addresses before it behind — holding the name, unknown to the running firewall. + for address in form.addresses: + try: + _hijack_ip(form.transport, parse_ip_or_int(address.ip_int), address) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + service_id = gen_id() + try: + db.query( + "INSERT INTO services (service_id, name, status, proto, transport, " + "fail_open, max_connections, over_limit_forwards, first_byte_timeout, " + "tls_cert, tls_key) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", + service_id, + refactor_name(form.name), + STATUS.STOP, + form.proto, + form.transport, + form.fail_open, + max(0, form.max_connections), + form.over_limit_forwards, + max(0, form.first_byte_timeout), + form.tls_cert, + form.tls_key, + ) + except sqlite3.IntegrityError as e: + # The name is the only thing an operator can collide with by accident, so it is + # the answer worth giving — but it is not the only constraint on this row, and + # answering "the name exists" to a CHECK that failed sends whoever reads it + # looking for a service that is not there. Ask the exception which it was. + if "name" not in str(e): + return {"status": f"The service was refused by the database: {e}"} + return {"status": "A service with this name already exists"} + try: + for address in form.addresses: + _insert_address(service_id, form.proto, form.transport, address) + except BaseException as e: + # All of it or none: a service that came up on half the addresses the operator + # listed is one they would believe is protecting the other half. Whatever the + # reason — a refusal from a check on the way in used to leave the service and its + # first addresses behind, holding the name and unknown to the running firewall. + db.query("DELETE FROM service_addresses WHERE service_id = ?;", service_id) + db.query("DELETE FROM services WHERE service_id = ?;", service_id) + if isinstance(e, sqlite3.IntegrityError): + return {"status": _address_taken(e)} + raise + await firewall.reload() + await refresh_frontend() + 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. + + Anything here changes what the datapath is, not what it enforces, so a running + 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 = {} + if form.name is not None: + fields["name"] = refactor_name(form.name) + if form.proto is not None: + if form.proto not in L4.ALL: + raise HTTPException(status_code=400, detail=f"Unknown protocol {form.proto!r}") + fields["proto"] = form.proto + if form.transport is not None: + if form.transport not in TRANSPORT.ALL: + raise HTTPException(status_code=400, detail=f"Unknown transport {form.transport!r}") + fields["transport"] = form.transport + if form.fail_open is not None: + fields["fail_open"] = form.fail_open + if form.max_connections is not None: + if form.max_connections < 0: + raise HTTPException(status_code=400, detail="A limit cannot be negative; 0 means none") + fields["max_connections"] = form.max_connections + if form.over_limit_forwards is not None: + fields["over_limit_forwards"] = form.over_limit_forwards + if form.first_byte_timeout is not None: + if form.first_byte_timeout < 0: + raise HTTPException(status_code=400, detail="A deadline cannot be negative; 0 means none") + fields["first_byte_timeout"] = form.first_byte_timeout + _check_tls_material(form.tls_cert, form.tls_key) + # What has to hold is the state this edit *lands on*, not what it carries: turning + # TLS on for a service that has never been given a certificate is a valid-looking + # request whose only symptom is the engine refusing to start later, on a restart the + # operator has by then attributed to something else. So the stored material counts + # towards it, and a service already holding a certificate can be switched on without + # pasting it again. + lands_on = form.proto if form.proto is not None else row["proto"] + if lands_on in (L4.TLS, L4.QUIC, L4.HTTP): + cert = form.tls_cert if form.tls_cert is not None else row["tls_cert"] + key = form.tls_key if form.tls_key is not None else row["tls_key"] + if not (cert and cert.strip()) or not (key and key.strip()): + missing = "a certificate and a private key" if not (cert and cert.strip()) and not (key and key.strip()) \ + else "a certificate" if not (cert and cert.strip()) else "a private key" + raise HTTPException( + status_code=400, + detail=f"This service terminates {str(lands_on).upper()}, so it needs " + f"{missing}. Give it one here — the engine would refuse to start " + f"without it, and the service would fail on its next start rather " + f"than now.", + ) + if form.tls_cert is not None: + fields["tls_cert"] = form.tls_cert + if form.tls_key is not None: + fields["tls_key"] = form.tls_key + # Only what actually changes. The form sends every setting on every save, and a field + # that already holds the value it is sent is not an edit: counted as one, opening the + # dialog and saving it untouched restarted a running service and dropped every + # connection it was carrying. + fields = {key: value for key, value in fields.items() if row[key] != value} + if not fields: + return {"status": "ok"} + + 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] + if transport != TRANSPORT.EXTERNAL: + # The endpoint of the operator's own proxy is the hand-off layer's alone. Left + # on the rows it is read by nothing and still sits in the unique index, refusing + # another service's hand-off the endpoint this one no longer uses. + new_addresses = [{**address, "proxy_ip": None, "proxy_port": None} + for address in new_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="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.", + ) + # 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: + 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 = ?, " + "proxy_ip = ?, proxy_port = ? WHERE address_id = ?;", + address["proto"], address["edge"], address["upstream"], + address["proxy_ip"], address["proxy_port"], 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}", + ) + + if set(fields) == {"name"}: + # A name is the operator's, not the datapath's: nothing running reads it, so it + # is not a reason to take the service down and bring it back. + manager.srv.name = fields["name"] + await refresh_frontend() + return {"status": "ok"} + + 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"} + + +# --- where a service is reachable --------------------------------------------- + + +@app.get("/{service_id}/addresses", response_model=list[AddressModel]) +async def get_addresses(service_id: str): + _service_or_404(service_id) + return [_address_row(a) for a in _addresses(service_id)] + + +@app.post("/{service_id}/addresses", response_model=StatusMessageModel) +async def add_address(service_id: str, form: AddressForm): + """Protect one more address with this service's existing chain. + + Not a restart: the datapath is already running and already enforcing the chain, so + this installs the rules that point the new address at it. The connections on the + other addresses are untouched. The one exception is a proxy service gaining its + first IPv6 address, because the listener has to be reopened in a family that can + accept it — the manager says so and rebuilds. + """ + row = _service_or_404(service_id) + if row["transport"] == TRANSPORT.EXTERNAL and not form.proxy_port: + raise HTTPException( + status_code=400, + detail="This service hands its traffic to your own proxy, so the new " + "address needs the port that proxy listens on for it", + ) + if row["transport"] == TRANSPORT.EXTERNAL and not is_ip_parse(form.ip_int): + raise HTTPException( + status_code=400, + detail="The external transport hands traffic to your own proxy and rewrites the " + "source address on return, which requires a concrete IP address rather than an interface.", + ) + # Before the row exists: what the address says has to mean something on this service. + _address_upstream(row["proto"], _address_edge(row["proto"], form), form) + try: + address_id = _insert_address(service_id, row["proto"], row["transport"], form) + except sqlite3.IntegrityError as e: + raise HTTPException(status_code=400, detail=_address_taken(e)) + @_once + def undo(): + # The row *and* the manager's copy of it: the manager re-read the list on its + # way in, so leaving it there would mean the next start protecting an address + # that was refused and no longer exists. + db.query("DELETE FROM service_addresses WHERE address_id = ?;", address_id) + firewall.get(service_id).reload_addresses() + + try: + await firewall.get(service_id).address_added(address_id, undo) + except UnsupportedChain as e: + undo() + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + undo() + raise HTTPException(status_code=500, detail=str(e)) + await refresh_frontend() + return {"status": "ok"} + + +@app.put("/{service_id}/addresses/{address_id}", response_model=StatusMessageModel) +async def edit_address(service_id: str, address_id: str, form: AddressForm): + """Change one address: where it is, and everything it says. + + The rules for it are taken back and reinstalled, which is what makes the whole row + rewritable rather than only its address. It used to touch `ip_int`, `port` and the + proxy endpoint alone, so the edge, the port the service is on and what that service + speaks were add-only — and the operator who set one of them while creating the + service had no way back to it except deleting the address and adding it again. That + is a worse answer than reinstalling rules the edit already reinstalls. + + Only this address stops being steered while that happens; the rest of the service + keeps its connections. A refusal puts the row back exactly as it was. + """ + row = _service_or_404(service_id) + was = _address_or_404(service_id, address_id) + if row["transport"] == TRANSPORT.EXTERNAL and not form.proxy_port: + raise HTTPException( + status_code=400, + detail="This service hands its traffic to your own proxy, so every address " + "needs the port that proxy listens on for it", + ) + if row["transport"] == TRANSPORT.EXTERNAL and not is_ip_parse(form.ip_int): + raise HTTPException( + status_code=400, + detail="The external transport hands traffic to your own proxy and rewrites the " + "source address on return, which requires a concrete IP address rather than an interface.", + ) + + # A field the caller did not mention keeps what the row already had. This is the + # endpoint that *moves* an address as well as the one that changes what it says, and + # 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: + form.target_port = was["target_port"] + if form.upstream is None: + form.upstream = was["upstream"] + + # 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"], edge, form) + + def write(ip_int, port, proto, edge, target_port, upstream, proxy_ip, proxy_port): + db.query( + "UPDATE service_addresses SET ip_int = ?, port = ?, proto = ?, edge = ?, " + "target_port = ?, upstream = ?, proxy_ip = ?, proxy_port = ? " + "WHERE address_id = ?;", + ip_int, port, proto, edge, target_port, upstream, proxy_ip, proxy_port, + address_id, + ) + + async def restore(): + """Put the address back exactly as it was, and steer it again. + + Best effort by design: the edit has already taken the old rules back, so the + alternative to trying is an address left unprotected because the *new* one was + impossible. A failure here is reported by the exception that caused it. + """ + write(was["ip_int"], was["port"], was["proto"], was["edge"], + was["target_port"], was["upstream"], was["proxy_ip"], was["proxy_port"]) + try: + await firewall.get(service_id).address_added(address_id) + except Exception: + pass + + try: + parsed_ip = parse_ip_or_int(form.ip_int) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + proxy_ip = _hijack_ip(row["transport"], parsed_ip, form) + manager = firewall.get(service_id) + await manager.address_removed(address_id) + try: + write(parsed_ip, form.port, L4.l4_of(edge), edge, target_port, upstream, + proxy_ip, _hijack_port(row["transport"], form)) + except sqlite3.IntegrityError as e: + await restore() + raise HTTPException(status_code=400, detail=_address_taken(e)) + try: + # A rebuild failing on the new address stops the service before it finds out; + # the manager puts the old row back and starts it again on that. + await manager.address_added(address_id, lambda: write( + was["ip_int"], was["port"], was["proto"], was["edge"], was["target_port"], + was["upstream"], was["proxy_ip"], was["proxy_port"])) + except UnsupportedChain as e: + await restore() + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + await restore() + raise HTTPException(status_code=500, detail=str(e)) + await refresh_frontend() + return {"status": "ok"} + + +@app.delete("/{service_id}/addresses/{address_id}", response_model=StatusMessageModel) +async def delete_address(service_id: str, address_id: str): + _service_or_404(service_id) + _address_or_404(service_id, address_id) + if len(_addresses(service_id)) == 1: + raise HTTPException( + status_code=400, + detail="This is the only address this service protects. Delete the service " + "itself, or add another address first.", + ) + await firewall.get(service_id).address_removed(address_id) + db.query("DELETE FROM service_addresses WHERE address_id = ?;", address_id) + await refresh_frontend() + return {"status": "ok"} + + +@app.delete("/{service_id}", response_model=StatusMessageModel) +async def delete_service(service_id: str): + _service_or_404(service_id) + await firewall.remove(service_id) + for row in db.query("SELECT filter_id FROM filters WHERE service_id = ?;", service_id): + clear_code(row["filter_id"]) + db.query("DELETE FROM regexes WHERE filter_id = ?;", row["filter_id"]) + db.query("DELETE FROM pyfilters WHERE filter_id = ?;", row["filter_id"]) + db.query("DELETE FROM filters WHERE service_id = ?;", service_id) + db.query("DELETE FROM service_addresses WHERE service_id = ?;", service_id) + stats.forget(db, service_id) + db.query("DELETE FROM services WHERE service_id = ?;", service_id) + await refresh_frontend() + return {"status": "ok"} + + +@app.post("/{service_id}/start", response_model=StatusMessageModel) +async def start_service(service_id: str): + _service_or_404(service_id) + try: + await firewall.get(service_id).next(STATUS.ACTIVE) + except UnsupportedChain as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + _note_filtering(service_id) + await refresh_frontend() + return {"status": "ok"} + + +@app.post("/{service_id}/stop", response_model=StatusMessageModel) +async def stop_service(service_id: str): + _service_or_404(service_id) + await firewall.get(service_id).next(STATUS.STOP) + await refresh_frontend() + return {"status": "ok"} + + +# --- the chain --------------------------------------------------------------- + + +@app.get("/{service_id}/filters", response_model=list[FilterModel]) +async def get_filters(service_id: str): + """The chain, in the order it runs.""" + _service_or_404(service_id) + return db.query( + "SELECT f.filter_id filter_id, f.service_id service_id, f.position position, " + "f.kind kind, f.proto proto, f.name name, f.active active, f.blocked blocked, " + "(SELECT COUNT(*) FROM regexes r WHERE r.filter_id = f.filter_id) n_regexes, " + "(SELECT COUNT(*) FROM pyfilters p WHERE p.filter_id = f.filter_id) n_functions, " + "(SELECT COUNT(*) FROM pyfilters p WHERE p.filter_id = f.filter_id AND p.active = 1) " + "n_functions_active " + "FROM filters f WHERE f.service_id = ? ORDER BY f.position ASC;", + service_id, + ) + + +@app.post("/{service_id}/filters", response_model=StatusMessageModel) +async def add_filter(service_id: str, form: FilterAddForm): + """Attach a filter to the end of a service's chain.""" + _service_or_404(service_id) + if form.kind not in KIND.ALL: + raise HTTPException(status_code=400, detail=f"Unknown filter kind {form.kind!r}") + last = db.query( + "SELECT COALESCE(MAX(position), -1) p FROM filters WHERE service_id = ?;", service_id + )[0]["p"] + filter_id = gen_id() + db.query( + "INSERT INTO filters (filter_id, service_id, position, kind, proto, name, active) " + "VALUES (?, ?, ?, ?, ?, ?, ?);", + filter_id, + service_id, + last + 1, + form.kind, + # A new pyfilter has no code yet, so it asks for nothing and speaks the simplest + # protocol there is. Saving code is what settles it. + PROTO.TCP, + refactor_name(form.name or form.kind), + form.active, + ) + if form.kind == KIND.PYFILTER: + write_code(filter_id, "") + + def undo(): + db.query("DELETE FROM filters WHERE filter_id = ?;", filter_id) + clear_code(filter_id) + + await _apply_chain(service_id, undo) + await refresh_frontend() + return {"status": "ok"} + + +@app.put("/{service_id}/filters/{filter_id}", response_model=StatusMessageModel) +async def edit_filter(service_id: str, filter_id: str, form: FilterEditForm): + was = _filter_or_404(service_id, filter_id) + fields = {} + if form.name is not None: + fields["name"] = refactor_name(form.name) + if form.active is not None: + fields["active"] = form.active + if fields: + db.query( + f"UPDATE filters SET {', '.join(f'{k} = ?' for k in fields)} WHERE filter_id = ?;", + *fields.values(), + filter_id, + ) + + def undo(): + db.query( + f"UPDATE filters SET {', '.join(f'{k} = ?' for k in fields)} WHERE filter_id = ?;", + *[was[k] for k in fields], + filter_id, + ) + + await _apply_chain(service_id, undo) + await refresh_frontend() + return {"status": "ok"} + + +@app.delete("/{service_id}/filters/{filter_id}", response_model=StatusMessageModel) +async def delete_filter(service_id: str, filter_id: str): + _filter_or_404(service_id, filter_id) + db.query("DELETE FROM regexes WHERE filter_id = ?;", filter_id) + db.query("DELETE FROM pyfilters WHERE filter_id = ?;", filter_id) + db.query("DELETE FROM filters WHERE filter_id = ?;", filter_id) + # Its history goes with it, or the chart would keep drawing a line for something + # that no longer exists, labelled with an id nobody can look up. + stats.forget_filter(db, filter_id) + clear_code(filter_id) + # Positions must stay contiguous: a gap is invisible until something orders by + # position and gets a different answer than the operator saw. + _renumber(service_id) + await _apply_chain(service_id) + await refresh_frontend() + return {"status": "ok"} + + +def _renumber_to(order: list[str]): + """Write an explicit order. Two passes because `position` is unique per service: + writing the final numbers directly would collide with the ones not yet moved.""" + for offset, filter_id in enumerate(order): + db.query("UPDATE filters SET position = ? WHERE filter_id = ?;", -1000 - offset, filter_id) + for position, filter_id in enumerate(order): + db.query("UPDATE filters SET position = ? WHERE filter_id = ?;", position, filter_id) + + +def _renumber(service_id: str): + rows = db.query( + "SELECT filter_id FROM filters WHERE service_id = ? ORDER BY position ASC;", service_id + ) + # Out of the way first: `position` is unique per service, so writing the final + # numbers directly would collide with the ones not yet moved. + for offset, row in enumerate(rows): + db.query( + "UPDATE filters SET position = ? WHERE filter_id = ?;", + -1000 - offset, + row["filter_id"], + ) + for position, row in enumerate(rows): + db.query( + "UPDATE filters SET position = ? WHERE filter_id = ?;", position, row["filter_id"] + ) + + +@app.post("/{service_id}/filters/order", response_model=StatusMessageModel) +async def reorder_filters(service_id: str, form: ReorderForm): + """Set the order the chain runs in. + + The whole list at once rather than a move: a partial reorder has to be reconciled + against whatever the operator's page last saw, and two tabs doing that at once + produce an order neither of them asked for. + """ + _service_or_404(service_id) + existing = [ + r["filter_id"] + for r in db.query("SELECT filter_id FROM filters WHERE service_id = ?;", service_id) + ] + if sorted(existing) != sorted(form.filters): + raise HTTPException( + status_code=400, + detail="The order must list exactly the filters this service has", + ) + previous = { + r["filter_id"]: r["position"] + for r in db.query( + "SELECT filter_id, position FROM filters WHERE service_id = ?;", service_id + ) + } + _renumber_to(form.filters) + + def undo(): + _renumber_to(sorted(previous, key=lambda fid: previous[fid])) + + await _apply_chain(service_id, undo) + await refresh_frontend() + return {"status": "ok"} + + +# --- a pyfilter's code ------------------------------------------------------- + + +class CodeCheckError(BaseModel): + type: str + message: str + #: 1-based, or 0 when nothing in the file could be pointed at. + line: int = 0 + column: int = 0 + #: The offending source line, so the reason can be shown without a second lookup. + text: str = "" + traceback: str = "" + + +class CodeCheckResult(BaseModel): + ok: bool + #: Which application protocol the code speaks, when it loads. + proto: str | None = None + #: The `@pyfilter` functions it defines, in the order the library found them. + filters: list[str] = [] + #: The models it asks for, which is what decides where it can run. + models: list[str] = [] + error: CodeCheckError | None = None + + +@app.post("/{service_id}/filters/{filter_id}/check", response_model=CodeCheckResult) +async def check_filter_code(service_id: str, filter_id: str, form: CodeForm): + """Would this code load? Answered without saving it. + + Always 200, even when the answer is no: a refusal here is the result, not a failure + of the request, and the editor wants a position to put a marker on rather than a + string to print. + """ + 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 await validate_code(form.code) + + +@app.get("/{service_id}/filters/{filter_id}/code", response_class=PlainTextResponse) +async def get_filter_code(service_id: str, filter_id: str): + """The user's Python. Plain text, not JSON: it is a file, not a value.""" + 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 read_code(filter_id) + + +@app.put("/{service_id}/filters/{filter_id}/code", response_model=StatusMessageModel) +async def set_filter_code(service_id: str, filter_id: str, form: CodeForm): + """Replace a pyfilter's code, and record which protocol it now speaks. + + The protocol is not asked for and cannot be set: the library reads it off the + filters' parameter annotations, because that is already what decides when each one + is called. Asking would mean two places that can disagree, and the symptom of a + disagreement is a filter that silently never runs — or, as it happened here, one + refused for asking for an `HttpRequest`, which is the only thing an HTTP filter does. + + A file whose filters want two *different* application protocols is refused, naming + both: a connection is only ever one of them. + """ + 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") + # 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 = 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) + if str(service["proto"]) == L4.UDP: + # Refused rather than accepted and never called: on a datagram these models + # decline to be built, so the filter would sit in the chain doing nothing and + # nothing would say so. + wanted = needs_a_stream(checked.get("models", [])) + if wanted: + raise HTTPException( + status_code=400, + detail=f"This service speaks UDP, and {', '.join(wanted)} needs a TCP " + f"stream underneath — an assembled stream or a parsed HTTP " + f"message. On datagrams the library would decline to build it and " + f"the filter would never be called. Ask for a RawPacket instead, " + f"which carries the datagram and its metadata.", + ) + proto = checked.get("proto", PROTO.TCP) + was = read_code(filter_id) + was_functions = db.query( + "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, 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, position) " + "VALUES (?, ?, ?, ?, ?);", + filter_id, fn["name"], fn["active"], fn["blocked"], fn["position"], + ) + + await _apply_chain(service_id, undo) + await refresh_frontend() + return {"status": "ok"} + + +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 + function that has gone from the file is dropped, one that has appeared is added + 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. + + `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. + """ + existing = { + row["name"] for row in db.query( + "SELECT name FROM pyfilters WHERE filter_id = ?;", filter_id + ) + } + for name in existing - set(defined): + db.query("DELETE FROM pyfilters WHERE filter_id = ? AND name = ?;", filter_id, name) + for position, name in enumerate(defined): + if name in existing: + # A survivor keeps its `active` and its counters, but not its old place: a + # function inserted above it moved it down in the file, and the file is what + # the order means. + db.query( + "UPDATE pyfilters SET position = ? WHERE filter_id = ? AND name = ?;", + position, filter_id, name, + ) + else: + db.query( + "INSERT INTO pyfilters (filter_id, name, position) VALUES (?, ?, ?);", + filter_id, name, position, + ) + + +#: How long the operator's code gets to load before the check gives up on it. +CHECK_TIMEOUT = 8 + + +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 + same build, the same compile, the same library. A stand-in that merely parsed the + file would accept things the datapath refuses, which is the class of lie the regex + tester exists to avoid. + + 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 — the check's own, not the backend's (see `_run_helper`). + """ + import tempfile + + with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f: + f.write(code) + probe = f.name + try: + 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": { + "type": "Timeout", "line": 0, "column": 0, "text": "", + "message": f"the file did not finish loading within {CHECK_TIMEOUT}s. " + f"Code at module level runs once per connection, so it has to " + f"return — a loop or a blocking call there never will.", + "traceback": "", + }, + } + _, stdout, stderr = ran + try: + return json.loads(stdout.decode()) + except (ValueError, UnicodeDecodeError): + # The checker itself failed, which is ours to explain rather than theirs. + 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": ""}} + + +#: The one model that does not need a connection under it. +#: +#: Every other model reaches for something only TCP has — an assembled stream, a parsed +#: HTTP message — and answers `NotReadyToRun` on a datagram, which means the filter is +#: never called. Stated here rather than inferred because the library expresses it by +#: declining at runtime, and a filter that silently never fires is the failure this +#: whole module is arranged to prevent. +DATAGRAM_SAFE_MODELS = {"RawPacket"} + + +def needs_a_stream(models: list[str]) -> list[str]: + return sorted(set(models) - DATAGRAM_SAFE_MODELS) + + +def describe_error(error: dict) -> str: + """The one-line form, for a caller that has nowhere to put a marker.""" + where = f" (line {error['line']})" if error.get("line") else "" + return f"{error.get('type', 'Error')}{where}: {error.get('message', '')}" + + +# --- the functions inside one pyfilter --------------------------------------- + + +@app.get("/{service_id}/filters/{filter_id}/functions", response_model=list[PyFunctionModel]) +async def get_functions(service_id: str, filter_id: str): + """Every `@pyfilter` the code defines, and whether it is switched on.""" + 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 db.query( + "SELECT filter_id, name, active, blocked FROM pyfilters " + "WHERE filter_id = ? ORDER BY position ASC, name ASC;", + filter_id, + ) + + +@app.put( + "/{service_id}/filters/{filter_id}/functions/{name}", response_model=StatusMessageModel +) +async def edit_function(service_id: str, filter_id: str, name: str, form: PyFunctionEditForm): + """Switch one function of a pyfilter on or off. + + The code is untouched: only the list of names handed to the library changes, which + is exactly what decides whether a function is ever called. Deleting the code to stop + consulting it, and pasting it back to resume, is the thing this exists to avoid. + """ + 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") + found = db.query( + "SELECT active FROM pyfilters WHERE filter_id = ? AND name = ?;", filter_id, name + ) + if not found: + raise HTTPException(status_code=404, detail="This filter defines no such function") + db.query( + "UPDATE pyfilters SET active = ? WHERE filter_id = ? AND name = ?;", + form.active, filter_id, name, + ) + await _apply_chain(service_id, lambda: db.query( + "UPDATE pyfilters SET active = ? WHERE filter_id = ? AND name = ?;", + found[0]["active"], filter_id, name)) + await refresh_frontend() + return {"status": "ok"} + + +# --- a regex filter's patterns ----------------------------------------------- + + +@app.get("/{service_id}/filters/{filter_id}/regexes", response_model=list[RegexModel]) +async def get_regexes(service_id: str, filter_id: str): + _filter_or_404(service_id, filter_id) + return db.query("SELECT * FROM regexes WHERE filter_id = ?;", filter_id) + + +def _decoded_pattern(encoded: str) -> str: + """The pattern a request carries, or a 400 saying why it is not one. + + Two things the obvious `b64decode(...)` does not do, and both of them matter because + what gets through here is compiled into a live service's chain. + + **Strict decoding.** Python's decoder ignores characters outside the alphabet unless + it is asked not to, so `"!!!"` is not an error — it is `b""`. A malformed field + therefore arrived as an empty pattern rather than as a refusal. + + **And an empty pattern is refused.** hyperscan compiles it happily (the flags allow a + pattern that matches an empty buffer, which real patterns like `a*` need) and then it + matches every buffer it is shown — so a service whose chain holds one refuses every + connection it carries. There is no legitimate way to ask for that, an empty field in + the form is the only way to arrive at it, and the symptom is a CTF service that has + silently stopped answering. It is worth one sentence at the door. + """ + try: + # Whitespace out first, then strict: a base64 field that arrived wrapped across + # lines is still a perfectly good one, and `validate=True` would refuse it for a + # newline. What is being caught here is a field that is not base64 at all. + raw = base64.b64decode("".join(str(encoded).split()), validate=True) + except (binascii.Error, ValueError): + raise HTTPException(status_code=400, detail="The pattern must be base64-encoded") + if not raw: + raise HTTPException( + status_code=400, + detail="A pattern cannot be empty: an empty one matches every byte of every " + "connection, so the service would refuse all of its traffic.", + ) + return raw.decode(errors="replace") + + +@app.post("/{service_id}/filters/{filter_id}/regexes", response_model=StatusMessageModel) +async def add_regex(service_id: str, filter_id: str, form: RegexAddForm): + row = _filter_or_404(service_id, filter_id) + if row["kind"] != KIND.REGEX: + raise HTTPException(status_code=400, detail="This filter does not hold patterns") + if form.mode not in MODE.ALL: + raise HTTPException(status_code=400, detail=f"Unknown mode {form.mode!r}") + pattern = _decoded_pattern(form.regex) + # 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 = await check_pattern(pattern, form.case_sensitive) + if not ok: + raise HTTPException(status_code=400, detail=f"Invalid pattern: {why}") + try: + regex_id = gen_id() + db.query( + "INSERT INTO regexes (regex_id, filter_id, regex, mode, case_sensitive, active) " + "VALUES (?, ?, ?, ?, ?, ?);", + regex_id, + filter_id, + form.regex, + form.mode, + form.case_sensitive, + form.active, + ) + except sqlite3.IntegrityError: + raise HTTPException(status_code=400, detail="This pattern is already in this filter") + await _apply_chain(service_id, lambda: db.query( + "DELETE FROM regexes WHERE regex_id = ?;", regex_id)) + await refresh_frontend() + return {"status": "ok"} + + +@app.put("/{service_id}/filters/{filter_id}/regexes/{regex_id}", response_model=StatusMessageModel) +async def edit_regex(service_id: str, filter_id: str, regex_id: str, form: RegexEditForm): + """Change a pattern in place, without dropping the connections it is matching. + + The same rule keeps its row, so it keeps its place and its counters — except when the + *pattern itself* changes. Then its history stops being its own: a count is about what + a rule matched, and carrying yesterday's blocks over to a matcher that now matches + something else would credit them to something that never made them. It is handed to + the filter rather than deleted, so the timeline keeps its shape — those connections + were refused, and correcting a typo afterwards does not unrefuse them. + """ + _filter_or_404(service_id, filter_id) + was = _regex_or_404(filter_id, regex_id) + + fields: dict = {} + if form.active is not None: + fields["active"] = form.active + if form.mode is not None: + if form.mode not in MODE.ALL: + raise HTTPException(status_code=400, detail=f"Unknown mode {form.mode!r}") + fields["mode"] = form.mode + if form.case_sensitive is not None: + fields["case_sensitive"] = form.case_sensitive + if form.regex is not None: + fields["regex"] = form.regex + + if not fields: + return {"status": "ok"} + + # 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 = await check_pattern(pattern, fields.get("case_sensitive", was["case_sensitive"])) + if not ok: + raise HTTPException(status_code=400, detail=f"Invalid pattern: {why}") + + changed_matcher = "regex" in fields and fields["regex"] != was["regex"] + if changed_matcher: + fields["blocked"] = 0 + + try: + db.query( + f"UPDATE regexes SET {', '.join(f'{k} = ?' for k in fields)} WHERE regex_id = ?;", + *fields.values(), + regex_id, + ) + except sqlite3.IntegrityError: + raise HTTPException( + status_code=400, detail="This filter already holds that exact pattern" + ) + def undo(): + db.query( + f"UPDATE regexes SET {', '.join(f'{k} = ?' for k in fields)} WHERE regex_id = ?;", + *[was[k] for k in fields], + regex_id, + ) + + await _apply_chain(service_id, undo) + # After the chain has accepted it, and not before: a refusal has to leave the rule + # exactly as it was, and history moved ahead of the verdict could not be put back. + if changed_matcher: + stats.disown_rule(db, filter_id, regex_id) + await refresh_frontend() + return {"status": "ok"} + + +@app.delete("/{service_id}/filters/{filter_id}/regexes/{regex_id}", response_model=StatusMessageModel) +async def delete_regex(service_id: str, filter_id: str, regex_id: str): + _filter_or_404(service_id, filter_id) + _regex_or_404(filter_id, regex_id) + db.query("DELETE FROM regexes WHERE regex_id = ?;", regex_id) + await _apply_chain(service_id) + await refresh_frontend() + return {"status": "ok"} + + +# --- what the service is doing ----------------------------------------------- + + +@app.get("/{service_id}/stats", response_model=StatsModel) +async def get_stats( + service_id: str, + range_from: int | None = None, + range_to: int | None = None, + buckets: int = 60, + step: int | None = None, +): + """What this service's filters have refused, over a range you choose. + + `range_from`/`range_to` are unix seconds; leaving them out means the last hour. + Everything in the answer counts the same range — the chart, the per-filter, + per-pattern and per-function totals, and the shares — because a page where the chart + honours the range and the table beside it does not is a page that contradicts itself. + + The bucket widens with the range rather than the count growing: twelve hours at + one-minute resolution is seven hundred bars in a few hundred pixels. `step` overrides + that with a width in seconds, for an operator who wants two ranges to be comparable + by eye; it is rounded to whole stored buckets and widened if it would draw more bars + than a chart can carry, and `bucket_seconds` reports what was actually used. + """ + service = _service_or_404(service_id) + buckets = max(5, min(int(buckets), 400)) + # The real instant, not the bucket it falls in: the window is aligned to buckets + # inside `stats`, and truncating here would report the current, partial minute as + # ending at the moment it began — which on a service a few seconds old prints as + # "23:35 → 23:35". + now = int(time.time()) + kept_from = stats.retention_start() + began = service.get("filtering_since") + end = min(int(range_to) if range_to else now, now) + start = int(range_from) if range_from else end - 3600 + if end < start: + start, end = end, start # asked for the wrong way round + # Clamped rather than refused: asking for a week of a two-day history is a reasonable + # thing to do, and so is asking for a day of a service that has been up for ten + # minutes. The answer is what there is, with the range actually served reported back + # so the interface can say what it drew. + start = max(start, kept_from) + # The second floor, and the one that bites every day: without it a new service is a + # chart of twenty-three empty hours with a smudge at the right-hand edge, which reads + # as "nothing is happening" when what it means is "this only started existing a + # moment ago". Only when the window reaches into the service's life at all — a window + # that ended before it began is answered as asked, and is empty because it is, rather + # than being dragged forward onto minutes nobody asked about. + if began is not None and end >= began: + start = max(start, began) + + window = stats.window(db, service_id, start, end, buckets, step if step else None) + counted = stats.totals(db, service_id, start, end) + by_filter = stats.totals_by_filter(db, service_id, start, end) + total = sum(counted.values()) + + def with_share(rows: list[dict]) -> list[dict]: + for row in rows: + row["share"] = round(100 * int(row["blocked"]) / total, 1) if total else 0.0 + return rows + + filters = db.query( + "SELECT filter_id id, name, kind, blocked all_time FROM filters " + "WHERE service_id = ? ORDER BY position ASC;", + service_id, + ) + for row in filters: + # Summed over the rules inside it, not looked up by its own id: a regex block is + # reported by its pattern and a Python one by its function, so a filter's own + # token appears only when neither named itself. + row["blocked"] = by_filter.get(row["id"], 0) + + patterns = db.query( + "SELECT r.regex_id id, r.regex name, f.kind kind, r.filter_id filter_id " + "FROM regexes r JOIN filters f ON r.filter_id = f.filter_id WHERE f.service_id = ?;", + service_id, + ) + for row in patterns: + row["blocked"] = counted.get(row["id"], 0) + # The pattern is stored base64 because it is bytes; a chart legend wants text. + try: + row["name"] = base64.b64decode(row["name"]).decode(errors="replace") + except (binascii.Error, ValueError): + pass + + functions = db.query( + "SELECT p.name name, 'pyfilter' kind, p.filter_id filter_id FROM pyfilters p " + "JOIN filters f ON p.filter_id = f.filter_id WHERE f.service_id = ?;", + service_id, + ) + for row in functions: + # A function's name is unique inside its file, not across the chain, so the + # token the datapath reports is what identifies it here too. + row["id"] = f"{row['filter_id']}/{row['name']}" + row["blocked"] = counted.get(row["id"], 0) + + # What a filter was credited with that no rule still in it accounts for: a pattern + # that was deleted, or one whose text was edited and so handed its history to the + # filter. Those blocks are in the chart and in the total because they happened, so + # the breakdown has to name them — otherwise it quietly adds up to less than the + # chart above it, and the operator is left looking for the difference. + accounted: dict[str, int] = {} + for row in patterns + functions: + accounted[row["filter_id"]] = accounted.get(row["filter_id"], 0) + int(row["blocked"]) + for row in filters: + left = int(row["blocked"]) - accounted.get(row["id"], 0) + if left <= 0: + continue + is_py = row["kind"] == KIND.PYFILTER + (functions if is_py else patterns).append({ + "id": f"{row['id']}/", + "name": "functions since removed" if is_py else "patterns since edited or removed", + "kind": row["kind"], + "filter_id": row["id"], + "blocked": left, + "all_time": None, + "residual": True, + }) + + named = {row["id"]: row["name"] for row in filters} + manager = None + try: + manager = firewall.get(service_id) + except Exception: + pass + traffic = manager.traffic() if manager else {} + seen, refused = traffic.get("connections"), traffic.get("connections_refused") + return { + "filters": with_share(filters), + "patterns": with_share(sorted(patterns, key=lambda r: -r["blocked"])), + "functions": with_share(sorted(functions, key=lambda r: -r["blocked"])), + "buckets": window["edges"], + "bucket_seconds": window["width"], + # Only the filters that actually refused something in the range: an empty line + # per filter would make a busy chain unreadable to say nothing. + "series": [ + {"id": fid, "name": named.get(fid, fid), "counts": counts} + for fid, counts in window["series"].items() + ], + "total": total, + "all_time": sum(int(row["all_time"]) for row in filters), + "range_from": start, + "range_to": end, + "kept_from": kept_from, + "filtering_since": began, + "traffic": { + **traffic, + # Only where both numbers are connections. Dividing refused connections by + # a packet count would produce a number that looks like a percentage and + # means nothing. + "refused_share": ( + round(100 * refused / seen, 2) if seen and refused is not None else None + ), + }, + } + + +@app.get("/{service_id}/logs", response_model=list[LogEntryModel]) +async def get_logs(service_id: str): + """The tail of what this service has been doing. + + Only the tail: new lines arrive over the `log` socket event as they happen, so this + exists to fill the panel when it opens rather than to be polled. It is bounded, so a + service that has blocked a million connections still answers instantly. + """ + _service_or_404(service_id) + return log_for(service_id).entries() + + +@app.delete("/{service_id}/logs", response_model=StatusMessageModel) +async def clear_logs(service_id: str): + _service_or_404(service_id) + log_for(service_id).clear() + # The list shows the newest problem in the log, and clearing is how it is dismissed. + await refresh_frontend() + return {"status": "ok"} + + +# --- the debugger ------------------------------------------------------------ + + +async def _ask_engine(request: dict) -> dict: + """Put the question to the binary that would enforce the answer.""" + try: + 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") + if ran is None: + raise HTTPException(status_code=400, detail="The pattern took too long to evaluate") + 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(stdout.decode()) + + +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 + stream matching, which is the only mode anything runs in now: a pattern blocks, and + blocking follows the stream across chunk boundaries. This used to take the action + too, because rewriting compiled in block mode and the two do not accept quite the + same patterns. + + `case_sensitive` is coerced rather than trusted: a caller checking a pattern it just + read back from the database hands over SQLite's 1, and the engine's request format + is strict about the difference — which came out as "invalid type: integer 1" against + a pattern that was perfectly fine. + """ + try: + res = await _ask_engine( + { + "patterns": [ + { + "id": "x", + "expr": pattern, + "case_sensitive": bool(case_sensitive), + } + ], + "sample": "", + } + ) + except HTTPException as e: + return False, str(e.detail) + if res.get("error"): + return False, res["error"] + if res.get("errors"): + return False, res["errors"][0]["error"] + return True, "ok" + + +@app.post("/debug-regex", response_model=DebugResponse) +async def debug_regexes(form: DebugForm): + """Try patterns against a sample, with the engine that will run them. + + Deliberately not a `re`-based approximation. hyperscan takes a subset of PCRE and + rejects things Python accepts, so anything else would bless patterns that cannot + be saved and disagree about what matches — a tester the operator would learn not + to trust, which is worse than none. + """ + res = await _ask_engine( + { + "patterns": [ + { + "id": p.id, + "expr": p.expr, + "case_sensitive": p.case_sensitive, + } + for p in form.patterns + ], + "sample": form.sample, + } + ) + return { + "matches": [ + {"id": m["id"], "start": m["from"], "end": m["to"]} for m in res.get("matches", []) + ], + "errors": res.get("errors", []), + "error": res.get("error"), + "unscannable": res.get("unscannable", []), + "truncated": res.get("truncated", False), + } diff --git a/backend/routers/tls.py b/backend/routers/tls.py deleted file mode 100644 index 5c2544e..0000000 --- a/backend/routers/tls.py +++ /dev/null @@ -1,177 +0,0 @@ -from fastapi import APIRouter, HTTPException -import subprocess -from pydantic import BaseModel -from modules.tls.manager import TLSManager, ip_parse -from modules.tls.service import reload_tls -from utils.models import StatusMessageModel -import secrets -import routers.nfproxy as nfproxy_router -import routers.nfregex as nfregex_router - -def gen_service_id(): - while True: - res = secrets.token_hex(8) - if not manager.get_stream(res): - break - return res - -class TLSStreamAddForm(BaseModel): - name: str - ip_int: str - port: int - cert: str - key: str - enabled: bool = True - -class TLSStreamEditForm(BaseModel): - name: str | None = None - ip_int: str | None = None - port: int | None = None - cert: str | None = None - key: str | None = None - -class TLSStreamResponse(BaseModel): - status: str - stream_id: str | None = None - -app = APIRouter() -manager = TLSManager() - -# (module, human name) pairs of the routers that can depend on a TLS stream -DEPENDENT_ROUTERS = (("nfproxy", nfproxy_router), ("nfregex", nfregex_router)) - -def get_dependent_services(stream_id: str) -> list[tuple[str, "object", str, str]]: - """Returns (module_name, router_module, service_id, status) for every nfproxy/nfregex service linked to a TLS stream.""" - deps = [] - for module_name, router_mod in DEPENDENT_ROUTERS: - rows = router_mod.db.query( - "SELECT service_id, status FROM services WHERE target_type = 'tls' AND tls_stream_id = ?;", stream_id - ) - for row in rows: - deps.append((module_name, router_mod, row["service_id"], row["status"])) - return deps - -async def sync_dependent_services_address(stream_id: str, new_ip: str, new_port: int): - """After a stream's ip/port changed, mirror it onto dependent services and rebind their live nft rules.""" - touched_modules = set() - for module_name, router_mod, srv_id, status in get_dependent_services(stream_id): - router_mod.db.query("UPDATE services SET ip_int = ?, port = ? WHERE service_id = ?;", new_ip, new_port, srv_id) - await router_mod.firewall.remove(srv_id) - await router_mod.firewall.reload() - await router_mod.firewall.get(srv_id).next(status) - touched_modules.add(module_name) - for module_name, router_mod in DEPENDENT_ROUTERS: - if module_name in touched_modules: - await router_mod.refresh_frontend() - -async def cascade_stop_dependents(stream_id: str): - """When a TLS stream is stopped, stop every active service that depends on it.""" - touched_modules = set() - for module_name, router_mod, srv_id, status in get_dependent_services(stream_id): - if status == "active": - await router_mod.firewall.get(srv_id).next("stop") - touched_modules.add(module_name) - for module_name, router_mod in DEPENDENT_ROUTERS: - if module_name in touched_modules: - await router_mod.refresh_frontend() - -async def startup(): - await reload_tls() - -async def shutdown(): - subprocess.run(["nginx", "-c", "/tmp/firegex_nginx.conf", "-s", "stop", "-e", "/tmp/firegex_nginx_error.log"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - -async def reset(form): - if form.delete: - manager.db.delete() - manager.db.init() - await reload_tls() - -@app.get('/streams') -async def get_streams(): - return manager.get_streams() - -@app.post('/streams', response_model=TLSStreamResponse) -async def add_stream(form: TLSStreamAddForm): - try: - ip_int = ip_parse(form.ip_int) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid address") - - if manager.get_stream_by_ip_port(ip_int, form.port): - raise HTTPException(status_code=400, detail="A TLS stream for this IP and Port already exists") - - stream_id = gen_service_id() - try: - manager.add_stream(stream_id, form.name, ip_int, form.port, form.cert, form.key, status="active" if form.enabled else "stop") - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - await reload_tls() - return {"status": "ok", "stream_id": stream_id} - -@app.put('/streams/{stream_id}', response_model=StatusMessageModel) -async def edit_stream(stream_id: str, form: TLSStreamEditForm): - stream = manager.get_stream(stream_id) - if not stream: - raise HTTPException(status_code=404, detail="TLS stream not found") - - new_ip_int = None - if form.ip_int is not None: - try: - new_ip_int = ip_parse(form.ip_int) - except ValueError: - raise HTTPException(status_code=400, detail="Invalid address") - - if form.port is not None and (form.port < 1 or form.port > 65535): - raise HTTPException(status_code=400, detail="Invalid port") - - check_ip = new_ip_int if new_ip_int is not None else stream["ip_int"] - check_port = form.port if form.port is not None else stream["port"] - address_changed = check_ip != stream["ip_int"] or check_port != stream["port"] - - if address_changed: - existing = manager.get_stream_by_ip_port(check_ip, check_port) - if existing and existing["id"] != stream_id: - raise HTTPException(status_code=400, detail="A TLS stream for this IP and Port already exists") - - try: - manager.update_stream(stream_id, name=form.name, ip_int=new_ip_int, port=form.port, cert=form.cert, key=form.key) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - if address_changed: - await sync_dependent_services_address(stream_id, check_ip, check_port) - - await reload_tls() - return {"status": "ok"} - -@app.delete('/streams/{stream_id}', response_model=StatusMessageModel) -async def delete_stream(stream_id: str): - stream = manager.get_stream(stream_id) - if not stream: - raise HTTPException(status_code=404, detail="TLS stream not found") - - if get_dependent_services(stream_id): - raise HTTPException(status_code=400, detail="Cannot delete this TLS stream because it is actively used by NFProxy or NFRegex services.") - - manager.delete_stream(stream_id) - await reload_tls() - return {"status": "ok"} - -@app.post('/streams/{stream_id}/start', response_model=StatusMessageModel) -async def start_stream(stream_id: str): - if not manager.get_stream(stream_id): - raise HTTPException(status_code=404, detail="TLS stream not found") - manager.update_status(stream_id, "active") - await reload_tls() - return {"status": "ok"} - -@app.post('/streams/{stream_id}/stop', response_model=StatusMessageModel) -async def stop_stream(stream_id: str): - if not manager.get_stream(stream_id): - raise HTTPException(status_code=404, detail="TLS stream not found") - manager.update_status(stream_id, "stop") - await reload_tls() - await cascade_stop_dependents(stream_id) - return {"status": "ok"} diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py index 57d16a0..2f0b9b1 100644 --- a/backend/utils/__init__.py +++ b/backend/utils/__init__.py @@ -1,16 +1,18 @@ import asyncio from ipaddress import ip_address, ip_interface import os +import re import socket import psutil import sys -import nftables +try: + import nftables +except ImportError: + nftables = None from socketio import AsyncServer from typing import Annotated, List, Union -from functools import wraps -from pydantic import BaseModel, ValidationError, Field +from pydantic import BaseModel, Field import traceback -from utils.models import StatusMessageModel from pathlib import Path from fastapi import HTTPException, status @@ -103,6 +105,40 @@ API_VERSION = _get_version() PortType = Annotated[int, Field(gt=0, lt=65536)] +#: What a backup entry may be called: a plain basename of one of the two shapes +#: `export_db()` produces — `.db` for a database and `.py` for a filter's code. +#: The charset forbids path separators and `..`, so a crafted key cannot escape the +#: directory it is joined onto. +#: +#: Here rather than in `app.py`, beside `safe_join`, because the two are one defence in +#: two halves: this decides what a name may contain and that one decides where it may +#: land. Keeping them apart meant the charset could only be exercised by importing the +#: whole application — a server, a JWT library and everything else `app.py` pulls in — +#: to check two regular expressions. +#: +#: Matched with `fullmatch`, never `match`: in Python `$` also matches *before a trailing +#: newline*, so `"services.db\n"` satisfied a pattern written to mean "this and nothing +#: else". +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. @@ -187,6 +223,31 @@ def is_ip_parse(ip:str): except Exception: return False +def is_interface_name(name: str) -> bool: + if not isinstance(name, str): + return False + name = name.strip() + return bool(re.match(r'^[a-zA-Z0-9_.:-]+$', name)) and 0 < len(name) <= 15 + +def parse_ip_or_int(ip: str) -> str: + """Normalise what an operator typed into what the rules and the key will use. + + **Stripped once, before either question is asked.** `is_interface_name` strips and + `is_ip_parse` does not, so surrounding whitespace used to decide *which of the two an + address was*: `" 10.0.0.1 "` failed the IP test, passed the interface-name test — + every character in it is in that charset — and was stored verbatim. Nothing then + broke loudly, which is the problem: the rules still matched (both sides re-parse the + value), but `"10.0.0.1"` and `"10.0.0.1/32"` are different strings, so the + `(ip_int, port, proto)` uniqueness key no longer saw them as the same address and two + services could each believe they were protecting it. + """ + ip_str = str(ip).strip() + if is_ip_parse(ip_str): + return ip_parse(ip_str) + if is_interface_name(ip_str): + return ip_str + raise ValueError(f"'{ip}' is neither a valid IP address nor a valid interface name") + def addr_parse(ip:str): return str(ip_address(ip)) @@ -201,18 +262,21 @@ def get_interfaces(): yield {"name": int_name, "addr":interf.address} return list(_get_interfaces()) +def get_interface_ips(iface_name: str) -> list[str]: + ips = [] + for int_name, interfs in psutil.net_if_addrs().items(): + if int_name == iface_name: + for interf in interfs: + if interf.family in [socket.AF_INET, socket.AF_INET6]: + ips.append(interf.address) + return ips + def nftables_int_to_json(ip_int): ip_int = ip_parse(ip_int) ip_addr = str(ip_int).split("/")[0] ip_addr_cidr = int(str(ip_int).split("/")[1]) return {"prefix": {"addr": ip_addr, "len": ip_addr_cidr}} -def nftables_json_to_int(ip_json_int): - if isinstance(ip_json_int,str): - return str(ip_parse(ip_json_int)) - else: - return f'{ip_json_int["prefix"]["addr"]}/{ip_json_int["prefix"]["len"]}' - class Singleton(object): __instance = None def __new__(class_, *args, **kwargs): @@ -222,12 +286,12 @@ class Singleton(object): class NFTableManager(Singleton): - table_name = "firegex" + table_name = "fgex" def __init__(self, init_cmd, reset_cmd): self.__init_cmds = init_cmd self.__reset_cmds = reset_cmd - self.nft = nftables.Nftables() + self.nft = nftables.Nftables() if nftables is not None else None def raw_cmd(self, *cmds): return self.nft.json_cmd({"nftables": list(cmds)}) @@ -247,15 +311,27 @@ class NFTableManager(Singleton): def reset(self): self.raw_cmd(*self.__reset_cmds) - def list_rules(self, tables = None, chains = None): - for filter in [ele["rule"] for ele in self.raw_list() if "rule" in ele ]: + def list_rules(self, tables = None, chains = None, family: str | None = None): + for filter in [ele["rule"] for ele in self.raw_list(tables, family) if "rule" in ele ]: if tables and filter["table"] not in tables: continue if chains and filter["chain"] not in chains: continue yield filter - - def raw_list(self): + + def raw_list(self, tables = None, family: str | None = None): + # One table of ours when that is all that is asked for, rather than the host's + # whole ruleset: on a node running Docker or Kubernetes that is thousands of rules + # of somebody else's, serialised and parsed on every lookup — and a lookup runs + # inside the backend's one event loop, on every start, stop, address change and + # statistics request. A table that is not there is simply no rules. + if tables and family: + out = [] + for table in tables: + code, listed, _ = self.raw_cmd({"list": {"table": {"family": family, "name": table}}}) + if code == 0: + out.extend(listed["nftables"]) + return out return self.cmd({"list": {"ruleset": None}})["nftables"] def _json_like(obj: BaseModel|List[BaseModel], unset=False, convert_keys:dict[str, str]=None, exclude:list[str]=None, mode:str="json"): @@ -275,36 +351,6 @@ def json_like(obj: BaseModel|List[BaseModel], unset=False, convert_keys:dict[str return [_json_like(ele, unset=unset, convert_keys=convert_keys, exclude=exclude, mode=mode) for ele in obj] return _json_like(obj, unset=unset, convert_keys=convert_keys, exclude=exclude, mode=mode) -def register_event(sio_server: AsyncServer, event_name: str, model: BaseModel, response_model: BaseModel|None = None): - def decorator(func): - @sio_server.on(event_name) # Automatically registers the event - @wraps(func) - async def wrapper(sid, data): - try: - # Parse and validate incoming data - parsed_data = model.model_validate(data) - except ValidationError: - return json_like(StatusMessageModel(status=f"Invalid {event_name} request")) - - # Call the original function with the parsed data - result = await func(sid, parsed_data) - # If a response model is provided, validate the output - if response_model: - try: - parsed_result = response_model.model_validate(result) - except ValidationError: - traceback.print_exc() - return json_like(StatusMessageModel(status=f"SERVER ERROR: Invalid {event_name} response")) - else: - parsed_result = result - # Emit the validated result - if parsed_result: - if isinstance(parsed_result, BaseModel): - return json_like(parsed_result) - return parsed_result - return wrapper - return decorator - def nicenessify(priority:int, pid:int|None=None): try: pid = os.getpid() if pid is None else pid diff --git a/backend/utils/models.py b/backend/utils/models.py index 7e9745b..4d9914e 100644 --- a/backend/utils/models.py +++ b/backend/utils/models.py @@ -16,6 +16,14 @@ class PasswordChangeForm(BaseModel): password: str expire: bool +class AuthModeForm(BaseModel): + #: True turns authentication off, False turns it back on. The two are **not** + #: symmetrical and the endpoint says so rather than this form: turning it off is an + #: administrator's deployment choice, while turning it on — asked while it is off, + #: when every caller is already an administrator — takes a token this instance signed + #: *before* it was turned off, and a password to ask for. See `set_auth_mode`. + disabled: bool + class ChangePasswordModel(BaseModel): status: str access_token: str|None = None diff --git a/backend/utils/sqlite.py b/backend/utils/sqlite.py index a8a10ff..85f5122 100644 --- a/backend/utils/sqlite.py +++ b/backend/utils/sqlite.py @@ -43,9 +43,6 @@ class SQLite(): if were_active: self.connect() - def delete_backup(self): - self.__backup = None - def disconnect(self) -> None: if self.conn: self.conn.close() @@ -133,12 +130,33 @@ class SQLite(): for table in tables: tname = table["name"] if tname == 'keys_values': - res[tname] = self.query(f"SELECT * FROM {tname} WHERE key NOT IN ('password', 'secret');") + # 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 " + f"('password', 'secret', 'auth_disabled', 'auth_disabled_host');" + ) else: res[tname] = self.query(f"SELECT * FROM {tname};") return res - def load(self, data: dict): + def load(self, data: dict, dry_run: bool = False): + """Replace the tables named in `data` with its rows, all in one transaction. + + `dry_run` does every write and then rolls it back: what an import uses to find out + whether each of its databases *would* load before it changes any of them. Loaded + one after another for real, a refusal from the third left the first two replaced — + `keys_values` among them, which is where the password and the signing secret live. + + Every identifier is quoted. Columns are taken from the table itself rather than + from the backup, so quoting cannot let anything in, and leaving them bare made a + column called `table` — the firewall's — a syntax error: no backup holding a + single firewall rule could be imported at all. + """ self.connect() cur = self.conn.cursor() try: @@ -151,7 +169,7 @@ class SQLite(): # Ignore anything that isn't a proper list of row objects if not isinstance(rows, list) or not all(isinstance(r, dict) for r in rows): continue - cur.execute(f"DELETE FROM {table_name};") + cur.execute(f'DELETE FROM "{table_name}";') if rows: # Column names are identifiers and can't be parameterized, so only # keep the ones that actually exist on the table - this stops a @@ -162,11 +180,11 @@ class SQLite(): if not cols: continue placeholders = ", ".join(["?"] * len(cols)) - col_names = ", ".join(cols) - query = f"INSERT INTO {table_name} ({col_names}) VALUES ({placeholders})" + col_names = ", ".join(f'"{c}"' for c in cols) + query = f'INSERT INTO "{table_name}" ({col_names}) VALUES ({placeholders})' for row in rows: cur.execute(query, [row.get(c) for c in cols]) - cur.execute("COMMIT") + cur.execute("ROLLBACK" if dry_run else "COMMIT") except Exception as e: cur.execute("ROLLBACK") raise e diff --git a/docs/Firegex_Screenshot.png b/docs/Firegex_Screenshot.png index 06fa7ed..330ad54 100644 Binary files a/docs/Firegex_Screenshot.png and b/docs/Firegex_Screenshot.png differ diff --git a/docs/firewall.md b/docs/firewall.md index ae15090..e159230 100644 --- a/docs/firewall.md +++ b/docs/firewall.md @@ -7,13 +7,13 @@ Firewall Rules is a plain [nftables](https://netfilter.org/) allow/drop/reject r Each rule matches on: - **Protocol**: `tcp`, `udp`, `both` (adds a matching TCP and UDP rule) or `any` (matches all protocols, ports are ignored). -- **Source / Destination**: an IP/CIDR, or an interface name (e.g. `eth0`) instead of an address. +- **Source / Destination**: an IP/CIDR, or an interface name (e.g. `eth0`) instead of an address — at most 15 characters of letters, digits and `_.:-`, as the kernel takes them, optionally ending in `*` to match every interface starting that way (`br-*`). - **Source / Destination port range**: `from`/`to` for each side; leave as the full `1-65535` range to match any port. - **Direction** (`mode`): `in` (incoming traffic — the nftables `INPUT`/`PREROUTING` hook), `out` (outgoing traffic — `OUTPUT`/`POSTROUTING`), or `forward` (traffic routed through this host, `FORWARD` — only meaningful with the `filter` table, see below). -- **Table**: `filter` — standard firewall rules evaluated at the normal input/output/forward hooks; `mangle` — rules evaluated earlier in the pipeline (`prerouting`/`postrouting`, before routing decisions), useful when a rule needs to run before other processing (e.g. before a [Hijack Port to Proxy](porthijack.md) or [TLS Decrypt](tls.md) rule takes effect on the same traffic). +- **Table**: `filter` — standard firewall rules evaluated at the normal input/output/forward hooks; `mangle` — rules evaluated earlier in the pipeline (`prerouting`/`postrouting`, before routing decisions), useful when a rule needs to run before other processing (e.g. before a an external-proxy [service](services.md) or TLS rule takes effect on the same traffic). - **Action**: `accept`, `drop`, or `reject` (closes the connection with an ICMP/RST reply instead of silently dropping it). `reject` on outgoing (`out`) traffic isn't meaningful — Firegex silently treats it as `drop` in that direction. -Rules are evaluated in order; the first match wins. Traffic that matches no rule falls through to the global **policy** (`accept`/`drop`/`reject`), which applies to incoming and forwarded traffic — outgoing traffic is always allowed by default regardless of the policy, so Firegex itself is never at risk of losing its own outbound connectivity by misconfiguring rules. +Rules are evaluated in order; the first match wins. A change nftables refuses is refused as a whole: the firewall already in force stays exactly as it was, and so does the saved configuration. Traffic that matches no rule falls through to the global **policy** (`accept`/`drop`/`reject`), which applies to incoming and forwarded traffic — outgoing traffic is always allowed by default regardless of the policy, so Firegex itself is never at risk of losing its own outbound connectivity by misconfiguring rules. ### Global settings @@ -27,9 +27,35 @@ 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, "Leave container traffic to the container runtime"): forwarded traffic whose destination another NAT rewrote — a port **Docker or podman published** for a container, a router's port forward — and traffic **the containers send**, to each other or out (anything arriving from `docker0`, `br-*`, `docker_gwbridge`, `podman*` or `cni-podman*`), is left to the container runtime's own rules when none of *yours* matched it, instead of meeting the forward policy. Without it, a drop policy cuts a web container off from its database on the same bridge. 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 -Rules are compiled directly into nftables' JSON rule format and applied via the nftables JSON API — there's no packet interception/inspection involved (unlike [Netfilter Regex](nfregex.md) or [Netfilter Proxy](nfproxy.md), which sit in front of a service via nfqueue). Firegex maintains its own dedicated chains (jumped to from the base `INPUT`/`OUTPUT`/`FORWARD` filter hooks and `PREROUTING`/`POSTROUTING` mangle hooks) so its rules can be fully reset without touching anything else on the system. +Rules are compiled directly into nftables' JSON rule format and applied via the nftables JSON API — there's no packet interception/inspection involved (unlike [services](services.md), which sit in front of a service and inspect its traffic). + +**Everything Firegex installs lives in tables of its own, and every object is named `fgex_`.** The filter rules go in `fgex_filter` and the mangle ones in `fgex_mangle` (one of each per address family), with base chains `fgex_input`, `fgex_forward`, `fgex_output`, `fgex_prerouting` and `fgex_postrouting` handing over to `fgex_rules_in`, `fgex_rules_out` and `fgex_rules_fwd`. You can see the whole of it with: + +```bash +sudo nft list ruleset | grep -A100 fgex_ +``` + +Two things follow from Firegex owning those tables rather than writing into the ones `iptables` uses: + +- **`iptables` keeps working.** Earlier versions put their chains into the tables named `filter` and `mangle`, which is where `iptables-nft` — the default `iptables` on most current distributions — keeps its own. `iptables` rejects a whole table as soon as it contains a rule it cannot express in its own format, and the connection-tracking rules Firegex writes for **allow_established** and **drop_invalid** are exactly that. The result was `iptables -L` and `iptables-save` answering ``table `filter' is incompatible, use 'nft' tool`` on a host where Docker, ufw, fail2ban or the organisers' own scripts were the ones asking. That cannot happen now: `iptables` never looks at a table Firegex owns. +- **Turning the firewall off no longer touches the host's own.** The default policy is a property of a base chain, and it used to be *your* `INPUT` chain that Firegex set to `drop` — and set back to `accept` on shutdown, silently undoing a default-deny an administrator had configured elsewhere. Firegex now sets the policy on its own base chain, so a reset is just deleting two tables. + +**If something else removes those tables, Firegex puts them back within a few seconds.** `nft -f /etc/nftables.conf` starts with `flush ruleset` on Debian, and so does `systemctl restart nftables`; either one used to switch the Firegex firewall off with nothing to say so. The same watch covers the services' table: each running service is steered at its filters again without restarting, and its log records the gap as an error, since traffic was not filtered while it lasted. The lasting fix is still to make whatever manages your nftables leave the `fgex` tables alone. With `flush ruleset`, for example, you can delete only your own tables instead. + +If you are upgrading from a version that used the shared tables, whatever it left behind is still there — Firegex does not go looking in tables it no longer owns. Clear it by hand once, and check the policy while you are there, since an older Firegex was the one driving it: + +```bash +sudo nft list table ip filter # and ip6, and the mangle pair +``` + +A rule's **table** field (`filter` or `mangle`) still means what it always did — it's the hook the rule is evaluated at, not the name of the nftables table it ends up in. + +Note that "accept" here means *Firegex* does not block the packet, not that nothing else will: other base chains at the same hook still get their say, exactly as they do between any two nftables tables. A `drop`, on the other hand, is final wherever it comes from. diff --git a/docs/nfproxy.md b/docs/nfproxy.md deleted file mode 100644 index c6eb795..0000000 --- a/docs/nfproxy.md +++ /dev/null @@ -1,323 +0,0 @@ -# 🌐 Netfilter Proxy - -Netfilter Proxy (`nfproxy`) is a filtering module that leverages [nfqueue](https://netfilter.org/projects/libnetfilter_queue/) to intercept network packets, then runs your own Python filter code against them inside the C++ core via an embedded interpreter. Unlike [Netfilter Regex](nfregex.md), which only matches raw byte patterns, nfproxy gives filters structured access to the traffic (parsed HTTP requests/responses, TCP streams, per-stream history, ...) and lets them make arbitrary decisions in Python. - -## How to use it - -1. Create a service: pick a protocol (`tcp` or `http`), the target `ip:port` (or attach it to a [TLS Decrypt](tls.md) stream, to see decrypted traffic), and a name. -2. Upload one or more Python filter files (see [writing a filter](#writing-a-filter) below). Each file can define multiple `@pyfilter` functions. -3. Start the service. From then on, every matching packet is routed through nfqueue to the embedded Python interpreter, which runs your filters and returns an action (accept/drop/reject/mangle) back to the C++ core. -4. The service page shows live counters (packets seen, blocked/mangled) and any Python exception raised by a filter, reported back over a unix socket — no restart needed to see them. - -Filters can be edited and re-uploaded while the service is running: changes take effect on the next packet. `fail_open` (an advanced per-service option) controls what happens if the filter process itself crashes or can't be reached: enabled, traffic is allowed through unfiltered rather than blocked, trading protection for availability — useful if you'd rather risk letting an attack through than take the service down during a competition. - -## Writing a filter - -Install the library and CLI: - -```bash -pip install -U firegex -``` - -`fgex` is an alias package for `firegex`: installing either one gives you the same `firegex` module and the `fgex`/`firegex` CLI commands. - -### The `pyfilter` decorator - -```python -from firegex.nfproxy import pyfilter -``` - -`pyfilter` marks a function as an active filter. A filter function: -- must be decorated with `@pyfilter`; -- must have every parameter type-annotated with one of the data structures listed below (parameters without a type annotation make the filter invalid, and default/keyword-argument values are ignored — the caller always builds the arguments itself from the live packet data); -- must `return` one of the [packet statements](#packet-statements) below (or `None`, which behaves like `ACCEPT`). - -```python -from firegex.nfproxy import pyfilter, ACCEPT, REJECT - -@pyfilter -def none_filter(): # A filter that does nothing - return ACCEPT -``` - -Filter names (the function name) must be unique within a filter file. - -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. - -```python -from firegex.nfproxy import pyfilter, ACCEPT, REJECT -from firegex.nfproxy.models import HttpRequest - -@pyfilter -def filter_with_args(http_request: HttpRequest): - if http_request.body and b"ILLEGAL" in http_request.body: - return REJECT -``` - -Here the filter is only called once the data required to build an `HttpRequest` is available (i.e. once the HTTP headers have been parsed, and again once the body is complete). If a filter needs multiple parameters, it's only called once every parameter can be built from the data received so far. - -### Packet statements - -A filter must return one of these values (importable from `firegex.nfproxy`): - -| Statement | Effect | -|---|---| -| `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). | -| `UNSTABLE_MANGLE` | The packet is modified and forwarded. Only available when filtering `RawPacket` — see below. This is an unstable feature, use it carefully. | - -### Data structures - -These are the types you can use as filter parameter annotations. Some are only available when the service's protocol is `http` (marked **HTTP only**); the rest work for both `tcp` and `http` services. - -#### `RawPacket` - -```python -from firegex.nfproxy.models import RawPacket -``` - -The raw packet as received from nfqueue — the lowest level of abstraction, and the **only** data structure that can be mutated (for `UNSTABLE_MANGLE`). - -- `data: bytes` — the packet's data, reassembled/reordered as a TCP stream (read-only). -- `is_input: bool` — `True` for an incoming packet, `False` for outgoing. -- `is_ipv6: bool` — `True` for IPv6, `False` for IPv4. -- `is_tcp: bool` — `True` for TCP, `False` for UDP. -- `l4_size: int` — size of the layer-4 payload (read-only). -- `raw_packet_header_len: int` — size of the original packet's header (read-only). -- `raw_packet: bytes` — the full packet, including the IP/TCP headers. Writable: assigning to it mangles the packet if the filter returns `UNSTABLE_MANGLE`, and updates `l4_size`/`l4_data` accordingly. -- `l4_data: bytes` — the layer-4 payload only, taken directly from the raw packet. Also writable for mangling; updates `l4_size`/`raw_packet` accordingly. - -Be careful: `l4_data`/`raw_packet` reflect a single physical packet as seen by nfqueue, while `data` is the TCP stream already reassembled/reordered by the C++ core — they can legitimately differ (e.g. out-of-order packets are accepted by default without invoking Python at all). - -#### `TCPInputStream` (alias `TCPClientStream`) - -```python -from firegex.nfproxy.models import TCPInputStream -``` - -The assembled TCP stream in the input (client → server) direction. A filter using this type is only called for incoming packets. - -- `data: bytes` — the entire input-direction stream assembled so far (read-only). -- `total_stream_size: int` — size of that stream (read-only). -- `is_ipv6: bool` - -#### `TCPOutputStream` (alias `TCPServerStream`) - -```python -from firegex.nfproxy.models import TCPOutputStream -``` - -Same as `TCPInputStream`, but for the output (server → client) direction; only called for outgoing packets. - -#### `HttpRequest` — HTTP only - -```python -from firegex.nfproxy.models import HttpRequest -``` - -The current HTTP request. This handler is called up to twice: once when the headers are complete, and again once the body is complete (if the whole request arrives in a single TCP packet, it's called only once). - -- `method: bytes` — the request method. -- `url: str | None` — the request URL. -- `headers: dict[str, str]` — request headers, keys/values exactly as received (case-sensitive); a repeated header becomes a list of values. -- `get_header(header: str, default=None) -> str` — looks up a header case-insensitively; if the header was repeated, its values are joined with a comma (this method never returns a list). -- `user_agent: str` -- `content_encoding: str` -- `content_length: int | None` -- `body: bytes` — `None` until the body has arrived. -- `body_decoded` — the body decoded according to `content_encoding` (`gzip`, `br`, `deflate` and `zstd` are supported). `False` if decoding failed and `body` isn't `None`. -- `http_version: str` -- `keep_alive: bool` -- `should_upgrade: bool` -- `upgrading_to_h2: bool` -- `upgrading_to_ws: bool` -- `ws_stream: list[websockets.frames.Frame]` — decoded WebSocket frames (permessage-deflate supported); see the [websockets docs](https://websockets.readthedocs.io/en/stable/). -- `stream: bytes` — buffer of the raw WebSocket traffic in this direction; only meaningful once `should_upgrade` is `True`. -- `headers_complete: bool` -- `message_complete: bool` -- `total_size: int` — size of the whole request seen so far. -- `history: HttpHistory` — previously completed requests/responses on this same stream, see [`HttpHistory`](#httphistory-alias-httpstreamhistory--http-only) below. - -#### `HttpRequestHeader` — HTTP only - -```python -from firegex.nfproxy.models import HttpRequestHeader -``` - -Same fields as `HttpRequest`, but only called once, when the headers are complete — the body is never buffered and `body` is always `None`. - -#### `HttpFullRequest` — HTTP only - -```python -from firegex.nfproxy.models import HttpFullRequest -``` - -Same fields as `HttpRequest`, but only called once the whole request (headers + body) is complete. Completed instances of this type are also what gets stored in `HttpHistory.requests`. - -#### `HttpResponse` — HTTP only - -```python -from firegex.nfproxy.models import HttpResponse -``` - -The current HTTP response — same shape and calling convention as `HttpRequest` (up to twice: headers complete, then body complete), plus: - -- `status_code: int` - -All the other fields listed for `HttpRequest` (`headers`, `get_header`, `body`, `body_decoded`, `content_encoding`, `content_length`, `http_version`, `keep_alive`, `should_upgrade`, `upgrading_to_h2`, `upgrading_to_ws`, `ws_stream`, `stream`, `headers_complete`, `message_complete`, `total_size`, `history`, `user_agent`) apply here too. - -#### `HttpResponseHeader` — HTTP only - -```python -from firegex.nfproxy.models import HttpResponseHeader -``` - -Same as `HttpResponse`, but only called once the headers are complete; `body` is always `None`. - -#### `HttpFullResponse` — HTTP only - -```python -from firegex.nfproxy.models import HttpFullResponse -``` - -Same as `HttpResponse`, but only called once the whole response is complete. Completed instances of this type are what gets stored in `HttpHistory.responses`. - -#### `HttpHistory` (alias `HttpStreamHistory`) — HTTP only - -```python -from firegex.nfproxy.models import HttpHistory -``` - -Gives a filter access to previously *completed* requests/responses on the same TCP stream — useful for correlating a response with the request(s) that came before it on a keep-alive connection, or for stateful logic that spans more than one exchange. - -- `requests: list[HttpFullRequest]` — a snapshot copy of the requests completed so far on this stream (does not include the request currently being processed). -- `responses: list[HttpFullResponse]` — a snapshot copy of the completed responses so far. - -You can use it in two ways: - -```python -from firegex.nfproxy import pyfilter, REJECT -from firegex.nfproxy.models import HttpHistory, HttpResponse - -# 1. as its own filter parameter -@pyfilter -def check_history(hist: HttpHistory): - if len(hist.requests) > 50: - return REJECT - -# 2. via the .history property available on any Http* instance -@pyfilter -def check_previous_requests(resp: HttpResponse): - if any(b"admin" in req.url.encode() for req in resp.history.requests if req.url): - return REJECT -``` - -The number of entries kept per stream is capped by the `FGEX_MAX_HISTORY_SIZE` global (default `100`) — see [Other global options](#other-global-options) below; once the cap is reached, the oldest entry is dropped as a new one is added. - -## Stream limiter - -What happens if a single TCP stream carries a lot of data? Past a configurable size, a "full stream" action kicks in. First import the enum: - -```python -from firegex.nfproxy import FullStreamAction -``` - -Then set these in the filter file's globals: - -- `FGEX_STREAM_MAX_SIZE: int` — maximum size (in bytes) of a stream before `FGEX_FULL_STREAM_ACTION` is triggered. This limit applies per data structure independently: e.g. if `TCPInputStream` has reached the limit but `HttpResponse` hasn't, the action only affects `TCPInputStream`. Default: 1MB. -- `FGEX_FULL_STREAM_ACTION: FullStreamAction` — the action taken once the limit is exceeded. Default: `FullStreamAction.FLUSH`. - -`FullStreamAction` values: - -| Value | Effect | -|---|---| -| `FLUSH` | Flush the stream and keep acquiring new packets (default). | -| `DROP` | Drop the next stream packets, like a `DROP` filter statement. | -| `REJECT` | Reject the stream and close the connection, like a `REJECT` filter statement. | -| `ACCEPT` | Stop calling pyfilters and accept the rest of the traffic as-is. | - -## Other global options - -```python -from firegex.nfproxy import ExceptionAction -``` - -- `FGEX_INVALID_ENCODING_ACTION: ExceptionAction` — action taken when parsing hits an invalid/unsupported encoding (a parser-level failure). Default: `ExceptionAction.REJECT`. Values: `ACCEPT` (accept the packet that caused the error), `DROP` (drop the connection), `REJECT` (reject the connection), `NOACTION` (do nothing — the error is signaled and the stream is accepted without calling any more pyfilters on it). -- `FGEX_MAX_HISTORY_SIZE: int` — max number of requests/responses kept per stream by [`HttpHistory`](#httphistory-alias-httpstreamhistory--http-only). Default: `100`. - -## Testing a filter locally - -You don't need a running Firegex instance to try out a filter: the `firegex` pip package ships a local proxy simulator. - -```text -➤ fgex nfproxy -h - - Usage: fgex nfproxy [OPTIONS] FILTER_FILE ADDRESS PORT - - Run an nfproxy simulation - -╭─ Arguments ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ * filter_file TEXT The path to the filter file [default: None] [required] │ -│ * address TEXT The address of the target to proxy [default: None] [required] │ -│ * port INTEGER The port of the target to proxy [default: None] [required] │ -╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ --proto [tcp|http] The protocol to proxy [default: tcp] │ -│ --from-address TEXT The address of the local server [default: None] │ -│ --from-port INTEGER The port of the local server [default: 7474] │ -│ -6 Use IPv6 for the connection │ -│ --help -h Show this message and exit. │ -╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -``` - -This runs a local proxy in front of `ADDRESS:PORT`, applying `FILTER_FILE` to the traffic — no Docker, no nftables/root privileges required. The filter file is reloaded automatically whenever it changes on disk, so you don't need to restart the simulator while iterating. - -```bash -fgex nfproxy test_http.py 127.0.0.1 8080 --proto http -``` - -## Full example - -```python -# Example filter file for an http-protocol service - -from firegex.nfproxy.models import RawPacket, HttpRequest, HttpHistory -from firegex.nfproxy import pyfilter, ACCEPT, REJECT, DROP, UNSTABLE_MANGLE, FullStreamAction - -# Lowest level of abstraction: only RawPacket can be mangled. -@pyfilter -def mangle_example(packet: RawPacket): - if b"TEST_MANGLING" in packet.l4_data: - packet.l4_data = packet.l4_data.replace(b"TEST", b"UNSTABLE") - return UNSTABLE_MANGLE - if b"BAD DATA" in packet.data: - return DROP - return ACCEPT - -# Higher level of abstraction: parsed HTTP requests. -@pyfilter -def http_filter(http: HttpRequest): - if http.method == b"GET" and http.url and "test" in http.url: - return REJECT - -# Using history to see previous requests on the same keep-alive stream. -@pyfilter -def repeated_probe_filter(hist: HttpHistory): - if len(hist.requests) > 20: - return REJECT - -# Stream size limits (applies per data structure, e.g. only to RawPacket streams above) -FGEX_STREAM_MAX_SIZE = 4096 -FGEX_FULL_STREAM_ACTION = FullStreamAction.REJECT -``` - -## How it works - -The proxy is built on a multi-threaded architecture that embeds Python for dynamic filtering: - -- **Packet interception**: the [nfqueue](https://netfilter.org/projects/libnetfilter_queue/) kernel module (part of [netfilter](https://netfilter.org/)) intercepts network packets; the rules attaching nfqueue to traffic are generated with the nftables JSON API by the Python manager. -- **Packet reading**: a dedicated thread reads packets from nfqueue. -- **Multi-threaded analysis**: the C++ binary launches multiple threads, each with its own Python interpreter — Python 3.12's [per-interpreter GIL](https://peps.python.org/pep-0684/) makes this real multithreading. Traffic is distributed across threads by hashing IP/port, so all packets of the same flow are handled by the same thread. -- **Python filter integration**: uploaded filters run inside these interpreters. -- **HTTP parsing**: [a Python wrapper for llhttp](https://github.com/domysh/pyllhttp) (forked/adapted to work across multiple interpreters) parses HTTP traffic. diff --git a/docs/nfregex.md b/docs/nfregex.md deleted file mode 100644 index 0e6f1b1..0000000 --- a/docs/nfregex.md +++ /dev/null @@ -1,35 +0,0 @@ -# 📡 Netfilter Regex - -Netfilter Regex (`nfregex`) filters network traffic by matching regular expressions directly against packet content, entirely inside a C++ binary (no embedded interpreter, no user-supplied code). It's the fastest filtering module Firegex offers, at the cost of flexibility: if you need custom logic (parsing structured data, stateful decisions, mangling packets), use [Netfilter Proxy](nfproxy.md) instead. - -## How to use it - -1. Create a service: pick a protocol (`tcp` or `udp`), the target `ip:port` (or attach it to a [TLS Decrypt](tls.md) stream, to see decrypted traffic, `tcp` only), and a name. -2. Add one or more regexes to the service. -3. Start the service. Matching traffic is dropped/rejected according to each regex's direction; everything else passes through unmodified — nfregex has no mangling capability (only [Netfilter Proxy](nfproxy.md) can mangle traffic). - -Each regex has: -- **Pattern**: a PCRE2-compatible regular expression, matched as raw bytes (not text) against the traffic. -- **Direction** (`mode`): `S` — match only client → server traffic (to the server); `C` — match only server → client traffic (to the client); `B` — match both directions. -- **Case sensitivity**: matching can be case-sensitive or case-insensitive per regex. -- **Active/inactive**: a regex can be disabled without deleting it — inactive regexes are kept (and still shown with their stats) but never evaluated against traffic. - -The service page shows, per regex, how many packets it has blocked so far, and whether it's currently active. - -`fail_open` (an advanced per-service option, shared with nfproxy) controls what happens if the underlying nfqueue/binary can't be reached: if enabled, traffic is allowed through unfiltered rather than blocked. - -### TCP vs UDP matching - -- **TCP**: nfregex reassembles and reorders the stream before matching, so a regex can match a pattern that's split across multiple packets. Out-of-order packets are handled transparently. -- **UDP**: there's no stream to reassemble — each datagram is matched independently, and only the match context (not the full payload) is retained afterwards. - -## How it works - -The packet filtering process is implemented in C++ and involves several key steps: - -- **Packet interception**: the [nfqueue](https://netfilter.org/projects/libnetfilter_queue/) kernel module intercepts network packets (a [netfilter](https://netfilter.org/) module) 🔍. The rules attaching nfqueue to the traffic are generated via the nftables JSON API by the Python manager. -- **Packet reading**: a dedicated thread reads packets from nfqueue. 🧵 -- **Packet parsing**: intercepted packets are parsed by [libtins](https://libtins.github.io/), a C++ library that extracts the payload from each packet. 📄 -- **Multi-threaded analysis**: multiple threads analyze packets concurrently. While the nfqueue module balances load based solely on IP addresses — resulting in a single thread handling all traffic in NAT environments like CTF networks — Firegex manages threads at the user level differently: traffic is routed based on IP addresses combined with port hashing, giving a more balanced workload while guaranteeing that a given flow is always analyzed by the same thread. ⚡️ -- **TCP handling**: for TCP connections, libtins uses a TCP follower to reorder packets received from the kernel. 📈 -- **Regex matching**: the extracted payload is matched using [vectorscan](https://github.com/VectorCamp/vectorscan) — a fork of [hyperscan](https://github.com/intel/hyperscan) that also runs on arm64. 🎯 diff --git a/docs/porthijack.md b/docs/porthijack.md deleted file mode 100644 index 1addf4a..0000000 --- a/docs/porthijack.md +++ /dev/null @@ -1,31 +0,0 @@ -# ⚡️ Hijack Port to Proxy - -Hijack Port to Proxy (`porthijack`) redirects traffic arriving on a public `ip:port` to a proxy you run yourself on `localhost`, without touching the target service's own configuration. Your proxy can then reach the real service on loopback (`127.0.0.1`/`::1`) and do whatever it wants with the traffic — including things Firegex's other modules can't do, like mangling packets (something [Netfilter Proxy](nfproxy.md) can't fully guarantee, since altering TCP payload sizes can desync the stream, while a userspace proxy fully re-terminates the connection and can freely resize packets). - -## How to use it - -1. Write and run your own proxy, listening on some local port, forwarding to the real service on loopback. -2. Create a service in the UI: the public `ip:port` clients connect to, the protocol (`tcp` or `udp`), and the local port your proxy listens on. -3. Start the service. Public traffic to that `ip:port` is now transparently redirected to your proxy; your proxy's own traffic to the real service (over loopback) is left untouched. - -To the original client, responses still appear to come from the original public `ip:port` — the client never sees your proxy's address. To the real service (if it's configured to only accept loopback connections, or logs source IPs), requests appear to come from your proxy, not from the original client. - -### Using the nfproxy simulator as your proxy - -You don't have to write a proxy from scratch: the `firegex` pip package's `fgex nfproxy` simulator (see the [Netfilter Proxy guide](nfproxy.md)) is a ready-made local proxy that applies a Python filter file to the traffic, and can double as the proxy this module redirects to — this is a convenient way to get nfproxy-style filtering (including mangling, since the simulator fully re-terminates the connection) for a service, driven entirely by port hijacking instead of nfqueue. - -```bash -pip install -U firegex -fgex nfproxy test_http.py 127.0.0.1 8080 --proto http --from-port 13377 -``` - -Then create a Hijack Port to Proxy service pointing at local port `13377`. Note: in the simulator, `RawPacket.raw_packet` always equals `RawPacket.l4_data` (there's no real IP/TCP header to mangle, since the simulator re-terminates the connection at the application layer) — see the [nfproxy guide](nfproxy.md) for more on `RawPacket`. - -## How it works - -This module works by rewriting packet fields directly with [nftables](https://netfilter.org/) rules — there's no connection tracking/NAT involved, just field mangling on matching packets: - -- **Inbound** (prerouting): for packets destined to the public `ip:port`, the destination address and port are rewritten in place to loopback and your proxy's port. -- **Outbound** (postrouting): for packets sourced from your proxy's port (destined back to the original client), the source address and port are rewritten back to the public `ip:port`, so the client sees replies coming from where it expects. - -This mangling only applies to packets actually matching the public `ip:port`/protocol you configured — traffic to loopback that isn't part of a hijacked flow is left alone. diff --git a/docs/pyfilter.md b/docs/pyfilter.md new file mode 100644 index 0000000..d0cbe72 --- /dev/null +++ b/docs/pyfilter.md @@ -0,0 +1,486 @@ +# 🐍 Python filters + +A **pyfilter** is one of the two kinds of filter you can attach to a [service](services.md): +your own Python, given structured access to the traffic. Where a regex filter only matches +raw byte patterns, a pyfilter sees parsed HTTP requests and responses, TCP streams and +per-stream history, and decides in code. + +## How to use it + +1. Create a service and choose its network layer — see [Services](services.md). Both layers + can run a pyfilter; only the proxy layer can run one alongside other filters. +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). + +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 +that hangs is killed and the traffic keeps moving; on NFQUEUE it runs inside the C++ process +through an embedded interpreter, and `fail_open` decides whether traffic keeps flowing if +that process dies. + +## Writing a filter + +Install the library and CLI: + +```bash +pip install -U firegex +``` + +`fgex` is an alias package for `firegex`: installing either one gives you the same `firegex` module and the `fgex`/`firegex` CLI commands. + +### The `pyfilter` decorator + +```python +from firegex.pyfilters import pyfilter +``` + +`pyfilter` marks a function as an active filter. A filter function: +- must be decorated with `@pyfilter`; +- must have every parameter type-annotated with one of the data structures listed below (parameters without a type annotation make the filter invalid, and default/keyword-argument values are ignored — the caller always builds the arguments itself from the live packet data); +- must `return` one of the [packet statements](#packet-statements) below (or `None`, which behaves like `ACCEPT`). + +```python +from firegex.pyfilters import pyfilter, ACCEPT, REJECT + +@pyfilter +def none_filter(): # A filter that does nothing + return ACCEPT +``` + +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. + +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. + +```python +from firegex.pyfilters import pyfilter, ACCEPT, REJECT +from firegex.pyfilters.models import HttpRequest + +@pyfilter +def filter_with_args(http_request: HttpRequest): + if http_request.body and b"ILLEGAL" in http_request.body: + return REJECT +``` + +Here the filter is only called once the data required to build an `HttpRequest` is available (i.e. once the HTTP headers have been parsed, and again once the body is complete). If a filter needs multiple parameters, it's only called once every parameter can be built from the data received so far. + +### Packet statements + +A filter must return one of these values (importable from `firegex.pyfilters`): + +| Statement | Effect | +|---|---| +| `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` | 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 + +These are the types you can use as filter parameter annotations. Some need a connection that speaks HTTP (marked **HTTP only**); the rest work whatever the traffic is. + +## The editor knows the library + +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. 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 + source; one that loads says how many functions it defines and which protocol that + makes it. + +None of those hints are written down anywhere: they are introspected from the installed +library. A model that gains a property gains a hint, and one that is renamed stops being +offered under the old name — autocompletion that disagrees with the library would be +worse than none, for the same reason a regex tester that disagrees with the engine is. + +The check is the same build, the same compile and the same library the datapath uses, so +a file the editor accepts is a file that loads. It runs in a process of its own, which is +also why a `while True:` at module level costs you a timeout and not the interface. + +One file can define as many `@pyfilter` functions as you like, and firegex lists them +individually with a switch each. Switching one off does not touch your code — it is left +out of the list of names the library is given, which is what decides whether it is +called — so a noisy debugging filter can be silenced and brought back without the code +ever leaving the file. Saving a new version reconciles the list with what the file now +defines, keeping your choices for the functions that are still there. + +**You never declare which protocol a filter file speaks — what it asks for decides.** Annotating a parameter with `HttpRequest` is what makes a file an HTTP filter; a file that asks only for a `RawPacket` or a TCP stream runs on anything. The two can live in the same file, so a service can carry one filter that parses HTTP and another that does not. What cannot: asking for two *different* application protocols in one file. A connection is only ever one of them, so saving such a file is refused, naming both functions, and you split them into two filters on the same service. + +#### 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 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, a verdict out is the one contract both layers keep. + +#### Which models work on UDP + +`RawPacket` is the only model that does not need a stream underneath it. Everything +else — an assembled `TCPInputStream`, a parsed `HttpRequest` — reaches for something a +datagram does not have, and on one the library declines to build it, which means **the +filter is never called**. + +So a Python filter runs on a UDP service, on either network layer, as long as it asks +for a `RawPacket`. Saving one that asks for anything else against a UDP service is +refused, naming the model: a filter sitting in the chain doing nothing, with nothing +saying so, is the failure this whole module is arranged to prevent. + +**HTTP/2 is not a gap any more, and it used to be the one that mattered.** A filter asking +for an `HttpRequest` works on HTTP/1.x, on HTTP/2 and on HTTP/3 alike. It did not work on +a connection that negotiated `h2`: those headers are HPACK-compressed, so the parser was +handed a compression format and the filter was never called — no block, no log, no +counter. The engine terminates HTTP/2 now and shows the chain the HTTP/1.1 each exchange +would have been, from the same code that does it for HTTP/3, so the same file covers every +version. Since gRPC is HTTP/2, that is also what makes gRPC filterable. + +**One thing about HTTP/2 to know if your filter keeps state.** Each HTTP/2 stream is its +own connection to a filter, with its own module globals — where an HTTP/1.1 keep-alive +connection carries many requests through one set of them. HTTP/2 interleaves its streams, +so sharing state between them would let one client's bytes decide another client's +verdict, and would let an attacker split a pattern across two streams to get past a +filter. One request, one state, is the honest mapping; it is the same rule an HTTP/3 +request stream follows. + +**QUIC is not that case, even though it is UDP on the wire.** A QUIC stream is ordered +and reliable, the engine terminates the connection to see inside it, and each stream +reaches the chain as its own connection — so every model works there exactly as it does +on TCP. On an HTTP/3 service the exchange is rendered as the HTTP/1.1 it would have been +before the chain sees it, so `HttpRequest` and the rest mean what they always meant; the +[services documentation](services.md#http3-is-shown-to-the-filters-as-http11) says what +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. + +`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. + +#### `RawPacket` + +```python +from firegex.pyfilters.models import RawPacket +``` + +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. +- `is_input: bool` — `True` for client → service, `False` for service → client. `is_output` is its inverse. +- `is_ipv6: bool` — `True` for IPv6, `False` for IPv4. +- `is_tcp: bool` — `True` for TCP. `False` for a datagram, and `False` for QUIC. +- `l4: str` — what carries this connection: `"tcp"`, `"udp"`, `"quic"` for a stream + inside a QUIC connection, or `"quic-datagram"` for a DATAGRAM frame in one. +- `is_stream: bool` — whether these bytes arrive in order, once each, as part of a + stream: true for TCP and for a QUIC stream, false for either kind of datagram. This is + what every model above `RawPacket` actually needs, and the two came apart when QUIC + arrived — it is a stream *and* it is UDP, so one flag answering both questions gets one + of them wrong. A filter asking for a stream model is simply not called for a datagram, + 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). + + + +#### `TCPInputStream` (alias `TCPClientStream`) + +```python +from firegex.pyfilters.models import TCPInputStream +``` + +The assembled TCP stream in the input (client → server) direction. A filter using this type is only called for incoming packets. + +- `data: bytes` — the entire input-direction stream assembled so far (read-only). +- `total_stream_size: int` — size of that stream (read-only). +- `is_ipv6: bool` + +#### `TCPOutputStream` (alias `TCPServerStream`) + +```python +from firegex.pyfilters.models import TCPOutputStream +``` + +Same as `TCPInputStream`, but for the output (server → client) direction; only called for outgoing packets. + +#### `HttpRequest` — HTTP only + +```python +from firegex.pyfilters.models import HttpRequest +``` + +The current HTTP request. This handler is called up to twice: once when the headers are complete, and again once the body is complete (if the whole request arrives in a single TCP packet, it's called only once). + +- `method: str` — the request method, e.g. `"GET"`. Documented as `bytes` before, which no comparison in a filter ever satisfied. +- `url: str | None` — the request URL. +- `headers: dict[str, str]` — request headers, keys/values exactly as received (case-sensitive); a repeated header becomes a list of values. +- `get_header(header: str, default=None) -> str` — looks up a header case-insensitively; if the header was repeated, its values are joined with a comma (this method never returns a list). +- `user_agent: str` +- `content_encoding: str` +- `content_length: int | None` — what the `Content-Length` header declared, or `None` when there was none (a chunked body, or one delimited by the connection closing). +- `body: bytes` — `None` until the body has arrived. +- `body_decoded` — the body decoded according to `content_encoding` (`gzip`, `br`, `deflate` and `zstd` are supported). `False` if decoding failed and `body` isn't `None`. +- `http_version: str` +- `keep_alive: bool` +- `should_upgrade: bool` +- `upgrading_to_h2: bool` +- `upgrading_to_ws: bool` +- `ws_stream: list[websockets.frames.Frame]` — decoded WebSocket frames (permessage-deflate supported); see the [websockets docs](https://websockets.readthedocs.io/en/stable/). +- `stream: bytes` — buffer of the raw WebSocket traffic in this direction; only meaningful once `should_upgrade` is `True`. +- `headers_complete: bool` +- `message_complete: bool` +- `total_size: int` — size of the whole request seen so far. +- `history: HttpHistory` — previously completed requests/responses on this same stream, see [`HttpHistory`](#httphistory-alias-httpstreamhistory--http-only) below. + +#### `HttpRequestHeader` — HTTP only + +```python +from firegex.pyfilters.models import HttpRequestHeader +``` + +Same fields as `HttpRequest`, but only called once, when the headers are complete — the body is never buffered and `body` is always `None`. + +#### `HttpFullRequest` — HTTP only + +```python +from firegex.pyfilters.models import HttpFullRequest +``` + +Same fields as `HttpRequest`, but only called once the whole request (headers + body) is complete. Completed instances of this type are also what gets stored in `HttpHistory.requests`. + +#### `HttpResponse` — HTTP only + +```python +from firegex.pyfilters.models import HttpResponse +``` + +The current HTTP response — same shape and calling convention as `HttpRequest` (up to twice: headers complete, then body complete), plus: + +- `status_code: int | None` — the numeric status, e.g. `404`. `None` on a request, and before the response line has been read. +- `status_phrase: str | None` — the reason phrase beside it, e.g. `"Not Found"`. Free text: a server may write anything there, so decide on the code, a header or the body. + +All the other fields listed for `HttpRequest` (`headers`, `get_header`, `body`, `body_decoded`, `content_encoding`, `content_length`, `http_version`, `keep_alive`, `should_upgrade`, `upgrading_to_h2`, `upgrading_to_ws`, `ws_stream`, `stream`, `headers_complete`, `message_complete`, `total_size`, `history`, `user_agent`) apply here too. + +#### `HttpResponseHeader` — HTTP only + +```python +from firegex.pyfilters.models import HttpResponseHeader +``` + +Same as `HttpResponse`, but only called once the headers are complete; `body` is always `None`. + +#### `HttpFullResponse` — HTTP only + +```python +from firegex.pyfilters.models import HttpFullResponse +``` + +Same as `HttpResponse`, but only called once the whole response is complete. Completed instances of this type are what gets stored in `HttpHistory.responses`. + +#### `HttpHistory` (alias `HttpStreamHistory`) — HTTP only + +```python +from firegex.pyfilters.models import HttpHistory +``` + +Gives a filter access to previously *completed* requests/responses on the same TCP stream — useful for correlating a response with the request(s) that came before it on a keep-alive connection, or for stateful logic that spans more than one exchange. + +- `requests: list[HttpFullRequest]` — a snapshot copy of the requests completed so far on this stream (does not include the request currently being processed). +- `responses: list[HttpFullResponse]` — a snapshot copy of the completed responses so far. + +You can use it in two ways: + +```python +from firegex.pyfilters import pyfilter, REJECT +from firegex.pyfilters.models import HttpHistory, HttpResponse + +# 1. as its own filter parameter +@pyfilter +def check_history(hist: HttpHistory): + if len(hist.requests) > 50: + return REJECT + +# 2. via the .history property available on any Http* instance +@pyfilter +def check_previous_requests(resp: HttpResponse): + if any(b"admin" in req.url.encode() for req in resp.history.requests if req.url): + return REJECT +``` + +The number of entries kept per stream is capped by the `FGEX_MAX_HISTORY_SIZE` global (default `100`) — see [Other global options](#other-global-options) below; once the cap is reached, the oldest entry is dropped as a new one is added. What each direction's history holds is also kept under `FGEX_STREAM_MAX_SIZE` bytes, the same budget as the stream itself, so on a connection carrying large bodies the oldest entries go sooner; the newest one is always kept. + +### gRPC messages + +**HTTP only.** `GrpcMessage`, `GrpcRequest` and `GrpcResponse` hand you **one gRPC message +at a time**, with its length prefix taken off. + +gRPC is HTTP/2, and firegex renders every version of HTTP to the filters as HTTP/1.1 — so +everything *around* a call already arrives through the models above: the method is the +path on an `HttpRequest`, the metadata are its headers, the status is in the trailer +section. What did not arrive was the body. A gRPC body is a sequence of messages, each one +a flag byte, a four-byte length and then the protobuf; a filter reading `request.body` read +that framing glued to the payload, and a pattern could match across the boundary between +two messages — a false positive with nothing to point at. + +```python +from firegex.pyfilters import pyfilter, ACCEPT, REJECT +from firegex.pyfilters.models import GrpcMessage + +@pyfilter +def refuse_a_payload(message: GrpcMessage): + if b"../" in message.payload: + return REJECT + return ACCEPT +``` + +| Member | What it is | +|---|---| +| `payload` | This message's bytes, without the length prefix | +| `compressed` | Whether this message's own flag byte says it is compressed | +| `is_request` | Whether it went from the client towards the service | +| `method` | The gRPC method, which is the HTTP path: `/package.Service/Method` | +| `grpc_status` | The status, where the message carrying it stated one | + +`GrpcRequest` and `GrpcResponse` are the same thing narrowed to one direction, so you do +not have to check `is_request` yourself. + +**It is called per message, not per body**, and that is the part that matters: a +server-streaming or bidirectional call has a body that does not finish until the stream +closes, so a model that waited for a finished body would call your filter once, at the +end — a log entry rather than a block. Each message is handed over as it completes, and a +message split across two packets is held until it is whole: deciding on half a payload is +how a pattern gets defeated by splitting it. + +Two things it deliberately does **not** do. It does not decompress a message whose flag +byte says it is compressed — what it is compressed with is the two peers' agreement +(`grpc-encoding`), and guessing is how a filter comes to read something that is not there, +so you get `compressed` and the raw bytes. And it does not decode the protobuf: without +your `.proto` there is no schema, so `payload` is bytes and a pattern against them is what +a ruleset has. + +A filter asking for one of these is simply **not called** on traffic that is not gRPC, the +way every other model declines when what it needs is not there. + +## Stream limiter + +What happens if a single TCP stream carries a lot of data? Past a configurable size, a "full stream" action kicks in. First import the enum: + +```python +from firegex.pyfilters import FullStreamAction +``` + +Then set these in the filter file's globals: + +- `FGEX_STREAM_MAX_SIZE: int` — maximum size (in bytes) of a stream before `FGEX_FULL_STREAM_ACTION` is triggered. This limit applies per data structure independently: e.g. if `TCPInputStream` has reached the limit but `HttpResponse` hasn't, the action only affects `TCPInputStream`. Default: 1MB. +- `FGEX_FULL_STREAM_ACTION: FullStreamAction` — the action taken once the limit is exceeded. Default: `FullStreamAction.FLUSH`. + +`FullStreamAction` values: + +| Value | Effect | +|---|---| +| `FLUSH` | Flush the stream and keep acquiring new packets (default). | +| `DROP` | Drop the next stream packets, like a `DROP` filter statement. | +| `REJECT` | Reject the stream and close the connection, like a `REJECT` filter statement. | +| `ACCEPT` | Stop calling pyfilters and accept the rest of the traffic as-is. | + +A compressed body is held to the same limit **decoded**: a megabyte of gzip can be a +gigabyte of anything that compresses well, so decoding stops at `FGEX_STREAM_MAX_SIZE` and +a body that would go past it meets the action above — refused with `REJECT` or `DROP`, +passed without calling the filter with `ACCEPT`, and with `FLUSH` handed over as it +arrived, still encoded, as a body in an encoding the library cannot undo would be. A +compressed WebSocket message is bounded the same way. + +## Other global options + +```python +from firegex.pyfilters import ExceptionAction +``` + +- `FGEX_INVALID_ENCODING_ACTION: ExceptionAction` — action taken when parsing hits an invalid/unsupported encoding (a parser-level failure). Default: `ExceptionAction.ACCEPT` — the traffic is carried without the HTTP filters seeing it, and the service log says so once per connection; `REJECT` is the stricter choice against request smuggling, and yours to make. Values: `ACCEPT` (accept the packet that caused the error), `DROP` (drop the connection), `REJECT` (reject the connection), `NOACTION` (do nothing — the error is signaled and the stream is accepted without calling any more pyfilters on it). One case never reaches this setting: a connection the filter took over already open — it started, was rebuilt or got new code while the connection was running — whose first bytes it saw were the middle of a message, such as the rest of an upload. That is not malformed traffic, so that direction of the connection is carried without its HTTP filters until it closes, and the log says so once, as information. A connection taken over between two messages is filtered as usual. +- `FGEX_MAX_HISTORY_SIZE: int` — max number of requests/responses kept per stream by [`HttpHistory`](#httphistory-alias-httpstreamhistory--http-only). Default: `100`. The entries of one direction together are also held under `FGEX_STREAM_MAX_SIZE` bytes. + +## Testing a filter locally + +You don't need a running Firegex instance to try out a filter: the `firegex` pip package ships a local proxy simulator. + +```text +➤ fgex pyfilters -h + + Usage: fgex pyfilters [OPTIONS] FILTER_FILE ADDRESS PORT + + Run your Python filters against a real service, locally + +╭─ Arguments ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ * filter_file TEXT The path to the filter file [default: None] [required] │ +│ * address TEXT The address of the target to proxy [default: None] [required] │ +│ * port INTEGER The port of the target to proxy [default: None] [required] │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ --from-address TEXT The address of the local server [default: None] │ +│ --from-port INTEGER The port of the local server [default: 7474] │ +│ -6 Use IPv6 for the connection │ +│ --help -h Show this message and exit. │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +``` + +This runs a local proxy in front of `ADDRESS:PORT`, applying `FILTER_FILE` to the traffic — no Docker, no nftables/root privileges required. The filter file is reloaded automatically whenever it changes on disk, so you don't need to restart the simulator while iterating. + +```bash +fgex pyfilters test_http.py 127.0.0.1 8080 +``` + +There is no protocol to pass: the file says which one it speaks by what its filters ask for, exactly as it does in a running instance. + +## Full example + +```python +# One file, two filters: one that wants raw payloads and one that wants parsed HTTP. +# Nothing declares a protocol — asking for an HttpRequest is what makes this an HTTP +# filter file. + +from firegex.pyfilters.models import RawPacket, HttpRequest, HttpHistory +from firegex.pyfilters import pyfilter, ACCEPT, REJECT, DROP, FullStreamAction + +# Lowest level of abstraction: the raw payload of each chunk. +@pyfilter +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 + +# Higher level of abstraction: parsed HTTP requests. +@pyfilter +def http_filter(http: HttpRequest): + if http.method == "GET" and http.url and "test" in http.url: + return REJECT + +# Using history to see previous requests on the same keep-alive stream. +@pyfilter +def repeated_probe_filter(hist: HttpHistory): + if len(hist.requests) > 20: + return REJECT + +# Stream size limits (applies per data structure, e.g. only to RawPacket streams above) +FGEX_STREAM_MAX_SIZE = 4096 +FGEX_FULL_STREAM_ACTION = FullStreamAction.REJECT +``` + +## How it works + +The proxy is built on a multi-threaded architecture that embeds Python for dynamic filtering: + +- **Packet interception**: the [nfqueue](https://netfilter.org/projects/libnetfilter_queue/) kernel module (part of [netfilter](https://netfilter.org/)) intercepts network packets; the rules attaching nfqueue to traffic are generated with the nftables JSON API by the Python manager. +- **Packet reading**: a dedicated thread reads packets from nfqueue. +- **Multi-threaded analysis**: the C++ binary launches multiple threads, each with its own Python interpreter — Python 3.12's [per-interpreter GIL](https://peps.python.org/pep-0684/) makes this real multithreading. Traffic is distributed across threads by hashing IP/port, so all packets of the same flow are handled by the same thread. +- **Python filter integration**: uploaded filters run inside these interpreters. +- **HTTP parsing**: [a Python wrapper for llhttp](https://github.com/domysh/pyllhttp) (forked/adapted to work across multiple interpreters) parses HTTP traffic. diff --git a/docs/screenshot_demo.py b/docs/screenshot_demo.py new file mode 100644 index 0000000..3322996 --- /dev/null +++ b/docs/screenshot_demo.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +""" +Take the README's screenshot of the interface, from the demo build. + +Usage: + python3 docs/screenshot_demo.py [url] [output_path] + +`url` defaults to the published demo; the release workflow builds the demo from the +release being published and serves it locally instead, so the picture is of that version +and not of whatever the Pages deploy last finished (built with `VITE_VERSION=`, the +header then names it instead of saying "demo"). Needs Playwright with chromium +(`pip install playwright && playwright install --with-deps chromium`). + +The viewport is fixed, so the image keeps its size from one release to the next however +much the demo's seed data happens to render. +""" +import sys +from pathlib import Path + +from playwright.sync_api import sync_playwright + +URL = sys.argv[1] if len(sys.argv) > 1 else "https://demo.firegex.pwnzer0tt1.it/" +OUTPUT = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(__file__).parent / "Firegex_Screenshot.png" +VIEWPORT = {"width": 1600, "height": 960} + + +def main(): + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page(viewport=VIEWPORT, device_scale_factor=2, color_scheme="dark") + page.goto(URL, wait_until="networkidle") + # The demo greets its visitor with a toast saying it is one, which is not part of + # the interface being pictured. + page.add_style_tag(content=".mantine-Notifications-root { display: none !important; }") + # The list fills in from the demo's in-memory backend after the page has loaded; + # a seeded service's name is what says it has. + page.wait_for_selector("text=shop-api", timeout=15000) + page.wait_for_timeout(1000) + page.screenshot(path=str(OUTPUT)) + browser.close() + print(f"Saved {OUTPUT} ({VIEWPORT['width']}x{VIEWPORT['height']} @2x)") + + +if __name__ == "__main__": + main() diff --git a/docs/services.md b/docs/services.md new file mode 100644 index 0000000..053ed01 --- /dev/null +++ b/docs/services.md @@ -0,0 +1,1059 @@ +# 🛡️ Services + +A **service** is one thing you are protecting, described in two independent parts: + +``` + filters regex (hyperscan) · pyfilter (Python) what to do with it + network layer NFQUEUE · proxy · your own proxy how to get hold of it +``` + +You choose each one separately. Any filter runs on any network layer that can host it, +in whatever order you put them, and you can change either without recreating the other. + +## Where a service is + +A service has a **list of addresses**, not one. The same daemon routinely answers on more +than one — a v4 and a v6 address, a public and an internal one, two ports of the same +process — and all of them deserve the same chain. Add them when you create the service or +afterwards, mixing address families — and interface names — freely. + +Adding an address to a running service **drops nothing**: the datapath is already up and +already enforcing the chain, so all that happens is that one more address is pointed at +it. On TCP and TLS the listener is dual-stack from the start, so adding IPv4 or IPv6 +addresses requires no restart. On UDP, a dedicated relay is created dynamically on the fly +without interrupting existing traffic. + +The alternative — one service per address, with the chains copied between them — is how +one of them silently stops being protected the first time a pattern is added to the other. + +### What one address can say + +An address is normally **where the service is**: firegex intercepts what the client +dialled and dials it back as the client, so the service sees the connection it would have +seen. That is what transparent means here, and it is what every address does unless you +say otherwise. + +Everything else lives behind the **⚙ button on the address**, and only what applies to +the service being configured is offered: + +| | When it is offered | What it does | +|---|---|---| +| **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 | 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 +panel it came from. Folded is not hidden: an option you cannot see you set is one that +will surprise you at the worst moment. The same tags are shown beside the address on the +service's page, in the same words, and open the same settings. + +**An address is a way in, and one more way in is one more address.** The option above is +not "also expose it on :443" — it says the opposite thing: *clients arrive here, and the +service is over there*. So a cleartext service on `:80` reached over TLS on `:443` is two +addresses, `:443` carrying the option that points at `:80`, and not one address on `:80` +with `443` typed into it. Written that way round it is intercepted where it already +listens and published nowhere, which is the one mistake this option makes easy. + +*The service is on another port* is the Proxy layer's rather than any protocol's: that layer +terminates the connection and opens the one to the service, so it is free to open it +somewhere else. NFQUEUE passes judgement on packets already on their way and opens +nothing; the hand-off layer gives the traffic to the proxy you run yourself, which decides +for itself where the service is. On both it is refused rather than stored, because a port +nothing reads would tell you your service is published while it is merely intercepted. + +So a service answering HTTP/1.1 in the clear on `:80`, and nothing else, is three rows: + +| Address | Clients speak | Sent on to | Sent as | +|---|---|---|---| +| `10.0.0.1:80` | clear | — | what arrived | +| `10.0.0.1:443` | TLS | `:80` | plaintext | +| `10.0.0.1:4433` | HTTP/3 | `:80` | plaintext | + +Firegex terminates what each client speaks, shows the filters the same HTTP/1.1 whichever +it was, runs **one chain** over all three, and hands the service the HTTP/1.1 it has always +understood. The service is not moved, reconfigured, or told about any of it — and only +firegex holds a certificate. + +Three things to know: + +- **On a published address firegex is not transparent**, which is the point of it. The + service sees a connection to `:80` — from the client's own address, as always — rather + than to the port the client dialled. +- **Declaring TLS is a promise**, and the engine keeps it: a client that opens that port + in the clear is refused rather than carried to a service expecting HTTPS. *In the clear* + is the permissive default and terminates a client that brings TLS anyway, decided per + connection, because refusing one at a port nobody promised would be a rule with nothing + behind it. +- **All of it can be changed afterwards**, from the pencil beside the address on the + service's page. Editing an address takes its rules back and reinstalls them, which is + what makes the whole row rewritable and not only the address; only that one address + stops being steered while it happens, and a refusal puts the row back untouched. + +### An address, or the interface it arrives on + +An address entry is a concrete IP (`192.168.1.10:80`, `::1:80`, CIDR `10.0.0.0/24:8080`) or +a **network interface name** (`eth0:80`, `wg0:8080`, `tun0:53`, `lo:80`). The picker lists +both, each interface shown with the addresses it is carrying at that moment, and takes +anything you type that is neither — the address a service will answer on does not have to +exist on this host yet. + +Which of the two to use is a question about who decides the address. An IP protects that +one place. An interface protects *whatever address that link is carrying*, matched on the +name, which is what you want when somebody else hands the address out — a VPN the +organisers dial you on, a DHCP lease, a tunnel that comes up mid-round. Nothing has to be +edited when the address changes, and nothing silently stops being protected when it does. + +The rules are built differently for each, so the layer matters: + +- **On NFQUEUE**: interface matching operates natively in nftables using `meta iifname ` (in prerouting) and `meta oifname ` (in postrouting). Packets arriving at or routed through that interface on the configured port are queued to userspace, regardless of the destination IP. +- **On Proxy (TCP & TLS)**: traffic entering through the interface (`meta iifname `) is redirected to the proxy listener. The engine connects upstream preserving the destination (`SO_ORIGINAL_DST`) and spoofing the client source IP (`IP_TRANSPARENT`), while policy routing diverts return packets (`meta oifname `). **Protecting a link protects everything that crosses it on that port** — including traffic that is only passing through. On a bridge like `docker0` that means the containers' own outgoing connections: a service protecting `docker0:443` takes every container's outbound HTTPS into firegex, which terminates it with that service's certificate, and the containers see a certificate that does not match the site they asked for. That is the interface doing what it says; it is not narrowed for you, because a link is also how you protect a service on **another machine** that this host routes for, which is the case the address is not yours to know. If you want only what is addressed to this host, name the address instead of the link. An interface stands for whatever addresses it carries, and firegex resolves them wherever it needs an address rather than a match — so an HTTPS edge, a published port and a client on this host all work the same whether you write `eth0` or the address itself. Traffic this host generates towards those addresses is redirected too, matched by address rather than by interface name — a connection from the host to one of its own addresses is routed through loopback, so there is no interface on it to match. That is what makes protecting `lo` also protect a client running beside the service. +- **On Proxy (UDP)**: because UDP relays need a concrete destination to bind upstream forwarding, Firegex automatically resolves the interface's primary assigned IP. If the interface has no IP assigned, use NFQUEUE instead. +- **External proxies**: the `external` transport takes an IP only. Its return rule recognises your proxy by one address and port to put the original port back, and an interface is not one address — so the interface half of the picker is not offered there at all. + +## What the service speaks + +One choice per service: **TCP**, **UDP**, **TLS**, **QUIC** or **HTTP**. It tells firegex +what is on the wire, which is what decides what the filters can be shown. + +| | Pick it for | The filters are shown | It needs | +|---|---|---|---| +| **TCP** | anything unencrypted on TCP — a web service on `:80`, a game protocol, a shell | the bytes as they travel; HTTP/1.1 parsed, and HTTP/2 in the clear recognised from its preface and rendered as HTTP/1.1 | nothing but the addresses | +| **UDP** | anything unencrypted on UDP — a game server, DNS, something homemade | one datagram at a time, both directions, `RawPacket` only | nothing but the addresses | +| **TLS** | a port where *everything* arrives encrypted | the decrypted stream, the same models a cleartext service gives | a certificate and key; the proxy layer. [What the service behind speaks](#what-the-service-behind-speaks) is its own question | +| **QUIC** | a QUIC service, in practice HTTP/3 | one stream at a time with its own state; HTTP/3 rendered as HTTP/1.1 | a certificate and key; the proxy layer | +| **HTTP** | one daemon reached **more than one way at once** — `:80`, `:443` and `:443` over UDP | every version as the same HTTP/1.1, gRPC included | a certificate and key, including for the cleartext addresses; the proxy layer | + +**The interface asks this as two questions**, because the list of five looks like five +alternatives on one axis and is not: + +> **Is the traffic encrypted?** in the clear · encrypted 🔒 +> **Encrypted with what?** TLS · QUIC · HTTPS — or, in the clear: **TCP** · **UDP** + +The first answer is the one the certificate hangs off, which is why it wears the lock; the +second only ever offers protocols of that kind. `HTTP` is shown as **HTTPS** there, because +beside TLS and QUIC an option labelled "HTTP" reads as the cleartext web protocol — which +is the confusion this picker was rebuilt around. It is `http` in the API and in a backup. + +**A plain web service is `TCP`.** Filtering HTTP needs nothing special: HTTP/1.1 is parsed +as it goes past, HTTP/2 in the clear is recognised from its connection preface, and +patterns match the plaintext because the plaintext is what is on the wire. + +Every layer carries TCP and UDP; the three that have to be decrypted are on the proxy +layer alone, because decrypting means terminating the connection — see [TLS](#tls), +[QUIC](#quic) and [HTTP](#http-every-version-through-one-chain). + +**UDP on the proxy layer preserves the client's address** transparently using `IP_TRANSPARENT` +source spoofing and policy routing, matching the behavior of TCP. See +[UDP on the proxy layer](#udp-on-the-proxy-layer). + +This used to be four modules — `nfregex` was NFQUEUE welded to regexes, `nfproxy` was +NFQUEUE welded to Python, `tls` was a separate object you had to keep in step with the +service that used it. Choosing regexes therefore also chose NFQUEUE, and switching meant +starting over. Nothing about the two decisions actually requires them to be one. + +## Network layers + +The two that inspect traffic are built on opposite trades. **The trade is not speed** — +the proxy layer measures faster on both bulk throughput and connection rate, for the +reason in the table below — it is what happens when a filter dies, and what you are +allowed to do to the traffic. Pick by those. + +| | **Proxy** | **NFQUEUE** | +|---|---|---| +| Unit of work | a connection | a packet | +| Transparency | dials your service **from the client's own address** (not its source port) | full — the real packets, on their way to your service | +| Reassembly | the kernel's, on two real sockets | rebuilt in userspace with libtins | +| Rewriting | none — a filter answers with a verdict | none — a filter answers with a verdict | +| If a filter dies | the engine rebuilds fail-open by hand | **the kernel keeps forwarding**, by itself | +| Cost | terminating a connection, once | **a userspace round trip per packet**, per filter | +| Measured ([how](../tests/bench/README.md#performance)) | **4035 MB/s** at 1 thread, **13 984** at 8 | 1820 at 1 thread, 2956 at 8 | +| Short connections | the two are indistinguishable — see below | | +| UDP | yes, fully transparent (source IP preserved, one relay per address) | yes, fully transparent | +| TLS and QUIC | yes, terminated here | no — decrypting means terminating | + +### Proxy + +The connection is terminated and reopened towards your service, so firegex owns both +halves. + +**What that buys** + +- **Reassembly is the kernel's problem.** Both sides are ordinary sockets, so + out-of-order and retransmitted segments are sorted out before a filter ever sees them + — rather than being rebuilt in userspace with the caveats that come with it. +- **Backpressure for free.** A slow filter slows the sender through TCP flow control + instead of overflowing a queue and dropping packets. +- **An ordered chain inside one process.** Filters are a list walked in order, so a long + chain costs one interception, not one per filter. + +- **It carries bulk traffic several times faster**, which reads backwards and is not: + NFQUEUE copies every packet out to the kernel's queue and takes a verdict back, once + per packet, while the proxy pays to terminate a connection once and then the kernel + moves the bytes. Measured on the same host, same filter, same traffic: **2.2×** at one + thread and **4.7× at eight**, with the ranges nowhere near touching. It also scales + better with `--threads` — 3.5× from one thread to eight, against about 1.5× for + NFQUEUE, whose work is largely a trip through the kernel that a second core cannot + take. + + **On short connections there is no measurable difference.** That is the axis where a + terminating proxy is supposed to lose, and it does not obviously — but neither does it + win: measured with the layers interleaved, they overlap, and the medians land within + 3% of each other. Anyone quoting a number for this axis is quoting noise, this page + included until it was measured properly. Both are in + [tests/bench/README.md](../tests/bench/README.md#performance), with the scripts that produce them. + +**What it costs** + +- **The kernel's fail-open backstop is gone**, and the engine has to rebuild it by hand: + `catch_unwind`, a deadline per filter, a filter that misbehaves losing its say rather + than the traffic being held. One that keeps missing its deadline — a Python filter + under a flood, usually — is asked again after half a minute rather than staying out; + one that crashed stays out until you next save a change to the filters. It works — + and it is code, where the other layer has a kernel guarantee. + +It stays invisible: it always dials your service **from the client's own address** on both +TCP and UDP, so anything that logs, rate-limits or bans by IP keeps working. That is not +a setting, because a service that suddenly saw one address for the whole internet would be +a regression nobody would attribute to us. + +What it cannot keep is the client's **source port**: the connection to your service is a +new one, from the client's address and a port the kernel picks. And a reset from either +end reaches the other as an ordinary close. + +**A service in a container is the ordinary case.** Protect the port the container is +published on (`docker run -p 8080:80` → this host's address, port `8080`), on either layer. +Firegex's rules run before the container runtime rewrites the destination, so it sees the +traffic first; the service is reached, filtered and shown the real client address whether +the client is on another host or on this one. Both were measured with a container behind +Docker's own rewrite, and `tests/integration/test_remote_clients.py` asks it of every +release with a stand-in that needs no Docker. + +### NFQUEUE + +Packets are lifted to userspace, inspected, and a verdict is handed back to the kernel. +Nothing is terminated. + +**What that buys** + +- **The kernel's fail-open backstop is real.** If a filter panics, hangs or crashes, the + kernel keeps forwarding untouched packets. +- **Full transparency.** The service sees the original packets, with original headers and + timestamps intact. +- **No connection termination.** Suitable for protocols that cannot be proxied or where + terminating the transport is prohibited. +- **Connections already open are protected too.** Starting a service, adding a filter or + rebuilding the chain inspects the connections that were open at that moment from their + next packet on. The proxy layer cannot do this: a connection made before its redirect + existed was never sent to it, and goes on straight to the service, unfiltered but + uninterrupted, until it closes. + +**What it costs** + +- **A userspace round trip per packet.** Every packet is copied across the netlink boundary, + 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. +- **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 + +On TCP a single listener fronts every protected address and recovers where each +connection was headed with `SO_ORIGINAL_DST`, reading the conntrack entry the redirect +left behind. **The kernel implements that option for TCP and SCTP only**; ask it about a +UDP socket and it answers `ENOPROTOOPT`. So UDP is relayed with **one dedicated socket +per protected address**, each with its upstream already known, and nothing has to be +recovered per datagram. + +What UDP on the proxy layer provides: + +- **Full source IP transparency.** Outbound datagrams towards your service are sent with + `IP_TRANSPARENT` using the client's own address and port, with mark-based policy routing + diverting return packets to the local engine. Your service sees the real client IP. +- **Zero-downtime dynamic address addition.** Adding new addresses to a running UDP service + dynamically binds new relay sockets through the control channel without restarting the + engine or dropping existing connections. +- **Relays answer only what was redirected to them.** Each relay is bound on an + ephemeral port of every address, because a redirect delivers to whichever address the + datagram arrived on; a datagram sent straight to that port, from anywhere, is dropped + by the rules rather than forwarded to your service. +- **Per-flow filter state.** Each client address is a flow with its own filter state and + its own Python module globals, released after a minute of silence — UDP has no close + to observe, so a timeout is the only thing that can end one. +- **Both directions inspected**, and replies leave through the listener socket so + conntrack rewrites them to appear from the address the client dialled. +- **One slow client holds up only itself.** Each flow's datagrams are judged in order by + a task of their own, so a filter taking its time over one client's datagram does not + keep the relay from reading everyone else's. +- **Flows already open follow the rules in force.** The kernel remembers where a UDP flow + was sent for as long as datagrams keep arriving, so firegex tells it to forget the + flows of an address whenever what steers them changes: one open before the service + started is filtered from its next datagram instead of going round the protection, one + carried across a restart moves to the new engine and its filters, one whose engine died + is answered again as soon as the engine is back, and once the service is stopped they go + straight to it. A QUIC connection an engine is still carrying is left with it until it + closes, because moving it to another engine would end it. + +`REJECT` means something narrower here: there is no connection to close, so the datagram +is simply not forwarded, and the next one from that client is judged afresh. + +### Holding up under load + +A service can be given a **limit on how many connections it carries at once**, and told +what to do with what does not fit. Both are on the proxy layer, which is the one that +accepts a connection and dials the service; NFQUEUE hands the kernel a verdict on packets +already in flight, and what it can run out of is queue — that is what fail-open is for. +There is no limit unless you set one. + +On NFQUEUE the same setting, under **Advanced settings**, means something narrower, and +only for a UDP service: **how many UDP flows keep their Python filters' state at once**. +A datagram has no close to observe, so a flow's state is otherwise kept until it has been +quiet for a minute. Past the limit nothing is refused — the flow quiet the longest loses +what its filters were keeping, and starts over from a clean state when it next sends. A +TCP stream lets go of its state when it closes, so the setting does not apply to one. + +Without a limit, connections that are opened and then say **nothing** are the cheapest +attack there is. Each costs two descriptors — one from the client, one to the service, +because the upstream is dialled as soon as the connection is accepted — and about five +hundred of them from a single host were enough to exhaust firegex entirely. No data has +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 +one service: measured with four hundred silent connections against a limited service, the +service beside it kept answering, which is what used to be lost. + +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. 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. + +**Hitting the limit leaves a mark in two places, because they answer different +questions.** The service log says it now, at warning level, so an operator watching +clients fail sees the reason rather than guessing — rate-limited, since being at the limit +means connections are arriving faster than they leave and a line each would be the flood +arriving twice. And a count is kept in the database with the first and last time it +happened, which is what survives a restart and outlives the live log's bounded ring: *did +we ever hit the wall* is a question asked the next morning. The service page shows it +above everything else. + +#### A deadline on the first byte + +The limit contains the damage; it cannot undo it, because a cap cannot tell a connection +that is silent because it is an attack from one that is silent because the client is slow. +An attacker who fills the limit keeps it filled. + +So a service can also be told to **close a connection that has said nothing** after a +number of seconds. That is the thing the limit cannot do: measured with the limit full and +the attacker still holding every socket, clients got back in once the deadline passed. + +Two properties make it safe to turn on: + +- It counts **either direction**. A service that greets its client — SMTP, SSH, most game + protocols — satisfies the deadline with its banner, exactly as a request would. A + deadline that required the *client* to speak first would have hung every one of them. +- It applies only until the **first** byte, and never again. A connection that has said + something and then goes quiet is a session, and sessions are allowed to think. This is + not an idle timeout, and it will not close a long-lived connection that is waiting. + +Zero means never, which is what every service did before. There is no default worth +picking for you: how long a legitimate client may reasonably stay silent is a fact about +your service, not about firegex. + +UDP flows are counted against the same number. They are the cheaper half of the same +attack — a flow is created per source address, a datagram's source is not verified by any +handshake, and six hundred datagrams from six hundred forged sources took four hundred +descriptors in three hundredths of a second and held them for a minute after the sender +had gone. + +### TLS + +TLS is one of the **transport protocols** a service can speak, beside TCP and UDP — +not a switch on top of TCP. That is the shape of the thing: asked what is on the wire, +the answer is TLS or it is not. + +It is offered on the **proxy** layer only, because decrypting means terminating the +connection and terminating is what that layer does. NFQUEUE inspects packets on their way +past, which is the whole reason it can fail open, and a hand-off to your own proxy has +nothing of firegex in the path at all — so on those layers the TLS option is there but +cannot be chosen, and the form says why. The same holds the other way round: choose TLS +and the two layers that cannot carry it grey out. + +Choose it and give the service a certificate and a private key; both fields take a paste, +a file picked from disk, or a file dropped onto them. The engine decrypts the connection, +the filters see the plaintext, and it is re-encrypted towards your service — all inside +the process that is already filtering, so **a TLS service occupies no port that a plain +one does not**, and clients keep dialling the address they always did. + +There used to be two ports, and a boolean. nginx terminated on one loopback port and +re-encrypted from a second, with the filters attached to the leg in between, so +protecting one address cost a pair of ports derived by hashing `ip:port` — ports that +were chosen rather than assigned, and could collide with something real. And because TLS +was a flag rather than a protocol, three combinations were expressible that are not real: +TLS on a UDP service, on NFQUEUE, and on a hand-off. Two of those cannot be said any +more; the third is refused when the service is created rather than when it first fails to +start. + +The private key is stored and never sent back to the browser; neither is the certificate, +so the fields are empty when you come back to edit and leaving them that way keeps what +is already there. Switching a service *to* TLS when it has never been given either is +refused there and then. + +#### What the service behind speaks + +**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. 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 | +|---|---| +| **As it arrives** | It speaks what arrived. The default, and what every address did before there was a choice: a connection terminated here goes back out encrypted. Firegex is *carrying* its encryption. | +| **In the clear** | It is an ordinary HTTP/1.1 service. Firegex terminates what the client used and forwards the plaintext, so **only firegex needs a certificate**. | +| **TLS** | It speaks HTTP/1.1 under its own TLS, whatever the client used to get here. | + +The second one is why this exists. A service that speaks HTTP and nothing else can be put +behind an encrypted edge with firegex providing the encryption: clients reach it over TLS +— or over **HTTP/3**, see below — on the port it already listens on in the clear. + +Two things follow from choosing anything but *as it arrives*, and both are worth knowing +before a round: + +- **ALPN is no longer mirrored** on that address. The protocol a client is told is + normally the one the service picked, and a service reached over HTTP/1.1 picks nothing. + On TLS the client is told nothing and settles on HTTP/1.1; on QUIC, firegex answers `h3` + — for itself, not for the service, because it is firegex that is speaking HTTP/3. +- **Stopping firegex stops the encryption**, because the encryption was firegex's. A + client dialling that port with TLS then reaches a service answering in the clear. + +##### HTTP/3 in front of a service that has never heard of it + +This is the one place in the engine where what leaves is **not** the version that +arrived, and it is only ever an explicit choice. An HTTP/3 exchange is already rendered as +the HTTP/1.1 it would have been, because that is what the filters have to be shown — so +that same rendering can be sent on to a service that speaks HTTP/1.1. Nothing is invented: +what the service receives is byte for byte what the chain inspected. + +One QUIC stream is one request, and HTTP/1.1 has no multiplexing, so each exchange opens +its own connection to the service — dialled from the client's own address like every other +dial firegex makes. + +What cannot be done, and is a limit rather than a missing setting: a QUIC edge carrying +anything **other than HTTP/3** to a service that speaks HTTP/1.1. Opaque bytes on a QUIC +stream have no HTTP/1.1 form, so there is nothing to send it. On such an address firegex +answers the client itself, in HTTP/3, and a client offering only something else is +refused by that handshake. + +#### What is carried through, and what cannot be + +**ALPN is the service's answer, carried.** Terminating a connection means answering for +your service, and answering something it did not say is how a proxy breaks a protocol it +was only meant to carry — a client told `h2` while the service speaks HTTP/1.1 sends +frames to something that cannot read them. So the client's hello is held open, the +service is asked with exactly the list the client offered, and the client is told exactly +what came back. If the service picks nothing, the client is told nothing: firegex does not +invent an agreement between two ends that did not reach one. + +That means HTTP/2 works if your service speaks it, and does not appear if it does not. +Before this, ALPN was dropped in both directions — every client fell back to HTTP/1.1 +whatever it asked for, and a client that insists on `h2` failed. + +> **HTTP/2 is terminated and rendered, exactly as HTTP/3 is.** HTTP/2 puts the method, +> the path and the headers in an **HPACK-compressed** HEADERS frame, so a firegex that +> only forwarded it showed a filter a compression format: a pattern written against a +> request line matched nothing, and a filter asking for an `HttpRequest` was handed the +> `PRI * HTTP/2.0` preface and then frames it could not read, so it was never called and +> nothing said so. Only a body travelled in the clear. Since gRPC *is* HTTP/2, gRPC could +> not be filtered at all. +> +> On a connection that negotiates `h2`, the engine now terminates it and shows the chain +> each exchange as the HTTP/1.1 it would have been — the same rendering HTTP/3 gets (see +> [below](#http3-is-shown-to-the-filters-as-http11)), from the same code. One pattern and +> one Python filter cover HTTP/1.1, HTTP/2 and HTTP/3. There is nothing to turn off on +> your service any more. +> +> Since gRPC is HTTP/2, that is also what makes gRPC filterable: the method name is the +> path, the metadata are headers and the status is a trailer section, all of them visible +> to a pattern and to a Python filter. For the message bodies themselves there is a +> `GrpcMessage` model that takes the length prefix off — see +> [the pyfilter documentation](pyfilter.md#grpc-messages). +> +> **HTTP/2 in the clear** (`h2c`, prior knowledge) is recognised too, on a plain `tcp` +> service with no certificate anywhere: such a client opens with a fixed 24-byte preface +> that no other protocol begins with. What is *not* handled is the old HTTP/1.1 +> `Upgrade: h2c` handshake — the upgrade request is filtered as the HTTP/1.1 request it +> is, but if your service answers `101` everything after that is HTTP/2 nobody rendered. +> RFC 9113 deprecated that mechanism and nginx dropped it in 1.25.1, so in practice no +> service accepts it. +> +> Two shapes are **refused rather than rendered**, because a tunnel has no HTTP exchange +> in it to show and inventing one would be worse than saying no: **server push**, which +> is turned off in the handshake with your service so it knows rather than watching its +> pushes disappear, and **CONNECT** streams, including the extended CONNECT that +> WebTransport uses. +> +> One difference to know about if you write stateful Python filters: **each HTTP/2 stream +> is its own connection to a filter**, with its own module globals, where an HTTP/1.1 +> keep-alive connection carries many requests through one. HTTP/2 interleaves its +> streams, so sharing state between them would let one client's bytes decide another's +> verdict — and would let an attacker split a pattern across two streams to get past a +> filter. + +**TLS versions are negotiated per leg**, which is what terminating means: a client on +TLS 1.2 can reach a service on TLS 1.3. Both legs do **TLS 1.2 and 1.3 only**. TLS 1.0 and +1.1 are not supported and cannot be turned on — the engine's TLS library does not +implement them at all, so this is a limit of what firegex can be rather than a setting +that is off. A service that must be reached over TLS 1.0 has to keep firegex out of its +path, on the `external` layer or the NFQUEUE one with the ciphertext filtered as it goes +by. + +The same goes for the cipher list: it is a modern fixed set, not something to configure, +and key exchange is always forward-secret. There is no option for RC4, 3DES, or +RSA key transport, and there is no switch that would bring them back. + +Client certificates are not requested. A service that authenticates its clients with mTLS +cannot be terminated here, because the certificate the client would present is one only +your service can check. + +**The key has to be one the engine can sign with**: RSA of at least 2048 bits, or an +ECDSA or Ed25519 key. A smaller RSA key is refused when the service starts, with the +engine's own reason. nginx could be told to accept one; the engine's TLS library has no +equivalent switch. + +#### Watching the decrypted traffic + +The plaintext never becomes a packet — that is exactly what removed the two ports — so +the engine writes it out itself. **`firegex0` carries the decrypted traffic of every TLS +and QUIC service and nothing else:** + +```bash +sudo tcpdump -i firegex0 -w decrypted.pcap +``` + +No filter expression, no port to look up, and one capture covering the whole instance +rather than one per service. Wireshark takes the same interface live, or opens the file +afterwards. Run it on the **host** running firegex: the container shares the host's +network namespace, so the interface is there. + +What arrives there is a **reconstruction**. The bytes are real — exactly what the filters +saw and exactly what was forwarded, after any rewriting — but the framing around them is +built by the engine, because the framing that crossed the wire was encrypted. Sequence +numbers start at zero, there are no retransmissions, and the segmentation is the engine's +read sizes rather than the peer's. What that buys is a stream Wireshark can follow; what +it costs is that a capture from here is not evidence of what was on the wire. + +Opening the socket it writes to needs `CAP_NET_RAW`. Firegex asks for it and starts +without it: a host that will not grant it loses the capture, not the firewall, and says +so once at startup. + +What comes out is decrypted traffic, which makes the file exactly as sensitive as the +private key that would have produced it. + +### QUIC + +QUIC is the fourth thing a service can speak, and the one with the least room for +argument about where it lives. + +TLS over TCP can at least be carried past unopened: the bytes are framed by a transport +the kernel understands, so a layer that forwards still has a packet to match and a rule +to match it with, and filtering the ciphertext is a thing you can choose to do. QUIC +leaves not even that. Past the Initial packet it encrypts the **frames, the stream +boundaries and the packet number** along with the payload, so a packet queued to +userspace is a datagram of noise. Terminating it is not the better option, it is the only +one — which is why the QUIC option greys out on the other two layers, exactly as TLS +does, and says so. + +On the wire QUIC is **UDP**, and that is what the rules match: a QUIC service's addresses +are steered the same way a datagram service's are, one endpoint bound per protected +address. What changes is what is behind the port — something that terminates rather than +something that forwards. + +Give it a certificate and a private key, as for TLS. QUIC carries TLS 1.3 inside itself, +so there is no unencrypted QUIC to fall back to and no version to negotiate. + +**A stream is what the filters see.** A QUIC connection carries many streams at once, and +each one is handed to the chain as its own connection: its own filter state, its own set +of module globals for a Python filter, released when that stream ends. That is the honest +mapping — a filter's state follows a stream of bytes from its start to its end, which is +what a QUIC stream is and what a QUIC connection is not — and on HTTP/3 it lands where +you would expect, one request to a stream. + +**A refusal ends the connection, not the stream.** Blocking one stream would leave the +client free to ask again on the next one, which is not what blocking means anywhere else +in firegex; it is the same thing that happens on HTTP/1.1 with keep-alive, where a +refused request takes the connection with it. The client is told why, in the close +reason, rather than being left to time out. + +#### HTTP/3 is shown to the filters as HTTP/1.1 + +A filter is supposed to be written once. `HttpRequest` on a QUIC service has to mean what +it means on the TCP service beside it, and a hyperscan pattern written for one has to +match on the other — otherwise moving a service to QUIC silently switches its filters +off. + +But HTTP/3 puts the method, the path and the headers in a **QPACK-compressed HEADERS +frame**. A filter shown the bytes of the stream would be shown a compression format, and +every pattern would stop matching. So firegex terminates HTTP/3 too, and renders each +exchange as the HTTP/1.1 message it would have been. That is what the chain is shown, and +what goes on towards your service is HTTP/3 again, re-encoded. + +The same bargain the [decrypted capture](#watching-the-decrypted-traffic) makes: **the +bytes are real and the framing is reconstructed.** Concretely — + +- the request line says `HTTP/1.1`, because that is the only version an HTTP/1 parser + will read, and what was on the wire was HTTP/3; +- `Host` is rendered from the `:authority` pseudo-header, which is where HTTP/3 puts it; +- a body whose length the message declared is shown under its own `Content-Length`; +- a body whose length it did not declare is shown **chunked**, which is what that message + is in HTTP/1.1 — and the head is held back until it is known whether a body is coming + at all, so that a request without one is never shown a `Transfer-Encoding` it did not + carry. A filter looking for that header is usually looking for smuggling, and one + invented by the proxy in front of it would be the worst possible answer. Held *briefly*: + a declared length settles the question outright, a message with no body has already + ended its stream, and the only thing left waiting is a sender holding the stream open — + which after 100 ms is rendered chunked rather than held, because a bidirectional + exchange the service speaks first in (gRPC's among them) would otherwise deadlock, each + end waiting for the other; +- a **trailer section is always shown to the filters**, and that is what decides the + framing of a message that has one and no body — in HTTP/1.1 a trailer section belongs to + a chunked message and nowhere else, which is exactly a gRPC trailers-only answer. The + one message whose rendering cannot carry a trailer section is one that declared its + length, since its HTTP/1.1 body ends where the header said it would; there the trailers + are **dropped rather than forwarded**, because a piece of a message no filter was shown + is a piece that travelled unfiltered. The engine logs it when it happens; +- hop-by-hop headers do not appear, because HTTP/3 forbids sending them. + +#### What is carried through, and what cannot be + +**ALPN is the service's answer, carried — and there is nothing to configure.** On TLS +over TCP the client's hello is held open, the service is asked with exactly the list the +client offered, and the client is told what came back. QUIC does the same. Its hello +travels inside an Initial packet, but Initial packets are protected with keys anyone can +derive from the packet itself — on purpose, so something in the path can read exactly +this — and firegex reads the client's list off it before answering. Your service is then +offered precisely what the client offered, and the client is told the one protocol your +service chose. HTTP/3, DNS over QUIC, a game's protocol or a CTF's own all work the same +way, with no list to keep in step. If a hello cannot be read (a QUIC version firegex does +not know), `h3` is offered instead and the service log says so. + +**0-RTT is not offered.** Early data is replayable by anyone who watched it go past, and +a filter that refused a request has no way to un-deliver the copy your service already +acted on. A round trip is the price of that not being true. + +**QUIC datagrams are carried, except on HTTP/3.** A datagram is unreliable and unordered, +which is to say it is not a stream — so a filter is handed a `RawPacket` for it and +nothing else, exactly as on a UDP service, and the stream and HTTP models stay out of the +way rather than being built on something that cannot support them. One connection's +datagrams are **one flow**: a filter keeping state sees them in order of arrival and can +still catch a pattern split across two of them, which is what the flow is for. A refused +datagram is **dropped and the connection carries on** — there is no conversation to end, +and taking the connection down would cost every stream on it for one message that was +already complete when it was judged. + +Over HTTP/3 they are not carried, and the handshake says so rather than letting a peer +find out by watching them disappear. An HTTP/3 datagram names the stream it belongs to, +and the request streams firegex opens towards your service are not the ones your client +opened, so the name would point at the wrong stream on the far side. **WebTransport is not +carried** for a further reason: its streams are bound to a session by a marker each of +them carries, which is a different shape from "one stream, one filter" and would need a +session model rather than a setting. + +**A new client is asked to prove its address.** The first Initial packet from an address +is answered with a Retry, so that nothing is opened towards your service on the word of a +source address nobody has checked — otherwise a forged source turns the relay into an +amplifier, which is the datagram flood the connection cap exists for with a handshake in +front of it. It costs an honest client one round trip. + +**The decrypted traffic is mirrored to `firegex0`, one TCP stream per QUIC stream** — and +over HTTP/3 that is one per request. What you read there is the same HTTP/1.1 the filters +were shown, which is the only honest thing it could be: what crossed the wire was a +compressed header block inside an encrypted packet, and there is no tool that would read +it back as an exchange. + +One thing to know before reading a port out of that capture: **the client port is +invented**. Every stream of one QUIC connection shares the client's real port, so each is +given one of its own — otherwise a hundred streams arrive as one conversation whose bytes +decode as nothing. The addresses are real and the service's port is real; the client's +names a stream, not a socket. + +**The connection limit is honoured; forwarding the excess unfiltered is not free here.** +On TCP a connection past the limit is relayed with no chain at all, costing nothing. A +QUIC connection past the limit is still terminated, decrypted and re-encrypted, because +there is no such thing as forwarding a QUIC connection unopened — what "unfiltered" +saves is the inspection, not the termination. + +### HTTP: every version through one chain + +`HTTP` is the one protocol on that list that does not say what is on the wire. It says +what the *service* is, and each of its addresses says how it is reached: + +| Address | What reaches it | +|---|---| +| **TCP** | HTTP/1.1 and HTTP/2 — in the clear or under TLS, whichever each client opens with | +| **UDP** | HTTP/3, over QUIC | + +One service, one filter chain, one certificate. Add `10.0.0.1:80` as TCP, `10.0.0.1:443` +as TCP and `10.0.0.1:443` as UDP, and every client that can reach your web service goes +through the same filters. + +**Why it exists.** HTTP/1.1 and HTTP/2 live on TCP and HTTP/3 lives on UDP, so covering +all three used to take two or three separate firegex services with the same patterns and +the same Python pasted into each of them. Keeping those in step by hand is exactly how one +of them quietly stops being protected — which is the same reason a service takes a *list* +of addresses rather than making you create one service per address. + +**Whether a connection is TLS is decided per connection**, from what the client actually +sent, not from a setting. So one `http` service in front of a daemon that answers in the +clear on `:80` and under TLS on `:443` carries both, and what leaves towards your service +matches what arrived: a cleartext connection is forwarded in the clear, an encrypted one +is re-encrypted. It is the same rule ALPN mirroring follows — firegex carries what the two +ends are doing rather than deciding it for them. + +**The service behind may answer in the clear too**, including on the HTTP/3 edge — see +[what the service behind speaks](#what-the-service-behind-speaks). Every edge is then +forwarded as HTTP/1.1, which is what an ordinary web service reached on `:80`, `:443` and +`:443/udp` at once looks like: three protocols in front, one behind, one chain over all +of them. + +**A certificate is required**, including for the cleartext addresses beside it. A service +that only ever answers in the clear should be a `TCP` one: HTTP/1.1 is filtered there as +it always was, and HTTP/2 in the clear is recognised from its connection preface. The TLS +and HTTP/3 edges are the whole of what `HTTP` adds, and neither exists without a +certificate. + +**Proxy layer only.** An `HTTP` service is reached over TLS on one port and QUIC on +another, and the NFQUEUE and hand-off layers can read neither — nor, in fact, the +cleartext edge, because HTTP/2 in the clear is HPACK-compressed and rendering it means +terminating the connection. + +Everything the [TLS](#tls) and [QUIC](#quic) sections say about what is and is not +supported applies here unchanged: the same TLS versions and ciphers, no client +certificates, no 0-RTT, no HTTP/3 datagrams and no WebTransport, and the same +`firegex0` capture of the decrypted traffic. + +## Filters + +### Regex + +Patterns matched by [hyperscan](https://github.com/VectorCamp/vectorscan) — the same +library on both network layers, so a pattern means the same thing wherever it runs. + +Every pattern in one filter is compiled into a single database, so matching fifty +patterns costs about what matching one costs. That is why patterns live inside a filter +rather than each being one, and why adding a pattern rebuilds rather than chains. + +Matching is **stateful per connection**: a pattern split across two packets is still +found, however far apart the halves land, and one client's bytes can never decide another +client's verdict. + +Each pattern has a direction — client to service, service to client, or both. A leaked +flag shows up on the way out; an exploit on the way in. + +A pattern **blocks** the connection. That is the only thing it does: + +``` +FLAG\{[a-z0-9]+\} service → client block +X-Debug: [^\r\n]* client → service block +../ both ways block +``` + +A pattern can be **edited in place**. A typo is usually found while the service is +running, and retyping the rule as a new one loses its place in the chain and drops the +connections the filter is holding — editing keeps both. What it does not keep is the +**counters**, when the pattern text itself changes: those numbers were about the text +that used to be there, and carrying them over would credit blocks to a matcher that +never made them. Changing anything else — the direction, the case sensitivity, whether +it is active — leaves the counters alone. The new pattern is checked by the engine +before it is stored, and a rule that would collide with another in the same filter is +refused rather than silently duplicated. + +**A pattern cannot rewrite, on any layer.** It used to be able to, and the reason it no +longer can is worth stating rather than leaving as a missing feature. Rewriting scanned +one chunk at a time, because bytes already forwarded cannot be taken back — so a match +straddling two chunks was never rewritten. Measured: a pattern sent whole reached the +service redacted, and the *same* pattern split across two TCP segments reached it intact. +Blocking, which scans in hyperscan's stream mode, caught the split version. + +What made that unacceptable was not the miss but its silence: no block, no log line, no +counter — just a rule the operator believed was applying. A filter that works on the +sender's segmentation is worse than no filter, because it is trusted. Blocking is the +verdict a pattern can honestly reach on a stream, so it is the only one offered. To +change bytes rather than refuse them, use a Python filter, which sees the reassembled +stream and can say so. + +### Python (pyfilter) + +Your own code, written against the [`firegex`](https://pypi.org/project/firegex/) library +and documented in [pyfilter.md](pyfilter.md). On the proxy layer it runs in a process of its +own, so code that hangs is killed rather than stalling traffic; on NFQUEUE it runs inside +the C++ process through an embedded interpreter. + +A file normally holds **several `@pyfilter` functions**, and each one is listed on the +filter's card with its own switch and its own count of what it has refused. Switching +one off leaves the code exactly where it is: only the list of names handed to the +library changes, and that list is what decides whether a function is ever called. The +alternative — deleting a function to stop consulting it, and pasting it back to resume — +is what these switches exist to replace, and it is the one that loses code mid-round. + +The code itself is visible on the card too, without opening the editor: expand it to +read what is running. + +The code decides which functions exist; you decide which of them run. So saving a new +version drops the functions that have gone from the file, adds the new ones switched on, +and leaves whatever you had set for the ones still there — an edit elsewhere in the file +must not quietly switch a function back on. + +**You do not tell firegex which protocol a filter speaks — the code does.** The library +already decides when to call a filter from what its parameters are annotated with, so +asking for an `HttpRequest` is what makes a file an HTTP filter, and asking only for a +`RawPacket` is what makes one run on anything. Firegex reads it back off the file when you +save it and shows it on the card. A file whose filters want two *different* application +protocols is refused when saved, naming both — a connection is only ever one of them, so +split them into two filters on the same service. + +A Python filter runs on UDP too, on either layer, as long as it asks for a `RawPacket`: +the stream and HTTP models need a connection underneath them and would never be built on +a datagram. Saving one that asks for them against a UDP service is refused rather than +accepted and never called. + +A filter reads metadata about the layers below it — addresses, ports, address family, +direction — and can change **only the payload**. That boundary is described in +[pyfilter.md](pyfilter.md), and it is what lets the same filter mean the same thing on +either network layer. + +## Testing a pattern + +Every regex filter card has a **try** button — and only a regex filter card, because +trying a pattern is a thing you do to a set of patterns, not to a service. It is a regex +tester inside firegex: patterns on the left, a sample below, matches highlighted. + +It runs **the same engine that will enforce the answer**. That is the entire point. A +tester built on Python's `re` or JavaScript's `RegExp` would accept backreferences and +lookarounds hyperscan rejects, and would disagree about what matches — so you would tune a +pattern against it and find out mid-round that it was never valid. Here, a pattern the +tester accepts is one you can save, and a pattern it rejects tells you why in hyperscan's +own words. + +One detail follows from being honest rather than approximate. A pattern is judged against +the mode it will actually run in — stream matching, which is how a pattern follows a +connection across chunk boundaries, and hyperscan does not accept quite the same patterns +in every mode — so the tester's verdict is the one the datapath will reach. And +occasionally a pattern is valid, will run, and still +cannot be highlighted here, because showing *where* a match starts needs the scan mode +that accepts slightly less. The tester says so instead of silently showing no matches. + +## What has been refused + +The service page answers three questions, because a single number answers none of them. + +**Which rule is doing the work.** Every filter, every pattern and every `@pyfilter` +function is listed with what it refused and **what share of the total is its doing**. A +chain where one pattern accounts for 96% of the blocking and the other nineteen for the +rest is a chain you would drop; you cannot see that from raw counts. + +**When it started.** A timeline, which a cumulative counter cannot give you: a filter +that blocked a thousand connections an hour ago looks exactly like one blocking them now. + +**How much of the traffic it is** — see below, where each layer reports what it can +honestly measure. + +### Choosing a window + +The last 15 minutes, hour, 6 hours, 24 hours, everything kept — or **two exact +instants**, for looking at something that has already happened: a round that ended, an +attack somebody described afterwards. + +**Everything on the page obeys the window.** The chart, the per-filter, per-pattern and +per-function totals, and the shares are all computed over the same range. A page where +the chart honours a fifteen-minute window and the table beside it quietly reports all of +time is a page that contradicts itself. The lifetime figure is reported once, beside the +range total, so a narrow window showing nothing is not mistaken for a service that has +never blocked anything. + +Widening the window widens the **bars**, not their number: twelve hours at one-minute +resolution is seven hundred bars in a few hundred pixels, which is a texture rather than +a chart. Blocks are stored once a minute and everything else is a sum of those, so no +range costs more than another. + +That is the **Auto** step, and you can override it. Pick a fixed one — a minute, five, +an hour — and it stays that whatever range you look at, which is what makes two windows +comparable by eye: five minutes of a quiet hour against five minutes of a busy one. A +step too fine for the range is widened rather than refused, and the header always names +the step actually drawn. + +History is kept for **48 hours**. Asking for more is clamped, and the panel says how far +back it actually goes. + +**No range starts before the service did.** A window is also floored at the first moment +the service could have refused anything — running, with a filter attached — so asking for +24 hours of a service that has been up for ten minutes gives you ten minutes, not +twenty-three hours of blank followed by a smudge. Those hours are not quiet hours; they +are hours in which the question was not being asked, and drawing them flat says the +opposite of what is true. The header states the range it actually drew, and why. + +The mark is set once and never moved. A service stopped for an hour and started again +has a real gap in the middle of its chart, which is worth seeing. A window that ended +*before* the service began is left exactly as you asked for it, and comes back empty — +which is the true answer, and a more useful one than being quietly moved to now. + +### Choosing a shape + +The same numbers, four ways, because they answer different questions: + +| | Good for | +|---|---| +| **Bars** | how much was refused in each step, stacked by filter | +| **Lines** | comparing filters against each other over time | +| **Area** | seeing where a burst begins and ends as a shape | +| **Share** | what proportion each filter contributed, step by step | + +Hovering anywhere over the plot marks the step under the cursor and reads it out: the +time it covers, what each filter refused in it, and the total. Reading a stacked bar by +eye tells you something was refused; it does not tell you which of two similar colours +was the larger, and it never tells you a number. + +### How much of the traffic + +Reported in the unit each layer can honestly produce, and never as a ratio between two +different units: + +- **Packets and bytes** that reached the service, counted by the **kernel** on the rules + that intercept it. Free, and nothing in userspace can skew it — the count comes from + netfilter, not from a filter reporting on itself. Reported on the layers whose rules + the kernel walks for every packet: NFQUEUE and the hand-off. +- **Connections seen and refused**, from the proxy engine. That layer works in + connections, which is the same unit a block is in, so the share of refused traffic + there is exact. + +Each layer reports what it can actually measure. NFQUEUE has no connection to count — it +works per packet, which is the whole reason it is cheap — so it leaves the connection +share empty rather than dividing refused connections by a packet count and calling the +result a percentage. The proxy layer has no honest packet count: its rule lives in a +`nat` chain, and conntrack translates a connection once, so every packet after the first +is handled without the rule being walked again. That counter is new connections wearing +a packet label, so it is not shown at all. + +The asymmetry is not a gap in the reporting; it is the difference between the two layers +showing through in what each of them can honestly say. + +Blocks are written out at most every few seconds and the history is pruned as it is +written. Both are deliberate: a service refusing thousands of connections a minute must +not be able to take the backend down through its own bookkeeping, which is exactly what +one row per block would do. + +## The live log + +Every service page carries a rolling log of what that service is actually doing, pushed +as it happens rather than polled: + +- **blocks**, naming what refused the connection — `connection refused by patterns: + /BLOCKME/`, or `refused by http: refuse_traversal()` for a Python filter, down to the + function rather than the file; not an opaque id you would have to go and look up + mid-round; +- **whatever your Python prints**, so `print()` while debugging a filter works the way + you expect; +- **what the datapath says about its own health** — a filter that lost its say after + missing its deadline, a fallback, a ruleset that was refused. These used to go to the + backend's stderr, where nobody looks during a competition. + +Warnings and errors do not wait for you to open the service: each one also pops up as a +notification wherever you are in the interface (one per service every twenty seconds, +the rest counted into it — except that an error always gets through a warning), and the service's row in the list carries a **WARNING** or +**ERROR** badge for an hour, with the line itself on hover. Clearing the service's log +dismisses it. + +It is bounded at both ends. A service under attack refuses thousands of connections a +minute; the tail keeps the last few hundred lines and the rest fall off, and lines are +coalesced before being sent so a burst arrives as one message instead of thousands. +Nothing about logging can turn into an outage. + +## Trying a ruleset without an instance + +The tester above needs a running firegex. The `firegex` pip package carries the same +matching, so a ruleset can be tried anywhere — on the laptop you are writing it on, +before the competition starts: + +```bash +pip install -U firegex + +fgex regex check rules.json # does it compile? what is in it? +fgex regex test rules.json --sample req.txt # what would it do to this? +fgex regex proxy rules.json 10.60.3.1 8080 # a local proxy applying it +``` + +The ruleset file is **the same shape firegex uses**, so one file works in both places: + +```json +[ + {"id": "traversal", "pattern": "\\.\\./", "direction": "c2s"}, + {"id": "flag-leak", "pattern": "FLAG\\{[a-z0-9]+\\}", "direction": "s2c", + "case_sensitive": false} +] +``` + +It binds the same hyperscan rather than approximating it with Python's `re`, so a +pattern it accepts is one firegex accepts, and one it rejects reports hyperscan's own +message. Where hyperscan is not installed it says so and stops, rather than quietly +answering with a different engine. + +One honest limit. `fgex regex proxy` matches **per chunk**, while a rule on a real +service matches across the whole stream — so a pattern split over two reads is caught in +production and not in the simulator, never the other way round. A ruleset that looks +clean here may still block something on a live service; one that blocks here always +would. + +## Editing while it runs + +Adding, removing, reordering, enabling and disabling filters, patterns and individual +`@pyfilter` functions all take effect on live connections **without dropping any of +them**. Both layers swap their configuration underneath the traffic. The exception is +adding, removing or reordering a whole filter on the NFQUEUE layer, where each filter +is a process of its own: there the chain of processes is rebuilt, traffic passes +unfiltered for the moment that takes, and the new processes take over the connections +already open from their next packet. + +An edit reaches only the filter it was made to. A Python filter keeps what it knows about +each open connection — its module globals, and how far into a request it has read — +through edits to the other filters; its own new code starts each connection afresh. A +filter that takes over a connection in the middle of a message (a request half uploaded) +does not judge the half it sees: HTTP filters leave that direction alone until the +connection closes, rather than refusing a valid request or calling it malformed. + +Adding an address does not drop anything either, and removing or moving one costs only +the connections on that address. + +Changing the service's own definition — its protocol, network layer, TLS or limits — +changes what the datapath *is*, so the service is restarted around it. The connections it +was carrying are not cut: new ones get the new settings, and the ones already open go on. + +Stopping a service does not cut them either. On the NFQUEUE layer the kernel simply stops +asking the filters. The proxy layer terminates the connections it carries, so its engine +is not killed: it is taken out of the rules at once, and new connections go straight to +the service. It carries the ones it already had, unfiltered, until they close, and then +exits. After a restart the old engine does the same, and keeps filtering those connections +the way it did before. Either way it gives up after ten minutes, closing whatever is still +open, and the service's log says so. Only shutting firegex itself down cuts them +straight away. + +## See also + +- [Firewall rules](firewall.md) — plain nftables rules, unrelated to services. +- [Writing a Python filter](pyfilter.md) — the `@pyfilter` API and its models. diff --git a/docs/tls.md b/docs/tls.md deleted file mode 100644 index fe2b268..0000000 --- a/docs/tls.md +++ /dev/null @@ -1,27 +0,0 @@ -# 🔒 TLS Decryption - -TLS Decryption is a decrypt-and-reinspect bridge for services that speak TLS natively. It exists because [Netfilter Regex](nfregex.md)/[Netfilter Proxy](nfproxy.md) can only inspect plaintext: if the real service terminates TLS itself, those modules would only ever see encrypted bytes. A TLS Decrypt stream terminates the public TLS connection, hands the plaintext to a local port where a filter service can attach, then re-encrypts before forwarding to the real backend. - -This is **not** a generic "put TLS in front of a plaintext service" reverse proxy — the real backend is expected to also speak TLS natively (mirroring, for example, a CTF challenge service that does its own TLS). - -## How to use it - -1. Create a stream: the public `ip:port` clients connect to, and the certificate/key pair to present to them. -2. Start the stream. -3. Create an nfproxy or nfregex service with target type "TLS Decrypt", pointing at this stream, and start it. The filter now sees decrypted traffic. -4. You can edit a stream's certificate, IP or port at any time, even while services are attached to it — dependent services are automatically rebound to the new values, no manual reconfiguration needed. - -### Start/stop cascade - -Starting a filter service attached to a stopped TLS stream automatically starts the stream first. Stopping a TLS stream automatically stops every filter service attached to it (since there would be nothing left for them to inspect). A stream that still has a service attached to it can't be deleted — stop and detach (or delete) the dependent services first. - -## How it works - -Two loopback ports are involved, deterministically derived from a hash of the stream's public `ip:port` (so they don't need to be manually assigned or tracked): - -1. `ssl_port` (loopback): [nginx](https://nginx.org/) terminates the public TLS connection here (TLS 1.2/1.3 only), using the stream's configured certificate/key, and forwards the **decrypted** traffic to `clear_port`. -2. `clear_port` (loopback): nginx re-encrypts the traffic (without verifying the real backend's certificate, since CTF services are commonly self-signed) and forwards it to the real `ip:port`. - -The only unencrypted hop is the loopback leg between `ssl_port` and `clear_port` — this is exactly where an nfproxy/nfregex service attaches to see decrypted content. Traffic never touches the network unencrypted: both the public-facing leg and the leg to the real backend are TLS. - -nftables DNATs the public `ip:port` to `ssl_port` so clients don't need to know about any of this — they just connect to the same address as always. diff --git a/fgex-lib/MANIFEST.in b/fgex-lib/MANIFEST.in index 540b720..23f4f83 100644 --- a/fgex-lib/MANIFEST.in +++ b/fgex-lib/MANIFEST.in @@ -1 +1,4 @@ -include requirements.txt \ No newline at end of file +recursive-include llhttp *.c *.h +include llhttp/PROVENANCE.md +include llhttp/LICENSE.pyllhttp +include llhttp/lib/LICENSE.llhttp diff --git a/fgex-lib/README.md b/fgex-lib/README.md index bbf9fcf..8a01ce3 100644 --- a/fgex-lib/README.md +++ b/fgex-lib/README.md @@ -1,6 +1,6 @@ # Firegex Python Library and CLI -This is the Python library for [Firegex](https://github.com/Pwnzer0tt1/firegex), a firewall built for CTF Attack-Defense competitions. It provides the `firegex.nfproxy` package used to write [Netfilter Proxy](../docs/nfproxy.md) Python packet filters, the `fgex` CLI, and a local proxy simulator (`proxysim`) for testing filters without a running Firegex instance. +This is the Python library for [Firegex](https://github.com/Pwnzer0tt1/firegex), a firewall built for CTF Attack-Defense competitions. It provides `firegex.pyfilters` — the package you write [Python filters](../docs/pyfilter.md) against — `firegex.regex` for pattern rulesets, the `fgex` CLI, and local simulators for both, so a filter can be tried before it ever reaches a running Firegex. ## Installation @@ -12,15 +12,38 @@ pip install -U firegex ## Documentation -**➡️ [Netfilter Proxy: writing and testing `@pyfilter` scripts](../docs/nfproxy.md)** — the `pyfilter` decorator, every data structure (`RawPacket`, `TCPInputStream`/`TCPOutputStream`, `HttpRequest`/`HttpResponse` and their variants, `HttpHistory`), packet statements, stream limits, the `fgex nfproxy` CLI simulator, and a full worked example. +It carries `firegex.regex`, which binds the same hyperscan firegex matches with, so +a **pattern ruleset** can be checked and tried without a running instance: -That page is also what's shown in the Firegex web UI itself (the docs button on the nfproxy pages) — both are generated from that single file, so they can never drift out of sync. +```bash +fgex regex check rules.json # does it compile? what is in it? +fgex regex test rules.json --sample req.txt # what would it do to this? +fgex regex proxy rules.json 10.60.3.1 8080 # a local proxy applying it +``` -## Firegex's other filtering modules +The ruleset file is the same shape firegex uses. Matching needs `libhs` (vectorscan) +installed; without it the tools say so and stop rather than answering with a different +engine, because a tester that disagrees with production is worse than none. -This library only covers Netfilter Proxy. Firegex ships several other independent modules, each with its own guide: +**➡️ [Writing and testing `@pyfilter` scripts](../docs/pyfilter.md)** — the `pyfilter` decorator, every data structure (`RawPacket`, `TCPInputStream`/`TCPOutputStream`, `HttpRequest`/`HttpResponse` and their variants, `HttpHistory`), packet statements, stream limits, the `fgex pyfilters` CLI simulator, and a full worked example. -- **[Netfilter Regex](../docs/nfregex.md)** — fast, PCRE2/hyperscan-based regex matching against raw traffic. -- **[Hijack Port to Proxy](../docs/porthijack.md)** — redirect traffic to your own external proxy without touching the target service. +That page is also what's shown in the Firegex web UI itself (the docs button on the service pages) — both are generated from that single file, so they can never drift out of sync. + +## The rest of Firegex + +This package is the part you write filters *with*. What runs them is a Firegex instance, +started from a clone with `python3 run.py` — there is deliberately no deploy command here, +because any second description of that script's arguments would drift from it. + +Two guides cover everything an instance does: + +- **[Services](../docs/services.md)** — a service is a **network layer** (NFQUEUE, the + terminating proxy, or a hand-off to a proxy you wrote) plus an ordered chain of + **filters** (hyperscan patterns, or the Python you write with this library). TLS, QUIC + and every version of HTTP are protocols a service can speak rather than separate things + to set up. - **[Firewall Rules](../docs/firewall.md)** — plain nftables allow/drop/reject rules. -- **[TLS Decryption](../docs/tls.md)** — decrypt-and-reinspect bridge for services that speak TLS natively. + +The modules this library used to list — `nfregex`, `nfproxy`, `porthijack` and `tls` — +were folded into services as choices rather than modules in 5.0.0, which is also when +`firegex.nfproxy` became `firegex.pyfilters`. diff --git a/fgex-lib/firegex/cli.py b/fgex-lib/firegex/cli.py index 99b1207..824807b 100644 --- a/fgex-lib/firegex/cli.py +++ b/fgex-lib/firegex/cli.py @@ -1,15 +1,18 @@ #!/usr/bin/env python3 +import json +import os +import socket +import sys + import typer from rich import print from rich.markup import escape from typer import Exit + from firegex import __version__ -from firegex.nfproxy.proxysim import run_proxy_simulation -from firegex.nfproxy.models import Protocols -import os -import socket +from firegex.pyfilters.proxysim import run_proxy_simulation app = typer.Typer( no_args_is_help=True, @@ -34,13 +37,12 @@ def test_connection(host, port, use_ipv6=False): finally: sock.close() -@app.command(help="Run an nfproxy simulation") -def nfproxy( +@app.command("pyfilters", help="Run your Python filters against a real service, locally") +def pyfilters( filter_file: str = typer.Argument(..., help="The path to the filter file"), address: str = typer.Argument(..., help="The address of the target to proxy"), port: int = typer.Argument(..., help="The port of the target to proxy"), - proto: Protocols = typer.Option(Protocols.TCP.value, help="The protocol to proxy"), from_address: str = typer.Option(None, help="The address of the local server"), from_port: int = typer.Option(7474, help="The port of the local server"), ipv6: bool = typer.Option(False, "-6", help="Use IPv6 for the connection"), @@ -53,7 +55,148 @@ def nfproxy( if not test_connection(address, port, ipv6): print(f"[bold red]Can't connect to {escape(address)}:{port}[/]") close_cli() - run_proxy_simulation(filter_file, proto.value, address, port, from_address, from_port, ipv6) + # No protocol to pass: the filter file says which one it speaks by what it asks for. + run_proxy_simulation(filter_file, address, port, from_address, from_port, ipv6) + +regex_app = typer.Typer( + no_args_is_help=True, + help="Try a regex ruleset with the engine firegex matches with", +) +app.add_typer(regex_app, name="regex") + + +def _load_ruleset(path: str): + """Read a ruleset, with the errors an operator can act on. + + The file is the same shape firegex itself uses, so a ruleset can be written here and + pasted there, or exported from there and replayed here. + """ + from firegex.regex import HyperscanMissing, Rule, available, validate + + if not available(): + print(f"[bold red]{escape(str(HyperscanMissing()))}[/]") + close_cli() + if not os.path.isfile(path): + print(f"[bold red]'{escape(os.path.abspath(path))}' not found[/]") + close_cli() + try: + with open(path) as f: + raw = json.load(f) + except json.JSONDecodeError as e: + print(f"[bold red]{escape(path)} is not valid JSON: {escape(str(e))}[/]") + close_cli() + if not isinstance(raw, list): + print("[bold red]A ruleset is a list of rules[/]") + close_cli() + + rules = [] + broken = False + for index, item in enumerate(raw): + try: + rule = Rule.from_dict(item) + except (KeyError, ValueError) as e: + print(f"[bold red]rule {index} is malformed: {escape(str(e))}[/]") + broken = True + continue + # Judged against the mode it would actually run in, so what is accepted here is + # what firegex will accept. + why = validate(rule) + if why: + print(f"[bold red]{escape(rule.id)}: {escape(why)}[/]") + broken = True + continue + rules.append(rule) + if broken: + close_cli() + if not rules: + print("[bold red]The ruleset is empty[/]") + close_cli() + return rules + + +@regex_app.command("check", help="Check that a ruleset compiles, and say what is in it") +def regex_check( + rules_file: str = typer.Argument(..., help="The path to the ruleset (JSON)"), +): + rules = _load_ruleset(rules_file) + print(f"[bold green]{len(rules)} rule(s), all valid[/]") + for rule in rules: + case = "" if rule.case_sensitive else ", any case" + print(f" [bold]{escape(rule.id)}[/] /{escape(rule.pattern)}/ " + f"[dim]{rule.direction.value}{case}, block[/]") + + +@regex_app.command("test", help="Run a ruleset over a sample and show what it would do") +def regex_test( + rules_file: str = typer.Argument(..., help="The path to the ruleset (JSON)"), + sample_file: str = typer.Option(None, "--sample", "-s", + help="File to match against; stdin when omitted"), + from_service: bool = typer.Option(False, "--from-service", + help="Treat the sample as traffic from the service"), +): + from firegex.regex import Ruleset + + rules = _load_ruleset(rules_file) + if sample_file: + if not os.path.isfile(sample_file): + print(f"[bold red]'{escape(os.path.abspath(sample_file))}' not found[/]") + close_cli() + with open(sample_file, "rb") as f: + sample = f.read() + else: + sample = sys.stdin.buffer.read() + if not sample: + print("[bold red]Nothing to match against[/]") + close_cli() + + ruleset = Ruleset(rules) + is_input = not from_service + blocked_by = ruleset.apply(sample, is_input) + + # Said before the verdict, because it changes what the verdict means: "passed + # through" from a run that could not try every rule is not the same answer. + for rule_id, why in ruleset.unscannable.items(): + print(f"[bold yellow]not tried here[/] [bold]{escape(rule_id)}[/]: {escape(why)}. " + f"It is valid and will run on a real service — only matching it against a " + f"sample needs a mode hyperscan does not offer for it.") + + if blocked_by is not None: + print(f"[bold red]blocked[/] by [bold]{escape(blocked_by)}[/]") + close_cli(0) + print("[bold green]passed through[/]" + + (" by the rules that could be tried" if ruleset.unscannable else "")) + + +@regex_app.command("proxy", help="Run a local proxy applying a ruleset, like `fgex pyfilters`") +def regex_proxy( + rules_file: str = typer.Argument(..., help="The path to the ruleset (JSON)"), + address: str = typer.Argument(..., help="The address of the target to proxy"), + port: int = typer.Argument(..., help="The port of the target to proxy"), + from_address: str = typer.Option(None, help="The address of the local server"), + from_port: int = typer.Option(7474, help="The port of the local server"), + ipv6: bool = typer.Option(False, "-6", help="Use IPv6 for the connection"), +): + from firegex.regex import Ruleset + from firegex.regex.sim import run_regex_simulation + + rules = _load_ruleset(rules_file) + if from_address is None: + from_address = "::1" if ipv6 else "127.0.0.1" + if not test_connection(address, port, ipv6): + print(f"[bold red]Can't connect to {escape(address)}:{port}[/]") + close_cli() + print("[dim]Blocking matches per chunk here, but across the whole stream on a real " + "service: a pattern split over two reads is caught there and not here.[/]") + ruleset = Ruleset(rules) + # Built before the first connection rather than on it, so the warning is on screen + # while the operator is still reading the banner instead of appearing mid-traffic. + for is_input in (True, False): + ruleset._db(is_input) + for rule_id, why in ruleset.unscannable.items(): + print(f"[bold yellow]not applied here[/] [bold]{escape(rule_id)}[/]: {escape(why)}. " + f"It is valid and will run on a real service.") + run_regex_simulation(ruleset, address, port, from_address, from_port, ipv6) + def version_callback(verison: bool): if verison: diff --git a/fgex-lib/firegex/nfproxy/__init__.py b/fgex-lib/firegex/nfproxy/__init__.py deleted file mode 100644 index 0c48a20..0000000 --- a/fgex-lib/firegex/nfproxy/__init__.py +++ /dev/null @@ -1,50 +0,0 @@ -import functools -from firegex.nfproxy.models import ( - RawPacket, - TCPInputStream, - TCPOutputStream, - TCPClientStream, - TCPServerStream, - HttpHistory, - HttpStreamHistory, -) -from firegex.nfproxy.internals.models import Action, FullStreamAction, ExceptionAction - -ACCEPT = Action.ACCEPT -DROP = Action.DROP -REJECT = Action.REJECT -UNSTABLE_MANGLE = Action.MANGLE - -def pyfilter(func): - """ - Decorator to mark functions that will be used in the proxy. - Stores the function reference in a global registry. - """ - if not hasattr(pyfilter, "registry"): - pyfilter.registry = set() - - pyfilter.registry.add(func.__name__) - - @functools.wraps(func) - def wrapper(*args, **kwargs): - return func(*args, **kwargs) - - return wrapper - -def get_pyfilters(): - """Returns the list of functions marked with @pyfilter.""" - if hasattr(pyfilter, "registry"): - return list(pyfilter.registry) - return [] - -def clear_pyfilter_registry(): - """Clears the pyfilter registry.""" - if hasattr(pyfilter, "registry"): - pyfilter.registry.clear() - -__all__ = [ - "ACCEPT", "DROP", "REJECT", "UNSTABLE_MANGLE", - "Action", "FullStreamAction", "ExceptionAction", "pyfilter", - "RawPacket", "TCPInputStream", "TCPOutputStream", "TCPClientStream", "TCPServerStream", - "HttpHistory", "HttpStreamHistory" -] \ No newline at end of file diff --git a/fgex-lib/firegex/nfproxy/internals/__init__.py b/fgex-lib/firegex/nfproxy/internals/__init__.py deleted file mode 100644 index fe2de9e..0000000 --- a/fgex-lib/firegex/nfproxy/internals/__init__.py +++ /dev/null @@ -1,173 +0,0 @@ -from inspect import signature -from firegex.nfproxy.internals.models import Action, FullStreamAction -from firegex.nfproxy.internals.models import FilterHandler, PacketHandlerResult -import functools -from firegex.nfproxy.internals.data import DataStreamCtx -from firegex.nfproxy.internals.exceptions import NotReadyToRun, StreamFullReject, DropPacket, RejectConnection, StreamFullDrop -from firegex.nfproxy.internals.data import RawPacket - -def context_call(glob, func, *args, **kargs): - glob["__firegex_tmp_args"] = args - glob["__firegex_tmp_kargs"] = kargs - glob["__firege_tmp_call"] = func - res = eval("__firege_tmp_call(*__firegex_tmp_args, **__firegex_tmp_kargs)", glob, glob) - if "__firegex_tmp_args" in glob.keys(): - del glob["__firegex_tmp_args"] - if "__firegex_tmp_kargs" in glob.keys(): - del glob["__firegex_tmp_kargs"] - if "__firege_tmp_call" in glob.keys(): - del glob["__firege_tmp_call"] - return res - -def generate_filter_structure(filters: list[str], proto:str, glob:dict) -> list[FilterHandler]: - from firegex.nfproxy.models import type_annotations_associations - if proto not in type_annotations_associations.keys(): - raise Exception("Invalid protocol") - res = [] - valid_annotation_type = type_annotations_associations[proto] - def add_func_to_list(func): - if not callable(func): - raise Exception(f"{func} is not a function") - sig = signature(func) - params_function = {} - - for k, v in sig.parameters.items(): - if v.annotation in valid_annotation_type.keys(): - params_function[v.annotation] = valid_annotation_type[v.annotation] - else: - raise Exception(f"Invalid type annotation {v.annotation} for function {func.__name__}") - - res.append( - FilterHandler( - func=func, - name=func.__name__, - params=params_function, - proto=proto - ) - ) - - for filter in filters: - if not isinstance(filter, str): - raise Exception("Invalid filter list: must be a list of strings") - if filter in glob.keys(): - add_func_to_list(glob[filter]) - else: - raise Exception(f"Filter {filter} not found") - return res - -def get_filters_info(code:str, proto:str) -> list[FilterHandler]: - glob = {} - exec("import firegex.nfproxy", glob, glob) - exec("firegex.nfproxy.clear_pyfilter_registry()", glob, glob) - exec(code, glob, glob) - filters = eval("firegex.nfproxy.get_pyfilters()", glob, glob) - try: - return generate_filter_structure(filters, proto, glob) - finally: - exec("firegex.nfproxy.clear_pyfilter_registry()", glob, glob) - - -def get_filter_names(code:str, proto:str) -> list[str]: - return [ele.name for ele in get_filters_info(code, proto)] - -def handle_packet(glob: dict) -> None: - internal_data = DataStreamCtx(glob) - - cache_call = {} # Cache of the data handler calls - cache_call[RawPacket] = internal_data.current_pkt - - result = PacketHandlerResult(glob) - - for filter in internal_data.filter_call_info: - final_params = [] - skip_call = False - for data_type, data_func in filter.params.items(): - if data_type not in cache_call.keys(): - try: - cache_call[data_type] = data_func(internal_data) - except NotReadyToRun: - cache_call[data_type] = None - skip_call = True - break - except StreamFullDrop: - result.action = Action.DROP - result.matched_by = "@MAX_STREAM_SIZE_REACHED" - return result.set_result() - except StreamFullReject: - result.action = Action.REJECT - result.matched_by = "@MAX_STREAM_SIZE_REACHED" - return result.set_result() - except DropPacket: - result.action = Action.DROP - result.matched_by = filter.name - return result.set_result() - except RejectConnection: - result.action = Action.REJECT - result.matched_by = filter.name - return result.set_result() - if cache_call[data_type] is None: - skip_call = True - break - final_params.append(cache_call[data_type]) - - if skip_call: - continue - - # Create an iterator with all the calls to be done - def try_to_call(params:list): - is_base_call = True - for i in range(len(params)): - if isinstance(params[i], list): - new_params = params.copy() - for ele in params[i]: - new_params[i] = ele - yield from try_to_call(new_params) - is_base_call = False - break - if is_base_call: - yield context_call(glob, filter.func, *params) - - for res in try_to_call(final_params): - if res is None: - continue #ACCEPTED - if not isinstance(res, Action): - raise Exception(f"Invalid return type {type(res)} for function {filter.name}") - if res == Action.MANGLE: - result.matched_by = filter.name - result.mangled_packet = internal_data.current_pkt.raw_packet - result.action = Action.MANGLE - elif res != Action.ACCEPT: - result.matched_by = filter.name - result.action = res - result.mangled_packet = None - return result.set_result() - - return result.set_result() # Will be MANGLE or ACCEPT - - -def compile(glob:dict) -> None: - internal_data = DataStreamCtx(glob, init_pkt=False) - - glob["print"] = functools.partial(print, flush = True) - - filters = glob["__firegex_pyfilter_enabled"] - proto = glob["__firegex_proto"] - - internal_data.filter_call_info = generate_filter_structure(filters, proto, glob) - - if "FGEX_STREAM_MAX_SIZE" in glob and int(glob["FGEX_STREAM_MAX_SIZE"]) > 0: - internal_data.stream_max_size = int(glob["FGEX_STREAM_MAX_SIZE"]) - - if "FGEX_FULL_STREAM_ACTION" in glob and isinstance(glob["FGEX_FULL_STREAM_ACTION"], FullStreamAction): - internal_data.full_stream_action = glob["FGEX_FULL_STREAM_ACTION"] - - if "FGEX_INVALID_ENCODING_ACTION" in glob and isinstance(glob["FGEX_INVALID_ENCODING_ACTION"], Action): - internal_data.invalid_encoding_action = glob["FGEX_INVALID_ENCODING_ACTION"] - - PacketHandlerResult(glob).reset_result() - - def fake_exit(*_a, **_k): - print("WARNING: This function should not be called", flush=True) - - glob["exit"] = fake_exit - diff --git a/fgex-lib/firegex/nfproxy/internals/data.py b/fgex-lib/firegex/nfproxy/internals/data.py deleted file mode 100644 index edb5746..0000000 --- a/fgex-lib/firegex/nfproxy/internals/data.py +++ /dev/null @@ -1,166 +0,0 @@ -from firegex.nfproxy.internals.models import FilterHandler -from firegex.nfproxy.internals.models import FullStreamAction, ExceptionAction - -class RawPacket: - "class rapresentation of the nfqueue packet sent in python context by the c++ core" - - def __init__(self, - data: bytes, - raw_packet: bytes, - is_input: bool, - is_ipv6: bool, - is_tcp: bool, - l4_size: int, - ): - self.__data = bytes(data) - self.__raw_packet = bytes(raw_packet) - self.__is_input = bool(is_input) - self.__is_ipv6 = bool(is_ipv6) - self.__is_tcp = bool(is_tcp) - self.__l4_size = int(l4_size) - self.__raw_packet_header_size = len(self.__raw_packet)-self.__l4_size - - @property - def is_input(self) -> bool: - "It's true if the packet is an input packet, false if it's an output packet" - return self.__is_input - - @property - def is_ipv6(self) -> bool: - "It's true if the packet is an ipv6 packet, false if it's an ipv4 packet" - return self.__is_ipv6 - - @property - def is_tcp(self) -> bool: - "It's true if the packet is a tcp packet, false if it's an udp packet" - return self.__is_tcp - - @property - def data(self) -> bytes: - "The data of the packet assembled and sorted from TCP" - return self.__data - - @property - def l4_size(self) -> int: - "The size of the layer 4 data" - return self.__l4_size - - @property - def raw_packet_header_len(self) -> int: - "The size of the original packet header" - return self.__raw_packet_header_size - - @property - def l4_data(self) -> bytes: - "The layer 4 payload of the packet" - return self.__raw_packet[self.raw_packet_header_len:] - - @l4_data.setter - def l4_data(self, v:bytes): - if not isinstance(v, bytes): - raise Exception("Invalid data type, data MUST be of type bytes") - #if len(v) != self.__l4_size: - # raise Exception("Invalid data size, must be equal to the original packet header size (due to a technical limitation)") - self.raw_packet = self.__raw_packet[:self.raw_packet_header_len]+v - - @property - def raw_packet(self) -> bytes: - "The raw packet with IP and TCP headers" - return self.__raw_packet - - @raw_packet.setter - def raw_packet(self, v:bytes): - if not isinstance(v, bytes): - raise Exception("Invalid data type, data MUST be of type bytes") - if len(v) > 2**16: - raise Exception("Invalid data size, must be less than 2^16 bytes") - #if len(v) != len(self.__raw_packet): - # raise Exception("Invalid data size, must be equal to the original packet size (due to a technical limitation)") - if len(v) < self.raw_packet_header_len: - raise Exception("Invalid data size, must be greater than the original packet header size") - self.__raw_packet = v - self.__l4_size = len(v)-self.raw_packet_header_len - - @classmethod - def _fetch_packet(cls, internal_data:"DataStreamCtx"): - if not isinstance(internal_data, DataStreamCtx): - if isinstance(internal_data, dict): - internal_data = DataStreamCtx(internal_data) - else: - raise Exception("Invalid data type, data MUST be of type DataStream, or glob dict") - - if "__firegex_packet_info" not in internal_data.filter_glob.keys(): - raise Exception("Packet info not found") - return cls(**internal_data.filter_glob["__firegex_packet_info"]) - - def __repr__(self): - return f"RawPacket(data={self.data}, raw_packet={self.raw_packet}, is_input={self.is_input}, is_ipv6={self.is_ipv6}, is_tcp={self.is_tcp}, l4_size={self.l4_size})" - - -class DataStreamCtx: - "class to store the context of the data handler" - - def __init__(self, glob: dict, init_pkt: bool = True): - if "__firegex_pyfilter_ctx" not in glob.keys(): - glob["__firegex_pyfilter_ctx"] = {} - self.__data = glob["__firegex_pyfilter_ctx"] - self.filter_glob = glob - self.current_pkt = RawPacket._fetch_packet(self) if init_pkt else None - self.call_mem = {} #A memory space valid only for the current packet handler - - @property - def filter_call_info(self) -> list[FilterHandler]: - if "filter_call_info" not in self.__data.keys(): - self.__data["filter_call_info"] = [] - return self.__data.get("filter_call_info") - - @filter_call_info.setter - def filter_call_info(self, v: list[FilterHandler]): - self.__data["filter_call_info"] = v - - @property - def stream_max_size(self) -> int: - if "stream_max_size" not in self.__data.keys(): - self.__data["stream_max_size"] = 1*8e20 # 1MB default value - return self.__data.get("stream_max_size") - - @stream_max_size.setter - def stream_max_size(self, v: int): - if not isinstance(v, int): - raise Exception("Invalid data type, data MUST be of type int") - self.__data["stream_max_size"] = v - - @property - def full_stream_action(self) -> FullStreamAction: - if "full_stream_action" not in self.__data.keys(): - self.__data["full_stream_action"] = FullStreamAction.FLUSH - return self.__data.get("full_stream_action") - - @full_stream_action.setter - def full_stream_action(self, v: FullStreamAction): - if not isinstance(v, FullStreamAction): - raise Exception("Invalid data type, data MUST be of type FullStreamAction") - self.__data["full_stream_action"] = v - - @property - def invalid_encoding_action(self) -> ExceptionAction: - if "invalid_encoding_action" not in self.__data.keys(): - self.__data["invalid_encoding_action"] = ExceptionAction.REJECT - return self.__data.get("invalid_encoding_action") - - @invalid_encoding_action.setter - def invalid_encoding_action(self, v: ExceptionAction): - if not isinstance(v, ExceptionAction): - raise Exception("Invalid data type, data MUST be of type ExceptionAction") - self.__data["invalid_encoding_action"] = v - - @property - def data_handler_context(self) -> dict: - if "data_handler_context" not in self.__data.keys(): - self.__data["data_handler_context"] = {} - return self.__data.get("data_handler_context") - - @data_handler_context.setter - def data_handler_context(self, v: dict): - self.__data["data_handler_context"] = v - diff --git a/fgex-lib/firegex/nfproxy/models/http.py b/fgex-lib/firegex/nfproxy/models/http.py deleted file mode 100644 index 7967ef8..0000000 --- a/fgex-lib/firegex/nfproxy/models/http.py +++ /dev/null @@ -1,860 +0,0 @@ -import pyllhttp -from firegex.nfproxy.internals.exceptions import NotReadyToRun -from firegex.nfproxy.internals.data import DataStreamCtx -from firegex.nfproxy.internals.exceptions import ( - StreamFullDrop, - StreamFullReject, - RejectConnection, - DropPacket, -) -from firegex.nfproxy.internals.models import FullStreamAction, ExceptionAction -from dataclasses import dataclass, field -from collections import deque -from compression import zstd -import gzip -import io -import zlib -import brotli -import traceback -from websockets.frames import Frame -from websockets.extensions.permessage_deflate import PerMessageDeflate -from pyllhttp import PAUSED_H2_UPGRADE, PAUSED_UPGRADE - - -@dataclass -class InternalHTTPMessage: - """Internal class to handle HTTP messages""" - - url: str | None = field(default=None) - headers: dict[str, str] = field(default_factory=dict) - lheaders: dict[str, str] = field( - default_factory=dict - ) # lowercase copy of the headers - body: bytes | None = field(default=None) - body_decoded: bool = field(default=False) - headers_complete: bool = field(default=False) - message_complete: bool = field(default=False) - status: str | None = field(default=None) - total_size: int = field(default=0) - user_agent: str = field(default_factory=str) - content_encoding: str = field(default=str) - content_type: str = field(default=str) - keep_alive: bool = field(default=False) - should_upgrade: bool = field(default=False) - http_version: str = field(default=str) - method: str = field(default=str) - content_length: int = field(default=0) - stream: bytes = field(default_factory=bytes) - ws_stream: list[Frame] = field(default_factory=list) # Decoded websocket stream - upgrading_to_h2: bool = field(default=False) - upgrading_to_ws: bool = field(default=False) - added_to_history: bool = field(default=False) - - -@dataclass -class InternalHttpBuffer: - """Internal class to handle HTTP messages""" - - _url_buffer: bytes = field(default_factory=bytes) - _raw_header_fields: dict[str, str | list[str]] = field(default_factory=dict) - _header_fields: dict[str, str] = field(default_factory=dict) - _body_buffer: bytes = field(default_factory=bytes) - _status_buffer: bytes = field(default_factory=bytes) - _current_header_field: bytes = field(default_factory=bytes) - _current_header_value: bytes = field(default_factory=bytes) - _ws_packet_stream: bytes = field(default_factory=bytes) - - -class InternalCallbackHandler: - buffers = InternalHttpBuffer() - msg = InternalHTTPMessage() - save_body = True - raised_error = False - has_begun = False - messages: deque[InternalHTTPMessage] = deque() - _ws_extentions = None - _ws_raised_error = False - release_message_headers = True - - def reset_data(self): - self.msg = InternalHTTPMessage() - self.buffers = InternalHttpBuffer() - self.messages.clear() - - def on_message_begin(self): - self.buffers = InternalHttpBuffer() - self.msg = InternalHTTPMessage() - self.has_begun = True - - def on_url(self, url): - self.buffers._url_buffer += url - self.msg.total_size += len(url) - - def on_url_complete(self): - self.msg.url = self.buffers._url_buffer.decode(errors="ignore") - self.buffers._url_buffer = b"" - - def on_status(self, status: bytes): - self.msg.total_size += len(status) - self.buffers._status_buffer += status - - def on_status_complete(self): - self.msg.status = self.buffers._status_buffer.decode(errors="ignore") - self.buffers._status_buffer = b"" - - def on_header_field(self, field): - self.msg.total_size += len(field) - self.buffers._current_header_field += field - - def on_header_field_complete(self): - pass # Nothing to do - - def on_header_value(self, value): - self.msg.total_size += len(value) - self.buffers._current_header_value += value - - def on_header_value_complete(self): - if self.buffers._current_header_field: - k, v = ( - self.buffers._current_header_field.decode(errors="ignore"), - self.buffers._current_header_value.decode(errors="ignore"), - ) - old_value = self.buffers._raw_header_fields.get(k, None) - - # raw headers are stored as thay were, considering to check changes between headers encoding - if isinstance(old_value, list): - old_value.append(v) - elif isinstance(old_value, str): - self.buffers._raw_header_fields[k] = [old_value, v] - else: - self.buffers._raw_header_fields[k] = v - - # Decoding headers normally - kl = k.lower() - if kl in self.buffers._header_fields: - self.buffers._header_fields[kl] += ( - f", {v}" # Should be considered as a single list separated by commas as said in the RFC - ) - else: - self.buffers._header_fields[kl] = v - - self.buffers._current_header_field = b"" - self.buffers._current_header_value = b"" - - def on_headers_complete(self): - self.msg.headers = self.buffers._raw_header_fields - self.msg.lheaders = self.buffers._header_fields - self.buffers._raw_header_fields = {} - self.buffers._current_header_field = b"" - self.buffers._current_header_value = b"" - self.msg.headers_complete = True - self.msg.method = self.method_parsed - self.msg.content_length = self.content_length_parsed - self.msg.should_upgrade = self.should_upgrade - self.msg.keep_alive = self.keep_alive - self.msg.http_version = self.http_version - self.msg.content_type = self.content_type - self.msg.content_encoding = self.content_encoding - self.msg.user_agent = self.user_agent - - def on_body(self, body: bytes): - if self.save_body: - self.msg.total_size += len(body) - self.buffers._body_buffer += body - - def on_message_complete(self): - self.msg.body = self.buffers._body_buffer - self.msg.should_upgrade = self.should_upgrade - self.buffers._body_buffer = b"" - encodings = [ele.strip() for ele in self.content_encoding.lower().split(",")] - decode_success = True - decoding_body = self.msg.body - for enc in reversed(encodings): - if not enc: - continue - if enc == "deflate": - try: - decompress = zlib.decompressobj(-zlib.MAX_WBITS) - decoding_body = decompress.decompress(decoding_body) - decoding_body += decompress.flush() - except Exception as e: - print(f"Error decompressing deflate: {e}: skipping", flush=True) - decode_success = False - break - elif enc == "br": - try: - decoding_body = brotli.decompress(decoding_body) - except Exception as e: - print(f"Error decompressing brotli: {e}: skipping", flush=True) - decode_success = False - break - elif ( - enc == "gzip" or enc == "x-gzip" - ): # https://datatracker.ietf.org/doc/html/rfc2616#section-3.5 - try: - if "gzip" in self.content_encoding.lower(): - with gzip.GzipFile(fileobj=io.BytesIO(decoding_body)) as f: - decoding_body = f.read() - except Exception as e: - print(f"Error decompressing gzip: {e}: skipping", flush=True) - decode_success = False - break - elif enc == "zstd": - try: - decoding_body = zstd.decompress(decoding_body) - except Exception as e: - print(f"Error decompressing zstd: {e}: skipping", flush=True) - decode_success = False - break - elif enc == "identity": - pass # No need to do anything https://datatracker.ietf.org/doc/html/rfc2616#section-3.5 (it's possible to be found also if it should't be used) - else: - decode_success = False - break - - if decode_success: - self.msg.body = decoding_body - self.msg.body_decoded = True - - self.msg.message_complete = True - self.has_begun = False - if not self._packet_to_stream(): - self.messages.append(self.msg) - - @property - def user_agent(self) -> str: - return self.msg.lheaders.get("user-agent", "") - - @property - def content_encoding(self) -> str: - return self.msg.lheaders.get("content-encoding", "") - - @property - def content_type(self) -> str: - return self.msg.lheaders.get("content-type", "") - - @property - def keep_alive(self) -> bool: - return self.should_keep_alive - - @property - def should_upgrade(self) -> bool: - return self.is_upgrading - - @property - def http_version(self) -> str: - if self.major and self.minor: - return f"{self.major}.{self.minor}" - else: - return "" - - @property - def method_parsed(self) -> str: - return self.method - - @property - def total_size(self) -> int: - """Total size used by the parser""" - tot = self.msg.total_size - for msg in self.messages: - tot += msg.total_size - return tot - - @property - def content_length_parsed(self) -> int: - return self.content_length - - def _is_input(self) -> bool: - raise NotImplementedError() - - def _packet_to_stream(self): - return self.should_upgrade and self.save_body - - def _stream_parser(self, data: bytes): - if self.msg.upgrading_to_ws: - if self._ws_raised_error: - self.msg.stream += data - self.msg.total_size += len(data) - return - self.buffers._ws_packet_stream += data - while True: - try: - new_frame, self.buffers._ws_packet_stream = ( - self._parse_websocket_frame(self.buffers._ws_packet_stream) - ) - except Exception: - print( - "[WARNING] Websocket parsing failed, passing data to stream...", - flush=True, - ) - traceback.print_exc() - self._ws_raised_error = True - self.msg.stream += self.buffers._ws_packet_stream - self.buffers._ws_packet_stream = b"" - self.msg.total_size += len(data) - return - if new_frame is None: - break - self.msg.ws_stream.append(new_frame) - self.msg.total_size += len(new_frame.data) - if self.msg.upgrading_to_h2: - self.msg.total_size += len(data) - self.msg.stream += data - - def _parse_websocket_ext(self): - ext_ws = [] - req_ext = [] - for ele in self.msg.lheaders.get("sec-websocket-extensions", "").split(","): - for xt in ele.split(";"): - req_ext.append(xt.strip().lower()) - - for ele in req_ext: - if ele == "permessage-deflate": - ext_ws.append(PerMessageDeflate(False, False, 15, 15)) - return ext_ws - - def _parse_websocket_frame(self, data: bytes) -> tuple[Frame | None, bytes]: - if self._ws_extentions is None: - if self._is_input(): - self._ws_extentions = [] # Fallback to no options - else: - self._ws_extentions = ( - self._parse_websocket_ext() - ) # Extentions used are choosen by the server response - read_buffering = bytearray() - - def read_exact(n: int): - nonlocal read_buffering - buffer = bytearray(read_buffering) - while len(buffer) < n: - data = yield - if data is None: - raise RuntimeError("Should not send None to this generator") - buffer.extend(data) - new_data = bytes(buffer[:n]) - read_buffering = buffer[n:] - return new_data - - parsing = Frame.parse( - read_exact, extensions=self._ws_extentions, mask=self._is_input() - ) - parsing.send(None) - try: - parsing.send(bytearray(data)) - except StopIteration as e: - return e.value, read_buffering - - return None, read_buffering - - def parse_data(self, data: bytes): - if self._packet_to_stream(): # This is a websocket upgrade! - self._stream_parser(data) - else: - try: - reason, consumed = self.execute(data) - if reason == PAUSED_UPGRADE: - self.msg.upgrading_to_ws = True - self.msg.message_complete = True - self._stream_parser(data[consumed:]) - elif reason == PAUSED_H2_UPGRADE: - self.msg.upgrading_to_h2 = True - self.msg.message_complete = True - self._stream_parser(data[consumed:]) - except Exception as e: - self.raised_error = True - raise e - - def pop_message(self): - return self.messages.popleft() - - def pop_all_messages(self): - tmp = self.messages - self.messages = deque() - return tmp - - def __repr__(self): - return f"" - - -class InternalHttpRequest(InternalCallbackHandler, pyllhttp.Request): - def __init__(self): - super(InternalCallbackHandler, self).__init__() - super(pyllhttp.Request, self).__init__() - - def _is_input(self): - return True - - -class InternalHttpResponse(InternalCallbackHandler, pyllhttp.Response): - def __init__(self): - super(InternalCallbackHandler, self).__init__() - super(pyllhttp.Response, self).__init__() - - def _is_input(self): - return False - - -class HttpHistory: - """ - HTTP History handler for pyfilters. - Provides access to completed previous requests and responses in the current TCP stream. - """ - - def __init__( - self, - requests: list["HttpFullRequest"] | None = None, - responses: list["HttpFullResponse"] | None = None, - ): - self._requests = list(requests) if requests is not None else [] - self._responses = list(responses) if responses is not None else [] - - @property - def requests(self) -> list["HttpFullRequest"]: - """List of previous completed HTTP requests""" - return self._requests.copy() - - @property - def responses(self) -> list["HttpFullResponse"]: - """List of previous completed HTTP responses""" - return self._responses.copy() - - @classmethod - def _fetch_packet(cls, internal_data: DataStreamCtx): - if ( - internal_data.current_pkt is None - or internal_data.current_pkt.is_tcp is False - ): - raise NotReadyToRun() - - for key in ("http_module", "http_full", "http_header"): - obj = internal_data.call_mem.get(f"_fetched_obj_{key}") - if obj is not None: - if isinstance(obj, list) and obj: - return [item.history for item in obj] - elif hasattr(obj, "history"): - return obj.history - - req_history_deque = internal_data.data_handler_context.get( - "http_history_requests", deque() - ) - resp_history_deque = internal_data.data_handler_context.get( - "http_history_responses", deque() - ) - return HttpHistory(list(req_history_deque), list(resp_history_deque)) - - def __repr__(self): - return f"" - - -HttpStreamHistory = HttpHistory - - -class InternalBasicHttpMetaClass: - """Internal class to handle HTTP requests and responses""" - - def __init__( - self, - parser: InternalHttpRequest | InternalHttpResponse, - msg: InternalHTTPMessage, - ): - self._parser = parser - self.raised_error = False - self._message: InternalHTTPMessage | None = msg - self._history: HttpHistory | None = None - self._contructor_hook() - - def _contructor_hook(self): - pass - - @property - def history(self) -> HttpHistory: - """HTTP History for the current stream connection""" - if self._history is None: - return HttpHistory([], []) - return self._history - - @property - def total_size(self) -> int: - """Total size of the message""" - return self._message.total_size - - @property - def url(self) -> str | None: - """URL of the message""" - return self._message.url - - @property - def headers(self) -> dict[str, str]: - """Headers of the message""" - return self._message.headers - - @property - def user_agent(self) -> str: - """User agent of the message""" - return self._message.user_agent - - @property - def content_encoding(self) -> str: - """Content encoding of the message""" - return self._message.content_encoding - - @property - def body(self) -> bytes: - """Body of the message""" - return self._message.body - - @property - def headers_complete(self) -> bool: - """If the headers are complete""" - return self._message.headers_complete - - @property - def message_complete(self) -> bool: - """If the message is complete""" - return self._message.message_complete - - @property - def http_version(self) -> str: - """HTTP version of the message""" - return self._message.http_version - - @property - def keep_alive(self) -> bool: - """If the message should keep alive""" - return self._message.keep_alive - - @property - def should_upgrade(self) -> bool: - """If the message should upgrade""" - return self._message.should_upgrade - - @property - def content_length(self) -> int | None: - """Content length of the message""" - return self._message.content_length - - @property - def upgrading_to_h2(self) -> bool: - """If the message is upgrading to HTTP/2""" - return self._message.upgrading_to_h2 - - @property - def upgrading_to_ws(self) -> bool: - """If the message is upgrading to Websocket""" - return self._message.upgrading_to_ws - - @property - def ws_stream(self) -> list[Frame]: - """Websocket stream""" - return self._message.ws_stream - - @property - def stream(self) -> bytes: - """Stream of the message""" - return self._message.stream - - def get_header(self, header: str, default=None) -> str: - """Get a header from the message without caring about the case""" - return self._message.lheaders.get(header.lower(), default) - - @classmethod - def _should_release_message_headers(cls) -> bool: - return True - - @staticmethod - def _before_fetch_callable_checks(internal_data: DataStreamCtx) -> bool: - raise NotImplementedError() - - @staticmethod - def _parser_class() -> str: - raise NotImplementedError() - - @classmethod - def _fetch_packet(cls, internal_data: DataStreamCtx): - if ( - internal_data.current_pkt is None - or internal_data.current_pkt.is_tcp is False - ): - raise NotReadyToRun() - - ParserType = ( - InternalHttpRequest - if internal_data.current_pkt.is_input - else InternalHttpResponse - ) - parser_key = f"{cls._parser_class()}_{'in' if internal_data.current_pkt.is_input else 'out'}" - - parser = internal_data.data_handler_context.get(parser_key, None) - if parser is None or parser.raised_error: - parser: InternalHttpRequest | InternalHttpResponse = ParserType() - internal_data.data_handler_context[parser_key] = parser - - parser.release_message_headers = cls._should_release_message_headers() - - - if not internal_data.call_mem.get( - cls._parser_class(), False - ): # Need to parse HTTP - internal_data.call_mem[cls._parser_class()] = True - parser.pop_all_messages() # Delete content on message deque - - # Setting websocket options if needed to the client parser - if internal_data.current_pkt.is_input: - ext_opt = internal_data.data_handler_context.get( - f"{cls._parser_class()}_ws_options_client" - ) - if ext_opt is not None and parser._ws_extentions != ext_opt: - parser._ws_extentions = ext_opt - - # Memory size managment - if ( - parser.total_size + len(internal_data.current_pkt.data) - > internal_data.stream_max_size - ): - match internal_data.full_stream_action: - case FullStreamAction.FLUSH: - # Deleting parser and re-creating it - parser.messages.clear() - parser.msg.total_size -= len(parser.msg.stream) - parser.msg.stream = b"" - parser.msg.total_size -= len(parser.msg.body) - parser.msg.body = b"" - print("[WARNING] Flushing stream", flush=True) - if ( - parser.total_size + len(internal_data.current_pkt.data) - > internal_data.stream_max_size - ): - parser.reset_data() - case FullStreamAction.REJECT: - raise StreamFullReject() - case FullStreamAction.DROP: - raise StreamFullDrop() - case FullStreamAction.ACCEPT: - raise NotReadyToRun() - - internal_data.call_mem["headers_were_set"] = ( - parser.msg.headers_complete - ) # This information is usefull for building the real object - - try: - parser.parse_data(internal_data.current_pkt.data) - except Exception as e: - traceback.print_exc() - match internal_data.invalid_encoding_action: - case ExceptionAction.REJECT: - raise RejectConnection() - case ExceptionAction.DROP: - raise DropPacket() - case ExceptionAction.NOACTION: - raise e - case ExceptionAction.ACCEPT: - raise NotReadyToRun() - - if parser.should_upgrade and not internal_data.current_pkt.is_input: - # Creating ws_option for the client - if not internal_data.data_handler_context.get( - f"{cls._parser_class()}_ws_options_client" - ): - ext = parser._parse_websocket_ext() - internal_data.data_handler_context[ - f"{cls._parser_class()}_ws_options_client" - ] = ext - - # Once the parsers has been triggered, we can return the object if needed - if not cls._before_fetch_callable_checks(internal_data): - raise NotReadyToRun() - - messages_tosend: list[InternalHTTPMessage] = [] - for i in range(len(parser.messages)): - messages_tosend.append(parser.pop_message()) - - if len(messages_tosend) > 0: - internal_data.call_mem["headers_were_set"] = ( - False # New messages completed so the current message headers were not set in this case - ) - - if ( - not internal_data.call_mem["headers_were_set"] - and parser.msg.headers_complete - and parser.release_message_headers - ): - messages_tosend.append( - parser.msg - ) # Also the current message needs to be sent due to complete headers - - if parser._packet_to_stream(): - messages_tosend.append( - parser.msg - ) # Also the current message needs to beacase a stream is going on - - messages_to_call = len(messages_tosend) - - if messages_to_call == 0: - raise NotReadyToRun() - - raw_max = internal_data.filter_glob.get("FGEX_MAX_HISTORY_SIZE", 100) - try: - max_history = max(0, int(raw_max)) - except (ValueError, TypeError): - max_history = 100 - - req_history_deque: deque = ( - internal_data.data_handler_context.setdefault( - "http_history_requests", deque(maxlen=max_history) - ) - ) - if req_history_deque.maxlen != max_history: - req_history_deque = deque(req_history_deque, maxlen=max_history) - internal_data.data_handler_context["http_history_requests"] = req_history_deque - - resp_history_deque: deque = ( - internal_data.data_handler_context.setdefault( - "http_history_responses", deque(maxlen=max_history) - ) - ) - if resp_history_deque.maxlen != max_history: - resp_history_deque = deque(resp_history_deque, maxlen=max_history) - internal_data.data_handler_context["http_history_responses"] = resp_history_deque - - built_instances = [] - for msg in messages_tosend: - history_snapshot = HttpHistory( - list(req_history_deque), list(resp_history_deque) - ) - instance = cls(parser, msg) - instance._history = history_snapshot - built_instances.append(instance) - - if msg.message_complete and not msg.added_to_history: - msg.added_to_history = True - if internal_data.current_pkt.is_input: - req_history_deque.append(HttpFullRequest(parser, msg)) - else: - resp_history_deque.append(HttpFullResponse(parser, msg)) - - if len(built_instances) == 1: - res = built_instances[0] - internal_data.call_mem[f"_fetched_obj_{cls._parser_class()}"] = res - return res - - internal_data.call_mem[f"_fetched_obj_{cls._parser_class()}"] = built_instances - return built_instances - - - -class HttpRequest(InternalBasicHttpMetaClass): - """ - HTTP Request handler - This data handler will be called twice, first with the headers complete, and second with the body complete - """ - - @staticmethod - def _before_fetch_callable_checks(internal_data: DataStreamCtx): - return internal_data.current_pkt.is_input - - @property - def method(self) -> bytes: - """Method of the request""" - return self._message.method - - @staticmethod - def _parser_class() -> str: - return "http_module" - - def __repr__(self): - return f"" - - -class HttpResponse(InternalBasicHttpMetaClass): - """ - HTTP Response handler - This data handler will be called twice, first with the headers complete, and second with the body complete - """ - - @staticmethod - def _before_fetch_callable_checks(internal_data: DataStreamCtx): - return not internal_data.current_pkt.is_input - - @property - def status_code(self) -> int: - """Status code of the response""" - return self._message.status - - - @staticmethod - def _parser_class() -> str: - return "http_module" - - def __repr__(self): - return f"" - - -class HttpFullRequest(HttpRequest): - """ - HTTP Request handler - This data handler will be called when the request data is complete - """ - - @classmethod - def _should_release_message_headers(cls) -> bool: - return False - - def _contructor_hook(self): - self._parser.release_message_headers = False - - @staticmethod - def _parser_class() -> str: - return "http_full" - - def __repr__(self): - return f"" - - -class HttpFullResponse(HttpResponse): - """ - HTTP Response handler - This data handler will be called when the response data is complete - """ - - @classmethod - def _should_release_message_headers(cls) -> bool: - return False - - def _contructor_hook(self): - self._parser.release_message_headers = False - - @staticmethod - def _parser_class() -> str: - return "http_full" - - def __repr__(self): - return f"" - - - -class HttpRequestHeader(HttpRequest): - """ - HTTP Request Header handler - This data handler will be called only once, the headers are complete, the body will be empty and not buffered - """ - - def _contructor_hook(self): - self._parser.save_body = False - - @staticmethod - def _parser_class() -> str: - return "http_header" - - -class HttpResponseHeader(HttpResponse): - """ - HTTP Response Header handler - This data handler will be called only once, the headers are complete, the body will be empty and not buffered - """ - - def _contructor_hook(self): - self._parser.save_body = False - - @staticmethod - def _parser_class() -> str: - return "http_header" diff --git a/fgex-lib/firegex/pyfilters/__init__.py b/fgex-lib/firegex/pyfilters/__init__.py new file mode 100644 index 0000000..22a9ccf --- /dev/null +++ b/fgex-lib/firegex/pyfilters/__init__.py @@ -0,0 +1,58 @@ +from firegex.pyfilters.models import ( + RawPacket, + TCPInputStream, + TCPOutputStream, + TCPClientStream, + TCPServerStream, + HttpHistory, + HttpStreamHistory, +) +from firegex.pyfilters.internals.models import Action, FullStreamAction, ExceptionAction + +ACCEPT = Action.ACCEPT +DROP = Action.DROP +REJECT = Action.REJECT + +#: Set on a function by `@pyfilter`. Marking the function *is* the registration — there +#: is no registry to keep, to clear between files, or to race two compilations against. +PYFILTER_MARK = "__firegex_pyfilter__" + + +def pyfilter(func): + """Mark a function as a filter, and hand it back unchanged. + + The mark lives on the function, so the file that defines it holds the whole list: + `collect_pyfilters` reads a module's globals in definition order. What that replaced + was a set of *names* on a module-global attribute of this decorator, which had three + consequences worth not having back — a set has no order, so the order filters ran in + was whatever hashing produced; the state outlived the file, so every caller had to + clear it before and after; and a name registered from somewhere the module globals + could not reach (a nested `def`) was registered and then not found. + + The function is returned as it is, not wrapped. The wrapper it used to get forwarded + `*args` and did nothing else, at the cost of a stack frame in every traceback the + operator has to read. + """ + setattr(func, PYFILTER_MARK, True) + return func + + +def collect_pyfilters(namespace: dict) -> list[str]: + """The filters a module defines, in the order it defines them. + + Definition order is the order they run in, which is the only order a reader of the + file can predict. + """ + seen, names = set(), [] + for name, obj in namespace.items(): + if callable(obj) and getattr(obj, PYFILTER_MARK, False) and id(obj) not in seen: + seen.add(id(obj)) + names.append(name) + return names + +__all__ = [ + "ACCEPT", "DROP", "REJECT", + "Action", "FullStreamAction", "ExceptionAction", "pyfilter", "collect_pyfilters", + "RawPacket", "TCPInputStream", "TCPOutputStream", "TCPClientStream", "TCPServerStream", + "HttpHistory", "HttpStreamHistory" +] \ No newline at end of file diff --git a/fgex-lib/firegex/pyfilters/internals/__init__.py b/fgex-lib/firegex/pyfilters/internals/__init__.py new file mode 100644 index 0000000..b57a0c8 --- /dev/null +++ b/fgex-lib/firegex/pyfilters/internals/__init__.py @@ -0,0 +1,317 @@ +from inspect import Parameter, signature +from itertools import product +from firegex.pyfilters.internals.models import Action, ExceptionAction, FullStreamAction +from firegex.pyfilters.internals.models import FilterHandler, PacketHandlerResult +import functools +from firegex.pyfilters.internals.data import DataStreamCtx +from firegex.pyfilters.internals.exceptions import NotReadyToRun, StreamFullReject, DropPacket, RejectConnection, StreamFullDrop +from firegex.pyfilters.internals.data import RawPacket + +def _type_name(annotation) -> str: + return getattr(annotation, "__name__", None) or str(annotation) + + +def _wanted_by(filters: list[str], glob: dict) -> list[tuple[str, object, list[object]]]: + """`(name, func, annotations)` for each named filter, looked up once. + + Both passes below need the same three things, and reading them twice is how the two + ended up disagreeing about what to say when an annotation is not a model — one + complained that no protocol provides it, the other that this file's protocol does + not. One reading, one message. + """ + found = [] + for name in filters: + if not isinstance(name, str): + raise Exception("Invalid filter list: must be a list of strings") + func = glob.get(name) + if func is None: + raise Exception(f"Filter {name} not found") + if not callable(func): + raise Exception(f"{func} is not a function") + annotations = [] + for param, spec in signature(func).parameters.items(): + if spec.annotation is Parameter.empty: + # The library decides when to call a filter from what it asks for, so a + # parameter that asks for nothing leaves it with nothing to go on. + raise Exception( + f"Parameter {param!r} of {name} has no type annotation. Annotate it " + f"with what the filter wants to be given — RawPacket, a TCP stream, " + f"an HTTP model — because that is what decides when it is called." + ) + annotations.append(spec.annotation) + found.append((name, func, annotations)) + return found + + +def infer_proto(wanted: list[tuple[str, object, list[object]]]) -> str: + """Which application protocol a file speaks, read off what it asks for. + + A filter file does not declare its protocol; it shows it. Asking for an + `HttpRequest` is what makes a file an HTTP filter, and asking only for a + `RawPacket` or a TCP stream is what makes one protocol-agnostic — so the same file + can hold both, and a TCP service can carry a filter that parses HTTP alongside one + that does not. + + The one thing that cannot work is a file asking for two *different* application + protocols, because a connection is only ever one of them. That is refused here, + naming both functions, rather than being resolved by picking a winner. + """ + from firegex.pyfilters.models import type_annotations_associations + + candidates = set(type_annotations_associations) + # What actually narrowed the choice, kept so a conflict can be explained in terms + # of the operator's own code instead of a protocol name they never typed. + narrowing: list[tuple[str, object, set[str]]] = [] + + for name, _func, annotations in wanted: + for annotation in annotations: + protos = { + proto + for proto, types in type_annotations_associations.items() + if annotation in types + } + if not protos: + known = sorted({ + _type_name(t) + for types in type_annotations_associations.values() + for t in types + }) + raise Exception( + f"Invalid type annotation {_type_name(annotation)} for function " + f"{name}: no protocol provides it. Available: " + ", ".join(known) + ) + if not candidates & protos: + clash = next((n for n in narrowing if not n[2] & protos), None) + if clash: + raise Exception( + f"{name} asks for {_type_name(annotation)}, which only " + f"{'/'.join(sorted(protos))} provides, while {clash[0]} asks for " + f"{_type_name(clash[1])}, which only " + f"{'/'.join(sorted(clash[2]))} does. A connection speaks one " + f"application protocol, so one file cannot filter both: split " + f"them into two filters on the same service." + ) + raise Exception( + f"{name} asks for {_type_name(annotation)}, which no protocol in " + f"this file can provide" + ) + if candidates & protos != candidates: + narrowing.append((name, annotation, protos)) + candidates &= protos + + return simplest_proto(candidates) + + +def simplest_proto(candidates=None) -> str: + """The least specific protocol among `candidates` — all of them by default. + + A file that only wants raw payloads is a TCP filter, not an HTTP one that happens + never to parse, so "fewest models" is what "simplest" means. + """ + from firegex.pyfilters.models import type_annotations_associations + return min( + candidates if candidates else type_annotations_associations, + key=lambda p: (len(type_annotations_associations[p]), p), + ) + + +def generate_filter_structure(filters: list[str], proto: str | None, glob: dict) -> list[FilterHandler]: + """One `FilterHandler` per filter, in the order given, for one protocol.""" + from firegex.pyfilters.models import type_annotations_associations + + wanted = _wanted_by(filters, glob) + if proto is None: + proto = infer_proto(wanted) + if proto not in type_annotations_associations: + raise Exception("Invalid protocol") + provides = type_annotations_associations[proto] + + handlers = [] + for name, func, annotations in wanted: + for annotation in annotations: + if annotation not in provides: + raise Exception( + f"Invalid type annotation {_type_name(annotation)} for function " + f"{name}: this file speaks {proto}, which does not provide it" + ) + handlers.append(FilterHandler( + func=func, + name=func.__name__, + params={a: provides[a] for a in annotations}, + proto=proto, + )) + return handlers + + +def get_filters_info(code:str, proto:str|None = None) -> list[FilterHandler]: + """The filters a file defines. `proto` is inferred from the code when not given. + + Only the operator's own source is `exec`ed. Everything around it used to be too — + the import, the registry reset before and after — which was a string compiled at + runtime for each step of what a plain call does, and which existed only because the + registry was global state that had to be cleared around every use. + """ + from firegex.pyfilters import collect_pyfilters + + glob: dict = {} + exec(code, glob, glob) + return generate_filter_structure(collect_pyfilters(glob), proto, glob) + + +def get_filter_names(code:str, proto:str|None = None) -> list[str]: + return [ele.name for ele in get_filters_info(code, proto)] + + +def get_code_proto(code:str) -> str: + """Which application protocol a file speaks, for a caller that needs to say so. + + Raises the same errors `get_filters_info` does, so a file that mixes two protocols + is reported here with the reason rather than silently classified as one of them. + """ + from firegex.pyfilters.models import type_annotations_associations + infos = get_filters_info(code) + if infos: + return infos[0].proto + # A file with no filters yet speaks the simplest protocol there is: it asks for + # nothing, so nothing is ruled out. + return min( + type_annotations_associations, + key=lambda p: (len(type_annotations_associations[p]), p), + ) + +#: What a model raises while being built, and the verdict it means. `NotReadyToRun` is +#: not here: it is the ordinary "there is nothing to give this filter yet", handled by +#: skipping the call rather than by ending the packet. +#: Each is credited to what actually decided it, never to the filter function: those two +#: are raised only when the parser could not read the traffic, and a function the +#: operator wrote was listed as having refused a connection it was never shown. +_RAISED_VERDICT = { + StreamFullDrop: (Action.DROP, "@MAX_STREAM_SIZE_REACHED"), + StreamFullReject: (Action.REJECT, "@MAX_STREAM_SIZE_REACHED"), + DropPacket: (Action.DROP, "@INVALID_ENCODING"), + RejectConnection: (Action.REJECT, "@INVALID_ENCODING"), +} + + +def handle_packet(glob: dict) -> None: + """Run every enabled filter over the chunk in `glob`, and leave a verdict there. + + The first filter to answer anything but ACCEPT ends the packet — every verdict + there is now terminal. `UNSTABLE_MANGLE` used to be the exception: it was remembered + and the walk carried on, because a later filter might still refuse the rewritten + chunk. It is gone, and so is that branch: see the note on `Action` in `models.py`. + """ + internal_data = DataStreamCtx(glob) + result = PacketHandlerResult(glob) + + # Built once per packet and shared by every filter that asks for the same thing: two + # filters both taking an `HttpRequest` parse the chunk once between them. + built = {RawPacket: internal_data.current_pkt} + + for filter in internal_data.filter_call_info: + args = [] + for data_type, build in filter.params.items(): + if data_type not in built: + try: + built[data_type] = build(internal_data) + except NotReadyToRun: + built[data_type] = None + except tuple(_RAISED_VERDICT) as raised: + action, matched_by = _RAISED_VERDICT[type(raised)] + result.action = action + result.matched_by = matched_by or filter.name + return result.set_result() + if built[data_type] is None: + args = None # nothing to give this filter yet; it is not called + break + args.append(built[data_type]) + + if args is None: + continue + + # A model may hand back several of itself — a chunk carrying three HTTP requests + # is three calls, not one call with a list. Where more than one parameter does + # that, the filter sees every combination. + for call_args in product(*(a if isinstance(a, list) else [a] for a in args)): + res = filter.func(*call_args) + if res is None or res == Action.ACCEPT: + continue + if not isinstance(res, Action): + raise Exception(f"Invalid return type {type(res)} for function {filter.name}") + result.matched_by = filter.name + result.action = res + return result.set_result() + + return result.set_result() # nothing refused it: ACCEPT + + +#: The knobs a filter file may set, and what each one is kept as. Three copies of "if +#: the name is there and the value is the right type, store it" differing only in the +#: name and the type; the one that was *not* a copy had drifted — it checked `Action` +#: where the setter and the documentation both said `ExceptionAction`, so the knob could +#: not be set at all, in either direction. +_SETTINGS = { + "FGEX_STREAM_MAX_SIZE": ("stream_max_size", int), + "FGEX_FULL_STREAM_ACTION": ("full_stream_action", FullStreamAction), + "FGEX_INVALID_ENCODING_ACTION": ("invalid_encoding_action", ExceptionAction), +} + + +def compile(glob: dict) -> None: + """Prepare a filter file's globals to be handed packets. + + Called once per connection, after the operator's source has been executed into + `glob`: it resolves which functions run, in what order, over what models, and reads + the file's settings. + """ + internal_data = DataStreamCtx(glob, init_pkt=False) + + 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( + names, glob.get("__firegex_proto"), glob + ) + + for name, (attribute, kind) in _SETTINGS.items(): + if name not in glob: + continue + value = glob[name] + if kind is int: + # A size is taken from anything that reads as one, and anything else is + # ignored — which is what the other two settings already did, and what this + # one only claimed to do: `int(value)` raises for a typo, so a file setting + # `FGEX_STREAM_MAX_SIZE = "1MB"` did not get the default, it failed to load + # altogether. The operator lost every filter in that file over an optional + # knob, and the message named neither the setting nor the file. + try: + value = int(value) + except (TypeError, ValueError): + continue + if value <= 0: + continue + elif not isinstance(value, kind): + continue + setattr(internal_data, attribute, value) + + PacketHandlerResult(glob).reset_result() + + def fake_exit(*_a, **_k): + print("WARNING: This function should not be called", flush=True) + + glob["exit"] = fake_exit diff --git a/fgex-lib/firegex/pyfilters/internals/data.py b/fgex-lib/firegex/pyfilters/internals/data.py new file mode 100644 index 0000000..3427953 --- /dev/null +++ b/fgex-lib/firegex/pyfilters/internals/data.py @@ -0,0 +1,226 @@ +from firegex.pyfilters.internals.models import ExceptionAction, FullStreamAction + +class RawPacket: + """One chunk of a connection, with what is known about where it came from. + + Everything below the application layer is **metadata** here, 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. + + That is a deliberate boundary rather than a missing feature. The two network layers + cannot honestly offer the same thing: on NFQUEUE a real header exists and rewriting + it desynchronises the connection in ways that surface minutes later somewhere else; + on the proxy the connection was terminated and reopened, so the headers on the wire + are the engine's own and a filter editing them would be editing nothing. The old + model papered over that by handing the proxy a literal `FAKE:IP:TCP:HEADERS:` + prefix, which meant a filter written against `raw_packet` did something different + depending on the transport it happened to be attached to — the exact failure the + unified model exists to prevent. + + So: read the metadata and the payload, and answer with a verdict. Nothing a filter is + shown can be written — the payload was, for `UNSTABLE_MANGLE`, and stopped being + with it. + """ + + def __init__(self, + data: bytes, + is_input: bool, + is_ipv6: bool, + is_tcp: bool, + src_ip: str = "", + dst_ip: str = "", + src_port: int = 0, + dst_port: int = 0, + l4: str | None = None, + ): + self.__data = bytes(data) + self.__is_input = bool(is_input) + self.__is_ipv6 = bool(is_ipv6) + # Derived from `is_tcp` when nothing said otherwise, because one of the engines + # sending this has only ever had two answers to give: the NFQUEUE binaries carry + # TCP and UDP and nothing else, and asking them to learn a third word to say the + # same thing would be a change with no question behind it. + self.__l4 = str(l4) if l4 else ("tcp" if is_tcp else "udp") + self.__is_tcp = self.__l4 == "tcp" + self.__src_ip = str(src_ip) + self.__dst_ip = str(dst_ip) + self.__src_port = int(src_port) + self.__dst_port = int(dst_port) + + @property + def is_input(self) -> bool: + "True if the chunk is going from the client to the service" + return self.__is_input + + @property + def is_output(self) -> bool: + "True if the chunk is going from the service to the client" + return not self.__is_input + + @property + def is_ipv6(self) -> bool: + "True if the connection is IPv6, false if it is IPv4" + return self.__is_ipv6 + + @property + def is_tcp(self) -> bool: + "True if the connection is TCP. False for a datagram, and false for QUIC" + return self.__is_tcp + + @property + def l4(self) -> str: + """What carries this connection. + + `tcp`, `udp`, `quic` for a stream inside a QUIC connection, or `quic-datagram` + for a DATAGRAM frame in one — which is in a connection carrying streams and is + not one, the distinction `is_stream` is there to answer. + """ + return self.__l4 + + @property + def is_stream(self) -> bool: + """True if these bytes arrive in order, once each, as part of a stream. + + The question every model above `RawPacket` actually asks. It used to be spelled + `is_tcp`, and the two came apart the day QUIC arrived: a QUIC stream is ordered + and reliable, so an assembled stream and a parsed HTTP message mean exactly what + they mean on TCP — and it is carried by UDP, so a filter asking what is on the + wire has to be told UDP. One flag answering both got one of them wrong whichever + way it was set. A datagram — plain UDP or QUIC's own — answers false here, so a + filter asking for a stream model is simply not called for one and a single file + can carry both. + """ + return self.__l4 in ("tcp", "quic") + + @property + def src_ip(self) -> str: + "Where this chunk came from" + return self.__src_ip + + @property + def dst_ip(self) -> str: + "Where this chunk is going" + return self.__dst_ip + + @property + def src_port(self) -> int: + "The port this chunk came from" + return self.__src_port + + @property + def dst_port(self) -> int: + "The port this chunk is going to" + return self.__dst_port + + @property + def client_ip(self) -> str: + "The client's address, whichever way this chunk is going" + return self.__src_ip if self.__is_input else self.__dst_ip + + @property + def client_port(self) -> int: + "The client's port, whichever way this chunk is going" + return self.__src_port if self.__is_input else self.__dst_port + + @property + def server_ip(self) -> str: + "The protected service's address, whichever way this chunk is going" + return self.__dst_ip if self.__is_input else self.__src_ip + + @property + def server_port(self) -> int: + "The protected service's port, whichever way this chunk is going" + return self.__dst_port if self.__is_input else self.__src_port + + @property + def data(self) -> bytes: + """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 + + @property + def data_size(self) -> int: + "How many bytes of application payload this chunk carries" + return len(self.__data) + + @classmethod + def _fetch_packet(cls, internal_data:"DataStreamCtx"): + if not isinstance(internal_data, DataStreamCtx): + if isinstance(internal_data, dict): + internal_data = DataStreamCtx(internal_data) + else: + raise Exception("Invalid data type, data MUST be of type DataStream, or glob dict") + + if "__firegex_packet_info" not in internal_data.filter_glob.keys(): + raise Exception("Packet info not found") + return cls(**internal_data.filter_glob["__firegex_packet_info"]) + + def __repr__(self): + way = "client -> service" if self.is_input else "service -> client" + return ( + f"RawPacket({way}, {self.src_ip}:{self.src_port} -> {self.dst_ip}:{self.dst_port}, " + f"{self.l4}/{'ip6' if self.is_ipv6 else 'ip4'}, " + f"{self.data_size} bytes)" + ) + + +#: The documented default for `FGEX_STREAM_MAX_SIZE`. It read `1*8e20` — eight hundred +#: exabytes, commented "1MB default value" — which meant `FGEX_FULL_STREAM_ACTION` never +#: fired unless the operator set a size by hand, and the float it produced would have +#: been refused by the setter beside it. +DEFAULT_STREAM_MAX_SIZE = 1024 * 1024 + + +class _Kept: + """One value in the per-connection context, with its default and its type. + + Five of these were five pairs of property/setter differing only in a name, a default + and one isinstance check — seventy lines in which the interesting part was three + words per entry. + """ + + def __init__(self, default, kind: type | None = None): + self.default = default + self.kind = kind + + def __set_name__(self, owner, name): + self.key = name + + def __get__(self, obj, _owner=None): + if obj is None: + return self + if self.key not in obj.store: + obj.store[self.key] = self.default() if callable(self.default) else self.default + return obj.store[self.key] + + def __set__(self, obj, value): + if self.kind is not None and not isinstance(value, self.kind): + raise Exception(f"Invalid data type, data MUST be of type {self.kind.__name__}") + obj.store[self.key] = value + + +class DataStreamCtx: + """The context of the data handler: what survives between packets of one connection. + + Everything below lives in a dict inside the filter's own globals, so it is per + connection and per direction — which is what keeps one client's bytes from deciding + another client's verdict. + """ + + filter_call_info = _Kept(list) + stream_max_size = _Kept(DEFAULT_STREAM_MAX_SIZE, int) + full_stream_action = _Kept(FullStreamAction.FLUSH, FullStreamAction) + #: Traffic the HTTP parser cannot read is carried, and said so, unless the filter file + #: asks for it to be refused. It was refused by default — so a request the service + #: would have taken and llhttp would not (a quirk of one client, a protocol that only + #: resembles HTTP) closed the connection with nothing chosen by anybody, and the block + #: was credited to the filter function as if it had decided it. Refusing is the + #: stricter answer to request smuggling and stays one line away; it is the operator's. + invalid_encoding_action = _Kept(ExceptionAction.ACCEPT, ExceptionAction) + data_handler_context = _Kept(dict) + + def __init__(self, glob: dict, init_pkt: bool = True): + self.store = glob.setdefault("__firegex_pyfilter_ctx", {}) + self.filter_glob = glob + self.current_pkt = RawPacket._fetch_packet(self) if init_pkt else None + self.call_mem = {} # A memory space valid only for the current packet handler diff --git a/fgex-lib/firegex/nfproxy/internals/exceptions.py b/fgex-lib/firegex/pyfilters/internals/exceptions.py similarity index 100% rename from fgex-lib/firegex/nfproxy/internals/exceptions.py rename to fgex-lib/firegex/pyfilters/internals/exceptions.py diff --git a/fgex-lib/firegex/nfproxy/internals/models.py b/fgex-lib/firegex/pyfilters/internals/models.py similarity index 62% rename from fgex-lib/firegex/nfproxy/internals/models.py rename to fgex-lib/firegex/pyfilters/internals/models.py index d8f1c65..9103dfa 100644 --- a/fgex-lib/firegex/nfproxy/internals/models.py +++ b/fgex-lib/firegex/pyfilters/internals/models.py @@ -2,11 +2,19 @@ from dataclasses import dataclass, field from enum import Enum class Action(Enum): - """Action to be taken by the filter""" + """Action to be taken by the filter. + + There used to be a fourth, `MANGLE` (`UNSTABLE_MANGLE` to a filter author): replace + the payload and let the chunk carry on. It was removed because it could not keep its + promise on a stream — a filter only ever sees one chunk, so a pattern split across + two of them was never manglable, and the miss was silent: nothing blocked, nothing + logged. The numbering is left as it is rather than closed up, because the value is + the wire format between the library and the datapath and reusing 3 for something + else is how an old filter starts meaning a new thing. + """ ACCEPT = 0 DROP = 1 REJECT = 2 - MANGLE = 3 class ExceptionAction(Enum): """Action to be taken by the filter when an exception occurs (used in some cases)""" @@ -36,13 +44,11 @@ class PacketHandlerResult: glob: dict = field(repr=False) action: Action = Action.ACCEPT matched_by: str = None - mangled_packet: bytes = None - + def set_result(self) -> None: self.glob["__firegex_pyfilter_result"] = { "action": self.action.value, "matched_by": self.matched_by, - "mangled_packet": self.mangled_packet } def reset_result(self) -> None: diff --git a/fgex-lib/firegex/pyfilters/internals/zstd_compat.py b/fgex-lib/firegex/pyfilters/internals/zstd_compat.py new file mode 100644 index 0000000..8cddd09 --- /dev/null +++ b/fgex-lib/firegex/pyfilters/internals/zstd_compat.py @@ -0,0 +1,66 @@ +"""One `zstd.decompress`, whichever Python is underneath. + +`compression.zstd` arrived in the standard library in Python 3.14. The library imported +it directly, while `setup.py` promised 3.10 — so on everything from 3.10 to 3.13 the +package installed cleanly and then raised `ModuleNotFoundError: No module named +'compression'` on the first import of an HTTP model. That was the whole of what stopped +it running on Python 3.12, which is what Ubuntu 24.04 LTS ships. + +The `zstandard` package fills the gap, and the shim is not a one-line alias because the +two do **not** answer the same question. `zstandard.decompress()` refuses a frame whose +header carries no content size — which is exactly the shape a server produces when it +compresses a response body on the fly, the common case here — while the standard +library's handles it. A filter that reads a body on 3.14 and gets nothing on 3.12 is the +same class of lie as a tester that disagrees with the engine, so this normalises to the +more capable behaviour rather than to whichever backend is present. +""" + +import io + +try: # Python 3.14 and later + from compression.zstd import ZstdFile as _ZstdFile + from compression.zstd import decompress as _decompress + + def _read(data: bytes, size: int) -> bytes: + with _ZstdFile(io.BytesIO(data)) as f: + return f.read(size) + +except ImportError: # Python 3.10 - 3.13 + import zstandard + + def _reader(data: bytes): + """Every frame in `data`, streamed. + + `stream_reader` rather than `ZstdDecompressor.decompress`: the latter needs the + content size in the frame header and raises without it. `read_across_frames` + matches the standard library, which decompresses a concatenation of frames as + one body — an encoder that flushed mid-response produces exactly that. + """ + return zstandard.ZstdDecompressor().stream_reader( + io.BytesIO(data), read_across_frames=True + ) + + def _decompress(data: bytes) -> bytes: + return _reader(data).read() + + def _read(data: bytes, size: int) -> bytes: + reader = _reader(data) + out = bytearray() + while len(out) < size: + piece = reader.read(size - len(out)) + if not piece: + break + out += piece + return bytes(out) + + +def decompress(data: bytes, max_length: int | None = None) -> bytes: + """`data` decompressed, or its first `max_length` bytes when that is given. + + Bounded by reading rather than by decompressing and measuring: a frame a few hundred + bytes long can declare gigabytes, and what arrives in an HTTP body is whatever the + other end chose to send. + """ + if max_length is None: + return _decompress(data) + return _read(data, max_length) diff --git a/fgex-lib/firegex/nfproxy/models/__init__.py b/fgex-lib/firegex/pyfilters/models/__init__.py similarity index 53% rename from fgex-lib/firegex/nfproxy/models/__init__.py rename to fgex-lib/firegex/pyfilters/models/__init__.py index c5dffb2..77fd4bc 100644 --- a/fgex-lib/firegex/nfproxy/models/__init__.py +++ b/fgex-lib/firegex/pyfilters/models/__init__.py @@ -1,10 +1,10 @@ -from firegex.nfproxy.models.tcp import ( +from firegex.pyfilters.models.tcp import ( TCPInputStream, TCPOutputStream, TCPClientStream, TCPServerStream, ) -from firegex.nfproxy.models.http import ( +from firegex.pyfilters.models.http import ( HttpRequest, HttpResponse, HttpRequestHeader, @@ -13,10 +13,19 @@ from firegex.nfproxy.models.http import ( HttpFullResponse, HttpHistory, HttpStreamHistory, + GrpcMessage, + GrpcRequest, + GrpcResponse, ) -from firegex.nfproxy.internals.data import RawPacket -from enum import Enum +from firegex.pyfilters.internals.data import RawPacket +#: What each application protocol can hand a filter. +#: +#: This table is the only place a protocol is defined, and a filter file's protocol is +#: read back off it rather than declared: whatever a filter's parameters are annotated +#: with is what decides both when it is called and which protocol its file speaks. A +#: second list of protocol names would be one more thing to keep in step, and the day +#: it fell behind the symptom would be a filter that silently never runs. type_annotations_associations = { "tcp": { RawPacket: RawPacket._fetch_packet, @@ -35,15 +44,17 @@ type_annotations_associations = { HttpFullResponse: HttpFullResponse._fetch_packet, HttpHistory: HttpHistory._fetch_packet, HttpStreamHistory: HttpStreamHistory._fetch_packet, + # gRPC is not a protocol beside HTTP, it is a body format inside it — so its + # models live here rather than under a protocol of their own. That is what keeps + # them free: a new protocol would mean a new value in `filters.proto`, and + # changing that schema deletes and recreates the services database. + GrpcMessage: GrpcMessage._fetch_packet, + GrpcRequest: GrpcRequest._fetch_packet, + GrpcResponse: GrpcResponse._fetch_packet, }, } -class Protocols(Enum): - TCP = "tcp" - HTTP = "http" - - __all__ = [ "RawPacket", "TCPInputStream", @@ -57,7 +68,9 @@ __all__ = [ "HttpFullRequest", "HttpFullResponse", "HttpHistory", + "GrpcMessage", + "GrpcRequest", + "GrpcResponse", "HttpStreamHistory", - "Protocols", ] diff --git a/fgex-lib/firegex/pyfilters/models/http.py b/fgex-lib/firegex/pyfilters/models/http.py new file mode 100644 index 0000000..9b9c1f4 --- /dev/null +++ b/fgex-lib/firegex/pyfilters/models/http.py @@ -0,0 +1,1387 @@ +from firegex import _llhttp +from firegex.pyfilters.internals.exceptions import NotReadyToRun +from firegex.pyfilters.internals.data import DEFAULT_STREAM_MAX_SIZE, DataStreamCtx +from firegex.pyfilters.internals.exceptions import ( + StreamFullDrop, + StreamFullReject, + RejectConnection, + DropPacket, +) +from firegex.pyfilters.internals.models import FullStreamAction, ExceptionAction +from dataclasses import dataclass, field +from collections import deque +from firegex.pyfilters.internals import zstd_compat +import gzip +import io +import zlib +import brotli +import traceback +from websockets.frames import Frame +from websockets.extensions.permessage_deflate import PerMessageDeflate +from firegex._llhttp import PAUSED_H2_UPGRADE, PAUSED_UPGRADE + + +#: Whether this process has said that it met connections in the middle of a message. +#: Once per process rather than per connection: a filter taking over from another meets +#: every connection that was open at that moment, and a line each would bury the log. +_told_met_mid_message = False + + +def _met_mid_message() -> None: + global _told_met_mid_message + if _told_met_mid_message: + return + _told_met_mid_message = True + print( + "[info] [http] connections already open when this filter took over were met in " + "the middle of a message; whatever could not be read from where it came in is " + "carried without its HTTP filters until that connection closes. Connections " + "opened from now on are filtered from their first byte.", + flush=True, + ) + + +@dataclass +class InternalHTTPMessage: + """Internal class to handle HTTP messages""" + + url: str | None = field(default=None) + headers: dict[str, str] = field(default_factory=dict) + lheaders: dict[str, str] = field( + default_factory=dict + ) # lowercase copy of the headers + body: bytes | None = field(default=None) + body_decoded: bool = field(default=False) + headers_complete: bool = field(default=False) + message_complete: bool = field(default=False) + #: The reason phrase, as the server wrote it. Free text. + status: str | None = field(default=None) + #: The numeric status. Separate from the phrase because they are different things, + #: and because for a long time only the phrase was reachable at all: the binding + #: never exposed `llhttp`'s status code, so `status_code` on the public model handed + #: back the phrase while the documentation promised an int. + status_code: int | None = field(default=None) + total_size: int = field(default=0) + user_agent: str = field(default_factory=str) + content_encoding: str = field(default="") + content_type: str = field(default="") + keep_alive: bool = field(default=False) + should_upgrade: bool = field(default=False) + http_version: str = field(default="") + method: str = field(default="") + content_length: int = field(default=0) + stream: bytes = field(default_factory=bytes) + ws_stream: list[Frame] = field(default_factory=list) # Decoded websocket stream + upgrading_to_h2: bool = field(default=False) + upgrading_to_ws: bool = field(default=False) + added_to_history: bool = field(default=False) + #: How much of `body` has already been handed to a model that consumes it. + #: + #: On the message rather than beside it because that is what it belongs to: a + #: keep-alive connection carries many messages through one parser, and a cursor kept + #: per connection would start the second message part-way in. + grpc_consumed: int = field(default=0) + #: How much of a gRPC frame that was flushed for being larger than the cap is still + #: to arrive. Passed over when it does, so the frame after it is found where it + #: starts rather than read out of the middle of the one before. + grpc_skip: int = field(default=0) + #: The body would have decoded to more than a filter may hold (`stream_max_size`), + #: so it was left as it arrived and the full-stream action decides what happens. + decoded_too_large: bool = field(default=False) + + +@dataclass +class InternalHttpBuffer: + """Internal class to handle HTTP messages""" + + _url_buffer: bytes = field(default_factory=bytes) + _raw_header_fields: dict[str, str | list[str]] = field(default_factory=dict) + _header_fields: dict[str, str] = field(default_factory=dict) + _body_buffer: bytes = field(default_factory=bytes) + _status_buffer: bytes = field(default_factory=bytes) + _current_header_field: bytes = field(default_factory=bytes) + _current_header_value: bytes = field(default_factory=bytes) + _ws_packet_stream: bytes = field(default_factory=bytes) + + +class _TooLarge(Exception): + """A body that would decode to more than the limit it was decoded under.""" + + +def _inflate(data: bytes, wbits: int, limit: int) -> bytes: + decoder = zlib.decompressobj(wbits) + out = decoder.decompress(data, limit + 1) + if len(out) > limit or decoder.unconsumed_tail: + raise _TooLarge() + out += decoder.flush(limit + 1 - len(out)) + if len(out) > limit: + raise _TooLarge() + return out + + +def _gunzip(data: bytes, limit: int) -> bytes: + with gzip.GzipFile(fileobj=io.BytesIO(data)) as f: + out = f.read(limit + 1) + if len(out) > limit: + raise _TooLarge() + return out + + +def _unbrotli(data: bytes, limit: int) -> bytes: + decoder = brotli.Decompressor() + out = decoder.process(data, output_buffer_limit=limit + 1) + if len(out) > limit: + raise _TooLarge() + if not decoder.is_finished(): + # What `brotli.decompress` says about a truncated stream, said here too: the + # incremental decoder just stops, and half a body is not the body. + raise brotli.error("the brotli stream ends before it is finished") + return out + + +def _unzstd(data: bytes, limit: int) -> bytes: + out = zstd_compat.decompress(data, max_length=limit + 1) + if len(out) > limit: + raise _TooLarge() + return out + + +def _flush_body(parser: "InternalCallbackHandler") -> None: + """Throw away the body in hand: the buffer it grows in, and not only the view of it. + + Only the view used to be cleared, and the size it counted taken off the total — so on + a gRPC stream, whose body is handed over as it grows, the buffer went on growing past + every flush while the count went negative and stopped triggering one at all. + """ + msg = parser.msg + buffered = parser.buffers._body_buffer + if parser.release_body_chunks: + # What has been framed is gone already. Of the rest, a header that has fully + # arrived says how long its frame is, and what is still to come of that frame is + # skipped; a partial header is a few bytes, and kept. + tail = buffered[msg.grpc_consumed:] + keep = tail + if len(tail) >= GRPC_HEADER_SIZE: + length = int.from_bytes(tail[1:GRPC_HEADER_SIZE], "big") + msg.grpc_skip = max(0, GRPC_HEADER_SIZE + length - len(tail)) + keep = b"" + msg.total_size -= len(buffered) - len(keep) + parser.buffers._body_buffer = keep + msg.body = keep + msg.grpc_consumed = 0 + return + msg.total_size -= len(buffered) + len(msg.body or b"") + parser.buffers._body_buffer = b"" + if msg.body is not None: + msg.body = b"" + + +class InternalCallbackHandler: + buffers = InternalHttpBuffer() + msg = InternalHTTPMessage() + save_body = True + raised_error = False + has_begun = False + messages: deque[InternalHTTPMessage] = deque() + _ws_extentions = None + _ws_raised_error = False + release_message_headers = True + #: Hand the message over as its body arrives, not only when it is finished. + #: + #: Off everywhere but the gRPC models, and each model gets a parser of its own + #: (`_parser_class`), so `HttpRequest` keeps its contract exactly: called twice, once + #: with the headers and once with the whole message. It exists because a gRPC stream + #: that is answered message by message — a server-streaming or bidirectional RPC — + #: has a body that does not finish until the stream does, so a filter shown only + #: finished bodies would be called once, at the end, which is too late to be a + #: firewall. + release_body_chunks = False + #: Whether a chunk has arrived that has not been handed over yet. + _body_chunk_pending = False + #: The most a body may decode to, and a WebSocket message decompress to. Set from + #: `stream_max_size` before every parse, because what a filter may hold is the + #: operator's number: a body arrives under that cap compressed, and a megabyte of + #: gzip is a gigabyte of zeroes — or of anything else that compresses as well. + max_decoded_size = DEFAULT_STREAM_MAX_SIZE + + def reset_data(self): + self.msg = InternalHTTPMessage() + self.buffers = InternalHttpBuffer() + self.messages.clear() + + def on_message_begin(self): + self.buffers = InternalHttpBuffer() + self.msg = InternalHTTPMessage() + self.has_begun = True + self._body_chunk_pending = False + + def on_url(self, url): + self.buffers._url_buffer += url + self.msg.total_size += len(url) + + def on_url_complete(self): + self.msg.url = self.buffers._url_buffer.decode(errors="ignore") + self.buffers._url_buffer = b"" + + def on_status(self, status: bytes): + self.msg.total_size += len(status) + self.buffers._status_buffer += status + + def on_status_complete(self): + self.msg.status = self.buffers._status_buffer.decode(errors="ignore") + self.buffers._status_buffer = b"" + + def on_header_field(self, field): + self.msg.total_size += len(field) + self.buffers._current_header_field += field + + def on_header_field_complete(self): + pass # Nothing to do + + def on_header_value(self, value): + self.msg.total_size += len(value) + self.buffers._current_header_value += value + + def on_header_value_complete(self): + if self.buffers._current_header_field: + k, v = ( + self.buffers._current_header_field.decode(errors="ignore"), + self.buffers._current_header_value.decode(errors="ignore"), + ) + old_value = self.buffers._raw_header_fields.get(k, None) + + # raw headers are stored as thay were, considering to check changes between headers encoding + if isinstance(old_value, list): + old_value.append(v) + elif isinstance(old_value, str): + self.buffers._raw_header_fields[k] = [old_value, v] + else: + self.buffers._raw_header_fields[k] = v + + # Decoding headers normally + kl = k.lower() + if kl in self.buffers._header_fields: + self.buffers._header_fields[kl] += ( + f", {v}" # Should be considered as a single list separated by commas as said in the RFC + ) + else: + self.buffers._header_fields[kl] = v + + self.buffers._current_header_field = b"" + self.buffers._current_header_value = b"" + + def on_headers_complete(self): + self.msg.headers = self.buffers._raw_header_fields + self.msg.lheaders = self.buffers._header_fields + self.buffers._raw_header_fields = {} + self.buffers._current_header_field = b"" + self.buffers._current_header_value = b"" + self.msg.headers_complete = True + self.msg.method = self.method_parsed + self.msg.status_code = self.status_code + self.msg.content_length = self.content_length_parsed + self.msg.should_upgrade = self.should_upgrade + self.msg.keep_alive = self.keep_alive + self.msg.http_version = self.http_version + self.msg.content_type = self.content_type + self.msg.content_encoding = self.content_encoding + self.msg.user_agent = self.user_agent + + def on_body(self, body: bytes): + if self.save_body: + self.msg.total_size += len(body) + self.buffers._body_buffer += body + if self.release_body_chunks: + # Visible as it arrives rather than only at the end. **Undecoded**, and + # that is not an oversight: `content-encoding` is undone once, in + # `on_message_complete`, and half a gzip stream is not half a body. The + # models that read this decline while an encoding is in the way. + self.msg.body = self.buffers._body_buffer + self._body_chunk_pending = True + + def on_message_complete(self): + self.msg.body = self.buffers._body_buffer + self.msg.should_upgrade = self.should_upgrade + self.buffers._body_buffer = b"" + encodings = [ele.strip() for ele in self.content_encoding.lower().split(",")] + decode_success = True + decoding_body = self.msg.body + # Every step under the same limit, so a stack of encodings cannot multiply past it. + limit = self.max_decoded_size + try: + for enc in reversed(encodings): + if not enc: + continue + if enc == "deflate": + # Both spellings, because both are on the wire. RFC 7230 defines + # `deflate` as the zlib format of RFC 1950, and plenty of servers + # send the raw stream instead — browsers accept either, and a decoder + # that takes only one lets half the compressed traffic reach a filter + # still compressed, where a pattern finds nothing and the only trace + # is a line on stdout saying it skipped. + for wbits in (-zlib.MAX_WBITS, zlib.MAX_WBITS): + try: + decoding_body = _inflate(decoding_body, wbits, limit) + except zlib.error: + continue + break + else: + print("Error decompressing deflate: neither raw nor zlib-wrapped: " + "skipping", flush=True) + decode_success = False + break + elif enc == "br": + try: + decoding_body = _unbrotli(decoding_body, limit) + except _TooLarge: + raise + except Exception as e: + print(f"Error decompressing brotli: {e}: skipping", flush=True) + decode_success = False + break + elif ( + enc == "gzip" or enc == "x-gzip" + ): # https://datatracker.ietf.org/doc/html/rfc2616#section-3.5 + try: + decoding_body = _gunzip(decoding_body, limit) + except _TooLarge: + raise + except Exception as e: + print(f"Error decompressing gzip: {e}: skipping", flush=True) + decode_success = False + break + elif enc == "zstd": + try: + decoding_body = _unzstd(decoding_body, limit) + except _TooLarge: + raise + except Exception as e: + print(f"Error decompressing zstd: {e}: skipping", flush=True) + decode_success = False + break + elif enc == "identity": + pass # No need to do anything https://datatracker.ietf.org/doc/html/rfc2616#section-3.5 (it's possible to be found also if it should't be used) + else: + decode_success = False + break + except _TooLarge: + print(f"[WARNING] The body decodes to more than {limit} bytes " + f"(FGEX_STREAM_MAX_SIZE): left as it arrived", flush=True) + self.msg.decoded_too_large = True + decode_success = False + + if decode_success: + self.msg.body = decoding_body + self.msg.body_decoded = True + + self.msg.message_complete = True + self.has_begun = False + if not self._packet_to_stream(): + self.messages.append(self.msg) + + @property + def user_agent(self) -> str: + return self.msg.lheaders.get("user-agent", "") + + @property + def content_encoding(self) -> str: + return self.msg.lheaders.get("content-encoding", "") + + @property + def content_type(self) -> str: + return self.msg.lheaders.get("content-type", "") + + @property + def keep_alive(self) -> bool: + return self.should_keep_alive + + @property + def should_upgrade(self) -> bool: + return self.is_upgrading + + @property + def http_version(self) -> str: + """`"1.1"`, `"1.0"`, `""` if the parser has not read a version yet. + + Written `if self.major and self.minor` before — so a minor of zero is falsy and + HTTP/1.0, and HTTP/2.0, both reported the empty string. A filter keying on the + version never saw either, and nothing said why. + """ + if self.major is None or self.minor is None: + return "" + return f"{self.major}.{self.minor}" + + @property + def method_parsed(self) -> str: + return self.method + + @property + def total_size(self) -> int: + """Total size used by the parser""" + tot = self.msg.total_size + for msg in self.messages: + tot += msg.total_size + return tot + + @property + def content_length_parsed(self) -> int: + return self.content_length + + def _is_input(self) -> bool: + raise NotImplementedError() + + def _packet_to_stream(self): + return self.should_upgrade and self.save_body + + def _stream_parser(self, data: bytes): + if self.msg.upgrading_to_ws: + if self._ws_raised_error: + self.msg.stream += data + self.msg.total_size += len(data) + return + self.buffers._ws_packet_stream += data + while True: + try: + new_frame, self.buffers._ws_packet_stream = ( + self._parse_websocket_frame(self.buffers._ws_packet_stream) + ) + except Exception: + print( + "[WARNING] Websocket parsing failed, passing data to stream...", + flush=True, + ) + traceback.print_exc() + self._ws_raised_error = True + self.msg.stream += self.buffers._ws_packet_stream + self.buffers._ws_packet_stream = b"" + self.msg.total_size += len(data) + return + if new_frame is None: + break + self.msg.ws_stream.append(new_frame) + self.msg.total_size += len(new_frame.data) + if self.msg.upgrading_to_h2: + self.msg.total_size += len(data) + self.msg.stream += data + + def _parse_websocket_ext(self): + ext_ws = [] + req_ext = [] + for ele in self.msg.lheaders.get("sec-websocket-extensions", "").split(","): + for xt in ele.split(";"): + req_ext.append(xt.strip().lower()) + + for ele in req_ext: + if ele == "permessage-deflate": + ext_ws.append(PerMessageDeflate(False, False, 15, 15)) + return ext_ws + + def _parse_websocket_frame(self, data: bytes) -> tuple[Frame | None, bytes]: + if self._ws_extentions is None: + if self._is_input(): + self._ws_extentions = [] # Fallback to no options + else: + self._ws_extentions = ( + self._parse_websocket_ext() + ) # Extentions used are choosen by the server response + read_buffering = bytearray() + + def read_exact(n: int): + nonlocal read_buffering + buffer = bytearray(read_buffering) + while len(buffer) < n: + data = yield + if data is None: + raise RuntimeError("Should not send None to this generator") + buffer.extend(data) + new_data = bytes(buffer[:n]) + read_buffering = buffer[n:] + return new_data + + parsing = Frame.parse( + read_exact, extensions=self._ws_extentions, mask=self._is_input(), + # A compressed message is inflated by the extension, and without a bound on + # it a frame of a few kilobytes is as many gigabytes as it says it is. Past + # the bound the frame is refused, and the connection's data is carried on as + # a plain stream — the same as any frame that will not parse. + max_size=self.max_decoded_size, + ) + parsing.send(None) + try: + parsing.send(bytearray(data)) + except StopIteration as e: + return e.value, read_buffering + + return None, read_buffering + + def parse_data(self, data: bytes): + if self._packet_to_stream(): # This is a websocket upgrade! + self._stream_parser(data) + else: + try: + reason, consumed = self.execute(data) + if reason == PAUSED_UPGRADE: + self.msg.upgrading_to_ws = True + self.msg.message_complete = True + self._stream_parser(data[consumed:]) + elif reason == PAUSED_H2_UPGRADE: + self.msg.upgrading_to_h2 = True + self.msg.message_complete = True + self._stream_parser(data[consumed:]) + except Exception as e: + self.raised_error = True + raise e + + def pop_message(self): + return self.messages.popleft() + + def pop_all_messages(self): + tmp = self.messages + self.messages = deque() + return tmp + + def __repr__(self): + return f"" + + +class InternalHttpRequest(InternalCallbackHandler, _llhttp.Request): + def __init__(self): + super(InternalCallbackHandler, self).__init__() + super(_llhttp.Request, self).__init__() + + def _is_input(self): + return True + + +class InternalHttpResponse(InternalCallbackHandler, _llhttp.Response): + def __init__(self): + super(InternalCallbackHandler, self).__init__() + super(_llhttp.Response, self).__init__() + + def _is_input(self): + return False + + +class HttpHistory: + """ + HTTP History handler for pyfilters. + Provides access to completed previous requests and responses in the current TCP stream. + """ + + def __init__( + self, + requests: list["HttpFullRequest"] | None = None, + responses: list["HttpFullResponse"] | None = None, + ): + self._requests = list(requests) if requests is not None else [] + self._responses = list(responses) if responses is not None else [] + + @property + def requests(self) -> list["HttpFullRequest"]: + """List of previous completed HTTP requests""" + return self._requests.copy() + + @property + def responses(self) -> list["HttpFullResponse"]: + """List of previous completed HTTP responses""" + return self._responses.copy() + + @classmethod + def _fetch_packet(cls, internal_data: DataStreamCtx): + if ( + internal_data.current_pkt is None + or internal_data.current_pkt.is_stream is False + ): + raise NotReadyToRun() + + for key in ("http_module", "http_full", "http_header"): + obj = internal_data.call_mem.get(f"_fetched_obj_{key}") + if obj is not None: + if isinstance(obj, list) and obj: + return [item.history for item in obj] + elif hasattr(obj, "history"): + return obj.history + + req_history_deque = internal_data.data_handler_context.get( + "http_history_requests", deque() + ) + resp_history_deque = internal_data.data_handler_context.get( + "http_history_responses", deque() + ) + return HttpHistory(list(req_history_deque), list(resp_history_deque)) + + def __repr__(self): + return f"" + + +HttpStreamHistory = HttpHistory + + +def _held(entry: "InternalBasicHttpMetaClass") -> int: + msg = entry._message + return max(msg.total_size, len(msg.body or b"")) + len(msg.stream) + + +def _trim_history(history: deque, budget: int) -> None: + """Keep a direction's history under the byte budget a stream is held to. + + `FGEX_MAX_HISTORY_SIZE` counts messages, and each of them can be as large as the cap + on the stream — so a keep-alive connection could hold a hundred bodies of a megabyte + each, per direction, for as long as it stayed open. The oldest go first; the newest + is always kept, since it fitted under the cap on its own. + """ + total = sum(_held(entry) for entry in history) + while len(history) > 1 and total > budget: + total -= _held(history.popleft()) + + +class InternalBasicHttpMetaClass: + """Internal class to handle HTTP requests and responses""" + + def __init__( + self, + parser: InternalHttpRequest | InternalHttpResponse, + msg: InternalHTTPMessage, + ): + self._parser = parser + self.raised_error = False + self._message: InternalHTTPMessage | None = msg + self._history: HttpHistory | None = None + self._contructor_hook() + + def _contructor_hook(self): + pass + + @property + def history(self) -> HttpHistory: + """HTTP History for the current stream connection""" + if self._history is None: + return HttpHistory([], []) + return self._history + + @property + def total_size(self) -> int: + """Total size of the message""" + return self._message.total_size + + @property + def url(self) -> str | None: + """URL of the message""" + return self._message.url + + @property + def headers(self) -> dict[str, str]: + """Headers of the message""" + return self._message.headers + + @property + def user_agent(self) -> str: + """User agent of the message""" + return self._message.user_agent + + @property + def content_encoding(self) -> str: + """Content encoding of the message""" + return self._message.content_encoding + + @property + def body(self) -> bytes: + """Body of the message""" + return self._message.body + + @property + def headers_complete(self) -> bool: + """If the headers are complete""" + return self._message.headers_complete + + @property + def message_complete(self) -> bool: + """If the message is complete""" + return self._message.message_complete + + @property + def http_version(self) -> str: + """HTTP version of the message""" + return self._message.http_version + + @property + def keep_alive(self) -> bool: + """If the message should keep alive""" + return self._message.keep_alive + + @property + def should_upgrade(self) -> bool: + """If the message should upgrade""" + return self._message.should_upgrade + + @property + def content_length(self) -> int | None: + """Content length of the message""" + return self._message.content_length + + @property + def upgrading_to_h2(self) -> bool: + """If the message is upgrading to HTTP/2""" + return self._message.upgrading_to_h2 + + @property + def upgrading_to_ws(self) -> bool: + """If the message is upgrading to Websocket""" + return self._message.upgrading_to_ws + + @property + def ws_stream(self) -> list[Frame]: + """Websocket stream""" + return self._message.ws_stream + + @property + def stream(self) -> bytes: + """Stream of the message""" + return self._message.stream + + def get_header(self, header: str, default=None) -> str: + """Get a header from the message without caring about the case""" + return self._message.lheaders.get(header.lower(), default) + + @classmethod + def _should_release_message_headers(cls) -> bool: + return True + + @classmethod + def _should_release_body_chunks(cls) -> bool: + return False + + @staticmethod + def _before_fetch_callable_checks(internal_data: DataStreamCtx) -> bool: + raise NotImplementedError() + + @staticmethod + def _parser_class() -> str: + raise NotImplementedError() + + @classmethod + def _fetch_packet(cls, internal_data: DataStreamCtx): + if ( + internal_data.current_pkt is None + or internal_data.current_pkt.is_stream is False + ): + raise NotReadyToRun() + + ParserType = ( + InternalHttpRequest + if internal_data.current_pkt.is_input + else InternalHttpResponse + ) + parser_key = f"{cls._parser_class()}_{'in' if internal_data.current_pkt.is_input else 'out'}" + + # A connection this filter met already open, and in the middle of a message: it + # cannot be read from where it came in, and it did nothing wrong. See below. + if internal_data.data_handler_context.get(f"{parser_key}_met_mid_message"): + raise NotReadyToRun() + + parser = internal_data.data_handler_context.get(parser_key, None) + if parser is None or parser.raised_error: + parser: InternalHttpRequest | InternalHttpResponse = ParserType() + internal_data.data_handler_context[parser_key] = parser + + parser.release_message_headers = cls._should_release_message_headers() + # Set here and not only in the constructor hook, for the same reason the line + # above is: parsing happens before any instance exists, and a flag applied + # afterwards would take effect one packet late. + parser.release_body_chunks = cls._should_release_body_chunks() + parser.max_decoded_size = internal_data.stream_max_size + + + if not internal_data.call_mem.get( + cls._parser_class(), False + ): # Need to parse HTTP + internal_data.call_mem[cls._parser_class()] = True + parser.pop_all_messages() # Delete content on message deque + + # Setting websocket options if needed to the client parser + if internal_data.current_pkt.is_input: + ext_opt = internal_data.data_handler_context.get( + f"{cls._parser_class()}_ws_options_client" + ) + if ext_opt is not None and parser._ws_extentions != ext_opt: + parser._ws_extentions = ext_opt + + # Memory size managment + if ( + parser.total_size + len(internal_data.current_pkt.data) + > internal_data.stream_max_size + ): + match internal_data.full_stream_action: + case FullStreamAction.FLUSH: + # Deleting parser and re-creating it + parser.messages.clear() + parser.msg.total_size -= len(parser.msg.stream) + parser.msg.stream = b"" + # Whatever the body is at: none yet for a message still in its + # headers — where `len(body)` used to raise, so headers past the cap + # were an exception rather than a flush and the packet failed open. + _flush_body(parser) + print("[WARNING] Flushing stream", flush=True) + if ( + parser.total_size + len(internal_data.current_pkt.data) + > internal_data.stream_max_size + ): + parser.reset_data() + case FullStreamAction.REJECT: + raise StreamFullReject() + case FullStreamAction.DROP: + raise StreamFullDrop() + case FullStreamAction.ACCEPT: + raise NotReadyToRun() + + internal_data.call_mem["headers_were_set"] = ( + parser.msg.headers_complete + ) # This information is usefull for building the real object + + try: + parser.parse_data(internal_data.current_pkt.data) + except Exception as e: + # **Met already open, and not yet read a single thing cleanly.** The + # datapath says when this filter's state began after its connection did: + # a queued filter that started, or was rebuilt, with the connection open, + # or new code taking over one. Its first bytes here can be the middle of a + # request — the rest of an upload — and llhttp reads that as a malformed + # message. It is not one, and judging it as one refused valid uploads in + # flight whenever a filter changed (with REJECT), or reported a working + # client as "not HTTP" (without). That direction is carried unfiltered + # until the connection closes instead. A connection met at the start of + # a message parses, is in step from then on, and is judged as usual. + if ( + internal_data.filter_glob.get("__firegex_joined_late") + and not internal_data.data_handler_context.get(f"{parser_key}_in_step") + ): + internal_data.data_handler_context[f"{parser_key}_met_mid_message"] = True + _met_mid_message() + raise NotReadyToRun() + action = internal_data.invalid_encoding_action + # Said once per connection and direction, and in one line: the parser is + # started afresh on the next packet, so traffic it cannot read fails on + # every packet — and a traceback each was the whole log, several hundred + # times, saying less than this does. + told = f"{parser_key}_unreadable_told" + if not internal_data.data_handler_context.get(told): + internal_data.data_handler_context[told] = True + print( + f"[warn] [http] this connection's " + f"{'requests' if internal_data.current_pkt.is_input else 'responses'}" + f" are not HTTP this filter can read ({e}): " + + { + ExceptionAction.ACCEPT: "they are carried without its HTTP " + "filters seeing them. FGEX_INVALID_ENCODING_ACTION = " + "ExceptionAction.REJECT refuses such connections instead.", + ExceptionAction.REJECT: "the connection is refused " + "(FGEX_INVALID_ENCODING_ACTION).", + ExceptionAction.DROP: "the packet is dropped " + "(FGEX_INVALID_ENCODING_ACTION).", + }.get(action, "reported as the filter raising."), + flush=True, + ) + match action: + case ExceptionAction.REJECT: + raise RejectConnection() + case ExceptionAction.DROP: + raise DropPacket() + case ExceptionAction.NOACTION: + raise e + case ExceptionAction.ACCEPT: + raise NotReadyToRun() + + # Read cleanly once: from here on this direction is a stream like any other. + internal_data.data_handler_context[f"{parser_key}_in_step"] = True + + if any(msg.decoded_too_large for msg in parser.messages): + # Too large once decoded is too large: the operator's answer to a stream + # past the cap is the answer here too, rather than a second policy nobody + # chose. Flushing has nothing left to throw away, so the message goes on + # as it arrived — still encoded, as an encoding this cannot undo would. + match internal_data.full_stream_action: + case FullStreamAction.REJECT: + raise StreamFullReject() + case FullStreamAction.DROP: + raise StreamFullDrop() + case FullStreamAction.ACCEPT: + raise NotReadyToRun() + + if parser.should_upgrade and not internal_data.current_pkt.is_input: + # Creating ws_option for the client + if not internal_data.data_handler_context.get( + f"{cls._parser_class()}_ws_options_client" + ): + ext = parser._parse_websocket_ext() + internal_data.data_handler_context[ + f"{cls._parser_class()}_ws_options_client" + ] = ext + + # Once the parsers has been triggered, we can return the object if needed + if not cls._before_fetch_callable_checks(internal_data): + raise NotReadyToRun() + + messages_tosend: list[InternalHTTPMessage] = [] + for i in range(len(parser.messages)): + messages_tosend.append(parser.pop_message()) + + if len(messages_tosend) > 0: + internal_data.call_mem["headers_were_set"] = ( + False # New messages completed so the current message headers were not set in this case + ) + + if ( + not internal_data.call_mem["headers_were_set"] + and parser.msg.headers_complete + and not parser.msg.message_complete + and parser.release_message_headers + ): + # The message still being read, handed over early because its headers are + # already known — which is what lets a filter act before a body arrives. + # + # `not message_complete` is the load-bearing half. `on_message_complete` + # appends the finished message to the queue but leaves `parser.msg` pointing + # at it, so without this a packet carrying two pipelined messages delivered + # the second one twice: once from the queue and once from here. Blocking is + # idempotent and survived that, which is why it went unnoticed — anything + # that counts did not. + messages_tosend.append(parser.msg) + + if ( + parser.release_body_chunks + and parser._body_chunk_pending + and not parser.msg.message_complete + and not any(msg is parser.msg for msg in messages_tosend) + ): + # A body still arriving, handed over as it does. Identity rather than + # equality on the guard: `InternalHTTPMessage` is a dataclass, so `in` would + # compare every field of every message to decide something that is a question + # about which object this is. + messages_tosend.append(parser.msg) + parser._body_chunk_pending = False + + if parser._packet_to_stream(): + messages_tosend.append( + parser.msg + ) # Also the current message needs to beacase a stream is going on + + messages_to_call = len(messages_tosend) + + if messages_to_call == 0: + raise NotReadyToRun() + + raw_max = internal_data.filter_glob.get("FGEX_MAX_HISTORY_SIZE", 100) + try: + max_history = max(0, int(raw_max)) + except (ValueError, TypeError): + max_history = 100 + + req_history_deque: deque = ( + internal_data.data_handler_context.setdefault( + "http_history_requests", deque(maxlen=max_history) + ) + ) + if req_history_deque.maxlen != max_history: + req_history_deque = deque(req_history_deque, maxlen=max_history) + internal_data.data_handler_context["http_history_requests"] = req_history_deque + + resp_history_deque: deque = ( + internal_data.data_handler_context.setdefault( + "http_history_responses", deque(maxlen=max_history) + ) + ) + if resp_history_deque.maxlen != max_history: + resp_history_deque = deque(resp_history_deque, maxlen=max_history) + internal_data.data_handler_context["http_history_responses"] = resp_history_deque + + built_instances = [] + for msg in messages_tosend: + history_snapshot = HttpHistory( + list(req_history_deque), list(resp_history_deque) + ) + instance = cls(parser, msg) + instance._history = history_snapshot + built_instances.append(instance) + + if msg.message_complete and not msg.added_to_history: + msg.added_to_history = True + if internal_data.current_pkt.is_input: + req_history_deque.append(HttpFullRequest(parser, msg)) + _trim_history(req_history_deque, internal_data.stream_max_size) + else: + resp_history_deque.append(HttpFullResponse(parser, msg)) + _trim_history(resp_history_deque, internal_data.stream_max_size) + + if len(built_instances) == 1: + res = built_instances[0] + internal_data.call_mem[f"_fetched_obj_{cls._parser_class()}"] = res + return res + + internal_data.call_mem[f"_fetched_obj_{cls._parser_class()}"] = built_instances + return built_instances + + + +class HttpRequest(InternalBasicHttpMetaClass): + """ + HTTP Request handler + This data handler will be called twice, first with the headers complete, and second with the body complete + """ + + @staticmethod + def _before_fetch_callable_checks(internal_data: DataStreamCtx): + return internal_data.current_pkt.is_input + + @property + def method(self) -> bytes: + """Method of the request""" + return self._message.method + + @staticmethod + def _parser_class() -> str: + return "http_module" + + def __repr__(self): + return f"" + + +class HttpResponse(InternalBasicHttpMetaClass): + """ + HTTP Response handler + This data handler will be called twice, first with the headers complete, and second with the body complete + """ + + @staticmethod + def _before_fetch_callable_checks(internal_data: DataStreamCtx): + return not internal_data.current_pkt.is_input + + @property + def status_code(self) -> int | None: + """The numeric status, e.g. `404`. + + This used to hand back the reason phrase while being documented and annotated as + an int, so `if res.status_code == 500` never matched and nothing said why. It was + not carelessness: the binding did not expose llhttp's status code, and there was + no way to reach it. The binding ships with this package now and does. + """ + return self._message.status_code + + @property + def status_phrase(self) -> str | None: + """The reason phrase, e.g. `"Not Found"`. + + Free text — a server may write anything here — so read it and decide on the code + above, a header, or the body. + """ + return self._message.status + + + @staticmethod + def _parser_class() -> str: + return "http_module" + + def __repr__(self): + return f"" + + +class HttpFullRequest(HttpRequest): + """ + HTTP Request handler + This data handler will be called when the request data is complete + """ + + @classmethod + def _should_release_message_headers(cls) -> bool: + return False + + def _contructor_hook(self): + self._parser.release_message_headers = False + + @staticmethod + def _parser_class() -> str: + return "http_full" + + def __repr__(self): + return f"" + + +class HttpFullResponse(HttpResponse): + """ + HTTP Response handler + This data handler will be called when the response data is complete + """ + + @classmethod + def _should_release_message_headers(cls) -> bool: + return False + + def _contructor_hook(self): + self._parser.release_message_headers = False + + @staticmethod + def _parser_class() -> str: + return "http_full" + + def __repr__(self): + return f"" + + + +class HttpRequestHeader(HttpRequest): + """ + HTTP Request Header handler + This data handler will be called only once, the headers are complete, the body will be empty and not buffered + """ + + def _contructor_hook(self): + self._parser.save_body = False + + @staticmethod + def _parser_class() -> str: + return "http_header" + + +class HttpResponseHeader(HttpResponse): + """ + HTTP Response Header handler + This data handler will be called only once, the headers are complete, the body will be empty and not buffered + """ + + def _contructor_hook(self): + self._parser.save_body = False + + @staticmethod + def _parser_class() -> str: + return "http_header" + + +#: The header in front of every gRPC message: one flag byte, then a big-endian length. +GRPC_HEADER_SIZE = 5 + + +@dataclass +class GrpcFrame: + """One length-prefixed message out of a gRPC body.""" + + #: Whether this message is compressed, which its own flag byte says. What it is + #: compressed *with* is `grpc-encoding` on the message that carried it. + compressed: bool + #: The bytes between the length prefix and the next one — a protobuf message, in + #: whatever schema the two ends agreed on out of band. + payload: bytes + + +def _grpc_frames(msg: InternalHTTPMessage) -> list[GrpcFrame]: + """Take every complete message the body has grown since the last look. + + Nothing is allocated on a claimed length: a frame is taken only once the bytes behind + it have actually arrived, so a header claiming four gigabytes costs nothing but the + wait — and the wait is already bounded by `FGEX_STREAM_MAX_SIZE`, which is what stops + a sender buying memory in this process. + """ + body = msg.body or b"" + taken: list[GrpcFrame] = [] + at = msg.grpc_consumed + if msg.grpc_skip: + passed = min(msg.grpc_skip, len(body) - at) + at += passed + msg.grpc_skip -= passed + while len(body) - at >= GRPC_HEADER_SIZE: + length = int.from_bytes(body[at + 1 : at + GRPC_HEADER_SIZE], "big") + end = at + GRPC_HEADER_SIZE + length + if len(body) < end: + break + taken.append( + GrpcFrame(compressed=body[at] != 0, payload=body[at + GRPC_HEADER_SIZE : end]) + ) + at = end + msg.grpc_consumed = at + return taken + + +def _forget_framed(parser: InternalCallbackHandler, msg: InternalHTTPMessage) -> None: + """Drop what has been framed from a body that is still arriving. + + A streaming RPC's body ends only when the stream does, and every frame in it had been + handed over already — so keeping it bought nothing but memory, one frame at a time, + for as long as the stream stayed open. + """ + if msg is not parser.msg or msg.message_complete or not msg.grpc_consumed: + return + done = msg.grpc_consumed + parser.buffers._body_buffer = parser.buffers._body_buffer[done:] + msg.body = parser.buffers._body_buffer + msg.total_size -= done + msg.grpc_consumed = 0 + + +def _speaks_grpc(msg: InternalHTTPMessage) -> bool: + return (msg.content_type or "").lower().startswith("application/grpc") + + +def _body_can_be_framed(msg: InternalHTTPMessage) -> bool: + """Whether the body as it stands can be read as gRPC frames at all. + + A `content-encoding` is undone once, when the message finishes, so while one is in + the way the bytes in hand are compressed and the framing is not in them. gRPC does not + use it — it compresses each message on its own and says so in the frame's flag byte — + so in practice this only ever declines for traffic that was never gRPC to begin with. + """ + if msg.message_complete: + return True + encodings = {e.strip() for e in (msg.content_encoding or "").lower().split(",")} + return not (encodings - {"", "identity"}) + + +class GrpcMessage(InternalBasicHttpMetaClass): + """One gRPC message, in either direction. + + gRPC is HTTP/2, so everything a filter could already ask about the exchange — the + method in the path, the headers, the trailer section carrying `grpc-status` — arrives + through `HttpRequest` and `HttpResponse`, because the engine renders every version of + HTTP as HTTP/1.1 before the chain sees it. What did not arrive was the *body*: a gRPC + body is a sequence of length-prefixed messages, so a filter reading `request.body` was + reading a five-byte header glued to a protobuf blob, and a pattern written against the + payload had to know to skip it. + + This hands over one message at a time, as each one completes. It is called **per + message and not per body**, which is what makes it usable on a streaming RPC: a + server-streaming or bidirectional call has a body that does not finish until the + stream does, and a filter shown only finished bodies would be called once, at the end. + + What it does **not** do is decompress a message whose own flag byte says it is + compressed, or decode the protobuf inside it. The first because what it is compressed + with is the peers' agreement (`grpc-encoding`) and guessing is how a filter comes to + read something that is not there; the second because without the `.proto` there is no + schema, and a pattern against the raw payload is what a ruleset has today. + """ + + @staticmethod + def _before_fetch_callable_checks(internal_data: DataStreamCtx) -> bool: + return True + + @staticmethod + def _parser_class() -> str: + return "http_grpc" + + @classmethod + def _should_release_message_headers(cls) -> bool: + # There is no message to show at the head: a gRPC exchange is its messages, and + # what is in the headers is what `HttpRequest` is for. + return False + + @classmethod + def _should_release_body_chunks(cls) -> bool: + return True + + def _contructor_hook(self): + self._parser.release_message_headers = False + self._parser.release_body_chunks = True + self._frame: GrpcFrame | None = None + self._is_input: bool = True + + @classmethod + def _fetch_packet(cls, internal_data: DataStreamCtx): + # The base drives the parser and decides which messages are ready; this only + # takes the gRPC framing out of the ones that have it. Written that way round so + # there is still exactly one HTTP parser in this library and this is not a second. + carried = super()._fetch_packet(internal_data) + if not isinstance(carried, list): + carried = [carried] + + pieces = [] + for one in carried: + msg = one._message + if not _speaks_grpc(msg) or not _body_can_be_framed(msg): + continue + for frame in _grpc_frames(msg): + piece = cls(one._parser, msg) + piece._history = one._history + piece._frame = frame + piece._is_input = internal_data.current_pkt.is_input + pieces.append(piece) + _forget_framed(one._parser, msg) + + if not pieces: + # Not "nothing matched" but "there is nothing to show yet" — a body half-way + # through a message, or an exchange that is not gRPC at all. The filter is not + # called, which is what `NotReadyToRun` means everywhere else. + raise NotReadyToRun() + + result = pieces[0] if len(pieces) == 1 else pieces + internal_data.call_mem[f"_fetched_obj_{cls._parser_class()}"] = result + return result + + @property + def payload(self) -> bytes: + """This message's bytes: the protobuf, without the length prefix.""" + return self._frame.payload if self._frame else b"" + + @property + def compressed(self) -> bool: + """Whether this message's own flag byte says it is compressed. + + What with is `grpc-encoding` on the message that carried it. Nothing here + decompresses it, so `payload` is the compressed bytes when this is true. + """ + return bool(self._frame and self._frame.compressed) + + @property + def is_request(self) -> bool: + """Whether this went from the client towards the service.""" + return self._is_input + + @property + def method(self) -> str | None: + """The gRPC method, which is the HTTP path: `/package.Service/Method`. + + Present on both directions, because both halves of an exchange belong to the same + stream and the engine renders the request that opened it. + """ + return self.url + + @property + def grpc_status(self) -> str | None: + """The status, where the message carrying this frame stated one. + + A gRPC status lives in the trailer section, and on a reply that refuses before + sending anything it lives in the headers instead — the *trailers-only* shape. Read + it where it is; on a reply that is still streaming it is not there yet, and this + is `None` rather than a guess. + """ + return self.get_header("grpc-status") + + def __repr__(self): + return ( + f"" + ) + + +class GrpcRequest(GrpcMessage): + """A gRPC message on its way to the service, and nothing coming back.""" + + @staticmethod + def _before_fetch_callable_checks(internal_data: DataStreamCtx) -> bool: + return internal_data.current_pkt.is_input + + @staticmethod + def _parser_class() -> str: + # Its own parser, as `HttpFullRequest` has one beside `HttpRequest`. Not tidiness: + # taking a frame *consumes* it, so two models sharing one parser would mean the + # second one is handed nothing and silently never runs. + return "http_grpc_req" + + +class GrpcResponse(GrpcMessage): + """A gRPC message on its way back to the client.""" + + @staticmethod + def _before_fetch_callable_checks(internal_data: DataStreamCtx) -> bool: + return not internal_data.current_pkt.is_input + + @staticmethod + def _parser_class() -> str: + return "http_grpc_res" diff --git a/fgex-lib/firegex/nfproxy/models/tcp.py b/fgex-lib/firegex/pyfilters/models/tcp.py similarity index 92% rename from fgex-lib/firegex/nfproxy/models/tcp.py rename to fgex-lib/firegex/pyfilters/models/tcp.py index 5717cf3..0f5ec36 100644 --- a/fgex-lib/firegex/nfproxy/models/tcp.py +++ b/fgex-lib/firegex/pyfilters/models/tcp.py @@ -1,6 +1,6 @@ -from firegex.nfproxy.internals.data import DataStreamCtx -from firegex.nfproxy.internals.exceptions import NotReadyToRun, StreamFullDrop, StreamFullReject -from firegex.nfproxy.internals.models import FullStreamAction +from firegex.pyfilters.internals.data import DataStreamCtx +from firegex.pyfilters.internals.exceptions import NotReadyToRun, StreamFullDrop, StreamFullReject +from firegex.pyfilters.internals.models import FullStreamAction class InternalTCPStream: def __init__(self, @@ -32,7 +32,7 @@ class InternalTCPStream: @classmethod def _fetch_packet(cls, internal_data:DataStreamCtx, is_input:bool=False): - if internal_data.current_pkt is None or internal_data.current_pkt.is_tcp is False: + if internal_data.current_pkt is None or internal_data.current_pkt.is_stream is False: raise NotReadyToRun() if internal_data.current_pkt.is_input != is_input: raise NotReadyToRun() diff --git a/fgex-lib/firegex/nfproxy/proxysim/__init__.py b/fgex-lib/firegex/pyfilters/proxysim/__init__.py similarity index 80% rename from fgex-lib/firegex/nfproxy/proxysim/__init__.py rename to fgex-lib/firegex/pyfilters/proxysim/__init__.py index d604cd1..ea8abde 100644 --- a/fgex-lib/firegex/nfproxy/proxysim/__init__.py +++ b/fgex-lib/firegex/pyfilters/proxysim/__init__.py @@ -1,18 +1,14 @@ import socket import os -from firegex.nfproxy.internals import get_filter_names +from firegex.pyfilters.internals import get_filter_names import traceback from multiprocessing import Process -from firegex.nfproxy import ACCEPT, DROP, REJECT, UNSTABLE_MANGLE +from firegex.pyfilters import ACCEPT, DROP, REJECT from rich.markup import escape from rich import print import asyncio from watchfiles import awatch, Change -fake_ip_header = b"FAKE:IP:TCP:HEADERS:" -fake_ip_header_len = len(fake_ip_header) - -MANGLE_WARNING = True class LogLevels: INFO = "INFO" @@ -36,7 +32,7 @@ def load_level_str(level:str): return f"\\[[red bold]{escape(level)}[/]]" def log_print(module:str, *args, level:str = LogLevels.INFO, **kwargs): - return print(f"{load_level_str(level)}[deep_pink4 bold]\\[nfproxy][/][medium_orchid3 bold]\\[{escape(module)}][/]", *args, **kwargs) + return print(f"{load_level_str(level)}[deep_pink4 bold]\\[pyfilters][/][medium_orchid3 bold]\\[{escape(module)}][/]", *args, **kwargs) async def _watch_filter_file(filter_file: str, reload_action): abs_path = os.path.abspath(filter_file) @@ -65,6 +61,7 @@ async def _forward_and_filter(filter_ctx: dict, is_input: bool, is_ipv6: bool, is_tcp: bool, + endpoints: dict, has_to_filter: bool = True): """Asynchronously forward data from reader to writer applying filters.""" try: @@ -81,14 +78,19 @@ async def _forward_and_filter(filter_ctx: dict, if has_to_filter: filter_ctx["__firegex_packet_info"] = { "data": data, - "l4_size": len(data), - "raw_packet": fake_ip_header + data, "is_input": is_input, "is_ipv6": is_ipv6, - "is_tcp": is_tcp + "is_tcp": is_tcp, + # Real addresses, because this proxy knows them. There is no + # invented header any more: a filter reads metadata and edits the + # payload, which is the same contract it gets in production. + "src_ip": endpoints["client_ip"] if is_input else endpoints["server_ip"], + "src_port": endpoints["client_port"] if is_input else endpoints["server_port"], + "dst_ip": endpoints["server_ip"] if is_input else endpoints["client_ip"], + "dst_port": endpoints["server_port"] if is_input else endpoints["client_port"], } try: - exec("firegex.nfproxy.internals.handle_packet(globals())", filter_ctx, filter_ctx) + exec("firegex.pyfilters.internals.handle_packet(globals())", filter_ctx, filter_ctx) except Exception as e: log_print("packet-handling", f"Error while executing filter: {escape(str(e))}, forwarding normally from now", @@ -132,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 @@ -142,22 +144,6 @@ async def _forward_and_filter(filter_ctx: dict, await writer.wait_closed() return - elif action == UNSTABLE_MANGLE.value: - mangled_packet = result.get("mangled_packet") - if mangled_packet is None or not isinstance(mangled_packet, bytes): - log_print("filter-parsing", "No mangled_packet found", level=LogLevels.ERROR) - has_to_filter = False - writer.write(data) - await writer.drain() - continue - log_print("mangle", f"Mangling packet caused by {escape(filter_name)} pyfilter") - if MANGLE_WARNING: - log_print("mangle", - "In the real execution mangling is not so stable as the simulation does, l4_data can be different by data", - level=LogLevels.WARNING) - writer.write(mangled_packet[fake_ip_header_len:]) - await writer.drain() - continue else: log_print("filter-parsing", f"Invalid action {action} found", level=LogLevels.ERROR) has_to_filter = False @@ -182,6 +168,10 @@ async def _handle_connection( """Handle a new incoming connection and create a remote connection.""" addr = writer.get_extra_info('peername') log_print("listener", f"Accepted connection from {escape(addr[0])}:{addr[1]}") + endpoints = { + "client_ip": addr[0], "client_port": addr[1], + "server_ip": target_ip, "server_port": target_port, + } try: remote_reader, remote_writer = await asyncio.open_connection( target_ip, target_port, @@ -204,8 +194,8 @@ async def _handle_connection( traceback.print_exc() filter_ctx = {} # Create asynchronous tasks for bidirectional forwarding. - task1 = asyncio.create_task(_forward_and_filter(filter_ctx, reader, remote_writer, True, ipv6, True, True)) - task2 = asyncio.create_task(_forward_and_filter(filter_ctx, remote_reader, writer, False, ipv6, True, True)) + task1 = asyncio.create_task(_forward_and_filter(filter_ctx, reader, remote_writer, True, ipv6, True, endpoints, True)) + task2 = asyncio.create_task(_forward_and_filter(filter_ctx, remote_reader, writer, False, ipv6, True, endpoints, True)) try: await asyncio.gather(task1, task2) except (KeyboardInterrupt, asyncio.CancelledError): @@ -238,19 +228,21 @@ def _proxy_asyncio_runner(filter_code: str, target_ip: str, target_port: int, lo except KeyboardInterrupt: log_print("listener", "Proxy server stopped", level=LogLevels.WARNING) -def _build_filter(filepath:str, proto:str): +def _build_filter(filepath:str): if os.path.isfile(filepath) is False: raise Exception(f"Filter file {filepath} not found") with open(filepath, "r") as f: filter_code = f.read() - filters = get_filter_names(filter_code, proto) + # No protocol is named here, or anywhere else: the file shows which one it speaks by + # what its filters ask for. Naming it in a second place is how a file and its + # declaration drift apart, and the drift shows up as a filter that never runs. + filters = get_filter_names(filter_code) filter_code += ( "\n\n__firegex_pyfilter_enabled = [" + ", ".join([repr(f) for f in filters]) + "]\n" - "__firegex_proto = " + repr(proto) + "\n" - "import firegex.nfproxy.internals\n" - "firegex.nfproxy.internals.compile(globals())\n" + "import firegex.pyfilters.internals\n" + "firegex.pyfilters.internals.compile(globals())\n" ) filter_glob = {} @@ -258,7 +250,7 @@ def _build_filter(filepath:str, proto:str): return filter_code -def run_proxy_simulation(filter_file:str, proto:str, target_ip:str, target_port:int, local_ip:str = None, local_port:int = 7474, ipv6:bool = False): +def run_proxy_simulation(filter_file:str, target_ip:str, target_port:int, local_ip:str = None, local_port:int = 7474, ipv6:bool = False): if local_ip is None: if ipv6: @@ -267,7 +259,7 @@ def run_proxy_simulation(filter_file:str, proto:str, target_ip:str, target_port: local_ip = "127.0.0.1" if os.path.isfile(filter_file) is False: - raise Exception(f"\\[nfproxy]\\[init] Filter file {filter_file} not found") + raise Exception(f"\\[pyfilters]\\[init] Filter file {filter_file} not found") else: filter_file = os.path.abspath(filter_file) @@ -283,7 +275,7 @@ def run_proxy_simulation(filter_file:str, proto:str, target_ip:str, target_port: compiled_filter = None try: - compiled_filter = _build_filter(filter_file, proto) + compiled_filter = _build_filter(filter_file) except Exception: log_print("reloader", f"Failed to build filter {escape(filter_file)}!", level=LogLevels.ERROR) traceback.print_exc() diff --git a/fgex-lib/firegex/regex/__init__.py b/fgex-lib/firegex/regex/__init__.py new file mode 100644 index 0000000..968eb29 --- /dev/null +++ b/fgex-lib/firegex/regex/__init__.py @@ -0,0 +1,342 @@ +"""Matching with the engine firegex actually matches with. + +`fgex` exists so a filter can be tried before it ever reaches a running instance, and +that is only worth anything if the answer is the same one. For Python filters that is +easy — `fgex pyfilters` runs the very library the datapath runs. For patterns it means +hyperscan, because that is what both datapaths use, and a tester built on Python's `re` +would accept backreferences and lookarounds hyperscan rejects and disagree about what +matches. You would tune a pattern here and find out mid-round that it was never valid. + +So this binds `libhs` directly, through ctypes: no build step, no wheel to go stale, and +by construction the same library the product loads. When it is not installed, the tools +say so and stop rather than quietly answering with a different engine. +""" + +import ctypes +import ctypes.util +from dataclasses import dataclass +from enum import Enum + +HS_SUCCESS = 0 +HS_SCAN_TERMINATED = -3 + +HS_FLAG_CASELESS = 1 +HS_FLAG_SINGLEMATCH = 8 +HS_FLAG_ALLOWEMPTY = 16 +HS_FLAG_SOM_LEFTMOST = 256 + +HS_MODE_BLOCK = 1 +HS_MODE_STREAM = 2 + + +class HyperscanMissing(RuntimeError): + """libhs is not installed, and nothing here will guess in its place.""" + + def __init__(self): + super().__init__( + "hyperscan (vectorscan) is not installed, and matching without it would " + "give you answers firegex does not agree with.\n" + " Fedora: dnf install vectorscan\n" + " Debian: apt install libvectorscan5\n" + " Arch: pacman -S vectorscan" + ) + + +class _CompileError(ctypes.Structure): + _fields_ = [("message", ctypes.c_char_p), ("expression", ctypes.c_int)] + + +_MATCH_HANDLER = ctypes.CFUNCTYPE( + ctypes.c_int, + ctypes.c_uint, # id + ctypes.c_ulonglong, # from + ctypes.c_ulonglong, # to + ctypes.c_uint, # flags + ctypes.c_void_p, # context +) + +_lib = None + + +def _hs(): + """Load libhs once, or explain why it cannot be.""" + global _lib + if _lib is not None: + return _lib + name = ctypes.util.find_library("hs") + candidates = [name] if name else [] + # find_library needs a toolchain to be reliable; the sonames are stable enough to + # try directly when it comes back empty. + candidates += ["libhs.so.5", "libhs.so", "libhs.dylib"] + for candidate in candidates: + try: + lib = ctypes.CDLL(candidate) + except OSError: + continue + lib.hs_compile_multi.restype = ctypes.c_int + lib.hs_compile_multi.argtypes = [ + ctypes.POINTER(ctypes.c_char_p), + ctypes.POINTER(ctypes.c_uint), + ctypes.POINTER(ctypes.c_uint), + ctypes.c_uint, + ctypes.c_uint, + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.POINTER(_CompileError)), + ] + lib.hs_free_database.argtypes = [ctypes.c_void_p] + lib.hs_free_compile_error.argtypes = [ctypes.POINTER(_CompileError)] + lib.hs_alloc_scratch.restype = ctypes.c_int + lib.hs_alloc_scratch.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p)] + lib.hs_free_scratch.argtypes = [ctypes.c_void_p] + lib.hs_scan.restype = ctypes.c_int + lib.hs_scan.argtypes = [ + ctypes.c_void_p, + ctypes.c_char_p, + ctypes.c_uint, + ctypes.c_uint, + ctypes.c_void_p, + _MATCH_HANDLER, + ctypes.c_void_p, + ] + _lib = lib + return _lib + raise HyperscanMissing() + + +def available() -> bool: + """Whether matching is possible at all here.""" + try: + _hs() + return True + except HyperscanMissing: + return False + + +class Direction(str, Enum): + """Which half of the traffic a rule looks at.""" + + BOTH = "both" + C2S = "c2s" + S2C = "s2c" + + def covers(self, is_input: bool) -> bool: + if self is Direction.BOTH: + return True + return (self is Direction.C2S) == is_input + + +@dataclass +class Rule: + """One pattern, in the same shape firegex stores it. + + There is no action to choose. A matching pattern refuses the connection, here and in + firegex, because that is the only verdict a pattern can honestly reach on a stream: + rewriting scanned one chunk at a time, so a match straddling two of them was never + rewritten, and the operator got no block, no log and no counter to say so. A ruleset + file may still carry `"action": "block"` — it is read and ignored — but anything else + is refused rather than quietly treated as a block. + """ + + id: str + pattern: str + direction: Direction = Direction.BOTH + case_sensitive: bool = True + + @classmethod + def from_dict(cls, raw: dict) -> "Rule": + action = str(raw.get("action", "block")).lower() + if action != "block": + raise ValueError( + f"rule {raw.get('id', raw.get('regex_id', '?'))!r} asks for " + f"action {action!r}; a pattern can only block" + ) + return cls( + id=str(raw.get("id", raw.get("regex_id", "?"))), + pattern=raw["pattern"] if "pattern" in raw else raw["regex"], + direction=Direction(raw.get("direction", "both")), + case_sensitive=bool(raw.get("case_sensitive", True)), + ) + + +@dataclass +class Match: + """Where a rule matched, and which one.""" + + rule: Rule + start: int + end: int + + +class Database: + """A compiled set of patterns. Immutable once built.""" + + def __init__(self, rules: list[Rule], mode: int): + lib = _hs() + self.rules = list(rules) + self._db = ctypes.c_void_p() + self._scratch = ctypes.c_void_p() + if not rules: + raise ValueError("no patterns to compile") + + expressions = (ctypes.c_char_p * len(rules))( + *[r.pattern.encode() for r in rules] + ) + base = HS_FLAG_ALLOWEMPTY | ( + HS_FLAG_SINGLEMATCH if mode == HS_MODE_STREAM else HS_FLAG_SOM_LEFTMOST + ) + flags = (ctypes.c_uint * len(rules))( + *[base | (0 if r.case_sensitive else HS_FLAG_CASELESS) for r in rules] + ) + ids = (ctypes.c_uint * len(rules))(*range(len(rules))) + error = ctypes.POINTER(_CompileError)() + rc = lib.hs_compile_multi( + expressions, flags, ids, len(rules), mode, None, + ctypes.byref(self._db), ctypes.byref(error), + ) + if rc != HS_SUCCESS: + message = "hyperscan could not compile the patterns" + if error: + if error.contents.message: + message = error.contents.message.decode(errors="replace") + lib.hs_free_compile_error(error) + raise ValueError(message) + if lib.hs_alloc_scratch(self._db, ctypes.byref(self._scratch)) != HS_SUCCESS: + raise RuntimeError("hyperscan could not allocate scratch space") + + def __del__(self): + try: + lib = _hs() + except Exception: + return + if getattr(self, "_scratch", None): + lib.hs_free_scratch(self._scratch) + if getattr(self, "_db", None): + lib.hs_free_database(self._db) + + def scan(self, data: bytes, limit: int = 1000) -> list[Match]: + """Every match in one buffer, with where it started.""" + if not data: + return [] + hits: list[Match] = [] + + def on_match(rule_id, start, end, _flags, _ctx): + hits.append(Match(self.rules[rule_id], int(start), int(end))) + return -1 if len(hits) >= limit else 0 + + rc = _hs().hs_scan( + self._db, data, len(data), 0, self._scratch, + _MATCH_HANDLER(on_match), None, + ) + if rc not in (HS_SUCCESS, HS_SCAN_TERMINATED): + raise RuntimeError(f"hyperscan scan failed ({rc})") + return hits + + +def validate(rule: Rule) -> str | None: + """Would firegex accept this rule? Returns the reason if not. + + Checked against the mode it will actually run in — stream matching, which is how a + pattern follows a connection across chunk boundaries — because hyperscan does not + accept quite the same patterns in every mode, and judging by the wrong one would + refuse a rule that works. + + The empty pattern is the one refusal that is ours rather than hyperscan's. It + compiles, because the flags have to allow a pattern that *can* match an empty buffer + for real ones like `a*` to work — and then it matches every buffer it is shown, so a + service carrying it refuses all of its traffic. firegex refuses it when a rule is + saved; refusing it here too is what keeps this tool's answer the same as the + product's, which is the whole reason this module binds libhs instead of using `re`. + """ + if not rule.pattern: + return ("a pattern cannot be empty: an empty one matches every byte of every " + "connection, so the service would refuse all of its traffic") + try: + Database([rule], HS_MODE_STREAM) + except ValueError as e: + return str(e) + return None + + +def scannable(rule: Rule) -> str | None: + """Can this rule be tried against a sample here, or only run on a real service? + + A separate question from [`validate`], and the difference is hyperscan's: matching a + stream and reporting *where* a match started are different modes, and block mode — + the only one that reports offsets — accepts slightly less. A pattern like + `a{1,1000}b` follows a stream perfectly well and cannot be block-scanned. + + So such a rule is valid, will run, and simply cannot be exercised locally. Saying so + is the honest answer; the datapath's own tester reports exactly the same thing, under + the name `unscannable`. + """ + try: + Database([rule], HS_MODE_BLOCK) + except ValueError as e: + return str(e) + return None + + +class Ruleset: + """A compiled ruleset, ready to be run over chunk after chunk. + + Compiled once and kept: hyperscan merges the automata of a whole set, so scanning + for fifty patterns costs about what scanning for one costs — but only if the set is + built once. Rebuilding per chunk would throw that away and make the cost of a rule + proportional to the traffic. + """ + + def __init__(self, rules: list[Rule]): + self.rules = list(rules) + self._cache: dict[bool, Database | None] = {} + #: The rules this simulator had to leave out, and why — valid patterns that + #: cannot be block-scanned. Read it and say so: silently matching with fewer + #: rules than the operator wrote is the failure this whole module avoids. + self.unscannable: dict[str, str] = {} + + def _db(self, is_input: bool) -> Database | None: + if is_input in self._cache: + return self._cache[is_input] + selected = [r for r in self.rules if r.direction.covers(is_input)] + try: + self._cache[is_input] = Database(selected, HS_MODE_BLOCK) if selected else None + except ValueError: + # One rule that cannot be block-scanned refused the whole set, and the caller + # got a `ValueError` out of what looked like a clean ruleset — `fgex regex + # check` had already said it was valid, because validity is judged in stream + # mode and that is the mode it will run in. Compiled one at a time, the ones + # that can be tried here still are, and the rest are named rather than + # silently dropped or allowed to take the run down. + usable = [] + for rule in selected: + why = scannable(rule) + if why: + self.unscannable[rule.id] = why + else: + usable.append(rule) + self._cache[is_input] = Database(usable, HS_MODE_BLOCK) if usable else None + return self._cache[is_input] + + def apply(self, data: bytes, is_input: bool) -> str | None: + """Run the ruleset over one chunk, as the proxy datapath would. + + Returns the id of the rule that refused the chunk, or `None` if it may be + forwarded as it stands. Nothing is ever handed back changed: a pattern blocks, + and that is the whole of what it can do. + + One caveat this cannot hide: matching here is per chunk, while a rule on a real + service matches across the whole stream. A pattern split over two reads is + caught there and not here — never the other way round, so a ruleset that looks + clean in the simulator may still block something in production. + """ + db = self._db(is_input) + if db: + hits = db.scan(data, limit=1) + if hits: + return hits[0].rule.id + return None + + +def load_rules(raw: list[dict]) -> list[Rule]: + """Read a ruleset in the shape firegex itself uses, so one file works in both.""" + return [Rule.from_dict(item) for item in raw] diff --git a/fgex-lib/firegex/regex/sim.py b/fgex-lib/firegex/regex/sim.py new file mode 100644 index 0000000..7510f05 --- /dev/null +++ b/fgex-lib/firegex/regex/sim.py @@ -0,0 +1,104 @@ +"""A local proxy that applies a regex ruleset, for trying one before it goes live. + +The counterpart of `firegex.pyfilters.proxysim` for patterns: point it at your service, +send traffic through it, and see what a ruleset would do. It matches with the same +hyperscan the datapath matches with, so a pattern that blocks here blocks there. + +One difference it cannot paper over, and says out loud: a *blocking* rule on a real +service matches across the whole stream, while this matches each chunk on its own. A +pattern split over two reads is caught in production and not here — never the other way +round, so this errs towards showing you less than will actually happen. +""" + +import asyncio +import socket + +from rich import print +from rich.markup import escape + +from firegex.regex import Ruleset + + +def _log(module: str, message: str, level: str = "INFO"): + colour = {"INFO": "blue", "WARNING": "yellow", "ERROR": "red"}.get(level, "blue") + print(f"[bold {colour}][{level}][/] [bold]\\[{escape(module)}][/] {message}") + + +async def _pump(rules: Ruleset, reader, writer, is_input: bool, on_block): + """Move one direction, applying the ruleset to every chunk.""" + try: + while True: + try: + data = await reader.read(4096) + except Exception: + break + if not data: + break + try: + blocked_by = rules.apply(data, is_input) + except Exception as e: + # A ruleset that blows up mid-connection must not take the traffic with + # it; the real datapath fails open too, and so does this. + _log("filter", f"failed on this chunk ({escape(str(e))}), forwarding it", "ERROR") + blocked_by = None + if blocked_by is not None: + on_block(blocked_by) + _log("block", f"connection refused by [bold]{escape(blocked_by)}[/]", "WARNING") + break + writer.write(data) + await writer.drain() + finally: + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + + +async def _handle(reader, writer, rules: Ruleset, target_ip: str, target_port: int, + ipv6: bool, counters: dict): + peer = writer.get_extra_info("peername") + _log("listener", f"accepted {escape(str(peer[0]))}:{peer[1]}") + try: + remote_reader, remote_writer = await asyncio.open_connection( + target_ip, target_port, family=socket.AF_INET6 if ipv6 else socket.AF_INET + ) + except Exception as e: + _log("listener", f"cannot reach {escape(target_ip)}:{target_port}: {escape(str(e))}", "ERROR") + writer.close() + return + + def on_block(rule_id: str): + counters[rule_id] = counters.get(rule_id, 0) + 1 + + await asyncio.gather( + _pump(rules, reader, remote_writer, True, on_block), + _pump(rules, remote_reader, writer, False, on_block), + ) + remote_writer.close() + + +async def _serve(rules: Ruleset, target_ip: str, target_port: int, + local_ip: str, local_port: int, ipv6: bool, counters: dict): + server = await asyncio.start_server( + lambda r, w: _handle(r, w, rules, target_ip, target_port, ipv6, counters), + local_ip, local_port, family=socket.AF_INET6 if ipv6 else socket.AF_INET, + ) + _log("listener", + f"listening on [bold]{escape(local_ip)}:{local_port}[/] " + f"and forwarding to [bold]{escape(target_ip)}:{target_port}[/]") + async with server: + await server.serve_forever() + + +def run_regex_simulation(rules: Ruleset, target_ip: str, target_port: int, + local_ip: str, local_port: int, ipv6: bool = False): + counters: dict[str, int] = {} + try: + asyncio.run(_serve(rules, target_ip, target_port, local_ip, local_port, ipv6, counters)) + except KeyboardInterrupt: + _log("listener", "stopped", "WARNING") + if counters: + print() + for rule_id, count in sorted(counters.items(), key=lambda kv: -kv[1]): + print(f" [bold]{escape(rule_id)}[/] refused {count} connection(s)") diff --git a/fgex-lib/llhttp/LICENSE.pyllhttp b/fgex-lib/llhttp/LICENSE.pyllhttp new file mode 100644 index 0000000..f8f628c --- /dev/null +++ b/fgex-lib/llhttp/LICENSE.pyllhttp @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Derrick Lyndon Pallas + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/fgex-lib/llhttp/PROVENANCE.md b/fgex-lib/llhttp/PROVENANCE.md new file mode 100644 index 0000000..d1d3dfe --- /dev/null +++ b/fgex-lib/llhttp/PROVENANCE.md @@ -0,0 +1,68 @@ +# Where the code in this directory comes from + +Two separate works live here, under two separate MIT notices, and the difference +matters when either is updated. + +## `lib/` — llhttp, upstream and unmodified + +* Version: **9.4.3** +* Source: , tag `release/v9.4.3` (30 July 2026) +* Tarball: `https://github.com/nodejs/llhttp/archive/refs/tags/release/v9.4.3.tar.gz` + (`sha256:1eb813c7437b31a87496a1cd3ed79f00746720f5e7e29c79b42c02cb69f36c39`) +* Licence: MIT, © Fedor Indutny and contributors — `lib/LICENSE.llhttp` + +Four files, copied verbatim from that tag, and nothing in them has been edited: + +| here | upstream | sha256 | +|---|---|---| +| `lib/llhttp.h` | `include/llhttp.h` | `5bc82fa51b19aa8bee7d921038393fbfc74e8cc9bae0844c2d82c102fb8cfd68` | +| `lib/llhttp.c` | `src/llhttp.c` | `391e7c99912abf3b1c9dd8a9c85fdeaac2663e9fa55b6f3871154d16fef8b892` | +| `lib/api.c` | `src/api.c` | `0f8590206fe2f264db2825401b5fb856b979ee1363dc88b06f36dc9577afb941` | +| `lib/http.c` | `src/http.c` | `a1f2b23168f8e9b5bfa464c89027d3ca259fc649ae3d7e72bfff997d1aeb5d72` | + +`llhttp.c` is *generated* upstream, from the TypeScript that describes the state +machine; the `release/*` tags are the branch where that output is committed, which is +why these can be taken as source without a Node toolchain. There is no `api.h`: the +public header is one file and already carries what that used to declare. It was here +until 9.4.3, included by nothing. + +### Updating + +Replace those four files from the new tag, update the table above, rebuild, and run +`pytest unit/test_http_parsing.py` from `tests/`. The binding needs no change for a +minor release: `parser_settings` is built with designated initialisers, so a callback +added upstream (`on_protocol_complete` arrived in 9.3.0) stays NULL, and the error list +is generated from `HTTP_ERRNO_MAP`, so new codes bring their own exceptions along. + +What does need looking at is the other direction: 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. Both are pinned in +`tests/unit/test_http_parsing.py` — a change there is a decision about what filters see, +not a test to fix. + +## `llhttp_module.c` — the Python binding, ours + +Derived from [pyllhttp](https://github.com/domysh/pyllhttp) (itself a fork of Derrick +Lyndon Pallas' `pyllhttp`, now archived), and modified. Licence: MIT — +`LICENSE.pyllhttp`. + +What differs from that fork, which is what to re-apply if anything is ever taken from it +again: + +* It is built as `firegex._llhttp` and declares `Py_MOD_PER_INTERPRETER_GIL_SUPPORTED`, + which is what lets it load in the subinterpreters `cpproxy` runs filters in — the + reason none of the alternatives with prebuilt wheels can be used. +* **Nothing here reaches for `pyllhttp` any more.** The types and the exception classes + carry this module's name, and the error path looks the class up in this module through + `sys.modules`, so each interpreter finds the classes it built. It used to + `PyImport_ImportModule("pyllhttp")` on every parse error: that package is not a + dependency and is installed on no machine the wheel is, so the import failed, printed + its traceback to stderr — the service log, on a filter — and returned *without setting + an exception*, which CPython reports as `SystemError: ... returned NULL without setting + an exception`. A malformed request said that instead of naming the parse error. Pinned + by `tests/unit/test_llhttp_binding.py`, which takes `pyllhttp` away before asking, and + by `wheel_smoke.py`, which `cibuildwheel` runs where it was never there. +* The exception class names are derived into a local buffer instead of being `malloc`ed + once onto a process-global table and never freed — that table is shared by every + interpreter, which is not somewhere a module claiming per-interpreter support may + write. diff --git a/fgex-lib/llhttp/lib/LICENSE.llhttp b/fgex-lib/llhttp/lib/LICENSE.llhttp new file mode 100644 index 0000000..23682c0 --- /dev/null +++ b/fgex-lib/llhttp/lib/LICENSE.llhttp @@ -0,0 +1,22 @@ +MIT License + +Copyright © 2018 Fedor Indutny + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/fgex-lib/llhttp/lib/api.c b/fgex-lib/llhttp/lib/api.c new file mode 100644 index 0000000..ae5e862 --- /dev/null +++ b/fgex-lib/llhttp/lib/api.c @@ -0,0 +1,517 @@ +#include +#include +#include + +#include "llhttp.h" + +#define CALLBACK_MAYBE(PARSER, NAME) \ + do { \ + const llhttp_settings_t* settings; \ + settings = (const llhttp_settings_t*) (PARSER)->settings; \ + if (settings == NULL || settings->NAME == NULL) { \ + err = 0; \ + break; \ + } \ + err = settings->NAME((PARSER)); \ + } while (0) + +#define SPAN_CALLBACK_MAYBE(PARSER, NAME, START, LEN) \ + do { \ + const llhttp_settings_t* settings; \ + settings = (const llhttp_settings_t*) (PARSER)->settings; \ + if (settings == NULL || settings->NAME == NULL) { \ + err = 0; \ + break; \ + } \ + err = settings->NAME((PARSER), (START), (LEN)); \ + if (err == -1) { \ + err = HPE_USER; \ + llhttp_set_error_reason((PARSER), "Span callback error in " #NAME); \ + } \ + } while (0) + +void llhttp_init(llhttp_t* parser, llhttp_type_t type, + const llhttp_settings_t* settings) { + llhttp__internal_init(parser); + + parser->type = type; + parser->settings = (void*) settings; +} + + +#if defined(__wasm__) + +extern int wasm_on_message_begin(llhttp_t * p); +extern int wasm_on_url(llhttp_t* p, const char* at, size_t length); +extern int wasm_on_status(llhttp_t* p, const char* at, size_t length); +extern int wasm_on_header_field(llhttp_t* p, const char* at, size_t length); +extern int wasm_on_header_value(llhttp_t* p, const char* at, size_t length); +extern int wasm_on_headers_complete(llhttp_t * p, int status_code, + uint8_t upgrade, int should_keep_alive); +extern int wasm_on_body(llhttp_t* p, const char* at, size_t length); +extern int wasm_on_message_complete(llhttp_t * p); + +static int wasm_on_headers_complete_wrap(llhttp_t* p) { + return wasm_on_headers_complete(p, p->status_code, p->upgrade, + llhttp_should_keep_alive(p)); +} + +const llhttp_settings_t wasm_settings = { + .on_message_begin = wasm_on_message_begin, + .on_url = wasm_on_url, + .on_status = wasm_on_status, + .on_header_field = wasm_on_header_field, + .on_header_value = wasm_on_header_value, + .on_headers_complete = wasm_on_headers_complete_wrap, + .on_body = wasm_on_body, + .on_message_complete = wasm_on_message_complete, +}; + + +llhttp_t* llhttp_alloc(llhttp_type_t type) { + llhttp_t* parser = malloc(sizeof(llhttp_t)); + llhttp_init(parser, type, &wasm_settings); + return parser; +} + +void llhttp_free(llhttp_t* parser) { + free(parser); +} + +#endif // defined(__wasm__) + +/* Some getters required to get stuff from the parser */ + +uint8_t llhttp_get_type(llhttp_t* parser) { + return parser->type; +} + +uint8_t llhttp_get_http_major(llhttp_t* parser) { + return parser->http_major; +} + +uint8_t llhttp_get_http_minor(llhttp_t* parser) { + return parser->http_minor; +} + +uint8_t llhttp_get_method(llhttp_t* parser) { + return parser->method; +} + +int llhttp_get_status_code(llhttp_t* parser) { + return parser->status_code; +} + +uint8_t llhttp_get_upgrade(llhttp_t* parser) { + return parser->upgrade; +} + + +void llhttp_reset(llhttp_t* parser) { + llhttp_type_t type = parser->type; + const llhttp_settings_t* settings = parser->settings; + void* data = parser->data; + uint16_t lenient_flags = parser->lenient_flags; + + llhttp__internal_init(parser); + + parser->type = type; + parser->settings = (void*) settings; + parser->data = data; + parser->lenient_flags = lenient_flags; +} + + +llhttp_errno_t llhttp_execute(llhttp_t* parser, const char* data, size_t len) { + return llhttp__internal_execute(parser, data, data + len); +} + + +void llhttp_settings_init(llhttp_settings_t* settings) { + memset(settings, 0, sizeof(*settings)); +} + + +llhttp_errno_t llhttp_finish(llhttp_t* parser) { + int err; + + /* We're in an error state. Don't bother doing anything. */ + if (parser->error != 0) { + return 0; + } + + switch (parser->finish) { + case HTTP_FINISH_SAFE_WITH_CB: + CALLBACK_MAYBE(parser, on_message_complete); + if (err != HPE_OK) return err; + + /* FALLTHROUGH */ + case HTTP_FINISH_SAFE: + return HPE_OK; + case HTTP_FINISH_UNSAFE: + parser->reason = "Invalid EOF state"; + return HPE_INVALID_EOF_STATE; + default: + abort(); + } +} + + +void llhttp_pause(llhttp_t* parser) { + if (parser->error != HPE_OK) { + return; + } + + parser->error = HPE_PAUSED; + parser->reason = "Paused"; +} + + +void llhttp_resume(llhttp_t* parser) { + if (parser->error != HPE_PAUSED) { + return; + } + + parser->error = 0; +} + + +void llhttp_resume_after_upgrade(llhttp_t* parser) { + if (parser->error != HPE_PAUSED_UPGRADE) { + return; + } + + parser->error = 0; +} + + +llhttp_errno_t llhttp_get_errno(const llhttp_t* parser) { + return parser->error; +} + + +const char* llhttp_get_error_reason(const llhttp_t* parser) { + return parser->reason; +} + + +void llhttp_set_error_reason(llhttp_t* parser, const char* reason) { + parser->reason = reason; +} + + +const char* llhttp_get_error_pos(const llhttp_t* parser) { + return parser->error_pos; +} + + +const char* llhttp_errno_name(llhttp_errno_t err) { +#define HTTP_ERRNO_GEN(CODE, NAME, _) case HPE_##NAME: return "HPE_" #NAME; + switch (err) { + HTTP_ERRNO_MAP(HTTP_ERRNO_GEN) + default: abort(); + } +#undef HTTP_ERRNO_GEN +} + + +const char* llhttp_method_name(llhttp_method_t method) { +#define HTTP_METHOD_GEN(NUM, NAME, STRING) case HTTP_##NAME: return #STRING; + switch (method) { + HTTP_ALL_METHOD_MAP(HTTP_METHOD_GEN) + default: abort(); + } +#undef HTTP_METHOD_GEN +} + +const char* llhttp_status_name(llhttp_status_t status) { +#define HTTP_STATUS_GEN(NUM, NAME, STRING) case HTTP_STATUS_##NAME: return #STRING; + switch (status) { + HTTP_STATUS_MAP(HTTP_STATUS_GEN) + default: abort(); + } +#undef HTTP_STATUS_GEN +} + + +void llhttp_set_lenient_headers(llhttp_t* parser, int enabled) { + if (enabled) { + parser->lenient_flags |= LENIENT_HEADERS; + } else { + parser->lenient_flags &= ~LENIENT_HEADERS; + } +} + + +void llhttp_set_lenient_chunked_length(llhttp_t* parser, int enabled) { + if (enabled) { + parser->lenient_flags |= LENIENT_CHUNKED_LENGTH; + } else { + parser->lenient_flags &= ~LENIENT_CHUNKED_LENGTH; + } +} + + +void llhttp_set_lenient_keep_alive(llhttp_t* parser, int enabled) { + if (enabled) { + parser->lenient_flags |= LENIENT_KEEP_ALIVE; + } else { + parser->lenient_flags &= ~LENIENT_KEEP_ALIVE; + } +} + +void llhttp_set_lenient_transfer_encoding(llhttp_t* parser, int enabled) { + if (enabled) { + parser->lenient_flags |= LENIENT_TRANSFER_ENCODING; + } else { + parser->lenient_flags &= ~LENIENT_TRANSFER_ENCODING; + } +} + +void llhttp_set_lenient_version(llhttp_t* parser, int enabled) { + if (enabled) { + parser->lenient_flags |= LENIENT_VERSION; + } else { + parser->lenient_flags &= ~LENIENT_VERSION; + } +} + +void llhttp_set_lenient_data_after_close(llhttp_t* parser, int enabled) { + if (enabled) { + parser->lenient_flags |= LENIENT_DATA_AFTER_CLOSE; + } else { + parser->lenient_flags &= ~LENIENT_DATA_AFTER_CLOSE; + } +} + +void llhttp_set_lenient_optional_lf_after_cr(llhttp_t* parser, int enabled) { + if (enabled) { + parser->lenient_flags |= LENIENT_OPTIONAL_LF_AFTER_CR; + } else { + parser->lenient_flags &= ~LENIENT_OPTIONAL_LF_AFTER_CR; + } +} + +void llhttp_set_lenient_optional_crlf_after_chunk(llhttp_t* parser, int enabled) { + if (enabled) { + parser->lenient_flags |= LENIENT_OPTIONAL_CRLF_AFTER_CHUNK; + } else { + parser->lenient_flags &= ~LENIENT_OPTIONAL_CRLF_AFTER_CHUNK; + } +} + +void llhttp_set_lenient_optional_cr_before_lf(llhttp_t* parser, int enabled) { + if (enabled) { + parser->lenient_flags |= LENIENT_OPTIONAL_CR_BEFORE_LF; + } else { + parser->lenient_flags &= ~LENIENT_OPTIONAL_CR_BEFORE_LF; + } +} + +void llhttp_set_lenient_spaces_after_chunk_size(llhttp_t* parser, int enabled) { + if (enabled) { + parser->lenient_flags |= LENIENT_SPACES_AFTER_CHUNK_SIZE; + } else { + parser->lenient_flags &= ~LENIENT_SPACES_AFTER_CHUNK_SIZE; + } +} + +void llhttp_set_lenient_header_value_relaxed(llhttp_t* parser, int enabled) { + if (enabled) { + parser->lenient_flags |= LENIENT_HEADER_VALUE_RELAXED; + } else { + parser->lenient_flags &= ~LENIENT_HEADER_VALUE_RELAXED; + } +} + +/* Callbacks */ + + +int llhttp__on_message_begin(llhttp_t* s, const char* p, const char* endp) { + int err; + CALLBACK_MAYBE(s, on_message_begin); + return err; +} + + +int llhttp__on_protocol(llhttp_t* s, const char* p, const char* endp) { + int err; + SPAN_CALLBACK_MAYBE(s, on_protocol, p, endp - p); + return err; +} + + +int llhttp__on_protocol_complete(llhttp_t* s, const char* p, const char* endp) { + int err; + CALLBACK_MAYBE(s, on_protocol_complete); + return err; +} + + +int llhttp__on_url(llhttp_t* s, const char* p, const char* endp) { + int err; + SPAN_CALLBACK_MAYBE(s, on_url, p, endp - p); + return err; +} + + +int llhttp__on_url_complete(llhttp_t* s, const char* p, const char* endp) { + int err; + CALLBACK_MAYBE(s, on_url_complete); + return err; +} + + +int llhttp__on_status(llhttp_t* s, const char* p, const char* endp) { + int err; + SPAN_CALLBACK_MAYBE(s, on_status, p, endp - p); + return err; +} + + +int llhttp__on_status_complete(llhttp_t* s, const char* p, const char* endp) { + int err; + CALLBACK_MAYBE(s, on_status_complete); + return err; +} + + +int llhttp__on_method(llhttp_t* s, const char* p, const char* endp) { + int err; + SPAN_CALLBACK_MAYBE(s, on_method, p, endp - p); + return err; +} + + +int llhttp__on_method_complete(llhttp_t* s, const char* p, const char* endp) { + int err; + CALLBACK_MAYBE(s, on_method_complete); + return err; +} + + +int llhttp__on_version(llhttp_t* s, const char* p, const char* endp) { + int err; + SPAN_CALLBACK_MAYBE(s, on_version, p, endp - p); + return err; +} + + +int llhttp__on_version_complete(llhttp_t* s, const char* p, const char* endp) { + int err; + CALLBACK_MAYBE(s, on_version_complete); + return err; +} + + +int llhttp__on_header_field(llhttp_t* s, const char* p, const char* endp) { + int err; + SPAN_CALLBACK_MAYBE(s, on_header_field, p, endp - p); + return err; +} + + +int llhttp__on_header_field_complete(llhttp_t* s, const char* p, const char* endp) { + int err; + CALLBACK_MAYBE(s, on_header_field_complete); + return err; +} + + +int llhttp__on_header_value(llhttp_t* s, const char* p, const char* endp) { + int err; + SPAN_CALLBACK_MAYBE(s, on_header_value, p, endp - p); + return err; +} + + +int llhttp__on_header_value_complete(llhttp_t* s, const char* p, const char* endp) { + int err; + CALLBACK_MAYBE(s, on_header_value_complete); + return err; +} + + +int llhttp__on_headers_complete(llhttp_t* s, const char* p, const char* endp) { + int err; + CALLBACK_MAYBE(s, on_headers_complete); + return err; +} + + +int llhttp__on_message_complete(llhttp_t* s, const char* p, const char* endp) { + int err; + CALLBACK_MAYBE(s, on_message_complete); + return err; +} + + +int llhttp__on_body(llhttp_t* s, const char* p, const char* endp) { + int err; + SPAN_CALLBACK_MAYBE(s, on_body, p, endp - p); + return err; +} + + +int llhttp__on_chunk_header(llhttp_t* s, const char* p, const char* endp) { + int err; + CALLBACK_MAYBE(s, on_chunk_header); + return err; +} + + +int llhttp__on_chunk_extension_name(llhttp_t* s, const char* p, const char* endp) { + int err; + SPAN_CALLBACK_MAYBE(s, on_chunk_extension_name, p, endp - p); + return err; +} + + +int llhttp__on_chunk_extension_name_complete(llhttp_t* s, const char* p, const char* endp) { + int err; + CALLBACK_MAYBE(s, on_chunk_extension_name_complete); + return err; +} + + +int llhttp__on_chunk_extension_value(llhttp_t* s, const char* p, const char* endp) { + int err; + SPAN_CALLBACK_MAYBE(s, on_chunk_extension_value, p, endp - p); + return err; +} + + +int llhttp__on_chunk_extension_value_complete(llhttp_t* s, const char* p, const char* endp) { + int err; + CALLBACK_MAYBE(s, on_chunk_extension_value_complete); + return err; +} + + +int llhttp__on_chunk_complete(llhttp_t* s, const char* p, const char* endp) { + int err; + CALLBACK_MAYBE(s, on_chunk_complete); + return err; +} + + +int llhttp__on_reset(llhttp_t* s, const char* p, const char* endp) { + int err; + CALLBACK_MAYBE(s, on_reset); + return err; +} + + +/* Private */ + + +void llhttp__debug(llhttp_t* s, const char* p, const char* endp, + const char* msg) { + if (p == endp) { + fprintf(stderr, "p=%p type=%d flags=%02x next=null debug=%s\n", s, s->type, + s->flags, msg); + } else { + fprintf(stderr, "p=%p type=%d flags=%02x next=%02x debug=%s\n", s, + s->type, s->flags, *p, msg); + } +} diff --git a/fgex-lib/llhttp/lib/http.c b/fgex-lib/llhttp/lib/http.c new file mode 100644 index 0000000..1ab91a5 --- /dev/null +++ b/fgex-lib/llhttp/lib/http.c @@ -0,0 +1,170 @@ +#include +#ifndef LLHTTP__TEST +# include "llhttp.h" +#else +# define llhttp_t llparse_t +#endif /* */ + +int llhttp_message_needs_eof(const llhttp_t* parser); +int llhttp_should_keep_alive(const llhttp_t* parser); + +int llhttp__before_headers_complete(llhttp_t* parser, const char* p, + const char* endp) { + /* Set this here so that on_headers_complete() callbacks can see it */ + if ((parser->flags & F_UPGRADE) && + (parser->flags & F_CONNECTION_UPGRADE)) { + /* For responses, "Upgrade: foo" and "Connection: upgrade" are + * mandatory only when it is a 101 Switching Protocols response, + * otherwise it is purely informational, to announce support. + */ + parser->upgrade = + (parser->type == HTTP_REQUEST || parser->status_code == 101); + } else { + parser->upgrade = (parser->method == HTTP_CONNECT); + } + return 0; +} + + +/* Return values: + * 0 - No body, `restart`, message_complete + * 1 - CONNECT request, `restart`, message_complete, and pause + * 2 - chunk_size_start + * 3 - body_identity + * 4 - body_identity_eof + * 5 - invalid transfer-encoding for request + */ +int llhttp__after_headers_complete(llhttp_t* parser, const char* p, + const char* endp) { + int hasBody; + + hasBody = parser->flags & F_CHUNKED || parser->content_length > 0; + if ( + (parser->upgrade && (parser->method == HTTP_CONNECT || + (parser->flags & F_SKIPBODY) || !hasBody)) || + /* See RFC 2616 section 4.4 - 1xx e.g. Continue */ + (parser->type == HTTP_RESPONSE && parser->status_code == 101) + ) { + /* Exit, the rest of the message is in a different protocol. */ + return 1; + } + + if (parser->type == HTTP_RESPONSE && parser->status_code == 100) { + /* No body, restart as the message is complete */ + return 0; + } + + /* See RFC 2616 section 4.4 */ + if ( + parser->flags & F_SKIPBODY || /* response to a HEAD request */ + ( + parser->type == HTTP_RESPONSE && ( + parser->status_code == 102 || /* Processing */ + parser->status_code == 103 || /* Early Hints */ + parser->status_code == 204 || /* No Content */ + parser->status_code == 304 /* Not Modified */ + ) + ) + ) { + return 0; + } else if (parser->flags & F_CHUNKED) { + /* chunked encoding - ignore Content-Length header, prepare for a chunk */ + return 2; + } else if (parser->flags & F_TRANSFER_ENCODING) { + if (parser->type == HTTP_REQUEST && + (parser->lenient_flags & LENIENT_CHUNKED_LENGTH) == 0 && + (parser->lenient_flags & LENIENT_TRANSFER_ENCODING) == 0) { + /* RFC 7230 3.3.3 */ + + /* If a Transfer-Encoding header field + * is present in a request and the chunked transfer coding is not + * the final encoding, the message body length cannot be determined + * reliably; the server MUST respond with the 400 (Bad Request) + * status code and then close the connection. + */ + return 5; + } else { + /* RFC 7230 3.3.3 */ + + /* If a Transfer-Encoding header field is present in a response and + * the chunked transfer coding is not the final encoding, the + * message body length is determined by reading the connection until + * it is closed by the server. + */ + return 4; + } + } else { + if (!(parser->flags & F_CONTENT_LENGTH)) { + if (!llhttp_message_needs_eof(parser)) { + /* Assume content-length 0 - read the next */ + return 0; + } else { + /* Read body until EOF */ + return 4; + } + } else if (parser->content_length == 0) { + /* Content-Length header given but zero: Content-Length: 0\r\n */ + return 0; + } else { + /* Content-Length header given and non-zero */ + return 3; + } + } +} + + +int llhttp__after_message_complete(llhttp_t* parser, const char* p, + const char* endp) { + int should_keep_alive; + + should_keep_alive = llhttp_should_keep_alive(parser); + parser->finish = HTTP_FINISH_SAFE; + parser->flags = 0; + + /* NOTE: this is ignored in loose parsing mode */ + return should_keep_alive; +} + + +int llhttp_message_needs_eof(const llhttp_t* parser) { + if (parser->type == HTTP_REQUEST) { + return 0; + } + + /* See RFC 2616 section 4.4 */ + if (parser->status_code / 100 == 1 || /* 1xx e.g. Continue */ + parser->status_code == 204 || /* No Content */ + parser->status_code == 304 || /* Not Modified */ + (parser->flags & F_SKIPBODY)) { /* response to a HEAD request */ + return 0; + } + + /* RFC 7230 3.3.3, see `llhttp__after_headers_complete` */ + if ((parser->flags & F_TRANSFER_ENCODING) && + (parser->flags & F_CHUNKED) == 0) { + return 1; + } + + if (parser->flags & (F_CHUNKED | F_CONTENT_LENGTH)) { + return 0; + } + + return 1; +} + + +int llhttp_should_keep_alive(const llhttp_t* parser) { + if (parser->http_major > 0 && parser->http_minor > 0) { + /* HTTP/1.1 */ + if (parser->flags & F_CONNECTION_CLOSE) { + return 0; + } + } else { + /* HTTP/1.0 or earlier */ + if (!(parser->flags & F_CONNECTION_KEEP_ALIVE)) { + return 0; + } + } + + return !llhttp_message_needs_eof(parser); +} diff --git a/fgex-lib/llhttp/lib/llhttp.c b/fgex-lib/llhttp/lib/llhttp.c new file mode 100644 index 0000000..7354577 --- /dev/null +++ b/fgex-lib/llhttp/lib/llhttp.c @@ -0,0 +1,10279 @@ +#include +#include +#include + +#ifdef __SSE4_2__ + #ifdef _MSC_VER + #include + #else /* !_MSC_VER */ + #include + #endif /* _MSC_VER */ +#endif /* __SSE4_2__ */ + +#if defined(__ARM_NEON__) || defined(__ARM_NEON) + #include +#endif /* __ARM_NEON__ */ + +#ifdef __wasm__ + #include +#endif /* __wasm__ */ + +#ifdef _MSC_VER + #define ALIGN(n) _declspec(align(n)) + #define UNREACHABLE __assume(0) +#else /* !_MSC_VER */ + #define ALIGN(n) __attribute__((aligned(n))) + #define UNREACHABLE __builtin_unreachable() +#endif /* _MSC_VER */ + +#include "llhttp.h" + +typedef int (*llhttp__internal__span_cb)( + llhttp__internal_t*, const char*, const char*); + +static const unsigned char llparse_blob0[] = { + 'o', 'n' +}; +static const unsigned char llparse_blob1[] = { + 'e', 'c', 't', 'i', 'o', 'n' +}; +static const unsigned char llparse_blob2[] = { + 'l', 'o', 's', 'e' +}; +#ifdef __SSE4_2__ +static const unsigned char ALIGN(16) llparse_blob3[] = { + 0x1, 0x9, 0xb, 0xc, 0xe, 0xff, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0 +}; +#endif /* __SSE4_2__ */ +static const unsigned char llparse_blob4[] = { + 'e', 'e', 'p', '-', 'a', 'l', 'i', 'v', 'e' +}; +static const unsigned char llparse_blob5[] = { + 'p', 'g', 'r', 'a', 'd', 'e' +}; +static const unsigned char llparse_blob6[] = { + 'c', 'h', 'u', 'n', 'k', 'e', 'd' +}; +#ifdef __SSE4_2__ +static const unsigned char ALIGN(16) llparse_blob7[] = { + 0x9, 0x9, ' ', '~', 0x80, 0xff, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0 +}; +#endif /* __SSE4_2__ */ +#ifdef __SSE4_2__ +static const unsigned char ALIGN(16) llparse_blob8[] = { + '!', '!', '#', '\'', '*', '+', '-', '.', '0', '9', 'A', + 'Z', '^', 'z', '|', '|' +}; +#endif /* __SSE4_2__ */ +#ifdef __SSE4_2__ +static const unsigned char ALIGN(16) llparse_blob9[] = { + '~', '~', 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, 0x0 +}; +#endif /* __SSE4_2__ */ +static const unsigned char llparse_blob10[] = { + 'e', 'n', 't', '-', 'l', 'e', 'n', 'g', 't', 'h' +}; +static const unsigned char llparse_blob11[] = { + 'r', 'o', 'x', 'y', '-', 'c', 'o', 'n', 'n', 'e', 'c', + 't', 'i', 'o', 'n' +}; +static const unsigned char llparse_blob12[] = { + 'r', 'a', 'n', 's', 'f', 'e', 'r', '-', 'e', 'n', 'c', + 'o', 'd', 'i', 'n', 'g' +}; +static const unsigned char llparse_blob13[] = { + 'p', 'g', 'r', 'a', 'd', 'e' +}; +static const unsigned char llparse_blob14[] = { + 'T', 'T', 'P' +}; +static const unsigned char llparse_blob15[] = { + 0xd, 0xa, 0xd, 0xa, 'S', 'M', 0xd, 0xa, 0xd, 0xa +}; +static const unsigned char llparse_blob16[] = { + 'C', 'E' +}; +static const unsigned char llparse_blob17[] = { + 'T', 'S', 'P' +}; +static const unsigned char llparse_blob18[] = { + 'N', 'O', 'U', 'N', 'C', 'E' +}; +static const unsigned char llparse_blob19[] = { + 'I', 'N', 'D' +}; +static const unsigned char llparse_blob20[] = { + 'E', 'C', 'K', 'O', 'U', 'T' +}; +static const unsigned char llparse_blob21[] = { + 'N', 'E', 'C', 'T' +}; +static const unsigned char llparse_blob22[] = { + 'E', 'T', 'E' +}; +static const unsigned char llparse_blob23[] = { + 'C', 'R', 'I', 'B', 'E' +}; +static const unsigned char llparse_blob24[] = { + 'L', 'U', 'S', 'H' +}; +static const unsigned char llparse_blob25[] = { + 'E', 'T' +}; +static const unsigned char llparse_blob26[] = { + 'P', 'A', 'R', 'A', 'M', 'E', 'T', 'E', 'R' +}; +static const unsigned char llparse_blob27[] = { + 'E', 'A', 'D' +}; +static const unsigned char llparse_blob28[] = { + 'N', 'K' +}; +static const unsigned char llparse_blob29[] = { + 'C', 'K' +}; +static const unsigned char llparse_blob30[] = { + 'S', 'E', 'A', 'R', 'C', 'H' +}; +static const unsigned char llparse_blob31[] = { + 'R', 'G', 'E' +}; +static const unsigned char llparse_blob32[] = { + 'C', 'T', 'I', 'V', 'I', 'T', 'Y' +}; +static const unsigned char llparse_blob33[] = { + 'L', 'E', 'N', 'D', 'A', 'R' +}; +static const unsigned char llparse_blob34[] = { + 'V', 'E' +}; +static const unsigned char llparse_blob35[] = { + 'O', 'T', 'I', 'F', 'Y' +}; +static const unsigned char llparse_blob36[] = { + 'P', 'T', 'I', 'O', 'N', 'S' +}; +static const unsigned char llparse_blob37[] = { + 'C', 'H' +}; +static const unsigned char llparse_blob38[] = { + 'S', 'E' +}; +static const unsigned char llparse_blob39[] = { + 'A', 'Y' +}; +static const unsigned char llparse_blob40[] = { + 'S', 'T' +}; +static const unsigned char llparse_blob41[] = { + 'I', 'N', 'D' +}; +static const unsigned char llparse_blob42[] = { + 'A', 'T', 'C', 'H' +}; +static const unsigned char llparse_blob43[] = { + 'G', 'E' +}; +static const unsigned char llparse_blob44[] = { + 'U', 'E', 'R', 'Y' +}; +static const unsigned char llparse_blob45[] = { + 'I', 'N', 'D' +}; +static const unsigned char llparse_blob46[] = { + 'O', 'R', 'D' +}; +static const unsigned char llparse_blob47[] = { + 'I', 'R', 'E', 'C', 'T' +}; +static const unsigned char llparse_blob48[] = { + 'O', 'R', 'T' +}; +static const unsigned char llparse_blob49[] = { + 'R', 'C', 'H' +}; +static const unsigned char llparse_blob50[] = { + 'P', 'A', 'R', 'A', 'M', 'E', 'T', 'E', 'R' +}; +static const unsigned char llparse_blob51[] = { + 'U', 'R', 'C', 'E' +}; +static const unsigned char llparse_blob52[] = { + 'B', 'S', 'C', 'R', 'I', 'B', 'E' +}; +static const unsigned char llparse_blob53[] = { + 'A', 'R', 'D', 'O', 'W', 'N' +}; +static const unsigned char llparse_blob54[] = { + 'A', 'C', 'E' +}; +static const unsigned char llparse_blob55[] = { + 'I', 'N', 'D' +}; +static const unsigned char llparse_blob56[] = { + 'N', 'K' +}; +static const unsigned char llparse_blob57[] = { + 'C', 'K' +}; +static const unsigned char llparse_blob58[] = { + 'U', 'B', 'S', 'C', 'R', 'I', 'B', 'E' +}; +static const unsigned char llparse_blob59[] = { + 'T', 'T', 'P' +}; +static const unsigned char llparse_blob60[] = { + 'C', 'E' +}; +static const unsigned char llparse_blob61[] = { + 'T', 'S', 'P' +}; +static const unsigned char llparse_blob62[] = { + 'A', 'D' +}; +static const unsigned char llparse_blob63[] = { + 'T', 'P', '/' +}; + +enum llparse_match_status_e { + kMatchComplete, + kMatchPause, + kMatchMismatch +}; +typedef enum llparse_match_status_e llparse_match_status_t; + +struct llparse_match_s { + llparse_match_status_t status; + const unsigned char* current; +}; +typedef struct llparse_match_s llparse_match_t; + +static llparse_match_t llparse__match_sequence_to_lower( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp, + const unsigned char* seq, uint32_t seq_len) { + uint32_t index; + llparse_match_t res; + + index = s->_index; + for (; p != endp; p++) { + unsigned char current; + + current = ((*p) >= 'A' && (*p) <= 'Z' ? (*p | 0x20) : (*p)); + if (current == seq[index]) { + if (++index == seq_len) { + res.status = kMatchComplete; + goto reset; + } + } else { + res.status = kMatchMismatch; + goto reset; + } + } + s->_index = index; + res.status = kMatchPause; + res.current = p; + return res; +reset: + s->_index = 0; + res.current = p; + return res; +} + +static llparse_match_t llparse__match_sequence_to_lower_unsafe( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp, + const unsigned char* seq, uint32_t seq_len) { + uint32_t index; + llparse_match_t res; + + index = s->_index; + for (; p != endp; p++) { + unsigned char current; + + current = ((*p) | 0x20); + if (current == seq[index]) { + if (++index == seq_len) { + res.status = kMatchComplete; + goto reset; + } + } else { + res.status = kMatchMismatch; + goto reset; + } + } + s->_index = index; + res.status = kMatchPause; + res.current = p; + return res; +reset: + s->_index = 0; + res.current = p; + return res; +} + +static llparse_match_t llparse__match_sequence_id( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp, + const unsigned char* seq, uint32_t seq_len) { + uint32_t index; + llparse_match_t res; + + index = s->_index; + for (; p != endp; p++) { + unsigned char current; + + current = *p; + if (current == seq[index]) { + if (++index == seq_len) { + res.status = kMatchComplete; + goto reset; + } + } else { + res.status = kMatchMismatch; + goto reset; + } + } + s->_index = index; + res.status = kMatchPause; + res.current = p; + return res; +reset: + s->_index = 0; + res.current = p; + return res; +} + +enum llparse_state_e { + s_error, + s_n_llhttp__internal__n_closed, + s_n_llhttp__internal__n_invoke_llhttp__after_message_complete, + s_n_llhttp__internal__n_pause_1, + s_n_llhttp__internal__n_invoke_is_equal_upgrade, + s_n_llhttp__internal__n_invoke_llhttp__on_message_complete_2, + s_n_llhttp__internal__n_chunk_data_almost_done_1, + s_n_llhttp__internal__n_chunk_data_almost_done, + s_n_llhttp__internal__n_consume_content_length, + s_n_llhttp__internal__n_span_start_llhttp__on_body, + s_n_llhttp__internal__n_invoke_is_equal_content_length, + s_n_llhttp__internal__n_chunk_size_almost_done, + s_n_llhttp__internal__n_invoke_test_lenient_flags_9, + s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_name_complete, + s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_name_complete_1, + s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_name_complete_2, + s_n_llhttp__internal__n_invoke_test_lenient_flags_10, + s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_value_complete, + s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_value_complete_1, + s_n_llhttp__internal__n_chunk_extension_quoted_value_done, + s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_value_complete_2, + s_n_llhttp__internal__n_error_30, + s_n_llhttp__internal__n_chunk_extension_quoted_value_quoted_pair, + s_n_llhttp__internal__n_error_31, + s_n_llhttp__internal__n_chunk_extension_quoted_value, + s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_value_complete_3, + s_n_llhttp__internal__n_error_33, + s_n_llhttp__internal__n_chunk_extension_value, + s_n_llhttp__internal__n_span_start_llhttp__on_chunk_extension_value, + s_n_llhttp__internal__n_error_34, + s_n_llhttp__internal__n_chunk_extension_name, + s_n_llhttp__internal__n_span_start_llhttp__on_chunk_extension_name, + s_n_llhttp__internal__n_chunk_extensions, + s_n_llhttp__internal__n_chunk_size_otherwise, + s_n_llhttp__internal__n_chunk_size, + s_n_llhttp__internal__n_chunk_size_digit, + s_n_llhttp__internal__n_invoke_update_content_length_1, + s_n_llhttp__internal__n_consume_content_length_1, + s_n_llhttp__internal__n_span_start_llhttp__on_body_1, + s_n_llhttp__internal__n_eof, + s_n_llhttp__internal__n_span_start_llhttp__on_body_2, + s_n_llhttp__internal__n_invoke_llhttp__after_headers_complete, + s_n_llhttp__internal__n_error_5, + s_n_llhttp__internal__n_headers_almost_done, + s_n_llhttp__internal__n_header_field_colon_discard_ws, + s_n_llhttp__internal__n_invoke_llhttp__on_header_value_complete, + s_n_llhttp__internal__n_span_start_llhttp__on_header_value, + s_n_llhttp__internal__n_header_value_discard_lws, + s_n_llhttp__internal__n_header_value_discard_ws_almost_done, + s_n_llhttp__internal__n_header_value_lws, + s_n_llhttp__internal__n_header_value_almost_done, + s_n_llhttp__internal__n_invoke_test_lenient_flags_17, + s_n_llhttp__internal__n_header_value_lenient, + s_n_llhttp__internal__n_header_value_relaxed, + s_n_llhttp__internal__n_error_55, + s_n_llhttp__internal__n_header_value_otherwise, + s_n_llhttp__internal__n_header_value_connection_token, + s_n_llhttp__internal__n_header_value_connection_ws, + s_n_llhttp__internal__n_header_value_connection_1, + s_n_llhttp__internal__n_header_value_connection_2, + s_n_llhttp__internal__n_header_value_connection_3, + s_n_llhttp__internal__n_header_value_connection, + s_n_llhttp__internal__n_error_57, + s_n_llhttp__internal__n_error_58, + s_n_llhttp__internal__n_header_value_content_length_ws, + s_n_llhttp__internal__n_header_value_content_length, + s_n_llhttp__internal__n_error_60, + s_n_llhttp__internal__n_error_59, + s_n_llhttp__internal__n_header_value_te_token_ows, + s_n_llhttp__internal__n_header_value, + s_n_llhttp__internal__n_header_value_te_token, + s_n_llhttp__internal__n_header_value_te_chunked_last, + s_n_llhttp__internal__n_header_value_te_chunked, + s_n_llhttp__internal__n_span_start_llhttp__on_header_value_1, + s_n_llhttp__internal__n_header_value_discard_ws, + s_n_llhttp__internal__n_invoke_load_header_state, + s_n_llhttp__internal__n_invoke_llhttp__on_header_field_complete, + s_n_llhttp__internal__n_header_field_general_otherwise, + s_n_llhttp__internal__n_header_field_general, + s_n_llhttp__internal__n_header_field_colon, + s_n_llhttp__internal__n_header_field_3, + s_n_llhttp__internal__n_header_field_4, + s_n_llhttp__internal__n_header_field_2, + s_n_llhttp__internal__n_header_field_1, + s_n_llhttp__internal__n_header_field_5, + s_n_llhttp__internal__n_header_field_6, + s_n_llhttp__internal__n_header_field_7, + s_n_llhttp__internal__n_header_field, + s_n_llhttp__internal__n_span_start_llhttp__on_header_field, + s_n_llhttp__internal__n_header_field_start, + s_n_llhttp__internal__n_headers_start, + s_n_llhttp__internal__n_url_to_http_09, + s_n_llhttp__internal__n_url_skip_to_http09, + s_n_llhttp__internal__n_url_skip_lf_to_http09_1, + s_n_llhttp__internal__n_url_skip_lf_to_http09, + s_n_llhttp__internal__n_req_pri_upgrade, + s_n_llhttp__internal__n_req_http_complete_crlf, + s_n_llhttp__internal__n_req_http_complete, + s_n_llhttp__internal__n_invoke_load_method_1, + s_n_llhttp__internal__n_invoke_llhttp__on_version_complete, + s_n_llhttp__internal__n_error_68, + s_n_llhttp__internal__n_error_75, + s_n_llhttp__internal__n_req_http_minor, + s_n_llhttp__internal__n_error_76, + s_n_llhttp__internal__n_req_http_dot, + s_n_llhttp__internal__n_error_77, + s_n_llhttp__internal__n_req_http_major, + s_n_llhttp__internal__n_span_start_llhttp__on_version, + s_n_llhttp__internal__n_req_after_protocol, + s_n_llhttp__internal__n_invoke_load_method, + s_n_llhttp__internal__n_invoke_llhttp__on_protocol_complete, + s_n_llhttp__internal__n_error_83, + s_n_llhttp__internal__n_req_after_http_start_1, + s_n_llhttp__internal__n_invoke_load_method_2, + s_n_llhttp__internal__n_invoke_llhttp__on_protocol_complete_1, + s_n_llhttp__internal__n_req_after_http_start_2, + s_n_llhttp__internal__n_invoke_load_method_3, + s_n_llhttp__internal__n_invoke_llhttp__on_protocol_complete_2, + s_n_llhttp__internal__n_req_after_http_start_3, + s_n_llhttp__internal__n_req_after_http_start, + s_n_llhttp__internal__n_span_start_llhttp__on_protocol, + s_n_llhttp__internal__n_req_http_start, + s_n_llhttp__internal__n_url_to_http, + s_n_llhttp__internal__n_url_skip_to_http, + s_n_llhttp__internal__n_url_fragment, + s_n_llhttp__internal__n_span_end_stub_query_3, + s_n_llhttp__internal__n_url_query, + s_n_llhttp__internal__n_url_query_or_fragment, + s_n_llhttp__internal__n_url_path, + s_n_llhttp__internal__n_span_start_stub_path_2, + s_n_llhttp__internal__n_span_start_stub_path, + s_n_llhttp__internal__n_span_start_stub_path_1, + s_n_llhttp__internal__n_url_server_with_at, + s_n_llhttp__internal__n_url_server, + s_n_llhttp__internal__n_url_schema_delim_1, + s_n_llhttp__internal__n_url_schema_delim, + s_n_llhttp__internal__n_span_end_stub_schema, + s_n_llhttp__internal__n_url_schema, + s_n_llhttp__internal__n_url_start, + s_n_llhttp__internal__n_span_start_llhttp__on_url_1, + s_n_llhttp__internal__n_url_entry_normal, + s_n_llhttp__internal__n_span_start_llhttp__on_url, + s_n_llhttp__internal__n_url_entry_connect, + s_n_llhttp__internal__n_req_spaces_before_url, + s_n_llhttp__internal__n_req_first_space_before_url, + s_n_llhttp__internal__n_invoke_llhttp__on_method_complete_1, + s_n_llhttp__internal__n_after_start_req_2, + s_n_llhttp__internal__n_after_start_req_3, + s_n_llhttp__internal__n_after_start_req_1, + s_n_llhttp__internal__n_after_start_req_4, + s_n_llhttp__internal__n_after_start_req_6, + s_n_llhttp__internal__n_after_start_req_8, + s_n_llhttp__internal__n_after_start_req_9, + s_n_llhttp__internal__n_after_start_req_7, + s_n_llhttp__internal__n_after_start_req_5, + s_n_llhttp__internal__n_after_start_req_12, + s_n_llhttp__internal__n_after_start_req_13, + s_n_llhttp__internal__n_after_start_req_11, + s_n_llhttp__internal__n_after_start_req_10, + s_n_llhttp__internal__n_after_start_req_14, + s_n_llhttp__internal__n_after_start_req_17, + s_n_llhttp__internal__n_after_start_req_16, + s_n_llhttp__internal__n_after_start_req_15, + s_n_llhttp__internal__n_after_start_req_18, + s_n_llhttp__internal__n_after_start_req_20, + s_n_llhttp__internal__n_after_start_req_21, + s_n_llhttp__internal__n_after_start_req_19, + s_n_llhttp__internal__n_after_start_req_23, + s_n_llhttp__internal__n_after_start_req_24, + s_n_llhttp__internal__n_after_start_req_26, + s_n_llhttp__internal__n_after_start_req_28, + s_n_llhttp__internal__n_after_start_req_29, + s_n_llhttp__internal__n_after_start_req_27, + s_n_llhttp__internal__n_after_start_req_25, + s_n_llhttp__internal__n_after_start_req_30, + s_n_llhttp__internal__n_after_start_req_22, + s_n_llhttp__internal__n_after_start_req_31, + s_n_llhttp__internal__n_after_start_req_32, + s_n_llhttp__internal__n_after_start_req_35, + s_n_llhttp__internal__n_after_start_req_36, + s_n_llhttp__internal__n_after_start_req_34, + s_n_llhttp__internal__n_after_start_req_37, + s_n_llhttp__internal__n_after_start_req_38, + s_n_llhttp__internal__n_after_start_req_42, + s_n_llhttp__internal__n_after_start_req_43, + s_n_llhttp__internal__n_after_start_req_41, + s_n_llhttp__internal__n_after_start_req_40, + s_n_llhttp__internal__n_after_start_req_39, + s_n_llhttp__internal__n_after_start_req_45, + s_n_llhttp__internal__n_after_start_req_44, + s_n_llhttp__internal__n_after_start_req_33, + s_n_llhttp__internal__n_after_start_req_46, + s_n_llhttp__internal__n_after_start_req_49, + s_n_llhttp__internal__n_after_start_req_50, + s_n_llhttp__internal__n_after_start_req_51, + s_n_llhttp__internal__n_after_start_req_52, + s_n_llhttp__internal__n_after_start_req_48, + s_n_llhttp__internal__n_after_start_req_47, + s_n_llhttp__internal__n_after_start_req_55, + s_n_llhttp__internal__n_after_start_req_57, + s_n_llhttp__internal__n_after_start_req_58, + s_n_llhttp__internal__n_after_start_req_56, + s_n_llhttp__internal__n_after_start_req_54, + s_n_llhttp__internal__n_after_start_req_59, + s_n_llhttp__internal__n_after_start_req_60, + s_n_llhttp__internal__n_after_start_req_53, + s_n_llhttp__internal__n_after_start_req_62, + s_n_llhttp__internal__n_after_start_req_63, + s_n_llhttp__internal__n_after_start_req_61, + s_n_llhttp__internal__n_after_start_req_66, + s_n_llhttp__internal__n_after_start_req_68, + s_n_llhttp__internal__n_after_start_req_69, + s_n_llhttp__internal__n_after_start_req_67, + s_n_llhttp__internal__n_after_start_req_70, + s_n_llhttp__internal__n_after_start_req_65, + s_n_llhttp__internal__n_after_start_req_64, + s_n_llhttp__internal__n_after_start_req, + s_n_llhttp__internal__n_span_start_llhttp__on_method_1, + s_n_llhttp__internal__n_res_line_almost_done, + s_n_llhttp__internal__n_invoke_test_lenient_flags_31, + s_n_llhttp__internal__n_res_status, + s_n_llhttp__internal__n_span_start_llhttp__on_status, + s_n_llhttp__internal__n_res_status_code_otherwise, + s_n_llhttp__internal__n_res_status_code_digit_3, + s_n_llhttp__internal__n_res_status_code_digit_2, + s_n_llhttp__internal__n_res_status_code_digit_1, + s_n_llhttp__internal__n_res_after_version, + s_n_llhttp__internal__n_invoke_llhttp__on_version_complete_1, + s_n_llhttp__internal__n_error_94, + s_n_llhttp__internal__n_error_108, + s_n_llhttp__internal__n_res_http_minor, + s_n_llhttp__internal__n_error_109, + s_n_llhttp__internal__n_res_http_dot, + s_n_llhttp__internal__n_error_110, + s_n_llhttp__internal__n_res_http_major, + s_n_llhttp__internal__n_span_start_llhttp__on_version_1, + s_n_llhttp__internal__n_res_after_protocol, + s_n_llhttp__internal__n_invoke_llhttp__on_protocol_complete_3, + s_n_llhttp__internal__n_error_116, + s_n_llhttp__internal__n_res_after_start_1, + s_n_llhttp__internal__n_res_after_start_2, + s_n_llhttp__internal__n_res_after_start_3, + s_n_llhttp__internal__n_res_after_start, + s_n_llhttp__internal__n_span_start_llhttp__on_protocol_1, + s_n_llhttp__internal__n_invoke_llhttp__on_method_complete, + s_n_llhttp__internal__n_req_or_res_method_2, + s_n_llhttp__internal__n_invoke_update_type_1, + s_n_llhttp__internal__n_req_or_res_method_3, + s_n_llhttp__internal__n_req_or_res_method_1, + s_n_llhttp__internal__n_req_or_res_method, + s_n_llhttp__internal__n_span_start_llhttp__on_method, + s_n_llhttp__internal__n_start_req_or_res, + s_n_llhttp__internal__n_invoke_load_type, + s_n_llhttp__internal__n_invoke_update_finish, + s_n_llhttp__internal__n_start, +}; +typedef enum llparse_state_e llparse_state_t; + +int llhttp__on_method( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__on_url( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__on_protocol( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__on_version( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__on_header_field( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__on_header_value( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__on_body( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__on_chunk_extension_name( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__on_chunk_extension_value( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__on_status( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__internal__c_load_initial_message_completed( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return state->initial_message_completed; +} + +int llhttp__on_reset( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__internal__c_update_finish( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->finish = 2; + return 0; +} + +int llhttp__on_message_begin( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__internal__c_load_type( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return state->type; +} + +int llhttp__internal__c_store_method( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp, + int match) { + state->method = match; + return 0; +} + +int llhttp__on_method_complete( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__internal__c_is_equal_method( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return state->method == 5; +} + +int llhttp__internal__c_update_http_major( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->http_major = 0; + return 0; +} + +int llhttp__internal__c_update_http_minor( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->http_minor = 9; + return 0; +} + +int llhttp__on_url_complete( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__internal__c_test_lenient_flags( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return (state->lenient_flags & 1) == 1; +} + +int llhttp__internal__c_test_lenient_flags_1( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return (state->lenient_flags & 256) == 256; +} + +int llhttp__internal__c_test_flags( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return (state->flags & 128) == 128; +} + +int llhttp__on_chunk_complete( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__on_message_complete( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__internal__c_is_equal_upgrade( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return state->upgrade == 1; +} + +int llhttp__after_message_complete( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__internal__c_update_content_length( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->content_length = 0; + return 0; +} + +int llhttp__internal__c_update_initial_message_completed( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->initial_message_completed = 1; + return 0; +} + +int llhttp__internal__c_update_finish_1( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->finish = 0; + return 0; +} + +int llhttp__internal__c_test_lenient_flags_2( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return (state->lenient_flags & 4) == 4; +} + +int llhttp__internal__c_test_lenient_flags_3( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return (state->lenient_flags & 32) == 32; +} + +int llhttp__before_headers_complete( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__on_headers_complete( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__after_headers_complete( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__internal__c_mul_add_content_length( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp, + int match) { + /* Multiplication overflow */ + if (state->content_length > 0xffffffffffffffffULL / 16) { + return 1; + } + + state->content_length *= 16; + + /* Addition overflow */ + if (match >= 0) { + if (state->content_length > 0xffffffffffffffffULL - match) { + return 1; + } + } else { + if (state->content_length < 0ULL - match) { + return 1; + } + } + state->content_length += match; + return 0; +} + +int llhttp__internal__c_test_lenient_flags_4( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return (state->lenient_flags & 512) == 512; +} + +int llhttp__on_chunk_header( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__internal__c_is_equal_content_length( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return state->content_length == 0; +} + +int llhttp__internal__c_test_lenient_flags_7( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return (state->lenient_flags & 128) == 128; +} + +int llhttp__internal__c_or_flags( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->flags |= 128; + return 0; +} + +int llhttp__internal__c_test_lenient_flags_8( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return (state->lenient_flags & 64) == 64; +} + +int llhttp__on_chunk_extension_name_complete( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__on_chunk_extension_value_complete( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__internal__c_update_finish_3( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->finish = 1; + return 0; +} + +int llhttp__internal__c_or_flags_1( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->flags |= 64; + return 0; +} + +int llhttp__internal__c_update_upgrade( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->upgrade = 1; + return 0; +} + +int llhttp__internal__c_store_header_state( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp, + int match) { + state->header_state = match; + return 0; +} + +int llhttp__on_header_field_complete( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__internal__c_load_header_state( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return state->header_state; +} + +int llhttp__internal__c_test_flags_4( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return (state->flags & 512) == 512; +} + +int llhttp__internal__c_test_lenient_flags_23( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return (state->lenient_flags & 2) == 2; +} + +int llhttp__internal__c_or_flags_5( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->flags |= 1; + return 0; +} + +int llhttp__internal__c_update_header_state( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->header_state = 1; + return 0; +} + +int llhttp__on_header_value_complete( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__internal__c_or_flags_6( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->flags |= 2; + return 0; +} + +int llhttp__internal__c_or_flags_7( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->flags |= 4; + return 0; +} + +int llhttp__internal__c_or_flags_8( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->flags |= 8; + return 0; +} + +int llhttp__internal__c_update_header_state_3( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->header_state = 6; + return 0; +} + +int llhttp__internal__c_update_header_state_1( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->header_state = 0; + return 0; +} + +int llhttp__internal__c_test_lenient_flags_20( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return (state->lenient_flags & 1024) == 1024; +} + +int llhttp__internal__c_update_header_state_6( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->header_state = 5; + return 0; +} + +int llhttp__internal__c_update_header_state_7( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->header_state = 7; + return 0; +} + +int llhttp__internal__c_test_flags_2( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return (state->flags & 32) == 32; +} + +int llhttp__internal__c_mul_add_content_length_1( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp, + int match) { + /* Multiplication overflow */ + if (state->content_length > 0xffffffffffffffffULL / 10) { + return 1; + } + + state->content_length *= 10; + + /* Addition overflow */ + if (match >= 0) { + if (state->content_length > 0xffffffffffffffffULL - match) { + return 1; + } + } else { + if (state->content_length < 0ULL - match) { + return 1; + } + } + state->content_length += match; + return 0; +} + +int llhttp__internal__c_or_flags_17( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->flags |= 32; + return 0; +} + +int llhttp__internal__c_test_flags_3( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return (state->flags & 8) == 8; +} + +int llhttp__internal__c_test_lenient_flags_21( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return (state->lenient_flags & 8) == 8; +} + +int llhttp__internal__c_or_flags_18( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->flags |= 512; + return 0; +} + +int llhttp__internal__c_and_flags( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->flags &= -9; + return 0; +} + +int llhttp__internal__c_update_header_state_8( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->header_state = 8; + return 0; +} + +int llhttp__internal__c_or_flags_20( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->flags |= 16; + return 0; +} + +int llhttp__on_protocol_complete( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__internal__c_load_method( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return state->method; +} + +int llhttp__internal__c_store_http_major( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp, + int match) { + state->http_major = match; + return 0; +} + +int llhttp__internal__c_store_http_minor( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp, + int match) { + state->http_minor = match; + return 0; +} + +int llhttp__internal__c_test_lenient_flags_25( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return (state->lenient_flags & 16) == 16; +} + +int llhttp__on_version_complete( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__internal__c_load_http_major( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return state->http_major; +} + +int llhttp__internal__c_load_http_minor( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + return state->http_minor; +} + +int llhttp__internal__c_update_status_code( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->status_code = 0; + return 0; +} + +int llhttp__internal__c_mul_add_status_code( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp, + int match) { + /* Multiplication overflow */ + if (state->status_code > 0xffff / 10) { + return 1; + } + + state->status_code *= 10; + + /* Addition overflow */ + if (match >= 0) { + if (state->status_code > 0xffff - match) { + return 1; + } + } else { + if (state->status_code < 0 - match) { + return 1; + } + } + state->status_code += match; + return 0; +} + +int llhttp__on_status_complete( + llhttp__internal_t* s, const unsigned char* p, + const unsigned char* endp); + +int llhttp__internal__c_update_type( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->type = 1; + return 0; +} + +int llhttp__internal__c_update_type_1( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + state->type = 2; + return 0; +} + +int llhttp__internal_init(llhttp__internal_t* state) { + memset(state, 0, sizeof(*state)); + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_start; + return 0; +} + +static llparse_state_t llhttp__internal__run( + llhttp__internal_t* state, + const unsigned char* p, + const unsigned char* endp) { + int match; + switch ((llparse_state_t) (intptr_t) state->_current) { + case s_n_llhttp__internal__n_closed: + s_n_llhttp__internal__n_closed: { + if (p == endp) { + return s_n_llhttp__internal__n_closed; + } + switch (*p) { + case 10: { + p++; + goto s_n_llhttp__internal__n_closed; + } + case 13: { + p++; + goto s_n_llhttp__internal__n_closed; + } + default: { + p++; + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_3; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_llhttp__after_message_complete: + s_n_llhttp__internal__n_invoke_llhttp__after_message_complete: { + switch (llhttp__after_message_complete(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_invoke_update_content_length; + default: + goto s_n_llhttp__internal__n_invoke_update_finish_1; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_pause_1: + s_n_llhttp__internal__n_pause_1: { + state->error = 0x16; + state->reason = "Pause on CONNECT/Upgrade"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__after_message_complete; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_is_equal_upgrade: + s_n_llhttp__internal__n_invoke_is_equal_upgrade: { + switch (llhttp__internal__c_is_equal_upgrade(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_invoke_llhttp__after_message_complete; + default: + goto s_n_llhttp__internal__n_pause_1; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_llhttp__on_message_complete_2: + s_n_llhttp__internal__n_invoke_llhttp__on_message_complete_2: { + switch (llhttp__on_message_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_invoke_is_equal_upgrade; + case 21: + goto s_n_llhttp__internal__n_pause_13; + default: + goto s_n_llhttp__internal__n_error_38; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_chunk_data_almost_done_1: + s_n_llhttp__internal__n_chunk_data_almost_done_1: { + if (p == endp) { + return s_n_llhttp__internal__n_chunk_data_almost_done_1; + } + switch (*p) { + case 10: { + p++; + goto s_n_llhttp__internal__n_invoke_llhttp__on_chunk_complete; + } + default: { + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_7; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_chunk_data_almost_done: + s_n_llhttp__internal__n_chunk_data_almost_done: { + if (p == endp) { + return s_n_llhttp__internal__n_chunk_data_almost_done; + } + switch (*p) { + case 10: { + p++; + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_6; + } + case 13: { + p++; + goto s_n_llhttp__internal__n_chunk_data_almost_done_1; + } + default: { + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_7; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_consume_content_length: + s_n_llhttp__internal__n_consume_content_length: { + size_t avail; + uint64_t need; + + avail = endp - p; + need = state->content_length; + if (avail >= need) { + p += need; + state->content_length = 0; + goto s_n_llhttp__internal__n_span_end_llhttp__on_body; + } + + state->content_length -= avail; + return s_n_llhttp__internal__n_consume_content_length; + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_start_llhttp__on_body: + s_n_llhttp__internal__n_span_start_llhttp__on_body: { + if (p == endp) { + return s_n_llhttp__internal__n_span_start_llhttp__on_body; + } + state->_span_pos0 = (void*) p; + state->_span_cb0 = llhttp__on_body; + goto s_n_llhttp__internal__n_consume_content_length; + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_is_equal_content_length: + s_n_llhttp__internal__n_invoke_is_equal_content_length: { + switch (llhttp__internal__c_is_equal_content_length(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_span_start_llhttp__on_body; + default: + goto s_n_llhttp__internal__n_invoke_or_flags; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_chunk_size_almost_done: + s_n_llhttp__internal__n_chunk_size_almost_done: { + if (p == endp) { + return s_n_llhttp__internal__n_chunk_size_almost_done; + } + switch (*p) { + case 10: { + p++; + goto s_n_llhttp__internal__n_invoke_llhttp__on_chunk_header; + } + default: { + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_8; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_test_lenient_flags_9: + s_n_llhttp__internal__n_invoke_test_lenient_flags_9: { + switch (llhttp__internal__c_test_lenient_flags_1(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_chunk_size_almost_done; + default: + goto s_n_llhttp__internal__n_error_20; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_name_complete: + s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_name_complete: { + switch (llhttp__on_chunk_extension_name_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_9; + case 21: + goto s_n_llhttp__internal__n_pause_5; + default: + goto s_n_llhttp__internal__n_error_19; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_name_complete_1: + s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_name_complete_1: { + switch (llhttp__on_chunk_extension_name_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_chunk_size_almost_done; + case 21: + goto s_n_llhttp__internal__n_pause_6; + default: + goto s_n_llhttp__internal__n_error_21; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_name_complete_2: + s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_name_complete_2: { + switch (llhttp__on_chunk_extension_name_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_chunk_extensions; + case 21: + goto s_n_llhttp__internal__n_pause_7; + default: + goto s_n_llhttp__internal__n_error_22; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_test_lenient_flags_10: + s_n_llhttp__internal__n_invoke_test_lenient_flags_10: { + switch (llhttp__internal__c_test_lenient_flags_1(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_chunk_size_almost_done; + default: + goto s_n_llhttp__internal__n_error_25; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_value_complete: + s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_value_complete: { + switch (llhttp__on_chunk_extension_value_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_10; + case 21: + goto s_n_llhttp__internal__n_pause_8; + default: + goto s_n_llhttp__internal__n_error_24; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_value_complete_1: + s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_value_complete_1: { + switch (llhttp__on_chunk_extension_value_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_chunk_size_almost_done; + case 21: + goto s_n_llhttp__internal__n_pause_9; + default: + goto s_n_llhttp__internal__n_error_26; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_chunk_extension_quoted_value_done: + s_n_llhttp__internal__n_chunk_extension_quoted_value_done: { + if (p == endp) { + return s_n_llhttp__internal__n_chunk_extension_quoted_value_done; + } + switch (*p) { + case 10: { + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_11; + } + case 13: { + p++; + goto s_n_llhttp__internal__n_chunk_size_almost_done; + } + case ';': { + p++; + goto s_n_llhttp__internal__n_chunk_extensions; + } + default: { + goto s_n_llhttp__internal__n_error_29; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_value_complete_2: + s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_value_complete_2: { + switch (llhttp__on_chunk_extension_value_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_chunk_extension_quoted_value_done; + case 21: + goto s_n_llhttp__internal__n_pause_10; + default: + goto s_n_llhttp__internal__n_error_27; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_error_30: + s_n_llhttp__internal__n_error_30: { + state->error = 0x2; + state->reason = "Invalid quoted-pair in chunk extensions quoted value"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_chunk_extension_quoted_value_quoted_pair: + s_n_llhttp__internal__n_chunk_extension_quoted_value_quoted_pair: { + static uint8_t lookup_table[] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 + }; + if (p == endp) { + return s_n_llhttp__internal__n_chunk_extension_quoted_value_quoted_pair; + } + switch (lookup_table[(uint8_t) *p]) { + case 1: { + p++; + goto s_n_llhttp__internal__n_chunk_extension_quoted_value; + } + default: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_value_3; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_error_31: + s_n_llhttp__internal__n_error_31: { + state->error = 0x2; + state->reason = "Invalid character in chunk extensions quoted value"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_chunk_extension_quoted_value: + s_n_llhttp__internal__n_chunk_extension_quoted_value: { + static uint8_t lookup_table[] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 + }; + if (p == endp) { + return s_n_llhttp__internal__n_chunk_extension_quoted_value; + } + switch (lookup_table[(uint8_t) *p]) { + case 1: { + p++; + goto s_n_llhttp__internal__n_chunk_extension_quoted_value; + } + case 2: { + p++; + goto s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_value_2; + } + case 3: { + p++; + goto s_n_llhttp__internal__n_chunk_extension_quoted_value_quoted_pair; + } + default: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_value_4; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_value_complete_3: + s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_value_complete_3: { + switch (llhttp__on_chunk_extension_value_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_chunk_extensions; + case 21: + goto s_n_llhttp__internal__n_pause_11; + default: + goto s_n_llhttp__internal__n_error_32; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_error_33: + s_n_llhttp__internal__n_error_33: { + state->error = 0x2; + state->reason = "Invalid character in chunk extensions value"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_chunk_extension_value: + s_n_llhttp__internal__n_chunk_extension_value: { + static uint8_t lookup_table[] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 3, 4, 3, 3, 3, 3, 3, 0, 0, 3, 3, 0, 3, 3, 0, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 5, 0, 0, 0, 0, + 0, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 3, 0, 3, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + }; + if (p == endp) { + return s_n_llhttp__internal__n_chunk_extension_value; + } + switch (lookup_table[(uint8_t) *p]) { + case 1: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_value; + } + case 2: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_value_1; + } + case 3: { + p++; + goto s_n_llhttp__internal__n_chunk_extension_value; + } + case 4: { + p++; + goto s_n_llhttp__internal__n_chunk_extension_quoted_value; + } + case 5: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_value_5; + } + default: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_value_6; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_start_llhttp__on_chunk_extension_value: + s_n_llhttp__internal__n_span_start_llhttp__on_chunk_extension_value: { + if (p == endp) { + return s_n_llhttp__internal__n_span_start_llhttp__on_chunk_extension_value; + } + state->_span_pos0 = (void*) p; + state->_span_cb0 = llhttp__on_chunk_extension_value; + goto s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_name_complete_3; + UNREACHABLE; + } + case s_n_llhttp__internal__n_error_34: + s_n_llhttp__internal__n_error_34: { + state->error = 0x2; + state->reason = "Invalid character in chunk extensions name"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_chunk_extension_name: + s_n_llhttp__internal__n_chunk_extension_name: { + static uint8_t lookup_table[] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 3, 0, 3, 3, 3, 3, 3, 0, 0, 3, 3, 0, 3, 3, 0, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 4, 0, 5, 0, 0, + 0, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 3, 0, 3, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + }; + if (p == endp) { + return s_n_llhttp__internal__n_chunk_extension_name; + } + switch (lookup_table[(uint8_t) *p]) { + case 1: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_name; + } + case 2: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_name_1; + } + case 3: { + p++; + goto s_n_llhttp__internal__n_chunk_extension_name; + } + case 4: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_name_2; + } + case 5: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_name_3; + } + default: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_name_4; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_start_llhttp__on_chunk_extension_name: + s_n_llhttp__internal__n_span_start_llhttp__on_chunk_extension_name: { + if (p == endp) { + return s_n_llhttp__internal__n_span_start_llhttp__on_chunk_extension_name; + } + state->_span_pos0 = (void*) p; + state->_span_cb0 = llhttp__on_chunk_extension_name; + goto s_n_llhttp__internal__n_chunk_extension_name; + UNREACHABLE; + } + case s_n_llhttp__internal__n_chunk_extensions: + s_n_llhttp__internal__n_chunk_extensions: { + if (p == endp) { + return s_n_llhttp__internal__n_chunk_extensions; + } + switch (*p) { + case 13: { + p++; + goto s_n_llhttp__internal__n_error_17; + } + case ' ': { + p++; + goto s_n_llhttp__internal__n_error_18; + } + default: { + goto s_n_llhttp__internal__n_span_start_llhttp__on_chunk_extension_name; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_chunk_size_otherwise: + s_n_llhttp__internal__n_chunk_size_otherwise: { + if (p == endp) { + return s_n_llhttp__internal__n_chunk_size_otherwise; + } + switch (*p) { + case 9: { + p++; + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_4; + } + case 10: { + p++; + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_5; + } + case 13: { + p++; + goto s_n_llhttp__internal__n_chunk_size_almost_done; + } + case ' ': { + p++; + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_4; + } + case ';': { + p++; + goto s_n_llhttp__internal__n_chunk_extensions; + } + default: { + goto s_n_llhttp__internal__n_error_35; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_chunk_size: + s_n_llhttp__internal__n_chunk_size: { + if (p == endp) { + return s_n_llhttp__internal__n_chunk_size; + } + switch (*p) { + case '0': { + p++; + match = 0; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case '1': { + p++; + match = 1; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case '2': { + p++; + match = 2; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case '3': { + p++; + match = 3; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case '4': { + p++; + match = 4; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case '5': { + p++; + match = 5; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case '6': { + p++; + match = 6; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case '7': { + p++; + match = 7; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case '8': { + p++; + match = 8; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case '9': { + p++; + match = 9; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'A': { + p++; + match = 10; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'B': { + p++; + match = 11; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'C': { + p++; + match = 12; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'D': { + p++; + match = 13; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'E': { + p++; + match = 14; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'F': { + p++; + match = 15; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'a': { + p++; + match = 10; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'b': { + p++; + match = 11; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'c': { + p++; + match = 12; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'd': { + p++; + match = 13; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'e': { + p++; + match = 14; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'f': { + p++; + match = 15; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + default: { + goto s_n_llhttp__internal__n_chunk_size_otherwise; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_chunk_size_digit: + s_n_llhttp__internal__n_chunk_size_digit: { + if (p == endp) { + return s_n_llhttp__internal__n_chunk_size_digit; + } + switch (*p) { + case '0': { + p++; + match = 0; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case '1': { + p++; + match = 1; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case '2': { + p++; + match = 2; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case '3': { + p++; + match = 3; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case '4': { + p++; + match = 4; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case '5': { + p++; + match = 5; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case '6': { + p++; + match = 6; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case '7': { + p++; + match = 7; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case '8': { + p++; + match = 8; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case '9': { + p++; + match = 9; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'A': { + p++; + match = 10; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'B': { + p++; + match = 11; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'C': { + p++; + match = 12; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'D': { + p++; + match = 13; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'E': { + p++; + match = 14; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'F': { + p++; + match = 15; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'a': { + p++; + match = 10; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'b': { + p++; + match = 11; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'c': { + p++; + match = 12; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'd': { + p++; + match = 13; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'e': { + p++; + match = 14; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + case 'f': { + p++; + match = 15; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length; + } + default: { + goto s_n_llhttp__internal__n_error_37; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_update_content_length_1: + s_n_llhttp__internal__n_invoke_update_content_length_1: { + switch (llhttp__internal__c_update_content_length(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_chunk_size_digit; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_consume_content_length_1: + s_n_llhttp__internal__n_consume_content_length_1: { + size_t avail; + uint64_t need; + + avail = endp - p; + need = state->content_length; + if (avail >= need) { + p += need; + state->content_length = 0; + goto s_n_llhttp__internal__n_span_end_llhttp__on_body_1; + } + + state->content_length -= avail; + return s_n_llhttp__internal__n_consume_content_length_1; + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_start_llhttp__on_body_1: + s_n_llhttp__internal__n_span_start_llhttp__on_body_1: { + if (p == endp) { + return s_n_llhttp__internal__n_span_start_llhttp__on_body_1; + } + state->_span_pos0 = (void*) p; + state->_span_cb0 = llhttp__on_body; + goto s_n_llhttp__internal__n_consume_content_length_1; + UNREACHABLE; + } + case s_n_llhttp__internal__n_eof: + s_n_llhttp__internal__n_eof: { + if (p == endp) { + return s_n_llhttp__internal__n_eof; + } + p++; + goto s_n_llhttp__internal__n_eof; + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_start_llhttp__on_body_2: + s_n_llhttp__internal__n_span_start_llhttp__on_body_2: { + if (p == endp) { + return s_n_llhttp__internal__n_span_start_llhttp__on_body_2; + } + state->_span_pos0 = (void*) p; + state->_span_cb0 = llhttp__on_body; + goto s_n_llhttp__internal__n_eof; + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_llhttp__after_headers_complete: + s_n_llhttp__internal__n_invoke_llhttp__after_headers_complete: { + switch (llhttp__after_headers_complete(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_invoke_llhttp__on_message_complete_1; + case 2: + goto s_n_llhttp__internal__n_invoke_update_content_length_1; + case 3: + goto s_n_llhttp__internal__n_span_start_llhttp__on_body_1; + case 4: + goto s_n_llhttp__internal__n_invoke_update_finish_3; + case 5: + goto s_n_llhttp__internal__n_error_39; + default: + goto s_n_llhttp__internal__n_invoke_llhttp__on_message_complete; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_error_5: + s_n_llhttp__internal__n_error_5: { + state->error = 0xa; + state->reason = "Invalid header field char"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_headers_almost_done: + s_n_llhttp__internal__n_headers_almost_done: { + if (p == endp) { + return s_n_llhttp__internal__n_headers_almost_done; + } + switch (*p) { + case 10: { + p++; + goto s_n_llhttp__internal__n_invoke_test_flags_1; + } + default: { + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_12; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_field_colon_discard_ws: + s_n_llhttp__internal__n_header_field_colon_discard_ws: { + if (p == endp) { + return s_n_llhttp__internal__n_header_field_colon_discard_ws; + } + switch (*p) { + case ' ': { + p++; + goto s_n_llhttp__internal__n_header_field_colon_discard_ws; + } + default: { + goto s_n_llhttp__internal__n_header_field_colon; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_llhttp__on_header_value_complete: + s_n_llhttp__internal__n_invoke_llhttp__on_header_value_complete: { + switch (llhttp__on_header_value_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_header_field_start; + case 21: + goto s_n_llhttp__internal__n_pause_18; + default: + goto s_n_llhttp__internal__n_error_48; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_start_llhttp__on_header_value: + s_n_llhttp__internal__n_span_start_llhttp__on_header_value: { + if (p == endp) { + return s_n_llhttp__internal__n_span_start_llhttp__on_header_value; + } + state->_span_pos0 = (void*) p; + state->_span_cb0 = llhttp__on_header_value; + goto s_n_llhttp__internal__n_span_end_llhttp__on_header_value; + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value_discard_lws: + s_n_llhttp__internal__n_header_value_discard_lws: { + if (p == endp) { + return s_n_llhttp__internal__n_header_value_discard_lws; + } + switch (*p) { + case 9: { + p++; + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_15; + } + case ' ': { + p++; + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_15; + } + default: { + goto s_n_llhttp__internal__n_invoke_load_header_state_1; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value_discard_ws_almost_done: + s_n_llhttp__internal__n_header_value_discard_ws_almost_done: { + if (p == endp) { + return s_n_llhttp__internal__n_header_value_discard_ws_almost_done; + } + switch (*p) { + case 10: { + p++; + goto s_n_llhttp__internal__n_header_value_discard_lws; + } + default: { + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_16; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value_lws: + s_n_llhttp__internal__n_header_value_lws: { + if (p == endp) { + return s_n_llhttp__internal__n_header_value_lws; + } + switch (*p) { + case 9: { + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_18; + } + case ' ': { + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_18; + } + default: { + goto s_n_llhttp__internal__n_invoke_load_header_state_5; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value_almost_done: + s_n_llhttp__internal__n_header_value_almost_done: { + if (p == endp) { + return s_n_llhttp__internal__n_header_value_almost_done; + } + switch (*p) { + case 10: { + p++; + goto s_n_llhttp__internal__n_header_value_lws; + } + default: { + goto s_n_llhttp__internal__n_error_54; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_test_lenient_flags_17: + s_n_llhttp__internal__n_invoke_test_lenient_flags_17: { + switch (llhttp__internal__c_test_lenient_flags_1(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_header_value_almost_done; + default: + goto s_n_llhttp__internal__n_error_52; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value_lenient: + s_n_llhttp__internal__n_header_value_lenient: { + if (p == endp) { + return s_n_llhttp__internal__n_header_value_lenient; + } + switch (*p) { + case 10: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_header_value_4; + } + case 13: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_header_value_5; + } + default: { + p++; + goto s_n_llhttp__internal__n_header_value_lenient; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value_relaxed: + s_n_llhttp__internal__n_header_value_relaxed: { + static uint8_t lookup_table[] = { + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 + }; + if (p == endp) { + return s_n_llhttp__internal__n_header_value_relaxed; + } + #ifdef __SSE4_2__ + if (endp - p >= 16) { + __m128i ranges; + __m128i input; + int match_len; + + /* Load input */ + input = _mm_loadu_si128((__m128i const*) p); + ranges = _mm_loadu_si128((__m128i const*) llparse_blob3); + + /* Find first character that does not match `ranges` */ + match_len = _mm_cmpestri(ranges, 6, + input, 16, + _SIDD_UBYTE_OPS | _SIDD_CMP_RANGES | + _SIDD_NEGATIVE_POLARITY); + + if (match_len != 0) { + p += match_len; + goto s_n_llhttp__internal__n_header_value_relaxed; + } + goto s_n_llhttp__internal__n_header_value_otherwise; + } + #endif /* __SSE4_2__ */ + #if defined(__ARM_NEON__) || defined(__ARM_NEON) + while (endp - p >= 16) { + uint8x16_t input; + uint8x16_t single; + uint8x16_t mask; + uint8x8_t narrow; + uint64_t match_mask; + int match_len; + + /* Load input */ + input = vld1q_u8(p); + /* Find first character that does not match `ranges` */ + single = vandq_u8( + vcgeq_u8(input, vdupq_n_u8(0x1)), + vcleq_u8(input, vdupq_n_u8(0x9)) + ); + mask = single; + single = vandq_u8( + vcgeq_u8(input, vdupq_n_u8(0xb)), + vcleq_u8(input, vdupq_n_u8(0xc)) + ); + mask = vorrq_u8(mask, single); + single = vandq_u8( + vcgeq_u8(input, vdupq_n_u8(0xe)), + vcleq_u8(input, vdupq_n_u8(0xff)) + ); + mask = vorrq_u8(mask, single); + narrow = vshrn_n_u16(vreinterpretq_u16_u8(mask), 4); + match_mask = ~vget_lane_u64(vreinterpret_u64_u8(narrow), 0); + if (match_mask == 0) { + match_len = 16; + } else { + match_len = __builtin_ctzll(match_mask) >> 2; + } + if (match_len != 16) { + p += match_len; + goto s_n_llhttp__internal__n_header_value_otherwise; + } + p += 16; + } + if (p == endp) { + return s_n_llhttp__internal__n_header_value_relaxed; + } + #endif /* __ARM_NEON__ */ + #ifdef __wasm_simd128__ + while (endp - p >= 16) { + v128_t input; + v128_t mask; + v128_t single; + int match_len; + + /* Load input */ + input = wasm_v128_load(p); + /* Find first character that does not match `ranges` */ + single = wasm_v128_and( + wasm_i8x16_ge(input, wasm_u8x16_const_splat(0x1)), + wasm_i8x16_le(input, wasm_u8x16_const_splat(0x9)) + ); + mask = single; + single = wasm_v128_and( + wasm_i8x16_ge(input, wasm_u8x16_const_splat(0xb)), + wasm_i8x16_le(input, wasm_u8x16_const_splat(0xc)) + ); + mask = wasm_v128_or(mask, single); + single = wasm_v128_and( + wasm_i8x16_ge(input, wasm_u8x16_const_splat(0xe)), + wasm_i8x16_le(input, wasm_u8x16_const_splat(0xff)) + ); + mask = wasm_v128_or(mask, single); + match_len = __builtin_ctz( + ~wasm_i8x16_bitmask(mask) + ); + if (match_len != 16) { + p += match_len; + goto s_n_llhttp__internal__n_header_value_otherwise; + } + p += 16; + } + if (p == endp) { + return s_n_llhttp__internal__n_header_value_relaxed; + } + #endif /* __wasm_simd128__ */ + switch (lookup_table[(uint8_t) *p]) { + case 1: { + p++; + goto s_n_llhttp__internal__n_header_value_relaxed; + } + default: { + goto s_n_llhttp__internal__n_header_value_otherwise; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_error_55: + s_n_llhttp__internal__n_error_55: { + state->error = 0xa; + state->reason = "Invalid header value char"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value_otherwise: + s_n_llhttp__internal__n_header_value_otherwise: { + if (p == endp) { + return s_n_llhttp__internal__n_header_value_otherwise; + } + switch (*p) { + case 10: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_header_value_1; + } + case 13: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_header_value_2; + } + default: { + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_19; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value_connection_token: + s_n_llhttp__internal__n_header_value_connection_token: { + static uint8_t lookup_table[] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 + }; + if (p == endp) { + return s_n_llhttp__internal__n_header_value_connection_token; + } + switch (lookup_table[(uint8_t) *p]) { + case 1: { + p++; + goto s_n_llhttp__internal__n_header_value_connection_token; + } + case 2: { + p++; + goto s_n_llhttp__internal__n_header_value_connection; + } + default: { + goto s_n_llhttp__internal__n_header_value_otherwise; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value_connection_ws: + s_n_llhttp__internal__n_header_value_connection_ws: { + if (p == endp) { + return s_n_llhttp__internal__n_header_value_connection_ws; + } + switch (*p) { + case 9: { + p++; + goto s_n_llhttp__internal__n_header_value_connection_ws; + } + case 10: { + goto s_n_llhttp__internal__n_header_value_otherwise; + } + case 13: { + goto s_n_llhttp__internal__n_header_value_otherwise; + } + case ' ': { + p++; + goto s_n_llhttp__internal__n_header_value_connection_ws; + } + case ',': { + p++; + goto s_n_llhttp__internal__n_invoke_load_header_state_6; + } + default: { + goto s_n_llhttp__internal__n_invoke_update_header_state_5; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value_connection_1: + s_n_llhttp__internal__n_header_value_connection_1: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_header_value_connection_1; + } + match_seq = llparse__match_sequence_to_lower(state, p, endp, llparse_blob2, 4); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + goto s_n_llhttp__internal__n_invoke_update_header_state_3; + } + case kMatchPause: { + return s_n_llhttp__internal__n_header_value_connection_1; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_header_value_connection_token; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value_connection_2: + s_n_llhttp__internal__n_header_value_connection_2: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_header_value_connection_2; + } + match_seq = llparse__match_sequence_to_lower(state, p, endp, llparse_blob4, 9); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + goto s_n_llhttp__internal__n_invoke_update_header_state_6; + } + case kMatchPause: { + return s_n_llhttp__internal__n_header_value_connection_2; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_header_value_connection_token; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value_connection_3: + s_n_llhttp__internal__n_header_value_connection_3: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_header_value_connection_3; + } + match_seq = llparse__match_sequence_to_lower(state, p, endp, llparse_blob5, 6); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + goto s_n_llhttp__internal__n_invoke_update_header_state_7; + } + case kMatchPause: { + return s_n_llhttp__internal__n_header_value_connection_3; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_header_value_connection_token; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value_connection: + s_n_llhttp__internal__n_header_value_connection: { + if (p == endp) { + return s_n_llhttp__internal__n_header_value_connection; + } + switch (((*p) >= 'A' && (*p) <= 'Z' ? (*p | 0x20) : (*p))) { + case 9: { + p++; + goto s_n_llhttp__internal__n_header_value_connection; + } + case ' ': { + p++; + goto s_n_llhttp__internal__n_header_value_connection; + } + case 'c': { + p++; + goto s_n_llhttp__internal__n_header_value_connection_1; + } + case 'k': { + p++; + goto s_n_llhttp__internal__n_header_value_connection_2; + } + case 'u': { + p++; + goto s_n_llhttp__internal__n_header_value_connection_3; + } + default: { + goto s_n_llhttp__internal__n_header_value_connection_token; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_error_57: + s_n_llhttp__internal__n_error_57: { + state->error = 0xb; + state->reason = "Content-Length overflow"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_error_58: + s_n_llhttp__internal__n_error_58: { + state->error = 0xb; + state->reason = "Invalid character in Content-Length"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value_content_length_ws: + s_n_llhttp__internal__n_header_value_content_length_ws: { + if (p == endp) { + return s_n_llhttp__internal__n_header_value_content_length_ws; + } + switch (*p) { + case 9: { + p++; + goto s_n_llhttp__internal__n_header_value_content_length_ws; + } + case 10: { + goto s_n_llhttp__internal__n_invoke_or_flags_17; + } + case 13: { + goto s_n_llhttp__internal__n_invoke_or_flags_17; + } + case ' ': { + p++; + goto s_n_llhttp__internal__n_header_value_content_length_ws; + } + default: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_header_value_7; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value_content_length: + s_n_llhttp__internal__n_header_value_content_length: { + if (p == endp) { + return s_n_llhttp__internal__n_header_value_content_length; + } + switch (*p) { + case '0': { + p++; + match = 0; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length_1; + } + case '1': { + p++; + match = 1; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length_1; + } + case '2': { + p++; + match = 2; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length_1; + } + case '3': { + p++; + match = 3; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length_1; + } + case '4': { + p++; + match = 4; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length_1; + } + case '5': { + p++; + match = 5; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length_1; + } + case '6': { + p++; + match = 6; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length_1; + } + case '7': { + p++; + match = 7; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length_1; + } + case '8': { + p++; + match = 8; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length_1; + } + case '9': { + p++; + match = 9; + goto s_n_llhttp__internal__n_invoke_mul_add_content_length_1; + } + default: { + goto s_n_llhttp__internal__n_header_value_content_length_ws; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_error_60: + s_n_llhttp__internal__n_error_60: { + state->error = 0xf; + state->reason = "Invalid `Transfer-Encoding` header value"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_error_59: + s_n_llhttp__internal__n_error_59: { + state->error = 0xf; + state->reason = "Invalid `Transfer-Encoding` header value"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value_te_token_ows: + s_n_llhttp__internal__n_header_value_te_token_ows: { + if (p == endp) { + return s_n_llhttp__internal__n_header_value_te_token_ows; + } + switch (*p) { + case 9: { + p++; + goto s_n_llhttp__internal__n_header_value_te_token_ows; + } + case ' ': { + p++; + goto s_n_llhttp__internal__n_header_value_te_token_ows; + } + default: { + goto s_n_llhttp__internal__n_header_value_te_chunked; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value: + s_n_llhttp__internal__n_header_value: { + static uint8_t lookup_table[] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 + }; + if (p == endp) { + return s_n_llhttp__internal__n_header_value; + } + #ifdef __SSE4_2__ + if (endp - p >= 16) { + __m128i ranges; + __m128i input; + int match_len; + + /* Load input */ + input = _mm_loadu_si128((__m128i const*) p); + ranges = _mm_loadu_si128((__m128i const*) llparse_blob7); + + /* Find first character that does not match `ranges` */ + match_len = _mm_cmpestri(ranges, 6, + input, 16, + _SIDD_UBYTE_OPS | _SIDD_CMP_RANGES | + _SIDD_NEGATIVE_POLARITY); + + if (match_len != 0) { + p += match_len; + goto s_n_llhttp__internal__n_header_value; + } + goto s_n_llhttp__internal__n_header_value_otherwise; + } + #endif /* __SSE4_2__ */ + #if defined(__ARM_NEON__) || defined(__ARM_NEON) + while (endp - p >= 16) { + uint8x16_t input; + uint8x16_t single; + uint8x16_t mask; + uint8x8_t narrow; + uint64_t match_mask; + int match_len; + + /* Load input */ + input = vld1q_u8(p); + /* Find first character that does not match `ranges` */ + single = vceqq_u8(input, vdupq_n_u8(0x9)); + mask = single; + single = vandq_u8( + vcgeq_u8(input, vdupq_n_u8(' ')), + vcleq_u8(input, vdupq_n_u8('~')) + ); + mask = vorrq_u8(mask, single); + single = vandq_u8( + vcgeq_u8(input, vdupq_n_u8(0x80)), + vcleq_u8(input, vdupq_n_u8(0xff)) + ); + mask = vorrq_u8(mask, single); + narrow = vshrn_n_u16(vreinterpretq_u16_u8(mask), 4); + match_mask = ~vget_lane_u64(vreinterpret_u64_u8(narrow), 0); + if (match_mask == 0) { + match_len = 16; + } else { + match_len = __builtin_ctzll(match_mask) >> 2; + } + if (match_len != 16) { + p += match_len; + goto s_n_llhttp__internal__n_header_value_otherwise; + } + p += 16; + } + if (p == endp) { + return s_n_llhttp__internal__n_header_value; + } + #endif /* __ARM_NEON__ */ + #ifdef __wasm_simd128__ + while (endp - p >= 16) { + v128_t input; + v128_t mask; + v128_t single; + int match_len; + + /* Load input */ + input = wasm_v128_load(p); + /* Find first character that does not match `ranges` */ + single = wasm_i8x16_eq(input, wasm_u8x16_const_splat(0x9)); + mask = single; + single = wasm_v128_and( + wasm_i8x16_ge(input, wasm_u8x16_const_splat(' ')), + wasm_i8x16_le(input, wasm_u8x16_const_splat('~')) + ); + mask = wasm_v128_or(mask, single); + single = wasm_v128_and( + wasm_i8x16_ge(input, wasm_u8x16_const_splat(0x80)), + wasm_i8x16_le(input, wasm_u8x16_const_splat(0xff)) + ); + mask = wasm_v128_or(mask, single); + match_len = __builtin_ctz( + ~wasm_i8x16_bitmask(mask) + ); + if (match_len != 16) { + p += match_len; + goto s_n_llhttp__internal__n_header_value_otherwise; + } + p += 16; + } + if (p == endp) { + return s_n_llhttp__internal__n_header_value; + } + #endif /* __wasm_simd128__ */ + switch (lookup_table[(uint8_t) *p]) { + case 1: { + p++; + goto s_n_llhttp__internal__n_header_value; + } + default: { + goto s_n_llhttp__internal__n_header_value_otherwise; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value_te_token: + s_n_llhttp__internal__n_header_value_te_token: { + static uint8_t lookup_table[] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 + }; + if (p == endp) { + return s_n_llhttp__internal__n_header_value_te_token; + } + switch (lookup_table[(uint8_t) *p]) { + case 1: { + p++; + goto s_n_llhttp__internal__n_header_value_te_token; + } + case 2: { + p++; + goto s_n_llhttp__internal__n_header_value_te_token_ows; + } + default: { + goto s_n_llhttp__internal__n_invoke_update_header_state_9; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value_te_chunked_last: + s_n_llhttp__internal__n_header_value_te_chunked_last: { + if (p == endp) { + return s_n_llhttp__internal__n_header_value_te_chunked_last; + } + switch (*p) { + case 10: { + goto s_n_llhttp__internal__n_invoke_update_header_state_8; + } + case 13: { + goto s_n_llhttp__internal__n_invoke_update_header_state_8; + } + case ' ': { + p++; + goto s_n_llhttp__internal__n_header_value_te_chunked_last; + } + case ',': { + goto s_n_llhttp__internal__n_invoke_load_type_1; + } + default: { + goto s_n_llhttp__internal__n_header_value_te_token; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value_te_chunked: + s_n_llhttp__internal__n_header_value_te_chunked: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_header_value_te_chunked; + } + match_seq = llparse__match_sequence_to_lower_unsafe(state, p, endp, llparse_blob6, 7); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + goto s_n_llhttp__internal__n_header_value_te_chunked_last; + } + case kMatchPause: { + return s_n_llhttp__internal__n_header_value_te_chunked; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_header_value_te_token; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_start_llhttp__on_header_value_1: + s_n_llhttp__internal__n_span_start_llhttp__on_header_value_1: { + if (p == endp) { + return s_n_llhttp__internal__n_span_start_llhttp__on_header_value_1; + } + state->_span_pos0 = (void*) p; + state->_span_cb0 = llhttp__on_header_value; + goto s_n_llhttp__internal__n_invoke_load_header_state_3; + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_value_discard_ws: + s_n_llhttp__internal__n_header_value_discard_ws: { + if (p == endp) { + return s_n_llhttp__internal__n_header_value_discard_ws; + } + switch (*p) { + case 9: { + p++; + goto s_n_llhttp__internal__n_header_value_discard_ws; + } + case 10: { + p++; + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_14; + } + case 13: { + p++; + goto s_n_llhttp__internal__n_header_value_discard_ws_almost_done; + } + case ' ': { + p++; + goto s_n_llhttp__internal__n_header_value_discard_ws; + } + default: { + goto s_n_llhttp__internal__n_span_start_llhttp__on_header_value_1; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_load_header_state: + s_n_llhttp__internal__n_invoke_load_header_state: { + switch (llhttp__internal__c_load_header_state(state, p, endp)) { + case 2: + goto s_n_llhttp__internal__n_invoke_test_flags_4; + case 3: + goto s_n_llhttp__internal__n_invoke_test_flags_5; + default: + goto s_n_llhttp__internal__n_header_value_discard_ws; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_llhttp__on_header_field_complete: + s_n_llhttp__internal__n_invoke_llhttp__on_header_field_complete: { + switch (llhttp__on_header_field_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_invoke_load_header_state; + case 21: + goto s_n_llhttp__internal__n_pause_19; + default: + goto s_n_llhttp__internal__n_error_45; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_field_general_otherwise: + s_n_llhttp__internal__n_header_field_general_otherwise: { + if (p == endp) { + return s_n_llhttp__internal__n_header_field_general_otherwise; + } + switch (*p) { + case ':': { + goto s_n_llhttp__internal__n_span_end_llhttp__on_header_field_2; + } + default: { + goto s_n_llhttp__internal__n_error_63; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_field_general: + s_n_llhttp__internal__n_header_field_general: { + static uint8_t lookup_table[] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + }; + if (p == endp) { + return s_n_llhttp__internal__n_header_field_general; + } + #ifdef __SSE4_2__ + if (endp - p >= 16) { + __m128i ranges; + __m128i input; + int match_len; + + /* Load input */ + input = _mm_loadu_si128((__m128i const*) p); + ranges = _mm_loadu_si128((__m128i const*) llparse_blob8); + + /* Find first character that does not match `ranges` */ + match_len = _mm_cmpestri(ranges, 16, + input, 16, + _SIDD_UBYTE_OPS | _SIDD_CMP_RANGES | + _SIDD_NEGATIVE_POLARITY); + + if (match_len != 0) { + p += match_len; + goto s_n_llhttp__internal__n_header_field_general; + } + ranges = _mm_loadu_si128((__m128i const*) llparse_blob9); + + /* Find first character that does not match `ranges` */ + match_len = _mm_cmpestri(ranges, 2, + input, 16, + _SIDD_UBYTE_OPS | _SIDD_CMP_RANGES | + _SIDD_NEGATIVE_POLARITY); + + if (match_len != 0) { + p += match_len; + goto s_n_llhttp__internal__n_header_field_general; + } + goto s_n_llhttp__internal__n_header_field_general_otherwise; + } + #endif /* __SSE4_2__ */ + switch (lookup_table[(uint8_t) *p]) { + case 1: { + p++; + goto s_n_llhttp__internal__n_header_field_general; + } + default: { + goto s_n_llhttp__internal__n_header_field_general_otherwise; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_field_colon: + s_n_llhttp__internal__n_header_field_colon: { + if (p == endp) { + return s_n_llhttp__internal__n_header_field_colon; + } + switch (*p) { + case ' ': { + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_13; + } + case ':': { + goto s_n_llhttp__internal__n_span_end_llhttp__on_header_field_1; + } + default: { + goto s_n_llhttp__internal__n_invoke_update_header_state_10; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_field_3: + s_n_llhttp__internal__n_header_field_3: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_header_field_3; + } + match_seq = llparse__match_sequence_to_lower(state, p, endp, llparse_blob1, 6); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 1; + goto s_n_llhttp__internal__n_invoke_store_header_state; + } + case kMatchPause: { + return s_n_llhttp__internal__n_header_field_3; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_invoke_update_header_state_11; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_field_4: + s_n_llhttp__internal__n_header_field_4: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_header_field_4; + } + match_seq = llparse__match_sequence_to_lower(state, p, endp, llparse_blob10, 10); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 2; + goto s_n_llhttp__internal__n_invoke_store_header_state; + } + case kMatchPause: { + return s_n_llhttp__internal__n_header_field_4; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_invoke_update_header_state_11; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_field_2: + s_n_llhttp__internal__n_header_field_2: { + if (p == endp) { + return s_n_llhttp__internal__n_header_field_2; + } + switch (((*p) >= 'A' && (*p) <= 'Z' ? (*p | 0x20) : (*p))) { + case 'n': { + p++; + goto s_n_llhttp__internal__n_header_field_3; + } + case 't': { + p++; + goto s_n_llhttp__internal__n_header_field_4; + } + default: { + goto s_n_llhttp__internal__n_invoke_update_header_state_11; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_field_1: + s_n_llhttp__internal__n_header_field_1: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_header_field_1; + } + match_seq = llparse__match_sequence_to_lower(state, p, endp, llparse_blob0, 2); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + goto s_n_llhttp__internal__n_header_field_2; + } + case kMatchPause: { + return s_n_llhttp__internal__n_header_field_1; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_invoke_update_header_state_11; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_field_5: + s_n_llhttp__internal__n_header_field_5: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_header_field_5; + } + match_seq = llparse__match_sequence_to_lower(state, p, endp, llparse_blob11, 15); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 1; + goto s_n_llhttp__internal__n_invoke_store_header_state; + } + case kMatchPause: { + return s_n_llhttp__internal__n_header_field_5; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_invoke_update_header_state_11; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_field_6: + s_n_llhttp__internal__n_header_field_6: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_header_field_6; + } + match_seq = llparse__match_sequence_to_lower(state, p, endp, llparse_blob12, 16); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 3; + goto s_n_llhttp__internal__n_invoke_store_header_state; + } + case kMatchPause: { + return s_n_llhttp__internal__n_header_field_6; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_invoke_update_header_state_11; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_field_7: + s_n_llhttp__internal__n_header_field_7: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_header_field_7; + } + match_seq = llparse__match_sequence_to_lower(state, p, endp, llparse_blob13, 6); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 4; + goto s_n_llhttp__internal__n_invoke_store_header_state; + } + case kMatchPause: { + return s_n_llhttp__internal__n_header_field_7; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_invoke_update_header_state_11; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_field: + s_n_llhttp__internal__n_header_field: { + if (p == endp) { + return s_n_llhttp__internal__n_header_field; + } + switch (((*p) >= 'A' && (*p) <= 'Z' ? (*p | 0x20) : (*p))) { + case 'c': { + p++; + goto s_n_llhttp__internal__n_header_field_1; + } + case 'p': { + p++; + goto s_n_llhttp__internal__n_header_field_5; + } + case 't': { + p++; + goto s_n_llhttp__internal__n_header_field_6; + } + case 'u': { + p++; + goto s_n_llhttp__internal__n_header_field_7; + } + default: { + goto s_n_llhttp__internal__n_invoke_update_header_state_11; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_start_llhttp__on_header_field: + s_n_llhttp__internal__n_span_start_llhttp__on_header_field: { + if (p == endp) { + return s_n_llhttp__internal__n_span_start_llhttp__on_header_field; + } + state->_span_pos0 = (void*) p; + state->_span_cb0 = llhttp__on_header_field; + goto s_n_llhttp__internal__n_header_field; + UNREACHABLE; + } + case s_n_llhttp__internal__n_header_field_start: + s_n_llhttp__internal__n_header_field_start: { + if (p == endp) { + return s_n_llhttp__internal__n_header_field_start; + } + switch (*p) { + case 10: { + p++; + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_1; + } + case 13: { + p++; + goto s_n_llhttp__internal__n_headers_almost_done; + } + case ':': { + goto s_n_llhttp__internal__n_error_44; + } + default: { + goto s_n_llhttp__internal__n_span_start_llhttp__on_header_field; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_headers_start: + s_n_llhttp__internal__n_headers_start: { + if (p == endp) { + return s_n_llhttp__internal__n_headers_start; + } + switch (*p) { + case ' ': { + p++; + goto s_n_llhttp__internal__n_invoke_test_lenient_flags; + } + default: { + goto s_n_llhttp__internal__n_header_field_start; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_url_to_http_09: + s_n_llhttp__internal__n_url_to_http_09: { + if (p == endp) { + return s_n_llhttp__internal__n_url_to_http_09; + } + switch (*p) { + case 9: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case 12: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + default: { + goto s_n_llhttp__internal__n_invoke_update_http_major; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_url_skip_to_http09: + s_n_llhttp__internal__n_url_skip_to_http09: { + if (p == endp) { + return s_n_llhttp__internal__n_url_skip_to_http09; + } + switch (*p) { + case 9: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case 12: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + default: { + p++; + goto s_n_llhttp__internal__n_url_to_http_09; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_url_skip_lf_to_http09_1: + s_n_llhttp__internal__n_url_skip_lf_to_http09_1: { + if (p == endp) { + return s_n_llhttp__internal__n_url_skip_lf_to_http09_1; + } + switch (*p) { + case 10: { + p++; + goto s_n_llhttp__internal__n_url_to_http_09; + } + default: { + goto s_n_llhttp__internal__n_error_64; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_url_skip_lf_to_http09: + s_n_llhttp__internal__n_url_skip_lf_to_http09: { + if (p == endp) { + return s_n_llhttp__internal__n_url_skip_lf_to_http09; + } + switch (*p) { + case 9: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case 12: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case 13: { + p++; + goto s_n_llhttp__internal__n_url_skip_lf_to_http09_1; + } + default: { + goto s_n_llhttp__internal__n_error_64; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_req_pri_upgrade: + s_n_llhttp__internal__n_req_pri_upgrade: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_req_pri_upgrade; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob15, 10); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + goto s_n_llhttp__internal__n_error_73; + } + case kMatchPause: { + return s_n_llhttp__internal__n_req_pri_upgrade; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_74; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_req_http_complete_crlf: + s_n_llhttp__internal__n_req_http_complete_crlf: { + if (p == endp) { + return s_n_llhttp__internal__n_req_http_complete_crlf; + } + switch (*p) { + case 10: { + p++; + goto s_n_llhttp__internal__n_headers_start; + } + default: { + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_27; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_req_http_complete: + s_n_llhttp__internal__n_req_http_complete: { + if (p == endp) { + return s_n_llhttp__internal__n_req_http_complete; + } + switch (*p) { + case 10: { + p++; + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_26; + } + case 13: { + p++; + goto s_n_llhttp__internal__n_req_http_complete_crlf; + } + default: { + goto s_n_llhttp__internal__n_error_72; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_load_method_1: + s_n_llhttp__internal__n_invoke_load_method_1: { + switch (llhttp__internal__c_load_method(state, p, endp)) { + case 34: + goto s_n_llhttp__internal__n_req_pri_upgrade; + default: + goto s_n_llhttp__internal__n_req_http_complete; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_llhttp__on_version_complete: + s_n_llhttp__internal__n_invoke_llhttp__on_version_complete: { + switch (llhttp__on_version_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_invoke_load_method_1; + case 21: + goto s_n_llhttp__internal__n_pause_21; + default: + goto s_n_llhttp__internal__n_error_69; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_error_68: + s_n_llhttp__internal__n_error_68: { + state->error = 0x9; + state->reason = "Invalid HTTP version"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_error_75: + s_n_llhttp__internal__n_error_75: { + state->error = 0x9; + state->reason = "Invalid minor version"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_req_http_minor: + s_n_llhttp__internal__n_req_http_minor: { + if (p == endp) { + return s_n_llhttp__internal__n_req_http_minor; + } + switch (*p) { + case '0': { + p++; + match = 0; + goto s_n_llhttp__internal__n_invoke_store_http_minor; + } + case '1': { + p++; + match = 1; + goto s_n_llhttp__internal__n_invoke_store_http_minor; + } + case '2': { + p++; + match = 2; + goto s_n_llhttp__internal__n_invoke_store_http_minor; + } + case '3': { + p++; + match = 3; + goto s_n_llhttp__internal__n_invoke_store_http_minor; + } + case '4': { + p++; + match = 4; + goto s_n_llhttp__internal__n_invoke_store_http_minor; + } + case '5': { + p++; + match = 5; + goto s_n_llhttp__internal__n_invoke_store_http_minor; + } + case '6': { + p++; + match = 6; + goto s_n_llhttp__internal__n_invoke_store_http_minor; + } + case '7': { + p++; + match = 7; + goto s_n_llhttp__internal__n_invoke_store_http_minor; + } + case '8': { + p++; + match = 8; + goto s_n_llhttp__internal__n_invoke_store_http_minor; + } + case '9': { + p++; + match = 9; + goto s_n_llhttp__internal__n_invoke_store_http_minor; + } + default: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_version_2; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_error_76: + s_n_llhttp__internal__n_error_76: { + state->error = 0x9; + state->reason = "Expected dot"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_req_http_dot: + s_n_llhttp__internal__n_req_http_dot: { + if (p == endp) { + return s_n_llhttp__internal__n_req_http_dot; + } + switch (*p) { + case '.': { + p++; + goto s_n_llhttp__internal__n_req_http_minor; + } + default: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_version_3; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_error_77: + s_n_llhttp__internal__n_error_77: { + state->error = 0x9; + state->reason = "Invalid major version"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_req_http_major: + s_n_llhttp__internal__n_req_http_major: { + if (p == endp) { + return s_n_llhttp__internal__n_req_http_major; + } + switch (*p) { + case '0': { + p++; + match = 0; + goto s_n_llhttp__internal__n_invoke_store_http_major; + } + case '1': { + p++; + match = 1; + goto s_n_llhttp__internal__n_invoke_store_http_major; + } + case '2': { + p++; + match = 2; + goto s_n_llhttp__internal__n_invoke_store_http_major; + } + case '3': { + p++; + match = 3; + goto s_n_llhttp__internal__n_invoke_store_http_major; + } + case '4': { + p++; + match = 4; + goto s_n_llhttp__internal__n_invoke_store_http_major; + } + case '5': { + p++; + match = 5; + goto s_n_llhttp__internal__n_invoke_store_http_major; + } + case '6': { + p++; + match = 6; + goto s_n_llhttp__internal__n_invoke_store_http_major; + } + case '7': { + p++; + match = 7; + goto s_n_llhttp__internal__n_invoke_store_http_major; + } + case '8': { + p++; + match = 8; + goto s_n_llhttp__internal__n_invoke_store_http_major; + } + case '9': { + p++; + match = 9; + goto s_n_llhttp__internal__n_invoke_store_http_major; + } + default: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_version_4; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_start_llhttp__on_version: + s_n_llhttp__internal__n_span_start_llhttp__on_version: { + if (p == endp) { + return s_n_llhttp__internal__n_span_start_llhttp__on_version; + } + state->_span_pos0 = (void*) p; + state->_span_cb0 = llhttp__on_version; + goto s_n_llhttp__internal__n_req_http_major; + UNREACHABLE; + } + case s_n_llhttp__internal__n_req_after_protocol: + s_n_llhttp__internal__n_req_after_protocol: { + if (p == endp) { + return s_n_llhttp__internal__n_req_after_protocol; + } + switch (*p) { + case '/': { + p++; + goto s_n_llhttp__internal__n_span_start_llhttp__on_version; + } + default: { + goto s_n_llhttp__internal__n_error_78; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_load_method: + s_n_llhttp__internal__n_invoke_load_method: { + switch (llhttp__internal__c_load_method(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_req_after_protocol; + case 1: + goto s_n_llhttp__internal__n_req_after_protocol; + case 2: + goto s_n_llhttp__internal__n_req_after_protocol; + case 3: + goto s_n_llhttp__internal__n_req_after_protocol; + case 4: + goto s_n_llhttp__internal__n_req_after_protocol; + case 5: + goto s_n_llhttp__internal__n_req_after_protocol; + case 6: + goto s_n_llhttp__internal__n_req_after_protocol; + case 7: + goto s_n_llhttp__internal__n_req_after_protocol; + case 8: + goto s_n_llhttp__internal__n_req_after_protocol; + case 9: + goto s_n_llhttp__internal__n_req_after_protocol; + case 10: + goto s_n_llhttp__internal__n_req_after_protocol; + case 11: + goto s_n_llhttp__internal__n_req_after_protocol; + case 12: + goto s_n_llhttp__internal__n_req_after_protocol; + case 13: + goto s_n_llhttp__internal__n_req_after_protocol; + case 14: + goto s_n_llhttp__internal__n_req_after_protocol; + case 15: + goto s_n_llhttp__internal__n_req_after_protocol; + case 16: + goto s_n_llhttp__internal__n_req_after_protocol; + case 17: + goto s_n_llhttp__internal__n_req_after_protocol; + case 18: + goto s_n_llhttp__internal__n_req_after_protocol; + case 19: + goto s_n_llhttp__internal__n_req_after_protocol; + case 20: + goto s_n_llhttp__internal__n_req_after_protocol; + case 21: + goto s_n_llhttp__internal__n_req_after_protocol; + case 22: + goto s_n_llhttp__internal__n_req_after_protocol; + case 23: + goto s_n_llhttp__internal__n_req_after_protocol; + case 24: + goto s_n_llhttp__internal__n_req_after_protocol; + case 25: + goto s_n_llhttp__internal__n_req_after_protocol; + case 26: + goto s_n_llhttp__internal__n_req_after_protocol; + case 27: + goto s_n_llhttp__internal__n_req_after_protocol; + case 28: + goto s_n_llhttp__internal__n_req_after_protocol; + case 29: + goto s_n_llhttp__internal__n_req_after_protocol; + case 30: + goto s_n_llhttp__internal__n_req_after_protocol; + case 31: + goto s_n_llhttp__internal__n_req_after_protocol; + case 32: + goto s_n_llhttp__internal__n_req_after_protocol; + case 33: + goto s_n_llhttp__internal__n_req_after_protocol; + case 34: + goto s_n_llhttp__internal__n_req_after_protocol; + case 46: + goto s_n_llhttp__internal__n_req_after_protocol; + default: + goto s_n_llhttp__internal__n_error_67; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_llhttp__on_protocol_complete: + s_n_llhttp__internal__n_invoke_llhttp__on_protocol_complete: { + switch (llhttp__on_protocol_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_invoke_load_method; + case 21: + goto s_n_llhttp__internal__n_pause_22; + default: + goto s_n_llhttp__internal__n_error_66; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_error_83: + s_n_llhttp__internal__n_error_83: { + state->error = 0x8; + state->reason = "Expected HTTP/, RTSP/ or ICE/"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_req_after_http_start_1: + s_n_llhttp__internal__n_req_after_http_start_1: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_req_after_http_start_1; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob14, 3); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + goto s_n_llhttp__internal__n_span_end_llhttp__on_protocol; + } + case kMatchPause: { + return s_n_llhttp__internal__n_req_after_http_start_1; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_protocol_3; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_load_method_2: + s_n_llhttp__internal__n_invoke_load_method_2: { + switch (llhttp__internal__c_load_method(state, p, endp)) { + case 33: + goto s_n_llhttp__internal__n_req_after_protocol; + default: + goto s_n_llhttp__internal__n_error_80; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_llhttp__on_protocol_complete_1: + s_n_llhttp__internal__n_invoke_llhttp__on_protocol_complete_1: { + switch (llhttp__on_protocol_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_invoke_load_method_2; + case 21: + goto s_n_llhttp__internal__n_pause_23; + default: + goto s_n_llhttp__internal__n_error_79; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_req_after_http_start_2: + s_n_llhttp__internal__n_req_after_http_start_2: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_req_after_http_start_2; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob16, 2); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + goto s_n_llhttp__internal__n_span_end_llhttp__on_protocol_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_req_after_http_start_2; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_protocol_3; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_load_method_3: + s_n_llhttp__internal__n_invoke_load_method_3: { + switch (llhttp__internal__c_load_method(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_req_after_protocol; + case 3: + goto s_n_llhttp__internal__n_req_after_protocol; + case 6: + goto s_n_llhttp__internal__n_req_after_protocol; + case 35: + goto s_n_llhttp__internal__n_req_after_protocol; + case 36: + goto s_n_llhttp__internal__n_req_after_protocol; + case 37: + goto s_n_llhttp__internal__n_req_after_protocol; + case 38: + goto s_n_llhttp__internal__n_req_after_protocol; + case 39: + goto s_n_llhttp__internal__n_req_after_protocol; + case 40: + goto s_n_llhttp__internal__n_req_after_protocol; + case 41: + goto s_n_llhttp__internal__n_req_after_protocol; + case 42: + goto s_n_llhttp__internal__n_req_after_protocol; + case 43: + goto s_n_llhttp__internal__n_req_after_protocol; + case 44: + goto s_n_llhttp__internal__n_req_after_protocol; + case 45: + goto s_n_llhttp__internal__n_req_after_protocol; + default: + goto s_n_llhttp__internal__n_error_82; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_llhttp__on_protocol_complete_2: + s_n_llhttp__internal__n_invoke_llhttp__on_protocol_complete_2: { + switch (llhttp__on_protocol_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_invoke_load_method_3; + case 21: + goto s_n_llhttp__internal__n_pause_24; + default: + goto s_n_llhttp__internal__n_error_81; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_req_after_http_start_3: + s_n_llhttp__internal__n_req_after_http_start_3: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_req_after_http_start_3; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob17, 3); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + goto s_n_llhttp__internal__n_span_end_llhttp__on_protocol_2; + } + case kMatchPause: { + return s_n_llhttp__internal__n_req_after_http_start_3; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_protocol_3; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_req_after_http_start: + s_n_llhttp__internal__n_req_after_http_start: { + if (p == endp) { + return s_n_llhttp__internal__n_req_after_http_start; + } + switch (*p) { + case 'H': { + p++; + goto s_n_llhttp__internal__n_req_after_http_start_1; + } + case 'I': { + p++; + goto s_n_llhttp__internal__n_req_after_http_start_2; + } + case 'R': { + p++; + goto s_n_llhttp__internal__n_req_after_http_start_3; + } + default: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_protocol_3; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_start_llhttp__on_protocol: + s_n_llhttp__internal__n_span_start_llhttp__on_protocol: { + if (p == endp) { + return s_n_llhttp__internal__n_span_start_llhttp__on_protocol; + } + state->_span_pos0 = (void*) p; + state->_span_cb0 = llhttp__on_protocol; + goto s_n_llhttp__internal__n_req_after_http_start; + UNREACHABLE; + } + case s_n_llhttp__internal__n_req_http_start: + s_n_llhttp__internal__n_req_http_start: { + if (p == endp) { + return s_n_llhttp__internal__n_req_http_start; + } + switch (*p) { + case ' ': { + p++; + goto s_n_llhttp__internal__n_req_http_start; + } + default: { + goto s_n_llhttp__internal__n_span_start_llhttp__on_protocol; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_url_to_http: + s_n_llhttp__internal__n_url_to_http: { + if (p == endp) { + return s_n_llhttp__internal__n_url_to_http; + } + switch (*p) { + case 9: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case 12: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + default: { + goto s_n_llhttp__internal__n_invoke_llhttp__on_url_complete_1; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_url_skip_to_http: + s_n_llhttp__internal__n_url_skip_to_http: { + if (p == endp) { + return s_n_llhttp__internal__n_url_skip_to_http; + } + switch (*p) { + case 9: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case 12: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + default: { + p++; + goto s_n_llhttp__internal__n_url_to_http; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_url_fragment: + s_n_llhttp__internal__n_url_fragment: { + static uint8_t lookup_table[] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 0, 1, 3, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + }; + if (p == endp) { + return s_n_llhttp__internal__n_url_fragment; + } + switch (lookup_table[(uint8_t) *p]) { + case 1: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case 2: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_url_6; + } + case 3: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_url_7; + } + case 4: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_url_8; + } + case 5: { + p++; + goto s_n_llhttp__internal__n_url_fragment; + } + default: { + goto s_n_llhttp__internal__n_error_84; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_end_stub_query_3: + s_n_llhttp__internal__n_span_end_stub_query_3: { + if (p == endp) { + return s_n_llhttp__internal__n_span_end_stub_query_3; + } + p++; + goto s_n_llhttp__internal__n_url_fragment; + UNREACHABLE; + } + case s_n_llhttp__internal__n_url_query: + s_n_llhttp__internal__n_url_query: { + static uint8_t lookup_table[] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 0, 1, 3, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 4, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + }; + if (p == endp) { + return s_n_llhttp__internal__n_url_query; + } + switch (lookup_table[(uint8_t) *p]) { + case 1: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case 2: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_url_9; + } + case 3: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_url_10; + } + case 4: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_url_11; + } + case 5: { + p++; + goto s_n_llhttp__internal__n_url_query; + } + case 6: { + goto s_n_llhttp__internal__n_span_end_stub_query_3; + } + default: { + goto s_n_llhttp__internal__n_error_85; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_url_query_or_fragment: + s_n_llhttp__internal__n_url_query_or_fragment: { + if (p == endp) { + return s_n_llhttp__internal__n_url_query_or_fragment; + } + switch (*p) { + case 9: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case 10: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_url_3; + } + case 12: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case 13: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_url_4; + } + case ' ': { + goto s_n_llhttp__internal__n_span_end_llhttp__on_url_5; + } + case '#': { + p++; + goto s_n_llhttp__internal__n_url_fragment; + } + case '?': { + p++; + goto s_n_llhttp__internal__n_url_query; + } + default: { + goto s_n_llhttp__internal__n_error_86; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_url_path: + s_n_llhttp__internal__n_url_path: { + static uint8_t lookup_table[] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 2, 2, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + }; + if (p == endp) { + return s_n_llhttp__internal__n_url_path; + } + switch (lookup_table[(uint8_t) *p]) { + case 1: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case 2: { + p++; + goto s_n_llhttp__internal__n_url_path; + } + default: { + goto s_n_llhttp__internal__n_url_query_or_fragment; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_start_stub_path_2: + s_n_llhttp__internal__n_span_start_stub_path_2: { + if (p == endp) { + return s_n_llhttp__internal__n_span_start_stub_path_2; + } + p++; + goto s_n_llhttp__internal__n_url_path; + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_start_stub_path: + s_n_llhttp__internal__n_span_start_stub_path: { + if (p == endp) { + return s_n_llhttp__internal__n_span_start_stub_path; + } + p++; + goto s_n_llhttp__internal__n_url_path; + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_start_stub_path_1: + s_n_llhttp__internal__n_span_start_stub_path_1: { + if (p == endp) { + return s_n_llhttp__internal__n_span_start_stub_path_1; + } + p++; + goto s_n_llhttp__internal__n_url_path; + UNREACHABLE; + } + case s_n_llhttp__internal__n_url_server_with_at: + s_n_llhttp__internal__n_url_server_with_at: { + static uint8_t lookup_table[] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 0, 1, 3, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 4, 5, 0, 0, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 0, 5, 0, 7, + 8, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 0, 5, 0, 5, + 0, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 0, 0, 0, 5, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + }; + if (p == endp) { + return s_n_llhttp__internal__n_url_server_with_at; + } + switch (lookup_table[(uint8_t) *p]) { + case 1: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case 2: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_url_12; + } + case 3: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_url_13; + } + case 4: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_url_14; + } + case 5: { + p++; + goto s_n_llhttp__internal__n_url_server; + } + case 6: { + goto s_n_llhttp__internal__n_span_start_stub_path_1; + } + case 7: { + p++; + goto s_n_llhttp__internal__n_url_query; + } + case 8: { + p++; + goto s_n_llhttp__internal__n_error_87; + } + default: { + goto s_n_llhttp__internal__n_error_88; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_url_server: + s_n_llhttp__internal__n_url_server: { + static uint8_t lookup_table[] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 0, 1, 3, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 4, 5, 0, 0, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 0, 5, 0, 7, + 8, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 0, 5, 0, 5, + 0, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 0, 0, 0, 5, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + }; + if (p == endp) { + return s_n_llhttp__internal__n_url_server; + } + switch (lookup_table[(uint8_t) *p]) { + case 1: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case 2: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_url; + } + case 3: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_url_1; + } + case 4: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_url_2; + } + case 5: { + p++; + goto s_n_llhttp__internal__n_url_server; + } + case 6: { + goto s_n_llhttp__internal__n_span_start_stub_path; + } + case 7: { + p++; + goto s_n_llhttp__internal__n_url_query; + } + case 8: { + p++; + goto s_n_llhttp__internal__n_url_server_with_at; + } + default: { + goto s_n_llhttp__internal__n_error_89; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_url_schema_delim_1: + s_n_llhttp__internal__n_url_schema_delim_1: { + if (p == endp) { + return s_n_llhttp__internal__n_url_schema_delim_1; + } + switch (*p) { + case '/': { + p++; + goto s_n_llhttp__internal__n_url_server; + } + default: { + goto s_n_llhttp__internal__n_error_90; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_url_schema_delim: + s_n_llhttp__internal__n_url_schema_delim: { + if (p == endp) { + return s_n_llhttp__internal__n_url_schema_delim; + } + switch (*p) { + case 9: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case 10: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case 12: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case 13: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case ' ': { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case '/': { + p++; + goto s_n_llhttp__internal__n_url_schema_delim_1; + } + default: { + goto s_n_llhttp__internal__n_error_90; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_end_stub_schema: + s_n_llhttp__internal__n_span_end_stub_schema: { + if (p == endp) { + return s_n_llhttp__internal__n_span_end_stub_schema; + } + p++; + goto s_n_llhttp__internal__n_url_schema_delim; + UNREACHABLE; + } + case s_n_llhttp__internal__n_url_schema: + s_n_llhttp__internal__n_url_schema: { + static uint8_t lookup_table[] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, + 0, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, + 0, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + }; + if (p == endp) { + return s_n_llhttp__internal__n_url_schema; + } + switch (lookup_table[(uint8_t) *p]) { + case 1: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case 2: { + goto s_n_llhttp__internal__n_span_end_stub_schema; + } + case 3: { + p++; + goto s_n_llhttp__internal__n_url_schema; + } + default: { + goto s_n_llhttp__internal__n_error_91; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_url_start: + s_n_llhttp__internal__n_url_start: { + static uint8_t lookup_table[] = { + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, + 0, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + }; + if (p == endp) { + return s_n_llhttp__internal__n_url_start; + } + switch (lookup_table[(uint8_t) *p]) { + case 1: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case 2: { + goto s_n_llhttp__internal__n_span_start_stub_path_2; + } + case 3: { + goto s_n_llhttp__internal__n_url_schema; + } + default: { + goto s_n_llhttp__internal__n_error_92; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_start_llhttp__on_url_1: + s_n_llhttp__internal__n_span_start_llhttp__on_url_1: { + if (p == endp) { + return s_n_llhttp__internal__n_span_start_llhttp__on_url_1; + } + state->_span_pos0 = (void*) p; + state->_span_cb0 = llhttp__on_url; + goto s_n_llhttp__internal__n_url_start; + UNREACHABLE; + } + case s_n_llhttp__internal__n_url_entry_normal: + s_n_llhttp__internal__n_url_entry_normal: { + if (p == endp) { + return s_n_llhttp__internal__n_url_entry_normal; + } + switch (*p) { + case 9: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case 12: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + default: { + goto s_n_llhttp__internal__n_span_start_llhttp__on_url_1; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_start_llhttp__on_url: + s_n_llhttp__internal__n_span_start_llhttp__on_url: { + if (p == endp) { + return s_n_llhttp__internal__n_span_start_llhttp__on_url; + } + state->_span_pos0 = (void*) p; + state->_span_cb0 = llhttp__on_url; + goto s_n_llhttp__internal__n_url_server; + UNREACHABLE; + } + case s_n_llhttp__internal__n_url_entry_connect: + s_n_llhttp__internal__n_url_entry_connect: { + if (p == endp) { + return s_n_llhttp__internal__n_url_entry_connect; + } + switch (*p) { + case 9: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + case 12: { + p++; + goto s_n_llhttp__internal__n_error_2; + } + default: { + goto s_n_llhttp__internal__n_span_start_llhttp__on_url; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_req_spaces_before_url: + s_n_llhttp__internal__n_req_spaces_before_url: { + if (p == endp) { + return s_n_llhttp__internal__n_req_spaces_before_url; + } + switch (*p) { + case ' ': { + p++; + goto s_n_llhttp__internal__n_req_spaces_before_url; + } + default: { + goto s_n_llhttp__internal__n_invoke_is_equal_method; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_req_first_space_before_url: + s_n_llhttp__internal__n_req_first_space_before_url: { + if (p == endp) { + return s_n_llhttp__internal__n_req_first_space_before_url; + } + switch (*p) { + case ' ': { + p++; + goto s_n_llhttp__internal__n_req_spaces_before_url; + } + default: { + goto s_n_llhttp__internal__n_error_93; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_llhttp__on_method_complete_1: + s_n_llhttp__internal__n_invoke_llhttp__on_method_complete_1: { + switch (llhttp__on_method_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_req_first_space_before_url; + case 21: + goto s_n_llhttp__internal__n_pause_29; + default: + goto s_n_llhttp__internal__n_error_112; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_2: + s_n_llhttp__internal__n_after_start_req_2: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_2; + } + switch (*p) { + case 'L': { + p++; + match = 19; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_3: + s_n_llhttp__internal__n_after_start_req_3: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_3; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob18, 6); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 36; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_3; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_1: + s_n_llhttp__internal__n_after_start_req_1: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_1; + } + switch (*p) { + case 'C': { + p++; + goto s_n_llhttp__internal__n_after_start_req_2; + } + case 'N': { + p++; + goto s_n_llhttp__internal__n_after_start_req_3; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_4: + s_n_llhttp__internal__n_after_start_req_4: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_4; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob19, 3); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 16; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_4; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_6: + s_n_llhttp__internal__n_after_start_req_6: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_6; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob20, 6); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 22; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_6; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_8: + s_n_llhttp__internal__n_after_start_req_8: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_8; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob21, 4); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 5; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_8; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_9: + s_n_llhttp__internal__n_after_start_req_9: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_9; + } + switch (*p) { + case 'Y': { + p++; + match = 8; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_7: + s_n_llhttp__internal__n_after_start_req_7: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_7; + } + switch (*p) { + case 'N': { + p++; + goto s_n_llhttp__internal__n_after_start_req_8; + } + case 'P': { + p++; + goto s_n_llhttp__internal__n_after_start_req_9; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_5: + s_n_llhttp__internal__n_after_start_req_5: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_5; + } + switch (*p) { + case 'H': { + p++; + goto s_n_llhttp__internal__n_after_start_req_6; + } + case 'O': { + p++; + goto s_n_llhttp__internal__n_after_start_req_7; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_12: + s_n_llhttp__internal__n_after_start_req_12: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_12; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob22, 3); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 0; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_12; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_13: + s_n_llhttp__internal__n_after_start_req_13: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_13; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob23, 5); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 35; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_13; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_11: + s_n_llhttp__internal__n_after_start_req_11: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_11; + } + switch (*p) { + case 'L': { + p++; + goto s_n_llhttp__internal__n_after_start_req_12; + } + case 'S': { + p++; + goto s_n_llhttp__internal__n_after_start_req_13; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_10: + s_n_llhttp__internal__n_after_start_req_10: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_10; + } + switch (*p) { + case 'E': { + p++; + goto s_n_llhttp__internal__n_after_start_req_11; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_14: + s_n_llhttp__internal__n_after_start_req_14: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_14; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob24, 4); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 45; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_14; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_17: + s_n_llhttp__internal__n_after_start_req_17: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_17; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob26, 9); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 41; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_17; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_16: + s_n_llhttp__internal__n_after_start_req_16: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_16; + } + switch (*p) { + case '_': { + p++; + goto s_n_llhttp__internal__n_after_start_req_17; + } + default: { + match = 1; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_15: + s_n_llhttp__internal__n_after_start_req_15: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_15; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob25, 2); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + goto s_n_llhttp__internal__n_after_start_req_16; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_15; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_18: + s_n_llhttp__internal__n_after_start_req_18: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_18; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob27, 3); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 2; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_18; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_20: + s_n_llhttp__internal__n_after_start_req_20: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_20; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob28, 2); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 31; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_20; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_21: + s_n_llhttp__internal__n_after_start_req_21: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_21; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob29, 2); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 9; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_21; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_19: + s_n_llhttp__internal__n_after_start_req_19: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_19; + } + switch (*p) { + case 'I': { + p++; + goto s_n_llhttp__internal__n_after_start_req_20; + } + case 'O': { + p++; + goto s_n_llhttp__internal__n_after_start_req_21; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_23: + s_n_llhttp__internal__n_after_start_req_23: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_23; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob30, 6); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 24; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_23; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_24: + s_n_llhttp__internal__n_after_start_req_24: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_24; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob31, 3); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 23; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_24; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_26: + s_n_llhttp__internal__n_after_start_req_26: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_26; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob32, 7); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 21; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_26; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_28: + s_n_llhttp__internal__n_after_start_req_28: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_28; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob33, 6); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 30; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_28; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_29: + s_n_llhttp__internal__n_after_start_req_29: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_29; + } + switch (*p) { + case 'L': { + p++; + match = 10; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_27: + s_n_llhttp__internal__n_after_start_req_27: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_27; + } + switch (*p) { + case 'A': { + p++; + goto s_n_llhttp__internal__n_after_start_req_28; + } + case 'O': { + p++; + goto s_n_llhttp__internal__n_after_start_req_29; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_25: + s_n_llhttp__internal__n_after_start_req_25: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_25; + } + switch (*p) { + case 'A': { + p++; + goto s_n_llhttp__internal__n_after_start_req_26; + } + case 'C': { + p++; + goto s_n_llhttp__internal__n_after_start_req_27; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_30: + s_n_llhttp__internal__n_after_start_req_30: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_30; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob34, 2); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 11; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_30; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_22: + s_n_llhttp__internal__n_after_start_req_22: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_22; + } + switch (*p) { + case '-': { + p++; + goto s_n_llhttp__internal__n_after_start_req_23; + } + case 'E': { + p++; + goto s_n_llhttp__internal__n_after_start_req_24; + } + case 'K': { + p++; + goto s_n_llhttp__internal__n_after_start_req_25; + } + case 'O': { + p++; + goto s_n_llhttp__internal__n_after_start_req_30; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_31: + s_n_llhttp__internal__n_after_start_req_31: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_31; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob35, 5); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 25; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_31; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_32: + s_n_llhttp__internal__n_after_start_req_32: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_32; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob36, 6); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 6; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_32; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_35: + s_n_llhttp__internal__n_after_start_req_35: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_35; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob37, 2); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 28; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_35; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_36: + s_n_llhttp__internal__n_after_start_req_36: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_36; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob38, 2); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 39; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_36; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_34: + s_n_llhttp__internal__n_after_start_req_34: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_34; + } + switch (*p) { + case 'T': { + p++; + goto s_n_llhttp__internal__n_after_start_req_35; + } + case 'U': { + p++; + goto s_n_llhttp__internal__n_after_start_req_36; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_37: + s_n_llhttp__internal__n_after_start_req_37: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_37; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob39, 2); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 38; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_37; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_38: + s_n_llhttp__internal__n_after_start_req_38: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_38; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob40, 2); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 3; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_38; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_42: + s_n_llhttp__internal__n_after_start_req_42: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_42; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob41, 3); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 12; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_42; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_43: + s_n_llhttp__internal__n_after_start_req_43: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_43; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob42, 4); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 13; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_43; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_41: + s_n_llhttp__internal__n_after_start_req_41: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_41; + } + switch (*p) { + case 'F': { + p++; + goto s_n_llhttp__internal__n_after_start_req_42; + } + case 'P': { + p++; + goto s_n_llhttp__internal__n_after_start_req_43; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_40: + s_n_llhttp__internal__n_after_start_req_40: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_40; + } + switch (*p) { + case 'P': { + p++; + goto s_n_llhttp__internal__n_after_start_req_41; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_39: + s_n_llhttp__internal__n_after_start_req_39: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_39; + } + switch (*p) { + case 'I': { + p++; + match = 34; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case 'O': { + p++; + goto s_n_llhttp__internal__n_after_start_req_40; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_45: + s_n_llhttp__internal__n_after_start_req_45: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_45; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob43, 2); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 29; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_45; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_44: + s_n_llhttp__internal__n_after_start_req_44: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_44; + } + switch (*p) { + case 'R': { + p++; + goto s_n_llhttp__internal__n_after_start_req_45; + } + case 'T': { + p++; + match = 4; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_33: + s_n_llhttp__internal__n_after_start_req_33: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_33; + } + switch (*p) { + case 'A': { + p++; + goto s_n_llhttp__internal__n_after_start_req_34; + } + case 'L': { + p++; + goto s_n_llhttp__internal__n_after_start_req_37; + } + case 'O': { + p++; + goto s_n_llhttp__internal__n_after_start_req_38; + } + case 'R': { + p++; + goto s_n_llhttp__internal__n_after_start_req_39; + } + case 'U': { + p++; + goto s_n_llhttp__internal__n_after_start_req_44; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_46: + s_n_llhttp__internal__n_after_start_req_46: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_46; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob44, 4); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 46; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_46; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_49: + s_n_llhttp__internal__n_after_start_req_49: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_49; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob45, 3); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 17; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_49; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_50: + s_n_llhttp__internal__n_after_start_req_50: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_50; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob46, 3); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 44; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_50; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_51: + s_n_llhttp__internal__n_after_start_req_51: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_51; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob47, 5); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 43; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_51; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_52: + s_n_llhttp__internal__n_after_start_req_52: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_52; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob48, 3); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 20; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_52; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_48: + s_n_llhttp__internal__n_after_start_req_48: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_48; + } + switch (*p) { + case 'B': { + p++; + goto s_n_llhttp__internal__n_after_start_req_49; + } + case 'C': { + p++; + goto s_n_llhttp__internal__n_after_start_req_50; + } + case 'D': { + p++; + goto s_n_llhttp__internal__n_after_start_req_51; + } + case 'P': { + p++; + goto s_n_llhttp__internal__n_after_start_req_52; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_47: + s_n_llhttp__internal__n_after_start_req_47: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_47; + } + switch (*p) { + case 'E': { + p++; + goto s_n_llhttp__internal__n_after_start_req_48; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_55: + s_n_llhttp__internal__n_after_start_req_55: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_55; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob49, 3); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 14; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_55; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_57: + s_n_llhttp__internal__n_after_start_req_57: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_57; + } + switch (*p) { + case 'P': { + p++; + match = 37; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_58: + s_n_llhttp__internal__n_after_start_req_58: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_58; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob50, 9); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 42; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_58; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_56: + s_n_llhttp__internal__n_after_start_req_56: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_56; + } + switch (*p) { + case 'U': { + p++; + goto s_n_llhttp__internal__n_after_start_req_57; + } + case '_': { + p++; + goto s_n_llhttp__internal__n_after_start_req_58; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_54: + s_n_llhttp__internal__n_after_start_req_54: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_54; + } + switch (*p) { + case 'A': { + p++; + goto s_n_llhttp__internal__n_after_start_req_55; + } + case 'T': { + p++; + goto s_n_llhttp__internal__n_after_start_req_56; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_59: + s_n_llhttp__internal__n_after_start_req_59: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_59; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob51, 4); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 33; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_59; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_60: + s_n_llhttp__internal__n_after_start_req_60: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_60; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob52, 7); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 26; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_60; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_53: + s_n_llhttp__internal__n_after_start_req_53: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_53; + } + switch (*p) { + case 'E': { + p++; + goto s_n_llhttp__internal__n_after_start_req_54; + } + case 'O': { + p++; + goto s_n_llhttp__internal__n_after_start_req_59; + } + case 'U': { + p++; + goto s_n_llhttp__internal__n_after_start_req_60; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_62: + s_n_llhttp__internal__n_after_start_req_62: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_62; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob53, 6); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 40; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_62; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_63: + s_n_llhttp__internal__n_after_start_req_63: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_63; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob54, 3); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 7; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_63; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_61: + s_n_llhttp__internal__n_after_start_req_61: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_61; + } + switch (*p) { + case 'E': { + p++; + goto s_n_llhttp__internal__n_after_start_req_62; + } + case 'R': { + p++; + goto s_n_llhttp__internal__n_after_start_req_63; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_66: + s_n_llhttp__internal__n_after_start_req_66: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_66; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob55, 3); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 18; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_66; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_68: + s_n_llhttp__internal__n_after_start_req_68: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_68; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob56, 2); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 32; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_68; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_69: + s_n_llhttp__internal__n_after_start_req_69: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_69; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob57, 2); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 15; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_69; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_67: + s_n_llhttp__internal__n_after_start_req_67: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_67; + } + switch (*p) { + case 'I': { + p++; + goto s_n_llhttp__internal__n_after_start_req_68; + } + case 'O': { + p++; + goto s_n_llhttp__internal__n_after_start_req_69; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_70: + s_n_llhttp__internal__n_after_start_req_70: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_70; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob58, 8); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 27; + goto s_n_llhttp__internal__n_invoke_store_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_after_start_req_70; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_65: + s_n_llhttp__internal__n_after_start_req_65: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_65; + } + switch (*p) { + case 'B': { + p++; + goto s_n_llhttp__internal__n_after_start_req_66; + } + case 'L': { + p++; + goto s_n_llhttp__internal__n_after_start_req_67; + } + case 'S': { + p++; + goto s_n_llhttp__internal__n_after_start_req_70; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req_64: + s_n_llhttp__internal__n_after_start_req_64: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req_64; + } + switch (*p) { + case 'N': { + p++; + goto s_n_llhttp__internal__n_after_start_req_65; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_after_start_req: + s_n_llhttp__internal__n_after_start_req: { + if (p == endp) { + return s_n_llhttp__internal__n_after_start_req; + } + switch (*p) { + case 'A': { + p++; + goto s_n_llhttp__internal__n_after_start_req_1; + } + case 'B': { + p++; + goto s_n_llhttp__internal__n_after_start_req_4; + } + case 'C': { + p++; + goto s_n_llhttp__internal__n_after_start_req_5; + } + case 'D': { + p++; + goto s_n_llhttp__internal__n_after_start_req_10; + } + case 'F': { + p++; + goto s_n_llhttp__internal__n_after_start_req_14; + } + case 'G': { + p++; + goto s_n_llhttp__internal__n_after_start_req_15; + } + case 'H': { + p++; + goto s_n_llhttp__internal__n_after_start_req_18; + } + case 'L': { + p++; + goto s_n_llhttp__internal__n_after_start_req_19; + } + case 'M': { + p++; + goto s_n_llhttp__internal__n_after_start_req_22; + } + case 'N': { + p++; + goto s_n_llhttp__internal__n_after_start_req_31; + } + case 'O': { + p++; + goto s_n_llhttp__internal__n_after_start_req_32; + } + case 'P': { + p++; + goto s_n_llhttp__internal__n_after_start_req_33; + } + case 'Q': { + p++; + goto s_n_llhttp__internal__n_after_start_req_46; + } + case 'R': { + p++; + goto s_n_llhttp__internal__n_after_start_req_47; + } + case 'S': { + p++; + goto s_n_llhttp__internal__n_after_start_req_53; + } + case 'T': { + p++; + goto s_n_llhttp__internal__n_after_start_req_61; + } + case 'U': { + p++; + goto s_n_llhttp__internal__n_after_start_req_64; + } + default: { + goto s_n_llhttp__internal__n_error_113; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_start_llhttp__on_method_1: + s_n_llhttp__internal__n_span_start_llhttp__on_method_1: { + if (p == endp) { + return s_n_llhttp__internal__n_span_start_llhttp__on_method_1; + } + state->_span_pos0 = (void*) p; + state->_span_cb0 = llhttp__on_method; + goto s_n_llhttp__internal__n_after_start_req; + UNREACHABLE; + } + case s_n_llhttp__internal__n_res_line_almost_done: + s_n_llhttp__internal__n_res_line_almost_done: { + if (p == endp) { + return s_n_llhttp__internal__n_res_line_almost_done; + } + switch (*p) { + case 10: { + p++; + goto s_n_llhttp__internal__n_invoke_llhttp__on_status_complete; + } + default: { + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_30; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_test_lenient_flags_31: + s_n_llhttp__internal__n_invoke_test_lenient_flags_31: { + switch (llhttp__internal__c_test_lenient_flags_1(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_invoke_llhttp__on_status_complete; + default: + goto s_n_llhttp__internal__n_error_99; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_res_status: + s_n_llhttp__internal__n_res_status: { + if (p == endp) { + return s_n_llhttp__internal__n_res_status; + } + switch (*p) { + case 10: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_status; + } + case 13: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_status_1; + } + default: { + p++; + goto s_n_llhttp__internal__n_res_status; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_start_llhttp__on_status: + s_n_llhttp__internal__n_span_start_llhttp__on_status: { + if (p == endp) { + return s_n_llhttp__internal__n_span_start_llhttp__on_status; + } + state->_span_pos0 = (void*) p; + state->_span_cb0 = llhttp__on_status; + goto s_n_llhttp__internal__n_res_status; + UNREACHABLE; + } + case s_n_llhttp__internal__n_res_status_code_otherwise: + s_n_llhttp__internal__n_res_status_code_otherwise: { + if (p == endp) { + return s_n_llhttp__internal__n_res_status_code_otherwise; + } + switch (*p) { + case 10: { + p++; + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_29; + } + case 13: { + p++; + goto s_n_llhttp__internal__n_res_line_almost_done; + } + case ' ': { + p++; + goto s_n_llhttp__internal__n_span_start_llhttp__on_status; + } + default: { + goto s_n_llhttp__internal__n_error_100; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_res_status_code_digit_3: + s_n_llhttp__internal__n_res_status_code_digit_3: { + if (p == endp) { + return s_n_llhttp__internal__n_res_status_code_digit_3; + } + switch (*p) { + case '0': { + p++; + match = 0; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code_2; + } + case '1': { + p++; + match = 1; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code_2; + } + case '2': { + p++; + match = 2; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code_2; + } + case '3': { + p++; + match = 3; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code_2; + } + case '4': { + p++; + match = 4; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code_2; + } + case '5': { + p++; + match = 5; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code_2; + } + case '6': { + p++; + match = 6; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code_2; + } + case '7': { + p++; + match = 7; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code_2; + } + case '8': { + p++; + match = 8; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code_2; + } + case '9': { + p++; + match = 9; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code_2; + } + default: { + goto s_n_llhttp__internal__n_error_102; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_res_status_code_digit_2: + s_n_llhttp__internal__n_res_status_code_digit_2: { + if (p == endp) { + return s_n_llhttp__internal__n_res_status_code_digit_2; + } + switch (*p) { + case '0': { + p++; + match = 0; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code_1; + } + case '1': { + p++; + match = 1; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code_1; + } + case '2': { + p++; + match = 2; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code_1; + } + case '3': { + p++; + match = 3; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code_1; + } + case '4': { + p++; + match = 4; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code_1; + } + case '5': { + p++; + match = 5; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code_1; + } + case '6': { + p++; + match = 6; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code_1; + } + case '7': { + p++; + match = 7; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code_1; + } + case '8': { + p++; + match = 8; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code_1; + } + case '9': { + p++; + match = 9; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code_1; + } + default: { + goto s_n_llhttp__internal__n_error_104; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_res_status_code_digit_1: + s_n_llhttp__internal__n_res_status_code_digit_1: { + if (p == endp) { + return s_n_llhttp__internal__n_res_status_code_digit_1; + } + switch (*p) { + case '0': { + p++; + match = 0; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code; + } + case '1': { + p++; + match = 1; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code; + } + case '2': { + p++; + match = 2; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code; + } + case '3': { + p++; + match = 3; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code; + } + case '4': { + p++; + match = 4; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code; + } + case '5': { + p++; + match = 5; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code; + } + case '6': { + p++; + match = 6; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code; + } + case '7': { + p++; + match = 7; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code; + } + case '8': { + p++; + match = 8; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code; + } + case '9': { + p++; + match = 9; + goto s_n_llhttp__internal__n_invoke_mul_add_status_code; + } + default: { + goto s_n_llhttp__internal__n_error_106; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_res_after_version: + s_n_llhttp__internal__n_res_after_version: { + if (p == endp) { + return s_n_llhttp__internal__n_res_after_version; + } + switch (*p) { + case ' ': { + p++; + goto s_n_llhttp__internal__n_invoke_update_status_code; + } + default: { + goto s_n_llhttp__internal__n_error_107; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_llhttp__on_version_complete_1: + s_n_llhttp__internal__n_invoke_llhttp__on_version_complete_1: { + switch (llhttp__on_version_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_res_after_version; + case 21: + goto s_n_llhttp__internal__n_pause_28; + default: + goto s_n_llhttp__internal__n_error_95; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_error_94: + s_n_llhttp__internal__n_error_94: { + state->error = 0x9; + state->reason = "Invalid HTTP version"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_error_108: + s_n_llhttp__internal__n_error_108: { + state->error = 0x9; + state->reason = "Invalid minor version"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_res_http_minor: + s_n_llhttp__internal__n_res_http_minor: { + if (p == endp) { + return s_n_llhttp__internal__n_res_http_minor; + } + switch (*p) { + case '0': { + p++; + match = 0; + goto s_n_llhttp__internal__n_invoke_store_http_minor_1; + } + case '1': { + p++; + match = 1; + goto s_n_llhttp__internal__n_invoke_store_http_minor_1; + } + case '2': { + p++; + match = 2; + goto s_n_llhttp__internal__n_invoke_store_http_minor_1; + } + case '3': { + p++; + match = 3; + goto s_n_llhttp__internal__n_invoke_store_http_minor_1; + } + case '4': { + p++; + match = 4; + goto s_n_llhttp__internal__n_invoke_store_http_minor_1; + } + case '5': { + p++; + match = 5; + goto s_n_llhttp__internal__n_invoke_store_http_minor_1; + } + case '6': { + p++; + match = 6; + goto s_n_llhttp__internal__n_invoke_store_http_minor_1; + } + case '7': { + p++; + match = 7; + goto s_n_llhttp__internal__n_invoke_store_http_minor_1; + } + case '8': { + p++; + match = 8; + goto s_n_llhttp__internal__n_invoke_store_http_minor_1; + } + case '9': { + p++; + match = 9; + goto s_n_llhttp__internal__n_invoke_store_http_minor_1; + } + default: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_version_7; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_error_109: + s_n_llhttp__internal__n_error_109: { + state->error = 0x9; + state->reason = "Expected dot"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_res_http_dot: + s_n_llhttp__internal__n_res_http_dot: { + if (p == endp) { + return s_n_llhttp__internal__n_res_http_dot; + } + switch (*p) { + case '.': { + p++; + goto s_n_llhttp__internal__n_res_http_minor; + } + default: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_version_8; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_error_110: + s_n_llhttp__internal__n_error_110: { + state->error = 0x9; + state->reason = "Invalid major version"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_res_http_major: + s_n_llhttp__internal__n_res_http_major: { + if (p == endp) { + return s_n_llhttp__internal__n_res_http_major; + } + switch (*p) { + case '0': { + p++; + match = 0; + goto s_n_llhttp__internal__n_invoke_store_http_major_1; + } + case '1': { + p++; + match = 1; + goto s_n_llhttp__internal__n_invoke_store_http_major_1; + } + case '2': { + p++; + match = 2; + goto s_n_llhttp__internal__n_invoke_store_http_major_1; + } + case '3': { + p++; + match = 3; + goto s_n_llhttp__internal__n_invoke_store_http_major_1; + } + case '4': { + p++; + match = 4; + goto s_n_llhttp__internal__n_invoke_store_http_major_1; + } + case '5': { + p++; + match = 5; + goto s_n_llhttp__internal__n_invoke_store_http_major_1; + } + case '6': { + p++; + match = 6; + goto s_n_llhttp__internal__n_invoke_store_http_major_1; + } + case '7': { + p++; + match = 7; + goto s_n_llhttp__internal__n_invoke_store_http_major_1; + } + case '8': { + p++; + match = 8; + goto s_n_llhttp__internal__n_invoke_store_http_major_1; + } + case '9': { + p++; + match = 9; + goto s_n_llhttp__internal__n_invoke_store_http_major_1; + } + default: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_version_9; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_start_llhttp__on_version_1: + s_n_llhttp__internal__n_span_start_llhttp__on_version_1: { + if (p == endp) { + return s_n_llhttp__internal__n_span_start_llhttp__on_version_1; + } + state->_span_pos0 = (void*) p; + state->_span_cb0 = llhttp__on_version; + goto s_n_llhttp__internal__n_res_http_major; + UNREACHABLE; + } + case s_n_llhttp__internal__n_res_after_protocol: + s_n_llhttp__internal__n_res_after_protocol: { + if (p == endp) { + return s_n_llhttp__internal__n_res_after_protocol; + } + switch (*p) { + case '/': { + p++; + goto s_n_llhttp__internal__n_span_start_llhttp__on_version_1; + } + default: { + goto s_n_llhttp__internal__n_error_115; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_llhttp__on_protocol_complete_3: + s_n_llhttp__internal__n_invoke_llhttp__on_protocol_complete_3: { + switch (llhttp__on_protocol_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_res_after_protocol; + case 21: + goto s_n_llhttp__internal__n_pause_30; + default: + goto s_n_llhttp__internal__n_error_114; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_error_116: + s_n_llhttp__internal__n_error_116: { + state->error = 0x8; + state->reason = "Expected HTTP/, RTSP/ or ICE/"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + case s_n_llhttp__internal__n_res_after_start_1: + s_n_llhttp__internal__n_res_after_start_1: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_res_after_start_1; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob59, 3); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + goto s_n_llhttp__internal__n_span_end_llhttp__on_protocol_4; + } + case kMatchPause: { + return s_n_llhttp__internal__n_res_after_start_1; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_protocol_5; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_res_after_start_2: + s_n_llhttp__internal__n_res_after_start_2: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_res_after_start_2; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob60, 2); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + goto s_n_llhttp__internal__n_span_end_llhttp__on_protocol_4; + } + case kMatchPause: { + return s_n_llhttp__internal__n_res_after_start_2; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_protocol_5; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_res_after_start_3: + s_n_llhttp__internal__n_res_after_start_3: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_res_after_start_3; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob61, 3); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + goto s_n_llhttp__internal__n_span_end_llhttp__on_protocol_4; + } + case kMatchPause: { + return s_n_llhttp__internal__n_res_after_start_3; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_protocol_5; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_res_after_start: + s_n_llhttp__internal__n_res_after_start: { + if (p == endp) { + return s_n_llhttp__internal__n_res_after_start; + } + switch (*p) { + case 'H': { + p++; + goto s_n_llhttp__internal__n_res_after_start_1; + } + case 'I': { + p++; + goto s_n_llhttp__internal__n_res_after_start_2; + } + case 'R': { + p++; + goto s_n_llhttp__internal__n_res_after_start_3; + } + default: { + goto s_n_llhttp__internal__n_span_end_llhttp__on_protocol_5; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_start_llhttp__on_protocol_1: + s_n_llhttp__internal__n_span_start_llhttp__on_protocol_1: { + if (p == endp) { + return s_n_llhttp__internal__n_span_start_llhttp__on_protocol_1; + } + state->_span_pos0 = (void*) p; + state->_span_cb0 = llhttp__on_protocol; + goto s_n_llhttp__internal__n_res_after_start; + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_llhttp__on_method_complete: + s_n_llhttp__internal__n_invoke_llhttp__on_method_complete: { + switch (llhttp__on_method_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_req_first_space_before_url; + case 21: + goto s_n_llhttp__internal__n_pause_26; + default: + goto s_n_llhttp__internal__n_error_1; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_req_or_res_method_2: + s_n_llhttp__internal__n_req_or_res_method_2: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_req_or_res_method_2; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob62, 2); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + match = 2; + goto s_n_llhttp__internal__n_invoke_store_method; + } + case kMatchPause: { + return s_n_llhttp__internal__n_req_or_res_method_2; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_111; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_update_type_1: + s_n_llhttp__internal__n_invoke_update_type_1: { + switch (llhttp__internal__c_update_type_1(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_span_start_llhttp__on_version_1; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_req_or_res_method_3: + s_n_llhttp__internal__n_req_or_res_method_3: { + llparse_match_t match_seq; + + if (p == endp) { + return s_n_llhttp__internal__n_req_or_res_method_3; + } + match_seq = llparse__match_sequence_id(state, p, endp, llparse_blob63, 3); + p = match_seq.current; + switch (match_seq.status) { + case kMatchComplete: { + p++; + goto s_n_llhttp__internal__n_span_end_llhttp__on_method_1; + } + case kMatchPause: { + return s_n_llhttp__internal__n_req_or_res_method_3; + } + case kMatchMismatch: { + goto s_n_llhttp__internal__n_error_111; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_req_or_res_method_1: + s_n_llhttp__internal__n_req_or_res_method_1: { + if (p == endp) { + return s_n_llhttp__internal__n_req_or_res_method_1; + } + switch (*p) { + case 'E': { + p++; + goto s_n_llhttp__internal__n_req_or_res_method_2; + } + case 'T': { + p++; + goto s_n_llhttp__internal__n_req_or_res_method_3; + } + default: { + goto s_n_llhttp__internal__n_error_111; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_req_or_res_method: + s_n_llhttp__internal__n_req_or_res_method: { + if (p == endp) { + return s_n_llhttp__internal__n_req_or_res_method; + } + switch (*p) { + case 'H': { + p++; + goto s_n_llhttp__internal__n_req_or_res_method_1; + } + default: { + goto s_n_llhttp__internal__n_error_111; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_span_start_llhttp__on_method: + s_n_llhttp__internal__n_span_start_llhttp__on_method: { + if (p == endp) { + return s_n_llhttp__internal__n_span_start_llhttp__on_method; + } + state->_span_pos0 = (void*) p; + state->_span_cb0 = llhttp__on_method; + goto s_n_llhttp__internal__n_req_or_res_method; + UNREACHABLE; + } + case s_n_llhttp__internal__n_start_req_or_res: + s_n_llhttp__internal__n_start_req_or_res: { + if (p == endp) { + return s_n_llhttp__internal__n_start_req_or_res; + } + switch (*p) { + case 'H': { + goto s_n_llhttp__internal__n_span_start_llhttp__on_method; + } + default: { + goto s_n_llhttp__internal__n_invoke_update_type_2; + } + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_load_type: + s_n_llhttp__internal__n_invoke_load_type: { + switch (llhttp__internal__c_load_type(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_span_start_llhttp__on_method_1; + case 2: + goto s_n_llhttp__internal__n_span_start_llhttp__on_protocol_1; + default: + goto s_n_llhttp__internal__n_start_req_or_res; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_invoke_update_finish: + s_n_llhttp__internal__n_invoke_update_finish: { + switch (llhttp__internal__c_update_finish(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_llhttp__on_message_begin; + } + UNREACHABLE; + } + case s_n_llhttp__internal__n_start: + s_n_llhttp__internal__n_start: { + if (p == endp) { + return s_n_llhttp__internal__n_start; + } + switch (*p) { + case 10: { + p++; + goto s_n_llhttp__internal__n_start; + } + case 13: { + p++; + goto s_n_llhttp__internal__n_start; + } + default: { + goto s_n_llhttp__internal__n_invoke_load_initial_message_completed; + } + } + UNREACHABLE; + } + default: + UNREACHABLE; + } + s_n_llhttp__internal__n_error_2: { + state->error = 0x7; + state->reason = "Invalid characters in url"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_finish_2: { + switch (llhttp__internal__c_update_finish_1(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_start; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_initial_message_completed: { + switch (llhttp__internal__c_update_initial_message_completed(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_update_finish_2; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_content_length: { + switch (llhttp__internal__c_update_content_length(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_update_initial_message_completed; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_8: { + state->error = 0x5; + state->reason = "Data after `Connection: close`"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_3: { + switch (llhttp__internal__c_test_lenient_flags_3(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_closed; + default: + goto s_n_llhttp__internal__n_error_8; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_2: { + switch (llhttp__internal__c_test_lenient_flags_2(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_invoke_update_initial_message_completed; + default: + goto s_n_llhttp__internal__n_closed; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_finish_1: { + switch (llhttp__internal__c_update_finish_1(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_2; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_13: { + state->error = 0x15; + state->reason = "on_message_complete pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_is_equal_upgrade; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_38: { + state->error = 0x12; + state->reason = "`on_message_complete` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_15: { + state->error = 0x15; + state->reason = "on_chunk_complete pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_message_complete_2; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_40: { + state->error = 0x14; + state->reason = "`on_chunk_complete` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_llhttp__on_chunk_complete_1: { + switch (llhttp__on_chunk_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_invoke_llhttp__on_message_complete_2; + case 21: + goto s_n_llhttp__internal__n_pause_15; + default: + goto s_n_llhttp__internal__n_error_40; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_2: { + state->error = 0x15; + state->reason = "on_message_complete pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_pause_1; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_9: { + state->error = 0x12; + state->reason = "`on_message_complete` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_llhttp__on_message_complete_1: { + switch (llhttp__on_message_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_pause_1; + case 21: + goto s_n_llhttp__internal__n_pause_2; + default: + goto s_n_llhttp__internal__n_error_9; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_36: { + state->error = 0xc; + state->reason = "Chunk size overflow"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_10: { + state->error = 0xc; + state->reason = "Invalid character in chunk size"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_4: { + switch (llhttp__internal__c_test_lenient_flags_4(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_chunk_size_otherwise; + default: + goto s_n_llhttp__internal__n_error_10; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_3: { + state->error = 0x15; + state->reason = "on_chunk_complete pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_update_content_length_1; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_14: { + state->error = 0x14; + state->reason = "`on_chunk_complete` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_llhttp__on_chunk_complete: { + switch (llhttp__on_chunk_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_invoke_update_content_length_1; + case 21: + goto s_n_llhttp__internal__n_pause_3; + default: + goto s_n_llhttp__internal__n_error_14; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_13: { + state->error = 0x19; + state->reason = "Missing expected CR after chunk data"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_6: { + switch (llhttp__internal__c_test_lenient_flags_1(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_invoke_llhttp__on_chunk_complete; + default: + goto s_n_llhttp__internal__n_error_13; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_15: { + state->error = 0x2; + state->reason = "Expected LF after chunk data"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_7: { + switch (llhttp__internal__c_test_lenient_flags_7(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_invoke_llhttp__on_chunk_complete; + default: + goto s_n_llhttp__internal__n_error_15; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_body: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_body(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_chunk_data_almost_done; + return s_error; + } + goto s_n_llhttp__internal__n_chunk_data_almost_done; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags: { + switch (llhttp__internal__c_or_flags(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_header_field_start; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_4: { + state->error = 0x15; + state->reason = "on_chunk_header pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_is_equal_content_length; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_12: { + state->error = 0x13; + state->reason = "`on_chunk_header` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_llhttp__on_chunk_header: { + switch (llhttp__on_chunk_header(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_invoke_is_equal_content_length; + case 21: + goto s_n_llhttp__internal__n_pause_4; + default: + goto s_n_llhttp__internal__n_error_12; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_16: { + state->error = 0x2; + state->reason = "Expected LF after chunk size"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_8: { + switch (llhttp__internal__c_test_lenient_flags_8(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_invoke_llhttp__on_chunk_header; + default: + goto s_n_llhttp__internal__n_error_16; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_11: { + state->error = 0x19; + state->reason = "Missing expected CR after chunk size"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_5: { + switch (llhttp__internal__c_test_lenient_flags_1(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_chunk_size_almost_done; + default: + goto s_n_llhttp__internal__n_error_11; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_17: { + state->error = 0x2; + state->reason = "Invalid character in chunk extensions"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_18: { + state->error = 0x2; + state->reason = "Invalid character in chunk extensions"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_20: { + state->error = 0x19; + state->reason = "Missing expected CR after chunk extension name"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_5: { + state->error = 0x15; + state->reason = "on_chunk_extension_name pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_test_lenient_flags_9; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_19: { + state->error = 0x22; + state->reason = "`on_chunk_extension_name` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_name: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_chunk_extension_name(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_name_complete; + return s_error; + } + goto s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_name_complete; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_6: { + state->error = 0x15; + state->reason = "on_chunk_extension_name pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_chunk_size_almost_done; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_21: { + state->error = 0x22; + state->reason = "`on_chunk_extension_name` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_name_1: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_chunk_extension_name(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) (p + 1); + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_name_complete_1; + return s_error; + } + p++; + goto s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_name_complete_1; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_7: { + state->error = 0x15; + state->reason = "on_chunk_extension_name pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_chunk_extensions; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_22: { + state->error = 0x22; + state->reason = "`on_chunk_extension_name` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_name_2: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_chunk_extension_name(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) (p + 1); + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_name_complete_2; + return s_error; + } + p++; + goto s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_name_complete_2; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_25: { + state->error = 0x19; + state->reason = "Missing expected CR after chunk extension value"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_8: { + state->error = 0x15; + state->reason = "on_chunk_extension_value pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_test_lenient_flags_10; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_24: { + state->error = 0x23; + state->reason = "`on_chunk_extension_value` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_value: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_chunk_extension_value(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_value_complete; + return s_error; + } + goto s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_value_complete; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_9: { + state->error = 0x15; + state->reason = "on_chunk_extension_value pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_chunk_size_almost_done; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_26: { + state->error = 0x23; + state->reason = "`on_chunk_extension_value` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_value_1: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_chunk_extension_value(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) (p + 1); + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_value_complete_1; + return s_error; + } + p++; + goto s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_value_complete_1; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_28: { + state->error = 0x19; + state->reason = "Missing expected CR after chunk extension value"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_11: { + switch (llhttp__internal__c_test_lenient_flags_1(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_chunk_size_almost_done; + default: + goto s_n_llhttp__internal__n_error_28; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_29: { + state->error = 0x2; + state->reason = "Invalid character in chunk extensions quote value"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_10: { + state->error = 0x15; + state->reason = "on_chunk_extension_value pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_chunk_extension_quoted_value_done; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_27: { + state->error = 0x23; + state->reason = "`on_chunk_extension_value` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_value_2: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_chunk_extension_value(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_value_complete_2; + return s_error; + } + goto s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_value_complete_2; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_value_3: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_chunk_extension_value(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) (p + 1); + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_error_30; + return s_error; + } + p++; + goto s_n_llhttp__internal__n_error_30; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_value_4: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_chunk_extension_value(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) (p + 1); + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_error_31; + return s_error; + } + p++; + goto s_n_llhttp__internal__n_error_31; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_11: { + state->error = 0x15; + state->reason = "on_chunk_extension_value pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_chunk_extensions; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_32: { + state->error = 0x23; + state->reason = "`on_chunk_extension_value` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_value_5: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_chunk_extension_value(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) (p + 1); + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_value_complete_3; + return s_error; + } + p++; + goto s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_value_complete_3; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_value_6: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_chunk_extension_value(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) (p + 1); + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_error_33; + return s_error; + } + p++; + goto s_n_llhttp__internal__n_error_33; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_12: { + state->error = 0x15; + state->reason = "on_chunk_extension_name pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_chunk_extension_value; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_23: { + state->error = 0x22; + state->reason = "`on_chunk_extension_name` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_llhttp__on_chunk_extension_name_complete_3: { + switch (llhttp__on_chunk_extension_name_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_chunk_extension_value; + case 21: + goto s_n_llhttp__internal__n_pause_12; + default: + goto s_n_llhttp__internal__n_error_23; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_name_3: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_chunk_extension_name(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) (p + 1); + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_span_start_llhttp__on_chunk_extension_value; + return s_error; + } + p++; + goto s_n_llhttp__internal__n_span_start_llhttp__on_chunk_extension_value; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_chunk_extension_name_4: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_chunk_extension_name(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) (p + 1); + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_error_34; + return s_error; + } + p++; + goto s_n_llhttp__internal__n_error_34; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_35: { + state->error = 0xc; + state->reason = "Invalid character in chunk size"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_mul_add_content_length: { + switch (llhttp__internal__c_mul_add_content_length(state, p, endp, match)) { + case 1: + goto s_n_llhttp__internal__n_error_36; + default: + goto s_n_llhttp__internal__n_chunk_size; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_37: { + state->error = 0xc; + state->reason = "Invalid character in chunk size"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_body_1: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_body(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_message_complete_2; + return s_error; + } + goto s_n_llhttp__internal__n_invoke_llhttp__on_message_complete_2; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_finish_3: { + switch (llhttp__internal__c_update_finish_3(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_span_start_llhttp__on_body_2; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_39: { + state->error = 0xf; + state->reason = "Request has invalid `Transfer-Encoding`"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause: { + state->error = 0x15; + state->reason = "on_message_complete pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__after_message_complete; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_7: { + state->error = 0x12; + state->reason = "`on_message_complete` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_llhttp__on_message_complete: { + switch (llhttp__on_message_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_invoke_llhttp__after_message_complete; + case 21: + goto s_n_llhttp__internal__n_pause; + default: + goto s_n_llhttp__internal__n_error_7; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags_1: { + switch (llhttp__internal__c_or_flags_1(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_llhttp__after_headers_complete; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags_2: { + switch (llhttp__internal__c_or_flags_1(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_llhttp__after_headers_complete; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_upgrade: { + switch (llhttp__internal__c_update_upgrade(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_or_flags_2; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_14: { + state->error = 0x15; + state->reason = "Paused by on_headers_complete"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__after_headers_complete; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_6: { + state->error = 0x11; + state->reason = "User callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_llhttp__on_headers_complete: { + switch (llhttp__on_headers_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_invoke_llhttp__after_headers_complete; + case 1: + goto s_n_llhttp__internal__n_invoke_or_flags_1; + case 2: + goto s_n_llhttp__internal__n_invoke_update_upgrade; + case 21: + goto s_n_llhttp__internal__n_pause_14; + default: + goto s_n_llhttp__internal__n_error_6; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_llhttp__before_headers_complete: { + switch (llhttp__before_headers_complete(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_llhttp__on_headers_complete; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_flags: { + switch (llhttp__internal__c_test_flags(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_invoke_llhttp__on_chunk_complete_1; + default: + goto s_n_llhttp__internal__n_invoke_llhttp__before_headers_complete; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_1: { + switch (llhttp__internal__c_test_lenient_flags_1(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_invoke_test_flags; + default: + goto s_n_llhttp__internal__n_error_5; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_17: { + state->error = 0x15; + state->reason = "on_chunk_complete pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_message_complete_2; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_42: { + state->error = 0x14; + state->reason = "`on_chunk_complete` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_llhttp__on_chunk_complete_2: { + switch (llhttp__on_chunk_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_invoke_llhttp__on_message_complete_2; + case 21: + goto s_n_llhttp__internal__n_pause_17; + default: + goto s_n_llhttp__internal__n_error_42; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags_3: { + switch (llhttp__internal__c_or_flags_1(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_llhttp__after_headers_complete; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags_4: { + switch (llhttp__internal__c_or_flags_1(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_llhttp__after_headers_complete; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_upgrade_1: { + switch (llhttp__internal__c_update_upgrade(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_or_flags_4; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_16: { + state->error = 0x15; + state->reason = "Paused by on_headers_complete"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__after_headers_complete; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_41: { + state->error = 0x11; + state->reason = "User callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_llhttp__on_headers_complete_1: { + switch (llhttp__on_headers_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_invoke_llhttp__after_headers_complete; + case 1: + goto s_n_llhttp__internal__n_invoke_or_flags_3; + case 2: + goto s_n_llhttp__internal__n_invoke_update_upgrade_1; + case 21: + goto s_n_llhttp__internal__n_pause_16; + default: + goto s_n_llhttp__internal__n_error_41; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_llhttp__before_headers_complete_1: { + switch (llhttp__before_headers_complete(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_llhttp__on_headers_complete_1; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_flags_1: { + switch (llhttp__internal__c_test_flags(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_invoke_llhttp__on_chunk_complete_2; + default: + goto s_n_llhttp__internal__n_invoke_llhttp__before_headers_complete_1; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_43: { + state->error = 0x2; + state->reason = "Expected LF after headers"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_12: { + switch (llhttp__internal__c_test_lenient_flags_8(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_invoke_test_flags_1; + default: + goto s_n_llhttp__internal__n_error_43; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_44: { + state->error = 0xa; + state->reason = "Invalid header token"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_header_field: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_header_field(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) (p + 1); + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_error_5; + return s_error; + } + p++; + goto s_n_llhttp__internal__n_error_5; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_13: { + switch (llhttp__internal__c_test_lenient_flags(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_header_field_colon_discard_ws; + default: + goto s_n_llhttp__internal__n_span_end_llhttp__on_header_field; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_61: { + state->error = 0xb; + state->reason = "Content-Length can't be present with Transfer-Encoding"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_47: { + state->error = 0xa; + state->reason = "Invalid header value char"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_15: { + switch (llhttp__internal__c_test_lenient_flags(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_header_value_discard_ws; + default: + goto s_n_llhttp__internal__n_error_47; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_49: { + state->error = 0xb; + state->reason = "Empty Content-Length"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_50: { + state->error = 0xf; + state->reason = "Empty Transfer-Encoding"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_18: { + state->error = 0x15; + state->reason = "on_header_value_complete pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_header_field_start; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_48: { + state->error = 0x1d; + state->reason = "`on_header_value_complete` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_header_value: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_header_value(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_header_value_complete; + return s_error; + } + goto s_n_llhttp__internal__n_invoke_llhttp__on_header_value_complete; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_header_state: { + switch (llhttp__internal__c_update_header_state(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_span_start_llhttp__on_header_value; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags_5: { + switch (llhttp__internal__c_or_flags_5(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_update_header_state; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags_6: { + switch (llhttp__internal__c_or_flags_6(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_update_header_state; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags_7: { + switch (llhttp__internal__c_or_flags_7(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_update_header_state; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags_8: { + switch (llhttp__internal__c_or_flags_8(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_span_start_llhttp__on_header_value; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_load_header_state_2: { + switch (llhttp__internal__c_load_header_state(state, p, endp)) { + case 5: + goto s_n_llhttp__internal__n_invoke_or_flags_5; + case 6: + goto s_n_llhttp__internal__n_invoke_or_flags_6; + case 7: + goto s_n_llhttp__internal__n_invoke_or_flags_7; + case 8: + goto s_n_llhttp__internal__n_invoke_or_flags_8; + default: + goto s_n_llhttp__internal__n_span_start_llhttp__on_header_value; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_load_header_state_1: { + switch (llhttp__internal__c_load_header_state(state, p, endp)) { + case 2: + goto s_n_llhttp__internal__n_error_49; + case 3: + goto s_n_llhttp__internal__n_error_50; + default: + goto s_n_llhttp__internal__n_invoke_load_header_state_2; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_46: { + state->error = 0xa; + state->reason = "Invalid header value char"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_14: { + switch (llhttp__internal__c_test_lenient_flags_1(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_header_value_discard_lws; + default: + goto s_n_llhttp__internal__n_error_46; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_51: { + state->error = 0x2; + state->reason = "Expected LF after CR"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_16: { + switch (llhttp__internal__c_test_lenient_flags(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_header_value_discard_lws; + default: + goto s_n_llhttp__internal__n_error_51; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_header_state_1: { + switch (llhttp__internal__c_update_header_state_1(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_span_start_llhttp__on_header_value_1; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_load_header_state_4: { + switch (llhttp__internal__c_load_header_state(state, p, endp)) { + case 8: + goto s_n_llhttp__internal__n_invoke_update_header_state_1; + default: + goto s_n_llhttp__internal__n_span_start_llhttp__on_header_value_1; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_53: { + state->error = 0xa; + state->reason = "Unexpected whitespace after header value"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_18: { + switch (llhttp__internal__c_test_lenient_flags(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_invoke_load_header_state_4; + default: + goto s_n_llhttp__internal__n_error_53; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_header_state_2: { + switch (llhttp__internal__c_update_header_state(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_llhttp__on_header_value_complete; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags_9: { + switch (llhttp__internal__c_or_flags_5(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_update_header_state_2; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags_10: { + switch (llhttp__internal__c_or_flags_6(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_update_header_state_2; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags_11: { + switch (llhttp__internal__c_or_flags_7(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_update_header_state_2; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags_12: { + switch (llhttp__internal__c_or_flags_8(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_llhttp__on_header_value_complete; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_load_header_state_5: { + switch (llhttp__internal__c_load_header_state(state, p, endp)) { + case 5: + goto s_n_llhttp__internal__n_invoke_or_flags_9; + case 6: + goto s_n_llhttp__internal__n_invoke_or_flags_10; + case 7: + goto s_n_llhttp__internal__n_invoke_or_flags_11; + case 8: + goto s_n_llhttp__internal__n_invoke_or_flags_12; + default: + goto s_n_llhttp__internal__n_invoke_llhttp__on_header_value_complete; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_54: { + state->error = 0x3; + state->reason = "Missing expected LF after header value"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_52: { + state->error = 0x19; + state->reason = "Missing expected CR after header value"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_header_value_1: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_header_value(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_test_lenient_flags_17; + return s_error; + } + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_17; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_header_value_2: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_header_value(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) (p + 1); + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_header_value_almost_done; + return s_error; + } + p++; + goto s_n_llhttp__internal__n_header_value_almost_done; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_header_value_4: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_header_value(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_header_value_almost_done; + return s_error; + } + goto s_n_llhttp__internal__n_header_value_almost_done; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_header_value_5: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_header_value(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) (p + 1); + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_header_value_almost_done; + return s_error; + } + p++; + goto s_n_llhttp__internal__n_header_value_almost_done; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_header_value_3: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_header_value(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_error_55; + return s_error; + } + goto s_n_llhttp__internal__n_error_55; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_20: { + switch (llhttp__internal__c_test_lenient_flags_20(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_header_value_relaxed; + default: + goto s_n_llhttp__internal__n_span_end_llhttp__on_header_value_3; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_19: { + switch (llhttp__internal__c_test_lenient_flags(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_header_value_lenient; + default: + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_20; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_header_state_4: { + switch (llhttp__internal__c_update_header_state(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_header_value_connection; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags_13: { + switch (llhttp__internal__c_or_flags_5(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_update_header_state_4; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags_14: { + switch (llhttp__internal__c_or_flags_6(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_update_header_state_4; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags_15: { + switch (llhttp__internal__c_or_flags_7(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_update_header_state_4; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags_16: { + switch (llhttp__internal__c_or_flags_8(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_header_value_connection; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_load_header_state_6: { + switch (llhttp__internal__c_load_header_state(state, p, endp)) { + case 5: + goto s_n_llhttp__internal__n_invoke_or_flags_13; + case 6: + goto s_n_llhttp__internal__n_invoke_or_flags_14; + case 7: + goto s_n_llhttp__internal__n_invoke_or_flags_15; + case 8: + goto s_n_llhttp__internal__n_invoke_or_flags_16; + default: + goto s_n_llhttp__internal__n_header_value_connection; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_header_state_5: { + switch (llhttp__internal__c_update_header_state_1(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_header_value_connection_token; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_header_state_3: { + switch (llhttp__internal__c_update_header_state_3(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_header_value_connection_ws; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_header_state_6: { + switch (llhttp__internal__c_update_header_state_6(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_header_value_connection_ws; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_header_state_7: { + switch (llhttp__internal__c_update_header_state_7(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_header_value_connection_ws; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_header_value_6: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_header_value(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_error_57; + return s_error; + } + goto s_n_llhttp__internal__n_error_57; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_mul_add_content_length_1: { + switch (llhttp__internal__c_mul_add_content_length_1(state, p, endp, match)) { + case 1: + goto s_n_llhttp__internal__n_span_end_llhttp__on_header_value_6; + default: + goto s_n_llhttp__internal__n_header_value_content_length; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags_17: { + switch (llhttp__internal__c_or_flags_17(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_header_value_otherwise; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_header_value_7: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_header_value(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_error_58; + return s_error; + } + goto s_n_llhttp__internal__n_error_58; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_56: { + state->error = 0x4; + state->reason = "Duplicate Content-Length"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_flags_2: { + switch (llhttp__internal__c_test_flags_2(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_header_value_content_length; + default: + goto s_n_llhttp__internal__n_error_56; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_header_value_9: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_header_value(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) (p + 1); + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_error_60; + return s_error; + } + p++; + goto s_n_llhttp__internal__n_error_60; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_header_state_8: { + switch (llhttp__internal__c_update_header_state_8(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_header_value_otherwise; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_header_value_8: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_header_value(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) (p + 1); + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_error_59; + return s_error; + } + p++; + goto s_n_llhttp__internal__n_error_59; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_21: { + switch (llhttp__internal__c_test_lenient_flags_21(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_span_end_llhttp__on_header_value_8; + default: + goto s_n_llhttp__internal__n_header_value_te_chunked; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_load_type_1: { + switch (llhttp__internal__c_load_type(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_21; + default: + goto s_n_llhttp__internal__n_header_value_te_chunked; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_header_state_9: { + switch (llhttp__internal__c_update_header_state_1(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_header_value; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_and_flags: { + switch (llhttp__internal__c_and_flags(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_header_value_te_chunked; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags_19: { + switch (llhttp__internal__c_or_flags_18(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_and_flags; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_22: { + switch (llhttp__internal__c_test_lenient_flags_21(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_span_end_llhttp__on_header_value_9; + default: + goto s_n_llhttp__internal__n_invoke_or_flags_19; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_load_type_2: { + switch (llhttp__internal__c_load_type(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_22; + default: + goto s_n_llhttp__internal__n_invoke_or_flags_19; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags_18: { + switch (llhttp__internal__c_or_flags_18(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_and_flags; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_flags_3: { + switch (llhttp__internal__c_test_flags_3(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_invoke_load_type_2; + default: + goto s_n_llhttp__internal__n_invoke_or_flags_18; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_or_flags_20: { + switch (llhttp__internal__c_or_flags_20(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_update_header_state_9; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_load_header_state_3: { + switch (llhttp__internal__c_load_header_state(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_header_value_connection; + case 2: + goto s_n_llhttp__internal__n_invoke_test_flags_2; + case 3: + goto s_n_llhttp__internal__n_invoke_test_flags_3; + case 4: + goto s_n_llhttp__internal__n_invoke_or_flags_20; + default: + goto s_n_llhttp__internal__n_header_value; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_23: { + switch (llhttp__internal__c_test_lenient_flags_23(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_error_61; + default: + goto s_n_llhttp__internal__n_header_value_discard_ws; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_flags_4: { + switch (llhttp__internal__c_test_flags_4(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_23; + default: + goto s_n_llhttp__internal__n_header_value_discard_ws; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_62: { + state->error = 0xf; + state->reason = "Transfer-Encoding can't be present with Content-Length"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_24: { + switch (llhttp__internal__c_test_lenient_flags_23(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_error_62; + default: + goto s_n_llhttp__internal__n_header_value_discard_ws; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_flags_5: { + switch (llhttp__internal__c_test_flags_2(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_24; + default: + goto s_n_llhttp__internal__n_header_value_discard_ws; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_19: { + state->error = 0x15; + state->reason = "on_header_field_complete pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_load_header_state; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_45: { + state->error = 0x1c; + state->reason = "`on_header_field_complete` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_header_field_1: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_header_field(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) (p + 1); + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_header_field_complete; + return s_error; + } + p++; + goto s_n_llhttp__internal__n_invoke_llhttp__on_header_field_complete; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_header_field_2: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_header_field(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) (p + 1); + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_header_field_complete; + return s_error; + } + p++; + goto s_n_llhttp__internal__n_invoke_llhttp__on_header_field_complete; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_63: { + state->error = 0xa; + state->reason = "Invalid header token"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_header_state_10: { + switch (llhttp__internal__c_update_header_state_1(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_header_field_general; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_store_header_state: { + switch (llhttp__internal__c_store_header_state(state, p, endp, match)) { + default: + goto s_n_llhttp__internal__n_header_field_colon; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_header_state_11: { + switch (llhttp__internal__c_update_header_state_1(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_header_field_general; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_4: { + state->error = 0x1e; + state->reason = "Unexpected space after start line"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags: { + switch (llhttp__internal__c_test_lenient_flags(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_header_field_start; + default: + goto s_n_llhttp__internal__n_error_4; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_20: { + state->error = 0x15; + state->reason = "on_url_complete pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_headers_start; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_3: { + state->error = 0x1a; + state->reason = "`on_url_complete` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_llhttp__on_url_complete: { + switch (llhttp__on_url_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_headers_start; + case 21: + goto s_n_llhttp__internal__n_pause_20; + default: + goto s_n_llhttp__internal__n_error_3; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_http_minor: { + switch (llhttp__internal__c_update_http_minor(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_llhttp__on_url_complete; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_http_major: { + switch (llhttp__internal__c_update_http_major(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_invoke_update_http_minor; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_url_3: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_url(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_url_skip_to_http09; + return s_error; + } + goto s_n_llhttp__internal__n_url_skip_to_http09; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_64: { + state->error = 0x7; + state->reason = "Expected CRLF"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_url_4: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_url(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_url_skip_lf_to_http09; + return s_error; + } + goto s_n_llhttp__internal__n_url_skip_lf_to_http09; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_73: { + state->error = 0x17; + state->reason = "Pause on PRI/Upgrade"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_74: { + state->error = 0x9; + state->reason = "Expected HTTP/2 Connection Preface"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_71: { + state->error = 0x2; + state->reason = "Expected CRLF after version"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_27: { + switch (llhttp__internal__c_test_lenient_flags_8(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_headers_start; + default: + goto s_n_llhttp__internal__n_error_71; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_70: { + state->error = 0x9; + state->reason = "Expected CRLF after version"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_26: { + switch (llhttp__internal__c_test_lenient_flags_1(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_req_http_complete_crlf; + default: + goto s_n_llhttp__internal__n_error_70; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_72: { + state->error = 0x9; + state->reason = "Expected CRLF after version"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_21: { + state->error = 0x15; + state->reason = "on_version_complete pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_load_method_1; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_69: { + state->error = 0x21; + state->reason = "`on_version_complete` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_version_1: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_version(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_version_complete; + return s_error; + } + goto s_n_llhttp__internal__n_invoke_llhttp__on_version_complete; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_version: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_version(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_error_68; + return s_error; + } + goto s_n_llhttp__internal__n_error_68; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_load_http_minor: { + switch (llhttp__internal__c_load_http_minor(state, p, endp)) { + case 9: + goto s_n_llhttp__internal__n_span_end_llhttp__on_version_1; + default: + goto s_n_llhttp__internal__n_span_end_llhttp__on_version; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_load_http_minor_1: { + switch (llhttp__internal__c_load_http_minor(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_span_end_llhttp__on_version_1; + case 1: + goto s_n_llhttp__internal__n_span_end_llhttp__on_version_1; + default: + goto s_n_llhttp__internal__n_span_end_llhttp__on_version; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_load_http_minor_2: { + switch (llhttp__internal__c_load_http_minor(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_span_end_llhttp__on_version_1; + default: + goto s_n_llhttp__internal__n_span_end_llhttp__on_version; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_load_http_major: { + switch (llhttp__internal__c_load_http_major(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_invoke_load_http_minor; + case 1: + goto s_n_llhttp__internal__n_invoke_load_http_minor_1; + case 2: + goto s_n_llhttp__internal__n_invoke_load_http_minor_2; + default: + goto s_n_llhttp__internal__n_span_end_llhttp__on_version; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_25: { + switch (llhttp__internal__c_test_lenient_flags_25(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_span_end_llhttp__on_version_1; + default: + goto s_n_llhttp__internal__n_invoke_load_http_major; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_store_http_minor: { + switch (llhttp__internal__c_store_http_minor(state, p, endp, match)) { + default: + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_25; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_version_2: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_version(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_error_75; + return s_error; + } + goto s_n_llhttp__internal__n_error_75; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_version_3: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_version(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_error_76; + return s_error; + } + goto s_n_llhttp__internal__n_error_76; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_store_http_major: { + switch (llhttp__internal__c_store_http_major(state, p, endp, match)) { + default: + goto s_n_llhttp__internal__n_req_http_dot; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_version_4: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_version(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_error_77; + return s_error; + } + goto s_n_llhttp__internal__n_error_77; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_78: { + state->error = 0x8; + state->reason = "Expected HTTP/, RTSP/ or ICE/"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_67: { + state->error = 0x8; + state->reason = "Invalid method for HTTP/x.x request"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_22: { + state->error = 0x15; + state->reason = "on_protocol_complete pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_load_method; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_66: { + state->error = 0x26; + state->reason = "`on_protocol_complete` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_protocol: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_protocol(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_protocol_complete; + return s_error; + } + goto s_n_llhttp__internal__n_invoke_llhttp__on_protocol_complete; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_protocol_3: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_protocol(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_error_83; + return s_error; + } + goto s_n_llhttp__internal__n_error_83; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_80: { + state->error = 0x8; + state->reason = "Expected SOURCE method for ICE/x.x request"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_23: { + state->error = 0x15; + state->reason = "on_protocol_complete pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_load_method_2; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_79: { + state->error = 0x26; + state->reason = "`on_protocol_complete` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_protocol_1: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_protocol(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_protocol_complete_1; + return s_error; + } + goto s_n_llhttp__internal__n_invoke_llhttp__on_protocol_complete_1; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_82: { + state->error = 0x8; + state->reason = "Invalid method for RTSP/x.x request"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_24: { + state->error = 0x15; + state->reason = "on_protocol_complete pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_load_method_3; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_81: { + state->error = 0x26; + state->reason = "`on_protocol_complete` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_protocol_2: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_protocol(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_protocol_complete_2; + return s_error; + } + goto s_n_llhttp__internal__n_invoke_llhttp__on_protocol_complete_2; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_25: { + state->error = 0x15; + state->reason = "on_url_complete pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_req_http_start; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_65: { + state->error = 0x1a; + state->reason = "`on_url_complete` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_llhttp__on_url_complete_1: { + switch (llhttp__on_url_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_req_http_start; + case 21: + goto s_n_llhttp__internal__n_pause_25; + default: + goto s_n_llhttp__internal__n_error_65; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_url_5: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_url(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_url_skip_to_http; + return s_error; + } + goto s_n_llhttp__internal__n_url_skip_to_http; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_url_6: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_url(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_url_skip_to_http09; + return s_error; + } + goto s_n_llhttp__internal__n_url_skip_to_http09; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_url_7: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_url(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_url_skip_lf_to_http09; + return s_error; + } + goto s_n_llhttp__internal__n_url_skip_lf_to_http09; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_url_8: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_url(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_url_skip_to_http; + return s_error; + } + goto s_n_llhttp__internal__n_url_skip_to_http; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_84: { + state->error = 0x7; + state->reason = "Invalid char in url fragment start"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_url_9: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_url(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_url_skip_to_http09; + return s_error; + } + goto s_n_llhttp__internal__n_url_skip_to_http09; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_url_10: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_url(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_url_skip_lf_to_http09; + return s_error; + } + goto s_n_llhttp__internal__n_url_skip_lf_to_http09; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_url_11: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_url(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_url_skip_to_http; + return s_error; + } + goto s_n_llhttp__internal__n_url_skip_to_http; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_85: { + state->error = 0x7; + state->reason = "Invalid char in url query"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_86: { + state->error = 0x7; + state->reason = "Invalid char in url path"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_url: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_url(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_url_skip_to_http09; + return s_error; + } + goto s_n_llhttp__internal__n_url_skip_to_http09; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_url_1: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_url(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_url_skip_lf_to_http09; + return s_error; + } + goto s_n_llhttp__internal__n_url_skip_lf_to_http09; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_url_2: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_url(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_url_skip_to_http; + return s_error; + } + goto s_n_llhttp__internal__n_url_skip_to_http; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_url_12: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_url(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_url_skip_to_http09; + return s_error; + } + goto s_n_llhttp__internal__n_url_skip_to_http09; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_url_13: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_url(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_url_skip_lf_to_http09; + return s_error; + } + goto s_n_llhttp__internal__n_url_skip_lf_to_http09; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_url_14: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_url(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_url_skip_to_http; + return s_error; + } + goto s_n_llhttp__internal__n_url_skip_to_http; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_87: { + state->error = 0x7; + state->reason = "Double @ in url"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_88: { + state->error = 0x7; + state->reason = "Unexpected char in url server"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_89: { + state->error = 0x7; + state->reason = "Unexpected char in url server"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_90: { + state->error = 0x7; + state->reason = "Unexpected char in url schema"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_91: { + state->error = 0x7; + state->reason = "Unexpected char in url schema"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_92: { + state->error = 0x7; + state->reason = "Unexpected start char in url"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_is_equal_method: { + switch (llhttp__internal__c_is_equal_method(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_url_entry_normal; + default: + goto s_n_llhttp__internal__n_url_entry_connect; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_93: { + state->error = 0x6; + state->reason = "Expected space after method"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_29: { + state->error = 0x15; + state->reason = "on_method_complete pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_req_first_space_before_url; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_112: { + state->error = 0x20; + state->reason = "`on_method_complete` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_method_2: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_method(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_method_complete_1; + return s_error; + } + goto s_n_llhttp__internal__n_invoke_llhttp__on_method_complete_1; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_store_method_1: { + switch (llhttp__internal__c_store_method(state, p, endp, match)) { + default: + goto s_n_llhttp__internal__n_span_end_llhttp__on_method_2; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_113: { + state->error = 0x6; + state->reason = "Invalid method encountered"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_105: { + state->error = 0xd; + state->reason = "Invalid status code"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_103: { + state->error = 0xd; + state->reason = "Invalid status code"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_101: { + state->error = 0xd; + state->reason = "Invalid status code"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_27: { + state->error = 0x15; + state->reason = "on_status_complete pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_headers_start; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_97: { + state->error = 0x1b; + state->reason = "`on_status_complete` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_llhttp__on_status_complete: { + switch (llhttp__on_status_complete(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_headers_start; + case 21: + goto s_n_llhttp__internal__n_pause_27; + default: + goto s_n_llhttp__internal__n_error_97; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_96: { + state->error = 0xd; + state->reason = "Invalid response status"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_29: { + switch (llhttp__internal__c_test_lenient_flags_1(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_invoke_llhttp__on_status_complete; + default: + goto s_n_llhttp__internal__n_error_96; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_98: { + state->error = 0x2; + state->reason = "Expected LF after CR"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_30: { + switch (llhttp__internal__c_test_lenient_flags_8(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_invoke_llhttp__on_status_complete; + default: + goto s_n_llhttp__internal__n_error_98; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_99: { + state->error = 0x19; + state->reason = "Missing expected CR after response line"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_status: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_status(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) (p + 1); + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_test_lenient_flags_31; + return s_error; + } + p++; + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_31; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_status_1: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_status(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) (p + 1); + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_res_line_almost_done; + return s_error; + } + p++; + goto s_n_llhttp__internal__n_res_line_almost_done; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_100: { + state->error = 0xd; + state->reason = "Invalid response status"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_mul_add_status_code_2: { + switch (llhttp__internal__c_mul_add_status_code(state, p, endp, match)) { + case 1: + goto s_n_llhttp__internal__n_error_101; + default: + goto s_n_llhttp__internal__n_res_status_code_otherwise; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_102: { + state->error = 0xd; + state->reason = "Invalid status code"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_mul_add_status_code_1: { + switch (llhttp__internal__c_mul_add_status_code(state, p, endp, match)) { + case 1: + goto s_n_llhttp__internal__n_error_103; + default: + goto s_n_llhttp__internal__n_res_status_code_digit_3; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_104: { + state->error = 0xd; + state->reason = "Invalid status code"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_mul_add_status_code: { + switch (llhttp__internal__c_mul_add_status_code(state, p, endp, match)) { + case 1: + goto s_n_llhttp__internal__n_error_105; + default: + goto s_n_llhttp__internal__n_res_status_code_digit_2; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_106: { + state->error = 0xd; + state->reason = "Invalid status code"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_status_code: { + switch (llhttp__internal__c_update_status_code(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_res_status_code_digit_1; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_107: { + state->error = 0x9; + state->reason = "Expected space after version"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_28: { + state->error = 0x15; + state->reason = "on_version_complete pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_res_after_version; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_95: { + state->error = 0x21; + state->reason = "`on_version_complete` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_version_6: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_version(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_version_complete_1; + return s_error; + } + goto s_n_llhttp__internal__n_invoke_llhttp__on_version_complete_1; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_version_5: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_version(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_error_94; + return s_error; + } + goto s_n_llhttp__internal__n_error_94; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_load_http_minor_3: { + switch (llhttp__internal__c_load_http_minor(state, p, endp)) { + case 9: + goto s_n_llhttp__internal__n_span_end_llhttp__on_version_6; + default: + goto s_n_llhttp__internal__n_span_end_llhttp__on_version_5; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_load_http_minor_4: { + switch (llhttp__internal__c_load_http_minor(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_span_end_llhttp__on_version_6; + case 1: + goto s_n_llhttp__internal__n_span_end_llhttp__on_version_6; + default: + goto s_n_llhttp__internal__n_span_end_llhttp__on_version_5; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_load_http_minor_5: { + switch (llhttp__internal__c_load_http_minor(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_span_end_llhttp__on_version_6; + default: + goto s_n_llhttp__internal__n_span_end_llhttp__on_version_5; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_load_http_major_1: { + switch (llhttp__internal__c_load_http_major(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_invoke_load_http_minor_3; + case 1: + goto s_n_llhttp__internal__n_invoke_load_http_minor_4; + case 2: + goto s_n_llhttp__internal__n_invoke_load_http_minor_5; + default: + goto s_n_llhttp__internal__n_span_end_llhttp__on_version_5; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_test_lenient_flags_28: { + switch (llhttp__internal__c_test_lenient_flags_25(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_span_end_llhttp__on_version_6; + default: + goto s_n_llhttp__internal__n_invoke_load_http_major_1; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_store_http_minor_1: { + switch (llhttp__internal__c_store_http_minor(state, p, endp, match)) { + default: + goto s_n_llhttp__internal__n_invoke_test_lenient_flags_28; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_version_7: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_version(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_error_108; + return s_error; + } + goto s_n_llhttp__internal__n_error_108; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_version_8: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_version(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_error_109; + return s_error; + } + goto s_n_llhttp__internal__n_error_109; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_store_http_major_1: { + switch (llhttp__internal__c_store_http_major(state, p, endp, match)) { + default: + goto s_n_llhttp__internal__n_res_http_dot; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_version_9: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_version(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_error_110; + return s_error; + } + goto s_n_llhttp__internal__n_error_110; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_115: { + state->error = 0x8; + state->reason = "Expected HTTP/, RTSP/ or ICE/"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_30: { + state->error = 0x15; + state->reason = "on_protocol_complete pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_res_after_protocol; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_114: { + state->error = 0x26; + state->reason = "`on_protocol_complete` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_protocol_4: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_protocol(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_protocol_complete_3; + return s_error; + } + goto s_n_llhttp__internal__n_invoke_llhttp__on_protocol_complete_3; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_protocol_5: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_protocol(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_error_116; + return s_error; + } + goto s_n_llhttp__internal__n_error_116; + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_26: { + state->error = 0x15; + state->reason = "on_method_complete pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_req_first_space_before_url; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_1: { + state->error = 0x20; + state->reason = "`on_method_complete` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_method: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_method(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_llhttp__on_method_complete; + return s_error; + } + goto s_n_llhttp__internal__n_invoke_llhttp__on_method_complete; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_type: { + switch (llhttp__internal__c_update_type(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_span_end_llhttp__on_method; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_store_method: { + switch (llhttp__internal__c_store_method(state, p, endp, match)) { + default: + goto s_n_llhttp__internal__n_invoke_update_type; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_error_111: { + state->error = 0x8; + state->reason = "Invalid word encountered"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_span_end_llhttp__on_method_1: { + const unsigned char* start; + int err; + + start = state->_span_pos0; + state->_span_pos0 = NULL; + err = llhttp__on_method(state, start, p); + if (err != 0) { + state->error = err; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_update_type_1; + return s_error; + } + goto s_n_llhttp__internal__n_invoke_update_type_1; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_update_type_2: { + switch (llhttp__internal__c_update_type(state, p, endp)) { + default: + goto s_n_llhttp__internal__n_span_start_llhttp__on_method_1; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_31: { + state->error = 0x15; + state->reason = "on_message_begin pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_load_type; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error: { + state->error = 0x10; + state->reason = "`on_message_begin` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_llhttp__on_message_begin: { + switch (llhttp__on_message_begin(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_invoke_load_type; + case 21: + goto s_n_llhttp__internal__n_pause_31; + default: + goto s_n_llhttp__internal__n_error; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_pause_32: { + state->error = 0x15; + state->reason = "on_reset pause"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_n_llhttp__internal__n_invoke_update_finish; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_error_117: { + state->error = 0x1f; + state->reason = "`on_reset` callback error"; + state->error_pos = (const char*) p; + state->_current = (void*) (intptr_t) s_error; + return s_error; + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_llhttp__on_reset: { + switch (llhttp__on_reset(state, p, endp)) { + case 0: + goto s_n_llhttp__internal__n_invoke_update_finish; + case 21: + goto s_n_llhttp__internal__n_pause_32; + default: + goto s_n_llhttp__internal__n_error_117; + } + UNREACHABLE; + } + s_n_llhttp__internal__n_invoke_load_initial_message_completed: { + switch (llhttp__internal__c_load_initial_message_completed(state, p, endp)) { + case 1: + goto s_n_llhttp__internal__n_invoke_llhttp__on_reset; + default: + goto s_n_llhttp__internal__n_invoke_update_finish; + } + UNREACHABLE; + } +} + +int llhttp__internal_execute(llhttp__internal_t* state, const char* p, const char* endp) { + llparse_state_t next; + + /* check lingering errors */ + if (state->error != 0) { + return state->error; + } + + /* restart spans */ + if (state->_span_pos0 != NULL) { + state->_span_pos0 = (void*) p; + } + + next = llhttp__internal__run(state, (const unsigned char*) p, (const unsigned char*) endp); + if (next == s_error) { + return state->error; + } + state->_current = (void*) (intptr_t) next; + + /* execute spans */ + if (state->_span_pos0 != NULL) { + int error; + + error = ((llhttp__internal__span_cb) state->_span_cb0)(state, state->_span_pos0, (const char*) endp); + if (error != 0) { + state->error = error; + state->error_pos = endp; + return error; + } + } + + return 0; +} \ No newline at end of file diff --git a/fgex-lib/llhttp/lib/llhttp.h b/fgex-lib/llhttp/lib/llhttp.h new file mode 100644 index 0000000..da24ef1 --- /dev/null +++ b/fgex-lib/llhttp/lib/llhttp.h @@ -0,0 +1,925 @@ + +#ifndef INCLUDE_LLHTTP_H_ +#define INCLUDE_LLHTTP_H_ + +#define LLHTTP_VERSION_MAJOR 9 +#define LLHTTP_VERSION_MINOR 4 +#define LLHTTP_VERSION_PATCH 3 + +#ifndef INCLUDE_LLHTTP_ITSELF_H_ +#define INCLUDE_LLHTTP_ITSELF_H_ +#ifdef __cplusplus +extern "C" { +#endif + +#include + +typedef struct llhttp__internal_s llhttp__internal_t; +struct llhttp__internal_s { + int32_t _index; + void* _span_pos0; + void* _span_cb0; + int32_t error; + const char* reason; + const char* error_pos; + void* data; + void* _current; + uint64_t content_length; + uint8_t type; + uint8_t method; + uint8_t http_major; + uint8_t http_minor; + uint8_t header_state; + uint16_t lenient_flags; + uint8_t upgrade; + uint8_t finish; + uint16_t flags; + uint16_t status_code; + uint8_t initial_message_completed; + void* settings; +}; + +int llhttp__internal_init(llhttp__internal_t* s); +int llhttp__internal_execute(llhttp__internal_t* s, const char* p, const char* endp); + +#ifdef __cplusplus +} /* extern "C" */ +#endif +#endif /* INCLUDE_LLHTTP_ITSELF_H_ */ + + +#ifndef LLLLHTTP_C_HEADERS_ +#define LLLLHTTP_C_HEADERS_ +#ifdef __cplusplus +extern "C" { +#endif + +enum llhttp_errno { + HPE_OK = 0, + HPE_INTERNAL = 1, + HPE_STRICT = 2, + HPE_LF_EXPECTED = 3, + HPE_UNEXPECTED_CONTENT_LENGTH = 4, + HPE_CLOSED_CONNECTION = 5, + HPE_INVALID_METHOD = 6, + HPE_INVALID_URL = 7, + HPE_INVALID_CONSTANT = 8, + HPE_INVALID_VERSION = 9, + HPE_INVALID_HEADER_TOKEN = 10, + HPE_INVALID_CONTENT_LENGTH = 11, + HPE_INVALID_CHUNK_SIZE = 12, + HPE_INVALID_STATUS = 13, + HPE_INVALID_EOF_STATE = 14, + HPE_INVALID_TRANSFER_ENCODING = 15, + HPE_CB_MESSAGE_BEGIN = 16, + HPE_CB_HEADERS_COMPLETE = 17, + HPE_CB_MESSAGE_COMPLETE = 18, + HPE_CB_CHUNK_HEADER = 19, + HPE_CB_CHUNK_COMPLETE = 20, + HPE_PAUSED = 21, + HPE_PAUSED_UPGRADE = 22, + HPE_PAUSED_H2_UPGRADE = 23, + HPE_USER = 24, + HPE_CR_EXPECTED = 25, + HPE_CB_URL_COMPLETE = 26, + HPE_CB_STATUS_COMPLETE = 27, + HPE_CB_HEADER_FIELD_COMPLETE = 28, + HPE_CB_HEADER_VALUE_COMPLETE = 29, + HPE_UNEXPECTED_SPACE = 30, + HPE_CB_RESET = 31, + HPE_CB_METHOD_COMPLETE = 32, + HPE_CB_VERSION_COMPLETE = 33, + HPE_CB_CHUNK_EXTENSION_NAME_COMPLETE = 34, + HPE_CB_CHUNK_EXTENSION_VALUE_COMPLETE = 35, + HPE_CB_PROTOCOL_COMPLETE = 38 +}; +typedef enum llhttp_errno llhttp_errno_t; + +enum llhttp_flags { + F_CONNECTION_KEEP_ALIVE = 0x1, + F_CONNECTION_CLOSE = 0x2, + F_CONNECTION_UPGRADE = 0x4, + F_CHUNKED = 0x8, + F_UPGRADE = 0x10, + F_CONTENT_LENGTH = 0x20, + F_SKIPBODY = 0x40, + F_TRAILING = 0x80, + F_TRANSFER_ENCODING = 0x200 +}; +typedef enum llhttp_flags llhttp_flags_t; + +enum llhttp_lenient_flags { + LENIENT_HEADERS = 0x1, + LENIENT_CHUNKED_LENGTH = 0x2, + LENIENT_KEEP_ALIVE = 0x4, + LENIENT_TRANSFER_ENCODING = 0x8, + LENIENT_VERSION = 0x10, + LENIENT_DATA_AFTER_CLOSE = 0x20, + LENIENT_OPTIONAL_LF_AFTER_CR = 0x40, + LENIENT_OPTIONAL_CRLF_AFTER_CHUNK = 0x80, + LENIENT_OPTIONAL_CR_BEFORE_LF = 0x100, + LENIENT_SPACES_AFTER_CHUNK_SIZE = 0x200, + LENIENT_HEADER_VALUE_RELAXED = 0x400 +}; +typedef enum llhttp_lenient_flags llhttp_lenient_flags_t; + +enum llhttp_type { + HTTP_BOTH = 0, + HTTP_REQUEST = 1, + HTTP_RESPONSE = 2 +}; +typedef enum llhttp_type llhttp_type_t; + +enum llhttp_finish { + HTTP_FINISH_SAFE = 0, + HTTP_FINISH_SAFE_WITH_CB = 1, + HTTP_FINISH_UNSAFE = 2 +}; +typedef enum llhttp_finish llhttp_finish_t; + +enum llhttp_method { + HTTP_DELETE = 0, + HTTP_GET = 1, + HTTP_HEAD = 2, + HTTP_POST = 3, + HTTP_PUT = 4, + HTTP_CONNECT = 5, + HTTP_OPTIONS = 6, + HTTP_TRACE = 7, + HTTP_COPY = 8, + HTTP_LOCK = 9, + HTTP_MKCOL = 10, + HTTP_MOVE = 11, + HTTP_PROPFIND = 12, + HTTP_PROPPATCH = 13, + HTTP_SEARCH = 14, + HTTP_UNLOCK = 15, + HTTP_BIND = 16, + HTTP_REBIND = 17, + HTTP_UNBIND = 18, + HTTP_ACL = 19, + HTTP_REPORT = 20, + HTTP_MKACTIVITY = 21, + HTTP_CHECKOUT = 22, + HTTP_MERGE = 23, + HTTP_MSEARCH = 24, + HTTP_NOTIFY = 25, + HTTP_SUBSCRIBE = 26, + HTTP_UNSUBSCRIBE = 27, + HTTP_PATCH = 28, + HTTP_PURGE = 29, + HTTP_MKCALENDAR = 30, + HTTP_LINK = 31, + HTTP_UNLINK = 32, + HTTP_SOURCE = 33, + HTTP_PRI = 34, + HTTP_DESCRIBE = 35, + HTTP_ANNOUNCE = 36, + HTTP_SETUP = 37, + HTTP_PLAY = 38, + HTTP_PAUSE = 39, + HTTP_TEARDOWN = 40, + HTTP_GET_PARAMETER = 41, + HTTP_SET_PARAMETER = 42, + HTTP_REDIRECT = 43, + HTTP_RECORD = 44, + HTTP_FLUSH = 45, + HTTP_QUERY = 46 +}; +typedef enum llhttp_method llhttp_method_t; + +enum llhttp_status { + HTTP_STATUS_CONTINUE = 100, + HTTP_STATUS_SWITCHING_PROTOCOLS = 101, + HTTP_STATUS_PROCESSING = 102, + HTTP_STATUS_EARLY_HINTS = 103, + HTTP_STATUS_RESPONSE_IS_STALE = 110, + HTTP_STATUS_REVALIDATION_FAILED = 111, + HTTP_STATUS_DISCONNECTED_OPERATION = 112, + HTTP_STATUS_HEURISTIC_EXPIRATION = 113, + HTTP_STATUS_MISCELLANEOUS_WARNING = 199, + HTTP_STATUS_OK = 200, + HTTP_STATUS_CREATED = 201, + HTTP_STATUS_ACCEPTED = 202, + HTTP_STATUS_NON_AUTHORITATIVE_INFORMATION = 203, + HTTP_STATUS_NO_CONTENT = 204, + HTTP_STATUS_RESET_CONTENT = 205, + HTTP_STATUS_PARTIAL_CONTENT = 206, + HTTP_STATUS_MULTI_STATUS = 207, + HTTP_STATUS_ALREADY_REPORTED = 208, + HTTP_STATUS_TRANSFORMATION_APPLIED = 214, + HTTP_STATUS_IM_USED = 226, + HTTP_STATUS_MISCELLANEOUS_PERSISTENT_WARNING = 299, + HTTP_STATUS_MULTIPLE_CHOICES = 300, + HTTP_STATUS_MOVED_PERMANENTLY = 301, + HTTP_STATUS_FOUND = 302, + HTTP_STATUS_SEE_OTHER = 303, + HTTP_STATUS_NOT_MODIFIED = 304, + HTTP_STATUS_USE_PROXY = 305, + HTTP_STATUS_SWITCH_PROXY = 306, + HTTP_STATUS_TEMPORARY_REDIRECT = 307, + HTTP_STATUS_PERMANENT_REDIRECT = 308, + HTTP_STATUS_BAD_REQUEST = 400, + HTTP_STATUS_UNAUTHORIZED = 401, + HTTP_STATUS_PAYMENT_REQUIRED = 402, + HTTP_STATUS_FORBIDDEN = 403, + HTTP_STATUS_NOT_FOUND = 404, + HTTP_STATUS_METHOD_NOT_ALLOWED = 405, + HTTP_STATUS_NOT_ACCEPTABLE = 406, + HTTP_STATUS_PROXY_AUTHENTICATION_REQUIRED = 407, + HTTP_STATUS_REQUEST_TIMEOUT = 408, + HTTP_STATUS_CONFLICT = 409, + HTTP_STATUS_GONE = 410, + HTTP_STATUS_LENGTH_REQUIRED = 411, + HTTP_STATUS_PRECONDITION_FAILED = 412, + HTTP_STATUS_PAYLOAD_TOO_LARGE = 413, + HTTP_STATUS_URI_TOO_LONG = 414, + HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE = 415, + HTTP_STATUS_RANGE_NOT_SATISFIABLE = 416, + HTTP_STATUS_EXPECTATION_FAILED = 417, + HTTP_STATUS_IM_A_TEAPOT = 418, + HTTP_STATUS_PAGE_EXPIRED = 419, + HTTP_STATUS_ENHANCE_YOUR_CALM = 420, + HTTP_STATUS_MISDIRECTED_REQUEST = 421, + HTTP_STATUS_UNPROCESSABLE_ENTITY = 422, + HTTP_STATUS_LOCKED = 423, + HTTP_STATUS_FAILED_DEPENDENCY = 424, + HTTP_STATUS_TOO_EARLY = 425, + HTTP_STATUS_UPGRADE_REQUIRED = 426, + HTTP_STATUS_PRECONDITION_REQUIRED = 428, + HTTP_STATUS_TOO_MANY_REQUESTS = 429, + HTTP_STATUS_REQUEST_HEADER_FIELDS_TOO_LARGE_UNOFFICIAL = 430, + HTTP_STATUS_REQUEST_HEADER_FIELDS_TOO_LARGE = 431, + HTTP_STATUS_LOGIN_TIMEOUT = 440, + HTTP_STATUS_NO_RESPONSE = 444, + HTTP_STATUS_RETRY_WITH = 449, + HTTP_STATUS_BLOCKED_BY_PARENTAL_CONTROL = 450, + HTTP_STATUS_UNAVAILABLE_FOR_LEGAL_REASONS = 451, + HTTP_STATUS_CLIENT_CLOSED_LOAD_BALANCED_REQUEST = 460, + HTTP_STATUS_INVALID_X_FORWARDED_FOR = 463, + HTTP_STATUS_REQUEST_HEADER_TOO_LARGE = 494, + HTTP_STATUS_SSL_CERTIFICATE_ERROR = 495, + HTTP_STATUS_SSL_CERTIFICATE_REQUIRED = 496, + HTTP_STATUS_HTTP_REQUEST_SENT_TO_HTTPS_PORT = 497, + HTTP_STATUS_INVALID_TOKEN = 498, + HTTP_STATUS_CLIENT_CLOSED_REQUEST = 499, + HTTP_STATUS_INTERNAL_SERVER_ERROR = 500, + HTTP_STATUS_NOT_IMPLEMENTED = 501, + HTTP_STATUS_BAD_GATEWAY = 502, + HTTP_STATUS_SERVICE_UNAVAILABLE = 503, + HTTP_STATUS_GATEWAY_TIMEOUT = 504, + HTTP_STATUS_HTTP_VERSION_NOT_SUPPORTED = 505, + HTTP_STATUS_VARIANT_ALSO_NEGOTIATES = 506, + HTTP_STATUS_INSUFFICIENT_STORAGE = 507, + HTTP_STATUS_LOOP_DETECTED = 508, + HTTP_STATUS_BANDWIDTH_LIMIT_EXCEEDED = 509, + HTTP_STATUS_NOT_EXTENDED = 510, + HTTP_STATUS_NETWORK_AUTHENTICATION_REQUIRED = 511, + HTTP_STATUS_WEB_SERVER_UNKNOWN_ERROR = 520, + HTTP_STATUS_WEB_SERVER_IS_DOWN = 521, + HTTP_STATUS_CONNECTION_TIMEOUT = 522, + HTTP_STATUS_ORIGIN_IS_UNREACHABLE = 523, + HTTP_STATUS_TIMEOUT_OCCURED = 524, + HTTP_STATUS_SSL_HANDSHAKE_FAILED = 525, + HTTP_STATUS_INVALID_SSL_CERTIFICATE = 526, + HTTP_STATUS_RAILGUN_ERROR = 527, + HTTP_STATUS_SITE_IS_OVERLOADED = 529, + HTTP_STATUS_SITE_IS_FROZEN = 530, + HTTP_STATUS_IDENTITY_PROVIDER_AUTHENTICATION_ERROR = 561, + HTTP_STATUS_NETWORK_READ_TIMEOUT = 598, + HTTP_STATUS_NETWORK_CONNECT_TIMEOUT = 599 +}; +typedef enum llhttp_status llhttp_status_t; + +#define HTTP_ERRNO_MAP(XX) \ + XX(0, OK, OK) \ + XX(1, INTERNAL, INTERNAL) \ + XX(2, STRICT, STRICT) \ + XX(3, LF_EXPECTED, LF_EXPECTED) \ + XX(4, UNEXPECTED_CONTENT_LENGTH, UNEXPECTED_CONTENT_LENGTH) \ + XX(5, CLOSED_CONNECTION, CLOSED_CONNECTION) \ + XX(6, INVALID_METHOD, INVALID_METHOD) \ + XX(7, INVALID_URL, INVALID_URL) \ + XX(8, INVALID_CONSTANT, INVALID_CONSTANT) \ + XX(9, INVALID_VERSION, INVALID_VERSION) \ + XX(10, INVALID_HEADER_TOKEN, INVALID_HEADER_TOKEN) \ + XX(11, INVALID_CONTENT_LENGTH, INVALID_CONTENT_LENGTH) \ + XX(12, INVALID_CHUNK_SIZE, INVALID_CHUNK_SIZE) \ + XX(13, INVALID_STATUS, INVALID_STATUS) \ + XX(14, INVALID_EOF_STATE, INVALID_EOF_STATE) \ + XX(15, INVALID_TRANSFER_ENCODING, INVALID_TRANSFER_ENCODING) \ + XX(16, CB_MESSAGE_BEGIN, CB_MESSAGE_BEGIN) \ + XX(17, CB_HEADERS_COMPLETE, CB_HEADERS_COMPLETE) \ + XX(18, CB_MESSAGE_COMPLETE, CB_MESSAGE_COMPLETE) \ + XX(19, CB_CHUNK_HEADER, CB_CHUNK_HEADER) \ + XX(20, CB_CHUNK_COMPLETE, CB_CHUNK_COMPLETE) \ + XX(21, PAUSED, PAUSED) \ + XX(22, PAUSED_UPGRADE, PAUSED_UPGRADE) \ + XX(23, PAUSED_H2_UPGRADE, PAUSED_H2_UPGRADE) \ + XX(24, USER, USER) \ + XX(25, CR_EXPECTED, CR_EXPECTED) \ + XX(26, CB_URL_COMPLETE, CB_URL_COMPLETE) \ + XX(27, CB_STATUS_COMPLETE, CB_STATUS_COMPLETE) \ + XX(28, CB_HEADER_FIELD_COMPLETE, CB_HEADER_FIELD_COMPLETE) \ + XX(29, CB_HEADER_VALUE_COMPLETE, CB_HEADER_VALUE_COMPLETE) \ + XX(30, UNEXPECTED_SPACE, UNEXPECTED_SPACE) \ + XX(31, CB_RESET, CB_RESET) \ + XX(32, CB_METHOD_COMPLETE, CB_METHOD_COMPLETE) \ + XX(33, CB_VERSION_COMPLETE, CB_VERSION_COMPLETE) \ + XX(34, CB_CHUNK_EXTENSION_NAME_COMPLETE, CB_CHUNK_EXTENSION_NAME_COMPLETE) \ + XX(35, CB_CHUNK_EXTENSION_VALUE_COMPLETE, CB_CHUNK_EXTENSION_VALUE_COMPLETE) \ + XX(38, CB_PROTOCOL_COMPLETE, CB_PROTOCOL_COMPLETE) \ + + +#define HTTP_METHOD_MAP(XX) \ + XX(0, DELETE, DELETE) \ + XX(1, GET, GET) \ + XX(2, HEAD, HEAD) \ + XX(3, POST, POST) \ + XX(4, PUT, PUT) \ + XX(5, CONNECT, CONNECT) \ + XX(6, OPTIONS, OPTIONS) \ + XX(7, TRACE, TRACE) \ + XX(8, COPY, COPY) \ + XX(9, LOCK, LOCK) \ + XX(10, MKCOL, MKCOL) \ + XX(11, MOVE, MOVE) \ + XX(12, PROPFIND, PROPFIND) \ + XX(13, PROPPATCH, PROPPATCH) \ + XX(14, SEARCH, SEARCH) \ + XX(15, UNLOCK, UNLOCK) \ + XX(16, BIND, BIND) \ + XX(17, REBIND, REBIND) \ + XX(18, UNBIND, UNBIND) \ + XX(19, ACL, ACL) \ + XX(20, REPORT, REPORT) \ + XX(21, MKACTIVITY, MKACTIVITY) \ + XX(22, CHECKOUT, CHECKOUT) \ + XX(23, MERGE, MERGE) \ + XX(24, MSEARCH, M-SEARCH) \ + XX(25, NOTIFY, NOTIFY) \ + XX(26, SUBSCRIBE, SUBSCRIBE) \ + XX(27, UNSUBSCRIBE, UNSUBSCRIBE) \ + XX(28, PATCH, PATCH) \ + XX(29, PURGE, PURGE) \ + XX(30, MKCALENDAR, MKCALENDAR) \ + XX(31, LINK, LINK) \ + XX(32, UNLINK, UNLINK) \ + XX(33, SOURCE, SOURCE) \ + XX(46, QUERY, QUERY) \ + + +#define RTSP_METHOD_MAP(XX) \ + XX(1, GET, GET) \ + XX(3, POST, POST) \ + XX(6, OPTIONS, OPTIONS) \ + XX(35, DESCRIBE, DESCRIBE) \ + XX(36, ANNOUNCE, ANNOUNCE) \ + XX(37, SETUP, SETUP) \ + XX(38, PLAY, PLAY) \ + XX(39, PAUSE, PAUSE) \ + XX(40, TEARDOWN, TEARDOWN) \ + XX(41, GET_PARAMETER, GET_PARAMETER) \ + XX(42, SET_PARAMETER, SET_PARAMETER) \ + XX(43, REDIRECT, REDIRECT) \ + XX(44, RECORD, RECORD) \ + XX(45, FLUSH, FLUSH) \ + + +#define HTTP_ALL_METHOD_MAP(XX) \ + XX(0, DELETE, DELETE) \ + XX(1, GET, GET) \ + XX(2, HEAD, HEAD) \ + XX(3, POST, POST) \ + XX(4, PUT, PUT) \ + XX(5, CONNECT, CONNECT) \ + XX(6, OPTIONS, OPTIONS) \ + XX(7, TRACE, TRACE) \ + XX(8, COPY, COPY) \ + XX(9, LOCK, LOCK) \ + XX(10, MKCOL, MKCOL) \ + XX(11, MOVE, MOVE) \ + XX(12, PROPFIND, PROPFIND) \ + XX(13, PROPPATCH, PROPPATCH) \ + XX(14, SEARCH, SEARCH) \ + XX(15, UNLOCK, UNLOCK) \ + XX(16, BIND, BIND) \ + XX(17, REBIND, REBIND) \ + XX(18, UNBIND, UNBIND) \ + XX(19, ACL, ACL) \ + XX(20, REPORT, REPORT) \ + XX(21, MKACTIVITY, MKACTIVITY) \ + XX(22, CHECKOUT, CHECKOUT) \ + XX(23, MERGE, MERGE) \ + XX(24, MSEARCH, M-SEARCH) \ + XX(25, NOTIFY, NOTIFY) \ + XX(26, SUBSCRIBE, SUBSCRIBE) \ + XX(27, UNSUBSCRIBE, UNSUBSCRIBE) \ + XX(28, PATCH, PATCH) \ + XX(29, PURGE, PURGE) \ + XX(30, MKCALENDAR, MKCALENDAR) \ + XX(31, LINK, LINK) \ + XX(32, UNLINK, UNLINK) \ + XX(33, SOURCE, SOURCE) \ + XX(34, PRI, PRI) \ + XX(35, DESCRIBE, DESCRIBE) \ + XX(36, ANNOUNCE, ANNOUNCE) \ + XX(37, SETUP, SETUP) \ + XX(38, PLAY, PLAY) \ + XX(39, PAUSE, PAUSE) \ + XX(40, TEARDOWN, TEARDOWN) \ + XX(41, GET_PARAMETER, GET_PARAMETER) \ + XX(42, SET_PARAMETER, SET_PARAMETER) \ + XX(43, REDIRECT, REDIRECT) \ + XX(44, RECORD, RECORD) \ + XX(45, FLUSH, FLUSH) \ + XX(46, QUERY, QUERY) \ + + +#define HTTP_STATUS_MAP(XX) \ + XX(100, CONTINUE, CONTINUE) \ + XX(101, SWITCHING_PROTOCOLS, SWITCHING_PROTOCOLS) \ + XX(102, PROCESSING, PROCESSING) \ + XX(103, EARLY_HINTS, EARLY_HINTS) \ + XX(110, RESPONSE_IS_STALE, RESPONSE_IS_STALE) \ + XX(111, REVALIDATION_FAILED, REVALIDATION_FAILED) \ + XX(112, DISCONNECTED_OPERATION, DISCONNECTED_OPERATION) \ + XX(113, HEURISTIC_EXPIRATION, HEURISTIC_EXPIRATION) \ + XX(199, MISCELLANEOUS_WARNING, MISCELLANEOUS_WARNING) \ + XX(200, OK, OK) \ + XX(201, CREATED, CREATED) \ + XX(202, ACCEPTED, ACCEPTED) \ + XX(203, NON_AUTHORITATIVE_INFORMATION, NON_AUTHORITATIVE_INFORMATION) \ + XX(204, NO_CONTENT, NO_CONTENT) \ + XX(205, RESET_CONTENT, RESET_CONTENT) \ + XX(206, PARTIAL_CONTENT, PARTIAL_CONTENT) \ + XX(207, MULTI_STATUS, MULTI_STATUS) \ + XX(208, ALREADY_REPORTED, ALREADY_REPORTED) \ + XX(214, TRANSFORMATION_APPLIED, TRANSFORMATION_APPLIED) \ + XX(226, IM_USED, IM_USED) \ + XX(299, MISCELLANEOUS_PERSISTENT_WARNING, MISCELLANEOUS_PERSISTENT_WARNING) \ + XX(300, MULTIPLE_CHOICES, MULTIPLE_CHOICES) \ + XX(301, MOVED_PERMANENTLY, MOVED_PERMANENTLY) \ + XX(302, FOUND, FOUND) \ + XX(303, SEE_OTHER, SEE_OTHER) \ + XX(304, NOT_MODIFIED, NOT_MODIFIED) \ + XX(305, USE_PROXY, USE_PROXY) \ + XX(306, SWITCH_PROXY, SWITCH_PROXY) \ + XX(307, TEMPORARY_REDIRECT, TEMPORARY_REDIRECT) \ + XX(308, PERMANENT_REDIRECT, PERMANENT_REDIRECT) \ + XX(400, BAD_REQUEST, BAD_REQUEST) \ + XX(401, UNAUTHORIZED, UNAUTHORIZED) \ + XX(402, PAYMENT_REQUIRED, PAYMENT_REQUIRED) \ + XX(403, FORBIDDEN, FORBIDDEN) \ + XX(404, NOT_FOUND, NOT_FOUND) \ + XX(405, METHOD_NOT_ALLOWED, METHOD_NOT_ALLOWED) \ + XX(406, NOT_ACCEPTABLE, NOT_ACCEPTABLE) \ + XX(407, PROXY_AUTHENTICATION_REQUIRED, PROXY_AUTHENTICATION_REQUIRED) \ + XX(408, REQUEST_TIMEOUT, REQUEST_TIMEOUT) \ + XX(409, CONFLICT, CONFLICT) \ + XX(410, GONE, GONE) \ + XX(411, LENGTH_REQUIRED, LENGTH_REQUIRED) \ + XX(412, PRECONDITION_FAILED, PRECONDITION_FAILED) \ + XX(413, PAYLOAD_TOO_LARGE, PAYLOAD_TOO_LARGE) \ + XX(414, URI_TOO_LONG, URI_TOO_LONG) \ + XX(415, UNSUPPORTED_MEDIA_TYPE, UNSUPPORTED_MEDIA_TYPE) \ + XX(416, RANGE_NOT_SATISFIABLE, RANGE_NOT_SATISFIABLE) \ + XX(417, EXPECTATION_FAILED, EXPECTATION_FAILED) \ + XX(418, IM_A_TEAPOT, IM_A_TEAPOT) \ + XX(419, PAGE_EXPIRED, PAGE_EXPIRED) \ + XX(420, ENHANCE_YOUR_CALM, ENHANCE_YOUR_CALM) \ + XX(421, MISDIRECTED_REQUEST, MISDIRECTED_REQUEST) \ + XX(422, UNPROCESSABLE_ENTITY, UNPROCESSABLE_ENTITY) \ + XX(423, LOCKED, LOCKED) \ + XX(424, FAILED_DEPENDENCY, FAILED_DEPENDENCY) \ + XX(425, TOO_EARLY, TOO_EARLY) \ + XX(426, UPGRADE_REQUIRED, UPGRADE_REQUIRED) \ + XX(428, PRECONDITION_REQUIRED, PRECONDITION_REQUIRED) \ + XX(429, TOO_MANY_REQUESTS, TOO_MANY_REQUESTS) \ + XX(430, REQUEST_HEADER_FIELDS_TOO_LARGE_UNOFFICIAL, REQUEST_HEADER_FIELDS_TOO_LARGE_UNOFFICIAL) \ + XX(431, REQUEST_HEADER_FIELDS_TOO_LARGE, REQUEST_HEADER_FIELDS_TOO_LARGE) \ + XX(440, LOGIN_TIMEOUT, LOGIN_TIMEOUT) \ + XX(444, NO_RESPONSE, NO_RESPONSE) \ + XX(449, RETRY_WITH, RETRY_WITH) \ + XX(450, BLOCKED_BY_PARENTAL_CONTROL, BLOCKED_BY_PARENTAL_CONTROL) \ + XX(451, UNAVAILABLE_FOR_LEGAL_REASONS, UNAVAILABLE_FOR_LEGAL_REASONS) \ + XX(460, CLIENT_CLOSED_LOAD_BALANCED_REQUEST, CLIENT_CLOSED_LOAD_BALANCED_REQUEST) \ + XX(463, INVALID_X_FORWARDED_FOR, INVALID_X_FORWARDED_FOR) \ + XX(494, REQUEST_HEADER_TOO_LARGE, REQUEST_HEADER_TOO_LARGE) \ + XX(495, SSL_CERTIFICATE_ERROR, SSL_CERTIFICATE_ERROR) \ + XX(496, SSL_CERTIFICATE_REQUIRED, SSL_CERTIFICATE_REQUIRED) \ + XX(497, HTTP_REQUEST_SENT_TO_HTTPS_PORT, HTTP_REQUEST_SENT_TO_HTTPS_PORT) \ + XX(498, INVALID_TOKEN, INVALID_TOKEN) \ + XX(499, CLIENT_CLOSED_REQUEST, CLIENT_CLOSED_REQUEST) \ + XX(500, INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR) \ + XX(501, NOT_IMPLEMENTED, NOT_IMPLEMENTED) \ + XX(502, BAD_GATEWAY, BAD_GATEWAY) \ + XX(503, SERVICE_UNAVAILABLE, SERVICE_UNAVAILABLE) \ + XX(504, GATEWAY_TIMEOUT, GATEWAY_TIMEOUT) \ + XX(505, HTTP_VERSION_NOT_SUPPORTED, HTTP_VERSION_NOT_SUPPORTED) \ + XX(506, VARIANT_ALSO_NEGOTIATES, VARIANT_ALSO_NEGOTIATES) \ + XX(507, INSUFFICIENT_STORAGE, INSUFFICIENT_STORAGE) \ + XX(508, LOOP_DETECTED, LOOP_DETECTED) \ + XX(509, BANDWIDTH_LIMIT_EXCEEDED, BANDWIDTH_LIMIT_EXCEEDED) \ + XX(510, NOT_EXTENDED, NOT_EXTENDED) \ + XX(511, NETWORK_AUTHENTICATION_REQUIRED, NETWORK_AUTHENTICATION_REQUIRED) \ + XX(520, WEB_SERVER_UNKNOWN_ERROR, WEB_SERVER_UNKNOWN_ERROR) \ + XX(521, WEB_SERVER_IS_DOWN, WEB_SERVER_IS_DOWN) \ + XX(522, CONNECTION_TIMEOUT, CONNECTION_TIMEOUT) \ + XX(523, ORIGIN_IS_UNREACHABLE, ORIGIN_IS_UNREACHABLE) \ + XX(524, TIMEOUT_OCCURED, TIMEOUT_OCCURED) \ + XX(525, SSL_HANDSHAKE_FAILED, SSL_HANDSHAKE_FAILED) \ + XX(526, INVALID_SSL_CERTIFICATE, INVALID_SSL_CERTIFICATE) \ + XX(527, RAILGUN_ERROR, RAILGUN_ERROR) \ + XX(529, SITE_IS_OVERLOADED, SITE_IS_OVERLOADED) \ + XX(530, SITE_IS_FROZEN, SITE_IS_FROZEN) \ + XX(561, IDENTITY_PROVIDER_AUTHENTICATION_ERROR, IDENTITY_PROVIDER_AUTHENTICATION_ERROR) \ + XX(598, NETWORK_READ_TIMEOUT, NETWORK_READ_TIMEOUT) \ + XX(599, NETWORK_CONNECT_TIMEOUT, NETWORK_CONNECT_TIMEOUT) \ + + +#ifdef __cplusplus +} /* extern "C" */ +#endif +#endif /* LLLLHTTP_C_HEADERS_ */ + + +#ifndef INCLUDE_LLHTTP_API_H_ +#define INCLUDE_LLHTTP_API_H_ +#ifdef __cplusplus +extern "C" { +#endif +#include + +#if defined(__wasm__) +#define LLHTTP_EXPORT __attribute__((visibility("default"))) +#elif defined(_WIN32) +#define LLHTTP_EXPORT __declspec(dllexport) +#else +#define LLHTTP_EXPORT +#endif + +typedef llhttp__internal_t llhttp_t; +typedef struct llhttp_settings_s llhttp_settings_t; + +typedef int (*llhttp_data_cb)(llhttp_t*, const char *at, size_t length); +typedef int (*llhttp_cb)(llhttp_t*); + +struct llhttp_settings_s { + /* Possible return values 0, -1, `HPE_PAUSED` */ + llhttp_cb on_message_begin; + + /* Possible return values 0, -1, HPE_USER */ + llhttp_data_cb on_protocol; + llhttp_data_cb on_url; + llhttp_data_cb on_status; + llhttp_data_cb on_method; + llhttp_data_cb on_version; + llhttp_data_cb on_header_field; + llhttp_data_cb on_header_value; + llhttp_data_cb on_chunk_extension_name; + llhttp_data_cb on_chunk_extension_value; + + /* Possible return values: + * 0 - Proceed normally + * 1 - Assume that request/response has no body, and proceed to parsing the + * next message + * 2 - Assume absence of body (as above) and make `llhttp_execute()` return + * `HPE_PAUSED_UPGRADE` + * -1 - Error + * `HPE_PAUSED` + */ + llhttp_cb on_headers_complete; + + /* Possible return values 0, -1, HPE_USER */ + llhttp_data_cb on_body; + + /* Possible return values 0, -1, `HPE_PAUSED` */ + llhttp_cb on_message_complete; + llhttp_cb on_protocol_complete; + llhttp_cb on_url_complete; + llhttp_cb on_status_complete; + llhttp_cb on_method_complete; + llhttp_cb on_version_complete; + llhttp_cb on_header_field_complete; + llhttp_cb on_header_value_complete; + llhttp_cb on_chunk_extension_name_complete; + llhttp_cb on_chunk_extension_value_complete; + + /* When on_chunk_header is called, the current chunk length is stored + * in parser->content_length. + * Possible return values 0, -1, `HPE_PAUSED` + */ + llhttp_cb on_chunk_header; + llhttp_cb on_chunk_complete; + llhttp_cb on_reset; +}; + +/* Initialize the parser with specific type and user settings. + * + * NOTE: lifetime of `settings` has to be at least the same as the lifetime of + * the `parser` here. In practice, `settings` has to be either a static + * variable or be allocated with `malloc`, `new`, etc. + */ +LLHTTP_EXPORT +void llhttp_init(llhttp_t* parser, llhttp_type_t type, + const llhttp_settings_t* settings); + +LLHTTP_EXPORT +llhttp_t* llhttp_alloc(llhttp_type_t type); + +LLHTTP_EXPORT +void llhttp_free(llhttp_t* parser); + +LLHTTP_EXPORT +uint8_t llhttp_get_type(llhttp_t* parser); + +LLHTTP_EXPORT +uint8_t llhttp_get_http_major(llhttp_t* parser); + +LLHTTP_EXPORT +uint8_t llhttp_get_http_minor(llhttp_t* parser); + +LLHTTP_EXPORT +uint8_t llhttp_get_method(llhttp_t* parser); + +LLHTTP_EXPORT +int llhttp_get_status_code(llhttp_t* parser); + +LLHTTP_EXPORT +uint8_t llhttp_get_upgrade(llhttp_t* parser); + +/* Reset an already initialized parser back to the start state, preserving the + * existing parser type, callback settings, user data, and lenient flags. + */ +LLHTTP_EXPORT +void llhttp_reset(llhttp_t* parser); + +/* Initialize the settings object */ +LLHTTP_EXPORT +void llhttp_settings_init(llhttp_settings_t* settings); + +/* Parse full or partial request/response, invoking user callbacks along the + * way. + * + * If any of `llhttp_data_cb` returns errno not equal to `HPE_OK` - the parsing + * interrupts, and such errno is returned from `llhttp_execute()`. If + * `HPE_PAUSED` was used as a errno, the execution can be resumed with + * `llhttp_resume()` call. + * + * In a special case of CONNECT/Upgrade request/response `HPE_PAUSED_UPGRADE` + * is returned after fully parsing the request/response. If the user wishes to + * continue parsing, they need to invoke `llhttp_resume_after_upgrade()`. + * + * NOTE: if this function ever returns a non-pause type error, it will continue + * to return the same error upon each successive call up until `llhttp_init()` + * is called. + */ +LLHTTP_EXPORT +llhttp_errno_t llhttp_execute(llhttp_t* parser, const char* data, size_t len); + +/* This method should be called when the other side has no further bytes to + * send (e.g. shutdown of readable side of the TCP connection.) + * + * Requests without `Content-Length` and other messages might require treating + * all incoming bytes as the part of the body, up to the last byte of the + * connection. This method will invoke `on_message_complete()` callback if the + * request was terminated safely. Otherwise a error code would be returned. + */ +LLHTTP_EXPORT +llhttp_errno_t llhttp_finish(llhttp_t* parser); + +/* Returns `1` if the incoming message is parsed until the last byte, and has + * to be completed by calling `llhttp_finish()` on EOF + */ +LLHTTP_EXPORT +int llhttp_message_needs_eof(const llhttp_t* parser); + +/* Returns `1` if there might be any other messages following the last that was + * successfully parsed. + */ +LLHTTP_EXPORT +int llhttp_should_keep_alive(const llhttp_t* parser); + +/* Make further calls of `llhttp_execute()` return `HPE_PAUSED` and set + * appropriate error reason. + * + * Important: do not call this from user callbacks! User callbacks must return + * `HPE_PAUSED` if pausing is required. + */ +LLHTTP_EXPORT +void llhttp_pause(llhttp_t* parser); + +/* Might be called to resume the execution after the pause in user's callback. + * See `llhttp_execute()` above for details. + * + * Call this only if `llhttp_execute()` returns `HPE_PAUSED`. + */ +LLHTTP_EXPORT +void llhttp_resume(llhttp_t* parser); + +/* Might be called to resume the execution after the pause in user's callback. + * See `llhttp_execute()` above for details. + * + * Call this only if `llhttp_execute()` returns `HPE_PAUSED_UPGRADE` + */ +LLHTTP_EXPORT +void llhttp_resume_after_upgrade(llhttp_t* parser); + +/* Returns the latest return error */ +LLHTTP_EXPORT +llhttp_errno_t llhttp_get_errno(const llhttp_t* parser); + +/* Returns the verbal explanation of the latest returned error. + * + * Note: User callback should set error reason when returning the error. See + * `llhttp_set_error_reason()` for details. + */ +LLHTTP_EXPORT +const char* llhttp_get_error_reason(const llhttp_t* parser); + +/* Assign verbal description to the returned error. Must be called in user + * callbacks right before returning the errno. + * + * Note: `HPE_USER` error code might be useful in user callbacks. + */ +LLHTTP_EXPORT +void llhttp_set_error_reason(llhttp_t* parser, const char* reason); + +/* Returns the pointer to the last parsed byte before the returned error. The + * pointer is relative to the `data` argument of `llhttp_execute()`. + * + * Note: this method might be useful for counting the number of parsed bytes. + */ +LLHTTP_EXPORT +const char* llhttp_get_error_pos(const llhttp_t* parser); + +/* Returns textual name of error code */ +LLHTTP_EXPORT +const char* llhttp_errno_name(llhttp_errno_t err); + +/* Returns textual name of HTTP method */ +LLHTTP_EXPORT +const char* llhttp_method_name(llhttp_method_t method); + +/* Returns textual name of HTTP status */ +LLHTTP_EXPORT +const char* llhttp_status_name(llhttp_status_t status); + +/* Enables/disables lenient header value parsing (disabled by default). + * + * Lenient parsing disables header value token checks, extending llhttp's + * protocol support to highly non-compliant clients/server. No + * `HPE_INVALID_HEADER_TOKEN` will be raised for incorrect header values when + * lenient parsing is "on". + * + * **Enabling this flag can pose a security issue since you will be exposed to + * request smuggling attacks. USE WITH CAUTION!** + */ +LLHTTP_EXPORT +void llhttp_set_lenient_headers(llhttp_t* parser, int enabled); + + +/* Enables/disables lenient handling of conflicting `Transfer-Encoding` and + * `Content-Length` headers (disabled by default). + * + * Normally `llhttp` would error when `Transfer-Encoding` is present in + * conjunction with `Content-Length`. This error is important to prevent HTTP + * request smuggling, but may be less desirable for small number of cases + * involving legacy servers. + * + * **Enabling this flag can pose a security issue since you will be exposed to + * request smuggling attacks. USE WITH CAUTION!** + */ +LLHTTP_EXPORT +void llhttp_set_lenient_chunked_length(llhttp_t* parser, int enabled); + + +/* Enables/disables lenient handling of `Connection: close` and HTTP/1.0 + * requests responses. + * + * Normally `llhttp` would error on (in strict mode) or discard (in loose mode) + * the HTTP request/response after the request/response with `Connection: close` + * and `Content-Length`. This is important to prevent cache poisoning attacks, + * but might interact badly with outdated and insecure clients. With this flag + * the extra request/response will be parsed normally. + * + * **Enabling this flag can pose a security issue since you will be exposed to + * poisoning attacks. USE WITH CAUTION!** + */ +LLHTTP_EXPORT +void llhttp_set_lenient_keep_alive(llhttp_t* parser, int enabled); + +/* Enables/disables lenient handling of `Transfer-Encoding` header. + * + * Normally `llhttp` would error when a `Transfer-Encoding` has `chunked` value + * and another value after it (either in a single header or in multiple + * headers whose value are internally joined using `, `). + * This is mandated by the spec to reliably determine request body size and thus + * avoid request smuggling. + * With this flag the extra value will be parsed normally. + * + * **Enabling this flag can pose a security issue since you will be exposed to + * request smuggling attacks. USE WITH CAUTION!** + */ +LLHTTP_EXPORT +void llhttp_set_lenient_transfer_encoding(llhttp_t* parser, int enabled); + +/* Enables/disables lenient handling of HTTP version. + * + * Normally `llhttp` would error when the HTTP version in the request or status line + * is not `0.9`, `1.0`, `1.1` or `2.0`. + * With this flag the invalid value will be parsed normally. + * + * **Enabling this flag can pose a security issue since you will allow unsupported + * HTTP versions. USE WITH CAUTION!** + */ +LLHTTP_EXPORT +void llhttp_set_lenient_version(llhttp_t* parser, int enabled); + +/* Enables/disables lenient handling of additional data received after a message ends + * and keep-alive is disabled. + * + * Normally `llhttp` would error when additional unexpected data is received if the message + * contains the `Connection` header with `close` value. + * With this flag the extra data will discarded without throwing an error. + * + * **Enabling this flag can pose a security issue since you will be exposed to + * poisoning attacks. USE WITH CAUTION!** + */ +LLHTTP_EXPORT +void llhttp_set_lenient_data_after_close(llhttp_t* parser, int enabled); + +/* Enables/disables lenient handling of incomplete CRLF sequences. + * + * Normally `llhttp` would error when a CR is not followed by LF when terminating the + * request line, the status line, the headers or a chunk header. + * With this flag only a CR is required to terminate such sections. + * + * **Enabling this flag can pose a security issue since you will be exposed to + * request smuggling attacks. USE WITH CAUTION!** + */ +LLHTTP_EXPORT +void llhttp_set_lenient_optional_lf_after_cr(llhttp_t* parser, int enabled); + +/* + * Enables/disables lenient handling of line separators. + * + * Normally `llhttp` would error when a LF is not preceded by CR when terminating the + * request line, the status line, the headers, a chunk header or a chunk data. + * With this flag only a LF is required to terminate such sections. + * + * **Enabling this flag can pose a security issue since you will be exposed to + * request smuggling attacks. USE WITH CAUTION!** + */ +LLHTTP_EXPORT +void llhttp_set_lenient_optional_cr_before_lf(llhttp_t* parser, int enabled); + +/* Enables/disables lenient handling of chunks not separated via CRLF. + * + * Normally `llhttp` would error when after a chunk data a CRLF is missing before + * starting a new chunk. + * With this flag the new chunk can start immediately after the previous one. + * + * **Enabling this flag can pose a security issue since you will be exposed to + * request smuggling attacks. USE WITH CAUTION!** + */ +LLHTTP_EXPORT +void llhttp_set_lenient_optional_crlf_after_chunk(llhttp_t* parser, int enabled); + +/* Enables/disables lenient handling of spaces after chunk size. + * + * Normally `llhttp` would error when after a chunk size is followed by one or more + * spaces are present instead of a CRLF or `;`. + * With this flag this check is disabled. + * + * **Enabling this flag can pose a security issue since you will be exposed to + * request smuggling attacks. USE WITH CAUTION!** + */ +LLHTTP_EXPORT +void llhttp_set_lenient_spaces_after_chunk_size(llhttp_t* parser, int enabled); + +/* Enables/disables relaxed handling of unusual characters in header values. + * + * RFC 9110 describes NULL, CR and LF as 'dangerous' and says they MUST be + * rejected, while other control characters are merely 'invalid' and discouraged, + * and are explicitly allowed by other standards (e.g. WHATWG Fetch) and + * in surprisingly common use on the web. + * + * This flag enables these 'invalid but common' characters, aiming to + * maximize compatibility without enabling any potentially dangerous scenarios. + * + * Unlike `llhttp_set_lenient_headers()`, this does NOT enable any other + * potentially unsafe behaviors (like accepting whitespace before colons + * or after the start line). + */ +LLHTTP_EXPORT +void llhttp_set_lenient_header_value_relaxed(llhttp_t* parser, int enabled); + +#ifdef __cplusplus +} /* extern "C" */ +#endif +#endif /* INCLUDE_LLHTTP_API_H_ */ + + +#endif /* INCLUDE_LLHTTP_H_ */ diff --git a/fgex-lib/llhttp/llhttp_module.c b/fgex-lib/llhttp/llhttp_module.c new file mode 100644 index 0000000..698d25c --- /dev/null +++ b/fgex-lib/llhttp/llhttp_module.c @@ -0,0 +1,658 @@ +#define PY_SSIZE_T_CLEAN +#include +#include +#include +#include +#include "lib/llhttp.h" + +#define STRING(x) #x +#define XSTRING(x) STRING(x) +#define LLHTTP_VERSION XSTRING(LLHTTP_VERSION_MAJOR) "." XSTRING(LLHTTP_VERSION_MINOR) "." XSTRING(LLHTTP_VERSION_PATCH) + +/* What this module is called, in one place. It is the name the types and the exception + * classes carry, and the name they are looked back up under at runtime. It used to be + * `pyllhttp` — the archived package this binding was forked from — which is not a + * dependency and is not installed anywhere this is shipped. */ +#define MODULE_NAME "firegex._llhttp" + +typedef struct{ + PyObject_HEAD + llhttp_t llhttp; +} llhttp_obj_t; + +typedef struct{ + llhttp_errno_t code; + const char *name; +} error_info; + +static error_info errors[] = { + #define HTTP_ERRNO_GEN(CODE, NAME, _) {CODE, #NAME}, + HTTP_ERRNO_MAP(HTTP_ERRNO_GEN) + #undef HTTP_ERRNO_GEN +}; + +/* + * `INVALID_METHOD` -> `InvalidMethodError`: the name this module publishes the + * exception for that code under. + * + * Written into the caller's buffer every time, never cached on the table above. That + * table is one array shared by every interpreter in the process, and this module + * declares per-interpreter GIL support — a name stored there is a name two interpreters + * can be writing at the same moment. It used to be `malloc`ed on first use, guarded by + * a NULL check two threads can pass together, and never freed. + */ +static void error_class_name(const char *snake, char *out, size_t out_len) { + size_t written = 0; + bool upper = true; + for (const char *c = snake; *c && written + 1 < out_len; ++c) { + if (isalpha((unsigned char)*c)) + out[written++] = (char)(upper ? toupper((unsigned char)*c) + : tolower((unsigned char)*c)); + else if (isdigit((unsigned char)*c)) + out[written++] = *c; + upper = !isalpha((unsigned char)*c); + } + for (const char *suffix = "Error"; *suffix && written + 1 < out_len; ++suffix) + out[written++] = *suffix; + out[written] = '\0'; +} + +/* + * 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) { + PyObject *result = PyObject_CallMethod(llhttp->data, name, NULL); + if (result) + Py_DECREF(result); + + if (PyErr_Occurred()) + return HPE_USER; + + if (HPE_PAUSED == llhttp_get_errno(llhttp)) { + llhttp_resume(llhttp); + return HPE_PAUSED; + } + + if (HPE_PAUSED_UPGRADE == llhttp_get_errno(llhttp)) { + llhttp_resume_after_upgrade(llhttp); + return HPE_PAUSED_UPGRADE; + } + + return HPE_OK; +} + +static int parser_data_callback(const char *name, llhttp_t *llhttp, const char *data, size_t length) { + PyObject *payload = PyMemoryView_FromMemory((char*)data, length, PyBUF_READ); + if (!payload) + return HPE_USER; + PyObject *result = PyObject_CallMethod(llhttp->data, name, "O", payload); + Py_DECREF(payload); + if (result) + Py_DECREF(result); + + if (PyErr_Occurred()) + return HPE_USER; + + if (HPE_PAUSED == llhttp_get_errno(llhttp)) { + llhttp_resume(llhttp); + return HPE_PAUSED; + } + + if (HPE_PAUSED_UPGRADE == llhttp_get_errno(llhttp)) { + llhttp_resume_after_upgrade(llhttp); + return HPE_PAUSED_UPGRADE; + } + + return HPE_OK; +} + +/* Macros now simply pass the name as a string literal */ +#define PARSER_CALLBACK(type) \ +static int parser_ ## type (llhttp_t *llhttp) { \ + return parser_callback(#type, llhttp); \ +} + +#define PARSER_DATA_CALLBACK(type) \ +static int parser_ ## type (llhttp_t *llhttp, const char *data, size_t length) { \ + return parser_data_callback(#type, llhttp, data, length); \ +} + +PARSER_CALLBACK(on_message_begin) +PARSER_DATA_CALLBACK(on_url) +PARSER_CALLBACK(on_url_complete) +PARSER_DATA_CALLBACK(on_status) +PARSER_CALLBACK(on_status_complete) +PARSER_DATA_CALLBACK(on_header_field) +PARSER_CALLBACK(on_header_field_complete) +PARSER_DATA_CALLBACK(on_header_value) +PARSER_CALLBACK(on_header_value_complete) +PARSER_CALLBACK(on_headers_complete) +PARSER_DATA_CALLBACK(on_body) +PARSER_CALLBACK(on_message_complete) +PARSER_CALLBACK(on_chunk_header) +PARSER_CALLBACK(on_chunk_complete) + +llhttp_settings_t parser_settings = { + .on_message_begin = parser_on_message_begin, + .on_url = parser_on_url, + .on_url_complete = parser_on_url_complete, + .on_status = parser_on_status, + .on_status_complete = parser_on_status_complete, + .on_header_field = parser_on_header_field, + .on_header_field_complete = parser_on_header_field_complete, + .on_header_value = parser_on_header_value, + .on_header_value_complete = parser_on_header_value_complete, + .on_headers_complete = parser_on_headers_complete, + .on_body = parser_on_body, + .on_message_complete = parser_on_message_complete, + .on_chunk_header = parser_on_chunk_header, + .on_chunk_complete = parser_on_chunk_complete, +}; + +static PyObject *request_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { + PyObject *self = type->tp_alloc(type, 0); + if (self) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + llhttp_init(llhttp, HTTP_REQUEST, &parser_settings); + llhttp->data = self; + } + return self; +} + +static PyObject *response_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { + PyObject *self = type->tp_alloc(type, 0); + if (self) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + llhttp_init(llhttp, HTTP_RESPONSE, &parser_settings); + llhttp->data = self; + } + return self; +} + +/* + * Turn an llhttp error code into the exception this module publishes for it. + * + * The classes are looked up in the module itself — `sys.modules` belongs to the + * interpreter asking, so each one finds the classes it built, which is the whole reason + * they are created per module instance rather than once per process. + * + * This used to import `pyllhttp`, a package that is not a dependency and is installed + * nowhere the wheel is: the import failed, its traceback was printed to stderr (the + * service log, on a filter), and the function returned **without setting an exception**, + * which CPython reports to the caller as `SystemError: ... returned NULL without setting + * an exception`. A malformed request — the traffic this exists to inspect — said that + * instead of naming the parse error. So every path out of here raises something. + */ +static void set_related_exception(llhttp_errno_t eno, llhttp_t *llhttp) { + const char *reason = llhttp_get_error_reason(llhttp); + if (!reason) + reason = "HTTP parse error"; + + for (size_t i = 0; i < sizeof(errors)/sizeof(errors[0]); ++i) { + if (errors[i].code != eno) + continue; + + char class_name[128]; + error_class_name(errors[i].name, class_name, sizeof(class_name)); + + PyObject *module = PyImport_ImportModule(MODULE_NAME); + if (module) { + PyObject *exception = PyObject_GetAttrString(module, class_name); + Py_DECREF(module); + if (exception) { + PyErr_SetString(exception, reason); + Py_DECREF(exception); + return; + } + } + /* The class is missing, which should not happen — but the traffic was still + * malformed, and saying so badly beats saying nothing. */ + PyErr_Clear(); + PyErr_Format(PyExc_RuntimeError, "%s: %s", class_name, reason); + return; + } + PyErr_Format(PyExc_RuntimeError, + "unknown HTTP parser error %d: %s", (int)eno, reason); +} + +static PyObject *parser_execute(PyObject *self, PyObject *payload) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + + Py_buffer buffer; + if (PyObject_GetBuffer(payload, &buffer, PyBUF_SIMPLE)){ + return NULL; + } + + if (!PyBuffer_IsContiguous(&buffer, 'C')) { + PyErr_SetString(PyExc_TypeError, "buffer is not contiguous"); + PyBuffer_Release(&buffer); + return NULL; + } + + llhttp_errno_t error = llhttp_execute(llhttp, buffer.buf, buffer.len); + + PyBuffer_Release(&buffer); + + if (PyErr_Occurred()) + return NULL; + + switch (error) { + case HPE_OK: + return Py_BuildValue("(nn)", error, buffer.len); + case HPE_PAUSED: + case HPE_PAUSED_UPGRADE: + case HPE_PAUSED_H2_UPGRADE: + return Py_BuildValue("(nn)", error, llhttp->error_pos - (const char*)buffer.buf); + default: + set_related_exception(error, llhttp); + return NULL; + } +} + +static PyObject *parser_pause(PyObject *self) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + llhttp_pause(llhttp); + Py_RETURN_NONE; +} + +static PyObject *parser_unpause(PyObject *self) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + llhttp_resume(llhttp); + Py_RETURN_NONE; +} + +static PyObject *parser_upgrade(PyObject *self) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + llhttp_resume_after_upgrade(llhttp); + Py_RETURN_NONE; +} + +static PyObject *parser_finish(PyObject *self) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + + llhttp_errno_t error = llhttp_finish(llhttp); + if (HPE_OK == error) + Py_RETURN_NONE; + + set_related_exception(error, llhttp); + return NULL; +} + +static PyObject *parser_reset(PyObject *self) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + llhttp_reset(llhttp); + Py_RETURN_NONE; +} + +static PyObject * parser_dummy_noargs(PyObject *self) { Py_RETURN_NONE; } +static PyObject * parser_dummy_onearg(PyObject *self, PyObject *arg) { Py_RETURN_NONE; } + +static PyMethodDef parser_methods[] = { + { "execute", (PyCFunction)parser_execute, METH_O }, + { "pause", (PyCFunction)parser_pause, METH_NOARGS }, + { "unpause", (PyCFunction)parser_unpause, METH_NOARGS }, + { "upgrade", (PyCFunction)parser_upgrade, METH_NOARGS }, + { "finish", (PyCFunction)parser_finish, METH_NOARGS }, + { "reset", (PyCFunction)parser_reset, METH_NOARGS }, + { "on_message_begin", (PyCFunction)parser_dummy_noargs, METH_NOARGS }, + { "on_url", (PyCFunction)parser_dummy_onearg, METH_O }, + { "on_url_complete", (PyCFunction)parser_dummy_noargs, METH_NOARGS }, + { "on_status", (PyCFunction)parser_dummy_onearg, METH_O }, + { "on_status_complete", (PyCFunction)parser_dummy_noargs, METH_NOARGS }, + { "on_header_field", (PyCFunction)parser_dummy_onearg, METH_O }, + { "on_header_field_complete", (PyCFunction)parser_dummy_noargs, METH_NOARGS }, + { "on_header_value", (PyCFunction)parser_dummy_onearg, METH_O }, + { "on_header_value_complete", (PyCFunction)parser_dummy_noargs, METH_NOARGS }, + { "on_headers_complete", (PyCFunction)parser_dummy_noargs, METH_NOARGS }, + { "on_body", (PyCFunction)parser_dummy_onearg, METH_O }, + { "on_message_complete", (PyCFunction)parser_dummy_noargs, METH_NOARGS }, + { "on_chunk_header", (PyCFunction)parser_dummy_noargs, METH_NOARGS }, + { "on_chunk_complete", (PyCFunction)parser_dummy_noargs, METH_NOARGS }, + { NULL } +}; + +static PyObject *parser_method(PyObject *self, void *closure) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + if (llhttp->type != HTTP_REQUEST) + Py_RETURN_NONE; + if (!llhttp->http_major && !llhttp->http_minor) + Py_RETURN_NONE; + + const char *name = method_name(llhttp->method); + if (!name) + Py_RETURN_NONE; + return PyUnicode_FromString(name); +} + +static PyObject *parser_major(PyObject *self, void *closure) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + if (!llhttp->http_major && !llhttp->http_minor) + Py_RETURN_NONE; + + return PyLong_FromUnsignedLong(llhttp->http_major); +} + +static PyObject *parser_minor(PyObject *self, void *closure) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + if (!llhttp->http_major && !llhttp->http_minor) + Py_RETURN_NONE; + + return PyLong_FromUnsignedLong(llhttp->http_minor); +} + +/* The numeric status of a response. + * + * llhttp has had it all along — it is a field on the parser struct, and + * `llhttp_get_status_code` reads it — but this binding never exposed it, so the only + * thing reachable from Python was the reason phrase the server wrote beside it. A filter + * asking "was this a 500?" could not be answered at all. + * + * `None` for a request, which has no status, and before a response line has been read: + * llhttp leaves the field at zero there, and zero is not a status code. */ +static PyObject *parser_status_code(PyObject *self, void *closure) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + if (llhttp->type != HTTP_RESPONSE) + Py_RETURN_NONE; + if (!llhttp->status_code) + Py_RETURN_NONE; + + return PyLong_FromUnsignedLong(llhttp_get_status_code(llhttp)); +} + +static PyObject *parser_content_length(PyObject *self, void *closure) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + if (!(llhttp->flags & F_CONTENT_LENGTH)) + Py_RETURN_NONE; + + return PyLong_FromUnsignedLong(llhttp->content_length); +} + +static bool get_lenient(const llhttp_t *llhttp, llhttp_lenient_flags_t flag) { + return llhttp->lenient_flags & flag; +} + +static int set_lenient(llhttp_t *llhttp, llhttp_lenient_flags_t flag, bool value) { + if (value) { + llhttp->lenient_flags |= flag; + } else { + llhttp->lenient_flags &= ~flag; + } + return 0; +} + +#define LENIENT_FLAG(name) \ +static PyObject * \ +parser_get_lenient_ ## name(PyObject *self, void *closure) \ + { return PyBool_FromLong(get_lenient(&((llhttp_obj_t*)self)->llhttp, LENIENT_ ## name)); } \ +\ +static int \ +parser_set_lenient_ ## name(PyObject *self, PyObject *value, void *closure) \ + { return set_lenient(&((llhttp_obj_t*)self)->llhttp, LENIENT_ ## name, PyObject_IsTrue(value)); } + +LENIENT_FLAG(HEADERS); +LENIENT_FLAG(CHUNKED_LENGTH); +LENIENT_FLAG(KEEP_ALIVE); +LENIENT_FLAG(TRANSFER_ENCODING); +LENIENT_FLAG(VERSION); + +static PyObject *parser_get_lenient_headers(PyObject *self, void *closure) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + return PyBool_FromLong(llhttp->lenient_flags & LENIENT_HEADERS); +} + +static int parser_set_lenient_headers(PyObject *self, PyObject *value, void *closure) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + llhttp_set_lenient_headers(llhttp, PyObject_IsTrue(value)); + return 0; +} + +static PyObject *parser_get_lenient_chunked_length(PyObject *self, void *closure) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + return PyBool_FromLong(llhttp->lenient_flags & LENIENT_CHUNKED_LENGTH); +} + +static int parser_set_lenient_chunked_length(PyObject *self, PyObject *value, void *closure) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + llhttp_set_lenient_chunked_length(llhttp, PyObject_IsTrue(value)); + return 0; +} + +static PyObject *parser_get_lenient_keep_alive(PyObject *self, void *closure) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + return PyBool_FromLong(llhttp->lenient_flags & LENIENT_KEEP_ALIVE); +} + +static int parser_set_lenient_keep_alive(PyObject *self, PyObject *value, void *closure) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + llhttp_set_lenient_keep_alive(llhttp, PyObject_IsTrue(value)); + return 0; +} + +static PyObject *parser_message_needs_eof(PyObject *self, void *closure) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + return PyBool_FromLong(llhttp_message_needs_eof(llhttp)); +} + +static PyObject *parser_should_keep_alive(PyObject *self, void *closure) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + return PyBool_FromLong(llhttp_should_keep_alive(llhttp)); +} + +static PyObject *parser_is_paused(PyObject *self, void *closure) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + return PyBool_FromLong(HPE_PAUSED == llhttp_get_errno(llhttp)); +} + +static PyObject *parser_is_upgrading(PyObject *self, void *closure) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + switch (llhttp_get_errno(llhttp)) { + case HPE_PAUSED_UPGRADE: + case HPE_PAUSED_H2_UPGRADE: + Py_RETURN_TRUE; + break; + default: + Py_RETURN_FALSE; + break; + } +} + +static PyObject *parser_is_busted(PyObject *self, void *closure) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + switch (llhttp_get_errno(llhttp)) { + case HPE_OK: + case HPE_PAUSED: + case HPE_PAUSED_UPGRADE: + Py_RETURN_FALSE; + default: + Py_RETURN_TRUE; + } +} + +static PyObject *parser_error(PyObject *self, void *closure) { + llhttp_t *llhttp = &((llhttp_obj_t*)self)->llhttp; + if (HPE_OK == llhttp_get_errno(llhttp)) + Py_RETURN_NONE; + return PyUnicode_FromString(llhttp_get_error_reason(llhttp)); +} + +static PyGetSetDef parser_getset[] = { + { "method", parser_method }, + { "status_code", parser_status_code }, + { "major", parser_major }, + { "minor", parser_minor }, + { "content_length", parser_content_length }, + { "lenient_headers", parser_get_lenient_HEADERS, parser_set_lenient_HEADERS }, + { "lenient_chunked_length", parser_get_lenient_CHUNKED_LENGTH, parser_set_lenient_CHUNKED_LENGTH }, + { "lenient_keep_alive", parser_get_lenient_KEEP_ALIVE, parser_set_lenient_KEEP_ALIVE }, + { "lenient_transfer_encoding", parser_get_lenient_TRANSFER_ENCODING, parser_set_lenient_TRANSFER_ENCODING }, + { "lenient_version", parser_get_lenient_VERSION, parser_set_lenient_VERSION }, + { "message_needs_eof", parser_message_needs_eof }, + { "should_keep_alive", parser_should_keep_alive }, + { "is_paused", parser_is_paused }, + { "is_upgrading", parser_is_upgrading }, + { "is_busted", parser_is_busted }, + { "error", parser_error }, + { NULL } +}; + +static void parser_dealloc(PyObject *self) { + llhttp_obj_t *llhttp = (llhttp_obj_t*)self; + Py_TYPE(self)->tp_free(self); +} + +static PyType_Slot request_slots[] = { + {Py_tp_doc, "llhttp request parser"}, + {Py_tp_new, request_new}, + {Py_tp_dealloc, parser_dealloc}, + {Py_tp_methods, parser_methods}, + {Py_tp_getset, parser_getset}, + {0, 0}, +}; + +static PyType_Spec request_spec = { + MODULE_NAME ".Request", + sizeof(llhttp_obj_t), + 0, + Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + request_slots, +}; + +static PyType_Slot response_slots[] = { + {Py_tp_doc, "llhttp response parser"}, + {Py_tp_new, response_new}, + {Py_tp_dealloc, parser_dealloc}, + {Py_tp_methods, parser_methods}, + {Py_tp_getset, parser_getset}, + {0, NULL}, +}; + +static PyType_Spec response_spec = { + MODULE_NAME ".Response", + sizeof(llhttp_obj_t), + 0, Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + response_slots, +}; + +/* One class per error code, per module instance: two subinterpreters must never share + * an exception object, which is what `Py_MOD_PER_INTERPRETER_GIL_SUPPORTED` promises. */ +static int add_error_classes(PyObject *m) { + PyObject *base_error = PyErr_NewException(MODULE_NAME ".Error", NULL, NULL); + if (!base_error) + return -1; + if (PyModule_AddObjectRef(m, "Error", base_error) < 0) { + Py_DECREF(base_error); + return -1; + } + + for (size_t i = 0; i < sizeof(errors)/sizeof(errors[0]); ++i) { + char class_name[128]; + char qualified_name[128 + sizeof(MODULE_NAME) + 1]; + error_class_name(errors[i].name, class_name, sizeof(class_name)); + snprintf(qualified_name, sizeof(qualified_name), MODULE_NAME ".%s", class_name); + + PyObject *exception = PyErr_NewException(qualified_name, base_error, NULL); + if (!exception) { + Py_DECREF(base_error); + return -1; + } + int added = PyModule_AddObjectRef(m, class_name, exception); + Py_DECREF(exception); + if (added < 0) { + Py_DECREF(base_error); + return -1; + } + } + + Py_DECREF(base_error); + return 0; +} + +static int add_type(PyObject *m, PyType_Spec *spec) { + PyObject *type = PyType_FromSpec(spec); + if (!type) + return -1; + /* The spec carries the dotted name; the module wants the last component of it. */ + int added = PyModule_AddObjectRef(m, spec->name + strlen(MODULE_NAME "."), type); + Py_DECREF(type); + return added; +} + +static int init_llhttp_module(PyObject *m) { + if (PyModule_AddStringConstant(m, "version", LLHTTP_VERSION) < 0) + goto fail; + + for (size_t i = 0; i < sizeof(errors)/sizeof(errors[0]); ++i) { + if (PyModule_AddIntConstant(m, errors[i].name, errors[i].code) < 0) + goto fail; + } + + if (add_error_classes(m) < 0) + goto fail; + + if (add_type(m, &request_spec) < 0) + goto fail; + + if (add_type(m, &response_spec) < 0) + goto fail; + + return 0; + +fail: + /* The module belongs to the import machinery, which destroys it when a `Py_mod_exec` + * slot fails; releasing it here as well handed back a reference that was never ours. + * Whatever raised first is the useful message, so nothing is overwritten either. */ + if (!PyErr_Occurred()) + PyErr_SetString(PyExc_RuntimeError, "Failed to initialize the llhttp module"); + return -1; +} + + +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} +}; + +static struct PyModuleDef llhttp_module = { + PyModuleDef_HEAD_INIT, + .m_name = "firegex._llhttp", + .m_doc = "llhttp wrapper", + .m_slots = llhttp_slots, + .m_size = 0, +}; + + +PyMODINIT_FUNC +PyInit__llhttp(void) { + return PyModuleDef_Init(&llhttp_module); +} + + + diff --git a/fgex-lib/pyproject.toml b/fgex-lib/pyproject.toml new file mode 100644 index 0000000..89ccc36 --- /dev/null +++ b/fgex-lib/pyproject.toml @@ -0,0 +1,42 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "firegex" +# Set in setup.py, where the release workflow writes the tag. +dynamic = ["version"] +description = "Firegex client" +readme = "README.md" +authors = [{ name = "Pwnzer0tt1", email = "pwnzer0tt1@poliba.it" }] +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", +] +dependencies = [ + "typer", + # Imported directly by the CLI and both simulators for their output. `typer` happens to + # bring it today; `typer-slim` does not, and nothing here should rest on which one it is. + "rich", + "pydantic>=2", + "typing-extensions>=4.7.1", + "pycryptodome", + "watchfiles", + "websockets", + # 1.2 is the first with `output_buffer_limit`, which is what lets a body be decoded only + # as far as a filter may hold it. See `_unbrotli` in pyfilters/models/http.py. + "brotli>=1.2.0", + # Python 3.14 has `compression.zstd` in the standard library; this fills the gap + # below it. See firegex/pyfilters/internals/zstd_compat.py. + "zstandard; python_version < '3.14'", +] + +[project.urls] +Homepage = "https://github.com/pwnzer0tt1/firegex" + +[project.scripts] +fgex = "firegex.cli:run" + +[tool.setuptools.packages.find] +include = ["firegex*"] diff --git a/fgex-lib/requirements.txt b/fgex-lib/requirements.txt deleted file mode 100644 index c71808a..0000000 --- a/fgex-lib/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -typer -pydantic>=2 -typing-extensions>=4.7.1 -pycryptodome -watchfiles -fgex -websockets -brotli -pyllhttp diff --git a/fgex-lib/setup.py b/fgex-lib/setup.py index cf8b80f..32dbedd 100644 --- a/fgex-lib/setup.py +++ b/fgex-lib/setup.py @@ -1,31 +1,34 @@ import setuptools -with open("README.md", "r", encoding="utf-8") as fh: - long_description = fh.read() - -with open('requirements.txt', 'r', encoding='utf-8') as f: - required = [ele.strip() for ele in f.read().splitlines() if not ele.strip().startswith("#") and ele.strip() != ""] +# The HTTP parser, built from source that ships with this package. +# +# It is llhttp — the parser Node uses — behind a binding that used to live in its own +# package, `pyllhttp`. Three things made vendoring it the only arrangement that works: +# the package was archived; every alternative binding with prebuilt wheels fails to load +# in the subinterpreters `cpproxy` runs filters in (they need +# `Py_MOD_PER_INTERPRETER_GIL_SUPPORTED`, which this declares and `httptools` does not); +# and binding the system `libllhttp` through ctypes would mean `pip install firegex` +# assuming a package the user had installed beforehand. +# +# So the C comes with the source, and the release workflow builds wheels for every +# platform it can. Where no wheel matches, pip compiles this — which needs a C toolchain, +# and is the deliberate trade: compiling is a thing pip knows how to do, assuming a +# system library is not. +LLHTTP = setuptools.Extension( + "firegex._llhttp", + sources=["llhttp/llhttp_module.c", "llhttp/lib/llhttp.c", + "llhttp/lib/http.c", "llhttp/lib/api.c"], + include_dirs=["llhttp/lib"], + language="c", + extra_compile_args=["-O3"], +) +# Everything else about the package is in pyproject.toml. The version stays here because +# the release workflow writes the tag over the placeholder, which is not a valid version +# until it has. VERSION = "{{VERSION_PLACEHOLDER}}" setuptools.setup( - name="firegex", - version= VERSION if "{" not in VERSION else "0.0.0", #uv pip install -U . --no-cache-dir for testing - author="Pwnzer0tt1", - author_email="pwnzer0tt1@poliba.it", - scripts=["fgex"], - py_modules=["firegex"], - install_requires=required, - include_package_data=True, - description="Firegex client", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/pwnzer0tt1/firegex", - packages=setuptools.find_packages(), - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", - "Operating System :: OS Independent", - ], - python_requires='>=3.10', + version=VERSION if "{" not in VERSION else "0.0.0", #uv pip install -U . --no-cache-dir for testing + ext_modules=[LLHTTP], ) diff --git a/fgex-lib/wheel_smoke.py b/fgex-lib/wheel_smoke.py new file mode 100644 index 0000000..898f116 --- /dev/null +++ b/fgex-lib/wheel_smoke.py @@ -0,0 +1,36 @@ +"""What a published wheel has to do, checked where nothing else is installed. + +`cibuildwheel` runs this against each built wheel in a clean environment, which is the +one property that matters here: it is the only place in the whole pipeline that looks +like a user's machine rather than a developer's. The error path of the parser binding +once imported `pyllhttp` — archived, not a dependency, present on both machines this was +written on and on no other — and every check we had passed anyway, because every check +we had parsed *valid* traffic. + +So: a good exchange, a bad one, and the exception that names it. +""" + +import sys + +from firegex import _llhttp + +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, response.status_code + +assert _llhttp.Request.__module__ == "firegex._llhttp", _llhttp.Request.__module__ +assert _llhttp.Error.__module__ == "firegex._llhttp", _llhttp.Error.__module__ + +request = _llhttp.Request() +try: + request.execute(b"NOTAMETHOD / HTTP/1.1\r\n\r\n") +except _llhttp.InvalidMethodError as error: + assert "method" in str(error).lower(), error +else: + raise AssertionError("a malformed request was parsed without complaint") + +# Not a detail: borrowing the exception from somewhere else is exactly what used to +# happen, and it only ever worked where that somewhere else happened to be installed. +assert "pyllhttp" not in sys.modules, "the parser reached for pyllhttp" + +print(f"llhttp {_llhttp.version} ok on {sys.implementation.name} {sys.version.split()[0]}") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1bc3a66..ce3981d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,13 +7,10 @@ import { Outlet, Route, Routes } from 'react-router'; import MainLayout from './components/MainLayout'; import { PasswordSend, ServerStatusResponse } from './js/models'; import { errorNotify, getstatus, HomeRedirector, IS_DEV, login, setpassword, socketio } from './js/utils'; -import NFRegex from './pages/NFRegex'; -import ServiceDetailsNFRegex from './pages/NFRegex/ServiceDetails'; -import PortHijack from './pages/PortHijack'; +import Services from './pages/Services'; +import ServiceDetails from './pages/Services/ServiceDetails'; import { Firewall } from './pages/Firewall'; import { useQueryClient } from '@tanstack/react-query'; -import NFProxy from './pages/NFProxy'; -import ServiceDetailsNFProxy from './pages/NFProxy/ServiceDetails'; import { useAuthStore, useSystemStore } from './js/store'; const AuthShell = ({ children }: { children: React.ReactNode }) => ( @@ -186,21 +183,15 @@ function App() { } } -import TLSDecrypt from './pages/TLSDecrypt'; const PageRouting = ({ getStatus }: { getStatus: () => void }) => { return }> - } /> - } > - } /> - - } > - } /> + } > + } /> } /> - } /> } /> diff --git a/frontend/src/components/AddNewRegex.tsx b/frontend/src/components/AddNewRegex.tsx deleted file mode 100644 index cbff378..0000000 --- a/frontend/src/components/AddNewRegex.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { Button, Group, Space, TextInput, Notification, Switch, Modal, Card, Text, Title, SegmentedControl, Box } from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { useState } from 'react'; -import { RegexAddForm } from '../js/models'; -import { b64decode, b64encode, okNotify } from '../js/utils'; -import { ImCross } from "react-icons/im" -import { nfregex } from './NFRegex/utils'; - -type RegexAddInfo = { - regex:string, - mode:string, - is_case_insensitive:boolean, - deactive:boolean -} - -function AddNewRegex({ opened, onClose, service }:{ opened:boolean, onClose:()=>void, service:string }) { - - const form = useForm({ - initialValues: { - regex:"", - mode:"C", - is_case_insensitive:false, - deactive:false - }, - validate:{ - regex: (value) => value !== "" ? null : "Regex is required", - mode: (value) => ['C', 'S', 'B'].includes(value) ? null : "Invalid mode", - } - }) - - const close = () =>{ - onClose() - form.reset() - setError(null) - } - - const [submitLoading, setSubmitLoading] = useState(false) - const [error, setError] = useState(null) - - const submitRequest = (values:RegexAddInfo) => { - setSubmitLoading(true) - - const request:RegexAddForm = { - is_case_sensitive: !values.is_case_insensitive, - service_id: service, - mode: values.mode?values.mode:"B", - regex: b64encode(values.regex), - active: !values.deactive - } - setSubmitLoading(false) - nfregex.regexesadd(request).then( res => { - if (!res){ - setSubmitLoading(false) - close(); - okNotify(`Regex ${b64decode(request.regex)} has been added`, `Successfully added ${request.is_case_sensitive?"case sensitive":"case insensitive"} regex to ${request.service_id} service`) - }else if (res.toLowerCase() === "invalid regex"){ - setSubmitLoading(false) - form.setFieldError("regex", "Invalid Regex") - }else{ - setSubmitLoading(false) - setError("Error: [ "+res+" ]") - } - }).catch( err => { - setSubmitLoading(false) - setError("Request Failed! [ "+err+" ]") - }) - - } - - - return -
- - - - - - Packet Direction - Choose which traffic this regex should filter - - form.setFieldValue('mode', val)} - data={[ - { label: 'Client → Server', value: 'C' }, - { label: 'Bidirectional', value: 'B' }, - { label: 'Server → Client', value: 'S' }, - ]} - color="cyan" - size="sm" - /> - - - - - - Case Insensitive - - - - Ignore letter casing when matching this regex against packets. - - - - Start Deactivated - - - - Create the filter but keep it disabled initially. - - - - - - - - - {error?<> - - } color="red" onClose={()=>{setError(null)}}> - Error: {error} - :null} - - -
-} - -export default AddNewRegex; diff --git a/frontend/src/components/DocsButton.tsx b/frontend/src/components/DocsButton.tsx index 021f2d5..5ae6268 100644 --- a/frontend/src/components/DocsButton.tsx +++ b/frontend/src/components/DocsButton.tsx @@ -1,19 +1,13 @@ import { ActionIcon, ActionIconProps, Box, Container, Modal, ScrollArea, ScrollAreaAutosize, Title, Tooltip } from "@mantine/core"; import { useState } from "react"; import { FaBookBookmark } from "react-icons/fa6"; -import { NFRegexDocs } from "./NFRegex/NFRegexDocs"; -import { NFProxyDocs } from "./NFProxy/NFProxyDocs"; -import { PortHijackDocs } from "./PortHijack/PortHijackDocs"; +import { ServicesDocs } from "./Services/ServicesDocs"; import { FirewallDocs } from "./Firewall/FirewallDocs"; -import { TLSDocs } from "./TLSDocs"; import { EnumToPrimitiveUnion } from "../js/utils"; export enum DocType{ - NFREGEX = "nfregex", - NFPROXY = "nfproxy", - PORTHIJACK = "porthijack", + SERVICES = "services", FIREWALL = "firewall", - TLS = "tls", } @@ -29,16 +23,10 @@ export const DocsButton = ({ doc, ...props }: { doc: EnumToPrimitiveUnion { - doc == DocType.NFREGEX ? - : - doc == DocType.NFPROXY ? - : - doc == DocType.PORTHIJACK ? - : + doc == DocType.SERVICES ? + : doc == DocType.FIREWALL ? : - doc == DocType.TLS ? - : Docs not found } 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/Header/AuthModeModal.tsx b/frontend/src/components/Header/AuthModeModal.tsx new file mode 100644 index 0000000..0d9cc8a --- /dev/null +++ b/frontend/src/components/Header/AuthModeModal.tsx @@ -0,0 +1,98 @@ +import { Alert, Button, Code, Group, List, Modal, Notification, Space, Text } from "@mantine/core"; +import { useState } from "react" +import { ImCross } from "react-icons/im"; +import { MdWarning } from "react-icons/md"; +import { setAuthMode } from "../../js/utils"; + +/** + * Hand access control to whatever sits in front of firegex, and take it back. + * + * The two directions are not symmetrical, and the modal says which one you are in. + * Turning authentication on again takes a session from before it was turned off — this + * browser keeps its token, so the operator who did it can undo it — or the host. A + * browser arriving afterwards cannot, which is what stops a passer-by from setting their + * own password and keeping the operator out for good. + * + * The page is reloaded afterwards rather than patched: the sockets were authorised under + * the rule that just changed and have been dropped, and coming back through the front + * door is how the interface finds out what the rules are now. + */ +function AuthModeModal({ opened, onClose, disable }: { + opened: boolean, onClose: () => void, + /** True to turn authentication off, false to put it back on. */ + disable: boolean, +}) { + const [loadingBtn, setLoadingBtn] = useState(false) + const [error, setError] = useState(null) + + const submitRequest = async () => { + setLoadingBtn(true) + await setAuthMode(disable).then(res => { + if (!res) { + window.location.reload() + } else { + setError(res) + } + }).catch(err => setError(err.toString())) + setLoadingBtn(false) + } + + return + {disable + ? <> + } + title="Everyone who can reach firegex becomes an administrator"> + + No password is asked for and no token is checked. Only do this when + something in front of firegex is doing the checking for you — a + reverse proxy that authenticates, or a network nobody else is on. + + + + + The password stays stored, but nothing asks for it. + + It lasts until firegex restarts. To keep it that way, run{" "} + python3 run.py config --unsafe-disable-auth on the host. + + + This browser keeps its session, so it can turn authentication back on. + A browser that arrives afterwards cannot — that takes the host:{" "} + python3 run.py config --no-unsafe-disable-auth. + + + + : <> + + The stored password is asked for again, and every request needs a + session. Anyone using firegex right now without one will be asked to + log in. + + + + + This works because this browser still holds a session from before + authentication was turned off. + + + It lasts until firegex restarts. To keep it that way, run{" "} + python3 run.py config --no-unsafe-disable-auth on the host. + + + } + + + + + + {error ? <> + } color="red" onClose={() => { setError(null) }}> + Error: {error} + : null} + +} + +export default AuthModeModal; diff --git a/frontend/src/components/Header/index.tsx b/frontend/src/components/Header/index.tsx index 93d4349..9aadc0f 100644 --- a/frontend/src/components/Header/index.tsx +++ b/frontend/src/components/Header/index.tsx @@ -1,85 +1,100 @@ -import React, { useState } from 'react'; -import { ActionIcon, Divider, Image, Menu, Tooltip, Burger, Space, AppShell, Box, Title } from '@mantine/core'; -import { errorNotify, getMainPath, isLargeScreen, logout } from '../../js/utils'; -import { AiFillHome } from "react-icons/ai" -import { useNavigate } from 'react-router'; -import { FaLock } from 'react-icons/fa'; -import { MdOutlineSettingsBackupRestore } from 'react-icons/md'; -import { ImExit } from 'react-icons/im'; -import ResetPasswordModal from './ResetPasswordModal'; -import ResetModal from './ResetModal'; -import { MenuDropDownWithButton } from '../MainLayout'; -import { useNavbarStore, useSystemStore } from '../../js/store'; - - -function HeaderPage(props: any) { - - const navigator = useNavigate() - const { navOpened, toggleNav } = useNavbarStore() - const { version, authDisabled } = useSystemStore() - - const logout_action = () => { - logout().then(r => { - window.location.reload() - }).catch(r => { - errorNotify("Logout failed!",`Error: ${r}`) - }) - } - - const go_to_home = () => { - navigator(`/${getMainPath()}`) - } - - const [changePasswordModal, setChangePasswordModal] = useState(false); - const [resetFiregexModal, setResetFiregexModal] = useState(false); - return - - - - - navigator("/")}/> - - - - [Fi]*regex -

By Pwnzer0tt1 {version && {version}}

-
-
- - - - - {!authDisabled && <> - Firewall Access - } onClick={() => setChangePasswordModal(true)}>Change Password - - } - Actions - } onClick={() => setResetFiregexModal(true)}>Reset Firegex - - - - - - - - {!authDisabled && - - - } - setChangePasswordModal(false)} /> - setResetFiregexModal(false)} /> - -
-} - -export default HeaderPage; +import React, { useState } from 'react'; +import { ActionIcon, Divider, Image, Menu, Tooltip, Burger, Space, AppShell, Box, Title } from '@mantine/core'; +import { errorNotify, getMainPath, isLargeScreen, logout } from '../../js/utils'; +import { AiFillHome } from "react-icons/ai" +import { useNavigate } from 'react-router'; +import { FaLock, FaUnlockAlt } from 'react-icons/fa'; +import { MdOutlineSettingsBackupRestore } from 'react-icons/md'; +import { ImExit } from 'react-icons/im'; +import AuthModeModal from './AuthModeModal'; +import ResetPasswordModal from './ResetPasswordModal'; +import ResetModal from './ResetModal'; +import { MenuDropDownWithButton } from '../MainLayout'; +import { useAuthStore, useNavbarStore, useSystemStore } from '../../js/store'; + + +function HeaderPage(props: any) { + + const navigator = useNavigate() + const { navOpened, toggleNav } = useNavbarStore() + const { version, authDisabled } = useSystemStore() + // A session from before authentication was turned off is what lets this browser put + // it back; without one the host is the only way, and offering the button would lie. + const { access_token } = useAuthStore() + + const logout_action = () => { + logout().then(r => { + window.location.reload() + }).catch(r => { + errorNotify("Logout failed!",`Error: ${r}`) + }) + } + + const go_to_home = () => { + navigator(`/${getMainPath()}`) + } + + const [changePasswordModal, setChangePasswordModal] = useState(false); + const [authModeModal, setAuthModeModal] = useState(false); + const [resetFiregexModal, setResetFiregexModal] = useState(false); + return + + + + + navigator("/")}/> + + + + [Fi]*regex +

By Pwnzer0tt1 {version && {version}}

+
+
+ + + + + {!authDisabled && <> + Firewall Access + } onClick={() => setChangePasswordModal(true)}>Change Password + } + onClick={() => setAuthModeModal(true)}>Turn Authentication Off + + } + {authDisabled && access_token && <> + Firewall Access + } + onClick={() => setAuthModeModal(true)}>Turn Authentication On + + } + Actions + } onClick={() => setResetFiregexModal(true)}>Reset Firegex + + + + + + + + {!authDisabled && + + + } + setChangePasswordModal(false)} /> + setAuthModeModal(false)} + disable={!authDisabled} /> + setResetFiregexModal(false)} /> + +
+} + +export default HeaderPage; diff --git a/frontend/src/components/InterfaceInput.tsx b/frontend/src/components/InterfaceInput.tsx index 0c621d6..bb1d6be 100644 --- a/frontend/src/components/InterfaceInput.tsx +++ b/frontend/src/components/InterfaceInput.tsx @@ -1,123 +1,348 @@ -import { Combobox, TextInput, useCombobox } from "@mantine/core"; -import { useEffect, useState } from "react"; -import { ipInterfacesQuery } from "../js/utils"; +import { Badge, Box, Combobox, Group, Loader, Text, TextInput, useCombobox } from "@mantine/core"; +import { useViewportSize } from "@mantine/hooks"; +import { useMemo, useState } from "react"; +import { TbCheck, TbDeviceDesktopSearch, TbPlugConnected } from "react-icons/tb"; +import { bareAddress, ipInterfacesQuery, isInterfaceName, isIpAddress } from "../js/utils"; -interface ItemProps{ +/** A value the caller wants offered beside what the host actually has (`ANY`, a CIDR). */ +export interface InterfaceOption { value: string - netint: string; + /** The badge it is listed under. */ + netint: string + label?: string } -interface InterfaceInputProps{ - initialCustomInterfaces?:ItemProps[], - includeInterfaceNames?:boolean, - onChange?: (value:string) => void, - value?:string, - defaultValue?:string +interface InterfaceInputProps { + initialCustomInterfaces?: InterfaceOption[] + /** Whether interface *names* are a valid answer here, or only addresses. */ + includeInterfaceNames?: boolean + onChange?: (value: string) => void + value?: string + defaultValue?: string + placeholder?: string + error?: React.ReactNode + disabled?: boolean } -export const InterfaceInput = ({ initialCustomInterfaces, includeInterfaceNames, onChange, value, defaultValue }:InterfaceInputProps) => { +type Kind = { label: string, color: string } - const [customIpInterfaces, setCustomIpInterfaces] = useState(initialCustomInterfaces??[]); +/** One width for the badge everywhere it appears, so the field and the list line up. */ +export const BADGE_WIDTH = 86 + +/** + * The narrowest the note beside a value may be squeezed before it moves to its own line. + * Just wide enough for `on docker0`; the point of having one at all is that without it a + * long value took every pixel and left the note wrapping towards a character per line. + */ +const DETAIL_FLOOR = 80 + +/** + * How wide the list is: what *this* host needs, between a floor and a ceiling. + * + * Three ways of deciding this were wrong before this one. Left to the content it + * collapsed to the field's width, because content allowed to wrap asks for less than it + * needs to stay on one line — 200px of list in a firewall rule. Pinned to the widest row + * any host could produce, every list was that wide whether or not anything in it was + * long, which beside a field half the size reads as a panel that escaped. A floor alone + * put the one long address back over three lines. + * + * So it is measured from the longest value actually in the list: a host whose addresses + * are `10.60.3.1` and `wg0` gets the floor, and one carrying a full-length IPv6 gets the + * room to show it with its note beside it. Monospace is what makes this honest — every + * character is the same width, so the arithmetic is the layout's, not a guess. + */ +const DROPDOWN_FLOOR = 420 +const DROPDOWN_CEILING = 560 +//: The value is `size="sm"` monospace, whose advance is 0.6em of 14px. +const CH = 8.5 +//: What the row costs before the value: the list's padding, the option's, the badge and +// the two gaps, plus the floor the note keeps for itself. +const ROW_CHROME = 36 + BADGE_WIDTH + 16 + DETAIL_FLOOR + +const dropdownWidth = (longestValue: number, viewport: number) => { + const room = Math.max(240, viewport - 24) + const needed = ROW_CHROME + Math.ceil(longestValue * CH) + return Math.min(room, Math.max( + Math.min(DROPDOWN_FLOOR, room), + Math.min(needed, DROPDOWN_CEILING), + )) +} + +/** + * What the operator has typed, named. + * + * The badge is not decoration: an interface name and an address are matched by + * completely different nftables rules, and a typo in one reads as the other — `eth-0` + * is a perfectly good interface name and a perfectly wrong address. Saying which one + * firegex understood, while it is being typed, is the cheapest place to catch that. + */ +export const addressKind = (value: string, interfacesAllowed = true): Kind => { + //: `/32` and `/128` are how the backend spells one address, not a range. + const v = bareAddress(value.trim()) + if (v === "") return { label: "ANY", color: "gray" } + if (isIpAddress(v, { cidr: true })) + return v.includes("/") + ? { label: v.includes(":") ? "IPv6 NET" : "IPv4 NET", color: "violet" } + : { label: v.includes(":") ? "IPv6" : "IPv4", color: "teal" } + // Named red where this field does not take one, rather than blue and then refused on + // submit: the two are told apart by eye here, and "that is an interface" is only + // reassuring somewhere an interface is an answer. + if (isInterfaceName(v)) + return interfacesAllowed + ? { label: "INTERFACE", color: "blue" } + : { label: "INVALID", color: "red" } + return { label: "INVALID", color: "red" } +} + +type Row = { + value: string + /** What is written in bold on the option. */ + title: string + /** The dimmed half: an interface's addresses, or an address's interface. */ + detail: string + badge: string + color: string + group: string + /** Lowercased haystack, so typing an interface name also finds its addresses. */ + haystack: string +} + +/** + * A link-local address is not a place anybody dials. + * + * Every interface carries an `fe80::` it was never configured with — offering them + * doubles the list with addresses that would put a service where no client will look + * for it, which is the same reason `udp_relay_host()` picks them last on the backend. + * They are still accepted if one is typed; they are just not *offered*, and an + * interface that has nothing else says so rather than listing one. + */ +const isLinkLocal = (addr: string) => + addr.toLowerCase().startsWith("fe80:") || addr.startsWith("169.254.") + +const IFACES_GROUP = "Network interfaces" +const ADDRS_GROUP = "Addresses on this host" +const PRESET_GROUP = "Common choices" + +/** + * Where an address — or an interface — is chosen. + * + * Both, because the datapath takes both, and the two are genuinely different things + * rather than two spellings of one: an address is a place, an interface is *wherever + * that cable currently is*, which is what you want for a link whose address is handed + * out by somebody else. So the list says which is which, and an interface carries the + * addresses it holds right now — the one question an operator has when choosing between + * them is "and what is on it". + * + * Free text is still the point: this is a combobox and not a select, because the + * address a service will answer on does not have to exist on this host yet. + */ +export const InterfaceInput = ({ + initialCustomInterfaces, includeInterfaceNames, onChange, value, defaultValue, + placeholder, error, disabled, +}: InterfaceInputProps) => { const interfacesQuery = ipInterfacesQuery() + const { width: viewport } = useViewportSize() - const getInterfaces = () => { - if (interfacesQuery.isLoading || !interfacesQuery.data) return [] - if(includeInterfaceNames){ - const result = interfacesQuery.data.map(item => ({netint:"IP", value:item.addr})) as ItemProps[] - interfacesQuery.data.map(item => item.name).filter((item, index, arr) => arr.indexOf(item) === index).forEach(item => result.push({netint:"INT", value:item})) - return result + const controlled = value !== undefined + const [internal, setInternal] = useState(defaultValue ?? "") + const current = controlled ? value : internal + //: What is being *searched for*, which is only the same as the value while typing. + // Opening the dropdown clears it, so a click always shows the whole host rather + // than the one option whose name is already in the box. + const [query, setQuery] = useState(null) + + const combobox = useCombobox({ onDropdownClose: () => combobox.resetSelectedOption() }) + + const rows = useMemo(() => { + const addrs = interfacesQuery.data ?? [] + //: One entry per interface, carrying every address it holds. + const byIface = new Map() + //: …and the other way round, because one address can be on more than one. + const byAddr = new Map() + for (const { name, addr } of addrs) { + if (!byIface.has(name)) byIface.set(name, []) + if (!byIface.get(name)!.includes(addr)) byIface.get(name)!.push(addr) + if (!byAddr.has(addr)) byAddr.set(addr, []) + if (!byAddr.get(addr)!.includes(name)) byAddr.get(addr)!.push(name) } - return (interfacesQuery.data.map(item => ({netint:item.name, value:item.addr})) as ItemProps[]) + + const presets: Row[] = (initialCustomInterfaces ?? []).map(item => ({ + value: item.value, + title: item.label ?? (item.value === "" ? "Any address" : item.value), + detail: "", + badge: item.netint, + color: "gray", + group: PRESET_GROUP, + haystack: `${item.label ?? ""} ${item.value} ${item.netint}`.toLowerCase(), + })) + + const ifaces: Row[] = includeInterfaceNames + ? [...byIface.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([name, ips]) => ({ + value: name, + title: name, + // A no-break space before each separator, so a line that has to wrap + // breaks *between* two addresses and never leaves a dot stranded at the + // start of the next one. + detail: ips.some(ip => !isLinkLocal(ip)) + ? ips.filter(ip => !isLinkLocal(ip)).join("\u00a0· ") + : (ips.length > 0 ? "link-local only" : "no address assigned"), + badge: "INTERFACE", + color: "blue", + group: IFACES_GROUP, + haystack: `${name} ${ips.join(" ")}`.toLowerCase(), + })) + : [] + + const ips: Row[] = [...byAddr.entries()] + .filter(([addr]) => !isLinkLocal(addr)) + .sort((a, b) => (a[0].includes(":") === b[0].includes(":")) + ? a[0].localeCompare(b[0]) + : (a[0].includes(":") ? 1 : -1)) + .map(([addr, names]) => ({ + value: addr, + title: addr, + detail: `on ${names.join(", ")}`, + badge: addr.includes(":") ? "IPv6" : "IPv4", + color: "teal", + group: ADDRS_GROUP, + haystack: `${addr} ${names.join(" ")}`.toLowerCase(), + })) + + const seen = new Set(presets.map(p => p.value)) + return [...presets, ...[...ifaces, ...ips].filter(r => !seen.has(r.value))] + }, [interfacesQuery.data, includeInterfaceNames, initialCustomInterfaces]) + + const search = (query ?? "").toLowerCase().trim() + const visible = search === "" ? rows : rows.filter(r => r.haystack.includes(search)) + const exact = rows.some(r => r.value === (query ?? current).trim()) + + const commit = (v: string) => { + if (!controlled) setInternal(v) + onChange?.(v) } - const interfaces = getInterfaces() + /** + * The badge is a column; everything else lives in the one beside it. + * + * Not three items in one wrapping row, which is what this was: a value too long to + * sit beside the badge wrapped to the *container's* left edge, under the badge, + * leaving a row three lines tall whose address started nowhere near the addresses + * above it. Inside its own column it wraps under itself instead, and the detail + * follows it — the value and its note stay one block, aligned down the list. + * + * Which of the two is the long one changes with the group — the addresses a link + * carries in one, the address itself in the other — so neither is given a fixed + * share: the value takes what it needs and the detail keeps a floor just wide + * enough for `on docker0`, below which it moves to its own line rather than being + * squeezed towards a character per line. The floor is what the longest address on + * a host leaves over at the list's width, so that row stays on one line too. + * + * The tick goes next to the value rather than at the end of the row, because the + * end of the row is a place that moves. + */ + const option = (row: Row) => + + {row.badge} + + + {row.title} + + {row.value === current + ? + : null} + {row.detail ? {row.detail} : null} + + + - const combobox = useCombobox({ - onDropdownClose: () => { - combobox.resetSelectedOption() - }, - }); + const group = (name: string, icon: React.ReactNode) => { + const inside = visible.filter(r => r.group === name) + if (inside.length === 0) return null + return {icon}{name}}> + {inside.map(option)} + + } - const data = [...customIpInterfaces.filter(v => !interfaces.map(v => v.value).includes(v.value)), ...interfaces] - const [selectedValue, setSelectedValue] = useState(null); - const [search, setSearch] = useState(''); - - const exactOptionMatch: ItemProps|undefined = data.find((item) => item.value === search); + const kind = addressKind(current ?? "", includeInterfaceNames !== false) + const typed = (query ?? "").trim() + const typedKind = addressKind(typed, includeInterfaceNames !== false) - const filteredOptions = data.filter((item) => item.value.toLowerCase().includes(search.toLowerCase().trim())).sort((a, b) => { - if (exactOptionMatch != null) { - if (a.value == exactOptionMatch.value) return -1 - if (b.value == exactOptionMatch.value) return 1 - } - return a.value.localeCompare(b.value) - }); - - const options = filteredOptions.sort( (a, b) => a.value == selectedValue? -1 : b.value == selectedValue? 1: a.value.localeCompare(b.value) ).map((item) => ( - - ( {item.value == selectedValue ? "SELECTED" : item.netint} ) -{">"} {item.value} - - )); - - useEffect(() => { - setSelectedValue(data.find((item) => item.value === defaultValue)?.value??null) - setSearch(defaultValue??'') - }, []) - - return <> - { - if (value === '$create') { - const item = { value: search, netint: "CUSTOM" }; - setCustomIpInterfaces((current) => [...current, item]); - setSelectedValue(search); - onChange?.(search) - } else { - setSelectedValue(value); - setSearch(value); - onChange?.(value) - } - combobox.closeDropdown(); + width={dropdownWidth( + rows.reduce((longest, r) => Math.max(longest, r.title.length), 0), + viewport, + )} + // Portalled, so the list is not clipped by whatever it sits in: inside a firewall + // rule's card it used to be cut off halfway down the options. + position="bottom-start" + disabled={disabled} + onOptionSubmit={(v) => { + commit(v) + setQuery(null) + combobox.closeDropdown() }} > + + {kind.label}} + rightSection={interfacesQuery.isLoading ? : } + rightSectionPointerEvents="none" + onChange={(event) => { + const v = event.currentTarget.value + setQuery(v) + commit(v) + combobox.openDropdown() + combobox.updateSelectedOptionIndex() + }} + // Opening clears the search rather than filtering by whatever is already + // in the box: the value is usually an exact option, and filtering by it + // would show the operator only the thing they were trying to change. + onClick={() => { setQuery(null); combobox.openDropdown() }} + onFocus={() => { setQuery(null); combobox.openDropdown() }} + onBlur={() => { setQuery(null); combobox.closeDropdown() }} + /> + - - } - value={value??search} - placeholder="10.1.1.1" - rightSectionPointerEvents="none" - onChange={(event) => { - combobox.openDropdown(); - combobox.updateSelectedOptionIndex(); - setSearch(event.currentTarget.value) - onChange?.(event.currentTarget.value) - - }} - onClick={(e) => { - combobox.openDropdown() - }} - onFocus={(e) => { - combobox.openDropdown() - }} - onBlur={(e) => { - combobox.closeDropdown(); - setSearch(selectedValue??''); - }} - /> - - - - - {options} - {(exactOptionMatch==null) && search.trim().length > 0 && ( - + Use this: {search} - )} - - + + + {group(PRESET_GROUP, null)} + {group(IFACES_GROUP, )} + {group(ADDRS_GROUP, )} + {!exact && typed.length > 0 ? + + {typedKind.label} + Use {typed} + + : null} + {visible.length === 0 && typed.length === 0 ? + {interfacesQuery.isLoading ? "Reading this host's interfaces…" + : "This host reports no address"} + : null} + + + + {includeInterfaceNames + ? "An interface name follows the link: whatever address it is given, the rules match on the name. An address protects that one address only." + : "One address here, not an interface name — the rules built from it need a single address to rewrite."} + + + - -} \ No newline at end of file +} diff --git a/frontend/src/components/MainLayout.tsx b/frontend/src/components/MainLayout.tsx index dd592d8..f303179 100644 --- a/frontend/src/components/MainLayout.tsx +++ b/frontend/src/components/MainLayout.tsx @@ -3,10 +3,11 @@ 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"; +import ProblemNotifier from './Services/ProblemNotifier'; function MainLayout({ children }:{ children:any }) { @@ -14,7 +15,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]) @@ -25,6 +27,7 @@ function MainLayout({ children }:{ children:any }) { > + {children} diff --git a/frontend/src/components/MarkdownDocs.tsx b/frontend/src/components/MarkdownDocs.tsx index 1e68aab..ac696b4 100644 --- a/frontend/src/components/MarkdownDocs.tsx +++ b/frontend/src/components/MarkdownDocs.tsx @@ -9,7 +9,7 @@ function toHighlightLanguage(lang: string | undefined): string { const GITHUB_BLOB_BASE = "https://github.com/Pwnzer0tt1/firegex/blob/main/"; -// Cross-references between doc files (e.g. "nfregex.md", "../fgex-lib/README.md") +// Cross-references between doc files (e.g. "pyfilter.md", "../fgex-lib/README.md") // are relative to the file's position in the repo, which only resolves correctly // on GitHub - inside the app they'd resolve against the current page's URL instead. // Repoint any relative link at the equivalent file on GitHub, given where the @@ -53,7 +53,7 @@ function buildMarkdownComponents(sourcePath: string): Components { // Renders a markdown documentation source (imported elsewhere via `?raw`) with // Firegex's Mantine styling, so every module's docs page shares one renderer // and one on-disk source of truth instead of duplicating hand-written JSX. -// `sourcePath` is the file's repo-relative path (e.g. "docs/nfproxy.md"), used +// `sourcePath` is the file's repo-relative path (e.g. "docs/pyfilter.md"), used // to resolve relative cross-references to other doc files. export const MarkdownDocs = ({ content, sourcePath }: { content: string; sourcePath: string }) => { return ( diff --git a/frontend/src/components/NFProxy/AddEditService.tsx b/frontend/src/components/NFProxy/AddEditService.tsx deleted file mode 100644 index b163c39..0000000 --- a/frontend/src/components/NFProxy/AddEditService.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import { Button, Group, Space, TextInput, Notification, Modal, Switch, SegmentedControl, Box, Tooltip, ActionIcon, Title, Tabs, Text, UnstyledButton, Card, Divider } from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { useEffect, useState } from 'react'; -import { okNotify, regex_ipv4, regex_ipv6, postapi } from '../../js/utils'; -import TLSServiceFields from '../TLSServiceFields'; -import { ImCross } from "react-icons/im" -import { nfproxy, Service } from './utils'; -import PortAndInterface from '../PortAndInterface'; -import { IoMdInformationCircleOutline } from "react-icons/io"; -import { TbNetwork, TbShieldLock, TbSettings, TbTarget } from "react-icons/tb"; -import { ServiceAddForm as ServiceAddFormOriginal } from './utils'; - -type ServiceAddForm = ServiceAddFormOriginal & { - autostart: boolean, - tls_enabled: boolean, - tls_cert: string, - tls_key: string -} - -function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>void, edit?:Service }) { - - const initialValues = { - name: "", - port:edit?.port??8080, - ip_int:edit?.ip_int??"", - proto:edit?.proto??"tcp", - fail_open: edit?.fail_open??false, - autostart: true, - tls_enabled: edit?.target_type === 'tls', - tls_stream_id: edit?.tls_stream_id ?? undefined, - tls_cert: "", - tls_key: "" - } - - const form = useForm({ - initialValues: initialValues, - validate:{ - name: (value) => edit? null : value !== "" ? null : "Service name is required", - port: (value) => (value>0 && value<65536) ? null : "Invalid port", - proto: (value) => ["tcp","http"].includes(value) ? null : "Invalid protocol", - ip_int: (value) => (value.match(regex_ipv6) || value.match(regex_ipv4)) ? null : "Invalid IP address", - } - }) - - useEffect(() => { - if (opened){ - form.setInitialValues(initialValues) - form.reset() - } - }, [opened]) - - - - const close = () =>{ - onClose() - form.reset() - setError(null) - } - - const [submitLoading, setSubmitLoading] = useState(false) - const [error, setError] = useState(null) - - const submitRequest = async (values: ServiceAddForm) => { - setSubmitLoading(true) - let streamId = values.tls_stream_id; - - if (edit){ - if (values.tls_enabled && values.tls_cert && values.tls_key) { - try { - const res = await postapi('tls/streams', { - name: values.name, - ip_int: values.ip_int, - port: values.port, - cert: values.tls_cert, - key: values.tls_key - }); - if (res.status !== "ok") { - setSubmitLoading(false); - setError("Failed to create TLS Stream!"); - return; - } - streamId = res.stream_id; - } catch (err: any) { - setSubmitLoading(false); - setError("Failed to create TLS Stream: " + err); - return; - } - } - - nfproxy.settings(edit.service_id, { port: values.port, proto: values.proto, ip_int: values.ip_int, fail_open: values.fail_open, target_type: values.tls_enabled ? "tls" : "flow", tls_stream_id: streamId }).then( res => { - if (!res){ - setSubmitLoading(false) - close(); - okNotify(`Service ${values.name} settings updated`, `Successfully updated settings for service ${values.name}`) - } - }).catch( err => { - setSubmitLoading(false) - setError("Request Failed! [ "+err+" ]") - }) - }else{ - if (values.tls_enabled && values.tls_cert && values.tls_key) { - try { - const res = await postapi('tls/streams', { - name: values.name, - ip_int: values.ip_int, - port: values.port, - cert: values.tls_cert, - key: values.tls_key - }); - if (res.status !== "ok") { - setSubmitLoading(false); - setError("Failed to create TLS Stream!"); - return; - } - streamId = res.stream_id; - } catch (err: any) { - setSubmitLoading(false); - setError("Failed to create TLS Stream: " + err); - return; - } - } - - const payload = { - name: values.name, - port: values.port, - proto: values.proto, - ip_int: values.ip_int, - fail_open: values.fail_open, - target_type: values.tls_enabled ? "tls" : "flow", - tls_stream_id: streamId, - } - nfproxy.servicesadd(payload).then( res => { - if (res.status === "ok" && res.service_id){ - setSubmitLoading(false) - close(); - if (values.autostart) nfproxy.servicestart(res.service_id) - okNotify(`Service ${values.name} has been added`, `Successfully added service with port ${values.port}`) - }else{ - setSubmitLoading(false) - setError("Invalid request! [ "+res.status+" ]") - } - }).catch( err => { - setSubmitLoading(false) - setError("Request Failed! [ "+err+" ]") - }) - } - } - - - return -
- - - }> - General - - }> - Advanced - - - - - {!edit ? ( - - ) : null} - - Protocol - - - - - Target Type - - form.setFieldValue('tls_enabled', false)} - style={{ - padding: '1rem', - borderRadius: '12px', - border: form.values.tls_enabled ? '1px solid var(--fourth_color)' : '2px solid var(--accent-color)', - backgroundColor: form.values.tls_enabled ? 'transparent' : 'rgba(0, 229, 255, 0.05)', - transition: 'all 0.2s ease' - }} - > - - -
- Direct Flow - Forward traffic directly without decryption -
-
-
- form.setFieldValue('tls_enabled', true)} - style={{ - padding: '1rem', - borderRadius: '12px', - border: !form.values.tls_enabled ? '1px solid var(--fourth_color)' : '2px solid var(--accent-color)', - backgroundColor: !form.values.tls_enabled ? 'transparent' : 'rgba(0, 229, 255, 0.05)', - transition: 'all 0.2s ease' - }} - > - - -
- TLS Decrypted - Decrypt TLS traffic for deep inspection -
-
-
-
- - {!form.values.tls_enabled ? ( - - ) : ( - - )} -
- - - - {!edit ? ( - - Auto-Start Service - Automatically start this service as soon as it is created. - - - ) : null} - - - Enable fail-open nfqueue - - Firegex uses nfqueue internally to handle packets. Enabling this option will allow packets to pass through the firewall automatically in case the regex engine is overloaded or crashes. - - - - - -
- - {error ? <> - - } color="red" onClose={() => { setError(null) }}> - Error: {error} - - : null} - - - - - - - -
- -} - -export default AddEditService; diff --git a/frontend/src/components/NFProxy/EditFilterModal.tsx b/frontend/src/components/NFProxy/EditFilterModal.tsx deleted file mode 100644 index a643e44..0000000 --- a/frontend/src/components/NFProxy/EditFilterModal.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { Button, FileButton, Group, Modal, Notification, Space, Textarea, ActionIcon, Tooltip } from "@mantine/core"; -import { nfproxy, Service } from "./utils"; -import { useEffect, useState } from "react"; -import { ImCross } from "react-icons/im"; -import { okNotify } from "../../js/utils"; -import { MdUploadFile, MdSave } from "react-icons/md"; -import { TbCode } from "react-icons/tb"; -import Editor from "@monaco-editor/react"; -import { Box } from "@mantine/core"; - -export const EditFilterModal = ({ opened, onClose, service }: { opened: boolean, onClose: () => void, service?: Service }) => { - const close = () => { - onClose() - setError(null) - } - - const [submitLoading, setSubmitLoading] = useState(false) - const [error, setError] = useState(null) - const [code, setCode] = useState(""); - - // Fetch code on open - useEffect(() => { - if (opened && service) { - nfproxy.getpyfilterscode(service.service_id).then(fetchedCode => { - setCode(fetchedCode || ""); - }).catch(err => { - setError("Error fetching code: " + err); - }); - } - }, [opened, service]) - - const handleSave = () => { - if (!service) return; - setSubmitLoading(true); - nfproxy.setpyfilterscode(service.service_id, code).then(res => { - if (!res) { - okNotify(`Service ${service.name} code updated`, `Successfully updated python code for proxy filter.`); - close(); - } else { - setError("Error: " + res); - } - }).catch(err => { - setError("Error: " + err); - }).finally(() => { - setSubmitLoading(false); - }); - } - - const handleFileUpload = (file: File | null) => { - if (file) { - file.text().then(text => { - setCode(text); - okNotify("File loaded", "Python code loaded into editor. Remember to save changes."); - }); - } - } - - return ( - - - Python Script Editor - - } - size="80%" - closeOnClickOutside={false} - centered - styles={{ body: { display: 'flex', flexDirection: 'column', height: '70vh' } }} - > - - setCode(value || "")} - options={{ - minimap: { enabled: false }, - fontSize: 14, - fontFamily: 'JetBrains Mono, monospace', - lineHeight: 1.5, - scrollBeyondLastLine: false, - smoothScrolling: true, - }} - /> - - - - - - - {(props) => ( - - )} - - - - - - - - - {error ? ( - <> - - } color="red" onClose={() => setError(null)}> - {error} - - - ) : null} - - ); -} diff --git a/frontend/src/components/NFProxy/ExceptionWarning.tsx b/frontend/src/components/NFProxy/ExceptionWarning.tsx deleted file mode 100644 index e20d389..0000000 --- a/frontend/src/components/NFProxy/ExceptionWarning.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { IoIosWarning } from "react-icons/io" -import { socketio, WARNING_NFPROXY_TIME_LIMIT } from "../../js/utils" -import { Tooltip } from "@mantine/core" -import { useEffect, useState } from "react" - - -export const ExceptionWarning = ({ service_id }: { service_id: string }) => { - const [lastExceptionTimestamp, setLastExceptionTimestamp] = useState(0) - - useEffect(() => { - socketio.emit("nfproxy-exception-join", { service: service_id }); - socketio.on(`nfproxy-exception-${service_id}`, (data) => { - setLastExceptionTimestamp(data) - }); - return () => { - socketio.emit("nfproxy-exception-leave", { service: service_id }); - } - }, []) - - const [_time, setTime] = useState(new Date()); - - useEffect(() => { - const interval = setInterval(() => { - setTime(new Date()); - }, 1000); - - return () => clearInterval(interval); - }, []); - - const deltaTime = new Date().getTime()-lastExceptionTimestamp - const minutes = Math.floor(deltaTime/(1000*60)) - const seconds = Math.floor(deltaTime/1000)%60 - - const deltaStringTime = `${minutes.toString().length>1?minutes:"0"+minutes}:${seconds.toString().length>1?seconds:"0"+seconds}` - - return <> - {(new Date().getTime()-lastExceptionTimestamp <= WARNING_NFPROXY_TIME_LIMIT)? - - - - :null} - -} \ No newline at end of file diff --git a/frontend/src/components/NFProxy/NFProxyDocs.tsx b/frontend/src/components/NFProxy/NFProxyDocs.tsx deleted file mode 100644 index b9a7023..0000000 --- a/frontend/src/components/NFProxy/NFProxyDocs.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { MarkdownDocs } from "../MarkdownDocs"; -// Single source of truth for this documentation: docs/nfproxy.md is rendered -// here as-is, and is also linked from fgex-lib/README.md (the pip package's -// own README), so the UI and the markdown files can never drift apart. -import docsMarkdown from "../../../../docs/nfproxy.md?raw"; - -export const NFProxyDocs = () => ; diff --git a/frontend/src/components/NFProxy/ServiceRow/RenameForm.tsx b/frontend/src/components/NFProxy/ServiceRow/RenameForm.tsx deleted file mode 100644 index fec56b6..0000000 --- a/frontend/src/components/NFProxy/ServiceRow/RenameForm.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Button, Group, Space, TextInput, Notification, Modal } from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { useEffect, useState } from 'react'; -import { okNotify } from '../../../js/utils'; -import { ImCross } from "react-icons/im" -import { nfproxy, Service } from '../utils'; - -function RenameForm({ opened, onClose, service }:{ opened:boolean, onClose:()=>void, service:Service }) { - - const form = useForm({ - initialValues: { name:service.name }, - validate:{ name: (value) => value !== ""? null : "Service name is required" } - }) - - const close = () =>{ - onClose() - form.reset() - setError(null) - } - - useEffect(()=> form.setFieldValue("name", service.name),[opened]) - - const [submitLoading, setSubmitLoading] = useState(false) - const [error, setError] = useState(null) - - const submitRequest = ({ name }:{ name:string }) => { - setSubmitLoading(true) - nfproxy.servicerename(service.service_id, name).then( res => { - if (!res){ - setSubmitLoading(false) - close(); - okNotify(`Service ${service.name} has been renamed in ${ name }`, `Successfully renamed service on port ${service.port}`) - }else{ - setSubmitLoading(false) - setError("Error: [ "+res+" ]") - } - }).catch( err => { - setSubmitLoading(false) - setError("Request Failed! [ "+err+" ]") - }) - - } - - - return -
- - - - - - {error?<> - - } color="red" onClose={()=>{setError(null)}}> - Error: {error} - - :null} - - -
- -} - -export default RenameForm; diff --git a/frontend/src/components/NFProxy/ServiceRow/index.tsx b/frontend/src/components/NFProxy/ServiceRow/index.tsx deleted file mode 100644 index a471a3a..0000000 --- a/frontend/src/components/NFProxy/ServiceRow/index.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { ActionIcon, Badge, Box, Divider, Menu, Space, Title, Tooltip, Card, Group, Button, Text } from '@mantine/core'; -import { useState } from 'react'; -import { FaPlay, FaStop, FaTrash } from 'react-icons/fa'; -import { nfproxy, Service, serviceQueryKey } from '../utils'; -import YesNoModal from '../../YesNoModal'; -import { errorNotify, isMediumScreen, okNotify, regex_ipv4 } from '../../../js/utils'; -import { BsTrashFill } from 'react-icons/bs'; -import { BiRename } from 'react-icons/bi'; -import RenameForm from './RenameForm'; -import { MenuDropDownWithButton } from '../../MainLayout'; -import { useQueryClient } from '@tanstack/react-query'; -import { TbPlugConnected, TbShieldLock, TbHexagon } from "react-icons/tb"; -import { FaFilter } from "react-icons/fa"; -import { IoSettingsSharp } from 'react-icons/io5'; -import AddEditService from '../AddEditService'; -import { FaPencilAlt } from "react-icons/fa"; -import { ExceptionWarning } from '../ExceptionWarning'; -import { MdChevronRight } from "react-icons/md"; -import TLSAssociationBadge from '../../TLSAssociationBadge'; - - -export default function ServiceRow({ service, onClick }:{ service:Service, onClick?:()=>void }) { - - let status_color = "gray"; - switch(service.status){ - case "stop": status_color = "red"; break; - case "active": status_color = "teal"; break; - } - - const queryClient = useQueryClient() - const [buttonLoading, setButtonLoading] = useState(false) - const [deleteModal, setDeleteModal] = useState(false) - const [renameModal, setRenameModal] = useState(false) - const [editModal, setEditModal] = useState(false) - const isMedium = isMediumScreen() - - const stopService = async () => { - setButtonLoading(true) - - await nfproxy.servicestop(service.service_id).then(res => { - if(!res){ - okNotify(`Service ${service.name} stopped successfully!`,`The service on ${service.port} has been stopped!`) - queryClient.invalidateQueries({ queryKey: serviceQueryKey }) - }else{ - errorNotify(`An error as occurred during the stopping of the service ${service.port}`,`Error: ${res}`) - } - }).catch(err => { - errorNotify(`An error as occurred during the stopping of the service ${service.port}`,`Error: ${err}`) - }) - setButtonLoading(false); - } - - const startService = async () => { - setButtonLoading(true) - await nfproxy.servicestart(service.service_id).then(res => { - if(!res){ - okNotify(`Service ${service.name} started successfully!`,`The service on ${service.port} has been started!`) - queryClient.invalidateQueries({ queryKey: serviceQueryKey }) - }else{ - errorNotify(`An error as occurred during the starting of the service ${service.port}`,`Error: ${res}`) - } - }).catch(err => { - errorNotify(`An error as occurred during the starting of the service ${service.port}`,`Error: ${err}`) - }) - setButtonLoading(false) - } - - const deleteService = () => { - nfproxy.servicedelete(service.service_id).then(res => { - if (!res){ - okNotify("Service delete complete!",`The service ${service.name} has been deleted!`) - queryClient.invalidateQueries({ queryKey: serviceQueryKey }) - }else - errorNotify("An error occurred while deleting a service",`Error: ${res}`) - }).catch(err => { - errorNotify("An error occurred while deleting a service",`Error: ${err}`) - }) - - } - - return <> - - - - - - - - - {service.name} - - {service.status.toUpperCase()} - - {service.target_type === 'tls' && } - - - - PORT: :{service.port} - - - • {service.target_type === 'tls' ? 'decrypted traffic to ' : ''}{service.ip_int} ON {service.proto.toUpperCase()} - - - - - - - e.stopPropagation()}> - - {service.status === "stop" ? ( - - ) : ( - - )} - - - - - - - Actions - } onClick={() => setRenameModal(true)}>Rename Service - } onClick={() => setEditModal(true)}>Settings - - - - - - - - - - - - - - setDeleteModal(false) } - action={deleteService} - opened={deleteModal} - /> - setRenameModal(false)} - opened={renameModal} - service={service} - /> - setEditModal(false)} - edit={service} - /> - -} diff --git a/frontend/src/components/NFProxy/UploadFilterModal.tsx b/frontend/src/components/NFProxy/UploadFilterModal.tsx deleted file mode 100644 index 647f764..0000000 --- a/frontend/src/components/NFProxy/UploadFilterModal.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Button, FileButton, Group, Modal, Notification, Space } from "@mantine/core"; -import { nfproxy, Service } from "./utils"; -import { useEffect, useState } from "react"; -import { ImCross } from "react-icons/im"; -import { okNotify } from "../../js/utils"; - -export const UploadFilterModal = ({ opened, onClose, service }: { opened: boolean, onClose: () => void, service?: Service }) => { - const close = () =>{ - onClose() - setError(null) - } - - const [submitLoading, setSubmitLoading] = useState(false) - const [error, setError] = useState(null) - const [file, setFile] = useState(null); - - useEffect(() => { - if (opened && file){ - file.bytes().then( code => { - setSubmitLoading(true) - nfproxy.setpyfilterscode(service?.service_id??"",code.toString()).then( res => { - if (!res){ - setSubmitLoading(false) - close(); - okNotify(`Service ${name} code updated`, `Successfully updated code for service ${name}`) - } - }).catch( err => { - setSubmitLoading(false) - setError("Error: "+err) - }) - }) - } - }, [opened, file]) - - return - - - - {(props) => } - - - - {error?<> - - } color="red" onClose={()=>{setError(null)}}> - Error: {error} - - :null} - - - - -} \ No newline at end of file diff --git a/frontend/src/components/NFProxy/utils.ts b/frontend/src/components/NFProxy/utils.ts deleted file mode 100644 index 9a3a313..0000000 --- a/frontend/src/components/NFProxy/utils.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { PyFilter, ServerResponse } from "../../js/models" -import { deleteapi, getapi, postapi, putapi } from "../../js/utils" -import { useQuery } from "@tanstack/react-query" - -export type Service = { - service_id:string, - name:string, - status:string, - port:number, - proto: string, - ip_int: string, - n_filters:number, - edited_packets:number, - blocked_packets:number, - fail_open:boolean, - target_type:string, - tls_stream_id:string|null, -} - -export type TLSConfig = { - tls_enabled: boolean, - tls_cert: string | null, - tls_key: string | null, -} - - -export type ServiceAddForm = { - name:string, - port:number, - proto:string, - ip_int:string, - fail_open: boolean, - target_type?: string, - tls_stream_id?: string | null, -} - -export type ServiceSettings = { - port?:number, - proto?:string, - ip_int?:string, - fail_open?: boolean, - target_type?: string, - tls_stream_id?: string | null, -} - -export type ServiceAddResponse = { - status: string, - service_id?: string, -} - -export const serviceQueryKey = ["nfproxy","services"] - -export const nfproxyServiceQuery = () => useQuery({queryKey:serviceQueryKey, queryFn:nfproxy.services}) -export const nfproxyServicePyfiltersQuery = (service_id:string) => useQuery({ - queryKey:[...serviceQueryKey,service_id,"pyfilters"], - queryFn:() => nfproxy.servicepyfilters(service_id) -}) - -export const nfproxyServiceFilterCodeQuery = (service_id:string) => useQuery({ - queryKey:[...serviceQueryKey,service_id,"pyfilters","code"], - queryFn:() => nfproxy.getpyfilterscode(service_id) -}) - -export const nfproxy = { - services: async () => { - return await getapi("nfproxy/services") as Service[]; - }, - serviceinfo: async (service_id:string) => { - return await getapi(`nfproxy/services/${service_id}`) as Service; - }, - pyfilterenable: async (service_id:string, filter_name:string) => { - const { status } = await postapi(`nfproxy/services/${service_id}/pyfilters/${filter_name}/enable`) as ServerResponse; - return status === "ok"?undefined:status - }, - pyfilterdisable: async (service_id:string, filter_name:string) => { - const { status } = await postapi(`nfproxy/services/${service_id}/pyfilters/${filter_name}/disable`) as ServerResponse; - return status === "ok"?undefined:status - }, - servicestart: async (service_id:string) => { - const { status } = await postapi(`nfproxy/services/${service_id}/start`) as ServerResponse; - return status === "ok"?undefined:status - }, - servicerename: async (service_id:string, name: string) => { - const { status } = await putapi(`nfproxy/services/${service_id}/rename`,{ name }) as ServerResponse; - return status === "ok"?undefined:status - }, - servicestop: async (service_id:string) => { - const { status } = await postapi(`nfproxy/services/${service_id}/stop`) as ServerResponse; - return status === "ok"?undefined:status - }, - servicesadd: async (data:ServiceAddForm) => { - return await postapi("nfproxy/services",data) as ServiceAddResponse; - }, - servicedelete: async (service_id:string) => { - const { status } = await deleteapi(`nfproxy/services/${service_id}`) as ServerResponse; - return status === "ok"?undefined:status - }, - servicepyfilters: async (service_id:string) => { - return await getapi(`nfproxy/services/${service_id}/pyfilters`) as PyFilter[]; - }, - settings: async (service_id:string, data:ServiceSettings) => { - const { status } = await putapi(`nfproxy/services/${service_id}/settings`,data) as ServerResponse; - return status === "ok"?undefined:status - }, - updatetlsconfig: async (service_id:string, data:TLSConfig) => { - const { status } = await putapi(`nfproxy/services/${service_id}/tls-config`,data) as ServerResponse; - return status === "ok"?undefined:status - }, - getpyfilterscode: async (service_id:string) => { - return await getapi(`nfproxy/services/${service_id}/code`) as string; - }, - setpyfilterscode: async (service_id:string, code:string) => { - const { status } = await putapi(`nfproxy/services/${service_id}/code`,{ code }) as ServerResponse; - return status === "ok"?undefined:status - } -} - - -export const EXAMPLE_PYFILTER = `# This in an example of a filter file with http protocol - -# From here we can import the DataTypes that we want to use: -# The data type must be specified in the filter functions -# And will also interally be used to decide when call some filters and how aggregate data -from firegex.nfproxy.models import RawPacket, HttpRequest - -# global context in this execution is dedicated to a single TCP stream -# - This code will be executed once at the TCP stream start -# - The filter will be called for each packet in the stream -# - You can store in global context some data you need, but exceeding with data stored could be dangerous -# - At the end of the stream the global context will be destroyed - -from firegex.nfproxy import pyfilter -# pyfilter is a decorator, this will make the function become an effective filter and must have parameters with a specified type - -from firegex.nfproxy import REJECT, ACCEPT, UNSTABLE_MANGLE, DROP -# - The filter must return one of the following values: -# - ACCEPT: The packet will be accepted -# - REJECT: The packet will be rejected (will be activated a mechanism to send a RST packet and drop all data in the stream) -# - UNSTABLE_MANGLE: The packet will be mangled and accepted -# - DROP: All the packets in this stream will be easly dropped - -# If you want, you can use print to debug your filters, but this could slow down the filter - -# Filter names must be unique and are specified by the name of the function wrapped by the decorator -@pyfilter -# This function will handle only a RawPacket object, this is the lowest level of the packet abstraction -def strange_filter(packet:RawPacket): - # Mangling packets can be dangerous, due to instability of the internal TCP state mangling done by the filter below - # Also is not garanteed that l4_data is the same of the packet data: - # packet data is the assembled TCP stream, l4_data is the TCP payload of the packet in the nfqueue - # Unorder packets in TCP are accepted by default, and python is not called in this case - # For this reason mangling will be only available RawPacket: higher level data abstraction will be read-only - if b"TEST_MANGLING" in packet.l4_data: - # It's possible to change teh raw_packet and l4_data values for mangling the packet, data is immutable instead - packet.l4_data = packet.l4_data.replace(b"TEST", b"UNSTABLE") - return UNSTABLE_MANGLE - # Drops the traffic - if b"BAD DATA 1" in packet.data: - return DROP - # Rejects the traffic - if b"BAD DATA 2" in packet.data: - return REJECT - # Accepts the traffic (default if None is returned) - return ACCEPT - -# Example with a higher level of abstraction -@pyfilter -def http_filter(http:HttpRequest): - if http.method == "GET" and "test" in http.url: - return REJECT - -# ADVANCED OPTIONS -# You can specify some additional options on the streaming managment -# pyproxy will automatically store all the packets (already ordered by the c++ binary): -# -# If the stream is too big, you can specify what actions to take: -# This can be done defining some variables in the global context -# - FGEX_STREAM_MAX_SIZE: The maximum size of the stream in bytes (default 1MB) -# NOTE: the stream size is calculated and managed indipendently by the data type handling system -# Only types required by at least 1 filter will be stored. -# - FGEX_FULL_STREAM_ACTION: The action to do when the stream is full -# - FullStreamAction.FLUSH: Flush the stream and continue to acquire new packets (default) -# - FullStreamAction.DROP: Drop the next stream packets - like a DROP action by filter -# - FullStreamAction.REJECT: Reject the stream and close the connection - like a REJECT action by filter -# - FullStreamAction.ACCEPT: Stops to call pyfilters and accept the traffic - -from firegex.nfproxy import FullStreamAction - -# Example of a global context -FGEX_STREAM_MAX_SIZE = 4096 -FGEX_FULL_STREAM_ACTION = FullStreamAction.REJECT -# This could be an ideal configuration if we expect to normally have streams with a maximum size of 4KB of traffic -` diff --git a/frontend/src/components/NFRegex/AddEditService.tsx b/frontend/src/components/NFRegex/AddEditService.tsx deleted file mode 100644 index 938d583..0000000 --- a/frontend/src/components/NFRegex/AddEditService.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { Button, Group, Space, TextInput, Notification, Modal, Switch, SegmentedControl, Box, Tooltip, ActionIcon, Title, Tabs, Text, UnstyledButton, Badge, Card, Divider } from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { useEffect, useState } from 'react'; -import { okNotify, regex_ipv4, regex_ipv6, postapi } from '../../js/utils'; -import TLSServiceFields from '../TLSServiceFields'; -import { ImCross } from "react-icons/im" -import { nfregex, Service } from './utils'; -import PortAndInterface from '../PortAndInterface'; -import { IoMdInformationCircleOutline } from "react-icons/io"; -import { TbNetwork, TbShieldLock, TbSettings, TbTarget } from "react-icons/tb"; -import { ServiceAddForm as ServiceAddFormOriginal } from './utils'; - -type ServiceAddForm = ServiceAddFormOriginal & { - autostart: boolean, - tls_enabled: boolean, - tls_cert: string, - tls_key: string -} - -function AddEditService({ opened, onClose, edit }:{ opened:boolean, onClose:()=>void, edit?:Service }) { - - const initialValues = { - name: "", - port:edit?.port??8080, - ip_int:edit?.ip_int??"", - proto:edit?.proto??"tcp", - fail_open: edit?.fail_open??false, - autostart: true, - tls_enabled: edit?.target_type === 'tls', - tls_stream_id: edit?.tls_stream_id ?? undefined, - tls_cert: "", - tls_key: "" - } - - const form = useForm({ - initialValues: initialValues, - validate:{ - name: (value) => edit? null : value !== "" ? null : "Service name is required", - port: (value) => (value>0 && value<65536) ? null : "Invalid port", - proto: (value) => ["tcp","udp"].includes(value) ? null : "Invalid protocol", - ip_int: (value) => (value.match(regex_ipv6) || value.match(regex_ipv4)) ? null : "Invalid IP address", - } - }) - - useEffect(() => { - if (opened){ - form.setInitialValues(initialValues) - form.reset() - } - }, [opened]) - - - - const close = () =>{ - onClose() - form.reset() - setError(null) - } - - const [submitLoading, setSubmitLoading] = useState(false) - const [error, setError] = useState(null) - - const submitRequest = async (values: ServiceAddForm) => { - setSubmitLoading(true) - let streamId = values.tls_stream_id; - - if (edit){ - if (values.tls_enabled && values.tls_cert && values.tls_key) { - try { - const res = await postapi('tls/streams', { - name: values.name, - ip_int: values.ip_int, - port: values.port, - cert: values.tls_cert, - key: values.tls_key - }); - if (res.status !== "ok") { - setSubmitLoading(false); - setError("Failed to create TLS Stream!"); - return; - } - streamId = res.stream_id; - } catch (err: any) { - setSubmitLoading(false); - setError("Failed to create TLS Stream: " + err); - return; - } - } - - nfregex.settings(edit.service_id, { port: values.port, proto: values.proto, ip_int: values.ip_int, fail_open: values.fail_open, target_type: values.tls_enabled ? "tls" : "flow", tls_stream_id: streamId }).then( res => { - if (!res){ - setSubmitLoading(false) - close(); - okNotify(`Service ${values.name} settings updated`, `Successfully updated settings for service ${values.name}`) - } - }).catch( err => { - setSubmitLoading(false) - setError("Request Failed! [ "+err+" ]") - }) - }else{ - if (values.tls_enabled && values.tls_cert && values.tls_key) { - try { - const res = await postapi('tls/streams', { - name: values.name, - ip_int: values.ip_int, - port: values.port, - cert: values.tls_cert, - key: values.tls_key - }); - if (res.status !== "ok") { - setSubmitLoading(false); - setError("Failed to create TLS Stream!"); - return; - } - streamId = res.stream_id; - } catch (err: any) { - setSubmitLoading(false); - setError("Failed to create TLS Stream: " + err); - return; - } - } - - const payload = { - name: values.name, - port: values.port, - proto: values.proto, - ip_int: values.ip_int, - fail_open: values.fail_open, - target_type: values.tls_enabled ? "tls" : "flow", - tls_stream_id: streamId, - } - nfregex.servicesadd(payload).then( res => { - if (res.status === "ok" && res.service_id){ - setSubmitLoading(false) - close(); - if (values.autostart) nfregex.servicestart(res.service_id) - okNotify(`Service ${values.name} has been added`, `Successfully added service with port ${values.port}`) - }else{ - setSubmitLoading(false) - setError("Invalid request! [ "+res.status+" ]") - } - }).catch( err => { - setSubmitLoading(false) - setError("Request Failed! [ "+err+" ]") - }) - } - } - - - return -
- - - }> - General - - }> - Advanced - - - - - {!edit ? ( - - ) : null} - - Protocol - - - - - Target Type - - form.setFieldValue('tls_enabled', false)} - style={{ - padding: '1rem', - borderRadius: '12px', - border: form.values.tls_enabled ? '1px solid var(--fourth_color)' : '2px solid var(--accent-color)', - backgroundColor: form.values.tls_enabled ? 'transparent' : 'rgba(0, 229, 255, 0.05)', - transition: 'all 0.2s ease' - }} - > - - -
- Direct Flow - Forward traffic directly without decryption -
-
-
- { if (form.values.proto === 'tcp') form.setFieldValue('tls_enabled', true) }} - style={{ - padding: '1rem', - borderRadius: '12px', - border: !form.values.tls_enabled ? '1px solid var(--fourth_color)' : '2px solid var(--accent-color)', - backgroundColor: !form.values.tls_enabled ? 'transparent' : 'rgba(0, 229, 255, 0.05)', - opacity: form.values.proto !== 'tcp' && !form.values.tls_enabled ? 0.5 : 1, - transition: 'all 0.2s ease', - cursor: form.values.proto !== 'tcp' ? 'not-allowed' : 'pointer' - }} - > - - -
- TLS Decrypted - Decrypt TLS traffic for deep inspection -
-
-
-
- - {!form.values.tls_enabled ? ( - - ) : ( - - )} -
- - - - {!edit ? ( - - Auto-Start Service - Automatically start this service as soon as it is created. - - - ) : null} - - - Enable fail-open nfqueue - - Firegex uses nfqueue internally to handle packets. Enabling this option will allow packets to pass through the firewall automatically in case the regex engine is overloaded or crashes. - - - - - -
- - {error ? <> - - } color="red" onClose={() => { setError(null) }}> - Error: {error} - - : null} - - - - - - - -
- -} - -export default AddEditService; diff --git a/frontend/src/components/NFRegex/NFRegexDocs.tsx b/frontend/src/components/NFRegex/NFRegexDocs.tsx deleted file mode 100644 index c1a031a..0000000 --- a/frontend/src/components/NFRegex/NFRegexDocs.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { MarkdownDocs } from "../MarkdownDocs"; -import docsMarkdown from "../../../../docs/nfregex.md?raw"; - -export const NFRegexDocs = () => ; diff --git a/frontend/src/components/NFRegex/ServiceRow/RenameForm.tsx b/frontend/src/components/NFRegex/ServiceRow/RenameForm.tsx deleted file mode 100644 index a643bec..0000000 --- a/frontend/src/components/NFRegex/ServiceRow/RenameForm.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Button, Group, Space, TextInput, Notification, Modal } from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { useEffect, useState } from 'react'; -import { okNotify } from '../../../js/utils'; -import { ImCross } from "react-icons/im" -import { nfregex, Service } from '../utils'; - -function RenameForm({ opened, onClose, service }:{ opened:boolean, onClose:()=>void, service:Service }) { - - const form = useForm({ - initialValues: { name:service.name }, - validate:{ name: (value) => value !== ""? null : "Service name is required" } - }) - - const close = () =>{ - onClose() - form.reset() - setError(null) - } - - useEffect(()=> form.setFieldValue("name", service.name),[opened]) - - const [submitLoading, setSubmitLoading] = useState(false) - const [error, setError] = useState(null) - - const submitRequest = ({ name }:{ name:string }) => { - setSubmitLoading(true) - nfregex.servicerename(service.service_id, name).then( res => { - if (!res){ - setSubmitLoading(false) - close(); - okNotify(`Service ${service.name} has been renamed in ${ name }`, `Successfully renamed service on port ${service.port}`) - }else{ - setSubmitLoading(false) - setError("Error: [ "+res+" ]") - } - }).catch( err => { - setSubmitLoading(false) - setError("Request Failed! [ "+err+" ]") - }) - - } - - - return -
- - - - - - {error?<> - - } color="red" onClose={()=>{setError(null)}}> - Error: {error} - - :null} - - -
- -} - -export default RenameForm; diff --git a/frontend/src/components/NFRegex/ServiceRow/index.tsx b/frontend/src/components/NFRegex/ServiceRow/index.tsx deleted file mode 100644 index 2e763a7..0000000 --- a/frontend/src/components/NFRegex/ServiceRow/index.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { ActionIcon, Badge, Box, Divider, Menu, Space, Title, Tooltip, Card, Group, Button, Text } from '@mantine/core'; -import { useState } from 'react'; -import { FaPlay, FaStop, FaTrash } from 'react-icons/fa'; -import { nfregex, Service, serviceQueryKey } from '../utils'; -import YesNoModal from '../../YesNoModal'; -import { errorNotify, isMediumScreen, okNotify, regex_ipv4 } from '../../../js/utils'; -import { BsTrashFill } from 'react-icons/bs'; -import { BiRename } from 'react-icons/bi'; -import RenameForm from './RenameForm'; -import { MenuDropDownWithButton } from '../../MainLayout'; -import { useQueryClient } from '@tanstack/react-query'; -import { IoSettingsSharp } from 'react-icons/io5'; -import AddEditService from '../AddEditService'; -import { TbShieldLock, TbHexagon } from "react-icons/tb"; -import { MdChevronRight } from "react-icons/md"; -import TLSAssociationBadge from '../../TLSAssociationBadge'; - - -export default function ServiceRow({ service, onClick }:{ service:Service, onClick?:()=>void }) { - - let status_color = "gray"; - switch(service.status){ - case "stop": status_color = "red"; break; - case "active": status_color = "teal"; break; - } - - const queryClient = useQueryClient() - const [buttonLoading, setButtonLoading] = useState(false) - const [deleteModal, setDeleteModal] = useState(false) - const [renameModal, setRenameModal] = useState(false) - const [editModal, setEditModal] = useState(false) - const isMedium = isMediumScreen() - - const stopService = async () => { - setButtonLoading(true) - - await nfregex.servicestop(service.service_id).then(res => { - if(!res){ - okNotify(`Service ${service.name} stopped successfully!`,`The service on ${service.port} has been stopped!`) - queryClient.invalidateQueries({ queryKey: serviceQueryKey }) - }else{ - errorNotify(`An error as occurred during the stopping of the service ${service.port}`,`Error: ${res}`) - } - }).catch(err => { - errorNotify(`An error as occurred during the stopping of the service ${service.port}`,`Error: ${err}`) - }) - setButtonLoading(false); - } - - const startService = async () => { - setButtonLoading(true) - await nfregex.servicestart(service.service_id).then(res => { - if(!res){ - okNotify(`Service ${service.name} started successfully!`,`The service on ${service.port} has been started!`) - queryClient.invalidateQueries({ queryKey: serviceQueryKey }) - }else{ - errorNotify(`An error as occurred during the starting of the service ${service.port}`,`Error: ${res}`) - } - }).catch(err => { - errorNotify(`An error as occurred during the starting of the service ${service.port}`,`Error: ${err}`) - }) - setButtonLoading(false) - } - - const deleteService = () => { - nfregex.servicedelete(service.service_id).then(res => { - if (!res){ - okNotify("Service delete complete!",`The service ${service.name} has been deleted!`) - queryClient.invalidateQueries({ queryKey: serviceQueryKey }) - }else - errorNotify("An error occurred while deleting a service",`Error: ${res}`) - }).catch(err => { - errorNotify("An error occurred while deleting a service",`Error: ${err}`) - }) - - } - - return <> - - - - - - - - - {service.name} - - {service.status.toUpperCase()} - - {service.target_type === 'tls' && } - - - - PORT: :{service.port} - - - • {service.target_type === 'tls' ? 'decrypted traffic to ' : ''}{service.ip_int} ON {service.proto.toUpperCase()} - - - - - - - e.stopPropagation()}> - {service.status === "stop" ? ( - - ) : ( - - )} - - - - - - - Actions - } onClick={() => setRenameModal(true)}>Rename Service - } onClick={() => setEditModal(true)}>Settings - - - - - - - - - - - - - - setDeleteModal(false) } - action={deleteService} - opened={deleteModal} - /> - setRenameModal(false)} - opened={renameModal} - service={service} - /> - setEditModal(false)} - edit={service} - /> - -} diff --git a/frontend/src/components/NFRegex/utils.ts b/frontend/src/components/NFRegex/utils.ts deleted file mode 100644 index 8ba9dea..0000000 --- a/frontend/src/components/NFRegex/utils.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { RegexFilter, ServerResponse } from "../../js/models" -import { deleteapi, getapi, postapi, putapi } from "../../js/utils" -import { RegexAddForm } from "../../js/models" -import { useQuery, useQueryClient } from "@tanstack/react-query" - -export type Service = { - name:string, - service_id:string, - status:string, - port:number, - proto: string, - ip_int: string, - n_packets:number, - n_regex:number, - fail_open:boolean, - target_type:string, - tls_stream_id:string|null, -} - -export type ServiceAddForm = { - name:string, - port:number, - proto:string, - ip_int:string, - fail_open: boolean, - target_type?: string, - tls_stream_id?: string | null, -} - -export type ServiceSettings = { - port?:number, - proto?:string, - ip_int?:string, - fail_open?: boolean, - target_type?: string, - tls_stream_id?: string | null, -} - -export type ServiceAddResponse = { - status: string, - service_id?: string, -} - -export const serviceQueryKey = ["nfregex","services"] - -export const nfregexServiceQuery = () => useQuery({queryKey:serviceQueryKey, queryFn:nfregex.services}) -export const nfregexServiceRegexesQuery = (service_id:string) => useQuery({ - queryKey:[...serviceQueryKey,service_id,"regexes"], - queryFn:() => nfregex.serviceregexes(service_id) -}) - -export const nfregex = { - services: async () => { - return await getapi("nfregex/services") as Service[]; - }, - serviceinfo: async (service_id:string) => { - return await getapi(`nfregex/services/${service_id}`) as Service; - }, - regexdelete: async (regex_id:number) => { - const { status } = await deleteapi(`nfregex/regexes/${regex_id}`) as ServerResponse; - return status === "ok"?undefined:status - }, - regexenable: async (regex_id:number) => { - const { status } = await postapi(`nfregex/regexes/${regex_id}/enable`) as ServerResponse; - return status === "ok"?undefined:status - }, - regexdisable: async (regex_id:number) => { - const { status } = await postapi(`nfregex/regexes/${regex_id}/disable`) as ServerResponse; - return status === "ok"?undefined:status - }, - servicestart: async (service_id:string) => { - const { status } = await postapi(`nfregex/services/${service_id}/start`) as ServerResponse; - return status === "ok"?undefined:status - }, - servicerename: async (service_id:string, name: string) => { - const { status } = await putapi(`nfregex/services/${service_id}/rename`,{ name }) as ServerResponse; - return status === "ok"?undefined:status - }, - servicestop: async (service_id:string) => { - const { status } = await postapi(`nfregex/services/${service_id}/stop`) as ServerResponse; - return status === "ok"?undefined:status - }, - servicesadd: async (data:ServiceAddForm) => { - return await postapi("nfregex/services",data) as ServiceAddResponse; - }, - servicedelete: async (service_id:string) => { - const { status } = await deleteapi(`nfregex/services/${service_id}`) as ServerResponse; - return status === "ok"?undefined:status - }, - regexesadd: async (data:RegexAddForm) => { - const { status } = await postapi("nfregex/regexes",data) as ServerResponse; - return status === "ok"?undefined:status - }, - serviceregexes: async (service_id:string) => { - return await getapi(`nfregex/services/${service_id}/regexes`) as RegexFilter[]; - }, - settings: async (service_id:string, data:ServiceSettings) => { - const { status } = await putapi(`nfregex/services/${service_id}/settings`,data) as ServerResponse; - return status === "ok"?undefined:status - }, - updatetlsconfig: async (service_id: string, data: { tls_enabled: boolean, tls_cert: string | null, tls_key: string | null }) => { - const { status } = await putapi(`nfregex/services/${service_id}/tls-config`, data) as ServerResponse; - return status === "ok" ? undefined : status; - }, -} \ No newline at end of file diff --git a/frontend/src/components/NavBar/index.tsx b/frontend/src/components/NavBar/index.tsx index a3738fe..4f04714 100644 --- a/frontend/src/components/NavBar/index.tsx +++ b/frontend/src/components/NavBar/index.tsx @@ -1,13 +1,11 @@ import { Divider, Group, MantineColor, ScrollArea, Text, ThemeIcon, Title, UnstyledButton, Box, AppShell } from "@mantine/core"; import { useState } from "react"; -import { TbPlugConnected, TbShieldLock } from "react-icons/tb"; +import { TbHexagon } from "react-icons/tb"; import { useNavigate } from "react-router"; import { useQueryClient } from "@tanstack/react-query"; -import { GrDirections } from "react-icons/gr"; import { PiWallLight } from "react-icons/pi"; import { useNavbarStore } from "../../js/store"; import { getMainPath, getapi, postapi, okNotify, errorNotify } from "../../js/utils"; -import { BsRegex } from "react-icons/bs"; import { MdDownload, MdUpload } from "react-icons/md"; function NavBarButton({ navigate, closeNav, name, icon, color, disabled, onClick }: @@ -39,11 +37,8 @@ export default function NavBar() { - } /> + } /> } /> - } /> - } /> - } /> Backup & Restore diff --git a/frontend/src/components/PortAndInterface.tsx b/frontend/src/components/PortAndInterface.tsx index c43824e..e874df6 100644 --- a/frontend/src/components/PortAndInterface.tsx +++ b/frontend/src/components/PortAndInterface.tsx @@ -1,36 +1,49 @@ -import { Box, Space, Title } from "@mantine/core" -import React from "react" -import PortInput from "./PortInput"; +import { Box, Space, Text, Title } from "@mantine/core" import { UseFormReturnType } from "@mantine/form"; import { InterfaceInput } from "./InterfaceInput"; +import PortInput from "./PortInput"; -type ItemProps = { - value: string; - netint: string; -} & any +export default function PortAndInterface({ + form, + int_name, + port_name, + label, + description, + orientation, + includeInterfaceNames = true, +}: { + form: UseFormReturnType, + int_name: string, + port_name: string, + label?: string, + /** One line under the label, for what the field takes that is not obvious. */ + description?: string, + orientation?: "line" | "column", + includeInterfaceNames?: boolean, +}) { + const line = !orientation || orientation == "line" + // One message under the pair, because either half can be the wrong one and they + // sit on a single line. The address box never received the form's `error` at all, + // so "Invalid IP address or interface name" was computed on every submit and shown + // nowhere; both boxes still turn red, which is what says which half it was. + const error = form.getInputProps(int_name).error ?? form.getInputProps(port_name).error -const AutoCompleteItem = React.forwardRef( - ({ netint, value, ...props }, ref) => - ( {netint} ) -{">"} {value} - -); - -export default function PortAndInterface({ form, int_name, port_name, label, orientation }:{ form:UseFormReturnType, int_name:string, port_name:string, label?:string, orientation?:"line"|"column" }) { - - - - return <> - {label?<> + return <> + {label ? <> {label} - :null} - - - {(!orientation || orientation == "line")? - <>:: - } - - + {description ? {description} : null} + : null} + + + {line ? + <>: : + } + + + {error ? {error} : null} -} \ No newline at end of file +} diff --git a/frontend/src/components/PortHijack/AddNewService.tsx b/frontend/src/components/PortHijack/AddNewService.tsx deleted file mode 100644 index 4ff5da1..0000000 --- a/frontend/src/components/PortHijack/AddNewService.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { Button, Group, Space, TextInput, Notification, Modal, Switch, SegmentedControl, Box, Card, Divider, Tabs, Title } from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { useState } from 'react'; -import { okNotify, regex_ipv6_no_cidr, regex_ipv4_no_cidr } from '../../js/utils'; -import { ImCross } from "react-icons/im" -import { porthijack } from './utils'; -import PortAndInterface from '../PortAndInterface'; -import { TbSettings, TbShieldLock } from 'react-icons/tb'; - -type ServiceAddForm = { - name:string, - public_port:number, - proxy_port:number, - proto:string, - ip_src:string, - ip_dst:string, - autostart: boolean, -} - -function AddNewService({ opened, onClose }:{ opened:boolean, onClose:()=>void }) { - - const form = useForm({ - initialValues: { - name:"", - public_port:80, - proxy_port:8080, - proto:"tcp", - ip_src:"", - ip_dst:"127.0.0.1", - autostart: false, - }, - validate:{ - name: (value) => value !== ""? null : "Service name is required", - public_port: (value) => (value>0 && value<65536) ? null : "Invalid public port", - proxy_port: (value) => (value>0 && value<65536) ? null : "Invalid proxy port", - proto: (value) => ["tcp","udp"].includes(value) ? null : "Invalid protocol", - ip_src: (value) => (value.match(regex_ipv6_no_cidr) || value.match(regex_ipv4_no_cidr)) ? null : "Invalid source IP address", - ip_dst: (value) => (value.match(regex_ipv6_no_cidr) || value.match(regex_ipv4_no_cidr)) ? null : "Invalid destination IP address", - } - }) - - const close = () =>{ - onClose() - form.reset() - setError(null) - } - - const [submitLoading, setSubmitLoading] = useState(false) - const [error, setError] = useState(null) - - const submitRequest = ({ name, proxy_port, public_port, autostart, proto, ip_src, ip_dst }:ServiceAddForm) =>{ - setSubmitLoading(true) - porthijack.servicesadd({name, proxy_port, public_port, proto, ip_src, ip_dst }).then( res => { - if (res.status === "ok" && res.service_id){ - setSubmitLoading(false) - close(); - if (autostart) porthijack.servicestart(res.service_id) - okNotify(`Service ${name} has been added`, `Successfully added service from port ${public_port} to ${proxy_port}`) - }else{ - setSubmitLoading(false) - setError("Invalid request! [ "+res.status+" ]") - } - }).catch( err => { - setSubmitLoading(false) - setError("Request Failed! [ "+err+" ]") - }) - } - - - return -
- - - }> - General - - }> - Advanced - - - - - - - Protocol - - - - - - - - - - - - - - - Auto-Start Service - - - - Automatically start this service as soon as it is created. - - - - - - - - - - - - {error?<> - - } color="red" onClose={()=>{setError(null)}}> - Error: {error} - - :null} - - -
- -} - -export default AddNewService; diff --git a/frontend/src/components/PortHijack/PortHijackDocs.tsx b/frontend/src/components/PortHijack/PortHijackDocs.tsx deleted file mode 100644 index 812a27a..0000000 --- a/frontend/src/components/PortHijack/PortHijackDocs.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { MarkdownDocs } from "../MarkdownDocs"; -import docsMarkdown from "../../../../docs/porthijack.md?raw"; - -export const PortHijackDocs = () => ; diff --git a/frontend/src/components/PortHijack/ServiceRow/ChangeDestination.tsx b/frontend/src/components/PortHijack/ServiceRow/ChangeDestination.tsx deleted file mode 100644 index 8d135e9..0000000 --- a/frontend/src/components/PortHijack/ServiceRow/ChangeDestination.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Button, Group, Space, Notification, Modal } from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { useEffect, useState } from 'react'; -import { okNotify, regex_ipv4_no_cidr, regex_ipv6_no_cidr } from '../../../js/utils'; -import { ImCross } from "react-icons/im" -import { porthijack, Service } from '../utils'; -import PortAndInterface from '../../PortAndInterface'; - -function ChangeDestination({ opened, onClose, service }:{ opened:boolean, onClose:()=>void, service:Service }) { - - const form = useForm({ - initialValues: { - ip_dst:service.ip_dst, - proxy_port:service.proxy_port - }, - validate:{ - proxy_port: (value) => (value>0 && value<65536) ? null : "Invalid proxy port", - ip_dst: (value) => (value.match(regex_ipv6_no_cidr) || value.match(regex_ipv4_no_cidr))? null : "Invalid destination IP address", - } - }) - - const close = () =>{ - onClose() - form.reset() - setError(null) - } - - useEffect(() => form.setValues({ip_dst:service.ip_dst, proxy_port: service.proxy_port}),[opened]) - - const [submitLoading, setSubmitLoading] = useState(false) - const [error, setError] = useState(null) - - const submitRequest = ({ ip_dst, proxy_port }:{ ip_dst:string, proxy_port:number }) => { - setSubmitLoading(true) - porthijack.changedestination(service.service_id, ip_dst, proxy_port).then( res => { - if (res.status === "ok"){ - setSubmitLoading(false) - close(); - okNotify(`Service ${service.name} has changed destination in ${ ip_dst }:${ proxy_port }`, `Successfully changed destination of service on port ${service.public_port}`) - }else{ - setSubmitLoading(false) - setError(res.status) - } - }).catch( err => { - setSubmitLoading(false) - setError("Request Failed! [ "+err+" ]") - }) - - } - - - return -
- - - - - - - {error?<> - - } color="red" onClose={()=>{setError(null)}}> - Error: {error} - - :null} - - -
- -} - -export default ChangeDestination; diff --git a/frontend/src/components/PortHijack/ServiceRow/RenameForm.tsx b/frontend/src/components/PortHijack/ServiceRow/RenameForm.tsx deleted file mode 100644 index 4d75c42..0000000 --- a/frontend/src/components/PortHijack/ServiceRow/RenameForm.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Button, Group, Space, TextInput, Notification, Modal } from '@mantine/core'; -import { useForm } from '@mantine/form'; -import { useEffect, useState } from 'react'; -import { okNotify } from '../../../js/utils'; -import { ImCross } from "react-icons/im" -import { porthijack, Service } from '../utils'; - -function RenameForm({ opened, onClose, service }:{ opened:boolean, onClose:()=>void, service:Service }) { - - const form = useForm({ - initialValues: { name:service.name }, - validate:{ name: (value) => value !== ""? null : "Service name is required" } - }) - - const close = () =>{ - onClose() - form.reset() - setError(null) - } - - useEffect(()=> form.setFieldValue("name", service.name),[opened]) - - const [submitLoading, setSubmitLoading] = useState(false) - const [error, setError] = useState(null) - - const submitRequest = ({ name }:{ name:string }) => { - setSubmitLoading(true) - porthijack.servicerename(service.service_id, name).then( res => { - if (!res){ - setSubmitLoading(false) - close(); - okNotify(`Service ${service.name} has been renamed in ${ name }`, `Successfully renamed service on port ${service.public_port}`) - }else{ - setSubmitLoading(false) - setError("Error: [ "+res+" ]") - } - }).catch( err => { - setSubmitLoading(false) - setError("Request Failed! [ "+err+" ]") - }) - - } - - - return -
- - - - - - {error?<> - - } color="red" onClose={()=>{setError(null)}}> - Error: {error} - - :null} - - -
- -} - -export default RenameForm; diff --git a/frontend/src/components/PortHijack/ServiceRow/index.tsx b/frontend/src/components/PortHijack/ServiceRow/index.tsx deleted file mode 100644 index bb8b2e3..0000000 --- a/frontend/src/components/PortHijack/ServiceRow/index.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { ActionIcon, Badge, Box, Divider, Menu, Space, Title, Tooltip, Card, Group, Button, Text } from '@mantine/core'; -import { useState } from 'react'; -import { FaPlay, FaStop, FaTrash } from 'react-icons/fa'; -import { porthijack, Service } from '../utils'; -import YesNoModal from '../../YesNoModal'; -import { errorNotify, isMediumScreen, okNotify } from '../../../js/utils'; -import { BsArrowRepeat, BsTrashFill } from 'react-icons/bs'; -import { BiRename } from 'react-icons/bi'; -import RenameForm from './RenameForm'; -import ChangeDestination from './ChangeDestination'; -import { useForm } from '@mantine/form'; -import { MenuDropDownWithButton } from '../../MainLayout'; -import { TbHexagon } from "react-icons/tb"; - -export default function ServiceRow({ service }:{ service:Service }) { - - let status_color = service.active ? "teal": "red" - - const [buttonLoading, setButtonLoading] = useState(false) - const [deleteModal, setDeleteModal] = useState(false) - const [renameModal, setRenameModal] = useState(false) - const [changeDestModal, setChangeDestModal] = useState(false) - const isMedium = isMediumScreen() - - const form = useForm({ - initialValues: { proxy_port:service.proxy_port }, - validate:{ proxy_port: (value) => (value > 0 && value < 65536)? null : "Invalid proxy port" } - }) - - const stopService = async () => { - setButtonLoading(true) - - await porthijack.servicestop(service.service_id).then(res => { - if(!res){ - okNotify(`Service ${service.name} stopped successfully!`,`The service on ${service.public_port} has been stopped!`) - }else{ - errorNotify(`An error as occurred during the stopping of the service ${service.public_port}`,`Error: ${res}`) - } - }).catch(err => { - errorNotify(`An error as occurred during the stopping of the service ${service.public_port}`,`Error: ${err}`) - }) - setButtonLoading(false); - } - - const startService = async () => { - setButtonLoading(true) - await porthijack.servicestart(service.service_id).then(res => { - if(!res){ - okNotify(`Service ${service.name} started successfully!`,`The service on ${service.public_port} has been started!`) - }else{ - errorNotify(`An error as occurred during the starting of the service ${service.public_port}`,`Error: ${res}`) - } - }).catch(err => { - errorNotify(`An error as occurred during the starting of the service ${service.public_port}`,`Error: ${err}`) - }) - setButtonLoading(false) - } - - const deleteService = () => { - porthijack.servicedelete(service.service_id).then(res => { - if (!res){ - okNotify("Service delete complete!",`The service ${service.name} has been deleted!`) - }else - errorNotify("An error occurred while deleting a service",`Error: ${res}`) - }).catch(err => { - errorNotify("An error occurred while deleting a service",`Error: ${err}`) - }) - - } - - return <> - - - - - - - - - {service.name} - - {service.active ? "ENABLED" : "DISABLED"} - - - - - FROM {service.ip_src}:{service.public_port} - - - • TO {service.ip_dst}:{service.proxy_port} ON {service.proto.toUpperCase()} - - - - - - e.stopPropagation()}> - {service.active ? ( - - ) : ( - - )} - - - - - - - Actions - } onClick={()=>setRenameModal(true)}>Rename service - } onClick={()=>setChangeDestModal(true)}>Change hijacking destination - - - - - - - - - setDeleteModal(false) } - action={deleteService} - opened={deleteModal} - /> - setRenameModal(false)} - opened={renameModal} - service={service} - /> - setChangeDestModal(false)} - opened={changeDestModal} - service={service} - /> - -} diff --git a/frontend/src/components/PortHijack/utils.ts b/frontend/src/components/PortHijack/utils.ts deleted file mode 100644 index 2c00417..0000000 --- a/frontend/src/components/PortHijack/utils.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { ServerResponse } from "../../js/models" -import { deleteapi, getapi, postapi, putapi } from "../../js/utils" -import { useQuery } from "@tanstack/react-query" - -export type GeneralStats = { - services:number -} - -export type Service = { - name:string, - service_id:string, - active:boolean, - proto: string, - ip_src: string, - ip_dst: string, - proxy_port: number, - public_port: number, -} - -export type ServiceAddForm = { - name:string, - public_port:number, - proxy_port:number, - proto:string, - ip_src: string, - ip_dst: string, -} - -export type ServiceAddResponse = ServerResponse & { service_id: string } - -export const queryKey = ["porthijack","services"] - -export const porthijackServiceQuery = () => useQuery({queryKey, queryFn:porthijack.services}) - -export const porthijack = { - services: async () : Promise => { - return await getapi("porthijack/services") as Service[]; - }, - serviceinfo: async (service_id:string) => { - return await getapi(`porthijack/services/${service_id}`) as Service; - }, - servicestart: async (service_id:string) => { - const { status } = await postapi(`porthijack/services/${service_id}/start`) as ServerResponse; - return status === "ok"?undefined:status - }, - servicerename: async (service_id:string, name: string) => { - const { status } = await putapi(`porthijack/services/${service_id}/rename`,{ name }) as ServerResponse; - return status === "ok"?undefined:status - }, - servicestop: async (service_id:string) => { - const { status } = await postapi(`porthijack/services/${service_id}/stop`) as ServerResponse; - return status === "ok"?undefined:status - }, - servicesadd: async (data:ServiceAddForm) => { - return await postapi("porthijack/services",data) as ServiceAddResponse; - }, - servicedelete: async (service_id:string) => { - const { status } = await deleteapi(`porthijack/services/${service_id}`) as ServerResponse; - return status === "ok"?undefined:status - }, - changedestination: async (service_id:string, ip_dst:string, proxy_port:number) => { - return await putapi(`porthijack/services/${service_id}/change-destination`, {proxy_port, ip_dst}) as ServerResponse; - } -} \ No newline at end of file diff --git a/frontend/src/components/PyFilterView/index.tsx b/frontend/src/components/PyFilterView/index.tsx deleted file mode 100644 index 80a07d3..0000000 --- a/frontend/src/components/PyFilterView/index.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Text, Badge, Space, ActionIcon, Tooltip, Box, Card, Group } from '@mantine/core'; -import { useState } from 'react'; -import { PyFilter } from '../../js/models'; -import { errorNotify, isMediumScreen, okNotify } from '../../js/utils'; -import { FaPause, FaPlay } from 'react-icons/fa'; -import { FaFilter } from "react-icons/fa"; -import { nfproxy } from '../NFProxy/utils'; -import { FaPencilAlt } from 'react-icons/fa'; - -export default function PyFilterView({ filterInfo }:{ filterInfo:PyFilter }) { - - const isMedium = isMediumScreen() - - const changeRegexStatus = () => { - (filterInfo.active?nfproxy.pyfilterdisable:nfproxy.pyfilterenable)(filterInfo.service_id, filterInfo.name).then(res => { - if(!res){ - okNotify(`Filter ${filterInfo.name} ${filterInfo.active?"deactivated":"activated"} successfully!`,`Filter '${filterInfo.name}' has been ${filterInfo.active?"deactivated":"activated"}!`) - }else{ - errorNotify(`Filter ${filterInfo.name} ${filterInfo.active?"deactivation":"activation"} failed!`,`Error: ${res}`) - } - }).catch( err => errorNotify(`Filter ${filterInfo.name} ${filterInfo.active?"deactivation":"activation"} failed!`,`Error: ${err}`)) - } - - return ( - - - - - {filterInfo.name} - - - - - }> - {filterInfo.blocked_packets} - - - - }> - {filterInfo.edited_packets} - - - - - - {filterInfo.active ? : } - - - - - - ) -} diff --git a/frontend/src/components/RegexView/index.tsx b/frontend/src/components/RegexView/index.tsx deleted file mode 100644 index 7df8f97..0000000 --- a/frontend/src/components/RegexView/index.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { Text, Title, Badge, Space, ActionIcon, Tooltip, Box, Card, Code, Group } from '@mantine/core'; -import { useState } from 'react'; -import { RegexFilter } from '../../js/models'; -import { b64decode, errorNotify, isMediumScreen, okNotify } from '../../js/utils'; -import { BsTrashFill } from "react-icons/bs" -import YesNoModal from '../YesNoModal'; -import { FaPause, FaPlay } from 'react-icons/fa'; -import { useClipboard } from '@mantine/hooks'; -import { FaFilter } from "react-icons/fa"; - -import { nfregex } from '../NFRegex/utils'; - -function RegexView({ regexInfo }:{ regexInfo:RegexFilter }) { - - const mode_string = regexInfo.mode === "C"? "C -> S": - regexInfo.mode === "S"? "S -> C": - regexInfo.mode === "B"? "C <-> S": "🤔" - - let regex_expr = b64decode(regexInfo.regex); - - const [deleteModal, setDeleteModal] = useState(false); - const clipboard = useClipboard({ timeout: 500 }); - - const deleteRegex = () => { - nfregex.regexdelete(regexInfo.id).then(res => { - if(!res){ - okNotify(`Regex ${regex_expr} deleted successfully!`,`Regex '${regex_expr}' ID:${regexInfo.id} has been deleted!`) - }else{ - errorNotify(`Regex ${regex_expr} deleation failed!`,`Error: ${res}`) - } - }).catch( err => errorNotify(`Regex ${regex_expr} deleation failed!`,`Error: ${err}`)) - } - - const changeRegexStatus = () => { - (regexInfo.active?nfregex.regexdisable:nfregex.regexenable)(regexInfo.id).then(res => { - if(!res){ - okNotify(`Regex ${regex_expr} ${regexInfo.active?"deactivated":"activated"} successfully!`,`Regex with id '${regexInfo.id}' has been ${regexInfo.active?"deactivated":"activated"}!`) - }else{ - errorNotify(`Regex ${regex_expr} ${regexInfo.active?"deactivation":"activation"} failed!`,`Error: ${res}`) - } - }).catch( err => errorNotify(`Regex ${regex_expr} ${regexInfo.active?"deactivation":"activation"} failed!`,`Error: ${err}`)) - } - - return ( - - - - - { - clipboard.copy(regex_expr) - okNotify("Regex copied to clipboard!", `The regex '${regex_expr}' has been copied to the clipboard!`) - }} - > - {regex_expr} - - - - - - - - {regexInfo.active ? : } - - - - setDeleteModal(true)} - size="xl" - radius="md" - variant="light" - > - - - - - - - - - }> - {regexInfo.n_packets} packets - - - - {regexInfo.active ? "ACTIVE" : "DISABLED"} - - - {regexInfo.is_case_sensitive ? "Strict" : "Loose"} - - - {mode_string} - - - - setDeleteModal(false)} - action={deleteRegex} - opened={deleteModal} - /> - - ) -} - -export default RegexView; diff --git a/frontend/src/components/Services/AddEditService.tsx b/frontend/src/components/Services/AddEditService.tsx new file mode 100644 index 0000000..0483f42 --- /dev/null +++ b/frontend/src/components/Services/AddEditService.tsx @@ -0,0 +1,465 @@ +import { Accordion, ActionIcon, Alert, Box, Button, Code, Group, Modal, NumberInput, Space, Switch, Text, TextInput, Tooltip } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { BsPlusLg, BsTrashFill } from 'react-icons/bs'; +import { errorNotify, isAddressOrInterface, isInterfaceName, okNotify } from '../../js/utils'; +import { addressCapabilities } from './AddressOptions'; +import AddressRow from './AddressRow'; +import LayerChoice from './LayerChoice'; +import PemInput from './PemInput'; +import ProtocolChoice from './ProtocolChoice'; +import { AddressForm, decrypts, L4, Upstream, Service, ServiceAddForm, serviceQueryKey, services, Transport } from './utils'; + +type AddressValues = { + ip_int: string, + port: number, + proxy_ip: string, + proxy_port: number, + /** `http` only: what is spoken at this address. */ + edge: string, + /** Where the service is, when it is not on this port. Empty means "it is". */ + target_port: number | string, + /** What the service behind this address speaks. */ + upstream: string, +} + +type FormValues = { + name: string, + proto: string, + transport: string, + fail_open: boolean, + max_connections: number, + over_limit_forwards: boolean, + first_byte_timeout: number, + tls_cert: string, + tls_key: string, + addresses: AddressValues[], + autostart: boolean, +} + +const emptyAddress = (): AddressValues => ({ + ip_int: "127.0.0.1", port: 80, proxy_ip: "127.0.0.1", proxy_port: 8080, + // Only ever read on an `http` service, where each address says what is spoken at it; + // everywhere else the service's own protocol decides and this is ignored. + edge: L4.TCP, + // Both behind the row's own button, and both absent unless they were set: an address + // is where the service listens, forwarded as it arrived, until somebody says else. + target_port: "", + upstream: Upstream.SAME, +}) + +/** + * Create or edit the network layer of a service. + * + * Only the network layer: what to do with the traffic is chosen afterwards, by + * attaching filters, and can be changed without coming back here. That split is the + * point of the whole module — a service used to have to be recreated to swap between + * regexes and Python, because the choice of one was also the choice of the other. + * + * Addresses are a list, and only when the service is being created. Editing them later + * happens on the service's own page, one at a time, because adding an address to a + * running service costs nothing while changing anything here restarts it. + */ +export default function AddEditService({ opened, onClose, edit }: { + opened: boolean, + onClose: () => void, + /** When given, the modal edits this service instead of creating one. */ + edit?: Service, +}) { + const queryClient = useQueryClient() + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + //: Whether leaving the two fields empty means anything. It does not for a new + // service, nor for one whose TLS switch has never been accompanied by material. + const stored = !!edit?.has_tls_material + + const form = useForm({ + initialValues: { + name: "", proto: L4.TCP, + transport: Transport.PROXY, fail_open: true, + max_connections: 0, over_limit_forwards: false, first_byte_timeout: 0, + tls_cert: "", tls_key: "", + addresses: [emptyAddress()], + autostart: true, + }, + validate: { + name: v => v !== "" ? null : "A name is required", + // An empty field is "unchanged" only when there is something to leave + // unchanged: turning TLS on for a service that was never given a certificate + // used to be accepted here and refused by nginx on the next start, which is + // a failure the operator meets long after the edit that caused it. What is + // checked whenever something *was* typed is the PEM envelope, the same check + // the backend makes — so the wrong field is named instead of the whole form. + tls_cert: (v, values) => { + if (!decrypts(values)) return null + if (v === "") return stored ? null : "A certificate is required" + return v.includes("-----BEGIN CERTIFICATE-----") ? null + : "Not a PEM certificate: no 'BEGIN CERTIFICATE' block in it" + }, + tls_key: (v, values) => { + if (!decrypts(values)) return null + if (v === "") return stored ? null : "A private key is required" + if (v.includes("ENCRYPTED PRIVATE KEY-----")) + return "Passphrase-protected keys cannot be used: decrypt it first" + return v.includes("PRIVATE KEY-----") ? null + : "Not a PEM private key: no 'BEGIN PRIVATE KEY' block in it" + }, + addresses: { + ip_int: (v, values) => !isAddressOrInterface(v, + { cidr: values.transport !== Transport.EXTERNAL }) + ? "Not an IP address, and not an interface name either" + : (values.transport === Transport.EXTERNAL && isInterfaceName(v) + ? "Your own proxy is handed one address: the return rule has to put the original port back, and an interface is not one address" + : null), + port: v => (v > 0 && v < 65536) ? null : "Invalid port", + proxy_port: (v, values) => (values.transport !== Transport.EXTERNAL || (v > 0 && v < 65536)) + ? null : "Your proxy's port is required", + }, + }, + }) + + useEffect(() => { + if (!opened) return + setError(null) + if (edit) form.setValues({ + name: edit.name, proto: edit.proto, transport: edit.transport, + fail_open: edit.fail_open, + max_connections: edit.max_connections, over_limit_forwards: edit.over_limit_forwards, + first_byte_timeout: edit.first_byte_timeout, + tls_cert: "", tls_key: "", + addresses: [emptyAddress()], + autostart: true, + }) + else form.reset() + }, [opened, edit?.service_id]) + + const close = () => { onClose(); form.reset(); setError(null) } + + const submit = async (values: FormValues) => { + setSubmitting(true) + const isExternal = values.transport === Transport.EXTERNAL + 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) { + // Addresses are not sent: they have their own endpoints, so that adding + // one does not drop the connections on the others. + const err = await services.edit(edit.service_id, { + name: values.name, proto: values.proto, transport: values.transport, + fail_open: values.fail_open, + max_connections: values.max_connections, + over_limit_forwards: values.over_limit_forwards, + first_byte_timeout: values.first_byte_timeout, + ...(decrypts(values) && values.tls_cert !== "" ? { tls_cert: values.tls_cert } : {}), + ...(decrypts(values) && values.tls_key !== "" ? { tls_key: values.tls_key } : {}), + }) + if (err) { setError(err); setSubmitting(false); return } + okNotify("Service updated", `${values.name} now uses the ${values.transport} transport`) + } else { + const payload: ServiceAddForm = { + name: values.name, proto: values.proto, transport: values.transport, + fail_open: values.fail_open, addresses, + max_connections: values.max_connections, + over_limit_forwards: values.over_limit_forwards, + first_byte_timeout: values.first_byte_timeout, + } + if (decrypts(values) && values.tls_cert !== "") payload.tls_cert = values.tls_cert + if (decrypts(values) && values.tls_key !== "") payload.tls_key = values.tls_key + const res = await services.add(payload) + if (res.status !== "ok" || !res.service_id) { setError(res.status); setSubmitting(false); return } + if (values.autostart) { + const err = await services.start(res.service_id) + if (err) errorNotify("The service was created but would not start", err) + } + okNotify("Service added", addresses.length === 1 + ? `${values.name} will front ${addresses[0].ip_int}:${addresses[0].port}` + : `${values.name} will front ${addresses.length} addresses`) + } + queryClient.invalidateQueries({ queryKey: serviceQueryKey }) + close() + } catch (err) { + setError(`${err}`) + } + setSubmitting(false) + } + + const isProxy = form.values.transport === Transport.PROXY + //: On NFQUEUE the same number means something narrower: how many UDP flows keep what + // their Python filters hold. A datagram has no close to observe, so that is the one + // thing there with nothing but a count and an idle timeout to bound it; a TCP stream + // lets go of its state when it closes, and a limit on it would do nothing. + const limitsQueuedFlows = form.values.transport === Transport.NFQUEUE + && form.values.proto === L4.UDP + //: Whether the engine terminates and decrypts this service — TLS or QUIC. What the + // certificate fields, their validation and the payload all key off, because the two + // protocols need exactly the same thing from the operator. + const isEncrypted = decrypts(form.values) + // What the fold would tell you if you opened it. Built from what *differs* rather + // than listing every setting, so a service nobody has tuned says so in three words + // and a service that has been says exactly how. + const changed = [ + isProxy && form.values.max_connections > 0 + ? `at most ${form.values.max_connections} at once` + : null, + isProxy && form.values.max_connections > 0 && form.values.over_limit_forwards + ? "the excess forwarded unfiltered" + : null, + limitsQueuedFlows && form.values.max_connections > 0 + ? `at most ${form.values.max_connections} UDP flows held` + : null, + isProxy && form.values.first_byte_timeout > 0 + ? `silent for ${form.values.first_byte_timeout}s is closed` + : null, + !isProxy && !form.values.fail_open ? "fail-open off" : null, + ].filter(Boolean) as string[] + // Leaving TLS selected while the layer that can decrypt it is not would send a + // combination the backend refuses, and the operator would be looking at a protocol + // the form is no longer offering. + //: The layer is not offered for a service that is decrypted, so it has to be set + // rather than assumed: a form that hides a control still submits its value. + useEffect(() => { + if (isEncrypted && form.values.transport !== Transport.PROXY) + form.setFieldValue('transport', Transport.PROXY) + }, [isEncrypted]) + useEffect(() => { + if (form.values.transport === Transport.PROXY) return + // Neither survives leaving the layer that honours it. A stored limit that does + // nothing is the same trap as a stored protocol nothing can decrypt. + if (isEncrypted) form.setFieldValue('proto', L4.TCP) + if (form.values.first_byte_timeout !== 0) form.setFieldValue('first_byte_timeout', 0) + }, [form.values.transport]) + useEffect(() => { + if (!isProxy && !limitsQueuedFlows && form.values.max_connections !== 0) + form.setFieldValue('max_connections', 0) + }, [form.values.transport, form.values.proto]) + const isExternal = form.values.transport === Transport.EXTERNAL + //: `http` is the one protocol whose addresses are not all on the same transport, so + // it is the one where each of them has to say which it is. + const isHttp = form.values.proto === L4.HTTP + // The form is tall — addresses are a list, and the layer choice explains itself — so + // the fields scroll and the actions do not. A submit button that falls below the + // fold of a modal is a button that is not there. + return +
+ + + + + form.setFieldValue('proto', v)} /> + {/* Directly under the choice that asks for them, not at the far end of the + form. They used to sit below the advanced settings, which put the reason + and the request a screenful apart: an operator who had just picked HTTP for + a service answering in the clear met the fields with nothing next to them + saying which choice had produced them, and read it as firegex wanting a + certificate for a service that has none. */} + {isEncrypted ? <> + + form.setFieldValue('tls_cert', v)} + error={form.errors.tls_cert} /> + + form.setFieldValue('tls_key', v)} + error={form.errors.tls_key} /> + {stored ? + Both are already stored: leave these empty to keep them. + : null} + : null} + + + {edit ? null : <> + + + Addresses to protect + + {isExternal + ? "One service, one hand-off, as many addresses as it answers on — IPv4 and IPv6 together if that is how it is reachable." + : "One service, one filter chain, as many ways in as it has — IPv4 and IPv6 together if that is how it is reachable, and an interface name where the address is not yours to know. One more way in is one more address here."} + + + + form.insertListItem('addresses', emptyAddress())}> + + + + + + {form.values.addresses.map((_, index) => + 1} />)} + {/* Said once under the list rather than as a validation error per row: + the operator is choosing between an address and an interface while they + fill it in, and being told afterwards that half of what the picker + offered was never available here is the worse way to learn it. */} + + {isExternal + ? "Interface names are not offered on this layer: the return rule recognises your proxy by one address and port to put the original port back, and an interface is not one address." + : <> + An interface name — eth0, wg0, tun0 — + protects whatever address that link currently carries, which is what you + want when somebody else hands it out. An address protects that one alone. + {(form.values.proto === L4.UDP || form.values.proto === L4.QUIC) + && form.values.transport === Transport.PROXY + ? " On UDP and QUIC this layer binds a relay to the interface's own address, so it has to have one; NFQUEUE needs none." + : ""} + } + + + } + + {/* Not a choice for a service that is decrypted: decrypting means + terminating the connection, and one layer does that. A picker with two + greyed-out options and one answer is a question nobody is being asked, so + it is said in a line instead — said rather than dropped, because the layer + is a real property of the service and an operator who has read about the + trade should not have to wonder which side of it they are on. */} + {isEncrypted + ? + Carried by the proxy layer, which is the only one that can + decrypt: the connection is terminated here and reopened towards + the service. + + : form.setFieldValue('transport', v)} />} + + + {/* One fold for everything an operator sets once — after something went + wrong, or before a competition — and then never looks at again. Left in + the open they are three more things to read past on every service that is + created, and the form's own submit button is already fighting for room. + Folded is not hidden: the summary says what differs from the defaults, so + a service that has been tuned says so without being opened. + + Which settings are in here depends on the layer, because they belong to + different ones. The connection limits are the proxy layer's — it is the + one that accepts a connection and dials the service, and those two + descriptors are what a limit counts. Fail-open is NFQUEUE's: it hands the + kernel a verdict on packets already in flight, and what it can run out of + is queue. The hand-off layer runs nothing of ours, so it has neither. */} + {isExternal ? null : <> + + + + Advanced settings + + {changed.length > 0 + ? changed.join(" · ") + : "Everything at its default."} + + + + {isProxy ? <> + 0 + ? "Counted across TCP connections and UDP flows together — they spend the same descriptors." + : "0 means no limit. Without one, connections that are opened and then say nothing can exhaust firegex and take every other service down with this one."} + min={0} step={64} allowDecimal={false} + {...form.getInputProps('max_connections')} + /> + + 0 + ? "Until the first byte only, in either direction — a service that greets its client satisfies it too. Once a connection has spoken it is never closed for going quiet." + : "0 means never. This is what a limit alone cannot do: a connection opened and left silent holds a descriptor here and one on your service, having asked for nothing."} + min={0} step={5} allowDecimal={false} + {...form.getInputProps('first_byte_timeout')} + /> + {form.values.max_connections > 0 ? <> + + + : null} + : <> + + {limitsQueuedFlows ? <> + + 0 + ? "Past it, the flow quiet the longest loses what its Python filters were keeping, and starts over." + : "0 means no limit: a flow keeps its Python filters' state until it has been quiet for a minute."} + min={0} step={64} allowDecimal={false} + {...form.getInputProps('max_connections')} + /> + : null} + } + + + + + } + + + {edit ? null : <> + + + } + + {edit ? + + Changing any of this restarts the service. The connections it is carrying + are not cut; new ones get the new settings. + : null} + + + + {/* Outside the scrolling area, so it is reachable however long the form + gets. The error lives here too: a refusal you have to scroll to find is + a refusal you do not see. */} + + {error ? setError(null)}> + {error} + : null} + + + + + +
+} diff --git a/frontend/src/components/Services/AddRegexModal.tsx b/frontend/src/components/Services/AddRegexModal.tsx new file mode 100644 index 0000000..57a8540 --- /dev/null +++ b/frontend/src/components/Services/AddRegexModal.tsx @@ -0,0 +1,147 @@ +import { Alert, Button, Group, Modal, SegmentedControl, Space, Switch, Text, TextInput } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { okNotify } from '../../js/utils'; +import RegexDebugger from './RegexDebugger'; +import { Mode, Regex, serviceQueryKey, services, Transport } from './utils'; + +/** UTF-8 bytes to base64, because the API carries a pattern as bytes. */ +const encode = (text: string) => { + const bytes = new TextEncoder().encode(text) + let binary = "" + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary) +} + +/** UTF-8 text out of the base64 the API carries. */ +const decode = (b64: string) => { + try { + const binary = atob(b64) + const bytes = Uint8Array.from(binary, c => c.charCodeAt(0)) + return new TextDecoder().decode(bytes) + } catch { + return "" + } +} + +/** + * Add a pattern, or change one that is already there. + * + * One modal for both, because they are the same form and the same validation — the + * engine is asked whether it would accept the pattern either way. Editing keeps the + * rule's row, so it keeps its place in the filter and its counters; the one exception + * is the pattern text itself, where the counter is reset because a count is about what + * a rule matched and the rule now matches something else. + */ +export default function AddRegexModal({ opened, onClose, serviceId, filterId, transport, edit }: { + opened: boolean, + onClose: () => void, + serviceId: string, + filterId: string, + /** The service's network layer, which decides whether rewriting is possible at all. */ + transport?: string, + /** When given, the modal edits this pattern instead of adding one. */ + edit?: Regex, +}) { + const queryClient = useQueryClient() + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const [debugOpen, setDebugOpen] = useState(false) + + const form = useForm({ + initialValues: { + regex: "", + mode: Mode.BOTH as string, + case_sensitive: true, + }, + validate: { regex: v => v !== "" ? null : "A pattern is required" }, + }) + + useEffect(() => { + if (!opened) return + setError(null) + if (edit) form.setValues({ + regex: decode(edit.regex), + mode: edit.mode, + case_sensitive: edit.case_sensitive, + }) + else form.reset() + }, [opened, edit?.regex_id]) + + const close = () => { onClose(); form.reset(); setError(null) } + + const submit = async (values: typeof form.values) => { + setSubmitting(true) + setError(null) + const body = { + regex: encode(values.regex), + mode: values.mode, + case_sensitive: values.case_sensitive, + } + try { + const err = edit + ? await services.editRegex(serviceId, filterId, edit.regex_id, body) + : await services.addRegex(serviceId, filterId, body) + if (err) setError(err) + else { + okNotify(edit ? "Pattern updated" : "Pattern added", + "It is already in force; no connection was dropped") + queryClient.invalidateQueries({ queryKey: serviceQueryKey }) + close() + } + } catch (err) { + setError(`${err}`) + } + setSubmitting(false) + } + + return <> + +
+ + + + + + Which direction to match + + A leaked flag shows up on the way out; an exploit on the way in. + + + + + + + {edit ? + The rule keeps its place and its counters. Changing the pattern + itself clears them: a count is about what a rule matched, and this + one would then match something else. + : null} + + + + + + + {error ? setError(null)}>{error} : null} +
+ setDebugOpen(false)} + initial={form.values.regex ? [{ + expr: form.values.regex, + caseSensitive: form.values.case_sensitive, + }] : undefined} /> + +} diff --git a/frontend/src/components/Services/AddressList.tsx b/frontend/src/components/Services/AddressList.tsx new file mode 100644 index 0000000..2c25f0e --- /dev/null +++ b/frontend/src/components/Services/AddressList.tsx @@ -0,0 +1,427 @@ +import { CodeHighlight } from '@mantine/code-highlight'; +import { ActionIcon, Badge, Box, Button, Card, Code, Collapse, Group, Modal, SegmentedControl, Space, Stack, Text, Tooltip } from '@mantine/core'; +import { useForm } from '@mantine/form'; +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { BsPlusLg, BsTrashFill } from 'react-icons/bs'; +import { MdEdit } from 'react-icons/md'; +import { TbEye, TbShieldLock } from 'react-icons/tb'; +import { bareAddress, errorNotify, ipInterfacesQuery, isAddressOrInterface, isInterfaceName, isIpAddress, okNotify } from '../../js/utils'; +import { addressKind, BADGE_WIDTH } from '../InterfaceInput'; +import PortAndInterface from '../PortAndInterface'; +import YesNoModal from '../YesNoModal'; +import AddressOptions, { addressCapabilities, addressTags, edgeHint, EDGE_OPTIONS } from './AddressOptions'; +import { Address, decrypts, L4, protoLabel, Service, serviceQueryKey, services, Transport, Upstream } from './utils'; + +type Values = { + ip_int: string, port: number, proxy_ip: string, proxy_port: number, + /** `http` only: what is spoken at this address. */ + edge: string, + /** Where the service is, when it is not on this port. Empty means "it is". */ + target_port: number | string, + /** What the service behind this address speaks. */ + upstream: string, +} + +/** + * What one address of a service is reached over, in the words the operator chose it in. + * + * Every protocol but `http` has one answer for the whole service, and the header already + * says it. `http` is the one that does not: a TCP address there carries HTTP/1.1 and + * HTTP/2, and the UDP one beside it carries HTTP/3, so a row that said only "HTTP" would + * be describing two different things with one word. + */ +const edgeLabel = (service: Service, address: { edge?: string, proto: string }) => + service.proto === L4.HTTP + ? ({ [L4.TCP]: "HTTP", [L4.TLS]: "HTTPS", [L4.QUIC]: "HTTP/3" }[ + address.edge ?? address.proto] ?? "HTTP") + : protoLabel(service.proto) + +function AddressModal({ opened, onClose, service, edit }: { + opened: boolean, onClose: () => void, service: Service, edit?: Address, +}) { + const queryClient = useQueryClient() + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const isExternal = service.transport === Transport.EXTERNAL + const isHttp = service.proto === L4.HTTP + + const form = useForm({ + initialValues: { + ip_int: "127.0.0.1", port: 80, proxy_ip: "127.0.0.1", proxy_port: 8080, + edge: L4.TCP, target_port: "", upstream: Upstream.SAME, + }, + validate: { + ip_int: v => !isAddressOrInterface(v, { cidr: !isExternal }) + ? "Not an IP address, and not an interface name either" + : (isExternal && isInterfaceName(v) + ? "Your own proxy is handed one address: the return rule has to put the original port back, and an interface is not one address" + : null), + port: v => (v > 0 && v < 65536) ? null : "Invalid port", + proxy_ip: v => (!isExternal || isIpAddress(v)) ? null : "Not an IP address: your proxy is reached at one, never at an interface", + proxy_port: v => (!isExternal || (v > 0 && v < 65536)) ? null : "Your proxy's port is required", + }, + }) + + useEffect(() => { + if (!opened) return + setError(null) + if (edit) form.setValues({ + //: Only the prefix that means "this one address" comes off. Cutting at the + // first slash took a real range down to its first host and saved it as one, + // which is a service quietly protecting a fraction of what it did before. + ip_int: bareAddress(edit.ip_int), port: edit.port, + proxy_ip: edit.proxy_ip ?? "127.0.0.1", proxy_port: edit.proxy_port ?? 8080, + edge: edit.edge ?? edit.proto, target_port: edit.target_port ?? "", + upstream: edit.upstream ?? Upstream.SAME, + }) + 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 + // PUT over the whole row, and `0` is how an operator says the traffic goes back + // to arriving where it is sent. An absent field keeps whatever the row had. + const payload = { + ip_int: values.ip_int, port: values.port, + ...(isHttp ? { edge: values.edge } : {}), + ...(caps.canPublish ? { + target_port: Number(values.target_port) !== values.port + ? Number(values.target_port) || 0 : 0, + } : {}), + // 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 { + const err = edit + ? await services.editAddress(service.service_id, edit.address_id, payload) + : await services.addAddress(service.service_id, payload) + if (err) setError(err) + else { + okNotify(edit ? "Address updated" : "Address added", + `${values.ip_int}:${values.port} is now part of ${service.name}`) + queryClient.invalidateQueries({ queryKey: serviceQueryKey }) + onClose() + } + } catch (err) { + setError(`${err}`) + } + setBusy(false) + } + + return +
+ + {isHttp ? <> + + What clients speak here + + + {edgeHint(form.values.edge)} + {" "}The filters see the same HTTP/1.1 whichever it is, so the chain + does not change. + + : null} + {/* The same options the ⚙ on a row of the creation form offers, in the same + words, and offered on an **edit** as well as on an add. + + They were add-only while this endpoint rewrote the address and nothing else, + which left an operator who had set one of them while creating the service with + no way back to it short of deleting the address. The edit already takes the + rules back and reinstalls them, so rewriting the rest of the row costs + nothing more than what it already spends. */} + {anyOption ? <> + + name} proto={service.proto} + transport={service.transport} /> + : null} + + {edit + ? "Only this address stops being protected while the rules are replaced. The rest of the service keeps its connections." + : "The chain is already running, so this only points one more address at it. Nothing is dropped."} + + + + + + {error ? {error} : null} +
+} + +/** + * Everywhere one service is protected. + * + * A list rather than a field, because a service routinely answers on more than one + * address and one chain should cover all of them — two services with hand-copied + * chains is how one of them silently stops being protected. + */ +export default function AddressList({ service }: { service: Service }) { + const queryClient = useQueryClient() + const [addOpen, setAddOpen] = useState(false) + const [editing, setEditing] = useState
(null) + const [removing, setRemoving] = useState
(null) + const addresses = service.addresses ?? [] + const only = addresses.length === 1 + //: 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. + const interfaces = ipInterfacesQuery() + const carriedBy = (name: string) => (interfaces.data ?? []) + .filter(i => i.name === name && !i.addr.toLowerCase().startsWith("fe80:")) + .map(i => i.addr) + //: …and the other direction, which is the same question asked of an address row. + const arrivesOn = (addr: string) => (interfaces.data ?? []) + .filter(i => i.addr === addr).map(i => i.name) + + /** + * What a row says beside the address, in the words the picker used to choose it. + * + * The same pairing as the list it was chosen from — an interface carries addresses, + * an address sits on an interface — because this is where an operator comes back to + * check what they picked, and finding it described differently is a reason to wonder + * whether it is the same thing. + */ + const rowDetail = (ip_int: string) => { + if (isInterfaceName(ip_int)) { + if (!interfaces.data) return "" + const ips = carriedBy(ip_int) + return ips.length > 0 ? `now ${ips.join("\u00a0· ")}` : "no address on it right now" + } + const names = arrivesOn(bareAddress(ip_int)) + return names.length > 0 ? `on ${names.join(", ")}` : "" + } + + const remove = async (address: Address) => { + try { + const err = await services.deleteAddress(service.service_id, address.address_id) + if (err) errorNotify("Could not remove the address", err) + else { + okNotify("Address removed", `${address.ip_int}:${address.port} is no longer protected`) + queryClient.invalidateQueries({ queryKey: serviceQueryKey }) + } + } catch (err) { + errorNotify("Could not remove the address", `${err}`) + } + } + + return <> + + + Protected addresses + + {service.transport === Transport.EXTERNAL + ? "Each one handed to your own proxy. Adding one costs no connections." + : "Addresses, or the interfaces they arrive on. The same chain runs on every one of them, and adding one costs no connections."} + + + + + + + {addresses.map(address => { + const kind = addressKind(address.ip_int) + const detail = rowDetail(address.ip_int) + return + + {/* The row a service is read from is laid out like the list it was + chosen in: the same badge, at the same width, then the value, then + what is on it. No protocol badge — it is a property of the service, + the header already says it once, and repeating it on every row is + noise that grows with the list. */} + {/* Grows to everything the buttons do not take, rather than to its + own contents. Sized to content, each row was as wide as its own + longest line — so whether the note beside the badges fitted was + decided by how long that note is, and the row carrying a full + IPv6 kept it inline while the one saying `now 127.0.0.1` pushed + it onto a second line. Two rows of one list, wrapping opposite + ways, for a reason nothing on screen explains. */} + + + + {kind.label} + + + {/* The badge is a column of its own, exactly as in the list this was + chosen from: everything that has to wrap wraps under the value + rather than under the badge. */} + + {bareAddress(address.ip_int)}:{address.port} + {decrypts(service) ? + }> + {edgeLabel(service, address)} + + : null} + {/* Said on the row, and in the same words the panel that sets + 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, + addressCapabilities(service.proto, service.transport, address.edge)).map(tag => + + setEditing(address)}> + {tag.label} + + )} + {/* The note takes a line of its own rather than the corner of + this one. Its basis is a full-length IPv6 with its prefix — when + that much room is not left beside the badges, the whole note wraps + down instead of being squeezed into the gap, which is what put + `fd07:…:fe4` and `0:be17` on two lines with the digits broken + between them. It shrank that far because the basis was 80px and + `anywhere` let it: a tag or two added to the row is all it took to + use the space up. The basis is the whole of the rule — a floor + beside it was tried and is what overflowed the card on a phone, + where the column is narrower than any floor worth setting, and it + bought nothing: an item only shares a line when its basis fitted + there, so on a line of its own it is already as wide as it can be. + `anywhere` stays as the last resort, so a phone wraps an address + rather than pushing it past the edge. */} + {detail ? + {detail} + : null} + + + + + setEditing(address)}> + + + + + + setRemoving(address)}> + + + + + + + + })} + + + + + setAddOpen(false)} service={service} /> + setEditing(null)} service={service} + edit={editing ?? undefined} /> + setRemoving(null)} + action={async () => { if (removing) await remove(removing) }} + /> + +} + +/** + * Where the decrypted traffic of every TLS and QUIC service can be watched. + * + * The engine decrypts inside the process that filters, so the plaintext never becomes + * packets on any interface — which is exactly what removed the two loopback ports a TLS + * service used to occupy, and would also have removed any way to watch it. So the engine + * writes the decrypted stream out itself, onto `firegex0`: one interface carrying every + * TLS service's plaintext and nothing else, which is what a capture tool wants to be + * pointed at. + * + * What arrives there is a **reconstruction** — the engine frames the bytes it decrypted + * as the TCP stream they were, because the stream that actually crossed the wire was + * encrypted. Wireshark follows it normally; it is not the wire, and the interface says so. + * + * A QUIC service is carried there too, one TCP stream per QUIC stream, which costs one + * more piece of invention: the streams of one connection share its four-tuple, so each is + * given a synthetic client port to be told apart by. An operator reading a port out of + * that capture has to know it names a stream and not a socket, which is why the panel + * says so rather than leaving it to be discovered. + * + * Collapsed by default: it is a thing you go looking for while debugging a filter that is + * not matching, not something that should sit in the way the rest of the time. + */ +function PlaintextCapture({ service }: { service: Service }) { + const [open, setOpen] = useState(false) + if (!decrypts(service)) return null + // An HTTPS service carries QUIC too, on whichever of its addresses say HTTP/3, and the + // invented port needs saying there exactly as much. + const quic = service.proto === L4.QUIC + || (service.proto === L4.HTTP && (service.addresses ?? []).some(a => a.edge === L4.QUIC)) + + return + + + + + + + Every decrypted service's traffic is written to firegex0, and + nothing else is. Point Wireshark or tcpdump at it on the host running + firegex — the container shares its network namespace, so the interface is + there — and you get the decrypted traffic of the whole instance, both + directions, with no filter to write. + + + + + + These are reconstructed packets, not the ones that crossed the wire — what + crossed the wire was encrypted, and the engine decrypts inside the process + rather than putting the plaintext back on a socket. That is what lets a TLS + service occupy no extra port at all. What is written is decrypted traffic: + as sensitive as the private key that would have produced it, so treat the + file the same way. + + {quic ? <> + + + On QUIC each stream arrives as a TCP stream of its own, and over + HTTP/3 that is one per request — what you see is the same HTTP/1.1 + the filters were shown, because what crossed the wire was a + compressed header block inside an encrypted packet. The client + port is invented: the streams of one connection share the + client's real port, so each is given one of its own to be told apart + by. It names a stream, not a socket — the address beside it is real. + + : null} + + +} diff --git a/frontend/src/components/Services/AddressOptions.tsx b/frontend/src/components/Services/AddressOptions.tsx new file mode 100644 index 0000000..8cb3927 --- /dev/null +++ b/frontend/src/components/Services/AddressOptions.tsx @@ -0,0 +1,165 @@ +import { Box, NumberInput, SegmentedControl, Stack, Text } from '@mantine/core'; +import PortAndInterface from '../PortAndInterface'; +import { decrypts, L4, Transport, Upstream } from './utils'; + +/** + * Everything an address can say beyond where it is, written once. + * + * Two places ask for it — the rows of the creation form, behind each row's ⚙, and the + * modal on the service's own page — and they used to ask in two sets of words. The same + * setting described twice reads as two settings, which is the whole reason this file + * exists; the capability rules below were written twice as well, so the form could offer + * something the backend then refused. + * + * The wording rule here: **say what firegex will do**, not what the field is called. + * "The service is on port" and "The service behind speaks" were both true and both left + * the operator to work out the effect for themselves. + */ + +/** + * 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, + /** `http` is the one protocol whose addresses are not all the same. */ + isHttp: proto === L4.HTTP, + //: Sending traffic somewhere other than where it arrived takes something that + // *dials*, and that is the layer rather than the protocol: the proxy opens the + // 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 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), + } +} + +/** What clients speak at one address of an HTTPS service. Shared so the row, the modal + * and the badge on the service page cannot come to call them three different things. */ +export const EDGE_OPTIONS = [ + { label: 'Clear', value: L4.TCP }, + { label: 'TLS', value: L4.TLS }, + { label: 'HTTP/3', value: L4.QUIC }, +] + +export const edgeHint = (edge: string) => + edge === L4.QUIC + ? "A UDP port speaking HTTP/3." + : edge === L4.TLS + ? "Encrypted only: a client that arrives in the clear here is refused." + : "Cleartext HTTP/1.1 and HTTP/2 — and a client that brings TLS anyway is taken too." + +export const upstreamHint = (upstream: string) => + upstream === Upstream.TCP + ? "Firegex decrypts and sends plain HTTP/1.1. Only firegex holds a certificate." + : upstream === Upstream.TLS + ? "Firegex encrypts with TLS, even for a client that arrived over HTTP/3." + : "Firegex sends what the client sent, encrypted again — so the service holds a certificate of its own." + +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, + proxy_port?: number | null, +} + +/** + * What is switched on for one address, in a word each. + * + * Shown under the address in the form and beside it on the service page — the same + * labels in both, because this is where an operator comes back to check what they set, + * and finding it named differently is a reason to wonder whether it is the same thing. + */ +export function addressTags(values: Extras, caps: ReturnType) { + const tags: { label: string, hint: string }[] = [] + const target = Number(values.target_port) + if (caps.canPublish && target && target !== values.port) tags.push({ + label: `→ :${target}`, + hint: `Clients arrive on :${values.port} — firegex dials the service on :${target}, which is where it listens.`, + }) + if (caps.canChooseUpstream && values.upstream === Upstream.TCP) tags.push({ + label: "plaintext to the service", + hint: upstreamHint(Upstream.TCP), + }) + if (caps.canChooseUpstream && values.upstream === Upstream.TLS) tags.push({ + label: "TLS to the service", + hint: upstreamHint(Upstream.TLS), + }) + if (caps.isExternal && values.proxy_port) tags.push({ + label: `→ ${values.proxy_ip ?? "127.0.0.1"}:${values.proxy_port}`, + hint: "Traffic for this address is handed to your own proxy there, and put back on the way out.", + }) + return tags +} + +/** + * The controls themselves, over whichever form is asking. + * + * `field` is what turns `target_port` into the name this form knows it by — bare in the + * modal, `addresses.3.target_port` in a row of the creation form. + */ +export default function AddressOptions({ form, field, values, proto, transport }: { + form: any, + field: (name: string) => string, + /** This address's own values, which is what the descriptions read back. */ + values: Extras, + proto: string, + transport: string, +}) { + 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) + + return + {/* The label names the fact, and the description says what firegex does with + it, because an effect alone leaves the direction open when both ends are + ports. "Send it to another port" was read as "expose it on another port" and + filled in the other way round: the service's own port typed into the address, + and the port to publish on typed here — a service intercepted where it already + listens and published nowhere. Both descriptions name **both** ends for that + reason, and the empty one says where a second way in actually comes from. */} + {caps.canPublish ? + : null} + {caps.canChooseUpstream ? + Send to the service + {upstreamHint(upstream)} + + : null} + {caps.isExternal ? + + + Its own port: the way back is recognised by it, so two addresses cannot + share one. + + : null} + +} diff --git a/frontend/src/components/Services/AddressRow.tsx b/frontend/src/components/Services/AddressRow.tsx new file mode 100644 index 0000000..9236795 --- /dev/null +++ b/frontend/src/components/Services/AddressRow.tsx @@ -0,0 +1,94 @@ +import { ActionIcon, Badge, Box, Group, Popover, SegmentedControl, Space, Text, Tooltip } from '@mantine/core'; +import { useState } from 'react'; +import { BsTrashFill } from 'react-icons/bs'; +import { IoSettingsSharp } from 'react-icons/io5'; +import PortAndInterface from '../PortAndInterface'; +import AddressOptions, { addressCapabilities, addressTags, edgeHint, EDGE_OPTIONS } from './AddressOptions'; + +/** + * One address being filled in, and everything that can be said about it. + * + * The row itself stays what it was — where the service answers, and on an HTTPS service + * what is spoken there — because that is what every address needs and most addresses need + * only that. Everything else is **behind one button**: traffic sent on to another port, + * what firegex speaks to the service, the endpoint of somebody else's proxy. Those were + * inline once, each of them, and a list of three addresses became a screenful of fields + * for cases most services never have. + * + * What is set is said back on the row as **tags**, and a tag opens the panel it came + * from: folded is not hidden, and an option an operator cannot see they set is an option + * that will surprise them at the worst moment. + * + * Which options exist at all, and how they are worded, live in `AddressOptions` — the + * modal on the service's own page asks for the same things and has to ask in the same + * words. + */ +export default function AddressRow({ form, index, proto, transport, canRemove }: { + form: any, + index: number, + /** What the service speaks, which decides which options exist at all. */ + proto: string, + transport: string, + canRemove: boolean, +}) { + const [open, setOpen] = useState(false) + const address = form.values.addresses[index] + const caps = addressCapabilities(proto, transport, address.edge) + const tags = addressTags(address, caps) + const anything = caps.canPublish || caps.canChooseUpstream || caps.isExternal + + return + + + + + {/* What is spoken here — on the row rather than in the panel, because on an + HTTPS service it is not an extra: it is what this address *is*. */} + {caps.isHttp ? + + : null} + {anything ? + + + setOpen(o => !o)}> + + + + + + + Only this address. Everything here is off unless you set it. + + `addresses.${index}.${name}`} + proto={proto} transport={transport} /> + + : null} + form.removeListItem('addresses', index)}> + + + + {/* What was set, under the address it was set on. Clicking one opens the panel + it came from — a tag that says a thing is on and cannot take you to it is a + label, and the operator is left hunting for where they set it. */} + {tags.length > 0 ? + {tags.map(tag => + setOpen(true)}> + {tag.label} + + )} + : null} + + +} diff --git a/frontend/src/components/Services/CodeEditorModal.tsx b/frontend/src/components/Services/CodeEditorModal.tsx new file mode 100644 index 0000000..49a57d4 --- /dev/null +++ b/frontend/src/components/Services/CodeEditorModal.tsx @@ -0,0 +1,206 @@ +import { Alert, Badge, Box, Button, Code, FileButton, Group, Loader, Modal, Space, Text, Tooltip } from '@mantine/core'; +import Editor, { Monaco } from "@monaco-editor/react"; +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect, useRef, useState } from 'react'; +import { MdUploadFile } from 'react-icons/md'; +import { TbCode } from 'react-icons/tb'; +import { getErrorMessage, okNotify } from '../../js/utils'; +import { registerPyfilterHints } from './pyfilterHints'; +import { CodeCheck, pyfilterApiQuery, serviceQueryKey, services } from './utils'; + +/** + * Deliberately a working example rather than a sketch: every parameter is annotated + * with a model, which is what decides when the filter is called, and the return is a + * packet statement. A filter whose parameters are unannotated is simply never valid. + */ +const STARTER = `from firegex.pyfilters import pyfilter, ACCEPT, REJECT +from firegex.pyfilters.models import HttpRequest + + +@pyfilter +def block_path_traversal(http_request: HttpRequest): + """Called once the request line and headers have been parsed.""" + if b"../" in http_request.url.encode(): + return REJECT + return ACCEPT +`; + +export default function CodeEditorModal({ opened, onClose, serviceId, filterId, filterName }: { + opened: boolean, + onClose: () => void, + serviceId: string, + filterId: string, + filterName: string, +}) { + const queryClient = useQueryClient() + const [code, setCode] = useState("") + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [check, setCheck] = useState(null) + const [checking, setChecking] = useState(false) + const api = pyfilterApiQuery() + const monacoRef = useRef(null) + const editorRef = useRef(null) + const disposeHints = useRef<(() => void) | null>(null) + + // Checked as you pause, by the process that will run it. The answer carries the + // line, so the reason lands where the mistake is instead of in a message that + // leaves you to go and find it. + useEffect(() => { + if (!opened) return + const timer = setTimeout(() => { + setChecking(true) + services.checkCode(serviceId, filterId, code) + .then(setCheck) + .catch(() => setCheck(null)) + .finally(() => setChecking(false)) + }, 700) + return () => clearTimeout(timer) + }, [code, opened, serviceId, filterId]) + + // The marker is what makes the error findable; the text under the editor is what + // makes it readable. Both come from the same answer. + useEffect(() => { + const monaco = monacoRef.current + const editor = editorRef.current + if (!monaco || !editor) return + const model = editor.getModel() + if (!model) return + const bad = check && !check.ok ? check.error : null + monaco.editor.setModelMarkers(model, "firegex", bad && bad.line > 0 ? [{ + severity: monaco.MarkerSeverity.Error, + message: `${bad.type}: ${bad.message}`, + startLineNumber: bad.line, + endLineNumber: bad.line, + startColumn: bad.column > 0 ? bad.column : 1, + endColumn: Math.max((bad.text?.length ?? 0) + 1, 2), + }] : []) + }, [check]) + + useEffect(() => () => { disposeHints.current?.() }, []) + + useEffect(() => { + if (!opened) return + setError(null) + services.code(serviceId, filterId) + // The endpoint answers text/plain, so an empty file is an empty string + // rather than a missing value. + .then(current => setCode(typeof current === "string" && current !== "" ? current : STARTER)) + .catch(err => setError(getErrorMessage(err))) + }, [opened, serviceId, filterId]) + + const onMount = (editor: any, monaco: Monaco) => { + editorRef.current = editor + monacoRef.current = monaco + if (api.data) { + disposeHints.current?.() + disposeHints.current = registerPyfilterHints(monaco, api.data) + } + } + + // The description arrives asynchronously; if it lands after the editor mounted, the + // providers are registered then rather than never. + useEffect(() => { + if (!api.data || !monacoRef.current) return + disposeHints.current?.() + disposeHints.current = registerPyfilterHints(monacoRef.current, api.data) + }, [api.data]) + + const save = () => { + setLoading(true) + services.setCode(serviceId, filterId, code).then(res => { + setLoading(false) + if (res) { setError(res); return } + queryClient.invalidateQueries({ queryKey: serviceQueryKey }) + onClose() + okNotify("Filter updated", "Reloaded without dropping a single connection") + }).catch(err => { + setLoading(false) + // Code that will not load is refused and the previous file keeps running. + // Saying why beats a generic failure. + setError(getErrorMessage(err)) + }) + } + + const loadFile = (file: File | null) => { + if (file) file.text().then(setCode) + } + + return {filterName}} + size="80%" + closeOnClickOutside={false} + centered + styles={{ body: { display: 'flex', flexDirection: 'column', height: '70vh' } }} + > + + Runs in a process of its own, so a filter that hangs is killed rather than + stalling the traffic. Each connection gets its own module globals. + + + setCode(value || "")} + options={{ + minimap: { enabled: false }, + fontSize: 14, + fontFamily: 'JetBrains Mono, monospace', + lineHeight: 1.5, + scrollBeyondLastLine: false, + smoothScrolling: true, + }} + /> + + {/* What the check just said, under the editor where the code is. A refusal + names the line and shows it; a file that loads says what it defines and + which protocol that makes it, which is the thing operators most often get + wrong by assumption. */} + + {error ? setError(null)}> + {error} + : null} + {check && !check.ok && check.error ? + + + {check.error.type} + {check.error.line > 0 ? <> at line {check.error.line} : null} + {" — "}{check.error.message} + {check.error.text ? <>
{check.error.text.trim()} : null} +
+ {check.error.traceback ? + + traceback + + : null} +
+
: null} + {check?.ok ? + loads + + {check.filters.length} function{check.filters.length === 1 ? "" : "s"} + {check.filters.length ? `: ${check.filters.join(", ")}` : ""} + {" • speaks "}{check.proto} + + {checking ? : null} + : null} +
+ + + + {props => ( + + )} + + + +
+} diff --git a/frontend/src/components/Services/FilterCard.tsx b/frontend/src/components/Services/FilterCard.tsx new file mode 100644 index 0000000..da42a7e --- /dev/null +++ b/frontend/src/components/Services/FilterCard.tsx @@ -0,0 +1,280 @@ +import { ActionIcon, Badge, Box, Button, Card, Code, Collapse, Group, Space, Stack, Switch, Text, Tooltip } from '@mantine/core'; +import { CodeHighlight } from '@mantine/code-highlight'; +import { useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { BsPlusLg, BsTrashFill } from 'react-icons/bs'; +import { MdEdit } from 'react-icons/md'; +import { FaCode, FaRegClone } from 'react-icons/fa'; +import { MdDragIndicator, MdExpandLess, MdExpandMore } from 'react-icons/md'; +import { VscRegex } from 'react-icons/vsc'; +import { errorNotify, okNotify } from '../../js/utils'; +import YesNoModal from '../YesNoModal'; +import AddRegexModal from './AddRegexModal'; +import CodeEditorModal from './CodeEditorModal'; +import RegexDebugger from './RegexDebugger'; +import { Filter, filterCodeQuery, filterFunctionsQuery, filterRegexesQuery, FilterKind, modeLabel, Regex, serviceQueryKey, services } from './utils'; + +const decode = (b64: string) => { + try { return decodeURIComponent(escape(atob(b64))) } catch { return atob(b64) } +} + +/** + * One link in the chain, with whatever it holds. + * + * A regex filter holds patterns, compiled together into one hyperscan database — which + * is why they live inside a filter rather than each being one. A pyfilter holds a + * Python module. + */ +export default function FilterCard({ filter, serviceId, dragHandle, position, transport, proto }: { + filter: Filter, + serviceId: string, + dragHandle?: any, + position: number, + /** The service's network layer, which decides whether rewriting is offered at all. */ + transport?: string, + /** The service's transport protocol, which decides what a filter is actually shown. */ + proto?: string, +}) { + const queryClient = useQueryClient() + const isRegex = filter.kind === FilterKind.REGEX + const regexes = filterRegexesQuery(isRegex ? serviceId : "", isRegex ? filter.filter_id : "") + const functions = filterFunctionsQuery(isRegex ? "" : serviceId, isRegex ? "" : filter.filter_id) + const [addOpen, setAddOpen] = useState(false) + // Which pattern the edit modal is about, kept separately from whether it is open: + // clearing it on close would turn the dialog back into "Add a pattern" while it is + // still fading out. + const [editing, setEditing] = useState(null) + const [editOpen, setEditOpen] = useState(false) + const [codeOpen, setCodeOpen] = useState(false) + const [debugOpen, setDebugOpen] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) + // Open by default: for a pyfilter the code *is* the filter, and a card that hides it + // shows a name and a counter — everything about the thing except what it does. It + // still collapses, for a chain long enough that the page needs scrolling. + const [showCode, setShowCode] = useState(true) + const [busy, setBusy] = useState(false) + const code = filterCodeQuery(isRegex ? "" : serviceId, isRegex ? "" : filter.filter_id) + + const refresh = () => queryClient.invalidateQueries({ queryKey: serviceQueryKey }) + + const run = async (what: () => Promise, failure: string) => { + setBusy(true) + try { + const err = await what() + if (err) errorNotify(failure, err) + else refresh() + } catch (err) { + errorNotify(failure, `${err}`) + } + setBusy(false) + } + + return <> + + + + {dragHandle ? + + : null} + {position + 1} + {isRegex ? : } + + + {filter.name} + + {isRegex ? "REGEX" : "PYFILTER"} + + {/* Only when it says something. A pyfilter's `tcp` is the + library's name for "asks for nothing in particular", and + showing it beside a service that speaks UDP read as a + contradiction — two different things wearing the same + three letters. */} + {!isRegex && filter.proto === "http" ? + + + HTTP + + : null} + {filter.blocked > 0 ? + + {filter.blocked} blocked + : null} + + + {isRegex + ? `${filter.n_regexes} pattern${filter.n_regexes === 1 ? "" : "s"}, matched in one pass` + : `${filter.n_functions} function${filter.n_functions === 1 ? "" : "s"}` + + (filter.n_functions_active < filter.n_functions + ? `, ${filter.n_functions - filter.n_functions_active} switched off` + : "") + + // What it is actually shown, which depends on what the + // service carries — a datagram has no stream to assemble. + (filter.proto === "http" + ? " • over parsed HTTP requests and responses" + : proto === "udp" + ? " • over raw datagrams" + : " • over raw payloads and TCP streams")} + + + + + + + run( + () => services.editFilter(serviceId, filter.filter_id, { active: e.currentTarget.checked }), + "Could not change the filter", + )} /> + + {isRegex + ? <> + + setDebugOpen(true)}> + + + + + setAddOpen(true)}> + + + + + : <> + + setShowCode(v => !v)}> + {showCode ? : } + + + + setCodeOpen(true)}> + + + + } + + setDeleteOpen(true)}> + + + + + + + {isRegex && regexes.data && regexes.data.length > 0 ? <> + + + {regexes.data.map(rx => + + + {decode(rx.regex)} + + block + {modeLabel(rx.mode)} + {rx.case_sensitive ? null : + any case} + {rx.blocked > 0 ? + {rx.blocked} : null} + + + run( + () => services.editRegex(serviceId, filter.filter_id, rx.regex_id, { active: e.currentTarget.checked }), + "Could not change the pattern", + )} /> + + { setEditing(rx); setEditOpen(true) }}> + + + + run( + () => services.deleteRegex(serviceId, filter.filter_id, rx.regex_id), + "Could not delete the pattern", + )}> + + + + )} + + : null} + + {/* One line per `@pyfilter` the code defines. The code says which exist; + these switches say which run, without the code being edited — deleting a + function to stop consulting it and pasting it back to resume is what + this replaces. */} + {!isRegex && (functions.data?.length ?? 0) > 0 ? <> + + + {functions.data!.map(fn => + + {fn.name}() + {fn.blocked > 0 ? + {fn.blocked} : null} + {fn.active ? null : + off} + + + run( + () => services.editFunction(serviceId, filter.filter_id, fn.name, + { active: e.currentTarget.checked }), + `Could not change ${fn.name}`, + )} /> + + )} + + : null} + + {!isRegex && (functions.data?.length ?? 0) === 0 ? <> + + + + No @pyfilter function yet, so this filter inspects nothing. + + + + : null} + + {!isRegex ? + + + + + : null} + + {isRegex && filter.n_regexes === 0 ? <> + + + No patterns yet, so this filter blocks nothing. + + + : null} + + + setAddOpen(false)} + serviceId={serviceId} filterId={filter.filter_id} transport={transport} /> + setEditOpen(false)} + serviceId={serviceId} filterId={filter.filter_id} transport={transport} + edit={editing ?? undefined} /> + setCodeOpen(false)} + serviceId={serviceId} filterId={filter.filter_id} filterName={filter.name} /> + setDebugOpen(false)} + initial={(regexes.data ?? []).map(rx => ({ + expr: decode(rx.regex), + caseSensitive: rx.case_sensitive, + }))} /> + setDeleteOpen(false)} + action={() => run(() => services.deleteFilter(serviceId, filter.filter_id), "Could not remove the filter")} + /> + +} diff --git a/frontend/src/components/Services/LayerChoice.tsx b/frontend/src/components/Services/LayerChoice.tsx new file mode 100644 index 0000000..2154495 --- /dev/null +++ b/frontend/src/components/Services/LayerChoice.tsx @@ -0,0 +1,210 @@ +import { ActionIcon, Alert, Badge, Box, Divider, Group, Popover, SegmentedControl, Space, Stack, Text, Tooltip } from '@mantine/core'; +import { useState } from 'react'; +import { FaMinus, FaPlus } from 'react-icons/fa'; +import { IoInformationCircleOutline } from 'react-icons/io5'; +import { carries, L4, Transport } from './utils'; + +/** + * Choosing the network layer. + * + * Three lines in the form — the control, and one sentence about what is selected — with + * the trade a click away in a popover. Earlier attempts put the whole comparison inline, + * first as a three-column table and then as a list of bullets; both were permanent + * clutter for something read once, and both pushed the form's own submit button off the + * screen. What stays inline is what you need while choosing; what opens is what you need + * while deciding. + */ + +type Layer = { + /** The headline, one line, carrying the reason you would pick this. */ + line: string, + good: string[], + bad: string[], + /** Folded in when the service speaks UDP, where the trade moves. */ + udpGood?: string[], + udpBad?: string[], + /** And when it speaks QUIC, where it moves again and further. */ + quicGood?: string[], + quicBad?: string[], + /** And when it speaks HTTP, which is every version of it at once. */ + httpGood?: string[], + httpBad?: string[], +} + +const LAYERS: Record = { + [Transport.PROXY]: { + line: "Terminates and reopens the connection — the faster of the two, and the only one that can decrypt TLS or QUIC.", + good: [ + "Carries bulk traffic 2-5× faster, and scales better with threads", + "Python filters rewrite a payload exactly, at any length", + "The kernel does the reassembly", + "Any number of filters, in one process", + "A slow filter slows the sender; nothing is dropped", + "Your service still sees the real client address", + ], + bad: [ + "Fail-open is rebuilt in userspace, not the kernel's", + ], + udpGood: [ + "One relay socket per address — no per-datagram state to recover", + "Your service still sees the real client address (transparent IP spoofing)", + ], + quicGood: [ + "The only layer that can see inside QUIC at all", + "Every stream is filtered on its own, with its own state", + "One endpoint per address, and the client address still reaches the service", + "Each stream reaches firegex0 as its own TCP stream, HTTP/3 as HTTP/1.1", + ], + quicBad: [ + "No 0-RTT: early data is replayable, and a refusal cannot be taken back", + "No datagrams on HTTP/3, and no WebTransport", + "A refused stream closes the connection carrying it", + ], + httpGood: [ + "HTTP/1.1, HTTP/2 and HTTP/3 through one chain, with one certificate", + "One filter file and one pattern cover all three: every version is shown as HTTP/1.1", + "Each address picks its own edge — cleartext, TLS, or HTTP/3", + "gRPC is filtered, request line and headers included, trailers included", + "TLS is decided per connection, so one port may serve both", + ], + httpBad: [ + "A certificate is needed even for the cleartext addresses beside it", + "Every HTTP/2 stream is its own connection to a filter, unlike keep-alive on HTTP/1.1", + "Server push and CONNECT tunnels are refused rather than rendered", + ], + }, + [Transport.NFQUEUE]: { + line: "Verdicts on the real packets — nothing in the path, and the kernel keeps forwarding if a filter dies.", + good: [ + "Your service sees the original packets, from the original client", + "If the filter process dies, traffic keeps flowing — by kernel guarantee", + "Nothing is terminated: no relay, no second connection", + ], + bad: [ + "A userspace round trip per packet: a fraction of the bulk throughput", + "Reassembly is rebuilt in userspace", + "One process per filter, up to eight", + "A Python filter rewriting a TCP payload desynchronises the stream", + ], + udpGood: ["Fully transparent on UDP, and Python rewrites datagrams exactly"], + }, + [Transport.EXTERNAL]: { + line: "Steers the traffic into a proxy you wrote — any protocol, but firegex inspects nothing.", + good: [ + "Any protocol, however exotic", + "Two stateless rules; firegex runs nothing", + "Your proxy sits in the path invisibly", + ], + bad: [ + "No filter can be attached — your proxy does the work", + "No blocks, no statistics, no log: firegex sees nothing", + ], + }, +} + +const LABELS: Record = { + [Transport.PROXY]: "Proxy", + [Transport.NFQUEUE]: "NFQUEUE", + [Transport.EXTERNAL]: "Your own proxy", +} + +function Points({ items, good }: { items: string[], good: boolean }) { + return + + + {good ? : } + + + {good ? "What it buys" : "What it costs"} + + + {items.map(text => + {text} + )} + +} + +export default function LayerChoice({ value, onChange, proto }: { + value: string, + onChange: (value: string) => void, + proto: string, +}) { + const [open, setOpen] = useState(false) + const layer = LAYERS[value] ?? LAYERS[Transport.PROXY] + const udp = proto === L4.UDP + const quic = proto === L4.QUIC + const http = proto === L4.HTTP + const unavailable = Object.keys(LABELS).filter(t => !carries(t, proto)) + const good = [...layer.good, ...(udp ? layer.udpGood ?? [] : []), + ...(quic ? layer.quicGood ?? [] : []), ...(http ? layer.httpGood ?? [] : [])] + const bad = [...layer.bad, ...(udp ? layer.udpBad ?? [] : []), + ...(quic ? layer.quicBad ?? [] : []), ...(http ? layer.httpBad ?? [] : [])] + + return <> + + + Network layer + + How the traffic is intercepted. What is done with it is up to the filters. + + + {/* Above the control, not beside it: the layer buttons stay clickable while + it is open, so the comparison can be read by switching between them. */} + + + {/* The tooltip is silenced while the popover is open, or both anchor + to the same target and the tooltip renders as an empty frame + behind it. */} + + setOpen(o => !o)}> + + + + + + + + {LABELS[value]} + + {udp || quic || http + ? + {proto.toUpperCase()} + + : null} + + {layer.line} + + + + + + + + + {/* Disabled rather than hidden, unlike the protocol picker above: hiding two of + three would leave a control with one option, which is not a control. A + greyed-out button that says why is a button you can learn from. */} + ({ + label, + value: transport, + disabled: !carries(transport, proto), + }))} + /> + + {layer.line} + {/* One line. What is worth reading here is which layers are out and why, not the + whole argument for it — that is what the documentation is for. */} + {unavailable.length > 0 ? + {unavailable.map(t => LABELS[t]).join(" and ")} cannot carry encrypted + traffic: decrypting means terminating the connection, and only this layer + does that. + : null} + +} diff --git a/frontend/src/components/Services/LogPanel.tsx b/frontend/src/components/Services/LogPanel.tsx new file mode 100644 index 0000000..6988107 --- /dev/null +++ b/frontend/src/components/Services/LogPanel.tsx @@ -0,0 +1,94 @@ +import { ActionIcon, Badge, Box, Code, Group, ScrollArea, Switch, Text, Tooltip } from '@mantine/core'; +import { useEffect, useRef, useState } from 'react'; +import { BsTrashFill } from 'react-icons/bs'; +import { socketio } from '../../js/utils'; +import { LogEntry, services } from './utils'; + +const COLOURS: Record = { + block: 'var(--mantine-color-yellow-4)', + warn: 'var(--mantine-color-orange-4)', + error: 'var(--mantine-color-red-4)', + output: 'var(--mantine-color-cyan-4)', + info: 'var(--mantine-color-dimmed)', +} + +const clock = (at: number) => new Date(at).toLocaleTimeString(undefined, { hour12: false }) + +/** + * What the service is doing, as it does it. + * + * The backend pushes lines over the `log` socket event rather than being polled, and + * coalesces them, so a service refusing thousands of connections a minute produces a + * readable stream instead of a browser that stops responding. The tail is bounded on + * both sides for the same reason. + */ +export default function LogPanel({ serviceId }: { serviceId: string }) { + const [entries, setEntries] = useState([]) + const [follow, setFollow] = useState(true) + const viewport = useRef(null) + const followRef = useRef(follow) + followRef.current = follow + + useEffect(() => { + let live = true + setEntries([]) + services.logs(serviceId).then(tail => { if (live) setEntries(tail) }).catch(() => { }) + + const onLog = (payload: { service_id: string, entries: LogEntry[] }) => { + if (payload.service_id !== serviceId) return + setEntries(current => { + // Keyed by seq because a reconnect can replay what we already have, and + // the same text at the same second is not the same line. + const seen = new Set(current.map(e => e.seq)) + const added = payload.entries.filter(e => !seen.has(e.seq)) + if (added.length === 0) return current + // Bounded here too: the panel is a tail, not a transcript. + return [...current, ...added].slice(-500) + }) + } + socketio.on("log", onLog) + return () => { live = false; socketio.off("log", onLog) } + }, [serviceId]) + + useEffect(() => { + if (!followRef.current || !viewport.current) return + viewport.current.scrollTo({ top: viewport.current.scrollHeight }) + }, [entries]) + + return + + + Live log + {entries.length} + + + + setFollow(e.currentTarget.checked)} /> + + + services.clearLogs(serviceId).then(() => setEntries([]))}> + + + + + + + + {entries.length === 0 + ? + Nothing yet. Blocks, anything your filters print, and whatever the + datapath says about its own health all show up here as it happens. + + : entries.map(entry => + + {clock(entry.at)} + + {entry.text} + )} + + + +} diff --git a/frontend/src/components/Services/PemInput.tsx b/frontend/src/components/Services/PemInput.tsx new file mode 100644 index 0000000..7b38b94 --- /dev/null +++ b/frontend/src/components/Services/PemInput.tsx @@ -0,0 +1,100 @@ +import { Box, Button, FileButton, Group, Text, Textarea } from '@mantine/core'; +import { useState } from 'react'; +import { TbFileUpload } from 'react-icons/tb'; + +/** How much of a file is worth reading. A PEM bundle is a few kilobytes; anything + * this size is not one, and reading it whole would freeze the tab to prove it. */ +const MAX_BYTES = 512 * 1024 + +/** + * A PEM field that can be typed into, pasted into, picked from disk, or dropped onto. + * + * The three ways in exist because the material arrives differently depending on where + * the operator is standing: a certificate copied out of a chat message is pasted, one + * sitting in `~/certs` is dropped, and one somewhere with a long path is picked. Only + * pasting used to be possible, which meant opening the file in an editor first — and a + * key that has been through an editor is a key that may have lost its trailing newline + * or gained a stray character, neither of which is visible in a textarea. + * + * The file is read in the browser and put into the same field a paste lands in, so + * there is one value being validated and submitted however it got there. Nothing is + * uploaded separately: a second endpoint taking a file would be a second way for the + * key to reach the backend, and the key is the one thing worth having exactly one of. + */ +export default function PemInput({ label, placeholder, expect, value, onChange, error }: { + label: string, + placeholder: string, + /** The PEM header this field is meant to hold, used to warn about a swap. */ + expect: string, + value: string, + onChange: (value: string) => void, + error?: React.ReactNode, +}) { + const [dragging, setDragging] = useState(false) + const [fileError, setFileError] = useState(null) + + const load = async (file: File | null) => { + if (!file) return + setFileError(null) + if (file.size > MAX_BYTES) { + setFileError(`${file.name} is ${Math.round(file.size / 1024)} KB — too large to be PEM material`) + return + } + try { + const text = await file.text() + // A DER or PKCS#12 file has no header to find, and pasting its bytes into a + // textarea produces something that looks like corruption rather than like + // the wrong format. Say which it is while the file name is still known. + if (!text.includes("-----BEGIN")) { + setFileError(`${file.name} is not PEM: no '-----BEGIN' in it. A .der or .p12 has to be converted first.`) + return + } + onChange(text) + } catch (err) { + setFileError(`${file.name} could not be read: ${err}`) + } + } + + const swapped = value !== "" && !value.includes(expect) && value.includes("-----BEGIN") + + return { e.preventDefault(); setDragging(true) }} + onDragLeave={() => setDragging(false)} + onDrop={e => { + e.preventDefault() + setDragging(false) + load(e.dataTransfer.files?.[0] ?? null) + }} + style={{ + borderRadius: 8, + outline: dragging ? '2px dashed var(--mantine-color-blue-5)' : '2px dashed transparent', + outlineOffset: 4, + transition: 'outline-color 120ms', + }} + > + + {label} + + {props => } + + +