Minor fixes for release

This commit is contained in:
Domingo Dirutigliano 2026-10-16 18:06:50 +02:00
parent a6643490c1
commit 21e3027f0b
No known key found for this signature in database
18 changed files with 230 additions and 113 deletions

View File

@ -248,6 +248,55 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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 # `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 # 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 # here, and the sdist goes up beside them: where no wheel matches, pip compiles from
@ -351,8 +400,11 @@ jobs:
user: __token__ user: __token__
password: ${{ secrets.PYPI_API_TOKEN }} password: ${{ secrets.PYPI_API_TOKEN }}
# After `firegex`, never beside it: this package is nothing but `firegex==<tag>`, 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: pypi_publish_fgex_alias:
needs: [run_tests, engine_tests] needs: pypi_publish_firegex
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read

View File

@ -66,7 +66,7 @@ there — a file only processed for *site* directories, so putting that path in
imports pytest and still leaves `pyworker.py --check` unable to find the filter library. 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 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 things that trip a root run were found by trying it, and both are fixed rather than worked
around: `tests/requirements.txt` was missing `fastapi` (see the file for why one unit test 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 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 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. a **password** and ran everything against an instance it could not authenticate to.
@ -108,7 +108,7 @@ Mount `backend/`, not `backend/proxysrc/`: `tests/pyworker.rs` launches `../modu
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. 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 `bench/requirements.txt` and CI installs none of them. 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 ## Architecture

View File

@ -57,12 +57,12 @@ RUN python3.14 -m pip install --no-cache-dir --break-system-packages /fgex-lib
#Building main conteiner #Building main conteiner
FROM --platform=$TARGETARCH base AS final FROM --platform=$TARGETARCH base AS final
COPY ./backend/requirements.txt /execute/requirements.txt COPY ./backend/pyproject.toml /execute/pyproject.toml
COPY ./fgex-lib /execute/fgex-lib COPY ./fgex-lib /execute/fgex-lib
RUN dnf -y update && dnf install -y gcc-c++ python3.14-devel uv git &&\ 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 ./fgex-lib &&\
uv pip install --no-cache --system -r /execute/requirements.txt &&\ 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 uv cache clean && dnf remove -y gcc-c++ python3.14-devel uv git && dnf clean all
COPY ./backend/ /execute/ COPY ./backend/ /execute/

15
backend/pyproject.toml Normal file
View File

@ -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.
]

View File

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

After

Width:  |  Height:  |  Size: 246 KiB

45
docs/screenshot_demo.py Normal file
View File

@ -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=<tag>`, 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()

View File

@ -1,4 +1,3 @@
include requirements.txt
recursive-include llhttp *.c *.h recursive-include llhttp *.c *.h
include llhttp/PROVENANCE.md include llhttp/PROVENANCE.md
include llhttp/LICENSE.pyllhttp include llhttp/LICENSE.pyllhttp

42
fgex-lib/pyproject.toml Normal file
View File

@ -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*"]

View File

@ -1,15 +0,0 @@
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'

View File

@ -23,32 +23,12 @@ LLHTTP = setuptools.Extension(
extra_compile_args=["-O3"], extra_compile_args=["-O3"],
) )
with open("README.md", "r", encoding="utf-8") as fh: # Everything else about the package is in pyproject.toml. The version stays here because
long_description = fh.read() # the release workflow writes the tag over the placeholder, which is not a valid version
# until it has.
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() != ""]
VERSION = "{{VERSION_PLACEHOLDER}}" VERSION = "{{VERSION_PLACEHOLDER}}"
setuptools.setup( setuptools.setup(
name="firegex", version=VERSION if "{" not in VERSION else "0.0.0", #uv pip install -U . --no-cache-dir for testing
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(),
ext_modules=[LLHTTP], ext_modules=[LLHTTP],
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
],
python_requires='>=3.10',
) )

View File

