From 9832e449c66bd3bc789115e56179bc877781d3e9 Mon Sep 17 00:00:00 2001 From: Domingo Dirutigliano Date: Thu, 10 Sep 2026 08:51:08 +0200 Subject: [PATCH] Initial commit for future 5.0.0 version: new UI, proxies and managment --- .dockerignore | 78 +- .github/workflows/demo-pages.yml | 2 +- .gitignore | 111 +- CLAUDE.md | 222 +- Dockerfile | 115 +- README.md | 35 +- backend/app.py | 157 +- backend/binsrc/classes/nfqueue.cpp | 85 +- backend/binsrc/{nfproxy.cpp => pyfilter.cpp} | 28 +- backend/binsrc/pyproxy/pyproxy.cpp | 73 +- backend/binsrc/pyproxy/settings.cpp | 2 +- backend/binsrc/pyproxy/stream_ctx.cpp | 50 +- backend/docker-entrypoint.sh | 40 +- backend/modules/firewall/nftables.py | 220 +- backend/modules/nfproxy/firegex.py | 205 -- backend/modules/nfproxy/firewall.py | 141 -- backend/modules/nfproxy/models.py | 66 - backend/modules/nfproxy/nftables.py | 153 -- backend/modules/nfregex/__init__.py | 0 backend/modules/nfregex/firegex.py | 190 -- backend/modules/nfregex/firewall.py | 127 - backend/modules/nfregex/models.py | 35 - backend/modules/nfregex/nftables.py | 148 -- backend/modules/porthijack/__init__.py | 0 backend/modules/porthijack/firewall.py | 84 - backend/modules/porthijack/models.py | 14 - backend/modules/porthijack/nftables.py | 102 - .../modules/{nfproxy => services}/__init__.py | 0 backend/modules/services/firewall.py | 526 +++++ backend/modules/services/logs.py | 187 ++ backend/modules/services/mirror.py | 49 + backend/modules/services/models.py | 375 +++ backend/modules/services/nftables.py | 589 +++++ backend/modules/services/pyapi.py | 99 + backend/modules/services/pyworker.py | 392 ++++ backend/modules/services/stats.py | 254 ++ backend/modules/services/transports.py | 933 ++++++++ backend/modules/tls/__init__.py | 1 - backend/modules/tls/manager.py | 92 - backend/modules/tls/nftables.py | 43 - backend/modules/tls/nginx.py | 83 - backend/modules/tls/service.py | 22 - backend/proxysrc/Cargo.lock | 484 ++++ backend/proxysrc/Cargo.toml | 33 + backend/proxysrc/README.md | 407 ++++ backend/proxysrc/src/capture.rs | 314 +++ backend/proxysrc/src/control.rs | 70 + backend/proxysrc/src/debug.rs | 313 +++ backend/proxysrc/src/filter.rs | 477 ++++ backend/proxysrc/src/hyperscan.rs | 544 +++++ backend/proxysrc/src/lib.rs | 24 + backend/proxysrc/src/main.rs | 339 +++ backend/proxysrc/src/proxy.rs | 682 ++++++ backend/proxysrc/src/pyworker.rs | 425 ++++ backend/proxysrc/src/rules.rs | 318 +++ backend/proxysrc/src/spec.rs | 152 ++ backend/proxysrc/src/tls.rs | 164 ++ backend/proxysrc/src/transparent.rs | 210 ++ backend/proxysrc/src/udp.rs | 330 +++ backend/proxysrc/tests/fail_open.rs | 308 +++ backend/proxysrc/tests/pyworker.rs | 489 ++++ backend/proxysrc/tests/rules.rs | 253 ++ backend/proxysrc/tests/throughput.rs | 63 + backend/proxysrc/tests/tls.rs | 211 ++ backend/routers/nfproxy.py | 454 ---- backend/routers/nfregex.py | 456 ---- backend/routers/porthijack.py | 184 -- backend/routers/services.py | 1934 +++++++++++++++ backend/routers/tls.py | 177 -- backend/utils/__init__.py | 2 +- backend/utils/models.py | 5 + backend/utils/sqlite.py | 9 +- docs/firewall.md | 25 +- docs/nfregex.md | 35 - docs/porthijack.md | 31 - docs/{nfproxy.md => pyfilter.md} | 164 +- docs/services.md | 645 +++++ docs/tls.md | 27 - fgex-lib/README.md | 23 +- fgex-lib/firegex/cli.py | 151 +- fgex-lib/firegex/nfproxy/__init__.py | 88 +- .../firegex/nfproxy/internals/__init__.py | 173 -- fgex-lib/firegex/nfproxy/internals/data.py | 166 -- fgex-lib/firegex/pyfilters/__init__.py | 58 + .../firegex/pyfilters/internals/__init__.py | 294 +++ fgex-lib/firegex/pyfilters/internals/data.py | 195 ++ .../internals/exceptions.py | 0 .../internals/models.py | 16 +- .../{nfproxy => pyfilters}/models/__init__.py | 20 +- .../{nfproxy => pyfilters}/models/http.py | 8 +- .../{nfproxy => pyfilters}/models/tcp.py | 6 +- .../proxysim/__init__.py | 66 +- fgex-lib/firegex/regex/__init__.py | 290 +++ fgex-lib/firegex/regex/sim.py | 106 + frontend/src/App.tsx | 17 +- frontend/src/components/AddNewRegex.tsx | 140 -- frontend/src/components/DocsButton.tsx | 20 +- .../src/components/Header/AuthModeModal.tsx | 98 + frontend/src/components/Header/index.tsx | 185 +- frontend/src/components/MarkdownDocs.tsx | 2 +- .../src/components/NFProxy/AddEditService.tsx | 281 --- .../components/NFProxy/EditFilterModal.tsx | 125 - .../components/NFProxy/ExceptionWarning.tsx | 43 - .../src/components/NFProxy/NFProxyDocs.tsx | 7 - .../NFProxy/ServiceRow/RenameForm.tsx | 68 - .../components/NFProxy/ServiceRow/index.tsx | 181 -- .../components/NFProxy/UploadFilterModal.tsx | 53 - frontend/src/components/NFProxy/utils.ts | 193 -- .../src/components/NFRegex/AddEditService.tsx | 284 --- .../src/components/NFRegex/NFRegexDocs.tsx | 4 - .../NFRegex/ServiceRow/RenameForm.tsx | 68 - .../components/NFRegex/ServiceRow/index.tsx | 177 -- frontend/src/components/NFRegex/utils.ts | 105 - frontend/src/components/NavBar/index.tsx | 9 +- .../components/PortHijack/AddNewService.tsx | 146 -- .../components/PortHijack/PortHijackDocs.tsx | 4 - .../ServiceRow/ChangeDestination.tsx | 72 - .../PortHijack/ServiceRow/RenameForm.tsx | 68 - .../PortHijack/ServiceRow/index.tsx | 165 -- frontend/src/components/PortHijack/utils.ts | 64 - .../src/components/PyFilterView/index.tsx | 67 - frontend/src/components/RegexView/index.tsx | 135 -- .../components/Services/AddEditService.tsx | 411 ++++ .../src/components/Services/AddRegexModal.tsx | 147 ++ .../src/components/Services/AddressList.tsx | 247 ++ .../components/Services/CodeEditorModal.tsx | 206 ++ .../src/components/Services/FilterCard.tsx | 286 +++ .../src/components/Services/LayerChoice.tsx | 178 ++ frontend/src/components/Services/LogPanel.tsx | 94 + frontend/src/components/Services/PemInput.tsx | 100 + .../src/components/Services/RegexDebugger.tsx | 237 ++ .../src/components/Services/ServiceRow.tsx | 199 ++ .../src/components/Services/ServicesDocs.tsx | 4 + .../src/components/Services/StatsCharts.tsx | 220 ++ .../src/components/Services/StatsPanel.tsx | 327 +++ .../src/components/Services/pyfilterHints.ts | 207 ++ frontend/src/components/Services/utils.ts | 473 ++++ .../src/components/TLSAssociationBadge.tsx | 38 - frontend/src/components/TLSDocs.tsx | 4 - frontend/src/components/TLSServiceFields.tsx | 118 - frontend/src/index.tsx | 7 +- frontend/src/js/demo.ts | 791 +++++-- frontend/src/js/utils.tsx | 508 ++-- frontend/src/pages/NFProxy/ServiceDetails.tsx | 252 -- frontend/src/pages/NFProxy/index.tsx | 124 - frontend/src/pages/NFRegex/ServiceDetails.tsx | 243 -- frontend/src/pages/NFRegex/index.tsx | 106 - frontend/src/pages/PortHijack/index.tsx | 79 - .../src/pages/Services/ServiceDetails.tsx | 246 ++ frontend/src/pages/Services/index.tsx | 90 + frontend/src/pages/TLSDecrypt/index.tsx | 236 -- frontend/vite.config.ts | 2 +- run.py | 237 +- tests/README.md | 349 ++- tests/api_test.py | 74 + tests/benchmark.py | 103 +- tests/connbench.py | 172 ++ tests/list_services.py | 4 + tests/nfproxy_test.py | 1054 --------- tests/nfregex_test.py | 477 ---- tests/patch_debugger.py | 28 + tests/patch_debugger_final.py | 7 + tests/patch_debugger_more.py | 39 + tests/patch_modal.py | 25 + tests/ph_test.py | 187 -- tests/regex_lib_test.py | 87 + tests/requirements.txt | 1 + tests/results/3.5.3-1T.csv | 51 + tests/results/3.5.3-8T-outlier.csv | 51 + tests/results/3.5.3-8T.csv | 51 + tests/results/4.0.5-1T.csv | 51 + tests/results/4.0.5-8T-run2.csv | 51 + tests/results/4.0.5-8T.csv | 51 + tests/results/5.0.0-nfqueue-1T-rerun.csv | 51 + tests/results/5.0.0-nfqueue-1T.csv | 51 + tests/results/5.0.0-nfqueue-8T-rerun.csv | 51 + tests/results/5.0.0-nfqueue-8T-run2.csv | 51 + tests/results/5.0.0-nfqueue-8T.csv | 51 + tests/results/5.0.0-proxy-1T-rerun.csv | 51 + tests/results/5.0.0-proxy-1T.csv | 51 + tests/results/5.0.0-proxy-8T-rerun.csv | 51 + tests/results/5.0.0-proxy-8T.csv | 51 + tests/results/Benchmark-1T.svg | 1904 +++++++++++++++ tests/results/Benchmark-8T.svg | 2089 +++++++++++++++++ tests/results_plotter.py | 101 + tests/run_tests.sh | 74 +- tests/services_test.py | 1095 +++++++++ tests/stress_test_history.py | 39 +- tests/test_http_history.py | 120 +- tests/tls_test.py | 186 -- tests/utils/firegexapi.py | 422 ++-- tests/utils/tcpserver.py | 18 +- tests/utils/tls_helpers.py | 67 +- 193 files changed, 26969 insertions(+), 10262 deletions(-) rename backend/binsrc/{nfproxy.cpp => pyfilter.cpp} (76%) delete mode 100644 backend/modules/nfproxy/firegex.py delete mode 100644 backend/modules/nfproxy/firewall.py delete mode 100644 backend/modules/nfproxy/models.py delete mode 100644 backend/modules/nfproxy/nftables.py delete mode 100644 backend/modules/nfregex/__init__.py delete mode 100644 backend/modules/nfregex/firegex.py delete mode 100644 backend/modules/nfregex/firewall.py delete mode 100644 backend/modules/nfregex/models.py delete mode 100644 backend/modules/nfregex/nftables.py delete mode 100644 backend/modules/porthijack/__init__.py delete mode 100644 backend/modules/porthijack/firewall.py delete mode 100644 backend/modules/porthijack/models.py delete mode 100644 backend/modules/porthijack/nftables.py rename backend/modules/{nfproxy => services}/__init__.py (100%) create mode 100644 backend/modules/services/firewall.py create mode 100644 backend/modules/services/logs.py create mode 100644 backend/modules/services/mirror.py create mode 100644 backend/modules/services/models.py create mode 100644 backend/modules/services/nftables.py create mode 100644 backend/modules/services/pyapi.py create mode 100644 backend/modules/services/pyworker.py create mode 100644 backend/modules/services/stats.py create mode 100644 backend/modules/services/transports.py delete mode 100644 backend/modules/tls/__init__.py delete mode 100644 backend/modules/tls/manager.py delete mode 100644 backend/modules/tls/nftables.py delete mode 100644 backend/modules/tls/nginx.py delete mode 100644 backend/modules/tls/service.py create mode 100644 backend/proxysrc/Cargo.lock create mode 100644 backend/proxysrc/Cargo.toml create mode 100644 backend/proxysrc/README.md create mode 100644 backend/proxysrc/src/capture.rs create mode 100644 backend/proxysrc/src/control.rs create mode 100644 backend/proxysrc/src/debug.rs create mode 100644 backend/proxysrc/src/filter.rs create mode 100644 backend/proxysrc/src/hyperscan.rs create mode 100644 backend/proxysrc/src/lib.rs create mode 100644 backend/proxysrc/src/main.rs create mode 100644 backend/proxysrc/src/proxy.rs create mode 100644 backend/proxysrc/src/pyworker.rs create mode 100644 backend/proxysrc/src/rules.rs create mode 100644 backend/proxysrc/src/spec.rs create mode 100644 backend/proxysrc/src/tls.rs create mode 100644 backend/proxysrc/src/transparent.rs create mode 100644 backend/proxysrc/src/udp.rs create mode 100644 backend/proxysrc/tests/fail_open.rs create mode 100644 backend/proxysrc/tests/pyworker.rs create mode 100644 backend/proxysrc/tests/rules.rs create mode 100644 backend/proxysrc/tests/throughput.rs create mode 100644 backend/proxysrc/tests/tls.rs delete mode 100644 backend/routers/nfproxy.py delete mode 100644 backend/routers/nfregex.py delete mode 100644 backend/routers/porthijack.py create mode 100644 backend/routers/services.py delete mode 100644 backend/routers/tls.py delete mode 100644 docs/nfregex.md delete mode 100644 docs/porthijack.md rename docs/{nfproxy.md => pyfilter.md} (62%) create mode 100644 docs/services.md delete mode 100644 docs/tls.md delete mode 100644 fgex-lib/firegex/nfproxy/internals/__init__.py delete mode 100644 fgex-lib/firegex/nfproxy/internals/data.py create mode 100644 fgex-lib/firegex/pyfilters/__init__.py create mode 100644 fgex-lib/firegex/pyfilters/internals/__init__.py create mode 100644 fgex-lib/firegex/pyfilters/internals/data.py rename fgex-lib/firegex/{nfproxy => pyfilters}/internals/exceptions.py (100%) rename fgex-lib/firegex/{nfproxy => pyfilters}/internals/models.py (62%) rename fgex-lib/firegex/{nfproxy => pyfilters}/models/__init__.py (69%) rename fgex-lib/firegex/{nfproxy => pyfilters}/models/http.py (99%) rename fgex-lib/firegex/{nfproxy => pyfilters}/models/tcp.py (93%) rename fgex-lib/firegex/{nfproxy => pyfilters}/proxysim/__init__.py (81%) create mode 100644 fgex-lib/firegex/regex/__init__.py create mode 100644 fgex-lib/firegex/regex/sim.py delete mode 100644 frontend/src/components/AddNewRegex.tsx create mode 100644 frontend/src/components/Header/AuthModeModal.tsx delete mode 100644 frontend/src/components/NFProxy/AddEditService.tsx delete mode 100644 frontend/src/components/NFProxy/EditFilterModal.tsx delete mode 100644 frontend/src/components/NFProxy/ExceptionWarning.tsx delete mode 100644 frontend/src/components/NFProxy/NFProxyDocs.tsx delete mode 100644 frontend/src/components/NFProxy/ServiceRow/RenameForm.tsx delete mode 100644 frontend/src/components/NFProxy/ServiceRow/index.tsx delete mode 100644 frontend/src/components/NFProxy/UploadFilterModal.tsx delete mode 100644 frontend/src/components/NFProxy/utils.ts delete mode 100644 frontend/src/components/NFRegex/AddEditService.tsx delete mode 100644 frontend/src/components/NFRegex/NFRegexDocs.tsx delete mode 100644 frontend/src/components/NFRegex/ServiceRow/RenameForm.tsx delete mode 100644 frontend/src/components/NFRegex/ServiceRow/index.tsx delete mode 100644 frontend/src/components/NFRegex/utils.ts delete mode 100644 frontend/src/components/PortHijack/AddNewService.tsx delete mode 100644 frontend/src/components/PortHijack/PortHijackDocs.tsx delete mode 100644 frontend/src/components/PortHijack/ServiceRow/ChangeDestination.tsx delete mode 100644 frontend/src/components/PortHijack/ServiceRow/RenameForm.tsx delete mode 100644 frontend/src/components/PortHijack/ServiceRow/index.tsx delete mode 100644 frontend/src/components/PortHijack/utils.ts delete mode 100644 frontend/src/components/PyFilterView/index.tsx delete mode 100644 frontend/src/components/RegexView/index.tsx create mode 100644 frontend/src/components/Services/AddEditService.tsx create mode 100644 frontend/src/components/Services/AddRegexModal.tsx create mode 100644 frontend/src/components/Services/AddressList.tsx create mode 100644 frontend/src/components/Services/CodeEditorModal.tsx create mode 100644 frontend/src/components/Services/FilterCard.tsx create mode 100644 frontend/src/components/Services/LayerChoice.tsx create mode 100644 frontend/src/components/Services/LogPanel.tsx create mode 100644 frontend/src/components/Services/PemInput.tsx create mode 100644 frontend/src/components/Services/RegexDebugger.tsx create mode 100644 frontend/src/components/Services/ServiceRow.tsx create mode 100644 frontend/src/components/Services/ServicesDocs.tsx create mode 100644 frontend/src/components/Services/StatsCharts.tsx create mode 100644 frontend/src/components/Services/StatsPanel.tsx create mode 100644 frontend/src/components/Services/pyfilterHints.ts create mode 100644 frontend/src/components/Services/utils.ts delete mode 100644 frontend/src/components/TLSAssociationBadge.tsx delete mode 100644 frontend/src/components/TLSDocs.tsx delete mode 100644 frontend/src/components/TLSServiceFields.tsx delete mode 100644 frontend/src/pages/NFProxy/ServiceDetails.tsx delete mode 100644 frontend/src/pages/NFProxy/index.tsx delete mode 100644 frontend/src/pages/NFRegex/ServiceDetails.tsx delete mode 100644 frontend/src/pages/NFRegex/index.tsx delete mode 100644 frontend/src/pages/PortHijack/index.tsx create mode 100644 frontend/src/pages/Services/ServiceDetails.tsx create mode 100644 frontend/src/pages/Services/index.tsx delete mode 100644 frontend/src/pages/TLSDecrypt/index.tsx create mode 100755 tests/connbench.py create mode 100644 tests/list_services.py delete mode 100644 tests/nfproxy_test.py delete mode 100644 tests/nfregex_test.py create mode 100644 tests/patch_debugger.py create mode 100644 tests/patch_debugger_final.py create mode 100644 tests/patch_debugger_more.py create mode 100644 tests/patch_modal.py delete mode 100644 tests/ph_test.py create mode 100644 tests/regex_lib_test.py create mode 100644 tests/results/3.5.3-1T.csv create mode 100644 tests/results/3.5.3-8T-outlier.csv create mode 100644 tests/results/3.5.3-8T.csv create mode 100644 tests/results/4.0.5-1T.csv create mode 100644 tests/results/4.0.5-8T-run2.csv create mode 100644 tests/results/4.0.5-8T.csv create mode 100644 tests/results/5.0.0-nfqueue-1T-rerun.csv create mode 100644 tests/results/5.0.0-nfqueue-1T.csv create mode 100644 tests/results/5.0.0-nfqueue-8T-rerun.csv create mode 100644 tests/results/5.0.0-nfqueue-8T-run2.csv create mode 100644 tests/results/5.0.0-nfqueue-8T.csv create mode 100644 tests/results/5.0.0-proxy-1T-rerun.csv create mode 100644 tests/results/5.0.0-proxy-1T.csv create mode 100644 tests/results/5.0.0-proxy-8T-rerun.csv create mode 100644 tests/results/5.0.0-proxy-8T.csv create mode 100644 tests/results/Benchmark-1T.svg create mode 100644 tests/results/Benchmark-8T.svg create mode 100755 tests/services_test.py delete mode 100644 tests/tls_test.py 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/.gitignore b/.gitignore index 82c5647..3f74f30 100644 --- a/.gitignore +++ b/.gitignore @@ -1,53 +1,58 @@ -**/*.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 +/tests/benchmark.csv +/tests/proxy_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 + +# Rust proxy engine build artifacts +/backend/proxysrc/target/ +# Copied from the repository root by fgex-lib/setup.py at build time. diff --git a/CLAUDE.md b/CLAUDE.md index edc70f4..4764098 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 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. +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 @@ -41,14 +41,24 @@ cd backend && DEBUG=1 python3 app.py DEBUG 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] +./run_tests.sh [password] # full suite (defaults to "testpassword") +python3 regex_lib_test.py # firegex.regex — needs libhs, not an instance +python3 services_test.py -p -t proxy|nfqueue|external [--ipv6] [--tls] python3 api_test.py -p -python3 ipfilter_test.py # access control (--allowed-ips/--proxy-ip-header/--unsafe-disable-auth) +python3 benchmark.py -p -r [-t proxy|nfqueue] +python3 ipfilter_test.py # access control ``` +`services_test.py` is the important one: the same filters are exercised on both network layers, because a filter that only works on one of them is exactly the failure the unified model exists to prevent. It also covers the parts that are easy to get subtly wrong — a second address added to a *running* service, a filter whose protocol is detected from its code, the bucketed statistics, and UDP being refused on the proxy layer with a reason. Run it twice in a row when changing anything about addresses or TLS: state left behind by the first pass only shows up on the second. + +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 . # then run cargo test inside it +cd backend/proxysrc && cargo test # on a host with libhs + fgex installed +cargo test --release --test throughput -- --ignored --nocapture # the matcher benchmark +``` + +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. `api_test.py` is the exception and says so — half of 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. + `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. @@ -56,48 +66,206 @@ CI (`.github/workflows/release.yml`) runs **only on `release: published`**, not ## 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. +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. -### 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. +### 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, and `losesClientAddress()` marks the one combination that gives something up. 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 rewrites exactly and the other has a kernel-guaranteed fail-open. + +### 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/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. + +**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. + +The cost is that **the client's address is not preserved** there: no transparent dial, so the service sees the engine. That is stated in the form where the layer is chosen, on the service itself (a `NO CLIENT IP` badge, `losesClientAddress()`), and in the docs. Do not quietly "fix" it with TPROXY: that leaves no NAT entry to key the return path off, so it cannot reach a service on this host, which is why it was ruled out before UDP came up. + +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. + +`ConnectionMeta.tcp` carries the L4 protocol to the filter, so the library's stream and HTTP models — which only apply to TCP — key off what the engine says rather than a hardcoded `True` in the worker. + +### 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). The one exception is a proxy service gaining its *first* IPv6 address, because the listener must be reopened in a family that can accept one — it restarts and logs that it did. +- **`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 proxy listener is dual-stack when any address is 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. + +**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. + +### 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: + +- **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. Rewriting uses it too: block-mode scanning gives start-of-match offsets, and the matched span is replaced with a literal. That is why the replacement has no capture-group references — hyperscan reports where a match is, not what its groups captured, and inventing them would mean a second engine deciding them. Adding `regex` back to `Cargo.toml` would undo this. +- **A pattern is validated against the mode it will run in.** Blocking compiles for streaming, rewriting for block scanning, and the two do not accept quite the same patterns (`a{1,1000}b` streams but does not block-scan). Judging by the wrong one refuses rules that work — the same class of lie as a tester that disagrees with the engine. `debug.rs` reports `unscannable` for patterns that are valid but cannot be highlighted. +- **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. +- **`RawPacket` is the only model that survives a datagram**, and that fact is stated once, as `DATAGRAM_SAFE_MODELS` in `routers/services.py`. Every other model declines to be built when `is_tcp` is false, so a filter asking for one on a UDP service would sit in the chain and never be called. `--check` reports the models a file asks for, saving refuses the combination naming them, and `Transport.check()` catches the case where the service was switched to UDP afterwards. Python filters *do* work on UDP on both layers: `cpproxy` gained `filter_action_udp` (no stream follower, no ack fixing — a datagram is complete in itself), and its per-flow contexts are bounded by `enforce_limit` because a datagram has no close to observe and a spoofed-source flood would otherwise accumulate one set of module globals per forged address. `handle_next_packet` used to throw for anything but TCP, with a message claiming UDP was supported. +- **A filter file is validated by the process that will run it.** `pyworker.py --check ` builds the module exactly as the worker does, compiles it, and prints one JSON object: `{ok, proto, filters}` or an error carrying **type, message, line, column, the source line and the traceback**. `validate_code()` runs it in a **subprocess with a timeout** — user code used to be `exec`ed inside the backend's own event loop by `detect_proto`, so a `while True:` at module level would have taken the interface down. `POST .../check` answers 200 even when the answer is no: a refusal is the result, and the editor wants a position to mark, not a string to print. Saving goes through the same check, so the operator is never handed "the filter code did not load: worker exited" and left to find the traceback in the service log. + - `_where_in_source` finds the line three ways: a `SyntaxError`'s own position; a traceback frame belonging to the file (`` counts as theirs — the library execs the source to find its filters, and everything the library runs from has a real path); otherwise any identifier in the message looked back up among the file's `def`s, **last definition wins**, because that is the one Python is left holding. + - `Filters` compiles once under the operator's file name instead of re-parsing per connection, so runtime tracebacks in the log name their file and their line too. + - A filter is called directly. `context_call` used to write the arguments into the module globals, `eval` a call expression built from them, and delete the three keys again — per filter, per packet — which is exactly `func(*args)`, because the operator's code was `exec`ed into those globals and so the function's `__globals__` *is* that dict. + - The default for `FGEX_STREAM_MAX_SIZE` is 1 MiB, as documented. It read `1*8e20` under a comment saying "1MB", so `FGEX_FULL_STREAM_ACTION` never fired unless a size was set by hand, and the float it produced would have been refused by the setter beside it. +- **The editor's hints are introspected, never written down.** `modules/services/pyapi.py` reads the models out of `type_annotations_associations` and their members out of the classes — including `writable`, from `property.fset`, which is how the interface can say that the payload is the only thing a filter may assign to. `GET /api/services/pyfilter-api` serves it and `pyfilterHints.ts` feeds Monaco's completion and hover from it. A second, hand-written description would be wrong the first time a model gained a property, and autocompletion that lies is worse than none. The route is declared **before** `/{service_id}`, or that one matches it and looks for a service by that name. +- **A block names the function, not just the file.** Both layers report `/`: the NFQUEUE binaries already reported the function name, and the proxy worker now returns it in the verdict frame (`[verdict][name len][name][payload]`). `ServiceManager._on_block` splits that one token in one place. A file with ten functions blocking 900 connections is not an answer to "which of them is doing it". +- **Nothing below the application layer crosses into a filter.** `RawPacket` exposes addresses, ports, family and direction as read-only metadata, and `data` — the payload — as the only writable thing; `mangled_data` comes back, never a packet. That boundary exists because the two layers cannot honestly offer the same thing underneath it: NFQUEUE has a real header whose rewriting desynchronises the stream, while the proxy terminated the connection and writes its own. The old model papered over that by handing the proxy a literal `FAKE:IP:TCP:HEADERS:` prefix as `raw_packet`, so the same filter did different things depending on where it was attached. On the proxy the metadata is *better* than a header would be — the engine knows both ends and sends them in a `KIND_OPEN` frame (`ConnectionMeta` → `Filter::connection_opened`), once per connection rather than on every chunk. +- **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 filtering module (`nfproxy`, `nfregex`, `porthijack`, `firewall`, `tls`) follows the same shape: +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 `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. +- `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. -### 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. +**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`; 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. -### 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 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: -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`). +- **`iptables` stopped working on the host.** `iptables-nft` refuses an entire table as soon as one rule in it cannot be expressed in iptables' own format, and it stores conntrack matches as an `xt` blob where nft writes a native `ct state`. Bisecting the rules firegex writes, `ct state` was the only one that tripped it — which is to say `allow_established` and `drop_invalid`, on in any sane configuration. From then on `iptables -L` and `iptables-save` answer ``table `filter' is incompatible, use 'nft' tool``, on a box where Docker, ufw or the organisers' scripts are the ones asking. +- **Stopping firegex opened the host.** `reset()` re-added `filter INPUT` with `policy accept`, and in nftables `add chain` on an existing chain *updates* its policy. That chain was the administrator's: a host carrying default-deny had it silently removed when the firegex firewall was switched off. + +A base chain carries its own policy wherever it lives, so default-deny is expressed exactly as before and a `drop` is terminal whichever table's chain reached it — nothing about what gets blocked changed. What changed is that firegex now adds a layer instead of taking one over: an `accept` in `fgex_rules_in` means "firegex does not block this", never "nothing else will", which is all it ever honestly meant. `reset()` is now deleting two tables per family (`add` before `delete`, because a batch is atomic and deleting a table that is not there fails all of it). + +`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. + +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. + +`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). 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. +- 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. + +### 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. The same cap on the same shape already existed on the NFQUEUE side (`MAX_UDP_FLOWS` in `pyproxy.cpp`) and had never been carried to the Rust engine; TCP had none anywhere. + +Say what it buys, and no more. **A cap does not save the service being attacked** — it cannot tell a connection that is silent because it is an attack from one that is silent because the client is slow, so an attacker who fills the limit still fills it. What it buys is that one service's attacker stops being everyone's: measured, 400 silent connections against a limited service left the service beside it answering. Do not let the interface or the docs imply otherwise. + +- One number for both halves, because TCP connections and UDP flows spend the same descriptors and two limits for that would be two things to keep in step. `0` is no limit, kept as a value rather than an absence, because "unlimited" is a choice. +- `over_limit_forwards` is the same trade as fail-open at a different moment, and off by default for the same reason: a filter that quietly stops filtering is the worse surprise. +- Folded into the form's one **Advanced settings** accordion, not dropped: these are settings an operator reaches for once — after something went wrong, or before a competition — and never again, and left in the open they are more things to read past on every service. One fold rather than one per topic, and its summary is built from what *differs from the defaults* (`changed`), so an untuned service says "Everything at its default" and a tuned one says exactly how. Folded is not hidden. What is inside depends on the layer, because the settings belong to different ones: the connection limits are the proxy's, fail-open is NFQUEUE's, and the hand-off layer has neither, so it gets no accordion at all. +- **Proxy layer only**, and absent from the form elsewhere. NFQUEUE hands the kernel a verdict on packets in flight; it accepts nothing and dials nothing. The form also resets it to `0` when the layer changes, because a stored setting that does nothing is the trap this whole file keeps arguing against. +- `Slot` is a guard, not a decrement at the end of `handle_connection`: that function has a dozen ways out — a refused chain, a failed dial, a timed-out handshake, a panic — and a counter that leaks on any one of them is a limit that tightens until it refuses everything. +- 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. + +**TLS is one of `L4`'s values, beside `tcp` and `udp` — 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. 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/services_test.py` pins all four cases including both mismatches. Before this, ALPN was dropped in both directions and every client silently fell back to HTTP/1.1. + +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. + +**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 TLS service's plaintext 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 before the engine starts (a socket opened before the interface exists finds nothing) and `FirewallManager.release_capture_if_idle()` removes it once nothing is decrypting, so nobody points a capture at an interface that will stay empty. 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. ### 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, written once per boot in `startup_main`. It used to be a module-level constant read at import, which is why `run.py config --password` could print "it will take effect immediately" at a process that had already decided every caller was an administrator — the hash landed in the database and nothing ever asked for it. `run.py`'s `put_runtime_settings()`/`get_runtime_setting()` are the one way in and out of a running instance's `keys_values` (Docker exec or the standalone rootfs, same function), used by both `--password` and `--[no-]unsafe-disable-auth`, and `config --show` says when the running instance disagrees with the persisted config. **`config --password` re-enables authentication when it was off**, live and persisted: setting a password is asking for one to be asked for, and a stored-but-unchecked password is worse than none because it reads like one. It stands down when the same command line said otherwise — an explicit `--unsafe-disable-auth`, or `--keep-auth-disabled` for an instance deliberately held open behind a proxy. Note the password block runs *after* the auth block in `handle_config_command`, which is why the auth block's "there is no password set" warning checks whether one is about to be set. + +`POST /api/auth-mode` turns it off and back on at runtime. **The two directions are not symmetrical, and that asymmetry is the design:** off is an administrator's deployment choice, but on, asked while it is off, would let any passer-by set a password and keep the real operator out for good — a lasting foothold, unlike the vandalism the mode already permits. So re-enabling takes a token this instance signed *before* it was turned off. Nothing new is signed while it is off (login and set-password both 403), so holding one is exactly that proof, and the operator who turned it off still has theirs. `token_is_valid()` is split out of `check_login` for this single caller: it needs the question without the "authentication is off, so yes" that `check_login` answers first. Either direction lasts only as long as the process, because the environment is what firegex comes up with — `run.py config` writes both the live database and `.firegex-conf.json`. + +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` from exported `keys_values` rows, so `/api/import` explicitly re-preserves the current password/secret around the import. +Each DB file's schema is versioned by hashing the Python schema dict; on mismatch the **entire file is deleted and recreated** (not migrated) — any schema dict change wipes that DB file on next startup, including `db/firegex.db`'s `keys_values` table (password/JWT secret). `dump()`/`load()` back the export/import backup feature; `dump()` deliberately excludes `password`, `secret` **and `auth_disabled`** from exported `keys_values` rows, so `/api/import` explicitly re-preserves all three around the import. The third one is the newest and the easiest to lose: it is not a credential, it is *whether credentials are asked for*, which belongs to the deployment and not to the configuration being restored — a backup able to carry it is a backup able to turn a firewall's authentication off. `auth_disabled` is also put back **after** `startup_main()`, not before like the other two, because `startup_main` seeds it from the environment: right when a process is starting, wrong during an import where nothing restarted and the environment did not change. `tests/api_test.py` turns authentication off *around* the import on purpose — asserting it is still on afterwards passes either way on a container whose environment agrees, and the bug only shows where the running state and the boot-time environment differ. ### Real-time frontend updates -One Socket.IO event, `"update"`, whose payload is a list of strings treated as a react-query `queryKey` prefix (e.g. `["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. +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 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. +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`**. It was `firegex.nfproxy` when that was one of several modules, and `firegex/nfproxy/__init__.py` still resolves — by aliasing the submodules into `sys.modules`, not by re-exporting them. That distinction is load-bearing: `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 that built second copies of `HttpRequest` would leave old filter files annotating a class the table has never heard of — importing cleanly and then never being called. One class per model, two spellings of one package. The CLI is `fgex pyfilters`, with `fgex nfproxy` kept as a hidden alias. + +**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. + +`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/regex_lib_test.py` pins the agreement (validity per mode, direction, rewriting precedence, the shared file format). 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. +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/Dockerfile b/Dockerfile index 7c2473c..132ea0c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,50 +1,65 @@ - -# 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 -lnetfilter_queue -pthread -lnfnetlink $(pkg-config --cflags --libs libtins libhs libmnl) -RUN g++ binsrc/nfproxy.cpp -o cpproxy -std=c++23 -O3 -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. +# iproute-tc: `tc` is a separate package from `ip` on Fedora, and it is what mirrors the +# decrypted traffic of every TLS service onto one capture interface. The kernel's own +# `dup to` in the netdev family would do the same without it, and is not used: it needs +# `nft_dup_netdev`, which plenty of kernels are built without, and the failure is a rule +# that will not load rather than a feature that degrades. +RUN dnf -y update && dnf install -y python3.14 libnetfilter_queue \ + libnfnetlink libmnl libcap-ng-utils nftables iproute iproute-tc \ + 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 -lnetfilter_queue -pthread -lnfnetlink $(pkg-config --cflags --libs libtins libhs libmnl) +RUN g++ binsrc/pyfilter.cpp -o cpproxy -std=c++23 -O3 -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 + +#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=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..bf70deb 100644 --- a/README.md +++ b/README.md @@ -84,11 +84,20 @@ 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**, mixing IPv4 and IPv6 freely: one daemon routinely answers on more than one, and all of them get the same chain. Adding an address 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 exact rewriting, an ordered chain of filters, 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 can **block** the connection or **rewrite** what it matched (redact a leaked flag, neuter a header). Rewriting is exact on the proxy layer, which owns both connections; the NFQUEUE layer refuses it rather than desynchronising the stream. + - **TLS** is a switch on the service, not a separate thing to create: nginx terminates the connection, the filters inspect the plaintext, and it is re-encrypted towards your service. + - 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. + - **TCP or UDP**, on every layer — but [UDP through the proxy does not preserve the client's address](docs/services.md#udp-on-the-proxy-layer-and-what-it-gives-up), because the kernel option that recovers a datagram's original destination is TCP-only. You get exact rewriting and per-flow filter state; your service sees firegex instead of the client. NFQUEUE filters UDP with the real packets untouched. Firegex says which trade you are making, where you make it. + - **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 it rewrites exactly and lets the kernel reassemble, at the cost of weight and of rebuilding fail-open by hand; NFQUEUE is per packet, costs almost nothing and keeps forwarding by kernel guarantee if a filter dies, at the cost of userspace reassembly and no pattern rewriting. + - **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 +111,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 +131,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..7398d5e 100644 --- a/backend/app.py +++ b/backend/app.py @@ -11,7 +11,7 @@ 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.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 @@ -39,12 +39,35 @@ 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"} + + +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 +145,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 +178,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 +241,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 +262,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 +275,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 +291,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""" @@ -267,6 +346,12 @@ reset, startup, shutdown = load_routers(api) async def startup_main(): db.init() + # The environment is the boot-time answer: `run.py start/restart --[no-]unsafe-disable-auth` + # says what this instance should come up as, and writing it here is what makes the flag + # readable at runtime without making it unreadable at boot. Changes made afterwards — + # from the interface, or with `run.py config` — outlive nothing but the process, which + # is why `run.py config` also persists them to .firegex-conf.json. + db.put("auth_disabled", "1" if UNSAFE_DISABLE_AUTH_ENV else "0") if os.getenv("PSW_HASH_SET"): db.put("password", os.getenv("PSW_HASH_SET")) try: @@ -309,17 +394,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 +# produces: ".db" databases and ".py" filter code 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$') @@ -341,9 +426,9 @@ async def import_db(data: dict): # 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): raise HTTPException(status_code=400, detail=f"Invalid backup: illegal filter filename '{fname}'") @@ -353,17 +438,23 @@ 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. + # Backups never contain the password, the secret or the auth mode (export_db strips + # all three), so preserve this instance's own values instead of losing them on import. current_password = db.get("password") current_secret = db.get("secret") + # This one has to go back *after* the restart below: `startup_main` seeds it from the + # environment, which is the right answer when the process is starting and the wrong + # one here — nothing restarted, the environment did not change, and an import that + # quietly reverted authentication to whatever the container was booted with would + # undo a `run.py config` nobody remembers making. + current_auth_disabled = db.get("auth_disabled") for db_path, db_data in db_imports: temp_db = SQLite(str(db_path)) @@ -372,7 +463,7 @@ async def import_db(data: dict): temp_db.load(db_data) if filter_imports: - os.makedirs('db/nfproxy_filters', exist_ok=True) + 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) @@ -385,6 +476,8 @@ async def import_db(data: dict): # Restart the application state await shutdown_main() await startup_main() + if current_auth_disabled is not None: + db.put("auth_disabled", current_auth_disabled) return {'status': 'ok'} @@ -403,6 +496,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/nfqueue.cpp b/backend/binsrc/classes/nfqueue.cpp index 4db983c..9aaa555 100644 --- a/backend/binsrc/classes/nfqueue.cpp +++ b/backend/binsrc/classes/nfqueue.cpp @@ -148,6 +148,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; } @@ -183,49 +212,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; - 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 *)data)+4, &payload_len, 2); - } - ipv6 = new Tins::IPv6((uint8_t*)data, 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*)data, 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); - // 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(data+_header_size, data_size-_header_size); - } - void fix_tcp_ack(){ need_tcp_fixing = need_tcp_fix(); if(!need_tcp_fixing){ @@ -308,15 +294,22 @@ class PktRequest { } } - void mangle_custom_pkt(const char* raw_pkt, size_t raw_pkt_size){ + // Rewrite the application payload, keeping the headers the packet arrived with. + // + // A filter can no longer see or write anything below the application layer, so + // there is no packet to take from it — only the payload it produced. That is also + // the honest shape: the proxy layer terminates the connection and writes its own + // headers, so a filter that could rewrite an IP header here and not there would be + // a filter that means two different things depending on where it was attached. + void mangle_custom_data(const char* data_ptr, size_t data_len){ if (action == FilterAction::NOACTION){ try{ - set_packet(raw_pkt, raw_pkt_size); + set_data(data_ptr, data_len); reserialize(); action = FilterAction::MANGLE; }catch(const std::exception& e){ #ifdef DEBUG - cerr << "[DEBUG] [PktRequest.mangle_custom_pkt] " << e.what() << endl; + cerr << "[DEBUG] [PktRequest.mangle_custom_data] " << e.what() << endl; #endif action = FilterAction::DROP; } diff --git a/backend/binsrc/nfproxy.cpp b/backend/binsrc/pyfilter.cpp similarity index 76% rename from backend/binsrc/nfproxy.cpp rename to backend/binsrc/pyfilter.cpp index 613b922..6866886 100644 --- a/backend/binsrc/nfproxy.cpp +++ b/backend/binsrc/pyfilter.cpp @@ -22,7 +22,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 +36,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,18 +47,29 @@ 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 the payload is the only thing it can write. The +proxy datapath terminates the connection and writes its own headers, so a filter that +could rewrite one here and not there would mean two different things depending on which +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) + "mangled_data": b"new payload" # The rewritten application payload (used if action = MANGLE) } PyFilterResponse { @@ -73,14 +83,14 @@ PyFilterResponse { 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 */ diff --git a/backend/binsrc/pyproxy/pyproxy.cpp b/backend/binsrc/pyproxy/pyproxy.cpp index 8169f66..590c06b 100644 --- a/backend/binsrc/pyproxy/pyproxy.cpp +++ b/backend/binsrc/pyproxy/pyproxy.cpp @@ -97,6 +97,66 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue { pyq->pkt->drop();// This is needed because the callback has to take the updated pkt pointer! } + //: How many UDP flows may hold a filter context at once. See `enforce_limit`. + static const size_t MAX_UDP_FLOWS = 4096; + + // One datagram, judged on its own. + // + // No stream to follow and no sequence numbers to fix: a datagram is complete in + // itself, which is also why a rewrite here is *exact* rather than the unstable + // thing it is over TCP — there is nothing for a different length to desynchronise. + // Each flow still gets its own module globals, keyed the same way a connection is. + void filter_action_udp(NfQueue::PktRequest* pkt, const string& data){ + auto stream_search = sctx.streams_ctx.find(pkt->sid); + pyfilter_ctx* stream_match; + if (stream_search == sctx.streams_ctx.end()){ + shared_ptr conf = config; + 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.enforce_limit(MAX_UDP_FLOWS); + sctx.streams_ctx.insert_or_assign(pkt->sid, stream_match); + }else{ + stream_match = stream_search->second; + } + + 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::MANGLE: + pkt->mangle_custom_data(result.mangled_data->c_str(), result.mangled_data->size()); + if (pkt->get_action() == NfQueue::FilterAction::DROP){ + cerr << "[ERROR] [filter_action_udp] Failed to mangle: Malformed Packet... the packet was dropped" << endl; + print_blocked_reason(*result.filter_match_by); + print_exception_reason(); + }else{ + print_mangle_reason(*result.filter_match_by); + } + return; + case PyFilterResponse::EXCEPTION: + case PyFilterResponse::INVALID: + print_exception_reason(); + 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; @@ -146,7 +206,7 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue { 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()); + pkt->mangle_custom_data(result.mangled_data->c_str(), result.mangled_data->size()); if (pkt->get_action() == NfQueue::FilterAction::DROP){ cerr << "[ERROR] [filter_action] Failed to mangle: Malformed Packet... the packet was dropped" << endl; print_blocked_reason(*result.filter_match_by); @@ -221,6 +281,17 @@ class PyProxyQueue: public NfQueue::ThreadNfQueue { void handle_next_packet(NfQueue::PktRequest* _pkt) override{ pkt = _pkt; // Setting packet context + 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..7483d47 100644 --- a/backend/binsrc/pyproxy/settings.cpp +++ b/backend/binsrc/pyproxy/settings.cpp @@ -78,7 +78,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..dcecbe2 100644 --- a/backend/binsrc/pyproxy/stream_ctx.cpp +++ b/backend/binsrc/pyproxy/stream_ctx.cpp @@ -39,13 +39,15 @@ const PyFilterResponse VALID_PYTHON_RESPONSE[4] = { struct py_filter_response { PyFilterResponse action; string* filter_match_by = nullptr; - string* mangled_packet = nullptr; + // The rewritten application payload, never a packet: nothing below the + // application layer crosses into the filter, so nothing below it comes back. + string* mangled_data = nullptr; - py_filter_response(PyFilterResponse action, string* filter_match_by = nullptr, string* mangled_packet = nullptr): - action(action), filter_match_by(filter_match_by), mangled_packet(mangled_packet){} + py_filter_response(PyFilterResponse action, string* filter_match_by = nullptr, string* mangled_data = nullptr): + action(action), filter_match_by(filter_match_by), mangled_data(mangled_data){} ~py_filter_response(){ - delete mangled_packet; + delete mangled_data; delete filter_match_by; } }; @@ -111,12 +113,19 @@ 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); @@ -211,22 +220,22 @@ struct pyfilter_ctx { 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){ + PyObject* mangled_data = PyDict_GetItemString(result, "mangled_data"); + if (mangled_data == nullptr){ del_item_from_glob("__firegex_pyfilter_result"); #ifdef DEBUG - cerr << "[DEBUG] [handle_packet] No result mangled_packet found" << endl; + cerr << "[DEBUG] [handle_packet] No result mangled_data found" << endl; #endif return py_filter_response(PyFilterResponse::INVALID); } - if (!PyBytes_Check(mangled_packet)){ + if (!PyBytes_Check(mangled_data)){ #ifdef DEBUG - cerr << "[DEBUG] [handle_packet] mangled_packet is not a bytes" << endl; + cerr << "[DEBUG] [handle_packet] mangled_data is not a bytes" << endl; #endif del_item_from_glob("__firegex_pyfilter_result"); return py_filter_response(PyFilterResponse::INVALID); } - string* pkt_str = new string(PyBytes_AsString(mangled_packet), PyBytes_Size(mangled_packet)); + string* pkt_str = new string(PyBytes_AsString(mangled_data), PyBytes_Size(mangled_data)); del_item_from_glob("__firegex_pyfilter_result"); return py_filter_response(PyFilterResponse::MANGLE, func_name, pkt_str); } @@ -265,6 +274,23 @@ struct stream_ctx { } } + // Keep the number of live filter contexts under a ceiling. + // + // A TCP flow is released when libtins sees the connection close. A datagram has no + // close to observe, so a UDP service under a spoofed-source flood would otherwise + // accumulate one set of Python module globals per forged address until the process + // died. Which context is dropped is arbitrary — the map is ordered by flow id, not + // by age — and that is the honest trade: the bound is the point, and a flow that + // loses its globals starts again from a clean state rather than taking the service + // with it. + void enforce_limit(size_t limit){ + while (streams_ctx.size() >= limit && !streams_ctx.empty()){ + auto victim = streams_ctx.begin(); + delete victim->second; + streams_ctx.erase(victim); + } + } + void clean(){ for (auto ele: streams_ctx){ delete ele.second; diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh index 9cad0e3..ee51872 100644 --- a/backend/docker-entrypoint.sh +++ b/backend/docker-entrypoint.sh @@ -20,18 +20,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/nftables.py b/backend/modules/firewall/nftables.py index 6822fca..7e09a16 100644 --- a/backend/modules/firewall/nftables.py +++ b/backend/modules/firewall/nftables.py @@ -1,47 +1,108 @@ +"""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, and costs nothing: a base chain carries +its own policy wherever it lives, so default-deny is expressed exactly as before, and a +`drop` is terminal no matter which table's chain reached it. What changes is that +firegex now *adds* a layer instead of taking over somebody else's — an `accept` here +means "firegex does not block this", never "nothing else will", which is what it already +meant in practice. Everything installed is visible under one prefix and removed by +deleting two tables per family. +""" + 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 +#: 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,63 +234,27 @@ 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}}} + # 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. + super().__init__(self.init_comands(), [ + 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}}}, + ) ]) - - 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 - }}} - + + + def init(self): + super().init() + def set(self, srvs:list[Rule], policy:str=Action.ACCEPT, opt:FirewallSettings = None): srvs = list(srvs) self.reset() @@ -248,7 +273,9 @@ class FiregexTables(NFTableManager): table=Table.FILTER )) - rules = self.init_comands(policy, opt) + list(self.insert_firegex_chains()) + self.get_rules(*srvs) + # 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.cmd(*rules) def get_rules(self,*srvs:Rule): @@ -297,7 +324,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 74c4fbf..0000000 --- a/backend/modules/nfproxy/firegex.py +++ /dev/null @@ -1,205 +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() - -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 = "" - - @classmethod - async def start(cls, srv: Service, outstream_func=None, exception_func=None): - self = cls() - self.srv = srv - 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()) - 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() - 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() - 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 stop(self): - 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 04bcdd6..0000000 --- a/backend/modules/nfproxy/firewall.py +++ /dev/null @@ -1,141 +0,0 @@ -import asyncio -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.sqlite import SQLite -from utils import run_func - -class STATUS: - STOP = "stop" - ACTIVE = "active" - -nft = FiregexTables() - -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.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 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) - 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 9f80d47..0000000 --- a/backend/modules/nfproxy/nftables.py +++ /dev/null @@ -1,153 +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 - -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}}, - {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} - ] - }}}, - {"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}}, - {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} - ] - }}} - ) - - - 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/__init__.py b/backend/modules/nfregex/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/modules/nfregex/firegex.py b/backend/modules/nfregex/firegex.py deleted file mode 100644 index d73b80f..0000000 --- a/backend/modules/nfregex/firegex.py +++ /dev/null @@ -1,190 +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() - -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() - - @classmethod - async def start(cls, srv: Service): - self = cls() - self.srv = srv - 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()) - 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 stop(self): - 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 6ae3d58..0000000 --- a/backend/modules/nfregex/firewall.py +++ /dev/null @@ -1,127 +0,0 @@ -import asyncio -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 - -class STATUS: - STOP = "stop" - ACTIVE = "active" - -nft = FiregexTables() - - -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 - - 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 start(self): - if not self.interceptor: - nft.delete(self.srv) - self.interceptor = await FiregexInterceptor.start(self.srv) - 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 997fc8a..0000000 --- a/backend/modules/nfregex/nftables.py +++ /dev/null @@ -1,148 +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 - -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}}, - {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} - ] - }}}, - {"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}}, - {"queue": {"num": str(init) if init == end else {"range":[init, end] }, "flags": ["bypass"]}} - ] - }}} - ) - - - 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/firewall.py b/backend/modules/services/firewall.py new file mode 100644 index 0000000..e2b881b --- /dev/null +++ b/backend/modules/services/firewall.py @@ -0,0 +1,526 @@ +"""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 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, STATUS, Address, Filter, Regex, Service +from modules.services.nftables import FiregexTables +from utils.sqlite import SQLite + +nft = FiregexTables() + +#: The user's Python, one file per filter. +CODE_DIR = "db/service_filters" + +#: 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 + + +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 + #: 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 = {} + #: Carried between lines so a multi-line traceback keeps its severity. + self._in_traceback = False + self._engine_level = LEVEL.INFO + + # --- 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 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("/") + if function: + self.db.query( + "UPDATE pyfilters SET blocked = blocked + 1 WHERE filter_id = ? AND name = ?;", + 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. + self.db.query( + "UPDATE regexes SET blocked = blocked + 1 WHERE regex_id = ?;", rule_id + ) + self.db.query( + "UPDATE filters SET blocked = blocked + 1 WHERE filter_id = ? OR filter_id = " + "(SELECT filter_id FROM regexes WHERE regex_id = ?);", + rule_id, + rule_id, + ) + # 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 _owning_filter(self, rule_id: str) -> str: + """Which filter a block belongs to. The id is a filter's or a pattern's.""" + found = self.db.query( + "SELECT filter_id FROM regexes WHERE regex_id = ?;", rule_id + ) + return found[0]["filter_id"] if found else rule_id + + def _describe(self, rule_id: str, function: str = "") -> str: + """Name what refused a connection, rather than echoing an opaque id. + + One query per block, on a path that only runs when something was already + refused — a block is rare compared to the traffic that is not blocked, and an + operator reading `refused by 4f2a91c8` learns nothing. + """ + 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): + self.log.add(LEVEL.ERROR, "a filter raised; the traffic was 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_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): + if self.active: + return + async with self.lock: + 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, + ) + try: + # Before the engine, which opens its socket to it at startup: an + # interface that appears afterwards is one this process will not find + # until it is restarted. + if self.srv.terminates_tls: + mirror.ensure() + 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) + 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): + if not self.active: + return + async with self.lock: + nft.delete(self.srv) + self._steer = {} + if self.transport: + await self.transport.stop() + self.transport = None + self._set_status(False, persist=persist) + self.log.add(LEVEL.INFO, "stopped") + + async def restart(self): + await self.disable() + await self.enable() + + async def update_chain(self): + """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. + """ + restart = False + 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") + await self.restart() + 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): + """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 + self.reload_addresses() + if not self.active: + return + if ( + self.srv.transport == transports.TRANSPORT.PROXY + and self.srv.has_ipv6 + and not was_dual + ): + self.log.add( + LEVEL.INFO, + "the first IPv6 address needs a listener that can accept one; rebuilding", + ) + await self.restart() + return + added = [addr for addr in self.srv.addresses if addr.id == address_id] + if not added: + return + async with self.lock: + nft.add(self.srv, added, **self._steer) + 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) + 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] + + 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() + 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() + + async def init(self): + nft.init() + await self.reload() + + 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): + async with self.lock: + if srv_id in self.services: + await self.services[srv_id].disable(persist=persist) + del self.services[srv_id] + if persist: + # A deleted service's log has nothing left to be about. + forget_log(srv_id) + self.release_capture_if_idle() + + def release_capture_if_idle(self) -> None: + """Take the capture interface away once nothing is decrypting. + + Asked of the managers rather than of the database, because what matters is which + services are *running*: a stopped one writes nothing, and an interface that is + there while nothing is decrypting is one somebody points a capture at and watches + stay empty. + """ + if any(m.active and m.srv.terminates_tls for m in self.services.values()): + return + mirror.release() + + async def close(self): + for key in list(self.services.keys()): + try: + await self.remove(key, persist=False) + except Exception: + self.services.pop(key, None) + + 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..e2c751f --- /dev/null +++ b/backend/modules/services/logs.py @@ -0,0 +1,187 @@ +"""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: + 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. + """ + if not any(entry["level"] == LEVEL.BLOCK 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 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..2997ab8 --- /dev/null +++ b/backend/modules/services/mirror.py @@ -0,0 +1,49 @@ +"""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 only while some TLS service is running. Nothing is lost by removing it — the +engine reopens its socket per process — and an interface that is there when nothing is +decrypting is an interface somebody points a capture at and watches stay empty. +""" + +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.""" + _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..f86b649 --- /dev/null +++ b/backend/modules/services/models.py @@ -0,0 +1,375 @@ +"""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 + + +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" + + ALL = (TCP, UDP, TLS) + + @staticmethod + def l4_of(proto: str) -> str: + """The transport a rule has to match to catch this service's traffic.""" + return L4.UDP if str(proto) == L4.UDP else L4.TCP + + +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 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, and is always the + service's own. 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. + """ + + def __init__( + self, + address_id: str, + service_id: str, + ip_int: str, + port: int, + proto: str = L4.TCP, + 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 + #: 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, + "proxy_ip": self.proxy_ip, + "proxy_port": self.proxy_port, + } + + @property + def is_ipv6(self) -> bool: + return ":" in str(self.ip_int) + + 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 terminates_tls(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. + """ + return str(self.proto) == L4.TLS + + @property + def l4(self) -> str: + """The transport the kernel sees, which is what a rule has to match.""" + return L4.l4_of(self.proto) + + @property + def hands_off(self) -> bool: + """Whether firegex only steers this traffic rather than inspecting it.""" + return self.transport == TRANSPORT.EXTERNAL + + @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) + + 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..8b06571 --- /dev/null +++ b/backend/modules/services/nftables.py @@ -0,0 +1,589 @@ +"""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 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. +""" + +from modules.services.models import TRANSPORT, Address, Service +from utils import NFTableManager, addr_parse, ip_family, 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 + +# 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. + + 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 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] + + +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 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.port == int(target_port) and ip_parse(self.ip_int) == ip_parse(target_ip): + return True + # The return leg of an external hand-off matches the operator's proxy, not the + # service. Recognising only the inbound rule would leave the outbound one behind + # every time a service was stopped, and it would keep rewriting. + if srv.transport == TRANSPORT.EXTERNAL and addr.proxy_port: + proxy_ip = one_address(addr.proxy_ip or "") or ( + "::1" if ip_family(target_ip) == "ip6" else "127.0.0.1" + ) + return self.port == int(addr.proxy_port) and ip_parse(self.ip_int) == ip_parse(proxy_ip) + 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", + } + } + } + + +def _drop_chain(name: str) -> list[dict]: + return [ + {"flush": {"chain": {"table": FiregexTables.table_name, "family": "inet", "name": name}}}, + {"delete": {"chain": {"table": FiregexTables.table_name, "family": "inet", "name": name}}}, + ] + + +class FiregexTables(NFTableManager): + # NFQUEUE transport: one pair of chains per position in the filter chain. + @staticmethod + 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}" + + # 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" + + 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), + ) + ], + # 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}, + ], + } + } + }, + _chain(self.nat_chain, "nat", "prerouting", -100), + # Locally generated traffic never reaches the nat prerouting hook, and + # a client on this host protecting a service on this host is exactly that. + _chain(self.nat_output_chain, "nat", "output", -100), + # `route`, so changing the mark forces the packet to be re-routed. + _chain(self.route_chain, "route", "output", -150), + # Installed once. A packet on the intercepted (redirected) connection + # is the engine answering a client and has to leave normally; anything + # else from a protected service is a reply to the engine's own dial, + # and the per-service rule below marks it home. + { + "add": { + "rule": { + "family": "inet", + "table": self.table_name, + "chain": self.route_chain, + "expr": [ + { + "match": { + "op": "in", + "left": {"ct": {"key": "status"}}, + "right": "dnat", + } + }, + {"accept": None}, + ], + } + } + }, + ], + [ + *[ + cmd + for position in range(MAX_CHAIN_POSITIONS) + for cmd in ( + *_drop_chain(self.queue_input_chain(position)), + *_drop_chain(self.queue_output_chain(position)), + ) + ], + *_drop_chain(self.hijack_in_chain), + *_drop_chain(self.hijack_local_chain), + *_drop_chain(self.hijack_out_chain), + *_drop_chain(self.divert_chain), + *_drop_chain(self.nat_chain), + *_drop_chain(self.nat_output_chain), + *_drop_chain(self.route_chain), + ], + ) + + def init(self): + super().init() + + # --- 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: + 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) -> dict: + verb = "insert" if insert else "add" + return { + verb: { + "rule": { + "family": "inet", + "table": self.table_name, + "chain": chain, + "expr": expr, + } + } + } + + # --- 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 + family = 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": + key = ( + f"[{one_address(target_ip)}]:{target_port}" + if family == "ip6" else f"{one_address(target_ip)}:{target_port}" + ) + 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) + + 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): + queue = {"queue": {"num": str(queue_num), "flags": ["bypass"]}} + 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, + )) + self.cmd(*cmds) + + 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 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 = one_address(addr.proxy_ip or "") or ("::1" if family == "ip6" else "127.0.0.1") + 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. + redirect = ( + self._not_ours() + + self._match(ip, port, family, l4, "daddr", "dport") + + [self.COUNTER, {"redirect": {"port": int(proxy_port)}}] + ) + self.cmd( + self._rule(self.nat_chain, redirect), + self._rule(self.nat_output_chain, redirect), + # Source preservation is not optional. A protected service that suddenly + # sees one address for the whole internet is a silent regression against + # the NFQUEUE transport, and the operator would find out from their own + # rate limiter rather than from us. + self._rule( + self.route_chain, + self._match(ip, port, family, l4, "saddr", "sport") + + [{"mangle": {"key": {"meta": {"key": "mark"}}, "value": PROXY_MARK}}], + ), + ) + + # --- 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.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): + try: + expr = rule["expr"] + # 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", {}): + expr = expr[1:] + if "payload" not in expr[0].get("match", {}).get("left", {}): + continue + 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"]}' + counter = next( + (e["counter"] for e in expr if isinstance(e, dict) and "counter" in e), + {}, + ) + 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): + 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 = { + TRANSPORT.NFQUEUE: {self.queue_input_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): + """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. + """ + 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) diff --git a/backend/modules/services/pyapi.py b/backend/modules/services/pyapi.py new file mode 100644 index 0000000..9413b98 --- /dev/null +++ b/backend/modules/services/pyapi.py @@ -0,0 +1,99 @@ +"""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 worth reporting on its own: the whole boundary the filter API rests + on is that metadata is read and only the payload is written, and an editor that says + so while you type teaches it better than a paragraph in the documentation. + """ + 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": "Silently drop this chunk and every one after it, without closing."}, + + ] + + 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..d5f510f --- /dev/null +++ b/backend/modules/services/pyworker.py @@ -0,0 +1,392 @@ +#!/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][payload] + verdict: 0 accept, 1 reject, 2 replace + +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, payload +out — that is the one contract both network layers can honestly keep. +""" + +import json +import re +import struct +import sys +import traceback + +VERDICT_ACCEPT = 0 +VERDICT_REJECT = 1 +VERDICT_REPLACE = 2 +# Sent once, after the user's file has been compiled. It is what lets the engine refuse +# a ruleset whose code does not load, instead of discovering it later as traffic that +# quietly stopped being filtered. +READY = 0xFF + +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": ""} + + +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": 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 + 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, bytes]: + """One chunk through the chain. Returns the verdict, who decided, and the payload.""" + 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 + # and UDP flows alike, and the library's stream and HTTP models key off it. + "is_tcp": bool(meta.get("is_tcp", True)), + "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, "", b"" + + action = result.get("action") + # Which function decided, so the block lands on it rather than on the whole file. + matched = result.get("matched_by") + matched = matched if isinstance(matched, str) else "" + if action == ACCEPT.value: + return VERDICT_ACCEPT, "", b"" + if action == REJECT.value: + return VERDICT_REJECT, matched, b"" + if action == DROP.value: + # A proxy has no way to swallow one chunk and keep the stream coherent — + # the application on the other side would be reading a hole. Closing is the + # honest equivalent, and it is what the operator meant by dropping. + return VERDICT_REJECT, matched, b"" + print(f"[warn] [pyworker] unknown action {action!r}", file=sys.stderr) + return VERDICT_ACCEPT, "", b"" + + +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, payload: bytes) -> None: + # The name is length-prefixed with a single byte: a Python identifier fits, and a + # separator would have to be one that cannot appear in one. + name = matched.encode()[:255] + stream.write( + struct.pack(">I", len(payload) + len(name) + 2) + + bytes([verdict, len(name)]) + + name + + payload + ) + stream.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 + sys.stdout.write(json.dumps(check(sys.argv[2]))) + sys.stdout.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() + + 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, out = 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. + traceback.print_exc() + verdict, matched, out = VERDICT_ACCEPT, "", b"" + _write(stdout, verdict, matched, out) + + +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..d027565 --- /dev/null +++ b/backend/modules/services/transports.py @@ -0,0 +1,933 @@ +"""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 traceback + +from modules.services.models import KIND, L4, PROTO, TRANSPORT, Filter, Regex +from modules.services.nftables import MAX_CHAIN_POSITIONS, one_address +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") + +#: How long to wait for a binary to acknowledge a configuration push. +ACK_TIMEOUT = 3 + + +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 + + +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): + 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 + 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 stop(self) -> None: + raise NotImplementedError + + 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() + self.on_engine(text) + except (asyncio.CancelledError, asyncio.IncompleteReadError): + pass + except Exception: + traceback.print_exc() + + return asyncio.create_task(run()) + + 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 filter code stands for, for block attribution. + self._codes: dict[str, str] = {} + + 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() + await self.reload(self.link) + return self.queue_num + + # --- 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, + }, + ), + ) + 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): + try: + while True: + data = await self.process.stdout.read(10 * 1024) + if not data: + return + if self.owner.on_output: + self.owner.on_output(self.srv.id, data.decode(errors="replace")) + except (asyncio.CancelledError, asyncio.IncompleteReadError): + pass + except Exception: + traceback.print_exc() + + # --- 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 ") or line.startswith("MANGLED "): + code = line.split()[1] + 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) + await self._push(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.""" + from firegex.pyfilters.internals import get_filter_names + + code = "" + if link.code_path and os.path.exists(link.code_path): + with open(link.code_path) as f: + code = f.read() + # Which filters the file defines, asked of the library rather than assumed: it + # decides what counts as one, and it refuses to compile a module that does not + # tell it. Every block the binary reports names one of these, so they are also + # what attributes a refusal to a rule. + try: + names = get_filter_names(code) if code.strip() else [] + except Exception: + # A file that will not even parse is the binary's refusal to make, with the + # error the operator needs; guessing an empty list here would hide it. + names = [] + # Only what the operator left switched on, intersected with what the file + # actually defines: a stale selection naming a function that has since been + # deleted must not stop the module from loading. + enabled = link.enabled_functions + if enabled is not None: + names = [name for name in names if name in set(enabled)] + # The binary reports a block by the function name; `/` is the + # one token both network layers send, so the backend attributes a block the same + # way whichever one produced it. + self._codes = {name: f"{link.id}/{name}" for name in names} + body = ( + code + + "\n\n__firegex_pyfilter_enabled = [" + + ", ".join(repr(name) for name in names) + + "]\n" + # 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" + ) + return len(body).to_bytes(4, byteorder="big") + body.encode() + + 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=ACK_TIMEOUT) + except asyncio.TimeoutError: + await self.stop() + raise Exception("the nfqueue binary did not acknowledge the filters") + finally: + self._ack = None + if not ok: + await self.stop() + raise Exception(f"the nfqueue binary rejected the filters: {detail}") + + async def stop(self) -> 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 srv.terminates_tls: + # 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. + 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] = [] + + 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] + # A filter appearing, disappearing or changing places changes which process sits + # at which priority, and that is the chain itself. Pushing new rules into the + # processes that happen to be running would enforce the old order. + if [link.id for link in active] != [stage.link.id for stage in self.stages]: + 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 = [] + + +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 + + +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) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._reader_task: asyncio.Task | None = None + self._ack: asyncio.Future | None = None + self.port: int | None = None + #: One relay port per protected address, for UDP. TCP needs none of this: a + #: single listener fronts every address and recovers where each connection was + #: headed from conntrack, which UDP cannot do — see `udp.rs`. + self.udp_ports: dict[str, int] = {} + + def _udp_targets(self) -> list: + """The addresses that each need a relay of their own. Empty for TCP.""" + if str(self.srv.proto) != L4.UDP: + return [] + from modules.services.nftables import resolve_target + out = [] + for addr in self.srv.addresses: + target = resolve_target(self.srv, addr) + if target: + out.append(target) + return out + + def _tls_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. + + `FGEX_PROXY_TLS_UPSTREAM` is set with them, because a service behind TLS speaks + TLS: nginx used to re-encrypt on the way out with `proxy_ssl on`, and the engine + does the same thing in the same place. + """ + if not self.srv.terminates_tls: + 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 "") + return { + "FGEX_PROXY_TLS_CERT": cert_path, + "FGEX_PROXY_TLS_KEY": key_path, + "FGEX_PROXY_TLS_UPSTREAM": "1", + } + + def _clear_tls_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 + # redirect keeps the address family, so an IPv6 connection sent at an IPv4-only + # listener is simply refused — and refused connections to a service that used to + # work is the worst way to find that out. One v6 address is enough to make the + # listener v6: a dual-stack socket takes the v4 ones as mapped addresses, which + # the engine unmaps before it does anything with them. + listen = "[::]:0" if self.srv.has_ipv6 else "0.0.0.0:0" + 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"), + # UDP gets 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 client's + # address is not preserved on that path, which the operator is told + # before choosing it. + "FGEX_PROXY_UDP": ",".join( + f"[{ip}]:{port}" if ":" in str(ip) else f"{ip}:{port}" + for ip, port in ( + (one_address(a), p) for a, p in self._udp_targets() + ) + ), + # 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), + **self._tls_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 self._udp_targets(): + 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}") + _, upstream, port = line.split() + self.udp_ports[upstream] = 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()) + # 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 reload(self, chain: list[ChainLink]) -> None: + if not self.process or self.process.returncode is not None: + return + 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=ACK_TIMEOUT) + except asyncio.TimeoutError: + 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 _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("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) + # 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: + 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_tls_material() + + +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) + 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.terminates_tls: + raise UnsupportedChain( + "Decrypting exists so that filters can see the plaintext, and this " + "service has no filters — nothing of firegex is in its path. Let your own " + "proxy speak TLS, or move 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..40f0e67 --- /dev/null +++ b/backend/proxysrc/Cargo.lock @@ -0,0 +1,484 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[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 = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "fgex-proxy" +version = "0.1.0" +dependencies = [ + "libc", + "rcgen", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "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 = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[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 = "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 = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[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", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[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 = [ + "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 = "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 = "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 = "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 = "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 = "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 = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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..d9a653c --- /dev/null +++ b/backend/proxysrc/Cargo.toml @@ -0,0 +1,33 @@ +[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" +# 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" + +[dev-dependencies] +# Test certificates are generated, never committed. +rcgen = "0.13" + +# 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..f3b808b --- /dev/null +++ b/backend/proxysrc/README.md @@ -0,0 +1,407 @@ +# Proxy datapath engine + +An alternative engine to the NFQUEUE one in `backend/binsrc/`. Instead of lifting +packets to userspace and handing a verdict back to the kernel, it terminates the +connection and owns both halves. + +Status: **working, and not yet the default anywhere.** A service can select it from +the Proxy Engine page, with regex and rewrite rules that apply to live connections, +Python filters run in a process of their own, and it has been measured against +nfregex. What it does not have is the C++ engine's parsed HTTP models. + +The two questions that could have killed the approach outright — does it still fail +open, and does the service still see the real client — are answered below, and by +tests rather than by argument. + +## What it has to prove first + +The NFQUEUE engine fails open in the kernel: the nft rule carries `flags: ["bypass"]` +and the binary asks for `NFQA_CFG_F_FAIL_OPEN`. If firegex dies, stalls, or falls +behind, packets flow anyway. In attack/defense that is the difference between "the +filter didn't fire" and "I lost the SLA". + +A userspace proxy has no such backstop: if the process dies, the socket dies with it. +So the property has to be rebuilt by hand, and it has to hold against user code that +is assumed to be broken: + +| Failure | Handled by | Outcome | +| --- | --- | --- | +| Filter panics | `catch_unwind` on a blocking task | Chunk forwarded, filter disabled at once | +| Filter never returns | Deadline on the blocking task | Chunk forwarded, filter disabled after N in a row | +| Every filter disabled | `degraded` flag | Chain skipped entirely — no copy, no thread hop | +| Connection handler panics | One task per connection | That connection only; the listener lives | + +The degraded path is deliberately the cheapest one. Failing open must not cost more +than working normally, or the failure mode becomes a second failure. + +## What the proxy buys + +- **Exact rewriting.** `Verdict::Replace` changes the payload length and the stream + stays correct, because there are two independent TCP connections. On NFQUEUE the + same operation desynchronises sequence numbers, which is why its action is still + called `UNSTABLE_MANGLE` there — see `docs/pyfilter.md`. +- **Real connection metadata.** A filter is told the client's and the service's + address and port once per connection (`ConnectionMeta`, sent as a `KIND_OPEN` + frame), and can read nothing else below the application layer. That is more than a + header could honestly say here: this engine terminated the connection, so the + headers on the wire are its own. +- **Reassembly from the kernel**, instead of `StreamFollower` and the out-of-order + caveat that comes with it. +- **Backpressure for free.** A slow filter slows the sender through TCP flow control + rather than overflowing a fixed-size queue and dropping packets. +- **Clean closes.** A rejected connection is shut down properly instead of leaving + the peer to infer something from missing packets. + +## The limitation that decided the design + +A blocking filter thread **cannot be cancelled**. When one blows the deadline the +engine walks away, but the thread lives until its own code decides to stop — it holds +a blocking-pool slot until the process restarts. The damage is bounded (a filter that +times out repeatedly is disabled and never called again; +`hanging_filter_fails_open_then_loses_its_say` asserts the leak stops growing), but +bounded is not zero. + +That is why user Python runs **out of process**: a hung worker can be killed, a hung +thread cannot. It also keeps `libpython` out of the datapath binary, which is where a +good share of the C++ engine's complexity lives. Nothing shipped now relies on the abandoned +thread path — regex rules run inline, the Python worker enforces its own deadline — +but the machinery stays, because it is what a future in-process filter would need. + +## Rules + +One JSON line on stdin carries the whole ruleset, one `ACK OK` / `ACK FAIL ` +line comes back — the same shape `cppregex` uses for its filter codes. Blocks are +reported as `BLOCKED ` so the backend can attribute them. + +```json +[{"kind":"regex","id":"r1","pattern":"FLAG\\{[a-z]+\\}","direction":"s2c"}, + {"kind":"replace","id":"r2","pattern":"secret","with":"[redacted]"}] +``` + +Two decisions worth knowing: + +- **A rejected ruleset changes nothing.** One bad pattern refuses the whole update + and the previous rules keep enforcing. Failing open covers a filter that breaks at + runtime; it is not a licence to quietly drop rules the operator asked for. The + backend mirrors this: a rule the engine will not compile is removed again rather + than left sitting in the list looking active. +- **`regex` matches the window, `replace` matches the chunk.** Matching the chunk + alone would miss a pattern split across two reads, which is the evasion nfregex + reassembles the stream to prevent. Rewriting the window instead of the chunk would + resend bytes that already left. The per-direction window defaults to 1MB + (`FGEX_PROXY_STREAM_WINDOW`) — that is the longest pattern that can still be caught + across a boundary, and every byte of it is rescanned per chunk, so it is a + throughput knob rather than a buffer. It belongs to one connection: one client's + traffic can never complete another's pattern. + +A block closes the **connection**, not the direction. Half-closing would let a +service that answers without waiting for the request still reach a client whose +request was refused — found by the suite, not by reading. + +## Python filters + +The thing the C++ engine has and this did not. It runs in a **child process**, and that is +the whole point rather than an implementation detail: the engine's own deadline can +only walk away from a blocking thread, which then holds a pool slot until the process +restarts. A child that misses its deadline is killed and respawned on the next chunk, +and the chunk it was holding is forwarded. `tests/pyworker.rs` asserts exactly that. + +The API is the one a pyfilters user already knows: + +```python +from firegex.pyfilters import pyfilter, ACCEPT, REJECT + +@pyfilter +def block_path_traversal(data: bytes, direction: str): + return REJECT if b"../" in data else ACCEPT +``` + +What a filter is handed is different, and deliberately so: a chunk of the stream and +a direction, not one of the C++ engine's parsed models. `HttpRequest` and friends are +the C++ engine's, and they are not here. The import is shimmed to resolve to the worker +rather than to an installed `fgex`, because the decorator has to register into this +worker and borrowing the names without the models would be the confusing option. + +Frames are length-prefixed binary on the worker's stdin/stdout, tracebacks go to +stderr where the backend already reads them. The worker announces itself once it has +executed the user's file, which is what lets a ruleset carrying code that does not +load be **refused when it is applied** instead of discovered later as traffic that +quietly stopped being filtered — the backend then puts the previous file back. + +One worker per rule, one conversation at a time. Serialising is the honest simple +thing while a frame is a request and a response; a pool is the next step if Python +filtering ever becomes the bottleneck. + +## TLS + +The engine terminates it. It decrypts from the client, the rules see plaintext that never +leaves the process, and it re-encrypts on the way out — `rustls`, so there is no OpenSSL +in the build either. + +What that replaced was nginx: one listener terminating the public connection and +forwarding plaintext to a second loopback port, which re-encrypted towards the real +service, with the filter engine attached to the leg in between. It worked, and it cost a +config generated per stream, **a pair of ports derived from hashing `ip:port`**, and one +hop where the traffic was in the clear. + +The ports are what settled it. They were chosen rather than assigned, so they could +collide with something the operator was actually running, and a service was protected on +them rather than on the address the world dialled. Terminating here means a TLS service +occupies no port that a plain one does not. + +The argument that this removes the hop in the clear is *not* the argument, and was worth +retiring: reading loopback on the box means already having code on the box. + +The upstream certificate is **not verified**, mirroring the `proxy_ssl_verify off` nginx +was configured with. That is the right call rather than laziness: the service on the +other side is the thing firegex is defending, it is behind loopback or a private link, +and in a competition it is invariably self-signed. Verifying it would only ever refuse to +protect it. + +Certificate and key reach the engine as **paths**, never as environment values: a private +key in an env dump is a private key in a log. The API reports whether TLS is on and never +returns the material. + +The key has to be one `ring` will sign with — RSA of at least 2048 bits, ECDSA, or +Ed25519. nginx could be told to accept a smaller RSA key with `@SECLEVEL=1`; rustls has +no equivalent, and a service carrying one is refused at startup with the reason. + +One thing the NFQUEUE binaries never need, because they never open a second connection: +on a path where both ends are local, `nat prerouting` is never consulted, and an intercept +in `nat output` would catch the engine's own dial and loop it back into itself. So the +engine stamps `SO_MARK` on every connection it opens and the intercept rules skip it. + +### Where the plaintext goes + +Terminating in-process means the decrypted traffic is never a packet, so there is nothing +for a capture tool to read — which would have been a real loss along with the ports. So +`capture.rs` writes it out: each connection's plaintext framed as the TCP stream it was, +sent over `AF_PACKET` to a dummy interface (`firegex0`) that carries every TLS service's +plaintext and nothing else. + +They are **reconstructed** packets. The bytes are real — what the rules saw, and what was +forwarded after any rewrite — but the framing is built here, because the framing on the +wire was encrypted: sequence numbers from zero, no retransmissions, segmentation from +this engine's read sizes. A SYN and a FIN are emitted so a capture tool has a stream to +follow rather than orphan segments. + +Every failure is silent and local. No interface, no socket (it needs `CAP_NET_RAW`), a +send that fails — the traffic goes on exactly as it would have. A capture aid must never +be able to interrupt the service it is watching. + +## Measured against nfregex + +`tests/proxy_benchmark.py` runs both engines on the same host with the same iperf3 +workload and the same patterns, adding rules one at a time — the method +`benchmark.py` already uses, with both binaries driven directly so it needs no +firegex instance. Figures below are MB/s on one machine; the interesting part is the +ratio, not the absolute number. + +| Rules | Proxy engine | nfregex (NFQUEUE) | Proxy + a Python filter | +| --- | --- | --- | --- | +| none | ~7600 | ~2450 | ~1310 | +| 1 | ~4300 | ~2100–2700 | ~1200 | +| 5 | ~2900 | ~2300–2800 | ~1140 | +| 10 | ~2880 | ~2400–3300 | ~1200 | + +A Python filter costs roughly another halving and then stops mattering: every chunk +is a round trip to a child process, and the regex rules that run alongside it are +free by comparison. It is the price of being able to kill a filter that hangs, and at +~1.2 GB/s it is far above what any service in a competition will offer. + +TLS is measured apart, with its own client, because iperf3 does not speak it — only +the ratio between the two rows means anything: + +| Through the engine | MB/s | +| --- | --- | +| plaintext | ~6900 | +| TLS terminated and re-encrypted | ~1450 | + +Both handshakes and AES on both legs, for about a fifth of the plaintext figure. That +is the cost of the engine doing the crypto rather than nginx — worth knowing before +choosing which of the two TLS paths to take, though it is not what should decide it. + +Moving bytes, the proxy is about **3x faster**: the kernel does the reassembly and +there is no per-packet round trip to userspace. With rules the two are **comparable** — +nfregex's readings swing widely enough that claiming a winner at 10 rules would be +reading noise. + +Getting there took two corrections that the benchmark, not review, made obvious: + +- The first version rescanned a 1MB rolling window with every rule on every chunk. + Throughput fell with each rule added — 100 MB/s at ten rules, against nfregex's + 2400 — while hyperscan on the other side did not move. Fixed by scanning only the + new bytes plus a bounded overlap, and by compiling all the patterns that share a + direction and a case setting into **one** `RegexSet`. +- After that, one rule still cost 4x the throughput of none. The cost was not the + matching: it was the thread hop and the copy that every filter paid so that user + code which hangs cannot stall the datapath. A compiled regex cannot hang, so it now + declares `may_block() == false` and runs inline against the relay's own buffers. + That took ten rules from 1264 to ~2950 MB/s. + +The deadline machinery is still there, and still the default: it is what Python +filters will need. It is simply no longer charged to filters that cannot use it. + +## Layout + +- `src/filter.rs` — verdicts, the `Filter` trait, the chain and its isolation policy. + `ChainHandle` swaps the chain under live connections so reconfiguring costs nobody. +- `src/proxy.rs` — accept loop, upstream selection and the two relay pumps. +- `src/transparent.rs` — `IP_TRANSPARENT`, original-destination recovery, and the + startup capability probe. +- `src/rules.rs` — the filters the backend configures, and the JSON they arrive in. +- `src/pyworker.rs` — the child process running the user's Python, and its deadline. +- `src/tls.rs` — termination and re-encryption, and the verifier that deliberately + accepts what the protected service presents. +- `src/control.rs` — the stdin/ACK control channel, running alongside the datapath so + a rule change never gates traffic. +- `src/spec.rs` — filters built from a text spec: `panic`, `hang` and friends. Test + scaffolding only, so the Python suite can ask for a filter that misbehaves without + linking against the crate. +- `tests/fail_open.rs` — the guarantees above, over real sockets. +- `tests/rules.rs` — matching, direction, rewriting, grouping, and the boundary cases. +- `tests/pyworker.rs` — a hung worker being killed, a crashed one recovering. +- `tests/tls.rs` — decrypt, inspect, re-encrypt, and rules seeing the plaintext. +- `../../tests/proxy_benchmark.py` — the comparison above. +- `../../tests/proxy_engine_test.py` — the same guarantees from outside the process. +- `../../tests/proxy_transparent_test.py` — transparency, in three network namespaces. + +## Source-IP transparency + +The service has to keep seeing the real client, or everything that logs, rate-limits +or bans by address starts seeing one address for the whole internet. `transparent.rs` +covers both halves: + +- **Where was the client going?** Under `tproxy` the kernel leaves the original + destination as the accepted socket's own local address. Under dnat/redirect — the + shape `porthijack` already uses — it survives only in conntrack, so it is fetched + with `SO_ORIGINAL_DST`. Both modes are implemented; `FGEX_PROXY_INTERCEPT` picks one. +- **Dial the service as the client.** `IP_TRANSPARENT` on the outbound socket, bound + to the client's address with a kernel-chosen port. + +`CAP_NET_ADMIN` is checked at startup, because a missing capability would otherwise +surface only as every service quietly seeing the proxy's address. If a transparent +dial fails at runtime the proxy falls back to its own address — traffic beats +identity — but counts it and says so once, loudly. + +### The ruleset tproxy needs + +Proven by `tests/proxy_transparent_test.py`, for a service outside this namespace. +Rule order is not cosmetic: replies addressed to a client we are impersonating belong +to an existing transparent socket and must be diverted **before** the forwarding +decision sends them back out. The next section covers the on-host case, which the +redirect intercept handles differently. + +``` +nft add rule ip pre socket transparent 1 meta mark set 0x1 accept +nft add rule ip pre iif tcp dport tproxy to : meta mark set 0x1 + +ip rule add fwmark 0x1 lookup 100 +ip route add local 0.0.0.0/0 dev lo table 100 +``` + +The `local` route is what makes marked packets be delivered here instead of routed by +destination. Without it a spoofed connection does not fail, it hangs — which is why +the test asserts a completed round trip and not just the address the service reports. +The same route serves the redirect intercept; only the divert rule differs. + +## Preserving the client address + +The service must keep seeing the real client, or everything that logs, rate-limits or +bans by address starts seeing one address for the whole internet. The proxy dials the +service from the client's address; the work is in getting the reply back. + +With the service on this same host, two flows look almost identical: + +| Flow | src | dst | must | +| --- | --- | --- | --- | +| Proxy answering the client | `service:port` | `client:client_port` | leave the box | +| Service answering the proxy | `service:port` | `client:` | stay local | + +No address tells them apart — only an ephemeral port chosen at connect time, which +nothing can be written against. **Conntrack can**: the first belongs to the +intercepted, DNATed connection, the second is a separate entry the proxy opened +itself. So the output chain reads + +``` +ct status dnat accept # the proxy answering a client: let it out +ip saddr tcp sport mark set # anything else from the service: bring it home +``` + +and the marked packets are delivered locally by the usual `fwmark` rule plus a +`local` default route. That is what makes source preservation work in the topology +firegex actually runs in, with no namespace of its own and no tproxy. + +The tproxy intercept creates no NAT entry and so has no such marker: there, a divert +rule broad enough to catch the service's reply also reroutes the proxy's own SYN-ACK +away from the client, and the connection hangs rather than fails. That was measured. +`modules/proxyengine/routing.check_topology` refuses the combination up front, so it +is an error at start instead of a stall under load, and points at the mode that does +work. + +| | intercept reaches proxy via | preserves client IP | needs `ip` | service on this host | +| --- | --- | --- | --- | --- | +| `redirect` | conntrack NAT + `SO_ORIGINAL_DST` | optional | only with preserve | yes | +| `tproxy` | `tproxy to :port` | optional | always | no | + +## Running it + +No Rust on the host? Build and test in a container: + +```bash +docker run --rm -v "$PWD/backend/proxysrc":/w -w /w rust:1-slim cargo test +``` + +End-to-end against the built binary, reusing the existing test helpers: + +```bash +docker run --rm -v "$PWD":/repo -w /repo/tests python:3-slim python3 proxy_engine_test.py +``` + +Transparency needs a real kernel, root and nftables, so it runs privileged: + +```bash +docker run --rm --privileged -v "$PWD":/repo -w /repo/tests fedora:44 sh -c \ + "dnf install -y python3 nftables iproute >/dev/null && python3 proxy_transparent_test.py" +``` + +Neither test is in `run_tests.sh` yet: they need no running firegex instance, CI does +not build this binary, and the transparency one rewrites the host's routing rules — +the same reason `ipfilter_test.py` is run on its own. They join the suite when the +Dockerfile builds the engine and a service can select it. + +## Backend side + +`backend/modules/proxyengine/` follows the per-module shape the other modules use: + +- `models.py` — `Service`, with `intercept` selecting the trade-off above. +- `nftables.py` — the ruleset, in the shared `firegex` inet table. One `filter` + prerouting chain (divert + tproxy intercepts) and one `nat` prerouting chain + (redirect intercepts). The divert rule is installed once by `init()`, never + per service, and per-service rules append after it so it stays first. +- `routing.py` — the policy routing tproxy needs, plus the two guards: `check()` for + a missing `ip` binary, `check_topology()` for the namespace constraint. + +- `firewall.py` — the `ServiceManager`/`FirewallManager` pair. One engine process per + service: it is started first because it is what picks the listening port (bind to 0, + read `PORT ` back, the same handshake `cppregex` uses for `QUEUE `), and the + rules are written afterwards. Stopping goes the other way round. + +`routers/proxyengine.py` mounts it at `/api/proxyengine` through the usual +auto-discovery, with its own `db/proxy-engine.db`. Its own module and its own DB +file on purpose: adding a field to an existing module's schema would wipe that +module's database, since `utils/sqlite.py` recreates rather than migrates. + +The Dockerfile builds the engine in a `proxyengine` stage and ships it next to +`cppregex`/`cpproxy`, and the runtime image now installs `iproute` for the return-path +rules. `FGEX_PROXY_BINARY` overrides the path for a dev build. + +## Next + +1. The C++ engine's parsed models (`HttpRequest` and the rest), so a filter can work above + raw bytes. +2. A worker pool, if Python filtering turns out to be a bottleneck. +3. Deciding whether the TLS module keeps its nginx. Everything it does is now possible + here; retiring `modules/tls/nginx.py` would be the first change in this whole line + of work that removes more code than it adds, and it is a product decision rather + than a technical one. diff --git a/backend/proxysrc/src/capture.rs b/backend/proxysrc/src/capture.rs new file mode 100644 index 0000000..6eab314 --- /dev/null +++ b/backend/proxysrc/src/capture.rs @@ -0,0 +1,314 @@ +//! 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. +//! +//! 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::{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, +} + +impl Tap { + /// 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), + }); + // 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. + pub fn closed(&self) { + 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 +} diff --git a/backend/proxysrc/src/control.rs b/backend/proxysrc/src/control.rs new file mode 100644 index 0000000..79fa334 --- /dev/null +++ b/backend/proxysrc/src/control.rs @@ -0,0 +1,70 @@ +//! 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::time::Duration; + +use tokio::io::{AsyncBufReadExt, BufReader}; + +use crate::filter::{ChainHandle, FilterChain}; +use crate::rules::parse_ruleset; + +fn ack(ok: bool, detail: &str) { + if ok { + println!("ACK OK"); + } else { + println!("ACK FAIL {detail}"); + } + use std::io::Write; + let _ = std::io::stdout().flush(); +} + +/// Apply rulesets 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) { + 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; + } + }; + if line.trim().is_empty() { + continue; + } + match parse_ruleset(&line).and_then(|filters| { + // Everything that has to be started is started here, while the old chain + // is still the one enforcing. + for filter in &filters { + filter.prepare()?; + } + Ok(filters) + }) { + 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..d28a7ac --- /dev/null +++ b/backend/proxysrc/src/filter.rs @@ -0,0 +1,477 @@ +//! 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::time::Duration; + +/// 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, + /// Whether this is a TCP connection or a UDP flow. A filter is shown it because the + /// library's stream and HTTP models only apply to one of them, and a filter written + /// against `RawPacket` may legitimately want to know which it is looking at. + pub tcp: bool, +} + +/// 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) {} +} + +/// 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); + +struct Slot { + filter: Arc, + /// Set once the filter has misbehaved enough to lose its say. + disabled: AtomicBool, + consecutive_timeouts: 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, + /// 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) + } + + 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), + }) + .collect(), + generation: CHAIN_GENERATION.fetch_add(1, Ordering::Relaxed), + deadline, + max_consecutive_timeouts, + degraded, + stats: Arc::new(ChainStats::default()), + } + } + + 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); + eprintln!( + "[warn] [filter] '{}' disabled after {:?}: traffic keeps flowing without it", + slot.filter.name(), + reason + ); + if self + .slots + .iter() + .all(|s| s.disabled.load(Ordering::Relaxed)) + { + self.degraded.store(true, Ordering::Relaxed); + eprintln!("[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. + /// + /// 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 { + 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()); + println!("BLOCKED {id}"); + use std::io::Write; + let _ = std::io::stdout().flush(); + 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()); + println!("BLOCKED {id}"); + use std::io::Write; + let _ = std::io::stdout().flush(); + 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/hyperscan.rs b/backend/proxysrc/src/hyperscan.rs new file mode 100644 index 0000000..6e883f3 --- /dev/null +++ b/backend/proxysrc/src/hyperscan.rs @@ -0,0 +1,544 @@ +//! 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; +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_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_SINGLEMATCH | 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) + } + + 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..47b01b0 --- /dev/null +++ b/backend/proxysrc/src/lib.rs @@ -0,0 +1,24 @@ +//! 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 hyperscan; +pub mod proxy; +pub mod pyworker; +pub mod rules; +pub mod spec; +pub mod capture; +pub mod tls; +pub mod transparent; +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..27049ba --- /dev/null +++ b/backend/proxysrc/src/main.rs @@ -0,0 +1,339 @@ +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::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() +} + +fn build_tls() -> Result { + let cert_path = std::env::var("FGEX_PROXY_TLS_CERT").ok(); + let key_path = std::env::var("FGEX_PROXY_TLS_KEY").ok(); + let server = match (cert_path, key_path) { + (Some(cert), Some(key)) => { + let cert = + 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}"))?; + Some(fgex_proxy::tls::server_config(&cert, &key)?) + } + (None, None) => None, + _ => return Err("FGEX_PROXY_TLS_CERT and FGEX_PROXY_TLS_KEY go together".to_string()), + }; + let upstream = if env_flag("FGEX_PROXY_TLS_UPSTREAM") { + Some(fgex_proxy::tls::client_config()?) + } else { + None + }; + Ok(TlsSetup { server, upstream }) +} + +/// 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(200); + 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); + } + }; + + // TLS terminated here rather than by an nginx in front. Rules see plaintext that + // never leaves the process. + let tls = match build_tls() { + 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)), + }; + + let cfg = ProxyConfig { + listen, + upstream, + spoof_source, + connect_timeout: Duration::from_millis(connect_timeout), + tls, + // Opened once for the process and shared: the interface exists only while some + // TLS service is running, and `None` here simply means nothing is watching. + capture: fgex_proxy::capture::Capture::open(), + // 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, + }; + 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(); + + println!("PORT {}", bound.port()); + use std::io::Write; + let _ = std::io::stdout().flush(); + eprintln!( + "[info] [main] listening on {bound} -> {upstream_raw} \ + (spoof_source={spoof_source})" + ); + + // Rulesets 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. + let deadline_dur = Duration::from_millis(deadline); + let control_chain = chain_handle.clone(); + tokio::spawn(async move { serve_stdin(control_chain, deadline_dur).await }); + + // How many connections have been through, reported on a timer rather than one + // line per connection: the backend wants a denominator, not a firehose. Counted + // where the connection is accepted and where a filter refuses it, so both numbers + // are in the same unit and their ratio means something — unlike a share computed + // against a packet count, which is what the kernel can offer and a block is not. + // 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. + // + // The client's address is *not* preserved on this path, and that is the trade the + // operator is shown before choosing it. + if let Ok(spec) = std::env::var("FGEX_PROXY_UDP") { + for target in spec.split(',').filter(|s| !s.trim().is_empty()) { + let upstream = parse_addr("FGEX_PROXY_UDP", target.trim()); + let bind: SocketAddr = if upstream.is_ipv6() { + "[::]:0".parse().unwrap() + } else { + "0.0.0.0:0".parse().unwrap() + }; + let relay = match fgex_proxy::udp::UdpRelay::bind( + bind, + upstream, + chain_handle.clone(), + cfg_self_mark, + max_connections, + over_limit_forwards, + Arc::clone(&counters), + ) + .await + { + Ok(relay) => relay, + Err(e) => { + eprintln!("[fatal] [main] cannot bind a UDP relay for {upstream}: {e}"); + exit(1); + } + }; + // One line per relay, so the backend can point each address's rule at the + // port that actually fronts it. + println!("UDP {} {}", upstream, relay.local_addr().unwrap().port()); + let _ = std::io::stdout().flush(); + tokio::spawn(async move { + if let Err(e) = relay.serve().await { + eprintln!("[fatal] [udp] relay for {upstream} died: {e}"); + } + }); + } + } + + // 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. + println!( + "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), + ); + let _ = std::io::stdout().flush(); + } + }); + + 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..6495799 --- /dev/null +++ b/backend/proxysrc/src/proxy.rs @@ -0,0 +1,682 @@ +//! 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, +}; +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>, +} + +impl TlsSetup { + pub fn is_off(&self) -> bool { + self.server.is_none() && self.upstream.is_none() + } +} + +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); + +/// 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, + /// 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, + } + } +} + +#[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, + 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. +struct Slot(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) => { + eprintln!("[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) { + eprintln!( + "[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 { + eprintln!("[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), + // 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(), + }; + eprintln!( + "[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") + })? + } + } +} + +async fn handle_connection( + client: TcpStream, + peer: SocketAddr, + cfg: Arc, + chain: ChainHandle, + stats: Arc, +) -> io::Result<()> { + let upstream = 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); + } + }, + }; + + // A rule that steers our own outbound traffic back at us would spin forever. + if upstream == cfg.listen { + stats.origin_lookup_failures.fetch_add(1, Ordering::Relaxed); + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("refusing to forward {upstream} to ourselves: check the intercept rules"), + )); + } + + 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); + + // 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, + tcp: true, + }, + ); + + // Only where there is something to reconstruct: with TLS off, 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 tap = if cfg.tls.is_off() { + None + } else { + Tap::open(cfg.capture.clone(), peer, upstream) + }; + + let (mut up, mut down) = if cfg.tls.is_off() { + let (client_rd, client_wr) = tokio::io::split(client); + let (server_rd, server_wr) = tokio::io::split(server); + spawn_pumps( + client_rd, client_wr, server_rd, server_wr, &chain, &stats, connection, None, + cfg.first_byte_timeout, + ) + } else { + // 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. + let (started, wanted) = 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 wanted: Vec> = start + .client_hello() + .alpn() + .map(|it| it.map(|p| p.to_vec()).collect()) + .unwrap_or_default(); + (Some(start), wanted) + } + None => (None, Vec::new()), + }; + + let (server, agreed): (Duplex, Option>) = match &cfg.tls.upstream { + Some(config) => { + let 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, so nothing to relay back: the client is told no + // protocol rather than one this end invented. + None => (Box::new(server), None), + }; + + 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"), + }; + let (client_rd, client_wr) = tokio::io::split(client); + let (server_rd, server_wr) = tokio::io::split(server); + spawn_pumps( + client_rd, client_wr, server_rd, server_wr, &chain, &stats, connection, + tap.clone(), cfg.first_byte_timeout, + ) + }; + + // 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. + 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, + ) { + if !rejected { + let _ = other.await; + return; + } + if tokio::time::timeout(REJECT_GRACE, &mut *other) + .await + .is_err() + { + other.abort(); + } + } + + tokio::select! { + r = &mut up => wind_down(&mut down, rejected(&r)).await, + r = &mut down => wind_down(&mut up, rejected(&r)).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, +} + +/// Start both directions. Split out so the plain and the TLS paths share it. +#[allow(clippy::type_complexity)] +#[allow(clippy::too_many_arguments)] +fn spawn_pumps( + client_rd: CR, + client_wr: CW, + server_rd: SR, + server_wr: SW, + chain: &ChainHandle, + stats: &Arc, + connection: ConnectionId, + tap: Option>, + 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. + let spoken = Arc::new(AtomicBool::new(false)); + 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)] +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) +} diff --git a/backend/proxysrc/src/pyworker.rs b/backend/proxysrc/src/pyworker.rs new file mode 100644 index 0000000..e93b72c --- /dev/null +++ b/backend/proxysrc/src/pyworker.rs @@ -0,0 +1,425 @@ +//! 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}; + +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; + +#[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>, + pub stats: std::sync::Arc, +} + +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), + stats: std::sync::Arc::new(WorkerStats::default()), + } + } + + 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. + 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); + eprintln!("[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, + ); + + 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(), + "is_tcp": meta.tcp, + }) + .to_string(); + let mut slot = match self.child.lock() { + Ok(slot) => slot, + Err(_) => return, + }; + if slot.is_none() { + match self.spawn() { + Ok(child) => *slot = Some(child), + Err(_) => return, + } + } + let Some(child) = slot.as_mut() else { return }; + let Some(stdin) = child.stdin.as_mut() else { + return; + }; + let frame = build_frame(connection, KIND_OPEN, payload.as_bytes()); + let deadline_at = Instant::now() + self.deadline; + if write_before(stdin, &frame, deadline_at).is_err() { + self.discard(&mut slot, "could not report a new connection"); + } + } + fn connection_closed(&self, connection: ConnectionId) { + // Best effort, and deliberately not on the datapath's critical path: the + // connection is already over, so nothing is waiting on this. A worker that + // has died in the meantime simply has no state left to free. + let mut slot = match self.child.lock() { + Ok(slot) => slot, + Err(_) => return, + }; + let Some(child) = slot.as_mut() else { return }; + let Some(stdin) = child.stdin.as_mut() else { return }; + let frame = build_frame(connection, KIND_CLOSE, &[]); + let deadline_at = Instant::now() + self.deadline; + if write_before(stdin, &frame, deadline_at).is_err() { + self.discard(&mut slot, "could not report a closed connection"); + } + } + fn prepare(&self) -> Result<(), String> { + self.warm_up() + } + 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/rules.rs b/backend/proxysrc/src/rules.rs new file mode 100644 index 0000000..2417a58 --- /dev/null +++ b/backend/proxysrc/src/rules.rs @@ -0,0 +1,318 @@ +//! 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) => { + eprintln!("[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()); + Verdict::Reject(Some(id)) + } + Ok(None) => Verdict::Accept, + // A scan that errors has told us nothing about the chunk, so the chunk + // goes through. The stream state is suspect from here on, which is worth + // saying once rather than on every subsequent chunk. + Err(e) => { + eprintln!("[warn] [filter] scan failed ({e}): chunk forwarded unfiltered"); + 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> { + 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 + }; + out.push(Arc::new(PyWorkerRule::new( + id, + command, + code_path, + enabled, + Duration::from_millis(timeout_ms), + ))) + } + } + } + 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..9cf82d2 --- /dev/null +++ b/backend/proxysrc/src/spec.rs @@ -0,0 +1,152 @@ +//! 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`, `block:` or `replace::`. +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..b83b4f7 --- /dev/null +++ b/backend/proxysrc/src/tls.rs @@ -0,0 +1,164 @@ +//! 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 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..f63b3f3 --- /dev/null +++ b/backend/proxysrc/src/transparent.rs @@ -0,0 +1,210 @@ +//! 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 tokio::net::{TcpListener, TcpSocket, TcpStream}; + +// 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() { + TcpSocket::new_v6()? + } 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 +} + +/// 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..c31f0d3 --- /dev/null +++ b/backend/proxysrc/src/udp.rs @@ -0,0 +1,330 @@ +//! The proxy layer for UDP: a relay, and one thing it deliberately does not do. +//! +//! 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*, so anything that logs or rate-limits by IP keeps +//! working. Neither survives the move to UDP unchanged: +//! +//! * **`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 +//! sidesteps it by binding **one socket per protected address**, each with the +//! upstream already known — nothing has to be recovered because nothing was lost. +//! * **The client's address is not preserved.** The protected service sees this engine, +//! not the client. That is a real loss, stated everywhere the operator can choose it, +//! and the reason the nfqueue layer remains the transparent way to filter 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::Ordering; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio::net::UdpSocket; + +use crate::proxy::ProxyStats; +use crate::filter::{ + next_connection_id, ChainHandle, ChainSessions, ConnectionId, ConnectionMeta, Direction, + Verdict, +}; + +/// 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); + +struct Flow { + /// The socket this relay dials the service from. Not the client's address: source + /// preservation is what UDP gives up here. + upstream: Arc, + connection: ConnectionId, + /// Filter state for the client→service direction. Owned by the receive loop, which + /// is single-threaded, so it needs no lock. + sessions: ChainSessions, + reply_task: tokio::task::JoinHandle<()>, + last_seen: Instant, +} + +/// 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, + /// How many flows may exist at once. `0` means no limit. + max_flows: usize, + /// Whether a datagram from a new source past the limit is forwarded unfiltered + /// rather than dropped. The operator's choice, the same one the TCP side offers. + over_limit_forwards: bool, + /// Shared with the TCP side, so one service reports one pair of numbers. + 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, + 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, + max_flows, + over_limit_forwards, + stats, + }) + } + + /// Send one datagram straight through, with no flow and therefore no filter state. + /// + /// This is what "forwarded unfiltered" has to mean for UDP: a flow *is* the state, so + /// admitting a datagram without creating one is admitting it without inspection — + /// which is exactly what the operator chose when they picked it over dropping. + async fn forward_unfiltered(&self, data: &[u8]) -> io::Result<()> { + let bind: SocketAddr = if self.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.self_mark { + // Stamped as ours for the same reason a flow's socket is: without it the + // rule that redirects this service's traffic catches our own send. + crate::transparent::set_self_mark( + ::as_raw_fd(&socket), + mark, + )?; + } + socket.send_to(data, self.upstream).await?; + Ok(()) + } + + 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) => { + eprintln!("[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. + // + // The same cap on the same shape already existed on the NFQUEUE side + // (`MAX_UDP_FLOWS` in `pyproxy.cpp`); it had simply never been + // carried here. + let limit = self.max_flows; + if limit > 0 && flows.len() >= limit { + self.stats.over_limit.fetch_add(1, Ordering::Relaxed); + if !self.stats.warned_limit.swap(true, Ordering::Relaxed) { + eprintln!( + "[warn] [udp] {limit} concurrent flows reached; datagrams \ + from new sources are being {} until it clears", + if self.over_limit_forwards { "forwarded unfiltered" } else { "dropped" }, + ); + } + if !self.over_limit_forwards { + continue; + } + // Forwarding without a flow means without filter state, which is + // what "unfiltered" has to mean here: a flow is the state. + if let Err(e) = self.forward_unfiltered(&buf[..len]).await { + eprintln!("[warn] [udp] cannot forward past the limit: {e}"); + } + continue; + } + match self.open(client).await { + Ok(flow) => flows.entry(client).or_insert(flow), + Err(e) => { + eprintln!("[warn] [udp] cannot reach {} for {client}: {e}", self.upstream); + continue; + } + } + } + }; + flow.last_seen = Instant::now(); + + // Re-read the handle every datagram, so a chain swapped in mid-flow takes + // effect without anyone losing their session. + let verdict = self + .chain + .current() + .run(Direction::ClientToServer, &buf[..len], &mut flow.sessions) + .await; + let payload: &[u8] = match &verdict { + Verdict::Accept => &buf[..len], + Verdict::Reject(_) => continue, + }; + if let Err(e) = flow.upstream.send(payload).await { + eprintln!("[warn] [udp] cannot forward to {}: {e}", self.upstream); + } + } + } + + /// Start relaying one client's flow: a socket towards the service, and a task + /// carrying the answers back. + async fn open(&self, client: SocketAddr) -> io::Result { + let bind: SocketAddr = if self.upstream.is_ipv6() { + "[::]:0".parse().unwrap() + } else { + "0.0.0.0:0".parse().unwrap() + }; + let upstream = UdpSocket::bind(bind).await?; + if let Some(mark) = self.self_mark { + // Stamped as ours, or the rule that redirects this service's traffic would + // catch the relay's own dial and send it straight back here. + crate::transparent::set_self_mark( + ::as_raw_fd(&upstream), + mark, + )?; + } + upstream.connect(self.upstream).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 even though the service will not see it: a filter reads who is talking, + // and that stays true whatever address the relay dials from. + self.chain.current().connection_opened( + connection, + &ConnectionMeta { + client, + server: self.upstream, + tcp: false, + }, + ); + + let reply_task = tokio::spawn(replies( + Arc::clone(&upstream), + Arc::clone(&self.listener), + client, + self.chain.clone(), + connection, + )); + + Ok(Flow { + upstream, + connection, + sessions: ChainSessions::new(connection), + reply_task, + last_seen: Instant::now(), + }) + } + + /// 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.reply_task.abort(); + self.chain.current().connection_closed(flow.connection); + } + } + } +} + +/// 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, +) { + // 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, + Err(e) => { + eprintln!("[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(_) => continue, + }; + if let Err(e) = listener.send_to(payload, client).await { + eprintln!("[warn] [udp] cannot answer {client}: {e}"); + } + } +} diff --git a/backend/proxysrc/tests/fail_open.rs b/backend/proxysrc/tests/fail_open.rs new file mode 100644 index 0000000..45bea7d --- /dev/null +++ b/backend/proxysrc/tests/fail_open.rs @@ -0,0 +1,308 @@ +//! 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) { + let upstream = spawn_echo().await; + let chain = ChainHandle::new(FilterChain::new( + parse_filters(filters).unwrap(), + Duration::from_millis(deadline_ms), + )); + 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 (addr, chain) = spawn_proxy("hang", 50).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" + ); + + // Let the abandoned threads exit, otherwise dropping the runtime waits on them. + fgex_proxy::spec::release_hangs(); +} + +/// 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/pyworker.rs b/backend/proxysrc/tests/pyworker.rs new file mode 100644 index 0000000..c549abc --- /dev/null +++ b/backend/proxysrc/tests/pyworker.rs @@ -0,0 +1,489 @@ +//! 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, +}; +use fgex_proxy::rules::parse_ruleset; + +/// 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()); +} + +/// 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(), + tcp: true, + }; + 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]); +} 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..3915204 --- /dev/null +++ b/backend/proxysrc/tests/tls.rs @@ -0,0 +1,211 @@ +//! 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::{Proxy, ProxyConfig, 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, + }, + ) + .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()), + }, + ) + .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()), + }, + ) + .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(()); +} 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..63cb70c --- /dev/null +++ b/backend/routers/services.py @@ -0,0 +1,1934 @@ +"""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 base64 +import binascii +import json +import os +import secrets +import sqlite3 +import subprocess +import sys +import time + +from fastapi import APIRouter, 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) +from modules.services.nftables import FiregexTables +from modules.services import transports +from modules.services.transports import PROXY_ENGINE, PYWORKER, UnsupportedChain +from utils import PortType, ip_parse, refactor_name, socketio_emit +from utils.models import ResetRequest, StatusMessageModel + +from utils.sqlite import SQLite + +app = APIRouter() + +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. The address carries the second answer. + "proto": 'VARCHAR(4) NOT NULL CHECK (proto IN ("tcp", "udp", "tls"))', + "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)", + # 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 + proxy_ip: str | None = None + proxy_port: int | None = None + + +class AddressForm(BaseModel): + ip_int: str + port: PortType + #: `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 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 + + +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] = [] + #: base64: what the sample becomes, produced by the engine's own rewriting code. + rewritten: str | None = None + 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() + 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"]) + + +async def shutdown(): + await firewall.close() + db.disconnect() + + +async def reset(params: ResetRequest): + if not params.delete: + 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. + """ + 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. + """ + try: + await firewall.get(service_id).update_chain() + except UnsupportedChain as e: + if undo: + undo() + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + if undo: + undo() + raise HTTPException(status_code=500, detail=str(e)) + _note_filtering(service_id) + + +# --- 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(DISTINCT f.filter_id) n_filters, + COALESCE(SUM(DISTINCT 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["addresses"] = [ + _address_row(a) for a in _addresses(row["service_id"]) + ] + return row + + +@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, 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. + """ + address_id = gen_id() + db.query( + "INSERT INTO service_addresses (address_id, service_id, ip_int, port, proto, " + "proxy_ip, proxy_port) VALUES (?, ?, ?, ?, ?, ?, ?);", + address_id, + service_id, + ip_parse(form.ip_int), + form.port, + L4.l4_of(proto), + form.proxy_ip or None, + form.proxy_port, + ) + return address_id + + +def _address_taken(e: sqlite3.IntegrityError) -> str: + """Say which uniqueness rule was broken, because they mean different things.""" + if "unique_hijack_target" in str(e): + 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 == L4.TLS: + if not (form.tls_cert and form.tls_key): + raise HTTPException( + status_code=400, + detail="A service that speaks TLS needs both a certificate and a private key", + ) + _check_tls_material(form.tls_cert, form.tls_key) + # 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", + ) + 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: + return {"status": "A service with this name already exists"} + try: + for address in form.addresses: + _insert_address(service_id, form.proto, address) + except sqlite3.IntegrityError 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. + db.query("DELETE FROM service_addresses WHERE service_id = ?;", service_id) + db.query("DELETE FROM services WHERE service_id = ?;", service_id) + return {"status": _address_taken(e)} + await firewall.reload() + await refresh_frontend() + return {"status": "ok", "service_id": service_id} + + +@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. + """ + 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_tls = (form.proto if form.proto is not None else row["proto"]) == L4.TLS + if lands_on_tls: + 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 TLS, so it needs {missing}. Give it one " + f"here — the engine would refuse to start without it, and the service " + f"would fail on its next start rather 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 + if not fields: + return {"status": "ok"} + try: + db.query( + f"UPDATE services SET {', '.join(f'{k} = ?' for k in fields)} WHERE service_id = ?;", + *fields.values(), + service_id, + ) + except sqlite3.IntegrityError: + raise HTTPException(status_code=400, detail="That name is already taken") + if "proto" in fields: + # The addresses carry the transport the kernel matches on, so that + # `(ip, port, proto)` can be unique. Kept in step here rather than being a second + # thing the operator sets — and derived, because `tls` is TCP on the wire. + try: + db.query( + "UPDATE service_addresses SET proto = ? WHERE service_id = ?;", + L4.l4_of(fields["proto"]), + service_id, + ) + except sqlite3.IntegrityError: + raise HTTPException( + status_code=400, + detail="Another service already protects one of these addresses on " + f"{fields['proto']}", + ) + + srv = load_service(service_id) + try: + await firewall.get(service_id).refresh(srv) + except UnsupportedChain as e: + raise HTTPException(status_code=400, detail=str(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): + row = _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", + ) + try: + address_id = _insert_address(service_id, row["proto"], form) + except sqlite3.IntegrityError as e: + raise HTTPException(status_code=400, detail=_address_taken(e)) + 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) + 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): + """Move an address. The rules for it are taken back and reinstalled.""" + 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", + ) + + def write(ip_int, port, proxy_ip, proxy_port): + db.query( + "UPDATE service_addresses SET ip_int = ?, port = ?, proxy_ip = ?, " + "proxy_port = ? WHERE address_id = ?;", + ip_int, port, 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["proxy_ip"], was["proxy_port"]) + try: + await firewall.get(service_id).address_added(address_id) + except Exception: + pass + + manager = firewall.get(service_id) + await manager.address_removed(address_id) + try: + write(ip_parse(form.ip_int), form.port, form.proxy_ip or None, form.proxy_port) + except sqlite3.IntegrityError as e: + await restore() + raise HTTPException(status_code=400, detail=_address_taken(e)) + try: + await manager.address_added(address_id) + 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) + firewall.release_capture_if_idle() + 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 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 = 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 FROM pyfilters WHERE filter_id = ?;", filter_id + ) + write_code(filter_id, form.code) + db.query("UPDATE filters SET proto = ? WHERE filter_id = ?;", proto, filter_id) + _reconcile_functions(filter_id, form.code) + + 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: + db.query( + "INSERT INTO pyfilters (filter_id, name, active, blocked) VALUES (?, ?, ?, ?);", + filter_id, fn["name"], fn["active"], fn["blocked"], + ) + + await _apply_chain(service_id, undo) + await refresh_frontend() + return {"status": "ok"} + + +def _reconcile_functions(filter_id: str, code: 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. + + A file that will not load leaves the list alone rather than emptying it: the + datapath refuses the ruleset with the real error a moment later, and wiping the + operator's choices on the way to that would be a second failure. + """ + from firegex.pyfilters.internals import get_filter_names + + try: + defined = get_filter_names(code) if code.strip() else [] + except Exception: + return + existing = { + row["name"] for row in db.query( + "SELECT name FROM pyfilters WHERE filter_id = ?;", filter_id + ) + } + 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 + + +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. + """ + import tempfile + + with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f: + f.write(code) + probe = f.name + try: + proc = subprocess.run( + [sys.executable or "python3", PYWORKER, "--check", probe], + capture_output=True, + timeout=CHECK_TIMEOUT, + ) + except subprocess.TimeoutExpired: + 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": "", + }, + } + finally: + try: + os.remove(probe) + except OSError: + pass + try: + return json.loads(proc.stdout.decode()) + except (ValueError, UnicodeDecodeError): + # The checker itself failed, which is ours to explain rather than theirs. + detail = proc.stderr.decode(errors="replace").strip() or "the check produced no answer" + 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) + + +@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}") + try: + pattern = base64.b64decode(form.regex).decode(errors="replace") + except (binascii.Error, ValueError): + raise HTTPException(status_code=400, detail="The pattern must be base64-encoded") + # Checked by the engine that will run it, not by a stand-in. A pattern accepted + # here and refused at start time would leave the operator with a service that + # will not come up and no idea which rule is at fault. + ok, why = check_pattern(pattern, form.case_sensitive) + 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. + try: + pattern = base64.b64decode(fields.get("regex", was["regex"])).decode(errors="replace") + except (binascii.Error, ValueError): + raise HTTPException(status_code=400, detail="The pattern must be base64-encoded") + ok, why = 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() + return {"status": "ok"} + + +# --- the debugger ------------------------------------------------------------ + + +def _ask_engine(request: dict) -> dict: + """Put the question to the binary that would enforce the answer.""" + try: + proc = subprocess.run( + [PROXY_ENGINE, "--debug-regex"], + input=json.dumps(request).encode(), + capture_output=True, + timeout=10, + ) + except FileNotFoundError: + raise HTTPException(status_code=500, detail="The matching engine is not installed") + except subprocess.TimeoutExpired: + raise HTTPException(status_code=400, detail="The pattern took too long to evaluate") + if proc.returncode != 0: + detail = proc.stderr.decode(errors="replace").strip() or "the engine refused the request" + raise HTTPException(status_code=400, detail=detail) + return json.loads(proc.stdout.decode()) + + +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 = _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 = _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", []), + "rewritten": res.get("rewritten"), + "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..12be7be 100644 --- a/backend/utils/__init__.py +++ b/backend/utils/__init__.py @@ -222,7 +222,7 @@ class Singleton(object): class NFTableManager(Singleton): - table_name = "firegex" + table_name = "fgex" def __init__(self, init_cmd, reset_cmd): self.__init_cmds = init_cmd diff --git a/backend/utils/models.py b/backend/utils/models.py index 7e9745b..aa06613 100644 --- a/backend/utils/models.py +++ b/backend/utils/models.py @@ -16,6 +16,11 @@ class PasswordChangeForm(BaseModel): password: str expire: bool +class AuthModeForm(BaseModel): + #: True turns authentication off. False is only ever a no-op here: putting it back on + #: is a decision that has to come from the host — 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..cb909b2 100644 --- a/backend/utils/sqlite.py +++ b/backend/utils/sqlite.py @@ -133,7 +133,14 @@ 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');") + # Three keys never travel in a backup. `password` and `secret` are this + # instance's credentials, and `auth_disabled` is *whether it asks for + # them* — a property of where this firegex is deployed, not of the + # configuration being backed up. A backup that could carry it would be a + # backup that can turn a firewall's authentication off on restore. + res[tname] = self.query( + f"SELECT * FROM {tname} WHERE key NOT IN ('password', 'secret', 'auth_disabled');" + ) else: res[tname] = self.query(f"SELECT * FROM {tname};") return res diff --git a/docs/firewall.md b/docs/firewall.md index ae15090..684f5ed 100644 --- a/docs/firewall.md +++ b/docs/firewall.md @@ -10,7 +10,7 @@ Each rule matches on: - **Source / Destination**: an IP/CIDR, or an interface name (e.g. `eth0`) instead of an address. - **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. @@ -32,4 +32,25 @@ Each of these, when enabled, inserts a small accept rule ahead of your own 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 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/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/nfproxy.md b/docs/pyfilter.md similarity index 62% rename from docs/nfproxy.md rename to docs/pyfilter.md index c6eb795..8d6da37 100644 --- a/docs/nfproxy.md +++ b/docs/pyfilter.md @@ -1,15 +1,24 @@ -# 🌐 Netfilter Proxy +# 🐍 Python filters -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. +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: 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. +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 / mangle). -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. +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 @@ -24,7 +33,7 @@ pip install -U firegex ### The `pyfilter` decorator ```python -from firegex.nfproxy import pyfilter +from firegex.pyfilters import pyfilter ``` `pyfilter` marks a function as an active filter. A filter function: @@ -33,7 +42,7 @@ from firegex.nfproxy import pyfilter - must `return` one of the [packet statements](#packet-statements) below (or `None`, which behaves like `ACCEPT`). ```python -from firegex.nfproxy import pyfilter, ACCEPT, REJECT +from firegex.pyfilters import pyfilter, ACCEPT, REJECT @pyfilter def none_filter(): # A filter that does nothing @@ -42,11 +51,17 @@ def none_filter(): # A filter that does nothing Filter names (the function name) must be unique within a filter file. +**They run in the order the file defines them**, top to bottom, and the first one to +return anything but `ACCEPT` ends the packet — so a filter placed above another decides +before it, and the interface lists them in that same order. (`UNSTABLE_MANGLE` is the +exception: the rewrite is remembered and the walk carries on, because a filter below may +still want to refuse the rewritten bytes.) + 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 +from firegex.pyfilters import pyfilter, ACCEPT, REJECT +from firegex.pyfilters.models import HttpRequest @pyfilter def filter_with_args(http_request: HttpRequest): @@ -58,42 +73,96 @@ Here the filter is only called once the data required to build an `HttpRequest` ### Packet statements -A filter must return one of these values (importable from `firegex.nfproxy`): +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` | 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. +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. Each one says + whether it is **read-only or writable**, which is the boundary the whole API rests on. +- **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 the only thing a filter can change. + +That is deliberate. The two network layers cannot honestly offer the same thing down there: on NFQUEUE a real header exists and rewriting it desynchronises the connection in ways that surface minutes later somewhere else, while the proxy terminated the connection and reopened it, so the headers on the wire are firegex's own and a filter editing them would be editing nothing. An earlier version papered over that by handing the proxy a literal `FAKE:IP:TCP:HEADERS:` prefix — so the same filter did different things depending on which layer it happened to be attached to. Metadata in, payload out is the one contract both layers keep. + +#### Which models work on UDP + +`RawPacket` is the only model that does not need a connection underneath it. Everything +else — an assembled `TCPInputStream`, a parsed `HttpRequest` — reaches for something only +TCP has, and on a datagram 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. + +Rewriting a datagram is **exact**, on both layers: there are no sequence numbers for a +different length to desynchronise. + +`REJECT` means something narrower here. There is no connection to close, so the datagram +is simply not delivered and the next one from the same flow is judged afresh. #### `RawPacket` ```python -from firegex.nfproxy.models import RawPacket +from firegex.pyfilters.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`). +One chunk of the connection, and what is known about it. The **only** data structure that can be mutated. -- `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. +- `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 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. +- `src_ip: str`, `dst_ip: str`, `src_port: int`, `dst_port: int` — where this chunk came from and where it is going (read-only). +- `client_ip`, `client_port`, `server_ip`, `server_port` — the same two endpoints named by role instead of by direction, so a filter does not have to branch on `is_input` to find out who the client is (read-only). +- `was_mangled: bool` — whether an earlier filter in the chain already rewrote this chunk. + -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 +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. @@ -105,7 +174,7 @@ The assembled TCP stream in the input (client → server) direction. A filter us #### `TCPOutputStream` (alias `TCPServerStream`) ```python -from firegex.nfproxy.models import TCPOutputStream +from firegex.pyfilters.models import TCPOutputStream ``` Same as `TCPInputStream`, but for the output (server → client) direction; only called for outgoing packets. @@ -113,7 +182,7 @@ Same as `TCPInputStream`, but for the output (server → client) direction; only #### `HttpRequest` — HTTP only ```python -from firegex.nfproxy.models import HttpRequest +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). @@ -142,7 +211,7 @@ The current HTTP request. This handler is called up to twice: once when the head #### `HttpRequestHeader` — HTTP only ```python -from firegex.nfproxy.models import HttpRequestHeader +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`. @@ -150,7 +219,7 @@ Same fields as `HttpRequest`, but only called once, when the headers are complet #### `HttpFullRequest` — HTTP only ```python -from firegex.nfproxy.models import HttpFullRequest +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`. @@ -158,7 +227,7 @@ Same fields as `HttpRequest`, but only called once the whole request (headers + #### `HttpResponse` — HTTP only ```python -from firegex.nfproxy.models import HttpResponse +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: @@ -170,7 +239,7 @@ All the other fields listed for `HttpRequest` (`headers`, `get_header`, `body`, #### `HttpResponseHeader` — HTTP only ```python -from firegex.nfproxy.models import HttpResponseHeader +from firegex.pyfilters.models import HttpResponseHeader ``` Same as `HttpResponse`, but only called once the headers are complete; `body` is always `None`. @@ -178,7 +247,7 @@ Same as `HttpResponse`, but only called once the headers are complete; `body` is #### `HttpFullResponse` — HTTP only ```python -from firegex.nfproxy.models import HttpFullResponse +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`. @@ -186,7 +255,7 @@ Same as `HttpResponse`, but only called once the whole response is complete. Com #### `HttpHistory` (alias `HttpStreamHistory`) — HTTP only ```python -from firegex.nfproxy.models import HttpHistory +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. @@ -197,8 +266,8 @@ Gives a filter access to previously *completed* requests/responses on the same T You can use it in two ways: ```python -from firegex.nfproxy import pyfilter, REJECT -from firegex.nfproxy.models import HttpHistory, HttpResponse +from firegex.pyfilters import pyfilter, REJECT +from firegex.pyfilters.models import HttpHistory, HttpResponse # 1. as its own filter parameter @pyfilter @@ -220,7 +289,7 @@ The number of entries kept per stream is capped by the `FGEX_MAX_HISTORY_SIZE` g 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 +from firegex.pyfilters import FullStreamAction ``` Then set these in the filter file's globals: @@ -240,7 +309,7 @@ Then set these in the filter file's globals: ## Other global options ```python -from firegex.nfproxy import ExceptionAction +from firegex.pyfilters import ExceptionAction ``` - `FGEX_INVALID_ENCODING_ACTION: ExceptionAction` — action taken when parsing hits an invalid/unsupported encoding (a parser-level failure). Default: `ExceptionAction.REJECT`. Values: `ACCEPT` (accept the packet that caused the error), `DROP` (drop the connection), `REJECT` (reject the connection), `NOACTION` (do nothing — the error is signaled and the stream is accepted without calling any more pyfilters on it). @@ -251,11 +320,11 @@ from firegex.nfproxy import ExceptionAction 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 +➤ fgex pyfilters -h - Usage: fgex nfproxy [OPTIONS] FILTER_FILE ADDRESS PORT + Usage: fgex pyfilters [OPTIONS] FILTER_FILE ADDRESS PORT - Run an nfproxy simulation + Run your Python filters against a real service, locally ╭─ Arguments ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ * filter_file TEXT The path to the filter file [default: None] [required] │ @@ -263,7 +332,6 @@ You don't need a running Firegex instance to try out a filter: the `firegex` pip │ * 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 │ @@ -274,22 +342,26 @@ You don't need a running Firegex instance to try out a filter: the `firegex` pip 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 +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 -# Example filter file for an http-protocol service +# 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.nfproxy.models import RawPacket, HttpRequest, HttpHistory -from firegex.nfproxy import pyfilter, ACCEPT, REJECT, DROP, UNSTABLE_MANGLE, FullStreamAction +from firegex.pyfilters.models import RawPacket, HttpRequest, HttpHistory +from firegex.pyfilters import pyfilter, ACCEPT, REJECT, DROP, FullStreamAction # Lowest level of abstraction: only RawPacket can be mangled. @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") + if b"TEST_MANGLING" in packet.data: + packet.data = packet.data.replace(b"TEST", b"UNSTABLE") return UNSTABLE_MANGLE if b"BAD DATA" in packet.data: return DROP diff --git a/docs/services.md b/docs/services.md new file mode 100644 index 0000000..1b2e56c --- /dev/null +++ b/docs/services.md @@ -0,0 +1,645 @@ +# 🛡️ 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 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. (The single exception is a proxy service gaining its first IPv6 address, because the +listener has to be reopened in a family that can accept one. The log says so.) + +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. + +## Transport protocol + +TCP, UDP or TLS, chosen on the service. Every layer carries TCP and UDP; TLS is on the +proxy layer alone, because decrypting means terminating the connection — see +[TLS](#tls). + +**UDP on the proxy layer does not preserve the client's address**, which is the one place +that layer stops being invisible. See +[UDP on the proxy layer](#udp-on-the-proxy-layer-and-what-it-gives-up); the interface +says the same thing where you choose, and marks any service in that combination. + +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 | full — dials your service **from the client's own address** | full — the real packets, on their way to your service | +| Reassembly | the kernel's, on two real sockets | rebuilt in userspace with libtins | +| Rewriting | **exact**, any length | patterns: no. Python: yes, unstable on TCP, exact on UDP | +| 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/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, **without the client's address** (see below) | yes, fully transparent | +| TLS termination | yes | no — decrypting means terminating | + +### Proxy + +The connection is terminated and reopened towards your service, so firegex owns both +halves. + +**What that buys** + +- **Exact rewriting.** Two independent connections means a replacement can be any + length: there are no sequence numbers shared with the client to desynchronise. This is +- **Reassembly is the kernel's problem.** Both sides are ordinary sockets, so + out-of-order and retransmitted segments are sorted out before a filter ever sees them + — rather than being rebuilt in userspace with the caveats that come with it. +- **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/README.md](../tests/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. It works — and it is code, where the other layer has a + kernel guarantee. +- **On UDP it stops being invisible**: the client's address is not preserved. See + [below](#udp-on-the-proxy-layer-and-what-it-gives-up). + +It stays invisible: it always dials your service **from the client's own address**, 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. + +### NFQUEUE + +Packets are lifted to userspace, inspected, and a verdict is handed back to the kernel. +Nothing is terminated. + +**What that buys** + +- **Nothing is in the path.** Your service receives the original packets, from the + original client, on the original connection. There is no proxy to be visible. +- **A real kernel backstop.** With `fail_open`, `NFQA_CFG_F_FAIL_OPEN` plus `bypass` on + the rule mean that if the filter process dies outright — crash, kill, anything — **the + traffic keeps flowing**. Nothing in userspace has to be correct for that to hold. +- **UDP**, with the real datagrams and the real client address. + +**What it costs** + +- **A userspace round trip per packet.** This is the one that surprises people, because + "no second connection, no relay" sounds cheap. Every packet — payload, handshake, + ACK — is copied to userspace and a verdict copied back, which is why bulk traffic + measures a fraction of the proxy's, and why adding threads helps it less. It is cheap + per *connection* and expensive per *packet*, and a stream of bytes is made of packets. + On short connections, where the packet count per connection is small, the two layers + measure the same. +- **Reassembly is yours.** TCP is rebuilt in userspace with libtins, which is where the + out-of-order caveat lives: a packet that arrives out of sequence is accepted without + the filter being called at all. +- **A filter costs a process.** The two binaries fuse transport and filter (`cppregex` + is NFQUEUE plus hyperscan, `cpproxy` is NFQUEUE plus an embedded interpreter), so a + chain is a chain of *processes*: each filter has its own queue and its own place in + the kernel's rule order, and a packet one filter accepts carries on to the next. Eight + is the ceiling, and a long chain costs a reassembly pass each. +- **Reordering rebuilds the chain**, because the order *is* the arrangement of processes + and rules. That is a visible interruption, and the log says so. On the proxy layer the + same edit costs nothing. +- **Patterns cannot rewrite here.** The matcher reports where a pattern matched; it has + no replacement path. A *Python* filter can mangle on this layer — unstably on TCP, + where changing a payload's length desynchronises the stream (hence `UNSTABLE_MANGLE`), + and **exactly on UDP**, where a datagram carries no sequence numbers to desynchronise. + +### UDP on the proxy layer, and what it gives up + +It works — and it is the one place where this layer stops being invisible, so it is +worth knowing exactly what changes. + +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 differently: firegex binds +**one socket per protected address**, each with its upstream already known, and nothing +has to be recovered per datagram. + +The consequence is the part to weigh: + +- **Your service sees firegex's address, not the client's.** On TCP this layer dials + your service *from the client's own address*, so anything that logs, rate-limits or + bans by IP keeps working. On UDP it cannot, and it says so rather than pretending — + the service page carries a **NO CLIENT IP** badge on any service in this combination. +- **Your filters still see the real client.** `RawPacket.client_ip` and `client_port` + are the actual peer: firegex knows who is talking even though it does not pass that + identity on. Only the protected service loses it. + +What you keep: + +- **Exact rewriting, at any length.** A datagram is self-contained, so there are no + sequence numbers for a longer or shorter payload to desynchronise. The caveat that + makes rewriting unstable on the NFQUEUE layer simply does not apply. +- **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 + +`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. + +**If the client's identity matters more than any of that, use NFQUEUE.** It filters UDP +with the real datagrams, from the real client, and a Python filter can rewrite them +exactly — with the kernel still holding the traffic up if the filter dies. That is the +transparent way to filter UDP, and this one is the way to get the proxy layer's chain +and its exact rewriting when you would rather have those. + +TPROXY would have given both, and is not used: it leaves no NAT entry to key the return +path off, so it cannot reach a service on this host at all. It was ruled out for that, +before UDP came up. + +### 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. + +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. + +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. 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 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. + +**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 +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. It exists only while some TLS service is +running. + +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. + +## 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: + +``` +FLAG\{[a-z0-9]+\} service → client → FLAG{redacted} +X-Debug: [^\r\n]* client → service → X-Debug: off +../ 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. + +Rewriting is proxy-only, and not because it was left unimplemented: on NFQUEUE a +replacement of a different length desynchronises the stream, and the connection breaks +some time later in a way nobody would trace back to the rule. Starting an NFQUEUE +service with a rewriting pattern is refused, saying so. + +Two things to know about a rewrite: + +- **The replacement is literal.** No `$1` capture references — hyperscan reports where a + match is, not what its groups captured, and inventing groups would mean a second engine + deciding them. +- **It works within one chunk of the stream.** Bytes already forwarded cannot be taken + back, so a match split across two of them is not rewritten. Blocking has no such limit, + because a block can still refuse the connection after the fact. + +### 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. + +The same holds for rewriting: set a pattern to rewrite and the tester shows you what the +sample **becomes**, produced by running the engine's own rewriting code over it rather +than by describing what it would do. + +Two details follow from being honest rather than approximate. A pattern is judged against +the mode it will actually run in — blocking matches live streams, rewriting scans blocks, +and hyperscan does not accept quite the same patterns in both — 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. + +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", "action": "block"}, + {"id": "flag-leak", "pattern": "FLAG\\{[a-z0-9]+\\}", "direction": "s2c", + "action": "rewrite", "with": "FLAG{redacted}"} +] +``` + +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. + +Two honest limits. `fgex regex proxy` matches **per chunk**, while a blocking 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. And the +simulator terminates the connection itself, so it always has the exact rewriting the +proxy layer has, even when you are aiming at an NFQUEUE service. + +## 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. + +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, or TLS — is +different: that changes what the datapath *is*, so the service is stopped and started +around it, and the connections it was carrying are dropped. The UI says so before you do it. + +## 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/README.md b/fgex-lib/README.md index bbf9fcf..b6afbc8 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 the `firegex.pyfilters` package used to write [Python filter](../docs/pyfilter.md) Python packet filters, the `fgex` CLI, and a local proxy simulator (`proxysim`) for testing filters without a running Firegex instance. ## Installation @@ -12,15 +12,28 @@ 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 +``` + +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. + +**➡️ [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. + +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. ## Firegex's other filtering modules This library only covers Netfilter Proxy. Firegex ships several other independent modules, each with its own guide: -- **[Netfilter Regex](../docs/nfregex.md)** — fast, PCRE2/hyperscan-based regex matching against raw traffic. +- **[Services](../docs/services.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. - **[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. +- **[Services](../docs/services.md)** — decrypt-and-reinspect bridge for services that speak TLS natively. diff --git a/fgex-lib/firegex/cli.py b/fgex-lib/firegex/cli.py index 99b1207..48673ac 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,16 @@ 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") +# `nfproxy` is what this was called when it was one of several modules. Kept as a second +# name rather than removed: it is in people's shell history and in scripts, and a command +# that has quietly stopped existing is a worse answer than one that still works. +@app.command("nfproxy", hidden=True) +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 +59,136 @@ 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, payload = ruleset.apply(sample, is_input) + + if blocked_by is not None: + print(f"[bold red]blocked[/] by [bold]{escape(blocked_by)}[/]") + close_cli(0) + if payload != sample: + print("[bold yellow]rewritten[/]") + print(escape(payload.decode(errors="replace"))) + close_cli(0) + print("[bold green]passed through unchanged[/]") + + +@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.[/]") + run_regex_simulation(Ruleset(rules), 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 index 0c48a20..758e395 100644 --- a/fgex-lib/firegex/nfproxy/__init__.py +++ b/fgex-lib/firegex/nfproxy/__init__.py @@ -1,50 +1,48 @@ -import functools -from firegex.nfproxy.models import ( - RawPacket, - TCPInputStream, - TCPOutputStream, - TCPClientStream, - TCPServerStream, - HttpHistory, - HttpStreamHistory, -) -from firegex.nfproxy.internals.models import Action, FullStreamAction, ExceptionAction +"""The old name for `firegex.pyfilters`, kept working. -ACCEPT = Action.ACCEPT -DROP = Action.DROP -REJECT = Action.REJECT -UNSTABLE_MANGLE = Action.MANGLE +The feature is called pyfilters. It was called nfproxy when it was one of several +modules, and that name is at the top of every filter file anyone has already written — +including the ones sitting in a running firegex's database, which would stop loading the +moment the import failed. So the old path still resolves, and resolves to *the same +objects*. -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 +That last part is the whole point, and why this is `sys.modules` aliasing rather than a +file of re-exports. `type_annotations_associations` maps model **classes** to protocols, +and both the library and the datapath decide when to call a filter by looking the +annotation up in that table. A shim that built second copies of `HttpRequest` and friends +would leave a filter written against the old path annotating a class the table has never +heard of: it would import cleanly and then never be called, or be refused as speaking an +unknown protocol. Aliasing the modules keeps one class per model, so `firegex.nfproxy` +and `firegex.pyfilters` are two spellings of one thing rather than two libraries. +""" -def get_pyfilters(): - """Returns the list of functions marked with @pyfilter.""" - if hasattr(pyfilter, "registry"): - return list(pyfilter.registry) - return [] +import sys -def clear_pyfilter_registry(): - """Clears the pyfilter registry.""" - if hasattr(pyfilter, "registry"): - pyfilter.registry.clear() +from firegex import pyfilters as _pyfilters -__all__ = [ - "ACCEPT", "DROP", "REJECT", "UNSTABLE_MANGLE", - "Action", "FullStreamAction", "ExceptionAction", "pyfilter", - "RawPacket", "TCPInputStream", "TCPOutputStream", "TCPClientStream", "TCPServerStream", - "HttpHistory", "HttpStreamHistory" -] \ No newline at end of file +# Every submodule, under both names. Listed rather than walked: an explicit list is what +# makes it obvious at review time which import paths are promised to keep working, and a +# walk would quietly start promising whatever happened to be added next. +for _name in ( + "", + ".models", + ".models.http", + ".models.tcp", + ".internals", + ".internals.data", + ".internals.models", + ".internals.exceptions", + ".proxysim", +): + _module = sys.modules.get("firegex.pyfilters" + _name) + if _module is None: + import importlib + + _module = importlib.import_module("firegex.pyfilters" + _name) + sys.modules["firegex.nfproxy" + _name] = _module + +# `firegex.nfproxy` itself is this module, not the aliased one, or `import firegex.nfproxy` +# would hand back a package whose `__name__` says pyfilters and whose docstring does not +# explain why. Its contents are the same objects either way. +from firegex.pyfilters import * # noqa: F401,F403,E402 +from firegex.pyfilters import __all__ # noqa: F401,E402 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/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..9d25e90 --- /dev/null +++ b/fgex-lib/firegex/pyfilters/internals/__init__.py @@ -0,0 +1,294 @@ +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. +_RAISED_VERDICT = { + StreamFullDrop: (Action.DROP, "@MAX_STREAM_SIZE_REACHED"), + StreamFullReject: (Action.REJECT, "@MAX_STREAM_SIZE_REACHED"), + DropPacket: (Action.DROP, None), + RejectConnection: (Action.REJECT, None), +} + + +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) + + # Absent means "read it off the code", which is the normal case: the file shows + # which protocol it speaks by what its filters ask for, so nothing has to be kept + # in step with it by hand. + internal_data.filter_call_info = generate_filter_structure( + glob["__firegex_pyfilter_enabled"], glob.get("__firegex_proto"), glob + ) + + 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 a nonsense size is + # ignored rather than refused: the file has already loaded. + value = int(value) + 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..bd2d613 --- /dev/null +++ b/fgex-lib/firegex/pyfilters/internals/data.py @@ -0,0 +1,195 @@ +from firegex.pyfilters.internals.models import FilterHandler +from firegex.pyfilters.internals.models import FullStreamAction, ExceptionAction + +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, edit `data`. `data` is the application payload and the only + thing a filter can change, which is also the only change that means the same thing + on both layers. + """ + + 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, + ): + self.__data = bytes(data) + self.__is_input = bool(is_input) + self.__is_ipv6 = bool(is_ipv6) + self.__is_tcp = bool(is_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 if it is UDP" + return self.__is_tcp + + @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. + + Read-only, like everything else here. It was the one writable member in the + library, and it existed for `UNSTABLE_MANGLE` alone; with that gone, assigning + to it would change a copy nothing forwards. A filter reads the traffic and + decides — it does not edit it. + """ + 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"{'tcp' if self.is_tcp else 'udp'}/{'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) + invalid_encoding_action = _Kept(ExceptionAction.REJECT, 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/nfproxy/models/__init__.py b/fgex-lib/firegex/pyfilters/models/__init__.py similarity index 69% rename from fgex-lib/firegex/nfproxy/models/__init__.py rename to fgex-lib/firegex/pyfilters/models/__init__.py index c5dffb2..6ee10d0 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, @@ -14,9 +14,15 @@ from firegex.nfproxy.models.http import ( HttpHistory, HttpStreamHistory, ) -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, @@ -39,11 +45,6 @@ type_annotations_associations = { } -class Protocols(Enum): - TCP = "tcp" - HTTP = "http" - - __all__ = [ "RawPacket", "TCPInputStream", @@ -58,6 +59,5 @@ __all__ = [ "HttpFullResponse", "HttpHistory", "HttpStreamHistory", - "Protocols", ] diff --git a/fgex-lib/firegex/nfproxy/models/http.py b/fgex-lib/firegex/pyfilters/models/http.py similarity index 99% rename from fgex-lib/firegex/nfproxy/models/http.py rename to fgex-lib/firegex/pyfilters/models/http.py index 7967ef8..10a9421 100644 --- a/fgex-lib/firegex/nfproxy/models/http.py +++ b/fgex-lib/firegex/pyfilters/models/http.py @@ -1,13 +1,13 @@ import pyllhttp -from firegex.nfproxy.internals.exceptions import NotReadyToRun -from firegex.nfproxy.internals.data import DataStreamCtx -from firegex.nfproxy.internals.exceptions import ( +from firegex.pyfilters.internals.exceptions import NotReadyToRun +from firegex.pyfilters.internals.data import DataStreamCtx +from firegex.pyfilters.internals.exceptions import ( StreamFullDrop, StreamFullReject, RejectConnection, DropPacket, ) -from firegex.nfproxy.internals.models import FullStreamAction, ExceptionAction +from firegex.pyfilters.internals.models import FullStreamAction, ExceptionAction from dataclasses import dataclass, field from collections import deque from compression import zstd diff --git a/fgex-lib/firegex/nfproxy/models/tcp.py b/fgex-lib/firegex/pyfilters/models/tcp.py similarity index 93% rename from fgex-lib/firegex/nfproxy/models/tcp.py rename to fgex-lib/firegex/pyfilters/models/tcp.py index 5717cf3..e0b1b1e 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, diff --git a/fgex-lib/firegex/nfproxy/proxysim/__init__.py b/fgex-lib/firegex/pyfilters/proxysim/__init__.py similarity index 81% rename from fgex-lib/firegex/nfproxy/proxysim/__init__.py rename to fgex-lib/firegex/pyfilters/proxysim/__init__.py index d604cd1..3e94d48 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", @@ -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..62bd806 --- /dev/null +++ b/fgex-lib/firegex/regex/__init__.py @@ -0,0 +1,290 @@ +"""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 + + +class Action(str, Enum): + BLOCK = "block" + + +@dataclass +class Rule: + """One pattern, in the same shape firegex stores it.""" + + id: str + pattern: str + direction: Direction = Direction.BOTH + case_sensitive: bool = True + action: Action = Action.BLOCK + + @classmethod + def from_dict(cls, raw: dict) -> "Rule": + 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)), + action=Action(raw.get("action", "block")), + ) + + +@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 hyperscan's own message if not. + + Checked against the mode it would actually run in: a blocking pattern is compiled + for stream matching and a rewriting one for block scanning, and hyperscan does not + accept quite the same patterns in both. + """ + mode = HS_MODE_STREAM + try: + Database([rule], mode) + 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[tuple[bool, Action], Database | None] = {} + + def _db(self, is_input: bool, action: Action) -> Database | None: + key = (is_input, action) + if key not in self._cache: + selected = [ + r for r in self.rules + if r.action is action and r.direction.covers(is_input) + ] + self._cache[key] = Database(selected, HS_MODE_BLOCK) if selected else None + return self._cache[key] + + def apply(self, data: bytes, is_input: bool) -> tuple[str | None, bytes]: + """Run the ruleset over one chunk, as the proxy datapath would. + + Returns the id of the rule that refused it, or `None` and the payload to + forward — rewritten if any rewriting rule matched. Blocking is decided before + rewriting, so a chunk that is going to be refused is not rewritten on its way + there. + + One caveat this cannot hide: matching here is per chunk, while a *blocking* rule + on a real service matches across the whole stream. A pattern split over two + reads is caught there and not here, so a rule that does not fire in the + simulator may still fire in production. Rewriting has the same scope in both, + because bytes already forwarded cannot be taken back either way. + """ + blocking = self._db(is_input, Action.BLOCK) + if blocking: + hits = blocking.scan(data, limit=1) + if hits: + return hits[0].rule.id, data + return None, data + + +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..7b7d897 --- /dev/null +++ b/fgex-lib/firegex/regex/sim.py @@ -0,0 +1,106 @@ +"""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, payload = 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, payload = None, data + if blocked_by is not None: + on_block(blocked_by) + _log("block", f"connection refused by [bold]{escape(blocked_by)}[/]", "WARNING") + break + if payload != data: + _log("rewrite", f"{escape(repr(data)[:60])} -> {escape(repr(payload)[:60])}") + writer.write(payload) + 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/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/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 - - - - - Firegex logonavigator("/")}/> - - - - [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 + + + + + Firegex logonavigator("/")}/> + + + + [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/MarkdownDocs.tsx b/frontend/src/components/MarkdownDocs.tsx index 1e68aab..bd35e7c 100644 --- a/frontend/src/components/MarkdownDocs.tsx +++ b/frontend/src/components/MarkdownDocs.tsx @@ -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/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..96e8193 --- /dev/null +++ b/frontend/src/components/Services/AddEditService.tsx @@ -0,0 +1,411 @@ +import { Accordion, ActionIcon, Alert, Box, Button, Group, Modal, NumberInput, SegmentedControl, 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, okNotify, regex_ipv4_no_cidr, regex_ipv6_no_cidr } from '../../js/utils'; +import PortAndInterface from '../PortAndInterface'; +import LayerChoice from './LayerChoice'; +import PemInput from './PemInput'; +import { AddressForm, carries, L4, Service, ServiceAddForm, serviceQueryKey, services, Transport } from './utils'; + +type AddressValues = { + ip_int: string, + port: number, + proxy_ip: string, + proxy_port: number, +} + +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, +}) + +const validAddress = (v: string) => + !!(v.match(regex_ipv6_no_cidr) || v.match(regex_ipv4_no_cidr)) + +/** + * 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: false, + }, + 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 (values.proto !== L4.TLS) 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 (values.proto !== L4.TLS) 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 => validAddress(v) ? null : "Invalid IP address", + 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: false, + }) + 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 => ({ + ip_int: a.ip_int, + port: a.port, + ...(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, + ...(values.proto === L4.TLS && values.tls_cert !== "" ? { tls_cert: values.tls_cert } : {}), + ...(values.proto === L4.TLS && 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 (values.proto === L4.TLS && values.tls_cert !== "") payload.tls_cert = values.tls_cert + if (values.proto === L4.TLS && 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 + const isTls = form.values.proto === L4.TLS + // 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, + 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. + 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 TLS flag that does nothing. + if (isTls) form.setFieldValue('proto', L4.TCP) + if (form.values.max_connections !== 0) form.setFieldValue('max_connections', 0) + if (form.values.first_byte_timeout !== 0) form.setFieldValue('first_byte_timeout', 0) + }, [form.values.transport]) + const isExternal = form.values.transport === Transport.EXTERNAL + const isUdp = form.values.proto === L4.UDP + + // 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 +
+ + + + + Transport protocol + + What the service speaks on the wire. TLS is one of them rather than a + switch on top of TCP: the engine decrypts it, so the filters see the + plaintext and no extra port is used. + + + {/* Beside the control it is greyed out in, not further down beside the layer + that decides it: the question a reader has is "why can I not click that". */} + {!carries(form.values.transport, L4.TLS) ? + TLS is unavailable on this layer: it inspects packets as they pass rather + than terminating the connection, and decrypting means terminating. Choose + the proxy layer to use it. + : null} + + + {edit ? null : <> + + + Addresses to protect + + One service, one filter chain, as many addresses as it answers on — + IPv4 and IPv6 together if that is how it is reachable. + + + + form.insertListItem('addresses', emptyAddress())}> + + + + + + {form.values.addresses.map((_, index) => + + + + + form.removeListItem('addresses', index)}> + + + + {isExternal ? + + : null} + )} + + } + + 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} + : } + + + + + } + + + {isTls ? <> + + 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. They are never sent back to the browser, so leaving these empty keeps them as they are." + : "The key is stored and used to configure nginx, and never sent back to the browser."} + + : null} + + {edit ? null : <> + + + } + + {edit ? + + Changing any of this restarts the service, which drops the connections it + is carrying. Adding an address, or adding and reordering filters, does not. + : 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..0ae7c86 --- /dev/null +++ b/frontend/src/components/Services/AddressList.tsx @@ -0,0 +1,247 @@ +import { CodeHighlight } from '@mantine/code-highlight'; +import { ActionIcon, Badge, Box, Button, Card, Code, Collapse, Group, Modal, 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 { errorNotify, okNotify, regex_ipv4_no_cidr, regex_ipv6_no_cidr } from '../../js/utils'; +import PortAndInterface from '../PortAndInterface'; +import YesNoModal from '../YesNoModal'; +import { Address, decrypts, Service, serviceQueryKey, services, Transport } from './utils'; + +type Values = { ip_int: string, port: number, proxy_ip: string, proxy_port: number } + +const validAddress = (v: string) => + !!(v.match(regex_ipv6_no_cidr) || v.match(regex_ipv4_no_cidr)) + +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 form = useForm({ + initialValues: { ip_int: "127.0.0.1", port: 80, proxy_ip: "127.0.0.1", proxy_port: 8080 }, + validate: { + ip_int: v => validAddress(v) ? null : "Invalid IP address", + port: v => (v > 0 && v < 65536) ? null : "Invalid port", + 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({ + ip_int: edit.ip_int.split("/")[0], port: edit.port, + proxy_ip: edit.proxy_ip ?? "127.0.0.1", proxy_port: edit.proxy_port ?? 8080, + }) + else form.reset() + }, [opened, edit?.address_id]) + + const submit = async (values: Values) => { + setBusy(true) + const payload = { + ip_int: values.ip_int, port: values.port, + ...(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 +
+ + {isExternal ? <> + + + + Its own port, not one another address already uses: the return rule puts + the original port back by recognising your proxy's, so two addresses + behind the same endpoint could not be told apart on the way out. + + : 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 + + 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 + + The same chain runs on every one of them. Adding one costs no connections. + + + + + + + {addresses.map(address => + + + {/* No protocol badge: it is the same on every address, because + it is a property of the service, and the header already says + it once. Repeating it on each row is noise that grows with + the list. */} + {address.ip_int}:{address.port} + {decrypts(service) ? + }> + TLS + + : null} + {address.proxy_port ? + → {address.proxy_ip ?? "127.0.0.1"}:{address.proxy_port} + : 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 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. + * + * 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 + + return + + + + + + + Every TLS service's plaintext 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. + + + +} 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..ea0f707 --- /dev/null +++ b/frontend/src/components/Services/FilterCard.tsx @@ -0,0 +1,286 @@ +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)} + + {false ? + + → {decode(rx.replace_with ?? "") || "(removed)"} + + : 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, + action: rx.action, + replaceWith: decode(rx.replace_with ?? ""), + }))} /> + 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..63bf3dc --- /dev/null +++ b/frontend/src/components/Services/LayerChoice.tsx @@ -0,0 +1,178 @@ +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[], +} + +const LAYERS: Record = { + [Transport.PROXY]: { + line: "Terminates and reopens the connection — exact rewriting, and the faster of the two.", + good: [ + "Carries bulk traffic 2-5× faster, and scales better with threads", + "Rewriting is exact, 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: ["Datagrams are rewritten exactly — no sequence numbers to break"], + udpBad: ["On UDP your service sees firegex, not the client"], + }, + [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", + "Patterns can only block, never rewrite", + ], + 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 unavailable = Object.keys(LABELS).filter(t => !carries(t, proto)) + const good = [...layer.good, ...(udp ? layer.udpGood ?? [] : [])] + const bad = [...layer.bad, ...(udp ? layer.udpBad ?? [] : [])] + + 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 ? UDP : 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} + {unavailable.length > 0 ? + {unavailable.map(t => LABELS[t]).join(" and ")} + {unavailable.length > 1 ? " are" : " is"} not available for a service that speaks + TLS: decrypting means terminating the connection, and only this layer does that. + Set the protocol to TCP to use {unavailable.length > 1 ? "them" : "it"}. + : null} + + {udp && value === Transport.PROXY ? + + UDP here does not preserve the client's address. Each address gets a + relay of its own, so your service sees firegex — SO_ORIGINAL_DST is + TCP-only. Your filters still read the real client; NFQUEUE keeps it end to end. + + : 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 => } + + +