@ -379,7 +379,7 @@ type Handler = (m: RegExpMatchArray, body: Json, query: Record<string, string>)
const routes: [string, RegExp, Handler][] = [ const routes: [string, RegExp, Handler][] = [
// ---- global // ---- global
["GET", /^status$/, () => ({ status: "run", loggined: true, version: "demo", auth_disabled: false })], ["GET", /^status$/, () => ({ status: "run", loggined: true, version: import.meta.env.VITE_VERSION || "demo", auth_disabled: false })],
["POST", /^login$/, () => ({ access_token: "demo-token", token_type: "bearer" })], ["POST", /^login$/, () => ({ access_token: "demo-token", token_type: "bearer" })],
["POST", /^set-password$/, () => ok], ["POST", /^set-password$/, () => ok],
["POST", /^change-password$/, () => ({ ...ok, access_token: "demo-token" })], ["POST", /^change-password$/, () => ({ ...ok, access_token: "demo-token" })],

View File

@ -12,9 +12,9 @@ most of this page is about. `pytest` does not collect this directory.
| `stress_test_history.py` | a soak test for `HttpHistory` under load | | `stress_test_history.py` | a soak test for `HttpHistory` under load |
| `results_plotter.py` | redraws the SVGs in `results/` from the CSVs beside them | | `results_plotter.py` | redraws the SVGs in `results/` from the CSVs beside them |
These need more than the tests do — `pip3 install -r requirements.txt` here, on top of These need more than the tests do — the `bench` group of `../pyproject.toml`
the one a directory up, and `iperf3` itself installed as a package because the python (`pip3 install --group ../pyproject.toml:bench` from here, pip 25.1 or later), and `iperf3`
binding links against it. itself installed as a package because the python binding links against it.
# Running a benchmark # Running a benchmark

View File

@ -1,6 +0,0 @@
# On top of ../requirements.txt.
# `iperf3` is a binding against libiperf, so the library has to be installed first
# (`apt-get install iperf3`, `dnf install iperf3`).
iperf3
matplotlib
numpy

View File

@ -110,7 +110,10 @@ def test_stopping_a_proxied_service_does_not_cut_its_connections(api, protected,
"a stopped service went on filtering the connections it had" "a stopped service went on filtering the connections it had"
time.sleep(3) time.sleep(3)
assert _held_open(server, b"a while later") == b"a while later" assert _held_open(server, b"a while later") == b"a while later"
assert _log_says(api, service_id, "carry on unfiltered"), \ # The line waits for a STATS report taken after the stop, which the engine sends
# every two seconds and drops while the backend is behind — under a full suite on
# a busy machine, ten seconds was once not enough to see one.
assert _log_says(api, service_id, "carry on unfiltered", wait=30), \
"the log did not say connections were still being carried" "the log did not say connections were still being carried"
finally: finally:
server.close_client() server.close_client()

55
tests/pyproject.toml Normal file
View File

@ -0,0 +1,55 @@
# Not a package: dependency groups only (PEP 735). `run_tests.sh` installs `test`;
# `bench` is what the benchmarks need on top of it.
[dependency-groups]
test = [
"pytest",
"requests",
"cryptography",
"psutil",
"pydantic",
"python-socketio[client]",
# The only way to speak QUIC from Python, and the integration tests for that layer have
# to speak it: what they check is the half the engine's own tests cannot reach — the
# rules that steer a QUIC address, the relay bound per address, and a filter attached
# through the API. Everything that needs it skips with a reason when it is absent, so a
# machine without it still runs the rest.
"aioquic",
# And the only way to speak gRPC from Python, which is what says HTTP/2 is filtered: a
# gRPC service is a real HTTP/2 service, its method name is an HPACK-compressed `:path`,
# and its status is a trailer section — the three things a firegex that only forwarded
# HTTP/2 could not show a filter. Everything that needs it skips with a reason when it is
# absent, so a machine without it still runs the rest.
"grpcio",
# `unit/test_nftables_addresses.py` imports the **backend** — the address-to-nftables
# translation is the backend's, and testing a copy of it would be testing a copy. That
# drags in `backend/utils`, which raises FastAPI's `HTTPException`, so FastAPI has to be
# here even though nothing in this suite serves a request. Without it the file cannot be
# collected, and a collection error is not one test failing: pytest stops the entire run
# before anything has executed. It went unnoticed for as long as it did because a machine
# that has ever run the backend outside the container already has it.
"fastapi",
# `unit/` imports the filter library straight out of `../fgex-lib`, so its runtime
# dependencies have to be here too. Not the published `firegex` itself, which would
# quietly shadow the source tree the suite is meant to be testing.
#
# The HTTP parser is deliberately NOT here. It is a C extension that ships inside
# firegex itself, so it has to be *built*: `run_tests.sh` does that with an editable
# install of `../fgex-lib`. Running `pytest` by hand without it gives
# `ImportError: cannot import name '_llhttp' from 'firegex'`.
"brotli",
"websockets",
# Unconditional here, unlike in the library, which only needs it below 3.14: the
# suite uses it to build the corpus *and* to exercise the fallback backend, so
# without it the cross-backend agreement test silently skips on the one Python
# that matters most.
"zstandard",
]
# The tests' dependencies stay pure python, so a CI run that only tests does not build a
# binding against a C library it will never call. `iperf3` is one: the library has to be
# installed first (`apt-get install iperf3`, `dnf install iperf3`).
bench = [
{ include-group = "test" },
"iperf3",
"matplotlib",
"numpy",
]

View File

@ -1,48 +0,0 @@
# What the tests need. The benchmarks need more — iperf3, matplotlib, numpy — and those
# are in bench/requirements.txt, so a CI run that only tests does not build a binding
# against a C library it will never call.
pytest
requests
cryptography
psutil
pydantic
python-socketio[client]
# The only way to speak QUIC from Python, and the integration tests for that layer have
# to speak it: what they check is the half the engine's own tests cannot reach — the
# rules that steer a QUIC address, the relay bound per address, and a filter attached
# through the API. Everything that needs it skips with a reason when it is absent, so a
# machine without it still runs the rest.
aioquic
# And the only way to speak gRPC from Python, which is what says HTTP/2 is filtered: a
# gRPC service is a real HTTP/2 service, its method name is an HPACK-compressed `:path`,
# and its status is a trailer section — the three things a firegex that only forwarded
# HTTP/2 could not show a filter. Everything that needs it skips with a reason when it is
# absent, so a machine without it still runs the rest.
grpcio
# `unit/test_nftables_addresses.py` imports the **backend** — the address-to-nftables
# translation is the backend's, and testing a copy of it would be testing a copy. That
# drags in `backend/utils`, which raises FastAPI's `HTTPException`, so FastAPI has to be
# here even though nothing in this suite serves a request. Without it the file cannot be
# collected, and a collection error is not one test failing: pytest stops the entire run
# before anything has executed. It went unnoticed for as long as it did because a machine
# that has ever run the backend outside the container already has it.
fastapi
# `unit/` imports the filter library straight out of `../fgex-lib`, so its runtime
# dependencies have to be here too. Listed one by one rather than with
# `-r ../fgex-lib/requirements.txt`, because that file lists `fgex` — the alias package,
# which depends on a *published* `firegex` and would quietly shadow the source tree the
# suite is meant to be testing.
#
# The HTTP parser is deliberately NOT here. It is a C extension that ships inside
# firegex itself, so it has to be *built*: `run_tests.sh` does that with an editable
# install of `../fgex-lib`. Running `pytest` by hand without it gives
# `ImportError: cannot import name '_llhttp' from 'firegex'`.
brotli
websockets
# Unconditional here, unlike in the library, which only needs it below 3.14: the
# suite uses it to build the corpus *and* to exercise the fallback backend, so
# without it the cross-backend agreement test silently skips on the one Python
# that matters most.
zstandard

View File

@ -47,7 +47,10 @@ fi
ADDRESS="${FIREGEX_ADDRESS:-http://127.0.0.1:4444/}" ADDRESS="${FIREGEX_ADDRESS:-http://127.0.0.1:4444/}"
ADDRESS="${ADDRESS%/}/" ADDRESS="${ADDRESS%/}/"
pip3 install -q -r requirements.txt # The `test` group of pyproject.toml, read here rather than with `pip install --group`,
# which needs pip 25.1 — older than what CI's runners ship.
python3 -c 'import tomllib; print("\n".join(tomllib.load(open("pyproject.toml", "rb"))["dependency-groups"]["test"]))' \
| pip3 install -q -r /dev/stdin
# The filter library is imported straight out of the source tree, and it carries a C # The filter library is imported straight out of the source tree, and it carries a C
# extension — the llhttp binding that parses HTTP — which therefore has to be built. An # extension — the llhttp binding that parses HTTP — which therefore has to be built. An
# editable install builds it in place; without this `unit/` cannot even be collected. # editable install builds it in place; without this `unit/` cannot even be collected.