From 5e23e9af02fecaa0fe2d178ff9eee88242eb095c Mon Sep 17 00:00:00 2001 From: gru <> Date: Sat, 3 Oct 2026 00:27:09 +0100 Subject: [PATCH] v0.24.5 --- .dockerignore | 14 + .env.example | 8 + .gitignore | 11 + AGENTS.md | 78 + CHANGELOG.md | 795 ++ Cargo.lock | 2994 ++++++ Cargo.toml | 77 + Dockerfile | 24 + README.md | 802 ++ ROADMAP.md | 658 ++ SHA256SUMS | 237 + UPDATE_CHECK.toml | 6 + V0.25.0_WEB_SERVER_PLAN.md | 2283 +++++ build.rs | 135 + debian/README.md | 60 + debian/build-package.sh | 26 + debian/build-repository.sh | 44 + debian/changelog | 197 + debian/control | 16 + debian/rules | 24 + debian/rustproxy.environment | 7 + debian/rustproxy.logrotate | 15 + debian/rustproxy.postinst | 14 + debian/rustproxy.service | 55 + debian/rustproxy.tmpfiles | 2 + debian/source/format | 1 + deploy/README.md | 47 + deploy/rustproxy.service | 40 + dev.sh | 140 + docker-agent/README.md | 46 + docker-agent/docker-agent.env.example | 10 + docker-agent/go.mod | 3 + docker-agent/install.sh | 58 + docker-agent/main.go | 701 ++ docker-agent/main_test.go | 245 + docker-agent/rustproxy-docker-agent.service | 35 + docs/ACME_PROVIDERS.md | 128 + docs/ACME_RFC2136.md | 157 + docs/API.md | 549 ++ docs/API_ACCESS.md | 231 + docs/ARCHITECTURE.md | 214 + docs/CACHE.md | 290 + docs/CACHE_DIAGNOSTICS.md | 187 + docs/CONTAINER.md | 43 + docs/CUSTOM_LOCATIONS.md | 96 + docs/DOCKER_DISCOVERY.md | 147 + docs/HTTP3.md | 157 + docs/INSTALL.md | 291 + docs/INTEGRATIONS_040.md | 161 + docs/LIVE_LOGS.md | 49 + docs/MANAGEMENT.md | 134 + docs/MIGRATION.md | 444 + docs/MOCK_AUTH.md | 37 + docs/MOCK_SERVER.md | 116 + docs/NOT_FOUND_HOSTS.md | 24 + docs/NPM_MIGRATION.md | 115 + docs/PROMETHEUS.md | 147 + docs/README.md | 46 + docs/REDIRECTS.md | 66 + docs/REFERENCES.md | 65 + docs/RENEWAL.md | 118 + docs/SECURITY.md | 207 + docs/SECURITY_AUDIT.md | 78 + docs/SETTINGS.md | 346 + docs/STREAMS.md | 61 + docs/TEST_REPORT.md | 52 + docs/TRANSFORM.md | 41 + docs/TRANSPORT.md | 211 + docs/V0.21.0_AUDIT.md | 77 + docs/V0.22.0_AUDIT.md | 106 + docs/V0.22.1_AUDIT.md | 43 + docs/V0.22.2_AUDIT.md | 43 + docs/V0.22.3_AUDIT.md | 49 + docs/V0.23.0_AUDIT.md | 48 + docs/V0.23.1_AUDIT.md | 31 + docs/V0.23.2_AUDIT.md | 27 + docs/V0.23.3_AUDIT.md | 25 + docs/V0.24.0_AUDIT.md | 40 + docs/V0.24.1_AUDIT.md | 48 + docs/V0.24.3_AUDIT.md | 56 + docs/V0.24.4_AUDIT.md | 21 + docs/V0.24.5_AUDIT.md | 59 + docs/VHOSTS.md | 404 + docs/VHOST_ACCESS.md | 121 + docs/screenshots/README.md | 8 + examples/acl-headers.json | 236 + examples/cache-dashboard.json | 159 + examples/config.json | 52 + examples/empty.json | 3 + examples/http3-vhost.json | 61 + examples/mock-vhost.json | 92 + examples/tinyauth-vhost.json | 69 + examples/transport.json | 69 + examples/vhosts-balanced.json | 138 + examples/vhosts-private.json | 110 + grafana/README.md | 34 + grafana/rustproxy-overview.json | 3679 ++++++++ install-debian13.sh | 138 + install.sh | 5 + langs/README.md | 40 + langs/en_US.json | 1677 ++++ langs/pl_pl.json | 1677 ++++ make_zip.py | 109 + migrations/0002_admin.sql | 36 + migrations/0003_certificate_renewals.sql | 12 + migrations/0004_admin_preferences.sql | 7 + migrations/0005_certificate_api.sql | 9 + .../0006_certificate_api_credentials.sql | 16 + nagios/README.md | 58 + nagios/check_rustproxy.py | 231 + nagios/check_rustproxy_api | 2 + nagios/check_rustproxy_backends | 2 + nagios/check_rustproxy_latency | 2 + nagios/check_rustproxy_queues | 2 + rust-toolchain.toml | 4 + scripts/acme_provider.py | 449 + scripts/acme_rfc2136.py | 5 + scripts/admin_audit.py | 63 + scripts/backup.py | 99 + scripts/check_source.py | 1105 +++ scripts/deploy.py | 770 ++ scripts/langs_check.py | 39 + scripts/migrate_npm.py | 589 ++ scripts/mock_auth.py | 147 + scripts/mock_server.py | 191 + scripts/os-acme-client/README.md | 107 + .../opnsense-rustproxy-cert-deploy.sh | 137 + scripts/preflight.py | 56 + scripts/release_smoke.py | 1067 +++ scripts/routeros-oci-build/.env | 39 + .../routeros-oci-build/.env.routeros.example | 40 + .../routeros-oci-build/Dockerfile.routeros | 51 + .../Dockerfile.routeros.dockerignore | 25 + scripts/routeros-oci-build/README.md | 137 + .../routeros-oci-build/build-routeros-oci.sh | 272 + .../routeros-oci-build/docker-entrypoint.sh | 18 + scripts/swagger_assets.py | 232 + scripts/test_compression.py | 249 + scripts/upgrade.py | 124 + src/acl.rs | 240 + src/acme.rs | 1172 +++ src/admin/documentation.rs | 89 + src/admin/mod.rs | 4249 +++++++++ src/admin/openapi.json | 8200 +++++++++++++++++ src/admin/public.rs | 21 + src/admin/web/acme.js | 184 + src/admin/web/app.js | 1005 ++ src/admin/web/cache-panel.js | 309 + src/admin/web/demo.html | 37 + src/admin/web/docker.js | 360 + src/admin/web/docs.css | 178 + src/admin/web/docs.html | 36 + src/admin/web/docs.js | 75 + src/admin/web/error_401.html | 37 + src/admin/web/error_403.html | 37 + src/admin/web/error_404.html | 37 + src/admin/web/error_429.html | 37 + src/admin/web/error_503.html | 37 + src/admin/web/error_504.html | 37 + src/admin/web/favicon.svg | 1 + src/admin/web/host404.js | 70 + src/admin/web/index.html | 2426 +++++ src/admin/web/integrations.js | 261 + src/admin/web/login-keys.json | 27 + src/admin/web/login.html | 47 + src/admin/web/login.js | 79 + src/admin/web/management.js | 295 + src/admin/web/redirects.js | 108 + src/admin/web/settings.js | 197 + src/admin/web/streams.js | 177 + src/admin/web/style.css | 7354 +++++++++++++++ src/admin/web/transform-panel.js | 170 + src/admin/web/vhosts.js | 899 ++ src/auth/files.rs | 143 + src/auth/mod.rs | 555 ++ src/auth/store.rs | 256 + src/balance.rs | 2195 +++++ src/basic_auth.rs | 329 + src/cache/cold.rs | 974 ++ src/cache/collapse.rs | 257 + src/cache/dashboard.rs | 429 + src/cache/fill.rs | 343 + src/cache/invalidation.rs | 584 ++ src/cache/lifecycle.rs | 281 + src/cache/lookup.rs | 644 ++ src/cache/mod.rs | 18 + src/cache/model.rs | 1668 ++++ src/cache/policy.rs | 570 ++ src/cache/range.rs | 184 + src/cache/tests.rs | 3153 +++++++ src/cache/tickets.rs | 366 + src/compression.rs | 384 + src/config.rs | 2036 ++++ src/db.rs | 560 ++ src/demo.rs | 108 + src/diagnostics.rs | 149 + src/docker.rs | 1344 +++ src/forwarding.rs | 516 ++ src/geoip.rs | 222 + src/headers.rs | 172 + src/http3.rs | 574 ++ src/lib.rs | 41 + src/logging.rs | 731 ++ src/main.rs | 1962 ++++ src/metrics.rs | 498 + src/not_found.rs | 130 + src/pages.rs | 406 + src/prometheus.rs | 1270 +++ src/proxy/body.rs | 439 + src/proxy/headers.rs | 138 + src/proxy/mod.rs | 3328 +++++++ src/proxy/websocket.rs | 1439 +++ src/rate_limit.rs | 326 + src/redirect.rs | 593 ++ src/runtime.rs | 621 ++ src/security.rs | 145 + src/server.rs | 1531 +++ src/settings.rs | 658 ++ src/stream.rs | 591 ++ src/system_stats/bsd.rs | 180 + src/system_stats/linux.rs | 84 + src/system_stats/mod.rs | 510 + src/tinyauth.rs | 512 + src/tls.rs | 1743 ++++ src/transform/css.rs | 65 + src/transform/html.rs | 130 + src/transform/js.rs | 140 + src/transform/json.rs | 6 + src/transform/mod.rs | 1030 +++ src/transform/svg.rs | 67 + src/update_check.rs | 357 + src/upstream.rs | 662 ++ src/vhost.rs | 849 ++ tests/fixtures/argon2id.json | 4 + tests/http2.rs | 52 + tests/http3.rs | 43 + tests/policies.rs | 108 + tests/transport_config.rs | 45 + update.sh | 155 + upgrade.sh | 4 + 240 files changed, 99957 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 SHA256SUMS create mode 100644 UPDATE_CHECK.toml create mode 100644 V0.25.0_WEB_SERVER_PLAN.md create mode 100644 build.rs create mode 100644 debian/README.md create mode 100755 debian/build-package.sh create mode 100755 debian/build-repository.sh create mode 100644 debian/changelog create mode 100644 debian/control create mode 100755 debian/rules create mode 100644 debian/rustproxy.environment create mode 100644 debian/rustproxy.logrotate create mode 100755 debian/rustproxy.postinst create mode 100644 debian/rustproxy.service create mode 100644 debian/rustproxy.tmpfiles create mode 100644 debian/source/format create mode 100644 deploy/README.md create mode 100644 deploy/rustproxy.service create mode 100755 dev.sh create mode 100644 docker-agent/README.md create mode 100644 docker-agent/docker-agent.env.example create mode 100644 docker-agent/go.mod create mode 100755 docker-agent/install.sh create mode 100644 docker-agent/main.go create mode 100644 docker-agent/main_test.go create mode 100644 docker-agent/rustproxy-docker-agent.service create mode 100644 docs/ACME_PROVIDERS.md create mode 100644 docs/ACME_RFC2136.md create mode 100644 docs/API.md create mode 100644 docs/API_ACCESS.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/CACHE.md create mode 100644 docs/CACHE_DIAGNOSTICS.md create mode 100644 docs/CONTAINER.md create mode 100644 docs/CUSTOM_LOCATIONS.md create mode 100644 docs/DOCKER_DISCOVERY.md create mode 100644 docs/HTTP3.md create mode 100644 docs/INSTALL.md create mode 100644 docs/INTEGRATIONS_040.md create mode 100644 docs/LIVE_LOGS.md create mode 100644 docs/MANAGEMENT.md create mode 100644 docs/MIGRATION.md create mode 100644 docs/MOCK_AUTH.md create mode 100644 docs/MOCK_SERVER.md create mode 100644 docs/NOT_FOUND_HOSTS.md create mode 100644 docs/NPM_MIGRATION.md create mode 100644 docs/PROMETHEUS.md create mode 100644 docs/README.md create mode 100644 docs/REDIRECTS.md create mode 100644 docs/REFERENCES.md create mode 100644 docs/RENEWAL.md create mode 100644 docs/SECURITY.md create mode 100644 docs/SECURITY_AUDIT.md create mode 100644 docs/SETTINGS.md create mode 100644 docs/STREAMS.md create mode 100644 docs/TEST_REPORT.md create mode 100644 docs/TRANSFORM.md create mode 100644 docs/TRANSPORT.md create mode 100644 docs/V0.21.0_AUDIT.md create mode 100644 docs/V0.22.0_AUDIT.md create mode 100644 docs/V0.22.1_AUDIT.md create mode 100644 docs/V0.22.2_AUDIT.md create mode 100644 docs/V0.22.3_AUDIT.md create mode 100644 docs/V0.23.0_AUDIT.md create mode 100644 docs/V0.23.1_AUDIT.md create mode 100644 docs/V0.23.2_AUDIT.md create mode 100644 docs/V0.23.3_AUDIT.md create mode 100644 docs/V0.24.0_AUDIT.md create mode 100644 docs/V0.24.1_AUDIT.md create mode 100644 docs/V0.24.3_AUDIT.md create mode 100644 docs/V0.24.4_AUDIT.md create mode 100644 docs/V0.24.5_AUDIT.md create mode 100644 docs/VHOSTS.md create mode 100644 docs/VHOST_ACCESS.md create mode 100644 docs/screenshots/README.md create mode 100644 examples/acl-headers.json create mode 100644 examples/cache-dashboard.json create mode 100644 examples/config.json create mode 100644 examples/empty.json create mode 100644 examples/http3-vhost.json create mode 100644 examples/mock-vhost.json create mode 100644 examples/tinyauth-vhost.json create mode 100644 examples/transport.json create mode 100644 examples/vhosts-balanced.json create mode 100644 examples/vhosts-private.json create mode 100644 grafana/README.md create mode 100644 grafana/rustproxy-overview.json create mode 100755 install-debian13.sh create mode 100755 install.sh create mode 100644 langs/README.md create mode 100644 langs/en_US.json create mode 100644 langs/pl_pl.json create mode 100755 make_zip.py create mode 100644 migrations/0002_admin.sql create mode 100644 migrations/0003_certificate_renewals.sql create mode 100644 migrations/0004_admin_preferences.sql create mode 100644 migrations/0005_certificate_api.sql create mode 100644 migrations/0006_certificate_api_credentials.sql create mode 100644 nagios/README.md create mode 100755 nagios/check_rustproxy.py create mode 100755 nagios/check_rustproxy_api create mode 100755 nagios/check_rustproxy_backends create mode 100755 nagios/check_rustproxy_latency create mode 100755 nagios/check_rustproxy_queues create mode 100644 rust-toolchain.toml create mode 100755 scripts/acme_provider.py create mode 100755 scripts/acme_rfc2136.py create mode 100755 scripts/admin_audit.py create mode 100755 scripts/backup.py create mode 100755 scripts/check_source.py create mode 100755 scripts/deploy.py create mode 100755 scripts/langs_check.py create mode 100755 scripts/migrate_npm.py create mode 100755 scripts/mock_auth.py create mode 100755 scripts/mock_server.py create mode 100644 scripts/os-acme-client/README.md create mode 100755 scripts/os-acme-client/opnsense-rustproxy-cert-deploy.sh create mode 100755 scripts/preflight.py create mode 100755 scripts/release_smoke.py create mode 100644 scripts/routeros-oci-build/.env create mode 100644 scripts/routeros-oci-build/.env.routeros.example create mode 100644 scripts/routeros-oci-build/Dockerfile.routeros create mode 100644 scripts/routeros-oci-build/Dockerfile.routeros.dockerignore create mode 100644 scripts/routeros-oci-build/README.md create mode 100755 scripts/routeros-oci-build/build-routeros-oci.sh create mode 100755 scripts/routeros-oci-build/docker-entrypoint.sh create mode 100755 scripts/swagger_assets.py create mode 100755 scripts/test_compression.py create mode 100755 scripts/upgrade.py create mode 100644 src/acl.rs create mode 100644 src/acme.rs create mode 100644 src/admin/documentation.rs create mode 100644 src/admin/mod.rs create mode 100644 src/admin/openapi.json create mode 100644 src/admin/public.rs create mode 100644 src/admin/web/acme.js create mode 100644 src/admin/web/app.js create mode 100644 src/admin/web/cache-panel.js create mode 100644 src/admin/web/demo.html create mode 100644 src/admin/web/docker.js create mode 100644 src/admin/web/docs.css create mode 100644 src/admin/web/docs.html create mode 100644 src/admin/web/docs.js create mode 100644 src/admin/web/error_401.html create mode 100644 src/admin/web/error_403.html create mode 100644 src/admin/web/error_404.html create mode 100644 src/admin/web/error_429.html create mode 100644 src/admin/web/error_503.html create mode 100644 src/admin/web/error_504.html create mode 100644 src/admin/web/favicon.svg create mode 100644 src/admin/web/host404.js create mode 100644 src/admin/web/index.html create mode 100644 src/admin/web/integrations.js create mode 100644 src/admin/web/login-keys.json create mode 100644 src/admin/web/login.html create mode 100644 src/admin/web/login.js create mode 100644 src/admin/web/management.js create mode 100644 src/admin/web/redirects.js create mode 100644 src/admin/web/settings.js create mode 100644 src/admin/web/streams.js create mode 100644 src/admin/web/style.css create mode 100644 src/admin/web/transform-panel.js create mode 100644 src/admin/web/vhosts.js create mode 100644 src/auth/files.rs create mode 100644 src/auth/mod.rs create mode 100644 src/auth/store.rs create mode 100644 src/balance.rs create mode 100644 src/basic_auth.rs create mode 100644 src/cache/cold.rs create mode 100644 src/cache/collapse.rs create mode 100644 src/cache/dashboard.rs create mode 100644 src/cache/fill.rs create mode 100644 src/cache/invalidation.rs create mode 100644 src/cache/lifecycle.rs create mode 100644 src/cache/lookup.rs create mode 100644 src/cache/mod.rs create mode 100644 src/cache/model.rs create mode 100644 src/cache/policy.rs create mode 100644 src/cache/range.rs create mode 100644 src/cache/tests.rs create mode 100644 src/cache/tickets.rs create mode 100644 src/compression.rs create mode 100644 src/config.rs create mode 100644 src/db.rs create mode 100644 src/demo.rs create mode 100644 src/diagnostics.rs create mode 100644 src/docker.rs create mode 100644 src/forwarding.rs create mode 100644 src/geoip.rs create mode 100644 src/headers.rs create mode 100644 src/http3.rs create mode 100644 src/lib.rs create mode 100644 src/logging.rs create mode 100644 src/main.rs create mode 100644 src/metrics.rs create mode 100644 src/not_found.rs create mode 100644 src/pages.rs create mode 100644 src/prometheus.rs create mode 100644 src/proxy/body.rs create mode 100644 src/proxy/headers.rs create mode 100644 src/proxy/mod.rs create mode 100644 src/proxy/websocket.rs create mode 100644 src/rate_limit.rs create mode 100644 src/redirect.rs create mode 100644 src/runtime.rs create mode 100644 src/security.rs create mode 100644 src/server.rs create mode 100644 src/settings.rs create mode 100644 src/stream.rs create mode 100644 src/system_stats/bsd.rs create mode 100644 src/system_stats/linux.rs create mode 100644 src/system_stats/mod.rs create mode 100644 src/tinyauth.rs create mode 100644 src/tls.rs create mode 100644 src/transform/css.rs create mode 100644 src/transform/html.rs create mode 100644 src/transform/js.rs create mode 100644 src/transform/json.rs create mode 100644 src/transform/mod.rs create mode 100644 src/transform/svg.rs create mode 100644 src/update_check.rs create mode 100644 src/upstream.rs create mode 100644 src/vhost.rs create mode 100644 tests/fixtures/argon2id.json create mode 100644 tests/http2.rs create mode 100644 tests/http3.rs create mode 100644 tests/policies.rs create mode 100644 tests/transport_config.rs create mode 100755 update.sh create mode 100755 upgrade.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e4e859b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.github +.upgrade-backup +target +node_modules +data +*.zip +*.sqlite3 +*.sqlite3-* +*.pem +*.key +*.p12 +*.pfx +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4597219 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Optional. Copy to .env. dev.sh sources this as a shell file; use trusted content only. +RUST_PROXY_DATA_DIR=./data +RUST_PROXY_PROXY_LISTEN=127.0.0.1:8080 +RUST_PROXY_ADMIN_LISTEN=127.0.0.1:9090 +RUST_PROXY_DEMO_LISTEN=127.0.0.1:9000 +# 1 starts a separate local demo backend and seeds a NEW database only. +RUST_PROXY_DEMO=1 +RUST_LOG=rust_proxy=info,warn diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd80a0b --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/target/ +/data/ +/.env +*.sqlite* +*.db* +*.log +__pycache__/ +*.zip +problems.txt +todo/ +scripts/routeros-oci-build/.env diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..800176f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,78 @@ +# RustProxy implementation rules + +Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes. +The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for +compatibility. Current source version: 0.24.5. + +## Delivery + +Maintain English documentation and Markdown files. Keep translations in discovered langs/*.json packs, with en_US as fallback. Never hardcode a two-language switch. Preserve the existing logo. +CHANGELOG.md uses version headings with no release dates. Patch increments may be +bundled; substantial optional integrations raise the minor version. 0.7.0 adds managed renewal, server deployment helpers, Basic Auth and visual locations. The reported compression threshold type mismatch is removed without narrowing. Cloudflare Tunnel and Docker discovery are deferred. + +Deliver a complete archive with dev.sh, tests and an updated verification report. +Do not include databases, credentials, real certificates/keys, target directories, +node_modules or fabricated Cargo.lock content. Preserve an actually resolved +lockfile when a compiler-enabled environment can produce it. + +## Engineering + +Keep proxy traffic independent from control-plane database queries and providers. +Use async I/O, streaming bodies, bounded DNS work, bounded queues and explicit +cancellation. Hold request and connection permits through full body/tunnel lifetimes. +Run ACLs before opening OR reusing an upstream connection. Resolve only configured +backend origins; validate every DNS answer and use the validated addresses directly. +Backend TLS certificate verification defaults OFF when `tls_verify` is omitted for vhost +backends. Administrators may explicitly enable chain/identity verification per HTTPS backend. +This compatibility default is limited to application backends: never widen it to Tinyauth or +other verifier clients. Disabling verification must not weaken backend address/DNS/loop/control- +plane protections. + +Preserve the explicit auth module path and upgrade regression coverage. Replace +managed source directories instead of leaving obsolete modules during upgrades. +Keep administrator intent, provider observations and live connection state separate. +Hot changes must publish validated, immutable snapshots after a successful revisioned +SQLite write. Invalid updates leave the old snapshot active. + +## Verification + +Run `./dev.sh check` and `./dev.sh test` when Rust is available. Run the SQL and upgrade +scripts even when it is not. UI tests use a simulated API; the Python protocol-peer +self-test is not a test of RustProxy. Record these distinctions accurately. +Do not mark a stage accepted or production-ready on the basis of static inspection. +Review real protocol behavior, restored data and resource bounds before expanding +scope. Do not expand provider work ahead of the core real-runtime acceptance gate. Keep that gate explicitly open until Rust runs. + +## Vhost invariants + +Use the accepted socket's actual local address and TLS state for frontend selection; +never infer a private binding or sticky identity from forwarding headers. Vhosts receive +frontend sockets only through `frontend.listener_ids` referencing `listeners.endpoints`. +Keep hostname ownership unambiguous. Socket/ALPN changes require restart, with actual +and desired plans visible. + +Keep health observations separate from desired configuration. Preserve observations +only for compatible members; re-enabled/changed origins require new health checks. +Keep backend leases through request/response/tunnel completion. Do not replay failed +writes to another backend. Cookies are routing affinity, never authentication. Health +probes must use the same origin/DNS policy, bounded concurrency and cancellation. +Retain HTTP/1.1 in TLS HTTP/2 negotiation; do not implement fallback by replaying traffic. + +## Vhost configuration invariants + +The pre-production route/inline-listener compatibility importer has been removed. +Persist only the current vhost document and named listener references; old documents +must fail validation instead of being silently converted. Keep path-pinned origins +distinct from balanced pools. Do not silently widen listener bindings. +ACME secrets belong in private credentials files, not SQLite, stdout, argv or API. +HTTP-01 only serves bounded tokens for domains in the active job. No CA call on boot. +The exporter checks actual peer CIDRs and is disabled by default; secret configuration +stores a variable name, never a token. Keep metric labels bounded by configuration. + +## Administration access regression gate (0.8.1) + +No new public admin routes without updating the exact method/path allowlist and +access tests. Operational assets and the OpenAPI schema are private. Keep the +public login document/bundle separate from the dashboard. Documentation uses +the existing cookie session/CSRF, not a second auth mechanism. Preserve the pinned +Swagger build configuration and keep external validators/CDNs disabled. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8101554 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,795 @@ +# Changelog + +## 0.24.5 + +- Normalize actually empty, length-unknown HTTP/3 request bodies before HTTP/1.1 upstream forwarding so Hyper emits a zero-length request instead of chunked framing for methods such as DELETE. +- Keep non-empty HTTP/3 bodies streaming by replaying the inspected first frame, leave declared `Content-Length` requests untouched, and do not alter HTTP/2 upstream framing. +- Add focused regression coverage for empty/non-empty H3 DELETE and generic empty H3 methods forwarded to HTTP/1.1. +- Keep the source package consistent with its existing release contracts by dropping the stale unreferenced `src/system_stats/bsd.rs` file and updating the offline container check to the already-shipped Debian 13/Trixie base images. +- Harden cache RAM admission without changing the HOT -> COLD storage model: HOT now has a 256 MiB allocation-aware default budget, fills reserve memory before buffer growth, and a small bounded allocator headroom replaces max-object-sized overshoot. +- Add a separate 64 MiB transient RAM budget plus a 32 MiB byte ceiling for the COLD writer queue; demoted bodies stay charged until persistence drops the `ColdDraft`, while persisted `ColdEntry` values retain only disk paths/metadata. +- Preserve body backing-allocation accounting across fill, revalidation and COLD promotion, expose HOT logical vs allocated bytes and the HOT/transient/COLD-queue limits, and add memory-pressure/LRU-admission counters. +- Make global cache disable authoritative over per-vhost enablement, remove the artificial 1 GiB HOT/object configuration ceiling, and hot-apply reduced HOT capacity with bounded LRU demotion/eviction. +- Add Settings memory sizing based on effective host/cgroup RAM with conservative, recommended and performance presets, a live recommended profile, host/cgroup headroom warnings, and controls for transient RAM, COLD queue RAM and bounded LRU admission. Automatic presets are disabled below 256 MiB effective RAM. +- Update release identity to 0.24.5. + +## 0.24.4 + +- Disable Linux Transparent Huge Pages for the RustProxy process with `prctl(PR_SET_THP_DISABLE)` before the Tokio runtime is created, avoiding a host-wide THP policy change while preventing 2 MiB anonymous huge pages from amplifying allocator RSS retention. +- Treat the process-local THP operation as best-effort: unsupported/rejected kernels emit a startup warning and RustProxy continues to run. +- Update release identity to 0.24.4. + +## 0.24.3 + +- Add a per-certificate reload operation for manual/file-backed certificate IDs and authorize it through the scoped Certificate API as well as the normal administrator session. +- Allow multiple Certificate API tokens (bounded to 32), operator comments, individual revocation and revoke-all while continuing to persist token hashes only. +- Replace the overview Core State block with a live connection/activity chart, move uptime into the Process block below process start, add RX/TX-style upload/download detail under Payload transferred, and persist per-browser hidden network-interface charts. + +- Add `MALLOC_ARENA_MAX=2` to managed Linux/systemd service units to reduce glibc arena growth and long-running RSS retention while retaining the environment-file override. +- Remove an identity-body copy from the transformation pipeline, remove `Vec` plus final output-copy allocations from the JavaScript/CSS minifiers, release successful-transform temporary buffers earlier, and use a throttled `malloc_trim(0)` after large Linux/glibc transforms. +- Make HTTP/2 lifecycle tuning listener-scoped like HTTP/3: keep H2/H2C and H3 enablement in **Settings -> Listeners**, expose per-listener H2 GOAWAY/lifetime limits under **HTTP connections -> HTTP/2**, retain per-listener QUIC limits under **HTTP/3**, and link listener rows to the matching tuning page. Existing top-level `http2` values remain a compatibility fallback for listeners without explicit H2 settings. +- Rename **Proxy process** to **Process** and dynamically hide unsupported process diagnostics and history charts instead of showing unknown values. +- Remove the stale, unreferenced legacy `src/system_stats/bsd.rs` parser file from the source package; shared library collectors remain the only BSD statistics implementation. +- Update release identity to 0.24.3. + +## 0.24.2 + +- Version-only release bump from 0.24.1; no functional, configuration or protocol changes. + +## 0.24.1 + +- Restore system network interface discovery and RX/TX counters after the shared-statistics refactor: use the cross-platform `sysinfo::Networks` API as the primary network inventory/counter source, merge `systemstat` as a library-level supplement/fallback for missing or partial inventory, preserve per-interface rate calculation, and stop silently truncating the interface list. +- Fix the compiler error in portable uptime collection by using the `systemstat` duration API correctly, and remove the now-unused `Future` import in the H2 server module. +- Remove RustProxy's artificial hostname-count ceilings from certificate SNI bindings, local certificate generation/import validation, ACME domain validation, vhost/redirect/404 hostnames, administration allowed hosts, host ACL policies and Docker aliases. Input validity and bounded control-plane/resource limits remain enforced. +- Replace per-BSD command/parsing statistics code with shared library collectors: `systemstat` for host metrics, `sysinfo::Networks` for network inventory/counters, and `metrics-process` for process metrics, using the documented dummy process fallback on targets without a native collector. +- Preserve Linux `/proc` and cgroup-specific details as additive compatibility metrics while using the common library API for portable host/process values. +- Add portable swap, system uptime, process virtual memory/limit, descriptor limit and process start-time diagnostics where the libraries support them. +- Add authenticated runtime metadata for platform, statistics source, resolved data directory and exact SQLite database path. +- Make local CLI data-directory discovery understand both `/var/lib/rustproxy` and the BSD/OPNsense `/var/db/rustproxy` convention. +- Move Logging into **Settings -> Logging** while retaining legacy `/logging` navigation compatibility. +- Update release identity to 0.24.1. + +## 0.24.0 + +- Replace abrupt downstream H2 idle termination with a GOAWAY-based graceful shutdown that rechecks current activity and waits for accepted streams. +- Add independent H2 idle timeout, maximum connection lifetime and drain timeout settings; lifetime may be disabled with `0`. +- Bound H2 draining so stalled streams cannot keep a retiring connection open indefinitely. +- Keep H2 Extended CONNECT/WebSocket streams under the same active-stream accounting and graceful drain path. +- Extract system statistics into platform backends while preserving Linux metric names, units and behavior; add native FreeBSD/OpenBSD/NetBSD sampling where available and explicit nulls otherwise. +- Add runtime platform to Proxy Process and Prometheus platform info. +- Add Settings UI/API/OpenAPI/language-pack support plus H2 lifecycle/configuration regressions. +- Reorganize **Settings -> HTTP connections** into HTTP/2 and HTTP/3 sub-tabs; move listener-scoped H3/QUIC enablement and limits out of the Listeners editor while preserving restart semantics and the existing listener configuration model. +- Update release identity to 0.24.0. + +## 0.23.3 + +- Make external certificate deployment convergent: create missing manual certificate ids, rotate existing manual ids, no-op identical RustProxy-owned generations, and keep managed ACME ids protected from external ownership. +- Make the OPNsense os-acme-client hook reconcile on every run so RustProxy remains the source of truth and deleted/drifted external certificate entries self-heal. +- Add per-vhost **Enable WebSocket legacy support** for older appliance/embedded WebSocket implementations. +- Force legacy-mode WebSocket upstream handshakes to HTTP/1.1 and force the WebSocket `Host` to the configured backend authority and align `Origin` to the matching backend scheme + authority. +- Tolerate legacy `101 Switching Protocols` response framing while keeping `Sec-WebSocket-Accept` and subprotocol validation strict. +- Add UI/OpenAPI/translations, documentation and regression contracts for the compatibility mode. +- Update package/source/UI identity and release verification contracts to 0.23.3. + +## 0.23.2 + +- Add selectable 60/120/300/600-sample history windows to the Cache and Transformation live charts, with bounded per-workspace browser persistence. +- Fix `rust-proxy --livelogs` on HTTPS administration listeners by detecting the HTTP 308 redirect and reconnecting over TLS instead of looping. +- Pin local CLI HTTPS to leaf certificates present in the private `data-dir/certs` directory and reuse the HTTP/HTTPS-aware client for config and certificate CLI API calls. +- Update package/source/UI identity and release verification contracts to 0.23.2. + +## 0.23.1 + +- Add default-collapsed, full-width live activity charts to the Cache and Transform workspaces. +- Plot Cache HIT/MISS/BYPASS rates for the selected scope using deltas from the existing cache runtime counters. +- Plot Transform applied/cache-HIT/skipped/error rates for the selected scope using the existing transform metrics endpoint. +- Reuse the administration dashboard SVG/grid/legend visual language, keep the existing 3-second live refresh, and add English/Polish language-pack labels. +- Update package/source/UI identity and release verification contracts to 0.23.1. + +## 0.23.0 + +- Bound inactive inbound HTTP/2 connections to five minutes so idle TLS/H2 sessions cannot accumulate indefinitely toward the process connection limit; export `rust_proxy_http2_active_connections` for direct diagnosis. +- Account hot-cache response-body backing allocation capacity rather than only visible body length, preventing `BytesMut` growth slack from bypassing the configured RAM budget and under-reporting resident cache memory. +- Fix the `--livelogs` stdin reader to send owned `String` values from the match body rather than a pattern guard, resolving Rust `E0507` during binary compilation. +- Add a scoped external Certificate API credential managed from a new **Settings → Certificate API** tab. RustProxy stores only the token hash plus a non-secret Key ID; the cleartext token is returned only on rotation. +- Allow the scoped Key ID + Bearer Token only on `POST /api/certificates/deploy` and `POST /api/certificates/reload`, require HTTPS for non-loopback token use, track last use, support immediate revoke, and retain normal administrator-session/CSRF access as an alternative. +- Keep production certificate operations available directly from the installed binary (`rust-proxy tls deploy` / `rust-proxy tls reload`) without requiring `dev.sh`. +- Add `rust-proxy --livelogs`: an interactive varnishlog-style all-vhost live access stream with structured filters (`host`, `vhost`, `backend`, `route`, `client`, `method`, `transport`, `cache`, status classes, path prefix, errors and slow requests), pause/resume, per-request JSON inspection and detailed error/WebSocket/H3/cache fields. +- Back live logs with a bounded non-blocking broadcast stream and authenticated SSE endpoint; slow consumers receive an explicit `gap` event with the dropped-entry count instead of silent loss. +- Split the large **Settings → Cache** form into **General**, **Resilience**, **Cold cache**, and **Static & diagnostics** sub-tabs while retaining one atomic cache-policy save. +- Add SQLite schema v5 for the scoped certificate credential, OpenAPI/security schemes, UI translations, audit events and 0.23.0 regression/release documentation. + +## 0.22.3 + +- Add downstream HTTP/1.1 `Upgrade: h2c` on listeners that explicitly enable cleartext HTTP/2, preserving the existing prior-knowledge path and rejecting malformed/body-bearing upgrade attempts before protocol switch. +- Reconstruct the accepted H1 upgrade request as HTTP/2 stream 1 and hand subsequent framing/streams to the existing Hyper H2 server path; include raw-TCP interoperability regression source. +- Add authenticated `POST /api/certificates/reload` and `tls reload` to re-read configured public certificate/key files and hot-publish a fresh TLS/H2/H3 snapshot without changing the desired configuration revision. +- Add `tls deploy --id ... --cert ... --key ...` and authenticated `POST /api/certificates/deploy` for externally issued local certificates such as Certbot. The CLI performs bounded local reads while the running service owns pair/SAN validation, immutable generation staging, revision-CAS publication and rollback on failure, avoiding root-owned staged PEMs when Certbot hooks run as root. +- Keep all vhosts assigned to one manual certificate id synchronized during external rotation; reject RustProxy-managed ACME ids to avoid dual renewal ownership and report the administration-TLS restart caveat explicitly. +- Add Certificates-panel reload UX, OpenAPI contracts, audit events, translations and 0.22.3 audit/test documentation. + +## 0.22.2 + +- Fix the 0.22.2 backend diagnostics build failure by replacing the oversized recursive `serde_json::json!` member payload with explicit `serde_json::Map` construction; the administration JSON schema is unchanged. +- Add bounded replay-safe backend failover retry for body-free GET/HEAD/OPTIONS requests, including retry policy controls, passive-health integration, logs and per-backend retry counters. +- Correct ordinary explicit-HTTP/2 upstream requests and add a real Hyper h2c prior-knowledge regression path. +- Add per-backend request-concurrency limits with bounded capacity waiting, `503` rejection and capacity/queue-timeout metrics. This limits full proxy exchanges, not TCP socket count. +- Add detailed upstream failure classes, last failure detail/timestamp, and backend health ejection/recovery timestamps to runtime diagnostics and Prometheus. +- Expand the backend administration table with concurrency capacity, last failure and last recovery columns. +- Parse public X.509 leaf metadata for issuer, SANs, validity, remaining days and expiry warning state, and surface ACME renewal status/last attempt/error in certificate inventory. +- Extend OpenAPI, language packs, source regressions, audit/TODO and release documentation for the expanded 0.22.2 scope. + +## 0.22.1 + +- Vhost UX: replaced separate WebSocket/H2 RFC 8441/H3 RFC 9220 checkboxes with one transport selector; H2/H3 modes keep HTTP/1.1 Upgrade as an explicit fallback and unavailable transports are disabled from listener capabilities. + +- UI polish: replaced the overview TLS listener tile with compact HTTP protocol and WebSocket protocol mini charts; Target and Cache key/tags cells remain copyable with full values. + +- Keep the overview metrics in one shared stats grid so the WebSocket/H2/H3/TLS row uses exactly the same card geometry as the primary metrics, without a transport-specific dashboard layout. Make Cache **Target** and **Cache key / tags** cells copy their full untruncated value on click/keyboard activation. +- Polish the 0.22.1 administration UI: move **Active tunnels** into the WebSocket dashboard card, flatten the H1/H2/H3 tunnel breakdown to match the standard transport-card styling, and align Cache modal close actions with the vhost modal header pattern. +- Fix Rust ownership in tunnel registration by releasing the client-IP mutex guard before moving the shared exchange into the active tunnel, and remove an unnecessary mutable H2 upstream reader binding. +- Move selective cache PURGE/BAN and the active BAN list into dedicated Cache modals, keep their launch actions beside **Purge selected scope**, and use null-safe inline operation feedback consistent with login errors. +- Add opt-in RFC 8441 WebSocket Extended CONNECT for HTTP/2, with per-vhost controls beside RFC 9220, listener-scoped SETTINGS advertisement, H2-to-H1 bridging, explicit H2 upstream forwarding, cancellation/idle timeout and transport-specific metrics. +- Add opt-in cleartext HTTP/2 prior-knowledge (`h2c`) listeners. Legacy HTTP/1.1 `Upgrade: h2c` remains deliberately unsupported because the current Hyper integration cannot safely reconstruct the required HTTP/2 stream 1 bootstrap. +- Add stable active-tunnel IDs, authenticated tunnel inventory/kill API, operator-supplied close reasons, audit records, RX/TX/duration metadata and an administration modal. +- Consolidate active WebSocket/tunnel visibility on the main dashboard as total plus H1 / H2 RFC 8441 / H3 RFC 9220 counters while retaining ordinary H2/H3 request statistics separately. +- Preserve `Accept-Ranges` from origins and synthesize it only for RustProxy-generated cached range responses; transformed/compressed representations continue to remove incompatible range advertising. +- Update source/package/UI/OpenAPI/Debian/Grafana/tooling identity to 0.22.1. + + +## 0.22.0 + +- Fix a Rust ownership regression in the upstream request path: retain a cloned origin URI for proactive refresh after the request URI is assigned. +- Add per-cache-key request collapsing for foreground MISS/fill traffic with bounded wait, configurable timeout fallback, cancellation-safe leader release and collapse metrics. +- Add single `Range: bytes=...` handling from complete cached objects, including `206`, `416`, `Content-Range`, `Accept-Ranges` and `If-Range`; multipart/unsupported ranges remain origin pass-through and are not cached as separate objects. +- Extend stale-if-error with configurable transport failure classes and an explicit 5xx status allowlist while preserving the previous default behavior. +- Add bounded proactive cache refresh with pre-expiry scheduling, deterministic sampling, concurrency limits and reuse of the existing validator revalidation path. +- Add selective cache PURGE by exact URL, prefix, tag, regex or cache key, including query/Vary handling, RAM + Cold Cache invalidation and a publication barrier preventing pre-PURGE fills from republishing stale data. +- Add lazy BAN rules with lookup-time enforcement, bounded rule count/TTL, incremental garbage collection, rule deletion and metrics. +- Add authenticated administration API and Cache panel controls for PURGE/BAN, broad-operation confirmation, per-object Purge URL actions, BAN listing/removal and audit entries. +- Extend cache diagnostics, OpenAPI, translations and regressions for the 0.22 cache behavior. +- Update package/source/UI/OpenAPI/Debian/Grafana/tooling identity to 0.22.0. +- Preserve the resolved dependency graph; compiler/live acceptance remains an explicit release-host gate when Cargo/Rust are unavailable in the packaging workspace. + +## 0.21.0 + +- Fix browser refresh on the dedicated **Transform** route by serving the dashboard document at `/transform` and covering it in the panel-route regression. +- Align the Cache **Transformed** marker with the dashboard geometry by replacing the capsule outline with the standard badge border and a 3px corner radius. +- Give the Live traffic **Transformed** series a dedicated violet chart token in dark/light themes so it remains distinct from HTTP/2 green. +- Add a dedicated Transform administration workspace with global/per-vhost runtime counters for applied/skipped/error transformations, transformed cache HITs, byte savings, reduction, transform time, kind breakdown and bounded skip reasons. +- Add a transformed-response series to **Live traffic** (fresh transformations plus transformed cache HITs) and render **Core state** uptime in compact human-readable units. +- Rename the proxy-owned backend timing diagnostic to `X-RustProxy-Backend-Time`; the legacy lookalike `X-Rust-Proxy-Backend-Time` is stripped but is no longer emitted. +- Fix Docker-vhost Passive Backend Health checkbox sizing so enabling the expanded editor cannot make fields overlap. +- Remove the unused mutable `origin` binding from the background revalidation path. +- Replace the monolithic `src/cache.rs` with the responsibility-based `src/cache/` module (`model`, lifecycle, cold storage, dashboard, tickets, lookup, policy, fill and tests) and update source/package gates for the new layout. +- Fix the cache split so method groups are emitted from module-scope `include!` files wrapped in their own `impl Cache` blocks; this avoids the Rust compiler error caused by `include!` directly in impl-item position. +- Persist transformed-representation metadata through hot/Cold Cache, mark transformed objects in the Cache dashboard, and append `transformed` to standardized RFC 9211 `Cache-Status` diagnostics; origin `X-Cache-Status` lookalikes are stripped rather than emitted. + +- Shard Cold Cache storage and add bounded asynchronous garbage collection plus selective manual cleanup. +- Add RFC 5861 stale windows, request `max-stale`, age-aware request `max-age=N`, and correct `must-revalidate` / `proxy-revalidate` behavior while preserving `s-maxage` priority. +- Harden transformed cache fills so transformed Content-Length is authoritative, incomplete streams cannot publish partial objects, and canonical transformed identity representations do not fragment on origin `Vary: Accept-Encoding`. +- Add the static-response transformation pipeline before cache/client compression, with strict `no-transform`, separate origin/representation validators and JS/CSS plus optional conservative HTML/SVG and parsed JSON transforms. +- Keep route cache overrides tri-state and add effective route/cache/transform diagnostics. +- Make Passive Backend Health explicit opt-in (default OFF) for ordinary and Docker vhosts, independent from active TCP/HTTP health, with per-error-class policy, separate counters and persisted Docker assignment settings. +- Extend administration UI/OpenAPI and regression coverage for Cold Cache cleanup, transform settings, passive health and cache semantics. +- Keep release/package/UI/OpenAPI/Debian/Grafana/tooling identity at 0.21.0. +- Close the 0.21.0 feature scope for release packaging. Parser-backed JS/CSS minifiers are deferred; compiler/live acceptance still has to be executed on a Rust-enabled release host. + +## 0.20.0 + +- Rebuild **Certificates** into Installed / Renewal / Generate tabs using the same accessible keyboard tab pattern as Settings. +- Keep certificate issuance, PEM import and existing-file registration inline in the Generate tab; successful imports and edits return to Installed. +- Add explicit ACME operation state cards and include the exact private Certbot log directory in failed issuance/renewal messages. +- Expose per-managed-certificate Certbot log directories from the authenticated ACME status endpoint so persisted renewal failures remain actionable after refresh/restart. +- Rework renewal entries into readable cards with provider/environment, last check, next check, expiry and grouped actions. +- Update release/package/UI/OpenAPI/Debian/Grafana/tooling identity to 0.20.0. + +## 0.19.4 + +- Align the Docker-vhost Aliases editor with the standard 38px Docker form controls and remove the inherited `host-chip-field` label margin that created an extra visual line below `Aliases`. +- Preserve backend traffic display semantics: idle renders as `0.0 KiB/s`, and positive rates below 0.1 KiB/s render as `<0.1 KiB/s` in both backend rows and aggregate vhost traffic. +- Add a presentation filter to **Cache -> Domains and effectiveness** with All / Cache enabled / Cache disabled choices, using each vhost's resolved cache settings while leaving aggregate statistics unchanged. +- Update release/package/UI/OpenAPI/Debian/Grafana/tooling identity to 0.19.4. + +## 0.19.3 + +- Fix the compiler error in the release checker body collector by converting Hyper/http-body-util boxed collection errors explicitly into `anyhow::Error` instead of calling `anyhow::Context` on an unsupported boxed error type. +- Simplify the dashboard footer to API latency and dropped-log count, and add a fail-soft built-in release checker using embedded `UPDATE_CHECK.toml` defaults. The checker reads the official Cargo manifest immediately and then hourly, exposes a newer-version link in the footer, and never performs automatic upgrades. +- Allow runtime release-check overrides through `--update-repository-url`, `--update-version-url` and `--update-check-interval-secs` (plus matching `RUST_PROXY_*` environment variables); interval `0` disables checks. +- Fix the remaining oversized Live Logs column checkboxes by excluding checkbox/radio inputs from both broad `.log-tools input` text-field sizing blocks that were overriding the shared 15px checkbox rules. +- Add an account-scoped light/dark administration theme stored in the existing SQLite `admin_preferences` document; dark remains the default and the last authenticated choice is cached locally only to avoid login/dashboard first-paint theme flicker. +- Refactor theme-sensitive UI colors into shared CSS variables and keep checkbox/radio controls out of generic text-input sizing/density rules. +- Rework the Live Logs column picker into a compact scrollbar-free list with standard-size checkboxes; short viewports use two columns instead of an internal scroll area. +- Keep expired-session re-authentication on the exact panel and Settings sub-tab, use the minimal public login document without flashing the authenticated shell, and show embedded login errors inline instead of as global toasts. +- Canonicalize the legacy `/configuration` shortcut to `/settings?tab=manual-config` and keep later Settings tab switches in the URL so F5 restores the exact page. +- Add one shared saving-state helper for settings/security/certificate forms: disable the active submit button, show `Saving…`, restore its translated label, and prevent duplicate submissions while a save is in flight. +- Highlight and focus the actual invalid control for local validation failures in Logging JSON, named listeners, Docker-agent listener selection, TLS policy, ACL profiles, manual JSON and certificate forms. +- Preserve selected certificate/key files after a failed certificate import/save so the administrator can correct the problem without reselecting both files. +- Mark Settings tabs with a subtle unsaved-change dot, including Manual config, and keep dirty values from being overwritten by live config refreshes. +- Update release/package/UI/OpenAPI/Debian/Grafana/tooling identity to 0.19.3. + +## 0.19.2 + +- Restore the vhost local-listener risk control for administration backends when the administration socket is startup-locked or otherwise reported only by live status; treat `localhost`, `127.0.0.0/8` and `::1` as equivalent loopback forms for the same administration port. +- Make **Discard changes** reload the persisted vhost being edited instead of replacing the editor with a new blank/default vhost. +- Add descriptive HTTP reason phrases to the default-response status selectors and headings. +- Add a temporary, continuously updated 4096-entry Live-log buffer that resets when the log window closes. +- Align inline checkbox labels and compact Docker-vhost alias controls. +- Stop backend traffic EWMA tails on an idle sampling window, show idle consistently as `0.0 KiB/s`, and render positive sub-0.1 KiB/s traffic as `<0.1 KiB/s` instead of a rounded zero. +- Make the Live Logs column picker substantially smaller with subtle 11px checkboxes; let the menu escape the log-dialog clipping boundary and open upward on short viewports. +- Default newly created vhosts to the standard HTTP `:80` listener when available; HTTPS listeners are selected explicitly. +- Left-align the Settings -> Docker agents save action to match the other settings forms. +- Suppress dashboard/login first-paint flicker and preserve the active Settings sub-tab across F5, including a re-login redirect back to `/settings?tab=...` and `/docker-vhosts`. +- Replace native browser confirmations with application-styled confirmation dialogs and add panel-area context to API error messages. +- Present login failures as an inline styled error below the form, including invalid-credential responses. +- Install a login-shell PATH profile for the managed `current` release directory on first install; updates check and warn about the profile without changing it. +- Enforce `no-store` and suppress cache diagnostics/validators for local placeholder/static/default pages. +- Allow vhost-ID renames with duplicate protection, confirmation and an explicit runtime-identity warning. +- Update source, UI, OpenAPI, regressions and package identity to 0.19.2. + +## 0.19.1 + +- Fix Default responses section saves by passing the normal `{expected_revision, value}` payload to `/api/config/pages` and add server-side `pages` section handling/regression coverage. +- Add frontend scheme to the primary cache hash so otherwise identical HTTP and HTTPS requests cannot reuse one object. +- Retain 4096 completed access-log entries while keeping the 256-entry live window; add one-shot on-demand history loading and document the API limit. +- Render backend traffic with KiB/s as the minimum unit, simplify the Live Logs column picker, align generic checkboxes and move Docker-vhost Advanced switches beside Backend protocol on desktop; remove the redundant alias help line. +- Remove stale simulated dashboard screenshots, retain only two explicitly labelled legacy references and add a documentation index for historical/version-labelled material. +- Restore the integration-test sources and independent Argon2id fixture required by release/source checks so compiler-enabled test builds no longer reference files missing from the supplied source archive. +- Update source, UI, OpenAPI, regressions and package identity to 0.19.1. + +## 0.19.0 + +- Persist per-administrator Live Logs column visibility in SQLite schema v4 without changing the revisioned proxy configuration; JSON export remains complete regardless of visible columns. +- Keep the Live Logs column selector visually consistent with the existing compact `check vh-check` controls instead of rendering oversized standalone checkbox widgets. +- Add authenticated `GET/PUT /api/preferences` endpoints for dashboard-only account preferences, protected by the existing session/CSRF control-plane guards. +- Remove the duplicate version badge from the top-right dashboard toolbar while keeping the footer release identity. +- Retain the full disabled-verification legacy X.509 fix and expanded upstream connector diagnostics introduced during the 0.18.8 follow-up. +- Update release/package identity to 0.19.0. + +## 0.18.8 + +- Make backend `tls_verify=false` a complete certificate-authentication bypass instead of delegating TLS CertificateVerify to webpki. This restores compatibility with legacy appliance certificates such as X.509 v1 while keeping TLS encryption and all backend address/DNS/loop protections in force; `tls_verify=true` is unchanged. +- Expand upstream connection diagnostics with the full nested Hyper/rustls/io error chain, backend/TLS context, 2 KiB UTF-8-safe detail storage and warning-level journal entries for connect failures. +- Replace Docker-vhost alias comma input with hostname-style chips for add/remove/paste workflows. +- Add per-Docker-vhost HTTP-to-HTTPS redirect, WebSocket and cache controls; new compatible assignments default HTTP-to-HTTPS redirect on while legacy assignments retain their prior redirect-off behavior until explicitly saved. +- Align Docker-vhost boolean controls with the application's standard checkbox classes and keep checkbox inputs from inheriting full-width text-input sizing. +- Add a Docker-agent default ACL-profile selector. Generated Docker vhosts inherit it unless the application selects its own ACL profile; existing per-app inline ACL configuration is unchanged. +- Remove the compression-codec negotiation explainer from the Overview dashboard. +- Update release/package identity to 0.18.8. + +## 0.18.7 + +- Collapse **Response time history** by default on the Overview dashboard while keeping live sampling active. +- Restyle per-interface RX/TX histories to use the same chart geometry and visual treatment as the aggregate RX/TX history, without nested panel backgrounds. +- Align expanded Docker-vhost routing and ACL controls with uniform field blocks; Preserve Host and per-app ACL switches now occupy the same label/control rhythm as neighboring inputs, and ACL selects no longer stretch to textarea height. +- Update release/package identity to 0.18.7. + +## 0.18.6 + +- Add bounded per-interface RX/TX diagnostics from `/proc/net/dev` to the administration status payload and render separate live interface histories without adding per-interface Prometheus labels. +- Strengthen compression negotiation regressions with explicit `q=` preference cases for gzip, Brotli, Zstd and deflate; clarify that dashboard counters track the codec actually selected per response. +- Align Docker-agent settings inputs/actions, keep Enabled/status/remove controls on one stable row, and add spacing between listener selection and the Add Docker agent action. +- Update release/package identity to 0.18.6. + +## 0.18.5 + +- Make compression accounting follow an explicit RustProxy-applied codec marker instead of re-reading `Content-Encoding`; add independent gzip/br/zstd/deflate counter regression coverage. +- Add an explicit HTTP/1.1 request counter and render total + HTTP/1.1 + HTTP/2 + HTTP/3 request-rate series on the Overview chart. +- Add an Open app action to published Docker vhosts, using the assigned frontend listener and expanded proxied hostname. +- Label Docker-generated pools in the Backends view with their primary proxied domain. +- Remove the global Open proxy shortcut from the page header. +- Make Compact density materially tighter than Dense across cards, controls, tables, Docker rows and charts. +- Default vhost/Docker proxy timeout controls to explicit 10/120/60/60-second connect/header/read/send limits instead of blank inherited values. +- Replace free-form Docker-agent listener-ID entry with the configured named-listener selector; move Enabled left of the centered connection-status badge. +- Update release/package identity to 0.18.5. + +## 0.18.4 + +- Default backend health checks to TCP for newly created or otherwise defaulted ordinary and Docker vhosts; explicit existing HTTP/off health modes are preserved. +- Replace the dense Docker-vhost service editor with summary-first rows. Publish state, service identity, backend runtime health and Hide/Show stay visible; routing, ACL, timeouts and health tuning are expanded only for the selected service. +- Collapse secondary Docker-agent inventory metadata behind Agent details while keeping host, service count, last observation and base domain immediately visible. +- Rework Settings -> Security into focused account/session and host-policy cards with on-demand password and policy editors. +- Move Administration audit into a dedicated modal and stop fetching audit entries merely by opening the Security settings tab. +- Update release/package identity and EN/PL UI copy to 0.18.4. + +## 0.18.3 + +- Add Docker-vhost search plus persistent per-browser Hide/Show Hidden view controls, and allow each Docker agent to select a configured frontend certificate inherited by its generated vhosts. +- Keep Docker inventory polling driven by each agent's configured 5..3600 second interval; the 5-second dashboard refresh reads cached observations only. +- Move redirect HTTP/2 and HTTP/3 toggles below listener selection and keep Cache `Updated` + `Live` together at the right side of the toolbar. +- Remove the unused `config::yes` helper warning. +- Add an Open-in-browser action beside each vhost Enabled state; wildcard-only hosts open the wildcard base domain, while mixed host lists prefer the first non-wildcard name. +- Restore the documented HTTP/1.1 backend default for newly created ordinary and Docker vhosts: preserve the public `Host`; existing explicit backend settings are not rewritten. +- Write HTTP/1 upstream header names in conventional title case for compatibility with legacy/custom origins that incorrectly treat header names as case-sensitive; HTTP/2 is unaffected. +- Add bounded `upstream_error` diagnostics to access logs/live logs so TCP/TLS/HTTP-client failures that previously appeared only as 503 with no upstream status can be distinguished. +- Restrict local/error response selectors to supported error statuses; application-style 404 hosts offer only bundled 401/403/404/429/503/504 templates, including a new dedicated 404 template. +- Make Docker Publish/Unpublish an immediate per-service configuration action, add per-service Apply, and replace unconditional UI rerendering with an operator-controlled Live view that reads only RustProxy's in-memory state. +- Add per-agent Docker inventory polling intervals (5..3600 seconds), while manual refresh still polls immediately. +- Add reusable ACL-profile plus inline IP/CIDR ACL controls per Docker application, and protect referenced profiles from deletion. +- Add per-vhost proxy connect/response-header/read/send timeout controls for ordinary and Docker-backed vhosts. +- Print the Docker-agent bearer token after installation alongside the certificate fingerprint. +- Improve 404 Hosts response UX with supported error-status selectors, application-style built-in status pages, cache-diagnostic suppression and protocol toggles beside listener selection. +- Add optional per-vhost JSONL access-log files under a configurable directory while retaining the combined-file mode, Logstash and Loki sinks. +- Lock HTTP/HTTPS/admin listener addresses that are supplied explicitly by systemd/CLI/environment so the control plane cannot remove, disable or rewrite process-owned binds; listeners not supplied at startup remain editable. +- Remove the separate remote-admin risk-acknowledgement requirement; plain remote administration now requires only `--allow-remote-admin`, while legacy `--accept-remote-admin-risk` is accepted as a hidden no-op so older systemd units still start. +- Tighten Compact/Dense dashboard density, rename the Shared RAM cache surface to **Cache**, expose redirect targets directly on cards, and keep 404 Hosts aligned with the application error-code set. +- Clarify that TLS Policy is inherited globally and show currently effective cipher suites alongside all available rustls suites. +- Treat ACME-issued certificates as managed/read-only in the custom-file editor, require certificates to be unassigned (including the administration panel/default binding) before deletion, and default new ACME issuance to the production CA environment. +- Make classic HTTP/1.1 Upgrade the default WebSocket path; RFC 9220 Extended CONNECT over HTTP/3 is now an explicit experimental per-vhost opt-in and is advertised only on QUIC listeners with an opted-in vhost. +- Split Docker discovery/vhost observability into dedicated `rust_proxy_docker_vhost_*` and `rust_proxy_docker_backend_*` Prometheus metric families; Docker pools no longer appear in ordinary vhost/backend series. +- Add a separate live **Docker vhosts** Prometheus collector group, enabled by default, with configured agent/assignment/domain gauges plus Docker vhost traffic/status/duration and full backend health/probe/runtime metrics. +- Extend Prometheus/OpenAPI/UI documentation and regression coverage for the Docker metric boundary. +- Extend the bundled Grafana operations dashboard with a dedicated Docker-vhost section and per-agent/service filters using only the separate Docker metric families. +- Fix Docker discovery compilation against the locked HTTP/body stack by mapping limited-body collection errors explicitly and logging `ApiError` via its `Debug` representation. + +## 0.18.2 + +- Secure Docker-agent transport by default with a persistent self-signed ECDSA certificate and SHA-256 certificate pinning in RustProxy; public-CA HTTPS remains available without a pin and plain HTTP is compatibility-only. +- Add certificate lifecycle helpers, hardened systemd state storage, installer fingerprint output and TLS regression tests for the standalone Docker agent. +- Enable per-Docker-vhost HTTP/TCP backend health checks using the same bounded backend-pool health machinery as ordinary vhosts, with live checking/up/down, latency, probe, request, active and error diagnostics in Docker vhosts. +- Rework Docker-agent and Docker-vhost UX around connection security, last observation, detected addresses, route controls and collapsible health-check tuning while retaining per-agent domains, aliases and per-service IP selection. + +## 0.18.1 + +- Extend the Docker agent inventory with all usable host addresses (interfaces, routed address, public lookup and optional advertised address) and preserve every published binding per service. +- Add per-service Docker backend-IP selection, per-agent default backend IP, arbitrary FQDNs and multiple aliases while retaining short-name expansion below each agent's own base domain. +- Support many Docker agents with independent domains such as `docker1.lab.local` and `docker2.lab.local`, plus operator-controlled custom domains. +- Remove all special `X-Rust-Proxy-Revision` response-header behavior; the name is no longer inserted, stripped, protected or accepted as a legacy diagnostic setting. +- Keep cache-Live placement, production password recovery and Settings navigation improvements from 0.18.0. + +## 0.18.0 + +- Add a standalone Go Docker discovery agent with a hardened systemd unit, token authentication, published TCP-port inventory and host/public-IP detection. +- Add per-agent Docker configuration, in-memory observations and dynamic Docker vhosts with operator-controlled service/subdomain assignments. +- Add Docker vhost/admin UI and OpenAPI endpoints for cached observations and explicit refresh. +- Move the cache Live toggle to the action-toolbar edge. +- Remove configurable `X-Rust-Proxy-Revision` response exposure and always strip the header. +- Use the installed `rust-proxy admin reset-password` command in production recovery guidance. +- Update EN/PL strings, documentation, release checks and package identity to 0.18.0. + +## 0.17.0 + +- Add Debian packaging assets, a hardened systemd unit, `rustproxy` service user/state/log directories, three-day maximum-gzip log rotation, local `.deb` build tooling and signed APT-repository generation suitable for `rustproxy.linuxiarz.pl`. +- Expand Prometheus coverage across all current bounded process/OS diagnostics plus GeoIP, certificate renewal, backend health-probe detail, stream, logging and frontend inventory metrics; add explicit token-free scraping for trusted actual-peer CIDRs. +- Add a Grafana operations dashboard under `grafana/`. +- Add dedicated listener/certificate-aware local-response / 404 hosts with built-in placeholder, empty/status-only and custom-HTML modes. +- Keep the default `RustProxy is running` placeholder uncacheable and intentionally free of optional proxy/cache/backend/diagnostic/security/compression headers. +- Reuse vhost-style hostname chips for Redirect source domains. +- Update OpenAPI, EN/PL UI, release checks and package identity to 0.17.0. + +## 0.16.1 + +- Remove internal configuration revision numbers from successful save feedback in dashboard editors/manual configuration while retaining revision-based CAS and stale-write protection internally. +- Update source, UI, OpenAPI, release tooling and packaging identity to 0.16.1. + +## 0.16.0 + +- Decouple Custom locations from the main vhost backend fallback. Unmatched paths now use the main pool even when custom rules exist; untouched legacy synthetic root routes are normalized automatically. +- Fix same-process public administration login by allowing `rustproxy_session` across only a guarded HTTPS self-admin backend that directly targets the configured administration listener and whose public host is explicitly allowed. +- Make RFC 9220 bootstrap wait for the backend HTTP/1.1 upgrade before downstream H3 success, and extend access logging with transport/WebSocket phase/error/reset/upstream status diagnostics. +- Add cache transient-memory gauges and procfs RSS/data/swap breakdowns to the dashboard and Prometheus. Existing allocator trimming/release behavior is unchanged; no new transient-memory throttle is introduced. +- Reorganize the Vhost editor around clearer backend cards, Authentication subcards and compact protocol controls. New vhosts select standard HTTP :80 plus HTTPS :443 together when available; Redirects reuse the same listener selector; disabled vhost cache hides its subordinate controls and new vhost drafts default cache to off. +- Show the local-listener risk acceptance only when the entered backend URL directly targets the configured administration listener. Add protected opt-in `X-RustProxy-Backend` and `X-RustProxy-Backend-Time` response diagnostics for selected member identity and origin response-header latency. +- Bump source, UI, OpenAPI and packaging identity to 0.16.0. + +## 0.15.1 + +- Fix Custom locations EN/PL translation loading by keeping all UI strings inside the language-pack `translations` map and add a regression check that rejects stray top-level translation keys. +- Make visual header rules easier to edit with numbered cards, clear field grouping, empty-state guidance and localized help. +- Add a live per-location behavior preview for matching, action, rewrite, cache, ACL and header rules, and reorganize the inline documentation with common examples. +- Add one live three-series HIT/MISS/BYPASS activity chart to the RAM cache card and CPU/RSS history charts to the Proxy process card. +- Extend offline source/release checks for the new location UX and dashboard charts; package identity is 0.15.1. + +## 0.15.0 + +- Expand vhost Custom locations with Nginx-style exact/prefix/`^~`/regex/regex-insensitive matching, upstream path preserve/strip/replace/regex rewrites and backend-free custom responses. +- Replace the location header JSON-only workflow with a visual request/response header builder, keep Advanced JSON as an escape hatch, add in-panel documentation and reorganize the location editor into compact functional sections. +- Add `nagios/` API checks for administration response time, unavailable/checking backends, backend latency and dropped logging/cold-cache work, with reusable protected session cookies and Nagios performance data. +- Move per-vhost `Server-Timing` and verified Cloudflare client-IP diagnostics into the main frontend/details configuration. +- Add compact dashboard history charts for RAM-cache residency, network RX/TX and mean response latency, plus administration API RTT and low-priority queue/drop information in the footer. +- Add independent cold-cache age policy controls: demote by time since last access, by total hot-tier residency, by LRU pressure and on graceful shutdown. +- Add verified effective client/source IP to live logs and logging field mappings; early responses still record at least the transport peer. +- Remove the runtime legacy renewal-observation JSON importer. Stopped source upgrades remove the obsolete sidecar as the final upgrade step without importing it or retaining a second legacy copy; SQLite remains the only live renewal-observation store. +- Normalize repeated literal path separators for routing/ACL decisions, matching Nginx `merge_slashes` behavior, while preserving the original request target to the backend when no rewrite is configured. URLs such as `/static/js//events.js?v=...` are therefore accepted without weakening encoded-separator/dot-segment protections. +- Bump source, panel, OpenAPI, upgrade tooling and package identity to 0.15.0 and extend offline regression contracts for the new controls. + +## 0.14.0 + +- Fix administration reverse-proxy Host/Origin handling: explicit DNS allowlist entries describe the external authority instead of the private admin socket port; direct IP/localhost access remains bound to the actual admin port. +- Trust forwarded administration host/scheme only from loopback or configured trusted proxy peers, use the external scheme for Origin/CSRF checks, and set/clear Secure session cookies consistently behind HTTPS termination. +- Implement RFC 9220 WebSocket over HTTP/3 Extended CONNECT, including `SETTINGS_ENABLE_CONNECT_PROTOCOL`, `:protocol=websocket` validation, HTTP/1.1 backend Upgrade translation, downstream 200 bootstrap, bidirectional H3 DATA transport and FIN/reset cancellation handling. +- Replace the separate `h3`/`h3-quinn` pair with `hpx-h3 2.5.19` and its integrated Quinn feature to expose the RFC 9220 protocol extension through the HTTP/3 stack. +- Rename dashboard backend, health, security, error-page and recommended-preset labels in English and Polish. +- Update release/static checks, HTTP/3 documentation and package identity. The source contains no `certificates.json` certificate metadata path. + +## 0.13.2 + +- Fix authenticated dashboard refresh/back-forward session restoration so valid cookies are honored on every panel document route instead of briefly rendering the login shell. +- Make managed software updates force a bounded hot-to-cold cache handoff whenever the cold tier is enabled, independent of the ordinary `cold_on_shutdown` preference, and extend the managed systemd stop budget. +- Persist non-secret automatic-certificate-renewal observations in SQLite schema v3, preload them before administration listeners are exposed, and import the older `certs/renewals.json` sidecar once for upgrade continuity. +- Move the raw full-document editor from primary navigation into **Settings -> Manual config** while retaining `/configuration` as a compatibility browser route. +- Update OpenAPI, EN/PL help, cache/renewal/API documentation, migration/backup checks and release identity for 0.13.2. + +## 0.13.1 + +- Make administrator browser sessions configurable from 7 to 30 days (30 days by default), replacing the previous short browser lifetime/idle behavior while preserving explicit logout/password/revoke invalidation. +- Refresh authenticated cookies only to the remaining absolute server-side expiry so session persistence cannot become an unbounded sliding session. +- Add per-backend `accept_local_listener_risk` with a dashboard confirmation and validation path for deliberate local-listener targets such as `127.0.0.1:9090`; `allow_private` and special-address protections remain independent. +- Update OpenAPI, transport/security/admin-session documentation, EN/PL strings and release regressions for 0.13.1. + +## 0.13.0 + +- Add first-class redirect hosts with dedicated dashboard navigation, named listeners/certificates, exact/prefix and scheme matching, URL/host/path targets, 301/302/303/307/308, path/query policies and redirect cache controls. +- Resolve redirect traffic locally before proxy routes while preserving listener ownership and global host ACLs. +- Change omitted backend `tls_verify` to default false and update UI/API/documentation accordingly. +- Compact TLS certificate cards: show DNS SAN coverage as the same policy-style badges used on vhosts, collapse bindings/files/assigned-vhost details, and inspect older/manual public PEM files when persisted coverage metadata is missing. +- Extend OpenAPI, English/Polish translations, regressions and release tooling for 0.13.0. + +## 0.12.1 + +- Add revisioned per-section configuration PUT endpoints and move ordinary dashboard forms to them, retaining full-document updates only where multiple sections must publish atomically. +- Decouple cache compatibility from the SQLite configuration revision so logging, administration, TLS inventory, metrics and other unrelated saves do not purge hot/cold objects. +- Add explicit dashboard subpage routes plus History API navigation so browser refresh preserves the selected panel. +- Add independent cold-tier policies for idle objects, LRU evictions and graceful shutdown, reduce the cold-write body queue, flush remaining hot objects before process exit, and trim released allocator memory on glibc/Linux. +- Expose certificate DNS SubjectAltName coverage separately from active SNI bindings for uploaded and ACME-managed certificates. +- Preserve secure-by-default backend TLS verification when omitted; keep the explicit debug-origin verification bypass and improve corrective validation/help text. +- Extend regression/OpenAPI/release checks and bump source/package identity to 0.12.1. + +## 0.12.0 + +- Move raw TCP/UDP stream management from Settings to a dedicated **Streams** main-navigation page directly below Vhosts. +- Add vhost-style searchable stream cards and a modal editor with Details, Listener & upstream, and Limits tabs plus an explicit **Add TCP/UDP listener** action. +- Add fixed-cardinality runtime telemetry per active stream: connections/sessions, completed sessions, active sessions, rejects, errors, RX/TX bytes and UDP packet counters. +- Add per-stream live RX/TX rate charts in the dashboard while keeping desired stream changes visibly pending until the process restarts and the listener is actually active. +- Change named HTTP/HTTPS listener bind-address editing from a textarea to chip-style addresses matching the Vhost hostname interaction, preserving existing multi-address validation. +- Keep stream forwarding as raw Layer 4 with no HTTP vhost, ACL, cache, headers, compression or authentication envelope. +- Extend authenticated-asset and offline release/source regressions for the new Streams page and telemetry contracts; bump Cargo, panel, OpenAPI and upgrade/package identity to 0.12.0. +- Extend the Debian 13+ one-line bootstrap with `auto`/`install`/`update` modes; managed updates build the new source as an unprivileged user and delegate to the existing backup/health-check/rollback update path while preserving saved listeners and private data. + +## 0.11.5 + +- Accept percent-encoded spaces (`%20`) in otherwise unambiguous request paths while preserving rejection of encoded separators, double-decoding hazards, controls and dot-segment ambiguity. +- Avoid the `h3-quinn 0.0.10` `stop_sending()` panic path when an HTTP/3 request body is dropped while a receive read is pending. +- Add raw layer-4 TCP/UDP stream hosts on dedicated listeners with bounded TCP connections / UDP client sessions and restart-bound configuration in the admin panel. +- Add a Debian 13+ source bootstrap that installs build dependencies and Rust, clones/builds the repository and installs the systemd service with the explicitly requested public admin profile. + +- Match Varnish built-in request-method behavior for the shared cache: `GET`/`HEAD` participate in lookup while successful POST/PUT/PATCH/DELETE/OPTIONS/TRACE requests no longer trigger an implicit global purge. +- Replace the conservative per-request-header/backend-member cache identity with a Varnish-style Host + URL primary identity, isolated by RustProxy vhost/config scope, and use response `Vary` as the secondary representation selector. +- Share cached objects across load-balanced members and across GET/HEAD, reducing avoidable MISSes while retaining Cookie/Authorization pass behavior and response privacy guards. +- Preserve `X-Cache-Resident-Age` across backend 304 revalidation and hot/cold tier movement. Cold metadata format 2 stores variant identity and resident age explicitly. +- Add cache regressions for Vary variants/schema changes, Host/URL hashing, explicit ports, HEAD eligibility and resident-age continuity; update release/package/API/UI metadata to 0.11.5. + +## 0.11.4 + +- Fix Force SSL frontend validation and messaging: require an actual non-TLS listener for the incoming redirect, prefer HTTPS port 443 when multiple TLS listeners are selected, and reject only ambiguous non-standard HTTPS target ports. +- Add a visual reusable ACL-profile rule editor with add/remove/action/CIDR rows. Keep bulk paste/import as an advanced path, normalize invisible Unicode whitespace and report malformed rules with their source line. +- When a vhost disables cache, strip and suppress RustProxy `X-Cache`, `X-Cache-Hits`, `Cache-Status`, `X-Cache-Reason` and `X-Cache-Resident-Age` diagnostics for that response. +- Render backend pools belonging to disabled vhosts as muted `Disabled` rows in the main dashboard instead of classifying them as unavailable. +- Add source/offline regression coverage and bump release/package/UI/OpenAPI/update metadata to 0.11.4. Rust compilation/runtime acceptance remains open in this packaging environment. + +## 0.11.3 + +- Add per-vhost control over forwarding the effective client IP to the application. Disabling it removes `Forwarded`, `X-Forwarded-For` and `X-Real-IP` only on the backend request; ACLs, GeoIP, rate limiting and Tinyauth continue to use the real client identity. +- Add a compact Vhost search/filter for vhost IDs, hostnames/wildcards and backend IDs/URLs. +- Fix the invalid `quinn = 0.11.22` manifest requirement to the resolved `0.11.12` and stop `dev.sh` from rewriting a valid checked-in `Cargo.lock`. +- Fix JSONL access logging under the hardened systemd unit by provisioning `/var/log/rustproxy` as a writable service log directory while keeping `ProtectSystem=strict`. +- Add source/offline regression coverage and bump release/package/UI/OpenAPI/update metadata to 0.11.3. Rust compilation/runtime acceptance remains open in this packaging environment. + +## 0.11.2 + +- Fix administration HTTPS access negotiated over HTTP/2. The admin guard now derives the effective authority from H2/H3 `:authority` (via the request URI) when no legacy `Host` header is present. +- Preserve strict HTTP/1.x Host handling and reject conflicting HTTP/2 `Host` versus `:authority` values to avoid ambiguous authority validation. +- Add per-vhost Force SSL beside frontend selection. Cleartext requests receive a non-cacheable HTTP 308 to the same hostname/path/query on the vhost's unique selected TLS port; ambiguous multi-port TLS selections are rejected. +- Map NPM `ssl_forced` (and migrator `--force-https`) to RustProxy `force_ssl`, retaining HTTP+HTTPS listener bindings when available instead of importing forced hosts as TLS-only. +- Add unit/integration regression coverage for HTTP/2 administration authority, `/healthz`, Force SSL redirect construction/validation and NPM `ssl_forced`; bump release/package/UI/OpenAPI/update metadata to 0.11.2. Rust compilation/runtime acceptance remains open in this packaging environment. + +## 0.11.1 + +- Fix release update health checks when the administration panel uses persisted HTTPS or a persisted bind different from the install-time CLI address. The check now resolves the active admin listener/scheme from `config.sqlite3` and honors the admin Host allowlist. +- Add a standard-library-only Nginx Proxy Manager SQLite migrator. It reads `proxy_host`, creates RustProxy vhosts/backends, preserves wildcard names/WebSocket/enabled/H2 intent, uses the RustProxy default certificate for imported TLS hosts unless a named certificate is selected, and keeps unsupported NPM policy/custom-config items as explicit review warnings. +- Make NPM migration dry-run by default, emit `npm-migration-plan.json`, detect hostname conflicts, split source records above RustProxy's 16-host limit and use revisioned authenticated API writes only with explicit `--apply`. +- Add offline regression coverage and bump release/package/UI/OpenAPI/update metadata to 0.11.1. Rust compilation/runtime acceptance remains open in this packaging environment. + +## 0.11.0 + +- Add reusable ACL Profiles with optional IP/CIDR ACL and country allow/block rules. Vhost profile, inline vhost ACL and route ACL enforcement remains cumulative. +- Add an ACL Profiles navigation page and per-vhost profile selection without removing individual vhost policy controls. +- Add chip-style hostname editing and validated leading `*.` wildcard domains. Exact hosts win before the longest matching wildcard on the same listener. +- Add compact browser-session request-rate history charts to vhost cards, with progressive compaction of older samples. +- Add the built-in HTTP 401 page and classify normal WebSocket tunnel EOF/reset/abort/broken-pipe termination after a successful 101 upgrade as `websocket_closed` rather than a body/proxy error. +- Redirect plaintext requests received on the HTTPS administration socket to HTTPS while keeping application content unavailable over plaintext. Permit valid DNS Host values when the HTTPS admin hostname allowlist is empty; explicit allowlists remain strict. +- Extend named listeners to up to 16 explicit local addresses under one protocol policy. Multiple-address configurations reject unspecified `0.0.0.0`/`::` binds. +- Align the Logging field-builder dialog with the Vhost configuration modal and reposition the Configure fields action next to the format preset. +- Make source ZIP generation deterministic and verify byte-identical archives in release smoke tests. +- Bump Cargo, panel, OpenAPI, installer/update metadata and package identity to 0.11.0. Rust compilation/runtime acceptance remains open in this packaging environment. + +## 0.10.0 + +- Add an optional persistent cold cache tier under the private data directory. Hot LRU/idle objects demote through a bounded asynchronous queue; fresh cold hits are SHA-256 checked and promoted back into the bounded RAM tier. +- Add hot/cold object, byte, promotion, demotion, cold-hit, eviction/error and queue-drop observability to the Cache API, panel and Prometheus output. +- Add cold-tier path, byte/object budgets and idle demotion settings; purge/config publication invalidates both tiers and startup rebuilds the same-revision cold index. +- Document browser hard-reload revalidation behavior and add translations for the exact `request_no_cache`, `request_max_age`, `request_min_fresh` and `request_pragma_no_cache` reasons. +- Add structured-log Standard, Nginx-style, ECS-style and Minimal presets plus a visual field picker/renamer with JSON preview; preserve advanced raw JSON mappings. +- Reuse the shared application card component for Logging sinks and remove release-specific fieldset border styling. +- Add baseline multi-stage Docker/OCI packaging with an unprivileged runtime user and persistent data volume. Docker discovery remains deferred. +- Bump Cargo, panel, OpenAPI, installer/update metadata and package identity to 0.10.0. Rust compilation/runtime acceptance remains open in this packaging environment. + +## 0.9.0 + +- Redesign the vhost list as readable cards with listener/protocol/TLS/cache identity and live pool health, request, active, error, latency and traffic metrics. +- Add bounded asynchronous structured access-log sinks for JSONL files, Logstash TCP and Loki HTTP(S), with configurable JSON field mapping/static fields and sink delivery/drop/error counters. +- Expand cache object inspection with body bytes, resident bytes and metadata overhead; add optional `X-Cache-Resident-Age`. +- Add administration HTTPS with either the application-managed default certificate or a selected inventory certificate. Persisted non-loopback administration binds are accepted when HTTPS is enabled. +- Keep loopback HTTP as the default and retain the explicit legacy risk gate for plain remote HTTP. Secure session cookies are marked `Secure` on HTTPS administration. +- Add global TLS 1.2/1.3 and rustls cipher-suite policy plus per-vhost TCP TLS overrides. HTTP/3 vhosts must use the global TLS 1.3 policy. +- Add dedicated Logging navigation and full-width Settings tabs with new Administration and TLS policy pages. +- Complete roadmap items for remote HTTPS administration and `X-Cache-Resident-Age`; bump release identity, OpenAPI and packaging metadata to 0.9.0. +- Rust compilation/runtime acceptance remains unverified in this packaging environment. + +## 0.8.1 + +- Enable the shared RAM cache by default. +- Replace blanket conditional-request bypass with local conditional HITs for fresh `If-None-Match` / `If-Modified-Since` requests. +- Add validator-based backend revalidation: request `no-cache`, freshness constraints and legacy `Pragma: no-cache` can revalidate a stored representation; backend 304 refreshes metadata and reuses the cached body. +- Keep request `no-store` as a hard bypass. Allow response `Cache-Control: no-cache` with ETag/Last-Modified to be stored only for mandatory validation; `no-store` and `private` still forbid shared storage. +- Retain expired validator-bearing objects for a bounded one-hour revalidation keep window without stale delivery. +- Raise the configurable object ceiling from 8 MiB to 1 GiB, still bounded by the global 1 GiB resident budget. +- Grow fill buffers incrementally and serialize very large fills through the existing fill semaphore to avoid preallocating the configured object ceiling. +- Exclude cache-control/Pragma and conditional/precondition headers from representation-key hashing because they govern reuse rather than representation identity. +- Add cache diagnostics and regressions for conditional HIT, revalidation, no-cache/no-store semantics and the 1 GiB configuration ceiling. +- Bump Cargo, panel, OpenAPI, installer/update metadata and package identity to 0.8.1. + +## 0.8.0 + +- Global static-cache defaults now include common image, stylesheet/script, font, media and download extensions, with request Cookie ignored for matching assets by default. The global and per-vhost controls are visible in the cache UI; `Set-Cookie`, `private`/`no-store` and `Vary: Cookie` still prevent shared caching. +- HTTP/2 and HTTP/3 are per-vhost frontend opt-outs next to listener selection. Controls are enabled only when a selected named listener supports the protocol; TLS H2 ALPN is narrowed by SNI per vhost. +- Settings -> Listeners uses normal page scrolling instead of an extra nested scrollbar. + +- Add per-vhost request throttling with graphical request/window/burst controls, country-specific limits and country block/allow-only policies. Return built-in HTML 429/403 responses while preserving custom error-page overrides. +- Download the PDDL `sapics/ip-location-db` user-country IPv4/IPv6 data during first installation over HTTPS and verify publisher SHA-256 checksums; expose load status without exposing client data. +- Verify the installed GeoIP database against current publisher SHA-256 files on every managed update and download only stale/missing assets before downtime. +- Make managed updates regenerate the systemd unit and restore it together with code/private data on failure; preserve and report the original failure reason and add a development-only `--reset-config` recovery path for intentionally incompatible saved vhost/settings documents. +- Add an opt-in per-vhost Cloudflare client-IP mode that accepts `CF-Connecting-IP` only when the direct peer belongs to Cloudflare's published edge CIDRs. +- Add Server-Timing response diagnostics. Disabled per-vhost H3 removes `Alt-Svc` and rejects HTTP/3 requests for that vhost. +- Move vhost listener binding exclusively to named listener selection. Listener addresses are configured under Settings; remove the pre-production inline/global listener compatibility importer and reject the old vhost format instead of migrating it. +- Make health-check mode `off` fully disable active probes and passive health classification instead of quietly learning health from production requests. +- Add a new-origin debugging preset for HTTPS-by-IP origins: default pool/private-origin/HTTP1/preserve-host enabled and backend TLS verification disabled for newly created panel backends, with explicit warnings about the certificate-verification risk and mismatched settings. +- Keep per-vhost inherit/override controls for static-cookie cache behavior while the global defaults cover common static extensions. Cookie is ignored only for the cache key/request bypass decision. +- Fix cache `Age` calculation to retain sub-second response delay and apply RFC 9111 corrected received age before adding response delay. +- Bump Cargo, panel, OpenAPI, installer/update metadata and package identity to 0.8.0. +- Rust compilation/runtime acceptance remains unverified in this packaging environment. + +## 0.7.3 + +- Generate the one-time administrator setup token from the release CLI even before + the first server start; retain the rule that it is invalid after setup completes. +- Add explicit remote-panel opt-in for `0.0.0.0`/`::`, requiring both + `--allow-remote-admin`; keep plain-HTTP risk visible. +- Make Linux/systemd install defaults listen on HTTP 80 and HTTPS 443; add + `--no-https` for explicit HTTP-only installation. +- Add deployment and admin-host regressions and bump release/package/UI/schema + identifiers to 0.7.3. +- Hotfix backend HTTPS-by-IP with optional `tls_server_name`: TLS SNI and hostname + verification may use a DNS name while the validated TCP target stays pinned to + the configured backend URL. +- Add an explicit per-backend `tls_verify` switch, secure by default. Administrators + may turn verification off for an HTTPS origin without weakening DNS/address/self-loop + policy. Add matching 503/504 HTML gateway pages while preserving custom-page overrides. +- Rust compilation/runtime acceptance remains unverified in this packaging environment. + +## 0.7.2 + +- Publish a distinct patch release; keep both previous 0.7.1 deliveries historical. +- Remove the build.rs panic on absent SWAGGER_UI_DOWNLOAD_URL. The asset preparer + defaults to pinned Swagger UI 5.32.11 independently of Cargo config discovery. +- Remove the independently downloading utoipa-swagger-ui adapter and embed the + prepared JS/CSS directly, for debug and release. Existing admin guards stay in place. +- Prepare assets with the existing Python 3.10+ tool requirement, now also needed + for direct Cargo builds. Support absolute local tag ZIPs, optional archive SHA-256, + bounded exact-member reads, validated download caching and retained license notices. +- Resolve build input paths from CARGO_MANIFEST_DIR, track local sources and outputs, + and report missing tools/assets explicitly instead of silently changing UI versions. +- Add executable synthetic-archive regression tests and protected license notices. +- Update release identifiers, packaging/update metadata and migration/build notes. +- Rust compilation and actual Swagger rendering remain unverified in this environment. + +## 0.7.1 + +- Add visual request/response header editing to each custom vhost location, with + set/append/remove, validation, isolation and synchronized optional JSON. +- Split anonymous sign-in from the operational document and module/language bundles. + Remove the login tagline; preserve the original brand and compact square style. +- Reduce public health to `status` and auth discovery to `setup_required` only. + Make the public allowlist exact by method and path; private/unknown requests are + denied before body/query extraction, including method mismatches and aliases. +- Keep Origin/Host/CSRF/session guards; reject duplicate CSRF/marker headers and + hide login/setup JSON parser details. Clear UI data across tabs and on revocation. +- Add authenticated `/docs` and OpenAPI with pinned, build-embedded Swagger UI, + same-session authorization, read-only execution by default and CSRF-confirmed writes. +- Replace updater reliance on a public version with local MainPID/executable checks. +- Add source contracts, browser script regressions, actual-router Rust tests and a + read-only anonymous-access probe. Rust/build/vendor-renderer acceptance not completed. + +## 0.7.0 + +- Renew managed ACME profiles automatically with shared worker bounds, jitter, + backoff, private observations and validated CAS hot deployment; expose opt-out, + check-now and dry-run controls. Keep previous certificates active on failure. +- Add install.sh/update.sh for Linux/systemd, root-owned release switching, + stopped full-data backups and code/data rollback on failed startup/version checks. +- Add per-vhost Basic Auth with Argon2id-only storage, optional verified upstream + credentials and cumulative graphical IP ACLs. Keep protected content out of cache. +- Add WebSocket toggles and graphical custom path locations, including explicit + exclusion of dedicated path origins from normal pool/sticky selections. +- Replace the sample welcome page with neutral information, make the protocol + fixture opt-in, unify square modal and Settings-tab styling, preserve branding. +- Add renewal/deployment helper tests and Rust/browser regressions. Rust compilation, + live CA renewal and real systemd deployment remain unverified in this environment. + + +## 0.6.0 + +- Replace separate legacy routes with vhosts and a one-way, validated compatibility importer; preserve path-pinned origins, ACLs and existing listener scope. +- Add HTTP-01 webroot domain validation, Cloudflare DNS-01 and OVH DNS-01 alongside RFC2136 using private Certbot workers. +- Unify certificate creation/import in a provider-picker modal; record optional issuer metadata in the library and keep secrets outside exported configuration. +- Add an independently configurable Prometheus endpoint with actual-peer CIDRs, optional environment-sourced bearer token, bounded scrapes and collector switches. +- Export application/admin/listener/vhost/backend/cache/system data and full-exchange vhost histograms with bounded configured labels. +- Split Settings into functional switching cards; preserve dirty values, keyboard access and translation discovery; remove Security and legacy routing sidebar entries. +- Add offline provider/browser regression tests and Rust tests for migration, ACME challenge isolation and scrape authorization. Rust compilation/runtime and actual CA/provider integrations remain unverified. +- Preserve the original brand assets. Automatic certificate renewal, Cloudflare Tunnel and Docker discovery remain deferred. + +## 0.5.0 + +- Add a compact dedicated cache panel with vhost/domain counters, object pagination, + TTL/Age/hits, memory/fill diagnostics and authenticated selective purge. +- Fix reserved administrator-cookie ordering and empty terminating DATA frames; + emit fixed cache decision reasons without exposing request secrets. +- Replace special X-Mock-Origin hiding with the explicit global/vhost revision + switch; ordinary response rules still handle arbitrary removable headers. +- Default HTTP/3 on TLS bindings, retain explicit null opt-outs, and explain + overlapping sockets with ownership and concrete protocol/limit differences. +- Return 503 for upstream connection/unavailability, 502 for invalid responses + and 504 for header timeouts; generated errors use no-store. +- Add basic asynchronous Certbot DNS-01/RFC2136 issuance, staging-first consent, + private TSIG/account profiles, output validation and explicit library import. + No automatic renewal scheduler or real CA validation is claimed. +- Add offline helper and UI checks plus Rust regression source. Rust/Cargo remain + unavailable in the packaging environment; compilation/runtime unverified. + + +## 0.4.0 + +- Rebuild standard Forwarded and X-Forwarded metadata at an explicit trusted-proxy boundary; use verified client IP for ACL/affinity, never for destination routing. +- Add per-vhost Tinyauth subrequests, identity header allowlists, safe same-login-origin redirects and fail-closed checks before origin/static/cache/WebSocket use. +- Move certificate upload into an independent inventory; assign certificates by ID per vhost and expose metadata-only inventory reads. ACME is not implemented. +- Add named multiple-IP listeners with descriptive scope, opt-in legacy routes, reusable vhost references and actual/desired plans. +- Add Quinn/h3-based HTTP/3 UDP transport, TLS 1.3/SNI, Alt-Svc, per-endpoint connection and per-connection stream/idle limits, shared proxy policies and bounded shutdown. Preserve TCP HTTP/2 plus HTTP/1.1. +- Hide request-ID/revision headers by default; add individual overrides and optional mock-origin stripping. Apply effective Server policy across all response paths, including extra sockets. +- Partition cache by actual frontend IP/port and safely probe unknown-length empty requests; preserve nonempty body frames instead of discarding them. +- Raise toast above active dialogs without making it inert under the modal; retain the logo, compact UI and discovered language packs. +- Add a loopback-only fake-auth development peer and Rust integration sources for forwarding, auth, extra-socket cache, listener ownership and real QUIC/TCP fallback. +- Execute frontend/helper/static checks documented in TEST_REPORT.md. Rust/Cargo are unavailable: no passing compilation, real proxy run or full RFC-conformance claim. + + +## 0.3.0 + +- Split the large status JSON macro into small objects and an explicit feature map; + preserve response fields without increasing Rust's macro recursion limit. +- Add optional per-vhost certificate inventory selection and PEM file upload. + Validate key/certificate pairing and names, write private immutable files, and + activate only through the existing revisioned configuration update. +- Add per-vhost cache overrides and selective invalidation, including stale + in-flight fill cancellation while leaving other host entries intact. +- Add administrator-authored, bounded global/vhost error pages, static vhost and + missing-path responses, direct-IP and unknown-host responses. Retain ACL checks, + HEAD handling and authentication challenges; never execute HTML in the admin UI. +- Move access logs into a separate modal/docked viewer with fixed filters, + vhost identity, pause, scoped search/status/method/cache controls and JSON export. +- Add a compact vhost list, tabbed dialog, live health/latency summaries and visual + per-host response/request headers; keep the original logo and favicon. +- Add density preferences and a Settings shortcut to Security. +- Add a Python-only mock origin with random data, cache/no-store/private/cookie + paths, compression-size payloads, controllable delay/health and error statuses. +- Add Rust regression source for pages, cache scoping, TLS staging and API guards. +- Rust compilation/runtime acceptance remains unverified: toolchain unavailable. + Executed checks and their exact limitations are recorded in TEST_REPORT.md. + + +## 0.2.0 + +- Replace SizeAbove(u16) with a custom u64 predicate; add >64 KiB threshold regressions. +- Add IP/port/TLS-bound virtual hosts with frontend(s), backend pool and path policies. +- Enforce real accepted destination matching; isolate legacy routes from extra sockets. +- Add weighted round-robin, least-connections, least-traffic, adaptive and IP hashing. +- Add bounded TCP/HTTP health checks, separate passive failures, recovery and slow start. +- Add connection keepalive controls, backup members, drain and opaque cookie/IP affinity. +- Preserve compatible observations/tokens across hot edits; recheck changed/re-enabled members. +- Keep leases through full uploads/responses/tunnels; never replay failed requests. +- Add per-vhost TLS-only HSTS, nosniff, frame/referrer/CSP/permissions headers. +- Retain HTTP/1.1 ALPN with HTTP/2 for TLS frontends and HTTPS origins. +- Separate cache identities by selected member; bypass cookie-affine pools. +- Add compact vhost editor, atomic legacy migration, backend observations/API/metrics/log IDs. +- Extend discovered English/Polish translations; preserve original logo and favicon. +- Add Rust regression source and a real-binary vhost/keepalive/health acceptance suite. +- Execute 63 simulated-API UI, 24 SQLite, 21 upgrade, 4 language-mirror, 10 protocol-peer + and 7 backend-fixture checks. None is a passing Rust build or runtime test. +- Rust/Cargo remain unavailable in the packaging environment; compilation/runtime + acceptance and a resolved Cargo.lock remain mandatory before deployment. + +## 0.1.0 + +Development source advancing directly from 0.0.6. Cloudflare is deferred. + +### Added + +- Persisted public listener addresses/ports, 80/443 presets, startup defaults and restart status. +- Bounded streaming shared RAM cache, TTL/Age, LRU, privacy/Vary protections, per-route bypass, optional HIT/MISS headers and audited purge. +- Streaming gzip, Brotli, Zstandard and deflate controls, final Server policy and a global client-response header editor. +- Compact dashboard with system/process/cache diagnostics, log filters and export. +- Compile-time language discovery, JSON packs, metadata/RTL/fallback, and preservation of extra packs during upgrade. +- Example unprivileged low-port service and cache/settings real-binary integration test source. + +### Changed + +- Allow ordinary remote-origin 80/443 while guarding local proxy/admin listener targets. +- Keep original logo/favicon; reduce panel spacing and improve data density. +- Invalidate cache and older fills on config changes, explicit purge and successful unsafe methods. +- Expand source/SQL/upgrade/language/frontend checks; actual execution scope is in docs/TEST_REPORT.md. + +### Verification boundary + +Rust/Cargo and real Rust integration were unavailable. This is a source candidate, +not a successfully compiled or production-certified binary. No lockfile is fabricated. + +## 0.0.6 + +Cumulative development source release. The planned 0.0.3, 0.0.4 and 0.0.5 scopes +are included here, not represented as separately published archives. + +### Fixed + +- Explicitly select `src/auth/mod.rs` to prevent the reported flat/folder module + conflict when obsolete 0.0.1 source remains in an existing checkout. +- Detect mixed source layouts and preserve the known legacy auth file; refuse to + delete unrecognized local changes. +- Support interactive username input for `admin init`. +- Replace managed source directories with rollback on copy failure rather than + encouraging overlay extraction. + +### Added + +- DNS and HTTPS manual backends with per-backend pools, bounded DNS workers, + validated DNS answers, optional CIDR allowlists and additional PEM trust anchors. +- rustls public TLS listener, persistent development default certificate, exact SNI + bindings, local generate/import commands and explicit hot reload. +- Public HTTP/2 over TLS ALPN and HTTP/2/auto backend protocol options. +- Validated HTTP/1.1 WebSocket handshakes and bounded bidirectional tunnels, + including TLS on either side, connection leases and a separate idle timeout. +- Transport metrics, WebSocket lifetime logging, TLS editor and advanced backend + controls in the English/Polish panel. +- Backup of configured/default certificate files alongside the SQLite snapshot. +- Upgrade regression tests, transport configuration tests, real protocol test + scripts and CI steps. Test execution limits are recorded in TEST_REPORT.md. + +### Changed + +- Public TLS is enabled by default by `dev.sh`; direct binary startup keeps it optional. +- Listener ports are protected across HTTP, HTTPS and administration. +- All Markdown documentation is maintained in English. +- The roadmap identifies the combined transport release and upcoming Docker stages. + +## 0.0.2 + +- Introduced RustProxy naming, administrator setup, Argon2id passwords, sessions, + CSRF checks, login throttling, password recovery and credential revocation. +- Added host/route ACLs, generic request/response header policies, administration + audit, SSE dashboard updates and security controls in the bilingual panel. +- Added an additive SQLite migration, offline validation and online database backup. +- Rust build/runtime verification was unavailable for the source package. + +## 0.0.1 + +- Initial manual-backend HTTP/1.1 proxy, streaming and keep-alive. +- SQLite configuration, dynamic host/alias/path routing and local token control plane. +- English/Polish interface, bounded access log, counters, CLI, demo backend and dev.sh. +- Rust build/runtime verification was unavailable for the source package. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2efb192 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2994 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "async-compression" +version = "0.4.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bytesize" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e93abca9e28e0a1b9877922aacb20576e05d4679ffa78c3d6dc22a26a216659" + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compression-codecs" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hpx-h3" +version = "2.5.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4b773a1c31de56cc61c87de6d1db73ed838c1d23d8fad454b302a38ae178ad7" +dependencies = [ + "bytes", + "fastrand", + "futures-util", + "http", + "pin-project-lite", + "quinn", + "tokio", + "tokio-util", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kstat-rs" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d0786643a0b49f595bd1fc81e1c1aa7bad8555bc820c6892f4d28cb20cf210" +dependencies = [ + "libc", + "thiserror 1.0.69", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libproc" +version = "0.14.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a54ad7278b8bc5301d5ffd2a94251c004feb971feba96c971ea4063645990757" +dependencies = [ + "bindgen", + "errno", + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "metrics-process" +version = "2.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4268d87f64a752f5a651314fc683f04da10be65701ea3e721ba4d74f79163cac" +dependencies = [ + "libc", + "libproc", + "mach2", + "once_cell", + "procfs", + "rlimit", + "windows", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[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 = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "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 = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[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 = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[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 = "procfs" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25485360a54d6861439d60facef26de713b1e126bf015ec8f98239467a2b82f7" +dependencies = [ + "bitflags", + "procfs-core", + "rustix", +] + +[[package]] +name = "procfs-core" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6401bf7b6af22f78b563665d15a22e9aef27775b79b149a66ca022468a4e405" +dependencies = [ + "bitflags", + "hex", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "futures-io", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[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 = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rlimit" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f35ee2729c56bb610f6dba436bf78135f728b7373bdffae2ec815b2d3eb98cc3" +dependencies = [ + "libc", +] + +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "rust-proxy" +version = "0.24.5" +dependencies = [ + "anyhow", + "arc-swap", + "argon2", + "axum", + "base64", + "brotli", + "bytes", + "clap", + "flate2", + "fs2", + "futures-util", + "hpx-h3", + "http", + "http-body", + "http-body-util", + "httpdate", + "hyper", + "hyper-rustls", + "hyper-util", + "ipnet", + "libc", + "metrics-process", + "quinn", + "rand 0.8.8", + "rcgen", + "regex", + "rpassword", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha1", + "sha2", + "sqlx", + "subtle", + "sysinfo", + "systemstat", + "tempfile", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "url", + "webpki-roots", + "zeroize", + "zstd", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[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 3.0.6", +] + +[[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 = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[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 = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "sha2", + "smallvec", + "thiserror 2.0.21", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.21", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "windows", +] + +[[package]] +name = "systemstat" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a583abe520746270ffdbdaf0e3039a806f29be9d7034d66466a4839a01de0610" +dependencies = [ + "bytesize", + "kstat-rs", + "lazy_static", + "libc", + "mach2", + "nom", + "time", + "winapi", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[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 = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[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", + "signal-hook-registry", + "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 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.21", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[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-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[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 = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf06bd8162af0734b344780deb55b42a2429ae430870d13fcc12f238e880fe6e" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae42c0555055784c70058d19ba8e275528e8a99a706684868ace5da4e716a4ab" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..aa47fa0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,77 @@ +[package] +name = "rust-proxy" +version = "0.24.5" +edition = "2021" +rust-version = "1.88" +description = "RustProxy - independent reverse proxy with local administration" +publish = false + +[dependencies] +argon2 = { version = "0.5.3", features = ["std"] } +sha2 = "0.10" +ipnet = "2.11" +rpassword = "7.4" +zeroize = "1.8" +anyhow = "1.0" +libc = "0.2" +arc-swap = "1.7" +axum = { version = "0.8", default-features = false, features = ["http1", "http2", "json", "query", "tokio", "ws"] } +bytes = "1.10" +clap = { version = "4.5", features = ["derive", "env"] } +futures-util = "0.3" +fs2 = "0.4" +http = "1.3" +http-body = "1.0" +http-body-util = "0.1" +hyper = { version = "1.6", features = ["client", "server", "http1", "http2"] } +hyper-util = { version = "0.1", features = ["client-legacy", "server-auto", "http1", "http2", "tokio"] } +rand = "0.8" +regex = "1.11" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite"] } +subtle = "2.6" +tokio = { version = "1.44", features = ["macros", "rt-multi-thread", "net", "signal", "time", "sync", "fs", "io-util", "process"] } +tokio-util = { version = "0.7", features = ["rt"] } +tower = { version = "0.5", features = ["util"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +url = "2.5" + +base64 = "0.22" +sha1 = "0.10" +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } +hyper-rustls = { version = "0.27", default-features = false, features = ["ring", "http1", "http2", "tls12"] } +rustls-pemfile = "2.2" +webpki-roots = "1.0" +rcgen = { version = "0.13.2", default-features = false, features = ["crypto", "pem", "ring"] } + +httpdate = "1.0" +tower-http = { version = "0.6.8", default-features = false, features = ["compression-gzip", "compression-br", "compression-zstd", "compression-deflate"] } +flate2 = "1.1" +brotli = "8" +zstd = "0.14" + +h3 = { package = "hpx-h3", version = "=2.5.19", features = ["quinn"] } +quinn = { version = "0.11.12", default-features = false, features = ["runtime-tokio", "rustls-ring"] } + +metrics-process = { version = "=2.4.3", default-features = false, features = ["dummy"] } +sysinfo = { version = "=0.38.4", default-features = false, features = ["network"] } + +[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly", target_os = "macos", target_os = "windows"))'.dependencies] +systemstat = "=0.2.7" + +# Swagger assets are prepared by build.rs and embedded behind the admin guard. +# No adapter build script, implicit version fallback or Cargo-config dependency. + +[build-dependencies] +serde_json = "1.0" + +[dev-dependencies] +tempfile = "3.19" + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = "debuginfo" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..58a0f8e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# syntax=docker/dockerfile:1 +FROM rust:1.88-trixie AS build +WORKDIR /build +COPY . . +RUN if [ ! -f Cargo.lock ]; then cargo generate-lockfile; fi \ + && cargo build --locked --release + +FROM debian:trixie-slim AS runtime +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates python3 certbot openssl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 10001 rustproxy \ + && useradd --system --uid 10001 --gid rustproxy --home /var/lib/rustproxy --shell /usr/sbin/nologin rustproxy \ + && install -d -o rustproxy -g rustproxy -m 0700 /var/lib/rustproxy \ + && install -d -o rustproxy -g rustproxy -m 0750 /var/log/rustproxy +COPY --from=build /build/target/release/rust-proxy /usr/local/bin/rust-proxy +USER rustproxy:rustproxy +ENV RUST_PROXY_DATA_DIR=/var/lib/rustproxy \ + RUST_PROXY_PROXY_LISTEN=0.0.0.0:8080 \ + RUST_PROXY_ADMIN_LISTEN=127.0.0.1:9090 +VOLUME ["/var/lib/rustproxy"] +EXPOSE 8080 8443 9090 +ENTRYPOINT ["/usr/local/bin/rust-proxy"] +CMD ["serve"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7247b0d --- /dev/null +++ b/README.md @@ -0,0 +1,802 @@ +# RustProxy 0.24.5 + +Independent Rust reverse proxy with a dashboard, SQLite configuration, +streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhosts and backend pools. + +**0.24.5 release source.** + +## Layer-4 streams + +Raw TCP/UDP forwarding has its own **Streams** page in the primary navigation, alongside **Vhosts**, **Redirects** and **404 hosts**. Streams use the same card/modal workflow as vhosts, but each stream owns a dedicated Layer-4 listener and one upstream and intentionally bypasses HTTP ACL/cache/header/compression logic. The page exposes live per-stream connections/sessions, rejects, errors, transferred bytes and RX/TX rate charts. Listener changes require a restart. See [TCP/UDP streams](docs/STREAMS.md). + +Named HTTP/HTTPS listener bind addresses use the same chip-style editor as vhost hostnames, including multi-address listeners. + +## What changed in 0.24.5 + +- Fix HTTP/3 requests with an actually empty, length-unknown body when forwarding to an HTTP/1.1 upstream. RustProxy now observes the H3 body end before handing the request to Hyper so methods such as `DELETE`, `POST`, `PUT` and `PATCH` are represented as a known zero-length body instead of being emitted as `Transfer-Encoding: chunked`. +- Preserve streaming semantics for non-empty HTTP/3 request bodies: the first inspected frame is replayed and the remaining body continues streaming; declared `Content-Length` bodies and HTTP/2 upstreams are left unchanged. +- Add focused regressions for bodyless HTTP/3 `DELETE`, non-empty `DELETE`, declared-length requests, HTTP/2 upstreams and the generic bodyless method set. +- Harden the existing HOT -> COLD cache model with allocation-aware HOT admission, bounded transient fill/tier-transfer RAM, a byte-bounded COLD writer queue and incremental `max_object_bytes` enforcement before buffer growth. +- Track HOT logical and allocated/charged bytes separately, preserve body allocation accounting through revalidation/COLD promotion, make global cache disable authoritative over vhost overrides, and remove the old artificial 1 GiB HOT/object ceiling. +- Add host/cgroup-aware conservative/recommended/performance cache presets in Settings. The panel shows the recommended values for detected effective RAM and warns about high cache share, low current headroom, an oversized COLD queue or a maximum object that cannot fit in transient RAM. +- Keep the source package consistent with its existing release contracts by dropping the stale unreferenced `src/system_stats/bsd.rs` file and updating the offline container check to the already-shipped Debian 13/Trixie base images. +- Update package/source/UI/OpenAPI/release identity to 0.24.5. + +## What changed in 0.24.4 + +- On Linux, opt the RustProxy process out of Transparent Huge Pages with `prctl(PR_SET_THP_DISABLE)` before Tokio creates worker threads. This keeps the memory policy process-local and avoids requiring a host-wide `/sys/kernel/mm/transparent_hugepage/enabled` change. +- Keep the existing managed `MALLOC_ARENA_MAX=2`, transform allocation reductions and bounded `malloc_trim(0)` paths from 0.24.3. +- Update package/source/UI/OpenAPI/release identity to 0.24.4. + +## What changed in 0.24.3 + +- Reduce long-running Linux/glibc RSS retention by shipping `MALLOC_ARENA_MAX=2` in the managed systemd service while keeping `/etc/rustproxy/environment` available as the administrator override. +- Reduce transform-path temporary allocations: identity bodies are borrowed instead of copied during decode, JavaScript/CSS no longer build a full `Vec` or copy the completed output buffer again, successful transforms release origin/decode buffers early, and large glibc transform churn gets a throttled `malloc_trim(0)`. +- Keep HTTP/2 and HTTP/3 transport enablement together in **Settings -> Listeners** and make both connection-tuning pages listener-scoped. **Settings -> HTTP connections -> HTTP/2** now edits lifecycle/GOAWAY limits per H2/h2c listener, while **HTTP/3** edits per-listener QUIC limits; listener rows link directly to the matching tuning page. Legacy top-level `http2` values remain a read-time fallback for older configurations. +- Rename the dashboard **Proxy process** card to **Process** and hide unsupported process metrics/charts instead of rendering `unknown`/`N/A` placeholders for platform-specific fields. +- Add targeted hot reload for manual/file-backed certificates with `POST /api/certificates/{certificate_id}/reload`; the operation keeps the configuration revision stable and is available to the scoped Certificate API credential as well as administrator sessions. +- Expand the scoped Certificate API to up to 32 independently revocable tokens, each with a non-secret operator comment; cleartext tokens remain one-time responses and only hashes are persisted. +- Replace the dashboard **Core State** table with a live connection/activity chart, move **Uptime** into **Process** directly below process start, split **Payload transferred** into upload/download counters, and let operators hide individual network-interface charts in browser-local preferences. +- Update package/source/UI/OpenAPI/release identity to 0.24.3. + +## What changed in 0.24.2 + +- Version-only release bump from 0.24.1 to 0.24.2; no runtime or configuration behavior changes. + +## What changed in 0.24.1 + +- Restore network interface/RX/TX diagnostics after the statistics-library refactor. Network inventory and byte counters now use `sysinfo::Networks` as the primary API and merge in `systemstat` as a library-level supplement/fallback, including targets where the primary API has no inventory; the implementation does not shell out to `netstat`/`sysctl` or reintroduce per-OS network parsers. +- Fix the portable system-uptime build error by using the duration API exposed by `systemstat` and remove the stale H2 `Future` import warning. +- Remove application-level item-count ceilings for certificate hostnames/SAN requests and the hostname lists that feed certificate selection (vhosts, redirects, 404 hosts, administration allowed hosts, host policies and Docker aliases). DNS syntax/length validation, certificate/key size bounds, global configuration size and other resource-safety limits remain in place. +- Replace the hand-written BSD statistics backend with shared library APIs: `systemstat` for portable host metrics, `sysinfo::Networks` for network inventory/counters, and `metrics-process` for process metrics. `systemstat` remains the library-level network supplement/fallback where `sysinfo` has no native inventory or returns only a partial view. Unsupported values remain null instead of being synthesized. +- Preserve Linux `/proc` and cgroup fields as additive Linux-only details so existing Linux names, units and semantics remain compatible. +- Expose additional portable diagnostics when available: system swap/uptime and process virtual memory, descriptor limit, start time, RSS and thread count. +- Add authenticated runtime metadata to `/api/status`: platform, statistics source, resolved data directory and database path. The UI does not guess installation paths. +- Make local CLI data-directory discovery platform-aware: Linux convention `/var/lib/rustproxy`, BSD/OPNsense convention `/var/db/rustproxy`, while an explicit `--data-dir` always wins. +- Move Logging from the primary navigation into **Settings -> Logging** and keep `/logging` as a compatibility redirect/canonical route. +- Update package/source/UI/OpenAPI/release identity to 0.24.1. + +This workspace does not provide a Rust toolchain by default. Compiler/type/runtime acceptance and resolver-generated `Cargo.lock` for the new statistics dependencies must be completed on a compiler-enabled release host. See [the 0.24.1 specification audit](docs/V0.24.1_AUDIT.md) and [verification report](docs/TEST_REPORT.md). + +## What changed in 0.24.0 + +- Rework downstream HTTP/2 connection retirement into graceful `GOAWAY -> drain -> TCP close`: idle shutdown now checks active streams and recent frame traffic before retiring a connection, and a bounded drain timeout prevents indefinite hangs. +- Add independent global HTTP/2 `idle_timeout_seconds`, `max_connection_lifetime_seconds` (`0` disables it) and `drain_timeout_seconds` settings, exposed through the Settings UI and section API. Lifetime expiry uses the same graceful path rather than an abrupt socket close. +- Keep RFC 8441 WebSocket-over-H2 streams inside the active-stream drain accounting, so existing tunnels may finish while new H2 streams are refused after GOAWAY. +- Extract system/process sampling into `system_stats` platform backends. Linux keeps the existing `/proc`/cgroup field names, units and semantics; FreeBSD/OpenBSD/NetBSD use native `sysctl`/system interfaces where available and leave unsupported metrics null. +- Show the runtime platform in **Proxy Process** and export `rust_proxy_platform_info{platform=...}`. +- Group protocol tuning under **Settings -> HTTP connections**, with HTTP/2 and HTTP/3 sub-tabs. H3/QUIC remains listener-scoped and restart-required, but its enablement and limits are edited from the HTTP/3 sub-tab instead of the listener editor. +- Update package/source/UI/OpenAPI/release identity to 0.24.0 and add H2 lifecycle/configuration regressions. + +This workspace does not provide `cargo`, `rustc` or `rustfmt`, so compiler/type/runtime acceptance remains a release-host gate. See [the 0.24.0 specification audit](docs/V0.24.0_AUDIT.md) and [verification report](docs/TEST_REPORT.md). + +## What changed in 0.23.3 + +- Add per-vhost **Enable WebSocket legacy support** compatibility mode for older appliances and embedded WebSocket servers such as iLO-class management controllers. +- In legacy mode, force the upstream WebSocket hop to HTTP/1.1, force the legacy WebSocket `Host` to the configured backend authority, align `Origin` to that backend scheme + authority, and tolerate legacy `101 Switching Protocols` framing while retaining strict `Sec-WebSocket-Accept` and subprotocol validation. +- Keep the compatibility behavior scoped only to WebSocket handshakes; ordinary HTTP traffic and vhosts without the option are unchanged. +- Add dashboard/OpenAPI/language-pack controls, source regressions and release verification for the new compatibility switch. +- Update source/package/UI identity and release verification contracts to 0.23.3. + +Rust compiler/type/runtime acceptance remains a release-host gate in this workspace when `cargo`, `rustc` or `rustfmt` are unavailable. See [the 0.23.3 specification audit](docs/V0.23.3_AUDIT.md) and [verification report](docs/TEST_REPORT.md). + +## What changed in 0.23.2 + +- Add a selectable live-chart history window for Cache and Transformation: 60, 120, 300 or 600 samples (about 3, 6, 15 or 30 minutes at the existing 3-second refresh). Each workspace remembers its choice in browser local storage and remains bounded. +- Fix `rust-proxy --livelogs` when administration TLS is enabled. The CLI now probes the local administration listener, switches to HTTPS after the expected HTTP 308 redirect, and pins the presented TLS leaf certificate to certificates stored in the private RustProxy data directory. +- Reuse the same HTTP/HTTPS-aware local administration client for CLI configuration and certificate API calls, so local commands no longer fail solely because the administration listener is HTTPS. +- Update source/package/UI identity and release verification contracts to 0.23.2. + +Rust compiler/type/runtime acceptance remains a release-host gate in this workspace when `cargo`, `rustc` or `rustfmt` are unavailable. See [the 0.23.2 specification audit](docs/V0.23.2_AUDIT.md) and [verification report](docs/TEST_REPORT.md). + +## What changed in 0.23.1 + +- Add a collapsible, full-width **live Cache activity** chart to the Cache workspace. It samples the selected scope and plots HIT, MISS and BYPASS rates while preserving the existing 3-second live refresh. +- Add a matching collapsible, full-width **live Transformation activity** chart. It plots transformed responses, transformed cache HITs, skipped/fail-open responses and errors for the selected scope. +- Keep both charts collapsed by default, retain their in-memory history while the workspace stays active, and reset chart history when the selected vhost scope changes. +- Update source/package/UI identity and release verification contracts to 0.23.1. + +## What changed in 0.23.0 + +- Add a scoped **Certificate API** credential configured from **Settings → Certificate API**. External automation uses `X-RustProxy-Certificate-Key` plus `Authorization: Bearer ...`; only deploy/reload are authorized, the token is stored only as a hash, and non-loopback use requires HTTPS. +- Keep production local certificate rotation independent of development tooling: call `rust-proxy tls deploy --id ID --cert fullchain.pem --key privkey.pem` or `rust-proxy tls reload` directly from a Certbot deploy hook or administrator shell. +- Add `rust-proxy --livelogs`, an interactive all-vhost request stream modeled after varnishlog but using structured commands instead of regex expressions. Filters include host/vhost/backend/route/client/method/transport/cache/status/path, errors and slow requests; `show ID` prints the complete stored event. +- Feed live logs through a bounded non-blocking broadcast/SSE path. If a viewer cannot keep up, RustProxy emits an explicit dropped-entry count rather than silently pretending the stream is complete. +- Split **Settings → Cache** into four sub-tabs: **General**, **Resilience**, **Cold cache**, and **Static & diagnostics**. The form still saves one cache policy atomically. +- Add SQLite schema v5, OpenAPI security definitions, audit/status metadata, translations and regression coverage for the new control-plane capabilities. + + +## What changed in 0.22.3 + +- Add standards-oriented downstream HTTP/1.1 `Upgrade: h2c` on cleartext listeners that already enable `h2c`, while preserving the existing HTTP/2 prior-knowledge path. The initiating body-free H1 request is reconstructed as HTTP/2 stream 1 after `101 Switching Protocols`; malformed upgrades are rejected before switching. +- Add authenticated certificate hot reload through `POST /api/certificates/reload` and `rust-proxy tls reload`. Configured public certificate/key files are re-read and validated without changing the desired configuration revision; new public TLS/H2/H3 handshakes use the refreshed snapshot while existing sessions continue. +- Add external certificate deployment for local issuers such as system Certbot: `rust-proxy tls deploy --id ID --cert fullchain.pem --key privkey.pem`. The CLI resolves/checks local Certbot files and calls authenticated loopback `POST /api/certificates/deploy`; the running RustProxy service validates the pair/SANs, creates immutable generation files with its own filesystem identity and convergently creates a missing manual certificate id or atomically rotates an existing one used by any number of vhosts; identical RustProxy-owned material is a no-op. +- Reject external deployment into RustProxy-managed ACME certificate ids to avoid dual renewal ownership, roll back staged files when publication fails, and explicitly report that startup-fixed administration TLS needs a process restart when it selects the changed certificate. +- Extend the Certificates workspace with an explicit **Reload certificate files** action plus OpenAPI, audit logging, translations, source regressions and the 0.22.3 verification/audit documentation. + +Rust compiler/type/runtime acceptance remains a release-host gate in this workspace because `cargo`, `rustc` and `rustfmt` are unavailable. See [the 0.22.3 specification audit](docs/V0.22.3_AUDIT.md) and [verification report](docs/TEST_REPORT.md). + +## What changed in 0.22.2 + +- Add replay-safe bounded backend failover retry for empty `GET`, `HEAD` and `OPTIONS` exchanges, with per-vhost retry policy, passive-health integration, logs and Prometheus counters. +- Send ordinary requests to explicit `protocol=http2` backends as HTTP/2 and retain a real Hyper h2c prior-knowledge upstream regression. +- Add per-backend `max_concurrent_requests` with a bounded capacity wait (`queue_timeout_ms`), immediate/timeout `503` rejection, dashboard controls and rejection/queue-timeout metrics. A TCP socket-count limit remains deliberately separate and is not claimed by this setting. +- Add detailed upstream failure classification and last-failure diagnostics for DNS, TLS, connect, response-header timeout, read/send timeout and I/O failures, plus health ejection/recovery timestamps. +- Extend the backend dashboard table with configured concurrency, last failure/reason and last recovery while retaining health, request/error, traffic and latency data in one operational view. +- Parse public leaf-certificate metadata for issuer, SANs, validity window, days remaining and expiry state; surface renewal status/last attempt/error in the certificate inventory without reading private keys. +- Update OpenAPI, translations, metrics documentation, regression sources and the 0.22.2 audit/TODO for the expanded release scope. + +Rust compiler/type/runtime acceptance remains a release-host gate in this workspace because `cargo`, `rustc` and `rustfmt` are unavailable. See [the 0.22.2 specification audit](docs/V0.22.2_AUDIT.md) and [verification report](docs/TEST_REPORT.md). + +## What changed in 0.22.1 + +- Use one shared overview stats grid for primary and transport/WebSocket metrics, so both visual rows have identical card sizing and spacing. Cache **Target** and **Cache key / tags** cells copy their full untruncated value on click or keyboard activation. +- Move selective **Purge / Ban** and **Active BANs** into dedicated Cache modals next to **Purge selected scope**; operation results use null-safe inline feedback styled like the login form. +- Add opt-in HTTP/2 RFC 8441 WebSocket Extended CONNECT beside the existing RFC 9220 per-vhost option. The listener advertises Extended CONNECT only when an opted-in vhost is bound to that H2 endpoint. +- Support RFC 8441 H2 clients through RustProxy to an HTTP/1.1 WebSocket upstream bridge or an explicitly configured HTTP/2 upstream. Lifecycle handling includes shutdown cancellation, idle timeout and byte counters. +- Add explicit cleartext HTTP/2 **h2c prior-knowledge** listeners. HTTP/1.1 `Upgrade: h2c` is intentionally not exposed because the current Hyper integration cannot safely reconstruct the required HTTP/2 stream 1 bootstrap. +- Add stable active-tunnel IDs, inventory (`client`, `host`, `upstream`, protocol, duration, RX/TX), authenticated kill with operator reason, and `tunnel.kill` audit records. +- Consolidate WebSocket visibility on the main dashboard as total active plus H1 / H2 RFC 8441 / H3 RFC 9220, while ordinary H2/H3 request counters remain separate. +- Retain the full 0.22.0 cache feature set: single-flight fills, cached single-range 206, selective PURGE/BAN, configurable stale-if-error and proactive refresh. +- Update release/package/UI/OpenAPI/Debian/Grafana/tooling identity to 0.22.1. + +The source package includes Rust regression definitions and Rust-independent release checks. This workspace does not contain `cargo`, `rustc` or `rustfmt`, so compiler/type/runtime acceptance remains a required release-host gate. See [the 0.22.1 specification audit](docs/V0.22.1_AUDIT.md) and [verification report](docs/TEST_REPORT.md). + +## What changed in 0.21.0 + +- Shard Cold Cache objects by the first two SHA-256 hex characters, add bounded asynchronous 90% -> 80% garbage collection, and expose selective asynchronous cleanup in the Cache panel. +- Implement RFC 5861 `stale-while-revalidate` / `stale-if-error`, request `max-stale`, age-aware request `max-age=N`, and correct shared-cache `must-revalidate` / `proxy-revalidate` semantics while retaining `s-maxage` precedence. +- Add a formal decode -> transform -> canonical cache -> encode stage for JS/CSS plus optional HTML/SVG/JSON transformations, with strict origin `no-transform`, separate origin/representation validators, transformed Content-Length, canonical `Accept-Encoding` handling and safe incomplete-fill behavior. +- Keep route cache overrides tri-state (`inherit` / enabled / disabled) and report effective vhost/route cache and transform decisions in access diagnostics. +- Make Passive Backend Health opt-in and disabled by default for ordinary and Docker-generated vhosts; active TCP/HTTP health remains independent and ordinary traffic errors remain observable even when they do not affect backend health. +- Add global Settings, vhost and Docker-discovery controls for transformation, plus OpenAPI/admin coverage and expanded cache/transform/health regressions. +- Add a dedicated **Transform** monitoring workspace with global/per-vhost runtime effectiveness counters, mark transformed objects in Cache, and expose transformed representations through the standardized `Cache-Status` diagnostic. +- Tighten Docker-vhost desktop layout so the transform switch stays with the other primary switches and Passive Backend Health fields remain readable when enabled, including checkbox sizing in the expanded editor. +- Add a transformed-response series to **Live traffic** and show **Core state** uptime in compact minute/hour/day/year form. +- Keep `/transform` refreshable as a first-class dashboard route; use a square-aligned Cache transformed badge and a dedicated violet color for the transformed live-traffic series. +- Rename the backend timing diagnostic to `X-RustProxy-Backend-Time`, while stripping the former `X-Rust-Proxy-Backend-Time` spelling as an untrusted upstream lookalike. +- Split the former monolithic `src/cache.rs` into a responsibility-based `src/cache/` module and remove the unused mutable background-revalidation binding. +- Keep package/source/UI/OpenAPI/Debian/Grafana/tooling identity at 0.21.0. + +The 0.21.0 feature scope is closed for release packaging. The conservative JS/CSS transformers remain supported as-is for this release; parser-backed replacements are deferred. This workspace still cannot execute the Rust compiler/runtime gate, so a release host must run that gate and live protocol acceptance before deployment. See [the 0.21.0 specification audit](docs/V0.21.0_AUDIT.md) and [verification report](docs/TEST_REPORT.md). + +## What changed in 0.20.0 + +- Rebuild **Certificates** as a three-tab workspace: **Installed**, **Renewal** and **Generate**, matching the Settings tab interaction and responsive layout. +- Move certificate generation/import/existing-file controls into the Generate tab so the workflow remains visible instead of opening a separate modal. +- Add clear ACME operation states and show the exact private Certbot `logs/` directory when issuance or renewal fails; persisted renewal failures also expose the corresponding profile log path. +- Improve renewal cards with provider/environment context, structured last/next/expiry fields and clearer action grouping. +- Update release/package/UI/OpenAPI/Debian/Grafana/tooling identity to 0.20.0. + +## What changed in 0.19.4 + +- Align the Docker-vhost Aliases chip editor with neighboring 38px controls and remove the inherited extra label margin that made the field look like it started on a new line. +- Keep backend traffic formatting explicit: idle is `0.0 KiB/s`, while any positive rate below 0.1 KiB/s is rendered as `<0.1 KiB/s` in backend rows and aggregate vhost traffic. +- Add an All / Cache enabled / Cache disabled selector to **Cache -> Domains and effectiveness**. Filtering affects only the domain table; aggregate cache statistics remain scoped by the selected vhost and are not changed by the presentation filter. +- Update release/package/UI/OpenAPI/Debian/Grafana/tooling identity to 0.19.4. + +## What changed in 0.19.3 + +- Fix the update-checker body collection error conversion so compiler-enabled Rust builds do not require `anyhow::Context` on Hyper/http-body-util's boxed body error. +- Add a dark/light administration theme selector in Settings. The preference is account-scoped in SQLite, defaults to the existing dark theme, and uses a small local cache only to prevent first-paint theme flicker before the authenticated preference is loaded. +- Consolidate theme-sensitive CSS around shared surface/text/border variables while preserving the RustProxy accent palette. +- Make the Live Logs column chooser a compact, scrollbar-free standard-checkbox list; on short screens it switches to two columns rather than adding internal scrollbars. +- Fix the remaining Live Logs checkbox inflation caused by broad `.log-tools input` text-field rules; checkbox/radio controls are now excluded from both sizing blocks. +- Simplify the dashboard footer to API latency + dropped-log count and add a fail-soft hourly release check against the official repository Cargo manifest; a newer version is linked in the footer without performing any automatic upgrade. +- Re-authenticate expired sessions through the minimal login page while preserving the exact panel and Settings sub-tab; embedded login errors are inline and the authenticated shell no longer flashes during the redirect. +- Canonicalize `/configuration` to `/settings?tab=manual-config` and persist every Settings tab in the URL for reliable F5/re-login restoration. +- Use a shared form-saving state that disables the active submit action, shows `Saving…`, restores translated labels and blocks duplicate saves. +- Highlight/focus the actual invalid field for local validation problems in Logging JSON, listeners, Docker agents, TLS policy, ACL profiles, Manual config and certificate forms. +- Keep selected certificate/key files after failed import/save attempts. +- Show a subtle unsaved-change marker on the affected Settings tab and preserve dirty Manual config text across live refreshes. +- Update package, UI, OpenAPI, release checks and tooling identity to 0.19.3. + +## What changed in 0.19.2 + +- Restore the local-listener risk checkbox for administration backends even when the administration listen socket is startup-owned/locked, including loopback equivalence between `localhost`, `127.0.0.0/8` and `::1` on the administration port. +- Make the vhost **Discard changes** action restore the persisted vhost instead of resetting the editor to a blank/default vhost. +- Use descriptive HTTP status labels in Settings -> Default responses, matching the 404-host editor. +- Let Live logs temporarily expand from the 256-entry live window to a continuously updated 4096-entry client buffer until the log window closes. +- Align inline checkbox/text controls and compact the Docker-vhost Aliases editor. +- Render idle backend traffic as `0.0 KiB/s`; positive sub-0.1 KiB/s activity renders as `<0.1 KiB/s` instead of a misleading zero. +- Replace native browser confirmation dialogs with RustProxy-styled confirmation modals and prefix API failures with the panel area that failed. +- Style sign-in failures inline under the login form, including `Incorrect username or password.` / its selected-language equivalent. +- Add `/opt/rustproxy/current` (or the selected prefix) to new login-shell `PATH` through `/etc/profile.d/rustproxy-path.sh` on install; managed update only verifies and warns about that profile. +- Keep placeholder/static/default pages strictly non-cacheable and suppress cache diagnostics/validators on those locally generated responses. +- Allow an existing vhost ID to be renamed with collision checks and an explicit warning about the new metric/log/cache identity and reset live health/traffic history. +- Update package, UI, OpenAPI, release checks and tooling identity to 0.19.2. + +## What changed in 0.19.1 + +- Fix **Settings -> Default responses** saves by sending the standard section-update `value` payload and accepting the `pages` section in the administration API. +- Include the frontend HTTP/HTTPS scheme in the shared-cache primary key so scheme-dependent origin responses cannot cross protocols. +- Keep the live log stream at 256 rows while retaining up to 4096 completed requests for a one-shot **Load history** action; filters are applied before the requested history limit. +- Make backend traffic rates use KiB/s as the smallest display unit, using fractional KiB/s for small traffic instead of large raw B/s values. +- Polish Live Logs column selection, vertically align generic checkbox labels and place Docker-vhost Advanced switches beside Backend protocol on wide layouts; the alias editor no longer adds a redundant help line. +- Reduce stale simulated UI screenshots to two explicitly labelled legacy references and add a documentation index that identifies historical/version-labelled material. +- Restore the integration-test sources and independent Argon2id fixture required by the source/release checks so the 0.19.1 archive is self-contained for compiler-enabled `cargo test`. +- Update source, UI, API schema, release checks and package identity to 0.19.1. + +## What changed in 0.19.0 + +- Harden managed updates against systemd `200/CHDIR` failures by repairing the managed data-root owner/mode and verifying that the saved service account can traverse it before the running service is stopped; inaccessible parent directories now fail early with an actionable error. +- Persist per-administrator **Live access logs** column visibility in SQLite. The preference changes only the dashboard table; filtered JSON export still contains every field present in each log entry. +- Render the Live Logs column picker with the same compact checkbox controls used elsewhere in the dashboard. +- Make backend `tls_verify=false` fully bypass X.509 certificate authentication/parsing callbacks used by webpki, restoring compatibility with legacy appliance certificates that fail with `UnsupportedCertVersion` while keeping TLS encryption and address/DNS/loop protections. +- Keep expanded upstream connect diagnostics with the nested Hyper/rustls/io error chain in Live Logs and the service journal. +- Remove the duplicate RustProxy version label from the top-right toolbar; the release version remains visible in the footer. +- Add SQLite schema v4 for administrator view preferences and update the administration API/release package identity to 0.19.0. + +## What changed in 0.18.8 + +- Expand failed-upstream diagnostics so access/live logs preserve the nested Hyper/rustls/io error chain plus origin, effective TLS request origin, certificate-verification mode and elapsed connection time; connect failures are also emitted to the service journal at warning level, and Live access logs show the detail in a dedicated Upstream error column. +Follow-up fixes keep the four Docker-vhost per-app switches in one compact row and add regression coverage that generated Docker vhosts inherit the global `Server` response-header policy when they do not define an override. + +- Replace the Docker-vhost alias CSV input with the same chip-style add/remove interaction used by hostname editors. +- Add per-Docker-vhost HTTP-to-HTTPS redirect, WebSocket and cache switches. New Docker assignments default the redirect on when the selected agent listeners can support it; existing pre-0.18.8 assignments keep their previous redirect-off behavior until saved with the new switch. +- Add an agent-level default ACL-profile selector next to the frontend certificate selector. Per-app ACL-profile selections still override the agent default, and inline per-app ACL rules remain unchanged. +- Remove the compression-codec explanatory sentence from the Overview dashboard. +- Update source, UI, API schema, release checks and package identity to 0.18.8. + +## What changed in 0.18.7 + +- Keep **Response time history** collapsed by default while continuing to collect and update its live series. +- Make per-interface RX/TX history use the same 60 px plotting geometry, grid and RX/TX line treatment as aggregate network history, without nested background cards. +- Normalize expanded Docker-vhost routing and ACL field layout so text/select/checkbox controls share a stable label/control baseline and ACL selects retain normal control height. +- Update source, UI, release checks and package identity to 0.18.7. + +## What changed in 0.18.6 + +- Add per-interface Linux network diagnostics from `/proc/net/dev`, with separate RX/TX history rows for every non-loopback interface in **Network and responses** while keeping Prometheus label cardinality unchanged. +- Strengthen compression telemetry verification with explicit quality-weight negotiation cases for gzip, Brotli, Zstd and deflate, and clarify in the UI that the counters represent the codec actually selected for each response. +- Polish **Settings -> Docker agents** field alignment/status controls and add a clear visual gap after listener selection before **Add Docker agent**. +- Update source, UI, EN/PL language packs, release checks and package identity to 0.18.6. + +## What changed in 0.18.5 + +- Fix compression telemetry accounting by attaching an internal marker for the codec RustProxy actually applied; gzip, Brotli, Zstd and deflate now have independent counter regression coverage. +- Add an HTTP/1.1 request counter and show total/H1.1/H2/H3 request rates as separate colored series on the Overview chart. +- Add **Open app** to published Docker vhosts and show the primary Docker proxied domain beside generated pool IDs in **Backends**. +- Remove the page-level **Open proxy** shortcut. +- Make **Compact** density substantially tighter than **Dense** across dashboard cards, controls, tables and Docker/vhost rows. +- Give ordinary and Docker vhosts explicit proxy timeout defaults (10 s connect, 120 s response headers, 60 s read and 60 s send) so new/defaulted editors never start blank. +- Replace the Docker-agent Listener IDs text field with the shared named-listener selector, and keep Enabled immediately to the left of the centered connection-status badge. +- Update source, UI, EN/PL language packs, release checks and package identity to 0.18.5. + +## What changed in 0.18.4 + +- Default new/missing backend health-check configuration to **TCP** instead of HTTP. Existing explicitly configured HTTP/off checks remain unchanged. +- Rework **Docker vhosts** into a summary-first view: each service keeps Publish, runtime health and visibility actions visible while route, ACL, timeout and health-check configuration stays collapsed until that service is edited. Agent diagnostics are collapsed behind **Agent details**. +- Rework **Settings -> Security** around compact account/session and host-policy sections, with password and policy editors collapsed until needed. +- Move **Administration audit** out of the Security page into a dedicated modal and load audit rows only when that modal is opened or refreshed. +- Retain the 0.18.3 HTTP/1 upstream title-case compatibility behavior and upstream-error diagnostics used for legacy/custom origin troubleshooting. +- Update source, UI, EN/PL language packs, release checks and package identity to 0.18.4. + +## What changed in 0.18.3 + +- Add configurable per-vhost access-log files (`logging.file.per_vhost`) with a dedicated directory and `_unmatched.jsonl` fallback while preserving the combined JSONL mode. +- Treat explicitly supplied systemd/CLI/environment HTTP, HTTPS and administration binds as startup-owned: their addresses cannot be changed/removed in Settings, while non-startup listeners remain editable. Remote plain HTTP administration needs `--allow-remote-admin` but no second risk-acknowledgement flag. +- Make Compact density tighter, rename the cache settings surface to **Cache**, display Redirect targets on cards, and let 404 Hosts choose any final HTTP status from 200 to 599. +- Show the effective global TLS cipher list in Settings, mark ACME certificates as managed/read-only, require unassignment before certificate deletion (including administration/default usage), and default new ACME requests to Production. +- Keep WebSocket support on the nginx-compatible HTTP/1.1 Upgrade path by default. RFC 9220 WebSocket over HTTP/3 remains available only as an explicit experimental vhost option. +- Separate Docker discovery/vhost observability from ordinary vhost/backend Prometheus families. Docker assignments now export dedicated `rust_proxy_docker_vhost_*` and `rust_proxy_docker_backend_*` series instead of appearing under `rust_proxy_vhost_*` / `rust_proxy_backend_*`. +- Add an independent **Docker vhosts** collector switch under **Settings -> Prometheus -> Metric groups**, enabled by default and live-applied like the other collector switches. +- Export bounded Docker agent/assignment/domain inventory plus Docker-vhost traffic/status/duration and the full backend readiness/health/request/error/byte/latency/EWMA/failure/probe observations. +- Add regression coverage that verifies Docker pools never leak into ordinary vhost/backend Prometheus families. +- Update OpenAPI, Prometheus documentation, release checks and package identity to 0.18.3. + +## What changed in 0.18.2 + +- Add optional Docker host discovery through a standalone Go agent in `docker-agent/`, with Bearer-token inventory access, systemd packaging helpers, multi-address host discovery and complete published-TCP binding discovery. The agent serves HTTPS by default with a persistent self-signed ECDSA certificate generated on first start. +- Pin the Docker agent certificate by SHA-256 in RustProxy instead of accepting arbitrary self-signed certificates. Public-CA agents remain supported without a pin; plaintext HTTP is an explicit compatibility mode only. +- Add **Settings -> Docker agents** and a main **Docker vhosts** page. Multiple agents each keep their own base domain (for example `docker1.lab.local` / `docker2.lab.local`), while each service can use a short name, an arbitrary FQDN and multiple aliases. +- Allow every Docker vhost to select a concrete detected Docker-host IP; wildcard bindings follow the selected IPv4/IPv6 address, while explicit Docker bindings retain their own host port. Agent-level default target IP remains optional. +- Reconcile enabled Docker assignments into ephemeral RustProxy vhosts without creating configuration revisions. Changed Docker published bindings/IP observations update routes on the next poll. +- Give Docker vhosts the same backend health engine used by ordinary vhosts. Per service, HTTP/TCP/off checks, path/Host/status/interval/timeout/rise/fall controls and live up/down/checking, latency, last-result, request/error/active diagnostics are available directly on the Docker vhosts page. +- Move the cache **Live** toggle to the right side of the cache action toolbar. +- Remove all dedicated `X-Rust-Proxy-Revision` response-header behavior. RustProxy no longer creates, strips, protects or migrates that header; if an upstream/operator provides it, it is treated like an ordinary response header. Internal configuration revision CAS remains unchanged. +- Replace production password-recovery guidance with `rust-proxy admin reset-password` and reorganize the Settings navigation for Docker-agent configuration. +- Update source, UI, OpenAPI, release tooling and package identity to 0.18.2. + +## What changed in 0.17.0 + +- Add a complete `debian/` packaging workflow for a native `rustproxy` package: hardened systemd service, dedicated `rustproxy` user, `/var/lib/rustproxy` and `/var/log/rustproxy`, daily three-day `gzip -9` log rotation, package/repository build scripts and repository layout suitable for `https://rustproxy.linuxiarz.pl/`. +- Expand the Prometheus exporter to cover every field in the current bounded OS/process diagnostics view, plus GeoIP, certificate renewal, backend probe/EWMA/passive-failure, redirect/404-host, stream and structured-log diagnostics. Add an explicit trusted-CIDR option that can allow scraping without a bearer token while always using the real transport peer. +- Ship `grafana/rustproxy-overview.json` with traffic, vhost/backend, cache, memory, protocol, stream, logging, inventory and certificate panels. +- Add a dedicated **404 hosts** editor. A local-response host owns domains/listeners/certificates like a redirect/vhost but never opens an upstream; it can return the built-in placeholder, an empty status response or bounded custom HTML. +- Make the built-in `RustProxy is running` placeholder strictly `no-store` and strip optional RustProxy server, cache, backend, diagnostic, security and compression headers from that minimal response. +- Reuse the vhost-style hostname chip editor for Redirect source domains and keep the 0.16.x UX improvements intact. +- Update source, UI, OpenAPI, release tooling and package identity to 0.17.0. + +## What changed in 0.16.1 + +- Keep the 0.16.0 proxy, cache, administration, H3 and editor behavior unchanged while polishing save feedback: successful configuration saves no longer expose the internal configuration revision number in modal/toast feedback. Revision CAS remains fully active internally for stale-write protection and is still available in operational status/diagnostics. +- Update source, UI, OpenAPI, release tooling and packaging identity to 0.16.1. + +## What changed in 0.16.0 + +- Separate the main vhost backend from **Custom locations**. Custom locations are now overrides only; when none matches, routing always falls back to the vhost pool. An untouched legacy synthetic `root /` route is normalized away, while a deliberately customized `/` location remains a valid override. +- Fix publishing the administration panel through an explicitly configured RustProxy vhost. The reserved `rustproxy_session` cookie is still stripped for ordinary upstreams, but is preserved for a tightly guarded HTTPS self-admin route that directly targets the configured admin listener, has `accept_local_listener_risk=true`, and uses a hostname listed in `administration.allowed_hosts`. +- Harden RFC 9220 WebSocket setup: RustProxy now completes the backend HTTP/1.1 `OnUpgrade` before returning downstream HTTP/3 success, avoiding a `200` followed by an immediate H3 reset when backend takeover fails. Access logs gain HTTP version/transport, WebSocket phase/error, H3 reset and upstream-status fields. +- Split memory diagnostics into process RSS categories and RustProxy cache allocations. The panel now shows total RSS, anonymous/file/shared RSS, data/swap plus hot cache and transient fill/cold-queue/cold-I/O bytes. Existing allocator page release (`malloc_trim` on supported glibc/Linux paths) is retained; 0.16.0 does not add a new cache-memory throttle. +- Refine the Vhost/Redirect editor UX: new vhosts select standard HTTP `:80` and HTTPS `:443` listeners together when available, H2/H3 switches sit side by side, Redirects reuse the exact same listener selector, Authentication separates the IP access list from provider settings, TinyAuth advanced fields stay collapsed, and disabled vhost cache hides all subordinate options. New vhost drafts explicitly start with cache disabled. +- Rework backend cards and add opt-in origin diagnostics: `X-RustProxy-Backend` exposes the selected member ID and `X-RustProxy-Backend-Time` exposes origin response-header latency. Both headers are proxy-owned/protected and are omitted when a response is served without contacting an origin. The local-listener risk control appears only when the entered backend URL actually targets the configured RustProxy administration listener. + +## What changed in 0.15.1 + +- Fix EN/PL language-pack placement for the 0.15 custom-location controls so documentation, match modes, rewrite controls and the visual header-rule builder render translated text instead of raw keys. +- Rework the visual header-rule editor into numbered rule cards with clearer operation/name/value fields, empty-state guidance and protected-header help. +- Add a live effective-behavior preview to every Custom location showing matching, proxy/local response, rewrite, cache, ACL and request/response header-rule counts before saving. +- Restructure the in-panel Custom locations documentation into matching, handling/policy and common-example sections. +- Add a combined live HIT/MISS/BYPASS activity chart based on per-sample counter deltas, while retaining the RAM-residency history. +- Add live process CPU and RSS charts to balance the dashboard process diagnostics card. + +## What changed in 0.15.0 + +- **Custom locations** now support prefix, priority-prefix (`^~`), exact, case-sensitive regex and case-insensitive regex matching. Proxy locations can preserve, strip, replace or regex-rewrite the upstream path; local locations can return a configured status/body/content type without opening a backend connection. +- Location request/response header rules now have a visual builder plus inline documentation; the Advanced JSON representation remains available for copy/paste and automation. See [Custom locations](docs/CUSTOM_LOCATIONS.md). +- Add `nagios/` checks for admin API RTT, backend availability, backend latency and dropped-work counters. See [Nagios / Icinga checks](nagios/README.md). +- Move `Server-Timing` and verified Cloudflare client-IP diagnostics to the main vhost frontend/details page. +- Add live dashboard histories for RAM cache, network RX/TX and mean response latency. The compact footer also exposes measured administration API RTT and cold/log drop counters. +- Split cold-cache demotion into explicit idle age and maximum hot-tier residency age, while retaining independent LRU and shutdown policies. +- Live logs now include the effective source IP after trusted-proxy/verified Cloudflare processing; requests rejected before that processing still record the transport peer. +- Remove the runtime legacy renewal-observation JSON importer. Stopped source upgrades remove the obsolete observation sidecar as the final upgrade step without importing it or retaining a second legacy copy; the application itself uses SQLite only. +- Repeated literal slashes are normalized for route/ACL matching but preserved toward the backend unless a rewrite is selected, so requests such as `/static/js//events.js?v=bf9b5489` are accepted without allowing encoded separator or traversal ambiguity. + +## What changed in 0.14.0 + +- Fix administration behind a trusted reverse proxy: the optional DNS allowlist now validates the browser-facing authority without forcing the internal admin port, while direct IP/`localhost` access remains tied to the real administration port and stays available. Trusted `X-Forwarded-Host`/`X-Forwarded-Proto` are used for same-origin checks and Secure session cookies. +- Implement RFC 9220 WebSocket bootstrapping on HTTP/3 with Extended CONNECT. RustProxy advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL`, validates `:protocol=websocket`, translates the bootstrap to the backend HTTP/1.1 Upgrade handshake, returns downstream `200`, and bridges WebSocket bytes over H3 DATA with FIN/reset handling. Generic CONNECT and unsupported Extended CONNECT protocols remain rejected. +- Switch the HTTP/3 integration to `hpx-h3 2.5.19` with its Quinn transport feature so the protocol layer can represent RFC 9220 Extended CONNECT without a local parser bypass. +- Rename dashboard labels: Backend pools -> Backends, debug-origin preset -> recommended preset, health/restore tab -> Backend health, per-host response security -> Security, and Pages -> Error pages (with corresponding Polish labels). +- Bump source, UI, OpenAPI, release tooling and documentation to 0.14.0. No `certificates.json` metadata store is present; certificate state remains in the current SQLite/PEM paths, while the separate historical `certs/renewals.json` importer is retained only for renewal-observation migration. + +## What changed in 0.13.2 + +- Fix direct dashboard refresh/session restoration: authenticated panel routes now validate the existing cookie before selecting the login or application shell, and restored browser-history login pages re-check the server session instead of showing a stale logged-out view. +- Preserve cache across managed software updates when the cold tier is enabled. RustProxy 0.13.2+ recognizes the updater's one-shot marker and forces a bounded hot-to-cold handoff even when ordinary `cold_on_shutdown` is disabled; RAM-only cache still cannot survive a process exit when cold storage is disabled. +- Persist non-secret automatic-certificate-renewal observations in SQLite and preload them before the administration listener starts. Existing `certs/renewals.json` observations are imported once, so **Check now** is no longer required after a restart just to repopulate last/next/expiry/result data. +- Move the raw full-document editor out of primary navigation into **Settings -> Manual config**. The old `/configuration` browser route remains as a compatibility alias that opens that Settings block. +- Add schema-v3 migration/release regressions, update OpenAPI/UI/docs and bump the source/package identity to 0.13.2. + +## What changed in 0.13.1 + +- Extend administrator browser sessions from the old short 8-hour/30-minute-idle behavior to a configurable absolute **7–30 day** lifetime (default 30 days). The setting is live, session cookies are refreshed only up to the server-side absolute expiry, and password/revoke/logout invalidation is unchanged. +- Add an explicit per-backend `accept_local_listener_risk` switch. Together with `allow_private=true` where required, it can intentionally proxy to a local RustProxy listener such as `127.0.0.1:9090`; the default remains blocked to prevent loops/control-plane exposure. The Vhost editor requires an explicit risk confirmation. +- Update OpenAPI, English/Polish UI, security/transport documentation, regression contracts and release identity for 0.13.1. + +## What changed in 0.13.0 + +- Add first-class **Redirects** beside Vhosts/Streams. Redirect hosts use named listeners and certificate assignments, support exact/prefix and HTTP/HTTPS matching, URL/host/path targets, 301/302/303/307/308, path append/strip/replace, query preserve/drop and explicit redirect cache lifetime. +- Redirects are resolved locally before proxy routing, never open an upstream connection, retain global host ACL enforcement and reject overlapping hostname/listener ownership with vhosts or other redirect hosts. +- Backend HTTPS certificate verification now defaults to **off** when `tls_verify` is omitted. Administrators can explicitly enable chain/identity verification per backend; Tinyauth and other verifier clients keep their independent secure defaults. +- TLS certificate cards now show DNS SAN coverage as compact vhost-style badges, keep verbose bindings/file/assignment data collapsed by default, and live-inspect older/manual public PEM files when `covered_hosts` metadata is missing. +- Update OpenAPI, English/Polish UI, regression contracts, documentation and release identity to 0.13.0. + +## What changed in 0.12.1 + +- Split ordinary dashboard saves into revisioned `PUT /api/config/{section}` endpoints while retaining full-document PUT for intentional multi-section transactions. Cache invalidation is decoupled from the global configuration revision so unrelated control-plane saves no longer discard hot/cold objects. +- Add explicit browser routes for dashboard subpages and History API synchronization so refreshing `/vhosts`, `/cache`, `/tls` and the other primary panels returns the same guarded application shell instead of the landing page. +- Extend cold-cache policy with separate idle, LRU-pressure and graceful-shutdown persistence switches, reduce queued body retention, flush hot objects directly to cold storage during graceful shutdown, and release allocator pages after large demotions on glibc/Linux. +- Show certificate DNS SubjectAltName coverage separately from active SNI bindings for uploaded and ACME-managed certificates. +- In 0.12.1 backend TLS verification still defaulted on when `tls_verify` was omitted; 0.13.0 intentionally changes that default to off. The debug-origin preset already wrote an explicit `tls_verify=false`. +- Update OpenAPI, regressions, release tooling and source identity to 0.12.1. + +## What changed in 0.12.0 + +- Promote TCP/UDP streams from Settings to the main navigation directly below Vhosts, with searchable stream cards and the same modal editing workflow used by vhosts. +- Add fixed-cardinality per-stream runtime telemetry for active connections/sessions, accepted/completed sessions, rejects, errors, bytes and UDP packets. The dashboard derives live RX/TX rates from those backend counters and keeps compact lifetime charts for the open panel session. +- Make restart-bound state explicit: desired stream configuration is shown separately from the active startup listeners, and changed/new/removed stream listeners remain marked pending until RustProxy restarts. +- Restyle named HTTP/HTTPS listener bind-address editing as address chips, matching the vhost hostname editor while preserving the existing 1–16 address validation and wildcard restrictions. +- Keep raw TCP/UDP forwarding outside HTTP ACL/cache/header/compression handling and retain bounded TCP connections / UDP client sessions. +- Bump the source release, panel, OpenAPI, upgrade tooling and package identity to 0.12.0 and extend offline regression checks for the Streams navigation/UI/telemetry contracts. + +## What changed in 0.11.5 + +- Rework shared-cache identity around Varnish built-in semantics: the primary object hash is vhost scope + normalized Host authority + exact URL, while response `Vary` selects representation variants. Unrelated request headers, frontend scheme and the selected load-balanced backend member no longer fragment the cache. +- Treat `GET` and `HEAD` as the cacheable methods. A cacheable HEAD shares the GET object and a HEAD miss fetches/stores the GET representation; other methods pass to the backend without implicitly clearing unrelated cached objects. +- Fix `X-Cache-Resident-Age` continuity. Resident time now survives successful 304 revalidation and hot/cold demotion/promotion instead of resetting to zero when metadata is refreshed or a cold object returns to RAM. +- Bump cold-cache metadata to format 2 for the Vary and resident-age fields, add variant-schema safety checks, and extend cache regressions for Vary selection, key sharing, HEAD eligibility and resident age. + +## What changed in 0.11.4 + +- Make Force SSL validation match redirect behavior: a selected plain HTTP listener is required, port 443 is preferred when several TLS ports are present, and ambiguous non-standard TLS targets get a clearer error. +- Replace the reusable ACL-profile free-form rule list with a compact visual rule builder plus an optional bulk import. Bulk parsing normalizes invisible Unicode spacing and reports the exact bad line instead of a generic syntax error. +- Suppress RustProxy cache diagnostic response headers entirely when the effective vhost cache is disabled, while keeping internal cache counters. +- In the dashboard backend-pool table, disabled vhosts are rendered muted and explicitly labeled Disabled instead of looking like failed backends. + +## What changed in 0.11.3 + +- Add a per-vhost **Forward client IP to backend** switch. It defaults ON; when disabled, application requests omit `Forwarded`, `X-Forwarded-For` and `X-Real-IP` while RustProxy, ACLs, rate limits, GeoIP and Tinyauth still use the authenticated client address. +- Add a compact Vhost search box that filters by vhost ID, hostname/wildcard, backend ID and backend URL without changing configuration. +- Fix the release manifest to use the resolved `quinn 0.11.12` already present in `Cargo.lock`, and stop `dev.sh` from regenerating a valid application lockfile on every run. +- Make the managed systemd service writable at `/var/log/rustproxy` so the JSONL file sink works with `ProtectSystem=strict`; the service now creates/manages the log directory through systemd. + +## What changed in 0.11.2 + +- Fix administration HTTPS over HTTP/2: the control-plane Host guard now accepts the protocol-native `:authority` value exposed by Hyper through the request URI instead of requiring a legacy `Host` field that browsers commonly omit on H2. +- Keep HTTP/1.x strict single-Host handling and reject absolute-form targets; on H2/H3, reject conflicting `Host` and `:authority` values rather than silently choosing one. +- Add regression tests for H2 authority handling and the real administration `/healthz` route so HTTPS administration works without forcing HTTP/1.1. +- Add per-vhost **Force SSL** directly below frontend selection. Plain HTTP receives a `308` to the same hostname/path/query on the selected HTTPS port; TLS listeners for a forced vhost must use one unambiguous port. +- Map Nginx Proxy Manager `ssl_forced` to RustProxy `force_ssl` during SQLite migration, retaining both HTTP and HTTPS listener bindings so the redirect actually works. + +## What changed in 0.11.1 + +- Fix managed-update health checks for administration HTTPS and persisted administration bind changes. The deployment checker now follows `administration.listen`, `administration.tls` and the configured Host allowlist instead of always probing the install-time address over plain HTTP. +- Add `scripts/migrate_npm.py`, a read-first Nginx Proxy Manager SQLite migrator for proxy-host names/backends. It preserves WebSocket/enabled/H2 intent, maps NPM TLS hosts to RustProxy TLS using the application default certificate, reports unsupported NPM features for manual review, writes a JSON plan and requires explicit `--apply` before changing RustProxy. +- Add offline regression coverage for the NPM migration planner and persisted HTTPS administration health-target resolution. + +## What changed in 0.11.0 + +- Add reusable ACL Profiles with country allow/block policy and IP/CIDR rules. Profiles can be attached to vhosts while existing per-vhost and route ACLs remain available and are enforced cumulatively. +- Improve hostname editing with chip-style input and leading `*.` wildcard domains. Exact hostnames take precedence, then the longest matching wildcard, so exact and wildcard vhosts can coexist on the same listener. +- Add compact per-vhost request-rate charts that retain the current browser panel session by progressively compacting older samples. +- Add the missing built-in HTTP 401 page and stop benign WebSocket EOF/reset/abort/broken-pipe closes after a successful upgrade from being reported as proxy body errors. +- Administration HTTPS now redirects plaintext requests on the HTTPS admin socket to the same authority/path over HTTPS. With no explicit admin hostname allowlist, valid DNS Host values are accepted; a configured allowlist remains strict. +- Named frontend listeners can bind the same policy to up to 16 explicit local addresses. Multi-address listeners reject wildcard `0.0.0.0`/`::` binds. +- Restyle the structured-log field builder to match the Vhost configuration modal and place its Configure fields action beside the preset selector. +- Source archives are now reproducible: sorted entries, deterministic timestamps/permissions and `SOURCE_DATE_EPOCH` support are verified by the release smoke test. + +## What changed in 0.10.0 + +- Cache now has two bounded tiers: hot RAM plus optional persistent cold disk storage. LRU/idle hot objects are demoted asynchronously; fresh cold hits are integrity-checked and promoted back to RAM. +- Cold cache settings expose the relative data path, disk/object limits and idle demotion delay. The Cache panel reports hot/cold objects, RAM/disk bytes, promotions, demotions, cold hits and errors. +- Cache diagnostics now distinguish browser hard-reload revalidation (`request_no_cache`, `request_max_age`, `request_pragma_no_cache`) from true cache misses. Normal browser cache can satisfy navigation without sending a request to RustProxy; use repeated `curl` GETs for proxy-level HIT testing. +- Logging format configuration adds Standard, Nginx-style, ECS-style and Minimal presets plus a graphical field picker/renamer with JSON preview. Advanced raw JSON mapping remains available. +- Logging sink cards reuse the shared square application card styling instead of release-specific rounded fieldset CSS. +- A baseline multi-stage Docker/OCI image is included. It runs as an unprivileged user, persists `/var/lib/rustproxy` and keeps administration loopback by default. Docker discovery uses the separate `docker-agent/`; the RustProxy container itself never needs the Docker socket. + +## What changed in 0.9.0 + +- Vhosts use readable cards with listener/protocol/TLS/cache identity and live backend health, request, active, error, latency and traffic metrics. +- Structured access logging can write configurable JSON/JSONL to one combined file or separate files per vhost, plus Logstash TCP and Loki HTTP(S), from a bounded asynchronous queue; sink delivery/drop/error counters are visible in the panel. +- Cache stored-object inspection reports body bytes, resident bytes and metadata overhead, with optional `X-Cache-Resident-Age`. +- Settings adds full-width Administration and TLS policy pages. The panel can run on HTTPS using the application-managed certificate or a selected certificate; a non-loopback bind is allowed when HTTPS is enabled. +- TLS 1.2/1.3 and rustls cipher suites are configurable globally and per vhost for TCP TLS. HTTP/3 remains tied to the global TLS 1.3 policy. +- Logging is a dedicated navigation page; Settings tabs fill the available width. +- Roadmap items completed here include remote HTTPS administration and `X-Cache-Resident-Age`. + +## What changed in 0.8.1 + +- Cache is enabled by default. +- Browser validators no longer cause blanket BYPASS: fresh `If-None-Match` / `If-Modified-Since` requests can be answered as local conditional HITs, including `304 Not Modified`. +- Request `no-cache`, `max-age` freshness constraints, `min-fresh` and legacy `Pragma: no-cache` use validator-based backend revalidation when a safe stored validator exists; backend `304` refreshes metadata and reuses the cached body. +- Request `no-store` remains a hard BYPASS. Response `Cache-Control: no-cache` may be stored when it has a usable validator, but is validated before every reuse; response `no-store`/`private` remain uncacheable. +- `cache.max_object_bytes` now permits values up to 1 GiB (still bounded by `cache.max_bytes`). Large fills grow incrementally and consume more fill permits instead of preallocating the configured object ceiling. +- Cache diagnostics add `cache_conditional_hit`, `cache_revalidated`, `request_no_cache`, `request_no_store` and related revalidation reasons. +- Release identity, tests, panel limits and cache documentation are updated to 0.8.1. + +## What changed in 0.8.0 + +- Per-vhost rate limiting plus country-specific limits and country block/allow-only rules, with built-in 429/403 pages. +- GeoIP country data is downloaded on first install from `sapics/ip-location-db` and verified against publisher SHA-256 files. Each managed update compares local files with the current publisher checksums and downloads only stale/missing assets. +- Optional Cloudflare real-client-IP forwarding per vhost, trusted only from Cloudflare edge CIDRs. +- Per-vhost HTTP/2 and HTTP/3 opt-outs live next to named frontend selection. Listener IP/ports and protocol capabilities stay in Settings; H2 ALPN is narrowed by SNI for vhosts that disable it. +- Health-check mode `off` now disables both active probes and passive health classification. +- New backend rows use the HTTPS-by-IP debugging defaults requested for preserved Host routing: default pool ON, private origin ON, HTTP/1.1, preserve Host ON and backend TLS verification OFF. The panel warns about this insecure TLS choice and about incomplete/mismatched debug settings. Existing backend values are not silently rewritten. +- Static-cookie cache boost is now visible globally and per vhost. The global defaults include common images, CSS/JS/WASM, fonts, audio/video and static downloads; matching assets ignore request Cookie while `Set-Cookie`, `private`/`no-store` and `Vary: Cookie` still forbid shared storage. +- Cache `Age` now follows corrected received age plus response delay without rounding sub-second delay up to a full second. +- Cargo, panel, OpenAPI, packaging and source-update metadata are 0.8.0. + +The 0.7.2 Swagger build fix remains in place: the pinned UI assets are prepared by +this project and embedded at build time without a mandatory Cargo environment setting. +The [0.7.1 source audit](docs/SECURITY_AUDIT.md) remains historical and is not a new +audit or proof of live security in 0.8.0. + +## Existing 0.7.0 foundation + +Managed certificates imported into the library renew automatically unless disabled. +Initial issuance/import is still explicit and staging-first; imported PEM alone is +not auto-managed. Existing 0.6 issuer metadata enables renewal by default, so review +managed certificates before upgrade. Nothing silently switches from staging to the +production CA. See [renewal and recovery](docs/RENEWAL.md). + +Read [installation and update](docs/INSTALL.md), [host access and locations](docs/VHOST_ACCESS.md), +[migration](docs/MIGRATION.md), [NPM migration](docs/NPM_MIGRATION.md), [ACME providers](docs/ACME_PROVIDERS.md), +[Prometheus](docs/PROMETHEUS.md), [cache diagnosis](docs/CACHE_DIAGNOSTICS.md), +[HTTP/3 scope](docs/HTTP3.md), [TCP/UDP streams](docs/STREAMS.md) and [container deployment](docs/CONTAINER.md). Cloudflare Tunnel remains deferred. Docker discovery is available through the standalone agent; see [Docker discovery](docs/DOCKER_DISCOVERY.md). +Examples are disposable full configurations: applying one replaces, not merges, +an existing document. + +## Requirements and first build + +### Debian 13+ one-line install/update + +The bootstrap installs the compiler/build dependencies and Rust, clones this repository and +builds the locked release. With no argument it runs in `auto` mode: a host without a managed +RustProxy installation is installed, while an existing `/opt/rustproxy/install.json` triggers +the safe `update.sh` path with backup, health check and rollback. First install starts HTTP on +`0.0.0.0:80`, HTTPS on `0.0.0.0:443` and the administration panel on plain HTTP +`0.0.0.0:9090` with the explicit `--allow-remote-admin` gate. Updates preserve the saved listener and +service settings. Restrict TCP/9090 with a firewall/private network. + +```bash +# automatic: install on a new host, update an existing managed installation +wget -qO- https://git.linuxiarz.pl/gru/RustProxy/raw/branch/master/install-debian13.sh | bash + +# explicit update +wget -qO- https://git.linuxiarz.pl/gru/RustProxy/raw/branch/master/install-debian13.sh | bash -s -- update +``` + +Manual build prerequisites used by that bootstrap: + +```bash +apt install -y build-essential gcc git ca-certificates python3 pkg-config wget sudo python3-certbot-dns-rfc2136 python3-certbot-dns-cloudflare +wget -qO- --https-only --secure-protocol=TLSv1_2 https://sh.rustup.rs | sh +``` + +Rust stable (declared minimum 1.88), Cargo, C compiler/linker, Bash and Python 3.10+. +Linux is the primary deployment target; +macOS/WSL2 need normal native build tools. Detailed OS diagnostics currently use +Linux procfs and cgroup v2. Unsupported measurements remain null/N/A. + +The first build needs access to the Rust toolchain and crate registry. `dev.sh` +never installs system software or runs sudo. Rustls handles runtime TLS; zstd's +native dependency still needs a C build toolchain. + +The Debian bootstrap intentionally builds with one Cargo job, LTO disabled and +`codegen-units=8`. This avoids `rustc` being killed by the OOM killer on small VPSes +while leaving the repository's normal release profile unchanged. For a manual +low-memory build use: + +```bash +CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_LTO=false \ + CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8 cargo build --release --locked +``` + +The 0.22.1 source archive preserves the resolved `Cargo.lock` supplied with the source tree and +updates only this package's own version entry. This packaging environment has no Cargo, so the +dependency graph was not re-resolved here. On the compiler-enabled deployment host, run the +locked check/test/release gates below and retain the verified lockfile. `dev.sh` can generate a +lockfile only when one is genuinely absent; it does not replace normal locked verification. + +```bash +cd RustProxy +bash dev.sh doctor +bash dev.sh check +bash dev.sh test +bash dev.sh +``` + +| Service | Development default | +|---|---| +| Default HTTP | `http://127.0.0.1:8080` | +| Default HTTPS | `https://localhost:8443` | +| Administration (default) | `http://127.0.0.1:9090`; HTTPS/remote bind is opt-in in Settings | +| Optional protocol fixture | `127.0.0.1:9000`, only with `RUST_PROXY_DEMO=1` | + +In a second terminal, print the one-time setup capability and use the browser, +or initialize an administrator locally: + +```bash +bash dev.sh admin setup-token +# Alternative: interactive account creation, no default credentials. +bash dev.sh admin init +``` + +There is no default username/password. Normal startup serves a neutral informational +page for an unconfigured address and does not seed sample vhosts. The self-signed +bootstrap certificate is retained under `data/certs` and is not publicly trusted. +Assign intended certificates explicitly. Public frontend TLS does not implicitly expose the separate administration listener. Administration remains loopback HTTP by default; enable its own HTTPS setting before choosing a remote bind. Existing databases are never reset by this default. + +Use the protocol fixture only explicitly when running the documented test suites; +`RUST_PROXY_DEMO=1 bash dev.sh` seeds a new disposable database. The separate +`bash dev.sh mock` helper remains available for cache/backend testing. + +For a system service, build as an ordinary user and follow the complete procedure +in [INSTALL.md](docs/INSTALL.md): + +```bash +bash dev.sh release +bash install.sh --plan --binary target/release/rust-proxy +sudo bash install.sh --binary target/release/rust-proxy +# A subsequent release: +sudo bash update.sh --binary target/release/rust-proxy +``` + +Copy `.env.example` to `.env` for startup defaults; it is trusted shell code. +`RUST_PROXY_DEMO=0` disables the demo. `RUST_PROXY_TLS=0` removes the startup TLS +default used when bootstrapping listeners. + +The release checker reads its compile-time defaults from repository-root `UPDATE_CHECK.toml`, +which is embedded in the final binary. It checks the configured `Cargo.toml` URL immediately +on startup and then every 3600 seconds by default. A failed request is ignored until the next +interval; it never blocks startup or proxy traffic and it never installs updates. Operators may +override the embedded values with global CLI options or equivalent environment variables: + +```text +--update-repository-url RUST_PROXY_UPDATE_REPOSITORY_URL +--update-version-url RUST_PROXY_UPDATE_VERSION_URL +--update-check-interval-secs RUST_PROXY_UPDATE_CHECK_INTERVAL_SECS +``` + +Set the interval to `0` to disable automatic checks. Debian/systemd installs may place the +environment overrides in `/etc/rustproxy/environment`; source/development runs may use the +normal process environment. + +Persisted frontend sockets live only in +`listeners.endpoints` and are edited under Settings -> Listeners. Listener address/TLS/H2/H3 +capability changes need a process restart; vhost H2/H3 switches only narrow those +capabilities and routing/pool policies otherwise use validated hot publication. + +## Private vhosts and backend pools + +Use **Settings -> Listeners** to configure frontend sockets, then use **Vhosts** to +select those named listeners and configure backend members. `examples/vhosts-private.json` +binds its named listener only to `127.0.0.2` and uses two +explicit loopback origins. Replace these with addresses assigned to your machine +and your real services. `examples/vhosts-balanced.json` shows an opt-in public +80/443 deployment; do not apply it unintentionally. + +A private vhost is selected by hostname **and the accepted socket's actual local +IP, port and TLS state**. Sending its Host/X-Forwarded-* values to a different IP +does not select it. A shared wildcard socket may accept TCP at multiple addresses, +but each vhost keeps its original routing constraints. IP binding is not a firewall +or authentication: NAT/another proxy can still forward traffic to that private IP. +Keep network restrictions and application authentication. + +New health-checked members remain in `checking` until the configured successes +make them eligible. No healthy member means 503; a healthy backup may be selected. +HTTP checks measure time to response headers, not whole-body throughput. Affinity +cookies bypass shared cache; they contain only an opaque routing token and are not +login sessions. Existing cookie sessions may finish on a draining healthy member. +The keepalive controls are connection reuse, **not Keepalived/VRRP**. + +Save pool/routing/security changes live. New/removed sockets and TLS ALPN changes +need a process restart; active vs desired endpoints are visible in Settings/API. +Old top-level routes are converted once into vhosts during validated startup/import. +There is no separate legacy routing UI or dispatcher; path-to-origin ownership is +retained. Read the migration guide before updating an existing database. See [VHOSTS.md](docs/VHOSTS.md) +for algorithms, defaults, bounds, caveats and the complete configuration contract. + +## Standard ports without running as root + +Settings -> Listeners accepts multiple named endpoints and socket addresses such as `0.0.0.0:80`, +`0.0.0.0:443`, `[::]:443` and custom ports. Presets change only the port: they do +not silently replace a loopback interface with a public one. The admin listener is independent of these controls and stays loopback by default. In Settings -> Administration, enabling HTTPS permits a persisted non-loopback bind using either the application-managed certificate or a selected certificate. Plain remote HTTP remains available only with the explicit `--allow-remote-admin` startup flag. + +Linux low-port binding can require `CAP_NET_BIND_SERVICE`. Use a dedicated service +account and the example in [deploy/README.md](deploy/README.md), not a root-run +application. Port conflicts and OS permissions cannot be proved by merely saving +configuration; bind failures are reported on restart. See +[settings, defaults and rollback](docs/SETTINGS.md). + +## Cache and response controls + +The shared cache is enabled by default. Normal fresh `200 GET` and cacheable HEAD +requests use `s-maxage`, `max-age` or `Expires`; a configurable default TTL applies only +to explicit `public` responses without an origin freshness lifetime and defaults to +zero. GET/HEAD share the same frontend scheme + Host + exact URL object, response `Vary` selects +representation variants, and unrelated request headers or the selected backend-pool +member do not fragment that object. Since 0.19.1 HTTP and HTTPS use distinct primary +cache identities so scheme-dependent origin responses cannot cross protocols. Non-GET/HEAD methods pass without globally +purging unrelated cached objects. + +Cookies (except the configured static-asset exception), authorization, ranges, +`If-Match`/`If-Unmodified-Since`, private/no-store responses, Set-Cookie, `Vary: *`, +streaming events and failed/incomplete bodies are not reused. `If-None-Match` and +`If-Modified-Since` can be answered from a fresh cache entry as local conditional HITs. +Request `no-cache`/freshness constraints revalidate a stored validator instead of +blanket-bypassing the cache. Response `no-cache` with a usable validator may be retained, +but must validate before every reuse. Request/response `no-store` remains a hard exclusion. + +Default cache memory limits are 256 MiB HOT allocated/charged data, 16 MiB per object, 64 MiB transient RAM, a 32 MiB COLD writer queue, 4096 HOT entries and eight fill permits. The panel also offers memory-aware conservative/recommended/performance presets derived from the lower of detected host RAM and a finite cgroup memory limit; automatic presets are disabled below 256 MiB effective RAM. `max_object_bytes` remains an independent per-object ceiling and cannot exceed the global HOT budget. If it is configured above the transient budget, the panel warns that objects near that size will pass through uncached because a fill cannot reserve enough bounded transient RAM. Optional cold cache defaults to disabled; when +enabled it uses a bounded disk path under the private data directory and asynchronously +demotes LRU/idle hot objects. Cold objects survive process restart for the same +configuration revision and are promoted to RAM on a fresh hit. Large fill buffers grow +incrementally and consume multiple permits. Actual process RSS also includes in-flight +responses, compression buffers and runtime allocations. HIT/MISS/BYPASS, hit count and +standardized RFC 9211 `Cache-Status` diagnostics are independently optional; required Age remains on cache hits. Optional `X-Cache-Resident-Age` tracks RustProxy residency and now survives revalidation/tier movement. +Purge is authenticated, CSRF-protected, audited and invalidates both tiers plus older +fills. See [cache behavior and limits](docs/CACHE.md) and +[cache diagnostics](docs/CACHE_DIAGNOSTICS.md). + +Response compression is streamed and negotiated with Accept-Encoding; enabled +algorithms are gzip, Brotli, Zstandard and deflate. Privacy-sensitive, ranged, +upgraded, pre-encoded and no-transform responses are excluded. Strong ETags are +weakened after transformation, and obsolete content digests are removed. Server +policy is applied after generic response rules. Protected framing/identity headers +cannot be edited through generic rules. + +With the separate demo enabled, `/cache-demo` returns an explicitly public 30-second +object. Enable cache and X-Cache in Settings, then issue this twice without cookies: + +```bash +curl -i -H 'Host: localhost' http://127.0.0.1:8080/cache-demo +``` + +The first eligible request is MISS, then HIT while fresh. The origin timestamp +stays unchanged on HIT. Unlike proxy/system measurements, this endpoint is explicitly +demo content. The reserved admin cookie is removed before eligibility; application cookies still +trigger privacy bypass. Use anonymous curl requests to check cache behavior. + +## Adding a language + +Create `langs/de_DE.json` with metadata and string translations, then rebuild and +restart. No JavaScript language switch or registry edits are needed: + +```json +{ + "meta": { + "locale": "de-DE", "name": "German", "native_name": "Deutsch", "direction": "ltr" + }, + "translations": { "settings": "Einstellungen", "overview": "Ubersicht" } +} +``` + +Missing keys fall back to `en_US.json`. Translation strings are rendered as text, +not HTML; locale and direction metadata also control formatting/RTL. The two +bundled languages are English and Polish. New packs are compile-time assets, not +untrusted runtime plugins. See [langs/README.md](langs/README.md). + +## Updating an installed source tree + +Read [MIGRATION.md](docs/MIGRATION.md). Back up actual data/certificates, stop the +old server and extract this archive into a separate, non-nested directory. From +the new source directory: + +```bash +python3 scripts/backup.py --data-dir /actual/RustProxy/data /private/backup-before-0.13.2 +bash upgrade.sh /actual/RustProxy --stopped +# Also pass --data-dir /actual/private/data when it is external. +cd /actual/RustProxy +bash dev.sh test +bash dev.sh +``` + +`.env`, data, unmanaged files and previous managed sources are preserved. Extra +language JSON files survive the update; shipped files replace their old names, +whose previous bytes remain in the backup. Unknown custom source edits are not +merged. Do not overlay individual source files onto an old tree. + +RustProxy 0.13.2 advances SQLite to schema version 3 by adding the non-secret +`certificate_renewals` observation table; configuration-document fields still use defaults +when omitted. Once a 0.13.2 database/document is saved, an older binary may reject the +newer schema or unknown fields. Roll back +using the pre-upgrade data backup as well as the old source, not just the binary. + +## Documentation and checks + +[documentation index](docs/README.md), [API](docs/API.md), [architecture](docs/ARCHITECTURE.md), +[security](docs/SECURITY.md), [transport](docs/TRANSPORT.md), +[vhosts and pools](docs/VHOSTS.md), [settings](docs/SETTINGS.md), [cache](docs/CACHE.md), +[roadmap](ROADMAP.md), [changelog](CHANGELOG.md), [verification](docs/TEST_REPORT.md). + +```bash +# Rust-independent checks: +python3 scripts/check_source.py +python3 scripts/release_smoke.py +# Runs those checks first, then the Rust suite when Cargo is available: +bash dev.sh test +# Source-only package, with no Git checkout required: +python3 make_zip.py /tmp/RustProxy-0.22.1.zip +``` + +## New management and test-origin guide + +See [MANAGEMENT.md](docs/MANAGEMENT.md) for the certificate staging workflow, +page/status behavior and logs; [MOCK_SERVER.md](docs/MOCK_SERVER.md) provides a +ready-to-use cache/health test setup. The example `examples/mock-vhost.json` is a +complete minimal document for a fresh test installation, not a merge patch. + +## Authentication and mock development + +In **Vhosts -> Authentication**, enable Tinyauth and supply its base URL (or the +complete `/api/auth/nginx` endpoint) and public login URL. Private verifiers need +the explicit private-network permission; optionally constrain allowed networks. +Default identity headers are Remote-User and Remote-Email. No embedded provider +password or disabled TLS verification is required. Do not protect the login host +with itself. See INTEGRATIONS_040.md for exact status/redirect behavior and limits. + +```bash +# Separate terminals, after building the real proxy on a toolchain-enabled host: +bash dev.sh mock --port 9001 --name origin-a --ttl 30 +bash dev.sh mock-auth --port 9300 +``` + +The fake-auth server accepts the intentionally insecure cookie `mock_auth=allow`; +it is a loopback-only development peer, never an authentication product. + +### Docker vhosts operational controls + +Docker vhosts now publish/unpublish immediately per service. Each Docker agent has a configurable inventory polling interval, while the optional Live view refreshes cached runtime state only. Docker-backed applications can use ACL profiles/inline ACLs and per-vhost proxy connect/header/read/send timeouts, matching the ordinary vhost proxy controls. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..ed8c332 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,658 @@ +# RustProxy roadmap + +## 0.24.5 + +Current direction: 0.24.4 -> 0.24.5 + +- [x] Resolve unknown-length, actually empty HTTP/3 bodies before HTTP/1.1 upstream framing. +- [x] Preserve streaming for non-empty H3 bodies and declared `Content-Length` requests. +- [x] Add source-level regression coverage for DELETE and other body-capable methods. +- [x] Bound HOT by allocation-aware admission, enforce `max_object_bytes` during fill, and add a separately bounded transient/COLD-queue memory model. +- [x] Preserve HOT -> COLD ownership semantics while charging demoted payloads until persistence drops them; expose logical/allocated/transient telemetry. +- [x] Make global cache disable authoritative over vhost overrides and remove the artificial 1 GiB HOT/object ceiling. +- [x] Add host/cgroup-aware conservative/recommended/performance cache presets and non-blocking memory/headroom warnings in Settings. +- [x] Update source/package/UI/OpenAPI identity and offline release checks to 0.24.5. +- [ ] Run compiler-enabled `./dev.sh check` / `./dev.sh test`, the H1/H2/H3 method matrix, and cache RSS/HOT/COLD stress acceptance on the release host. + +## 0.24.4 + +Current direction: 0.24.3 -> 0.24.4 + +- [x] Make the Linux THP mitigation process-local with `PR_SET_THP_DISABLE` before Tokio worker creation. +- [x] Preserve the existing glibc arena limit and allocator-trim behavior. +- [x] Add source/release regression coverage for THP opt-out ordering and release identity. +- [ ] Run compiler-enabled `./dev.sh check` / `./dev.sh test`, release build and a sustained production RSS comparison with host THP returned to its normal policy. + +## 0.24.3 + +Current direction: 0.24.2 -> 0.24.3 + +- [x] Reduce Linux/glibc arena retention by adding `MALLOC_ARENA_MAX=2` to managed systemd units. +- [x] Remove redundant identity-body and JavaScript/CSS transformation allocations; release successful-transform temporaries earlier and trim large glibc transform churn at a bounded cadence. +- [x] Move HTTP/3 enablement to listener editing while keeping HTTP/3 tuning under HTTP connections. +- [x] Rename Proxy Process to Process and hide unsupported platform diagnostics dynamically. +- [x] Remove the stale unreferenced per-BSD parser source from the release archive. +- [x] Update source/package/UI/OpenAPI identity and Rust-independent release gates to 0.24.3. +- [ ] Run compiler-enabled `./dev.sh check` / `./dev.sh test`, release build and sustained cache/transform RSS acceptance on the release host. + +## 0.24.2 + +Current direction: 0.24.1 -> 0.24.2 + +- [x] Bump package/source/UI/OpenAPI release identity to 0.24.2 without functional changes. + +## 0.24.1 + +Current direction: 0.24.0 -> 0.24.1 + +- [x] Replace the duplicated BSD statistics implementation with shared cross-platform library APIs. +- [x] Preserve Linux-specific `/proc`/cgroup diagnostics as additive compatibility fields. +- [x] Populate additional portable system/process metrics exposed by the selected libraries where available. +- [x] Expose low-risk runtime metadata through the authenticated status API, including the resolved data/database path. +- [x] Make CLI installed-data discovery use Linux and BSD/OPNsense conventions without hardcoding the path in the browser. +- [x] Move Logging into Settings and retain legacy `/logging` navigation compatibility. +- [x] Fix the release-host compiler error in portable uptime sampling and remove the stale H2 import warning. +- [x] Remove artificial hostname-count ceilings from certificate bindings/issuance and hostname-list configuration while retaining syntax, payload and resource-safety bounds. +- [x] Update source/package/UI/OpenAPI identity and source-side regression contracts to 0.24.1. +- [ ] Generate and commit a resolver-produced `Cargo.lock`, then run compiler-enabled `cargo fmt --all`, `cargo check --locked --all-targets` and `cargo test --locked --all-targets` on the release host. +- [ ] Run native FreeBSD/OpenBSD/NetBSD diagnostics acceptance and final H2/H3/WebSocket/proxy-statistics regression. + +See [the 0.24.1 specification audit](docs/V0.24.1_AUDIT.md). + +## 0.24.0 + +Current direction: 0.23.3 -> 0.24.0 + +- [x] Graceful H2 idle retirement with activity/stream recheck, GOAWAY and bounded drain. +- [x] Independent H2 maximum connection lifetime and drain timeout settings. +- [x] Settings API/UI/OpenAPI contracts for all three H2 lifecycle limits. +- [x] Extract system statistics into platform backends without changing Linux metric semantics. +- [x] Add FreeBSD/OpenBSD/NetBSD native best-effort sampling and explicit unsupported/null metrics. +- [x] Surface platform in Proxy Process and Prometheus metadata. +- [x] Update source/package/UI identity and source-side regression contracts to 0.24.0. +- [ ] Run compiler-enabled `cargo fmt --all`, `cargo check --locked --all-targets` and `cargo test --locked --all-targets` on the release host. + +See [the 0.24.0 specification audit](docs/V0.24.0_AUDIT.md). + +## 0.23.3 + +Current direction: 0.23.2 -> 0.23.3 + +- [x] Add opt-in per-vhost legacy WebSocket compatibility without changing ordinary HTTP behavior. +- [x] Force legacy-mode upstream WebSocket handshakes to HTTP/1.1 and force legacy WebSocket `Host` to the configured backend authority and align `Origin` with it. +- [x] Tolerate old `101` response framing while keeping the WebSocket accept proof and subprotocol validation strict. +- [x] Add dashboard/OpenAPI/language-pack controls and source regressions. +- [x] Update package/source identity and Rust-independent release gates to 0.23.3. +- [ ] Run compiler-enabled `./dev.sh check` / `./dev.sh test`, release build and live iLO4/legacy WebSocket acceptance on the release host. + +See [the 0.23.3 specification audit](docs/V0.23.3_AUDIT.md). + +## 0.23.2 + +Current direction: 0.23.1 -> 0.23.2 + +- [x] Let Cache and Transformation live charts retain 60, 120, 300 or 600 samples without changing the 3-second polling cadence. +- [x] Persist each chart history-window choice locally in the browser and keep history memory bounded. +- [x] Fix local CLI administration transport detection so HTTPS administration no longer traps `--livelogs` in a 308 reconnect loop. +- [x] Pin local CLI HTTPS to certificates from the private RustProxy certificate directory and reuse the transport for config/TLS CLI calls. +- [x] Update package/source identity and Rust-independent release gates to 0.23.2. +- [ ] Run compiler-enabled `./dev.sh check` / `./dev.sh test`, release build and live HTTPS `--livelogs` acceptance on the release host. + +See [the 0.23.2 specification audit](docs/V0.23.2_AUDIT.md). + +## 0.23.1 + +Current direction: 0.23.0 -> 0.23.1 + +- [x] Add default-collapsed, full-width live activity charts to the Cache and Transform workspaces. +- [x] Plot selected-scope Cache HIT/MISS/BYPASS rates from runtime counters without adding proxy-path work. +- [x] Plot selected-scope Transform applied/cache-HIT/skipped/error rates from the existing runtime metrics endpoint. +- [x] Reuse the dashboard SVG/chart styling, live 3-second refresh and language-pack model. +- [x] Update package/source identity and Rust-independent release gates to 0.23.1. +- [ ] Run compiler-enabled `./dev.sh check` / `./dev.sh test`, release build and browser acceptance with sustained Cache/Transform traffic on the release host. + +See [the 0.23.1 specification audit](docs/V0.23.1_AUDIT.md). + +## 0.23.0 + +Current direction: 0.22.3 -> 0.23.0 + +- [x] Add scoped external certificate API authentication with Key ID + Bearer Token, hash-only storage, rotation/revoke/status and last-used metadata. +- [x] Restrict the external credential to certificate deploy/reload and require HTTPS outside loopback. +- [x] Add Settings → Certificate API UX and preserve direct production binary commands for local deploy/reload. +- [x] Add `rust-proxy --livelogs` with a bounded live stream, interactive structured filters, detailed request inspection and explicit lag reporting. +- [x] Split Settings → Cache into General, Resilience, Cold cache and Static & diagnostics sub-tabs without changing the atomic cache settings document. +- [ ] Run compiler-enabled `rust-proxy`/Cargo check, tests and release build plus live token-auth certificate rotation and sustained high-rate live-log acceptance on the release host. +- [ ] Upstream mTLS/client certificate remains deliberately deferred. + +See [the 0.23.0 specification audit](docs/V0.23.0_AUDIT.md). + +## 0.22.3 + +Current direction: 0.22.2 -> 0.22.3 + +- [x] Add HTTP/1.1 `Upgrade: h2c` on opt-in cleartext H2 listeners while preserving prior-knowledge h2c. +- [x] Validate Upgrade/HTTP2-Settings and reject malformed or body-bearing bootstrap requests before `101`. +- [x] Reconstruct the initiating H1 request as HTTP/2 stream 1 and retain the normal Hyper H2 path for later streams. +- [x] Add dedicated certificate hot reload without mutating desired configuration or revision. +- [x] Add external certificate deployment for Certbot/manual issuers with immutable generation staging and atomic multi-vhost switch by certificate id. +- [x] Add API/CLI/panel support plus audit/OpenAPI/documentation for certificate reload/deploy. +- [ ] Run compiler-enabled `./dev.sh check` / `./dev.sh test`, release build and live H1 -> h2c Upgrade -> H2 plus TLS/H2/H3 certificate-rotation acceptance on the release host. +- [ ] Upstream mTLS/client certificate remains deliberately deferred. + +See [the 0.22.3 specification audit](docs/V0.22.3_AUDIT.md). + +## 0.22.2 + +Current direction: 0.22.1 -> 0.22.2 + +- [x] Add replay-safe failover retry with bounded policy and observability. +- [x] Correct explicit HTTP/2 upstream request versioning and retain h2c prior-knowledge regression coverage. +- [x] Add per-backend maximum concurrent proxy exchanges with bounded queue timeout / 503 behavior and metrics. +- [x] Add detailed upstream failure classification plus last failure and health ejection/recovery timestamps. +- [x] Consolidate backend operations data in the existing dashboard table: health, active/limit, requests/errors, latency, traffic, last failure and last recovery. +- [x] Parse and expose public-certificate issuer/SAN/validity/remaining-days/expiry status plus renewal status/last attempt/error. +- [ ] Add a distinct maximum TCP connection/socket limit per backend; request concurrency is implemented but is intentionally not presented as a socket limit. +- [ ] Run compiler-enabled `./dev.sh check` / `./dev.sh test` and live H1/H2/H3 plus H2 -> proxy -> H2 acceptance on the release host. + +See [the 0.22.2 specification audit](docs/V0.22.2_AUDIT.md). + +## 0.22.1 + +Current direction: 0.22.0 -> 0.22.1 + +- [x] Move selective PURGE/BAN and active BAN management into dedicated Cache modals with inline operation feedback. +- [x] Add per-vhost RFC 8441 HTTP/2 WebSocket Extended CONNECT beside RFC 9220. +- [x] Bridge H2 RFC 8441 WebSockets to H1 upstreams and preserve Extended CONNECT to explicit H2 upstreams. +- [x] Add opt-in cleartext HTTP/2 prior-knowledge (`h2c`) listeners. +- [x] Add stable active-tunnel inventory, per-tunnel kill, operator reason and audit logging. +- [x] Split WebSocket tunnel statistics by H1 / H2 RFC 8441 / H3 RFC 9220 on the main dashboard and Prometheus. +- [x] Preserve the 0.22.0 cache feature set and `Accept-Ranges` policy. +- [x] Update 0.22.1 source/package identity and Rust-independent release gates. +- [ ] Run the compiler-enabled `./dev.sh check` / `./dev.sh test` gate plus live H1/H2/H3/h2c/RFC8441 acceptance on the release host. + +See [the 0.22.1 specification audit](docs/V0.22.1_AUDIT.md). + +## 0.21.0 + +Current direction: 0.20.0 -> 0.21.0 + +- [x] Shard Cold Cache storage and add bounded asynchronous GC plus selective cleanup. +- [x] Add RFC 5861 stale windows, request `max-stale`, correct request `max-age=N`, and correct revalidation-directive semantics. +- [x] Add the extensible decode -> transform -> canonical cache -> encode stage with transformed lengths/validators, strict `no-transform`, HEAD/fill safety and canonical `Vary: Accept-Encoding` handling. +- [x] Add global, per-vhost and Docker-discovery transformation controls and administration UI/API coverage. +- [x] Make Passive Backend Health explicit opt-in for ordinary and Docker vhosts while preserving independent active health checks and unconditional observability. +- [x] Keep route cache override tri-state and expose effective route/cache/transform diagnostics. +- [x] Close 0.21.0 with the current conservative JS/CSS transformers; defer parser-backed replacements to a later development cycle. +- [ ] Release-host gate: run Rust 1.88+ fmt/check/test/clippy/release-build and live H1/H2/H3/cache/compression/restart acceptance before deployment. +- [x] Finish release polish: backend timing header rename, human-readable uptime, transformed Live traffic series, Docker Passive Backend Health layout fix, warning cleanup and cache module split. +- [x] Keep package/source identity and OpenAPI at 0.21.0 and package the completed feature scope as the 0.21.0 release source. + +See [the 0.21.0 specification audit](docs/V0.21.0_AUDIT.md). + +## 0.20.0 + +Current direction: 0.19.4 -> 0.20.0 + +- [x] Split Certificates into Installed / Renewal / Generate tabs. +- [x] Move generation/import into the tabbed workspace and keep existing certificate actions compatible. +- [x] Improve ACME/renewal status readability and display exact Certbot log locations on failures. +- [x] Update package/source identity, OpenAPI and release checks to 0.20.0. + +## 0.19.4 + +Current direction: 0.19.3 -> 0.19.4 + +- [x] Normalize Docker-vhost Aliases field geometry with neighboring inputs and remove the extra inherited label spacing. +- [x] Retain `0.0 KiB/s` idle and `<0.1 KiB/s` positive low-rate backend traffic formatting with release regression coverage. +- [x] Add Cache domain-table filtering by resolved cache-enabled state without changing aggregate cache statistics. +- [x] Update package/source identity, OpenAPI and release checks to 0.19.4. + +## 0.19.3 + +Current direction: 0.19.2 -> 0.19.3 + +- [x] Preserve the exact panel and Settings tab through session expiry/re-login without shell flicker. +- [x] Canonicalize `/configuration` into persistent Settings tab routing. +- [x] Add shared save-button busy state and field-focused validation feedback. +- [x] Preserve certificate import file selections after failed saves. +- [x] Add per-Settings-tab unsaved-change indicators, including Manual config. +- [x] Add persisted dark/light administrator theme selection and consolidate theme-sensitive CSS tokens. +- [x] Keep Live Logs column selection compact with standard checkboxes and no internal scrollbars. +- [x] Add a fail-soft embedded-config release checker with hourly default cadence and CLI/environment overrides. +- [x] Keep the release checker compiler-compatible by mapping boxed HTTP body collection failures explicitly into `anyhow::Error`. +- [x] Reduce the footer to API latency/dropped logs and fix the last broad Live Logs checkbox sizing override. +- [x] Update package/source identity, OpenAPI and release checks to 0.19.3. + +## 0.19.2 + +Current direction: 0.19.1 -> 0.19.2 + +- [x] Restore administration-backend local-listener risk detection for startup-locked listeners and loopback aliases; make vhost discard restore the persisted draft baseline. +- [x] Use descriptive status names for default responses. +- [x] Add an on-demand 4096-entry live log buffer that lasts until the log window closes. +- [x] Align inline checkboxes and reduce Docker-vhost alias editor height. +- [x] Render idle backend traffic as `0.0 KiB/s` and positive sub-0.1 KiB/s traffic as `<0.1 KiB/s`. +- [x] Remove cache diagnostics/validators from locally generated placeholder/default pages and enforce `no-store`. +- [x] Allow safe vhost-ID rename with collision protection and operator warning. +- [x] Update package/source identity, OpenAPI and release checks to 0.19.2. + +## 0.19.1 + +Current direction: 0.19.0 -> 0.19.1 + +- [x] Repair Default responses section serialization/server routing. +- [x] Isolate cache objects by frontend scheme. +- [x] Keep a 256-entry live log window with 4096 retained entries available on demand. +- [x] Polish backend traffic units, checkbox/column UI and Docker-vhost Advanced layout/alias spacing. +- [x] Remove stale screenshots and index historical/version-labelled documentation. +- [x] Restore source-package test files/fixtures required by the release gates. +- [x] Update package/source identity, OpenAPI and release checks to 0.19.1. + +## 0.19.0 + +Current direction: 0.18.8 -> 0.19.0 + +- [x] Persist per-admin Live Logs column visibility in SQLite without affecting JSON export or proxy configuration revisions. +- [x] Remove the duplicate top-right version badge. +- [x] Carry the legacy X.509 `tls_verify=false` compatibility fix and expanded upstream diagnostics into the release. +- [x] Add schema-v4 migration, administration API coverage and update package/source identity to 0.19.0. + +## 0.18.8 + +Current direction: 0.18.7 -> 0.18.8 + +- [x] Use hostname-style chips for Docker-vhost aliases. +- [x] Add per-app HTTP-to-HTTPS redirect, WebSocket and cache switches for Docker vhosts. +- [x] Add an agent-level default ACL-profile selector without removing per-app ACL overrides/inline rules. +- [x] Remove the compression-codec explainer from Overview. +- [x] Update package/source identity, API schema and release checks to 0.18.8. + +## 0.18.7 + +Current direction: 0.18.6 -> 0.18.7 + +- [x] Keep Response time history collapsed by default without stopping its live samples. +- [x] Make per-interface RX/TX charts visually consistent with the aggregate network history. +- [x] Fix expanded Docker-vhost route/ACL field alignment from the 0.18.6 UI. +- [x] Update package/source identity and release checks to 0.18.7. + +## 0.18.6 + +Current direction: 0.18.5 -> 0.18.6 + +- [x] Add network-throughput diagnostics split by non-loopback host interface. +- [x] Re-audit compression counters and add q-weighted negotiation coverage for all four supported codecs. +- [x] Polish Docker-agent settings alignment and spacing around listener selection / Add agent. +- [x] Update package/source identity and release checks to 0.18.6. + +## 0.18.5 + +Current direction: 0.18.4 -> 0.18.5 + +- [x] Make compression counters track the codec actually applied by RustProxy and add independent per-codec regression coverage. +- [x] Split the Overview request-rate chart into total, HTTP/1.1, HTTP/2 and HTTP/3 series. +- [x] Add a proxied-app launcher to Docker vhosts and show their domains beside generated backend-pool IDs. +- [x] Remove the global Open proxy shortcut. +- [x] Make Compact density clearly tighter than Dense. +- [x] Give proxy timeout controls explicit safe defaults instead of blank inherited values. +- [x] Replace Docker-agent listener CSV entry with the named-listener picker and tighten the agent status-row UX. +- [x] Update package/source identity and release checks to 0.18.5. + +## 0.18.4 + +Current direction: 0.18.3 -> 0.18.4 + +- [x] Make TCP the default backend health-check mode without rewriting existing explicit HTTP/off configuration. +- [x] Make Docker vhosts summary-first so large container inventories do not render every routing/ACL/timeout/health control at once. +- [x] Simplify Settings -> Security with progressive disclosure for credential and host-policy editing. +- [x] Move Administration audit to a dedicated on-demand modal. +- [x] Update package/source identity and release checks to 0.18.4. + +## 0.18.3 + +Current direction: 0.18.2 -> 0.18.3 + +- [x] Export Docker vhosts/backends under dedicated Prometheus metric families rather than ordinary vhost/backend families. +- [x] Add a separate live Docker-vhosts metric-group switch in Prometheus settings. +- [x] Cover Docker vhost traffic/duration and backend health/latency/probe/runtime observations with bounded labels. +- [x] Add regression checks for ordinary-vs-Docker metric isolation. +- [x] Add configurable separate access-log files per vhost and preserve bounded asynchronous sink behavior. +- [x] Lock startup-owned HTTP/HTTPS/admin binds while keeping non-startup listeners editable. +- [x] Polish compact UI, 404 response statuses, redirect summaries, global TLS cipher visibility and managed-certificate lifecycle UX. +- [x] Default WebSocket proxying to HTTP/1.1 Upgrade and make RFC 9220 HTTP/3 Extended CONNECT an explicit experimental vhost opt-in. + +## 0.18.2 + +Current direction: 0.18.1 -> 0.18.2 + +- [x] Serve the standalone Docker agent over HTTPS by default using a persistent self-signed ECDSA certificate. +- [x] Add SHA-256 certificate pinning for self-signed Docker agents while retaining normal public-CA validation when no pin is configured. +- [x] Add per-service Docker backend health-check settings backed by the normal bounded backend health machinery. +- [x] Show Docker backend health/runtime diagnostics and agent TLS/observation state in the Docker vhosts UX. + +## 0.18.1 + +Current direction: 0.18.0 -> 0.18.1 + +- [x] Add a standalone, token-authenticated Docker discovery agent that reports only running containers with non-loopback published TCP ports. +- [x] Add multi-agent endpoint/token/base-domain/listener/default-IP settings and keep observed Docker inventory separate from desired persisted assignments. +- [x] Detect multiple usable Docker-host addresses and preserve every public Docker bind for each discovered service. +- [x] Add a Docker vhosts panel with short names, arbitrary FQDNs, aliases and per-service backend-IP selection; reconcile assignments into dynamic ephemeral vhosts without revision writes. +- [x] Move the cache Live toggle to the right edge of the cache action toolbar. +- [x] Remove all dedicated `X-Rust-Proxy-Revision` handling; it is no longer generated, stripped, protected or migrated. +- [x] Replace production password recovery help with the installed `rust-proxy admin reset-password` command. + +## 0.17.0 + +Current direction: 0.16.1 -> 0.17.0 + +- [x] Add Debian/systemd/logrotate packaging and local APT-repository generation. +- [x] Export all currently implemented bounded process/OS diagnostics and extend safe Prometheus coverage for backend, GeoIP, certificate renewal, stream and logging state. +- [x] Add explicit trusted-CIDR Prometheus scraping without a token and preserve actual-peer-only ACL evaluation. +- [x] Ship a Grafana operations dashboard. +- [x] Add dedicated certificate/listener-aware local-response / 404 hosts and a dashboard editor. +- [x] Keep the built-in welcome placeholder `no-store` and strip optional response metadata. +- [x] Match Redirect source-domain editing UX to Vhost hostname chips. + +## 0.16.1 + +Current direction: 0.16.0 -> 0.16.1 + +- [x] Keep revision CAS as an internal concurrency mechanism but stop showing revision numbers in successful configuration-save feedback. +- [x] Preserve all 0.16.0 proxy/cache/H3/admin/UX behavior and bump release identity to 0.16.1. + +## 0.16.0 + +- [x] Keep the main vhost backend as an internal fallback separate from Custom locations, with legacy synthetic-root normalization. +- [x] Preserve the reserved administration session cookie only for an explicitly allowed, TLS-protected self-admin reverse-proxy route; ordinary upstreams remain isolated. +- [x] Complete backend WebSocket takeover before HTTP/3 Extended CONNECT success and add phase/error/reset/upstream diagnostics to access logs. +- [x] Split process-memory and cache-memory reporting into RSS categories plus hot/transient fill/cold-queue/cold-I/O gauges while retaining the existing allocator-release paths. + +## 0.15.1 + +Current direction: 0.15.0 -> 0.15.1 + +- [x] Repair EN/PL Custom locations/help translations and reject misplaced language-pack keys in release checks. +- [x] Rework visual header-rule editing and add a live effective-behavior preview to each location. +- [x] Expand in-panel location documentation with structured sections and common examples. +- [x] Add combined HIT/MISS/BYPASS activity and Proxy process CPU/RSS charts. + +## 0.15.0 + +Current direction: 0.14.0 -> 0.15.0 + +- [x] Add Nginx-style Custom location matching, rewrite and local-response controls with inline documentation and a visual header-rule builder. +- [x] Add reusable Nagios/Icinga API checks for control-plane latency, backend availability/latency and dropped-work counters. +- [x] Move per-vhost timing/Cloudflare diagnostics to the main frontend configuration and add source IP to live logs. +- [x] Add dashboard history charts for RAM cache, network throughput and response latency plus compact API/footer diagnostics. +- [x] Add separate idle-age and total-hot-residency cold-cache policies. +- [x] Remove the runtime renewal-observation JSON importer and quarantine obsolete state only during a stopped source upgrade. +- [x] Accept repeated literal path separators by normalizing them for routing/ACL decisions while preserving the original backend target. + +## 0.14.0 + +Current direction: 0.13.2 -> 0.14.0 + +- [x] Accept explicitly allowlisted browser-facing administration DNS authorities behind a trusted reverse proxy without requiring the internal admin port. +- [x] Keep direct IP/localhost administration access on the configured admin port and derive same-origin/cookie security from trusted external proxy metadata. +- [x] Implement RFC 9220 WebSocket Extended CONNECT over HTTP/3 without a side-channel transport or HTTP/1.1 bootstrap fields on the client-facing H3 request. +- [x] Move the H3 stack to `hpx-h3 2.5.19` with integrated Quinn transport and advertise Extended CONNECT support. +- [x] Rename the requested dashboard sections/actions in EN/PL and update release contracts/documentation. + +## 0.13.2 + +Current direction: 0.13.1 -> 0.13.2 + +- [x] Honor a valid administrator session on direct panel refresh and revalidate stale browser-history login documents. +- [x] Force hot-cache handoff to enabled cold storage during managed updates and provide a longer controlled-stop budget. +- [x] Store automatic-renewal observations in SQLite schema v3 and restore them before the administration listener starts, with one-time legacy JSON import. +- [x] Move the raw configuration document editor under **Settings -> Manual config** while preserving `/configuration` as a compatibility alias. +- [x] Update documentation, OpenAPI, language help, offline regressions and release identity. + +## 0.13.1 + +Current direction: 0.13.0 -> 0.13.1 + +- [x] Extend administrator browser sessions to a configurable absolute 7–30 day lifetime, default 30 days. +- [x] Keep password change, logout and revoke-all as immediate session invalidation boundaries. +- [x] Allow deliberate local-listener backends, including port 9090, only behind explicit per-backend risk acceptance while retaining private/special-address policy. +- [x] Add panel confirmation, OpenAPI fields, EN/PL strings, documentation and regressions. + +## 0.13.0 + +Current direction: 0.12.1 -> 0.13.0 + +- [x] Add first-class redirect hosts and dashboard navigation. +- [x] Support scheme/path matching, URL/host/path targets, 301/302/303/307/308, path/query and redirect-cache controls. +- [x] Share named listeners and certificate inventory with redirects while rejecting conflicting frontend ownership. +- [x] Default backend HTTPS `tls_verify` to false when omitted; keep explicit opt-in verification. +- [x] Compact certificate inventory cards, render DNS SANs as vhost-style badges and inspect legacy/manual public PEM files when stored coverage metadata is absent. +- [x] Update OpenAPI, docs, translations, regressions and release identity. + +## 0.12.1 + +Current direction: 0.12.0 -> 0.12.1 + +- [x] Split ordinary configuration writes into revisioned section endpoints and retain atomic full-document publication for intentional multi-section changes. +- [x] Stop unrelated control-plane saves from rotating cache compatibility identity. +- [x] Preserve dashboard subpage routing across browser refresh/back-forward navigation. +- [x] Add independently selectable cold-cache idle/LRU/shutdown persistence policies. +- [x] Flush hot cache objects to cold storage during graceful shutdown and release RAM after bulk demotion where the allocator supports it. +- [x] Expose certificate DNS SubjectAltName coverage separately from configured SNI bindings. +- [x] Improve HTTPS backend TLS validation guidance while preserving secure-by-default omitted configuration and the explicit debug preset. +- [x] Update OpenAPI, regression checks and release identity to 0.12.1. +- [ ] Compile and execute the Rust test suite on a Rust 1.88+ staging host. + +## 0.12.0 + +Current direction: 0.11.5 -> 0.12.0 + +- [x] Move Layer-4 TCP/UDP stream management out of Settings into a dedicated main-navigation page directly below Vhosts. +- [x] Give Streams the vhost-style searchable card + modal workflow with explicit active-versus-desired restart state. +- [x] Add fixed-cardinality per-stream connection/session, reject, error, byte and UDP packet telemetry without database work on the data path. +- [x] Add browser-session RX/TX rate charts derived from monotonic backend stream counters. +- [x] Restyle named listener bind addresses with the same chip interaction used for vhost hostnames. +- [x] Extend offline source/release regression gates for Streams navigation, authenticated assets, UI contracts and L4 metrics source invariants. + +- [x] Stop successful unsafe methods from globally purging otherwise unrelated cached GET objects. +- [x] Use Host + URL as the logical primary cache identity and response `Vary` for representation variants instead of hashing unrelated request headers/backend members. +- [x] Share cache objects across backend-pool members and GET/HEAD requests. +- [x] Preserve resident age through 304 revalidation and hot/cold movement; persist the additional cold metadata in format 2. +- [x] Add variant-schema safety and cache regression coverage for the new behavior. +- [x] Make Force SSL require a real HTTP ingress and choose port 443 deterministically when several TLS ports are selected. +- [x] Add a graphical ACL-profile rule editor and resilient bulk-rule import with exact line errors. +- [x] Hide RustProxy cache diagnostics when a vhost explicitly disables cache. +- [x] Distinguish disabled vhost pools from unhealthy backends in the dashboard. +- [x] Add per-vhost client-IP forwarding control without weakening RustProxy ACL/GeoIP/rate-limit/Tinyauth identity. +- [x] Add a compact vhost search/filter for large installations. +- [x] Keep the checked-in Cargo lockfile stable and correct the Quinn manifest version to the resolved release. +- [x] Make the standard JSONL log directory writable under the hardened systemd service. +- [x] Accept HTTP/2/H3 `:authority` on the HTTPS administration panel without requiring a duplicate legacy Host header. +- [x] Reject ambiguous requests where legacy Host conflicts with protocol-native authority, while preserving strict HTTP/1.x origin-server semantics. +- [x] Add administration H2 authority and `/healthz` regression tests. +- [x] Add per-vhost Force SSL with an HTTP 308 redirect to the configured TLS frontend while preserving path/query. +- [x] Preserve NPM `ssl_forced` in the SQLite migrator as RustProxy Force SSL with usable HTTP+HTTPS listener bindings. +- [x] Fix managed update/rollback health checks for persisted administration HTTPS and changed admin bind/Host policy. +- [x] Add a dry-run-first SQLite migration tool for Nginx Proxy Manager proxy-host names and backends, with generic RustProxy TLS certificate fallback and manual-review warnings for unsupported NPM features. +- [x] Add offline migration-planner and HTTPS health-target regression coverage. +- [x] Add reusable ACL Profiles with country allow/block and IP/CIDR policy, attachable per vhost while retaining cumulative inline vhost and route ACL controls. +- [x] Add chip-style hostname editing and leading `*.` wildcard domains with exact-host and longest-wildcard precedence. +- [x] Add compact browser-session request-rate charts to vhost cards with progressive lifetime compaction. +- [x] Add the built-in HTTP 401 error page and treat benign post-upgrade WebSocket closes as normal tunnel completion. +- [x] Redirect plaintext requests arriving on the HTTPS administration socket and accept valid DNS Host values when no explicit admin hostname allowlist is configured. +- [x] Allow one named frontend listener policy to bind up to 16 explicit local addresses while forbidding wildcard binds in multi-address mode. +- [x] Align the Logging format builder with the Vhost configuration modal and reposition its field-configuration action. +- [x] Make source ZIP generation deterministic and verify reproducibility in release smoke tests. +- [x] Add bounded hot RAM and optional persistent cold disk cache tiers with asynchronous demotion/promotion, restart recovery, integrity metadata and separate hot/cold statistics. +- [x] Add cold-tier settings for path, disk/object budgets and idle demotion time while preserving global hot RAM limits. +- [x] Add structured-log format presets plus a visual field selector/renamer with JSON preview and advanced raw mapping fallback. +- [x] Normalize Logging sink cards to the shared application card styling instead of release-specific fieldset CSS. +- [x] Add baseline multi-stage Docker/OCI packaging with an unprivileged runtime user and persistent data volume. +- [x] Document browser hard-reload cache revalidation semantics and expose the exact request revalidation reasons in the cache inspector. +- [x] Redesign vhost cards around listener/protocol/certificate/cache identity plus live pool health, requests, active exchanges, errors, latency and traffic rate. +- [x] Add bounded structured JSON access-log sinks for JSONL files, Logstash TCP and Loki HTTP(S), with configurable field mapping/static fields and sink counters. +- [x] Expand cache object inspection with body bytes, resident bytes, overhead and aggregate stored-object memory counters. +- [x] Add optional `X-Cache-Resident-Age` and expose resident age for stored HITs. +- [x] Add administration HTTPS using the application-managed default certificate or a selected certificate, with restart-aware persisted bind address and hostname allow list. +- [x] Permit non-loopback administration binding when HTTPS is enabled; keep plain remote HTTP behind the legacy explicit risk gate. +- [x] Add global TLS protocol/cipher policy plus per-vhost TLS 1.2/1.3 and cipher overrides for TCP TLS. HTTP/3 vhosts inherit the global TLS 1.3 policy. +- [x] Make Settings tabs fill the available width and add dedicated Administration/TLS policy pages plus a separate Logging navigation page. +- [x] Enable shared cache by default while keeping explicit vhost/route disable controls. +- [x] Implement fresh conditional HIT for `If-None-Match` / `If-Modified-Since`. +- [x] Implement backend validator revalidation and cached-body reuse on backend 304. +- [x] Treat request/response `no-cache` as validation semantics rather than no-store; preserve `no-store` as hard exclusion. +- [x] Retain stale validators for bounded revalidation without stale serving. +- [x] Raise maximum object configuration to 1 GiB and remove eager object-sized fill allocation. +- [x] Add diagnostics, real-binary smoke scenarios, unit regressions and 0.8.1 release documentation. +- [x] Per-vhost H2/H3 frontend opt-outs next to named-listener selection, capability-gated by Settings listeners. +- [x] Global static-cookie cache defaults for common assets, visible global/per-vhost controls and response-side privacy guards. +- [x] Listener editor uses page scrolling without a nested scrollbar. +- [x] Per-vhost fixed-window request rate limiting with burst allowance. +- [x] Per-country rate overrides and block/allow-only country access rules backed by verified install-time GeoIP data. +- [x] Cloudflare client IP mode gated by direct-peer Cloudflare CIDRs. +- [x] Per-vhost H2/H3 frontend opt-outs and Server-Timing diagnostics. +- [x] Named listener selection in vhosts; listening IP/ports managed in Settings. +- [x] Health-check `off` disables active and passive classification. +- [x] HTTPS-by-IP backend debugging defaults and warnings. +- [x] Global static-cookie cache defaults plus per-vhost inheritance/overrides with response privacy guards. +- [x] Corrected cache Age calculation. +- [x] Updated deployment regressions, docs and 0.8.0 release identity. +- [x] Release CLI can generate the one-time setup token before first server start. +- [x] Explicit two-flag risk gate for wildcard remote administration binding. +- [x] Numeric Host validation for the wildcard panel; DNS hostnames remain rejected. +- [x] Linux/systemd install defaults enable 80/443; explicit `--no-https` opt-out. +- [x] Updated deployment regressions, docs and 0.7.3 release identity. +- [ ] Build/check/test the Rust binary and exercise remote-panel/systemd behavior live. +- [x] Explicit release identity, without overwriting the 0.7.1 artifact. +- [x] Swagger asset default independent of environment presence/Cargo config lookup. +- [x] Embedded assets prepared by our build, without an adapter's old-version fallback. +- [x] Offline local ZIP, bounded member validation, optional SHA-256 and safe caching. +- [x] Executable build-helper regressions and updated build/migration instructions. +- [ ] Build/check/test real Rust and render the actual upstream Swagger distribution. +- [x] Per-location visual proxy-request and client-response header rules. +- [x] Minimal anonymous login document and filtered extensible language bundle. +- [x] Exact default-deny admin route/method policy including private assets and docs. +- [x] Protected OpenAPI/Swagger, cookie session and CSRF-confirmed mutations. +- [x] Generic public discovery/errors and cross-tab UI revocation cleanup. +- [x] Source audit, access probe and regression tests with explicit execution limits. +- [ ] Compile and run router/proxy tests, inspect real Swagger rendering, and run the + anonymous probe against the built release before production acceptance. +- [x] Scheduled renewal for HTTP-01, Cloudflare, OVH and RFC2136 managed profiles. +- [x] Validated revisioned hot certificate publication with unchanged/failure safety. +- [x] Per-certificate opt-out, manual check, dry run, next/last/expiry observations. +- [x] Separate install/update scripts; isolated service, full data backup and rollback. +- [x] Vhost Basic Auth with bounded Argon2id checks and optional credential forwarding. +- [x] Per-vhost WebSocket toggle and graphical cumulative CIDR ACL editing. +- [x] Visual prefix locations with pool exclusion for dedicated origins. +- [x] Neutral welcome page, opt-in protocol fixture, shared square modal/Settings style. +- [x] Independent proxy and local administrator control plane; no Docker dependency. +- [x] SQLite documents, revision CAS, immutable snapshots, audit and private backups. +- [x] Accounts, bounded sessions, setup capability, origin/CSRF protections. +- [x] Host/path routes, aliases, explicit private-origin permission and CIDR ACLs. +- [x] Streaming bodies and full-exchange limits; bounded logs, SSE and Prometheus. +- [x] Public TLS/SNI, verified HTTPS upstreams, guarded DNS, HTTP/2 and WebSocket source. +- [x] Whole-source upgrade, legacy auth-module repair and rollback on copy failure. +- [x] Persist public HTTP/HTTPS bind addresses and ports, including 80/443 presets. +- [x] Distinguish active listeners, desired listeners and startup defaults; show restart pending. +- [x] Allow remote origins on ordinary 80/443 without allowing local listener loops. +- [x] RAM cache with TTL/Age, entry/object/byte limits, LRU eviction and route bypass. +- [x] Bounded streaming fills; do not publish partial, failed, oversized or trailer bodies. +- [x] Privacy exclusions, conservative Vary identity and ACL-before-cache ordering. +- [x] Optional X-Cache, X-Cache-Hits and Cache-Status; counters and authenticated purge. +- [x] Invalidate old fill generations on purge, config publication and successful unsafe methods. +- [x] Streaming gzip, Brotli, Zstandard and deflate negotiation with privacy/no-transform safeguards. +- [x] Preserve/hide/custom Server policy; client-response and upstream-request header editors. +- [x] Denser responsive dashboard; original logo retained. +- [x] OS/process CPU, RAM/RSS, load, descriptors, threads, network, cgroup and cache diagnostics. +- [x] Compile-time language discovery from langs/*.json; metadata, fallback and RTL. +- [x] Extra language files survive managed source updates. +- [x] Log filtering by host/path/cache outcome and bounded JSON export. +- [x] Unit and real-binary integration test source; 49 simulated-API frontend checks. +- [x] Example unprivileged systemd service with low-port capability. +- [x] Remove reported compression threshold type mismatch without narrowing or panic conversion. +- [x] Explicit frontend IP/port/TLS ownership inside a vhost; actual-destination isolation. +- [x] Multiple backend members, weights, backup and drain modes. +- [x] Weighted round-robin, least active exchanges, least traffic, adaptive and IP hash. +- [x] Connection keepalive/reuse limits and timers, not a Keepalived/VRRP integration. +- [x] Bounded TCP/HTTP health checks, slow-response exclusion, fall/rise and recovery. +- [x] Opaque cookie/IP affinity; compatible edit continuity, safe cache bypass. +- [x] Full-exchange backend leases; no automatic replay of failed writes. +- [x] Per-vhost response/security headers, TLS-only HSTS and explicit preload policy. +- [x] HTTP/2 TLS preference retains HTTP/1.1 for clients and HTTPS origins. +- [x] Vhost editor, atomic legacy migration, health/traffic/latency dashboard and API metrics. +- [x] All new user-facing text in discovered language packs; logo unchanged. +- [x] Rust regression and real-binary acceptance test source; 63 simulated-API UI checks. +- [x] Complete source update, examples, migration documentation and evidence report. +- [x] Split oversized status JSON expansion instead of masking it with a larger recursion limit. +- [x] Optional per-vhost certificate upload/selection with bounded private staging and validated SNI mappings. +- [x] Independent vhost cache behavior, inherited defaults and shared resident/object ceilings. +- [x] **Selective cache invalidation by vhost**, promoted from Cache evolution; invalidate pending fills too. +- [x] Compact manager list and tabbed vhost dialog, visual header rules and live health/latency. +- [x] Dashboard density presets and top alignment, without redesigning the logo. +- [x] Separate modal/docked live logs with persistent controls, vhost filters, pause and scoped export. +- [x] Global/vhost error pages, maintenance/static response and direct-IP/unknown-host behavior. +- [x] Security shortcut within Settings; new labels remain in discoverable language packs. +- [x] Python-only random-data mock origin for cache, health, delay, compression and error-page development. +- [x] Regression test source for new runtime policies; executed frontend/helper checks in TEST_REPORT.md. +- [x] Canonical Forwarded/X-Forwarded-For/X-Real-IP with explicit bounded proxy trust; keep destination-IP routing independent. +- [x] Per-vhost Tinyauth auth subrequest, sanitized identity headers, restricted login redirects and fail-closed handling before cache/static/upgrades. +- [x] Separate certificate inventory tab with upload, per-vhost selection and metadata-only read API; leave ACME as future work. +- [x] Named multiple-IP listeners, descriptive scope, reusable vhost references and explicit legacy-route permission. +- [x] HTTP/3 UDP adapter using Quinn/h3 with TLS 1.3, same SNI/policy pipeline, Alt-Svc, requested limits and bounded drain; retain TCP H2/H1. +- [x] Request ID/revision off by default, independent per-vhost controls, optional mock-origin removal and effective per-vhost Server policy. +- [x] Actual frontend socket in cache identity; streaming-safe empty request detection for H2/H3. +- [x] Toast above active modal backdrop, including browser top-layer/inertness handling. +- [x] Python fake-auth peer plus Rust regression sources for auth, headers, extra-frontend cache, listeners and real QUIC/TCP. +- [x] Dedicated bounded cache panel: resident objects/bytes, per-domain effectiveness, TTL/Age/hits, reasons and vhost filters. +- [x] Remove the reserved administrator cookie before cache eligibility; preserve application-cookie privacy. +- [x] Handle empty terminating DATA frames and expose fixed X-Cache-Reason / Cache-Status detail diagnostics. +- [x] Replace special mock-origin hiding with explicit revision controls; keep general header rules. +- [x] Default HTTP/3 for TLS while persisting explicit opt-out and TCP fallbacks. +- [x] Diagnose overlapping sockets with addresses, ownership, protocol/limit differences and remedies. +- [x] Classify unavailable/invalid/timed-out upstreams as 503/502/504 respectively. +- [x] Basic Let's Encrypt DNS-01/RFC2136 job and library-import workflow through Certbot; private TSIG/account state and staging-first consent. +- [x] Offline adapter tests, browser fixtures and Rust regression sources; unchanged brand assets. +- [x] Vhosts-only runtime/UI and one-way legacy import preserving path/backend and IP ownership. +- [x] HTTP-01 active-job/domain-scoped challenge serving with private webroot storage. +- [x] Certbot Cloudflare and OVH API DNS-01 adapters alongside RFC2136. +- [x] Unified provider/import dialog, masked credentials, explicit consent and issuer metadata. +- [x] Dedicated opt-in Prometheus endpoint, scrape CIDRs and environment-token authentication. +- [x] Application/admin/listener/vhost/backend/cache/system collectors and exchange histograms. +- [x] Functional Settings tabs with preserved dirty fields, keyboard navigation and no Security sidebar. +- [x] Offline provider regressions, simulated-API UI checks and unexecuted Rust acceptance tests. +- [ ] Validate real HTTP-01, Cloudflare/OVH/RFC2136 DNS updates, CA issuance, cancellation cleanup and certificate hot-deployment. +- [x] Add the managed renewal scheduler and audited validated deployment source. +- [ ] Verify real renewal/publication and add external expiry alerts, revocation/account lifecycle. +- [ ] Resolve and retain Cargo.lock; run check, test and all real-binary suites. +- [ ] Verify real Tinyauth integration, revocation, trusted forwarding and independent HTTP/3 client interoperability. +- [ ] Run an independent RFC 9114/QUIC conformance suite, malformed-stream/load tests and actual TLS/UDP production deployment checks. +- [ ] Execute vhost isolation/weighted balancing/health/recovery/keepalive/affinity acceptance against a real Rust binary. +- [ ] Verify compression output with independent Brotli/Zstandard decoders. +- [ ] Run real TLS/HTTP2/WebSocket/cache concurrency, cancellation and graceful-drain tests. +- [ ] Benchmark HIT/MISS/streaming RSS and CPU under slow clients and origin failures. +- [ ] Fuzz malformed HTTP/cache directives and language-pack validation. +- [ ] Exercise real non-root 80/443 bindings and systemd service confinement. +- [ ] Verify native browser HTTP/CSP/cookie enforcement (navigation blocked in this environment). +- [ ] Validate the dedicated scrape endpoint/token with a real Prometheus server and production network policy. +- [x] X-Cache-Resident-Age - implemented in 0.9.0 + +## Optional module follow-ups + +### Docker discovery + +Implemented in 0.18.1 through the standalone `docker-agent/` provider boundary. +Future work can add provider-side filters, mTLS and DNS-provider automation; the core remains useful without Docker and never opens the Docker socket itself. + +### Cache evolution + +Conditional revalidation is implemented. Stale-while-revalidate/stale-if-error, request collapsing, +tag-based invalidation and richer per-route policy remain open. Persistent hot/cold storage is implemented in 0.10.0. +Selective invalidation by vhost is implemented in 0.3.0; the domain/object inspector in 0.5.0; tags are not. No VCL engine, +distributed cache, ESI or Varnish-protocol compatibility is implemented. + +### Operations + +Container/OCI packaging is implemented in 0.10.0. Certificate alerting/revocation and account lifecycle, reproducible releases, +performance baselines and cross-platform diagnostics remain open. Remote HTTPS administration is implemented in 0.9.0; +loopback remains the default and plain remote HTTP remains explicitly risk-gated. diff --git a/SHA256SUMS b/SHA256SUMS new file mode 100644 index 0000000..d2063ac --- /dev/null +++ b/SHA256SUMS @@ -0,0 +1,237 @@ +61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore +4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example +9cd6d2c6e3d9acaec82000530d28b7e2a24c39313c6d96c1b15ffd13a7984bde .gitignore +6e27536e3111c9ffceb406bd4b0cf756909fff1e51c8d682ebe734539bd22bc8 AGENTS.md +53127d46ca032af306e9ded8dc2be1afc5a47f07cd4e6b1eff0a6409a5efe3bd CHANGELOG.md +1c54d0333fd6dbbff27ebe6b814c955cf9ab0e776f60496615fa70ebd3bda0a3 Cargo.lock +aed16132478f472abc172dae8c544eefdaab1f88f9d7b6bb00dc7e7934338eab Cargo.toml +d6d22ba63f4bc3d16a13012ecaabff8a156be57c824d2f20f66976502fb27d1f Dockerfile +96916966502e9e64b4f774a6644dd045764babab2e44e8eec9d5dfad933035ee README.md +0eacc532549167c6c371b76769aeb8b359597c99a0aa1f4560fa76750b1516bb ROADMAP.md +da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml +a55fafd4c9fc17f8af591407e7f86d222ce06658c64dcffda92acbf420f969f1 build.rs +de73fa3783786d4e66ab1e5ed9c4e44e1201a298e223c692c604cd50e30f7ff2 debian/README.md +53f5d02d09d8a7616b0f9954af9a6dbb26bea60b1823da614dda411911f89c23 debian/build-package.sh +0763a4bd8b429f7922b23a7276ff694f3a751b83fb127f6ad7ec654e5e507ecd debian/build-repository.sh +7d169934839fa7a48b888da5cf801ae6f2bb533715de1da10e4bf3c2bd1b5f4d debian/changelog +c90457b19b1b2f7998d3db59a656d215eb0fab23769197405960457b66d95cf7 debian/control +dff2ecb9c14623416981f444bc6df1c4e2bf55d330260f36a312d8d42245400f debian/rules +c537d54fd92a9b97c5f6e6ec63bc1276cda324513e2946b341b2193f63b62e6a debian/rustproxy.environment +f14b43b9ff4c8d767aa0476b28d0c66bcabf18d20986bc897e4a826695184f37 debian/rustproxy.logrotate +d1c483406fb53674ed8db5f30de599b5a91fa1c6b28242eb70a64d6a736430bb debian/rustproxy.postinst +143de32f4eb4d9e833a24476c88bedce2761fe6bc4b20a7986f1b884a2db23e6 debian/rustproxy.service +8e3884153448d76cb5027296ce50a1b614854d9732ba88a4ad3f6b25a3810753 debian/rustproxy.tmpfiles +e8e18df40bcd69d6aa404282679186d9b8256ac63a5b4e80d7f081e8a0095a2e debian/source/format +57d737d73b828bf763b210c8ddea39d7a63224f8d509a518d0b876ba2e6a805a deploy/README.md +50329bd9d6980fb1c90a5cb29966b19a257905791d1d07d185d6bd4e3b0bcb1a deploy/rustproxy.service +8c321c96974e79f725470d38b3aa3780d5b8c432a5c89a6f8d36bd575014657f dev.sh +5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md +5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example +9e9f3f17f38f9b0730f598f65988ce8ef562d2182ef6778fc7292b990e9cf22f docker-agent/go.mod +9d77c90767fa929ee8338734d72ce83284d3d965673270f62cb2b1613d587a33 docker-agent/install.sh +8aeb74c89c914712c14b6e5958bd3242ad298c96d9be6d3163f9380c3847f9ba docker-agent/main.go +3d4051a1f805d145df3877476e4b87773e74be8e123045065955a5133afdc68c docker-agent/main_test.go +634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service +0dbacca69143bb1e38d74270e24562a6749e387b26ef869643e876df6e0b1edb docs/ACME_PROVIDERS.md +888bbf7812e7283202f303680145c99b3f1a7a52fb3923fc3de1a6cbcfb72d00 docs/ACME_RFC2136.md +30822eed02d9a5fbc73ee6cfa1348c67c719665c026462c84e4106bd2a0e6b68 docs/API.md +da6b26d4bc6adec43c5c546eae42d92a5145095b9084bde652818892acd44f0a docs/API_ACCESS.md +98ad4df87b6620c0418c8c39fa6e8e1f8be88576edde2945f9be6e5dae7acb78 docs/ARCHITECTURE.md +9ad82a202abb308e9bf54fdf1a657104e0e1b5d93d05f0b1fb36248ba25a3993 docs/CACHE.md +0450813e3c875371d75cc41835f6362abb5300cd4bdf545f3011d9df8101170c docs/CACHE_DIAGNOSTICS.md +db5534427ff0f004940fd6960906d3eaa75347a110e6322827c51c5d63f20adb docs/CONTAINER.md +c822cd1d6a055d5ddec06d4f6077f0b13837dfb25ede1ec1f4e9a52e182db946 docs/CUSTOM_LOCATIONS.md +7db65a96f696aff1d7f74e2041cfc6ecdf437b1412ece90cee26da1ceff4cf7e docs/DOCKER_DISCOVERY.md +061e1ccee4f3e70126a27ba8bdbac10e895fdb4e6e4607c5c6a6ab1d164baf02 docs/HTTP3.md +7c3281b5e15101c434cbe62250f161adae050bf49826dbb93cf0ce5371affbb4 docs/INSTALL.md +0a124bd3eafaa9c6b3bc2b811fdc3e6295a6a2978766c37cc0735c27f4cf6d59 docs/INTEGRATIONS_040.md +2baaa1d34979c33caead9b271a7d235c673acb4e188c94917d975c25c179c242 docs/LIVE_LOGS.md +4e1504d1a343b94c2252545cbd5248af79f158c4566754dfe11f3c15a19ec0da docs/MANAGEMENT.md +8b7d8106f19543f5e8f801e9f7a35513eb0d02769528b1175943e914297843aa docs/MIGRATION.md +5f090a190000c6badab2a74e5d4968e70c34de43b59c1452f5b05503b5c5c18a docs/MOCK_AUTH.md +1f85fac0e06e432ef6f744f0b5f9c1b7a27a1e9e1ad30dc1a218eab96b1caffe docs/MOCK_SERVER.md +e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND_HOSTS.md +3da047768f32064c5ef0ad7a4c73c4b14930fe7317c54f5650f426af9f484593 docs/NPM_MIGRATION.md +8538816f6b64780ca4f652d189c5d2c00e40b0c8fcd1ad77ae2e11d39058dce6 docs/PROMETHEUS.md +8ecc6a0ac347c66e668255402ca4c46ad5dcb44bd463c86ab23843f9cc8d6e27 docs/README.md +6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md +0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md +ce5afb4f531bb5fb4c453deb49f643f2a563f32f7908c995e5bc8e0d7de995b7 docs/RENEWAL.md +c6fa646500c0acac18bff6349cbb1d821680a849c8648b4eaa64f2a4305f9b9d docs/SECURITY.md +620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md +fce52cc908fb86cab16072c0297b6cdaf4857e84185783bc96240d9a6bc321fd docs/SETTINGS.md +3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md +a6c7acde02c37b0f1f3f074bc1a1d9b899391a76ba719a16f0a2d57cfa6f1dda docs/TEST_REPORT.md +67de3ed624b92bc8d8ba7655cd55dde95d2a909f772e3e42535fd80459ab4c5a docs/TRANSFORM.md +b02957b708174c9def830ced3c70c943a47af7a33467ef858255d04ab92c4af0 docs/TRANSPORT.md +96a6816915d526f00e261846131ee3f942ef332af996cf0f73c79ec1ba9f9c7a docs/V0.21.0_AUDIT.md +0c1c3ba6e8724d85ec54e254e70bbf05e063db025280ef4af866b774fb43f76b docs/V0.22.0_AUDIT.md +3f811c457bf6be662969fbac32c3cfcc49b13c2f00d0125e4f2eeab768f59f0e docs/V0.22.1_AUDIT.md +36c2fbab9ec1276715f7d4a7a4aa49725b2fec5e1ba58d97d00a87b259257ea7 docs/V0.22.2_AUDIT.md +579547e18982bb1a527417aaa8bf88b40d02d2e68dc71215b9815d1134a4f865 docs/V0.22.3_AUDIT.md +0fd3d7bad0b3b437cddf40f2dd184b7a1f62f0340a319eb6f6d966960b666e46 docs/V0.23.0_AUDIT.md +ab7d48c2a359928da653263e69b4739cfff849a03fa1270491cb1ce553989788 docs/V0.23.1_AUDIT.md +9cb1b028be2a4a9cf105fb14617ac5776d131814a5e92b497ff816929aa3db41 docs/V0.23.2_AUDIT.md +e4d47b50fb0ce33d26817fc0afcc2f05ac53bc9b62c7e50e61b9fa9734e817b0 docs/V0.23.3_AUDIT.md +984092eeb3899ed696f94059d88f7834703b151682d5c8af9fabacc7a823ea0b docs/V0.24.0_AUDIT.md +5782adf99a292783149882e844ad07b49d4ac0ea478e8393758cff849d48c13d docs/V0.24.1_AUDIT.md +230005cbd56fed4bc832940c0a49cc14f50231650308724df105b0506bb34210 docs/V0.24.3_AUDIT.md +b891db1453cd7cf6b10f79879f4edaf7c83f14e91e4e95826919d40cb5dca2d4 docs/V0.24.4_AUDIT.md +8d6519b00e01e5cd41ee6772941fdc7e9a70fe29626df71cefced6eefe6a94d0 docs/V0.24.5_AUDIT.md +bd7959e325685085507a20b22254c7024ceb9396b6314257694bd764ec096ba5 docs/VHOSTS.md +e32472cea5ef3cd25c058e55df8adfcc1d7bf03e0d12dba69d2f2b9b94fe04b7 docs/VHOST_ACCESS.md +c336f998027330c58d5afbbbb3a3a682fd44ae3997643882fe342633822f8bb3 docs/screenshots/README.md +5502e8ce44276213803b66c58e3a4d804a1354f5ed5d83f47ee805e8c44ab088 examples/acl-headers.json +2431dd88a2d9ad2cf3cd4b8fcdfa602563ab1013c612d8917fddbe7febfb2dc3 examples/cache-dashboard.json +e31ac05a99615e4ea6c4c9a8b2e090968583fe3959f838a600b5740cb170f8c5 examples/config.json +39cad993003930febf98460311d185a278e7a9508b6b591299d9339d0f0c51cf examples/empty.json +695cc7621aa403148fcd0868f351b6e9ccc076fe35610e779af4a72f94d7d5f4 examples/http3-vhost.json +b26833cb4251f9390a44192ecc827ff7f052fd19016a5ccf7498c94503ab4ce3 examples/mock-vhost.json +134d4a59727b27accb3141d4cee26a4a74157d236de4996bfd682e639462b2b5 examples/tinyauth-vhost.json +5a8f3a8a054abc89c6e78896b9fe2f903212199a24dc3d2d2bcbd0696a31bcde examples/transport.json +17e7971fa9d9b141019fba3135d87cb1aa0cbc55d56b0718f879f880b615da26 examples/vhosts-balanced.json +e06905b6363103b1bcee1a09b9e02292033f114d88f3edbea22ddab53e9e5071 examples/vhosts-private.json +529b47c6d4ea44d2a25d1cf9561cb5ee1e05f50a568c6dba5889741bcf936434 grafana/README.md +1f43923617da0a0b667a5c9bd98457c78024a786701f56a07be5f24f5eec8ad8 grafana/rustproxy-overview.json +f45e5cc3d0c253534978d4b752b0667e2a7906ccf5a22106400e0f6b70b00d23 install-debian13.sh +858b39fa6886b71d20690bf369e76580922364b3cc7f0cf6ba9b040df9cd76e3 install.sh +29cada0936762f26495064d1524a668262ed729b21f20aebddc8b6e5cfea363b langs/README.md +2e3e5dc918d2838e91310b78922d8d84cbf6c9e8dcd3a0deedf6dd5045f07423 langs/en_US.json +9e1ed1d4f3daa0461ef3d3e137dca66c30141858943c0461a1c80b092a11d5f8 langs/pl_pl.json +0b9cd9ec07cb31ab0d0c75f08fe1ffee04dca099e90513017835f095d347012a make_zip.py +e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql +20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql +5c62da30fa9db594a4d656c77ae676d3c388072b6a85f91170ec7d911d7cde43 migrations/0004_admin_preferences.sql +57dc28982f416874d5d00d1d438a18846d1ce35cb940c4bcf67fd9a8bc57339c migrations/0005_certificate_api.sql +faa7e50922bf180c363f7627fbcfe8079831e36282220d17481cf69bc64b57f1 migrations/0006_certificate_api_credentials.sql +478220552ffeea3de00e2866d427d9f87f4cfc4ae5232e5c3a774e62425b1e0c nagios/README.md +91233910aa4dab07ae917fef93d018a7a0beaab697f9a0bb889ac613e595cca4 nagios/check_rustproxy.py +a42e01b341725c9da0928edf40c8c3b9c814ef90592f208c52b1f2c56a2ad383 nagios/check_rustproxy_api +ee50f38c037765fab742e888d4b9fc4d874c31c785c6064d4eca3d2c4d24d944 nagios/check_rustproxy_backends +1465396cf1358067e9fe48b3d61f28fd4ac65c577b67c502950ce6b42ecbe3b3 nagios/check_rustproxy_latency +6e036f736a2b40feb78eec17bf34871771524190f283591d1c89e48e6d2271e6 nagios/check_rustproxy_queues +7458b2d74495e6fb1c1edeea4d5a793705476431dadb60627187111f91441e13 rust-toolchain.toml +4c0a0a2131c5144ed97228db89ad0b78f08b93dc24b373bd0dc49ca137ad8324 scripts/acme_provider.py +e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/acme_rfc2136.py +ea2c670aa1fc2c145fb3666711642d526c2adb01537fc3b6398664e33136f0e4 scripts/admin_audit.py +761aadaffbfb08c32afca28af7e5379f933712563b00dddab7261a9505cb7d5b scripts/backup.py +7155159c6a76448bec8134900c8022c79c1047d0814cb7ab227d2eb331c2a53f scripts/check_source.py +15b3e90be1d89a9b25643bca3226ee4cf912bc2e9bba62bd281ea7a789552943 scripts/deploy.py +6facb2d65bfab234f09855097d12da8f72669a38ebceb4b1cbd03ab42b8d2469 scripts/langs_check.py +60504dd426195fca88d77e075d607c04540dbe8aae2cd806077b3818e0e0cf08 scripts/migrate_npm.py +d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a scripts/mock_auth.py +2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 scripts/mock_server.py +2d4c859aae5ba357a6d30024c8487ae8f59f01b192711f13f9c2f8fee0f48b50 scripts/os-acme-client/README.md +c73b814a42bb4ff703441dbd177ee625055eeb115f9b5bb0c2150aede573a159 scripts/os-acme-client/opnsense-rustproxy-cert-deploy.sh +635ec74ae1be91145e42d4e4580e2015df5b54177a17f04c42b1ee14075b9190 scripts/preflight.py +ae0834cfcc518f4bc5ce1203a703fd5ebcbe71cd02463fdcaf6d776a1241cb2e scripts/release_smoke.py +0f2deebabf637b890f38eb0317e3b26be75d3c169e154f86caf1df82f939325e scripts/routeros-oci-build/.env +45be151cd85fc2ef2f1fdc3003ace63381af262e73af8a685ab48cb697cc0de1 scripts/routeros-oci-build/.env.routeros.example +3b48cff8506e5a6ae7d3876f47721f5de7d63e2e02264a78cf5b6ddd52d9fbc6 scripts/routeros-oci-build/Dockerfile.routeros +7c7a3e51f43b43e1c962a4b0bb591f99c1f06977bf77f23719f7f867dfe7eb8e scripts/routeros-oci-build/Dockerfile.routeros.dockerignore +e6fc807f43f290315c753d0d0d2b9eb081c4dd16690f8c35edef91863d08efe3 scripts/routeros-oci-build/README.md +d53b2ae7315ac143bd17d6327611a39e7c23666408bb95e39da2ca8517322ccb scripts/routeros-oci-build/build-routeros-oci.sh +3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a scripts/routeros-oci-build/docker-entrypoint.sh +02ba853fbfcd0d722b1ba121016ebfbe5354dcf7710c708e3110c2ea9fa7e1a5 scripts/swagger_assets.py +102b23de19a88a8e60de392fb83aefd3dfb33e90cc97ed1690d9f3d4a33d8866 scripts/test_compression.py +61e6af8cbca6894e32c0b93f945087d40100e4ca4112249306ea7bbeea8ccf3a scripts/upgrade.py +081aad397b2b9485e20b684dc11682d3a16464c5b4f110cd461f8518eb74ad99 src/acl.rs +2d1ffc1c64e76c647abd3134719fb561b1f27d584c92d867011d8de7dd7f6662 src/acme.rs +fdba60fa997aa18cb0a7ef50e286a8b6fd877d14fe829d4b0bcb5c7c269a5ae6 src/admin/documentation.rs +44d4d26ac04cd9fb15f73ff0dffb8d7fc76062fda2ab4c3eb4e6c7172085877f src/admin/mod.rs +6a78b88b4bb20457bb72b4d01157d596fa498432f916de37fc4ee9a38fa8267e src/admin/openapi.json +b4fa393b780b2676b5d72e0f90cd7290292f424b8062d4b5983e722d6ca72c32 src/admin/public.rs +23c55f23deeec844a9abc2289440c9e0583aaeb72416fc5f3632a43a864c0efd src/admin/web/acme.js +cab34b79502b15c8c5b6e6b7ca0d0b7389179e75906053c8c1daa2ac29b343da src/admin/web/app.js +5412033412495e84e62415413dce8a65dc01db72dfbf56e50253c9d7cbfd37e2 src/admin/web/cache-panel.js +50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html +3b84a7a08f9f68e2c78fed4523b594c31a251addc5480a69c5f6949908230539 src/admin/web/docker.js +4b8a6c0e4c3870432354c88e6248655faa6072cc1f8d0c6906aa970174ec245d src/admin/web/docs.css +c8e509c22696af1fbb423f748dae4906c1584c5da8d114da0bb297c87ec1dbc7 src/admin/web/docs.html +41b641dfa703afd655cd49861f0e714b073b8deb6dbea396cae1b763012f50d5 src/admin/web/docs.js +232b766f278ae4424705af351910677fcd7ac9a20bb8320d9a12b763c91219a2 src/admin/web/error_401.html +16293363c7848471d866092fec5e83023df087aa824989e87450cee7b7ca1263 src/admin/web/error_403.html +f7d0955ab18c15e04369204e789f341d2e832f927c1ff20a3b2b9e35e7898f00 src/admin/web/error_404.html +7dc3634e3d508176545936f77db9deb3dc51f0abb71985e1950aa91b81b6f44f src/admin/web/error_429.html +386b9dc3aecebe7727f7cf7bbd2a02ab036a3abf01315975111a51559128df73 src/admin/web/error_503.html +e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/error_504.html +0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg +dee3b113907f77138cdf919efdfb097e45b889cff3893bfc6f02ed998643bbae src/admin/web/host404.js +4768f187f04b41e6423bd83e4f596e084eb1c1ac6592c422d5f2e034fb4fea96 src/admin/web/index.html +24db88842707f764064657712d3660c6ac129fbfb47d188c0903925cf8244ce5 src/admin/web/integrations.js +cdab0c11a28deebdf401c0453fbc76b675e9e643722106fb20e812bb9648ccde src/admin/web/login-keys.json +a253d202dc9a147f2add34c64e0f35c38fdc7f667f9908933ce5f959b1a92a2e src/admin/web/login.html +4c38f9398f008b7d27d7100c951465fcecb24d51a1b8973f66e1365d208bbb99 src/admin/web/login.js +e41619e5cb0ba01a3d287748f28eb38bbe7641f9bedade252d286c18e619c400 src/admin/web/management.js +22318f39f7bb1ae98e65737d3c2ad9e63065b8e94ad59bed560c0e80d3a46949 src/admin/web/redirects.js +dafc8b023f334091c7b5a04920038bce46ea3ef4b068e1f88cffe49d44eaeeee src/admin/web/settings.js +1a30affd6a717095a1763998a5850a79ae0f86b7b694806f2aba2b880735dd1a src/admin/web/streams.js +a330875bda5dab02b15a5d0587d24e93380f4d694841d31e89d2d1f294542f82 src/admin/web/style.css +a6f8edf5cd4d5b3485b5ea687f82e3a3b57b5be40c136026e7ad00b5e180891d src/admin/web/transform-panel.js +3b92b83803a309809b0321ba6575dbbb4fa8a133235a2de1fecdba0b3f553103 src/admin/web/vhosts.js +1e5c6c6157f8b66d90751a0480cb90c6fdfa229330fc4c3f3504bc0c689c3d60 src/auth/files.rs +606f5491f94da007881a679051677a4609881d3151ac753de02556e0e76e1ce8 src/auth/mod.rs +1ceb57d97cbff655f185e126d98b8dc7884c7aac865e1f70e3c6d199685c13a7 src/auth/store.rs +168f55f75ad6ebd80f98c3376f5885cdc8d313f8b03f4e93066a28cf7b8c8b81 src/balance.rs +e2bf9d4f8a8c8c7f9a8df96872a657b9706d05f3f3a83721fe912ca0af37b29a src/basic_auth.rs +075b65ac7278fe11481855e844b8ff0654162e8b3e4194e8e3fbc1c0a1d05767 src/cache/cold.rs +1f7073115229bbfb1dd7ea487e1cd97e5815cb1767e068fda7bcb2c8c4b866c0 src/cache/collapse.rs +bdfbbebaeaac5301d775c9b2c9f1ea6505b667d6adeea5c4af542e4e0f61c502 src/cache/dashboard.rs +bbd6de8568fea79cc4a11d33b905af75713e609c8f37ec943c82f0d180e66dcc src/cache/fill.rs +9110e0662022da6a0518bfbf93872143e8ecafb0f541c0d30a0288ad09fc5883 src/cache/invalidation.rs +5cf902ffa805f4c5296fe006dc4d0a3c7e03ff758a68480ba0a4b6e4af9e5fb3 src/cache/lifecycle.rs +8c451cf8008bdb9db19cbd6b45d1352f9c3790a1cd3a6b2341b21789fea9ffb2 src/cache/lookup.rs +58c03fde09e5eab40968679793b364f4f1cf5fd832bd79ebcdc92bb23871ad82 src/cache/mod.rs +61c95d48cc6a3b1d8619030ce34c2a3c340c09af50f3e05840d8f69564d348fc src/cache/model.rs +cae9d35be0e31b5f942e3d3b20e7496581ec6491d7974509ece4cf540271b1f5 src/cache/policy.rs +7e2bcfbb2b57732cdc704d9df7a9298fb80e735e37984431bc936630e41e7f8a src/cache/range.rs +09633b26d089b5a7944262b1d175c17c99de3d1704a03aac5344b4a29f3f279c src/cache/tests.rs +9874abc56a3f38bd06f663965588b709d2a8d29e2317dd3dd898c5aaf9460209 src/cache/tickets.rs +5d1788e6da0669546729e7bea56c64f3b989d399793e67d34fdbf427a6e0c65b src/compression.rs +09279a1f5e6e97ca6f856081bc1e710ae0b57b708bc6a62157741a5ace2fdfbb src/config.rs +d465a0a7fd36bbd6a59cb6e551291530c16697865583cb038bc72ff0673d3240 src/db.rs +19ab6c3ae37009c544291cb0b65c1bb9c721e168f95c1ed3fa88155aca7463ad src/demo.rs +f93f0b1fdb894b29a271fc6f88ccc0afbd5c847ba3297cad3709858e17d05292 src/diagnostics.rs +d7c1093b62dd37479dbefd1bc9f18d2fbc0ee5d4ebe155f7a7bbaccd058b1fd3 src/docker.rs +43f09add98bfd3b2333486979e163bd31f3716c7517dc99c47dc2f4e72c8951a src/forwarding.rs +eff4484fd9630009471f824c57c5c74f3d3c5378f9e46a3bdda03ede37daec82 src/geoip.rs +185a7874d2ba45e6705fb69703fb99c96eef2a8134f43343e2b20f105f1a1be3 src/headers.rs +2f6dd8c74fe1d22a8fa4ec5a21d0d334a667ede1b11629541f4802252765b21b src/http3.rs +a6630962453c7e68a409aac45da51093148b9b3c8d044ab13897a8c9e476ae4d src/lib.rs +df14373ce3f9c5740d7501fdd4cb42d8b7b07b23ad86f2b69b5e534f0e0f01eb src/logging.rs +9125df29c30aac3812d8f6e8b8ee92c151ffc0ce344f334a5cbc81cd3ac3e1ef src/main.rs +7022ec2e991e9906e30b06f5bca58f96b9d7e09cc92a27202b5196d54e341b75 src/metrics.rs +64494e5746912cfcb10612ea4e0b336004b1f738bd7999be7e58fc96abadb6e1 src/not_found.rs +e4b1f61c12437cc840c36eb7ab7b69b03f310bca3f89288ade9992d9dd1b7e2c src/pages.rs +f355d84c16cef89c43785fdc02181d6ad234fbc9c834367843822ff970088d00 src/prometheus.rs +3097b53fee3cfa4bc0316b2aa85aa46e054da4463ce4baf5e771ade957952fb0 src/proxy/body.rs +69edf65d8dea5b6e942e682c96a67f072bd2196c9c4901a68f8ab8d8d4464598 src/proxy/headers.rs +808d8d61635f10012788129f361e52084cf4e7cdcb0d7df986d1e1836476ff11 src/proxy/mod.rs +64665d778a90a22e93af7a17be037255d59fff3220d8924737da9be965acae69 src/proxy/websocket.rs +6b24aa692ed3cc7d20b561c90239e26e203f8e44f64d4bb45b8b63ba05906d4f src/rate_limit.rs +acdca1bf861f95b85f08dbbcf69e8a2090335cab4d542f2509383bcc67a0e699 src/redirect.rs +d6be7744938707714f4e948fc62dd17119f93a068ffcde7537ac51aba945d32e src/runtime.rs +c403b63a4192e9549b8a82f29e6966fddd2130be1a9a86a895e7692168de98ea src/security.rs +d35fbad33476b465d456dde8984daa4e52e34b9d546ab9e48dffcc40c74a4669 src/server.rs +a80e8fc2990e62b8fd28426c8e1d213c9761577462179e96637d646ad3d87518 src/settings.rs +d6f7d8c8e4155c931f037543fde258d0ef6354f7c829b6239e097fde69f07031 src/stream.rs +5663d7bacf5699cf607c5bc1440f6f7650c46d0929a3e9b74e20ac8997a8ca4e src/system_stats/linux.rs +5dada498502763f3183c0c3758d1f6c5139366936fef4be585a018c4746a2c7b src/system_stats/mod.rs +a026c13cc4757cb01bfd4595ac6b57a4d837482e40cd399e8d086a54700b2b97 src/tinyauth.rs +898553df5b2b416c4f0d9b7dc7706d9db5f79b9d12753354a1a1a7e45d48d159 src/tls.rs +f188257276292ae3ff683d1cc9162dd679728ef13247ba65cd498f00be489112 src/transform/css.rs +8b74590ef49d228c0c5959752215b5a8d376014436650f6b4ec647333930d713 src/transform/html.rs +388f602176f152ef29a7c3ecd3ab328d6534b6d1eaae6e7c35a959b432232413 src/transform/js.rs +d51b1ada3cdfce44fccdff1e56ded76baff092c97572a27dc73174881ff37c25 src/transform/json.rs +d3120af52cf47ef07a18d10ffa4dcbe558f2ae38c1d738de8d471f7c6f4ec358 src/transform/mod.rs +6eb6e5b49b73ff8da615ca0fd1d871b87f2f1dc352b0587935ff0b602cecbcf7 src/transform/svg.rs +f255ed094a1cedf86f767bc062b1c589c5940a4f024274ca8238d2e83d0ede6c src/update_check.rs +77cabbcd9730e01d19e1147ede94272f6100d97281f7892d2b1bc0279c780f63 src/upstream.rs +ea3ec51c0ffac30670cca8580374cbd1f67a792dfab2e6ece46aaf22c70636b1 src/vhost.rs +f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json +1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs +fd8655c72f35f83586af20a9f04468528cf2cfea96a6329cb582972ed8a6548f tests/http3.rs +eecf680f001f2110dde4eceaccf2f922859a16c946a644f8854e8de4d3771f7c tests/policies.rs +728999110a6d8cacb62bc0399bc333fa23a66526ee23f563a3c93bf00e5fad19 tests/transport_config.rs +77ee069a5c0f69c49d2688eff3c9aa0f3e39fa02042cfe348e410a23b8f3e116 update.sh +af688fd4e67e54a8a2ff243fdfe916a20ceae6251baef207a4e974de2149f308 upgrade.sh diff --git a/UPDATE_CHECK.toml b/UPDATE_CHECK.toml new file mode 100644 index 0000000..737bc83 --- /dev/null +++ b/UPDATE_CHECK.toml @@ -0,0 +1,6 @@ +# Embedded defaults for the built-in RustProxy release checker. +# The file is compiled into the rust-proxy binary. Runtime overrides are available +# through CLI flags / environment variables documented in README.md. +repository_url = "https://git.linuxiarz.pl/gru/RustProxy" +version_url = "https://git.linuxiarz.pl/gru/RustProxy/raw/branch/master/Cargo.toml" +interval_seconds = 3600 diff --git a/V0.25.0_WEB_SERVER_PLAN.md b/V0.25.0_WEB_SERVER_PLAN.md new file mode 100644 index 0000000..eda6a89 --- /dev/null +++ b/V0.25.0_WEB_SERVER_PLAN.md @@ -0,0 +1,2283 @@ +# RustProxy 0.25.0+ — Full Web Server / PHP-FPM Plan + +Status: design and implementation TODO +Target: RustProxy 0.25.0 + +Baseline reviewed: RustProxy 0.24.2 + +## 1. Goal + +RustProxy 0.25.0 should remain a reverse proxy exactly as it is today, while optionally becoming a full web server for a vhost: + +- serve static files directly from disk; +- serve directory indexes; +- support HTTP validators and byte ranges; +- execute PHP through PHP-FPM / FastCGI; +- provide structured `try_files` and internal rewrite behavior; +- support Nginx-style location matching already present in RustProxy; +- provide presets for common applications such as Drupal, WordPress, Laravel and Symfony; +- allow fully custom structured configuration; +- keep all current proxy, cache, ACL, authentication, TLS, HTTP/2, HTTP/3, WebSocket, header and security behavior intact for existing configurations. + +The feature must be additive. A 0.24.2 configuration loaded by 0.25.0 must continue to behave as a reverse proxy without requiring edits and without silently changing routing semantics. + +--- + +## 2. Non-negotiable compatibility rules + +### 2.1 Do not reinterpret existing configuration + +The meaning of the following existing fields must not change: + +- `Vhost.backends`; +- `Vhost.routes`; +- `PathRoute.path_prefix`; +- `PathRoute.match_type`; +- `PathRoute.backend_id`; +- `PathRoute.cache`; +- `PathRoute.acl`; +- `PathRoute.headers`; +- `PathRoute.rewrite`; +- `PathRoute.response`; +- vhost/global ACLs; +- Basic Auth; +- Tinyauth; +- rate limits; +- security headers; +- response compression; +- transform configuration; +- cache configuration; +- listener selection; +- HTTP/1.1, HTTP/2 and HTTP/3 behavior; +- WebSocket behavior; +- backend balancing/health/affinity; +- custom pages; +- revision/CAS configuration publication. + +In particular, the current `PathRewrite` remains an **upstream proxy path rewrite**. It must not be silently changed into an internal web-server rewrite. + +### 2.2 Existing route precedence stays unchanged + +Keep the existing location matching rules: + +1. exact; +2. longest prefix; +3. `prefix_priority` / Nginx `^~` skips regex evaluation; +4. first matching regex/regex-insensitive route in configuration order; +5. longest ordinary prefix; +6. vhost fallback. + +Repeated literal slashes keep the existing normalized routing behavior. Existing rejection of ambiguous encoded separators, encoded `%`, dot segments, controls and malformed path forms must remain. + +### 2.3 Existing vhosts remain proxy vhosts by default + +If the new `web` configuration is absent or disabled: + +- vhost validation remains equivalent to 0.24.2; +- a backend pool remains required; +- the main vhost backend remains the fallback; +- existing Custom Locations remain optional overrides; +- local `return` behavior remains unchanged; +- no local filesystem or FastCGI operation is performed. + +### 2.4 No arbitrary Nginx configuration parser + +Do not add a raw `nginx.conf` execution/parser path. + +RustProxy should expose structured configuration that can represent the common Nginx behaviors needed for application hosting. Unknown fields remain rejected. + +### 2.5 No hidden preset behavior + +A preset generates ordinary RustProxy configuration. After generation, every field is editable. Runtime behavior must never depend on a hidden `wordpress=true` or `drupal=true` flag. + +--- + +## 3. Baseline behavior in 0.24.2 that must be preserved + +The current code already provides useful building blocks and they should be reused rather than replaced: + +- vhost/frontend selection is tied to the accepted local listener address and TLS state; +- exact/wildcard hostname ownership is already separated from path matching; +- main backend fallback is separate from user Custom Locations; +- Custom Locations already support: + - prefix; + - `^~`-style priority prefix; + - exact match; + - case-sensitive regex; + - case-insensitive regex; +- existing proxy rewrites support: + - preserve; + - strip prefix; + - replace prefix; + - regex replacement with captures; +- route ACLs are additive to host/profile/vhost ACLs; +- global, vhost and route header rules already exist; +- a route can already generate a local response without opening an upstream connection; +- authentication and ACL decisions run before upstream I/O; +- the immutable compiled snapshot is published only after validated revisioned configuration writes; +- the response pipeline already centralizes security headers, server headers, diagnostics and compression. + +0.25.0 should add local web origins to this architecture, not create a separate HTTP stack. + +--- + +## 4. User-facing vhost model + +Add a dedicated **WWW** / **Web Server** tab inside the existing vhost editor. + +Recommended tab order: + +```text +Details +Backends +Health +Locations +WWW +Auth +SSL +Cache +Transform +Security +Pages +Advanced +``` + +Do not create separate top-level modules for Drupal, WordPress or PHP. + +### 4.1 UI mode + +The UI may display a derived mode: + +- `Proxy`; +- `Web Server`; +- `Mixed`. + +Prefer deriving the label from actual configuration instead of making a new required runtime `mode` field. + +Examples: + +- no `web` config -> Proxy; +- `web.enabled=true`, local web fallback, no proxy routes -> Web Server; +- `web.enabled=true` plus proxy routes/backends -> Mixed. + +### 4.2 WWW tab — main sections + +#### Web root + +- Enable Web Server; +- document root; +- fallback target; +- index files; +- autoindex; +- default MIME type; +- symlink policy; +- default static security policy; +- effective path preview. + +#### Application preset + +- Custom; +- Static website; +- SPA; +- Generic PHP front controller; +- WordPress; +- Drupal modern; +- Drupal legacy `q=` routing; +- Joomla; +- Laravel; +- Symfony; +- Nextcloud; +- phpMyAdmin. + +The preset is a generator only. + +#### PHP / FastCGI + +- enable PHP; +- PHP-FPM profile; +- Unix socket or TCP endpoint; +- test connection; +- connect/read/write timeout; +- maximum connections; +- request body limit; +- default FastCGI params; +- custom FastCGI params; +- optional PATH_INFO behavior; +- effective `SCRIPT_FILENAME` preview. + +#### Static files + +- index list; +- directory redirect; +- autoindex; +- validator behavior; +- Range support; +- dotfile policy; +- executable/source-file deny policy; +- optional per-location root/alias. + +#### Routing / front controller + +- structured `try_files` editor; +- internal URI fallback; +- named internal location fallback; +- internal rewrites; +- rewrite flow `last` / `break`; +- query-string behavior; +- internal redirect limit. + +#### Advanced + +- named internal locations; +- FastCGI params; +- MIME overrides; +- route-specific web handler configuration; +- conditional rules from an explicit safe allowlist; +- effective behavior preview. + +--- + +## 5. Proposed persisted configuration + +The exact Rust types can be adjusted during implementation, but the compatibility properties below should remain. + +### 5.1 Add an optional vhost web section + +Add an optional field to `Vhost`: + +```rust +#[serde(default, skip_serializing_if = "Option::is_none")] +pub web: Option, +``` + +When absent, behavior is exactly the legacy proxy behavior. + +Suggested shape: + +```json +{ + "web": { + "enabled": true, + "document_root": "/var/www/example/public", + "fallback": "web", + "indexes": ["index.php", "index.html"], + "autoindex": false, + "symlinks": "within_root", + "default_mime": "application/octet-stream", + "php_profiles": [], + "named_locations": [] + } +} +``` + +Suggested fallback values: + +- `proxy` — retain the main backend pool as unmatched-path fallback; +- `web` — local web root is unmatched-path fallback; +- `not_found` — no origin fallback, use the existing 404/page path. + +Rules: + +- if `web` is absent/disabled, fallback is implicitly legacy `proxy`; +- `fallback=proxy` requires at least one proxy backend; +- `fallback=web` does not require an HTTP backend; +- `fallback=not_found` does not require an HTTP backend unless a proxy route references one. + +### 5.2 Add optional local web handling to a PathRoute + +Do not change the meaning of existing fields. + +Add an optional field: + +```rust +#[serde(default, skip_serializing_if = "Option::is_none")] +pub web: Option, +``` + +Action resolution for persisted routes: + +```text +response != null -> existing local Return action +web != null -> new local Web action +otherwise -> existing Proxy action +``` + +Validation must reject ambiguous combinations. + +For a `web` route: + +- `response` must be null; +- `backend_id` must be null unless a future explicit proxy web action is added; +- existing `PathRewrite` must remain `preserve`; internal web rewrite uses a new field/type; +- proxy cache override should initially be rejected/unused for local static/FastCGI routes rather than silently changing meaning; +- route ACL and header rules remain valid and should apply. + +This allows the existing location matcher to be reused for static/PHP routes without creating a second public URL matcher. + +### 5.3 Do not repurpose existing PathRewrite + +Add a separate web rewrite model, for example: + +```json +{ + "web": { + "handler": "static", + "rewrites": [ + { + "pattern": "^/(.*)$", + "replacement": "/index.php", + "query": "q=$1", + "flow": "last" + } + ] + } +} +``` + +The existing proxy `rewrite` field continues to modify only the upstream proxy target. + +### 5.4 Structured try_files + +Prefer a structured model over accepting an arbitrary Nginx directive string. + +Example: + +```json +{ + "try_files": [ + {"kind": "file", "value": "$uri"}, + {"kind": "directory", "value": "$uri"}, + {"kind": "named", "value": "drupal"} + ] +} +``` + +Other supported terminal candidates: + +```json +{"kind": "uri", "value": "/index.php", "query": "$args"} +``` + +```json +{"kind": "status", "value": 404} +``` + +The UI can render a familiar preview: + +```text +try_files $uri $uri/ @drupal; +``` + +without storing or executing raw Nginx syntax. + +### 5.5 Named locations + +Named locations are internal-only and should not become public URL patterns. + +Suggested shape: + +```json +{ + "named_locations": [ + { + "id": "drupal", + "action": { + "type": "internal_redirect", + "uri": "/index.php", + "query": "q=$uri" + } + } + ] +} +``` + +Rules: + +- IDs use the existing safe ID rules where possible; +- named locations cannot be requested directly by a client; +- only `try_files` / internal rewrite may enter them; +- loops are bounded; +- their configuration is compiled into the immutable snapshot. + +--- + +## 6. Necessary internal routing change + +A pure Web Server vhost cannot require a fake HTTP backend. + +0.24.2 currently requires `1..32` backends and the compiled main backend fallback is also used to establish route ownership. 0.25.0 therefore needs a small internal routing refactor, but it must be behavior-preserving for all old configurations. + +### 6.1 Regression-gated refactor + +Introduce an internal compiled action/target concept, for example: + +```rust +enum CompiledRouteAction { + Proxy(CompiledProxyAction), + Return(LocationResponse), + Static(CompiledStaticAction), + FastCgi(CompiledFastCgiAction), +} +``` + +or an equivalent representation. + +This is an internal model only. It does not require rewriting old persisted JSON. + +Compile old configurations exactly as follows: + +```text +PathRoute.response = Some -> Return +PathRoute.response = None and PathRoute.web = None -> Proxy +legacy vhost fallback -> Proxy +``` + +New configurations may additionally compile to Static/FastCGI/local web fallback. + +### 6.2 Host ownership must not depend on having a proxy fallback + +Frontend hostname/listener ownership already exists separately and must remain authoritative. + +For an enabled vhost: + +```text +claimed frontend +-> existing custom location selection +-> configured fallback target (proxy/web/not_found) +``` + +Do not continue to a broader wildcard merely because a path did not match, preserving the existing claimed-vhost behavior. + +### 6.3 Backend validation becomes conditional + +Legacy proxy vhost: + +```text +web absent/disabled -> require 1..32 backends exactly as today +``` + +Web vhost: + +```text +0..32 backends allowed +``` + +but validation must require a backend if any configured route or fallback uses Proxy. + +No HTTP backend should be required for a pure static/PHP vhost. + +### 6.4 Legacy root normalization + +`PathRoute::is_legacy_main_fallback()` must treat any new web action/configuration as a deliberate customized `/` route. + +A new web route must never be removed as the old synthetic root fallback. + +--- + +## 7. Request pipeline + +The existing outer request/response pipeline is valuable and should be retained. + +Desired high-level order: + +```text +accepted connection +-> current listener/TLS/frontend selection +-> current host/path normalization +-> current redirect/local-host handling +-> current host/profile/vhost ACL +-> current rate limits +-> current Basic Auth / Tinyauth +-> current location selection +-> current location ACL +-> action dispatch: + Return -> existing local return + Static -> new static handler + FastCGI -> new FastCGI handler + Proxy -> existing proxy path unchanged +-> current error-page processing +-> current global/vhost/location response header rules +-> current security headers/server headers/diagnostics +-> current compression where safe +-> H1/H2/H3 response body +``` + +### 7.1 Critical rule + +Do not fork a second HTTP server pipeline for local web content. + +Static and FastCGI responses must flow through the same outer policies as proxy responses wherever those policies make sense. + +### 7.2 Local web response marker + +Do not mark normal local website files with the existing behavior that forces placeholder/default pages to `no-store` and strips validators. + +Add a distinct response extension/marker for local web-origin responses if needed so that: + +- proxy-cache diagnostics are not incorrectly added; +- `ETag` and `Last-Modified` remain intact; +- application `Cache-Control` remains intact; +- global/vhost/location response headers still apply; +- security headers still apply; +- compression may still apply when safe; +- custom error pages may still replace error responses. + +--- + +## 8. Static file server + +Create a dedicated module instead of growing `proxy/mod.rs` further. + +Suggested files: + +```text +src/web/mod.rs +src/web/static_files.rs +src/web/path.rs +src/web/rewrite.rs +src/web/fastcgi.rs +src/web/fastcgi_protocol.rs +src/web/presets.rs # only if presets are also generated server-side +``` + +### 8.1 Required static features + +- GET; +- HEAD; +- 405 for unsupported methods when the route is a pure static file handler; +- `Allow: GET, HEAD` where appropriate; +- document root; +- per-location root override; +- alias support; +- directory detection; +- trailing-slash directory redirect; +- index resolution; +- MIME type detection; +- configurable MIME overrides; +- default `application/octet-stream`; +- `Content-Length`; +- `Last-Modified`; +- `ETag`; +- `If-None-Match`; +- `If-Modified-Since`; +- `304 Not Modified`; +- byte ranges; +- `206 Partial Content`; +- `416 Range Not Satisfiable`; +- `Accept-Ranges: bytes`; +- `If-Range`; +- streaming file bodies; +- no whole-file buffering; +- directory autoindex optional and OFF by default; +- correct HEAD metadata with no body; +- correct behavior under HTTP/1.1, HTTP/2 and HTTP/3. + +### 8.2 Validators + +Recommended ETag strategy for 0.25.0: + +- metadata-based validator using stable file metadata such as size + modification time; +- do not hash the complete file on every request; +- document whether the tag is weak or strong; +- obey HTTP conditional precedence correctly (`If-None-Match` before date validators). + +### 8.3 Ranges + +P0: + +- one satisfiable byte range; +- suffix ranges; +- open-ended ranges; +- correct 416 with `Content-Range: bytes */size`; +- `If-Range`. + +For syntactically valid multi-range requests, either implement multipart ranges or safely ignore the Range and return a complete `200`. Do not generate malformed multipart responses. + +### 8.4 Index handling + +Example: + +```text +index index.php index.html; +``` + +If a directory resolves to `index.php`, **never serve PHP source as a static file**. + +The index URI must enter the internal web dispatch path so the PHP/FastCGI handler is selected. + +### 8.5 Autoindex + +- default OFF; +- HTML escape names; +- URL encode links; +- never expose filesystem paths outside the configured root; +- do not show denied dotfiles; +- do not follow a symlink that violates the root policy; +- HEAD support; +- bounded directory entry count or streamed/bounded generation to avoid memory abuse. + +--- + +## 9. Filesystem security + +This is a release blocker for 0.25.0. + +### 9.1 Reuse current path validation + +Reuse the existing normalized routing path as the basis for local URL mapping. + +Do not add a second decoder that could allow double decoding. + +Continue rejecting: + +- encoded `/`; +- encoded `\\`; +- encoded `%`; +- dot segments; +- malformed percent escapes; +- control characters; +- ambiguous separators; +- malformed UTF-8. + +### 9.2 Root escape prevention + +A requested path must never escape its configured document root/alias through: + +- `..`; +- percent encoding; +- symlinks; +- rename races where feasible to prevent; +- platform separator differences. + +Prefer a capability/open-beneath style implementation rather than simple string-prefix checks. + +Cross-platform correctness for Linux/BSD is more important than Linux-only `sendfile` optimization. + +### 9.3 Symlink policy + +Suggested explicit values: + +- `deny`; +- `within_root` — recommended default; +- `allow` — advanced opt-in with clear warning. + +Never make `allow` the default. + +### 9.4 Sensitive files + +Provide a secure default/preset policy for common sensitive paths: + +- `.git`; +- `.svn`; +- `.hg`; +- `.env`; +- `.htaccess`; +- `.htpasswd`; +- editor/backup files where configured; +- application-specific config files from presets. + +Do not over-block arbitrary dotfiles if the administrator explicitly opts out, but presets should be secure by default. + +### 9.5 PHP/source disclosure prevention + +PHP-like executable source extensions must not silently fall back to static serving when: + +- PHP-FPM is down; +- the PHP route is invalid; +- FastCGI times out; +- the FastCGI connection fails. + +Return an error (`502`/`504` as appropriate). Never expose source code as a fallback. + +Protect at least common PHP script extensions used by the configured policy (`.php`, and optional configured extensions such as `.phtml`/`.phar`). + +--- + +## 10. Internal web routing + +### 10.1 try_files + +Required behavior: + +```text +try_files $uri $uri/ @drupal +``` + +and: + +```text +try_files $uri $uri/ /index.php?$args +``` + +Candidate checks should be performed relative to the effective route root/alias. + +No candidate may escape the allowed filesystem root. + +### 10.2 Internal redirect + +An internal redirect may change URI/query and run web route selection again. + +Required safeguards: + +- hard maximum redirect/rewrite depth, e.g. 10; +- loop detection/diagnostics; +- no client-visible redirect unless explicitly configured as a 3xx response; +- re-evaluate the effective location after `last`; +- preserve or replace query according to explicit configuration; +- internal redirect target must pass the same path validator. + +### 10.3 `last` and `break` + +Implement explicit structured behavior: + +- `last` — update internal URI and reselect location; +- `break` — stop rewrite evaluation and continue the current location handler with the rewritten internal path. + +Do not reuse the existing proxy rewrite enum for this purpose. + +### 10.4 Safe variables + +Initial variable allowlist should include only values with clear semantics: + +- `$uri` — normalized internal URI path; +- `$request_uri` — original path/query where safe for value expansion, never for filesystem joining directly; +- `$args`; +- `$host`; +- `$scheme`; +- `$request_method`; +- regex captures `$1..$9` for the current web rewrite rule; +- `$document_root` for FastCGI parameter expansion; +- `$request_filename` as a derived safe path representation for conditions, not as an unchecked arbitrary path. + +No arbitrary environment variable expansion from public requests. + +### 10.5 Safe conditional rules + +Do not implement general Nginx `if` scripting. + +Support structured conditions needed by normal web hosting: + +- file exists; +- file does not exist; +- regular file exists; +- directory exists; +- URI regex; +- request method; +- hostname if genuinely needed. + +Example equivalent of: + +```nginx +if (!-e $request_filename) { + rewrite ^/(.*)$ /index.php?q=$1 last; +} +``` + +should be representable as an explicit condition + internal rewrite, not raw script text. + +--- + +## 11. FastCGI / PHP-FPM + +Implement FastCGI as a local web origin, not as an HTTP proxy backend. + +### 11.1 Endpoints + +Support: + +- Unix socket: + +```text +/run/php/php8.4-fpm.sock +``` + +- TCP: + +```text +127.0.0.1:9000 +``` + +Optionally allow remote TCP FastCGI only through explicit configured endpoints. Never derive the endpoint from request data. + +### 11.2 FastCGI protocol + +Implement/test the required FastCGI responder flow: + +- `FCGI_BEGIN_REQUEST`; +- role `FCGI_RESPONDER`; +- `FCGI_PARAMS`; +- `FCGI_STDIN`; +- `FCGI_STDOUT`; +- `FCGI_STDERR`; +- `FCGI_END_REQUEST`; +- FastCGI name/value length encoding; +- record splitting for values/bodies larger than one record; +- bounded stderr capture/logging; +- protocol error handling; +- request IDs; +- connection close/reuse state. + +### 11.3 Connection model + +- async I/O; +- bounded number of FastCGI connections per profile; +- queue timeout; +- connect timeout; +- read timeout; +- write/body idle timeout; +- backpressure; +- cancellation on downstream disconnect; +- no unlimited buffers; +- do not assume FastCGI multiplexing support; +- reuse a connection only after a clean `END_REQUEST` and when configured/safe. + +PHP-FPM compatibility is the primary target. + +### 11.4 Request body + +- stream POST/PUT/PATCH bodies to `FCGI_STDIN`; +- do not buffer normal uploads completely in memory; +- enforce a configurable maximum request size for web/PHP routes; +- handle missing/invalid Content-Length safely; +- preserve streaming behavior for HTTP/2 and HTTP/3; +- abort the FastCGI request/connection safely when the client disconnects. + +### 11.5 CGI/FastCGI response parsing + +Parse CGI-style response headers from `FCGI_STDOUT`: + +- `Status`; +- `Content-Type`; +- `Content-Length` if valid; +- `Location`; +- repeated `Set-Cookie`; +- arbitrary safe end-to-end headers. + +Then: + +- remove/reject hop-by-hop/framing headers that an application must not control; +- do not trust an application-supplied transfer framing header; +- pass the body as a streaming HTTP response; +- map malformed FastCGI output to `502`; +- map connect failure to `502`; +- map timeout to `504`; +- map unavailable/capacity state consistently with RustProxy conventions. + +### 11.6 PHP-FPM profiles + +Suggested profile: + +```json +{ + "id": "php-default", + "endpoint": { + "type": "unix", + "path": "/run/php/php8.4-fpm.sock" + }, + "connect_timeout_ms": 1000, + "response_timeout_seconds": 120, + "write_timeout_seconds": 60, + "max_connections": 32, + "queue_timeout_ms": 5000, + "keep_conn": true, + "max_request_body_bytes": 134217728, + "params": {} +} +``` + +Limits are examples and must be validated against existing RustProxy resource conventions before final defaults are chosen. + +--- + +## 12. FastCGI parameters + +Provide a safe built-in default equivalent to the useful part of common `fastcgi_params` / `fastcgi.conf` setups. + +Required parameters: + +- `GATEWAY_INTERFACE`; +- `SERVER_SOFTWARE`; +- `QUERY_STRING`; +- `REQUEST_METHOD`; +- `CONTENT_TYPE`; +- `CONTENT_LENGTH`; +- `SCRIPT_FILENAME`; +- `SCRIPT_NAME`; +- `REQUEST_URI`; +- `DOCUMENT_URI`; +- `DOCUMENT_ROOT`; +- `SERVER_PROTOCOL`; +- `REQUEST_SCHEME`; +- `HTTPS`; +- `REMOTE_ADDR`; +- `REMOTE_PORT`; +- `SERVER_ADDR`; +- `SERVER_PORT`; +- `SERVER_NAME`. + +Also expose incoming request headers as safe CGI `HTTP_*` parameters after normal RustProxy header validation/sanitization. + +### 12.1 Trust boundary + +`REMOTE_ADDR` must come from RustProxy's already validated effective client identity, not directly from an untrusted forwarding header. + +`SERVER_ADDR` / `SERVER_PORT` must come from the accepted local frontend. + +`SERVER_NAME` comes from the normalized claimed vhost/host. + +### 12.2 SCRIPT_FILENAME + +Default PHP mapping: + +```text +$document_root$fastcgi_script_name +``` + +but calculate it structurally rather than concatenating unchecked request text. + +Before opening FastCGI: + +- resolve against the permitted document root; +- require an existing regular file unless explicitly configured otherwise; +- enforce symlink/root policy; +- reject traversal/ambiguous paths; +- never pass an arbitrary filesystem path supplied by a client. + +### 12.3 Custom params + +Allow per-profile and per-route overrides. + +Precedence suggestion: + +```text +built-in defaults +-> PHP profile params +-> route params +``` + +Protect parameters that define RustProxy's trust boundary from unsafe browser-controlled replacement where needed. + +### 12.4 PATH_INFO + +Default OFF/safe. + +Provide an explicit structured option for applications that require PATH_INFO, similar in purpose to `fastcgi_split_path_info`, with tests for script/path confusion. + +Never enable permissive PHP path guessing by default. + +Document a secure PHP-FPM/PHP recommendation such as disabling unsafe path fixing where appropriate. + +--- + +## 13. Mapping Nginx concepts to RustProxy + +| Nginx concept | RustProxy 0.25 design | +| --- | --- | +| `server_name` | existing vhost `frontend.hosts` | +| `listen` | existing named listeners | +| `location /x` | existing `PathMatch::Prefix` | +| `location ^~ /x` | existing `PathMatch::PrefixPriority` | +| `location = /x` | existing `PathMatch::Exact` | +| `location ~ regex` | existing `PathMatch::Regex` | +| `location ~* regex` | existing `PathMatch::RegexInsensitive` | +| `root` | vhost web root / route root override | +| `alias` | route web alias | +| `index` | vhost/route web indexes | +| `try_files` | structured web `try_files` | +| `rewrite ... last` | new internal web rewrite + location reselection | +| `rewrite ... break` | new internal web rewrite without reselection | +| `if (-e ...)` | structured file condition | +| `if (!-e ...)` | structured not-exists condition | +| `fastcgi_pass` | PHP/FastCGI profile endpoint | +| `fastcgi_param` | FastCGI profile/route params | +| `add_header` | existing response header rules | +| `return` | existing `LocationResponse` | +| `Cache-Control` | existing response header rules / preset helper | +| `Vary` | existing response header rules plus compression-owned values | +| `status_zone` | RustProxy metrics keyed by bounded vhost/route IDs, not arbitrary dynamic zones | + +Do not duplicate existing header/ACL/return functionality inside the new WWW subsystem. + +--- + +## 14. Drupal examples + +### 14.1 Simple Drupal/front-controller behavior + +Nginx-style intent: + +```nginx +location / { + try_files $uri $uri/ @drupal; + index index.php index.html; +} + +location @drupal { + rewrite ^/(.*)$ /index.php?q=$1 last; +} +``` + +RustProxy should represent this structurally as: + +```text +Web root: /var/www/drupal +Indexes: index.php, index.html +Fallback static handler: + try file $uri + try directory $uri + otherwise -> @drupal + +Named location @drupal: + internal URI -> /index.php + query -> q= + reselect location + +Regex location \.php$: + FastCGI -> php-default + script must exist under document root +``` + +### 14.2 Advanced imagecache behavior + +Nginx-style intent: + +```nginx +location ^~ /files/imagecache/ { + add_header Cache-Control "public, max-age=604800"; + if_modified_since exact; + index index.php; + if (!-e $request_filename) { + rewrite ^/(.*)$ /index.php?q=$1 last; + break; + } + add_header Vary Accept-Encoding; +} +``` + +RustProxy representation: + +```text +Location match: + type: prefix_priority + path: /files/imagecache/ + +Action: + local web/static + +Response headers: + existing HeaderRules: + Cache-Control = public, max-age=604800 + Vary += Accept-Encoding (with normal de-duplication/compression rules) + +Index: + index.php + +Condition: + if requested file does not exist + +Internal rewrite: + regex: ^/(.*)$ + target: /index.php + query: q=$1 + flow: last +``` + +The existing proxy `PathRewrite` is not used for this internal rewrite. + +--- + +## 15. WordPress preset + +Generate ordinary configuration equivalent in intent to: + +```nginx +location / { + try_files $uri $uri/ /index.php?$args; +} + +location ~ \.php$ { + fastcgi_pass ...; +} +``` + +Preset requirements: + +- administrator selects WordPress root; +- default indexes include `index.php`, `index.html`; +- local static fallback; +- front-controller fallback to `/index.php` preserving query args; +- PHP regex route; +- PHP script existence check; +- block `.ht*`; +- block common secret/backup files; +- never serve PHP source statically; +- allow normal `/wp-content` static files; +- no hidden WordPress runtime flag. + +--- + +## 16. Drupal presets + +Provide at least two clear choices: + +### Drupal modern + +- normal static file checks; +- front controller `/index.php`; +- preserve normal query string; +- secure PHP route; +- common private file restrictions appropriate to the chosen document root. + +### Drupal legacy `q=` + +- normal static file checks; +- front controller `/index.php?q=`; +- preserve/append original query according to explicit generated config; +- secure PHP route. + +Do not guess the site's Drupal generation silently. The selected preset should be visible before applying. + +--- + +## 17. Laravel / Symfony presets + +### Laravel + +- document root must point to `public/`; +- static file/directory check; +- `/index.php` front controller; +- PHP-FPM; +- deny direct access outside the public root by construction. + +### Symfony + +- document root `public/`; +- static file check; +- `/index.php` front controller; +- PHP-FPM; +- secure source-file behavior. + +The UI should warn if the administrator selects a known project root while the expected public directory exists beneath it, but never silently rewrite an administrator path without showing the result. + +--- + +## 18. Static / SPA presets + +### Static website + +- web fallback static; +- indexes `index.html`; +- no PHP; +- autoindex off; +- validators/ranges enabled; +- secure dotfile policy. + +### SPA + +Equivalent intent: + +```text +try file $uri +otherwise internal fallback /index.html +``` + +API/static prefixes can still use existing Custom Locations or explicit web locations. + +--- + +## 19. Nextcloud/phpMyAdmin and advanced presets + +Treat these as presets, not special server code. + +They may need: + +- additional blocked paths; +- PATH_INFO handling; +- well-known redirects/rewrites; +- larger upload limits; +- application-specific headers. + +Before shipping a preset, add fixture tests that verify the generated structured configuration. Do not ship a preset based only on an approximate Nginx snippet. + +--- + +## 20. MIME handling + +Add a maintained built-in MIME map for common web types: + +- HTML; +- CSS; +- JavaScript; +- JSON; +- XML; +- SVG; +- images; +- fonts; +- audio/video; +- WASM; +- PDF; +- archives; +- text. + +Rules: + +- match file extension case-insensitively where appropriate; +- administrator may override/add mappings; +- unknown type -> `application/octet-stream`; +- never infer executable behavior from MIME type; +- PHP execution is determined only by an explicit FastCGI route/handler. + +--- + +## 21. Cache and compression behavior + +### 21.1 Browser cache + +Use normal HTTP headers for local static content: + +- `Cache-Control`; +- `Expires` if configured; +- `ETag`; +- `Last-Modified`; +- `Vary` where required. + +Existing route/vhost response header rules should remain the main mechanism for custom cache headers. + +### 21.2 RustProxy object cache + +For 0.25.0 P0, do not require local static files to pass through the proxy object cache. The disk file is already the local origin and the OS page cache provides the primary file cache. + +Likewise, do not silently apply the existing proxy cache semantics to FastCGI unless a dedicated design is added and tested. + +A future FastCGI cache can be added explicitly. + +### 21.3 Compression + +Reuse the existing response compression layer when safe. + +Rules: + +- do not compress a byte-range response incorrectly; +- preserve validator semantics; +- make `Vary: Accept-Encoding` correct; +- do not double-compress a FastCGI response that already has Content-Encoding; +- honor existing no-transform/private compression rules where applicable; +- HEAD remains bodyless. + +Optional precompressed `.br`/`.gz` static file selection can be deferred unless implemented with complete validator/Vary tests. + +--- + +## 22. Headers + +Reuse existing HeaderRules. + +Do not create another web-only header engine. + +For a local web route: + +- response header rules apply to static/FastCGI responses; +- request header rules should apply before FastCGI `HTTP_*` parameter generation; +- protected forwarding/framing headers remain protected; +- canonical client identity remains RustProxy-owned; +- FastCGI output cannot override hop-by-hop transport framing. + +--- + +## 23. Authentication / ACL / rate limits + +Local web content must not bypass current gates. + +The following must continue to run before filesystem content/FastCGI application data is returned: + +- host ACL; +- reusable ACL profile; +- vhost ACL; +- route ACL; +- country policy; +- vhost rate limits; +- Basic Auth; +- Tinyauth. + +Do not create a direct static-file shortcut before authentication. + +Authenticated responses retain current privacy protections. + +--- + +## 24. Error handling + +Reuse existing custom error pages where possible. + +Expected mapping: + +- missing static file -> 404; +- denied path/file policy -> 403/404 according to chosen security policy; +- unsupported static method -> 405; +- invalid Range -> 416; +- FastCGI connect/protocol failure -> 502; +- FastCGI timeout -> 504; +- FastCGI capacity/queue exhaustion -> 503; +- internal rewrite loop -> 500 with bounded diagnostic log; +- invalid internal target -> 400/500 depending on whether it originated from public input or invalid administrator configuration. + +Do not leak absolute filesystem paths, socket paths or PHP-FPM stderr to public clients. + +Operational logs may expose sanitized administrator-useful detail. + +--- + +## 25. Observability + +Add bounded metrics/log fields without high-cardinality public values. + +### 25.1 Request origin/action + +Expose a bounded action category: + +- `proxy`; +- `return`; +- `static`; +- `fastcgi`. + +### 25.2 Static metrics + +Suggested counters: + +- static requests; +- static bytes served; +- 304 count; +- range responses; +- not found; +- denied path; +- index resolutions; +- internal redirects/rewrite loop failures. + +### 25.3 FastCGI metrics + +Suggested counters/gauges: + +- requests; +- active connections; +- queued requests; +- connect errors; +- protocol errors; +- timeouts; +- response latency; +- bytes in/out. + +Use bounded profile/route IDs from configuration as labels. Never use arbitrary URI, script filename or query string as a Prometheus label. + +### 25.4 Live logs + +Add useful bounded fields: + +- route ID; +- action type; +- FastCGI profile ID; +- status; +- duration; +- optional relative script path, sanitized/truncated; +- internal rewrite count. + +Do not log FastCGI secrets or full POST bodies. + +--- + +## 26. Admin UI details + +### 26.1 Main WWW card + +Fields: + +```text +[ ] Enable web server +Document root: [/var/www/example/public] +Unmatched requests: [Serve from web root | Proxy | 404] +Index files: [index.php] [index.html] +Autoindex: [off] +Symlinks: [within root] +Preset: [Custom v] +``` + +Show a warning if the root is not accessible by the RustProxy service user. + +### 26.2 PHP card + +```text +[ ] Enable PHP/FastCGI +Profile: php-default +Endpoint type: Unix socket / TCP +Socket: /run/php/php8.4-fpm.sock +or host:port: 127.0.0.1:9000 +[Test connection] +``` + +Then Advanced: + +- timeouts; +- max connections; +- queue timeout; +- request body limit; +- custom FastCGI params; +- PATH_INFO. + +### 26.3 Front controller builder + +Provide common choices: + +```text +Existing file -> serve it +Existing directory -> serve/index it +Otherwise -> /index.php +Query handling -> preserve +``` + +Advanced representation may show: + +```text +try_files $uri $uri/ /index.php?$args +``` + +but the saved model remains structured. + +### 26.4 Web locations + +Do not hide that URL matching is the same RustProxy location system. + +A location card can show action: + +- Proxy; +- Return; +- Static files; +- PHP/FastCGI. + +The existing **Locations** tab remains the complete routing view. The **WWW** tab provides web-server-oriented defaults, presets and helpers. + +Avoid maintaining two independent location lists. + +### 26.5 Effective behavior preview + +Extend the current location preview with: + +- action: Static / FastCGI; +- effective document root/alias; +- index list; +- try_files chain; +- selected PHP profile; +- internal rewrite summary; +- script filename template; +- ACL/header composition. + +--- + +## 27. API / OpenAPI + +Update: + +- vhost schema; +- route schema; +- WebServerSettings; +- WebRouteSettings; +- FastCGI profile schemas; +- try_files candidates; +- internal rewrite/condition schemas; +- named location schemas; +- status/diagnostic schemas if new metrics are returned. + +Keep: + +- authenticated admin API rules; +- CSRF/revision CAS behavior; +- `deny_unknown_fields` semantics; +- current `/configuration` compatibility behavior; +- no new anonymous admin endpoint. + +A connection test endpoint for FastCGI, if added, must be authenticated and bounded and must not permit an arbitrary user-supplied network destination outside the validated draft/config context. + +--- + +## 28. Configuration validation + +Validation must happen before publishing a snapshot. + +Validate at least: + +- document root is absolute; +- configured path length/control characters; +- alias/root compatibility; +- index filenames contain no separators/traversal; +- MIME override names/values; +- FastCGI endpoint syntax; +- Unix socket path syntax; +- timeout ranges; +- connection/queue limits; +- request body limit; +- unique PHP profile IDs; +- unique named location IDs; +- named fallback references exist; +- rewrite regex compiles within bounded regex limits; +- internal rewrite replacement length; +- try_files item count; +- rewrite/redirect depth configured within hard server maximum; +- web route and local response are mutually exclusive; +- web route and legacy proxy-only settings do not conflict; +- proxy route requires a backend pool; +- `fallback=proxy` requires a backend pool; +- pure web vhost may have zero backends; +- PHP handler references an existing FastCGI profile; +- PHP source cannot accidentally be configured as generic static fallback without an explicit dangerous override, preferably disallow entirely. + +Filesystem existence checks may be shown as UI/runtime diagnostics, but decide carefully whether a temporarily missing mount should invalidate the persisted configuration. Prefer safe runtime `503/404` diagnostics over preventing startup solely because a removable/mounted document root is momentarily absent, unless security depends on canonicalization. + +--- + +## 29. Snapshot/runtime model + +All web configuration must compile into the same immutable snapshot philosophy as the rest of RustProxy. + +Request path must not query SQLite. + +Compile/store: + +- normalized root/alias configuration; +- compiled regexes; +- validated FastCGI endpoints; +- parsed header/param templates; +- named location lookup maps; +- MIME map overlays; +- bounded limits; +- route action enum/representation. + +Keep dynamic state outside desired config: + +- FastCGI connection pool state; +- FastCGI counters; +- observed socket availability; +- filesystem metadata/cache observations if any are added. + +Do not persist live FastCGI connection state. + +--- + +## 30. FastCGI profile lifecycle + +On config publication: + +- reuse compatible runtime pool state when profile identity and endpoint/security parameters are unchanged, if practical; +- otherwise retire the old pool gracefully; +- existing in-flight requests hold their runtime references until completion; +- new requests use the new snapshot/profile; +- no request replay to another PHP-FPM endpoint after partial body transmission. + +This should follow the existing backend lease/publication philosophy. + +--- + +## 31. System/service integration + +RustProxy remains non-root. + +Documentation must explain: + +### Static roots + +The RustProxy service user needs read/search permission on the configured document tree. + +RustProxy must not automatically `chmod`, `chown` or upload site content in 0.25.0. + +### PHP-FPM Unix socket + +The service user needs permission to connect to the PHP-FPM socket. + +Document typical approaches: + +- configure PHP-FPM socket owner/group/mode; +- add RustProxy service user to the appropriate group where operationally acceptable. + +Do not automatically alter PHP-FPM configuration. + +### MAC policies + +Mention SELinux/AppArmor/system policy considerations where relevant, but do not weaken them automatically. + +--- + +## 32. Preset generation rules + +Presets must: + +- modify only the current vhost draft; +- show what will change before/after apply; +- preserve hostname/listener/TLS/auth settings unless a preset explicitly needs a web field; +- never delete backends silently; +- never silently remove custom routes; +- warn about route conflicts; +- create deterministic route IDs; +- produce valid ordinary configuration; +- be editable immediately after generation; +- pass the same validator as handwritten configuration. + +If the vhost already has routes, offer a clear conflict strategy in the UI rather than overwriting them automatically. + +--- + +## 33. Example proposed configuration — pure WordPress + +Illustrative only; final schema names may differ. + +```json +{ + "id": "wordpress", + "enabled": true, + "frontend": { + "hosts": ["www.example.com"], + "listener_ids": ["https"] + }, + "backends": [], + "web": { + "enabled": true, + "document_root": "/srv/www/wordpress", + "fallback": "web", + "indexes": ["index.php", "index.html"], + "autoindex": false, + "symlinks": "within_root", + "default_mime": "application/octet-stream", + "php_profiles": [ + { + "id": "php-default", + "endpoint": { + "type": "unix", + "path": "/run/php/php8.4-fpm.sock" + } + } + ] + }, + "routes": [ + { + "id": "php", + "path_prefix": "\\.php$", + "match_type": "regex", + "enabled": true, + "web": { + "handler": "fastcgi", + "fastcgi_profile": "php-default", + "require_script_file": true + } + }, + { + "id": "root-web", + "path_prefix": "/", + "match_type": "prefix", + "enabled": true, + "web": { + "handler": "static", + "try_files": [ + {"kind": "file", "value": "$uri"}, + {"kind": "directory", "value": "$uri"}, + {"kind": "uri", "value": "/index.php", "query": "$args"} + ] + } + } + ] +} +``` + +A final implementation may use the vhost web fallback for the root static action instead of persisting a synthetic `/` route. Whichever representation is chosen, do not reintroduce the old ambiguity between the vhost fallback and a user Custom Location. + +--- + +## 34. Example proposed configuration — mixed static/PHP/proxy + +Use case: + +- website served locally; +- `/api/` proxied to an application backend; +- `.php` sent to PHP-FPM. + +Conceptually: + +```text +/api/ -> existing Proxy action +*.php -> new FastCGI action +all else -> local Static web fallback +``` + +The `/api/` proxy must use the same existing backend selection, health, retry/capacity, WebSocket and cache rules as 0.24.2. + +Turning on WWW must not route `/api/` through FastCGI or filesystem code when the existing Custom Location selected Proxy. + +--- + +## 35. Source-file change map + +Expected areas. Keep changes focused. + +### `src/vhost.rs` + +- optional `web` vhost settings; +- optional route web settings; +- validation; +- conditional backend requirement; +- legacy synthetic-root normalization updated to recognize deliberate web route configuration. + +### `src/config.rs` + +- compile web settings; +- compile route actions; +- compile local web fallback; +- keep existing match precedence; +- validate cross references; +- preserve claimed-host behavior. + +### `src/proxy/mod.rs` + +Keep existing proxy branch as intact as practical. + +Changes should mainly be: + +- dispatch selected compiled action; +- call the new web module for Static/FastCGI; +- preserve common ACL/auth/response pipeline; +- distinguish local web responses from placeholder/error local responses; +- expose final/effective route to response policy if internal rewrites can select another route. + +Do not implement filesystem/FastCGI protocol details directly in this large module. + +### `src/web/*` + +New implementation modules for: + +- secure path resolution; +- static files; +- conditions; +- try_files; +- internal rewrites; +- FastCGI protocol/client; +- MIME handling. + +### `src/admin/web/vhosts.js` + +- WWW tab; +- web settings editor; +- PHP profile editor; +- preset generator; +- web route actions; +- effective behavior preview; +- validation messages. + +### `src/admin/openapi.json` + +- schema additions only; preserve existing paths/contracts where possible. + +### `langs/en_US.json`, `langs/pl_pl.json` + +- all new UI text goes through discovered language packs; +- no hardcoded PL/EN switch. + +### Documentation + +Add at least: + +```text +docs/WEB_SERVER.md +docs/STATIC_FILES.md +docs/FASTCGI.md +docs/WEB_REWRITES.md +docs/WEB_PRESETS.md +``` + +Update: + +```text +README.md +ROADMAP.md +CHANGELOG.md +docs/VHOSTS.md +docs/CUSTOM_LOCATIONS.md +docs/API.md +docs/SECURITY.md +docs/TEST_REPORT.md +``` + +--- + +## 36. Tests — existing behavior regression gate + +Before considering new WWW functionality accepted, prove old behavior is unchanged. + +Keep/add tests for: + +- exact route precedence; +- `^~` priority prefix; +- regex order; +- longest prefix; +- custom route -> main backend fallback; +- claimed exact host does not fall through to broader wildcard; +- old synthetic root normalization; +- deliberate customized `/` remains a real route; +- existing preserve/strip/replace/regex proxy rewrite behavior; +- existing query preservation; +- local `return`; +- backend pinning; +- balancing; +- ACL composition; +- Basic Auth; +- Tinyauth; +- cache behavior; +- header rules; +- security headers; +- compression; +- WebSockets; +- H1/H2/H3. + +Add fixture tests that deserialize representative 0.24.2 JSON and verify effective compiled behavior under 0.25.0. + +--- + +## 37. Tests — static files + +Required tests: + +- GET existing file; +- HEAD existing file; +- missing file 404; +- directory trailing slash redirect; +- directory index HTML; +- directory index PHP enters FastCGI rather than static source; +- autoindex disabled; +- autoindex enabled; +- MIME known extension; +- MIME unknown extension; +- Content-Length; +- Last-Modified; +- ETag; +- If-None-Match 304; +- If-Modified-Since 304; +- validator precedence; +- Range start/end; +- suffix Range; +- open-ended Range; +- invalid/unsatisfiable Range 416; +- If-Range match/miss; +- HEAD + Range semantics; +- large file streaming; +- client disconnect; +- compression with static file; +- range not incorrectly compressed; +- custom response headers; +- custom error pages; +- H1/H2/H3. + +--- + +## 38. Tests — filesystem security + +Include explicit attacks: + +```text +/../secret +/%2e%2e/secret +/%2fetc/passwd +/%5c..%5csecret +/%252e%252e/secret +//..//secret +/a;b +malformed % escape +invalid UTF-8 escape +symlink -> outside root +symlink chain -> outside root +alias boundary confusion +root prefix string collision (/srv/www vs /srv/www2) +``` + +Also test races/behavior to the extent the selected safe filesystem API allows. + +PHP source must remain undisclosed under every failure case. + +--- + +## 39. Tests — try_files and rewrites + +Required: + +- `$uri` existing file; +- `$uri/` existing directory; +- fallback URI; +- named location; +- status fallback `=404` equivalent; +- query preserve; +- query replace; +- query append if supported; +- regex captures; +- `last` reselects location; +- `break` remains in location; +- maximum internal redirects; +- direct loop A -> A; +- loop A -> B -> A; +- rewritten path still passes ambiguity/path validation; +- condition file exists/not exists; +- condition directory exists; +- route ACL remains enforced after internal reselection. + +--- + +## 40. Tests — FastCGI + +Build a deterministic fake FastCGI peer for automated tests. Real PHP-FPM remains a release-host integration gate. + +Test: + +- Unix socket connect; +- TCP connect; +- BEGIN/PARAMS/STDIN protocol framing; +- long FastCGI param length encoding; +- record splitting; +- GET; +- HEAD; +- POST body streaming; +- large upload; +- chunked/H2/H3 request body adaptation; +- standard PHP-like response; +- `Status` header; +- repeated `Set-Cookie`; +- `Location`; +- stderr handling; +- END_REQUEST; +- malformed response headers; +- premature EOF; +- protocol status failure; +- connect refused; +- connect timeout; +- response timeout; +- queue capacity exhaustion; +- clean connection reuse; +- broken connection not reused; +- downstream cancellation; +- protected hop-by-hop headers stripped; +- `SCRIPT_FILENAME` correct; +- missing script rejected before FastCGI; +- script symlink outside root rejected; +- `REMOTE_ADDR` uses trusted effective identity; +- FastCGI custom params precedence; +- PATH_INFO disabled default; +- PATH_INFO explicit mode. + +--- + +## 41. Tests — presets/UI + +For every shipped preset: + +- generated JSON/schema validates; +- deterministic output; +- expected web root/fallback; +- expected routes; +- PHP handler where needed; +- safe blocked paths; +- no hidden runtime preset flag; +- applying a preset does not silently delete unrelated hostname/TLS/auth settings; +- conflict warning for existing routes; +- Custom mode round-trips generated values; +- Advanced JSON and visual editor edit the same model; +- EN/PL language packs contain every new key; +- `scripts/check_source.py` language key checks pass; +- JS syntax checks pass. + +--- + +## 42. Release-host integration tests + +Before production-ready status, test with real services: + +### PHP-FPM + +- supported distro PHP-FPM over Unix socket; +- TCP PHP-FPM; +- upload through PHP; +- slow PHP timeout; +- FPM restart while RustProxy remains running; +- socket disappears/reappears; +- connection pool recovery. + +### Applications + +At minimum: + +- WordPress install/front page/admin login/static assets/permalinks; +- Drupal front page/static assets/front-controller routing; +- Laravel or Symfony front controller; +- SPA fallback. + +### Transports + +Run application requests over: + +- HTTP/1.1; +- HTTP/2; +- HTTP/3. + +No separate local-web implementation per transport. + +--- + +## 43. Performance/resource requirements + +- no whole-file static buffering; +- no whole-request PHP upload buffering; +- bounded FastCGI connections; +- bounded FastCGI queue; +- bounded stderr capture; +- bounded rewrite depth; +- bounded try_files count; +- bounded index list; +- bounded named locations; +- bounded custom params; +- bounded autoindex generation; +- no request-path SQLite access; +- no request-path external configuration/provider calls; +- cancellation-aware I/O; +- full response/request permits retained for body lifetime consistent with existing resource policy. + +Cross-platform correctness first. `sendfile`/zero-copy may be an optimization after correct async streaming is proven. + +--- + +## 44. Suggested hard limits + +Final values should be selected consistently with existing project limits, but define explicit bounds for at least: + +- max web routes per vhost; +- max try_files candidates per route; +- max internal rewrites per route; +- max internal redirect depth; +- max named locations; +- max PHP profiles; +- max FastCGI custom params; +- max param name/value length; +- max FastCGI stderr captured per request; +- max index entries; +- max MIME overrides; +- max request body per web/PHP route; +- max FastCGI connections/profile; +- max FastCGI queue waiters. + +Do not create attacker-controlled unbounded vectors/maps on the request path. + +--- + +## 45. Implementation sequence + +### Phase 0 — lock current behavior + +- [ ] Add/confirm regression tests for current route/fallback behavior. +- [ ] Add 0.24.2 config fixtures. +- [ ] Add tests proving existing `PathRewrite` semantics. +- [ ] Add tests for ACL/auth/header/security order. +- [ ] Record current H1/H2/H3 behavior. + +### Phase 1 — additive schema and internal action model + +- [ ] Add optional `Vhost.web`. +- [ ] Add optional `PathRoute.web`. +- [ ] Add new config structs with `deny_unknown_fields`. +- [ ] Add validation. +- [ ] Add internal compiled action/target model. +- [ ] Compile all old configs to exactly existing Proxy/Return behavior. +- [ ] Allow zero HTTP backends only when configuration has no Proxy requirement. +- [ ] Update host ownership/fallback selection without changing legacy behavior. +- [ ] Update synthetic-root normalization. +- [ ] Run full regression suite before implementing filesystem access. + +### Phase 2 — static file engine + +- [ ] Secure URL -> filesystem path resolver. +- [ ] Root/alias. +- [ ] GET/HEAD. +- [ ] directory redirects. +- [ ] index. +- [ ] MIME. +- [ ] Content-Length. +- [ ] ETag/Last-Modified. +- [ ] conditional GET. +- [ ] Range/If-Range. +- [ ] streaming body. +- [ ] symlink policy. +- [ ] dotfile/sensitive-file policy. +- [ ] PHP source deny. +- [ ] optional autoindex. +- [ ] local web response marker/pipeline integration. + +### Phase 3 — web routing + +- [ ] structured try_files. +- [ ] named internal locations. +- [ ] safe variables. +- [ ] file/directory conditions. +- [ ] internal rewrite engine. +- [ ] query handling. +- [ ] `last`. +- [ ] `break`. +- [ ] internal redirect limit. +- [ ] final route response-header policy handling. +- [ ] rewrite/loop diagnostics. + +### Phase 4 — FastCGI/PHP-FPM + +- [ ] FastCGI wire protocol. +- [ ] Unix sockets. +- [ ] TCP. +- [ ] connection pool. +- [ ] timeouts/queue/cancellation. +- [ ] PARAMS generation. +- [ ] streaming STDIN. +- [ ] streaming STDOUT. +- [ ] bounded STDERR. +- [ ] CGI response header parser. +- [ ] status/error mapping. +- [ ] PHP script existence/security. +- [ ] FastCGI params. +- [ ] optional PATH_INFO. +- [ ] real PHP-FPM integration test. + +### Phase 5 — WWW dashboard + +- [ ] new tab. +- [ ] web root editor. +- [ ] fallback editor. +- [ ] PHP profiles. +- [ ] FastCGI test UI. +- [ ] static options. +- [ ] try_files builder. +- [ ] rewrite builder. +- [ ] named locations. +- [ ] location action Static/FastCGI. +- [ ] effective behavior preview. +- [ ] route conflict detection. +- [ ] no duplicate independent location system. + +### Phase 6 — presets + +- [ ] Static. +- [ ] SPA. +- [ ] Generic PHP. +- [ ] WordPress. +- [ ] Drupal modern. +- [ ] Drupal legacy `q=`. +- [ ] Joomla. +- [ ] Laravel. +- [ ] Symfony. +- [ ] Nextcloud. +- [ ] phpMyAdmin. +- [ ] fixture tests for every preset. + +### Phase 7 — API/observability/docs + +- [ ] OpenAPI. +- [ ] metrics. +- [ ] Prometheus. +- [ ] live logs. +- [ ] status diagnostics. +- [ ] EN/PL translations. +- [ ] docs. +- [ ] README/ROADMAP/CHANGELOG. + +### Phase 8 — release acceptance + +- [ ] `python3 scripts/preflight.py`. +- [ ] `python3 scripts/check_source.py`. +- [ ] `python3 scripts/release_smoke.py`. +- [ ] `./dev.sh fmt`. +- [ ] `./dev.sh check`. +- [ ] `./dev.sh test`. +- [ ] JS syntax checks. +- [ ] OpenAPI/lang JSON parse. +- [ ] real PHP-FPM Unix/TCP. +- [ ] WordPress runtime. +- [ ] Drupal runtime. +- [ ] H1/H2/H3 runtime. +- [ ] security/path traversal suite. +- [ ] large file/upload streaming/resource test. +- [ ] update `docs/TEST_REPORT.md` with what was actually executed vs blocked. + +--- + +## 46. Explicit non-goals for 0.25.0 + +Unless separately accepted, do not expand scope into: + +- full Nginx config parser; +- `.htaccess` parser; +- Apache config compatibility; +- arbitrary embedded scripting; +- CGI process execution; +- mod_php equivalent; +- Node/Python/Ruby process manager; +- site file upload/file manager; +- automatic CMS installer/updater; +- automatic PHP-FPM configuration changes; +- arbitrary shell commands from web config; +- arbitrary environment variable exposure; +- FastCGI cache without a separate cache design; +- reverse proxy behavior rewrite; +- new authentication stack; +- new TLS stack. + +The feature is a web/static/FastCGI origin inside the existing RustProxy vhost and policy architecture. + +--- + +## 47. Definition of Done + +RustProxy 0.25.0 Web Server is complete when all of the following are true: + +- an unchanged 0.24.2 proxy config behaves the same under 0.25.0; +- a vhost can run with zero HTTP backends when it is a pure web server; +- a vhost can mix local static/PHP and existing proxied locations; +- static files are streamed securely from disk; +- directory/index handling works; +- PHP source cannot leak through static fallback; +- PHP-FPM works over Unix socket and TCP; +- request/response bodies stream with bounded resources; +- FastCGI params have safe trusted values; +- `try_files` handles normal front-controller applications; +- internal rewrites are bounded and do not reuse/alter legacy proxy rewrite semantics; +- Drupal/WordPress presets generate editable ordinary config; +- existing ACL/Auth/Tinyauth/rate/security/header/compression policies still apply; +- existing H1/H2/H3 request pipeline is reused; +- path traversal/symlink/root escape tests pass; +- existing proxy/location/cache/WebSocket regression tests pass; +- UI/API/manual JSON all edit the same canonical model; +- release documentation clearly distinguishes verified runtime tests from static/source checks. + +--- + +## 48. Recommended architectural summary + +The lowest-risk design is: + +```text +Existing vhost/frontend/listener/auth/ACL pipeline + | + v + Existing location matcher + | + +--------+---------+-----------+ + | | | + Proxy Return Web + | | | + existing code existing code +---------------------+ + Static FastCGI + | | + filesystem origin PHP-FPM origin + +----------+----------+ + | + v + existing response policy + headers/security/compression/etc. +``` + +The WWW tab is a configuration UX over this canonical vhost model, not a separate server process and not a second competing routing engine. + +The main compatibility rule is simple: + +> If `web` is not configured, the request must follow the same path and semantics as RustProxy 0.24.2. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..afa8eed --- /dev/null +++ b/build.rs @@ -0,0 +1,135 @@ +//! Build extensible language packs and pinned, authenticated Swagger UI assets. +use serde_json::{json, Value}; +use std::{collections::HashSet, env, fs, path::PathBuf, process::Command}; + +// Prepare the actual embedded bytes here, not in a dependency's earlier build. +// Cargo config discovery is deliberately irrelevant to the pinned default. +fn swagger_assets( + root: &std::path::Path, + out: &std::path::Path, +) -> Result<(), Box> { + for name in [ + "SWAGGER_UI_DOWNLOAD_URL", + "SWAGGER_UI_ARCHIVE_SHA256", + "RUST_PROXY_PYTHON", + "CARGO_NET_OFFLINE", + ] { + println!("cargo:rerun-if-env-changed={name}"); + } + let helper = root.join("scripts/swagger_assets.py"); + println!("cargo:rerun-if-changed={}", helper.display()); + let interpreters = match env::var_os("RUST_PROXY_PYTHON") { + Some(value) if !value.is_empty() => vec![value], + _ => vec!["python3".into(), "python".into()], + }; + for interpreter in interpreters { + match Command::new(&interpreter).arg(&helper).arg("--out-dir").arg(out).status() { + Ok(status) if status.success() => return Ok(()), + Ok(_) => return Err("Swagger UI asset preparation failed; see the diagnostic above and docs/API_ACCESS.md".into()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(error.into()), + } + } + Err("Python 3.10+ is required for build assets; install python3 or set RUST_PROXY_PYTHON to its executable".into()) +} +fn main() -> Result<(), Box> { + let root = PathBuf::from( + env::var_os("CARGO_MANIFEST_DIR").ok_or("Cargo did not supply CARGO_MANIFEST_DIR")?, + ); + let out = PathBuf::from(env::var_os("OUT_DIR").ok_or("Cargo did not supply OUT_DIR")?); + swagger_assets(&root, &out)?; + let langs = root.join("langs"); + println!("cargo:rerun-if-changed={}", langs.display()); + let mut paths: Vec<_> = fs::read_dir(&langs) + .expect("langs directory is required") + .map(|entry| entry.expect("read language entry").path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "json")) + .collect(); + paths.sort(); + assert!( + !paths.is_empty() && paths.len() <= 128, + "expected 1..128 language packs" + ); + let mut languages = Vec::new(); + let mut locales = HashSet::new(); + for path in paths { + println!("cargo:rerun-if-changed={}", path.display()); + let id = path + .file_stem() + .unwrap() + .to_str() + .expect("language filename must be UTF-8"); + assert!( + id.len() <= 64 + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'), + "invalid language filename: {}", + path.display() + ); + let content = fs::read(&path).expect("read language JSON"); + assert!(content.len() <= 262_144, "language pack exceeds 256 KiB"); + let pack: Value = + serde_json::from_slice(&content).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + let meta = pack["meta"] + .as_object() + .expect("language meta object is required"); + for key in ["locale", "name", "native_name", "direction"] { + assert!( + meta.get(key) + .and_then(Value::as_str) + .is_some_and(|v| !v.is_empty() && v.len() <= 128), + "{id}: missing/invalid {key}" + ); + } + assert!( + matches!(meta["direction"].as_str(), Some("ltr" | "rtl")), + "{id}: invalid direction" + ); + let locale = meta["locale"].as_str().unwrap(); + assert!( + locale.split('-').all(|part| !part.is_empty() + && part.len() <= 8 + && part.bytes().all(|b| b.is_ascii_alphanumeric())), + "{id}: use a BCP-47 style locale" + ); + assert!( + locales.insert(locale.to_ascii_lowercase()), + "duplicate locale {locale}" + ); + let translations = pack["translations"] + .as_object() + .expect("translations object is required"); + assert!( + translations.len() <= 4096 + && translations.iter().all(|(k, v)| !k.is_empty() + && k.len() <= 128 + && v.as_str().is_some_and(|s| s.len() <= 16_384)), + "{id}: translations must be bounded strings" + ); + languages.push(json!({"id":id,"meta":meta,"translations":translations})); + } + assert!( + languages.iter().any(|p| p["id"] == "en_US"), + "en_US.json is the required fallback pack" + ); + let login_path = root.join("src/admin/web/login-keys.json"); + println!("cargo:rerun-if-changed={}", login_path.display()); + let login_keys: HashSet = + serde_json::from_slice(&fs::read(login_path).expect("read public translation keys")) + .expect("parse public translation keys"); + let mut login_languages = languages.clone(); + for language in &mut login_languages { + language["translations"] + .as_object_mut() + .expect("translations object") + .retain(|key, _| login_keys.contains(key)); + } + let login_bundle = + serde_json::to_vec(&json!({"default":"en_US","languages":login_languages})).unwrap(); + fs::write(out.join("login-langs.json"), login_bundle).expect("write login translations"); + let bundle = serde_json::to_vec(&json!({"default":"en_US","languages":languages})).unwrap(); + assert!(bundle.len() <= 8_388_608, "language bundle exceeds 8 MiB"); + fs::write(out.join("langs.json"), bundle).expect("write language bundle"); + Ok(()) +} diff --git a/debian/README.md b/debian/README.md new file mode 100644 index 0000000..56cd908 --- /dev/null +++ b/debian/README.md @@ -0,0 +1,60 @@ +# Debian packaging + +This directory builds the `rustproxy` binary package for RustProxy 0.24.5. +The package installs: + +- `/usr/bin/rust-proxy` +- `/etc/rustproxy/environment` (conffile) +- `rustproxy.service` +- logrotate policy for `/var/log/rustproxy` +- Grafana dashboard under `/usr/share/rustproxy/grafana/` + +The service runs as the unprivileged `rustproxy` system user. `CAP_NET_BIND_SERVICE` +is the only ambient/bounding capability, allowing ports 80/443 without root. The +administration listener is loopback-only by default. systemd and `postinst` both +ensure `/var/lib/rustproxy` and `/var/log/rustproxy` exist with mode `0750`. + +## Build the `.deb` + +Install build prerequisites (including Rust 1.88+), then from the source tree: + +```bash +./debian/build-package.sh +``` + +The script uses the committed `Cargo.lock`, runs `cargo fetch --locked`, invokes +`dpkg-buildpackage -us -uc -b`, and copies generated package artifacts to `dist/`. +If dependencies are pre-fetched/vendored, set `CARGO_NET_OFFLINE=true` for an +offline Cargo build. + +## Repository for rustproxy.linuxiarz.pl + +Install repository tooling: + +```bash +apt install dpkg-dev apt-utils gzip gnupg +``` + +Build the repository: + +```bash +RUSTPROXY_REPO_GPG_KEY='' ./debian/build-repository.sh +``` + +Publish the generated `repository/` directory at the web root of +`https://rustproxy.linuxiarz.pl/`. Export the public repository key separately and +install it on clients as `/usr/share/keyrings/rustproxy.gpg`, then use: + +```text +deb [signed-by=/usr/share/keyrings/rustproxy.gpg] https://rustproxy.linuxiarz.pl stable main +``` + +The repository script emits `Packages`, maximum-gzip `Packages.gz`, `Release`, and, +when a GPG key is configured, `Release.gpg` plus `InRelease`. + +## Logs + +Structured file logging defaults to `/var/log/rustproxy/access.jsonl` when enabled +in RustProxy. Logrotate runs daily, keeps at most three days, compresses immediately +with `gzip -9`, and uses `copytruncate` because RustProxy owns the open log file. +Systemd journal output remains independent of this JSONL rotation. diff --git a/debian/build-package.sh b/debian/build-package.sh new file mode 100755 index 0000000..2afca00 --- /dev/null +++ b/debian/build-package.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT" + +for tool in cargo rustc dpkg-buildpackage; do + command -v "$tool" >/dev/null 2>&1 || { echo "Missing build tool: $tool" >&2; exit 2; } +done + +RUSTC_VERSION=$(rustc --version | awk '{print $2}') +if ! dpkg --compare-versions "$RUSTC_VERSION" ge 1.88; then + echo "RustProxy requires rustc >= 1.88 (found $RUSTC_VERSION)" >&2 + exit 2 +fi + +# Fetch against Cargo.lock before the Debian build. Use CARGO_NET_OFFLINE=true +# when the registry/cache has already been prepared or dependencies are vendored. +cargo fetch --locked + +dpkg-buildpackage -us -uc -b + +mkdir -p dist +find .. -maxdepth 1 -type f \( -name 'rustproxy_0.24.5_*.deb' -o -name 'rustproxy_0.24.5_*.changes' -o -name 'rustproxy_0.24.5_*.buildinfo' \) \ + -exec cp -f {} dist/ \; +echo "Packages copied to $ROOT/dist" diff --git a/debian/build-repository.sh b/debian/build-repository.sh new file mode 100755 index 0000000..95c468e --- /dev/null +++ b/debian/build-repository.sh @@ -0,0 +1,44 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +REPO=${1:-"$ROOT/repository"} +DEBS=${2:-"$ROOT/dist"} +ARCH=${RUSTPROXY_REPO_ARCH:-$(dpkg --print-architecture)} +CODENAME=${RUSTPROXY_REPO_CODENAME:-stable} +COMPONENT=${RUSTPROXY_REPO_COMPONENT:-main} + +for tool in dpkg-scanpackages apt-ftparchive gzip; do + command -v "$tool" >/dev/null 2>&1 || { echo "Missing repository tool: $tool" >&2; exit 2; } +done + +POOL="$REPO/pool/$COMPONENT/r/rustproxy" +BINARY="$REPO/dists/$CODENAME/$COMPONENT/binary-$ARCH" +mkdir -p "$POOL" "$BINARY" +find "$DEBS" -maxdepth 1 -type f -name 'rustproxy_*.deb' -exec cp -f {} "$POOL/" \; + +cd "$REPO" +dpkg-scanpackages --arch "$ARCH" "pool/$COMPONENT" /dev/null > "dists/$CODENAME/$COMPONENT/binary-$ARCH/Packages" +gzip -9 -n -c "dists/$CODENAME/$COMPONENT/binary-$ARCH/Packages" > "dists/$CODENAME/$COMPONENT/binary-$ARCH/Packages.gz" +apt-ftparchive \ + -o APT::FTPArchive::Release::Origin=RustProxy \ + -o APT::FTPArchive::Release::Label=RustProxy \ + -o APT::FTPArchive::Release::Suite="$CODENAME" \ + -o APT::FTPArchive::Release::Codename="$CODENAME" \ + -o APT::FTPArchive::Release::Architectures="$ARCH" \ + -o APT::FTPArchive::Release::Components="$COMPONENT" \ + release "dists/$CODENAME" > "dists/$CODENAME/Release" + +if [ -n "${RUSTPROXY_REPO_GPG_KEY:-}" ]; then + command -v gpg >/dev/null 2>&1 || { echo "RUSTPROXY_REPO_GPG_KEY is set but gpg is missing" >&2; exit 2; } + gpg --batch --yes --local-user "$RUSTPROXY_REPO_GPG_KEY" --armor --detach-sign \ + --output "dists/$CODENAME/Release.gpg" "dists/$CODENAME/Release" + gpg --batch --yes --local-user "$RUSTPROXY_REPO_GPG_KEY" --clearsign \ + --output "dists/$CODENAME/InRelease" "dists/$CODENAME/Release" +else + rm -f "dists/$CODENAME/Release.gpg" "dists/$CODENAME/InRelease" + echo "WARNING: repository is unsigned. Set RUSTPROXY_REPO_GPG_KEY for production." >&2 +fi + +echo "Repository ready: $REPO" +echo "Publish this directory as https://rustproxy.linuxiarz.pl/" diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..ab7393d --- /dev/null +++ b/debian/changelog @@ -0,0 +1,197 @@ +rustproxy (0.24.5) stable; urgency=medium + + * Fix empty HTTP/3 request-body forwarding to HTTP/1.1 upstreams, including DELETE. + * Preserve streaming for non-empty H3 request bodies and existing H1/H2 behavior. + + -- RustProxy Release Fri, 02 Oct 2026 23:27:00 +0200 + +rustproxy (0.24.4) stable; urgency=medium + + * Disable Transparent Huge Pages locally for the RustProxy Linux process before Tokio startup. + * Keep host THP policy unchanged and retain existing glibc arena/trim tuning. + + -- RustProxy Release Fri, 02 Oct 2026 15:20:00 +0200 + +rustproxy (0.24.3) stable; urgency=medium + + * Reduce glibc allocator retention and transformation-path temporary allocations. + * Move HTTP/3 enablement to Listeners while retaining dedicated QUIC tuning. + * Hide unsupported process diagnostics dynamically in the dashboard. + + -- RustProxy Release Fri, 02 Oct 2026 09:45:00 +0200 + +rustproxy (0.24.2) stable; urgency=medium + + * Version-only release bump; no functional changes from 0.24.1. + + -- RustProxy Release Thu, 01 Oct 2026 22:11:00 +0200 + +rustproxy (0.24.1) stable; urgency=medium + + * Use shared cross-platform statistics APIs and expose more portable metrics. + * Add authenticated runtime path metadata and platform-aware installed-data discovery. + * Move Logging into Settings while retaining legacy navigation compatibility. + + -- RustProxy Release Thu, 01 Oct 2026 21:00:00 +0200 + +rustproxy (0.24.0) stable; urgency=medium + + * Graceful HTTP/2 GOAWAY/drain shutdown with independent idle, lifetime and drain limits. + * Extract system statistics backends and add BSD platform support plus process platform reporting. + + -- RustProxy Release Thu, 01 Oct 2026 18:46:00 +0200 + +rustproxy (0.23.3) stable; urgency=medium + + * Add opt-in per-vhost legacy WebSocket compatibility for older appliances. + * Align legacy WebSocket Origin/Host and tolerate legacy 101 response framing. + + -- RustProxy Maintainers Thu, 01 Oct 2026 00:00:00 +0000 + +rustproxy (0.23.2) stable; urgency=medium + + * Add configurable live-chart history windows for Cache and Transformation. + * Fix HTTPS administration transport handling for --livelogs and local CLI API calls. + + -- RustProxy Maintainers Thu, 01 Oct 2026 11:36:19 +0200 + +rustproxy (0.23.1) stable; urgency=medium + + * Add collapsible full-width live Cache and Transform activity charts. + * Update package and administration UI identity to 0.23.1. + + -- RustProxy Maintainers Thu, 01 Oct 2026 11:11:00 +0200 + +rustproxy (0.23.0) stable; urgency=medium + + * Add HTTP/1.1 Upgrade: h2c and external certificate hot-deploy/reload support. + + -- RustProxy Maintainers Thu, 01 Oct 2026 08:30:00 +0200 + +rustproxy (0.22.2) stable; urgency=medium + + * Add replay-safe backend failover retry with bounded policy and metrics. + * Correct explicit HTTP/2 upstream request handling and add H2 regression coverage. + * Add vhost retry controls to the administration UI and OpenAPI schema. + + -- RustProxy Maintainers Thu, 01 Oct 2026 07:10:00 +0200 + +rustproxy (0.22.1) stable; urgency=medium + + * Add HTTP/2 RFC 8441 WebSocket transport and opt-in h2c prior-knowledge listeners. + * Add active tunnel inventory/kill administration with audit and H1/H2/H3 metrics. + * Move selective PURGE/BAN workflows into modal UI and fix null-safe operation feedback. + + -- RustProxy Maintainers Wed, 30 Sep 2026 22:45:00 +0200 + +rustproxy (0.22.0) stable; urgency=medium + + * Add cache request collapsing, single-range HTTP 206 reuse, selective PURGE/BAN, + configurable stale-if-error classes and proactive cache refresh. + * Extend the authenticated cache API, dashboard, metrics and regression coverage. + + -- RustProxy Maintainers Wed, 30 Sep 2026 21:00:00 +0200 + +rustproxy (0.21.0) stable; urgency=medium + + * Add sharded Cold Cache with bounded asynchronous garbage collection. + * Implement RFC 5861 stale handling and request max-stale semantics. + * Add per-vhost static-response transformations before cache/compression. + + -- RustProxy Maintainers Tue, 29 Sep 2026 21:25:00 +0200 + +rustproxy (0.20.0) stable; urgency=medium + + * Split Certificates into Installed, Renewal and Generate tabs. + * Improve ACME renewal status and failed-operation messages. + * Show exact private Certbot log directories for certificate failures. + + -- RustProxy Maintainers Tue, 29 Sep 2026 12:14:00 +0200 + +rustproxy (0.19.4) stable; urgency=medium + + * Align Docker-vhost Aliases with neighboring form controls. + * Preserve sub-0.1 KiB/s backend traffic rendering. + * Add cache-domain filtering by effective cache state. + + -- RustProxy Maintainers Tue, 29 Sep 2026 11:30:00 +0200 + +rustproxy (0.19.3) stable; urgency=medium + + * Preserve exact Settings location across re-login and browser refresh. + * Add shared saving state, field-focused validation and unsaved-tab markers. + * Keep certificate import file selections after failed saves. + + -- RustProxy Maintainers Tue, 29 Sep 2026 09:32:00 +0200 + +rustproxy (0.19.2) stable; urgency=medium + + * Improve default-response status labels and Live-log buffering. + * Polish checkbox/alias layout and backend rate formatting. + * Keep local pages non-cacheable and allow guarded vhost-ID renames. + + -- RustProxy Maintainers Mon, 28 Sep 2026 23:26:00 +0200 + +rustproxy (0.19.1) stable; urgency=medium + + * Fix Default responses saves and isolate cache objects by frontend scheme. + * Retain on-demand Live Logs history and polish traffic/checkbox/Docker Advanced UI. + * Remove stale screenshots and update documentation/release contracts. + + -- RustProxy Maintainers Mon, 28 Sep 2026 22:14:00 +0200 + +rustproxy (0.19.0) stable; urgency=medium + + * Persist per-admin Live Logs column visibility in SQLite while keeping full JSON exports. + * Fix disabled backend TLS verification for legacy X.509 certificates and retain detailed upstream diagnostics. + * Remove the duplicate version label from the top-right dashboard toolbar. + + -- RustProxy Maintainers Mon, 28 Sep 2026 14:55:00 +0200 + +rustproxy (0.18.8) stable; urgency=medium + + * Add Docker-vhost alias chips plus redirect, WebSocket and cache controls. + * Add a Docker-agent default ACL profile and remove the Overview codec explainer. + + -- RustProxy Maintainers Mon, 28 Sep 2026 12:43:00 +0200 + +rustproxy (0.18.7) stable; urgency=medium + + * Polish Overview network charts and collapse response-time history by default. + * Align expanded Docker-vhost routing and ACL controls. + + -- RustProxy Maintainers Mon, 28 Sep 2026 12:05:00 +0200 + +rustproxy (0.18.6) stable; urgency=medium + + * Fix compression/protocol telemetry and improve Docker/backend dashboard UX. + * Remove the Open proxy shortcut and make Compact density materially tighter. + + -- RustProxy Maintainers Mon, 28 Sep 2026 10:36:37 +0200 + +rustproxy (0.18.4) stable; urgency=medium + + * Default backend health checks to TCP and streamline Docker vhost/Security UX. + * Move administration audit to an on-demand modal. + + -- RustProxy Maintainers Mon, 28 Sep 2026 09:45:00 +0200 + +rustproxy (0.18.3) stable; urgency=medium + + * Separate Docker vhost/backend Prometheus metric families and collector group. + + -- RustProxy Maintainers Sun, 27 Sep 2026 17:45:00 +0200 + +rustproxy (0.18.2) stable; urgency=medium + + * Add optional Docker-agent discovery and dynamic Docker vhost assignments. + * Remove revision-header exposure and refresh dashboard/settings UX. + + -- RustProxy package maintainer Sun, 27 Sep 2026 15:06:00 +0200 + +rustproxy (0.17.0) stable; urgency=medium + + * Package RustProxy 0.17.0 with a hardened systemd service, log rotation, + Prometheus/Grafana assets and local-response host support. + + -- RustProxy package maintainer Sat, 27 Sep 2026 11:35:00 +0200 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..498115a --- /dev/null +++ b/debian/control @@ -0,0 +1,16 @@ +Source: rustproxy +Section: net +Priority: optional +Maintainer: RustProxy package maintainer +Build-Depends: debhelper-compat (= 13), cargo, rustc (>= 1.88), pkg-config, libsqlite3-dev +Standards-Version: 4.7.0 +Rules-Requires-Root: no +Homepage: https://rustproxy.linuxiarz.pl + +Package: rustproxy +Architecture: any +Depends: ${shlibs:Depends}, ${misc:Depends}, adduser, ca-certificates, logrotate, gzip +Description: independent Rust reverse proxy with local administration + RustProxy is an independent reverse proxy with HTTP/1.1, HTTP/2, HTTP/3, + WebSocket, TLS, caching, redirects, local response hosts, TCP/UDP streams, + diagnostics and a local administration dashboard. diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..25b97ae --- /dev/null +++ b/debian/rules @@ -0,0 +1,24 @@ +#!/usr/bin/make -f + +export DEB_BUILD_MAINT_OPTIONS = hardening=+all +export CARGO_TERM_COLOR = never + +%: + dh $@ + +override_dh_auto_configure: + # Cargo is the authoritative build system; no generated configure step. + +override_dh_auto_build: + cargo build --release --locked + +override_dh_auto_test: +ifeq (,$(filter nocheck,$(DEB_BUILD_OPTIONS))) + cargo test --locked +endif + +override_dh_auto_install: + install -D -m0755 target/release/rust-proxy debian/rustproxy/usr/bin/rust-proxy + install -D -m0640 debian/rustproxy.environment debian/rustproxy/etc/rustproxy/environment + install -D -m0644 grafana/rustproxy-overview.json debian/rustproxy/usr/share/rustproxy/grafana/rustproxy-overview.json + install -D -m0644 grafana/README.md debian/rustproxy/usr/share/doc/rustproxy/grafana/README.md diff --git a/debian/rustproxy.environment b/debian/rustproxy.environment new file mode 100644 index 0000000..5f6f985 --- /dev/null +++ b/debian/rustproxy.environment @@ -0,0 +1,7 @@ +# RustProxy Debian package defaults. +# systemctl restart rustproxy after changing listener environment values. +RUST_LOG=rust_proxy=info,warn +RUST_PROXY_DATA_DIR=/var/lib/rustproxy +RUST_PROXY_ADMIN_LISTEN=127.0.0.1:9090 +RUST_PROXY_PROXY_LISTEN=0.0.0.0:80 +RUST_PROXY_TLS_LISTEN=0.0.0.0:443 diff --git a/debian/rustproxy.logrotate b/debian/rustproxy.logrotate new file mode 100644 index 0000000..d4aaa60 --- /dev/null +++ b/debian/rustproxy.logrotate @@ -0,0 +1,15 @@ +/var/log/rustproxy/*.jsonl /var/log/rustproxy/*.log /var/log/rustproxy/vhosts/*.jsonl { + daily + rotate 3 + maxage 3 + missingok + notifempty + compress + compresscmd /usr/bin/gzip + compressext .gz + compressoptions -9 + dateext + dateformat -%Y%m%d + copytruncate + su rustproxy rustproxy +} diff --git a/debian/rustproxy.postinst b/debian/rustproxy.postinst new file mode 100755 index 0000000..2f38b9e --- /dev/null +++ b/debian/rustproxy.postinst @@ -0,0 +1,14 @@ +#!/bin/sh +set -e + +if ! getent group rustproxy >/dev/null 2>&1; then + addgroup --system rustproxy >/dev/null +fi +if ! getent passwd rustproxy >/dev/null 2>&1; then + adduser --system --ingroup rustproxy --home /var/lib/rustproxy --no-create-home \ + --disabled-password --shell /usr/sbin/nologin rustproxy >/dev/null +fi +install -d -o rustproxy -g rustproxy -m 0750 /var/lib/rustproxy /var/log/rustproxy + +#DEBHELPER# +exit 0 diff --git a/debian/rustproxy.service b/debian/rustproxy.service new file mode 100644 index 0000000..8b66c18 --- /dev/null +++ b/debian/rustproxy.service @@ -0,0 +1,55 @@ +[Unit] +Description=RustProxy reverse proxy +Documentation=https://rustproxy.linuxiarz.pl +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +User=rustproxy +Group=rustproxy +WorkingDirectory=/var/lib/rustproxy +Environment=MALLOC_ARENA_MAX=2 +EnvironmentFile=-/etc/rustproxy/environment +ExecStart=/usr/bin/rust-proxy serve +Restart=on-failure +RestartSec=3s +KillSignal=SIGINT +KillMode=control-group +TimeoutStopSec=120s +UMask=0027 + +# Bind 80/443 without running the service as root. +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +NoNewPrivileges=true + +# Runtime filesystem policy. Diagnostics intentionally retain normal /proc access. +StateDirectory=rustproxy +StateDirectoryMode=0750 +LogsDirectory=rustproxy +LogsDirectoryMode=0750 +RuntimeDirectory=rustproxy +RuntimeDirectoryMode=0750 +ReadWritePaths=/var/lib/rustproxy /var/log/rustproxy +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +LockPersonality=true +RestrictRealtime=true +RestrictSUIDSGID=true +RestrictNamespaces=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallArchitectures=native + +# High enough for many frontend/upstream sockets without removing all limits. +LimitNOFILE=65536 +TasksMax=4096 + +[Install] +WantedBy=multi-user.target diff --git a/debian/rustproxy.tmpfiles b/debian/rustproxy.tmpfiles new file mode 100644 index 0000000..0ec940f --- /dev/null +++ b/debian/rustproxy.tmpfiles @@ -0,0 +1,2 @@ +d /var/lib/rustproxy 0750 rustproxy rustproxy - +d /var/log/rustproxy 0750 rustproxy rustproxy - diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..89ae9db --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (native) diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..bb0d3ae --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,47 @@ +# Example Linux service + +`rustproxy.service` is an operator-reviewed deployment example, not an installed or +tested service. It runs the compiled executable as a dedicated `rustproxy` account, +uses the public administration profile shown below and grants only the capability needed +for low-port binding. It does not run the proxy as root or grant Docker socket access. + +Before installation, run the real Rust acceptance suite, build a release executable, +create the unprivileged account/group through your normal system administration +process and install the executable at `/opt/rustproxy/rust-proxy`. The service +manager creates `/var/lib/rustproxy` as private writable state; the actual private +data directory is `/var/lib/rustproxy`. `LogsDirectory=rustproxy` provisions +`/var/log/rustproxy` for the JSONL access-log sink while the rest of the filesystem +remains protected by `ProtectSystem=strict`. The service enables 80/443 startup +defaults, does not start a demo backend and uses a self-signed fallback certificate +until trusted certificates are imported. Firewall, DNS and trust configuration are +separate operator responsibilities. + +Existing SQLite listener overrides take precedence over these startup defaults. +Review Settings before moving an old database into this service. Public addresses +are deliberately explicit in the example. Customize the unit for other paths, +addresses, limits or a service-account convention. Never point it at a user-owned +source directory containing writable executables or real public web content. + +After starting through your service manager, run account/certificate CLI operations +as the same service account with `--data-dir /var/lib/rustproxy`. The example +uses `ProtectHome=true`, so certificate files intended for runtime use must be in +the data directory, not in a home directory. Use an SSH tunnel to access the local +admin port remotely. This reference unit deliberately binds administration to `0.0.0.0:9090` with +`--allow-remote-admin`. It is plain HTTP and must be limited by firewall/private-network +policy. The normal installer remains loopback-safe unless that flag is explicitly supplied. For a systemd drop-in, reset `ExecStart` +before replacing it: + +```ini +[Service] +ExecStart= +ExecStart=/opt/rustproxy/current/rust-proxy --data-dir /var/lib/rustproxy --admin-listen 0.0.0.0:9090 serve --proxy-listen 0.0.0.0:80 --tls-listen 0.0.0.0:443 --allow-remote-admin +ReadWritePaths=/var/log/rustproxy +``` + +Saving HTTP/TLS or TCP/UDP stream listener changes in the UI does not restart this service. Restart explicitly +only after checking permission, free ports and certificate readiness. A bad saved +bind can prevent startup; preserve a pre-change database backup and consult +`docs/MIGRATION.md` for recovery. Verify the unit on the target system with +`systemd-analyze verify` and exercise actual non-root binding before accepting a +production deployment. This packaging environment did not run systemd or bind +privileged ports through this unit. diff --git a/deploy/rustproxy.service b/deploy/rustproxy.service new file mode 100644 index 0000000..288d986 --- /dev/null +++ b/deploy/rustproxy.service @@ -0,0 +1,40 @@ +# Reference for install.sh defaults; the installer generates paths from its arguments. +[Unit] +Description=RustProxy reverse proxy +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +User=rustproxy +Group=rustproxy +WorkingDirectory=/var/lib/rustproxy +UMask=0077 +Environment=RUST_LOG=rust_proxy=info,warn +Environment=MALLOC_ARENA_MAX=2 +Environment=PATH=/opt/rustproxy-certbot/bin:/usr/local/bin:/usr/bin:/bin +EnvironmentFile=-/etc/rustproxy/environment +ExecStart=/opt/rustproxy/current/rust-proxy --data-dir /var/lib/rustproxy --admin-listen 0.0.0.0:9090 serve --proxy-listen 0.0.0.0:80 --tls-listen 0.0.0.0:443 --allow-remote-admin +Restart=on-failure +RestartSec=3 +KillSignal=SIGINT +KillMode=control-group +TimeoutStopSec=120 +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +LogsDirectory=rustproxy +LogsDirectoryMode=0770 +ReadWritePaths=/var/lib/rustproxy /var/log/rustproxy +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +LockPersonality=true + +[Install] +WantedBy=multi-user.target diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..fc02fa1 --- /dev/null +++ b/dev.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" +umask 077 +if [[ -f .env ]]; then + # This is a local shell configuration file. Do not use untrusted .env files. + set -a + # shellcheck disable=SC1091 + source .env + set +a +fi +if ! command -v cargo >/dev/null 2>&1 && [[ -x "$HOME/.cargo/bin/cargo" ]]; then + export PATH="$HOME/.cargo/bin:$PATH" +fi +mode="${1:-run}" +if [[ $# -gt 0 ]]; then shift; fi +case "$mode" in + help|-h|--help) + cat <<'HELP' +RustProxy 0.24.5 + ./dev.sh Build and start proxy + local panel + ./dev.sh run [serve flags] Same, with extra server options + ./dev.sh admin setup-token Generate/print a one-time setup capability before first login + ./dev.sh admin init --username NAME + Create the sole administrator (hidden password prompt) + ./dev.sh admin reset-password + Recover the password and revoke all existing sessions + ./dev.sh admin logout-all Revoke sessions without changing the password + ./dev.sh admin status Show administrator setup status + ./dev.sh tls generate --name app --hosts app.localhost,alias.localhost + Create a named self-signed certificate (no overwrite) + ./dev.sh tls import --name app --cert fullchain.pem --key privkey.pem + Validate and import a PEM pair (private key mode 600) + ./dev.sh tls deploy --id app --cert /etc/letsencrypt/live/app/fullchain.pem --key /etc/letsencrypt/live/app/privkey.pem + Deploy an externally issued pair to an existing manual certificate id + ./dev.sh tls reload Re-read and hot-apply configured certificate files without changing config revision + ./dev.sh config reload Revalidate the current runtime configuration + ./dev.sh repair Preserve known legacy files; no compilation required + ./dev.sh doctor Check source layout and show available build tools + ./dev.sh token Compatibility alias for admin setup-token + ./dev.sh config get Export the running desired config as JSON + ./dev.sh config apply FILE Validate, persist and hot-apply a JSON document + ./dev.sh config validate FILE + Validate only; works without a running server + ./dev.sh backup DIRECTORY Back up SQLite and configured certificates using Python 3 + ./dev.sh mock [--port 9001] Start a random-data HTTP origin (Python only; Ctrl+C to stop) + ./dev.sh mock-auth [--port 9300] + Start a LOOPBACK-ONLY fake Tinyauth peer (never production) + ./dev.sh acme --doctor Check local Certbot availability without network access + ./dev.sh acme --cert-dir DIR Read provider request JSON from stdin; stage PEM files + ./dev.sh test Run offline release checks and all Rust tests + ./dev.sh build Build the development binary only + ./dev.sh release Build an optimized binary (target/release/rust-proxy) + ./dev.sh fmt Format Rust sources with rustfmt + ./dev.sh check Cargo check for all targets + +Defaults: HTTP 127.0.0.1:8080, HTTPS 127.0.0.1:8443, panel 127.0.0.1:9090. +The optional diagnostic origin uses 127.0.0.1:9000 and is OFF by default. +Set RUST_PROXY_TLS=0 to disable the TLS listener. Default certificate is self-signed. +Set RUST_PROXY_DEMO=1 to explicitly start a diagnostic origin and seed a NEW database. +Configuration options: .env.example, README.md, docs/API.md, ./dev.sh run --help. +HELP + exit 0 ;; + run|admin|token|config|tls|repair|doctor|backup|test|build|release|fmt|check|mock|mock-auth|acme) ;; + *) printf 'Unknown command: %s. Use ./dev.sh help\n' "$mode" >&2; exit 2 ;; +esac +if [[ "$mode" == "backup" ]]; then + exec python3 scripts/backup.py --data-dir "${RUST_PROXY_DATA_DIR:-$ROOT/data}" "$@" +fi +if ! command -v python3 >/dev/null 2>&1; then + printf 'Python 3.10+ is required for source preflight and upgrade safety checks.\n' >&2 + exit 127 +fi +python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else "Python 3.10+ is required.")' +if [[ "$mode" == "doctor" ]]; then + python3 scripts/preflight.py + printf 'Source layout: OK\n' + for tool in cargo rustc cc python3; do + printf '%s: ' "$tool" + command -v "$tool" || printf 'NOT FOUND\n' + done + exit 0 +fi +if [[ "$mode" == "acme" ]]; then exec python3 scripts/acme_provider.py "$@"; fi +if [[ "$mode" == "mock-auth" ]]; then exec python3 scripts/mock_auth.py "$@"; fi +if [[ "$mode" == "mock" ]]; then exec python3 scripts/mock_server.py "$@"; fi +python3 scripts/preflight.py --repair-known +if [[ "$mode" == "repair" ]]; then printf 'Source preflight: OK\n'; exit 0; fi +if [[ "$mode" == "test" ]]; then + python3 scripts/check_source.py + python3 scripts/release_smoke.py +fi +if ! command -v cargo >/dev/null 2>&1; then + cat >&2 <<'MISSING' +Rust/Cargo is not installed or not in PATH. +Install the stable Rust toolchain using https://rustup.rs, then open a new terminal. +Linux also needs a C compiler/linker (for example: build-essential or gcc). +macOS: install the Xcode command line tools. Windows: use WSL2 + Bash. +This script does not install system software or run sudo automatically. +MISSING + exit 127 +fi +export RUST_PROXY_DATA_DIR="${RUST_PROXY_DATA_DIR:-$ROOT/data}" +export RUST_PROXY_PROXY_LISTEN="${RUST_PROXY_PROXY_LISTEN:-127.0.0.1:8080}" +export RUST_PROXY_ADMIN_LISTEN="${RUST_PROXY_ADMIN_LISTEN:-127.0.0.1:9090}" +if [[ "${RUST_PROXY_TLS:-1}" == "1" ]]; then + export RUST_PROXY_TLS_LISTEN="${RUST_PROXY_TLS_LISTEN:-127.0.0.1:8443}" +else + unset RUST_PROXY_TLS_LISTEN +fi +export RUST_PROXY_DEMO_LISTEN="${RUST_PROXY_DEMO_LISTEN:-127.0.0.1:9000}" +export RUST_LOG="${RUST_LOG:-rust_proxy=info,warn}" +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" +if [[ "$CARGO_TARGET_DIR" != /* ]]; then export CARGO_TARGET_DIR="$ROOT/$CARGO_TARGET_DIR"; fi +if [[ "$mode" == "fmt" ]]; then exec cargo fmt --all "$@"; fi +if [[ ! -f Cargo.lock ]]; then + printf 'Resolving dependencies once; creating Cargo.lock (keep it for future builds).\n' >&2 + cargo generate-lockfile +fi +case "$mode" in + check) exec cargo check --locked --all-targets "$@" ;; + release) exec cargo build --locked --release "$@" ;; +esac +printf 'Building RustProxy 0.24.5...\n' >&2 +cargo build --locked +BIN="$CARGO_TARGET_DIR/debug/rust-proxy" +case "$mode" in + build) printf 'Binary: %s\n' "$BIN" ;; + token) exec "$BIN" token "$@" ;; + admin) exec "$BIN" admin "$@" ;; + config) exec "$BIN" config "$@" ;; + tls) exec "$BIN" tls "$@" ;; + test) + cargo test --locked --all-targets ;; + run) + args=(serve) + if [[ "${RUST_PROXY_DEMO:-0}" == "1" ]]; then args+=(--demo); fi + exec "$BIN" "${args[@]}" "$@" ;; +esac diff --git a/docker-agent/README.md b/docker-agent/README.md new file mode 100644 index 0000000..1dbbefb --- /dev/null +++ b/docker-agent/README.md @@ -0,0 +1,46 @@ +# RustProxy Docker Agent + +Small read-only discovery service for Docker hosts. It talks to the local Docker Engine Unix socket, reports running containers with non-loopback published TCP ports, preserves every host binding for each service, and exposes the inventory to RustProxy through a Bearer-token protected HTTPS endpoint. + +The inventory also reports multiple usable host addresses: active host interface addresses, the primary routed address, an optional public-IP lookup result, and the optional advertised/preferred address. Loopback/link-local and obvious Docker-generated bridge/veth interfaces are omitted from the host-address list. + +## TLS by default + +HTTPS is enabled by default. On first start the agent creates a persistent ECDSA P-256 self-signed certificate under `/var/lib/rustproxy-docker-agent/` and reuses it across restarts. The private key is written with mode `0600`. + +The installer prints the certificate SHA-256 fingerprint. Configure that fingerprint in **RustProxy -> Settings -> Docker agents**. RustProxy then pins the exact agent certificate while still verifying the TLS handshake signatures. A changed/replaced certificate is rejected until the configured fingerprint is updated. + +Show the current fingerprint again with: + +```bash +sudo rustproxy-docker-agent --print-fingerprint +``` + +If you replace the generated certificate with one signed by a normal public CA, the fingerprint field in RustProxy may be left empty so standard CA/hostname validation is used. Plain HTTP is available only when `RUSTPROXY_DOCKER_AGENT_TLS=false`; it is not the default. + +## Quick install on systemd + +```bash +sudo ./install.sh +``` + +The installer runs tests, builds the binary, creates the service account, writes a random token on first install, installs the hardened unit, starts it and prints the TLS fingerprint. + +## Build manually + +```bash +go test ./... +go build -trimpath -ldflags='-s -w' -o rustproxy-docker-agent . +``` + +## systemd + +1. Create a dedicated `rustproxy-docker-agent` system user and grant it read access to the Docker socket (commonly by membership in the `docker` group). Docker socket access is privileged; isolate this account and the host firewall. +2. Copy the binary to `/usr/local/bin/rustproxy-docker-agent`. +3. Copy `docker-agent.env.example` to `/etc/rustproxy/docker-agent.env`, generate a long random token and bind only to an address reachable by RustProxy. +4. Ensure `/var/lib/rustproxy-docker-agent` is writable only by the service identity. The shipped systemd unit creates it through `StateDirectory=`. +5. Copy `rustproxy-docker-agent.service` to `/etc/systemd/system/`, then run `systemctl daemon-reload && systemctl enable --now rustproxy-docker-agent`. + +The inventory endpoint is `GET /v1/inventory` with `Authorization: Bearer `. `/healthz` does not expose Docker inventory. Both are served over HTTPS by default. + +`RUSTPROXY_DOCKER_ADVERTISE_IP` marks a preferred address but does not suppress automatic discovery of the host's other addresses. `RUSTPROXY_DOCKER_PUBLIC_IP_URL` adds the externally observed address when enabled. RustProxy can choose a concrete reported address independently for every Docker-backed vhost. diff --git a/docker-agent/docker-agent.env.example b/docker-agent/docker-agent.env.example new file mode 100644 index 0000000..e66f470 --- /dev/null +++ b/docker-agent/docker-agent.env.example @@ -0,0 +1,10 @@ +RUSTPROXY_DOCKER_AGENT_LISTEN=0.0.0.0:9876 +RUSTPROXY_DOCKER_AGENT_TOKEN=replace-with-at-least-24-random-characters +# HTTPS is enabled by default. The agent creates and reuses a self-signed certificate. +RUSTPROXY_DOCKER_AGENT_TLS=true +RUSTPROXY_DOCKER_AGENT_CERT_FILE=/var/lib/rustproxy-docker-agent/agent.crt +RUSTPROXY_DOCKER_AGENT_KEY_FILE=/var/lib/rustproxy-docker-agent/agent.key +# Optional preferred address. Other usable host addresses are still auto-detected and reported. +RUSTPROXY_DOCKER_ADVERTISE_IP= +# Optional external/public address lookup. Set empty to disable. +RUSTPROXY_DOCKER_PUBLIC_IP_URL=https://api.ipify.org diff --git a/docker-agent/go.mod b/docker-agent/go.mod new file mode 100644 index 0000000..f6905c4 --- /dev/null +++ b/docker-agent/go.mod @@ -0,0 +1,3 @@ +module rustproxy/docker-agent + +go 1.22 diff --git a/docker-agent/install.sh b/docker-agent/install.sh new file mode 100755 index 0000000..176dfdb --- /dev/null +++ b/docker-agent/install.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ ${EUID:-$(id -u)} -ne 0 ]]; then + echo "Run as root: sudo ./install.sh" >&2 + exit 2 +fi +command -v go >/dev/null 2>&1 || { echo "Go is required to build the agent." >&2; exit 2; } +command -v systemctl >/dev/null 2>&1 || { echo "systemd is required." >&2; exit 2; } +getent group docker >/dev/null 2>&1 || { echo "Docker group not found; install Docker first." >&2; exit 2; } + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +cd "$ROOT" +go test ./... +go build -trimpath -ldflags='-s -w' -o "$TMP/rustproxy-docker-agent" . + +if ! id rustproxy-docker-agent >/dev/null 2>&1; then + useradd --system --no-create-home --shell /usr/sbin/nologin --gid docker rustproxy-docker-agent +else + usermod -a -G docker rustproxy-docker-agent +fi +install -m 0755 "$TMP/rustproxy-docker-agent" /usr/local/bin/rustproxy-docker-agent +install -d -m 0750 /etc/rustproxy +install -d -o rustproxy-docker-agent -g docker -m 0750 /var/lib/rustproxy-docker-agent +if [[ ! -f /etc/rustproxy/docker-agent.env ]]; then + install -m 0640 docker-agent.env.example /etc/rustproxy/docker-agent.env + TOKEN=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n') + sed -i "s/replace-with-at-least-24-random-characters/$TOKEN/" /etc/rustproxy/docker-agent.env + echo "Created /etc/rustproxy/docker-agent.env with a random token." +fi +install -m 0644 rustproxy-docker-agent.service /etc/systemd/system/rustproxy-docker-agent.service +systemctl daemon-reload +systemctl enable --now rustproxy-docker-agent.service +systemctl --no-pager --full status rustproxy-docker-agent.service || true + +CERT_FILE=/var/lib/rustproxy-docker-agent/agent.crt +for _ in $(seq 1 30); do + [[ -s "$CERT_FILE" ]] && break + sleep 0.1 +done +if [[ -s "$CERT_FILE" ]]; then + FINGERPRINT=$(/usr/local/bin/rustproxy-docker-agent --cert-file "$CERT_FILE" --print-fingerprint) + printf '\nAgent certificate SHA-256:\n%s\n' "$FINGERPRINT" +else + echo "Agent certificate was not created; inspect journalctl -u rustproxy-docker-agent." >&2 +fi + +TOKEN=$(sed -n 's/^RUSTPROXY_DOCKER_AGENT_TOKEN=//p' /etc/rustproxy/docker-agent.env | head -n1) +if [[ -n "$TOKEN" ]]; then + printf '\nBearer token:\n%s\n' "$TOKEN" +else + echo "Bearer token is missing from /etc/rustproxy/docker-agent.env." >&2 +fi + +echo "Installed. Configure RustProxy -> Settings -> Docker agents with the https:// endpoint, bearer token, and certificate fingerprint printed above." diff --git a/docker-agent/main.go b/docker-agent/main.go new file mode 100644 index 0000000..83bbdac --- /dev/null +++ b/docker-agent/main.go @@ -0,0 +1,701 @@ +package main + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "flag" + "fmt" + "io" + "log" + "math/big" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +const version = "0.24.5" + +type config struct { + Listen string + Token string + DockerSock string + AdvertiseIP string + PublicIPURL string + Hostname string + TLS bool + CertFile string + KeyFile string + Fingerprint string +} + +type dockerPort struct { + IP string `json:"IP"` + PrivatePort uint16 `json:"PrivatePort"` + PublicPort uint16 `json:"PublicPort"` + Type string `json:"Type"` +} + +type dockerContainer struct { + ID string `json:"Id"` + Names []string `json:"Names"` + Image string `json:"Image"` + Labels map[string]string `json:"Labels"` + Ports []dockerPort `json:"Ports"` +} + +type binding struct { + BindIP string `json:"bind_ip"` + PublicPort uint16 `json:"public_port"` +} + +type service struct { + Key string `json:"key"` + TechnicalName string `json:"technical_name"` + ContainerID string `json:"container_id"` + ContainerName string `json:"container_name"` + Image string `json:"image"` + PrivatePort uint16 `json:"private_port"` + Protocol string `json:"protocol"` + Bindings []binding `json:"bindings"` +} + +type hostAddress struct { + IP string `json:"ip"` + Source string `json:"source"` + Interface string `json:"interface,omitempty"` + Scope string `json:"scope"` +} + +type inventory struct { + AgentVersion string `json:"agent_version"` + Hostname string `json:"hostname"` + PublicIP string `json:"public_ip"` + PublicIPSource string `json:"public_ip_source"` + CertificateSHA256 string `json:"certificate_sha256"` + Addresses []hostAddress `json:"addresses"` + ObservedAt string `json:"observed_at"` + Services []service `json:"services"` +} + +type unixRoundTripper struct{ sock string } + +func (u unixRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", u.sock) + }, + DisableKeepAlives: false, + } + clone := req.Clone(req.Context()) + clone.URL.Scheme = "http" + clone.URL.Host = "docker" + return transport.RoundTrip(clone) +} + +func dockerContainers(ctx context.Context, sock string) ([]dockerContainer, error) { + client := &http.Client{Transport: unixRoundTripper{sock: sock}, Timeout: 4 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://docker/containers/json?all=0", nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("docker API: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("docker API returned %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + var items []dockerContainer + dec := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)) + if err := dec.Decode(&items); err != nil { + return nil, fmt.Errorf("decode Docker response: %w", err) + } + return items, nil +} + +func cleanName(names []string) string { + if len(names) == 0 { + return "container" + } + name := strings.TrimPrefix(names[0], "/") + if name == "" { + return "container" + } + return name +} + +func dnsLabel(value string) string { + value = strings.ToLower(value) + var b strings.Builder + dash := false + for _, r := range value { + ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') + if ok { + b.WriteRune(r) + dash = false + } else if b.Len() > 0 && !dash { + b.WriteByte('-') + dash = true + } + } + out := strings.Trim(b.String(), "-") + if out == "" { + out = "app" + } + if len(out) > 48 { + out = strings.Trim(out[:48], "-") + } + return out +} + +func serviceIdentity(c dockerContainer, p dockerPort) (key, technical string) { + name := cleanName(c.Names) + project := c.Labels["com.docker.compose.project"] + composeService := c.Labels["com.docker.compose.service"] + replica := c.Labels["com.docker.compose.container-number"] + suffix := strconv.Itoa(int(p.PrivatePort)) + if project != "" && composeService != "" { + if replica == "" { + replica = name + } + key = fmt.Sprintf("compose:%s/%s/%s:%d/%s", project, composeService, replica, p.PrivatePort, p.Type) + technicalBase := project + "-" + composeService + if replica != "1" { + technicalBase += "-" + replica + } + technical = dnsLabel(technicalBase + "-" + suffix) + } else { + key = fmt.Sprintf("container:%s:%d/%s", name, p.PrivatePort, p.Type) + technical = dnsLabel(name + "-" + suffix) + } + return key, technical +} + +func validIP(raw string) string { + ip := net.ParseIP(strings.TrimSpace(raw)) + if ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsMulticast() || ip.IsLinkLocalUnicast() { + return "" + } + return ip.String() +} + +func servicesFrom(containers []dockerContainer) []service { + byKey := make(map[string]*service) + bindingSeen := make(map[string]map[string]struct{}) + for _, c := range containers { + name := cleanName(c.Names) + for _, p := range c.Ports { + if p.PublicPort == 0 || p.Type != "tcp" { + continue + } + if bind := net.ParseIP(strings.TrimSpace(p.IP)); bind != nil && bind.IsLoopback() { + continue + } + key, technical := serviceIdentity(c, p) + item := byKey[key] + if item == nil { + id := c.ID + if len(id) > 12 { + id = id[:12] + } + item = &service{ + Key: key, TechnicalName: technical, ContainerID: id, ContainerName: name, + Image: c.Image, PrivatePort: p.PrivatePort, Protocol: p.Type, + } + byKey[key] = item + bindingSeen[key] = make(map[string]struct{}) + } + bindIP := strings.TrimSpace(p.IP) + dedupe := bindIP + "|" + strconv.Itoa(int(p.PublicPort)) + if _, ok := bindingSeen[key][dedupe]; ok { + continue + } + bindingSeen[key][dedupe] = struct{}{} + item.Bindings = append(item.Bindings, binding{BindIP: bindIP, PublicPort: p.PublicPort}) + } + } + out := make([]service, 0, len(byKey)) + for _, item := range byKey { + sort.Slice(item.Bindings, func(i, j int) bool { + left := net.ParseIP(strings.TrimSpace(item.Bindings[i].BindIP)) + right := net.ParseIP(strings.TrimSpace(item.Bindings[j].BindIP)) + leftWildcard := left == nil || left.IsUnspecified() + rightWildcard := right == nil || right.IsUnspecified() + if leftWildcard != rightWildcard { + return !leftWildcard + } + if item.Bindings[i].BindIP != item.Bindings[j].BindIP { + return item.Bindings[i].BindIP < item.Bindings[j].BindIP + } + return item.Bindings[i].PublicPort < item.Bindings[j].PublicPort + }) + out = append(out, *item) + } + sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) + return out +} + +func publicIPFromURL(ctx context.Context, rawURL string) string { + if rawURL == "" { + return "" + } + client := &http.Client{Timeout: 2 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return "" + } + resp, err := client.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "" + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 128)) + if err != nil { + return "" + } + return validIP(string(body)) +} + +func routeIP() string { + conn, err := net.DialTimeout("udp", "1.1.1.1:53", time.Second) + if err != nil { + return "" + } + defer conn.Close() + if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok { + return validIP(addr.IP.String()) + } + return "" +} + +func ipScope(ip net.IP) string { + switch { + case ip.IsPrivate(): + return "private" + case ip.IsLinkLocalUnicast(): + return "link_local" + default: + return "public" + } +} + +func skipInterface(name string) bool { + name = strings.ToLower(strings.TrimSpace(name)) + return name == "docker0" || strings.HasPrefix(name, "docker") || strings.HasPrefix(name, "veth") || strings.HasPrefix(name, "br-") || strings.HasPrefix(name, "virbr") +} + +func localHostAddresses() []hostAddress { + interfaces, err := net.Interfaces() + if err != nil { + return nil + } + out := make([]hostAddress, 0) + for _, iface := range interfaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 || skipInterface(iface.Name) { + continue + } + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + raw := addr.String() + if host, _, err := net.ParseCIDR(raw); err == nil { + raw = host.String() + } else if host, _, err := net.SplitHostPort(raw); err == nil { + raw = host + } + clean := validIP(raw) + if clean == "" { + continue + } + ip := net.ParseIP(clean) + out = append(out, hostAddress{IP: clean, Source: "interface", Interface: iface.Name, Scope: ipScope(ip)}) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Interface != out[j].Interface { + return out[i].Interface < out[j].Interface + } + return out[i].IP < out[j].IP + }) + return out +} + +type ipDetector struct { + mu sync.Mutex + publicIP string + expires time.Time +} + +func (d *ipDetector) public(ctx context.Context, rawURL string) string { + if rawURL == "" { + return "" + } + d.mu.Lock() + defer d.mu.Unlock() + if time.Now().Before(d.expires) { + return d.publicIP + } + d.publicIP = publicIPFromURL(ctx, rawURL) + d.expires = time.Now().Add(5 * time.Minute) + if d.publicIP == "" { + d.expires = time.Now().Add(30 * time.Second) + } + return d.publicIP +} + +func mergeHostAddress(out *[]hostAddress, index map[string]int, candidate hostAddress) { + candidate.IP = validIP(candidate.IP) + if candidate.IP == "" { + return + } + if candidate.Scope == "" { + candidate.Scope = ipScope(net.ParseIP(candidate.IP)) + } + if pos, ok := index[candidate.IP]; ok { + existing := &(*out)[pos] + if candidate.Interface != "" && existing.Interface == "" { + existing.Interface = candidate.Interface + } + if candidate.Source != "" && !strings.Contains(existing.Source, candidate.Source) { + if existing.Source == "" { + existing.Source = candidate.Source + } else { + existing.Source += "," + candidate.Source + } + } + return + } + index[candidate.IP] = len(*out) + *out = append(*out, candidate) +} + +func detectedAddresses(ctx context.Context, cfg config, detector *ipDetector) ([]hostAddress, string, string) { + out := make([]hostAddress, 0, 8) + index := make(map[string]int) + for _, item := range localHostAddresses() { + mergeHostAddress(&out, index, item) + } + routed := routeIP() + if routed != "" { + mergeHostAddress(&out, index, hostAddress{IP: routed, Source: "route"}) + } + public := detector.public(ctx, cfg.PublicIPURL) + if public != "" { + mergeHostAddress(&out, index, hostAddress{IP: public, Source: "public_api"}) + } + advertised := validIP(cfg.AdvertiseIP) + if advertised != "" { + mergeHostAddress(&out, index, hostAddress{IP: advertised, Source: "override"}) + } + + switch { + case advertised != "": + return out, advertised, "override" + case routed != "": + return out, routed, "route" + case public != "": + return out, public, "public_api" + case len(out) > 0: + return out, out[0].IP, out[0].Source + default: + return out, "", "unavailable" + } +} + +func certificateFingerprintDER(der []byte) string { + sum := sha256.Sum256(der) + return hex.EncodeToString(sum[:]) +} + +func certificateFingerprint(certFile string) (string, error) { + raw, err := os.ReadFile(certFile) + if err != nil { + return "", err + } + block, _ := pem.Decode(raw) + if block == nil || block.Type != "CERTIFICATE" { + return "", fmt.Errorf("%s does not contain a PEM certificate", certFile) + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", fmt.Errorf("parse certificate: %w", err) + } + return certificateFingerprintDER(cert.Raw), nil +} + +func atomicWrite(path string, data []byte, mode os.FileMode) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0750); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".rustproxy-agent-*") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err := tmp.Chmod(mode); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) +} + +func ensureSelfSignedCertificate(certFile, keyFile, hostname string) (string, bool, error) { + certInfo, certErr := os.Stat(certFile) + keyInfo, keyErr := os.Stat(keyFile) + if certErr == nil && keyErr == nil { + if certInfo.IsDir() || keyInfo.IsDir() { + return "", false, errors.New("TLS certificate/key path points to a directory") + } + if _, err := tls.LoadX509KeyPair(certFile, keyFile); err != nil { + return "", false, fmt.Errorf("load TLS certificate/key: %w", err) + } + fp, err := certificateFingerprint(certFile) + return fp, false, err + } + if (certErr == nil) != (keyErr == nil) { + return "", false, errors.New("only one TLS certificate/key file exists; refusing to replace the other") + } + if certErr != nil && !os.IsNotExist(certErr) { + return "", false, certErr + } + if keyErr != nil && !os.IsNotExist(keyErr) { + return "", false, keyErr + } + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return "", false, fmt.Errorf("generate TLS key: %w", err) + } + serialLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serial, err := rand.Int(rand.Reader, serialLimit) + if err != nil { + return "", false, fmt.Errorf("generate TLS serial: %w", err) + } + now := time.Now().UTC() + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: hostname, Organization: []string{"RustProxy Docker Agent"}}, + NotBefore: now.Add(-5 * time.Minute), + NotAfter: now.AddDate(10, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + if hostname != "" { + if ip := net.ParseIP(hostname); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else { + template.DNSNames = append(template.DNSNames, hostname) + } + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + if err != nil { + return "", false, fmt.Errorf("create TLS certificate: %w", err) + } + keyDER, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return "", false, fmt.Errorf("marshal TLS key: %w", err) + } + if err := atomicWrite(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0600); err != nil { + return "", false, fmt.Errorf("write TLS key: %w", err) + } + if err := atomicWrite(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0644); err != nil { + _ = os.Remove(keyFile) + return "", false, fmt.Errorf("write TLS certificate: %w", err) + } + return certificateFingerprintDER(der), true, nil +} + +func tokenOK(got, expected string) bool { + if expected == "" || len(got) != len(expected) { + return false + } + return subtle.ConstantTimeCompare([]byte(got), []byte(expected)) == 1 +} + +func handler(cfg config) http.Handler { + mux := http.NewServeMux() + detector := &ipDetector{} + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + if cfg.Fingerprint != "" { + w.Header().Set("X-RustProxy-Agent-Certificate-SHA256", cfg.Fingerprint) + } + _, _ = io.WriteString(w, `{"status":"ok"}`) + }) + mux.HandleFunc("/v1/inventory", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + auth := strings.TrimSpace(r.Header.Get("Authorization")) + const prefix = "Bearer " + if !strings.HasPrefix(auth, prefix) || !tokenOK(strings.TrimSpace(strings.TrimPrefix(auth, prefix)), cfg.Token) { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + containers, err := dockerContainers(r.Context(), cfg.DockerSock) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + addresses, primaryIP, primarySource := detectedAddresses(r.Context(), cfg, detector) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + if cfg.Fingerprint != "" { + w.Header().Set("X-RustProxy-Agent-Certificate-SHA256", cfg.Fingerprint) + } + _ = json.NewEncoder(w).Encode(inventory{ + AgentVersion: version, Hostname: cfg.Hostname, PublicIP: primaryIP, PublicIPSource: primarySource, CertificateSHA256: cfg.Fingerprint, + Addresses: addresses, ObservedAt: time.Now().UTC().Format(time.RFC3339), Services: servicesFrom(containers), + }) + }) + return mux +} + +func envOr(name, fallback string) string { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + return fallback +} + +func envBool(name string, fallback bool) bool { + raw := strings.TrimSpace(os.Getenv(name)) + if raw == "" { + return fallback + } + value, err := strconv.ParseBool(raw) + if err != nil { + return fallback + } + return value +} + +func main() { + hostname, _ := os.Hostname() + cfg := config{} + flag.StringVar(&cfg.Listen, "listen", envOr("RUSTPROXY_DOCKER_AGENT_LISTEN", "127.0.0.1:9876"), "listen address") + flag.StringVar(&cfg.Token, "token", envOr("RUSTPROXY_DOCKER_AGENT_TOKEN", ""), "Bearer token (prefer environment variable)") + flag.StringVar(&cfg.DockerSock, "docker-socket", envOr("RUSTPROXY_DOCKER_SOCKET", "/var/run/docker.sock"), "Docker Engine unix socket") + flag.StringVar(&cfg.AdvertiseIP, "advertise-ip", envOr("RUSTPROXY_DOCKER_ADVERTISE_IP", ""), "optional preferred host IP; all usable host addresses are still reported") + flag.StringVar(&cfg.PublicIPURL, "public-ip-url", envOr("RUSTPROXY_DOCKER_PUBLIC_IP_URL", "https://api.ipify.org"), "public IP lookup URL; empty disables") + flag.StringVar(&cfg.Hostname, "hostname", envOr("RUSTPROXY_DOCKER_HOSTNAME", hostname), "reported host name") + flag.BoolVar(&cfg.TLS, "tls", envBool("RUSTPROXY_DOCKER_AGENT_TLS", true), "serve HTTPS; enabled by default") + flag.StringVar(&cfg.CertFile, "cert-file", envOr("RUSTPROXY_DOCKER_AGENT_CERT_FILE", "/var/lib/rustproxy-docker-agent/agent.crt"), "TLS certificate file") + flag.StringVar(&cfg.KeyFile, "key-file", envOr("RUSTPROXY_DOCKER_AGENT_KEY_FILE", "/var/lib/rustproxy-docker-agent/agent.key"), "TLS private key file") + initCert := flag.Bool("init-cert", false, "create the self-signed TLS certificate if missing and exit") + printFingerprint := flag.Bool("print-fingerprint", false, "print SHA-256 certificate fingerprint and exit") + flag.Parse() + + if *initCert { + fingerprint, created, err := ensureSelfSignedCertificate(cfg.CertFile, cfg.KeyFile, cfg.Hostname) + if err != nil { + log.Fatal(err) + } + fmt.Printf("%s\n", fingerprint) + if created { + log.Printf("created self-signed certificate %s", cfg.CertFile) + } + return + } + if *printFingerprint { + fingerprint, err := certificateFingerprint(cfg.CertFile) + if err != nil { + log.Fatal(err) + } + fmt.Printf("%s\n", fingerprint) + return + } + if len(cfg.Token) < 24 { + log.Fatal("RUSTPROXY_DOCKER_AGENT_TOKEN must contain at least 24 characters") + } + if _, err := os.Stat(cfg.DockerSock); err != nil { + log.Fatalf("Docker socket unavailable: %v", err) + } + if _, _, err := net.SplitHostPort(cfg.Listen); err != nil { + log.Fatalf("invalid listen address: %v", err) + } + if cfg.TLS { + fingerprint, created, err := ensureSelfSignedCertificate(cfg.CertFile, cfg.KeyFile, cfg.Hostname) + if err != nil { + log.Fatalf("TLS certificate: %v", err) + } + cfg.Fingerprint = fingerprint + if created { + log.Printf("generated self-signed TLS certificate; SHA-256 %s", fingerprint) + } + } + + server := &http.Server{Addr: cfg.Listen, Handler: handler(cfg), ReadHeaderTimeout: 3 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 16 << 10} + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + go func() { + <-ctx.Done() + shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(shutdown) + }() + scheme := "http" + if cfg.TLS { + scheme = "https" + } + log.Printf("RustProxy Docker Agent %s listening on %s://%s", version, scheme, cfg.Listen) + if cfg.Fingerprint != "" { + log.Printf("TLS certificate SHA-256: %s", cfg.Fingerprint) + } + var err error + if cfg.TLS { + err = server.ListenAndServeTLS(cfg.CertFile, cfg.KeyFile) + } else { + err = server.ListenAndServe() + } + if err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } +} diff --git a/docker-agent/main_test.go b/docker-agent/main_test.go new file mode 100644 index 0000000..5a5ffd1 --- /dev/null +++ b/docker-agent/main_test.go @@ -0,0 +1,245 @@ +package main + +import ( + "context" + "crypto/tls" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +func fakeDocker(t *testing.T, payload string) string { + t.Helper() + dir := t.TempDir() + sock := filepath.Join(dir, "docker.sock") + ln, err := net.Listen("unix", sock) + if err != nil { + t.Fatal(err) + } + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/containers/json" { + t.Fatalf("unexpected path %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(payload)) + })} + go srv.Serve(ln) + t.Cleanup(func() { _ = srv.Close(); _ = os.Remove(sock) }) + return sock +} + +func TestServicesFromUsesStableComposeIdentity(t *testing.T) { + items := []dockerContainer{{ID: strings.Repeat("a", 64), Names: []string{"/web-1"}, Image: "demo", Labels: map[string]string{"com.docker.compose.project": "lab", "com.docker.compose.service": "web", "com.docker.compose.container-number": "1"}, Ports: []dockerPort{{IP: "0.0.0.0", PrivatePort: 8080, PublicPort: 18080, Type: "tcp"}, {PrivatePort: 53, PublicPort: 1053, Type: "udp"}}}} + got := servicesFrom(items) + if len(got) != 1 { + t.Fatalf("got %d services", len(got)) + } + if got[0].Key != "compose:lab/web/1:8080/tcp" || got[0].TechnicalName != "lab-web-8080" { + t.Fatalf("unexpected service %#v", got[0]) + } + if len(got[0].Bindings) != 1 || got[0].Bindings[0].PublicPort != 18080 { + t.Fatalf("unexpected bindings %#v", got[0].Bindings) + } +} + +func TestServicesFromKeepsComposeReplicasAndAllBindings(t *testing.T) { + labels := func(number string) map[string]string { + return map[string]string{"com.docker.compose.project": "lab", "com.docker.compose.service": "web", "com.docker.compose.container-number": number} + } + items := []dockerContainer{ + {ID: "a", Names: []string{"/lab-web-1"}, Labels: labels("1"), Ports: []dockerPort{{IP: "0.0.0.0", PrivatePort: 8080, PublicPort: 18080, Type: "tcp"}, {IP: "::", PrivatePort: 8080, PublicPort: 18080, Type: "tcp"}, {IP: "10.10.0.21", PrivatePort: 8080, PublicPort: 18081, Type: "tcp"}}}, + {ID: "b", Names: []string{"/lab-web-2"}, Labels: labels("2"), Ports: []dockerPort{{IP: "0.0.0.0", PrivatePort: 8080, PublicPort: 18082, Type: "tcp"}}}, + } + got := servicesFrom(items) + if len(got) != 2 { + t.Fatalf("expected two replicas, got %#v", got) + } + if got[0].Key != "compose:lab/web/1:8080/tcp" || got[1].Key != "compose:lab/web/2:8080/tcp" { + t.Fatalf("unexpected replica keys %#v", got) + } + if len(got[0].Bindings) != 3 { + t.Fatalf("expected all published bindings, got %#v", got[0].Bindings) + } + if got[0].Bindings[0].BindIP != "10.10.0.21" || got[0].Bindings[0].PublicPort != 18081 { + t.Fatalf("concrete binding should be retained, got %#v", got[0].Bindings) + } +} + +func TestServicesFromSkipsLoopbackOnlyBindings(t *testing.T) { + items := []dockerContainer{{ID: "abc", Names: []string{"/app"}, Labels: map[string]string{}, Ports: []dockerPort{ + {IP: "127.0.0.1", PrivatePort: 8080, PublicPort: 18080, Type: "tcp"}, + {IP: "::1", PrivatePort: 8080, PublicPort: 18081, Type: "tcp"}, + {IP: "0.0.0.0", PrivatePort: 8082, PublicPort: 18082, Type: "tcp"}, + }}} + got := servicesFrom(items) + if len(got) != 1 || len(got[0].Bindings) != 1 || got[0].Bindings[0].PublicPort != 18082 { + t.Fatalf("unexpected public services %#v", got) + } +} + +func TestIPDetectorCachesPublicLookup(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + _, _ = w.Write([]byte("203.0.113.44")) + })) + defer srv.Close() + detector := &ipDetector{} + for range 2 { + ip := detector.public(context.Background(), srv.URL) + if ip != "203.0.113.44" { + t.Fatalf("unexpected detection %s", ip) + } + } + if calls.Load() != 1 { + t.Fatalf("expected one public lookup, got %d", calls.Load()) + } +} + +func TestMergeHostAddressKeepsInterfaceAndSources(t *testing.T) { + var got []hostAddress + index := map[string]int{} + mergeHostAddress(&got, index, hostAddress{IP: "10.20.30.40", Source: "interface", Interface: "eno1", Scope: "private"}) + mergeHostAddress(&got, index, hostAddress{IP: "10.20.30.40", Source: "route"}) + if len(got) != 1 || got[0].Interface != "eno1" || got[0].Source != "interface,route" { + t.Fatalf("unexpected merged address %#v", got) + } +} + +func TestInventoryRequiresTokenAndReportsMultipleAddressModel(t *testing.T) { + sock := fakeDocker(t, `[{"Id":"1234567890abcdef","Names":["/app1"],"Image":"example/app","Labels":{},"Ports":[{"IP":"0.0.0.0","PrivatePort":8080,"PublicPort":18080,"Type":"tcp"}]}]`) + cfg := config{Token: strings.Repeat("x", 24), DockerSock: sock, AdvertiseIP: "203.0.113.10", PublicIPURL: "", Hostname: "docker-a"} + srv := httptest.NewServer(handler(cfg)) + defer srv.Close() + + res, err := http.Get(srv.URL + "/v1/inventory") + if err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", res.StatusCode) + } + _ = res.Body.Close() + + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL+"/v1/inventory", nil) + req.Header.Set("Authorization", "Bearer "+cfg.Token) + client := &http.Client{Timeout: time.Second} + res, err = client.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", res.StatusCode) + } + var got inventory + if err := json.NewDecoder(res.Body).Decode(&got); err != nil { + t.Fatal(err) + } + if got.PublicIP != "203.0.113.10" || got.PublicIPSource != "override" || len(got.Services) != 1 { + t.Fatalf("unexpected inventory %#v", got) + } + found := false + for _, address := range got.Addresses { + if address.IP == "203.0.113.10" && strings.Contains(address.Source, "override") { + found = true + } + } + if !found { + t.Fatalf("advertised address missing from %#v", got.Addresses) + } +} + +func TestSkipInterfaceFiltersDockerGeneratedLinksButKeepsHostBridges(t *testing.T) { + for _, name := range []string{"docker0", "docker_gwbridge", "veth1234", "br-123456", "virbr0"} { + if !skipInterface(name) { + t.Fatalf("expected %s to be filtered", name) + } + } + for _, name := range []string{"eno1", "eth0", "br0", "bond0", "wg0"} { + if skipInterface(name) { + t.Fatalf("expected %s to remain", name) + } + } +} + +func TestSelfSignedCertificateIsCreatedOnceAndFingerprintStable(t *testing.T) { + dir := t.TempDir() + certFile := filepath.Join(dir, "agent.crt") + keyFile := filepath.Join(dir, "agent.key") + first, created, err := ensureSelfSignedCertificate(certFile, keyFile, "docker-a.lab.local") + if err != nil { + t.Fatal(err) + } + if !created || len(first) != 64 { + t.Fatalf("unexpected initial certificate result created=%v fingerprint=%q", created, first) + } + second, createdAgain, err := ensureSelfSignedCertificate(certFile, keyFile, "docker-a.lab.local") + if err != nil { + t.Fatal(err) + } + if createdAgain || second != first { + t.Fatalf("certificate should be stable: first=%q second=%q created=%v", first, second, createdAgain) + } + if mode := fileMode(t, keyFile); mode.Perm() != 0600 { + t.Fatalf("private key mode = %o, want 600", mode.Perm()) + } + if mode := fileMode(t, certFile); mode.Perm() != 0644 { + t.Fatalf("certificate mode = %o, want 644", mode.Perm()) + } +} + +func fileMode(t *testing.T, path string) os.FileMode { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + return info.Mode() +} + +func TestTLSInventoryReportsServingCertificateFingerprint(t *testing.T) { + sock := fakeDocker(t, `[{"Id":"abc","Names":["/app"],"Image":"demo","Labels":{},"Ports":[{"IP":"0.0.0.0","PrivatePort":8080,"PublicPort":18080,"Type":"tcp"}]}]`) + dir := t.TempDir() + certFile := filepath.Join(dir, "agent.crt") + keyFile := filepath.Join(dir, "agent.key") + fingerprint, _, err := ensureSelfSignedCertificate(certFile, keyFile, "localhost") + if err != nil { + t.Fatal(err) + } + cfg := config{Token: strings.Repeat("z", 24), DockerSock: sock, Hostname: "docker-tls", TLS: true, CertFile: certFile, KeyFile: keyFile, Fingerprint: fingerprint} + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + server := &http.Server{Handler: handler(cfg)} + go func() { _ = server.ServeTLS(listener, certFile, keyFile) }() + t.Cleanup(func() { _ = server.Close() }) + + transport := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} // test-only: verify the endpoint is actually TLS; RustProxy pins it in production. + client := &http.Client{Transport: transport, Timeout: time.Second} + req, _ := http.NewRequest(http.MethodGet, "https://"+listener.Addr().String()+"/v1/inventory", nil) + req.Header.Set("Authorization", "Bearer "+cfg.Token) + res, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + if got := res.Header.Get("X-RustProxy-Agent-Certificate-SHA256"); got != fingerprint { + t.Fatalf("certificate header = %q, want %q", got, fingerprint) + } + var got inventory + if err := json.NewDecoder(res.Body).Decode(&got); err != nil { + t.Fatal(err) + } + if got.CertificateSHA256 != fingerprint { + t.Fatalf("inventory fingerprint = %q, want %q", got.CertificateSHA256, fingerprint) + } +} diff --git a/docker-agent/rustproxy-docker-agent.service b/docker-agent/rustproxy-docker-agent.service new file mode 100644 index 0000000..ca1c54a --- /dev/null +++ b/docker-agent/rustproxy-docker-agent.service @@ -0,0 +1,35 @@ +[Unit] +Description=RustProxy Docker discovery agent +After=docker.service network-online.target +Wants=network-online.target +Requires=docker.service + +[Service] +Type=simple +User=rustproxy-docker-agent +Group=docker +EnvironmentFile=/etc/rustproxy/docker-agent.env +StateDirectory=rustproxy-docker-agent +StateDirectoryMode=0750 +UMask=0027 +ExecStart=/usr/local/bin/rustproxy-docker-agent +Restart=on-failure +RestartSec=3 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +ProtectClock=true +ProtectKernelLogs=true +RestrictSUIDSGID=true +RestrictNamespaces=true +LockPersonality=true +MemoryDenyWriteExecute=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallArchitectures=native + +[Install] +WantedBy=multi-user.target diff --git a/docs/ACME_PROVIDERS.md b/docs/ACME_PROVIDERS.md new file mode 100644 index 0000000..939fc86 --- /dev/null +++ b/docs/ACME_PROVIDERS.md @@ -0,0 +1,128 @@ +> 0.7 update: configured managed certificates now have automatic renewal and +> validated hot deployment. Initial issuance/import remains explicit. Read +> [RENEWAL.md](RENEWAL.md) for defaults, retained profiles and opt-out. + +# Certificate providers (0.6.0) + +Source implementation; real provider/DNS/CA issuance and Rust runtime have not been +verified in the packaging environment. Offline tests substitute Certbot, so they +cannot establish that a DNS token, zone policy or CA deployment works. + +## Workflow + +Open Certificates -> Add certificate. Choose one provider or import mode, enter +the domains and metadata, then request issuance. Production is selected by default +in the panel and still requires the separate explicit production confirmation. Staging +remains available for testing and its certificates are not browser-trusted. +After a successful job, import its validated pair into the library, then assign the +certificate to a vhost. No CA operation occurs on startup or merely opening a form. + +| Selection | Mechanism and required configuration | +|---|---| +| HTTP-01 / domain validation | Certbot webroot; domain names and a publicly reachable plaintext HTTP listener | +| Cloudflare | DNS-01; scoped API token; `certbot-dns-cloudflare` installed | +| OVH | DNS-01; `ovh-eu` or `ovh-ca`, application key/secret, consumer key; `certbot-dns-ovh` installed | +| RFC2136 | DNS-01; server/port, TSIG name/secret/algorithm; `certbot-dns-rfc2136` installed | +| PEM import | Upload fullchain/certificate and corresponding private key, then validate | +| Existing server files | Advanced: reference a PEM pair already in the certificate directory | + +Python 3 and Certbot must be executable by the RustProxy service user. Install +plugins in that SAME Certbot environment. The application does not install packages +or execute sudo. Check the CLI installation with `bash dev.sh acme --doctor` and +`certbot plugins`; this does not validate provider credentials or DNS reachability. +DNS uses the API/plugin appropriate to the selected provider, not a Cloudflare +Tunnel integration or a hand-written replacement for ACME. + +## HTTP-01 requirements and scope + +Public TCP port **80** for every requested name must reach an active plaintext +RustProxy listener. NAT from public 80 to a private/custom HTTP port is allowed; +setting only port 8080 in public DNS is not sufficient. Check all published A/AAAA +addresses and multi-node routing. TLS-only listeners are not enough. There is no +TLS-ALPN-01 mode. This implementation accepts DNS names, not IP certificates. +Wildcard certificates require one of the DNS-01 providers, not HTTP-01. + +During a running HTTP-01 job, RustProxy serves only +`/.well-known/acme-challenge/` for that job's exact normalized hostnames. +Only GET/HEAD and bounded regular files from `certs/acme-http01` are accepted; +queries, traversal, unrelated names, HTTPS requests and inactive jobs cannot read +these files. The reserved path precedes application auth/cache/static pages so a +CA can validate ownership without logging in. This exception does not bypass +application authentication for any other path. Responses are no-store. + +A CDN or upstream proxy must forward this path and the correct Host without +rewriting it. There is no automatic forwarding configuration at your DNS/CDN vendor. +Expired challenge jobs do not leave an open generic file server. + +## DNS credentials and least privilege + +Cloudflare: use an API token with `Zone:DNS:Edit` restricted to the zones used for +issuance. The adapter intentionally does not accept the account-wide Global API Key. + +OVH: use a consumer key allowing `GET /domain/zone/` and GET/PUT/POST/DELETE for +`/domain/zone//*`. Repeat only for required zones, not the whole account. +Choose the region matching the account. Consult the official plugin docs if API +policies change. A domain registered at OVH but DNS hosted elsewhere must use the +actual authoritative DNS provider; registration alone is not sufficient. + +RFC2136: retain the TSIG-limited update policy described in ACME_RFC2136.md. DNS +provider propagation delay is configurable. The helper rejects malformed tokens, +unknown/cross-provider fields and INI injection. It does not test your DNS policy +in advance. Wildcard DNS-01 issuance does not add wildcard HOST routing: vhost +hostnames remain exact matches. + +## API input + +Common fields: `provider`, `certificate_id`, `domains` (one or more DNS names), `email`, +`environment` (`staging`/`production`), `agree_tos`, `confirm_production`. RustProxy +does not impose its own domain-count ceiling; the selected CA/provider may still +apply its external certificate/SAN policy. +DNS providers accept `propagation_seconds`. + +Provider-specific fields: + +- `http01`: none; wildcard domains are rejected. +- `cloudflare`: `api_token`. +- `ovh`: `endpoint`, `application_key`, `application_secret`, `consumer_key`. +- `rfc2136`: `dns_server`, `dns_port`, `key_name`, `key_secret`, `key_algorithm`, `sign_query`. + +`POST /api/acme/issue` is authenticated and CSRF-protected; one job runs at a time. +`GET /api/acme` is safe status metadata only. Job status is process-local; account +and credential files persist. A completed certificate has optional issuer metadata +with provider/environment, not a copy of request credentials. + +## Secret storage, renewal and limitations + +Provider secrets are sent to the private worker on stdin, not command arguments. +They are not stored in exported JSON, SQLite, localStorage or audit records. +Files are private (0600 credentials, 0700 directories); these permissions do NOT +encrypt them. The service user/root can read them. Certbot account/lineage state +and provider credential files persist for managed renewal under +`certs/acme/--`; RFC2136 keeps its old path without a +provider suffix. Issued PEM outputs use new immutable names before library import. + +Back up these private profiles separately while issuance is idle: the standard +SQLite/configured-PEM backup does not include every account/credential profile. +Do not put real credentials into shell history or source-control examples. +Restrict the admin interface and service environment. Rotate exposed API credentials. + +Configured managed certificates in 0.7 renew through the shared RustProxy worker; +validated changed material is published automatically while failed checks preserve +the active generation. Use the certificate's toggle to disable scheduled renewal. +PEM-only imports remain manual. A separate system Certbot timer may own a **different, +manual RustProxy certificate id** and publish it with `tls deploy --id ...`; it must not +also own a RustProxy-managed ACME profile. There is no external expiry alert, revocation +or certificate garbage collector. Cancellation +kills the worker group; interrupted DNS cleanup can leave TXT records an operator +must remove. See [renewal and recovery](RENEWAL.md) and test CA staging first. + +## Primary references + +- HTTP-01/DNS-01 constraints: https://letsencrypt.org/docs/challenge-types/ +- Certbot webroot and operational use: https://eff-certbot.readthedocs.io/en/latest/using.html +- Cloudflare plugin/API scope: https://certbot-dns-cloudflare.readthedocs.io/en/stable/ +- OVH plugin/API scopes: https://certbot-dns-ovh.readthedocs.io/en/latest/ +- RFC2136 plugin: https://certbot-dns-rfc2136.readthedocs.io/en/stable/ + +These document upstream requirements, not evidence that this RustProxy build has +passed live provider or CA acceptance. diff --git a/docs/ACME_RFC2136.md b/docs/ACME_RFC2136.md new file mode 100644 index 0000000..20821a1 --- /dev/null +++ b/docs/ACME_RFC2136.md @@ -0,0 +1,157 @@ +# Basic Let's Encrypt / RFC2136 integration (0.5.0) + +For the multi-provider UI, HTTP-01, Cloudflare and OVH, see ACME_PROVIDERS.md. +Managed automatic renewal added in 0.7 is documented in RENEWAL.md. +The RFC2136 fields and private account paths below remain supported. + +## Scope and prerequisites + +This is an integrated control-plane adapter for **Certbot + certbot-dns-rfc2136**, +not a newly written ACME or DNS-update protocol client. The plugin creates and +cleans up DNS-01 TXT records through TSIG-signed RFC2136 updates. It must be +installed separately. No real CA issuance or authoritative-DNS update has been +performed in the packaging environment. +Rust compilation, process integration and actual certificate deployment remain +acceptance work (TEST_REPORT.md). + +Use Linux/Unix with Python 3.10+, a current compatible Certbot/plugin installation, +and `python3` and `certbot` available in the RustProxy service's PATH. Check: + +```bash +bash dev.sh acme --doctor +certbot --version +certbot plugins --authenticators +``` + +The doctor command reports executable discovery and the required plugin's name; +it does NOT verify the plugin, DNS permissions or network connectivity. Nothing +is installed automatically, and RustProxy never invokes sudo. A service hardening +policy must allow child processes, the cert directory, DNS updates/queries to the +chosen server and outbound HTTPS to the CA. Do not run the proxy as root just to +make Certbot available. Use a private, service-owned cert directory (0700). + +The operator must control publicly valid DNS names and an authoritative DNS +server that permits the configured TSIG identity to change **only the necessary +_acme-challenge TXT names**. Do not grant zone-wide update authority merely for +certificates. A private update-server IP is acceptable when the resulting TXT +records are visible to the public validation resolvers. Split DNS/views, CAA, +replication delays and delegation must be checked on the operator's DNS setup. +DNS-01 does not require opening the application's HTTP listener for a challenge. + +## Panel workflow + +Open **Certificates > Let's Encrypt / RFC2136**. Enter a certificate library ID, +contact email, comma-separated DNS names (including optional `*.example.org`), +DNS update server IP/port, TSIG name, base64 secret, algorithm and propagation +wait. ASCII/punycode DNS names only; up to 16 names. SHA256 is the default TSIG +algorithm; SHA384/SHA512 are also allowed. SHA1/MD5 are not exposed. A TSIG key +name may be a single label such as `keyname.`. Optional signed SOA queries support +DNS servers/views that require them. Port defaults to 53; propagation defaults +to 60 seconds (allowed 10..900). + +Start with **staging**. Its certificates are not publicly trusted. Production +requires a separate explicit confirmation, in addition to agreement with the +Let's Encrypt subscriber agreement. The CA directory is fixed for each mode; +arbitrary directory URLs, shell hooks and arbitrary extra Certbot options are +not accepted by this interface. + +Requesting starts one bounded asynchronous job, not a proxy-path operation. Refresh +shows running, failed, or ready. At ready, the Rust control plane has checked PEM +parsing, matching key and every requested DNS name before exposing only metadata. +Click **Add issued certificate to library**, then select its ID in the vhost SSL +tab and save. Replacing an existing ID requires confirmation and retains its +legacy SNI host assignments; the whole current configuration is revalidated with +revision CAS. Failed issuance/validation/import leaves active configuration alone. +Certificates are loaded for new handshakes; changing listener protocols still +requires restart. Existing connections retain their negotiated certificate. + +## Renewal and operator responsibilities + +Configured RFC2136 managed profiles participate in the 0.7 renewal scheduler. +Validated changed certificate material is published automatically; there is a +per-certificate opt-out, explicit Check now and Dry run. Initial issuance/import +remains explicit. The old 0.5 helper alone was issuance-only; do not confuse +retained files or an unrelated OS Certbot timer with RustProxy publication. +See RENEWAL.md for prerequisites, timing, rollback boundaries and legacy profiles. +No external expiry notification, revocation UI or account manager is included. + +Account state and TSIG credentials are kept privately under: + +```text +data/certs/acme/-/ + rfc2136.ini # SECRET, mode 0600 + profile.json # non-secret parameters, mode 0600 + config/ # Certbot account, renewal, live and archive data + work/ + logs/ # private Certbot diagnostics +``` + +The location follows the configured certificate directory, not always `data/certs`. +There is one job per RustProxy process and a filesystem lock per profile. Bounds: +16 KiB request, 128 profiles, 128 staged ACME pairs and 128 KiB per PEM file. The +provider timeout is 1200 seconds plus propagation wait; the Rust worker has a +2160-second outer deadline and stops its process group on cancellation/shutdown. +Normal TXT cleanup belongs to the plugin. A hard kill, machine crash or DNS error +can leave challenge TXT records: inspect DNS and clean only the failed challenge +values, not unrelated records. Only the latest job status lives in memory; restart +loses that status, not the private Certbot files. + +TSIG is supplied through stdin to the helper, never argv, SQLite, exports or job +responses. The browser clears the secret field after submission and does not put +it in local/session storage. Private credentials/account files are deliberately +retained for renewal; protect them and private logs as secrets. Keep unused staged +pairs under operator control; active files are never silently deleted. + +The existing `dev.sh backup` includes SQLite and configured PEM files, **not the +ACME account/profile directory**. Back up `certs/acme` separately while no issuance +is active, preserving ownership/modes and encryption appropriate to private keys. +Source upgrade leaves the data directory alone. Do not ship profiles in source ZIPs. + +## API and local helper + +`GET /api/acme` returns the latest sanitized job, provider name and +`automatic_renewal: false`. `POST /api/acme/issue` requires the normal authenticated +session, Origin/CSRF protections and an explicit bounded JSON request: + +```json +{ + "certificate_id": "example", + "email": "operator@example.org", + "domains": ["example.org", "*.example.org"], + "environment": "staging", + "agree_tos": true, + "confirm_production": false, + "dns_server": "192.0.2.53", + "dns_port": 53, + "key_name": "rustproxy.example.org.", + "key_secret": "REPLACE_WITH_YOUR_PRIVATE_BASE64_TSIG_SECRET", + "key_algorithm": "HMAC-SHA256", + "sign_query": false, + "propagation_seconds": 60 +} +``` + +This documentation example is intentionally not an executable credential. A +successful job submission returns 202; busy returns 429. Invalid input, consent +and provider failures use fixed `acme.*` diagnostics rather than returning +provider stderr or credentials. Look in the private profile logs for DNS/plugin +errors. A 202 response means accepted, not issued. + +Advanced local staging without the running API: + +```bash +# request.json must be private (0600), with real operator-owned parameters. +bash dev.sh acme --cert-dir /absolute/private/cert-directory < request.json +``` + +This CLI only stages files and prints metadata. Unlike the integrated API, it +cannot run Rust's key/SAN validation on its own and never activates a certificate. +Import through the normal validated library/config workflow. Do not post secret +JSON into shell arguments, source examples, tickets or logs. + +## Primary references + +- Plugin: https://certbot-dns-rfc2136.readthedocs.io/en/stable/ +- Certbot CLI: https://eff-certbot.readthedocs.io/en/stable/man/certbot.html +- Staging: https://letsencrypt.org/docs/staging-environment/ +- Challenge background: https://letsencrypt.org/docs/challenge-types/ diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..a3c598b --- /dev/null +++ b/docs/API.md @@ -0,0 +1,549 @@ +# Administration API + +The API is served on the separate administration listener, normally +`http://127.0.0.1:9090`. It is never mounted on reverse-proxy HTTP/TLS listeners. +Loopback HTTP is the default. Settings can enable administration HTTPS, choose a +certificate and persist a non-loopback bind; plain remote HTTP requires the explicit +startup `--allow-remote-admin` gate. Responses carry no-store, CSP and other browser +protection headers. + +## Browser documentation and anonymous surface + +Open `/docs` **on the administration listener** after signing in. The OpenAPI JSON, +JavaScript, styles and Swagger vendor assets all require the same session. Writes +are opt-in and CSRF-protected. See [API_ACCESS.md](API_ACCESS.md). + +Only GET/HEAD `/`, the panel routes `/overview`, `/vhosts`, `/streams`, +`/acl-profiles`, `/configuration`, `/tls`, `/cache`, `/logging`, `/settings`, +`/assets/login.js`, `/assets/login-langs.json`, `/assets/style.css`, `/favicon.svg`, +`/healthz`, `/api/auth/status`, and POST `/api/auth/setup`, `/api/auth/login` are +anonymous. Panel routes serve the same guarded shell as `/`, so a browser refresh +preserves the selected subpage instead of falling back to the landing page. Root +optionally authenticates and selects login or operational HTML. `/healthz` is exactly `{"status":"ok"}`. +Unknown/private paths and method mismatches return a generic 401 without a session. +Only top-level `/docs` and `/docs/` navigations redirect to `/?next=docs`; API/schema +fetches never redirect to login HTML. Other invalid Host/Origin checks can return 403. + +## Authentication + +| Method / path | Input or result | +|---|---| +| `GET /api/auth/status` | Public `setup_required` boolean only; no version or operational state | +| `POST /api/auth/setup` | One-time `{token, username, password}`; creates no session | +| `POST /api/auth/login` | `{username, password}`; sets session cookie and returns session data | +| `GET /api/auth/session` | Username, CSRF token, absolute expiry and idle timeout | +| `POST /api/auth/logout` | Revoke the current session | +| `POST /api/auth/logout-all` | Revoke all administrator sessions | +| `POST /api/auth/password` | `{current_password, new_password}`; changes password and revokes sessions | + +Mutations, including setup/login, require `Origin` equal to the active administration scheme plus `{Host}` and +`X-RustProxy-Request: 1`. Authenticated mutations also need `X-CSRF-Token` from +session data. Send JSON as `application/json`. A permanent bearer access token +is not supported; the old setup capability does not authorize API reads/writes. + +The `rustproxy_session` cookie is HttpOnly, SameSite=Strict and Path=/ with the configured +7–30 day absolute lifetime (default 30 days); server-side revocation still applies. HTTPS +administration adds the Secure attribute; behind a trusted HTTPS-terminating reverse proxy +the external forwarded scheme controls that flag. A protected SSH/VPN tunnel remains valid, +while direct remote administration should use HTTPS plus firewall/private-network policy. +Direct IP/localhost Host values must use the actual administration port. A DNS name explicitly +listed in `administration.allowed_hosts` represents the browser-facing authority and may use +the trusted reverse proxy's external port. Foreign Origin/cross-site requests are rejected. +There is no permissive CORS mode. +Auth request bodies are limited to 4096 bytes. + +Typical errors include 401 for missing/invalid credentials, 403 for origin/CSRF +rejection, 409 for already-completed setup, 422 for password policy and 429 for +throttling/busy workers. Error objects usually contain an `error` code/string; +framework extraction failures are not guaranteed to have that JSON shape. + +## Configuration and observations + +| Method / path | Result | +|---|---| +| `GET /healthz` | Public process health/version; not a backend health probe | +| `GET /api/config` | `{revision, config}` | +| `PUT /api/config` | Input `{expected_revision, config}`; returns the applied envelope; retained for atomic multi-section changes | +| `PUT /api/config/{section}` | Input `{expected_revision, value}`; replaces one validated section and returns the applied envelope | +| `GET /api/backends` | Authenticated per-vhost backend observations and active/desired frontend plans | +| `GET /api/status` | Uptime, active revision, configured counts, listener addresses, feature flags, aggregate metrics, active/desired TCP/UDP streams with per-stream L4 counters, active/desired administration state, TLS policy/cipher inventory and structured-log sink counters | + +`status.streams` contains `active`, `desired`, `restart_required` and a `metrics` map keyed by startup-active stream ID. Stream counters include active/total/completed sessions, rejects, errors, RX/TX bytes and UDP packet counts; the dashboard derives rates from byte-counter deltas. +| `GET /api/logs` | Recent bounded access entries, ring capacity and contention-drop count | +| `GET /api/audit` | Last 100 audit entries; database retention is 10,000 | +| `GET /api/events` | SSE `snapshot` events containing status and logs | +| `GET /metrics` | Prometheus text exposition | +| `GET /api/metrics` | Alias of `/metrics` | + +Except for health and authentication/bootstrap routes, these endpoints require a +session. These administrative previews are not anonymous. The separate configurable +exporter uses its own CIDRs and optional bearer token; see the scrape section below. + +SSE has eight viewer slots, generates a snapshot approximately every two seconds +and rechecks session validity. It does not queue an unlimited per-viewer event log +or guarantee replay. The browser falls back to polling. An active dashboard renews +idle activity, not the absolute session deadline. + +Configuration updates return 409 for a stale revision, 422 for validation failure +and 429 when the eight-operation update admission bound is reached. Persistence and +snapshot publication finish before a successful response. Disconnecting the caller +after the detached writer starts does not cancel an accepted write. +The document limit is 256 KiB, with an additional 1024-byte request-envelope allowance. +For ordinary Settings saves prefer `PUT /api/config/{section}`. Supported sections are +`listeners`, `response`, `cache`, `logging`, `forwarding`, `headers`, `prometheus`, +`administration`, `tls`, `vhosts`, `redirects`, `not_found_hosts`, `streams`, `acl-profiles` and `host-policies`. +Each section write still uses revision CAS, full validation and atomic persistence. +Only a change to the derived cache-compatibility identity purges hot/cold cache; +unrelated section saves do not. + +## Configuration fields + +The canonical document uses `vhosts`, `redirects`, `listeners`, `administration`, `tls`, `logging`, +`prometheus`, `cache`, `compression`, `server_header`, `headers`, `host_policies` and other defaulted +settings. Unknown fields are rejected. `backends`/`routes` at the top level are +input-only legacy migration fields, omitted from saved canonical documents. + +```json +{ + "vhosts": [{ + "id": "application", + "frontend": { + "hosts": ["app.localhost", "alias.localhost"], + "listeners": [{"address":"127.0.0.1:8080", "tls":false, "http2":false}] + }, + "backends": [{"id":"application", "url":"http://127.0.0.1:9000", "allow_private":true}], + "routes": [{"id":"root", "path_prefix":"/"}] + }] +} +``` + +The backend service must already exist. Routes without `backend_id` select from +the vhost pool; an explicit ID pins that path to a member of the same pool. +`vhosts[].force_ssl=true` redirects matching cleartext requests with HTTP 308 to the +same hostname/path/query. Force SSL requires both a selected plain HTTP listener and a +selected TLS listener. Port 443 is preferred when several TLS ports are selected; +otherwise multiple non-standard TLS ports are rejected as an ambiguous redirect target. +`vhosts[].forward_client_ip` defaults to true. Set it to false only when an application +must see RustProxy's transport peer instead of the visitor: backend requests then omit +`Forwarded`, `X-Forwarded-For` and `X-Real-IP`, while `X-Forwarded-Host/Proto/Port/URI` +remain and RustProxy/Tinyauth still use the real client address. See examples and +VHOSTS.md. HTTPS upstream certificate verification is disabled when `tls_verify` is omitted. Set `tls_verify: true` to validate the origin certificate chain and identity. For verified IP-based origins, use a DNS `tls_server_name` present in the +certificate and provide `tls_ca_pem` when the issuer is private. Disabling verification +retains TLS encryption and all backend address/DNS policy checks. Certificate metadata references +files under `/certs`; it does not create a vhost or expose a listener. +First-class redirect hosts live under `redirects`; see `REDIRECTS.md` for matching, target, status, path/query and cache behavior. + +ACL rules have `network` CIDR and `action` (`allow`/`deny`). Omitted ACL means no +restriction in that scope; an explicit empty ACL defaults to deny. Matching deny +wins. Host and route decisions are ANDed. Identity is the socket peer, not an +untrusted forwarded header. There are up to 128 rules per ACL. + +Headers run global, then vhost (where applicable), then route rules. `set` replaces, `append` adds a +separate value and `remove` accepts no value. Transport, WebSocket handshake and +client-identity headers are protected. Up to 32 rules per direction/scope, 128-byte +names and 8192-byte values are allowed. Response rules and matching vhost security policy also cover locally generated +proxy errors/denials. Multiple Set-Cookie fields remain separate. + +Routing matches exact normalized hostnames, then Nginx-style location precedence: exact path, +longest priority prefix (`^~`), first matching regex in declaration order, then longest ordinary +prefix at a segment boundary (`/api` matches `/api/x`, not `/apix`). Case-insensitive regex is +also supported. Repeated literal slashes are merged for route/ACL selection, while an unrevised +backend request retains the original path/query. Locations can explicitly strip/replace/regex- +rewrite the upstream path or return a local response. Ambiguous encoded separators and traversal +forms remain rejected. Vhost pools support load balancing as described in VHOSTS.md; locations +may pin traffic to one member. Wildcard hostname matching is supported only in the documented +leading-label form; hostname regex remains unsupported. + +## Metrics and logs + +Metric names use `rust_proxy_` and bounded aggregate labels. Transport counters cover +HTTP/2 requests/connections, TLS handshakes/errors and WebSocket tunnel totals, +active tunnels, errors, frame bytes and closed-tunnel duration sum/count. Ordinary +HTTP body bytes and WebSocket frame bytes are separate measurements. + +Access entries are emitted at exchange completion or tunnel closure, not only when +headers arrive. WebSocket exchanges retain status 101 and a `websocket_closed` +outcome when cleanly released. Errors/cancellation affect the outcome. No query +values, request bodies, cookies, password material or WebSocket payloads are logged +by the access logger. Error diagnostics use stderr tracing. The in-memory access ring retains up to 4096 +completed entries; live SSE/UI snapshots use the newest 256 by default. The dashboard can +temporarily expand an open Live Logs window to the retained 4096-entry buffer and keep +merging new live snapshots until that window closes. This is not a durable lossless log archive. + + +## 0.1.0 cache, listener and diagnostics additions + +| Method / path | Contract | +|---|---| +| GET /assets/langs.json | Public compiled language bundle: default ID, discovered language metadata/translations | +| GET /api/cache | Authenticated cache settings, metrics and bounded object metadata; never returns cached bodies | +| POST /api/cache/purge | Full/global or vhost-scoped purge with the normal mutation protections and audit | +| POST /api/cache/invalidate | Selective authenticated PURGE/BAN by exact URL, prefix, tag, safe regex or cache key; optional host/vhost scope | +| GET /api/cache/bans | List active in-memory BAN rules | +| DELETE /api/cache/bans/{id} | Remove one BAN rule with audit | +| POST /api/cache/cold/cleanup | Start an authenticated/CSRF-protected asynchronous selective Cold Cache cleanup job | +| GET /api/cache/cold/cleanup/{id} | Read status/result of an authenticated selective cleanup job | + +Configuration includes defaulted `listeners`, `cache`, `transform`, `compression`, `server_header` +objects and optional per-route `cache` (`false` bypasses, null/omitted inherits). +The complete documented field contracts are in SETTINGS.md and CACHE.md. Listener +changes persist immediately but require restart; all other settings here are hot +published. Settings forms use revisioned section writes where one settings area can +be changed independently; full-document PUT remains for changes that must publish +multiple sections atomically. + +Status and SSE snapshots include `cache`, `system`, and `listeners` with active, +desired, startup-default addresses and `restart_required`. Metrics include four +compression counters; log entries include `cache` (HIT/MISS/BYPASS, or `-` before +cache classification). OS measurements are optional/null and sampled off the +request path. Unsupported is not represented as zero. Prometheus adds fixed-name +cache/resource series; absent OS measurements are omitted. No arbitrary URL/cache-key +labels are introduced and the metrics endpoint remains authenticated. + +The language bundle is generated by build.rs from langs/*.json and embedded into the +binary; uploading a new file requires recompilation/restart. It exposes public +translations only, not filesystem paths, administrator data or credentials. + +## 0.2.0 vhosts and backend state + +`vhosts` defaults to an empty array. All models, enum values, defaults and bounds +are documented in VHOSTS.md, with complete examples under `examples/vhosts-*.json`. +Use the same revisioned PUT endpoint; there is no unauthenticated health-control +endpoint or separate unsynchronized frontend/backend write API. + +`GET /api/backends` returns `{pools, active_frontends, desired_frontends}`. Pool state +includes `id`, `algorithm`, `ready`, `health_mode`, `sticky_mode`, `keepalive`, +`members`. Members expose ID/origin/weight, status, healthy flag, active exchanges, +request/error/byte totals, EWMA byte rate/header latency, last check/result, failure +and success counters, transition count and last upstream status. It does not expose +affinity tokens. Member status is up/down/checking/draining/disabled. In health-off +mode, eligibility is not proof that the origin is live. Draining describes operator +intent; inspect `healthy` as well. `ready` reflects new-assignment availability; +a pool containing only draining members may still serve existing cookie sessions. + +Status contains `backend_pools` and `vhosts`; listener state additionally includes +`active_endpoints`, `desired_endpoints`. Each endpoint reports its address, TLS and +H2/H3 transport capabilities. `restart_required` covers socket and listener-capability +changes. Listener capabilities are configured only under `listeners.endpoints`. A vhost +then narrows them with `http2_enabled` and `http3_enabled`; both default true. For TLS, +SNI selects either H2+HTTP/1.1 or HTTP/1.1-only ALPN for the matched vhost, so disabling +H2 on one hostname does not disable it for another hostname sharing the listener. + +Metrics add `rust_proxy_backend_*` per bounded vhost/member labels. Access entries +add nullable `backend` (`vhost/member` for a forwarded pool exchange). Cache hits +have no actual upstream member request and do not increase its request counters. +The `/assets/vhosts.js` frontend uses the same API/CSRF/revision +boundaries; the static script itself, like other frontend assets, is publicly readable. + +## 0.3.0 certificate upload and scoped operations + +`POST /api/certificates/upload` accepts JSON +`{"cert_pem":"PEM chain","key_pem":"PEM private key","hosts":["app.example"]}`. +It requires the same session, Origin and CSRF guards as configuration updates. +The request limit is 300 KiB; each PEM value is at most 128 KiB. At most two +blocking upload workers run, and the directory holds at most 128 upload-prefixed +pairs. This endpoint is staging only, not a configuration mutation. + +Success: `{"certificate":{"id":"upload-...","covered_hosts":["app.example"], +"hosts":[],"cert_file":"upload-....pem","key_file":"upload-....key"},"staged":true}`. +`covered_hosts` contains the normalized DNS names extracted from the leaf certificate +SubjectAltName extension; `hosts` remains the configured active SNI bindings. No key or +certificate PEM is returned. Add the metadata to `config.tls.certificates` and its ID +to a vhost's `certificate_id`, then save `tls`/`vhosts` as needed. The panel keeps a +combined full-document Save only when staged certificate metadata and vhost assignment +must be published atomically. Unknown certificate references, mismatched keys, +uncovered DNS names and conflicting same-name selections fail validation. +There is no ACME enrollment, expiry/chain-trust guarantee or remote PEM retrieval. + +`GET /api/cache` adds `vhosts: [{id, usage: {entries, resident_bytes}, settings}]`. +`POST /api/cache/purge?vhost=app` purges only that configured vhost and invalidates +its pending fills. Missing/unknown IDs return 404, not an accidental global purge. +Omitting the query purges all entries. Both actions are audited and require CSRF; +an empty body is accepted. Configuration updates purge only when cache compatibility +changes; unrelated section saves retain both hot and cold objects. + +`POST /api/cache/cold/cleanup` accepts a JSON selection with optional +`unused_for_seconds`, optional `oldest` (maximum count) and `hits_zero`. At least one +criterion is required. It returns HTTP 202 with a `ColdCleanupJob`; poll +`GET /api/cache/cold/cleanup/{id}` until `status` is `done`. The result contains +`removed_entries` and `freed_bytes`. This is intentionally separate from full/scope purge +but reuses the same internal Cold Cache candidate/removal machinery as automatic GC. + +### 0.22.1 selective cache invalidation + +`POST /api/cache/invalidate` accepts an `InvalidationRequest`: + +```json +{ + "operation": "purge", + "selector": "exact", + "value": "/assets/app.js?v=42", + "host": "app.example", + "vhost": "application", + "ttl_seconds": null, + "confirm_wide": false +} +``` + +`operation` is `purge` or `ban`. `selector` is `exact`, `prefix`, `tag`, `regex` or +`cache_key`. `host` and `vhost` are optional independent scope restrictions. BAN accepts +`ttl_seconds` (1..604800, default 3600) and the runtime keeps at most 1024 active rules. +Every regex operation and prefix `/` requires `confirm_wide=true`; the browser panel asks +for a danger confirmation before sending it. Regex input is length-bounded and compiled +with Rust's non-backtracking `regex` engine plus a compiled-size limit. + +The response includes `result` with operation/selector/value/host/vhost, +`affected_entries`, optional `ban_id`, `elapsed_ms` and `status`, plus current cache metrics +and active BAN rules. Selective PURGE removes RAM and Cold Cache matches, removes the full +`Vary` family under a matched primary key and prevents fills that started before the PURGE +from publishing afterward. BAN creation does not synchronously scan the store: matching +objects created before the rule are rejected on lookup and removed incrementally by cache +maintenance; fresh objects created after the rule may be stored. BAN rules are runtime +state and are not persisted across process restart. + +`GET /api/cache/bans` returns the active rule views. `DELETE /api/cache/bans/{id}` removes +one rule. All invalidation mutations use the standard administrator session, same-origin +request marker and CSRF protections; requested operator/scope information is written to the +existing audit log. + +The `transform` configuration section is replaceable through the normal revision-CAS +`PUT /api/config/transform` endpoint. Vhost objects carry a `transform` override and +Docker assignments carry `transform_enabled`; both default to disabled per vhost. + +`GET /api/transform` returns in-memory transformation observability for the administration +Transform workspace: totals and per-vhost counters for examined/applied/skipped/error +responses, transformed cache HITs, original/transformed/saved bytes, accumulated transform +time, applied counts by content kind and bounded skip/fail-open reasons. These counters are +runtime diagnostics and reset when the RustProxy process restarts. + +`GET /api/logs` accepts optional `vhost`, `q`, exact numeric `status`, `cache` and +`limit` parameters. Limit clamps to 1..4096 and defaults to 256; search length is at most +256 bytes. Filters run over the retained ring before the limit is applied. `cache` accepts +HIT, MISS, BYPASS or `-`. Entries now include nullable `vhost` independently of `backend`: +cached and static responses can be attributed without claiming that an origin was contacted. +SSE still sends the newest 256 entries per global snapshot. The Live Logs UI can seed a +temporary 4096-entry buffer on demand and then merge subsequent live snapshots into it until +the window closes. This is not a durable log stream or a per-tenant authorization boundary. + +The status endpoint keeps existing field names, adds feature flags for new +capabilities, and assembles small values rather than one deeply expanded macro. +The management script `/assets/management.js` is a public static asset; APIs remain +protected. Pages and per-vhost cache fields use the standard configuration endpoint. + +## 0.4.0 certificate inventory, forwarding, auth and listeners + +`GET /api/certificates` requires the existing admin session and returns revision, +metadata-only certificates including `covered_hosts`, active `hosts`, `assigned_vhosts`, +file names and the per-entry default flag, plus `acme:false`. Inventory reads only the +public certificate PEM on a blocking worker and derives current DNS SubjectAltName values, +so older/manual entries and in-place renewals do not depend on stale persisted coverage +metadata. Persisted `covered_hosts` is only a fallback when the public PEM cannot be read. +It never reads a private key for this enrichment and never returns PEM contents or private-key bytes. Existing upload remains a +CSRF-protected staging operation. The inventory UI saves its returned metadata in +`config.tls.certificates`; vhost assignment is a later independent configuration +save. Every configuration mutation retains revision CAS and validation. + +Vhost additions: `tinyauth`, `server_header`, `diagnostic_headers`, +`backend_header`, `backend_timing_header`, and +`frontend.listener_ids`. Global additions: `forwarding`, +`diagnostic_headers`, and `listeners.endpoints`. Each effective +TLS binding can carry `http3` with the five fields described in HTTP3.md. These +are full-config fields, not independent unaudited mutation endpoints. Unknown or +invalid references, trust ranges, URLs, limits and protected header names fail +validation before publication. + +Status contains named_endpoints and active/desired endpoint transport metadata, +H3 connection counters and feature flags. Feature flags describe included source +capabilities, not evidence of successful build/conformance testing. Existing +Prometheus metrics add rust_proxy_http3_connections_total, +rust_proxy_http3_active_connections and rust_proxy_http3_rejected_connections_total +and rust_proxy_http3_requests_total. See source `metrics.rs` for exact HELP/type +contracts and INTEGRATIONS_040.md for configuration examples. + + +## 0.22.3 certificate hot reload and external deployment + +`POST /api/certificates/reload` is an authenticated, Origin/CSRF-protected control-plane +operation. It re-reads every certificate/key file referenced by the current desired +configuration, validates a fresh immutable routing/TLS snapshot and publishes it at the +**same configuration revision**. Invalid or mismatched material returns an error and the +previous snapshot stays active. The response contains `reloaded`, `revision`, `public_tls`, +`http3_new_handshakes` and `administration_restart_required`. New public H1/H2 TLS and HTTP/3 +handshakes use the refreshed certificates; established connections are not renegotiated. +Administration TLS is startup-fixed and therefore reports when a process restart is needed. + +`POST /api/certificates/{certificate_id}/reload` is the targeted variant for a manual/file-backed certificate (`issuer == null`). It validates the requested configured ID and atomically republishes the immutable TLS snapshot at the same configuration revision. It is intended for external renewers that replace the files already referenced by RustProxy. Application-managed ACME certificate IDs are rejected with `409` because their renewal pipeline owns those files. The endpoint accepts either the normal administrator session + CSRF flow or a scoped Certificate API token. + +`POST /api/certificates/deploy` accepts +`{"certificate_id":"shared-cert","cert_pem":"...","key_pem":"..."}` under the same +authenticated Origin/CSRF boundary. It is a convergent upsert for externally owned certificate +ids: a missing id creates a manual certificate entry, an existing manual id is rotated in place, +and an identical RustProxy-owned generation is a no-op. The pair is validated **inside the running +RustProxy process** and changed material is staged under immutable generation-specific files before +a revision-CAS configuration publish. This is important for root-run Certbot hooks when RustProxy +itself runs as an unprivileged service account: final generations are created with the service's +filesystem identity rather than `root:root`. Managed ACME ids are rejected, and failed +configuration publication removes any newly staged generation. + +For a certificate owned by a local external issuer such as system Certbot, prefer the local +CLI deploy hook, which calls that loopback API after bounded local file checks: + +```bash +rust-proxy tls deploy --id shared-cert \ + --cert /etc/letsencrypt/live/example/fullchain.pem \ + --key /etc/letsencrypt/live/example/privkey.pem +``` + +The command resolves Certbot's local `live/` symlinks, checks bounded regular files/private-key permissions, then sends the pair to `POST /api/certificates/deploy`. The running service validates the certificate/key and DNS SANs, stages generation-specific files inside RustProxy's private certificate directory, and creates or switches the **manual** certificate id through the normal revision-CAS configuration path. A single id may already be selected by multiple vhosts, so those vhosts switch together +without individual edits. RustProxy-managed ACME entries (`issuer != null`) are rejected to +avoid two renewal owners for the same lineage. The existing `/api/certificates/upload` remains +the generic PEM staging endpoint; private certificate paths are never accepted from a remote +request. + +## 0.23.0 scoped Certificate API credentials + +External automation no longer needs a full administrator browser session. From **Settings -> Certificate API**, an administrator can create multiple dedicated credentials (up to 32), each consisting of a Key ID, Bearer token and optional non-secret comment. Each Key ID can be rotated independently. The clear token is returned only by the create/rotation response; SQLite stores only its SHA-256 hash plus metadata/comment. Every credential has exactly two scopes: certificate deploy and certificate reload. + +API clients send both headers: + +```text +Authorization: Bearer +X-RustProxy-Certificate-Key: +``` + +A Certificate API credential is accepted only by `POST /api/certificates/deploy`, `POST /api/certificates/reload`, and `POST /api/certificates/{certificate_id}/reload`; it cannot read configuration, logs, metrics or any other administration endpoint. Non-loopback use requires the configured HTTPS administration surface. The normal administrator session + Origin/request-marker + CSRF path remains valid for the same operations. Credential management remains administrator-only: `GET /api/certificate-api` lists metadata, `PUT /api/certificate-api` creates/rotates one Key ID, `DELETE /api/certificate-api/{key_id}` revokes one token, and `DELETE /api/certificate-api` revokes all tokens. + +Example external deploy: + +```bash +curl --fail --show-error \ + -H 'Authorization: Bearer TOKEN' \ + -H 'X-RustProxy-Certificate-Key: certbot-main' \ + -H 'Content-Type: application/json' \ + --data-binary @deploy.json \ + https://proxy-admin.example/api/certificates/deploy +``` + +For an on-host Certbot hook, a token is optional: invoke the installed binary directly instead: + +```bash +rust-proxy tls deploy --id shared-cert \ + --cert /etc/letsencrypt/live/example/fullchain.pem \ + --key /etc/letsencrypt/live/example/privkey.pem +``` + +`dev.sh` is development tooling and is not required for production certificate rotation. + +## 0.5.0 cache inspector and certificate jobs + +`GET /api/cache` additionally returns `dashboard: {domains, objects, +matching_objects, offset, limit, statistics_revision}`. Domain rows flatten +hits/misses/bypasses/stored/saved_body_bytes/reasons beside vhost/domain and +resident counts. Object rows contain vhost/domain/path/body_bytes/resident_bytes/ +hits/age_seconds/remaining_ttl_seconds, never bodies, request headers or queries. +`vhost=ID` filters, `vhost=` selects legacy only; omitted selects all. Limit 1..200 +(default 50), offset <=16384. Unknown vhost ->404. The same scope semantics apply +to POST /api/cache/purge. Existing global metrics remain process-lifetime values. + +The historical 0.5 `GET /api/acme` response did not support renewal; see the +current 0.7 response below. +`POST /api/acme/issue` accepts at most 16 KiB of JSON with certificate_id, email, +domains, environment, agree_tos, confirm_production, dns_server, dns_port, key_name, +key_secret, key_algorithm, sign_query and propagation_seconds. See +ACME_RFC2136.md for exact constraints. It returns 202 `{job}` only after admission; +one process-wide job, 429 when busy. Job fields: id, certificate_id, status, +environment, message, certificate. The ready certificate is immutable PEM metadata, +not private bytes. Import is a separate normal config CAS write. Both endpoints +are behind the administrator session; POST retains Origin/CSRF and audit. + +LogEntry adds `cache_reason`, a fixed string token. Debug response headers add +X-Cache-Reason when X-Cache is enabled, and standardized RFC 9211 `Cache-Status` detail +when enabled. Transformed cached representations add the `transformed` Cache-Status +extension parameter. `X-Cache-Status` is stripped if supplied by an origin but is not +emitted by RustProxy. +`diagnostic_headers.hide_mock_origin` is removed and old documents containing it are rejected on +export; `request_id` is the only diagnostic-header switch. No revision response-header behavior remains. TLS listener +http3 omission is default-on; explicit null is persisted off. Legacy listeners use +https_http3 with identical default/null behavior. + +## 0.6 provider and scrape additions + +`POST /api/acme/issue` accepts `provider`: `http01`, `cloudflare`, `ovh` or +`rfc2136` (omitted retains RFC2136 compatibility). Common fields include +`certificate_id`, `domains`, `email`, `environment`, `agree_tos` and +`confirm_production`. DNS providers also accept `propagation_seconds`. +Cross-provider/unknown fields are rejected. Provider-specific fields, secret +handling and request examples are in ACME_PROVIDERS.md. `GET /api/acme` returns +only public job status and validated certificate metadata, not input credentials. +Issuer metadata on certificates is optional `{ "provider": "cloudflare", +"environment": "staging" }`. Do not confuse metadata with CA trust validation. + +`config.prometheus` controls the new dedicated HTTP scrape endpoint, which uses +actual-peer CIDRs and optional bearer authentication, not administrator cookies. +The exact configured GET path only is accepted (query strings rejected). Disabled +or changed/restart-pending settings return 404; denied peers 403; invalid tokens +401; non-GET 405; exhausted scrape capacity 429. No administrative actions are +available there. Admin `/api/metrics` and `/metrics` remain session-protected +previews on the admin listener, even when the dedicated endpoint is disabled. +See PROMETHEUS.md for the full settings and metric contract. Docker discovery is an independent collector group (`include_docker_vhosts`) and exports only dedicated `rust_proxy_docker_vhost_*` / `rust_proxy_docker_backend_*` families; ordinary vhost/backend families exclude dynamic Docker routes. + +## 0.7 renewal and host access + +`GET /api/acme` now returns `{job, providers, automatic_renewal:true, +interval_seconds:43200, renewals}`. `renewals` is keyed by certificate ID and +contains last_checked, next_check, last_renewed, not_after (Unix seconds), failures, +status and a public message token. No credential/account data is returned. +`GET /api/certificates` also includes renewal observations. Statuses are observations, +not a promise of a successful future CA operation. + +`POST /api/acme/renew` accepts only `{certificate_id, dry_run:false}`. It uses an +existing managed lineage and returns 202 `{job}` after admission, or 404 unknown ID, +409 unmanaged/invalid profile or disabled prerequisites, 429 worker busy. Actual +worker/CA failures arrive in job state. No force-renew flag is offered. Dry runs +never deploy their test certificate and never postpone a due real renewal. +The usual session, Origin/CSRF protections and audit apply. + +A certificate's `issuer` supports `auto_renew` (default true for saved managed +metadata, explicit false opts out). Toggle through ordinary revisioned config; +no provider credentials need resubmitting. Old immutable generations are retained. + +`POST /api/basic-auth/hash` accepts `{username, password}` with at most 4096 bytes +of request JSON. It requires an administrator session and the same mutation/CSRF +guards, returns `{username, password_hash}`, and never saves the plaintext password. +Passwords are 12..128 UTF-8 bytes with no control characters. Usernames are 1..128 +bytes, no colon/control characters. Hashing has two shared blocking-worker slots; +no available slot returns 429. Invalid input returns 400. This is NOT a public +login endpoint and does not create a browser session for a vhost. + +A vhost accepts `websockets` and `basic_auth` with enabled, realm, require_tls, +forward_credentials and users[{username,password_hash}]. Basic and Tinyauth cannot +be enabled simultaneously. Hash import accepts only the bounded Argon2id policy +created by the hash endpoint, not arbitrary-cost PHC strings or htpasswd formats. +Missing `websockets` retains the previous true behavior; the new-host UI selects +false. Basic TLS defaults true and forwarding defaults false. + +Backend pool members have `pool_eligible` (default true). False excludes that +member from default load balancing and sticky selection without blocking an +explicit location `backend_id`. Visual custom locations edit existing vhost +`routes`: ID, path_prefix, backend_id, enabled, cache, ACL and advanced headers. +This is prefix routing, not regex, rewrite or nginx configuration evaluation. +See [host access](VHOST_ACCESS.md) for cumulative ACL and authentication semantics. + + +## 0.18 Docker discovery additions + +| Method / path | Contract | +|---|---| +| `GET /api/docker` | Authenticated cached in-memory Docker-agent observations; no agent poll and no configuration write | +| `POST /api/docker/refresh` | Authenticated + Origin/CSRF immediate poll of enabled agents followed by ephemeral route reconciliation; no configuration revision write | +| `PUT /api/config/docker-agents` | Revision-CAS replacement of desired Docker-agent settings, per-agent domains and service hostname/alias/IP assignments | + +`config.docker_agents` stores desired agent endpoint/token/base-domain/IP override/listener references, a per-agent 5..3600 second inventory poll interval, and assignments. Inventory data returned by agents is not persisted in the configuration document. The normal configuration API remains authoritative for desired state; the Docker observation API is operational state only. Docker assignments may also carry an ACL profile, inline ACL, health-check settings and per-vhost proxy timeouts. Publishing/unpublishing is therefore an ordinary CAS configuration update and does not require polling the agent. + +## HTTP/2 lifecycle settings + +As of 0.24.3 the preferred configuration is per listener: `listeners.endpoints[].binding.http2_settings` contains `idle_timeout_seconds`, `max_connection_lifetime_seconds` and `drain_timeout_seconds` for that TLS H2 or cleartext H2C endpoint. Update these values by revision-CAS replacement of the `listeners` section (`PUT /api/config/listeners`), the same ownership model used by per-listener HTTP/3 limits. `max_connection_lifetime_seconds: 0` disables the absolute lifetime limit. Idle and lifetime expiry both use graceful GOAWAY/drain shutdown. + +The top-level `http2` object introduced in 0.24.0 and `PUT /api/config/http2` remain accepted for backward compatibility. They are a fallback only for listeners that do not yet have explicit `binding.http2_settings`; the structured 0.24.3 UI writes listener-scoped values. + +The runtime system diagnostics object includes `platform`, supplied by the shared system-statistics backend (`Linux`, `FreeBSD`, `OpenBSD`, `NetBSD`, or the generic/unsupported fallback). The browser only renders that value; it does not detect the operating system itself. diff --git a/docs/API_ACCESS.md b/docs/API_ACCESS.md new file mode 100644 index 0000000..f6da3d4 --- /dev/null +++ b/docs/API_ACCESS.md @@ -0,0 +1,231 @@ +# Administration API access - 0.24.5 + +## Sign-in boundary + +The administration listener remains separate from reverse-proxy listeners and uses +loopback HTTP by default. Settings -> Administration can enable HTTPS, select either +the application-managed default certificate or an inventory certificate, persist a +new bind address and allow explicit DNS hostnames. A persisted non-loopback bind is +accepted when HTTPS is enabled. The legacy startup flags remain only for deliberately +plain remote HTTP. Public vhost `/docs` paths are not captured by the admin listener. + +When the dashboard is published through a trusted reverse proxy, an explicit +`administration.allowed_hosts` DNS entry is matched against the external authority rather +than the private admin socket port. `X-Forwarded-Host` is trusted only from loopback or a +configured trusted proxy; the external `X-Forwarded-Proto` additionally requires the +forwarded-proto trust setting except for loopback. Origin/CSRF checks and Secure cookies use +that external origin. Direct IP/localhost access still requires the real administration port. + +### Publishing the panel through RustProxy itself + +0.16.0 supports the explicitly opted-in same-process topology where a public HTTPS vhost proxies to the local administration listener. The reserved `rustproxy_session` cookie is passed through only when all of these conditions are true: the browser-facing request is TLS, its DNS hostname is explicitly present in `administration.allowed_hosts`, the selected backend directly addresses the configured administration socket by IP, and that backend has `accept_local_listener_risk=true`. The control-plane route is forced out of shared cache. + +This is a narrow exception to the normal isolation rule. For every ordinary backend RustProxy still removes `rustproxy_session` before forwarding requests and strips an upstream attempt to set that cookie in responses. DNS backend names are not accepted for the special self-admin cookie path. This prevents a normal application backend from reading or replacing the administration session. + +The anonymous page contains only the sign-in/setup form, logo, shared stylesheet, +a dedicated login script, and filtered language strings. It does not fetch the +configuration, status, metrics, logs, certificate inventory, dashboard modules or +the full translation bundle. Adding a new language JSON still discovers that +language at build time; login strings use the same fallback rules. + +Exact anonymous methods/paths: + +| Method | Paths | Content | +|---|---|---| +| GET/HEAD | `/`, `/overview`, `/vhosts`, `/redirects`, `/streams`, `/acl-profiles`, `/configuration`, `/tls`, `/cache`, `/logging`, `/settings` | Minimal login when no valid cookie is present; the same URL returns the authenticated application shell when the supplied session is valid. `/configuration` is a compatibility alias for Settings -> Manual config. | +| GET/HEAD | `/assets/login.js`, `/assets/login-langs.json`, `/assets/style.css`, `/favicon.svg` | Login assets; no runtime state | +| GET/HEAD | `/healthz` | `{"status":"ok"}` only | +| GET/HEAD | `/api/auth/status` | `{"setup_required":false}` or `true` only | +| POST | `/api/auth/login`, `/api/auth/setup` | Credential/capability validation, exact Origin and request marker required | + +The document allowlist above is intentionally session-aware: a browser refresh on a protected +panel path first validates the cookie server-side instead of rendering a false logged-out state. +The login document also rechecks `/api/auth/session` when restored from browser history, preventing +a stale back/forward-cache copy from contradicting a still-valid session. Every other admin +path/method, including full assets, `/api/metrics`, `/metrics`, unknown endpoints and the OpenAPI +schema, requires an active database session. +Missing/invalid/expired cookies return `401 {"error":"auth.required"}` before +query/body extraction. Invalid Host/Origin may return 403 first. Only GET/HEAD +`/docs` and `/docs/` navigate to login through a fixed `303 /?next=docs` redirect; +API/spec/assets never return login HTML instead of a status error. + + +Administrator browser sessions have a configurable absolute lifetime of **7–30 days** +(default 30) through `administration.session_days`. The server stores only a hash of the +session secret, keeps the cookie HttpOnly/SameSite=Strict (and Secure on HTTPS), and +never extends a session past its creation-time absolute expiry. The cookie is refreshed +with only the remaining lifetime so an upgraded still-valid short session can adopt the +new configured bound. Logout, password change and revoke-all still invalidate server-side +sessions immediately. + +Responses are no-store. Headers include CSP, nosniff, frame denial, no-referrer and +restricted camera/microphone/geolocation permissions. Error responses do not expose +request-body excerpts for malformed anonymous login/setup input. Anonymous responses contain no application version; fingerprint/revision +response headers are stripped on the admin surface. Authenticated status and the +protected schema intentionally retain the current version. The application name/logo and the single setup-needed bit are intentional +public information, not claims of anonymity or software fingerprint prevention. + +Authenticated `GET /api/status` also returns a small `runtime` object with the platform, +statistics adapter name, resolved data directory and SQLite database path. This is used by +the Settings UI so Linux, BSD/OPNsense and custom installations show their actual runtime +paths instead of browser-side guesses. The object is not available anonymously and does +not expose credentials, environment values, certificate contents or arbitrary filesystem +listings. + +## Swagger UI + +`/docs/openapi.json` describes the local endpoints and common request schemas; it +contains no live configuration, actual credentials, generated keys, user accounts, +cache entries or listener addresses. Extensible schemas summarize configuration; +the Rust validator remains authoritative for the full configuration model. + +Documentation uses the same HttpOnly `rustproxy_session` cookie as the panel. +Do not paste session cookies into the schema or store them in browser storage. +GET execution is enabled by default; write execution requires a checkbox and an +explicit confirmation for each request. The script validates the current session, +allows only documented same-origin operations, and sends the current session's +`X-CSRF-Token` plus `X-RustProxy-Request: 1`. The browser supplies Origin. The server +performs the real authorization checks regardless of any browser options. + +Login/setup operations and indefinite SSE streams are documented but not executed +by Swagger UI. Native logout/password writes can invalidate the browser session; reload or +sign in again afterward. Ordinary API mutations still require the administrator session, +Origin/request marker and CSRF token. + +0.23.0 adds one deliberately narrow exception for certificate automation. An operator can +create one or more **Certificate API Key ID + Bearer token** credentials in Settings -> Certificate API. Each credential may carry an operator comment and authorizes only: + +- `POST /api/certificates/deploy` +- `POST /api/certificates/reload` +- `POST /api/certificates/{certificate_id}/reload` + +Both `Authorization: Bearer ` and `X-RustProxy-Certificate-Key: ` are required. +The clear token is returned only on creation/rotation; the database stores its SHA-256 hash. Up to 32 Key IDs can coexist, and each can be revoked independently or all at once. The credential does not authorize configuration, logs, metrics, users or any other endpoint. Non-loopback use is accepted only on the configured HTTPS administration surface. Revocation from Settings invalidates that credential immediately. + +Local host automation does not need this token. The installed binary can run +`rust-proxy tls deploy ...` or `rust-proxy tls reload` directly; it obtains short-lived local +CLI authority from the local database and calls the guarded loopback administration API. +`dev.sh` is a development helper and is not required in production. + +The external validator, query-supplied schema/server configuration and persisted +Swagger authorization are disabled. Runtime JS/CSS are served from the same admin +origin behind its guard. Only the two necessary vendor asset filenames and a plain-text license notice +(`/docs/licenses.txt`) are routed; all require an administrator session. +Swagger's inline layout styles have a document-scoped CSP exception; scripts stay +self-only with no unsafe-eval allowance. The ordinary dashboard CSP is unchanged. + +Logout broadcasts an in-memory notification between tabs. The panel drops its +operational document on logout/revocation; documentation clears its schema, +responses and write opt-in. Docs recheck sessions on actions, visibility and every +30 seconds while visible. Browser history restoration revalidates; this is not a +promise to erase browser/OS screenshots or data intentionally downloaded by an admin. + +## First build / offline assets (fixed in 0.7.2) + +No `SWAGGER_UI_DOWNLOAD_URL` environment variable or `.cargo/config.toml` is required. +The default is Swagger UI **5.32.11**, selected by `scripts/swagger_assets.py` and +called from this application's `build.rs`. The former `utoipa-swagger-ui` adapter +is removed: there is no separate dependency build silently choosing its older +historical browser default. Direct Cargo builds using `--manifest-path` use the +project's absolute manifest directory, not the caller's working directory. + +Python **3.10+ is now also required for direct Cargo builds**, as it already was +for `dev.sh`. `build.rs` tries `python3`, then `python` if that executable is absent. +Use `RUST_PROXY_PYTHON=/absolute/path/to/python3` to select an interpreter explicitly. +The helper uses Python's verified HTTPS transport and standard-library ZIP reader; +`curl` is no longer a Swagger build requirement. Missing tools/network/assets give +an actionable build error rather than an absent-environment-variable panic. + +Normal build (no Swagger environment setup): + +```bash +cd RustProxy +bash dev.sh check +bash dev.sh test +bash dev.sh release +``` + +The first build still needs the normal Cargo registry and the pinned upstream +archive. Swagger JS/CSS and license notices are embedded in **both debug and +release binaries**, served behind the existing admin guard. No runtime download, +CDN, public asset endpoint or alternate authentication mechanism was introduced. + +An offline or GitHub-restricted build can use a reviewed local copy of the same +GitHub **tag archive**, with its original `swagger-ui-5.32.11/` directory structure: + +```bash +export SWAGGER_UI_DOWNLOAD_URL=file:///srv/build-assets/swagger-ui-5.32.11.zip +# Optional: require the archive digest independently verified by your build team. +# export SWAGGER_UI_ARCHIVE_SHA256=<64-hex-digit-verified-digest> +bash dev.sh check +bash dev.sh test +bash dev.sh release +``` + +Unset/empty source values use the default pin. Different remote URLs and old tag +archives are rejected. Local file URLs support escaped spaces and must be absolute. +`SWAGGER_UI_ARCHIVE_SHA256`, when set, checks the entire archive before publication. +No upstream checksum is fabricated: HTTPS provenance, the archive's declared tag +and its layout are not themselves a cryptographic authenticity guarantee for an +operator-supplied ZIP. Independently review/pin local inputs. + +The helper only reads exact, bounded members; it does not extract arbitrary ZIP +paths. It retains licenses/notices, validates package name/version, rejects duplicate +required members, symlinks and oversized assets, caches only validated downloads in +Cargo's output directory, and writes outputs atomically. Failed preparation blocks +compilation instead of embedding stale assets. A `source.json` receipt records +archive and output SHA-256 values in `OUT_DIR/swagger-ui/`. + +Cargo tracks changes to the helper, source/digest/interpreter environment variables, +local or cached ZIP and generated outputs. Repeated identical preparation preserves +output timestamps. `CARGO_NET_OFFLINE=true` forbids a new helper download; ordinary +Cargo dependencies must also be cached for a fully offline build. A local archive +is the recommended explicit setup for `cargo --offline`; Cargo CLI offline flags +are not assumed to propagate automatically into subprocess environments. + +0.24.1 adds cross-platform statistics dependencies. This packaging workspace has no Cargo toolchain, so it does not fabricate or hand-edit a resolver result. Generate and preserve `Cargo.lock` with Cargo on the compiler-enabled release host, then run `cargo check --locked` / the normal `dev.sh` gates before deployment. + +Build-helper tests use synthetic ZIPs; they do not claim to render Swagger. This +environment lacks Rust/Cargo and cannot fetch the upstream assets. Compilation, +real embedding/rendering and live access-control acceptance remain to be run on a +build server. Browser tests still label their simulated API/Swagger renderer. + +## Focused live regression probe + +After running the built server: + +```bash +python3 scripts/admin_audit.py --url http://127.0.0.1:9090 +``` + +The script sends anonymous read-only loopback probes, follows no redirects, ignores +proxy environment variables, prints no response bodies, and fails on unexpected +public content/statuses. It does not send credentials or mutate configuration. It +is also called by the real-binary security integration suite. Rust router tests +add write/method/duplicate-cookie/CSRF checks. A passing probe is a regression gate, +not a complete pentest, dependency scan or guarantee against all vulnerabilities. + +## Custom location headers + +Vhosts -> edit -> Custom locations -> Location headers. Add rules independently +for proxy requests (to backend) and responses (to client). `set` replaces, `append` +adds another field value, `remove` has no value. Maximum 32 rules per direction. +The existing JSON editor is synchronized and remains optional. Invalid JSON, +newlines, unknown operations and protected names cannot silently save an old draft. + +Rules apply global -> vhost -> matching location. Proxy transport, forwarding, +verified authentication identity, configured Server/security/diagnostic policies +remain system-managed and can override or forbid a conflicting custom rule. +Values are literal, not Nginx variables; changing Host/forwarded identity still uses +the designated backend/forwarding controls. Headers for sibling locations remain +unchanged. No configuration schema migration is needed for existing header rules. + +## References + +- OWASP REST Security Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html +- OWASP Session Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html +- Swagger cookie authentication: https://swagger.io/docs/specification/v3_0/authentication/cookie-authentication/ +- Swagger UI configuration: https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/ +- Cargo configuration discovery: https://doc.rust-lang.org/cargo/reference/config.html +- Cargo build scripts: https://doc.rust-lang.org/cargo/reference/build-scripts.html +- Pinned browser release: https://github.com/swagger-api/swagger-ui/tree/v5.32.11 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..d8f4ac2 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,214 @@ +# Architecture + +## Control plane and forwarding plane + +SQLite stores administrator intent, including vhosts/paths, policies, backend definitions +and certificate metadata. `config::compile` validates this document and builds an +immutable Snapshot with routing tables, compiled ACL/header policies, per-backend +clients and an optional rustls ServerConfig. ArcSwap publishes the active snapshot. +No public request consults SQLite or a provider to choose its route. + +A configuration mutation obtains a bounded admission slot and builds a candidate on +a blocking worker. The slot remains held if the HTTP caller is cancelled while the +worker is running. A serialized detached writer checks the expected revision, +commits the desired document in SQLite and swaps the active snapshot with no await +between commit completion and publication. Invalid candidates leave the old state +active. A crash between persistence and publication is recovered by loading the +committed document at startup; this is not one cross-memory/SQLite transaction. + +## Module responsibilities + +| Module | Responsibility | +|---|---| +| `server` | Bounded listeners, TLS acceptance, HTTP/1.1/HTTP/2 selection, connection leases and shutdown | +| `proxy` | Host/authority routing, ACL sequencing, forwarding and streaming instrumentation | +| `proxy::websocket` | Handshake validation, upgrade tasks and bounded bidirectional byte transport | +| `upstream` | Per-backend pools, guarded resolver, connection deadlines and verified HTTPS clients | +| `tls` | Local PEM files, generation/import, validation, SNI and loaded certificate snapshots | +| `config` | Desired models, normalization, validation and compiled snapshots | +| `vhost` | Host-owned frontend/member models, actual-address selection and listener planning | +| `balance` | Full-exchange leases, health/recovery, weighted scheduling, connection/affinity settings | +| `security` | Compiled per-vhost response protection, TLS-only HSTS | +| `acl`, `headers` | Generic access and header policies, no application-specific exceptions | +| `auth`, `admin` | Administrator lifecycle, local API, browser sessions, CSRF, SSE and static UI | +| `db` | SQLite schema, migrations, desired-state persistence and revision checks | +| `metrics`, `logging` | Aggregate instrumentation, bounded access ring and trace output | +| `transform` | Bounded decode/transform stage for eligible static identity representations before cache/compression | +| `docker` | Desired Docker-agent settings, in-memory observations and ephemeral vhost reconciliation | +| `runtime` | Shared process state, snapshot updates, admission limits and tunnel tracking | +| `demo` | A separate opt-in demonstration backend with its own listener | + +Docker discovery is an optional provider boundary implemented through the standalone +`docker-agent/`; the RustProxy process never opens the Docker socket. Cloudflare Tunnel +remains a future module. Certbot-based HTTP-01/Cloudflare DNS/OVH DNS/RFC2136 issuance and managed renewal are optional +control-plane adapters. Provider work stays outside proxy request paths and +publishes certificates only through validated configuration snapshots. + +## Request lifetime + +A listener owns a shared TCP connection lease. Each admitted request owns an +in-flight permit and an Exchange, which remains alive through streamed request and +response bodies. HTTP/2 connections share the public TCP cap with HTTP/1.1 and TLS. +Upgraded WebSocket tasks retain both the Exchange and TCP lease after Hyper stops +managing the HTTP connection, preventing upgrades from escaping limits or counters. + +Routing captures one snapshot. Socket peer identity, host and route ACLs are checked +before DNS or upstream connection reuse. Forwarding uses a URI built from a stored +backend origin; the public client never supplies an arbitrary destination. Header +rules cannot change the identity already used by ACL. Ordinary bodies pass through +without full aggregation. For an explicitly transform-enabled vhost, eligible bounded +static responses use the logical pipeline `origin -> decode supported Content-Encoding +-> transform -> cache identity representation -> downstream compression`. Origin +`no-transform` is authoritative; changed bytes receive a RustProxy representation ETag +while origin validators are retained separately for revalidation. WebSocket data uses +fixed-size copy buffers. + +New requests on existing keep-alive/HTTP/2 connections load the current snapshot. +Existing bodies and tunnels retain the old snapshot/backend. Each accepted TLS +connection captures the current certificate configuration before its handshake. +Certificate validation and file reads are never performed inside the SNI resolver. + +## Startup and shutdown + +Startup opens/migrates SQLite, loads existing intent, prepares required certificates, +compiles the snapshot and binds the requested listeners. The demo only seeds a new +database. A corrupt configuration is not automatically reset. After the runtime is published, +the optional Docker poll loop contacts enabled agents; failures remain observations and do not reset desired configuration or block ordinary proxy service. + +Shutdown cancels listeners and tracked tunnels, drains admitted HTTP connections +within the configured grace period, drains accepted configuration writers and +closes SQLite. Bounded runtime shutdown avoids waiting forever for cancelled OS DNS +workers. This does not turn a blocking getaddrinfo call into a killable operation. + +## Deliberate development tradeoffs + +The panel remains loopback HTTP. All active configuration changes currently rebuild +backend pools and certificate state, even if unrelated fields changed. Compatible health/counter/affinity observations are retained independently; idle +connection and certificate reuse optimization still requires measurements. Backend loop protection checks local listener addresses by default while allowing remote origins on any valid port. A per-backend `accept_local_listener_risk` opt-in can deliberately allow a local RustProxy listener (for example port 9090) without bypassing private/special-address policy. DNS special-range restrictions +are basic safeguards rather than an independently audited network policy engine. +No performance target has been established or measured for this package. + + +## 0.1.0 response pipeline and resources + +Observe pins one immutable snapshot for the exchange. Host/route ACLs precede the +cache and all upstream I/O. Eligible GET lookup uses effective request headers +and a revisioned representation key. On a miss the origin response is validated +before and after global/route header rules; a bounded tee records complete bodies +while still yielding frames immediately. No request-path SQLite/provider lookup is +introduced. Server policy and optional streaming compression apply after cache +lookup/fill wrapping. The outer MeteredBody counts delivered encoded payload frames. + +Cache publication uses a short mutex section and LRU/byte/entry bounds; fill buffers +have eight semaphore slots. Purge increments a generation, preventing older in-flight +fills from publishing. Config commit invalidates cache before snapshot publication, +without awaiting between those operations. New listener intent is persisted but +active sockets remain unchanged until restart; original CLI defaults are retained +separately so clearing an override has accurate desired-state reporting. + +`cache`, `transform`, `compression`, `settings`, `diagnostics`, and `system_stats` are dedicated modules. `diagnostics` owns periodic sampling/export while `system_stats` adapts the shared `systemstat` host API, `sysinfo::Networks` network API, and one `metrics-process` process API. Network inventory/counters use `sysinfo` as the primary source and merge `systemstat` as a secondary library source, so missing/partial primary inventory is supplemented without shell commands; no BSD/Linux shell-command network parser is maintained. `metrics-process` uses its documented dummy fallback where no native process collector exists, so unsupported fields remain absent without adding another platform parser. Linux adds its compatibility-only `/proc`/cgroup fields after the common sample. The +sampler runs blocking OS/library reads off the async request path roughly every two +seconds and stops through the process cancellation token. Language discovery runs +in build.rs, not in browser hardcoded PL/EN logic. The result is an embedded public +JSON asset; existing logo resources remain intact. + +## Vhost pool publication and observations + +The immutable routing snapshot contains actual-address-constrained routes, compiled +frontends, pools and both TLS ALPN configurations. The process separately retains +its actually bound listener plan. New routing uses desired bindings immediately; +new sockets/ALPN take effect only at restart. This can temporarily deny moved hosts, +but never silently broadens them to still-active old bindings. + +Each pool owns immutable member definitions and clients. Small synchronized health, +scheduling and traffic observations live separately. Compatible publication reuses +observations/tokens by vhost/member/origin/health identity; changed or re-enabled +members are quarantined. Request leases retain the old member/client until both +body directions or the upgraded tunnel finish. Changes do not replay requests. + +A weak-App, cancellation-aware supervisor schedules a rotating, bounded window of +probes outside the data path. DNS and configured-origin policies are shared with +normal forwarding; HTTP checks use a dedicated connection and discard the body. +Traffic sampling and status snapshots operate on these observations, not SQLite. + +Forwarding order is actual frontend selection -> host/vhost/path ACL -> eligible +member selection -> member-isolated cache lookup -> guarded upstream request. +Cookie affinity bypasses cache. Cache hits release the selection without recording +an actual upstream request. Response protection is applied after cache selection, +and per-vhost HSTS is removed on plaintext, including synthetic responses. + +## Integrations in 0.4.0 + +`forwarding` compiles the explicit trust boundary and reconstructs canonical +metadata. `tinyauth` owns immutable verifier configuration and an outbound client; +it authorizes before the existing origin/cache path. `http3` adapts bounded Quinn +streams to the same Axum router and response Body without a separate cache/auth +implementation. Named listener IDs resolve to actual bindings while compiling +vhosts; accepted destination addresses still control route visibility. + +TLS and QUIC use the same validated certificate resolver. Active endpoint limits +remain fixed until restart; new QUIC handshakes can use the latest certificate +snapshot without changing Retry token keys. Graceful shutdown signals H3 GOAWAY +and bounds drain before endpoint close. Runtime conformance remains to be tested. + + +## 0.5.0 cache inspection and certificate jobs + +Cache decision counters and entry metadata live in the bounded cache store. A +separate authenticated paginated read API returns metadata, not bodies, queries or +request headers. Expiry cleanup also runs during inspection; generation barriers +still prevent old in-flight fills from republishing after purge/config changes. + +The ACME manager is outside proxy request paths: one job semaphore, a tracked +child-process group, JSON over stdin, sanitized status and private Certbot/RFC2136 +profiles. Only validated immutable certificate metadata can enter the existing +library through explicit revision CAS publication. See ACME_RFC2136.md for the +required external tools and unverified integration boundary; RENEWAL.md documents +the 0.7 scheduler and automated publication. + +## 0.6 boundaries + +`legacy` is an input-only migration module, not a traffic dispatcher. It emits the +same canonical vhost model used by the UI. Validated startup migration uses one +CAS write; failure before validation completion leaves the stored JSON untouched. + +`acme` manages one private worker job; `scripts/acme_provider.py` adapts Certbot +webroot/Cloudflare/OVH/RFC2136. Only the running HTTP-01 job's names and token files +are exposed by the reserved challenge middleware. Provider files remain private, +not part of SQLite or generic configuration exports. Issuance/import are separate. + +`prometheus` owns a dedicated read-only HTTP router, actual-peer CIDRs, environment +bearer token and bounded scrape work. It does not share administrator cookies. +Instrumentation is process-local; per-vhost counters reuse stable-ID observations +across configuration changes. Domain cache gauges intentionally reset with revision. +Admin request timing measures response headers, while proxy/vhost timing measures +complete body/tunnel lifetime. Neither renderer queries a provider or SQLite to +handle public proxy requests. + +## 0.7 renewal, access and deployment boundaries + +A shutdown-tracked weak-reference scheduler selects due configured managed +certificates. It shares the issuance semaphore and per-profile advisory lock. +Provider work remains outside request paths. Publication validates PEM/key/names +and the complete current configuration, then uses the same SQLite CAS/snapshot +path. Only changed generations publish; failures do not touch the active snapshot. +TCP and QUIC handshakes consult current TLS state. Certificate publication also +uses the existing cache-generation invalidation. See RENEWAL.md for timing/limits. + +Basic Auth is a separate vhost gate after cumulative IP ACLs and before static +responses, cache and upstream selection. Argon2id verification is bounded by a +shared semaphore held by blocking workers and an IP failure table. Hashes, not +passwords, are persisted. A verified Authorization header is optional and reapplied +after custom request headers; unverified identity headers are removed. Protected +responses bypass the shared cache and authentication failures cannot become custom +200 responses. WebSocket admission uses the same gates before opening a tunnel. + +`pool_eligible=false` distinguishes a location-pinned backend from ordinary pool +membership. The GUI edits the same prefix route model as the configuration API; +there is no second nginx-style parser or unvalidated location execution path. + +Server deployment uses root-owned immutable release directories, an atomic current +symlink, a non-root systemd service and private service-owned data. A stopped-state +backup precedes update publication. Failed health/version checks attempt to restore +both old code and data; backups and failed-generation state remain for recovery. +Live systemd/database recovery acceptance is not replaced by helper mocks. diff --git a/docs/CACHE.md b/docs/CACHE.md new file mode 100644 index 0000000..904a271 --- /dev/null +++ b/docs/CACHE.md @@ -0,0 +1,290 @@ +# Shared hot/cold cache + +See [CACHE_DIAGNOSTICS.md](CACHE_DIAGNOSTICS.md) for practical HIT/MISS/BYPASS diagnosis. + +## Scope + +RustProxy implements a bounded reverse-proxy cache with a hot RAM tier and an optional +persistent cold disk tier. Version 0.11.5 aligned request-method handling and most logical +hash behavior with Varnish built-in semantics. Since 0.19.1 the logical primary identity +includes the frontend scheme as well as Host + exact request URL, so `http://host/path` +and `https://host/path` cannot reuse the same stored response. RustProxy still isolates +different vhost/config scopes internally and uses response `Vary` to select representation +variants. + +This is not a VCL implementation. Generic Varnish grace, ESI, public unauthenticated +BAN/PURGE and distributed cache state are not implemented. RustProxy 0.22.1 adds bounded +foreground request collapsing, authenticated selective PURGE/BAN and optional proactive +refresh while keeping directive-controlled stale delivery through RFC 5861 and request +`max-stale`; it is not an unconditional availability/grace layer. RustProxy also keeps its +validator-based handling of response `no-cache` instead of reproducing Varnish hit-for-miss +behavior exactly. + +## Settings + +| Field | Default | Bounds / meaning | +|---|---|---| +| enabled | true | Global master switch; vhosts may disable cache but cannot re-enable a globally disabled cache | +| max_bytes | 268435456 | Hard HOT allocation budget (body backing allocation + charged entry metadata); minimum 64 KiB, no artificial high ceiling | +| max_object_bytes | 16777216 | Maximum logical body accepted by one fill; minimum 1 KiB and at most `max_bytes` | +| max_entries | 4096 | 1..16384 hot objects | +| transient_max_bytes | 67108864 | Hard RAM budget for fill buffers and complete bodies moving between HOT/COLD | +| cold_queue_max_bytes | 33554432 | Byte ceiling for complete demoted objects waiting for the COLD writer; at most `transient_max_bytes` | +| hot_eviction_max_objects | 64 | 1..4096 LRU removals attempted for one HOT admission before the new object passes uncached | +| cold_enabled | false | Enable persistent disk tier; never implied by hot-cache enable | +| cold_path | cache/cold | Relative path under the private RustProxy data directory | +| cold_max_bytes | 4294967296 | 64 KiB..1 TiB disk budget | +| cold_max_entries | 50000 | 1..1000000 cold objects | +| cold_gc_interval_seconds | 60 | 5..86400; interval between asynchronous pressure-GC checks | +| cold_gc_max_delete | 256 | 1..10000; maximum objects removed by one automatic GC pass | +| cold_after_seconds | 300 | 5..604800; idle hot objects are eligible after this delay | +| cold_on_idle | true | Move idle eligible hot objects to cold storage | +| cold_max_hot_seconds | 3600 | 5..2592000; total RAM-residency age used when `cold_on_age=true` | +| cold_on_age | false | Demote objects after the maximum hot-tier age even if they remain frequently accessed | +| cold_on_lru | true | Move objects displaced by hot-tier LRU pressure to cold storage | +| cold_on_shutdown | true | Best-effort direct hot-to-cold flush during ordinary graceful shutdown/restart | +| default_ttl_seconds | 0 | Only explicit public responses without origin freshness | +| max_ttl_seconds | 86400 | 1..604800; maximum configured retention/freshness cap | +| bypass_query | false | Bypass requests containing a query when enabled | +| bypass_prefixes | [] | Up to 64 normalized absolute path prefixes | +| static_ignore_cookies | true | For configured static extensions only, ignore request Cookie for eligibility/keying | +| expose_status | false | Emit `X-Cache: HIT`, `MISS` or `BYPASS` | +| expose_hits | false | Emit per-object `X-Cache-Hits` | +| expose_cache_status | false | Emit standardized `Cache-Status` (RFC 9211) | +| expose_resident_age | false | Emit `X-Cache-Resident-Age` on reused stored responses | +| collapse_enabled | true | Collapse concurrent foreground fills for the same primary cache key | +| collapse_wait_timeout_ms | 1500 | 10..60000 ms follower wait before fallback | +| collapse_timeout_fallback | origin | `origin` or `stale_if_available` after follower timeout | +| stale_if_error_timeout | true | Allow RFC 5861 stale fallback for timeout failures | +| stale_if_error_connection | true | Allow stale fallback for connection failures | +| stale_if_error_dns | true | Allow stale fallback for DNS failures | +| stale_if_error_tls | true | Allow stale fallback for TLS failures | +| stale_if_error_statuses | [500,502,503,504] | Up to 32 unique eligible HTTP 5xx statuses | +| proactive_refresh_enabled | false | Schedule sampled cached objects for validator refresh before expiry | +| proactive_refresh_before_expiry_seconds | 15 | 1..604800 seconds before expiry | +| proactive_refresh_percent | 10 | 0..100 deterministic percentage of primary keys | +| proactive_refresh_max_inflight | 4 | 1..64 concurrent proactive refreshes | + +The hot tier enforces `max_bytes` and `max_entries` before publication. HOT accounting uses the retained body allocation (the fill buffer capacity transferred into `Bytes`) plus directly owned entry metadata and a conservative per-entry charge for the HOT hash/LRU/variant indexes, while `hot_logical_bytes` reports only visible body lengths. Rust container/allocator APIs do not expose every bucket/node allocation exactly, so `hot_allocated_bytes` is intentionally a conservative charged estimate rather than a promise that it equals process RSS. In-progress fills reserve a separate bounded transient budget before growing their buffers. When HOT pressure would exceed the budget plus a small allocator-rounding headroom (roughly 1/64 of HOT, capped at 1 MiB), RustProxy removes LRU objects first; if admission still cannot be made safe within `hot_eviction_max_objects`, the response continues normally without being cached. `max_object_bytes` is checked incrementally before every body-buffer growth, so an oversized response is not fully accumulated before rejection. + +When cold storage is enabled, +`cold_on_lru`, `cold_on_idle` and `cold_on_age` independently choose which hot objects are demoted. +`cold_after_seconds` measures time since the last access. `cold_max_hot_seconds` measures total +hot-tier residency, so it can move a frequently accessed object to disk after a configured age. +The administration UI exposes these as separate policies to avoid conflating "old" with "unused". +Demotions use an asynchronous queue bounded by both object count and `cold_queue_max_bytes`. A demoted `Entry` is removed from HOT accounting and moved, not copied, into a `ColdDraft`; its `Bytes` payload remains charged to `transient_max_bytes` until persistence completes and the draft is dropped. The persisted `ColdEntry` contains paths/metadata rather than the body allocation. This prevents the COLD writer queue from becoming a second unbounded RAM cache. If an HTTP response is already using a clone of the same `Bytes`, that last transport-owned reference can keep the backing allocation alive until the response finishes; the cache-owned HOT/COLD reference itself is released after persistence. Process RSS is therefore monitored separately from cache accounting. Supported +glibc/Linux builds ask the allocator to release unused pages after bulk demotion. During +ordinary graceful shutdown, `cold_on_shutdown` performs a best-effort direct flush of remaining hot +objects before the store is closed. A **managed software update** (`update.sh` / `scripts/deploy.py`) +uses a private one-shot marker and, on RustProxy 0.13.2+, forces this hot-to-cold handoff whenever +the cold tier is enabled even if `cold_on_shutdown=false`. The updater gives the process a larger +stop budget and removes the marker before backing up state. If the cold tier is disabled there is +nowhere persistent to hand the hot objects, so RAM-only cache is still lost on process exit. The +body is stored as a raw `.body` file and metadata as JSON with a SHA-256 body digest. A fresh cold hit is integrity-checked and promoted to +RAM. Cold format 3, introduced in 0.21.0, stores objects below the first two hexadecimal +characters of the SHA-256 cache key, for example `cache/cold/7f/.body` and +`cache/cold/7f/.json`. Shard directories are created lazily. The previous flat +Cold Cache layout is deliberately not migrated; legacy flat objects are removed and can +be refilled from origin. Format 3 metadata also carries RFC 5861 windows, revalidation +flags, origin validators, last access and hit count. + +Automatic Cold Cache GC is asynchronous. When either byte or entry usage reaches about +90% of its configured limit, it selects old/rarely used candidates and cleans toward +about 80%, removing at most `cold_gc_max_delete` objects in one pass. Metadata is +snapshotted in small batches and disk deletion is queued instead of holding request-path +state across a full-cache sort/delete loop. + +## Varnish-style identity and variants + +The logical primary cache identity is: + +- a stable cache-compatibility revision derived only from settings that can change + cached representation/origin semantics, plus vhost scope (internal isolation), +- normalized frontend scheme (`http`/`https`), +- normalized original Host authority, including an explicit port, +- exact request path and query. + +Selected backend-pool member, route/backend IDs and unrelated request headers are not +part of the representation hash. This means two healthy members of the same pool can +serve/fill the same public cache object, while HTTP and HTTPS remain isolated. Headers +such as User-Agent, X-Forwarded-For or browser client hints no longer create unnecessary +variants by themselves. + +The response `Vary` header provides the secondary representation identity. For example, +`Vary: Accept-Language` stores independent language variants under the same scheme + Host + URL +primary hash. `Vary: *`, malformed Vary fields or an excessive Vary field set are not +stored. If an origin changes the Vary schema for a URL, incompatible old variants are +removed so an obsolete no-Vary object cannot shadow a newer Vary representation (or +vice versa). + +Cookie and Authorization requests still pass by default, matching Varnish built-in +safety behavior. RustProxy's static-cookie optimization can ignore Cookie only for the +configured static file extensions; response `Set-Cookie`, `private`, `no-store` and +`Vary: Cookie` still prevent unsafe shared reuse. + +## Request methods and invalidation + +GET and HEAD participate in lookup. They share one representation identity. On a +cacheable HEAD miss RustProxy fetches the backend representation as GET, may populate +the shared object, and strips the response body before delivering the original HEAD +request. + +POST, PUT, PATCH, DELETE, OPTIONS and TRACE pass to the backend and are not stored. +They do **not** implicitly purge the whole cache. This fixes the pre-0.11.5 behavior +where any successful unsafe request could erase unrelated static objects and destroy +the hit ratio. + +Cache invalidation remains explicit: + +- authenticated full purge can clear all objects or one vhost scope, +- selective PURGE supports exact URL, URL prefix, `Cache-Tag`, safe regex and primary cache key, with optional host and vhost restrictions, +- exact/prefix matching uses the stored request target including query; a match removes the whole `Vary` family under that primary cache key, +- selective PURGE operates on RAM and Cold Cache and advances a publication generation so no fill that started before the operation can publish afterward, even if the selector depended on response metadata such as `Cache-Tag`, +- BAN records a bounded TTL rule without synchronously scanning the entire store; objects created before a matching active BAN are rejected during lookup/insertion and are removed later by bounded GC, +- new fills created after a BAN rule are allowed, so BAN invalidates the old representation set rather than permanently blocking a URL, +- regex rules and prefix `/` are treated as broad operations and require explicit confirmation, +- only cache-compatibility changes (cache policy, configured header/origin semantics or vhosts) advance the normal cache generation/revision; unrelated logging/TLS/admin/metrics/response-presentation saves retain objects. + +There is no public unauthenticated PURGE/BAN endpoint. If an application changes a GET +resource after POST/PUT/PATCH/DELETE, use origin freshness/validators or the authenticated +administration invalidation path; RustProxy does not guess the affected URLs. + +## Eligibility and freshness + +Hard request bypasses include Cookie (outside the static exception), Authorization, +Upgrade, `If-Match`, `If-Unmodified-Since`, nonempty request bodies, configured path/query +exclusions and request `Cache-Control: no-store`. + +A single `Range: bytes=...` request is special rather than a normal bypass. When a complete +fresh cached representation is available, RustProxy evaluates `If-Range` and can return +`206 Partial Content` directly with `Content-Range`, `Accept-Ranges`, the correct sliced +`Content-Length`, validators and normal cache `Age`; an unsatisfied range returns `416` +with `Content-Range: bytes */`. A mismatching `If-Range` returns the complete cached +representation. If origin access is needed, RustProxy forwards the Range request as an +identity, non-transformed stream. Multiple/multipart, duplicate or otherwise unsupported +Range forms are passed through to origin and are never stored as separate range objects. + +A fresh cached representation can satisfy `If-None-Match`/`If-Modified-Since` locally. +Request `no-cache`, `max-age=0` and legacy `Pragma: no-cache` require validation when a +safe stored validator exists. `max-age=N` and `min-fresh=N` are freshness constraints: +they reuse the cached response when its current Age/remaining freshness satisfies the +client limit and otherwise drive normal revalidation/fetch behavior. Request `max-stale` +can explicitly allow a retained stale response: bare `max-stale` accepts any retained +staleness, while `max-stale=N` bounds it. Freshness expiry does not itself physically +delete an object; normal RAM LRU/Cold Cache GC may still evict it. `must-revalidate` and +`proxy-revalidate` override client stale permission. With a safe validator, an origin +304 refreshes metadata and reuses the stored body. + +Responses with `Set-Cookie`, `private`, `no-store`, `Content-Range`, trailers, +SSE/gRPC, `Vary: *`, invalid freshness metadata or unusable size/lifetime are not +stored. Response `Cache-Control: no-cache` may be retained only with ETag or +Last-Modified and must validate before every reuse. Both origin and transformed response +policy must remain eligible. + +`s-maxage` takes precedence over `max-age`, then Expires/Date. `Age` uses the RFC 9111 +current-age model and includes origin age; it is not a measure of how long the object has +been in RustProxy. Cache metadata keeps normal TTL, `stale-while-revalidate`, +`stale-if-error`, `must-revalidate` and `proxy-revalidate` separately. A representation +is therefore **fresh**, **stale-but-servable** only under an applicable directive, or +**expired/unusable**. + +Inside `stale-while-revalidate=N`, RustProxy may return stale immediately and starts one +deduplicated background refresh for that cache identity. Inside `stale-if-error=N`, stale +is used only after an eligible origin failure. Version 0.22 makes timeout, connection, DNS +and TLS classes independently configurable and uses an explicit configurable 5xx status +allowlist; defaults preserve the previous behavior (`500`, `502`, `503`, `504` plus all four +transport classes). After TTL plus the corresponding stale window, that mode is unavailable. +`must-revalidate` and `proxy-revalidate` still permit storage, but prohibit all stale delivery +after expiry, including RFC 5861 and request `max-stale`. + +Optional proactive refresh schedules a deterministic percentage of cache keys before normal +expiry. It reuses the same validator-based refresh logic and background-revalidation guard, +keeps only one schedule per key, and enforces a global concurrent-refresh bound. A newly +completed fill can schedule this work immediately, so sampled objects do not require another +client request to start the pre-expiry timer. The feature is disabled by default. + +## Resident age + +`X-Cache-Resident-Age` is the RustProxy-specific whole-second clock for how long the +stored representation has remained in this cache. In 0.11.5 that clock is independent +from the HTTP freshness `Age` clock and survives: + +- ordinary hot-cache HITs, +- successful backend 304 revalidation, +- hot-to-cold demotion, +- cold-to-hot promotion. + +A value of `0` is valid only during the first sub-second of residency. Revalidation or +tier movement must no longer reset a long-lived object to zero. + +## Streaming and capacity + +RustProxy tees response frames while delivering them; it does not wait for the complete +origin body before sending the first bytes. Only a complete successful body is +published. Oversize, body error, trailer and cancelled-fill paths discard the candidate. +Large buffers grow incrementally and consume multiple bounded fill permits. + +The configured resident byte limit accounts for body bytes, estimated header storage +and fixed per-entry overhead; it is not total process RSS. Active HIT references, +in-flight origin responses, compression buffers and runtime allocations also use memory. + +With `collapse_enabled=true`, the first foreground MISS for a primary cache key owns the +fill while concurrent followers wait for publication. The fill guard is held through the +actual response-body tee, so followers cannot wake merely because origin headers arrived. +Leader error/cancellation releases waiters. A follower that wakes to a different `Vary` +representation re-enters the single-flight decision instead of immediately stampeding the +origin. After `collapse_wait_timeout_ms`, the configured fallback is either an independent +origin request or an eligible stale response. SWR/background revalidation keeps its separate +existing deduplication guard. + +## Diagnostics and API + +`GET /api/cache` reports settings, HIT/MISS/BYPASS, hot/cold counts and bytes, +promotions/demotions, errors, fill pressure, object views and per-domain reason counters. +Hit ratio is `hits / (hits + misses)` and intentionally excludes BYPASS traffic. + +`POST /api/cache/purge` requires the existing administrator session, same-origin marker +and CSRF token. `?vhost=` purges only that vhost scope; omitting it purges all cache +objects. Purge is audited. + +`POST /api/cache/invalidate` performs authenticated selective `purge` or `ban` using +`exact`, `prefix`, `tag`, `regex` or `cache_key`. Optional `host` and `vhost` restrict scope; +BAN accepts a bounded TTL. Regex and prefix `/` require `confirm_wide=true`. The result +reports the selector/scope, affected entry count, elapsed time, status and BAN id when one +was created. `GET /api/cache/bans` lists active rules and `DELETE /api/cache/bans/{id}` +removes one. These operations use the normal administration audit path. The Cache panel +exposes the same controls plus per-object **Purge URL**. + +`POST /api/cache/cold/cleanup` starts a selective asynchronous Cold Cache cleanup using +the same candidate/removal machinery as automatic GC. Criteria can select entries unused +for a duration, cap removal to the oldest N objects, and/or require `hits == 0`. +`GET /api/cache/cold/cleanup/{id}` reports job status, removed object count and freed +bytes. Purge-all and purge-vhost remain the full-clear mechanisms; selective invalidation +is deliberately separate. + +`X-Cache`, `X-Cache-Hits`, `Cache-Status`, `X-Cache-Reason` and +`X-Cache-Resident-Age` are optional diagnostics. `Cache-Status` is the standardized +RFC 9211 field name; RustProxy does not rename it to the legacy/custom `X-Cache-Status`. +HTTP/2 and HTTP/3 serialize field names in lowercase, so `cache-status:` on the wire is +normal. When the cached representation was produced by the transform pipeline, +RustProxy appends the extension parameter `transformed`, for example: + +```http +Cache-Status: RustProxy; hit; ttl=556488; detail="cache_hit"; transformed +``` + +Origin lookalike values (`Cache-Status`, `X-Cache-Status` and the other RustProxy +cache diagnostics) are removed before RustProxy emits its own diagnostics. HTTP `Age` +remains present on reused cached responses because it is protocol freshness metadata, +not a debug header. The Cache dashboard also marks transformed objects explicitly. + +## Pool behavior + +Backend selection and health policy still run before an origin fetch. The selected pool +member no longer fragments the cache identity in 0.11.5, so a fresh public object can be +reused regardless of which healthy member would have been selected for a MISS. A pool +with no eligible member still returns 503 before RustProxy can use the cache; this cache +is not a stale-availability layer. + +Cookie affinity and protected/authenticated traffic keep their existing privacy bypass +rules. Per-vhost response/security policy also applies to HIT responses. diff --git a/docs/CACHE_DIAGNOSTICS.md b/docs/CACHE_DIAGNOSTICS.md new file mode 100644 index 0000000..f1ede6f --- /dev/null +++ b/docs/CACHE_DIAGNOSTICS.md @@ -0,0 +1,187 @@ +# Cache diagnostics and behavior in 0.22.3 + +This describes implemented source, not a verified Rust runtime. See +[TEST_REPORT.md](TEST_REPORT.md). + +## 0.19.1 cache identity safety + +The frontend scheme is part of the primary cache identity. `http://host/path` and +`https://host/path` therefore cannot share an object, preventing responses that depend +on `X-Forwarded-Proto` or equivalent origin logic from crossing schemes. + +## 0.11.5 cache changes + +The cache follows the Varnish built-in model for the behaviors that most directly +affect hit ratio, with the 0.19.1 scheme-isolation difference noted above: + +- GET and HEAD use the cache; other ordinary methods pass without clearing unrelated objects. +- The logical primary identity is frontend scheme + normalized Host authority + exact URL inside the current vhost/config scope. +- Selected backend-pool member and unrelated request headers do not fragment the primary object. +- Response `Vary` chooses representation variants under that object. +- GET and HEAD share the same stored representation; a cacheable HEAD miss can fill it from a backend GET. +- `X-Cache-Resident-Age` survives 304 revalidation and hot/cold tier movement. + +RustProxy still deliberately bypasses Cookie and Authorization requests by default, +matching Varnish built-in safety behavior. The configured static-extension optimization +can ignore Cookie only for selected static paths, subject to response privacy guards. + +## Why objects used to disappear after POST/PUT/etc. + +Before 0.11.5 a successful non-safe request could call a global cache purge. A periodic +POST, API heartbeat or form submission could therefore erase every cached static object +even though the request was unrelated to those URLs. In 0.11.5 POST/PUT/PATCH/DELETE, +OPTIONS and TRACE pass through and do not implicitly purge the shared cache. + +Only configuration changes that alter cache/origin compatibility invalidate incompatible cache +state; unrelated control-plane saves keep reusable hot/cold objects. Explicit administrator purge +still invalidates the selected cache state. Use the purge action when the application really needs +explicit invalidation. + +## `Age` versus `X-Cache-Resident-Age` + +These are different clocks: + +- `Age` is HTTP freshness age. It can already be thousands of seconds when RustProxy + receives an object because the origin/CDN `Date`/`Age` contributes to it. +- `X-Cache-Resident-Age` is RustProxy-specific and counts whole seconds that the stored + representation has remained resident in this cache. + +In 0.11.5 resident age is preserved across a successful backend 304 and across +hot-to-cold/cold-to-hot movement. An immediate first HIT can legitimately show `0` +because less than one full second has elapsed; a long-lived object should then keep +increasing rather than repeatedly resetting to zero. + +## Practical HIT test + +Enable cache plus `X-Cache`, `X-Cache-Hits` and `X-Cache-Resident-Age`, then make +repeated identical requests that read the complete body: + +```bash +for n in 1 2 3; do + curl -q -sS -D - -o /dev/null \ + -H 'Host: app.localhost' \ + http://127.0.0.2:8888/cache + sleep 2 +done +``` + +For an eligible response, expect MISS then HIT/HIT. `X-Cache-Resident-Age` should grow +between later HITs. A browser is a poorer diagnostic because its own cache may satisfy +a navigation without sending any request to RustProxy. + +HEAD is now eligible too. It shares the GET representation, but GET is still the best +basic test because it proves that the body was completely consumed and published. + +## Vary test + +A response with: + +```text +Vary: Accept-Language +``` + +stores language variants under one primary scheme + Host + URL object identity. Two English +requests can HIT each other; a Polish request must not receive the English body. If the +origin later changes between no `Vary` and `Vary: Accept-Language`, incompatible older +variants are removed rather than used as a fallback. + +`Vary: *` is not cacheable. + +## Cookie/static behavior + +`request_cookie` is a BYPASS by default. With `static_ignore_cookies=true`, configured +static extensions such as `.css`, `.js`, fonts and images ignore the request Cookie for +cache eligibility/keying. This is intentionally limited to static paths. A response +with `Set-Cookie`, `Cache-Control: private/no-store`, or `Vary: Cookie` still cannot be +shared through that optimization. + +This setting is useful for raising the asset hit ratio for logged-in users without +turning dynamic personalized pages into shared objects. + +## Conditional requests and reloads + +Fresh cached objects can satisfy `If-None-Match`/`If-Modified-Since` locally. Request +`Cache-Control: no-cache`, `max-age`, `min-fresh` and legacy `Pragma: no-cache` select +validation instead of creating a separate cache key. If a stored validator exists and +the backend returns 304, RustProxy refreshes metadata and reuses the body. + +Hard browser reloads commonly add revalidation directives, so they may produce +`cache_revalidated` instead of a plain `cache_hit`. This is different from a cache miss. + +## Reason tokens + +When cache diagnostics are enabled, these fixed reason tokens explain decisions: + +| Token | Meaning | +|---|---| +| `cache_disabled` | Effective cache is disabled. | +| `route_disabled` | Selected route explicitly disables cache. | +| `request_method` | Method is not GET/HEAD. It passes and does not purge the cache. | +| `request_cookie`, `request_authorization` | Private request state bypasses shared cache. Static Cookie exception may apply. | +| `request_no_store` | Client forbids use/storage of shared cache. | +| `request_no_cache`, `request_max_age`, `request_min_fresh`, `request_pragma_no_cache` | Client requests validation. | +| `request_body`, `request_range`, `request_precondition` | Request is outside reusable shared-object semantics. | +| `query_excluded`, `path_excluded` | Configured exclusion matched. | +| `tinyauth`, `sticky_cookie` | Authentication/affinity policy deliberately bypasses shared cache. | +| `response_set_cookie`, `response_cache_control` | Origin response is not safely shareable. | +| `response_vary`, `response_ttl_size_or_metadata` | Vary/freshness/size/metadata prevents storage. | +| `response_headers_policy` | Transformed client-response policy is not cacheable. | +| `cache_miss`, `cache_hit` | No reusable variant was found, or a variant was reused. | +| `cache_conditional_hit` | Fresh cache metadata satisfied the client validator locally. | +| `cache_revalidated` | Backend 304 validated the stored body and it was reused. | +| `cache_revalidated_uncached` | Validation succeeded but refreshed policy no longer permits storage. | + +A MISS only means lookup found no reusable object. The later origin response can still +fail storage because of privacy headers, size, incomplete streaming, trailers or a +changed policy. + + +## Memory accounting in 0.16.0 + +The process **RSS total** is an operating-system measurement and is intentionally shown separately from RustProxy's own cache accounting. Do not add the rows together: they overlap. The process block reports: + +- `RSS total` (`VmRSS`) - all resident pages currently attributed to the process; +- `RSS anonymous` (`RssAnon`) - anonymous resident pages, which typically include heap/stacks and other private allocations; +- `RSS file-backed / shared` (`RssFile` / `RssShmem`) - resident mapped file/shared-memory pages; +- `data address space / swap` (`VmData` / `VmSwap`) - address-space and swapped-page context, not extra resident bytes. + +The cache block separately reports memory RustProxy can attribute to the cache subsystem: + +- `hot logical` - visible cached body bytes (`body.len()`); +- `hot allocated` - the hard-budget charge for retained backing allocations plus entry metadata (`resident_bytes` remains a compatibility alias); +- `transient` - bounded cache allocations temporarily outside the hot store; +- `fill` - active response buffers being assembled before publication; +- `cold queue` - complete bodies waiting for cold-tier persistence; +- `cold I/O` - bodies currently being written to or read from the cold tier. + +This explains why, for example, a process RSS around 630 MiB can coexist with only about 100 MiB of reported hot cache: the old `Pamięć hot` card was never a whole-process memory counter. TLS/QUIC/Hyper/Tokio allocations, thread stacks, configuration/routing state, active request bodies and allocator-retained pages are also part of RSS but are not falsely assigned to a RustProxy subsystem when the source cannot measure them directly. + +The existing Linux/glibc allocator-release path remains in place: large cache demotion/purge paths call `malloc_trim(0)` through `release_allocator_memory()`. Since 0.24.5, HOT allocation, active fills and bodies moving to/from COLD are explicitly budgeted. COLD queue admission is byte-bounded as well as count-bounded. RSS is still a process-wide view containing the same cache memory plus TLS/QUIC/Hyper/Tokio, stacks and allocator retention, so cache gauges must not be added to RSS. Long-running acceptance should verify that HOT stays within `hot_limit_bytes`, transient stays within `transient_limit_bytes`, demotion drains transient bytes, and process RSS reaches a practical plateau under representative traffic. + +## Hot and cold tiers + +HOT RAM is bounded by allocated/charged `max_bytes` and `max_entries`; `hot_logical_bytes` separately exposes payload length. Fill and COLD handoff/read memory are bounded by `transient_max_bytes`, and queued demotions also obey `cold_queue_max_bytes`. Optional cold storage demotes LRU +or sufficiently idle hot objects asynchronously. Cold hits verify the body digest and +promote the object back to RAM. Version 0.11.5 uses cold metadata format 2 to persist +Vary identity and resident age; old-format metadata is not reused. + +Cold is not required for normal hot-cache stability. Disabling cold must not cause hot +objects to disappear on a timer. With cold disabled, hot objects leave RAM only because +of expiry, hot capacity/LRU pressure, explicit purge, configuration generation changes, +or replacement of the same logical representation. + +## Cache panel and API + +The Cache panel shows hot/cold object counts, RAM/disk usage, HIT/MISS/BYPASS, hit ratio, +fill pressure, evictions/expiry, per-domain decisions and stored objects. Live polling +refreshes the view; it does not itself purge cache objects. + +`GET /api/cache?offset=0&limit=50` returns aggregate and object data. Add `vhost=ID` to +filter. `POST /api/cache/purge?vhost=ID` invalidates one vhost; omitting the query purges +all cache objects. Mutations remain authenticated, CSRF-protected and audited. + +Hit ratio is `HIT / (HIT + MISS)`. BYPASS requests are excluded, so a large number of +cookie/authenticated requests can reduce backend offload without mathematically lowering +that displayed ratio. For the highest safe reusable-object ratio, give static assets +long explicit public TTLs, keep their URLs stable/immutable, leave the static-cookie +optimization enabled when appropriate, and avoid unnecessary `Vary` fields. diff --git a/docs/CONTAINER.md b/docs/CONTAINER.md new file mode 100644 index 0000000..d5d2001 --- /dev/null +++ b/docs/CONTAINER.md @@ -0,0 +1,43 @@ +# Container / OCI deployment + +RustProxy ships a baseline multi-stage `Dockerfile`. It compiles the locked Rust +release and runs it as the unprivileged `rustproxy` user (UID 10001). The core image does +not mount the Docker socket. Docker discovery uses the separate `docker-agent/` process on +each Docker host, so the proxy container never needs Docker-socket access. See +[Docker discovery](DOCKER_DISCOVERY.md). + +Build locally with a normal OCI builder: + +```bash +docker build -t rustproxy:0.22.3 . +``` + +Persist `/var/lib/rustproxy`; it contains the SQLite database, certificates and the +optional cold-cache tier. The default container proxy listener is `0.0.0.0:8080`. +Administration remains `127.0.0.1:9090` inside the container and is intentionally +not remotely reachable by merely publishing port 9090. + +```bash +docker volume create rustproxy-data +docker run --rm -d --name rustproxy \ + -p 8080:8080 \ + -v rustproxy-data:/var/lib/rustproxy \ + rustproxy:0.22.3 + +docker exec rustproxy rust-proxy admin setup-token +``` + +To expose administration, first enable administration HTTPS in the persisted +RustProxy configuration and choose a certificate, then bind the administration +listener to a non-loopback address. Publish the admin port only after HTTPS is +active. Plain remote HTTP keeps the legacy explicit risk gate. + +The runtime image includes Python, Certbot and OpenSSL for the built-in HTTP-01 +certificate helper. Provider-specific Certbot DNS plugins are not installed in the +baseline image; extend the image with the required plugin package before using a DNS +provider. + +For access-log files, prefer a writable path under `/var/lib/rustproxy` or mount a +separate writable log volume. The built-in JSON file sink is disabled by default. +Low ports such as 80/443 require an explicit container capability or host port +mapping; the image itself does not run as root. diff --git a/docs/CUSTOM_LOCATIONS.md b/docs/CUSTOM_LOCATIONS.md new file mode 100644 index 0000000..7912f5e --- /dev/null +++ b/docs/CUSTOM_LOCATIONS.md @@ -0,0 +1,96 @@ +# Custom locations + +RustProxy 0.22.3 supports the common location-level controls that are typically used for application reverse proxies. The UI intentionally exposes a safe subset instead of accepting arbitrary Nginx configuration text. + +## Routing model in 0.16.0 + +The vhost's main backend pool is an internal fallback and is **not** one of the Custom locations. Custom locations are optional overrides only. Request routing is therefore: + +```text +exact custom location +-> priority/regex/prefix custom location according to the documented precedence +-> main vhost backend pool +``` + +An empty Custom locations list is valid. Adding `/test`, a regex, a local response or a dedicated-backend location cannot disable `/`, `/abc` or other unmatched paths. Older saved documents that contain the former untouched synthetic `root` prefix `/` entry are normalized automatically. If `/` was deliberately customized with a backend, cache/ACL/header rule, rewrite or local response, it remains a real Custom location and intentionally overrides the main fallback. + +## Dashboard editor + +The visual editor is the primary workflow. Every location card now shows a live **Effective behavior preview** at the bottom with the match mode/pattern, proxy or local-response action, selected backend, rewrite behavior, cache policy, ACL composition and request/response header-rule counts. The preview is calculated from the unsaved form state so an administrator can verify intent before saving. + +Request and response header rules use numbered visual cards with localized **Operation / Header name / Header value** controls. `Set`, `Append` and `Remove` are supported. The Advanced JSON view remains available for automation/copy-paste, but is no longer required for normal editing. The documentation button in the location tab explains precedence, rewrites, local responses, policy composition and common examples. + +## Match modes + +Each location has an ID, a pattern and one match mode: + +| RustProxy mode | Nginx-style equivalent | Behaviour | +| --- | --- | --- | +| `prefix` | `location /path/` | Longest matching path prefix. A matching regex may still win. | +| `prefix_priority` | `location ^~ /path/` | Longest prefix and, when selected, skips regex locations. | +| `exact` | `location = /path` | Exact normalized path only. Exact matches have highest priority. | +| `regex` | `location ~ pattern` | Case-sensitive Rust regular expression. Regex locations are tested in configuration order. | +| `regex_insensitive` | `location ~* pattern` | Case-insensitive Rust regular expression. Regex locations are tested in configuration order. | + +Routing and ACL checks collapse repeated literal slashes, matching Nginx's usual `merge_slashes on` behaviour. For example `/static/js//events.js` is routed as `/static/js/events.js`. Unless a rewrite is configured, the original request target is still sent to the backend. + +Encoded separators (`%2f`, `%5c`), encoded `%`, dot segments and other ambiguous path forms are still rejected instead of being silently normalized. + +## Actions + +A location can either proxy to a backend or return a local response. + +### Proxy + +The location can use the vhost pool or pin traffic to one backend. A dedicated backend can be created from the location editor. Cache policy can inherit from the vhost or be explicitly enabled/disabled for the location. + +Supported path transformations: + +- **Preserve** — send the original client path unchanged. +- **Strip prefix** — remove the matched prefix before proxying. +- **Replace prefix** — replace the matched prefix with another normalized path prefix. +- **Regex rewrite** — available for regex locations; Rust regex captures can be referenced as `$1` or `${1}` in the replacement. + +The query string is preserved. Rewrites change only the upstream path; cache identity remains based on the public client URL. + +### Local response + +`Return custom response` is the equivalent of a simple Nginx `return`: choose an HTTP status from 200 through 599, a Content-Type and a response body. RustProxy does not open a backend connection for this action. `HEAD`, `204` and `304` responses do not send a body. + +For a redirect, use a 3xx status and add a `Location` response header in the visual header configurator. + +## Request and response headers + +Every location has a visual header configurator and an Advanced JSON view. Both edit the same data. + +Supported operations are: + +- `set` — replace the header with the configured value; +- `append` — append another header value; +- `remove` — remove the header. + +Example JSON: + +```json +{ + "request": [ + {"op": "set", "name": "X-App-Route", "value": "api"} + ], + "response": [ + {"op": "set", "name": "Cache-Control", "value": "no-store"}, + {"op": "remove", "name": "X-Legacy-Header"} + ] +} +``` + +Hop-by-hop/framing headers, forwarding identity headers, RustProxy request IDs and WebSocket handshake headers are protected and cannot be overwritten by custom rules. This prevents a location rule from changing transport framing or spoofing the verified client identity. + +## Access control + +A location can define its own IPv4/IPv6 CIDR ACL. The location ACL is additive: it cannot override a deny enforced by the host policy, reusable ACL profile or vhost ACL. + +Authentication, vhost rate limits, country policy, security headers, compression, custom error pages and WebSocket policy still apply through the normal vhost pipeline. + +## What is intentionally not an Nginx parser + +Custom locations do **not** execute arbitrary Nginx directives or configuration snippets. RustProxy does not currently implement location-level `try_files`, filesystem aliases/roots, embedded scripting, arbitrary `if` expressions or arbitrary module directives. Use the explicit UI controls and the documented JSON schema instead; unknown fields are rejected on save. diff --git a/docs/DOCKER_DISCOVERY.md b/docs/DOCKER_DISCOVERY.md new file mode 100644 index 0000000..2d3ea42 --- /dev/null +++ b/docs/DOCKER_DISCOVERY.md @@ -0,0 +1,147 @@ +# Docker discovery and dynamic vhosts + +RustProxy 0.22.3 includes an optional standalone Docker discovery agent. The RustProxy core never opens the Docker socket. Run `docker-agent/rustproxy-docker-agent` on every Docker host that should be discovered; RustProxy polls each agent over HTTPS by default using that agent's Bearer token and certificate pin. + +## State model + +- **Desired state** is stored in the normal RustProxy SQLite configuration: agent endpoint/token, per-agent base domain, optional default backend IP, listener IDs, optional frontend certificate and service-to-vhost assignments. +- **Observed state** stays in memory: Docker host addresses, container metadata and every non-loopback published TCP binding. +- A successful poll reconciles enabled assignments into ephemeral vhosts. Polling does not write a new configuration revision. +- If a published host port, binding or detected host address changes, the next poll rebuilds the effective route. +- If a service disappears, its dynamic vhost disappears until the service is observed again. +- Docker discovery is optional; ordinary vhosts and all other proxy functions remain independent of Docker. + +The agent returns only running containers with Docker-published TCP ports. Bindings limited to loopback (`127.0.0.0/8` or `::1`) are excluded. Multiple bindings for the same container port are retained instead of being collapsed to one address/port pair. + +## Install an agent on a Docker host + +The agent is written in Go and uses only the standard library. + +```bash +cd docker-agent +sudo ./install.sh +``` + +The installer runs the Go tests, builds one binary, creates the `rustproxy-docker-agent` system user, generates a random token on first install, starts `rustproxy-docker-agent.service` and prints the persistent self-signed certificate SHA-256 fingerprint. + +Manual build: + +```bash +cd docker-agent +go test ./... +go build -trimpath -ldflags='-s -w' -o rustproxy-docker-agent . +``` + +Agent configuration is read from `/etc/rustproxy/docker-agent.env`: + +```text +RUSTPROXY_DOCKER_AGENT_LISTEN=0.0.0.0:9876 +RUSTPROXY_DOCKER_AGENT_TOKEN= +RUSTPROXY_DOCKER_AGENT_TLS=true +RUSTPROXY_DOCKER_AGENT_CERT_FILE=/var/lib/rustproxy-docker-agent/agent.crt +RUSTPROXY_DOCKER_AGENT_KEY_FILE=/var/lib/rustproxy-docker-agent/agent.key +RUSTPROXY_DOCKER_ADVERTISE_IP= +RUSTPROXY_DOCKER_PUBLIC_IP_URL=https://api.ipify.org +``` + +`RUSTPROXY_DOCKER_ADVERTISE_IP` is only a preferred address hint; it does not hide the other host addresses. The inventory includes usable addresses found on active host network interfaces, the primary routed address, the optional public-IP lookup result and the advertised address. Loopback/link-local and obvious Docker-generated bridge/veth interfaces are omitted; explicit Docker bind addresses are still preserved with each service. Each host-address entry includes its source and, where available, interface name/scope. + +The inventory endpoint is `GET /v1/inventory` and requires `Authorization: Bearer `. `GET /healthz` exposes no inventory. HTTPS is enabled by default. The agent creates an ECDSA P-256 self-signed certificate once and keeps it in `/var/lib/rustproxy-docker-agent/`; the key is mode `0600`. + +`install.sh` prints both the generated/reused bearer token and the SHA-256 certificate fingerprint. Copy the SHA-256 fingerprint printed by `install.sh` into the agent settings in RustProxy. You can print it again with `sudo rustproxy-docker-agent --print-fingerprint`. With a fingerprint configured, RustProxy accepts only that exact certificate and still checks the TLS handshake signatures. If the agent uses a certificate issued by a normal public CA, leave the fingerprint empty to use standard CA/hostname verification. Plain HTTP is retained only for explicit compatibility setups and exposes the Bearer token to the network. + +Docker-socket access is privileged. Restrict access to the agent port to the RustProxy host even when TLS is enabled. + +## Configure multiple Docker hosts + +Open **Settings -> Docker agents** and create one entry for every Docker host. Each agent also has its own inventory **poll interval** (5..3600 seconds, 30 seconds by default); manual refresh bypasses the interval. Agents are independent and may use different domains, for example: + +```text +docker-a -> docker1.lab.local +docker-b -> docker2.lab.local +docker-c -> apps.example.net +``` + +For each agent configure: + +- stable agent ID and display name; +- agent endpoint, normally `https://10.10.0.21:9876`; +- the Bearer token from that Docker host; +- the SHA-256 certificate fingerprint printed by the default self-signed agent installer, or an empty fingerprint only for a public-CA certificate; +- that agent's base domain; +- optional default backend IP; +- one or more RustProxy listener IDs; +- an optional configured frontend certificate to apply to every generated vhost for that agent; +- an optional default ACL profile inherited by generated vhosts unless a service selects its own profile. + +RustProxy polls each enabled agent according to its configured interval (30 seconds by default, checked by the scheduler in bounded steps). The 5-second Live-view refresh reads only cached RustProxy memory and does not force a Docker request. **Docker vhosts** shows the latest cached observation for every configured agent. + +## Publish a discovered service + +Use the search field in **Docker vhosts** to filter by agent, service, hostname or alias. Individual service rows can be hidden from the normal view; **Show Hidden** beside Docker agents settings reveals them so they can be restored. This is a per-browser view preference and does not disable or unpublish the vhost. + +For each discovered service you can change the mapping directly in **Docker vhosts**: + +- **Hostname**: a short name such as `app1`, or a complete FQDN such as `portal.example.com`; +- **Aliases**: zero or more additional short names/FQDNs, edited with the same add/remove chips as normal vhost hostnames; +- **Backend host IP**: Auto/default or one concrete address detected on the Docker host; +- **Backend protocol**: HTTP or HTTPS; +- **Preserve Host**: forward the requested hostname to the container when enabled; new HTTP/1.1 assignments default to enabled, while an existing explicit disabled value is preserved; +- **Redirect HTTP to HTTPS**: per-app Force SSL switch. New assignments default on when the agent has a usable plain-HTTP + TLS listener pair; assignments saved before 0.18.8 retain their previous redirect-off behavior until explicitly saved with the new switch; +- **WebSockets**: enable or disable classic HTTP/1.1 Upgrade forwarding for this application; +- **Cache**: enable or disable normal RustProxy caching for this generated vhost; +- **Transform**: opt this generated vhost into the global static-response transform engine; it is off by default and still requires global transformation to be enabled; +- **Backend health check**: HTTP/TCP/off active-probe mode, path/Host override, interval, timeout, slow threshold, rise/fall counters and accepted HTTP status range. +- **Passive Backend Health**: independent opt-in policy, OFF by default, with separate connect/timeout/I/O/HTTP-5xx/slow-response switches and optional passive rise/fall overrides. +- **Access control**: an optional reusable ACL profile plus an optional inline IP/CIDR ACL for this application. A per-app profile overrides the agent default profile; inline rules remain cumulative with the effective profile. +- **Proxy timeouts**: optional connect, response-header, read-idle and send-idle timeouts; blank values inherit RustProxy defaults. + +Publish/Unpublish is applied immediately. Editing a published service uses the per-service **Apply changes** action; there is no page-wide assignment save step. The optional **Live view** switch refreshes only cached RustProxy observations/health state and does not contact the Docker agent. + +A short name is expanded below that agent's base domain. With base domain `docker1.lab.local`, `app1` becomes `app1.docker1.lab.local`. A full FQDN is not rewritten. Aliases use the same rule, so `app-old` becomes `app-old.docker1.lab.local`, while `legacy.example.com` remains unchanged. + +This makes renaming and aliasing a Docker-backed vhost a normal RustProxy configuration change; the container/service identity remains the stable link to discovery. + +## Multiple Docker-host IPs and bindings + +The agent reports all usable addresses it can see on the Docker host. This is intended for hosts that have separate public, management and LAN addresses. + +RustProxy also keeps every published Docker binding. For example, one container port may be published as: + +```text +10.21.0.15:18080 +192.168.50.15:28080 +``` + +Selecting `10.21.0.15` for the Docker vhost uses port `18080`; selecting `192.168.50.15` uses `28080`. For Docker wildcard bindings (`0.0.0.0` / `::`), RustProxy uses the selected host address with the wildcard binding's published port. + +Backend selection order is: + +1. the Docker vhost's explicit `target_ip`; +2. the agent's optional `default_target_ip`; +3. a concrete Docker bind address; +4. the agent's preferred/primary detected address when compatible with the binding; +5. another compatible detected host address. + +An explicit target that is not compatible with the service's Docker bindings does not silently fall back to another IP; that dynamic vhost is not materialized until the selected address/binding is available. + +The generated backend is pinned to the selected IP through an address allowlist and uses the matching Docker-published host port. Cache, Transform, WebSockets and HTTP-to-HTTPS redirect are materialized from the per-app switches. If the Docker agent has a frontend certificate selected, that certificate ID is copied to every generated vhost and participates in the normal TLS/SNI certificate validation and ownership rules. The effective ACL profile is the per-app profile when selected, otherwise the agent default profile. Active backend health checks default to TCP. Passive Backend Health is materialized from the Docker assignment separately and defaults to OFF, so discovery refresh/restart does not silently re-enable production-traffic quarantine. The Docker vhosts page exposes the same passive policy as ordinary vhosts. Normal 5xx/timeouts/connect/I/O/slow-response observations remain visible in counters while passive health is OFF. Active checks can be switched between HTTP/TCP/off per service without changing the passive policy. + +## DNS + +DNS for Docker vhost names must resolve to the RustProxy frontend/listener that should receive the HTTP(S) request. For a per-agent base domain this is normally a wildcard record, for example: + +```text +*.docker1.lab.local -> +*.docker2.lab.local -> +``` + +Custom FQDNs and aliases likewise need DNS records pointing at the relevant RustProxy frontend. The Docker-host addresses detected by the agent are backend routing candidates; they are not automatically published to DNS. + +RustProxy does not change DNS records itself in 0.22.3. +## Prometheus metrics + +Docker discovery has its own Prometheus collector switch, `prometheus.include_docker_vhosts`, enabled by default. It is intentionally separate from `include_vhosts` and `include_backends`. Dynamic Docker routes never appear under ordinary `rust_proxy_vhost_*` or `rust_proxy_backend_*` families. + +The Docker collector exports bounded configured labels only: agent ID, generated Docker-vhost ID, assigned service key, configured domains/aliases and backend ID. It includes `rust_proxy_docker_vhost_*` traffic/status/duration metrics and `rust_proxy_docker_backend_*` readiness, health, active work, requests/errors/bytes, current/EWMA latency, failure/success counters, health transitions and last-probe observations. No token, agent endpoint, backend URL or arbitrary request Host value is exported. + diff --git a/docs/HTTP3.md b/docs/HTTP3.md new file mode 100644 index 0000000..ea73ae2 --- /dev/null +++ b/docs/HTTP3.md @@ -0,0 +1,157 @@ +# HTTP/3 transport (0.22.3) + +**Uncompiled development source in this environment.** This release adds an actual +QUIC/HTTP/3 adapter, not only an Alt-Svc header. It does not claim independent full-RFC +conformance, interoperability or production performance until the acceptance tests run. + +## Stack and configuration + +The adapter delegates QUIC transport/TLS to Quinn 0.11.12 and HTTP/3/QPACK plus the +Quinn adapter to `hpx-h3 2.5.19` with its `quinn` feature. The crate is imported as `h3` +in source. These are Cargo dependencies, not bundled binaries. +Rustls provides TLS 1.3 for QUIC; SNI certificates come from the existing inventory. +HTTP/1.1 and optional HTTP/2 remain on TCP at the same port. HTTP/3 is on UDP. +There is no HTTP/3 upstream client in this revision: origins still use HTTP/1.1 or HTTP/2. + +Each vhost has `http3_enabled` (default true). When false, RustProxy removes +`Alt-Svc` from responses for that vhost and rejects HTTP/3 requests for it with 421, +while HTTP/1.1 and any permitted HTTP/2 traffic on the selected TLS listener keep +working. The UDP listener is still a listener-level resource; the vhost switch narrows +use of that shared capability rather than hot-unbinding the UDP socket. + +HTTP/2 follows the same UI model through `http2_enabled` (default true). A vhost may +only opt into H2 when one of its selected named listeners enables H2. For TLS, RustProxy +reads ClientHello/SNI before finishing the handshake and selects H2+HTTP/1.1 or +HTTP/1.1-only ALPN for that vhost. The listener remains the capability ceiling. + + +Since 0.5.0, an omitted `http3` enables QUIC with defaults on TLS bindings. +Explicit `http3: null` disables it and is preserved when serialized. An object +enables it with chosen limits. Plain HTTP defaults to no QUIC. In 0.24.3 the +**Enable HTTP/3 over QUIC (UDP)** switch is edited with the TLS endpoint under +**Settings -> Listeners**. The listener row links to **Settings -> HTTP connections +-> HTTP/3**, which contains only advertisement, connection, stream and idle-timeout +tuning for listeners where H3 is enabled. Both enablement and tuning remain +restart-required. Configure this only on named `listeners.endpoints[].binding`: + +```json +{ + "address": "127.0.0.1:8443", + "tls": true, + "http2": true, + "http3": { + "alt_svc_max_age": 3600, + "max_concurrent_connections": 512, + "max_concurrent_bidistream": 100, + "max_concurrent_unistream": 100, + "max_idle_timeout": 10 + } +} +``` + +| Field | Meaning and accepted limits | +|---|---| +| alt_svc_max_age | Advertisement lifetime, seconds, 0..604800 | +| max_concurrent_connections | Established/pending accepted connection slots per endpoint, 1..4096 | +| max_concurrent_bidistream | Peer bidirectional streams per connection, 1..1024 | +| max_concurrent_unistream | Peer unidirectional streams per connection, 3..1024 | +| max_idle_timeout | QUIC idle timeout, seconds, 0..86400; zero means no local QUIC idle timeout | + +Three unidirectional streams are required for the peer control and QPACK streams. +These stream limits are NOT global to all listeners. The existing global in-flight +request and application body/header deadlines still apply. Setting QUIC idle to zero +does not disable application deadlines, and the peer can negotiate its own idle timeout. +All changes to socket/ALPN/QUIC transport parameters require restart. + +## Transport behavior and safety + +- Bind UDP before announcing readiness; failure to bind is a startup failure. +- Advertise `Alt-Svc: h3=":port"; ma=N` only on an ACTUAL active TLS+H3 endpoint. + Origin Alt-Svc is stripped so it cannot advertise the origin or a disabled service. +- UDP address validation/Retry, bounded pending handshakes, a 10-second handshake + deadline, per-endpoint permits, stream flow control and bounded application concurrency. +- 32 KiB decoded HTTP field section limit; request content-length checks and rejection + of forbidden connection/framing headers; streamed bodies and trailers. +- Shared routing pipeline: real local socket -> validated authority -> trusted client + address -> ACL -> Tinyauth -> cache -> origin -> response/security/Server policies. +- 0-RTT is disabled to avoid replaying writes; no retry to another origin or transport. +- Endpoint active migration is disabled. Missing real local destination data on a + wildcard QUIC connection fails closed rather than substituting a forwarded IP. +- Configuration reload changes SNI certificates for new QUIC handshakes without + changing active socket limits. Graceful shutdown requests GOAWAY then bounded drain. +- Counters: http3_requests, http3_connections, http3_active_connections and + http3_rejected_connections in status/Prometheus; H3 request count also in dashboard. + +A connection receives a maximum aggregate 8 MiB QUIC window and 256 KiB per-stream +window; these limits are not process memory guarantees. Many simultaneous connections +can use substantial memory. Size connection/stream limits to the machine and measure. +No active path migration, 0-RTT, server push, WebTransport or HTTP datagrams are enabled. +RFC 9220 WebSocket Extended CONNECT is experimental and disabled by default per vhost. +RustProxy advertises SETTINGS_ENABLE_CONNECT_PROTOCOL on a QUIC listener only when at least +one vhost bound to that listener explicitly enables `websocket_rfc9220`; request routing +still rejects RFC 9220 for vhosts that did not opt in. Generic CONNECT and other Extended +CONNECT protocols remain unsupported and are answered with 501. The downstream H3 request must +not carry HTTP/1.1-only `Connection`, `Upgrade`, `Sec-WebSocket-Key` or +`Sec-WebSocket-Accept` fields. RustProxy creates a private RFC 6455 key for the HTTP/1.1 +backend handshake; a valid backend `101` becomes downstream `200`, after which H3 DATA +bytes are the WebSocket byte stream. Client FIN half-closes the backend write side; +backend EOF finishes the H3 send side; abnormal/forced termination uses +`H3_REQUEST_CANCELLED`. No side TCP listener or protocol bypass is used. + +### WebSocket bootstrap ordering and diagnostics in 0.16.0 + +For RFC 9220, a validated backend `101` is no longer enough to send the client-facing H3 success immediately. RustProxy first awaits Hyper's backend `OnUpgrade` and obtains the actual upgraded stream. Only then is downstream `200` returned and the H3 DATA bridge started. If backend takeover is cancelled or fails, the client receives a normal 503/502 response instead of `200` followed by an immediate `H3_REQUEST_CANCELLED` reset. + +Access logging can now distinguish this path with `http_version`, `transport`, `websocket_bootstrap`, `websocket_phase`, `websocket_error`, `h3_reset_code` and `upstream_status`. This makes Safari/iOS interoperability failures distinguishable from backend Socket.IO responses and from H3 tunnel resets. Query-string logging remains subject to the normal privacy policy; do not use access logging to capture session secrets. + +The advertised port is the real listener port. Behind port-rewriting NAT, use a +matching externally reachable UDP/TCP port or do not enable this advertisement; there +is no separate external-port override. Open UDP as well as TCP in your firewall/load +balancer. Scope labels do not perform those operations. Clients lacking QUIC or blocked +UDP can still connect using TCP; no guarantee is made about browser protocol preference. + +## Verification gate + +```bash +bash dev.sh check +cargo test --locked --test http3 -- --nocapture +cargo test --locked --test integrations --test listeners +bash dev.sh test +``` + +`tests/http3.rs` creates a real loopback UDP endpoint, a trusted generated test +certificate, a real origin and a Quinn+h3 client. It checks MISS/HIT, body preservation, +Server/diagnostic policy, connection capacity and ALPN HTTP/2/HTTP/1.1 compatibility. +The same integration test explicitly enables `websocket_rfc9220`, then opens an RFC 9220 Extended CONNECT stream and translates it +to a real HTTP/1.1 backend Upgrade, sends a masked WebSocket frame as H3 DATA, half-closes +the client send side and verifies byte-transparent DATA return plus tunnel metrics. Unit +regressions separately reject HTTP/1.1-only proof/framing headers on the H3 side. +It is provided but was NOT run in the packaging environment. The same-stack client +is a regression test, not independent interoperability evidence. + +After applying examples/http3-vhost.json on a disposable test instance and starting +`bash dev.sh mock --port 9001`, use an independently built curl with HTTP3 support: + +```bash +curl --version +curl --http3-only --cacert data/certs/default.pem -D - https://localhost:8443/cache +curl --http3-only --cacert data/certs/default.pem -D - https://localhost:8443/cache +curl --http2 --cacert data/certs/default.pem -D - https://localhost:8443/cache +curl --http1.1 --cacert data/certs/default.pem -D - https://localhost:8443/cache +``` + +Do not substitute `--http3` fallback or `-k` for strict protocol/certificate validation. +Also test independent clients, malformed-frame handling, stream exhaustion, slow +uploads/downloads, IPv6 wildcard destination metadata, cancellation, graceful drain, +certificate rotation, and memory/CPU at configured maxima before exposure. +No full RFC conformance or load-test result is asserted by this source package. + +## Primary references + +- HTTP/3 RFC 9114: https://www.rfc-editor.org/rfc/rfc9114.html +- QUIC RFC 9000: https://www.rfc-editor.org/rfc/rfc9000.html +- QPACK RFC 9204: https://www.rfc-editor.org/rfc/rfc9204.html +- Quinn: https://docs.rs/quinn/latest/quinn/ +- RFC 8441 Extended CONNECT: https://www.rfc-editor.org/rfc/rfc8441.html +- RFC 9220 WebSocket over HTTP/3: https://www.rfc-editor.org/rfc/rfc9220.html +- hpx-h3 2.5.19: https://docs.rs/hpx-h3/2.5.19/h3/ diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..8f5df75 --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,291 @@ +# Installing and updating RustProxy 0.24.5 + +The scripts prepare a Linux/systemd installation. This source archive is **not a +verified production build**: Rust/Cargo, real CA operations, live systemd startup +and rollback against a real Rust database have not been exercised in the packaging +environment. Perform the acceptance steps below on an isolated server first. + +## Debian 13+ one-line source install/update + +```bash +# auto: first install or managed update +wget -qO- https://git.linuxiarz.pl/gru/RustProxy/raw/branch/master/install-debian13.sh | bash + +# force the update path +wget -qO- https://git.linuxiarz.pl/gru/RustProxy/raw/branch/master/install-debian13.sh | bash -s -- update +``` + +The bootstrap installs build dependencies, installs Rust with rustup for an unprivileged build user, +clones `https://git.linuxiarz.pl/gru/RustProxy` and builds with `cargo build --release --locked`. +Its default `auto` mode calls `install.sh` only when no managed installation exists. When +`/opt/rustproxy/install.json` exists it calls `update.sh`, which uses the saved installation record, +backs up private state, switches the release, verifies health/version and rolls code/data/unit back +on failure. First install intentionally starts the admin listener on `0.0.0.0:9090` over plain HTTP +using `--allow-remote-admin`; updates preserve the saved admin/listener +settings. Firewall/private-network restriction is therefore required. The normal `install.sh` +default remains loopback-only. + +Equivalent prerequisite commands are: + +```bash +apt install -y build-essential gcc git ca-certificates python3 pkg-config wget sudo python3-certbot-dns-rfc2136 python3-certbot-dns-cloudflare +wget -qO- --https-only --secure-protocol=TLSv1_2 https://sh.rustup.rs | sh +``` + +## Build as a normal user + +Requirements: stable Rust (minimum declared 1.88), Cargo, a C compiler/linker, +Python 3.10+, Bash. Linux/systemd deployment also requires `systemctl`, `runuser` +and a usable non-login service account (created by the installer when missing). +The static TOML checker additionally needs Python 3.11+. No script downloads an +executable or installs a compiler as root. + +```bash +cd RustProxy +bash dev.sh doctor +bash dev.sh check +bash dev.sh test +bash dev.sh release +``` + +The first build resolves and creates `Cargo.lock`; retain the real lockfile and +binary hash for repeatable subsequent builds. There is no fabricated lockfile or +precompiled binary in the archive. Set `--binary` explicitly when using a custom +`CARGO_TARGET_DIR`. Review and trust the source and built binary before installation. + +## First installation + +The normal first install also downloads `user-country-ipv4.csv` and +`user-country-ipv6.csv` from the `sapics/ip-location-db` latest release over HTTPS and +verifies each file against the publisher SHA-256 release before writing it privately to +`DATA_DIR/geoip`. This enables country access/rate rules. Use `--skip-geoip` only when +you intentionally do not want the database; allow-only country policies then fail +closed for unresolved clients. Updates preserve the installed database. + + +```bash +bash install.sh --plan --binary target/release/rust-proxy +sudo bash install.sh --binary target/release/rust-proxy +``` + +Defaults: HTTP `0.0.0.0:80`, HTTPS `0.0.0.0:443`, administration +`127.0.0.1:9090`, code `/opt/rustproxy`, all private state `/var/lib/rustproxy`, +service account `rustproxy`. The initial page is a small informational response; no demonstration +backend or sample application is started. The first database has no configured +vhosts. Existing, nonempty data is refused rather than reset. + +The first install also writes `/etc/profile.d/rustproxy-path.sh`, which prepends the +managed `PREFIX/current` directory (normally `/opt/rustproxy/current`) to `PATH` for +new login shells. Open a new login shell, or source that profile explicitly, before +expecting `rust-proxy` to resolve without its absolute path. + +HTTPS is enabled by default and starts with a **self-signed certificate**, not a +publicly trusted certificate. For an explicit HTTP-only installation use: + +```bash +sudo bash install.sh --binary target/release/rust-proxy --no-https +``` +Assign the intended certificate in the panel before exposing applications. Existing +HTTP/3 defaults enable QUIC on TLS listeners: permit the corresponding UDP port +when desired, or explicitly disable H3 in Listeners. Review firewall rules yourself; +the installer does not change them. Public frontend TLS and administration TLS are +independent settings. + +Generate the one-time setup capability directly from the installed release CLI: + +```bash +sudo -u rustproxy /opt/rustproxy/current/rust-proxy \ + --data-dir /var/lib/rustproxy admin setup-token +``` + +This works before the first server start. After an administrator exists, setup +tokens remain invalid. You can alternatively initialize the account interactively: + +```bash +sudo -u rustproxy /opt/rustproxy/current/rust-proxy \ + --data-dir /var/lib/rustproxy admin init +``` + +For direct remote administration, first sign in on the default loopback listener and +open **Settings -> Administration**. Enable HTTPS, choose the application-managed +certificate or a certificate from the inventory, set the bind address (for example +`0.0.0.0:9090`) and optionally add DNS names accepted by Host/Origin validation. +Save and restart RustProxy. The persisted remote bind is accepted only while +administration TLS is enabled. Keep the listener behind a firewall/private network; +the certificate must be trusted by the browser if you want a clean HTTPS connection. + +An SSH tunnel remains available without exposing the listener directly: + +```bash +ssh -L 9090:127.0.0.1:9090 operator@your-server +``` + +Open `http://127.0.0.1:9090` on that workstation while the administration listener +is still using its default HTTP mode. No default administrator/password is added. + +Plain-HTTP remote administration is possible with the explicit allow flag: + +```bash +sudo bash install.sh --binary target/release/rust-proxy \ + --admin 0.0.0.0:9090 --allow-remote-admin +``` + +That plain-HTTP path has no transport confidentiality and should be limited to a trusted +private network. Prefer the built-in administration HTTPS mode for a direct remote +listener. + +Other installation switches: `--prefix`, `--data-dir`, `--user`, `--http`, `--https`, +`--no-https`, `--admin`, `--allow-remote-admin`, `--sha256` (expected binary digest), `--no-start`. Paths must be dedicated, +absolute, separate, non-nested and without spaces or symlink components. +`--plan` reads the supplied binary but changes nothing; it does not prove port +availability, compiler success or systemd readiness. + + +### Startup-owned listeners + +When HTTP, HTTPS or administration listen addresses are supplied explicitly by systemd, CLI +arguments or the corresponding `RUST_PROXY_*_LISTEN` environment variables, RustProxy treats +those exact binds as process-owned. Settings shows them as locked and configuration updates +cannot remove, disable or rewrite them. Listeners not supplied at process start remain normal +editable desired-state entries. + +## ACME prerequisites + +Automatic renewal runs inside RustProxy, not a separately installed cron timer. +Install `certbot`, the DNS plugins you use (`certbot-dns-cloudflare`, +`certbot-dns-ovh`, `certbot-dns-rfc2136`), Python and OpenSSL on the server. HTTP-01 +uses Certbot's webroot authenticator, not a competing standalone web server. +Certbot and its plugins must be in the **same Python environment**. An optional +root-owned virtual environment at `/opt/rustproxy-certbot` is on the service PATH; +otherwise use `/usr/local/bin` or `/usr/bin`. Avoid an executable under an +administrator's home directory: the unit uses `ProtectHome=true`. + +Check the effective service account's environment rather than only your shell: + +```bash +sudo -u rustproxy env PATH=/opt/rustproxy-certbot/bin:/usr/local/bin:/usr/bin:/bin \ + python3 scripts/acme_provider.py --doctor +``` + +The provider scripts are embedded in the binary; the source file above is just a +preflight command. The service owns its complete private `certs/acme` tree. Do not +run a second Certbot renewal timer over those same profiles. See [renewal](RENEWAL.md). + +### Using a separate system Certbot + +If another service already owns a certificate through the system Certbot, keep that lineage +outside RustProxy's managed ACME profiles and create/use a **manual** RustProxy certificate id. +A Certbot deploy hook can publish every successful renewal to all vhosts that already select +that id: + +```bash +/usr/bin/rust-proxy tls deploy --id shared-cert \ + --cert /etc/letsencrypt/live/example/fullchain.pem \ + --key /etc/letsencrypt/live/example/privkey.pem +``` + +The command reads the Certbot files locally and sends them to the authenticated loopback `POST /api/certificates/deploy` endpoint. The running RustProxy service writes the private generation with its own filesystem identity and hot-publishes it for new public TLS/H2/H3 handshakes. No per-vhost edit is needed. If another +local process instead replaces the exact files already referenced by RustProxy, run +`/usr/bin/rust-proxy tls reload` (or authenticated `POST /api/certificates/reload`) afterward. If the replacement belongs to one configured manual certificate ID, automation may instead call `POST /api/certificates/{certificate_id}/reload`; the same endpoint accepts a scoped Certificate API Key ID + Bearer token and does not change the configuration revision. +Administration TLS is startup-fixed and may still require a service restart. + +Optional environment overrides go in a root-owned `/etc/rustproxy/environment` +(mode 0600); create the directory when needed. Restart after changing service +environment values. Provider secrets remain in private profile files, not this +file or the exported configuration. A systemd environment file is not a shell script. + +## Update an installation created by install.sh + +Build and test the new release as a normal user first, then: + +```bash +bash update.sh --plan --binary target/release/rust-proxy +sudo bash update.sh --binary target/release/rust-proxy +``` + +Before stopping the service, the updater fetches the current publisher SHA-256 +files for the `user-country` GeoIP IPv4/IPv6 assets. Matching local files are left +untouched; stale or missing assets are staged, verified and published only after +the private-data backup exists. Use `--skip-geoip` only when intentionally accepting +a stale/missing country database (for example during an upstream outage). + +Use the original `--prefix` for a non-default installation. Other startup/account/ +data settings are read from root-owned `install.json`; update does not silently +replace them with command-line defaults. Change intentional listener settings in +RustProxy, or maintain the unit separately when needed. + +Update mode checks that `/etc/profile.d/rustproxy-path.sh` still matches the saved +installation prefix, but deliberately does not rewrite it. A missing or changed file +produces an explicit warning before service downtime; repair the profile separately +if direct `rust-proxy` shell invocation is required. + +The updater verifies the supplied binary hash and runs only its `--version` as the +service account. Immediately before stopping a RustProxy 0.14.0+ service it creates a +private one-shot managed-update marker. During graceful shutdown the process forces hot +objects into the enabled cold tier even when ordinary `cold_on_shutdown=false`; the marker +is removed before backup. The managed unit allows up to 120 seconds for controlled stop. +If cold storage is disabled, RAM-only cache cannot survive the process exit. The updater +then stages an immutable release, stops the service and backs up the **entire** data directory. This includes SQLite and sidecars, certificates, account +keys, provider credentials, Certbot renewal configs and internal relative symlinks. +Code changes use an atomic `current` symlink switch. It then starts the new process +and requires systemd activity, minimal local `/healthz` readiness, and the actual +MainPID executable matching the staged, locally version-checked binary. The local +health probe follows the persisted administration bind and HTTP/HTTPS mode (including +the configured Host allowlist), rather than assuming the install-time plain-HTTP +address. Version is no longer exposed by the anonymous health endpoint. +The managed systemd unit is regenerated from the saved installation record and +`daemon-reload` is run before startup. The previous unit is included in the backup +and restored on rollback. Keep custom service policy outside this managed unit or +reapply it after an update. + +A failed start/health check restores the previous release, private data **and** +managed systemd unit. The reported error now includes the original update failure; +recent service status/journal output is emitted before rollback when available. +Failed-generation data is retained alongside the original data directory; the +backup is retained under `/opt/rustproxy/backups`. A failed rollback health check is +reported explicitly. Protect these directories: they contain private keys and +credentials. They are never automatically pruned. Plan enough free space for the +full backup, restored data, retained failed state and old/new executable releases. + +During development, if an older saved vhost/settings document is intentionally +incompatible with the current format, use: + +```bash +sudo bash update.sh --reset-config --binary target/release/rust-proxy +``` + +This happens only after the backup is created. It deletes the saved desired +configuration row so the new process creates current listener defaults; administrator +account data, certificates, ACME/provider files and other private state are retained. +Do not use this flag when the old vhost/settings configuration must be preserved. +After a failed update, **do not run `install.sh`**: rollback leaves the previous +installation installed. Fix the reported cause and rerun `update.sh` (optionally +with `--reset-config` for this development-only incompatible-config case). + +An update interrupts traffic; zero-downtime deployment is not implemented. `--no-start` +leaves the service stopped and cannot certify a healthy installation. Backup failure +leaves the old code/data in place and attempts to restart a previously running service. +Keep an independent off-host backup; local rollback is not a disaster-recovery plan. + +## Existing source-tree installations from earlier archives + +Do not point a first installer at your old live data. To keep the current source- +tree deployment, stop RustProxy and use the retained source updater: + +```bash +bash /path/to/new/RustProxy/update.sh --source-tree /path/to/old/RustProxy --stopped +``` + +This mode keeps the old `upgrade.sh` contract and does not install a systemd service +or build a binary. Back up **all** private data and ACME profiles separately before +starting it. See [migration](MIGRATION.md). To adopt the new service layout, make an +explicit offline migration with ownership, absolute Certbot paths and port settings +reviewed; do not merely move a Certbot account tree to another location. + +## Server acceptance + +Check actual non-root HTTP/TLS/QUIC binds, certificate assignment, a real renewal +validation (`Dry run`), backend failures, live traffic, Basic Auth over TLS, ACLs, +WebSocket enable/disable and location isolation. Verify the backup can be restored +on another isolated instance. Run `systemctl status rustproxy` and +`journalctl -u rustproxy` after installation/update. Only then decide on rollout. diff --git a/docs/INTEGRATIONS_040.md b/docs/INTEGRATIONS_040.md new file mode 100644 index 0000000..59dc62c --- /dev/null +++ b/docs/INTEGRATIONS_040.md @@ -0,0 +1,161 @@ +# RustProxy 0.4.0 - forwarding, Tinyauth, listeners and certificate library + +This is source implementation, not a verified binary. Read TEST_REPORT.md before deployment. +The admin panel remains local; public frontend TLS does not make the admin listener remote-safe. + +## Forwarded identity and the trust boundary + +Every origin request now gets canonical `X-Forwarded-For`, `Forwarded`, `X-Real-IP`, +`X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Forwarded-Port` and `X-Forwarded-URI`. +Without configured trusted proxies, incoming proxy headers are discarded. The socket +peer IP is used, so a client cannot supply an arbitrary address in X-Forwarded-For. +Both header formats are emitted; RFC 7239 IPv6 nodes are quoted and bracketed. +There is no ephemeral client port in the representation fingerprint. + +When RustProxy is behind a real reverse proxy, Settings -> Forwarding accepts explicit +IPv4/IPv6 CIDRs (max 32). Do not enter application client networks as trusted proxies. +The last hop must sanitize incoming forwarding headers. `0.0.0.0/0` and `::/0` are rejected. +With a trusted socket peer, X-Forwarded-For is preferred; Forwarded's `for=` chain is the +fallback. The chain is walked right-to-left and anything before the nearest untrusted +hop is discarded. Malformed or oversized chains fall back to the actual peer. +`X-Forwarded-Proto` is honored only with the separate `trust_forwarded_proto` opt-in. +Authority/Host always comes from validated Host or HTTP/2/3 `:authority`, not X-Forwarded-Host. +ACLs and IP affinity use this centrally resolved client address. Listener selection +continues to use the REAL local destination socket and actual TLS state. + +```json +"forwarding": { + "trusted_proxies": ["10.87.7.10/32"], + "trust_forwarded_proto": true +} +``` + +That address is illustrative, not a default. Leave the list empty for direct clients. +RFC 7239: https://www.rfc-editor.org/rfc/rfc7239.html + +## Tinyauth per vhost + +Open Vhosts -> Edit -> Authentication. Enable protection, enter the verification +server and the public login URL, allow a private verifier explicitly if needed, +and save. An origin URL automatically appends `/api/auth/nginx`; an explicit URL +path remains unchanged. No public `/tinyauth` proxy route needs to be created. + +The following matches the supplied nginx-style setup: + +```json +"tinyauth": { + "enabled": true, + "url": "http://10.87.7.99:3000/api/auth/nginx", + "login_url": "https://auth.linuxiarz.pl/login", + "allow_private": true, + "address_allowlist": ["10.87.7.99/32"], + "connect_timeout_seconds": 5, + "timeout_seconds": 10, + "request_headers": ["cookie", "authorization", "x-api-key", "user-agent", "accept"], + "response_headers": ["remote-user", "remote-email"], + "redirect_forbidden": true +} +``` + +Verification is an internal body-free GET, carrying canonical forwarding headers, +X-Original-URL and X-Original-Method. Original method, path and query remain available +to Tinyauth, while the upload body is sent only to the application after authorization. +The RustProxy admin session cookie is never passed to the verifier or application. +Only 2xx approves access. Selected Remote-* / safe X-* response headers become trusted +identity headers. Incoming Remote-* and configured identity headers are removed, +including after administrator request-header rules. Missing identity fields are not +filled from client values. Transport/framing/forwarding fields cannot be selected +as identity headers. Each direction supports at most 32 names; approved identity values are bounded +to 8 KiB in total. + +401 redirects to a validated X-Tinyauth-Location on the configured login origin, +or constructs `login_url?redirect_uri=`. +403 stays 403 unless the provider returns an allowed same-origin redirect and the +403-redirect option is enabled. Cross-origin locations are rejected. Verifier errors +fail closed (502/504), not open. Auth denial/error is not replaced by custom error pages. +Public login URLs require HTTPS; HTTP is allowed only for loopback test servers. +Additional CA PEM can extend trust for Tinyauth HTTPS verification; there is no +verify-off switch for the authentication verifier. The per-backend `tls_verify` option +does not apply to Tinyauth. +Configure Tinyauth itself to trust the ACTUAL RustProxy source IP, not 0.0.0.0/0. + +Authorization runs before origin connection, static pages, WebSocket upgrade to the +origin and every cache lookup. Authenticated vhosts always BYPASS the internal shared +cache and emit `Cache-Control: private, no-store`, even when their cache toggle is on. +This intentionally also disables privacy-sensitive compression through the existing +predicate. Login/session validity is NOT cached. Revocation is checked on each new +HTTP request or WebSocket handshake; an already upgraded socket is not periodically +reauthenticated. There is no automatic replay of POST or failed upgrades. +Do not enable this protection on the Tinyauth login vhost itself (login loop). + +Provider reference: https://tinyauth.app/docs/guides/nginx-proxy-manager/ +Header reference: https://tinyauth.app/docs/reference/headers/ + +## Named listeners + +Settings -> Listeners owns every proxy socket. Each endpoint has a stable ID, display +name, scope label, enabled state and a binding with numeric IP:port, TLS, HTTP/2 and +optional HTTP/3 parameters. Scope labels do not install firewall rules. + +Vhosts reference endpoints only through `frontend.listener_ids`. Inline frontend +listener definitions and the old global listener-pair compatibility fields are not +accepted in 0.8.0. A named endpoint can be shared by multiple vhosts; same-socket +transport settings must agree. Disabled endpoints create no socket and grant no vhost +binding. Unknown/duplicate IDs and admin-port conflicts are rejected. + +Listener transport changes require restart. The panel shows desired and actually bound +endpoints separately. A wildcard bind never widens a vhost's own destination-IP match. + +## Certificate library + +Certificates is an independent sidebar section. Import a PEM certificate/full chain +and PEM private key, give the entry an ID, specify domain names to validate the pair, +and save it to the inventory. In Vhost -> SSL certificates, select the saved entry. +Import does not add public SNI names by itself; active vhost assignment supplies names. +The advanced filename editor remains for CLI-managed existing pairs and direct SNI maps. +The library shows assignment and default status. ACME/Let's Encrypt and expiry renewal +are not implemented; the inventory/API boundary is ready for that future work. + +Uploads: at most 128 KiB per file, key matching/name validation, immutable randomly +named private files, limited parallel imports and mode 600 on Unix. Config stores +filenames and IDs, never PEM private key bytes. File inputs are cleared after import. +Failure between staging and configuration publication may leave unused private files; +they are not automatically activated or deleted. Removing inventory metadata does not +delete key files. Back up the cert directory together with SQLite. +Existing referenced/default entries cannot be removed in the UI without unassigning them. +Certificate changes affect new handshakes; existing sessions continue normally. +The resolver is shared by listeners: one DNS name cannot use different certificates +on different destination IPs. Conflicts are rejected rather than silently chosen. +HTTP-only startup also prepares the private certificate directory, but creates no TLS +socket unless configured. No existing certificates are overwritten. + +## Response policies and cache on extra frontends + +Global and per-vhost `server_header` accept `preserve`, `hide`, `custom` plus `value`. +Omitted/null vhost policy inherits the global choice. Final policy applies to origin +responses, HITs, local pages and errors for inline/named TCP/TLS/H3 listeners. +Generic header rules run before this dedicated Server policy. + +As of 0.18.8, `diagnostic_headers` exposes only the request-ID switch: + +```json +"diagnostic_headers": { + "request_id": false +} +``` + +There is no revision response-header switch or special `X-Rust-Proxy-Revision` handling. If an upstream or ordinary response-header rule supplies that name, it behaves like any other non-protected response header. +Request IDs remain available internally/upstream and in logs. X-Mock-Origin is preserved and can +be removed with ordinary global or per-vhost response-header rules. An explicit vhost diagnostic +object overrides the request-ID choice; null inherits. + +Cache keys now include the actual frontend IP:port and stable canonical forwarding +metadata. Empty HTTP/2/HTTP/3 GET bodies without an exact length are checked by peeking +at most eight frames, skipping empty DATA frames; nonempty data/trailers are +replayed to the origin without buffering the full body. +A stream with data/trailers bypasses cache. Reusing the same socket with a different +client source PORT no longer changes the forwarded representation. +Client IP still affects the conservative all-header fingerprint, so different clients +can have separate cache entries. Auth, cookies, no-store, private, upgrades and partial +bodies still bypass caching. Cookies created by a login or the sticky-session feature +are not a cache bug. Close/consume the entire response before expecting a stored HIT. diff --git a/docs/LIVE_LOGS.md b/docs/LIVE_LOGS.md new file mode 100644 index 0000000..18e2a5a --- /dev/null +++ b/docs/LIVE_LOGS.md @@ -0,0 +1,49 @@ +# Interactive live logs + +RustProxy provides an operator CLI that follows structured request/error events from every HTTP vhost: + +```bash +rust-proxy --livelogs +``` + +The command connects only to the loopback administration listener and creates a bounded local CLI session from the private RustProxy database. When `./data/config.sqlite3` is absent, the bare command discovers common installed layouts: `/var/lib/rustproxy` is preferred on Linux/non-BSD systems and `/var/db/rustproxy` on BSD/OPNsense-style installations, with the alternate convention checked as a fallback. An explicit `--data-dir` always wins and custom installations should pass the same path used by the service. `--admin-listen` can select a non-default loopback port. + + +## Administration HTTP/HTTPS transport + +The CLI first probes the loopback administration `/healthz` endpoint over HTTP. An HTTP-only administration listener stays on HTTP; the `308 Permanent Redirect` emitted by a TLS-enabled administration listener switches the CLI to HTTPS before a CLI session cookie is sent. HTTPS is pinned to leaf certificates present in the private `/certs` directory, so a configured self-signed administration certificate does not require a system trust-store installation. + +This fixes the previous reconnect loop where `--livelogs` treated the TLS listener's expected HTTP-to-HTTPS `308` response as a stream failure. The same local transport selection is used by CLI config and certificate API operations. + +The live path is separate from configured file/syslog sinks. Request processing publishes into a bounded Tokio broadcast channel without waiting for viewers. A slow viewer receives an explicit `gap` event with the number of entries lost from that viewer's live queue. The CLI also consults the retained history after connecting/reconnecting and de-duplicates entries by request id. + +## Interactive commands + +No regex language is required. Type commands while the stream is running: + +```text +host api.example.com +vhost api +backend origin-a +route users +client 192.0.2.10 +method GET +transport h2 +cache MISS +status 5xx +path /api/ +errors on +slow 250 +detail on +filters +show 12345 +pause +resume +reset +clear +quit +``` + +Use `*` to clear a scalar filter, for example `backend *`, and `slow off` / `errors off` for boolean filters. `show ID` prints the complete retained structured event for that request. `detail on` expands the continuously printed view with route/upstream/cache/transform/WebSocket/H3/byte metadata. + +The server revalidates the streaming session every 10 seconds rather than writing session state for every request. Password changes, session revocation or expiry therefore terminate an existing stream within that bounded interval. diff --git a/docs/MANAGEMENT.md b/docs/MANAGEMENT.md new file mode 100644 index 0000000..37f6bc9 --- /dev/null +++ b/docs/MANAGEMENT.md @@ -0,0 +1,134 @@ +# Vhost management (updated for 0.4.0) + +This guide describes implemented source behavior. Read TEST_REPORT.md before +acceptance: the packaging environment did not compile or run the Rust backend. + +## Compact operations + +Vhosts opens a compact list with domains, bound sockets, backend count and live +health/latency. Edit opens a modal with Details, Backends, Health, SSL, Cache, +Security, Pages, Authentication and Advanced tabs. Existing source/ACL JSON remains available. +The structure is inspired by proxy-host managers, not a claim of full feature +parity with nginx-proxy-manager. Density is configured in Settings and stored only +as a browser preference. Logo and favicon have not been redesigned. + +## Optional certificate upload + +In Certificates, upload certificate/full chain PEM and an unencrypted private-key +PEM. The pair is cryptographically validated and written to private immutable files; +the form then saves inventory metadata through revision CAS. No vhost or DNS name +is required just to import a certificate. In a separate vhost save, choose its ID +in SSL; validation checks that it covers the host's configured DNS names. +The file inputs clear after the request. Failed/stale inventory saves can leave +unbound staged files; no key bytes are returned by the API or stored in the config. +Clear/inherit uses the global SNI/default behavior. + +Limits: 128 KiB per file, 300 KiB JSON request, up to 16 supplied DNS names, two concurrent +upload workers, 64 configured certificate inventory entries and 128 staged/upload +pairs on disk. Canceled uploads remain staged; no automatic deletion of potentially +active keys is attempted. New uploads eventually stop at that limit. Perform +manual cleanup only after backup and confirmation that neither the saved +configuration nor the serving snapshot references those files, preferably stopped. +The configured-certificate backup does not collect unused staging files. + +Certificates must cover the configured DNS names. This does not prove browser trust, +chain completeness, revocation status or remaining lifetime. ACME-issued certificates +are shown as managed/read-only inventory entries; they are renewed through the ACME +workflow rather than edited as uploaded custom PEM files. New issuance selects the +production CA environment by default but still requires explicit production confirmation; +staging remains available for testing. A certificate can be deleted only after it has +been unassigned and saved everywhere, including vhosts, redirects, 404 hosts, the +default TLS certificate and the administration panel. Encrypted-key password prompts +are not supported. The single SNI resolver rejects different selected certificates for +one DNS name on different IP bindings. Socket changes still require restart; changing +a certificate for an existing listener uses hot snapshot publication after Save. +Requests already in flight are not interrupted. + +## Cache and response headers + +Cache selectors distinguish inheritance, on and off. Blank numeric fields inherit. +TTL/object limits, query/path exclusions and optional diagnostics are configurable +per host. The memory/entry ceilings are shared globally; one host cannot increase +its object ceiling above the global limit. Explicit per-vhost enable is permitted +when the global cache is explicitly disabled. Cookie-affine pools and path cache=false still +bypass. Purge on a host clears its entries and invalidates its in-progress fills, +without rewriting configuration or evicting other hosts. Changing configuration +still performs a full invalidation. + +Security includes visual rules for headers sent to the backend and to the client, +and per-vhost HSTS/nosniff/frame/referrer/CSP/permissions policies. Advanced JSON +edits update the visual rows. Global, vhost and path header rules run in order; +existing final Server/security/protocol safeguards retain precedence. In +particular, a hidden Server cannot be restored by an ordinary header rule. An +explicit per-vhost Server override can replace the inherited global Server policy. + +## Custom pages + +Global Settings / Pages supplies error defaults, direct-IP and unknown-host +responses. Vhost / Pages supplies an optional static response, a missing-path +fallback and error overrides. Modes are inheritance, built-in congratulations, +empty and administrator HTML (typed or loaded from an HTML file). + +Supported error keys: 400, 401, 403, 404, 408, 413, 429, 500, 502, 503 and 504. +Keep the original status by leaving status unset; an explicit status is allowed +from 200, 204, 400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503 or 504. Only empty +mode permits 204. HTML is at most 32 KiB UTF-8 per page and the complete serialized +configuration remains bounded to 256 KiB. No CSS/image asset hosting or directory +serving is provided; use self-contained HTML or an upstream for multi-file sites. +The built-in congratulations text is English; replace it with your own HTML for +other languages. RustProxy-generated 503 and 504 failures use matching built-in dark +HTML pages when no administrator override is configured; vhost/global custom error +pages still take precedence. An origin's own 503/504 response is not rewritten unless +a custom error override is configured. Admin interface translations still come from +language packs. + +Request behavior: + +1. Validate authority/path and real local binding; check applicable host/path ACLs + and enabled Tinyauth authentication before serving any content. +2. Use the explicit direct-IP page for a literal IPv4/IPv6 authority, if enabled. +3. Otherwise use the matching vhost's static response, if configured. +4. If no path is selected, use that vhost's missing-path fallback; if there is no + matching vhost, use the global unknown-host fallback. Otherwise keep normal 404. +5. Forward normally when a route exists, then replace a configured error with its + vhost override or global default. Apply the normal response/security policy. + +Static/fallback endpoints accept GET/HEAD; other methods get 405 with Allow. +HEAD has the selected response headers and no body. Default status for a static +vhost/direct-IP page is 200; a missing-path/unknown-host fallback retains 404 unless +explicitly changed. Error responses retain their original status by default. +Already-authored pages are not rewritten a second time by error rules. Replacement +drops old encoding, length, cookies and other representation metadata, while +preserving authentication challenges, Retry-After and Allow. Pages use no-store. + +ACL denial always prevents upstream access, even when its 403 page is configured +to return 200. A success status for an authentication/server error can mislead +clients and monitoring. Prefer preserving original statuses. Direct-IP HTTPS +must still finish TLS; this HTTP feature cannot bypass certificate validation. + +HTML is not previewed in the admin DOM. It is trusted administrator content and +may execute scripts when served to visitors. Never import untrusted HTML. + +## Separate live-log window + +Use Live logs in the toolbar or a host's Logs button. Choose modal or docked mode; +filters remain above a scrollable table. Filters combine vhost, method, status +family, cache/error outcome and text (host, path, method, vhost, backend). Pause +freezes the displayed bounded snapshot, not collection. Resume uses the latest +snapshot. Export contains exactly the selected rows. Closing/reopening keeps +filters; signing out clears viewer state. + +The source is a 256-entry in-memory access ring refreshed through SSE/polling. +It is not an unbounded tail, durable file, guaranteed replay or audit store. The +vhost field comes from the actual selected frontend and remains available for +cache/static responses, without fabricating a backend request. Pool health data +are live observations, not settings; disabled checks are not proof of liveness. + +## Additional 0.4.0 controls + +The Authentication tab enables Tinyauth. Security includes inherited/per-vhost +Server and diagnostic header settings. Details selects named listeners shared +with other vhosts; inline listeners are not part of the current configuration model. Certificates are managed in +the separate inventory rather than uploading keys from each vhost editor. +The toast moves into the active dialog/top layer so its message and action remain +visible and interactive above the backdrop. See INTEGRATIONS_040.md for contracts. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 0000000..9aa4637 --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,444 @@ +# 0.7.3 -> 0.8.0 deployment changes + +## 0.15.1 -> 0.16.0 + +There is no SQLite schema migration. Existing vhosts, backends, cache files, administrator accounts and sessions are retained. + +Custom locations are now interpreted only as overrides. An untouched legacy synthetic `root` prefix `/` location with no custom backend/cache/ACL/header/rewrite/local-response behavior is normalized away and replaced by the internal main-vhost fallback. A deliberately customized `/` location remains a real override. No manual edit is required for the normal legacy `root` entry. + +Publishing the administration listener through a vhost remains explicit-risk functionality. For reserved session-cookie passthrough the public request must be HTTPS, the external hostname must be listed in `administration.allowed_hosts`, the backend must directly address the configured administration listener by IP, and that backend must have `accept_local_listener_risk=true`. Ordinary upstreams continue to have `rustproxy_session` removed in both directions. + +Access-log JSON may contain the additional fields `http_version`, `transport`, `websocket_bootstrap`, `websocket_phase`, `websocket_error`, `h3_reset_code` and `upstream_status`. Cache/system metrics add memory-breakdown gauges; existing consumers should ignore unknown fields as usual. + +## 0.15.0 -> 0.15.1 + +There is no schema or configuration-format migration. This patch changes only administration UI language-pack placement, Custom locations editing/help/preview and dashboard visualizations. Existing vhosts, locations, cache settings and SQLite data remain unchanged. + +## 0.14.0 -> 0.15.0 + +There is no new SQLite schema version in 0.15.0. Renewal observations remain in the existing +schema-v3 `certificate_renewals` table. The application no longer contains a runtime JSON importer. + +When `upgrade.sh` / `scripts/upgrade.py` is used while RustProxy is stopped, an obsolete +`/certs/renewals.json` observation sidecar is validated as a regular file and removed as +the final source-upgrade step. It is not imported and no second legacy copy is retained. If the +source-upgrade transaction fails before that final removal, the old sidecar remains untouched. + +If a site replaces source manually instead of using the stopped upgrader, the old sidecar is simply +ignored. Back it up and remove it. An empty renewal-observation table is safe: the ordinary scheduler +reconstructs non-secret status/timestamps on its first 5–10 minute startup check, while certificates, +keys, ACME account material and renewal profiles remain unchanged. + +## 0.13.1 -> 0.13.2 + +Back up `config.sqlite3` and the configured certificate files before updating. On first +0.13.2 open, RustProxy applies the additive SQLite schema-v3 migration and creates +`certificate_renewals`; existing configuration, administrator accounts, sessions, audit +records and revisions are retained. The table stores only non-secret renewal observation +state (timestamps/status/message). If it is empty and the older private +`certs/renewals.json` exists, validated observations are imported once and the sidecar is +removed only after the database write succeeds. That one-time runtime compatibility path existed in +0.13.2/0.14.0 and is removed in 0.15.0; use the 0.15.0 stopped upgrader described above instead. + +The managed `update.sh` path for 0.13.2+ also marks the stop as a software update so an +enabled cold tier receives remaining hot cache objects even when ordinary +`cold_on_shutdown=false`. This is a best-effort persistent handoff bounded by the managed +stop timeout; no cache persistence is possible when cold storage is disabled. + +The raw JSON editor moved to **Settings -> Manual config**. `/configuration` remains a +browser compatibility alias, so old bookmarks do not require migration. + +## 0.8.0 -> 0.8.1 cache behavior + +The source default for `cache.enabled` changes to `true`. Existing saved configurations +that explicitly contain `enabled: false` remain disabled; the new default applies to new +or missing cache settings and is not a silent rewrite of an administrator choice. +`cache.max_object_bytes` may now be configured up to 1 GiB when `cache.max_bytes` is at +least as large. Browser conditional requests/no-cache refreshes now use conditional HIT +or backend revalidation instead of blanket BYPASS; `no-store` remains a hard exclusion. + + +- Existing vhosts deserialize new traffic-policy fields with conservative/default-off + behavior except `http2_enabled` and `http3_enabled`, which default true to preserve prior protocol behavior. +- The pre-production inline/global listener format is intentionally no longer readable. + Recreate development listener configuration under `listeners.endpoints` and bind each + vhost with `frontend.listener_ids`. No automatic vhost migration is performed. +- Existing backend TLS settings are preserved. Only newly created panel backend rows use + the 0.13.0 default `tls_verify:false`; enable verification explicitly for origins that require certificate identity checks. +- First installs fetch verified GeoIP country files. Existing installations upgraded with + `update.sh` keep their data directory; install the two verified files under `data/geoip` + before enabling country rules if the installation predates 0.8.0. + + +No database schema migration is introduced. The release changes deployment defaults +and control-plane startup behavior: + +- `admin setup-token` can create the one-time setup token before first server start. +- New managed systemd installations default to `0.0.0.0:80` and `0.0.0.0:443`. + Existing installations keep the saved service unit during `update.sh`; update does + not silently add port 443. +- Wildcard administration is opt-in only. Direct server startup requires + `--admin-listen 0.0.0.0:9090 serve --allow-remote-admin`. + The installer uses the equivalent `--admin` and `--allow-remote-admin` options. + This is plain HTTP; keep it behind a firewall/private network or use a tunnel. + +Back up private state before updating and run the real Rust check/test suite on the +target build host. + +--- + +# 0.7.1 -> 0.7.2 build fix + +This is a distinct source release. It removes the absent-SWAGGER-environment panic; +it does not migrate the database, alter vhosts or reset any account/certificate. +Back up private data as usual and update the complete source tree, not just build.rs. +The patch changes Cargo dependencies and adds a required build helper, so copying +only the Rust file would leave an inconsistent package. + +The existing `dev.sh` Python 3.10+ requirement now also applies to direct Cargo +builds. No `.cargo/config.toml` or manual `SWAGGER_UI_DOWNLOAD_URL` export is required. +The default pinned tag is fetched on the first build; a reviewed local tag archive +remains supported for restricted build hosts. See [API_ACCESS.md](API_ACCESS.md). +No default to an older Swagger distribution is introduced. + +Use the existing stopped-source `update.sh --source-tree` workflow or build a new +binary and use the managed-service updater as documented in [INSTALL.md](INSTALL.md). +`dev.sh` backs up an older Cargo.lock before regenerating it; do not fake the new +lockfile by replacing its root version text. A valid new lockfile is not included. +Rebuild/restart after successful tests; the old process cannot load embedded changes. +There is no need to clear application data or certificates, or to run `cargo clean` +just to remove this panic. Test the real admin session and `/docs` after building. + +--- + +# 0.7.0 -> 0.7.1 access changes + +No database/configuration schema migration is introduced by this patch. Back up +existing data/certificates and use the existing install/update instructions. +The reported release is source-only; compile and test before installing a binary. + +Operational JS and full translation packs are now session-protected. Old tabs +should be reloaded after upgrading. Automation reading `/api/auth/status.version` +or `/healthz.version` must use authenticated `/api/status` instead. Dedicated +Prometheus scraping retains its own CIDR/token policy and remains independent of +browser sessions. No reverse-proxy vhost auth policy is relaxed by this patch. + +In 0.7.1 the Swagger pin depended on `.cargo/config.toml`. That requirement is +removed in 0.7.2 (see the build notes above). Source-tree updates still back up and +replace that managed directory; retain any unrelated local Cargo settings from +your private backup. Read [API_ACCESS.md](API_ACCESS.md) for current build inputs. + +Run `python3 scripts/admin_audit.py --url http://127.0.0.1:9090` after deployment. +It prints only status checks, no response bodies or credentials. + +--- + +# Updating, backup and recovery + +## 0.7.0: renewal and service deployment + +See [INSTALL.md](INSTALL.md) for separate installer/updater commands and complete +code-plus-data rollback. Older source deployments retain `update.sh --source-tree`. +Back up the entire private state, including Certbot profiles, before any update. +Do not overlay a new binary onto a live, untested database without a recovery plan. + +Managed 0.6 certificate issuer metadata defaults to automatic renewal in 0.7. +The scheduler starts after a 5-10 minute delay and reuses existing consented +profiles. Imported PEM without issuer metadata is not scheduled. Review auto-renew +flags, service PATH, DNS permissions and HTTP-01 reachability before starting. +The new process does not implicitly switch staging certificates to production. + +Missing WebSocket flags preserve existing enabled behavior. New UI-authored vhosts +default to disabled. Existing backend members remain in the normal pool; new +dedicated location origins default to `pool_eligible:false`. Basic Auth is opt-in, +requires actual TLS by default and combines with (rather than bypasses) IP ACLs. + +Normal dev/server startup no longer starts the demonstration origin. Existing +sample vhosts are not silently deleted: remove or replace them deliberately, or +explicitly enable the protocol fixture in an isolated old test installation. New +databases show a neutral informational landing page with no sample backend. + +New fields are not a promise of backward parser compatibility. Downgrade by +restoring both old code and the pre-upgrade private-data snapshot. No verified Rust +build is supplied; run check/test and server acceptance before rollout. + +## 0.6.0: historical vhost migration note + +0.6.0 originally shipped a one-way importer from the pre-vhost development format. +That importer is removed in 0.8.0. Current builds accept only `vhosts`, named +`listeners.endpoints` and `frontend.listener_ids`. Development databases containing +top-level route/backend documents or inline frontend listeners must be recreated or +manually rewritten before starting 0.8.0. No automatic compatibility conversion runs. + +The dedicated Prometheus listener remains opt-in/disabled by default. Settings -> +Prometheus controls it. A restart is required for enable/bind/token-variable changes. +Certificate provider metadata remains additive and secrets remain outside exported +configuration. + +## 0.5.0 behavior changes + +Back up the database and configured PEM pairs before upgrading. The managed +updater replaces the full source tree, not live data. Build and test on staging. +No verified binary or fabricated Cargo.lock is supplied. + +TLS bindings with omitted `http3` now enable UDP with defaults; older versions +omitted disabled options when serializing, so review ALL TLS bindings. To retain +TCP-only operation, explicitly save `http3: null` on the named TLS listener before +restarting the new process. Shared/wildcard +IP-port bindings must agree on all protocol flags and H3 limits. A UDP bind failure +is not silently ignored. HTTP/1.1 and optional HTTP/2 remain on TCP. + +`diagnostic_headers.hide_mock_origin` was removed and is no longer accepted or +exported; use explicit response-header rules if needed. X-Mock-Origin is a normal upstream field; remove it with ordinary +response-header rules when desired. Request ID remains off by default. The removed 0.17 `diagnostic_headers.revision` field is no longer part of the configuration schema. Custom Server values +are NOT automatically rewritten to the new binary version. + +Cache contents are process-local. Reserved admin-cookie removal now occurs before +cache eligibility; application cookies still bypass. Domain counters reset on +configuration publication. See CACHE_DIAGNOSTICS.md for explaining BYPASS. + +ACME/RFC2136 is optional and adds external Python/Certbot/plugin requirements. +No DNS update or CA request happens simply by upgrading. Issuance is explicit, +staging-first and does not activate a certificate without import. Existing +`dev.sh backup` does not include the private `certs/acme` account/TSIG profiles; +back those up separately when issuance is idle. No automatic renewal is included. +See ACME_RFC2136.md for limitations and restoration responsibilities. + + +## Reported auth module compilation failure + +0.0.1 used `src/auth.rs`. 0.0.2 moved the implementation into `src/auth/mod.rs` and +`src/auth/store.rs`. Overlay extraction can retain both layouts and cause Rust +E0761. Missing `Store::session_at`, `admin_record`, `end_session` and +`revoke_all_sessions` messages can follow because the implementation module did not +load. The 0.0.2 archive itself did not contain both auth layouts. + +0.4.0 retains an explicit `#[path = "auth/mod.rs"]` declaration, including for direct +Cargo invocations. Preflight also detects mixed source trees. It recognizes the +exact original auth file by SHA-256 and moves it to +`.upgrade-backup/legacy-auth/auth.rs.0.0.1`. Unknown custom source is left untouched +and the development script stops with instructions. Never delete custom files just +to silence a compiler error. + +```bash +bash dev.sh doctor +bash dev.sh repair +``` + +These commands do not require Rust. Repair is deliberately limited; a safe whole +source-tree update is preferable to extracting individual files over a checkout. + +## Recommended source update + +Extract the new archive beside, not inside, the installed project. First back up +the existing database using the new backup script. The destination must not exist: + +```bash +unzip RustProxy-0.4.0.zip -d /home/mg/rustproxy-040-staging +cd /home/mg/rustproxy-040-staging/RustProxy +python3 scripts/backup.py --data-dir /home/mg/RustProxy/data \ + /home/mg/RustProxy-backup-before-0.4.0 +``` + +A missing database means there is no database to back up. Do not initialize an empty +replacement accidentally. For data stored elsewhere, use the actual directory. + +Stop RustProxy using its terminal or service manager. Then: + +```bash +bash upgrade.sh /home/mg/RustProxy --stopped +# External data directory: also pass --data-dir /actual/private/data +cd /home/mg/RustProxy +bash dev.sh test +bash dev.sh +``` + +The updater checks a process lock on POSIX, requires the explicit `--stopped` +acknowledgement and rejects nested source/target paths and managed symlinks. +It preserves `.env`, `data`, `.git`, `target` and unmanaged files. Source, scripts, +tests, examples and documentation are replaced as whole managed paths. Old managed +files remain in `.upgrade-backup/before-0.4.0-`. An interrupted copy +exception triggers source rollback. Abrupt power loss is not a transactional +filesystem update; inspect the backup and source tree before restarting. + +The updater does not evaluate the old `.env` or discover arbitrary external data +paths. Pass `--data-dir` when necessary, and stop every process yourself before +changing source. It does not perform a database migration or backup automatically. +Custom source edits are backed up, not merged. + +## Database compatibility + +RustProxy 0.13.2 advances the SQLite schema from version 2 to **version 3** with one +additive `certificate_renewals` table for non-secret ACME observation state. The existing +0.0.1-to-0.0.2 auth migration still runs when needed. Configuration-document additions +remain defaulted/non-destructive. Existing accounts, policies, revisions and configuration +remain intact. Starting with 0.15.0 the application reads renewal observations only from this table; +historical JSON observation state is handled, when present, by the stopped source-upgrade remove +described above rather than by the running service. + +The generated default certificate is created on first TLS-enabled startup, in +`/certs`. Its key is never overwritten by startup. A partial or invalid +pair causes a clear startup error; it is not silently discarded. Demo settings +never overwrite an existing configuration, including an empty existing one. + +## Backup contents and restore + +`./dev.sh backup NEW_DIRECTORY` uses SQLite's online backup API, so committed WAL +content is included. The result contains a private `config.sqlite3`, `backup.json` +with hashes/revision metadata and any configured/default certificate files under +`certs/`. Unreferenced staged certificate files are not included. Do not rotate or +manually replace certificate files while taking a backup. This is a consistent +SQLite snapshot plus file copies, not one cross-filesystem transaction. + +Backups contain credential hashes, session records and private keys. Keep them +private, encrypted at rest as appropriate, and outside public web directories. +They do not contain `.env`, source edits or a generated dependency lockfile; preserve +those separately. Existing backup destinations and certificate symlinks are refused. + +To restore, stop the server, verify backup hashes and keep the current data directory +as a separate recovery copy. Restore into a **new private directory**, including +`config.sqlite3` and `certs/` together, then point `RUST_PROXY_DATA_DIR` to it. +Use directory mode 700 and file mode 600. Do not copy stale `-wal` or `-shm` files +from another database beside the restored snapshot. Start the matching version and +run `./dev.sh admin logout-all` or reset the password to revoke restored sessions. +Verify routes, TLS identities and administration before resuming traffic. + +## Downgrade + +Older versions reject unknown configuration fields such as `tls`, `protocol` or +`tls_ca_pem`. Restoring old source alone is therefore not a safe downgrade after +saving new configuration. Restore the matching pre-upgrade database and environment +from a verified backup, with the server stopped. Do not edit password hashes or +schema versions manually. + + +## 0.0.6 -> 0.1.0 document and source changes + +No new SQL schema version is introduced. Old documents default to inherited +listeners, disabled cache/compression and preserve-Server behavior. New optional +route cache policy does not change existing route behavior. Existing account, +revision, TLS and ACL state is retained. + +Managed paths now include build.rs, langs, deploy and make_zip.py. Extra *.json +language packs from the installed langs directory are copied into the new tree +when the new distribution does not contain the same filename. Shipped packs are +updated, with old bytes preserved in the source backup. Invalid custom packs can +correctly block a build; review them with scripts/langs_check.py. No symlinked +language pack is followed. The updater never copies a database from the package. + +The old lockfile is preserved in the managed source backup and a new dependency +graph is generated by Cargo on first build. No 0.1.0 resolved Cargo.lock is included +because Cargo was unavailable here. Keep the actual newly resolved file afterward. + +Saving any 0.1.0 document serializes its new fields. Older binaries use strict +unknown-field rejection, so rolling back only the source/binary may fail startup. +Restore the pre-upgrade database/certificates into a new private directory and keep +the newer data separately. Do not directly edit live SQLite to remove fields. + +A saved invalid listener address or missing low-port permission can prevent +startup. The online config CLI cannot repair an offline server. Keep a pre-change +backup and use the documented stopped-server restore path; remove persisted +listener overrides through Settings while the service is still running before +changing deployment assumptions. The cache itself is RAM only and is not backed up. + +## 0.1.0 -> 0.2.0: compression, vhosts and pool behavior + +The `SizeAbove` u16/u64 mismatch is fixed by replacing that predicate with a custom +u64 implementation. Do not use `try_into().unwrap()` or truncate the setting to +work around the old source: configured thresholds above 65535 must remain valid. +This source change removes that call, but is not a successful compiler result; +run `bash dev.sh check` and `bash dev.sh test` on a Rust-enabled staging machine. + +SQL schema version stays 2; accounts, ACLs, certificates and revisions are retained. +New `vhosts` defaults empty; named TLS listeners default to H2/H3 capability when those fields are omitted. Legacy routes +continue on the global listeners and are never silently migrated or exposed on +additional IPs. The editor's explicit migration action converts one unambiguous +route group in a single revision and keeps the migrated route group's existing health behavior. + +New vhosts default to TCP health checks and remain unavailable until +`rise` checks succeed. Plan the correct path/status/timeouts before migration. +Health/counter/affinity observations are ephemeral. They survive compatible edits, +not restarts; new/changed/re-enabled origins require new checks. Cache and idle +upstream pools are not carried across config publication. Admitted exchanges finish +with their old snapshot and are not replayed on a different backend. + +HTTPS origins previously configured `http2` now retain HTTP/1.1 ALPN compatibility. +Plaintext `http2` is still strict h2c. Per-listener IP/port/TLS/HTTP2 changes require +restart. During an address move, hot routing can deny the previous destination +before the new socket is bound. Plan a maintenance window, verify assigned IPs, +privileges/certificates and retain an offline-restorable backup. + +After saving any 0.2.0 document, 0.1.0 can reject new fields even without a vhost, +because `https_http2` is serialized. Downgrade requires matching pre-0.2.0 data, +source and environment. Never remove fields by editing a live SQLite database. +The archive contains no fabricated lockfile, executable, database or private key. +Generate and retain the real dependency graph before acceptance/deployment. + +## 0.2.0 -> 0.3.0 + +The reported `json_internal!` recursion error is addressed by splitting +`status_payload` into small JSON values and inserting feature flags through a map. +Do not overlay only this function: this release also adds the `query` Axum feature, +`src/pages.rs`, `management.js`, new configuration fields and matching UI assets. +The earlier u64 compression predicate remains in place. Compilation has not been +executed in the packaging environment; this is not a successful-build assertion. + +Existing database documents deserialize with empty/default `pages`, vhost `cache` +and `certificate_id` fields. No SQL schema migration is added. Per-vhost certificate +selection rewrites the effective SNI map in a snapshot, not the files. Upload is a +staging action: Save must publish a validated configuration before traffic uses it. +Canceling the editor leaves an unbound pair, not an active certificate change. + +Use the complete backup/whole-source update procedure above. The existing backup +copies configured keys and fallback files. Unbound upload staging files are not in +the configuration and therefore are not part of that configured-certificate backup. +Retain them separately only when needed. Never delete a filename still referenced +by the configuration or currently serving snapshot; stop the process before manual +cleanup and preserve a backup of the full private certificate directory. + +New defaults do not activate custom pages or vhost cache overrides. An explicitly +disabled global cache can still be overridden by one vhost; the global byte/entry/object +limits still bound it. Current releases rotate cache compatibility only for configuration +changes that can alter cached/origin semantics; unrelated control-plane publication preserves +reusable objects. The dedicated per-vhost Purge action remains selective. + +Downgrade requires restoring the earlier database/configuration together with its +matching source: 0.2.0 rejects the new fields. Do not merely relabel a new document. +The source archive contains no fabricated lockfile. Resolve and retain Cargo.lock +on the first compiler-enabled build and run all tests before restarting production. + +## 0.3.0 -> 0.4.0 + +Use the full-source update above, not an overlay of a few Rust files. This revision +adds Quinn/h3 dependencies, new modules and a new embedded integrations.js asset. +No successful compiler run or dependency resolution is claimed. Generate and retain +a real Cargo.lock, then run check and all tests before acceptance. + +SQL schema stays 2. Proxy sockets are persisted only in `listeners.endpoints`; a new +database is seeded from startup CLI listener addresses. Existing development documents +using the removed listener format are rejected rather than migrated. Tinyauth and +vhost Server policy continue to use their normal defaults. New diagnostic defaults remove x-request-id from client responses unless it is enabled explicitly where needed. Access logs retain request IDs. The old revision diagnostic field/header behavior was removed in 0.18.2. Forwarding headers are +rebuilt from actual peers unless known proxy CIDRs are explicitly configured. + +Reference named listener IDs per vhost instead of duplicating bindings. There is no +inline/global compatibility conversion path in 0.8.0. Scope names have no firewall meaning. Changed sockets, ALPN or H3 limits +require restart; opening UDP is necessary for H3. Saving intent does not prove +that an OS bind or UDP firewall rule will succeed. + +The certificate directory/fallback is prepared even with HTTP-only startup, so +library uploads can be staged before enabling TLS. The generated fallback is +self-signed development material and does not automatically open an HTTPS socket. +Import the PEM pair in Certificates, save inventory, then select its ID in SSL. +Unbound staged files are not automatically backed up; preserve private files needed +for rollback and never remove any currently referenced certificate/key. + +Tinyauth-protected vhosts bypass shared cache regardless of cache toggles. Failures +deny access instead of serving cached/static content. Configure its verifier URL, +public login URL and explicit private-origin permission before enabling it on an +existing service. Test login, refusal, logout/revocation and WebSockets on staging. + +The examples are complete replacement documents for disposable test databases, +not merge patches. Saving a 0.4 document serializes new fields; older binaries may +reject them. Downgrade requires matching pre-upgrade database, certificates, +environment and source, with the process stopped. The source backup alone is not +a database/configuration rollback. diff --git a/docs/MOCK_AUTH.md b/docs/MOCK_AUTH.md new file mode 100644 index 0000000..889ea96 --- /dev/null +++ b/docs/MOCK_AUTH.md @@ -0,0 +1,37 @@ +# Fake Tinyauth protocol peer - local development only + +This is NOT authentication. Any client can mint the allow cookie. The fixture binds +only to 127.0.0.1 and must never protect a production application. + +```bash +bash dev.sh mock --port 9001 --name origin-a --ttl 30 +# Separate terminal: +bash dev.sh mock-auth --port 9300 +``` + +In a disposable vhost's Authentication tab, set: +- verifier: http://127.0.0.1:9300 (adds /api/auth/nginx) +- login: http://127.0.0.1:9300/login +- explicitly allow private verifier IPs +- forwarded identities: remote-user and remote-email + +Use your actual frontend address/hostname. Example for a local plain-HTTP vhost: + +```bash +# Denied -> 302 to configured login, no application request: +curl -i -H 'Host: app.localhost' http://127.0.0.2:8181/inspect +# Allowed -> app receives mock-user / mock@example.test; cache remains BYPASS: +curl -i -H 'Host: app.localhost' -H 'Cookie: mock_auth=allow' http://127.0.0.2:8181/inspect +# Force authentication failure even with an allow cookie: +curl -X POST -H 'Content-Type: application/json' -d '{"status":503}' http://127.0.0.1:9300/control +# Simulate slow verifier; choose less than or greater than the vhost total timeout: +curl -X POST -H 'Content-Type: application/json' -d '{"delay_ms":12000}' http://127.0.0.1:9300/control +# Restore default deny/allow behavior: +curl -X POST -H 'Content-Type: application/json' -d '{"reset":true}' http://127.0.0.1:9300/control +curl http://127.0.0.1:9300/state +``` + +/control replaces status/delay/redirect behavior (omitted values reset to defaults). +Optional location sets X-Tinyauth-Location for redirect-policy tests. /state exposes +only the last 16 metadata records, not Authorization, cookies or query strings. +Real Rust-router auth tests are in tests/integrations.rs. diff --git a/docs/MOCK_SERVER.md b/docs/MOCK_SERVER.md new file mode 100644 index 0000000..af064fe --- /dev/null +++ b/docs/MOCK_SERVER.md @@ -0,0 +1,116 @@ +# Python-only mock origin + +Run independently of Cargo/Rust, in a separate terminal: + +```bash +bash dev.sh mock --port 9001 --name origin-a --ttl 30 +``` + +This runs until Ctrl+C. A second origin for balancing uses another terminal: + +```bash +bash dev.sh mock --port 9002 --name origin-b --ttl 30 +``` + +The helper binds 127.0.0.1 by default. It accepts IPv4 addresses only and refuses +non-loopback bindings without --allow-remote. Do not expose it publicly: +/control deliberately has no authentication. It has 32 connection workers, +15-second socket timeout, a 2 MiB maximum response and a 10-second delay ceiling. +It is a development fixture, not a secure production website. + +## Configure a test vhost + +After building and starting RustProxy, create a vhost with domain mock.localhost, +frontend 127.0.0.1:8080, backend http://127.0.0.1:9001, private backend permission, +HTTP health path /health, and Cache enabled with X-Cache diagnostics. Leave sticky +cookies off to test shared cache. Wait for its backend to become healthy. + +`examples/mock-vhost.json` is a complete ready-to-apply document for a disposable +installation. It explicitly enables the per-vhost cache (the 0.8.1 global default is +also enabled), adds a custom 503 page, and serves the congratulations page on direct-IP access. Applying +it replaces all existing routes/vhosts; it is not a merge patch. Review first: + +```bash +bash dev.sh config validate examples/mock-vhost.json +bash dev.sh config apply examples/mock-vhost.json +``` + +Do not replace your real configuration for this demonstration. Use the panel to +add equivalent settings to an existing project instead. + +## Verify cache, not just the origin + +Requests must go through the proxy with the right Host: + +```bash +curl -si -H 'Host: mock.localhost' http://127.0.0.1:8080/cache +curl -si -H 'Host: mock.localhost' http://127.0.0.1:8080/cache +``` + +After the first complete response, expect MISS then HIT with an identical nonce +while fresh. Origin JSON always generates a new nonce and timestamp; a cache hit +therefore makes reuse visible. Health probes can advance the sequence too, so +compare nonce/body, not an assumption that sequence increases by exactly one. +GET is the clearest fill test because it consumes the complete body. HEAD is also +cacheable in 0.11.5 and shares the GET representation. Only response `Vary` fields +create request-header representation variants; unrelated request headers and the +selected backend-pool member no longer fragment the same Host + URL object. + +Use the host's Purge action and request again to expect a new MISS. Waiting past +TTL also refreshes. To compare with a non-cacheable route: + +```bash +curl -si -H 'Host: mock.localhost' http://127.0.0.1:8080/nocache +curl -si -H 'Host: mock.localhost' http://127.0.0.1:8080/nocache +``` + +Bodies should change and must not become HIT. A diagnostic MISS means lookup was +attempted, not that the response was stored: no-store/private/Set-Cookie may reject +storage after origin headers arrive. BYPASS indicates a request-side exclusion. + +## Endpoints + +| Path | Behavior | +|---|---| +| / | Small HTML page with links | +| /cache?ttl=30 | Random JSON; public max-age; Vary: Accept-Language | +| /nocache | Random JSON; no-store | +| /private | Private JSON; cannot enter shared cache | +| /cookie | Sets a cookie; must not enter shared cache | +| /large?size=131072 | Compressible bytes; public freshness; useful above 65535 bytes | +| /slow?ms=800 | Delayed no-store response | +| /health | 200/503 according to controlled health; delay is also applied | +| /state | Current health, delay, sequence and origin ID | +| /status/401, /status/403, /status/404, /status/503 | Origin error responses for page tests; 401 includes a challenge | +| POST /control | JSON with healthy:boolean and/or delay_ms:0..10000 | + +Compression needs enabled proxy compression and an appropriate Accept-Encoding. +The origin itself does not compress. Inspect both response headers and decoded body; +not all curl builds support Brotli or Zstandard. Example: + +```bash +curl -si --compressed -H 'Host: mock.localhost' \ + 'http://127.0.0.1:8080/large?size=131072' +``` + +## Failover, latency and recovery + +Control the origin directly, not through the proxy, especially after it is marked +down. Configure the health path /health (the default root page always returns 200). + +```bash +curl -s -H 'Content-Type: application/json' -d '{"healthy":false}' http://127.0.0.1:9001/control +curl -s -H 'Content-Type: application/json' -d '{"healthy":true,"delay_ms":1200}' http://127.0.0.1:9001/control +curl -s -H 'Content-Type: application/json' -d '{"healthy":true,"delay_ms":0}' http://127.0.0.1:9001/control +``` + +Watch health/RTT in the vhost list/editor and dashboard. Fall/rise thresholds and +probe interval delay transitions intentionally. Add origin-b as a second backend +for failover and balancing tests. Random origin data alone does not prove that the +Rust scheduler, compression or cache is correct; run the Rust acceptance tests. + +## Verification scope + +The mock server is a development fixture, not a proxy test. Exercise its endpoints +directly with curl when needed, then run the RustProxy suite separately with +`bash dev.sh test` on a host that has Cargo. diff --git a/docs/NOT_FOUND_HOSTS.md b/docs/NOT_FOUND_HOSTS.md new file mode 100644 index 0000000..09f4c11 --- /dev/null +++ b/docs/NOT_FOUND_HOSTS.md @@ -0,0 +1,24 @@ +# Local-response / 404 hosts + +RustProxy 0.22.3 has a dedicated **404 hosts** page for domains that should be +owned by RustProxy but must not contact a backend. + +Each entry has the same frontend model as vhosts/redirects: + +- one or more hostnames, +- named listener selection, +- optional TLS certificate, +- independent HTTP/2 and HTTP/3 switches. + +The response can be: + +- the built-in `RustProxy is running` placeholder, +- an application-style built-in status page for 401, 403, 404, 429, 503 or 504, +- an empty/status-only response, +- bounded custom HTML. + +The status is selected rather than typed manually. Application-style pages expose only the bundled 401/403/404/429/503/504 templates. Placeholder, empty and custom-HTML modes expose the supported application error set: 400/401/403/404/408/413/429/500/502/503/504. HTTP/2 and HTTP/3 toggles are shown beside the listener controls because they apply to the selected frontend listeners. Host/listener ownership is +validated against vhosts and redirect hosts, so two active configuration objects +cannot claim the same frontend binding. + +All 404-host responses remain local and never open an upstream connection. They are not stored in RustProxy cache. Before the response leaves RustProxy, cache policy/validator headers and RustProxy `X-Cache*` diagnostics are removed, so 404 hosts do not advertise cache behavior on the wire. The built-in `RustProxy is running` placeholder also intentionally omits optional RustProxy server, diagnostic, security, compression and backend headers. diff --git a/docs/NPM_MIGRATION.md b/docs/NPM_MIGRATION.md new file mode 100644 index 0000000..4b9788e --- /dev/null +++ b/docs/NPM_MIGRATION.md @@ -0,0 +1,115 @@ +# Nginx Proxy Manager migration + +RustProxy ships `scripts/migrate_npm.py` for a one-time, dry-run-first migration of +Nginx Proxy Manager (NPM) proxy hosts from its SQLite database. + +The tool reads NPM only. It does not modify the NPM database and it never imports +Let's Encrypt/private-key material. It logs in to the RustProxy administration API, +reads the current revision, merges generated vhosts into the existing configuration, +and writes only when `--apply` is supplied. + +## What is migrated + +For each NPM `proxy_host` row: + +- `domain_names` -> RustProxy vhost hostnames, including leading `*.` wildcards; +- `forward_scheme`, `forward_host`, `forward_port` -> one RustProxy backend; +- `allow_websocket_upgrade` -> vhost WebSocket policy; +- `enabled` -> vhost enabled state; +- `http2_support` -> vhost HTTP/2 policy; +- NPM hosts with a certificate/forced SSL -> RustProxy TLS listener. Certificate + files are not copied: the application-managed RustProxy default certificate is + used unless `--certificate-id` names an existing RustProxy inventory certificate; +- `ssl_forced` -> RustProxy `force_ssl=true`. When HTTP and HTTPS listeners exist, + both are attached so cleartext requests can receive RustProxy's `308` redirect to + HTTPS. If no TLS listener exists, an SSL-forced NPM host is skipped instead of being + imported with weaker semantics. + +Migrated backends use `allow_private=true` because NPM commonly proxies to LAN or +container origins. The migration tool follows RustProxy 0.22.1 and imports backend `tls_verify=false` unless you later enable verification explicitly. +Use `--npm-compatible-backend-tls` when an NPM HTTPS origin depends on +the common Nginx behavior of not verifying the upstream certificate; review that +exception after migration. + +The following are reported for manual review rather than guessed: NPM access lists, +advanced Nginx snippets, custom locations, NPM cache settings and `block_exploits`. +Existing RustProxy hostname ownership is never silently replaced; conflicting NPM +rows are skipped and shown in the report. + +## SQLite location + +The standard NPM SQLite path inside the container is `/data/database.sqlite`. On a +Docker host with a bind mount, pass the corresponding host path. For a named volume, +`docker volume inspect` shows its mountpoint. + +Read the live mounted database rather than copying only `database.sqlite` from a +running WAL-mode instance; copying without its WAL file can omit recent changes. + +## Dry run + +Set the password in the environment or omit it and use the interactive prompt: + +```bash +export RUSTPROXY_USERNAME=admin +export RUSTPROXY_PASSWORD='...' + +python3 scripts/migrate_npm.py \ + --database /path/to/npm/data/database.sqlite \ + --rustproxy https://rustproxy.example.net:9090 +``` + +The default run is read-only. It creates `npm-migration-plan.json` with mode `0600` +and prints every imported/skipped NPM row plus manual-review warnings. + +If the administration panel intentionally uses the application self-signed +certificate and the script connects directly to it, add: + +```bash +--insecure-admin-tls +``` + +Prefer `--ca-file /path/to/ca.pem` or a hostname with a trusted certificate where +available. + +By default the first enabled HTTP listener and first enabled TLS listener are used. +For `ssl_forced` hosts both are selected when available. `--force-https` applies the +same Force SSL policy to every imported host. Select explicit listener IDs when needed: + +```bash +--http-listener public-http --https-listener public-https +``` + +## Apply + +After reviewing `npm-migration-plan.json`: + +```bash +python3 scripts/migrate_npm.py \ + --database /path/to/npm/data/database.sqlite \ + --rustproxy https://rustproxy.example.net:9090 \ + --apply +``` + +The write uses the revision returned by `GET /api/config`; a concurrent RustProxy +configuration change causes the API compare-and-swap to fail rather than overwrite a +newer revision. + +## Running from a helper container + +If the Docker host does not have Python 3, mount the NPM data read-only and mount the +RustProxy source/script into a temporary Python container. For example, from the +RustProxy directory: + +```bash +docker run --rm -it \ + --network host \ + -v /path/to/npm/data:/npm:ro \ + -v "$PWD/scripts/migrate_npm.py:/migrate_npm.py:ro" \ + python:3.13-alpine \ + python /migrate_npm.py \ + --database /npm/database.sqlite \ + --rustproxy https://127.0.0.1:9090 \ + --insecure-admin-tls +``` + +Use `--apply` only after the dry-run plan is reviewed. diff --git a/docs/PROMETHEUS.md b/docs/PROMETHEUS.md new file mode 100644 index 0000000..e58bb3f --- /dev/null +++ b/docs/PROMETHEUS.md @@ -0,0 +1,147 @@ +# Dedicated Prometheus endpoint (0.22.3) + +RustProxy exposes Prometheus text exposition 0.0.4 on a dedicated read-only +listener. It is independent from administrator browser sessions and does not +export request paths, query strings, cookies, credentials, backend URLs or ACME +account secrets. + +## Configuration + +```json +{ + "prometheus": { + "enabled": false, + "listen": "127.0.0.1:9191", + "path": "/metrics", + "allowed_cidrs": ["127.0.0.0/8", "::1/128"], + "bearer_token_env": null, + "allow_trusted_network_without_token": false, + "include_system": true, + "include_vhosts": true, + "include_backends": true, + "include_docker_vhosts": true, + "include_cache_domains": true, + "include_streams": true, + "include_logging": true + } +} +``` + +The listener is plain HTTP. Keep it on loopback, a private monitoring network or +behind a protected tunnel. `allowed_cidrs` is always evaluated against the actual +transport peer (`PeerAddr`); `Forwarded` and `X-Forwarded-For` never grant metrics +access. + +### Bearer token + +Set `bearer_token_env` to an environment-variable name such as +`RUST_PROXY_METRICS_TOKEN`. RustProxy stores only the variable name. The value must +contain 32..4096 printable non-whitespace ASCII characters and must be present at +startup. + +### Trusted network without a token + +`allow_trusted_network_without_token=true` explicitly allows a peer already matched +by `allowed_cidrs` to scrape without a bearer token. This can be used for a private +Prometheus VLAN. An explicit IPv4/IPv6 `/0` is rejected in this mode. Keep the CIDR +list narrow; enabling this option is an authentication decision, not a forwarded-IP +feature. + +A non-loopback exporter requires either a bearer-token variable or this explicit +trusted-network bypass. Changing enabled/listen/token-variable requires restart; +path, CIDRs, collector switches and the trusted-network bypass apply live. + +Only `GET` on the exact configured path without a query string is accepted. A +wrong path is `404`, wrong method `405`, disallowed peer `403`, invalid/missing +required bearer `401`, and exporter concurrency exhaustion `429`. + +## Coverage audit in 0.19.1 + +The exporter represents all scalar fields currently sampled by `diagnostics::View` when available. The per-interface `network_interfaces` list added in 0.18.6 is intentionally dashboard-only so kernel-created interface names cannot create an uncontrolled Prometheus label set. RustProxy also exports the bounded operational data already available from traffic metrics, cache, vhosts, backend pools, streams, logging, GeoIP and ACME renewal observations. + +| Area | Exported diagnostics | +|---|---| +| Global traffic | requests/completions/active/rejects/errors, status classes, request/response bytes, exchange lifetime, accepted/active/rejected connections | +| Protocols | HTTP/1.1, HTTP/2 and HTTP/3 requests, H2/H3 connections/rejects, TLS connections/handshake failures, WebSocket tunnels/bytes/errors/lifetime, compression counters | +| Admin | request/status counters and time to control-plane response headers | +| Process/host | diagnostic source, runtime platform info, sampled timestamp, system/process CPU, logical CPUs, RAM total/used, system swap total/used, system uptime, RSS total/anon/file/shmem, virtual memory/limit, VmData, process swap, FDs/limit, threads, process start time, load 1/5/15, network totals/rates, process disk I/O, cgroup memory current/max | +| GeoIP | loaded state plus IPv4/IPv6 range counts | +| Cache | hot entries/bytes, global hit ratio and counters, cold entries/bytes and movement/error counters, fill bytes, cold queue bytes/items, active cold read/write bytes, combined transient bytes, configured-domain hot/cold occupancy, hit ratio and decision data | +| Vhosts | desired enable/domain/path/cache settings, per-vhost traffic/status/error/byte counters and cumulative exchange-duration histogram | +| Redirect/404 hosts | desired enable state, domain/rule counts and local-response status | +| Backends | pool readiness/policy info, bounded backend status, health/drain/backup, active work and configured request-concurrency/queue limits, requests/errors/bytes, weight, separate active/passive failure state, active/passive successes and health-failure totals, unconditional HTTP-5xx/timeout/connect/I/O/slow-response observation counters, DNS/TLS/header-timeout/read-timeout/send-timeout diagnostics, capacity rejection/queue-timeout counters, plus `passive_http_5xx_total`, `passive_timeouts_total`, `passive_connect_errors_total`, `passive_io_errors_total` and `passive_slow_total` aliases, health transitions, last status, current/EWMA latency, last-probe/failure/ejection/recovery timestamps, traffic rate, and retry attempt/success totals for ordinary vhosts | +| Docker vhosts | separate `rust_proxy_docker_vhost_*` and `rust_proxy_docker_backend_*` families: configured agents/assignments/domains, vhost traffic/status/duration plus backend readiness, health, requests/errors/bytes, latency/EWMA, separate active/passive failure state, unconditional observation counters and matching passive-health aliases, transitions and probe results | +| Streams | desired enable state, active/session/reject/error counters, bytes/packets and max connections | +| Logging | structured sink queue capacity, sent/error/drop counters plus the global live-log contention drop counter | +| TLS/ACME | configured certificate count, auto-renew flag, bounded issuer metadata, latest ACME job state, renewal check/expiry timestamps, failure count and status | +| Inventory | listeners/protocol flags, vhost/redirect/404/stream/ACL counts, request capacity and available permits, config revision and restart-required state | + +Values unavailable on the running OS are omitted rather than fabricated. Series +labels are bounded by configured IDs/domains, finite states and fixed protocol/status +families. Per-request URLs and arbitrary Host input are never labels. + +This is complete for **currently implemented bounded diagnostic views**. It does +not intentionally expose secrets, raw configuration documents, access-log entries +or unbounded per-request values merely to make them visible to Prometheus. + +## Prometheus examples + +Loopback/private CIDR scrape without a token when the explicit trusted option is +used: + +```yaml +scrape_configs: + - job_name: rustproxy + scrape_interval: 15s + metrics_path: /metrics + static_configs: + - targets: ['127.0.0.1:9191'] +``` + +With bearer authentication: + +```yaml + authorization: + type: Bearer + credentials_file: /etc/prometheus/secrets/rustproxy-token +``` + +Vhost rate: + +```promql +sum by (vhost) (rate(rust_proxy_vhost_requests_total[5m])) +``` + +Vhost p95 exchange lifetime: + +```promql +histogram_quantile(0.95, + sum by (vhost, le) (rate(rust_proxy_vhost_exchange_duration_seconds_bucket[5m]))) +``` + +Docker-vhost request rate (kept separate from ordinary vhosts): + +```promql +sum by (agent, vhost, service) (rate(rust_proxy_docker_vhost_requests_total[5m])) +``` + +Docker backend availability and latency: + +```promql +rust_proxy_docker_backend_up +rust_proxy_docker_backend_ewma_latency_seconds +``` + +Certificate expiry window: + +```promql +rust_proxy_certificate_renewal_not_after_timestamp_seconds - time() +``` + +The supplied dashboard is `grafana/rustproxy-overview.json`; Debian packages also +install it under `/usr/share/rustproxy/grafana/`. Its dedicated **Docker vhosts** row +uses only the separate Docker metric families and provides Docker-agent/service +filters so multi-agent installations can be inspected independently from ordinary +vhosts/backends. + +Reference: https://prometheus.io/docs/instrumenting/exposition_formats/ diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..535d731 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,46 @@ +# Documentation index + +This index separates current operational documentation from historical/version-labelled material. A version in an older document title describes the milestone that introduced or last audited that topic; it is not the current RustProxy release identity. + +## Current operational entry points + +- [INSTALL.md](INSTALL.md) - installation and managed updates. +- [SETTINGS.md](SETTINGS.md) - listeners, response defaults and administration settings. +- [API.md](API.md) and [API_ACCESS.md](API_ACCESS.md) - administration API and access controls. +- [VHOSTS.md](VHOSTS.md), [VHOST_ACCESS.md](VHOST_ACCESS.md), [CUSTOM_LOCATIONS.md](CUSTOM_LOCATIONS.md) - HTTP routing and access behavior. +- [DOCKER_DISCOVERY.md](DOCKER_DISCOVERY.md) - Docker discovery agent and generated vhosts. +- [CACHE.md](CACHE.md) and [CACHE_DIAGNOSTICS.md](CACHE_DIAGNOSTICS.md) - cache semantics and diagnostics. +- [PROMETHEUS.md](PROMETHEUS.md), [HTTP3.md](HTTP3.md), [STREAMS.md](STREAMS.md) - monitoring and transport features. +- [SECURITY.md](SECURITY.md) - current security model and limitations. +- [TEST_REPORT.md](TEST_REPORT.md) - release verification status and explicit environment limitations. +- [0.24.5 specification audit](V0.24.5_AUDIT.md) - HTTP/3 empty-request-body normalization for HTTP/1.1 upstreams and DELETE regression matrix. +- [0.24.4 specification audit](V0.24.4_AUDIT.md) - process-local Linux THP mitigation and memory-retention verification matrix. +- [0.24.3 specification audit](V0.24.3_AUDIT.md) - memory-retention, H3 settings placement and dynamic diagnostics source-vs-spec matrix. +- [0.24.1 specification audit](V0.24.1_AUDIT.md) - current shared-statistics/runtime-metadata/Settings navigation source-vs-spec matrix. +- [0.24.0 specification audit](V0.24.0_AUDIT.md) - historical H2 graceful-shutdown/system-statistics source-vs-spec matrix. +- [0.23.3 specification audit](V0.23.3_AUDIT.md) - historical legacy WebSocket compatibility source-vs-spec matrix. +- [0.23.2 specification audit](V0.23.2_AUDIT.md) - adjustable live-chart history and HTTPS local-CLI/live-log source-vs-spec matrix. +- [0.23.1 specification audit](V0.23.1_AUDIT.md) - historical initial Cache/Transform live-chart source-vs-spec matrix. +- [0.23.0 specification audit](V0.23.0_AUDIT.md) - historical certificate API/live-logs/cache-UX source-vs-spec matrix. +- [Interactive live logs](LIVE_LOGS.md) - `rust-proxy --livelogs`, structured filters, detail view, bounded stream/gap semantics. +- [0.22.3 specification audit](V0.22.3_AUDIT.md) - historical h2c Upgrade/external-certificate audit. +- [0.22.2 specification audit](V0.22.2_AUDIT.md) - historical retry/H2/concurrency source-vs-spec matrix. +- [0.22.1 specification audit](V0.22.1_AUDIT.md) - historical UI/RFC8441/h2c/tunnel source-vs-spec matrix. +- [0.21.0 specification audit](V0.21.0_AUDIT.md) - historical 0.21 source-vs-spec matrix. + +## Historical or version-labelled context + +These files are retained because they document migrations, original feature contracts or past audits, but their version labels and conclusions must not be treated as a fresh 0.24.1 audit: + +| File | Historical marker | Use | +|---|---|---| +| `INTEGRATIONS_040.md` | 0.4.0 | Original integration/forwarding milestone. | +| `MANAGEMENT.md` | updated for 0.4.0 | Early vhost-management behavior and terminology. | +| `SECURITY_AUDIT.md` | 0.7.1 | Historical limited administration-exposure audit; not a current security certification. | +| `MIGRATION.md` | 0.7.3 -> 0.8.0 | Version-specific deployment migration notes. | +| `ACME_RFC2136.md` | 0.5.0 | Original RFC2136 integration notes; current renewal behavior is also documented in `RENEWAL.md`. | +| `TRANSPORT.md` | early transport contract | Background contract; current HTTP/3-specific behavior is in `HTTP3.md`. | + +## Screenshots + +See [screenshots/README.md](screenshots/README.md). Only two explicitly labelled legacy simulated references remain; stale simulated UI captures were removed in 0.19.1. diff --git a/docs/REDIRECTS.md b/docs/REDIRECTS.md new file mode 100644 index 0000000..278e02e --- /dev/null +++ b/docs/REDIRECTS.md @@ -0,0 +1,66 @@ +# Redirect hosts + +RustProxy 0.13.0 adds first-class redirect hosts on the **Redirects** dashboard page. A redirect host owns one or more source hostnames and named HTTP/HTTPS listeners and answers locally without opening a backend connection. + +Each host contains ordered redirect rules. The longest matching path wins. Rules can match `http`, `https` or either scheme and can use exact or path-prefix matching. Targets can be: + +- `url`: an absolute `http://` or `https://` URL, +- `host`: a hostname/authority with the target scheme set to preserve, HTTP or HTTPS, +- `path`: an absolute path on the same request authority. + +Supported status codes are 301, 302, 303, 307 and 308. Use 307/308 when the client must preserve the HTTP method. Path handling can append the complete source path, strip the matched prefix before appending, or replace the path entirely. Source query parameters can be preserved or dropped. `cache_seconds=0` emits `Cache-Control: no-store`; a positive value emits a bounded public `max-age`. + +Redirect hosts use the same named listener inventory and certificate library as vhosts. The dashboard uses the same listener-selection component for both editors. A new redirect preselects the standard enabled HTTP :80 and HTTPS :443 listeners together when available, while existing bindings are preserved. The same hostname cannot overlap a vhost or another redirect host on the same socket. Global host ACL policy still runs before a redirect response. Redirect rules never make DNS/TCP/TLS connections to the target; the client follows the resulting `Location` header. + +Configuration lives under `redirects` and normal dashboard saves use `PUT /api/config/redirects`. + + +## Example + +```json +{ + "redirects": [ + { + "id": "legacy-site", + "name": "Legacy site redirects", + "frontend": { + "hosts": ["old.example.com"], + "listener_ids": ["http", "https"] + }, + "certificate_id": "old-example", + "rules": [ + { + "id": "application", + "source_scheme": "any", + "match_type": "prefix", + "path_prefix": "/app", + "target_type": "host", + "target": "app.example.com", + "target_scheme": "https", + "path_mode": "strip_prefix", + "query_mode": "preserve", + "status_code": 308, + "cache_seconds": 300 + }, + { + "id": "fallback", + "match_type": "prefix", + "path_prefix": "/", + "target_type": "url", + "target": "https://www.example.com/", + "path_mode": "replace", + "query_mode": "drop", + "status_code": 302, + "cache_seconds": 0 + } + ] + } + ] +} +``` + +Rules are evaluated by longest path first; exact matching wins over prefix matching at the same path. A disabled redirect host reserves its hostname/listener ownership just like a disabled vhost, so another frontend cannot silently take over that traffic. + +## Hostname editor + +The Redirect source-domain field uses the same chip editor as Vhosts: add one hostname/wildcard at a time, remove individual chips, and keep listener selection in the shared frontend control. diff --git a/docs/REFERENCES.md b/docs/REFERENCES.md new file mode 100644 index 0000000..b7e05fe --- /dev/null +++ b/docs/REFERENCES.md @@ -0,0 +1,65 @@ +# Implementation references + +Primary specifications and library documentation consulted while implementing the +transport code. These references are not evidence that this source package was built +or tested successfully against a resolved dependency graph. + +## HTTP and WebSocket + +- HTTP semantics and intermediary header handling: https://www.rfc-editor.org/rfc/rfc9110 +- HTTP/1.1 messaging: https://www.rfc-editor.org/rfc/rfc9112 +- HTTP/2: https://www.rfc-editor.org/rfc/rfc9113 +- WebSocket handshake and framing: https://www.rfc-editor.org/rfc/rfc6455 +- Extended CONNECT bootstrap semantics: https://www.rfc-editor.org/rfc/rfc8441 +- WebSocket over HTTP/3: https://www.rfc-editor.org/rfc/rfc9220 +- hpx-h3 2.5.19: https://docs.rs/hpx-h3/2.5.19/h3/ +- Hyper HTTP/2 server builder: https://docs.rs/hyper/latest/hyper/server/conn/http2/struct.Builder.html +- Hyper upgrades: https://docs.rs/hyper/latest/hyper/upgrade/index.html +- hyper-util legacy client: https://docs.rs/hyper-util/latest/hyper_util/client/legacy/struct.Client.html +- Resolver interface: https://docs.rs/hyper-util/latest/hyper_util/client/legacy/connect/dns/index.html +- Tokio bounded-buffer bidirectional copying: https://docs.rs/tokio/latest/tokio/io/fn.copy_bidirectional.html + +## TLS + +- HTTPS connector builder: https://docs.rs/hyper-rustls/latest/hyper_rustls/struct.HttpsConnectorBuilder.html +- rustls signing key/certificate validation: https://docs.rs/rustls/latest/rustls/sign/struct.CertifiedKey.html +- rustls server certificate resolver: https://docs.rs/rustls/latest/rustls/server/trait.ResolvesServerCert.html +- SNI certificate bindings: https://docs.rs/rustls/latest/rustls/server/struct.ResolvesServerCertUsingSni.html +- Development certificate generation: https://docs.rs/rcgen/0.13.2/rcgen/fn.generate_simple_self_signed.html + +## Persistence and security + +- SQLx SQLite connection model: https://docs.rs/sqlx/latest/sqlx/struct.SqliteConnection.html +- SQLite online backup API: https://www.sqlite.org/backup.html +- OWASP SSRF guidance: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html +- OWASP password storage: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html +- Prometheus instrumentation practices: https://prometheus.io/docs/practices/instrumentation/ + +The next Docker stages must use actual published port bindings and reconciliation, +not infer exposure from EXPOSE alone. Start with the official engine networking and +events documentation, and record the API versions actually tested in that release. + + +## Cache and compression + +- HTTP caching semantics and Age/freshness: https://www.rfc-editor.org/rfc/rfc9111 +- Cache-Status response field: https://www.rfc-editor.org/rfc/rfc9211 +- tower-http streaming compression: https://docs.rs/tower-http/latest/tower_http/compression/ +- Compression implementation API: https://docs.rs/crate/tower-http/latest/source/src/compression/service.rs +- Compression predicates: https://docs.rs/tower-http/latest/tower_http/compression/predicate/ + +References describe upstream APIs/specifications, not successful compilation of the +unresolved 0.2.0 dependency graph. Consult the exact Cargo.lock when generated. + +## Vhosts, keepalive and negotiation + +- Client pool timers, idle limits and HTTP/2 pings: https://docs.rs/hyper-util/latest/hyper_util/client/legacy/struct.Builder.html +- DNS Name parsing interface: https://docs.rs/hyper-util/latest/hyper_util/client/legacy/connect/dns/struct.Name.html +- TLS ALPN server configuration: https://docs.rs/rustls/latest/rustls/server/struct.ServerConfig.html +- HTTP strict transport security: https://www.rfc-editor.org/rfc/rfc6797 + +The library documentation is reference material, not proof that a particular future +resolved graph compiles. The reported tower-http 0.6.11 `SizeAbove::new(u16)` call +has been removed entirely; the custom predicate uses the generic Predicate API. + +- Generic compression predicate API: https://docs.rs/tower-http/latest/tower_http/compression/trait.Predicate.html diff --git a/docs/RENEWAL.md b/docs/RENEWAL.md new file mode 100644 index 0000000..5a4e91a --- /dev/null +++ b/docs/RENEWAL.md @@ -0,0 +1,118 @@ +# Automatic certificate renewal + +## Scope and defaults + +RustProxy 0.7.0 renews **configured managed certificates** whose `issuer` metadata +identifies an existing HTTP-01, Cloudflare, OVH or RFC2136 Certbot profile. Issuance +and initial library import are still explicit, staging-first operations. Importing +a PEM pair alone does not create a managed lineage or authorize CA requests. + +For managed certificates, `issuer.auto_renew` defaults to `true`, including saved +0.6 metadata lacking that field. Explicit `false` opts out. Therefore upgrading +an already configured managed certificate can enable scheduled CA checks; review +this before starting the new binary. Missing profiles fail closed with an error; +there is no silent recreation, provider switch or production issuance. + +Certificates shows an independent enable/disable action, Check now and Dry run, +last check, next check, expiry and result. Expiry is populated after a worker check; +it is not a pre-existing inventory of every manually imported certificate. Renewal +is not a configurable new ACME account lifecycle or revocation feature. + +## Schedule and recovery + +Saved renewal observations are loaded from SQLite **before administration listeners are +exposed**, so last check, next check, expiry and result are available immediately after a +restart without pressing **Check now**. RustProxy then waits 5-10 minutes before the first +automatic CA check, avoiding a renewal burst during startup. Successful checks are scheduled +approximately every 12 hours with up to 30 minutes of additional jitter. Only one issue/renew +worker runs at once. Failures back off from one to six hours; a missing/invalid profile is +checked again after one hour. Observation rows contain non-secret status/timestamps only and +are stored only in `config.sqlite3`. RustProxy 0.15.0 no longer reads or imports historical JSON +observation sidecars at runtime. When SQLite has no observation row, the normal scheduler safely +reconstructs status on its next automatic check; this does not replace or regenerate certificate, +key, account or renewal-profile material. A manual dry run does not postpone a real due check. The +service must be running; this is not an independent timer that works while RustProxy is stopped. + +The helper runs `certbot renew --cert-name rustproxy` in the original isolated +lineage, reusing saved authenticator/account/credential settings. It does not use +`--force-renewal`, change the CA or run directory hooks. Do not add shell hooks +to the private Certbot renewal configuration: hooks already saved by an operator +are still part of Certbot's trusted configuration. Certbot decides when the +certificate needs renewal. A successful exit can mean nothing was due: actual +certificate and key bytes are compared with the currently installed generation. + +For changed material, the helper verifies validity times using OpenSSL and creates +private immutable, content-addressed staging files. Rust validates the certificate/ +key match and requested domain coverage, then validates a complete new snapshot +including current vhost assignments. Only a successful revisioned SQLite write +publishes that snapshot. New TCP and QUIC TLS handshakes use the new certificate; +existing connections are not restarted. A simultaneous manual replacement/removal +or automatic-renewal disable wins over an older automatic worker result. + +Worker failure, invalid PEM/SANs, expiry regression or configuration conflict leaves +the previously active generation intact. If Certbot succeeded but publication did +not, the next check compares files again and can retry the same staged generation. +Unchanged checks do not write a new configuration revision. Changed certificate publications +use the ordinary revisioned configuration path, but certificate inventory changes are not part +of cache compatibility and therefore do not discard otherwise compatible hot/cold objects. + +Private profile/file checks, single-worker and per-profile locks, bounded subprocess +timeouts and process-group cancellation constrain work. Original certificate files +and Certbot archives are retained, not automatically deleted. Immutable renewal +staging is limited to 1024 keys; initial issuance staging remains limited to 128. +Plan monitored disk space and controlled offline cleanup after verified backups; +there is no automatic certificate garbage collector or external expiry alert yet. + +## Provider-specific prerequisites + +HTTP-01 still requires public TCP port 80 to reach an active plaintext RustProxy +listener for every name. The reserved challenge handler exposes only bounded tokens +for the current worker's domains, before application auth/ACL/cache. It is not a +public file browser. Wildcard certificates use DNS-01, not HTTP-01. + +Cloudflare, OVH and RFC2136 need their original DNS permissions, credentials and +propagation settings. Secrets are stored in private files, not returned by the +renew endpoint or included in desired configuration. They are not encrypted at +rest. The helper, Certbot, plugins and OpenSSL must be usable by the service user. +Keep absolute profile paths stable; moving the Certbot directory requires explicit +migration of its stored paths. A system-wide timer should not also manage these +RustProxy-isolated profiles. + +For a certificate intentionally owned by a separate system Certbot instead of this managed +renewal subsystem, use a manual certificate id and a Certbot deploy hook that runs, for example: + +```bash +/usr/bin/rust-proxy tls deploy --id shared-cert \ + --cert /etc/letsencrypt/live/example/fullchain.pem \ + --key /etc/letsencrypt/live/example/privkey.pem +``` + +The command resolves Certbot's `live/` symlinks locally, validates and stages a new pair, then +publishes the existing id through the normal revisioned configuration path. Do not point this +workflow at a certificate carrying RustProxy `issuer` metadata; one lineage must have one owner. + +Staging certificates are not trusted by browsers. A Dry run checks renewal +validation but does not deploy the staging result. Only an explicit production +issuance creates a production profile; renewal never upgrades staging implicitly. + +## API + +All routes below require the existing local administrator session and CSRF checks. + +- `GET /api/acme`: current job, provider names, nominal interval and renewal states. +- `POST /api/acme/renew`: `{"certificate_id":"site","dry_run":false}`; returns 202 + and a job. Only identity/options, no API credential resubmission. +- Certificate opt-out is a revisioned config update setting + `tls.certificates[].issuer.auto_renew` to `false`. + +`--dry-run` is **not** a claim of live CA validation in this release's test report: +packaging tests used a simulated Certbot. Run a real staging/production-lineage +validation on your server. Full runtime scheduler/publication acceptance is pending. + +## Primary references + +Certbot renewal behavior and flags: +https://eff-certbot.readthedocs.io/en/stable/using.html#renewing-certificates +https://eff-certbot.readthedocs.io/en/stable/man/certbot.html + +Challenge types: https://letsencrypt.org/docs/challenge-types/ diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..4cc19d6 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,207 @@ +# Security model and limitations + +This is a development source release with no executed Rust integration results in +the packaging environment. Do not expose it as a production security boundary until +the actual build, protocol tests, deployment review and an appropriate security +assessment have passed. + +## Administration + +Administration binds to loopback HTTP by default. Settings can enable a dedicated +administration HTTPS listener, choose the application-managed certificate or a +certificate from the inventory, persist a non-loopback bind and allow explicit DNS +Host/Origin names. Remote persisted binding is accepted only with administration TLS. +An explicit startup `--allow-remote-admin` can expose plain HTTP on a non-loopback address; +that path should remain behind a trusted firewall/private network boundary. +Passwords use salted Argon2id; password work has admission limits and runs off the +network I/O path. Setup requires local filesystem authority or the one-time random +capability. No default administrator/password is created. + +Sessions have HttpOnly/SameSite=Strict cookies, idle and absolute expiries and +credential-generation revocation. Mutations require scheme-aware Host/Origin checks, +the request marker and a CSRF token. Local CLI reset changes the hash and invalidates +all existing sessions without editing SQLite manually. Administration HTTPS marks the +session cookie Secure; HTTP cannot. The admin certificate and bind changes are +restart-required. A dedicated metrics credential is separate from browser sessions. + +The data directory and SQLite files are private. The local OS user with data-directory +access is a recovery authority and can create short-lived CLI sessions. Protect that +account, filesystem and backup copies; browser authentication does not defend against +a compromised process owner. `.env` is trusted shell code, never untrusted input. + +## Backend and routing boundary + +Only configured HTTP/HTTPS origins may be dialed. Absolute-form client targets and +CONNECT cannot turn the proxy into an unrestricted forward proxy. ACL is evaluated +before new TCP/TLS connections and before reuse of an existing upstream connection. +Host and route restrictions combine; route allow cannot override host deny. +Forwarded headers are rebuilt from socket identity by default. An explicit +trusted-proxy CIDR policy can accept a bounded existing chain and derive the client +IP for ACL/affinity; see INTEGRATIONS_040.md. Routing still uses the actual local +destination, never any forwarded address or host. + +Private/loopback destinations require explicit approval. DNS answers pass the same +policy and optional per-backend CIDR restriction; an unsafe answer fails the entire +resolution. Validated results are used without a second DNS lookup. Special-address +blocks include common metadata/link-local targets and normalize mapped IPv6. +Listener self-target restrictions prevent simple loops and control-plane access by +default. A privileged administrator can deliberately set `accept_local_listener_risk=true` +on one backend; private/loopback destinations additionally require `allow_private=true`. +This is intended for consciously accepted local-listener use (including port 9090) and +can create a request loop or expose administration through a public vhost. It does not +relax metadata/link-local/special-address filtering or CIDR restrictions. Keep network +egress and frontend ACLs as defense in depth. + +No backend `Location` redirect is followed internally. HTTPS transport encryption remains +available, but backend trust/hostname verification is off by default. Set `tls_verify: true` +for origins where certificate-chain and hostname authentication is required. With +verification disabled, a network attacker able to intercept that route may impersonate +the origin. Address/DNS restrictions still apply; the local-listener exception exists only +when explicitly accepted as above. Operator-supplied backend configuration is privileged +configuration, not a field that an unauthenticated caller can edit. + +## TLS and tunnels + +Private key files are size-bounded, regular files, owner-only on POSIX and opened +without following final-component symlinks. Named files cannot escape the certificate +directory by using path separators. Keep the entire directory tree private as well. +Keys and certificate name coverage are validated before publication. Generated +self-signed certificates are development material, not automatic public trust. + +Existing TLS sessions/tunnels are not revoked by changing a certificate or ACL. +Policies apply to new requests and handshakes. Restart/stop the relevant process for +immediate termination until a scoped administrative close operation is implemented. + +WebSocket payloads remain opaque byte streams. The proxy validates upgrade metadata, +not application-level authorization or message semantics. Frame/message limits on a +backend remain the backend's responsibility. Proxy buffers, tunnel/connection counts +and inactivity timers bound proxy-side resource usage. + +## Information and resource controls + +Access logging does not collect credential headers, query values, cookies, bodies +or WebSocket payloads. Hostnames and paths are logged; never put secrets in them. Do not enable third-party verbose tracing or application logging without +reviewing their output separately. Access rings, viewer slots, auth work, updates, +DNS workers and request/connection admission are bounded. A slow log viewer does not +create an unbounded delivery queue. + +Backups include TLS private keys and credential/session state. Restore matching +SQLite and certificate data together, then revoke restored sessions. Never commit +`.env`, the data directory, backups or generated certificates to a public repository. + +## Not a completed security feature + +Docker socket access controls, service-account metrics access, +2FA, roles, provider secret storage, certificate +expiry automation, a full special-address registry policy and independent fuzz/load +assessment remain roadmap work. Feature availability is not evidence of a completed +security audit or a performance guarantee. + + +## Cache, response transforms and ports (0.1.0) + +Cache is enabled by default in 0.8.1 and ACLs precede lookup. Cookie (outside the +configured static-asset exception), Authorization, Range, unsafe preconditions and +request/response `no-store` remain hard shared-cache exclusions. Conditional validators +can be satisfied from a fresh object or revalidated against the origin. Response +`no-cache` with a usable validator may be retained only for mandatory validation before reuse. Origin privacy is +checked before custom header removal, and generated request IDs are not cached. Bounded +fill permits, object/entry/byte limits and generation checks protect cancellation, +update and purge paths. Purge is authenticated, CSRF-protected and audited, not a +public PURGE/BAN capability. + +Compression is opt-in, with authenticated/private/no-transform exclusions. Response +rules cannot overwrite protected framing and identity fields. Required Age remains +on cache hits even when debug headers are disabled. Server hiding is cosmetic and +must not be considered an access-control or vulnerability mitigation substitute. + +Standard public 80/443 ports are supported without globally forbidding remote +origins using those ports. Local listener targets remain guarded. Review SETTINGS.md +for interface-enumeration and restart semantics. A Linux service can grant only +CAP_NET_BIND_SERVICE to a dedicated unprivileged account; the application does not +acquire capabilities itself. Public listener controls do not implicitly expose the separate admin +API. The provided systemd unit is an unexecuted example, not an audit result. + +Language packs are trusted build inputs with size/type/metadata bounds. The browser +renders strings as text and falls back per key. Translations are not executable +plugins, and no directory browsing or runtime arbitrary file loading is exposed. + +## 0.2.0 frontend and pool boundaries + +An explicit vhost binding is checked using the accepted stream's real local IP, +port and TLS state. Host, SNI and forwarded headers cannot assert a different local +address. A shared wildcard TCP listener does not grant every vhost access on every +accepted IP. Legacy routing does not inherit additional frontend sockets. Conflicting +hostname ownership is rejected, including disabled declarations. + +Binding a private IP is not an authorization mechanism or firewall. NAT, port +forwarding or another proxy can make that destination reachable. Configure network +restrictions and application authentication as well; no trusted-proxy identity or +Keepalived/VRRP service is introduced. New unavailable IP bindings fail startup. + +Health probes share literal/DNS/allowlist/loop protections, including actual old +listeners and desired new listeners, and cannot follow redirects to arbitrary +origins. They are bounded and do not use user cookies or credentials. Probes can +still hit an operator-selected GET path; select a side-effect-free health endpoint. + +Affinity uses opaque random tokens but is not authentication, CSRF protection or +session replication. Tokens survive compatible edits, not process restarts. Cookie +mode bypasses cache; the dedicated cookie is stripped before origin forwarding and +cannot be overridden by an origin Set-Cookie. No failed request, especially a write, +is replayed on another member to obtain a successful response. + +Dedicated per-vhost security headers also cover matching-host denials/errors and +cache hits. HSTS is removed on plaintext; includeSubDomains/preload remain explicit +opt-ins. Preload directives do not enroll a domain in a browser list. Arbitrary CSP +and Permissions-Policy values are encoding-validated, not semantically audited. +Old configurations retain origin headers unless an explicit policy overrides them. + +## Certificate uploads and custom pages (0.3.0) + +Certificate upload is an authenticated local-control-plane mutation protected by +Origin and CSRF validation, body/per-file bounds and two-worker admission. PEM pairs +are parsed, key-matched and checked against supplied DNS names. Private immutable +files use generated simple names, mode 600 and the existing no-symlink/directory +lock protections. Public API responses/config/audit contain metadata, not key PEM. +JavaScript clears file controls after use, but does not claim secure erasure of +browser memory. Only configuration publication activates an inventory selection. +No new listener or public certificate-upload endpoint is created. + +Custom HTML is fully trusted administrator-authored content served on the chosen +public vhost. It may contain executable HTML/scripts there; it is never previewed +or inserted as HTML in the admin DOM. Do not upload untrusted markup. The fixed +welcome page interpolates only the software version, not Host, URL or user input. +HTML is bounded to 32 KiB per page and by the overall 256 KiB configuration limit. +Responses use no-store; old representation encoding/length/cookies are discarded +when replacing an origin error. Authentication challenges, Allow and Retry-After +are retained, then administrator response/security rules are applied. + +ACLs run before static/fallback/direct-IP pages. Even a custom status 200 for a +blocked request never authorizes an upstream fetch. Such overrides can confuse +clients and monitoring; retaining the original 401/403/5xx status is recommended. +Direct-IP HTTPS still needs a completed TLS handshake and a suitable certificate; +an HTTP welcome page cannot remove a TLS certificate-name error. + +## Integration boundaries in 0.4.0 + +Tinyauth runs after host/path ACLs and before any cached/static/origin response or +WebSocket upgrade. Incoming identity headers are stripped, including after generic +header rules, and only configured fields from a successful verifier are copied. +The verifier gets no request body or admin session cookie. Its DNS/private-network +and TLS checks match the origin safety boundary. Authentication failure cannot be +turned into a 200 by custom pages. Denials and protected responses are no-store; +there is no auth-result or protected-content shared cache. Existing upgraded +sessions are not reauthenticated continuously. + +Only configured login-origin redirects are accepted. Do not expose the fake-auth +helper or treat its test cookie as an actual login. H3 uses the same routing, +auth, request limits and response policies; 0-RTT is disabled. Transport resource +limits do not replace rate limiting, firewalls or independent security/conformance +testing. H3 is uncompiled/unexecuted in this packaging environment. + +Diagnostic response fields are hidden by default, but origin secrets still need +explicit response rules. In 0.5.0 the dedicated controls expose request ID and +revision independently; the obsolete hide_mock_origin field is not accepted. X-Mock-Origin can be +removed using ordinary response-header rules. Scope labels on listeners never +enforce network restrictions. See ACME_RFC2136.md for private TSIG/account storage +and the separate backup requirement for manual DNS-01 issuance. diff --git a/docs/SECURITY_AUDIT.md b/docs/SECURITY_AUDIT.md new file mode 100644 index 0000000..5792211 --- /dev/null +++ b/docs/SECURITY_AUDIT.md @@ -0,0 +1,78 @@ +# Limited administration exposure audit - 0.7.1 + +## Scope and evidence + +Reviewed the supplied 0.7.0 source and this patch's admin router/middleware, +authentication endpoints, sessions, API assets, dashboard login/logout, Swagger +integration, proxy header rule precedence and deployment readiness consumer. +This is a manual source review plus static/browser-script regression tests. It +is **not** a penetration test of the user's installed binary or a complete audit +of proxy/TLS/QUIC/ACME/operating-system/dependency security. No Rust compiler was +available; runtime enforcement and complete vendor rendering remain unverified. + +The reported unauthenticated operational JSON disclosure was not reproduced on +an installed server. In the input source, `/api/config`, status, logs, metrics, +backend/cache and certificate endpoints already passed through a session guard. +The concrete findings were public version/mode metadata, unnecessarily public +operational HTML/JS/full language assets, broad path-only method exceptions and +incomplete browser-state minimization. Do not infer that a user's older build, +reverse-proxy front or a different exposed endpoint had the same behavior. + +## Findings and source changes + +| Observation | 0.7.1 change | Verification limit | +|---|---|---| +| `/healthz` disclosed version; auth discovery disclosed version/mode/password policy | Health reduced to one status field; auth discovery to setup-required boolean | Source/contract tests; actual router tests supplied, not run | +| Full operational HTML/modules/language bundle could be fetched anonymously | Root selects minimal login or authenticated document; full modules/bundle are private | Login fetch/DOM checks use mocked fetch, not real HTTP | +| Public route exemptions were not a strict method/path matrix | Explicit allowlist; private and unknown requests denied before JSON/query parsing | Six additional async Rust test functions supplied, unexecuted | +| Anonymous JSON rejection could include extractor input detail | Login/setup map parser failures to a fixed message, preserving 400/413/415 status semantics | Source and unexecuted router tests | +| Mutation marker/CSRF duplicate values were not explicitly rejected | Require one exact Origin, marker and session-bound CSRF value | Existing guards retained; added unexecuted duplicate-header tests | +| Stale DOM/form values can outlive a visible logout screen | Scrub inputs/state, notify other tabs, reload root; docs clear schema/results on session change | Chromium script tests with simulated location/storage | +| New API docs would otherwise create another disclosure surface | Protect document, schema and every necessary asset with existing guard; no wildcard asset route | Route/schema contracts; vendor-renderer acceptance outstanding | +| Interactive docs can carry credentials into requests | Fixed same-origin schema, per-action session verification, no external validator/persisted auth, writes opt-in + CSRF | Custom interceptor tests use a vendor stub; server guard is the authority | +| Updater used public version in `/healthz` | Locally verify systemd MainPID executable against staged release after minimal readiness | Temporary-file and simulated-systemd tests; real deployment not run | + +## Deliberate public minimum + +Anonymous login assets, favicon/shared CSS, setup-needed boolean, minimal health +status, and guarded login/one-time setup POSTs remain. These are necessary for +sign-in, initial administration and local readiness. Static CSS may contain UI +selectors; it contains no runtime configuration or secrets. Invalid Host/Origin +still returns generic 403 instead of bypassing the boundary. + +Login/setup POST success is allowed to create/return session information **only +after credential/capability validation**. No API Bearer token or setup token was +added as an alternative authority for operational reads. Setup remains one-time, +local-capability-based; password checking/rate limiting/session invalidation were +not weakened or replaced by JavaScript-only checks. + +## Separate Prometheus listener + +Admin `/metrics` and `/api/metrics` require the browser/admin session. The optional +standalone Prometheus listener remains independent, disabled by default, with +configured source CIDRs and its existing bearer-token policy. Non-loopback export +requires a token by the existing Rust validation. A loopback exporter can be +intentionally configured without a browser login. Do not treat it as an overlooked +admin API exception, and do not place it on an untrusted network without reviewing +its token, CIDRs and protected transport. This patch does not verify its runtime. + +## Outstanding acceptance and operational caveats + +- Build and execute `dev.sh check` / `dev.sh test`; run `admin_audit.py` against + the actual upgraded service. Compare 401/no-store responses without cookies. +- Open real `/docs` in a normal browser; verify rendering, actual cookie+Origin+CSRF, + logout, CSP console output and failure after revocation. Vendor assets were not + downloaded/rendered here, and build dependency compatibility is unverified. +- Test real install/update restart/rollback; local MainPID and `/proc` verification + now fail closed when the expected executable cannot be established. +- Retain loopback-only administration and use a protected tunnel. Cookie/session + security does not protect a compromised local account, malicious browser extension, + an administrator sharing exported configuration, or every possible web attack. +- No RBAC/read-only administrator role, general API token scopes, dependency CVE + scan, fuzzing or proof of vulnerability absence is delivered by this small patch. +- Review a real resolved Cargo.lock and the pinned Swagger artifact before shipping + binaries. Source review is not a substitute for release acceptance. + +For the exact exposure matrix and test/build commands, read [API_ACCESS.md](API_ACCESS.md). +The review uses OWASP REST/session guidance as a checklist, not a certification: +https://cheatsheetseries.owasp.org/cheatsheets/REST_Security_Cheat_Sheet.html diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md new file mode 100644 index 0000000..f01d447 --- /dev/null +++ b/docs/SETTINGS.md @@ -0,0 +1,346 @@ +# Listener and response settings + +## Configuration and update semantics + +GET `/api/config` returns a revisioned envelope. PUT the entire document with +`expected_revision`. Defaults apply when fields are omitted. Unknown fields are +rejected; failed validation/persistence leaves the old snapshot active. Existing +backend/route/TLS/ACL data must be retained when changing settings. The panel does +this and preserves dirty settings forms across status/language refreshes. + +```json +{ + "listeners": { + "endpoints": [ + {"id":"http", "name":"HTTP", "scope":"public", "enabled":true, + "binding":{"address":"0.0.0.0:80", "tls":false, "http2":false, "http3":null}}, + {"id":"https", "name":"HTTPS", "scope":"public", "enabled":true, + "binding":{"address":"0.0.0.0:443", "tls":true, "http2":true, + "http2_settings":{"idle_timeout_seconds":300, "max_connection_lifetime_seconds":0, "drain_timeout_seconds":10}}} + ] + }, + "server_header": {"mode":"custom", "value":"RustProxy/v0.20.0"}, + "compression": {"gzip":true, "br":true, "zstd":true, "deflate":false, "min_bytes":1024}, + "headers": { + "request": [{"op":"set", "name":"x-proxy-name", "value":"RustProxy"}], + "response": [ + {"op":"remove", "name":"x-powered-by"}, + {"op":"set", "name":"x-served-by", "value":"RustProxy"} + ] + } +} +``` + +This is a settings fragment, not a replacement for the existing routing/vhost document. +A complete opt-in example is in `examples/cache-dashboard.json`. + +The dashboard exposes the complete raw JSON editor under **Settings -> Manual config**. +The historical `/configuration` browser route is retained only as a compatibility alias and +opens that Settings block; it is no longer a primary-navigation item. Prefer the structured +section editors for routine changes so unrelated sections are not republished. + +## Administration HTTPS + +`administration` is separate from public proxy listeners. It defaults to the startup +loopback address and plain HTTP. Persisted address/certificate changes are validated +immediately but take effect after process restart. A non-loopback persisted bind is +accepted only when `administration.tls` is true. Plain remote HTTP remains available only when process startup explicitly allows it with +`--allow-remote-admin`. + +```json +{ + "administration": { + "listen": "0.0.0.0:9090", + "tls": true, + "certificate_id": "admin-example", + "allowed_hosts": ["admin.example.com"], + "session_days": 30 + } +} +``` + +`certificate_id: null` uses `data/certs/default.pem` and `default.key`, generated by +the application. A selected inventory certificate is loaded from the same private +certificate directory. `session_days` controls the absolute browser-session lifetime +and must be 7..30 (default 30). It applies without restart; changing listener/TLS/certificate +settings still requires restart. Session cookies are HttpOnly/SameSite=Strict and receive +the `Secure` attribute on HTTPS. When HTTPS is enabled, accidental plain HTTP received on the same administration +port receives a permanent redirect to the same authority/path over `https://`; the +plaintext path contains no administration handlers. An empty `allowed_hosts` accepts +valid DNS Host values on the configured HTTPS port as well as valid numeric destination +addresses. Once `allowed_hosts` contains entries it becomes a strict DNS allowlist for +browser-facing DNS authorities. An explicitly listed hostname may arrive from a trusted +reverse proxy on an external port such as 443 even when the private admin socket is 9090. +Direct IP/`localhost` access is still accepted only on the actual configured admin port. +For HTTPS termination in front of a plain local admin socket, the trusted proxy must pass +its external Host and scheme; `X-Forwarded-Host` is accepted only from loopback/configured +trusted proxy peers and `X-Forwarded-Proto` additionally follows the forwarded-proto trust +policy. Origin/CSRF comparison and the session cookie Secure flag use that external origin. +Use a firewall and normal network policy even with HTTPS. + +## Structured access logging + +The **Settings -> Logging** tab controls optional external sinks while the existing bounded +live ring remains available to the panel. Sink I/O is performed by a bounded background +queue; request processing only performs a non-blocking enqueue. Supported destinations: + +- JSONL file append, either one combined file such as `/var/log/rustproxy/access.jsonl` or + one file per resolved vhost under a configurable directory (default `/var/log/rustproxy/vhosts`). + Unmatched traffic uses `_unmatched.jsonl`. The managed systemd service provisions + `/var/log/rustproxy` for the service account and keeps that directory writable under + `ProtectSystem=strict`. Other custom paths still need matching service permissions. +- Logstash TCP using `host:port` (IP or DNS). Each event is one JSON line. +- Loki HTTP(S) push. Optional bearer credentials are read from an environment variable + named by `bearer_token_env`; the secret itself is not stored in configuration. + +`logging.format.fields` maps output field names to standard event fields; an empty map +emits the complete standard event. The panel provides Standard, Nginx-style, ECS-style +and Minimal presets plus a visual field selector/renamer with JSON preview. Advanced raw +mapping remains available for aliases not representable by the simple builder. +`static_fields` adds constant string fields. Loki labels are separately configurable. +`/api/status.logging.sinks` reports queue capacity, dropped events, successfully delivered +events and sink errors. + +## Hot and cold cache tiers + +`cache.max_bytes` and `cache.max_entries` bound the HOT RAM tier. `max_bytes` is charged from retained body backing allocation plus entry metadata rather than only payload length. `cache.transient_max_bytes` separately bounds in-progress fills and full bodies moving between HOT and COLD; `cache.cold_queue_max_bytes` additionally caps queued demotions in bytes, and `cache.hot_eviction_max_objects` bounds one LRU admission attempt. The source defaults are 256 MiB HOT, 16 MiB maximum object, 64 MiB transient and 32 MiB COLD queue. The Settings panel calculates conservative/recommended/performance presets from the lower of host RAM and a finite cgroup memory limit without changing the COLD disk budget. It also warns when HOT + transient is a large fraction of effective RAM, when the additional configured cache budget is large compared with current host/cgroup headroom, when the COLD queue exceeds transient RAM, or when the object ceiling cannot fit in transient RAM. Automatic presets are disabled below 256 MiB effective RAM. On a 2 GiB effective-memory VM the recommended profile selects approximately 400 MiB HOT, 96 MiB transient, 16 MiB maximum object, 48 MiB COLD queue, 6400 HOT entries and a 64-object LRU admission limit. The optional persistent +cold tier is controlled by `cold_enabled`, `cold_path`, `cold_max_bytes`, +`cold_max_entries`, `cold_gc_interval_seconds`, `cold_gc_max_delete`, +`cold_after_seconds`, `cold_on_lru`, `cold_on_idle` and `cold_on_shutdown`. +`cold_gc_interval_seconds` defaults to 60 and `cold_gc_max_delete` to 256. Pressure GC +starts at roughly 90% of the configured byte/entry budget and cleans toward roughly 80% +without deleting more than the configured per-pass cap. `cold_path` is relative to the RustProxy data directory and cannot +contain parent traversal or an absolute path. The two demotion switches independently +select LRU-pressure and idle objects; shutdown flush is a best-effort direct write during +a graceful restart. Demotions use a small bounded queue and release hot accounting before +disk persistence so cold storage does not retain an unnecessary second body buffer in RAM. +Cold metadata is readable JSON and bodies are raw `.body` files; reads are size/SHA-256 +checked before promotion to RAM. Since 0.21.0 objects are sharded by the first two SHA-256 +hex characters (`cache/cold/00/...` through `ff/...`), with shard directories created on +first write. The previous flat Cold Cache is intentionally not migrated. Purge operations +clear both in-memory indexes and schedule disk cleanup; selective cleanup uses the same +GC/eviction path and runs asynchronously. + +Version 0.22 adds foreground fill collapsing (`collapse_enabled`, +`collapse_wait_timeout_ms`, `collapse_timeout_fallback`), detailed stale-if-error policy +(`stale_if_error_timeout`, `stale_if_error_connection`, `stale_if_error_dns`, +`stale_if_error_tls`, `stale_if_error_statuses`) and opt-in proactive refresh +(`proactive_refresh_enabled`, `proactive_refresh_before_expiry_seconds`, +`proactive_refresh_percent`, `proactive_refresh_max_inflight`). Defaults preserve the +previous stale-if-error behavior; proactive refresh is disabled by default. Selective +PURGE/BAN is an operational API/panel action rather than persisted configuration. See +[CACHE.md](CACHE.md) for request-collapse, Range and invalidation semantics. + +## Static-response transformation + +`transform` is a formal identity-response stage between the upstream response and shared +cache storage. `transform.enabled` is the global kill switch, but every vhost must also +set its own `transform.enabled=true`; vhosts stay disabled by default even when the global +engine is enabled. Global defaults enable JavaScript and CSS transformation and leave +HTML, SVG and JSON off. `extensions` controls extension-based detection and +`max_body_bytes` (1 KiB..32 MiB, 2 MiB default) bounds aggregation/decoding. + +The pipeline is `origin -> decode supported Content-Encoding -> transform -> cache +identity representation -> client compression`. JavaScript/CSS can be enabled normally; +HTML minification, SVG optimization and JSON compaction are independent optional formats. +Recognition uses safe configured extensions and known Content-Type values. Binary/image +formats outside those explicit transform types are not transformed. + +Origin `Cache-Control: no-transform` always disables transformation. When bytes change, +RustProxy removes representation-specific length/digest/encoding metadata and creates a +new strong `rustproxy-*` ETag for the transformed representation. The original origin +ETag/Last-Modified are kept separately and used for origin revalidation, so a proxy ETag +is never sent upstream as though it validated the original object. The transformed +identity body is what enters cache, preventing repeated minification on every HIT. + +## Global TLS protocol and cipher policy + +`tls.policy` is the global baseline inherited by every TLS host. It controls TLS 1.2, +TLS 1.3 and optional rustls cipher-suite names. An empty `cipher_suites` list uses the +ring provider defaults. Settings shows both the currently effective cipher suites and +the complete selectable suite inventory. At least one protocol version and +one compatible effective suite are required. This policy is also used by administration +HTTPS. HTTP/3 requires TLS 1.3 and a TLS 1.3 suite. Vhosts can narrow TCP TLS with +`tls_policy`; when a vhost has HTTP/3 enabled on a selected H3-capable listener it must +match the global policy because QUIC TLS is shared per UDP listener. Available suite names are exposed in authenticated status for +the Settings editor. + +## Listeners: saved intent, not hot rebinding + +When an HTTP, HTTPS or administration address was explicitly supplied by systemd/CLI +(or the corresponding service environment variable), that exact startup bind is a +process contract: the panel shows it as locked and configuration updates cannot remove, +disable or change it. If an address was not explicitly supplied at startup, listener +entries remain freely addable/removable/editable subject to normal validation. + +`listeners.endpoints` is the only persisted frontend socket configuration. Each +endpoint has a stable id/name, an informational scope label, an enabled flag and one +primary binding containing IP:port, TLS, HTTP/2 plus its optional lifecycle settings, and optional HTTP/3 settings. Optional +`additional_addresses` reuse that same transport policy on up to 15 further addresses. +When more than one address is configured every address must be explicit; `0.0.0.0` and +`::` are rejected so a multi-IP selection cannot silently mean every interface. IP/port +and transport capability belong here, not inside a vhost. IPv6 uses `[address]:port`. + +A vhost references endpoints through `frontend.listener_ids`. Listener changes are +restart-required because they alter TCP/UDP sockets or TLS ALPN capability. The +listener editor owns the HTTP/2/H2C and HTTP/3 enable/disable switches. An H2/H2C row +links to **HTTP connections -> HTTP/2** for idle, absolute-lifetime and graceful-drain +limits; an enabled H3 row links to **HTTP connections -> HTTP/3** for advertisement, +connection, stream and idle-timeout limits. Both tuning pages are listener-scoped and +do not own protocol enablement. Vhost H2/H3 switches only narrow the protocols already +enabled by the selected endpoints; they never create a protocol that Settings disabled. + +The administration listener is independent and cannot share a frontend port. +Bindings must be valid local unicast/wildcard addresses; conflicting overlapping +endpoints are rejected rather than silently widened. + +## Server and other client headers + +`server_header.mode` is `preserve` (default), `hide`, or `custom`. Custom values are +nonempty, bounded to 256 bytes and validated against header injection. Preserve +means retain the result of origin/global/route rules; it does not add a missing +header. Hide/custom take final precedence, including proxy-generated errors. + +Global and route `headers.response` apply to client responses; `headers.request` +apply only upstream. Each scope/direction supports up to 32 ordered set/append/remove +rules. Remove has no value. Global rules precede route rules. Multiple Set-Cookie +values remain distinct. Admin-session cookie names are never forwarded or set by +public proxy rules. Transport, framing and trusted forwarding/identity fields are +protected. Cache diagnostic fields are owned by the cache controls, not generic rules. + +Global response rules also cover generated errors; route-specific rules apply only +after an origin route response exists. Client policy changes use hot configuration +publication and invalidate the RAM cache. In-flight exchanges retain their old +snapshot; no mixed-version configuration is applied midway through a response. + +## Compression + +All four flags default false. `min_bytes` defaults to 1024 (valid 32..1048576). +The library's default small-body/content-type predicate is also retained; actual +minimum for a known-size body is at least that default. Unknown-length text bodies +can be streamed without pre-buffering merely to discover their size. + +Accept-Encoding quality negotiation is delegated to tower-http. Text, JSON, +JavaScript, XML, SVG and wasm are eligible; already encoded, SSE, range, upgrade, +HEAD/204/304 and privacy-sensitive responses are excluded. Cookies/authorization +bypass even when an origin marks a response public. Origin no-transform/privacy +exclusions cannot be defeated by deleting a response header in the editor. Cached +identity bodies can be streamed through compression on HIT; origin encodings are +passed through without recompression. + +Vary: Accept-Encoding is added by compression; content length is removed for the +transformed body. Strong ETags are weakened, and identity-body content digests and +Accept-Ranges are removed. Encoders use their fastest preset; there is no panel +compression-quality slider in this release. Real binary interoperability tests +are still an acceptance requirement, not a result of this packaging session. + +## Diagnostics and units + +Samples run off the traffic path roughly every two seconds. CPU and network rates +need two successful samples. Host CPU is aggregate; process CPU uses 100% per busy +logical CPU and can exceed 100%. Neither is normalized to a container CPU quota. +Host memory is MemTotal minus MemAvailable. RSS/FDs/threads and process I/O come +from procfs. Network rates sum non-loopback interfaces in the process network +namespace, not just RustProxy traffic. Load average is OS scope, not latency. + +The process's cgroup v2 memory files are read at the standard mount location when +available. Linux keeps the existing `/proc`/cgroup metric names, units and behavior. +FreeBSD/OpenBSD/NetBSD use native system interfaces where the same metric is available; +unknown or unsupported values remain null rather than being fabricated. Snapshot timestamps and sample age are shown; +cache counters and proxy request metrics are separate from OS resource metrics. +The dashboard history is browser-local and resets on reload/sign-out. No time-series +database, disk utilization chart or Windows/macOS OS sampler is implemented. + +## HTTP/2 compatibility and per-vhost settings + +HTTP/2 lifecycle settings are listener-scoped under `listeners.endpoints[].binding.http2_settings`: + +- `idle_timeout_seconds` (default 300) retires only a truly unused H2 connection; active streams and current frame I/O postpone it. +- `max_connection_lifetime_seconds` (default 0) is an independent absolute lifetime; `0` disables this limit. +- `drain_timeout_seconds` (default 10) bounds how long accepted streams may finish after retirement begins. + +Idle and lifetime retirement both send GOAWAY, stop accepting new streams, drain already accepted streams and then close TCP. RFC 8441 WebSocket/H2 Extended CONNECT streams are counted as active until their response body/tunnel ends. The same settings apply to TLS H2 and cleartext H2C on that named listener. + +For backward compatibility, the top-level `http2` object is still accepted as the fallback for an H2/H2C listener that has no explicit `binding.http2_settings`. The structured UI writes explicit per-listener settings; operators should treat the top-level object and `PUT /api/config/http2` as legacy compatibility surfaces rather than the preferred configuration path. + +A named TLS listener with `binding.http2: true` advertises H2 capability and always +retains HTTP/1.1 fallback. `binding.http2: false` makes that listener HTTP/1.1-only. +Cleartext H2 uses `binding.h2c: true`. Changing listener transport capability or H2 lifecycle settings requires restart. + +A vhost can additionally set `http2_enabled: false`. RustProxy reads TLS ClientHello +and SNI before committing to ALPN, so a vhost that disables H2 gets HTTP/1.1-only +ALPN even while another hostname on the same listener keeps H2. `http3_enabled` is +the equivalent per-vhost H3 opt-out. The panel enables these switches only when a +selected listener provides the protocol. No failed application request is replayed +to downgrade. + +Vhosts have their own backend pool, health/keepalive/affinity controls, ACL and +security/custom response headers. See VHOSTS.md. Global Server policy still takes +final precedence. There is no per-vhost compression algorithm override. + +The reported `SizeAbove(u16)` compilation error is removed by a custom u64 predicate, +not a cast or panic-producing conversion. Compression thresholds retain the full +32..1048576-byte setting range. Known body length/Content-Length is compared exactly; +unknown streaming lengths are eligible without buffering the full body. Actual +compression execution still requires the compiler-enabled acceptance suite. + +## Management in 0.3.0 + +Settings contains a direct Security shortcut (accounts, sessions and host ACLs) +and a vhost shortcut (SSL, headers, cache and pages). Compact is the default density; +Dense and Comfortable change spacing/control sizes without browser zoom or logo +redesign. The preference is local to the browser, not a global proxy setting. + +Global Pages configures error overrides, direct-IP and unknown-host responses. +See MANAGEMENT.md for inheritance, allowed statuses and security implications. +Access logs have moved out of the dashboard into a separate modal/docked window. +Filters stay above the scrollable rows; counts and exports use the same selection. + +## Named listeners, forwarding and diagnostics (0.4.0) + +As of 0.8.0, the vhost editor no longer asks for a free-form listener IP. Configure +listener IP/port/protocol once in this Settings section, then select named endpoints in +the vhost. The pre-production inline/global listener format is no longer accepted. This separates +socket intent from host routing and avoids duplicating listening IP configuration across +vhosts. + +`listeners.endpoints` is the only proxy-listener configuration. Each record has id, +name, scope, enabled and a binding with address, tls, http2, optional http2_settings, h2c and optional http3. Scope +is only a label. A vhost uses `frontend.listener_ids`; every reference must exist. +Conflicting protocols on shared sockets are rejected. +Changes to effective transport plans need restart, including H2 lifecycle and H3 limits. The UI +shows saved named entries and actual bound transports separately. + +`forwarding` controls trusted CIDRs and forwarded-proto acceptance. +`diagnostic_headers` exposes only `request_id` (false initially). Vhosts can override the diagnostic +policy and Server policy; absent/null means inherit. Runtime/cache/static/error +responses pass through the same final header policy. + +See INTEGRATIONS_040.md and HTTP3.md for complete JSON, limits and security details. + + +## 0.5.0 changes (override earlier baseline examples) + +A named TLS listener with omitted http3 uses default H3 limits. Explicit null is +persisted as disabled. Overlapping named sockets must match protocols AND H3 limits; +the error identifies owners and remedies. A changed binding requires restart, including +UDP protocol/limit changes. Check UDP port availability before upgrading. + +The diagnostic control is `request_id` (false by default). The removed hide_mock_origin field is not accepted; use normal response-header removal +for X-Mock-Origin. Existing custom Server values remain unchanged. + +Cache decision/object inspection is under Cache, separate from Overview. Basic +manual certificate issuance is under Certificates; TSIG is never a configuration +field. See CACHE_DIAGNOSTICS.md and ACME_RFC2136.md for exact scopes and limits. + +## 0.6 navigation and exporter + +The Settings page now has eight switching cards: Listeners, Response, Cache, +Headers, Forwarding, Pages, Prometheus and Security. Unsaved values remain in +place while switching; switching alone never writes configuration. Security is +not a separate sidebar item. Use arrow keys/Home/End in the tab bar. + +The Prometheus card controls enabled, bind IP/port, URL path, client CIDRs, +bearer-token environment variable name and optional collectors. Only the variable +name is exported; supply the actual secret to the service separately. See +PROMETHEUS.md for immediate revocation, restart rules and scrape configuration. diff --git a/docs/STREAMS.md b/docs/STREAMS.md new file mode 100644 index 0000000..bec2823 --- /dev/null +++ b/docs/STREAMS.md @@ -0,0 +1,61 @@ +# TCP/UDP streams + +RustProxy 0.13.0 exposes raw Layer-4 forwarding on the main **Streams** page, alongside **Vhosts** and **Redirects**. A stream combines one dedicated TCP or UDP listener with one upstream. Stream traffic does **not** enter the HTTP pipeline: no HTTP vhost routing, ACL, cache, headers, compression, Tinyauth or TLS termination is applied. + +## Dashboard workflow + +Use **Streams > New stream** or **Add TCP/UDP listener**. The modal mirrors the Vhost workflow and contains Details, Listener & upstream, and Limits tabs. Existing streams are shown as searchable cards with live runtime counters and RX/TX rate charts. + +A saved listener change is desired state only until RustProxy restarts. The page keeps the active startup listener visible and marks changed/new/removed stream configuration as pending restart. + +The HTTP/HTTPS listener editor remains under **Settings > Listeners**. Its bind addresses use the same chip-style interaction as vhost hostnames. Those named HTTP/HTTPS listeners are separate from stream listeners. + +## Configuration document + +```json +{ + "streams": [ + { + "id": "ssh", + "name": "SSH relay", + "enabled": true, + "protocol": "tcp", + "listen": "0.0.0.0:2222", + "upstream": "10.0.0.20:22", + "connect_timeout_seconds": 5, + "idle_timeout_seconds": 300, + "max_connections": 512 + }, + { + "id": "dns-udp", + "name": "DNS UDP", + "enabled": true, + "protocol": "udp", + "listen": "0.0.0.0:5353", + "upstream": "10.0.0.53:53", + "connect_timeout_seconds": 5, + "idle_timeout_seconds": 30, + "max_connections": 2048 + } + ] +} +``` + +TCP uses a bidirectional byte relay with a connect timeout, inactivity timeout and connection limit. UDP creates a bounded per-client session mapped to one connected upstream socket, expires it after inactivity and limits concurrent sessions. + +## Runtime telemetry + +The administration status snapshot exposes fixed-cardinality metrics for active startup streams. The Streams page displays: + +- active TCP connections or UDP client sessions; +- accepted connections/sessions and completed sessions; +- capacity rejects and relay/upstream errors; +- bytes received from clients and returned from upstreams; +- UDP packet counters; +- live RX/TX rates derived from monotonic backend byte counters. + +The chart history exists only in the current browser panel session. Backend counters are process-lifetime counters and reset on process restart. + +## Listener safety + +RustProxy validates collisions before publishing configuration. TCP stream listeners may not overlap HTTP/TLS, administration or Prometheus TCP listeners. UDP stream listeners may not overlap HTTP/3 UDP listeners. TCP and UDP may intentionally use the same numeric port because they are different transports. Direct loops into another RustProxy listener of the same transport are rejected. diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md new file mode 100644 index 0000000..3add57d --- /dev/null +++ b/docs/TEST_REPORT.md @@ -0,0 +1,52 @@ +# Verification report - RustProxy 0.24.5 + +## Scope + +RustProxy 0.24.5 is a protocol-framing and cache-memory maintenance release over 0.24.4. It keeps the 0.24.4 process-local THP mitigation, fixes the H3 -> H1 boundary for actually empty request bodies whose length is initially unknown, and hardens the existing HOT RAM -> COLD disk cache lifecycle without changing the COLD storage format. + +## Source contracts reviewed + +- `ReceiveBody` in `src/http3.rs` reports end-of-stream only after the H3 DATA side reaches EOF and trailers are resolved; absence of `Content-Length` alone is not treated as proof of an empty body. +- `normalize_h3_h1_unknown_body()` runs only for HTTP/3 requests selected for an HTTP/1.1 upstream and only when no declared/exact body length is already available. +- EOF replaces the request body with `Body::empty()`. Non-empty first frames are replayed through `ReplayBody` and the remaining request body stays streaming. +- HTTP/2 upstreams, non-H3 downstreams and declared `Content-Length` requests are not pre-read by this normalization. +- The change is generic to body-capable methods; there is no Proxmox-specific or DELETE-to-H2 fallback. +- Published HOT objects are bounded by `max_bytes` using body backing capacity plus directly owned metadata and a conservative HOT-index charge; logical payload bytes are reported separately. +- Fill buffers reserve a bounded transient budget before growth, enforce `max_object_bytes` per chunk, and make HOT LRU room before further cache-buffer allocation. Failure leaves the response streaming uncached. +- HOT -> COLD demotion moves the `Entry` rather than cloning the payload. The `Bytes` allocation remains charged to transient RAM while queued/writing and disappears from the persisted `ColdEntry` after the write completes. +- The COLD writer queue is bounded by both object count and bytes; full COLD reads/promotions/revalidation reads reserve transient RAM before loading a body. +- Global `cache.enabled=false` is authoritative over vhost overrides. HOT/object values no longer have the old artificial 1 GiB configuration ceiling. +- Settings exposes conservative/recommended/performance presets derived from effective host/cgroup memory, plus non-blocking high-budget/headroom and inconsistent transient/object/queue warnings. A 2 GiB effective-memory VM gets approximately 400 MiB HOT / 96 MiB transient / 16 MiB max object / 48 MiB COLD queue under the recommended profile. +- Release identity is 0.24.5 across Cargo/package/UI/OpenAPI/release helpers. The existing dependency graph is unchanged; only the root `rust-proxy` package version changes. + +## Verification status in this workspace + +| Check | Status | +|---|---| +| `python3 scripts/preflight.py` | PASS | +| `python3 scripts/langs_check.py` | PASS | +| Browser JavaScript `node --check` | PASS | +| Shell `bash -n` | PASS | +| Python syntax / bytecode compile | PASS | +| Docker agent `go test ./...` / `go vet ./...` / `go build ./...` | PASS | +| Existing `Cargo.lock` dependency graph | PRESERVED; root package version updated to 0.24.5 | +| `python3 scripts/check_source.py` | PASS | +| `python3 scripts/release_smoke.py` | PASS | +| `./dev.sh fmt` / `cargo fmt --all` | BLOCKED: Rust/Cargo toolchain unavailable in this workspace | +| `./dev.sh check` / `cargo check --locked --all-targets` | BLOCKED: Rust/Cargo toolchain unavailable in this workspace | +| `./dev.sh test` Rust phase | BLOCKED: Rust/Cargo toolchain unavailable in this workspace | +| H1/H2/H3 live method matrix | test-host acceptance gate | +| Proxmox DELETE H3 access-log verification | test-host acceptance gate | +| HOT 8 MiB sustained-load/RSS acceptance | test-host acceptance gate | +| HOT 2 GiB bounded-admission acceptance | test-host acceptance gate | +| 5 s HOT -> COLD demotion/RSS/transient drain | test-host acceptance gate | +| Oversized-object streaming without full cache buffering | source regression added; runtime acceptance gate remains | +| Concurrent COLD read/write under small transient budget | test-host acceptance gate | + +## Protocol acceptance target + +Run GET/POST/PUT/PATCH/DELETE over H1, H2 and H3. For DELETE, test both an empty body and a body-bearing request (use a mock upstream if the chosen Proxmox endpoint does not accept a DELETE body). The original H3 empty DELETE must no longer produce `501`; H1/H2 behavior must remain unchanged; Proxmox must receive `DELETE` and the same URI; and a raw HTTP/1.1 upstream must show non-chunked zero-length framing for the empty H3 case. + +For cache acceptance, test a small 8 MiB HOT tier under sustained traffic, a deliberately large 2 GiB HOT tier, `cold_after_seconds=5`, a response above `max_object_bytes`, and concurrent COLD promotion/demotion under a small transient limit. Published `hot_allocated_bytes` must remain within the HOT limit; transient/queue usage must drain after COLD persistence; large responses must keep streaming after their cache candidate is rejected; and RSS must plateau rather than track total traffic volume. + +See [V0.24.5_AUDIT.md](V0.24.5_AUDIT.md) for the source/spec matrix and remaining runtime gates. diff --git a/docs/TRANSFORM.md b/docs/TRANSFORM.md new file mode 100644 index 0000000..c10bec5 --- /dev/null +++ b/docs/TRANSFORM.md @@ -0,0 +1,41 @@ +# Static-response transformation + +RustProxy 0.22.3 retains static transformation between origin decoding and shared-cache +storage. A transformed identity representation is cached once and downstream compression +is selected later for each client. Per-vhost and Docker-discovery transform opt-in remain +disabled by default even when the global engine is available. + +## Administration dashboard + +The main **Transform** workspace reads `GET /api/transform` and exposes in-memory runtime +statistics globally or per vhost: + +- responses examined, transformed, skipped/fail-open and failed; +- transformed cache HITs (served without running the transformer again); +- original, transformed and saved bytes before client compression; +- average transform time; +- applied transforms by JavaScript/CSS/HTML/SVG/JSON; +- bounded skip/fail-open reason counters; +- a per-vhost effectiveness table. + +Counters reset on process restart. They are diagnostic telemetry, not persisted cache +metadata. The Cache workspace separately marks stored transformed objects. + +## Cache diagnostics + +When `cache.expose_cache_status=true`, transformed cached representations are identified +with the RFC 9211 field plus an extension parameter: + +```http +Cache-Status: RustProxy; hit; ttl=556488; detail="cache_hit"; transformed +``` + +The field remains `Cache-Status`, not `X-Cache-Status`. HTTP/2 and HTTP/3 normalize field +names to lowercase on the wire. Origin-provided `Cache-Status` and `X-Cache-Status` values +are removed before RustProxy emits its own diagnostics. + +## Release status + +The dashboard/telemetry does not change the existing v0.22.3 release gate. JavaScript and +CSS still require mature parser-backed minifiers, and the complete Rust compiler/runtime +acceptance gate must pass before release acceptance. diff --git a/docs/TRANSPORT.md b/docs/TRANSPORT.md new file mode 100644 index 0000000..76ff356 --- /dev/null +++ b/docs/TRANSPORT.md @@ -0,0 +1,211 @@ +# Transport contract + +This document describes the implementation contract of 0.22.3. It is not a record +of successful Rust execution; see TEST_REPORT.md for verification status. + +## Protocol matrix + +| Connection | Implemented mode | +|---|---| +| Client to cleartext listener | HTTP/1.1 plus opt-in HTTP/2 prior knowledge and HTTP/1.1 `Upgrade: h2c`; WebSocket Upgrade on H1 and RFC 8441 Extended CONNECT on H2 when enabled per vhost | +| Client to TLS listener | HTTP/1.1 or HTTP/2 through ALPN; WebSocket uses H1 Upgrade by default or opt-in RFC 8441 Extended CONNECT on H2 | +| Client to HTTP/3 TLS listener | HTTP/3 over QUIC; RFC 9220 WebSocket Extended CONNECT only when a bound vhost explicitly opts in | +| Proxy to HTTP backend, `http1` | HTTP/1.1 | +| Proxy to HTTPS backend, `http1` | TLS with HTTP/1.1 | +| Proxy to HTTP backend, `http2` | HTTP/2 prior knowledge; the backend must support it | +| Proxy to HTTPS backend, `http2` | Prefer HTTP/2 through ALPN, retain HTTP/1.1 | +| Proxy to HTTPS backend, `auto` | ALPN HTTP/2 or HTTP/1.1 | +| Proxy to HTTP backend, `auto` | HTTP/1.1; no automatic h2c upgrade | +| Proxy to a WebSocket backend | HTTP/1.1 Upgrade over HTTP or HTTPS | + +Cleartext downstream HTTP/2 is an explicit listener opt-in. The same listener accepts both +the HTTP/2 prior-knowledge preface and a standards-oriented body-free HTTP/1.1 `Upgrade: h2c` +bootstrap. Upgrade requests must include `Connection: Upgrade, HTTP2-Settings`, exactly one +`Upgrade: h2c` and one valid base64url `HTTP2-Settings` field. RustProxy sends `101`, waits for +the client's normal HTTP/2 connection preface and initial SETTINGS frame, then reconstructs the +accepted HTTP/1.1 request as HTTP/2 stream 1 before handing later streams to Hyper's ordinary H2 +state machine. Body-bearing or malformed upgrade requests are rejected before `101`. HTTP/1.1 +remains available on the same cleartext listener. + +Enabling WebSocket on a vhost keeps the HTTP/1.1 Upgrade path by default. `websocket_rfc8441` +and `websocket_rfc9220` are independent per-vhost opt-ins for HTTP/2 and HTTP/3 Extended +CONNECT with `:protocol=websocket`. HTTP/2 listeners advertise ENABLE_CONNECT_PROTOCOL when +serving H2; QUIC advertises it when required by the HTTP/3 transport. Routing still rejects +Extended CONNECT on vhosts that did not opt in. Ordinary CONNECT and other Extended CONNECT +protocols remain rejected because RustProxy is not an open forward proxy. RFC 8441 may bridge +to an HTTP/1.1 WebSocket backend or use a backend explicitly configured for HTTP/2. + +HTTP/2 and `auto` upstream configuration require `preserve_host: false` in this +release. The target URI, pool key and HTTP/2 authority use the configured backend. +Use HTTP/1.1 for a backend that requires the original Host. The original hostname +is also available through the proxy-generated `X-Forwarded-Host` field. + +For ordinary proxy traffic, an explicit `protocol=http2` backend is forwarded with an +HTTP/2 request version. Cleartext `http://` uses h2 prior knowledge. HTTPS keeps the +existing ALPN policy and offers HTTP/2 with HTTP/1.1 compatibility; RustProxy does not +replay a failed TLS request merely to downgrade protocols. + +Balanced routes may perform bounded failover retry only for body-free `GET`, `HEAD` and +`OPTIONS`. Defaults allow one retry on connection failure, response-header timeout or +`502/503/504`, always selecting another currently healthy backend. Path-pinned routes, +WebSocket handshakes and write/body-bearing requests are never automatically replayed. + +## DNS and upstream TLS + +Only explicit HTTP/HTTPS backend origins are accepted; no credentials, path, query +or fragment are allowed. Client requests cannot choose an arbitrary upstream URL. + +Each new DNS connection uses a guarded resolver. Every returned IPv4/IPv6 address +must pass the configured address policy, with IPv4-mapped IPv6 normalized for checks. +A mixed safe/unsafe response is rejected as a whole. Up to 32 answers and 16 OS +resolver workers are allowed. Validated answers go directly to the HTTP connector: +there is no second unvalidated DNS lookup before dialing. Existing pooled sockets +remain bound to their original peer until closed; they do not silently move when +DNS changes. New snapshots get new connection pools. + +`allow_private` is explicit approval for eligible private/loopback addresses, not a +universal SSRF bypass. `address_allowlist` further restricts eligible addresses by +CIDR. Include both address families when a hostname legitimately resolves to both. +Link-local/metadata, multicast, unspecified and certain special ranges remain +blocked. Remote origins may use any valid backend port, including 9090. A target that +resolves to an actual/local RustProxy proxy, TLS or administration listener remains +blocked by default, including validated DNS answers. An administrator may intentionally +override only that self-target guard per backend with `accept_local_listener_risk: true`; +loopback/private targets still require `allow_private: true`. This opt-in can create a +proxy loop or expose the administration plane, so keep it disabled unless that route is +deliberate and separately access-controlled. For example, an intentional local backend +`http://127.0.0.1:9090` requires both `allow_private: true` and +`accept_local_listener_risk: true`; this only permits the connection and does not bypass +the administration listener's own Host/Origin/authentication policy. The special-range +policy is a basic safeguard, not a substitute for network egress controls or an independent +security audit. + +HTTPS upstream transport remains encrypted, but certificate-chain/hostname verification +is **off by default**. Set `tls_verify: true` to authenticate the backend identity. An +optional `tls_ca_pem` adds up to 16 trust anchors within 64 KiB per backend when +verification is enabled. The configured `tls_server_name`, when present, determines TLS +identity while DNS/TCP still targets the configured URL. Upstream redirects are returned +to the client, not followed internally. + +## Public certificates + +`dev.sh` enables `127.0.0.1:8443` by default. Direct binary startup enables TLS only +when `serve --tls-listen ADDRESS` or the corresponding environment setting is used. +Changing listener addresses or enabling a new listener requires a restart. + +`data/certs/default.pem` and `default.key` are created once when needed. Generated +certificates are self-signed development material; they do not provide public trust. +Explicit bindings use exact hostname names and already-present certificate/key files. +The API stores filenames and names, never private key bytes. CLI generate/import +refuses to overwrite named files. Input size, regular-file status, private-key +permissions, matching keys, chain parsing and name coverage are validated. + +```bash +rust-proxy tls generate --name app --hosts app.localhost,alias.localhost +rust-proxy tls import --name app-v2 --cert fullchain.pem --key private.key +rust-proxy tls deploy --id shared-cert \ + --cert /etc/letsencrypt/live/example/fullchain.pem \ + --key /etc/letsencrypt/live/example/privkey.pem +rust-proxy tls reload +``` + +Select these files in the panel or `tls.certificates` configuration. Set +`tls.default_certificate` to a binding ID to change the fallback. Unknown/no SNI +receives that fallback, not a default HTTP route. Unknown Host still returns 404. +Wildcard/SAN coverage and certificate expiry/issuer/renewal status are exposed by the certificate inventory; certificate ownership remains explicit per certificate id. + +`tls deploy` is intended for an external issuer such as a system Certbot. Certbot's +`live/*.pem` symlinks are resolved locally; the final files are bounded regular files and +the private key must not be group/world readable. The local CLI performs bounded file/permission checks and sends the PEM pair through the authenticated loopback deploy API. The running RustProxy process treats the request as a convergent upsert: it creates a missing manual `certificate_id`, rotates changed material through immutable generation-specific files, and returns a no-op for an identical RustProxy-owned generation. The same id may be assigned to many vhosts, which all switch together when the pair changes. RustProxy-managed ACME certificate ids are deliberately rejected by this command so one lineage never has two renewal owners. + +`POST /api/certificates/reload` and `rust-proxy tls reload` re-read all currently configured +certificate/key files and publish a fresh public TLS snapshot without changing the desired +configuration or its revision. This supports an operator that replaces the already configured +files by another safe local mechanism. Validation and file reads happen on bounded control-plane +workers; invalid material leaves the previous snapshot active. New public TLS/H2 and HTTP/3 +handshakes use the refreshed snapshot, while established sessions/tunnels are not renegotiated. +The administration TLS listener remains startup-fixed and reports that a service restart is +required when its selected certificate changed. + +For an externally managed/manual certificate whose configured files were replaced in place, `POST /api/certificates/{certificate_id}/reload` provides the same revision-stable hot-publication contract while targeting that certificate ID. The TLS resolver is immutable, so RustProxy validates and republishes a complete snapshot atomically; this avoids partially updated SNI state. Managed ACME certificate IDs are not accepted by this manual reload path. + +## WebSocket behavior + +The default WebSocket path validates an HTTP/1.1 GET upgrade, version 13, one valid key, +connection upgrade tokens and a body-free handshake. RFC 8441 over HTTP/2 and RFC 9220 over +HTTP/3 must be enabled explicitly for the selected vhost and use Extended CONNECT rather +than H1-only Upgrade/Connection/Key headers. When RFC 8441/9220 is bridged to an HTTP/1.1 +backend, RustProxy performs and validates the backend RFC 6455 handshake before exposing a +successful downstream tunnel. Direct HTTP/2 upstream RFC 8441 validates the 2xx response and +selected subprotocol. Protected handshake fields cannot be replaced by generic custom-header +rules. Unsupported/malformed upgrades fail; unsolicited upstream 101 responses become a +gateway error. + +After an accepted handshake the proxy copies bytes in both directions using bounded +buffers and backpressure. It does not aggregate messages, interpret application +payloads, log frames or hardcode a particular WebSocket application. Fragmentation, +control frames and negotiated extensions remain the peers' responsibility. +The demo echo server separately caps messages/frames at 64 KiB. + +Request and TCP-connection leases survive Hyper's upgrade and are held until the +tunnel closes. An independent idle timeout observes traffic in either direction. +Shutdown cancels tunnels; routing/ACL changes affect new handshakes, not existing tunnels. +The administration API and dashboard expose active tunnels with a stable exchange ID, client, +host, upstream, transport, duration and RX/TX counters. An administrator can cancel one tunnel; +the action and supplied reason are written to the audit log. + +## HTTP/2 connection retirement + +Downstream H2 idle retirement is activity-aware: RustProxy tracks transport I/O and accepted streams, rechecks that a connection is actually unused, then asks Hyper for graceful shutdown. Hyper sends GOAWAY and refuses newer streams while accepted streams continue. Each H2/H2C listener uses `binding.http2_settings.max_connection_lifetime_seconds` for the independent absolute lifetime (zero disables it) and `binding.http2_settings.drain_timeout_seconds` for the maximum drain before TCP is forcibly dropped. Older configurations without per-listener values inherit the legacy top-level `http2` settings. RFC 8441 Extended CONNECT/WebSocket streams remain active for this accounting until their tunnel body ends. + +## Resource and timeout defaults + +| Resource | Default / bound | +|---|---| +| Shared public TCP connections, HTTP + TLS | 512; configurable up to 4096 | +| Active request/body/tunnel exchanges | 256; configurable up to 4096 | +| Concurrent HTTP/2 streams per public connection | 64 | +| HTTP/2 header list / send buffer | 32 KiB / 64 KiB | +| HTTP/2 stream / connection receive window | 65,535 bytes / 1 MiB | +| HTTP/2 idle / max lifetime / drain | 300 s / disabled (0) / 10 s | +| Public HTTP/1 header timeout | 10 seconds | +| TLS accept / upstream connect including TLS | 10 seconds | +| DNS lookup caller timeout / OS workers | 5 seconds / 16 | +| Backend response-header deadline | 120 seconds, including upload until headers | +| HTTP body idle timeout | 60 seconds | +| WebSocket idle timeout / upgrade completion | 300 seconds / 10 seconds | +| Idle pooled upstream connections | 4 per backend origin, 30-second idle timeout | +| Configuration writers / log-viewer streams | 8 / 8 | + +CLI serve flags expose connection/in-flight counts and HTTP body, WebSocket idle, +upstream and shutdown timeouts. OS DNS calls cannot be forcibly cancelled; their +worker permits remain occupied until completion. Runtime shutdown stops waiting +for blocking workers after an additional bounded timeout. + +Request and response bodies stream without collection, including trailers. Body-bearing/write requests are not transparently replayed. Replay-safe body-free +GET/HEAD/OPTIONS may use the bounded backend retry policy described above. Bytes counted at the body layer are not +acknowledged network bytes. WebSocket counters measure raw frame bytes separately +from HTTP body counters and exclude TLS/TCP overhead. + +## Vhost sockets and keepalive in 0.22.3 + +Public connections across global and vhost endpoints share the same connection +budget. Per-vhost routing uses the actual accepted local IP/port/TLS state, including +when an OS wildcard socket is shared. See VHOSTS.md for overlap rules and restrictions. +Changing sockets or their ALPN policy needs restart. Certificate refresh remains hot. + +TLS `http2: true` advertises h2 followed by http/1.1; false advertises HTTP/1.1 only. +An HTTP/1.1-only client, including one without ALPN, can connect when h2 is enabled. +HTTPS origins configured `http2` also negotiate h2 or HTTP/1.1. This is one TLS +negotiation, not a failed-request retry or TLS-verification downgrade. Plaintext +h2c remains explicit and strict: when `h2c: true`, a cleartext listener accepts both HTTP/2 +prior knowledge and validated HTTP/1.1 `Upgrade: h2c`. The Upgrade bootstrap is body-free, +requires `HTTP2-Settings`, and becomes HTTP/2 stream 1 after `101`. Optional downstream HTTP/3 +remains a separate QUIC listener capability (see HTTP3.md). + +Pool keepalive settings configure idle connection reuse, idle timeout/limit, TCP +keepalive and HTTP/2 active-connection pings. Health probes are separate no-reuse +HTTP/1.1 or raw TCP operations; their concurrency cap is 16. Existing active exchanges +are not killed by disabling reuse or draining a member. Config publication rebuilds +client pools while preserving compatible observations and existing exchange leases. +The full contract and bounds are in VHOSTS.md. Keepalived/VRRP is not involved. diff --git a/docs/V0.21.0_AUDIT.md b/docs/V0.21.0_AUDIT.md new file mode 100644 index 0000000..175d920 --- /dev/null +++ b/docs/V0.21.0_AUDIT.md @@ -0,0 +1,77 @@ +# RustProxy 0.21.0 specification audit + +## Status vocabulary + +- **SOURCE-FIXED**: a concrete source defect found during this audit was changed and regression coverage was added. Rust execution is still pending in this workspace. +- **SOURCE-AUDITED**: the required implementation and source-level regression coverage are present; live/runtime acceptance is still pending where applicable. +- **DEFERRED**: intentionally moved out of the closed 0.21.0 feature scope. +- **RUNTIME OPEN**: source work is present, but the required compiler/live test cannot be completed in this workspace. + +## Main TODO audit + +| # | Requirement | Status | Audit result | +|---:|---|---|---| +| 1 | Transformed response stored in cache | SOURCE-FIXED | `Plan::restrict()` uses transformed `Content-Length`; transformed bytes are stored and reused. | +| 2 | Full request `max-stale` semantics | SOURCE-FIXED | Freshness and retention are separated; bounded/unbounded `max-stale` and revalidation flags have regressions. | +| 3 | Request `max-age=N` | SOURCE-FIXED | Only `max-age=0` forces validation unconditionally; other values are checked against cached age. | +| 4 | HEAD + transform + cache | SOURCE-FIXED / RUNTIME OPEN | Cacheable HEAD fills through internal GET; cache-disabled empty HEAD keeps origin representation headers. H1/H2/H3 live acceptance remains open. | +| 5 | Incomplete body / HEAD cache fill | SOURCE-FIXED | Dropped/error/trailer-incomplete fills are rejected and counted; a later complete fill can succeed. | +| 6 | Route cache tri-state | SOURCE-AUDITED | `Option` preserves inherit/true/false through model and UI; effective routing diagnostics are logged. | +| 7 | Parser-based JavaScript minifier | **DEFERRED** | 0.21.0 keeps the conservative scanner; a parser-backed replacement is deferred rather than changing the resolved dependency graph during release closure. | +| 8 | Parser-based CSS minifier | **DEFERRED** | 0.21.0 keeps the conservative scanner; a parser-backed replacement is deferred rather than changing the resolved dependency graph during release closure. | +| 9 | HTML minification | SOURCE-AUDITED | Optional/default-off conservative transform preserves whitespace and raw-text elements and removes only safe comments. | +| 10 | SVG optimization | SOURCE-AUDITED | Optional/default-off conservative comment cleanup preserves text/tspan whitespace and CDATA; it is not documented as a full optimizer. | +| 11 | JSON compacting | SOURCE-AUDITED | JSON is parsed and compact-serialized; malformed JSON fails open; transformed length/ETag/cache behavior is covered. | +| 12 | Formal transform pipeline | SOURCE-AUDITED | Proxy flow is origin -> decode -> transform -> canonical cache representation -> client encoding. | +| 13 | Content-Encoding | SOURCE-FIXED / RUNTIME OPEN | gzip/br/zstd decoding is implemented and transformed identity responses remove the `Accept-Encoding` Vary dimension only after canonicalization. Live all-codec acceptance remains open. | +| 14 | ETag / Last-Modified validators | SOURCE-FIXED | Origin validators are retained separately; client conditionals use the representation ETag; origin revalidation uses origin validators. | +| 15 | `Cache-Control: no-transform` | SOURCE-AUDITED | Origin policy is captured before response rules; final rules may add protection; cache storage remains allowed and origin representation/validators are retained. | +| 16 | `stale-if-error` | SOURCE-AUDITED | Separate metadata/window and fallback paths for origin errors/timeouts are present; revalidation flags block stale. | +| 17 | `stale-while-revalidate` | SOURCE-AUDITED | Immediate stale delivery, bounded background refresh and exact-variant single-flight are implemented. | +| 18 | `must-revalidate` | SOURCE-AUDITED | Storage remains allowed; stale serving through SWR/SIE/max-stale is blocked. | +| 19 | `proxy-revalidate` | SOURCE-AUDITED | Shared-cache stale behavior mirrors `must-revalidate`. | +| 20 | Cache metadata | SOURCE-AUDITED | TTL/stale windows/revalidation flags/origin validators persist in RAM and Cold Cache metadata. | +| 21 | Explicit cache object states | SOURCE-AUDITED | `Fresh`, `StaleButServable`, and `Expired` are explicit decisions rather than a single expiry comparison. | +| 22 | `s-maxage` precedence | SOURCE-AUDITED | Shared-cache parsing keeps `s-maxage` ahead of `max-age` and regression coverage exists. | +| 23 | `immutable` | SOURCE-FIXED | Duplicate/argument forms no longer invalidate Cache-Control; freshness is not extended and explicit client validation still works. | +| 24 | Cold Cache sharding | SOURCE-AUDITED | `00..ff` SHA-256 sharding, lazy directories, startup scan and no flat-cache migration are implemented. | +| 25 | Cold Cache GC | SOURCE-AUDITED | Async bounded GC uses configured interval, high/low watermarks and shared eviction accounting. | +| 26 | Cold read vs GC race | SOURCE-FIXED | Reads are pinned with entry generation; an old read guard cannot unpin a replacement using the same cache key. | +| 27 | Manual selective cleanup | SOURCE-AUDITED | Async cleanup reuses GC/eviction selection and reports removed objects/freed bytes. | +| 28 | Global transform settings | SOURCE-AUDITED | Global engine/type/extensions/body-limit settings exist. | +| 29 | Per-vhost transform opt-in | SOURCE-AUDITED | Per-vhost transform remains disabled by default even when the global engine is enabled. | +| 30 | Docker transform opt-in | SOURCE-AUDITED | Docker assignments have separate default-off transform configuration propagated to runtime vhosts. | +| 31 | Cache reasons | SOURCE-FIXED | `fill_incomplete`, `transform_length_mismatch`, `transform_error` and other bounded reasons are recorded. | +| 32 | Routing / transform diagnostics | SOURCE-FIXED | Access logs carry vhost, route, effective cache/transform state; the admin UI now has a dedicated Transform runtime dashboard, and transformed cache objects are marked in both Cache UI and `Cache-Status`. | +| 33 | Transform + cache integration | SOURCE-FIXED / RUNTIME OPEN | Source regression now exercises MISS -> transform -> store -> HIT for JS/CSS/HTML/SVG/JSON with ETag/length/body checks. Rust execution remains open. | +| 34 | Transform + compression | SOURCE-AUDITED / RUNTIME OPEN | Canonical identity cache representation and codec negotiation exist; live identity/gzip/br/zstd end-to-end acceptance remains open. | +| 35 | `Vary` | SOURCE-FIXED | Canonicalized identity drops only `Accept-Encoding`; custom Vary dimensions are retained and exact-variant SWR locking is preserved. | +| 36 | Query string | SOURCE-FIXED | `?v=1`, `?v=2` and `?F` remain distinct cache keys; `bypass_query` is a request policy and transform does not rewrite the key. | +| 37 | Conditional requests | SOURCE-FIXED / RUNTIME OPEN | Representation ETag vs origin validator behavior is covered at source-test level; live integration remains open. | +| 38 | Restart | SOURCE-FIXED / RUNTIME OPEN | Cold restart regression preserves transformed ETag, origin validators, stale metadata and `no-transform` original representation. | +| 39 | Rust compiler quality gate | **RUNTIME OPEN** | `cargo`, `rustc` and `rustfmt` are unavailable here; fmt/check/test/clippy/release build cannot be claimed. | +| 40 | Remaining tests | PARTIAL | Source/package/SQLite/upgrade/JS/JSON/OpenAPI/shell and Docker-agent gates can run here; Rust-backed suites remain open. | +| 41 | Packaging | SOURCE-AUDITED / RUNTIME OPEN | 0.21.0 source packaging is deterministic and the feature scope is closed; compiler/live release-host verification remains open. | + +## Passive Backend Health addendum + +The later Passive Backend Health specification is implemented as an opt-in policy: + +- ordinary request failures remain observable when passive health is disabled; +- `passive_health.enabled` defaults to `false` for normal and Docker-generated vhosts; +- active TCP/HTTP health remains independent; +- connect/timeout/I/O/HTTP-5xx/slow-response classes have individual switches; +- passive `fall`/`rise` can override active thresholds; +- active and passive counters/state are separate; +- current unhealthy source/reason hands off correctly when one health domain recovers while the other remains unhealthy; +- Docker assignments persist and reapply the policy across discovery rebuilds; +- OpenAPI documents passive health for both Docker assignments and ordinary vhost `load_balancing`; +- Prometheus keeps unconditional observation counters and exposes passive-health aliases without making observation itself a health decision. + +## Deferred follow-up and release-host verification + +- Parser-backed JavaScript and CSS minifiers are deferred to a later development cycle; 0.21.0 intentionally retains the conservative implementations and the existing resolved dependency graph. +- The release host must run the complete Rust 1.88+ gate: `cargo fmt --check`, `cargo check --all-targets`, `cargo test --all-targets`, `cargo clippy --all-targets -- -D warnings`, and `cargo build --release`. +- The compiled binary must pass the required live H1/H2/H3/cache/compression/restart acceptance tests before deployment. + +The 0.21.0 source is feature-complete and packaged as release source; runtime acceptance remains explicitly external to this toolchain-less workspace. diff --git a/docs/V0.22.0_AUDIT.md b/docs/V0.22.0_AUDIT.md new file mode 100644 index 0000000..f39041b --- /dev/null +++ b/docs/V0.22.0_AUDIT.md @@ -0,0 +1,106 @@ +# RustProxy 0.22.0 specification audit + +This audit maps the 0.22 cache work directly to the requested scope. The implementation status below is based on the current source tree. Rust unit/integration tests are present, but this packaging workspace has no `cargo`, `rustc` or `rustfmt`; compiler/runtime acceptance therefore remains open until the release-host gate runs. + +## P0 - request collapsing / cache stampede protection + +| Requirement | Status | Implementation | +|---|---|---| +| Per-cache-key lock / single-flight | IMPLEMENTED | `src/cache/collapse.rs` tracks one foreground fill flight per primary cache key. | +| First request fills, followers wait | IMPLEMENTED | Leader owns `FillLeaderGuard`; followers await the retained completion state. | +| Wait timeout | IMPLEMENTED | `cache.collapse_wait_timeout_ms`, validated to 10..60000 ms. | +| Controlled timeout fallback | IMPLEMENTED | `cache.collapse_timeout_fallback`: origin or stale-if-available. | +| Release on error/cancel | IMPLEMENTED | Guard `Drop` removes the flight and publishes completion even after purge cancellation. | +| SWR integration | IMPLEMENTED | Foreground collapsing is separate from and compatible with the existing background-revalidation dedupe. | +| Metrics | IMPLEMENTED | Collapsed fills, timeouts and current waiters are exposed in cache metrics. | +| Concurrent MISS/variant regressions | SOURCE-TESTED | Rust tests cover wait-until-publish, timeout/leader drop, cancelled-follower metric cleanup and retry after a different `Vary` representation wins the first fill. | + +## P1 - Range / HTTP 206 + +| Requirement | Status | Implementation | +|---|---|---| +| `Range: bytes=...` | IMPLEMENTED | One byte range, including bounded, open-ended and suffix forms. | +| `206 Partial Content` | IMPLEMENTED | Local cache slicing returns 206 with exact body length. | +| `Content-Range` / `Accept-Ranges` | IMPLEMENTED | Emitted for satisfiable cached ranges; unsatisfied ranges return 416 with `bytes */len`. | +| `If-Range` | IMPLEMENTED | Strong ETag and HTTP-date handling; weak validators do not match. | +| One range without full response buffering | IMPLEMENTED | Cache HIT slices the already-complete cached object; origin Range responses remain streamed. | +| Multipart decision | DEFINED | Multipart/multiple/unsupported Range forms are passed through to origin and are not synthesized or cached as separate range objects. | +| Compression/transform policy | DEFINED | Origin Range path requests identity encoding and skips transformation/client compression; local range is cut from the canonical complete cached representation. | +| Cache HIT + Range without origin | IMPLEMENTED | Complete cached representation serves the requested single range directly. | +| Preserve validators/Age | IMPLEMENTED | Cached range response retains cache validators and cache Age; only range framing/length/status is rewritten. | +| Revalidation + Range | IMPLEMENTED | A stale complete object revalidated by origin 304 is then evaluated again for 206/416/If-Range behavior. | +| Range regressions | SOURCE-TESTED | Rust tests cover 206, suffix/open-ended ranges, 416, validators, `If-Range`, duplicate fields and multipart rejection. | + +## P2 - extended stale-if-error policy + +| Requirement | Status | Implementation | +|---|---|---| +| Timeout / connection / DNS / TLS classes | IMPLEMENTED | Independent booleans select eligible transport failure classes. | +| Optional HTTP status list | IMPLEMENTED | `stale_if_error_statuses`, max 32 unique 5xx values; default `500,502,503,504`. | +| Backward-compatible default | IMPLEMENTED | Default transport classes remain enabled and the previous 5xx set is preserved. | +| Regression coverage | SOURCE-TESTED | Policy test covers class/status inclusion and exclusion; existing RFC 5861 window tests remain. | + +## P2 - proactive cache refresh + +| Requirement | Status | Implementation | +|---|---|---| +| Threshold before expiry | IMPLEMENTED | `proactive_refresh_before_expiry_seconds`. | +| Limit fraction without traffic | IMPLEMENTED | Deterministic `proactive_refresh_percent` sampling, default disabled. | +| Reuse existing revalidation | IMPLEMENTED | Reuses validator-based `refresh_cache_once` and background-revalidation guard. | +| Avalanche protection | IMPLEMENTED | Per-key schedule dedupe plus `proactive_refresh_max_inflight`; a just-filled object can schedule refresh without another client request. | +| Regression coverage | SOURCE-TESTED | Guard test covers schedule dedupe and global concurrency limit. | + +## P1 - selective Cache PURGE / BAN + +### PURGE + +| Requirement | Status | Implementation | +|---|---|---| +| Exact URL | IMPLEMENTED | Exact path/query or absolute URL selector. | +| Host + URL | IMPLEMENTED | Optional host/authority filter combines with exact/prefix/pattern selection. | +| URL prefix | IMPLEMENTED | Prefix matches the actual request target, including query text. | +| Cache key | IMPLEMENTED | Exact 64-hex primary cache key selector. | +| Cache tag | IMPLEMENTED | `Cache-Tag` values are parsed/stored and can be purged by tag. | +| Regex/pattern | IMPLEMENTED | Rust `regex` engine with 512-character input bound and 1 MiB compiled-size bound; every regex purge/BAN requires explicit broad-operation confirmation. | +| RAM + persistent/cold | IMPLEMENTED | Matching hot and Cold Cache objects are removed; cold file deletion is queued. | +| Query + real cache identity | IMPLEMENTED | Stored target and primary cache key are both available to invalidation. | +| `Vary` variants | IMPLEMENTED | Matching one representation removes the whole primary-key variant family. | +| Pre-PURGE fill race | IMPLEMENTED | Selective PURGE advances the publication generation, so any fill started before the operation cannot publish afterward; matching single-flight waiters are also released. | + +### BAN + +| Requirement | Status | Implementation | +|---|---|---| +| Rule without immediate full scan | IMPLEMENTED | BAN creation only records a rule. | +| Lookup enforcement | IMPLEMENTED | Hot/cold lookup rejects objects whose creation predates a matching active BAN. | +| GC | IMPLEMENTED | Lifecycle maintenance removes banned objects incrementally with a bounded batch. | +| Remove one rule | IMPLEMENTED | Authenticated `DELETE /api/cache/bans/{id}` and Cache-panel action. | +| Rule count/TTL bound | IMPLEMENTED | Maximum 1024 active rules; TTL is bounded to 1..604800 seconds. | +| New objects after BAN | IMPLEMENTED | BAN intentionally targets representations that existed before the rule; a fresh post-BAN fill may be stored. | + +### API, panel and observability + +| Requirement | Status | Implementation | +|---|---|---| +| Dedicated admin endpoint | IMPLEMENTED | `POST /api/cache/invalidate`. | +| PURGE/BAN type | IMPLEMENTED | `operation: purge|ban`. | +| exact/prefix/tag/regex/cache_key | IMPLEMENTED | Typed selector in API/OpenAPI and UI. | +| Vhost selector | IMPLEMENTED | Optional validated `vhost`; optional host filter is separate. | +| Explicit broad warning | IMPLEMENTED | Regex and prefix `/` require `confirm_wide=true`; UI shows a danger confirmation. | +| Result data | IMPLEMENTED | Operation, selector, scope inputs, affected count, optional BAN id, elapsed milliseconds and status. | +| Audit log | IMPLEMENTED | Operator and requested operation/scope/value are written through the existing administrative audit store. | +| Metrics | IMPLEMENTED | PURGE counts/entries, BAN create/remove/matches/active count plus collapsing/proactive metrics. | +| `Cache -> Purge / Ban` | IMPLEMENTED | Dedicated controls and active-BAN table in the Cache panel. | +| Per-object `Purge URL` | IMPLEMENTED | Cache object table exposes a targeted purge action. | + +## Release gate + +The 0.22.0 source scope is implemented and covered by Rust-independent package checks plus source-level Rust regression definitions. The following gate remains mandatory before treating the build as runtime-accepted: + +```bash +bash dev.sh doctor +bash dev.sh check +bash dev.sh test +``` + +That gate must run on a host with the Rust toolchain and must be followed by the project's live H1/H2/H3/cache/compression/restart acceptance checks. diff --git a/docs/V0.22.1_AUDIT.md b/docs/V0.22.1_AUDIT.md new file mode 100644 index 0000000..de0e48f --- /dev/null +++ b/docs/V0.22.1_AUDIT.md @@ -0,0 +1,43 @@ +# RustProxy 0.22.1 specification audit + +## Scope + +This audit covers the 0.22.1 delta over 0.22.0: Cache administration UI polish, HTTP/2 RFC 8441 WebSocket, cleartext HTTP/2 prior knowledge, and identifiable/killable active tunnels. The 0.22.0 cache audit remains historical evidence for the retained cache implementation. + +## Requirement matrix + +| Requirement | Source implementation | Regression / verification | +|---|---|---| +| PURGE/BAN form in a modal beside **Purge selected scope** | `src/admin/web/index.html`, `cache-panel.js` | JS syntax + source-release checks | +| Active BAN list in a separate modal | `src/admin/web/index.html`, `cache-panel.js` | JS syntax + source-release checks | +| BAN/PURGE messages cannot dereference a missing status node | null-safe `operationMessage(...)` | source-release marker + UI syntax checks | +| RFC 8441 `CONNECT` + `:protocol=websocket` | `proxy/websocket.rs`, `proxy/mod.rs` | `tests/http2.rs` + module tests | +| RFC 8441 per-vhost opt-in beside RFC 9220 | `vhost.rs`, `config.rs`, `vhosts.js` | config/unit source regressions | +| Listener advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL` only where required | `config::Snapshot::websocket_rfc8441_on`, `server.rs` | source/config regression; Hyper builder contract | +| H2 client -> H1 upstream WebSocket | H2 request DATA <-> upgraded H1 byte bridge in `bridge_h2_to_h1` | source regression + compiler/live release-host gate | +| H2 client -> explicit H2 upstream | preserve H2 CONNECT/Protocol and meter request/response bodies | source regression + compiler/live release-host gate | +| WebSocket cancellation / idle timeout | `TunnelSession` cancellation + H2 idle watcher + existing H1/H3 timeout loops | source review; live release-host gate | +| h2c explicit enable/disable | `Listener.h2c`, Settings UI and validation | `transport_config` / vhost tests | +| h2c prior knowledge | HTTP/2 connection-preface detection in `server.rs` | server prefix unit test + live release-host gate | +| HTTP/1.1 `Upgrade: h2c` | deliberately deferred; no fake `101` path is shipped | docs + source-release guard | +| Stable active tunnel ID | request/exchange ID stored in `TunnelSession` | source review/API contract | +| List client/host/upstream/protocol/duration/RX/TX | `TunnelInfo`, `GET /api/tunnels`, modal | OpenAPI + source-release checks | +| Kill one tunnel | `POST /api/tunnels/{id}/kill` cancellation | OpenAPI + source-release checks | +| Audit close reason | `tunnel.kill` audit with operator reason | admin source regression | +| Main dashboard H1/H2/H3 tunnel stats | metrics + overview card | JS/source checks + Prometheus contract | +| Overview card geometry is shared across both visual rows | one `stats-grid` containing primary + WebSocket/H2/H3/TLS cards | source-release check | +| Full Cache Target / Cache key + tags remain retrievable when cells ellipsize | keyboard/click copy cells in `cache-panel.js`, full value in `title` | JS/source-release check | + +## Protocol notes + +RFC 8441 Extended CONNECT is connection-scoped at the HTTP/2 SETTINGS level. RustProxy therefore advertises the capability on a listener connection only when at least one bound vhost enables RFC 8441; request routing still enforces the selected vhost's own flag. + +`h2c` in 0.22.1 means HTTP/2 prior knowledge. The legacy HTTP/1.1 `Upgrade: h2c` bootstrap is not accepted because a correct implementation must carry the initiating H1 request into HTTP/2 stream 1 after the `101` response. The current Hyper integration does not expose a safe bootstrap path for that operation, so the release does not claim partial support. + +## Accept-Ranges policy + +RustProxy does not globally inject `Accept-Ranges: bytes` into ordinary responses. It preserves the origin header where representation semantics remain unchanged and synthesizes `Accept-Ranges: bytes` for a cached single-range response handled by RustProxy. Transform/compression paths remove incompatible range advertising. + +## Release gate + +The packaging workspace has no Rust toolchain. JavaScript/JSON/language/source/package checks can be executed here, but `cargo check --locked`, `cargo test --locked`, Clippy, release build and live H2/h2c/RFC8441 interoperability remain mandatory on a Rust-enabled release host before deployment. diff --git a/docs/V0.22.2_AUDIT.md b/docs/V0.22.2_AUDIT.md new file mode 100644 index 0000000..21b3b93 --- /dev/null +++ b/docs/V0.22.2_AUDIT.md @@ -0,0 +1,43 @@ +# RustProxy 0.22.2 specification audit + +## Scope + +This audit covers the 0.22.2 delta over 0.22.1: replay-safe backend failover retry, explicit HTTP/2 upstream request handling, bounded per-backend request concurrency, richer upstream diagnostics, an expanded backend operations view, and public X.509 expiry metadata. The retained 0.22.1 RFC 8441/h2c-listener/tunnel work and 0.22.0 cache work remain historical evidence for retained functionality. + +## Requirement matrix + +| Requirement | Source implementation | Regression / verification | +|---|---|---| +| Retry only replay-safe methods | `proxy/mod.rs::retry_safe_method` permits GET/HEAD/OPTIONS only | proxy source regression; compiler gate open here | +| Never replay body-bearing writes | retry requires an exact empty request body; POST/PUT/PATCH fail the method gate | source regression + release checks | +| Retry another backend | `Pool::select_retry_wait(..., excluded)` excludes attempted IDs | `failover_selection_never_reuses_an_attempted_backend` | +| Bounded retry policy | `RetryPolicy.max_retries` validates 0..3; bounded 5xx list | balance policy regressions | +| Explicit H2 upstream | explicit `protocol=http2` uses HTTP/2 request versioning | proxy regression + real Hyper h2c upstream test source | +| Per-backend request concurrency | `PoolMember.max_concurrent_requests`, atomic full-exchange lease acquisition | `backend_concurrency_limit_queues_then_rejects_and_recovers` | +| Bounded capacity queue / 503 | `queue_timeout_ms` validates 0..60000; async selection waits in bounded intervals and returns `SelectionFailure::Capacity` | capacity timeout regression | +| Reject vs queue-timeout observability | `capacity_rejections_total` always counts final capacity rejection; `queue_timeouts_total` only counts an elapsed non-zero queue | `zero_capacity_queue_timeout_rejects_without_counting_a_queue_timeout` | +| No false TCP connection claim | limit is documented and named as concurrent proxy requests/exchanges, not `max_connections` | TODO/roadmap explicitly keep socket-count limit open | +| Failure classification | DNS, TLS, connect, response-header timeout, read/send timeout and generic I/O flow through `Lease::error_detail` | source contract checks + detailed failure regression | +| Last failure | backend view exposes class, bounded detail and timestamp | `detailed_backend_failures_are_exposed_without_losing_aggregate_counters` | +| Health ejection/recovery timestamps | health transitions store last ejected/recovered timestamps | source contract checks | +| Backend operational view | dashboard combines status, active/limit, requests/errors, latency, traffic, last failure and last recovery | JS/language/source checks | +| X.509 validity metadata | public leaf PEM parser extracts SAN, issuer, notBefore/notAfter and days remaining without reading keys | X.509 time + generated-certificate regression sources | +| Expiry warning | inventory reports `ok`, `warning` (<=30 days), `expired`, or `unknown` | source/OpenAPI/UI checks | +| Renewal status in certificate view | inventory flattens current renewal status, last check and last failure message | OpenAPI/UI/source checks | +| Full downstream H2 -> RustProxy -> upstream H2 interoperability | implementation path present | **OPEN:** live/compiler-enabled E2E required | + +## Capacity semantics + +`max_concurrent_requests` is a full proxy-exchange permit. The permit remains held for the response body/tunnel lifetime through the existing backend lease, preventing a streaming response from releasing capacity after headers only. A value of `0` preserves the historical unlimited behavior. `queue_timeout_ms=0` rejects immediately when no eligible backend has capacity; a positive value performs a bounded wait and then returns HTTP 503. + +Pool readiness remains a health/availability signal rather than a transient-capacity signal. Backup members can receive traffic when eligible primary members are saturated. Sticky affinity is honored only when the matched backend has capacity; otherwise normal eligible selection can move the request to another backend. + +A physical TCP connection/socket cap is deliberately not claimed in 0.22.2 because Hyper connection pooling requires a distinct transport-level mechanism. + +## Certificate safety + +Certificate inventory parsing opens only the configured public certificate PEM. Private keys are not opened by the metadata path. The parser is deliberately narrow: it extracts the leaf issuer, RFC 5280 validity values and DNS SAN coverage while rustls remains responsible for certificate/key validation in serving paths. + +## Release gate + +This packaging workspace has no `cargo`, `rustc` or `rustfmt`, and outbound DNS is unavailable, so Rust parsing/type checking, compilation, linking and execution cannot be claimed here. Rust-independent JavaScript/JSON/language/source/package/Go gates are run locally. A Rust-enabled release host must still run `./dev.sh check`, `./dev.sh test`, release build and live H1/H2/H3/H2-to-H2 acceptance before deployment. diff --git a/docs/V0.22.3_AUDIT.md b/docs/V0.22.3_AUDIT.md new file mode 100644 index 0000000..ff55541 --- /dev/null +++ b/docs/V0.22.3_AUDIT.md @@ -0,0 +1,49 @@ +# RustProxy 0.22.3 specification audit + +## Scope + +This audit covers the 0.22.3 delta over 0.22.2: downstream HTTP/1.1 `Upgrade: h2c`, dedicated certificate hot reload, and deployment of certificate/key pairs owned by an external local issuer such as system Certbot. Upstream mTLS/client-certificate support is intentionally deferred. + +## Requirement matrix + +| Requirement | Source implementation | Regression / verification | +|---|---|---| +| Preserve h2c prior knowledge | cleartext listeners still sniff the HTTP/2 connection preface before entering HTTP/1 | existing prior-knowledge regression plus source/release checks | +| Accept HTTP/1.1 `Upgrade: h2c` only when enabled | H1 service checks the listener `h2c` capability before returning `101` | `malformed_h2c_upgrade_is_rejected_before_switching_protocols` and source checks | +| Validate the Upgrade request | requires HTTP/1.1, `Connection: Upgrade, HTTP2-Settings`, exactly one `Upgrade: h2c`, one bounded base64url settings payload, Host/authority, no request body | `h2c_upgrade_validation_rejects_missing_settings_and_bodies` | +| Reconstruct H1 request as H2 stream 1 | bounded HPACK bootstrap encodes pseudo/header fields and injects a stream-1 HEADERS frame after client preface + initial SETTINGS | `http1_h2c_upgrade_bootstraps_request_as_http2_stream_one` source regression | +| Keep later streams on normal H2 engine | upgraded I/O is handed to the same Hyper HTTP/2 server builder used by native H2 | source contract review; runtime gate open here | +| Bound malformed bootstrap traffic | initial preface/SETTINGS/header block have explicit size/shape checks; invalid input closes/rejects rather than entering routing | source checks + malformed request test source | +| Dedicated certificate reload API | authenticated `POST /api/certificates/reload` invokes `App::reload_certificates` | admin regression + OpenAPI/source checks | +| Reload does not mutate desired config/revision | recompile uses the existing revision and only swaps the immutable effective snapshot | `certificate_reload_keeps_configuration_revision_stable` | +| Failed reload preserves active snapshot | compilation completes before `ArcSwap` publication; an error returns without `store` | source review; release-host negative runtime acceptance required | +| Public H1/H2/H3 new handshakes use refreshed cert | public TCP TLS reads the current snapshot per ClientHello; QUIC config is rebuilt in the published snapshot | source review; live release-host acceptance required | +| Existing sessions are not interrupted | reload swaps configuration state only; it does not cancel public connection/tunnel tokens | source review | +| External Certbot/manual pair can be deployed | `tls deploy --id ... --cert ... --key ...` reads local PEMs and calls authenticated `POST /api/certificates/deploy`; the service stages a generation and publishes the existing manual certificate id | CLI/API/source checks + external pair staging regression | +| Certbot `live/*.pem` symlinks work | local CLI canonicalizes the supplied path once, then opens the resolved regular file with bounded reads | source checks; live Certbot-path acceptance recommended | +| Private key safety | external key is size-bounded, must be a regular file and on Unix may not grant group/world permissions; staged files use private creation semantics | source review + existing TLS file safety contracts | +| Pair/SAN validation before publication | `stage_external_pair` calls the existing certificate/key parser and extracts DNS SANs | `external_pair_stages_generation_and_rolls_back_cleanly` | +| Atomic multi-vhost switch | existing certificate id keeps all vhost assignments; only its generation filenames/coverage change in one revision-CAS config update | source/API review | +| No dual owner with built-in ACME | `tls deploy` rejects entries carrying RustProxy `issuer` metadata | source contract checks | +| Roll back staged files if publication fails | failed config/API publication invokes `remove_external_pair` for the staged generation | source review + helper regression | +| Administration TLS caveat is explicit | reload/deploy report that startup-fixed administration TLS needs restart when it selects the affected inventory certificate | API/CLI/UI/OpenAPI checks | +| Upstream mTLS | intentionally not part of 0.22.3 | deferred by product decision | + +## h2c bootstrap semantics + +The Upgrade path is enabled only on a cleartext listener that already opts into `h2c`. It does not make ordinary HTTP listeners opportunistically negotiate HTTP/2. A valid H1 request receives `101 Switching Protocols`; the client then sends the normal HTTP/2 connection preface and its initial non-ACK SETTINGS frame. RustProxy inserts a synthetic body-free HEADERS frame for stream 1 and lets Hyper own subsequent H2 framing, flow control, stream lifecycle and graceful shutdown. + +The bootstrap deliberately rejects H1 request bodies. This avoids replaying or buffering a body across the protocol switch and keeps the transition bounded. Prior-knowledge h2c remains unchanged. + +## External certificate ownership model + +There are two supported external workflows: + +1. **Generation deploy (recommended for Certbot):** a local deploy hook calls `tls deploy`. The CLI reads Certbot's current `live/` targets and sends the bounded PEM pair to authenticated loopback `POST /api/certificates/deploy`; the running service validates and creates a new immutable pair in its private certificate directory, then switches an existing manual certificate id through the normal revisioned configuration API. Every vhost already assigned to that id moves together. +2. **In-place file replacement:** an operator replaces files already referenced by the desired configuration through another safe local mechanism, then calls `POST /api/certificates/reload` or `tls reload`. The desired configuration and revision do not change. + +RustProxy-managed ACME ids are excluded from external deploy so the same certificate lineage cannot have two renewal owners. The API does not accept arbitrary host filesystem paths from a remote request. + +## Release gate + +This packaging workspace has no `cargo`, `rustc` or `rustfmt`. Rust parsing/type checking, compilation, linking and Rust test execution cannot be claimed here. Rust-independent JavaScript/JSON/language/source/package/Go gates are run locally. A Rust-enabled release host must still run `bash dev.sh doctor`, `bash dev.sh check`, `bash dev.sh test`, a release build, the raw H1 -> h2c Upgrade -> H2 stream-1 test, native h2c prior-knowledge, TLS/H2/H3 certificate hot reload, invalid-certificate rollback and normal H1/H2/H3 acceptance before deployment. diff --git a/docs/V0.23.0_AUDIT.md b/docs/V0.23.0_AUDIT.md new file mode 100644 index 0000000..5c4486f --- /dev/null +++ b/docs/V0.23.0_AUDIT.md @@ -0,0 +1,48 @@ +# RustProxy 0.23.0 specification audit + +## Scope + +0.23.0 builds on 0.22.3 and adds three control-plane/operations changes: scoped external certificate automation credentials, interactive all-vhost live logs, and a reorganized cache settings UX. Upstream mTLS/client-certificate support remains intentionally deferred. + +## External certificate automation + +- The administrator can create/rotate/revoke one scoped certificate credential from **Settings → Certificate API**. +- The credential is represented by a non-secret Key ID plus a Bearer Token. Only the token hash is persisted in SQLite schema v5; the cleartext token is returned only by the rotate operation. +- Token authentication is accepted only for `POST /api/certificates/deploy` and `POST /api/certificates/reload`. +- Token authentication does not grant `/api/config`, logs, metrics, certificate inventory, ACME or other administration access. +- Non-loopback token requests require HTTPS. Loopback HTTP remains available for local Certbot hooks. +- Normal administrator session + CSRF authentication continues to work on deploy/reload. +- Direct production CLI operations are `rust-proxy tls deploy ...` and `rust-proxy tls reload`; `dev.sh` is not required. +- Rotation, revoke, deploy and reload operations are audited. Credential status exposes Key ID, creation time and last-used time but never the hash/token. + +## Live logs + +- `rust-proxy --livelogs` creates a bounded local CLI session and opens an authenticated SSE stream from the running daemon. +- Request handling publishes complete access-log records to a bounded `tokio::sync::broadcast` channel with no await/block in the proxy path. +- The CLI supports structured interactive filters without regex syntax: host, vhost, backend, route, client, method, transport, cache result, exact/status class, path prefix, errors-only and minimum duration. +- `pause`, `resume`, `reset`, `filters`, `clear`, `detail` and `show ` provide interactive control and full JSON inspection of recent displayed entries. +- The detailed view includes upstream errors/status, cache reason/effective state, transform effective state, WebSocket bootstrap/phase/error, H3 reset code and byte counters. +- A lagging receiver gets an explicit SSE `gap` event with the number of entries discarded by the bounded channel. +- Session validity is periodically rechecked by the stream endpoint; logout/password-generation changes terminate access. + +## Cache settings UX + +The existing cache settings IDs and one atomic `cache-form` submission remain unchanged. The UI only groups them into four persistent DOM sub-pages: + +1. General +2. Resilience +3. Cold cache +4. Static & diagnostics + +No cache setting was removed or split into an independently saved document. + +## Production memory hardening + +- Inbound HTTP/2 connections now have a five-minute I/O inactivity ceiling. This prevents idle TLS/H2 sessions from accumulating indefinitely toward the process-wide connection semaphore while preserving active streams and normal connection reuse. +- Runtime metrics expose `http2_active_connections`, and Prometheus exports `rust_proxy_http2_active_connections`; the overview HTTP/2 tile shows the live count. +- Hot-cache accounting uses the response body backing allocation capacity before `BytesMut::freeze()`, not only the visible body length, so geometric buffer slack cannot silently bypass `cache.max_bytes`. +- Existing cold-cache demotion/purge allocator trimming remains in place on glibc Linux. + +## Open release-host gate + +The source includes Rust regression definitions for certificate-token scoping/hash-only persistence and structured live-log filtering/SSE parsing. A compiler-enabled release host must still run the normal `dev.sh check`, `dev.sh test`, release build, live external-token certificate deploy/reload and sustained live-log load acceptance before production deployment. diff --git a/docs/V0.23.1_AUDIT.md b/docs/V0.23.1_AUDIT.md new file mode 100644 index 0000000..5764c54 --- /dev/null +++ b/docs/V0.23.1_AUDIT.md @@ -0,0 +1,31 @@ +# RustProxy 0.23.1 specification audit + +## Scope + +0.23.1 is a patch release on top of 0.23.0. It adds live operational charts to the existing Cache and Transform workspaces without changing proxy-path behavior, configuration schemas or administration API payloads. The 0.23.0 Certificate API, Bearer-token scoping, `--livelogs` and four-page Cache settings contracts remain unchanged. + +## Cache live activity chart + +- The Cache workspace contains one full-width `
` chart and leaves it collapsed by default. +- The chart uses the existing authenticated `/api/cache` refresh path and the selected vhost scope; it does not add a new endpoint or proxy-path query. +- HIT, MISS and BYPASS cumulative counters are converted in the browser to per-second rates from consecutive samples. +- The chart keeps at most 60 in-memory samples, reuses the dashboard SVG grid/line styling and resets history when the selected scope changes or the administration session is reset. +- The existing 3-second Live toggle remains the refresh cadence; disabling Live stops automatic chart updates together with the rest of the Cache workspace. + +## Transform live activity chart + +- The Transform workspace contains one matching full-width `
` chart and leaves it collapsed by default. +- The chart uses the existing authenticated `/api/transform` payload and selected vhost scope. +- Applied transformations, transformed cache HITs, skipped/fail-open responses and errors are converted from cumulative counters to per-second rates between samples. +- The chart keeps at most 60 in-memory samples, uses the existing dashboard chart visual language and resets history on scope/session changes. +- No transformation body, request body, cache object body or unbounded request-derived label is loaded into the chart. + +## Retained 0.23.0 contracts + +- Certificate API credentials remain Key ID + Bearer Token, hash-only at rest and restricted to deploy/reload. +- `rust-proxy --livelogs` remains a bounded structured all-vhost live stream with explicit lag reporting. +- Cache settings remain one atomic document split visually into General, Resilience, Cold cache and Static & diagnostics pages. + +## Open release-host gate + +Rust-independent source, language, JavaScript, packaging and release-smoke checks are part of this source delivery. A compiler-enabled release host must still run `./dev.sh check`, `./dev.sh test`, a release build and live browser acceptance with sustained Cache/Transform traffic before production deployment. diff --git a/docs/V0.23.2_AUDIT.md b/docs/V0.23.2_AUDIT.md new file mode 100644 index 0000000..e32e0d0 --- /dev/null +++ b/docs/V0.23.2_AUDIT.md @@ -0,0 +1,27 @@ +# RustProxy 0.23.2 specification audit + +## Scope + +0.23.2 is a patch release on top of 0.23.1. It extends the Cache/Transformation live charts with an operator-selectable bounded history window and fixes local CLI access when the administration listener uses HTTPS. No proxy-path, SQLite schema or administration API payload change is introduced. + +## Cache / Transformation chart history + +- Both live charts remain default-collapsed and full-width when expanded. +- Existing cumulative counters are still converted to per-second rates with the existing 3-second refresh. +- Each workspace offers 60, 120, 300 and 600 retained samples, corresponding to roughly 3, 6, 15 and 30 minutes. +- The selected limit is stored in browser local storage separately for Cache and Transformation. +- Reducing the limit trims the in-memory arrays immediately; increasing it grows only with future samples. The maximum remains bounded at 600 samples per series. +- Scope/session resets still clear the corresponding chart history. + +## Local CLI / live-log HTTPS + +- The root cause of the 0.23.0/0.23.1 `rust-proxy --livelogs` reconnect loop is the CLI's previous hard-coded `http://{admin}/api/live-logs` URI. A TLS-enabled administration socket intentionally returns HTTP 308 on plaintext requests, and the CLI treated that expected redirect as a stream failure. +- The CLI now probes the loopback `/healthz` endpoint before creating authenticated API requests. HTTP success selects `http`; the administration listener's 308 with an HTTPS `Location` selects `https`. +- The probe carries no CLI session cookie. After scheme selection, the live SSE stream and retained-history API use the same selected transport. +- HTTPS certificate verification is pinned to leaf certificates read from the private `/certs` directory. This supports the app-managed self-signed default certificate without disabling TLS verification globally or relying on public DNS/SAN routing to reach the loopback listener. +- The same local client is used by `config get/reload/apply` and `tls reload/deploy`, eliminating the same hard-coded-HTTP failure mode there. +- The local administration address remains required to be loopback. + +## Verification status + +Rust-independent source, JavaScript/JSON, shell, Go-agent and release-smoke checks are run in the packaging workspace. `cargo`, `rustc` and `rustfmt` are unavailable here, so compiler/type tests, a live TLS handshake against the built binary and sustained browser/live-log runtime acceptance remain release-host gates. diff --git a/docs/V0.23.3_AUDIT.md b/docs/V0.23.3_AUDIT.md new file mode 100644 index 0000000..f26d834 --- /dev/null +++ b/docs/V0.23.3_AUDIT.md @@ -0,0 +1,25 @@ +# RustProxy 0.23.3 specification audit + +## Scope + +0.23.3 is a compatibility patch on top of 0.23.2. It adds an opt-in, per-vhost legacy WebSocket mode intended for older appliance and embedded WebSocket servers. Cache/Transformation history controls and HTTPS-aware local CLI/live logs from 0.23.2 are retained unchanged. No SQLite schema change is introduced. + +## Legacy WebSocket mode + +- `websocket_legacy_support` defaults to false and is valid only when `websockets` is enabled. +- The vhost editor exposes **Enable WebSocket legacy support** and persists the field in the normal vhost document/API. +- The switch is compiled into the immutable per-vhost frontend snapshot; there is no global compatibility downgrade. +- For WebSocket exchanges only, legacy mode forces the upstream hop to classic HTTP/1.1, including when the normal backend protocol setting prefers HTTP/2. HTTP/2 or HTTP/3 clients are bridged to the HTTP/1.1 upstream path. +- Before the upstream handshake, the WebSocket upstream `Host` is forced to the configured backend authority and `Origin` is rewritten to `://`. This intentionally overrides Preserve Host for the legacy WebSocket handshake only, so old same-origin checks see the appliance address they expect. +- Legacy upstream `101 Switching Protocols` validation tolerates historical syntax differences: missing `Connection: Upgrade`, repeated `Upgrade: websocket`, transfer-encoding metadata and zero Content-Length. Non-zero Content-Length is still rejected. +- The `Sec-WebSocket-Accept` proof remains exact and any selected `Sec-WebSocket-Protocol` must still have been offered by the client. +- Legacy response framing headers are removed before the downstream response. +- Ordinary HTTP requests and WebSocket vhosts without the switch use the existing strict path unchanged. + +## iLO4 evidence / intended acceptance + +The supplied browser HAR shows repeated attempts to `wss://ilo.lab.danio298.com/wss/ircport` with a standard RFC 6455 request (`Upgrade: websocket`, `Connection: Upgrade`, `Origin: https://ilo.lab.danio298.com`) and no HTTP response (`status: 0`). That is consistent with an upstream handshake being closed or rejected before a browser-visible status is returned. The new mode specifically addresses the two proxy-sensitive compatibility points identified in source review: upstream Origin/Host mismatch and strict rejection of legacy `101` response framing. Live iLO4 acceptance remains a release-host/runtime gate. + +## Verification status + +Rust-independent source, JavaScript/JSON, language-pack, shell, Go-agent and release-smoke checks are run in the packaging workspace. `cargo`, `rustc` and `rustfmt` are unavailable here, so compiler/type tests, release binary build and live iLO4/legacy-WebSocket runtime acceptance remain release-host gates. diff --git a/docs/V0.24.0_AUDIT.md b/docs/V0.24.0_AUDIT.md new file mode 100644 index 0000000..4432fb0 --- /dev/null +++ b/docs/V0.24.0_AUDIT.md @@ -0,0 +1,40 @@ +# RustProxy 0.24.0 specification audit + +## Scope + +0.24.0 changes downstream HTTP/2 connection retirement and extracts runtime system statistics into platform backends. Existing H1/H2/H3 routing, RFC 8441 WebSocket support, legacy WebSocket compatibility (including iLO4-class origins), cache and proxy behavior are retained unless stated below. + +## HTTP/2 lifecycle + +- `http2.idle_timeout_seconds` defaults to 300 seconds and remains independent from maximum lifetime. +- `http2.max_connection_lifetime_seconds` defaults to `0`, which disables the absolute lifetime limit. +- `http2.drain_timeout_seconds` defaults to 10 seconds and bounds graceful draining after shutdown begins. +- Idle qualification checks both recent H2 transport activity and the active stream count immediately before retirement. +- Idle or lifetime retirement calls Hyper graceful shutdown, which emits HTTP/2 GOAWAY, refuses newer streams and continues polling already accepted streams. +- Active response bodies, including RFC 8441 Extended CONNECT/WebSocket bodies, hold a stream lease until end-of-stream/error/drop. +- After accepted streams finish, the H2 connection future completes and the TCP transport is dropped normally. A drain timeout forces transport closure if a stream does not finish. +- Process shutdown uses the same GOAWAY/drain path; its drain is additionally capped by the process grace period. + +Source regressions cover idle GOAWAY, active-stream idle suppression, lifetime GOAWAY plus forced drain timeout, and successful active-stream completion during lifetime drain. + +## Settings organization + +The former top-level **HTTP/2 connections** Settings tab is now **HTTP connections** with HTTP/2 and HTTP/3 sub-tabs. HTTP/2 lifecycle settings still save through the live `http2` section. HTTP/3 remains stored per TLS listener and requires restart, but its QUIC enablement, advertisement lifetime, connection/stream limits and idle timeout are edited from the HTTP/3 sub-tab. The Listeners editor retains bind/TLS/H2/h2c controls and preserves each listener's existing H3 configuration when unrelated listener fields are saved. + +Legacy `/settings?tab=http2` links are normalized to `/settings?tab=connections`. + +## System statistics/platform runtime + +`system_stats` is the shared sampling module. Linux retains the existing `linux_procfs` source and the same names, units and `/proc`/cgroup semantics for CPU, process CPU, memory, RSS breakdown, FDs, threads, load, network, process I/O and cgroup memory. + +FreeBSD, OpenBSD and NetBSD use the BSD backend and native OS mechanisms where available: `sysctl`, `getrusage`, `ps` and `netstat`. A metric that cannot be obtained with the same meaning is left `None`; values are not synthesized. Other Unix targets report the platform and leave unavailable system metrics unsupported/null. + +The status payload now includes `platform`; Proxy Process displays it. Prometheus also exposes `rust_proxy_platform_info{platform="..."} 1`. + +## Retained WebSocket compatibility contracts + +The 0.23.3 legacy WebSocket mode remains opt-in per vhost. It continues to use HTTP/1.1 upstream, align Host and Origin with the configured backend, tolerate the documented legacy 101 framing, and keep Sec-WebSocket-Accept/subprotocol proof strict. This retained path is separate from RFC 8441 H2 WebSocket Extended CONNECT, whose accepted stream participates in the new H2 drain accounting. + +## Release verification + +Source-side Python/OpenAPI/language/JavaScript/shell/release-package checks are run in this workspace. The workspace does not provide `cargo`, `rustc` or `rustfmt`, so `cargo fmt --all`, `cargo check --locked --all-targets`, `cargo test --locked --all-targets`, release build acceptance, and native BSD compiler/runtime execution remain release-host gates. No passing Rust compiler result is claimed here. diff --git a/docs/V0.24.1_AUDIT.md b/docs/V0.24.1_AUDIT.md new file mode 100644 index 0000000..e0a8b4c --- /dev/null +++ b/docs/V0.24.1_AUDIT.md @@ -0,0 +1,48 @@ +# RustProxy 0.24.1 specification audit + +## Scope + +0.24.1 is a maintenance release over 0.24.0. It keeps the existing HTTP/2 graceful shutdown and HTTP/3 behavior, replaces duplicated BSD system-statistics collection with shared library APIs, restores network interface/RX/TX diagnostics through a dedicated cross-platform network API plus library fallback, exposes resolved runtime installation metadata to authenticated administrators, moves Logging into Settings, fixes compiler issues reported from a Rust-enabled release host, and removes artificial hostname-count ceilings from certificate/routing hostname lists. + +## Shared system statistics API + +| Requirement | Source implementation | Status | +|---|---|---| +| Avoid separate metric implementations per BSD family | `src/system_stats/mod.rs` collects portable host values through `systemstat`, network inventory/counters through `sysinfo::Networks` merged with a `systemstat` library supplement/fallback, and process values through `metrics-process`; the old `src/system_stats/bsd.rs` command/parser backend is removed | implemented in source | +| Linux compatibility | `src/system_stats/linux.rs::augment` keeps `/proc/stat`, `/proc/meminfo`, `/proc/self/status`, `/proc/self/io` and cgroup-v2 details after common sampling | implemented in source; compiler/runtime gate open | +| FreeBSD | `systemstat` host API + `sysinfo::Networks` + `metrics-process`; network inventory/counters are supplemented by `systemstat` when useful | source-integrated; native build/runtime gate open | +| OpenBSD | `systemstat` host API plus available `metrics-process` values; `sysinfo` network inventory may be unavailable so the shared adapter uses the merged `systemstat` source | source-integrated; native gate open | +| NetBSD | `systemstat` host API + `sysinfo::Networks` + shared `metrics-process` dummy fallback; unavailable process values remain null | source-integrated; native gate open | +| DragonFly BSD | `systemstat` host API plus shared `metrics-process` dummy fallback; network inventory merges `sysinfo` when available with the `systemstat` source | source-integrated; native gate open | +| macOS / Windows | same common host API and the process API on their supported targets | source-integrated; native gate open | +| Do not invent unsupported values | all portable fields are `Option`; failed/unsupported library calls leave values `None` | implemented in source | + +The portable view now includes CPU, logical CPU count, RAM, swap, system uptime, load average, network totals/rates/per-interface values, process CPU, RSS, virtual memory, descriptor count/limit, process start time and thread count where the selected libraries provide them. Linux-only detailed RSS categories, process data/swap, process I/O and cgroup limits remain explicit additive fields rather than guessed equivalents on BSD. + +The release-host `systemstat` compile failure in uptime conversion is fixed by using the duration's `as_secs()` API. The stale `std::future::Future` import in `src/server.rs` is also removed. + +### Network inventory regression fix + +The first 0.24.1 library adapter built the interface list only from `systemstat::network_stats()` successes. A backend error for a discovered interface therefore removed that interface entirely, which could yield `0 interfaces` and null aggregate RX/TX on both Linux and BSD installations. The corrected adapter uses `sysinfo::Networks::new_with_refreshed_list()` as the primary interface/counter API, supplements it with `systemstat`, merges counters by interface name, filters only loopback (`lo`, `loN`), and never shells out. This also preserves interfaces when either library returns only a partial inventory. The old 64-interface truncation is removed so discovered non-loopback interfaces are not silently hidden. Rate calculation remains based on deltas between the existing low-frequency snapshots. + +## Hostname/certificate cardinality + +RustProxy no longer imposes fixed item-count ceilings on certificate SNI hostname bindings, generated/imported certificate hostname checks, ACME domain requests, vhost/redirect/404 hostname lists, administration allowed hosts, host ACL hostname lists or Docker aliases. These lists remain constrained by DNS syntax and length rules plus the existing bounded control-plane inputs/global configuration size. Certificate PEM/key size limits, certificate-chain bounds and abandoned-upload storage bounds are deliberately retained as resource-safety controls. External ACME CAs/providers may enforce their own certificate/SAN quotas. + +## Runtime installation information + +`/api/status` now includes a `runtime` object with `platform`, `statistics_source`, `data_directory` and `database_path`. It is not added to the anonymous route allowlist; the existing administrator guard protects the status endpoint. These values describe installation/runtime state and contain no credentials or certificate/private-key contents. + +The browser displays the returned values and performs no path/platform detection. For local CLI operations, an explicit `--data-dir` always wins. When using the default local path without a database, installed-data discovery checks platform conventions: `/var/lib/rustproxy` first on Linux/non-BSD and `/var/db/rustproxy` first on BSD, then the alternate convention. + +## Settings navigation + +The standalone Logging primary-navigation panel is removed. Its existing form is mounted as **Settings -> Logging**. `/logging` remains accepted as a legacy URL and is canonicalized to `/settings?tab=logging`; logging persistence and sink behavior are unchanged. + +## Retained 0.24.0 transport behavior + +The 0.24.0 H2 connection state/stream lease tracking, idle recheck, GOAWAY, bounded drain, maximum connection lifetime and accepted-stream completion path are unchanged by this patch. HTTP/3/QUIC remains under **Settings -> HTTP connections -> HTTP/3** and retains listener-scoped restart semantics. + +## Open release gates + +This workspace has no `cargo`, `rustc` or `rustfmt`. A Rust-enabled release-host attempt exposed the uptime API mismatch and unused import; both are fixed in source, but this workspace cannot rerun compilation to prove that no later compiler error remains. A release host must generate/preserve the resolver-produced lockfile and rerun `cargo fmt --all`, `cargo check --locked --all-targets`, `cargo test --locked --all-targets`, a release build, native BSD sampling tests and the final H2/H3/WebSocket/proxy-statistics regression before production deployment. diff --git a/docs/V0.24.3_AUDIT.md b/docs/V0.24.3_AUDIT.md new file mode 100644 index 0000000..07791cd --- /dev/null +++ b/docs/V0.24.3_AUDIT.md @@ -0,0 +1,56 @@ +# RustProxy 0.24.3 specification audit + +## Scope + +0.24.3 is a focused runtime/UI maintenance release over 0.24.2. It reduces long-running Linux/glibc RSS retention in the transform-heavy path, keeps HTTP/2/H2C and HTTP/3 enablement in the listener editor while making both tuning pages listener-scoped, makes OS diagnostics render only values actually supplied by the current platform, expands external certificate automation, and improves overview/network observability. + +## Memory-retention changes + +| Requirement | Source implementation | Status | +|---|---|---| +| Bound glibc arena proliferation for managed systemd installs | `deploy/rustproxy.service`, `debian/rustproxy.service` and `scripts/deploy.py::service_unit` set `MALLOC_ARENA_MAX=2`; `/etc/rustproxy/environment` remains the administrator override | implemented in source | +| Avoid a duplicate identity transform buffer | `src/transform/mod.rs::decode` returns `Cow<[u8]>`; identity input is borrowed and compressed input owns only the decoded buffer | implemented in source | +| Reduce JS/CSS minifier peak allocations | `src/transform/js.rs` and `src/transform/css.rs` iterate with `Peekable` rather than collecting `Vec`, and return the completed `String` allocation with `into_bytes()` | implemented in source | +| Release temporary transform buffers earlier | successful changed transforms drop the decoded/collected origin buffers before final response construction | implemented in source | +| Give large glibc transform churn a bounded RSS-release opportunity | transformed responses that release at least 256 KiB can call `malloc_trim(0)` no more than once per 30 seconds on Linux GNU targets | implemented in source; sustained-RSS acceptance remains open | +| Keep non-glibc targets portable | allocator-specific tuning/trim code is guarded for Linux GNU; other targets keep the portable allocation path | implemented in source | + +`MALLOC_ARENA_MAX=2` is an allocator-tuning default, not an application memory limit. It is useful for this multi-threaded long-running proxy because it limits how many glibc malloc arenas can retain freed pages. It does not replace cache limits, cgroup limits or sustained-load testing. + +The cache already calls `malloc_trim(0)` after bulk demotion/purge paths. 0.24.3 does not change cache policy semantics: in particular `cold_on_age` remains an explicit operator choice and defaults to false. This release reduces allocator/transformation retention without silently changing cache freshness or tiering behavior. + +## HTTP/2 and HTTP/3 settings placement + +Both downstream protocol families now use the named-listener ownership model. `binding.http2` / `binding.h2c` enable HTTP/2, `binding.http2_settings` stores its lifecycle limits, and `binding.http3` is either `null` (disabled) or an object containing QUIC limits. In 0.24.3: + +- **Settings -> Listeners** owns the H2, H2C and **Enable HTTP/3 over QUIC (UDP)** switches. +- An H2/H2C listener links to **Settings -> HTTP connections -> HTTP/2**; an enabled H3 listener links to the HTTP/3 page. +- The HTTP/2 page edits idle, absolute-lifetime and graceful-drain limits independently for every H2/H2C listener. The HTTP/3 page continues to edit advertisement, connection, bidirectional/unidirectional stream and idle-timeout limits per enabled TLS listener. +- The legacy top-level `http2` object remains a fallback when an older listener has no explicit `http2_settings`, so existing configurations keep their effective behavior until saved through the new UI. +- Listener/H2/H3 tuning changes remain restart-required. Each tuning page is blocked while the listener form has unsaved changes, preventing stale copies of the listener section from overwriting each other. + +## Dynamic diagnostics + +The dashboard card is named **Process**. Optional process/system fields are backed by nullable statistics. The browser hides rows and history charts when a field is unavailable on the current platform instead of substituting `unknown`, `N/A` or a synthetic zero. Linux continues to expose its `/proc`/cgroup details; other supported systems show only values supplied by the shared collectors. + +The same rule is applied to optional system CPU/RAM/swap/load/uptime and network/latency chart rows where the data source can be absent. This is a presentation change only; the authenticated status API continues to represent unsupported metrics as `null`. + +## Certificate automation extensions + +| Requirement | Source implementation | Status | +|---|---|---| +| Reload one manual/file-backed certificate after an external file replacement | `POST /api/certificates/{certificate_id}/reload` calls `App::reload_certificate`; ACME-managed IDs are rejected and the configuration revision stays unchanged | implemented in source | +| Permit the same reload through Certificate API authorization | `certificate_api_operation` recognizes the validated per-ID reload path and preserves the external HTTPS/loopback restriction | implemented in source | +| Support multiple independently managed automation tokens | SQLite schema v6 introduces `certificate_api_credentials`, bounded to 32 rows, with Key ID, hash, comment and usage metadata | implemented in source | +| Individual and bulk revocation | `DELETE /api/certificate-api/{key_id}` revokes one; `DELETE /api/certificate-api` revokes all | implemented in source | +| Never persist cleartext automation tokens | PUT returns the clear token once; only SHA-256 hashes and non-secret metadata/comments are stored | implemented in source | + +## Dashboard extensions + +- **Core State** is replaced by a same-footprint live connection chart for open connections and active exchanges, with backend count retained as chart context. **Uptime** moves to **Process**, directly below process start. +- **Payload transferred** keeps the aggregate value and adds compact download (`bytes_out`) / upload (`bytes_in`) counters. +- Each network-interface chart has a **Hide** action. Hidden interface names are stored only in browser `localStorage` (`rp-hidden-network-interfaces`) and can be restored from the dashboard; no server configuration is changed. + +## Open release gates + +This workspace does not provide `cargo`, `rustc` or `rustfmt`. Static source, language-pack, JavaScript, shell, Python, deployment/archive and Go-agent gates can run here, but a Rust-enabled release host must still run formatting, `cargo check --locked --all-targets`, `cargo test --locked --all-targets`, a release build, live H2/H3/WebSocket regression and a sustained 10-12 hour cache/transform RSS comparison before production promotion. diff --git a/docs/V0.24.4_AUDIT.md b/docs/V0.24.4_AUDIT.md new file mode 100644 index 0000000..4c6d515 --- /dev/null +++ b/docs/V0.24.4_AUDIT.md @@ -0,0 +1,21 @@ +# RustProxy 0.24.4 specification audit + +## Scope + +0.24.4 is a focused Linux memory-retention maintenance release over 0.24.3. Production diagnostics showed that a large share of anonymous RSS could be backed by 2 MiB Transparent Huge Pages while the live RustProxy cache residency was much smaller. A host-wide switch from THP `always` to `madvise` removed those huge-page mappings after restart. 0.24.4 internalizes that mitigation for RustProxy only. + +## Source/spec matrix + +| Requirement | Source contract | Status | +|---|---|---| +| Do not require a host-wide THP policy change | `src/main.rs` calls `prctl(PR_SET_THP_DISABLE, 1, 0, 0, 0)` on Linux | implemented in source | +| Apply the policy before runtime worker heaps are created | `disable_transparent_huge_pages()` runs before `tokio::runtime::Builder::new_multi_thread()` | implemented in source | +| Keep non-Linux platforms unchanged | the THP call is guarded by `cfg(target_os = "linux")`; other platforms use a no-op helper | implemented in source | +| Avoid turning kernel capability into a startup dependency | a failed `prctl` emits a warning and startup continues | implemented in source | +| Retain the 0.24.3 glibc mitigation | managed systemd units still set `MALLOC_ARENA_MAX=2`; existing bounded `malloc_trim(0)` paths remain | retained | + +## Runtime acceptance + +Source inspection cannot establish steady-state RSS. On a Linux release host, return `/sys/kernel/mm/transparent_hugepage/enabled` to the host's normal policy, restart the 0.24.4 binary, and verify `/proc//status` reports `THP_enabled: 0`. Under representative sustained traffic, `/proc//smaps_rollup` should keep `AnonHugePages` at zero or negligible levels without changing the global THP setting. Record RSS, anonymous RSS, cache resident/transient bytes and request rate for the same 10-12 hour acceptance window used by 0.24.3. + +Compiler/type/runtime acceptance remains open in this source workspace because the Rust toolchain is unavailable. diff --git a/docs/V0.24.5_AUDIT.md b/docs/V0.24.5_AUDIT.md new file mode 100644 index 0000000..3466c80 --- /dev/null +++ b/docs/V0.24.5_AUDIT.md @@ -0,0 +1,59 @@ +# RustProxy 0.24.5 specification audit + +## Scope + +0.24.5 fixes HTTP/3 request-body framing when an H3 request has no declared `Content-Length`, carries no DATA, and is forwarded to an HTTP/1.1 upstream. Before the fix, the H3 receive body remains length-unknown until its stream end is polled; Hyper can therefore serialize body-capable HTTP/1.1 methods such as `DELETE` with `Transfer-Encoding: chunked`. Some upstream servers reject chunked request bodies even when the logical body is empty. + +## Source/spec matrix + +| Requirement | Source contract | Status | +|---|---|---| +| Preserve HTTP method and URI | normalization changes only the request body; method/URI rewriting remains in the existing proxy path | implemented in source | +| Detect an actually empty H3 body | for H3 -> H1 with no declared/exact length, `normalize_h3_h1_unknown_body()` polls until EOF or the first semantically significant frame | implemented in source | +| Represent empty H3 body as zero length | EOF replaces the streaming wrapper with `Body::empty()`, giving Hyper an exact zero-length size hint | implemented in source | +| Preserve non-empty bodies | the first non-empty DATA/trailer frame is wrapped in `ReplayBody`; the remaining tail stays streaming | implemented in source | +| Do not change H2 upstream framing | normalization exits before polling unless the selected upstream version is HTTP/1.1 | implemented in source | +| Do not preconsume declared bodies | requests carrying `Content-Length` are left unchanged | implemented in source | +| Avoid DELETE-only/Proxmox-only behavior | normalization applies generically to body-capable H3 methods forwarded to H1; GET/HEAD/CONNECT retain Hyper's existing implicit-empty behavior | implemented in source | + +## Regression coverage added + +`src/proxy/mod.rs` contains focused async tests for: + +- empty length-unknown H3 `POST`, `PUT`, `PATCH` and `DELETE` to HTTP/1.1 becoming exact zero-length bodies; +- non-empty length-unknown H3 `DELETE` preserving its payload through `ReplayBody`; +- H3 request bodies remaining untouched for HTTP/2 upstreams; +- H1/H2 downstream request bodies remaining untouched by the H3-specific normalization; +- declared-length H3 `DELETE` bodies not being preconsumed. + +## Runtime acceptance + +Compiler/type/runtime acceptance remains open in this source workspace because the Rust toolchain is unavailable. On the test host, run `./dev.sh check` and `./dev.sh test`, then verify the full H1/H2/H3 method matrix. For the original Proxmox case, compare `/var/log/pveproxy/access.log` and a raw/mock HTTP/1.1 upstream. The H3 bodyless DELETE must arrive as `DELETE` with the same URI and without chunked request framing; a zero-length representation such as `Content-Length: 0` is expected for HTTP/1.1. + +## Cache RAM hardening included in the same 0.24.5 source release + +The cache keeps the existing HOT RAM -> COLD disk lifecycle. The changes are admission/accounting safeguards rather than a COLD format redesign: + +- `cache.max_bytes` is a hard published-HOT charge based on the body backing allocation retained by `Bytes`, directly owned entry metadata and a conservative charge for HOT hash/LRU/variant indexes. `hot_logical_bytes` remains the visible payload total; `hot_allocated_bytes`/`resident_bytes` are the charged allocation view, not a duplicate RSS measurement. +- response fill uses a `Vec` whose actual `capacity()` is reserved before growth and transferred zero-copy into `Bytes`; `max_object_bytes` is checked before every chunk append; +- active fills reserve from `transient_max_bytes` and compare HOT + active fill against HOT capacity plus a small allocation-rounding headroom (about 1/64 HOT, capped at 1 MiB and further bounded for small caches); LRU entries are removed before further fill allocation where possible; +- one admission can remove at most `hot_eviction_max_objects`; if sufficient space cannot be made safely, the response continues uncached; +- ordinary HOT -> COLD demotion removes the entry from HOT and moves the same `Entry` into `ColdDraft`. The payload is not cloned. A transient lease follows the draft until persistence completes; the persisted `ColdEntry` has paths/metadata and no `Bytes` body; +- the COLD writer queue remains count-bounded and is additionally capped by `cold_queue_max_bytes`; COLD reads/promotions and revalidation reads reserve transient bytes before reading full bodies; +- global `cache.enabled=false` cannot be overridden by a vhost `enabled=true`; +- HOT/object settings no longer have the old artificial 1 GiB UI/source ceiling. Safety comes from the configured byte budgets rather than an arbitrary maximum. + +Default memory values are 256 MiB HOT, 16 MiB maximum object, 64 MiB transient and 32 MiB COLD queue, with 4096 HOT entries and a 64-object LRU admission bound. The administration panel can calculate conservative/recommended/performance settings from the lower of detected host RAM and a finite cgroup memory maximum. For example, the recommended profile on an effective 2 GiB VM selects approximately 400 MiB HOT, 96 MiB transient, 16 MiB maximum object, 48 MiB COLD queue, 6400 entries and a 64-object LRU admission limit. Presets are disabled below 256 MiB effective RAM. The panel also checks the configured budgets against current host/cgroup memory headroom and warns without blocking manual configuration. COLD disk capacity is intentionally not changed by RAM presets. + +Monitoring exposes HOT objects, HOT logical bytes, HOT allocated bytes, HOT limit, transient current/limit, COLD queue current/limit, COLD read/write activity, evicted/expired/rejected counts and existing process RSS/RssAnon/VmData/VmSize diagnostics. Cache RAM gauges and process RSS are overlapping views and must not be summed. + +### Cache runtime acceptance still required + +The source-level/offline gates verify configuration, UI/API wiring and static contracts. Because this workspace has no Rust toolchain, compile/type/runtime acceptance must still be performed on the test host. In addition to `./dev.sh check` and `./dev.sh test`, exercise at least: + +- HOT 8 MiB under sustained traffic and verify HOT allocated stays bounded and RSS plateaus; +- HOT 2 GiB with a deliberately high preset and verify admission remains bounded; +- `cold_after_seconds=5`, verify HOT allocated falls after demotion and transient/COLD queue bytes return to zero after writes drain; +- an object larger than `max_object_bytes`, verify the client receives the full streamed response while the cache fill aborts before full buffering; +- concurrent COLD hits/writes under a small transient limit, verify memory-pressure rejection/pass-through rather than unbounded RAM growth; +- all four global/vhost enabled combinations, especially global OFF + vhost ON => effective OFF. diff --git a/docs/VHOSTS.md b/docs/VHOSTS.md new file mode 100644 index 0000000..c9315c2 --- /dev/null +++ b/docs/VHOSTS.md @@ -0,0 +1,404 @@ +> 0.7 adds graphical locations, Basic Auth, cumulative IP ACL editing and a +> WebSocket toggle. See [host access and locations](VHOST_ACCESS.md). + +# Virtual hosts, frontend bindings and backend pools - 0.2.0 + +This is the source implementation contract. Rust compilation and runtime acceptance +were not performed in the packaging environment; see TEST_REPORT.md. + +## One vhost, one backend pool + +A `vhosts` entry owns an `id`, `enabled`, `frontend`, `backends`, `load_balancing`, +`routes`, optional `acl`, `headers` and `security_headers`. The pre-production top-level +routing and inline-listener formats are no longer accepted. Unknown JSON fields are +rejected. The examples are complete documents, not JSON merge patches: + +- `examples/vhosts-private.json`: explicit loopback IP, two existing HTTP origins, + adaptive balancing, HTTP health checks, keepalive, no cache or cookie affinity. +- `examples/vhosts-balanced.json`: explicitly public HTTP 80 and TLS 443, cookie + affinity and a deliberately short HSTS value; requires a trusted, imported `app` + certificate/key and real origins. It does not create those services or files. + +In the examples, both origins need `/health` to return a quick 2xx response. Adjust +that path/status range for the real service. New pools start quarantined until +`rise` successful checks; an unavailable or misconfigured check deliberately causes +503 instead of sending traffic to an unverified member. `mode: off` opts out. + +The editor includes frontends, members, balancing, health, connection reuse, sticky +sessions and response protection. Custom locations have graphical match/action/rewrite/ACL/header +controls plus synchronized Advanced JSON for automation. Unsaved drafts survive translation/SSE refreshes. A stale draft must be +reloaded before saving: the panel does not silently merge competing revisions. + +Existing vhost IDs can be renamed in the dashboard as long as the new ID is valid and +unique. The rename keeps the configured frontend hostnames, routes and backend IDs, but it +intentionally starts a new vhost runtime identity for metrics/log attribution and current +health/traffic history. Because the vhost configuration participates in cache compatibility, +a rename also rotates that compatibility identity and invalidates incompatible cached objects. +The editor warns before applying the rename. + +## Frontend selection and private IP isolation + +`frontend.hosts` is a normalized hostname-pattern list. Exact hostnames and a single +leading wildcard such as `*.example.com` are supported; regex and mid-label wildcards +are not. Routing tries the exact hostname first, then the longest matching wildcard. +This means `api.example.com` can have its own vhost while `*.example.com` handles other +subdomains on the same listener without stealing the exact route. +`frontend.listener_ids` references named `listeners.endpoints` configured under +Settings. A listener owns its IP:port, TLS flag and H2/H3 transport capabilities; +a vhost never stores a listening address directly. For a new dashboard vhost, the +editor preselects both an enabled HTTP listener on port 80 and an enabled TLS listener +on port 443 when they exist (public-scope listeners are preferred). Existing vhost +bindings are never replaced by that convenience default. + +`force_ssl` defaults to false. When enabled, any cleartext request that matches this +vhost receives HTTP `308 Permanent Redirect` to the same normalized hostname and +original path/query. At least one selected plain HTTP listener and one selected TLS +listener are required so there is both an ingress to redirect and an HTTPS target. If +several TLS ports are selected, port 443 is preferred; otherwise the selected TLS target +port must be unique. + +`forward_client_ip` defaults to true. Normal vhosts therefore receive the sanitized +`Forwarded`, `X-Forwarded-For` and `X-Real-IP` client identity generated by RustProxy. +Set it to false for applications whose own trusted-proxy/bypass logic must see the +RustProxy TCP peer instead. Only the backend application request loses those three +client-IP headers; ACLs, GeoIP, rate limiting and Tinyauth still use the effective client +address, and origin metadata such as `X-Forwarded-Proto`, `X-Forwarded-Host`, +`X-Forwarded-Port` and `X-Forwarded-URI` remains available to the backend. + +`websockets` enables WebSocket handling for the vhost. The dashboard defaults new WebSocket +vhosts to the nginx-compatible HTTP/1.1 Upgrade transport. `websocket_legacy_support: true` is +a separate per-vhost compatibility switch for older appliances and embedded servers. It affects +only WebSocket handshakes: RustProxy forces an HTTP/1.1 upstream WebSocket hop, uses the configured backend authority as upstream `Host`, +rewrites `Origin` to the matching upstream scheme + backend authority, and tolerates legacy `101 Switching Protocols` response +framing while keeping the `Sec-WebSocket-Accept` proof and offered subprotocol checks strict. +Ordinary HTTP requests are unchanged. `websocket_rfc8441: true` and `websocket_rfc9220: true` are +separate opt-ins for HTTP/2 and HTTP/3 Extended CONNECT and are valid only when WebSocket +support is enabled. They are presented together in the vhost editor. +RFC 8441 requires an H2-capable selected listener (TLS ALPN or cleartext h2c prior knowledge). +Because the RFC 9220 advertisement is QUIC-connection scoped, one opted-in vhost causes the +shared QUIC listener to advertise Extended CONNECT, while request routing still rejects RFC +9220 on non-opted-in vhosts. + +`http2_enabled` and `http3_enabled` are per-vhost opt-outs (both default true). The +panel presents the two switches together and enables each only when at least one +selected listener provides that protocol. For TLS H2, ClientHello/SNI selects H2+HTTP/1.1 or HTTP/1.1-only ALPN for +the matched vhost. H3 remains a shared UDP listener capability; disabling it on a +vhost removes Alt-Svc and rejects H3 for that host. + +Each vhost can optionally set `tls_policy` with `tls12`, `tls13` and a list of rustls +cipher-suite names. Omitting it inherits `tls.policy` from Settings. The override is +selected from ClientHello/SNI before the TCP TLS handshake, so a vhost can for example +run TLS 1.2 only while another frontend continues to use the global TLS 1.2/1.3 policy. +At least one protocol version and a compatible effective cipher suite are required. +Because HTTP/3 shares QUIC TLS on the UDP listener and requires TLS 1.3, a vhost with +HTTP/3 enabled must use the same TLS policy as the global setting. Disable HTTP/3 on +that vhost before using a differing per-vhost TLS policy. + +Routing checks Host/HTTP authority, actual accepted local IP, local port and actual +TLS state. Neither `Forwarded` nor `X-Forwarded-*` can create frontend access. A +request for a private hostname on a different local IP is not routed to that host. +Unknown or unmatched hosts return 404. + +Bindings must be local addresses assigned to the OS. The process does not add IPs, +manage routes, configure firewall rules, acquire virtual IPs or run Keepalived/VRRP. +A missing IP, occupied port or permission error aborts startup without wildcard +fallback. Low ports may need deployment capabilities; see `../deploy/README.md`. + +Identical or same-family wildcard sockets are consolidated only when the named +listener transport capabilities agree. Vhost routing restrictions are not +consolidated: a wildcard TCP listener can accept on a public IP while still rejecting +a private vhost there. Per-vhost H2 is narrowed by SNI and therefore does not require +a second TCP socket. IPv4/IPv6 wildcard overlap is rejected; use explicit addresses. +Native IPv4 binds are required instead of mapped IPv6 literals. The administration +port is reserved even on a different IP. + +IP isolation is not client authentication. A firewall, router, NAT or preceding +proxy can forward external clients to the private destination IP; RustProxy sees +that local destination and the connecting peer. Use network ACLs, firewall rules +and application authentication too. Trusted forwarding headers have a separate explicit CIDR policy (see +INTEGRATIONS_040.md); PROXY protocol is not implemented. Interface changes require config +reload/restart to refresh the upstream loop-guard's local-address enumeration. + +The same literal hostname pattern may occur only where its listener bindings remain +unambiguous. Exact and wildcard patterns are distinct ownership keys, so an exact host +and its parent wildcard may coexist. Disabled vhosts reserve their patterns and are +still validated, but create no pool or additional active listener. + +## Paths, policies and migration + +`routes` contains per-vhost paths: `id`, `path_prefix`, `enabled`, nullable `cache`, +optional `acl`, `headers` and `backend_id`. An empty array becomes one enabled root route. Longest +segment-boundary prefix wins. Origins receive the original path/query; prefixes +are not stripped. IDs must use ASCII letters, digits, hyphens and underscores. +Host-policy, reusable ACL-profile, vhost and route ACLs are ANDed before pool +selection/DNS/cache. A vhost can attach one reusable `acl_profile_id`; the profile may +contain an IP ACL plus a country block/allow-only rule. Inline vhost/location controls +remain independent and cannot be weakened by the profile. Their client identity is the +validated effective peer address, not an untrusted forwarding header. + +There is no legacy routing UI/runtime or automatic importer in 0.8.0. Development +databases using the old top-level routes or inline frontend listeners must be reset +or migrated explicitly before startup. An omitted path backend ID selects from the +balanced pool; a specified ID must belong to the same vhost and does not fail over to a +different application's member. Imported health checks start OFF. Ambiguous names, +unreferenced old backends and invalid/oversized documents stop migration before +saving. See MIGRATION.md before updating an existing installation. + +## Backend members and connection reuse + +Each member has `id`, HTTP(S) origin `url`, `weight` (1..256), `enabled` (true), +`backup` (false), `drain` (false), `allow_private` (false), +`accept_local_listener_risk` (false), `preserve_host` (true), `protocol` (`http1`), +`address_allowlist`, optional `tls_server_name`, `tls_verify` (false) and optional +`tls_ca_pem`. `tls_server_name` is an HTTPS-only DNS hostname +used for SNI and, while verification is enabled, certificate hostname verification; +DNS/TCP still targets the configured `url`. `tls_verify` defaults to false. HTTPS connections remain encrypted, but backend certificate authentication is skipped until an administrator explicitly sets `tls_verify: true`; this includes compatibility with legacy certificate encodings that modern webpki validation rejects. Address/DNS policy remains enforced; local-listener self-targeting has only the explicit risk exception described below. `tls_ca_pem` is ignored while verification is disabled. +Backend IDs are unique inside a vhost. URLs cannot contain credentials, paths, +queries or fragments. +Every DNS answer and every configured literal must pass the same upstream policy +for health probes and forwarding. Active, pending and prior-snapshot frontend +addresses and local administration are guarded against self-proxying by default. +For an intentional local target such as `http://127.0.0.1:9090`, enable both +`allow_private: true` and `accept_local_listener_risk: true`. The latter is a narrow, +explicit acceptance of loop/control-plane exposure risk; metadata/link-local and other +special-address blocks are not bypassed. In the dashboard this risk checkbox is hidden +unless the entered backend URL directly targets an administration listener reported by +persisted configuration or live status. Startup-owned/locked administration sockets are +therefore handled too. On the same administration port, loopback spellings such as +`localhost`, `127.0.0.1` (and the rest of `127.0.0.0/8`) and `::1` are treated as +equivalent for showing the explicit risk control, so it is not presented as a normal +backend option. + +A vhost can independently enable two proxy-owned response diagnostics: + +- `backend_header: true` -> `X-RustProxy-Backend: `, +- `backend_timing_header: true` -> `X-RustProxy-Backend-Time: ms`. + +The timing value covers the origin request through receipt of the backend response +headers; it is not full response-body duration. Both proxy-owned names are protected +from custom header rules and any upstream values are removed before RustProxy optionally +writes its own value. The former `X-Rust-Proxy-Backend-Time` spelling is also stripped +as a legacy lookalike but is never emitted. Responses served without contacting an +origin do not expose a selected backend or stale origin timing. +`timing_headers` remains the separate opt-in `Server-Timing` diagnostic. + +`keepalive` controls **connection pooling**, not an HA service: + +| Field | Default | Accepted range | +|---|---|---| +| `enabled` | true | Boolean | +| `idle_timeout_seconds` | 30 | 1..3600 | +| `max_idle_per_host` | 4 | 1..64 | +| `tcp_keepalive_seconds` | 30 | 1..3600 | + +These configure HTTP idle reuse and TCP keepalive; HTTP/2 also uses pings for active +connections, with a 10-second ping timeout and no pinging solely to keep idle +connections alive. Disabling reuse sets the idle pool limit to zero and disables +those keepalive probes. It does not abort already active requests/tunnels. The idle +limit is not a separate per-backend active-request cap; global connection/exchange +bounds still apply. HTTP health probes use separate no-reuse HTTP/1.1 connections. + +`http2` over HTTPS prefers h2 but keeps HTTP/1.1 ALPN compatibility. Over plaintext, +`http2` remains explicit prior-knowledge h2c with no retry-based downgrade. `auto` +over HTTP is HTTP/1.1. `http2`/`auto` require `preserve_host: false` to keep origin +URI/authority/TLS identity consistent. HTTPS backend certificate verification is disabled when `tls_verify` is omitted. Enable `tls_verify: true` when origin identity must be validated; for IP-based origins use a DNS `tls_server_name` present in the certificate and provide `tls_ca_pem` when the issuer is private. + +## Scheduling + +| `algorithm` | Decision | +|---|---| +| `round_robin` | Smooth weighted round-robin | +| `least_connections` (default) | Lowest `(active exchanges + 1) / effective weight` | +| `least_traffic` | Lowest recent EWMA payload rate plus active-exchange allowance, divided by effective weight | +| `adaptive` | Combines active exchanges, EWMA time-to-headers and payload rate, divided by effective weight | +| `ip_hash` | Weighted rendezvous hash of vhost/member and canonical actual peer IP | + +An active exchange spans upload, response body and upgraded tunnel, not just the +wait for response headers. Slow-start gradually increases effective weight after +initial success/recovery (`slow_start_seconds`: 10, allowed 0..300; 0 disables it). +IP hashing uses configured weights instead of slow-start to keep assignments stable. +These are explicit heuristics, not workload predictions or benchmark-backed claims. +No remote host CPU measurement is used by the scheduler. + +Only enabled, healthy, non-draining primary members receive new assignments. If +none exists, eligible backups are used. All unavailable means 503 with Retry-After; +there is no fallback into another vhost. Balanced routes may retry a body-free `GET`, +`HEAD` or `OPTIONS` on another healthy member according to `load_balancing.retry`: + +| Retry field | Default | Accepted range / meaning | +|---|---|---| +| `max_retries` | 1 | 0..3; zero disables failover retry | +| `retry_connect_errors` | true | Retry connection establishment failures | +| `retry_timeouts` | true | Retry the response-header timeout | +| `retry_statuses` | `[502,503,504]` | Up to 8 HTTP 5xx statuses | + +A backend already attempted in the same exchange is excluded from later attempts. +Path-pinned routes and WebSocket handshakes are not retried. POST/PUT/PATCH and any +request with a body are never automatically replayed. Failed attempts still feed the +normal passive-health policy and retry counters/logs; successful failover updates sticky +affinity to the backend that actually answered. + +## Health checks and recovery + +| Field | Default | Accepted range / meaning | +|---|---|---| +| `mode` | `tcp` | `off`, `tcp`, `http` | +| `path` | `/` | Leading slash, up to 1024 bytes; no authority/fragment | +| `host` | null | Optional validated HTTP Host override | +| `interval_ms` | 5000 | 250..300000 | +| `timeout_ms` | 2000 | 50..60000; no greater than interval | +| `slow_response_ms` | 1500 | 0 disables; otherwise no greater than timeout | +| `fall` / `rise` | 3 / 2 | 1..20 consecutive failed/successful checks | +| `expected_status_min` / `max` | 200 / 399 | Inclusive, ordered HTTP status range within 100..599 | + +TCP checks verify that an approved origin address accepts a connection. HTTP checks +perform GET on the configured path, without redirects, cookies or authorization. +They use HTTP/1.1, including verified TLS when appropriate, and measure time to +headers, not the full body. For an h2c-only service, use TCP checks. Slow probes +count as active failures. Active TCP/HTTP health is independent from `passive_health`. + +`passive_health.enabled` defaults to `false`. With that default, normal user traffic +never changes the shared backend healthy/unhealthy state: HTTP 5xx, connection errors, +timeouts, response-body I/O failures and slow responses are still counted/logged but +cannot quarantine the member. When explicitly enabled, the administrator can choose +`count_connect_errors`, `count_timeouts`, `count_io_errors`, `count_http_5xx` and +`count_slow_responses`, with optional passive `fall`/`rise` overrides. Missing overrides +inherit the active health thresholds. Active and passive streaks are separate, so a +successful user request cannot erase an active-probe failure streak and an active probe +cannot erase a passive failure streak. `mode: off` disables only active probes; an +explicitly enabled passive policy may still quarantine a backend. + +One cancellation-aware supervisor schedules at most 16 probes, one per member, +with a rotating window for fairness. The tick is 250 ms; configured intervals are +best-effort under resource pressure, not precise service-level guarantees. DNS +workers have their own bounded slots. Probe bodies are not collected or cached. + +## Sticky sessions and drain + +`sticky.mode` is `off`, `cookie` or `ip_hash`. IP mode overrides the normal algorithm +using the actual peer IP. Users behind one NAT/proxy share that identity. + +Cookie mode uses a random 32-character opaque member token, not a backend URL. +The default name is `RP_AFFINITY_`; custom names accept 1..96 ASCII letters, +digits, underscores or hyphens and cannot use the admin session name. TTL defaults +to 3600 seconds (60..604800), with Path=/, HttpOnly, SameSite=Lax and Secure on TLS. +`__Host-`/`__Secure-` names require every frontend to use TLS. Cookies are host-only, +so aliases have independent browser cookies. They do not authenticate users. + +Cookie-affine requests always bypass shared cache. The dedicated routing cookie +is stripped before forwarding; origins cannot override it via Set-Cookie. Unknown, +duplicate or unhealthy-target tokens are ignored/reassigned. Existing valid cookie +sessions may continue on a healthy draining/backup member; new sessions do not +choose a draining member. Disabling a member prevents all new assignments, +including existing cookie sessions, while already admitted exchanges finish. + +Affinity tokens, health and traffic observations survive compatible hot edits. +A changed origin/health policy or re-enabled member is checked afresh. Process +restart resets tokens/counters; clients can be reassigned. Session data itself is +never replicated: applications needing durable sessions require a shared store. + +## Response protection + +`security_headers` supports `hsts` (`enabled`, `max_age_seconds`, +`include_subdomains`, `preload`), `nosniff`, `frame_options` (`preserve`, `deny`, +`sameorigin`), `referrer_policy`, `content_security_policy` and `permissions_policy`. +Header values are validated against splitting and bounded to 8192 bytes. + +HSTS is configured only for TLS responses. For a matched vhost, plaintext responses +remove Strict-Transport-Security even if an origin/custom rule adds it. On TLS, +disabled HSTS means no forced replacement of an origin value; use a custom remove +rule to suppress that value. Enabled HSTS overrides generic rules. `max_age_seconds` +accepts 0..63072000 (default31536000); setting zero is available for explicit expiry. +Preload is opt-in and requires enabled HSTS, includeSubDomains and at least one year. +This only emits the directive; it does not register a hostname in a preload list. +Start with a short lifetime and verify every affected hostname before increasing it. + +Request rules run global -> vhost -> path, then protected identity fields are +regenerated. Response rules run global -> vhost -> path, followed by dedicated +security/Server policy and optional compression. Security headers cover cache hits +and matched-host synthetic errors/ACL denials too. Protected transport fields +remain uneditable. `false`, `preserve` or null security settings otherwise leave +origin values unchanged. CSP and Permissions-Policy must suit the application; +RustProxy validates header encoding, not the semantic correctness of those policies. + +## Live state, cache and limits + +The dashboard defaults a newly created vhost to `cache.enabled: false`. When the +effective cache state is disabled, the editor hides the remaining per-vhost cache +controls; enabling cache (or explicitly choosing inheritance from an enabled global +cache) reveals them. This is a UI default for new drafts, not a migration that rewrites +existing vhost cache settings. + + +`GET /api/backends` requires an admin session and returns `pools`, +`active_frontends`, `desired_frontends`. `/api/status` and SSE contain `backend_pools` +and complete active/desired endpoint plans. The dashboard renders per-member state, +active exchanges, request/error totals, EWMA header latency and payload traffic. +Prometheus adds bounded vhost/member labels; access logs include `backend` as +`vhost/member` for actual pool forwarding (null when no upstream exchange is made). + +Bytes are observed payload, not NIC throughput; response counters include the +client-delivered encoded representation. Header latency includes connection/setup and any request upload; it is not full +exchange time. Active probes use body-free requests for an independent measurement. +Health mode off is labeled as checks off, not proof of a probe passing. Statistics +are observations, not persisted configuration. They reset on process restart. + +Cookie affinity bypasses cache entirely. A pool with no eligible member does not invent +an unselected cached fallback. Config changes invalidate incompatible cache state. +RFC 5861/request `max-stale` behavior is owned by the shared cache and does not alter +backend member selection; request coalescing remains separate from stale-while-revalidate +deduplication. + +Limits: 64 vhosts; hostname lists are not capped by an application-level item count; +listener entries remain capped at 16 per host, with 32 members and 64 paths per host; +128 backend definitions and 256 paths across all vhosts; +128 additional declared frontend entries plus two globals; 256 KiB serialized +configuration. Existing request/connection/DNS/queue bounds remain in force. + +Every successful config publication rebuilds upstream connection pools. Compatible +member observations/tokens and in-flight exchanges survive, but idle sockets are +not retained across publication. Listener/address/ALPN changes are saved immediately +and require restart; routing changes apply to existing bound sockets immediately. +When moving a host away from an old IP, it can become unavailable until restart +rather than being served on an address no longer permitted by desired policy. + +## Per-vhost operations in 0.3.0 + +The compact list opens a tabbed editor rather than displaying a full form beside +the list. Health summaries update on the list and editor without replacing unsaved +form controls. Header rules have a visual request/response editor; Advanced JSON +remains available and stays synchronized with those rows. + +The SSL tab selects an existing certificate ID or uploads a chain/private-key +pair into private staging. Only Save activates the selection. A shared global SNI +resolver cannot serve different certificates for the same DNS name by destination +IP, so conflicting selections are rejected. Browser upload is local-admin only; +there is no ACME, encrypted-key password prompt, certificate download or automated +cleanup. Details and limits: MANAGEMENT.md. + +Cache overrides inherit field by field and use one shared memory budget. The list +and Cache tab offer selective purge. An explicitly disabled global cache can still be +overridden by an explicitly enabled vhost. Path exclusions and sticky-cookie safeguards +remain in force. See CACHE.md. + +The **Transform** tab is a separate vhost opt-in. `transform.enabled` defaults to false +for every vhost even when the global transform engine is enabled. JavaScript, CSS, HTML, +SVG and JSON can inherit or override global format switches, and the extension list may +be replaced per vhost. Eligible origin responses are transformed before shared-cache +storage and before downstream compression. Origin `Cache-Control: no-transform` always +wins and cannot be overridden from the vhost. + +Pages offers a static/maintenance response, a fallback for unassigned paths, and +error overrides. Vhosts still retain their configured backend pool; static mode +avoids forwarding but is not a backend-less static-file server. Host/path ACLs run +first. Explicit error-status overrides change presentation, not authorization. +Empty/inherit means use the global error policy; to disable one inherited error +page specifically, configure an explicit replacement rather than expecting null +to override inheritance. See MANAGEMENT.md for exact precedence. + + +### Backend Host compatibility + +New backends use the backend URL host for the upstream `Host` header by default, matching nginx `proxy_pass` behavior. Enable **Preserve client Host** only when the application intentionally routes on the public hostname. This is especially important for embedded/lighttpd-style administration panels that may close or reject requests carrying an unknown Host. Existing explicit settings are preserved during upgrades. + +When an upstream request fails before an HTTP response is received, access logs include a bounded `upstream_error` field in addition to `upstream_status`. diff --git a/docs/VHOST_ACCESS.md b/docs/VHOST_ACCESS.md new file mode 100644 index 0000000..3409fe4 --- /dev/null +++ b/docs/VHOST_ACCESS.md @@ -0,0 +1,121 @@ +# Vhost access and visual locations + +## 0.8.0 edge traffic controls + +The Security/Traffic policy section now includes per-client-IP request limits, burst, +window length, country-specific limit overrides, country blocking or allow-only mode, +per-vhost HTTP/3 enable/disable, optional `Server-Timing`, and a Cloudflare real-client-IP +switch. Country rules use the install-time GeoIP country database. Allow-only mode +fails closed when a country cannot be resolved; block mode only blocks resolved matches. +Exceeded limits return 429 plus `Retry-After`; country policy denial returns 403. Both +use built-in HTML pages unless a configured custom error page replaces them. + +Cloudflare mode trusts `CF-Connecting-IP` only when the **direct peer** is inside the +compiled Cloudflare edge CIDRs. An arbitrary client sending that header directly does +not become trusted. The verified address becomes the effective client IP for ACLs, +rate limiting, country lookup and forwarded metadata. + +Vhost listener editing is now selection-only. Listener addresses/IPs and transport +settings live under Settings > Listeners; a vhost selects one or more named endpoints. +Existing inline/legacy bindings are promoted to named endpoints when the vhost is saved. + +New backend rows use the HTTPS-by-IP debugging defaults: include in the default LB pool, +allow private origin, HTTP/1.1, preserve client Host and backend TLS verification OFF. +The panel shows the risk and asks before saving an HTTPS-by-IP backend that does not +match the complete preset. Existing backend values are not rewritten automatically. + +Health mode `off` disables active probes only. Passive Backend Health is a separate +opt-in policy and defaults to OFF. With the default policy, normal request failures are +still counted but never mark a backend healthy/unhealthy; an administrator may explicitly +enable passive health even while active probe mode remains `off`. + + +## WebSockets + +`vhosts[].websockets` controls HTTP/1.1 Upgrade forwarding for that host. Existing +configuration omitting it retains the previous enabled behavior. New hosts created +in the panel default to disabled. Disabled valid upgrades return 403 without opening +an upstream connection; normal HTTP continues. Basic Auth/Tinyauth and ACLs still +apply to an allowed handshake. This does not implement WebSockets over HTTP/3, +WebTransport or generalized CONNECT, and does not terminate already open tunnels +when toggled; it governs subsequent handshakes. After a successful `101`, normal TCP +EOF/reset/abort/broken-pipe closure is recorded as tunnel closure rather than an invalid +proxy request; handshake/protocol errors remain errors. + +## Basic Auth and ACL + +The Authentication tab selects None, Tinyauth or HTTP Basic. Basic and Tinyauth +cannot both be enabled. The Basic editor manages users, realm, TLS requirement +and whether verified `Authorization: Basic ...` is passed to the backend. + +Only bounded Argon2id password hashes are persisted. New/changed passwords are +sent to `POST /api/basic-auth/hash` through the protected administrator API; no +password is stored in browser storage, desired configuration, audit or access logs. +An empty password while editing keeps the saved hash. New passwords use the existing +12-128 byte administrator password policy; Basic credentials cannot contain control +characters. Usernames are case-sensitive, up to 128 UTF-8 bytes, without colons or +control characters. This is not an htpasswd/APR1/bcrypt importer. + +TLS is required by default and is checked from the accepted connection, not +`X-Forwarded-Proto`. Plaintext HTTP is allowed only by an explicit host option; avoid +that outside a deliberately protected TLS-termination network. HTTP Basic encodes, +not encrypts, the username/password. Keep the frontend and any credential-carrying +upstream hop protected. Administrator access defaults to loopback; direct remote access is supported when Administration HTTPS is enabled and the bind/hostname policy is configured. + +Successful credentials are removed before forwarding by default. Selecting forward +passes the original **verified** Basic Authorization header, not a password from +configuration or an unverified client value. The final enforcement step runs after +custom request-header rules, which cannot reintroduce removed credentials. +`Remote-User`/`Remote-Email` are stripped in Basic mode; the separate Tinyauth mode +continues to use verified identity headers from that service. + +Global host policies, reusable ACL profiles, vhost ACLs and location ACLs remain +cumulative: access requires all applicable policies **and** successful authentication. +An ACL profile can be defined once under **ACL profiles**, combine default allow/deny +IPv4/IPv6 CIDR rules with country block/allow-only policy, then be attached to several +vhosts. Per-vhost rules remain available and are evaluated in addition to the profile. +Within an IP ACL, deny takes precedence. This is not an Nginx `satisfy any` mode: a +permitted IP cannot skip another applicable policy or a password. The graphical inline +ACL editor still supports default allow/deny and IPv4/IPv6 CIDR rules. +Trusted-proxy settings control which peer can contribute the effective client IP; +untrusted forwarding headers do not grant network access. + +Authentication runs before static pages, cache, origin connections and upgrades. +401 responses include a Basic challenge and `private, no-store`; custom pages or +header rules cannot convert these failures into successful access. Protected vhosts +bypass shared response caching. A process-wide limit allows two Argon2 workers; +failed-password accounting is bounded to 4096 IPs, with ten failures per minute +blocking that address for the rest of the window. Busy hashing returns 503; a +blocked address returns 429 with Retry-After. This is abuse resistance, not MFA or +a replacement for perimeter rate limiting, especially for many clients behind NAT. + +## Custom locations + +The **Custom locations** tab supports ordinary prefix, priority prefix (`^~`), exact (`=`), +case-sensitive regex (`~`) and case-insensitive regex (`~*`) matching. Exact matches win first; +a selected priority prefix skips regex evaluation; regexes are tested in declaration order; the +longest ordinary prefix is the fallback. Exact paths preserve a meaningful trailing slash. + +A proxy location can use the normal pool, pin one backend, inherit/override cache behavior and +apply an additional ACL. Path handling can preserve the public URL, strip a prefix, replace a +prefix or perform a capture-based regex rewrite. Query parameters are preserved. Repeated literal +slashes are normalized only for routing/ACL decisions; without an explicit rewrite the backend +receives the original target, including `//`. Encoded separators, percent double-decoding and dot +segments remain rejected. + +A location can alternatively **Return custom response** with a 200–599 status, Content-Type and +body without opening a backend connection. Route/vhost ACL and authentication still apply. A 3xx +return plus a visual `Location` response-header rule is the supported simple redirect pattern. + +Request/response headers have a graphical `set`/`append`/`remove` builder, inline documentation +and a synchronized **Advanced JSON** view. Protected framing, forwarding-identity, request-ID and +WebSocket handshake headers cannot be overridden. The full routes JSON remains available as an +automation/escape hatch, but invalid/unknown fields are rejected rather than silently ignored. + +RustProxy intentionally implements an explicit safe subset, not arbitrary Nginx configuration: +location-level `try_files`, filesystem `root`/`alias`, embedded scripting, arbitrary `if` and +module directives are not accepted. See [Custom locations](CUSTOM_LOCATIONS.md) for examples and +the exact supported subset. + +Reference: HTTP Basic security considerations, RFC 7617: +https://www.rfc-editor.org/rfc/rfc7617.html#section-4 diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md new file mode 100644 index 0000000..1612fef --- /dev/null +++ b/docs/screenshots/README.md @@ -0,0 +1,8 @@ +# UI preview policy + +Only two legacy simulated previews are retained as narrow visual references. They were generated with injected HTML/CSS/JS and simulated API/fetch/storage, so they are **not current Rust runtime evidence** and may differ from the 0.22.3 UI. Runtime verification status belongs in [../TEST_REPORT.md](../TEST_REPORT.md). + +- `login.png`: anonymous minimal Polish login document. +- `location-headers.png`: request/response rules for an individual location. + +RustProxy 0.19.1 removes the other old simulated screenshots so stale layouts are not presented as current product state. diff --git a/examples/acl-headers.json b/examples/acl-headers.json new file mode 100644 index 0000000..fd098c2 --- /dev/null +++ b/examples/acl-headers.json @@ -0,0 +1,236 @@ +{ + "host_policies": [ + { + "hosts": [ + "localhost", + "127.0.0.1", + "demo.localhost" + ], + "acl": { + "default": "deny", + "rules": [ + { + "network": "127.0.0.0/8", + "action": "allow" + }, + { + "network": "::1/128", + "action": "allow" + } + ] + } + } + ], + "headers": { + "request": [ + { + "op": "set", + "name": "x-rustproxy-example", + "value": "global" + } + ], + "response": [ + { + "op": "remove", + "name": "server" + }, + { + "op": "set", + "name": "x-content-type-options", + "value": "nosniff" + } + ] + }, + "vhosts": [ + { + "id": "example-1", + "frontend": { + "hosts": [ + "localhost" + ], + "listener_ids": [ + "example-1-http" + ] + }, + "backends": [ + { + "id": "demo", + "url": "http://127.0.0.1:9000", + "allow_private": true, + "preserve_host": true + } + ], + "load_balancing": { + "health_check": { + "mode": "off" + } + }, + "routes": [ + { + "id": "demo", + "path_prefix": "/", + "backend_id": "demo", + "enabled": true, + "headers": { + "request": [ + { + "op": "set", + "name": "x-rustproxy-example", + "value": "route" + } + ], + "response": [ + { + "op": "set", + "name": "x-rustproxy-demo", + "value": "enabled" + } + ] + } + }, + { + "id": "blocked-admin", + "path_prefix": "/admin", + "backend_id": "demo", + "enabled": true, + "acl": { + "default": "deny", + "rules": [] + } + } + ] + }, + { + "id": "example-2", + "frontend": { + "hosts": [ + "127.0.0.1" + ], + "listener_ids": [ + "example-1-http" + ] + }, + "backends": [ + { + "id": "demo", + "url": "http://127.0.0.1:9000", + "allow_private": true, + "preserve_host": true + } + ], + "load_balancing": { + "health_check": { + "mode": "off" + } + }, + "routes": [ + { + "id": "demo", + "path_prefix": "/", + "backend_id": "demo", + "enabled": true, + "headers": { + "request": [ + { + "op": "set", + "name": "x-rustproxy-example", + "value": "route" + } + ], + "response": [ + { + "op": "set", + "name": "x-rustproxy-demo", + "value": "enabled" + } + ] + } + }, + { + "id": "blocked-admin", + "path_prefix": "/admin", + "backend_id": "demo", + "enabled": true, + "acl": { + "default": "deny", + "rules": [] + } + } + ] + }, + { + "id": "example-3", + "frontend": { + "hosts": [ + "demo.localhost" + ], + "listener_ids": [ + "example-1-http" + ] + }, + "backends": [ + { + "id": "demo", + "url": "http://127.0.0.1:9000", + "allow_private": true, + "preserve_host": true + } + ], + "load_balancing": { + "health_check": { + "mode": "off" + } + }, + "routes": [ + { + "id": "demo", + "path_prefix": "/", + "backend_id": "demo", + "enabled": true, + "headers": { + "request": [ + { + "op": "set", + "name": "x-rustproxy-example", + "value": "route" + } + ], + "response": [ + { + "op": "set", + "name": "x-rustproxy-demo", + "value": "enabled" + } + ] + } + }, + { + "id": "blocked-admin", + "path_prefix": "/admin", + "backend_id": "demo", + "enabled": true, + "acl": { + "default": "deny", + "rules": [] + } + } + ] + } + ], + "listeners": { + "endpoints": [ + { + "id": "example-1-http", + "name": "example-1 HTTP", + "scope": "internal", + "enabled": true, + "binding": { + "address": "127.0.0.1:8080", + "tls": false, + "http2": false, + "http3": null + } + } + ] + } +} diff --git a/examples/cache-dashboard.json b/examples/cache-dashboard.json new file mode 100644 index 0000000..13a4410 --- /dev/null +++ b/examples/cache-dashboard.json @@ -0,0 +1,159 @@ +{ + "listeners": { + "endpoints": [ + { + "id": "example-1-http", + "name": "example-1 HTTP", + "scope": "internal", + "enabled": true, + "binding": { + "address": "127.0.0.1:8080", + "tls": false, + "http2": false, + "http3": null + } + } + ] + }, + "server_header": { + "mode": "custom", + "value": "RustProxy/v0.5.0" + }, + "compression": { + "gzip": true, + "br": true, + "zstd": true, + "deflate": false, + "min_bytes": 1024 + }, + "cache": { + "enabled": true, + "max_bytes": 268435456, + "max_object_bytes": 16777216, + "max_entries": 4096, + "transient_max_bytes": 67108864, + "cold_queue_max_bytes": 33554432, + "hot_eviction_max_objects": 64, + "default_ttl_seconds": 0, + "max_ttl_seconds": 86400, + "bypass_query": false, + "bypass_prefixes": [ + "/private" + ], + "expose_status": true, + "expose_hits": true, + "expose_cache_status": true + }, + "headers": { + "request": [], + "response": [ + { + "op": "remove", + "name": "x-powered-by" + }, + { + "op": "set", + "name": "x-served-by", + "value": "RustProxy" + } + ] + }, + "vhosts": [ + { + "id": "example-1", + "frontend": { + "hosts": [ + "localhost" + ], + "listener_ids": [ + "example-1-http" + ] + }, + "backends": [ + { + "id": "demo", + "url": "http://127.0.0.1:9000", + "allow_private": true, + "preserve_host": true + } + ], + "load_balancing": { + "health_check": { + "mode": "off" + } + }, + "routes": [ + { + "id": "demo", + "path_prefix": "/", + "backend_id": "demo", + "enabled": true + } + ] + }, + { + "id": "example-2", + "frontend": { + "hosts": [ + "127.0.0.1" + ], + "listener_ids": [ + "example-1-http" + ] + }, + "backends": [ + { + "id": "demo", + "url": "http://127.0.0.1:9000", + "allow_private": true, + "preserve_host": true + } + ], + "load_balancing": { + "health_check": { + "mode": "off" + } + }, + "routes": [ + { + "id": "demo", + "path_prefix": "/", + "backend_id": "demo", + "enabled": true + } + ] + }, + { + "id": "example-3", + "frontend": { + "hosts": [ + "demo.localhost" + ], + "listener_ids": [ + "example-1-http" + ] + }, + "backends": [ + { + "id": "demo", + "url": "http://127.0.0.1:9000", + "allow_private": true, + "preserve_host": true + } + ], + "load_balancing": { + "health_check": { + "mode": "off" + } + }, + "routes": [ + { + "id": "demo", + "path_prefix": "/", + "backend_id": "demo", + "enabled": true + } + ] + } + ] +} diff --git a/examples/config.json b/examples/config.json new file mode 100644 index 0000000..f5666cb --- /dev/null +++ b/examples/config.json @@ -0,0 +1,52 @@ +{ + "vhosts": [ + { + "id": "demo", + "frontend": { + "hosts": [ + "localhost", + "127.0.0.1", + "demo.localhost" + ], + "listener_ids": [ + "demo-http" + ] + }, + "backends": [ + { + "id": "demo", + "url": "http://127.0.0.1:9000", + "allow_private": true, + "preserve_host": true + } + ], + "load_balancing": { + "health_check": { + "mode": "off" + } + }, + "routes": [ + { + "id": "root", + "path_prefix": "/" + } + ] + } + ], + "listeners": { + "endpoints": [ + { + "id": "demo-http", + "name": "demo HTTP", + "scope": "internal", + "enabled": true, + "binding": { + "address": "127.0.0.1:8080", + "tls": false, + "http2": false, + "http3": null + } + } + ] + } +} diff --git a/examples/empty.json b/examples/empty.json new file mode 100644 index 0000000..ce143e7 --- /dev/null +++ b/examples/empty.json @@ -0,0 +1,3 @@ +{ + "vhosts": [] +} diff --git a/examples/http3-vhost.json b/examples/http3-vhost.json new file mode 100644 index 0000000..e4fc717 --- /dev/null +++ b/examples/http3-vhost.json @@ -0,0 +1,61 @@ +{ + "listeners": { + "endpoints": [ + { + "id": "local-https", + "name": "Local TLS / HTTP3", + "scope": "internal", + "enabled": true, + "binding": { + "address": "127.0.0.1:8443", + "tls": true, + "http2": true, + "http3": { + "alt_svc_max_age": 3600, + "max_concurrent_connections": 512, + "max_concurrent_bidistream": 100, + "max_concurrent_unistream": 100, + "max_idle_timeout": 10 + } + } + } + ] + }, + "vhosts": [ + { + "id": "h3-demo", + "frontend": { + "hosts": [ + "localhost" + ], + "listener_ids": [ + "local-https" + ] + }, + "backends": [ + { + "id": "origin-a", + "url": "http://127.0.0.1:9001", + "allow_private": true + } + ], + "load_balancing": { + "health_check": { + "mode": "off" + } + }, + "cache": { + "enabled": true, + "expose_status": true, + "expose_hits": true + }, + "server_header": { + "mode": "custom", + "value": "RustProxy/v0.5.0" + }, + "diagnostic_headers": { + "request_id": false + } + } + ] +} diff --git a/examples/mock-vhost.json b/examples/mock-vhost.json new file mode 100644 index 0000000..678567b --- /dev/null +++ b/examples/mock-vhost.json @@ -0,0 +1,92 @@ +{ + "cache": { + "enabled": false + }, + "compression": { + "gzip": true, + "br": true, + "min_bytes": 1024 + }, + "pages": { + "direct_ip": { + "kind": "welcome", + "status": 200 + } + }, + "vhosts": [ + { + "id": "mock", + "frontend": { + "hosts": [ + "mock.localhost" + ], + "listener_ids": [ + "mock-http" + ] + }, + "backends": [ + { + "id": "origin-a", + "url": "http://127.0.0.1:9001", + "allow_private": true, + "preserve_host": true + } + ], + "load_balancing": { + "health_check": { + "mode": "http", + "path": "/health", + "interval_ms": 1000, + "timeout_ms": 800, + "slow_response_ms": 600, + "fall": 2, + "rise": 1 + }, + "slow_start_seconds": 0 + }, + "cache": { + "enabled": true, + "expose_status": true, + "expose_hits": true, + "expose_cache_status": true, + "max_ttl_seconds": 300, + "default_ttl_seconds": 30 + }, + "headers": { + "response": [ + { + "op": "set", + "name": "x-site", + "value": "mock" + } + ] + }, + "security_headers": { + "nosniff": true + }, + "pages": { + "errors": { + "503": { + "kind": "html", + "html": "Maintenance

Origin unavailable

Please try again soon.

" + } + } + } + } + ], + "listeners": { + "endpoints": [ + { + "id": "mock-http", + "name": "mock HTTP", + "scope": "internal", + "enabled": true, + "binding": { + "address": "127.0.0.1:8080", + "tls": false, + "http2": false + } + } + ] + } +} diff --git a/examples/tinyauth-vhost.json b/examples/tinyauth-vhost.json new file mode 100644 index 0000000..1c4908f --- /dev/null +++ b/examples/tinyauth-vhost.json @@ -0,0 +1,69 @@ +{ + "listeners": { + "endpoints": [ + { + "id": "private-http", + "name": "Private applications", + "scope": "internal", + "enabled": true, + "binding": { + "address": "127.0.0.2:8181", + "tls": false, + "http2": false + } + } + ] + }, + "vhosts": [ + { + "id": "tinyauth-app", + "frontend": { + "hosts": [ + "app.localhost" + ], + "listener_ids": [ + "private-http" + ] + }, + "backends": [ + { + "id": "origin-a", + "url": "http://127.0.0.1:9001", + "allow_private": true + } + ], + "load_balancing": { + "health_check": { + "mode": "off" + } + }, + "cache": { + "enabled": true, + "expose_status": true, + "expose_hits": true + }, + "server_header": { + "mode": "custom", + "value": "RustProxy/v0.5.0" + }, + "diagnostic_headers": { + "request_id": false + }, + "tinyauth": { + "enabled": true, + "url": "http://10.87.7.99:3000", + "login_url": "https://auth.linuxiarz.pl/login", + "allow_private": true, + "address_allowlist": [ + "10.87.7.99/32" + ], + "connect_timeout_seconds": 5, + "timeout_seconds": 10, + "response_headers": [ + "remote-user", + "remote-email" + ] + } + } + ] +} diff --git a/examples/transport.json b/examples/transport.json new file mode 100644 index 0000000..bd96033 --- /dev/null +++ b/examples/transport.json @@ -0,0 +1,69 @@ +{ + "tls": { + "certificates": [ + { + "id": "app", + "hosts": [ + "app.localhost" + ], + "cert_file": "app.pem", + "key_file": "app.key" + } + ] + }, + "vhosts": [ + { + "id": "example-1", + "frontend": { + "hosts": [ + "app.localhost" + ], + "listener_ids": [ + "example-1-http" + ] + }, + "backends": [ + { + "id": "demo-dns", + "url": "http://localhost:9000", + "protocol": "http1", + "allow_private": true, + "preserve_host": true, + "address_allowlist": [ + "127.0.0.0/8", + "::1/128" + ] + } + ], + "load_balancing": { + "health_check": { + "mode": "off" + } + }, + "routes": [ + { + "id": "app", + "path_prefix": "/", + "backend_id": "demo-dns", + "enabled": true + } + ] + } + ], + "listeners": { + "endpoints": [ + { + "id": "example-1-http", + "name": "example-1 HTTP", + "scope": "internal", + "enabled": true, + "binding": { + "address": "127.0.0.1:8080", + "tls": false, + "http2": false, + "http3": null + } + } + ] + } +} diff --git a/examples/vhosts-balanced.json b/examples/vhosts-balanced.json new file mode 100644 index 0000000..b018426 --- /dev/null +++ b/examples/vhosts-balanced.json @@ -0,0 +1,138 @@ +{ + "listeners": { + "endpoints": [ + { + "id": "public-app-http", + "name": "public-app HTTP", + "scope": "internal", + "enabled": true, + "binding": { + "address": "0.0.0.0:80", + "tls": false, + "http2": false + } + }, + { + "id": "public-app-https", + "name": "public-app HTTPS", + "scope": "internal", + "enabled": true, + "binding": { + "address": "0.0.0.0:443", + "tls": true, + "http2": true + } + } + ] + }, + "vhosts": [ + { + "id": "public-app", + "enabled": true, + "frontend": { + "hosts": [ + "app.example.test" + ], + "listener_ids": [ + "public-app-http", + "public-app-https" + ] + }, + "backends": [ + { + "id": "app-a", + "url": "http://127.0.0.1:9001", + "weight": 3, + "enabled": true, + "allow_private": true, + "preserve_host": true, + "protocol": "http1", + "address_allowlist": [ + "127.0.0.1/32" + ] + }, + { + "id": "app-b", + "url": "http://127.0.0.1:9002", + "weight": 1, + "enabled": true, + "allow_private": true, + "preserve_host": true, + "protocol": "http1", + "address_allowlist": [ + "127.0.0.1/32" + ] + } + ], + "load_balancing": { + "algorithm": "adaptive", + "slow_start_seconds": 10, + "health_check": { + "mode": "http", + "path": "/health", + "interval_ms": 5000, + "timeout_ms": 2000, + "slow_response_ms": 1500, + "fall": 3, + "rise": 2, + "expected_status_min": 200, + "expected_status_max": 299 + }, + "keepalive": { + "enabled": true, + "idle_timeout_seconds": 30, + "max_idle_per_host": 4, + "tcp_keepalive_seconds": 30 + }, + "sticky": { + "mode": "cookie", + "ttl_seconds": 3600 + } + }, + "security_headers": { + "hsts": { + "enabled": true, + "max_age_seconds": 300, + "include_subdomains": false, + "preload": false + }, + "nosniff": true, + "frame_options": "sameorigin", + "referrer_policy": "strict-origin-when-cross-origin" + }, + "headers": { + "response": [ + { + "op": "remove", + "name": "x-powered-by" + }, + { + "op": "set", + "name": "x-served-by", + "value": "RustProxy" + } + ] + }, + "routes": [ + { + "id": "root", + "path_prefix": "/", + "cache": false + } + ] + } + ], + "tls": { + "default_certificate": "app", + "certificates": [ + { + "id": "app", + "hosts": [ + "app.example.test" + ], + "cert_file": "app.pem", + "key_file": "app.key" + } + ] + } +} diff --git a/examples/vhosts-private.json b/examples/vhosts-private.json new file mode 100644 index 0000000..0675bd7 --- /dev/null +++ b/examples/vhosts-private.json @@ -0,0 +1,110 @@ +{ + "listeners": { + "endpoints": [ + { + "id": "private-app-http", + "name": "private-app HTTP", + "scope": "internal", + "enabled": true, + "binding": { + "address": "127.0.0.2:8080", + "tls": false, + "http2": false + } + } + ] + }, + "vhosts": [ + { + "id": "private-app", + "enabled": true, + "frontend": { + "hosts": [ + "private.localhost" + ], + "listener_ids": [ + "private-app-http" + ] + }, + "backends": [ + { + "id": "app-a", + "url": "http://127.0.0.1:9001", + "weight": 3, + "enabled": true, + "allow_private": true, + "preserve_host": true, + "protocol": "http1", + "address_allowlist": [ + "127.0.0.1/32" + ] + }, + { + "id": "app-b", + "url": "http://127.0.0.1:9002", + "weight": 1, + "enabled": true, + "allow_private": true, + "preserve_host": true, + "protocol": "http1", + "address_allowlist": [ + "127.0.0.1/32" + ] + } + ], + "load_balancing": { + "algorithm": "adaptive", + "slow_start_seconds": 10, + "health_check": { + "mode": "http", + "path": "/health", + "interval_ms": 5000, + "timeout_ms": 2000, + "slow_response_ms": 1500, + "fall": 3, + "rise": 2, + "expected_status_min": 200, + "expected_status_max": 299 + }, + "keepalive": { + "enabled": true, + "idle_timeout_seconds": 30, + "max_idle_per_host": 4, + "tcp_keepalive_seconds": 30 + }, + "sticky": { + "mode": "off", + "ttl_seconds": 3600 + } + }, + "security_headers": { + "hsts": { + "enabled": false + }, + "nosniff": true, + "frame_options": "sameorigin", + "referrer_policy": "strict-origin-when-cross-origin" + }, + "headers": { + "response": [ + { + "op": "remove", + "name": "x-powered-by" + }, + { + "op": "set", + "name": "x-served-by", + "value": "RustProxy" + } + ] + }, + "routes": [ + { + "id": "root", + "path_prefix": "/", + "cache": false + } + ] + } + ] +} diff --git a/grafana/README.md b/grafana/README.md new file mode 100644 index 0000000..92f997e --- /dev/null +++ b/grafana/README.md @@ -0,0 +1,34 @@ +# Grafana dashboard + +`rustproxy-overview.json` is a provisioning/import dashboard for the dedicated +RustProxy Prometheus exporter. It covers global traffic, status classes, vhost +latency, backend health/latency, a dedicated Docker-vhost section, hot/cold/transient +cache memory, Linux process and cgroup diagnostics, HTTP/2/HTTP/3/TLS/WebSocket +counters, Layer-4 streams, structured-log sink health and configuration inventory. + +The **Docker vhosts** row uses only the dedicated `rust_proxy_docker_*` metric +families and includes configured/enabled counts, request/error rates, p95 exchange +latency, backend health/EWMA latency/active work, traffic and probe age. Dashboard +variables **Docker agent** and **Docker service** filter only this section, which keeps +multi-agent installations readable without changing the ordinary vhost panels. + +Import the JSON in Grafana and select a Prometheus datasource when prompted. The +dashboard expects RustProxy 0.18.3 metric names and a scrape interval around 15s. +Collectors can be disabled in RustProxy settings; panels for disabled collectors +will simply show no series. + +Recommended scrape job: + +```yaml +scrape_configs: + - job_name: rustproxy + scrape_interval: 15s + metrics_path: /metrics + static_configs: + - targets: ['rustproxy-host:9191'] +``` + +When the exporter is protected with a bearer token, configure Prometheus +`authorization.credentials_file`. For a private monitoring VLAN, RustProxy can +instead explicitly allow actual peers from configured trusted CIDRs to scrape +without a token. Forwarded client-IP headers never satisfy that trust check. diff --git a/grafana/rustproxy-overview.json b/grafana/rustproxy-overview.json new file mode 100644 index 0000000..2f7042d --- /dev/null +++ b/grafana/rustproxy-overview.json @@ -0,0 +1,3679 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "id": 1, + "type": "row", + "title": "Overview", + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "panels": [] + }, + { + "id": 2, + "type": "stat", + "title": "Request rate", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 1 + }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "custom": {} + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(rate(rust_proxy_requests_total[$__rate_interval]))", + "legendFormat": "req/s", + "range": true + } + ] + }, + { + "id": 3, + "type": "stat", + "title": "5xx / errors rate", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 1 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": {} + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(rate(rust_proxy_errors_total[$__rate_interval]))", + "legendFormat": "errors/s", + "range": true + } + ] + }, + { + "id": 4, + "type": "stat", + "title": "Active exchanges", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 1 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": {} + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(rust_proxy_requests_active)", + "legendFormat": "active", + "range": true + } + ] + }, + { + "id": 5, + "type": "stat", + "title": "Process RSS", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 1 + }, + "fieldConfig": { + "defaults": { + "unit": "bytes", + "custom": {} + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto" + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_process_rss_bytes", + "legendFormat": "RSS", + "range": true + } + ] + }, + { + "id": 6, + "type": "timeseries", + "title": "Traffic and responses", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 6 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum(rate(rust_proxy_requests_total[$__rate_interval]))", + "legendFormat": "requests/s", + "range": true + }, + { + "refId": "B", + "expr": "sum(rate(rust_proxy_requests_completed_total[$__rate_interval]))", + "legendFormat": "completed/s", + "range": true + }, + { + "refId": "C", + "expr": "sum(rate(rust_proxy_errors_total[$__rate_interval]))", + "legendFormat": "errors/s", + "range": true + }, + { + "refId": "D", + "expr": "sum(rate(rust_proxy_requests_rejected_total[$__rate_interval]))", + "legendFormat": "rejected/s", + "range": true + } + ] + }, + { + "id": 7, + "type": "timeseries", + "title": "Response status classes", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 6 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (class) (rate(rust_proxy_responses_total[$__rate_interval]))", + "legendFormat": "{{class}}", + "range": true + } + ] + }, + { + "id": 8, + "type": "row", + "title": "Vhosts and backends", + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 13 + }, + "panels": [] + }, + { + "id": 9, + "type": "timeseries", + "title": "Requests by vhost", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 14 + }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (vhost) (rate(rust_proxy_vhost_requests_total[$__rate_interval]))", + "legendFormat": "{{vhost}}", + "range": true + } + ] + }, + { + "id": 10, + "type": "timeseries", + "title": "Vhost p95 exchange latency", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 14 + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "histogram_quantile(0.95, sum by (le,vhost) (rate(rust_proxy_vhost_exchange_duration_seconds_bucket[$__rate_interval])))", + "legendFormat": "{{vhost}}", + "range": true + } + ] + }, + { + "id": 11, + "type": "timeseries", + "title": "Backend health", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 21 + }, + "fieldConfig": { + "defaults": { + "unit": "bool", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_backend_up", + "legendFormat": "{{vhost}} / {{backend}}", + "range": true + } + ] + }, + { + "id": 12, + "type": "timeseries", + "title": "Backend header latency", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 21 + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_backend_latency_seconds", + "legendFormat": "{{vhost}} / {{backend}}", + "range": true + } + ] + }, + { + "id": 13, + "type": "timeseries", + "title": "Backend active exchanges", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 21 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_backend_active_exchanges", + "legendFormat": "{{vhost}} / {{backend}}", + "range": true + } + ] + }, + { + "id": 14, + "type": "timeseries", + "title": "Backend error rate", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 28 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (vhost,backend) (rate(rust_proxy_backend_errors_total[$__rate_interval]))", + "legendFormat": "{{vhost}} / {{backend}}", + "range": true + } + ] + }, + { + "id": 15, + "type": "timeseries", + "title": "Backend traffic estimate", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 28 + }, + "fieldConfig": { + "defaults": { + "unit": "Bps", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_backend_traffic_bytes_per_second", + "legendFormat": "{{vhost}} / {{backend}}", + "range": true + } + ] + }, + { + "id": 58, + "type": "row", + "title": "Docker vhosts", + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 35 + }, + "panels": [] + }, + { + "id": 59, + "type": "stat", + "title": "Docker agents configured", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 36 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": {} + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto" + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_docker_agents_configured", + "legendFormat": "value", + "range": true + } + ] + }, + { + "id": 60, + "type": "stat", + "title": "Docker vhosts configured", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 36 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": {} + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto" + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_docker_vhosts_configured", + "legendFormat": "value", + "range": true + } + ] + }, + { + "id": 61, + "type": "stat", + "title": "Docker vhosts enabled", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 36 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": {} + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(rust_proxy_docker_vhost_enabled{agent=~\"$docker_agent\",service=~\"$docker_service\"})", + "legendFormat": "value", + "range": true + } + ] + }, + { + "id": 62, + "type": "stat", + "title": "Docker backend pools ready", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 36 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": {} + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(rust_proxy_docker_backend_pool_ready{agent=~\"$docker_agent\",service=~\"$docker_service\"})", + "legendFormat": "value", + "range": true + } + ] + }, + { + "id": 63, + "type": "timeseries", + "title": "Docker request rate", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 41 + }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (agent,service) (rate(rust_proxy_docker_vhost_requests_total{agent=~\"$docker_agent\",service=~\"$docker_service\"}[$__rate_interval]))", + "legendFormat": "{{agent}} / {{service}}", + "range": true + } + ] + }, + { + "id": 64, + "type": "timeseries", + "title": "Docker errors and rejects", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 41 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (agent,service) (rate(rust_proxy_docker_vhost_errors_total{agent=~\"$docker_agent\",service=~\"$docker_service\"}[$__rate_interval]))", + "legendFormat": "{{agent}} / {{service}} errors", + "range": true + }, + { + "refId": "B", + "expr": "sum by (agent,service) (rate(rust_proxy_docker_vhost_rejected_total{agent=~\"$docker_agent\",service=~\"$docker_service\"}[$__rate_interval]))", + "legendFormat": "{{agent}} / {{service}} rejected", + "range": true + } + ] + }, + { + "id": 65, + "type": "timeseries", + "title": "Docker p95 exchange latency", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 41 + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "histogram_quantile(0.95, sum by (le,agent,service) (rate(rust_proxy_docker_vhost_exchange_duration_seconds_bucket{agent=~\"$docker_agent\",service=~\"$docker_service\"}[$__rate_interval])))", + "legendFormat": "{{agent}} / {{service}}", + "range": true + } + ] + }, + { + "id": 66, + "type": "timeseries", + "title": "Docker backend health", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 48 + }, + "fieldConfig": { + "defaults": { + "unit": "bool", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_docker_backend_up{agent=~\"$docker_agent\",service=~\"$docker_service\"}", + "legendFormat": "{{agent}} / {{service}} / {{backend}}", + "range": true + } + ] + }, + { + "id": 67, + "type": "timeseries", + "title": "Docker backend EWMA latency", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 48 + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_docker_backend_ewma_latency_seconds{agent=~\"$docker_agent\",service=~\"$docker_service\"}", + "legendFormat": "{{agent}} / {{service}} / {{backend}}", + "range": true + } + ] + }, + { + "id": 68, + "type": "timeseries", + "title": "Docker backend active exchanges", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 48 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_docker_backend_active_exchanges{agent=~\"$docker_agent\",service=~\"$docker_service\"}", + "legendFormat": "{{agent}} / {{service}} / {{backend}}", + "range": true + } + ] + }, + { + "id": 69, + "type": "timeseries", + "title": "Docker backend error rate", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 55 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (agent,service,backend) (rate(rust_proxy_docker_backend_errors_total{agent=~\"$docker_agent\",service=~\"$docker_service\"}[$__rate_interval]))", + "legendFormat": "{{agent}} / {{service}} / {{backend}}", + "range": true + } + ] + }, + { + "id": 70, + "type": "timeseries", + "title": "Docker backend traffic", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 55 + }, + "fieldConfig": { + "defaults": { + "unit": "Bps", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (agent,service,backend) (rate(rust_proxy_docker_backend_bytes_in_total{agent=~\"$docker_agent\",service=~\"$docker_service\"}[$__rate_interval]))", + "legendFormat": "{{agent}} / {{service}} / {{backend}} in", + "range": true + }, + { + "refId": "B", + "expr": "sum by (agent,service,backend) (rate(rust_proxy_docker_backend_bytes_out_total{agent=~\"$docker_agent\",service=~\"$docker_service\"}[$__rate_interval]))", + "legendFormat": "{{agent}} / {{service}} / {{backend}} out", + "range": true + } + ] + }, + { + "id": 71, + "type": "timeseries", + "title": "Docker backend probe age", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 55 + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "time() - rust_proxy_docker_backend_last_probe_timestamp_seconds{agent=~\"$docker_agent\",service=~\"$docker_service\"}", + "legendFormat": "{{agent}} / {{service}} / {{backend}}", + "range": true + } + ] + }, + { + "id": 16, + "type": "row", + "title": "Cache", + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 62 + }, + "panels": [] + }, + { + "id": 17, + "type": "stat", + "title": "Hot cache RAM", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 63 + }, + "fieldConfig": { + "defaults": { + "unit": "bytes", + "custom": {} + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto" + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_cache_resident_bytes", + "legendFormat": "hot", + "range": true + } + ] + }, + { + "id": 18, + "type": "stat", + "title": "Transient cache RAM", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 63 + }, + "fieldConfig": { + "defaults": { + "unit": "bytes", + "custom": {} + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto" + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_cache_transient_bytes", + "legendFormat": "transient", + "range": true + } + ] + }, + { + "id": 19, + "type": "stat", + "title": "Cold cache disk", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 63 + }, + "fieldConfig": { + "defaults": { + "unit": "bytes", + "custom": {} + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto" + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_cache_cold_bytes", + "legendFormat": "cold", + "range": true + } + ] + }, + { + "id": 20, + "type": "stat", + "title": "Hot objects", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 63 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": {} + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto" + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_cache_entries", + "legendFormat": "hot objects", + "range": true + } + ] + }, + { + "id": 21, + "type": "timeseries", + "title": "Cache decision rate", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 68 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rate(rust_proxy_cache_hits_total[$__rate_interval])", + "legendFormat": "HIT/s", + "range": true + }, + { + "refId": "B", + "expr": "rate(rust_proxy_cache_misses_total[$__rate_interval])", + "legendFormat": "MISS/s", + "range": true + }, + { + "refId": "C", + "expr": "rate(rust_proxy_cache_bypasses_total[$__rate_interval])", + "legendFormat": "BYPASS/s", + "range": true + }, + { + "refId": "D", + "expr": "rate(rust_proxy_cache_rejected_fills_total[$__rate_interval])", + "legendFormat": "rejected fills/s", + "range": true + } + ] + }, + { + "id": 22, + "type": "timeseries", + "title": "Transient cache breakdown", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 68 + }, + "fieldConfig": { + "defaults": { + "unit": "bytes", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_cache_fill_bytes_active", + "legendFormat": "fill", + "range": true + }, + { + "refId": "B", + "expr": "rust_proxy_cache_cold_queue_bytes", + "legendFormat": "cold queue", + "range": true + }, + { + "refId": "C", + "expr": "rust_proxy_cache_cold_write_bytes_active", + "legendFormat": "cold write", + "range": true + }, + { + "refId": "D", + "expr": "rust_proxy_cache_cold_read_bytes_active", + "legendFormat": "cold read", + "range": true + } + ] + }, + { + "id": 23, + "type": "timeseries", + "title": "Cold tier movement", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 75 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rate(rust_proxy_cache_cold_hits_total[$__rate_interval])", + "legendFormat": "cold hits/s", + "range": true + }, + { + "refId": "B", + "expr": "rate(rust_proxy_cache_cold_promotions_total[$__rate_interval])", + "legendFormat": "cold->hot/s", + "range": true + }, + { + "refId": "C", + "expr": "rate(rust_proxy_cache_hot_demotions_total[$__rate_interval])", + "legendFormat": "hot->cold/s", + "range": true + }, + { + "refId": "D", + "expr": "rate(rust_proxy_cache_cold_evictions_total[$__rate_interval])", + "legendFormat": "cold evictions/s", + "range": true + }, + { + "refId": "E", + "expr": "rate(rust_proxy_cache_cold_errors_total[$__rate_interval])", + "legendFormat": "cold errors/s", + "range": true + } + ] + }, + { + "id": 24, + "type": "timeseries", + "title": "Cache domain hit ratio", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 75 + }, + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_cache_domain_hit_ratio", + "legendFormat": "{{vhost}} / {{domain}}", + "range": true + } + ] + }, + { + "id": 25, + "type": "row", + "title": "Process and host diagnostics", + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 82 + }, + "panels": [] + }, + { + "id": 26, + "type": "timeseries", + "title": "Process memory breakdown", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 83 + }, + "fieldConfig": { + "defaults": { + "unit": "bytes", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_process_rss_bytes", + "legendFormat": "RSS total", + "range": true + }, + { + "refId": "B", + "expr": "rust_proxy_process_rss_anon_bytes", + "legendFormat": "RSS anon", + "range": true + }, + { + "refId": "C", + "expr": "rust_proxy_process_rss_file_bytes", + "legendFormat": "RSS file", + "range": true + }, + { + "refId": "D", + "expr": "rust_proxy_process_rss_shmem_bytes", + "legendFormat": "RSS shmem", + "range": true + }, + { + "refId": "E", + "expr": "rust_proxy_process_data_bytes", + "legendFormat": "VmData", + "range": true + }, + { + "refId": "F", + "expr": "rust_proxy_process_swap_bytes", + "legendFormat": "swap", + "range": true + } + ] + }, + { + "id": 27, + "type": "timeseries", + "title": "Cgroup memory", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 83 + }, + "fieldConfig": { + "defaults": { + "unit": "bytes", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_cgroup_memory_current_bytes", + "legendFormat": "current", + "range": true + }, + { + "refId": "B", + "expr": "rust_proxy_cgroup_memory_max_bytes", + "legendFormat": "max", + "range": true + } + ] + }, + { + "id": 28, + "type": "timeseries", + "title": "CPU", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 91 + }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_process_cpu_percent", + "legendFormat": "process", + "range": true + }, + { + "refId": "B", + "expr": "rust_proxy_system_cpu_percent", + "legendFormat": "system", + "range": true + } + ] + }, + { + "id": 29, + "type": "timeseries", + "title": "Load average", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 91 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_system_load1", + "legendFormat": "load1", + "range": true + }, + { + "refId": "B", + "expr": "rust_proxy_system_load5", + "legendFormat": "load5", + "range": true + }, + { + "refId": "C", + "expr": "rust_proxy_system_load15", + "legendFormat": "load15", + "range": true + } + ] + }, + { + "id": 30, + "type": "timeseries", + "title": "Process resources", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 91 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_process_open_fds", + "legendFormat": "open FDs", + "range": true + }, + { + "refId": "B", + "expr": "rust_proxy_process_threads", + "legendFormat": "threads", + "range": true + } + ] + }, + { + "id": 31, + "type": "timeseries", + "title": "Network throughput", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 98 + }, + "fieldConfig": { + "defaults": { + "unit": "Bps", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_system_network_rx_bytes_per_second", + "legendFormat": "RX", + "range": true + }, + { + "refId": "B", + "expr": "rust_proxy_system_network_tx_bytes_per_second", + "legendFormat": "TX", + "range": true + } + ] + }, + { + "id": 32, + "type": "timeseries", + "title": "Process disk I/O", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 98 + }, + "fieldConfig": { + "defaults": { + "unit": "Bps", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rate(rust_proxy_process_read_bytes[$__rate_interval])", + "legendFormat": "read", + "range": true + }, + { + "refId": "B", + "expr": "rate(rust_proxy_process_write_bytes[$__rate_interval])", + "legendFormat": "write", + "range": true + } + ] + }, + { + "id": 33, + "type": "row", + "title": "Protocols", + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 105 + }, + "panels": [] + }, + { + "id": 34, + "type": "timeseries", + "title": "HTTP protocol requests", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 106 + }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rate(rust_proxy_http1_requests_total[$__rate_interval])", + "legendFormat": "HTTP/1.1", + "range": true + }, + { + "refId": "B", + "expr": "rate(rust_proxy_http2_requests_total[$__rate_interval])", + "legendFormat": "HTTP/2", + "range": true + }, + { + "refId": "C", + "expr": "rate(rust_proxy_http3_requests_total[$__rate_interval])", + "legendFormat": "HTTP/3", + "range": true + } + ] + }, + { + "id": 35, + "type": "timeseries", + "title": "Connections", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 106 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_connections_active", + "legendFormat": "all active", + "range": true + }, + { + "refId": "B", + "expr": "rust_proxy_http3_active_connections", + "legendFormat": "H3 active", + "range": true + } + ] + }, + { + "id": 36, + "type": "timeseries", + "title": "TLS / H3 rejects", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 106 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rate(rust_proxy_tls_handshake_errors_total[$__rate_interval])", + "legendFormat": "TLS handshake errors/s", + "range": true + }, + { + "refId": "B", + "expr": "rate(rust_proxy_http3_rejected_connections_total[$__rate_interval])", + "legendFormat": "H3 rejects/s", + "range": true + } + ] + }, + { + "id": 37, + "type": "timeseries", + "title": "WebSocket tunnels", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 113 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_websocket_tunnels_active", + "legendFormat": "active", + "range": true + }, + { + "refId": "B", + "expr": "rate(rust_proxy_websocket_tunnels_total[$__rate_interval])", + "legendFormat": "opened/s", + "range": true + }, + { + "refId": "C", + "expr": "rate(rust_proxy_websocket_errors_total[$__rate_interval])", + "legendFormat": "errors/s", + "range": true + }, + { + "refId": "D", + "expr": "rate(rust_proxy_websocket_handshake_errors_total[$__rate_interval])", + "legendFormat": "handshake errors/s", + "range": true + } + ] + }, + { + "id": 38, + "type": "timeseries", + "title": "Compression", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 113 + }, + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rate(rust_proxy_compression_gzip_total[$__rate_interval])", + "legendFormat": "gzip/s", + "range": true + }, + { + "refId": "B", + "expr": "rate(rust_proxy_compression_br_total[$__rate_interval])", + "legendFormat": "br/s", + "range": true + }, + { + "refId": "C", + "expr": "rate(rust_proxy_compression_zstd_total[$__rate_interval])", + "legendFormat": "zstd/s", + "range": true + }, + { + "refId": "D", + "expr": "rate(rust_proxy_compression_deflate_total[$__rate_interval])", + "legendFormat": "deflate/s", + "range": true + } + ] + }, + { + "id": 39, + "type": "row", + "title": "Layer-4 streams and logging", + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 120 + }, + "panels": [] + }, + { + "id": 40, + "type": "timeseries", + "title": "Stream sessions", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 121 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_stream_active", + "legendFormat": "{{stream}} active", + "range": true + }, + { + "refId": "B", + "expr": "sum by (stream) (rate(rust_proxy_stream_errors_total[$__rate_interval]))", + "legendFormat": "{{stream}} errors/s", + "range": true + }, + { + "refId": "C", + "expr": "sum by (stream) (rate(rust_proxy_stream_rejected_total[$__rate_interval]))", + "legendFormat": "{{stream}} rejects/s", + "range": true + } + ] + }, + { + "id": 41, + "type": "timeseries", + "title": "Stream throughput", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 121 + }, + "fieldConfig": { + "defaults": { + "unit": "Bps", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (stream) (rate(rust_proxy_stream_bytes_in_total[$__rate_interval]))", + "legendFormat": "{{stream}} in", + "range": true + }, + { + "refId": "B", + "expr": "sum by (stream) (rate(rust_proxy_stream_bytes_out_total[$__rate_interval]))", + "legendFormat": "{{stream}} out", + "range": true + } + ] + }, + { + "id": 42, + "type": "timeseries", + "title": "Structured log sink", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 128 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_log_sink_queue_capacity", + "legendFormat": "queue capacity", + "range": true + }, + { + "refId": "B", + "expr": "rate(rust_proxy_log_sink_queue_dropped_total[$__rate_interval])", + "legendFormat": "dropped/s", + "range": true + }, + { + "refId": "C", + "expr": "rate(rust_proxy_log_sink_errors_total[$__rate_interval])", + "legendFormat": "errors/s", + "range": true + } + ] + }, + { + "id": 43, + "type": "timeseries", + "title": "Admin/control plane", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 128 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rate(rust_proxy_admin_requests_total[$__rate_interval])", + "legendFormat": "admin requests/s", + "range": true + }, + { + "refId": "B", + "expr": "rust_proxy_request_permits_available", + "legendFormat": "request permits", + "range": true + }, + { + "refId": "C", + "expr": "rust_proxy_restart_required", + "legendFormat": "restart required", + "range": true + } + ] + }, + { + "id": 44, + "type": "row", + "title": "Inventory", + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 135 + }, + "panels": [] + }, + { + "id": 45, + "type": "timeseries", + "title": "Configured objects", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 136 + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_vhosts_configured", + "legendFormat": "vhosts", + "range": true + }, + { + "refId": "B", + "expr": "rust_proxy_redirect_hosts_configured", + "legendFormat": "redirects", + "range": true + }, + { + "refId": "C", + "expr": "rust_proxy_not_found_hosts_configured", + "legendFormat": "404 hosts", + "range": true + }, + { + "refId": "D", + "expr": "rust_proxy_stream_hosts_configured", + "legendFormat": "streams", + "range": true + }, + { + "refId": "E", + "expr": "rust_proxy_certificates_configured", + "legendFormat": "certificates", + "range": true + }, + { + "refId": "F", + "expr": "rust_proxy_acl_profiles_configured", + "legendFormat": "ACL profiles", + "range": true + } + ] + }, + { + "id": 46, + "type": "timeseries", + "title": "Listener protocols", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 136 + }, + "fieldConfig": { + "defaults": { + "unit": "bool", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_listener_protocol_enabled", + "legendFormat": "{{address}} / {{protocol}}", + "range": true + } + ] + }, + { + "id": 47, + "type": "row", + "title": "Certificates and GeoIP", + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 143 + }, + "panels": [] + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "id": 48, + "type": "timeseries", + "title": "Certificate expiry remaining", + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 144 + }, + "description": "Seconds until the observed certificate notAfter timestamp.", + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_certificate_renewal_not_after_timestamp_seconds - time()", + "legendFormat": "{{certificate}}", + "range": true + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "drawStyle": "line", + "lineInterpolation": "linear", + "lineWidth": 1, + "fillOpacity": 10, + "showPoints": "never", + "spanNulls": false, + "axisPlacement": "auto" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "calcs": [] + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "id": 49, + "type": "timeseries", + "title": "Certificate renewal failures", + "gridPos": { + "h": 7, + "w": 6, + "x": 12, + "y": 144 + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_certificate_renewal_failures", + "legendFormat": "{{certificate}}", + "range": true + } + ] + }, + { + "id": 50, + "type": "stat", + "title": "GeoIP loaded", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 18, + "y": 144 + }, + "fieldConfig": { + "defaults": { + "unit": "bool", + "custom": {} + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto" + }, + "targets": [ + { + "refId": "A", + "expr": "rust_proxy_geoip_loaded", + "legendFormat": "loaded", + "range": true + } + ] + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 151 + }, + "id": 51, + "panels": [], + "title": "Deep backend and certificate diagnostics", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "Smoothed backend latency maintained by the pool.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 0, + "y": 152 + }, + "id": 52, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rust_proxy_backend_ewma_latency_seconds", + "legendFormat": "{{vhost}} / {{backend}}", + "range": true, + "refId": "A" + } + ], + "title": "Backend EWMA latency", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "Passive and consecutive health failure counters/gauges.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 8, + "y": 152 + }, + "id": 53, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rust_proxy_backend_passive_failures", + "legendFormat": "{{vhost}} / {{backend}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rust_proxy_backend_consecutive_failures", + "legendFormat": "consecutive {{vhost}} / {{backend}}", + "range": true, + "refId": "B" + } + ], + "title": "Backend passive failures", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "Seconds since the most recent recorded backend health probe.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 8, + "x": 16, + "y": 152 + }, + "id": 54, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "time() - rust_proxy_backend_last_probe_timestamp_seconds", + "legendFormat": "{{vhost}} / {{backend}}", + "range": true, + "refId": "A" + } + ], + "title": "Backend probe age", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 159 + }, + "id": 55, + "panels": [], + "title": "Renewal scheduler details", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "Time until next renewal check and time since last check.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 160 + }, + "id": 56, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rust_proxy_certificate_renewal_next_check_timestamp_seconds - time()", + "legendFormat": "next check {{certificate}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "time() - rust_proxy_certificate_renewal_last_checked_timestamp_seconds", + "legendFormat": "since check {{certificate}}", + "range": true, + "refId": "B" + } + ], + "title": "Certificate renewal schedule", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "description": "Latest bounded renewal status and configured auto-renew switch.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 160 + }, + "id": 57, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rust_proxy_certificate_renewal_status_info", + "legendFormat": "{{certificate}} / {{status}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "editorMode": "code", + "expr": "rust_proxy_certificate_auto_renew_enabled", + "legendFormat": "auto renew {{certificate}}", + "range": true, + "refId": "B" + } + ], + "title": "Certificate renewal status", + "type": "timeseries" + } + ], + "refresh": "15s", + "schemaVersion": 40, + "tags": [ + "rustproxy", + "reverse-proxy" + ], + "templating": { + "list": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "type": "datasource", + "query": "prometheus", + "current": {}, + "hide": 0, + "refresh": 1 + }, + { + "name": "docker_agent", + "label": "Docker agent", + "type": "query", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "query": { + "query": "label_values(rust_proxy_docker_agent_enabled, agent)", + "refId": "StandardVariableQuery" + }, + "definition": "label_values(rust_proxy_docker_agent_enabled, agent)", + "current": { + "selected": false, + "text": "All", + "value": "$__all" + }, + "hide": 0, + "includeAll": true, + "allValue": ".*", + "multi": true, + "options": [], + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1 + }, + { + "name": "docker_service", + "label": "Docker service", + "type": "query", + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "query": { + "query": "label_values(rust_proxy_docker_vhost_enabled{agent=~\"$docker_agent\"}, service)", + "refId": "StandardVariableQuery" + }, + "definition": "label_values(rust_proxy_docker_vhost_enabled{agent=~\"$docker_agent\"}, service)", + "current": { + "selected": false, + "text": "All", + "value": "$__all" + }, + "hide": 0, + "includeAll": true, + "allValue": ".*", + "multi": true, + "options": [], + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 1 + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "RustProxy 0.24.5 - Operations", + "uid": "rustproxy-0170", + "version": 2, + "weekStart": "" +} \ No newline at end of file diff --git a/install-debian13.sh b/install-debian13.sh new file mode 100755 index 0000000..2347a00 --- /dev/null +++ b/install-debian13.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Debian 13+ one-line bootstrap for first install or safe source update. +# Builds as an unprivileged user, then delegates host changes to install.sh/update.sh. +set -euo pipefail + +REPO_URL="${RUSTPROXY_REPO_URL:-https://git.linuxiarz.pl/gru/RustProxy.git}" +ADMIN_LISTEN="${RUSTPROXY_ADMIN_LISTEN:-0.0.0.0:9090}" +PREFIX="${RUSTPROXY_PREFIX:-/opt/rustproxy}" +MODE="${1:-auto}" + +case "$MODE" in + auto|install|update) ;; + --install) MODE=install ;; + --update) MODE=update ;; + -h|--help) + cat <<'USAGE' +Usage: install-debian13.sh [auto|install|update] + + auto Install when RustProxy is absent; update when /opt/rustproxy/install.json exists (default) + install Require a fresh installation + update Require an existing managed installation and perform a safe update + +Environment: + RUSTPROXY_REPO_URL Git repository URL + RUSTPROXY_ADMIN_LISTEN Admin bind used only on first install (default 0.0.0.0:9090) + RUSTPROXY_PREFIX Managed install prefix (default /opt/rustproxy) + RUSTPROXY_BUILD_JOBS Cargo jobs for low-memory builds (default 1) +USAGE + exit 0 + ;; + *) + echo "Unknown mode: $MODE (expected auto, install or update)." >&2 + exit 2 + ;; +esac + +if [[ ${EUID:-$(id -u)} -ne 0 ]]; then + echo "Run this bootstrap as root (for example: wget -qO- ... | bash)." >&2 + exit 1 +fi + +. /etc/os-release +if [[ ${ID:-} != debian ]] || [[ ${VERSION_ID%%.*} -lt 13 ]]; then + echo "This bootstrap supports Debian 13+. Detected: ${PRETTY_NAME:-unknown}." >&2 + exit 1 +fi + +install_record="$PREFIX/install.json" +if [[ "$MODE" == auto ]]; then + if [[ -f "$install_record" ]]; then + MODE=update + elif [[ -e "$PREFIX/current" || -e /etc/systemd/system/rustproxy.service ]]; then + echo "RustProxy artifacts exist but $install_record is missing; refusing automatic overwrite." >&2 + echo "Recover the managed install record or use the project migration/recovery procedure." >&2 + exit 1 + else + MODE=install + fi +fi + +if [[ "$MODE" == update && ! -f "$install_record" ]]; then + echo "Update requested, but managed installation record is missing: $install_record" >&2 + exit 1 +fi +if [[ "$MODE" == install && -e "$install_record" ]]; then + echo "Install requested, but RustProxy is already installed. Use update mode." >&2 + exit 1 +fi + +echo "RustProxy bootstrap mode: $MODE" + +export DEBIAN_FRONTEND=noninteractive +apt-get update +apt-get install -y \ + build-essential gcc git ca-certificates python3 pkg-config wget sudo \ + python3-certbot-dns-rfc2136 python3-certbot-dns-cloudflare + +created_builder=0 +if [[ -n ${SUDO_USER:-} && ${SUDO_USER} != root ]] && id "${SUDO_USER}" >/dev/null 2>&1; then + builder=${SUDO_USER} + builder_home=$(getent passwd "$builder" | cut -d: -f6) +else + builder=rustproxy-build + builder_home=/var/tmp/rustproxy-build + if ! id "$builder" >/dev/null 2>&1; then + useradd --create-home --home-dir "$builder_home" --shell /bin/bash "$builder" + created_builder=1 + fi +fi + +work=$(mktemp -d /var/tmp/rustproxy-source.XXXXXX) +chown "$builder":"$(id -gn "$builder")" "$work" +cleanup() { + rm -rf "$work" + if [[ $created_builder -eq 1 ]]; then + userdel -r "$builder" >/dev/null 2>&1 || true + fi +} +trap cleanup EXIT + +run_builder() { + runuser -u "$builder" -- env HOME="$builder_home" PATH="$builder_home/.cargo/bin:/usr/local/bin:/usr/bin:/bin" "$@" +} + +if ! run_builder bash -lc 'command -v cargo >/dev/null 2>&1'; then + run_builder bash -lc "wget -qO- --https-only --secure-protocol=TLSv1_2 https://sh.rustup.rs | sh -s -- -y --profile minimal" +fi + +run_builder git clone --depth 1 "$REPO_URL" "$work/RustProxy" + +# Keep source builds reliable on small Debian VPSes. The repository release +# profile may use LTO/codegen settings that can make rustc exceed RAM limits. +BUILD_JOBS="${RUSTPROXY_BUILD_JOBS:-1}" +if ! [[ "$BUILD_JOBS" =~ ^[1-9][0-9]*$ ]]; then + echo "RUSTPROXY_BUILD_JOBS must be a positive integer." >&2 + exit 1 +fi + +run_builder bash -lc "cd '$work/RustProxy' && CARGO_BUILD_JOBS='$BUILD_JOBS' CARGO_PROFILE_RELEASE_LTO=false CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8 cargo build --release --locked" + +cd "$work/RustProxy" +if [[ "$MODE" == install ]]; then + bash install.sh \ + --binary target/release/rust-proxy \ + --prefix "$PREFIX" \ + --admin "$ADMIN_LISTEN" \ + --allow-remote-admin \ + + printf '\nRustProxy installed. Admin panel: http://%s\n' "$ADMIN_LISTEN" + printf 'WARNING: remote administration uses plain HTTP. Restrict TCP/9090 with a firewall/private network.\n' +else + # update.sh reads the saved installation record and therefore preserves the + # existing data directory, HTTP/TLS/admin binds and service account. + bash update.sh \ + --binary target/release/rust-proxy \ + --prefix "$PREFIX" + printf '\nRustProxy updated successfully. Existing configuration and data were preserved.\n' +fi diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..3b2a8b5 --- /dev/null +++ b/install.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Install an already built binary; never download or compile code as root. +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +exec python3 "$ROOT/scripts/deploy.py" install "$@" diff --git a/langs/README.md b/langs/README.md new file mode 100644 index 0000000..4f56146 --- /dev/null +++ b/langs/README.md @@ -0,0 +1,40 @@ +# Language packs + +Add `de_DE.json` (or another safe unique filename) here and rebuild with Cargo. +`build.rs` discovers all `*.json` files; there is no frontend language allowlist. +The binary embeds the bundle at `/assets/langs.json`, which is accessible before +login. No runtime network dependency or external translation service is required. + +```json +{ + "meta": { + "locale": "de-DE", + "name": "German", + "native_name": "Deutsch", + "direction": "ltr" + }, + "translations": { + "overview": "Ubersicht", + "settings": "Einstellungen" + } +} +``` + +Use UTF-8 and a BCP-47 locale accepted by browser Intl. Filenames may use letters, +numbers, `_` and `-`. `en_US.json` is the required fallback. `pl_pl.json` preserves +the requested filename; its metadata locale is `pl-PL`. Missing keys fall back to +English; an explicitly empty string is retained. Unknown keys are permitted for +future extensions. `rtl` enables right-to-left layout. HTML is never evaluated: +translations and metadata labels are rendered using textContent. + +The saved selection is matched by ID or locale. Browser language preferences are +used when no saved language matches. Short legacy selections such as `pl` are +matched through metadata, not language-specific code. Adding or removing a file +triggers a Cargo rebuild. Duplicate locales, invalid metadata, non-string values +and oversized packs cause a build error. Limits: 128 packs, 256 KiB per pack, +4096 keys per pack, 8 MiB total bundle. + +Run `python3 scripts/langs_check.py` before a build. The checker also validates +that every static UI translation key has an English fallback. Extra JSON packs survive the managed updater when the new release does not contain +the same filename. Keep a separate backup too; shipped filenames are replaced and +the previous bytes are retained in the source backup. diff --git a/langs/en_US.json b/langs/en_US.json new file mode 100644 index 0000000..336a6de --- /dev/null +++ b/langs/en_US.json @@ -0,0 +1,1677 @@ +{ + "meta": { + "locale": "en-US", + "name": "English", + "native_name": "English", + "direction": "ltr" + }, + "translations": { + "overview": "Overview", + "routing": "Legacy routes", + "configuration": "Configuration", + "manualConfig": "Manual config", + "runtime": "RUNTIME", + "localOnly": "ADMINISTRATION", + "logout": "Sign out", + "firstSlice": "INDEPENDENT CORE / EXPLICIT ACCESS", + "loginTitle": "Administration", + "unlock": "Unlock administration", + "tokenHelp": "Run this command in a second terminal and paste the token below.", + "token": "Local access token", + "connect": "Connect to proxy", + "tokenNote": "Administration defaults to loopback HTTP. Enable Administration HTTPS before using a non-loopback bind.", + "requests": "Requests", + "activeExchanges": "Active exchanges", + "bodyLifetime": "Full body lifetime", + "bandwidth": "Payload transferred", + "bothDirections": "Both directions", + "errors": "Errors", + "errorDescription": "5xx / body / cancelled", + "liveTraffic": "LIVE TRAFFIC", + "liveTransformed": "Transformed", + "requestsPerSecond": "Requests per second", + "live": "LIVE", + "lastMinute": "Recent samples / resets on reload", + "coreState": "CORE STATE", + "configurationRevision": "Applied revision", + "backends": "Backends", + "routes": "Routes", + "connections": "Open connections", + "uptime": "Uptime", + "activity": "ACTIVITY", + "accessLog": "Access log", + "boundedLog": "Last 50 / retained ring 4096", + "time": "Time", + "request": "Request", + "status": "Status", + "duration": "Duration", + "outcome": "Outcome", + "noTraffic": "No traffic yet. Open the proxy to make your first request.", + "reload": "Reload configuration", + "addBackend": "Add / edit backend", + "backendUrl": "Backend origin (HTTP / HTTPS, IP / DNS)", + "allowPrivate": "Explicitly allow private / loopback IP", + "preserveHost": "Preserve original Host header", + "saveBackend": "Save backend", + "clear": "Clear", + "addRoute": "Add / edit route", + "hostnames": "Hostnames and aliases, comma-separated", + "pathPrefix": "Path prefix (not stripped)", + "backend": "Backend", + "enabled": "Enabled", + "saveRoute": "Save route", + "rawConfig": "Configuration document", + "export": "Export JSON", + "configHelp": "A valid save is applied atomically. Invalid or stale changes leave the running configuration untouched.", + "apply": "Validate, save and apply", + "noRestart": "Routing and response policies apply live; listener changes require restart.", + "roadmapHelp": "Release details and planned features are documented in ROADMAP.md.", + "overviewTitle": "Traffic, in focus.", + "overviewDescription": "Traffic, cache and operating-system measurements in one compact dashboard.", + "routingTitle": "Connect the right dots.", + "routingDescription": "Existing backend/route format remains supported. New deployments can use one vhost with its own frontend and pool.", + "configurationTitle": "One source of truth.", + "configurationDescription": "One canonical configuration: vhosts, listeners and shared policies.", + "connected": "CONNECTED TO LOCAL CORE", + "disconnected": "CORE NOT REACHABLE", + "edit": "Edit", + "remove": "Delete", + "privateAllowed": "PRIVATE IP APPROVED", + "configured": "CONFIGURED / NOT PROBED", + "disabled": "DISABLED", + "emptyBackends": "No backends configured. Add an explicit HTTP or HTTPS origin.", + "emptyRoutes": "No routes configured. Unknown hosts return 404.", + "saving": "Saving…", + "unsavedChanges": "Unsaved changes", + "saved": "Configuration applied.", + "confirmDelete": "Delete this item? The change takes effect immediately.", + "confirmTitle": "Confirm action", + "confirmationLabel": "CONFIRMATION", + "warningLabel": "WARNING", + "continueAction": "Continue", + "cancelAction": "Cancel", + "discardAction": "Discard changes", + "errorContext": "{area}: {message}", + "stale": "Configuration changed elsewhere. Reload before saving.", + "busy": "A save is already in progress.", + "loginError": "Sign-in failed.", + "fresh": "Configuration reloaded.", + "locked": "Signed out. The session was revoked.", + "backendUsed": "This backend is referenced by a route. Delete or change the route first.", + "renaming": "IDs cannot be renamed in this form. Use the JSON editor to update references atomically.", + "complete": "complete", + "cancelled": "cancelled", + "body_error": "body error", + "proxy_or_upstream_error": "proxy/upstream error", + "security": "Security", + "sessionNote": "Local HTTP administration. Passwords use Argon2id and session cookies are HttpOnly. Do not expose the panel publicly.", + "setupHelp": "Create the first administrator using a one-time capability from the local terminal.", + "setupToken": "One-time setup token", + "username": "Username", + "password": "Password", + "confirmPassword": "Confirm password", + "setupTitle": "Create your administrator", + "signIn": "Sign in", + "createAndSignIn": "Create account and sign in", + "securityTitle": "Access stays deliberate.", + "securityDescription": "One administrator. Revocable sessions. Explicit host and route access policies.", + "manualBackends": "MANUAL BACKENDS", + "routePolicies": "Access policy and custom headers", + "useRouteAcl": "Enable route ACL", + "aclDefault": "Default action", + "allow": "Allow", + "deny": "Deny", + "aclRules": "One rule per line: allow/deny followed by CIDR", + "aclHelp": "Use IPv4/IPv6 CIDRs, e.g. 192.168.1.0/24 or 10.0.0.5/32. Deny wins. An allowed IP must still authenticate; a location cannot bypass the host ACL.", + "routeHeaders": "Request / response headers (JSON)", + "headersHelp": "Operations: set, append, remove. Transport, body encoding and client-identity headers are protected.", + "currentSession": "CURRENT SESSION", + "recoveryHelp": "Lost access? Run rust-proxy admin reset-password locally. Existing sessions will be revoked.", + "revokeAll": "Sign out all sessions", + "changePassword": "Change password", + "currentPassword": "Current password", + "newPassword": "New password (12-128 UTF-8 bytes)", + "savePassword": "Change and sign out", + "hostPolicies": "Host access policies", + "editHostPolicy": "Add / replace host policy", + "savePolicy": "Apply host policy", + "auditRetention": "SQLITE / LAST 100 / RETAINED 10,000", + "audit": "Administration audit", + "reloadAudit": "Reload", + "actor": "Actor", + "action": "Action", + "detail": "Detail", + "noAudit": "No audit entries.", + "noPolicies": "No host ACLs. Route ACLs may still restrict access.", + "expires": "Absolute session expiry: ", + "passwordChanged": "Password changed. All sessions were revoked. Sign in again.", + "passwordMismatch": "The passwords do not match.", + "ruleSyntax": "Use one rule per line, for example: allow 127.0.0.1/32", + "confirmRevoke": "Sign out every administrator session, including this one?", + "streamLive": "SSE live", + "streamFallback": "Polling fallback", + "auth.required": "Your session expired or was revoked. Sign in again.", + "auth.invalid_credentials": "Incorrect username or password.", + "auth.password_policy": "The password must contain 12-128 UTF-8 bytes and no line breaks.", + "auth.invalid_username": "Use 3-64 letters, digits, dots, dashes or underscores.", + "auth.invalid_setup_token": "The setup token is invalid. Read it from the local terminal.", + "auth.already_initialized": "An administrator already exists. Sign in.", + "auth.rate_limited": "Too many attempts. Retry after 60 seconds or use local password recovery.", + "auth.busy": "Password workers are busy. Retry shortly.", + "auth.csrf": "Request protection failed. Reload the page and retry.", + "auth.invalid_host": "Use the configured administration address and allowed hostname.", + "auth.invalid_origin": "Cross-origin administration is blocked.", + "auth.credentials_changed": "Credentials changed concurrently. Sign in again.", + "auth.unavailable": "Administration storage is temporarily unavailable.", + "events.too_many_viewers": "Live viewer limit reached; polling is available.", + "protocol": "Upstream protocol", + "protocolHelp": "HTTP/2 and auto use the backend authority. Preserve Host is available with HTTP/1.1 only. RFC 8441 can use an explicit HTTP/2 upstream; other WebSocket transports bridge through HTTP/1.1.", + "addressAllowlist": "Optional address allowlist (CIDR, one per line)", + "caPem": "Additional trusted CA / certificate (PEM, optional)", + "backendTlsHelp": "TLS encryption remains available, but certificate/hostname verification is off unless enabled explicitly. Private addresses still need explicit approval, including DNS answers.", + "advancedTransport": "Transport and trust", + "tls": "Certificates", + "tlsTitle": "Encrypted, deliberately.", + "tlsDescription": "Exact SNI names. Private local certificate files. Apply bindings without restarting.", + "certificateBindings": "Certificate bindings", + "addCertificate": "Add certificate", + "certificateId": "Certificate ID", + "certificateFile": "Certificate filename", + "privateKeyFile": "Private key filename", + "defaultCertificate": "Use as the default certificate", + "saveCertificate": "Apply certificate", + "reloadCertificates": "Reload certificate files", + "certificateHelp": "Upload a PEM pair, reference files already in data/certs, or deploy an externally issued certificate with `./dev.sh tls deploy --id ID --cert FULLCHAIN --key PRIVKEY`. Reload re-reads configured files without changing the configuration revision.", + "certificateHint": "External Certbot hook: ./dev.sh tls deploy --id shared-cert --cert /etc/letsencrypt/live/example/fullchain.pem --key /etc/letsencrypt/live/example/privkey.pem", + "emptyCertificates": "No explicit SNI bindings. The generated default certificate is used while the TLS listener is enabled.", + "certificateSafety": "Certificates secure TLS connections, not routing. Assign a certificate to a vhost. Changes affect new handshakes.", + "wsActive": "WebSocket tunnels", + "h2Requests": "HTTP/2 requests", + "h2ActiveConnections": "Active H2 connections", + "tlsFailures": "TLS handshake errors", + "tlsListener": "TLS listener", + "notEnabled": "Disabled", + "websocket_closed": "WebSocket closed", + "defaultLabel": "DEFAULT", + "language": "Language", + "settings": "Settings", + "settingsTitle": "Configure your edge.", + "settingsDescription": "Switch between function tabs. Unsaved changes stay in their forms.", + "cacheHitRatio": "Cache hit ratio", + "processMemory": "Process memory", + "systemLoad": "SYSTEM LOAD", + "systemChart": "Recent system CPU usage", + "systemMemory": "System RAM", + "systemSwap": "System swap", + "systemUptime": "System uptime", + "loadAverage": "Load 1 / 5 / 15 min", + "cacheOverview": "RAM cache", + "cacheObjects": "Objects / memory", + "cacheEvictions": "Evicted / expired", + "processDiagnostics": "Process", + "platform": "Platform", + "processCpu": "CPU (100% = 1 core)", + "fileDescriptors": "File descriptors / limit", + "processThreads": "OS threads", + "processVirtualMemory": "Virtual memory / limit", + "processStartTime": "Process start", + "containerMemory": "cgroup RAM / limit", + "networkDiagnostics": "Network and responses", + "hostScope": "OS scope", + "networkRate": "RX / TX per second", + "meanLatency": "Mean exchange duration", + "filterLogs": "Filter host or path", + "filterResult": "Filter result", + "allResults": "All results", + "routeCache": "Route cache policy", + "inheritCache": "Inherit global policy", + "bypassCache": "Always bypass", + "listeners": "Listeners", + "httpConnections": "HTTP connections", + "httpConnectionsHelp": "Connection lifecycle and transport limits for HTTP/2 and HTTP/3.", + "http2Tab": "HTTP/2", + "http3Tab": "HTTP/3", + "http2Settings": "HTTP/2 connections", + "http2SettingsHelp": "HTTP/2 enablement is controlled per listener. Tune idle, maximum lifetime and graceful GOAWAY drain limits here.", + "http2IdleTimeout": "Idle timeout (seconds)", + "http2MaxLifetime": "Maximum connection lifetime (seconds, 0 = disabled)", + "http2DrainTimeout": "Drain timeout (seconds)", + "http2DrainHelp": "These limits apply to this listener. Idle timeout applies only to unused connections; maximum lifetime is absolute; drain bounds active streams after GOAWAY. Changes require restart.", + "http2Save": "Save HTTP/2 settings", + "http2NoEnabledListeners": "No listener has HTTP/2 enabled. Enable HTTP/2 or h2c in Settings > Listeners first.", + "http2ListenersDirty": "Save pending listener changes before editing HTTP/2 settings.", + "restartLabel": "RESTART", + "listenersHelp": "Named IP and port bindings. Changes require restart. The administration endpoint remains separate.", + "httpAddress": "HTTP bind address and port", + "httpsAddress": "HTTPS bind address and port", + "enableHttps": "Enable HTTPS listener", + "lowPortsHelp": "Privileged ports may need CAP_NET_BIND_SERVICE. Do not run the whole application as root.", + "saveListeners": "Save listeners", + "inheritListeners": "Use startup defaults", + "responseSettings": "Client response", + "hotApply": "LIVE APPLY", + "serverHeader": "Server header", + "serverPreserve": "Preserve origin value", + "serverHide": "Hide completely", + "serverCustom": "Replace / add custom value", + "serverValue": "Custom Server value", + "compression": "Streaming compression", + "compressionMin": "Minimum response size (bytes)", + "compressionHelp": "Negotiates Accept-Encoding. Skips private responses, cookies, ranges, SSE and no-transform; existing compressed bodies are passed through.", + "saveResponse": "Apply response settings", + "cacheSettings": "Cache", + "enableCache": "Enable cache", + "cacheSafety": "Complete public-safe GET responses are cached. Authorization, cookies and private/no-store responses still bypass. One byte range can be served from a complete cached object; multipart/unsupported ranges pass through.", + "cacheMemory": "HOT allocated budget (MiB)", + "cacheObject": "Maximum object (KiB)", + "cacheEntries": "Maximum entries", + "cacheMaxTtl": "Maximum TTL (seconds)", + "cacheDefaultTtl": "Fallback TTL for public responses (0 = off)", + "bypassQuery": "Bypass URLs with a query string", + "cachePrefixes": "Bypass path prefixes, one per line", + "cacheAgeHelp": "Age is always sent on cache hits. Counters remain available even when diagnostic headers are hidden. Only cache-affecting configuration changes invalidate incompatible objects; managed software updates hand hot objects to cold storage when the cold tier is enabled.", + "saveCache": "Apply cache policy", + "purgeCache": "Purge all cache", + "customHeaders": "Global custom headers", + "globalHeadersHelp": "Request rules go to the backend; response rules go to the client. Route rules run after global rules. Server policy takes final precedence.", + "responseHeaders": "Response to client", + "requestHeaders": "Request to backend", + "addHeader": "Add header rule", + "saveHeaders": "Apply header rules", + "unavailable": "N/A", + "threads": "threads", + "realSample": "Measured", + "noKnownLimit": "unknown / unlimited", + "unlimited": "unlimited", + "restartPending": "Saved listener changes are pending. Restart RustProxy to apply them.", + "activeListeners": "Currently active", + "headerLimit": "At most 32 rules per direction.", + "headerOperation": "Operation", + "header_set": "Set", + "header_append": "Append", + "header_remove": "Remove", + "headerName": "Header name", + "headerValue": "Header value", + "confirmInherit": "Restore listener defaults from startup flags and environment on the next restart?", + "confirmPurge": "Purge every cached object and cancel all pending cache fills?", + "cachePurged": "Cache purged; removed entries", + "logsDropped": "dropped", + "updateAvailable": "New version", + "vhosts": "Vhosts", + "vhostsTitle": "Virtual hosts & backends", + "vhostsDescription": "Manage hostnames, frontend bindings, backends and per-host policies.", + "vhHealthTitle": "Backends", + "vhManage": "Manage vhosts", + "vhHealthHint": "Live state: leases cover the entire exchange; traffic is an EWMA of payload bytes.", + "vhHealthEmpty": "No backend diagnostics yet.", + "vhVhost": "Vhost", + "vhActive": "Active", + "vhRequests": "Requests", + "vhErrors": "Errors", + "vhLatency": "Latency (EWMA)", + "vhTraffic": "Traffic/s", + "vhIntro": "Each vhost owns its bound IPs, paths and backends. Unlisted addresses cannot reach it. Listener changes require restart.", + "vhList": "Virtual hosts", + "vhSearch": "Search vhosts", + "vhSearchPlaceholder": "Search hostname, vhost or backend", + "vhSearchEmpty": "No vhosts match this search.", + "vhNew": "New vhost", + "vhMigrate": "Migrate a legacy route group", + "vhImport": "Load into editor", + "vhMigrationHint": "Migration is saved atomically. Routes sharing one host and one backend are grouped; ambiguous host ownership is rejected.", + "vhEditor": "Vhost configuration", + "vhReset": "Discard changes", + "vhSafetyHint": "Private IP binding is not authentication. Keep firewall/ACL rules. HSTS preload is opt-in; affinity cookies are not login sessions.", + "vhId": "Vhost ID", + "vhHosts": "Hostnames", + "vhEnabled": "Enabled", + "vhFrontends": "Frontends / bound addresses", + "vhAddFrontend": "Add frontend", + "vhAddress": "IP:port (IPv6: [address]:port)", + "vhTls": "TLS enabled", + "vhHttp2": "Prefer HTTP/2 over TLS; retain HTTP/1.1", + "vhBackendPool": "Backends", + "vhAddBackend": "Add backend", + "vhMemberId": "Backend ID", + "vhUrl": "Origin URL", + "vhWeight": "Weight", + "vhBackup": "Backup only", + "vhDrain": "Drain new sessions", + "vhPrivate": "Allow private origin IP", + "vhPreserveHost": "Preserve client Host (HTTP/1.1 only)", + "vhProtocol": "Origin protocol", + "vhProtocolHttp1": "HTTP/1.1", + "vhProtocolAuto": "TLS ALPN: HTTP/2 + HTTP/1.1", + "vhProtocolHttp2": "HTTP/2 (TLS: with HTTP/1.1 fallback; plaintext: h2c)", + "vhAddressAllowlist": "Origin IP/CIDR allowlist (optional)", + "vhTlsServerName": "TLS server name / SNI (optional)", + "vhTlsVerify": "Verify backend certificate and hostname", + "vhTlsVerifyHelp": "Disabled when omitted. Enable it when RustProxy must validate the backend certificate chain and identity. If verification fails, set TLS server name to a DNS name present in the certificate and/or add the private CA PEM. Do not use an IP address as SNI.", + "vhCa": "Additional trusted CA (PEM, optional)", + "vhBalancing": "Balancing & connection reuse", + "vhAlgorithm": "Algorithm", + "vhRoundRobin": "Smooth weighted round-robin", + "vhLeastConnections": "Weighted least active exchanges", + "vhLeastTraffic": "Weighted least traffic (EWMA)", + "vhAdaptive": "Adaptive: activity + latency + traffic", + "vhIpHash": "Client IP hash", + "vhSlowStart": "Recovery slow-start (seconds)", + "vhRetryMax": "Failover retries", + "vhRetryConnect": "Retry connection failures", + "vhRetryTimeouts": "Retry response-header timeouts", + "vhRetryStatuses": "Retry HTTP statuses", + "vhRetryHelp": "Retries are limited to body-free GET, HEAD and OPTIONS requests, use a different healthy backend and never replay WebSocket or write requests.", + "vhKeepalive": "Reuse upstream connections (keepalive)", + "vhIdleTimeout": "Idle pool timeout (seconds)", + "vhMaxIdle": "Max idle connections / origin", + "vhTcpKeepalive": "TCP / HTTP/2 keepalive interval (seconds)", + "vhHealth": "Backend health", + "vhHealthMode": "Check type", + "vhOff": "Off", + "vhTcp": "TCP connect", + "vhHttp": "HTTP status + response time", + "vhHealthPath": "HTTP check path", + "vhHealthHost": "HTTP Host override (optional)", + "vhInterval": "Interval (ms)", + "vhTimeout": "Timeout (ms)", + "vhSlow": "Slow threshold (ms; 0 = off)", + "vhFall": "Failures before down", + "vhRise": "Successes before recovery", + "vhStatusMin": "Minimum HTTP status", + "vhStatusMax": "Maximum HTTP status", + "vhHealthNote": "HTTP probes use HTTP/1.1 and measure time to headers, not body duration. Use TCP for h2c-only services. New backends wait for successful probes. Active health is independent from passive health.", + "vhPassiveHealth": "Passive Backend Health", + "vhPassiveEnabled": "Allow normal request failures to mark a backend unhealthy", + "vhPassiveHelp": "Off by default. Errors are still logged and counted, but normal user traffic cannot change backend health until this is explicitly enabled.", + "vhPassiveConnect": "Count connection errors", + "vhPassiveTimeouts": "Count timeouts", + "vhPassiveIo": "Count body/read/write I/O errors", + "vhPassive5xx": "Count HTTP 5xx responses", + "vhPassiveSlow": "Count slow responses", + "vhPassiveFall": "Passive failures before down (empty = active fall)", + "vhPassiveRise": "Passive successes before recovery (empty = active rise)", + "vhPassiveWarning": "Enabling HTTP 5xx or slow-response failures can quarantine the whole backend after repeated errors from a single endpoint.", + "vhSticky": "Sticky sessions", + "vhStickyMode": "Affinity mode", + "vhCookie": "Opaque proxy cookie", + "vhCookieName": "Cookie name (empty = per-vhost default)", + "vhCookieTtl": "Cookie lifetime (seconds)", + "vhStickyNote": "Cookie affinity bypasses shared cache. Drain preserves existing cookie sessions. IP affinity uses the verified client IP under the configured trusted-proxy policy.", + "vhSecurity": "Security", + "vhHsts": "Enable HSTS (TLS responses only)", + "vhHstsAge": "HSTS max-age (seconds)", + "vhHstsSub": "HSTS includeSubDomains", + "vhHstsPreload": "HSTS preload (explicit opt-in)", + "vhNosniff": "X-Content-Type-Options: nosniff", + "vhFrame": "X-Frame-Options", + "vhPreserve": "Preserve origin value", + "vhDeny": "DENY", + "vhSameorigin": "SAMEORIGIN", + "vhReferrer": "Referrer-Policy (optional)", + "vhCsp": "Content-Security-Policy (optional)", + "vhPermissions": "Permissions-Policy (optional)", + "vhAdvanced": "Advanced: paths, ACL and custom headers", + "vhPaths": "Paths (JSON; optional backend_id pins a path to a single pool member)", + "vhAcl": "Vhost ACL (JSON or null)", + "vhHeaders": "Request / response header rules (JSON)", + "vhEmpty": "No vhosts yet. Existing routes continue to work.", + "vhUnsaved": "Discard unsaved vhost changes?", + "vhDelete": "Remove this vhost? Bound sockets are closed after restart.", + "vhConflict": "That vhost ID already exists.", + "vhRowLimit": "The maximum number of rows has been reached.", + "vhMigrationAmbiguous": "Hostnames overlap other legacy routes. Resolve ownership in JSON before migration.", + "vhMigrationPending": "Legacy migration will be committed with this save.", + "vhMigrationNone": "Select a legacy route", + "vhJsonInvalid": "Invalid JSON or data type in paths, ACL or headers.", + "vhUp": "Up", + "vhDown": "Down", + "vhChecking": "Checking", + "vhDraining": "Draining", + "vhDisabled": "Disabled", + "vhHealthDisabled": "Checks off", + "vhHealthUnknown": "Unknown", + "vhNewLabel": "NEW", + "vhEditLabel": "EDIT", + "vhFrontendNote": "Use an IP assigned to this machine. A wildcard accepts every IP in its address family; host routing still checks the local destination. TLS certificates are configured in the TLS tab.", + "close": "Close", + "inherit": "Inherit", + "liveLogs": "Live logs", + "logVhost": "Filter by vhost", + "logAllVhosts": "All vhosts", + "logAllMethods": "All methods", + "logPause": "Pause", + "logPaused": "PAUSED", + "logResume": "Resume", + "logPin": "Dock", + "logUndock": "Open as modal", + "logColumns": "Columns", + "logColumnsRequired": "At least one Live Logs column must remain visible.", + "logLoadHistory": "Increase buffer", + "logHistoryLoaded": "Live buffer increased to {count} retained entries.", + "logHost": "Host", + "logBufferHint": "Live view starts with the latest 256 completed exchanges. Increase buffer to seed it with retained history and keep accumulating live entries up to 4096 until the log window is closed. Export includes the currently filtered rows.", + "settingsSecurityHint": "Accounts and ACLs are global. SSL, HSTS, headers and cache are also configured inside each vhost.", + "theme": "Theme", + "themeDark": "Dark", + "themeLight": "Light", + "density": "Interface density", + "densityCompact": "Compact", + "densityDense": "Dense", + "densityComfortable": "Comfortable", + "globalPages": "Default responses", + "pagesHelp": "Pages for direct IP, unknown hosts and errors. An empty status selector preserves the original status. Static pages do not contact the upstream.", + "pageInherit": "Default / inherited", + "pageWelcome": "Congratulations page", + "pageEmpty": "Empty response", + "pageHtml": "Custom HTML", + "pageStatus": "Response status", + "pageOriginalStatus": "Original status", + "pageHtmlSource": "HTML source", + "pageUploadHtml": "Optional HTML file (32 KiB maximum)", + "pageTooLarge": "HTML must not exceed 32 KiB in UTF-8.", + "customErrorPages": "Custom error pages", + "pageStaticHost": "Static / maintenance host", + "pageMissingPath": "Unmatched path", + "pageDirectIp": "Direct IP access", + "pageUnknownHost": "Unknown hostname", + "pagesSafetyHint": "HTML is public content, not an admin template. ACLs still run first. Explicit HTTP 200 for an error masks its status to clients and monitoring. Total configuration size is limited.", + "vhDetailsTab": "Details", + "vhSsl": "SSL certificates", + "vhCertificate": "Named certificate", + "vhCertificateDefault": "Global SNI / default certificate", + "vhCertificateFile": "Certificate / full chain (PEM)", + "vhPrivateKeyFile": "Private key (PEM)", + "vhUploadCertificate": "Upload and validate pair", + "vhSelectCertificateFiles": "Choose both a certificate and private key file.", + "vhCertificateTooLarge": "Each PEM file is limited to 128 KiB.", + "vhCertificateNeedsHosts": "Enter vhost domain names in Details first.", + "vhCertificateStaged": "Pair verified and staged. Save this vhost to activate its certificate.", + "vhCertificateUploadHint": "Optional upload verifies key match and domain coverage. Private keys are stored in private files, never returned by the API. Save the vhost to activate the selection. Uploads do not save other changes. Listener changes require restart.", + "vhCache": "Cache", + "vhCacheTtl": "Maximum TTL (seconds, blank inherits)", + "vhCacheDefault": "Public default TTL (blank inherits)", + "vhCacheObject": "Object limit (KiB, blank inherits)", + "cacheEnabled": "Enable cache", + "cacheBypassQuery": "Bypass query strings", + "cacheStatusHeader": "Expose X-Cache", + "cacheHitsHeader": "Expose X-Cache-Hits", + "cacheRfcHeader": "Expose Cache-Status", + "cacheBypassPrefixes": "Bypass path prefixes", + "vhCacheCustomPrefixes": "Override inherited bypass paths", + "vhCacheHint": "Per-host policy can enable caching independently of the global default. All vhosts share global RAM and entry ceilings. Cookie affinity, private responses and authorization still bypass cache.", + "vhPurgeCache": "Purge host cache", + "vhPurgeConfirm": "Purge only this vhost cache? In-progress fills for this host will also be invalidated.", + "vhPages": "Error pages", + "vhHeadersOrder": "Order: global rules, vhost rules, path rules, then security/Server policy. Advanced JSON remains available.", + "vhNewHealthHint": "Save the vhost to start observing its backend health.", + "listenerAdd": "Add listener", + "listenerMigrate": "Convert defaults to named listeners", + "listenerScopeHint": "Internal / public is a label, not a firewall. New listeners do not expose legacy routes unless explicitly selected.", + "listenerLimit": "Maximum 64 named listeners.", + "listenerEndpoint": "Listener", + "listenerName": "Display name", + "listenerScope": "Label", + "listenerInternal": "Internal", + "listenerPublic": "Public", + "listenerLegacy": "Allow legacy routes", + "listenerInUse": "Listener is assigned to vhosts", + "listenerDuplicate": "Listener IDs must be unique.", + "listenerChooseHint": "Select named listeners configured in Settings > Listeners. A disabled listener grants no access.", + "listenerMissing": "Missing listener", + "http3Settings": "HTTP/3 / QUIC", + "http3SettingsHelp": "HTTP/3 enablement is controlled in Settings > Listeners. Tune advertisement, connection and stream limits here.", + "http3NoTlsListeners": "No TLS listeners are configured. Add and save a TLS listener first.", + "http3NoEnabledListeners": "No TLS listener has HTTP/3 enabled. Enable HTTP/3 in Settings > Listeners first.", + "http3ListenersDirty": "Save pending listener changes before editing HTTP/3 settings.", + "http3Save": "Save HTTP/3 settings", + "h2Configure": "Configure HTTP/2 limits", + "h3Enable": "Enable HTTP/3 over QUIC (UDP)", + "h3Configure": "Configure HTTP/3 limits", + "h3Limits": "HTTP/3 limits and advertisement", + "h3_alt_svc_max_age": "Alt-Svc lifetime (s)", + "h3_max_concurrent_connections": "Connections per listener", + "h3_max_concurrent_bidistream": "Bidirectional streams per connection", + "h3_max_concurrent_unistream": "Unidirectional streams per connection", + "h3_max_idle_timeout": "QUIC idle timeout (s; 0 = unlimited)", + "h3Help": "HTTP/3 requires TLS. Keep TCP plus the same UDP port available; HTTP/1.1 and HTTP/2 remain. Zero disables the local QUIC idle timeout. Changes require restart.", + "h3TuningHelp": "These limits apply only when HTTP/3 is enabled for this TLS listener. Changes require restart.", + "h3Requests": "HTTP/3 requests", + "certImport": "Import certificate", + "certImportSave": "Import and save in library", + "certLibraryHint": "Upload fullchain/certificate and private key in PEM. Save once, then assign the certificate to one or more vhosts.", + "certCheckDomains": "Domains to verify (comma-separated)", + "certAcmeLater": "Issued certificates are imported into the library and assigned per vhost.", + "certAdvanced": "Existing files / advanced", + "certLegacyNames": "Legacy SNI names (optional; prefer per-vhost assignment)", + "certAssigned": "Assigned vhosts", + "certUnassigned": "Not assigned", + "certInUse": "Certificate is in use. Reassign vhosts and change the default before removal.", + "certDuplicate": "This certificate ID exists. Use a new ID for safe rotation.", + "certManage": "Manage certificate library", + "certSelectHint": "Select an imported certificate. Uploads and rotation are in the Certificates tab. Assignments are validated against vhost names.", + "vhTinyauth": "Authentication and IP access", + "vhTabAuth": "Authentication", + "taEnable": "Enable Tinyauth protection", + "taUrl": "Tinyauth URL / verification endpoint", + "taLogin": "Public login URL", + "taRedirectForbidden": "Accept same-origin provider redirects for 403", + "taConnect": "Connect timeout (s)", + "taTimeout": "Total verification timeout (s)", + "taRequestHeaders": "Client headers sent to Tinyauth", + "taResponseHeaders": "Verified identity headers sent to application", + "taHelp": "An origin URL adds /api/auth/nginx automatically. Verification is body-free and runs before cache and WebSocket upgrades. Protected responses use private, no-store. Configure Tinyauth to trust the actual RustProxy IP.", + "vhResponsePolicy": "Response identity and diagnostics", + "vhInherit": "Inherit global settings", + "diagnosticHeaders": "Diagnostic response headers", + "diagInherit": "Inherit diagnostic headers", + "diagRequestId": "Expose X-Request-ID", + "hideMockOrigin": "Hide X-Mock-Origin", + "diagHelp": "X-Request-ID is opt-in. Other upstream response headers are controlled with ordinary response-header rules.", + "forwardingSettings": "Client identity / forwarding", + "forwardingHelp": "By default, client-supplied forwarding headers are discarded. Add only immediate proxies you control. Trusted chains are evaluated right-to-left, stopping at the first untrusted address.", + "forwardingCanonical": "RustProxy generates Forwarded, X-Forwarded-For, X-Real-IP, X-Forwarded-Host, X-Forwarded-Proto, X-Forwarded-Port and X-Forwarded-URI. Socket destination never comes from a header.", + "trustedProxies": "Trusted proxy CIDRs (one per line; /32 for one IPv4, /128 for one IPv6)", + "trustForwardedProto": "Trust X-Forwarded-Proto only from trusted proxies", + "listenerId": "Listener ID", + "cache": "Cache", + "cacheTitle": "Cache, explained.", + "cacheDescription": "Objects, domain effectiveness and precise decisions.", + "cacheRefresh": "Refresh", + "cacheLive": "Live (3 s)", + "cachePurgeScope": "Purge selected scope", + "cacheObjectCount": "Resident objects", + "cacheSaved": "Body bytes served from RAM", + "cacheCounterScope": "Domain counters since configuration revision", + "cacheDomains": "Domains and effectiveness", + "cacheScopeHint": "Memory limits, fills and evictions are global. Domain counters reset on configuration publication; purging objects does not reset counters.", + "cacheDomain": "Domain", + "cacheReasons": "Decision counters", + "cacheNoDomains": "No cache decisions yet. Send traffic to a configured host.", + "cacheObjectList": "Stored objects", + "cachePrevious": "Previous", + "cacheNext": "Next", + "cacheObjectHint": "Most recently used first. Click Target or Cache key / tags to copy the full value. Bodies and request headers are not exposed.", + "cachePath": "Path", + "cacheBodySize": "Body", + "cacheAge": "Age", + "cacheTtlRemaining": "Remaining TTL", + "cacheNoObjects": "No fresh objects in this scope. Inspect the decision counters above.", + "cacheLegacy": "Unassigned", + "cacheGlobalLimit": "Global limit", + "cacheFills": "Active fills (global)", + "cacheRejected": "Rejected fills (global)", + "cacheUpdated": "Updated", + "acmeTitle": "Let's Encrypt / ACME", + "acmeHelp": "Issue an ACME certificate, add it to the library, then assign it to a vhost. Automatic renewal starts after import.", + "acmeEmail": "ACME contact email", + "acmeDomains": "DNS names, comma-separated (wildcards only with DNS-01)", + "acmeEnvironment": "CA environment", + "acmeStaging": "Staging - not browser-trusted", + "acmeProduction": "Production", + "acmeDnsServer": "DNS update server IP", + "acmeDnsPort": "DNS update port", + "acmeKeyName": "TSIG key name", + "acmeSecret": "TSIG secret (base64)", + "acmeAlgorithm": "TSIG algorithm", + "acmePropagation": "DNS propagation wait (seconds)", + "acmeSignQuery": "Sign SOA discovery query", + "acmeTos": "I accept the Let's Encrypt subscriber agreement and authorize domain validation.", + "acmeConfirmProduction": "I confirm production issuance (CA rate limits apply).", + "acmeIssue": "Request certificate", + "acmeIdle": "No issuance job in this process.", + "acmeImport": "Add issued certificate to library", + "acmeRenewalHint": "Initial issuance/import remains explicit. Managed certificate profiles are then checked automatically while RustProxy is running; saved renewal status is restored from SQLite after restart.", + "acmeReplace": "Replace the existing library certificate? Current vhost assignments will be retained and revalidated.", + "acmeImported": "Certificate saved. Assign it in the vhost SSL tab.", + "certFuture": "Managed automatic renewal is available for supported ACME providers; saved renewal observations are persisted in SQLite.", + "cacheReason_cache_hit": "Fresh object reused", + "cacheReason_cache_miss": "No matching fresh object", + "cacheReason_cache_disabled": "Cache disabled", + "cacheReason_route_disabled": "Route bypass", + "cacheReason_request_cookie": "Application cookie", + "cacheReason_request_authorization": "Authorization header", + "cacheReason_request_cache_control": "Client cache directive / hard refresh", + "cacheReason_request_pragma": "Client Pragma", + "cacheReason_request_method": "Method other than GET", + "cacheReason_request_body": "Nonempty request body", + "cacheReason_request_range": "Range request", + "cacheReason_request_conditional": "Conditional request", + "cacheReason_request_upgrade": "Protocol upgrade", + "cacheReason_query_excluded": "Query excluded", + "cacheReason_path_excluded": "Path excluded", + "cacheReason_request_cache_control_invalid": "Invalid request cache directives", + "cacheReason_response_status": "Non-200 response", + "cacheReason_response_set_cookie": "Origin Set-Cookie", + "cacheReason_response_cache_control": "Origin privacy/cache directive", + "cacheReason_response_cache_control_invalid": "Invalid origin cache directives", + "cacheReason_response_vary": "Uncacheable Vary", + "cacheReason_response_range": "Partial response", + "cacheReason_response_trailers": "Response trailers", + "cacheReason_response_ttl_size_or_metadata": "TTL, size or metadata rejected", + "cacheReason_response_headers_policy": "Response rule prevents storage", + "cacheReason_configuration_changed": "Configuration changed", + "cacheReason_not_proxy_response": "Local page / rejected request", + "cacheReason_tinyauth": "Tinyauth protected content", + "cacheReason_sticky_cookie": "Sticky-session cookie mode", + "acme.working": "Certificate request is running.", + "acme.ready_to_import": "Certificate validated; ready to add to the library.", + "acme.certbot_not_installed": "Certbot is not installed or is not in the service PATH. Install Certbot and certbot-dns-rfc2136.", + "acme.certbot_failed_check_private_log": "Certbot failed. Check the private ACME logs; the exact path is shown with the failed operation.", + "acme.production_confirmation": "Explicit production confirmation is required.", + "acme.consent_required": "Accept the subscriber agreement and confirm the selected CA environment.", + "acme.provider_timeout": "Certificate provider deadline exceeded. Check the Certbot log path shown with the failed operation before retrying.", + "acme.invalid_key_secret": "TSIG secret must be valid base64 (16-512 decoded bytes).", + "acme.job_busy": "Another certificate request is already running.", + "acme.cancelled": "Certificate job cancelled on shutdown.", + "acme.certificate_validation_failed": "Issued certificate/key or domain coverage validation failed. Nothing was activated.", + "acme.invalid_request": "ACME: invalid request.", + "acme.unknown_field": "ACME: unknown field.", + "acme.invalid_id": "ACME: invalid id.", + "acme.invalid_email": "ACME: invalid email.", + "acme.invalid_domains": "ACME: invalid domains.", + "acme.invalid_domain": "ACME: invalid domain.", + "acme.dns_names_only": "ACME: dns names only.", + "acme.invalid_environment": "ACME: invalid environment.", + "acme.tos_required": "ACME: tos required.", + "acme.invalid_dns_server": "ACME: invalid dns server.", + "acme.invalid_limits": "ACME: invalid limits.", + "acme.invalid_key_algorithm": "ACME: invalid key algorithm.", + "acme.invalid_sign_query": "ACME: invalid sign query.", + "acme.unsafe_directory": "ACME: unsafe directory.", + "acme.unsafe_output_path": "ACME: unsafe output path.", + "acme.invalid_pem_size": "ACME: invalid pem size.", + "acme.invalid_pem": "ACME: invalid pem.", + "acme.profile_limit": "ACME: profile limit.", + "acme.profile_busy": "ACME: profile busy.", + "acme.staging_limit": "ACME: staging limit.", + "acme.request_too_large": "ACME: request too large.", + "acme.local_io_or_json_error": "ACME: local io or json error.", + "acme.no_certificate_directory": "ACME: no certificate directory.", + "acme.python_not_available": "ACME: python not available.", + "acme.worker_io": "ACME: worker io.", + "acme.invalid_worker_response": "ACME: invalid worker response.", + "acme.worker_failed": "ACME: worker failed.", + "acme.validation_worker_failed": "ACME: validation worker failed.", + "cacheResidentLabel": "Cache memory", + "certificateFuture": "ACME providers: HTTP-01, Cloudflare, OVH and RFC2136. Renewal is manual.", + "prometheusTitle": "Prometheus", + "prometheusHelp": "A dedicated read-only HTTP listener, separate from admin sessions. Binding, enabling or changing the token variable requires a restart; CIDRs, path and collectors apply live.", + "prometheusEnable": "Enable the scrape endpoint", + "prometheusListen": "Exporter bind IP and port", + "prometheusPath": "Metrics path", + "prometheusCidrs": "Allowed scraper IPs (CIDR, one per line)", + "prometheusTokenEnv": "Environment variable holding the bearer token (optional on loopback)", + "prometheusTokenHelp": "Enter a variable NAME, not the secret. Set its value to a random 32+ character token in the service environment. Non-loopback binding requires it. This endpoint is HTTP: use a private network or a TLS tunnel. The Prometheus credential file must contain the same token.", + "prometheusCollectors": "Metric groups", + "prometheusPreview": "Preview current metrics", + "prometheusPreviewFailed": "Could not fetch metrics", + "prometheusActive": "Active endpoint", + "prometheusDisabled": "Exporter disabled. Authenticated preview remains available.", + "certificateProvider": "Certificate source / validation method", + "providerHttp01": "Let's Encrypt - domain validation (HTTP-01)", + "providerCloudflare": "Let's Encrypt - Cloudflare API (DNS-01)", + "providerOvh": "Let's Encrypt - OVH API (DNS-01)", + "providerRfc2136": "Let's Encrypt - RFC2136 / TSIG (DNS-01)", + "providerImport": "Import certificate and private key (PEM)", + "providerExisting": "Existing server files (advanced)", + "providerHint_http01": "Requires Certbot. Every domain must resolve to this proxy and be reachable on public TCP port 80. RustProxy serves the challenge without stopping HTTP traffic. Wildcards need DNS-01. Staging certificates are not browser-trusted; automatic renewal is not installed.", + "providerHint_cloudflare": "Requires Certbot + certbot-dns-cloudflare. Use a restricted API token with Zone:DNS:Edit for the necessary zones only. Supports wildcard certificates. Secrets stay in private server files.", + "providerHint_ovh": "Requires Certbot + certbot-dns-ovh. Supply application key, application secret and consumer key authorized for the required DNS zones. Supports wildcard certificates.", + "providerHint_rfc2136": "Requires Certbot + certbot-dns-rfc2136 and a DNS server accepting signed RFC2136 updates. Limit the TSIG key to the required challenge records.", + "providerHint_import": "Upload a matching PEM/fullchain and private key. Save in the library, then assign the certificate in a vhost SSL tab.", + "providerHint_existing": "Advanced: only existing private files inside the configured certificate directory. Never put private key contents in JSON.", + "acmeCfToken": "Cloudflare API token", + "acmeOvhEndpoint": "OVH API region", + "acmeOvhApplicationKey": "OVH application key", + "acmeOvhApplicationSecret": "OVH application secret", + "acmeOvhConsumerKey": "OVH consumer key", + "acme.invalid_provider": "Select a supported certificate provider.", + "acme.invalid_api_credential": "API credential is missing or has invalid characters.", + "acme.invalid_ovh_endpoint": "Select OVH Europe or Canada.", + "acme.http01_no_wildcard": "HTTP-01 cannot issue wildcard names. Select a DNS-01 provider.", + "acme.http01_listener_required": "HTTP-01 needs an active plaintext HTTP listener reachable on public port 80.", + "cacheScopeLegacy": "Unassigned / old scope", + "certificateLibrary": "Certificates", + "certificateWorkspaceHelp": "Manage installed certificates, automatic renewal, and generation/import in separate views.", + "certificateTabInstalled": "Installed", + "certificateTabRenewal": "Renewal", + "certificateTabGenerate": "Generate", + "certificateInstalledTitle": "Installed certificates", + "certificateInstalledHelp": "Certificates available to vhosts, redirects, 404 hosts, and the administration listener.", + "certificateGenerateTitle": "Generate and import", + "certificateGenerateHelp": "Choose Let's Encrypt/ACME issuance, PEM upload, or existing files from RustProxy's private certificate directory.", + "certificateOperationStatus": "Latest ACME operation", + "certificateNoRenewalOperation": "No renewal operation in this process.", + "certificateStateIdle": "Idle", + "certificateStateRunning": "Running", + "certificateStateReady": "Ready", + "certificateStateComplete": "Complete", + "certificateStateFailed": "Failed", + "certbotLogs": "Certbot logs", + "systemDiagnostics": "System / process", + "vhWebsockets": "WebSocket support", + "vhLocations": "Custom locations", + "locationPath": "Location prefix", + "locationPool": "Vhost backends", + "locationMissing": "Missing backend", + "locationLimit": "A host supports up to 64 locations.", + "locationAccess": "Location access control", + "locationHeaders": "Location headers", + "locationAddBackend": "Add dedicated backend", + "locationAdd": "Add location", + "locationHelp": "The longest matching prefix wins. The original path and query are retained. Select a backend from this host or add a dedicated backend. Host authentication and ACL always apply.", + "aclUse": "Enable IP access list", + "aclAllow": "Allow", + "aclNetwork": "IP network (CIDR)", + "aclAction": "Action", + "aclAdd": "Add IP rule", + "authMode": "Authentication", + "authNone": "None", + "authTinyauth": "Tinyauth", + "authBasic": "HTTP Basic Auth", + "basicRealm": "Realm (login prompt)", + "basicRequireTls": "Require HTTPS for Basic Auth", + "basicForward": "Forward verified Authorization to backend", + "basicPassword": "Password (12-128 bytes)", + "basicKeepPassword": "Leave empty to keep the password", + "basicHashSaved": "Password set", + "basicHashRequired": "Set a password for each user.", + "basicAddUser": "Add user", + "basicHelp": "Only Argon2id hashes are stored. Verified credentials are removed from upstream requests unless forwarding is enabled. Enable forwarding only to a trusted HTTPS backend or an isolated network. Protected hosts bypass shared cache.", + "basic.too_many_users": "At most 64 users per host.", + "basic.invalid_username": "Invalid username.", + "basic.duplicate_username": "Duplicate username.", + "basic.unsupported_hash": "Use an Argon2id password hash generated by RustProxy.", + "basic.invalid_hash": "Invalid password hash.", + "basic.invalid_realm": "Invalid login realm.", + "basic.users_required": "Basic Auth requires at least one user.", + "basic.busy": "Password workers are busy; try again.", + "renewalTitle": "Automatic renewal", + "renewalHelp": "Managed ACME certificates are checked about every 12 hours while RustProxy is running. Certbot decides when renewal is due. Validated replacements are deployed without restarting listeners. Keep provider credentials and HTTP-01 port 80 available.", + "renewalNone": "No managed ACME certificates in the library.", + "renewalEnabled": "Automatic renewal enabled", + "renewalDisabled": "Automatic renewal disabled", + "renewalEnable": "Enable automatic renewal", + "renewalDisable": "Disable automatic renewal", + "renewalNow": "Check now", + "renewalDryRun": "Validate renewal (dry run)", + "renewalLast": "Last check", + "renewalNext": "Next check", + "renewalExpires": "Valid until", + "acme.renewing": "Checking certificate renewal", + "acme.renewed": "Certificate renewed and deployed", + "acme.not_due": "Certificate does not need renewal yet", + "acme.renewal_test_ok": "Renewal validation passed; active certificate unchanged", + "acme.renewal_profile_missing": "Saved renewal profile is missing or unreadable. Restore the private ACME profile.", + "acme.configuration_changed": "Certificate configuration changed during renewal; nothing deployed.", + "acme.deployment_failed": "Renewed certificate failed configuration validation; previous certificate remains active.", + "acme.not_managed": "Only managed ACME certificates can be renewed automatically.", + "acme.certificate_not_found": "Certificate no longer exists.", + "acme.renewal_disabled": "Automatic renewal is disabled.", + "acme.openssl_not_installed": "OpenSSL is required to inspect certificate validity.", + "cacheReason_basic_auth": "HTTP Basic authentication", + "locationPoolEligible": "Include in the default load-balancing pool", + "locationId": "Location ID", + "vhIpAccess": "Vhost IP access list", + "apiDocs": "API documentation", + "auth.invalid_request": "Invalid request. Check the supplied fields.", + "locationProxyHeaders": "Proxy request headers (to backend)", + "locationResponseHeaders": "Response headers (to client)", + "locationHeadersHelp": "Set replaces a header, append adds a value, remove deletes it. Applied after global and vhost rules, only for this location. Values are literal (no nginx variables). Transport and verified identity headers stay system-managed.", + "headersAdvancedJson": "Advanced header rules (JSON)", + "headersInvalid": "Invalid header rule. Check name, operation and value.", + "headersProtected": "Transport and proxy identity headers cannot be overridden here. Use forwarding, authentication or response settings.", + "docsTitle": "API documentation", + "docsSession": "Uses your administrator session. Writes require CSRF protection.", + "docsSignIn": "Sign in to view documentation", + "docsPanel": "Back to panel", + "docsWrites": "Enable write operations", + "docsWriteConfirm": "This sends a real change to RustProxy. Continue?", + "docsExpired": "Session ended. Sign in again.", + "docsError": "Documentation could not be loaded.", + "docsAccount": "Signed in as", + "docsReadOnly": "Read-only execution by default. SSE streams and login/setup are not executed here.", + "docsLogout": "Sign out", + "authPrivateNote": "Access is restricted to authorized administrators.", + "advanced": "Advanced", + "docsLicenses": "Swagger UI licenses", + "vhTrafficPolicy": "Traffic policy", + "vhHttp3Enabled": "Enable HTTP/3 for this vhost", + "vhCloudflareClientIp": "Use verified Cloudflare client IP", + "vhTimingHeaders": "Expose Server-Timing diagnostics", + "vhRateEnabled": "Enable per-client IP rate limit", + "vhRateRequests": "Requests per window", + "vhRateWindow": "Window (seconds)", + "vhRateBurst": "Burst allowance", + "vhCountryMode": "Country access policy", + "vhCountryBlock": "Block listed countries", + "vhCountryAllow": "Allow only listed countries", + "vhCountryList": "Countries (ISO 3166-1 alpha-2)", + "vhCountryPolicyHelp": "Country rules use the installed GeoIP database. Allow-only mode fails closed when a client country cannot be resolved.", + "vhCountry": "Country", + "vhAddCountryRate": "Add country rate limit", + "vhCountryRateLimit": "At most 64 country-specific rate limits are allowed.", + "vhRateHelp": "Country-specific limits override the base per-IP limit. Exceeded limits return 429; denied countries return 403.", + "vhHealthOffWarning": "Active backend probes are disabled. Passive Backend Health is configured separately and remains off unless explicitly enabled.", + "vhFrontendSelectionOnly": "Select named listeners for this vhost. Listener IP addresses, ports and H2/H3 capabilities are configured only in Settings > Listeners.", + "vhForceSsl": "Force SSL (redirect HTTP to HTTPS)", + "vhForceSslHelp": "Plain HTTP requests for this vhost receive a 308 redirect to HTTPS while preserving path and query. Select at least one plain HTTP listener and at least one TLS listener. If several TLS ports are selected, port 443 is used; otherwise the TLS target port must be unambiguous.", + "vhForceSslNeedsTls": "Force SSL requires at least one enabled TLS listener for this vhost.", + "vhForceSslPortConflict": "Force SSL cannot choose the HTTPS redirect target because selected TLS listeners use multiple non-standard ports. Select one TLS port or include port 443.", + "vhForwardClientIp": "Forward client IP to backend", + "vhForwardClientIpHelp": "Keep enabled for normal reverse-proxy behavior. Disable to withhold Forwarded, X-Forwarded-For and X-Real-IP from the application while RustProxy, ACLs, rate limits and Tinyauth still use the real client IP.", + "vhClientIpHidden": "Client IP hidden from backend", + "listenerMigrateInline": "Existing vhost listener (will migrate)", + "vhDebugOriginPreset": "Apply recommended preset", + "vhDebugOriginTlsWarning": "This preset keeps backend TLS certificate verification disabled. The connection is encrypted, but CA and hostname identity are not checked. Continue?", + "vhOriginPresetHelp": "Debug origin defaults for new backend rows: HTTPS by IP, default LB pool ON, private origin ON, HTTP/1.1, Preserve client Host ON and TLS verification OFF. Re-enable TLS verification whenever the origin certificate can be validated.", + "vhDebugOriginMismatch": "This HTTPS-by-IP origin does not match the debug forwarding preset (pool ON, private origin ON, HTTP/1.1, preserve Host ON, TLS verification OFF). If you enable verification, set TLS server name to the certificate DNS name and add its private CA when needed. Continue with the current values?", + "vhCacheStaticCookie": "Ignore request cookies for selected static file extensions", + "vhCacheStaticCustom": "Use a custom static extension list", + "vhCacheStaticExtensions": "Static extensions (one per line, without dot)", + "vhCacheStaticHelp": "Inherit the global policy or override it for this vhost. Ignore Cookie only for files whose bytes do not depend on the session; Set-Cookie, private/no-store and Vary: Cookie still block shared storage.", + "cacheStaticTitle": "Static asset cache boost", + "cacheStaticCookie": "Ignore request cookies for static files", + "cacheStaticExtensions": "Global static extensions (one per line, without dot)", + "cacheStaticHelp": "Enable this only for files whose bytes do not depend on the session. Request Cookie then does not affect the shared cache key for these extensions; responses with Set-Cookie, private/no-store or Vary: Cookie are still never stored.", + "vhHttp2Enabled": "Enable HTTP/2 for this vhost", + "vhFrontendProtocolsHelp": "H2 and H3 are per-vhost opt-outs. A switch is available only when at least one selected listener has that protocol enabled in Settings > Listeners.", + "administration": "Administration", + "administrationActiveHttp": "Active HTTP administration", + "administrationActiveHttps": "Active HTTPS administration", + "administrationCertificate": "Certificate", + "administrationGeneratedCertificate": "Application-generated default certificate", + "administrationHelp": "HTTPS allows the administration listener to bind beyond loopback. Plain HTTP on the HTTPS port is redirected automatically. Listener and certificate changes take effect after restart.", + "administrationHosts": "Allowed DNS hostnames (optional strict allowlist)", + "administrationHttps": "Enable HTTPS for administration", + "administrationListen": "Listen address", + "cacheOverhead": "Overhead", + "cacheResidentAge": "X-Cache-Resident-Age", + "controlPlane": "CONTROL PLANE", + "logging": "Logging", + "loggingTitle": "Structured access logging", + "loggingDescription": "Write configurable JSON access logs to a file, Logstash or Loki.", + "loggingHelp": "Live log viewing stays in memory. External sinks use a bounded background queue and do not block request I/O.", + "loggingFile": "JSON file", + "loggingPath": "File path", + "loggingAddress": "TCP address", + "loggingTokenEnv": "Bearer token environment variable", + "loggingLabels": "Labels (JSON object)", + "loggingFormat": "Configurable JSON format", + "loggingFormatHelp": "Map output names to source fields. Leave mapping empty for the complete standard entry.", + "loggingFields": "Field mapping (JSON object)", + "loggingStatic": "Static fields (JSON object)", + "loggingJsonInvalid": "Logging JSON fields must contain valid JSON objects.", + "loggingQueue": "Sink queue", + "loggingDropped": "dropped", + "loggingSent": "sent", + "loggingCapacity": "capacity", + "tls12": "TLS 1.2", + "tls13": "TLS 1.3", + "tlsPolicy": "TLS policy", + "tlsPolicyHelp": "This is the global TLS policy inherited by every TLS host in the application. Per-vhost policy may only narrow it. HTTP/3 requires TLS 1.3.", + "tlsCiphers": "Cipher suites (one per line; empty = rustls defaults)", + "tlsAvailableCiphers": "Available cipher suites", + "tlsPolicyVersionRequired": "Enable at least one TLS version.", + "vhTlsOverride": "Override global TLS policy", + "vhTlsOverrideHelp": "Use only when this vhost needs different TLS versions or cipher suites. Empty cipher list keeps rustls defaults.", + "vhTlsHttp3Conflict": "HTTP/3 uses the global TLS policy. Disable HTTP/3 on this vhost before using a different TLS override.", + "vhListeners": "Listeners", + "vhProtocols": "Protocols", + "loggingMode": "JSON / ASYNC / BOUNDED", + "loggingPushUrl": "Push URL", + "cacheHotObjects": "Hot objects", + "cacheHotMemory": "Hot memory", + "cacheColdObjects": "Cold objects", + "cacheColdDisk": "Cold disk", + "cacheTierMoves": "Tier moves", + "cacheColdHits": "cold hits", + "cacheTier": "Tier", + "cacheStorage": "Memory / disk", + "cacheColdTitle": "Persistent cold cache", + "cacheColdEnable": "Enable persistent disk tier", + "cacheColdHelp": "Choose what is moved from hot RAM to persistent disk. Cold hits are read asynchronously and promoted back to RAM. Metadata is readable JSON; bodies are raw .body files.", + "cacheColdPath": "Cold path (inside data directory)", + "cacheColdBytes": "Cold disk budget (MiB)", + "cacheColdEntries": "Cold object limit", + "cacheColdAfter": "Demote after idle seconds", + "cacheHot": "Hot", + "cacheCold": "Cold", + "cacheReason_request_no_cache": "Client no-cache / hard reload requires validation", + "cacheReason_request_max_age": "Client max-age requires validation", + "cacheReason_request_min_fresh": "Client min-fresh requires validation", + "cacheReason_request_pragma_no_cache": "Client Pragma no-cache / legacy hard reload", + "cacheReason_cache_revalidated": "Stored object validated by origin", + "cacheReason_cache_conditional_hit": "Fresh object satisfied a conditional request", + "cacheReason_request_no_store": "Client no-store bypass", + "cacheReason_request_precondition": "Client precondition requires origin", + "loggingPreset": "Format preset", + "loggingPresetCustom": "Custom", + "loggingPresetStandard": "RustProxy standard", + "loggingPresetNginx": "Nginx-style JSON", + "loggingPresetEcs": "ECS-style field names", + "loggingPresetMinimal": "Minimal", + "loggingBuilder": "Visual field builder", + "loggingBuilderOpen": "Configure fields", + "loggingAdvancedJson": "Advanced JSON mapping", + "loggingBuilderHelp": "Select source fields and optionally rename their JSON keys. Applying the visual editor normalizes each source field to at most one output key; use advanced JSON for duplicate aliases.", + "loggingSourceField": "Source field", + "loggingOutputField": "Output JSON key", + "loggingPreview": "Preview", + "loggingOutputRequired": "Every enabled log field needs an output JSON key.", + "loggingOutputDuplicate": "Output JSON keys must be unique.", + "aclProfiles": "ACL profiles", + "acl-profiles": "ACL profiles", + "acl-profilesTitle": "Reusable ACL profiles", + "acl-profilesDescription": "Define reusable IP and country access rules, then attach them to one or more virtual hosts.", + "aclProfilesIntro": "Reusable access profiles can be attached to multiple vhosts. Per-vhost ACL and country rules remain active and are enforced together with the profile.", + "aclProfilesReusable": "REUSABLE POLICY", + "aclProfileNew": "New profile", + "aclProfileEdit": "Editing profile", + "aclProfileEditor": "Profile configuration", + "aclProfileId": "Profile ID", + "aclProfileName": "Display name", + "aclProfileIpEnabled": "Enable IP ACL", + "aclProfileRules": "IP rules — one “allow|deny CIDR” per line", + "aclProfileCombineHelp": "Profile and vhost rules are restrictive: a request must pass both policies.", + "aclProfileIpRulesShort": "IP rules", + "aclProfileIpOff": "IP ACL disabled", + "aclProfileCountriesOff": "Country filtering disabled", + "aclProfileAttached": "Attached to", + "aclProfileInUse": "Profile is attached to vhosts", + "aclProfilesEmpty": "No reusable ACL profiles yet.", + "aclProfileCountryInvalid": "Country codes must use two-letter ISO codes.", + "aclProfileDuplicate": "An ACL profile with this ID already exists.", + "vhAclProfile": "ACL profile", + "vhAclProfileNone": "No reusable profile", + "vhAclProfileHelp": "Optional reusable profile. Inline vhost ACL and country rules below are still enforced in addition to the profile.", + "vhHostAdd": "Add hostname", + "vhHostsHelp": "Press Enter or comma to add a hostname. Wildcards are supported only as a leading *. (for example *.example.com). Exact hosts take priority over wildcard matches.", + "vhHostInvalid": "Invalid hostname or wildcard pattern.", + "vhHostRequired": "Add at least one hostname.", + "vhLifetimeChart": "Lifetime request rate", + "vhReqPerSecond": "req/s", + "vhPeak": "peak", + "listenerAddresses": "Bind addresses", + "listenerAddressesInvalid": "Provide 1–16 unique listener addresses.", + "listenerAddressesExplicit": "When a listener uses multiple addresses, select explicit IPs; 0.0.0.0 and :: are not allowed.", + "ruleSyntaxLine": "Invalid ACL rule on line", + "vhForceSslNeedsHttp": "Force SSL needs at least one selected plain HTTP (non-TLS) listener. A listener named “HTTP” may still be configured as TLS in Settings > Listeners.", + "aclProfileRuleBuilder": "IP rules", + "aclProfileBulkRules": "Paste / import rules", + "aclProfileBulkHelp": "Paste one “allow|deny CIDR” rule per line. Invisible Unicode spacing is normalized during import and errors identify the exact line.", + "aclProfileImportRules": "Import rules", + "aclProfileRulesImported": "ACL rules imported into the visual editor.", + "streamHosts": "Stream hosts", + "streamsHelp": "Raw TCP/UDP forwarding on dedicated ports. Stream traffic bypasses HTTP vhosts, ACL, cache, headers and compression.", + "streamAdd": "Add stream host", + "saveStreams": "Save stream hosts", + "streamRestartHelp": "Changing a stream listener requires a RustProxy restart.", + "streamRestartPending": "Saved stream changes are pending. Restart RustProxy to apply them.", + "streamActive": "Currently active streams", + "streamNone": "No stream hosts active", + "streamEndpoint": "Stream endpoint", + "streamId": "Stream ID", + "streamName": "Display name", + "streamProtocol": "Protocol", + "streamListen": "Listen address", + "streamUpstream": "Upstream address", + "streamConnectTimeout": "TCP connect timeout (seconds)", + "streamIdleTimeout": "Idle/session timeout (seconds)", + "streamMaxConnections": "Max connections/sessions", + "streamTcp": "TCP", + "streamUdp": "UDP", + "streamLimit": "At most 128 stream hosts are supported.", + "streamDuplicate": "Stream host IDs must be unique.", + "streams": "Streams", + "streamsTitle": "TCP / UDP streams", + "streamsDescription": "Manage raw Layer-4 listeners, upstreams and live traffic telemetry.", + "streamsPageIntro": "TCP/UDP streams are independent Layer-4 proxies. Each stream owns a dedicated listener and upstream and bypasses the HTTP vhost, ACL and cache stack.", + "streamLayer4": "LAYER 4 / TCP + UDP", + "streamList": "Streams", + "streamSearch": "Search streams", + "streamSearchPlaceholder": "ID, name, listener or upstream…", + "streamSearchEmpty": "No streams match this search.", + "streamEmpty": "No TCP/UDP streams yet. Add a listener to start forwarding Layer-4 traffic.", + "streamNew": "New stream", + "streamAddListener": "Add TCP/UDP listener", + "streamEditor": "Stream configuration", + "streamEditLabel": "EDITING", + "streamNewLabel": "NEW", + "streamDetailsTab": "Details", + "streamNetworkTab": "Listener & upstream", + "streamLimitsTab": "Limits", + "streamNetwork": "Listener & upstream", + "streamLimits": "Connection / session limits", + "streamDedicatedListenerHelp": "A stream listener is created together with the stream and is separate from HTTP/HTTPS listeners.", + "streamLimitsHelp": "Limits apply to concurrent TCP connections or per-client UDP sessions. TCP connect timeout is not used by UDP.", + "streamListenAdd": "Add listen address", + "streamListenHelp": "Enter one IP:port listener address and press Enter. Listener changes become active after RustProxy restarts.", + "streamListenInvalid": "The listener address must be one IP:port value without spaces or commas.", + "streamListenRequired": "Add a listener address.", + "streamDelete": "Delete this stream? The active listener remains until RustProxy restarts.", + "streamPendingRestart": "Pending restart", + "streamRunning": "Running", + "streamActiveSessions": "Active connections / sessions", + "streamSessionsTotal": "Connections / sessions", + "streamErrors": "Errors", + "streamRejected": "Rejected", + "streamTransferred": "Transferred", + "streamTrafficChart": "Live traffic rate", + "streamClientToUpstream": "client → upstream", + "streamUpstreamToClient": "upstream → client", + "streamNoLiveMetrics": "No live metrics until this stream is active after restart.", + "streamIdleShort": "Idle timeout", + "listenerAddressAdd": "Add bind address", + "listenerAddressesHelp": "Press Enter or comma to add a bind address. Multiple addresses use the same listener policy; wildcard 0.0.0.0/:: cannot be mixed with explicit addresses.", + "cacheColdOnIdle": "Move old/idle objects to cold storage", + "cacheColdOnLru": "Move hot LRU evictions to cold storage", + "cacheColdOnShutdown": "Flush hot cache to cold storage during graceful shutdown", + "certificateCovers": "Certificate domains", + "certificateCoverageUnknown": "Domain data (DNS SAN) is unavailable. Reload certificate files or import the certificate again.", + "certificateDetails": "Certificate details", + "certificateFiles": "Certificate / key files", + "redirects": "Redirects", + "redirectsTitle": "Redirect with intent.", + "redirectsDescription": "Local redirect hosts with explicit match, target, status, path and query behavior.", + "redirectsPageIntro": "Redirect hosts answer locally without opening an upstream connection. Rules can match scheme and path, then redirect to a URL, another host or a path.", + "redirectLayer": "HTTP REDIRECTION", + "redirectList": "Redirect hosts", + "redirectSearch": "Search redirects", + "redirectSearchPlaceholder": "ID, hostname, target…", + "redirectNew": "New redirect", + "redirectEmpty": "No redirect hosts configured.", + "redirectSearchEmpty": "No redirects match this search.", + "redirectEditor": "Redirect configuration", + "redirectId": "Redirect ID", + "redirectName": "Display name", + "redirectHosts": "Source hostnames", + "redirectFrontend": "FRONTEND", + "redirectListeners": "Listeners", + "redirectBehavior": "BEHAVIOR", + "redirectRules": "Rules", + "redirectAddRule": "Add rule", + "redirectRulesHelp": "Longest matching path wins. 307/308 preserve the request method; 301/302/303 may change it in clients.", + "redirectSafetyHint": "Redirect hosts do not contact a backend. Global host ACLs still apply. Avoid self-referential rules that can create browser redirect loops.", + "redirectRule": "Rule", + "redirectRuleId": "Rule ID", + "redirectSourceScheme": "Incoming scheme", + "redirectSchemeAny": "HTTP or HTTPS", + "redirectSchemeHttp": "HTTP", + "redirectSchemeHttps": "HTTPS", + "redirectSchemePreserve": "Preserve incoming scheme", + "redirectMatchType": "Path match", + "redirectMatchPrefix": "Prefix", + "redirectMatchExact": "Exact", + "redirectSourcePath": "Source path", + "redirectTargetType": "Target type", + "redirectTargetUrl": "Absolute URL", + "redirectTargetHost": "Host / application", + "redirectTargetPath": "Path on the same host", + "redirectTarget": "Target", + "redirectTargetScheme": "Target scheme", + "redirectPathMode": "Path handling", + "redirectPathAppend": "Append full source path", + "redirectPathStrip": "Strip matched prefix and append remainder", + "redirectPathReplace": "Use configured target path only", + "redirectQueryMode": "Query handling", + "redirectQueryPreserve": "Preserve source query", + "redirectQueryDrop": "Drop source query", + "redirectStatus": "HTTP status", + "redirect301": "301 Moved Permanently", + "redirect302": "302 Found", + "redirect303": "303 See Other", + "redirect307": "307 Temporary Redirect (preserve method)", + "redirect308": "308 Permanent Redirect (preserve method)", + "redirectCacheSeconds": "Browser/shared cache max-age (seconds; 0 = no-store)", + "redirectHostsRequired": "Add at least one source hostname.", + "redirectListenersRequired": "Select at least one listener.", + "redirectRulesRequired": "Add at least one redirect rule.", + "redirectDuplicate": "A redirect with this ID already exists.", + "redirectUnsaved": "Discard unsaved redirect changes?", + "redirectNewLabel": "NEW", + "administrationSessionDays": "Administrator session lifetime (days)", + "administrationSessionDaysHelp": "Browser sessions use an absolute 7–30 day lifetime. Changing this value applies immediately; password changes and “revoke all sessions” still invalidate sessions at once.", + "vhLocalListenerRisk": "Allow local RustProxy listener as backend (risk)", + "vhLocalListenerRiskHelp": "Shown when the backend URL directly targets the configured or active RustProxy administration listener. localhost, 127.0.0.1 and ::1 are treated as equivalent loopback forms on the same administration port. Enabling it can expose the control plane or create a proxy loop; private/loopback targets still require “Allow private origin IP”.", + "vhLocalListenerRiskConfirm": "This can route requests into a local RustProxy listener, including the administration port, and may create a loop or expose the control plane. Accept this risk?", + "cacheResidentHistory": "Resident RAM history", + "networkHistory": "RX / TX history", + "responseLatencyHistory": "Response time history", + "sourceIp": "Source IP", + "cacheColdOnAge": "Move objects to cold storage after a maximum hot-tier age", + "cacheColdMaxHot": "Maximum hot-tier age (seconds)", + "cacheColdPolicyHelp": "Idle time measures how long an object has not been used. Hot-tier age measures continuous residency in RAM even if the object is still requested. LRU pressure and graceful-shutdown handoff are independent policies.", + "documentation": "Documentation", + "locationPathPattern": "Path / regex pattern", + "locationMatchType": "Match type", + "locationMatchPrefix": "Prefix", + "locationMatchPriority": "Priority prefix (^~)", + "locationMatchExact": "Exact path (=)", + "locationMatchRegex": "Regex (~)", + "locationMatchRegexI": "Regex, case-insensitive (~*)", + "locationMatch_prefix": "prefix", + "locationMatch_prefix_priority": "priority prefix", + "locationMatch_exact": "exact", + "locationMatch_regex": "regex", + "locationMatch_regex_insensitive": "regex i", + "locationAction": "Action", + "locationProxyAction": "Proxy to backend", + "locationReturnAction": "Custom response (return)", + "locationProxyShort": "proxy", + "locationReturnShort": "local response", + "locationHandling": "Request handling", + "locationRewriteMode": "Backend path rewrite", + "locationRewritePreserve": "Keep original path", + "locationRewriteStrip": "Strip matched prefix", + "locationRewriteReplace": "Replace matched prefix", + "locationRewriteRegex": "Regex replacement", + "locationRewriteValue": "Replacement path / expression", + "locationRewriteHelp": "Query parameters are preserved. Prefix rewrites operate on the normalized routing path. Regex replacement supports Rust regex captures such as $1. Rewritten paths are validated before forwarding.", + "locationResponseStatus": "Response status", + "locationResponseType": "Content-Type", + "locationResponseBody": "Response body", + "headersVisualBuilder": "Visual configurator", + "locationDocsTitle": "Custom locations documentation", + "headersDocsTitle": "Header rules documentation", + "locationDocsIntro": "Custom locations are evaluated inside the selected vhost after host authentication and ACL. They never bypass vhost security.", + "locationDocsPrefix": "Prefix: matches a complete path segment prefix. The longest prefix wins. Priority prefix behaves like nginx ^~ and prevents regex locations from taking precedence.", + "locationDocsExact": "Exact: matches only the exact normalized path, like nginx location = /path.", + "locationDocsRegex": "Regex: first matching regex wins after exact/priority-prefix processing. Case-sensitive and case-insensitive modes are supported. Rust regex syntax is used; look-around/backreferences in the pattern are intentionally unsupported.", + "locationDocsReturn": "Custom response: answer locally with a chosen HTTP status, Content-Type and body without opening a backend connection. Location response-header rules still apply.", + "locationDocsRewrite": "Backend path rewrite: keep the original target, strip/replace a matched prefix, or use regex capture replacement. The original query string is preserved.", + "locationDocsAccess": "Per-location IP ACL, cache override and request/response header rules are composed with the vhost policy. A location cannot disable host authentication or host ACL.", + "locationDocsLimits": "Up to 64 locations per vhost. Regexes and response bodies are bounded; transport, forwarding identity and WebSocket protocol headers remain system-managed.", + "headersDocsIntro": "Header rules are applied in order: global → vhost → matching location. Use the visual configurator for normal changes; JSON remains an advanced escape hatch.", + "headersDocsSet": "Set: replace all existing values of a header with the configured literal value.", + "headersDocsAppend": "Append: add another value without deleting existing values.", + "headersDocsRemove": "Remove: delete the header before forwarding or before sending the response.", + "headersDocsProtected": "Protected transport/identity headers (Host, Connection, Content-Length, Forwarded/X-Forwarded-*, WebSocket proof headers and similar) cannot be overridden here.", + "headersDocsOrder": "Values are literal; nginx variables are not interpreted. Request rules run before canonical forwarding metadata is applied, and response rules run before final RustProxy security/diagnostic policy.", + "cacheColdPolicyTitle": "Hot → cold policy", + "headerRule": "Header rule", + "headerRulesEmpty": "No rules yet. Add a rule below.", + "headerRuleHelp": "Choose Set, Append or Remove. Transport, forwarding identity and WebSocket protocol headers remain protected.", + "locationPreviewTitle": "Effective behavior preview", + "locationPreviewMatch": "Matching", + "locationPreviewHandling": "Handling", + "locationPreviewRewrite": "Backend path", + "locationPreviewCache": "Cache", + "locationPreviewAccess": "Access", + "locationPreviewHeaders": "Header rules", + "locationPreviewDisabled": "This location is disabled and will not match requests.", + "locationPreviewBackendSkipped": "backend is not contacted", + "locationPreviewVhostAcl": "Vhost authentication and ACL only", + "locationPreviewCustomAcl": "Vhost policy + location ACL", + "locationPreviewRequestRules": "request", + "locationPreviewResponseRules": "response", + "locationPreviewQueryPreserved": "query string is preserved", + "locationDocsMatchHeading": "1. Matching and precedence", + "locationDocsActionHeading": "2. Proxy, rewrite and local response", + "locationDocsPolicyHeading": "3. Access, cache and headers", + "locationDocsExamplesHeading": "Common examples", + "locationDocsExampleExact": "Health endpoint: Exact (=) /health + Custom response 200 answers locally without a backend connection.", + "locationDocsExampleStatic": "Static assets: Priority prefix (^~) /static/ keeps regex locations from overriding it; cache can be enabled for this location.", + "locationDocsExampleRegex": "Versioned API: Regex (~) ^/api/v[0-9]+/ can select a backend and use regex replacement groups such as $1.", + "cacheOutcomeHistory": "HIT / MISS / BYPASS activity", + "processCpuHistory": "Process CPU history", + "processRssHistory": "Process RSS history", + "cacheMemoryBreakdown": "Hot / transient cache RAM", + "cacheTransientBreakdown": "Fill / cold queue / cold I/O", + "cacheTransientMemory": "Transient cache RAM", + "cacheFillShort": "fill", + "cacheQueueShort": "cold queue", + "cacheIoShort": "cold I/O", + "processRssTotal": "RSS total", + "processRssAnon": "Anonymous RSS", + "processRssFile": "File-backed / shared RSS", + "processDataSwap": "Data address space / swap", + "memoryAccountingHint": "OS RSS categories and RustProxy cache accounting are overlapping views; do not add them together.", + "logTransport": "Transport", + "logWebSocket": "WebSocket", + "logUpstreamError": "Upstream error", + "listenerDefaultPortsHint": "New vhosts automatically select the standard HTTP :80 listener when it is available.", + "vhBackendDiagnostics": "Backend diagnostics", + "vhBackendHeader": "Expose selected backend (X-RustProxy-Backend)", + "vhBackendTimingHeader": "Expose backend response time (X-RustProxy-Backend-Time)", + "vhBackendDiagnosticsHelp": "Opt-in response headers. Backend time measures origin request-to-response-headers latency; responses served without contacting an origin do not report a backend.", + "authModeHelp": "Choose one authentication provider. The IP access list below is independent and can be combined with either provider.", + "taAdvanced": "Advanced Tinyauth settings", + "notfound": "404 hosts", + "notfoundTitle": "404 hosts, under control.", + "notfoundDescription": "Reserve domains that should answer locally with a status, placeholder or custom HTML.", + "notfoundPageIntro": "404 hosts reserve selected domains and answer locally without contacting a backend. They can use the same listeners and certificates as vhosts.", + "notfoundLayer": "LOCAL RESPONSE", + "notfoundList": "404 hosts", + "notfoundSearch": "Search 404 hosts", + "notfoundSearchPlaceholder": "ID, name or hostname…", + "notfoundNew": "New 404 host", + "notfoundEditor": "404 host configuration", + "notfoundId": "Host ID", + "notfoundName": "Display name", + "notfoundFrontend": "FRONTEND", + "notfoundBehavior": "RESPONSE", + "notfoundResponse": "Local response", + "notfoundMode": "Response type", + "notfoundModeWelcome": "RustProxy placeholder", + "notfoundModeEmpty": "Status only / empty", + "notfoundModeHtml": "Custom HTML", + "notfoundStatus": "HTTP status", + "notfoundHtml": "Custom HTML", + "notfoundResponseHelp": "The host never opens an upstream connection. The built-in placeholder is no-store and intentionally omits optional RustProxy diagnostic/server headers.", + "notfoundHosts": "Hostnames", + "notfoundNewLabel": "NEW", + "notfoundHostsRequired": "Add at least one hostname.", + "notfoundListenersRequired": "Select at least one listener.", + "notfoundHtmlRequired": "Custom HTML cannot be blank.", + "notfound204Empty": "HTTP 204, 205 and 304 responses must use Empty mode.", + "notfoundDuplicate": "A 404 host with this ID already exists.", + "notfoundUnsaved": "Discard unsaved 404 host changes?", + "notfoundEmpty": "No 404 hosts configured.", + "notfoundSearchEmpty": "No 404 hosts match the search.", + "prometheusTrustedNoToken": "Allow token-free reads from trusted CIDRs", + "prometheusTrustedNoTokenHelp": "Clients still must match Allowed client CIDRs. Enable only on a private, trusted monitoring network; forwarded headers are never trusted for this check.", + "docker-vhosts": "Docker vhosts", + "docker-vhostsTitle": "Docker services, named.", + "docker-vhostsDescription": "Discover published Docker ports and map services to base-domain names, custom domains and aliases without copying observed state into SQLite.", + "dockerVhosts": "Docker vhosts", + "dockerVhostsIntro": "Published TCP ports discovered by configured Docker agents. Map each service to a short name under the agent base domain or to any FQDN, add aliases, choose the Docker host IP, and inspect or tune live backend health.", + "dockerDiscovery": "DOCKER DISCOVERY", + "dockerRefresh": "Refresh agents", + "dockerAgentsSettings": "Docker agents settings", + "dockerAgents": "Docker agents", + "dockerDesiredState": "DESIRED STATE / SQLITE", + "dockerAgentsHelp": "Configure one agent per Docker host. HTTPS with a persistent self-signed certificate is the agent default; paste its SHA-256 fingerprint to pin it. Each agent keeps its own base domain and optional default backend IP.", + "dockerAgentAdd": "Add Docker agent", + "dockerAgentsSave": "Save and connect agents", + "dockerAgent": "DOCKER AGENT", + "dockerAgentNew": "New agent", + "dockerAgentId": "Agent ID", + "dockerAgentName": "Display name", + "dockerAgentEndpoint": "Agent endpoint", + "dockerAgentToken": "Bearer token", + "dockerBaseDomain": "Base domain", + "dockerTargetOverride": "Target IP override", + "dockerTargetAuto": "automatic", + "dockerListeners": "Listeners", + "dockerListenersRequired": "Select at least one listener for the Docker agent.", + "dockerNoAgents": "No Docker agents configured.", + "dockerNoAgentsConfigured": "Configure a Docker agent in Settings first.", + "dockerPublish": "Publish", + "dockerPreserveHost": "Preserve Host", + "dockerServiceMissing": "Not present in the latest inventory", + "dockerConnected": "CONNECTED", + "dockerError": "ERROR", + "dockerWaiting": "WAITING", + "dockerAgentDisabled": "This Docker agent is disabled.", + "dockerNoObservation": "No observation yet. Refresh the agents.", + "dockerHost": "Docker host", + "dockerDetectedIp": "Detected IP", + "dockerEffectiveIp": "Routing IP", + "dockerNoPublishedPorts": "No published TCP ports were reported.", + "dockerAssignmentsSave": "Save Docker vhosts", + "dockerDefaultTarget": "Default backend IP", + "dockerDetectedIps": "Detected host IPs", + "dockerHostname": "Hostname", + "dockerHostnameHelp": "Short names use {domain}; a full FQDN uses its own domain.", + "dockerAliases": "Aliases", + "dockerAliasAdd": "Add alias", + "dockerAliasesHelp": "Add aliases with Enter or comma. Short names use the agent base domain; full FQDNs are kept as entered.", + "dockerBackendIp": "Backend host IP", + "dockerBackendIpAuto": "Auto / agent default", + "dockerBackendScheme": "Backend protocol", + "dockerBindingIp": "Docker binding", + "dockerAgentDefaultIp": "agent default", + "dockerAgentEndpointHelp": "Use https:// for the default agent setup. Plain HTTP is supported only for compatibility and is visibly marked insecure.", + "dockerTlsFingerprint": "TLS certificate SHA-256", + "dockerTlsFingerprintHelp": "For the default self-signed agent certificate, paste the fingerprint printed by install.sh or rustproxy-docker-agent --print-fingerprint. Leave empty only for a certificate trusted by a public CA.", + "dockerTlsPinned": "TLS PINNED", + "dockerTlsPublicCa": "TLS / PUBLIC CA", + "dockerTlsPlain": "PLAIN HTTP", + "dockerTlsPlainHelp": "The agent token and inventory are not protected by TLS on this endpoint.", + "dockerTlsPublicCaHelp": "Certificate chain and hostname are verified against the system/public CA roots.", + "dockerAgentConnectionFailed": "Agent connection failed", + "dockerAgentVersion": "Agent version", + "dockerLastChecked": "Last checked", + "dockerNeverChecked": "never", + "dockerJustNow": "just now", + "dockerSecondsAgo": "{seconds}s ago", + "dockerMinutesAgo": "{minutes}m ago", + "dockerBackendMissing": "SERVICE MISSING", + "dockerBackendNotActive": "NOT PUBLISHED", + "dockerBackendChecking": "CHECKING", + "dockerBackendHealthOff": "HEALTH OFF", + "dockerBackendUp": "UP", + "dockerBackendDown": "DOWN", + "dockerBackendDraining": "DRAINING", + "dockerBackendNoMetrics": "Runtime metrics appear after the dynamic backend is active.", + "dockerHealthCheck": "Backend health check", + "dockerHealthSummary": "Active TCP/HTTP probes; passive health is configured separately", + "dockerPassiveHealth": "Passive backend health", + "dockerPassiveHealthSummary": "Off by default; opt in to health decisions from normal requests", + "dockerHealthMode": "Mode", + "dockerHealthHttp": "HTTP", + "dockerHealthTcp": "TCP", + "dockerHealthOff": "Off", + "dockerHealthPath": "HTTP path", + "dockerHealthHost": "Health Host header", + "dockerHealthHostAuto": "backend host / automatic", + "dockerHealthInterval": "Interval (ms)", + "dockerHealthTimeout": "Timeout (ms)", + "dockerHealthSlow": "Slow threshold (ms)", + "dockerHealthFall": "Failures to mark down", + "dockerHealthRise": "Successes to mark up", + "dockerHealthStatusMin": "Min HTTP status", + "dockerHealthStatusMax": "Max HTTP status", + "dockerHealthLatency": "Latency", + "dockerHealthResult": "Last probe", + "dockerHealthRequests": "Requests", + "dockerHealthErrors": "Errors", + "dockerHealthActive": "Active", + "loggingPerVhost": "Separate file per vhost", + "loggingDirectory": "Per-vhost directory", + "loggingPerVhostHelp": "When enabled, each vhost writes to its own JSONL file. Unmatched traffic uses _unmatched.jsonl.", + "listenerStartupLocked": "This startup/systemd address cannot be changed or removed here. Other addresses and listener options remain editable.", + "administrationStartupLocked": "This address is fixed by the process startup/systemd configuration and cannot be changed here.", + "notfoundStatusInvalid": "Select a supported HTTP error status.", + "tlsEffectiveCiphers": "Currently effective cipher suites", + "certificateManaged": "ACME managed", + "certManagedReadOnly": "ACME-managed certificates are renewed automatically and cannot be edited as custom file certificates.", + "vhWebsocketTransport": "WebSocket transport", + "vhWebsocketHttp1": "HTTP/1.1 Upgrade (recommended)", + "vhWebsocketRfc9220": "RFC 9220 Extended CONNECT over HTTP/3", + "vhWebsocketTransportHelp": "Enable WebSocket with the separate switch, then choose the transport. H2/H3 modes use Extended CONNECT while HTTP/1.1 Upgrade remains the fallback.", + "dockerLiveView": "Live view", + "dockerPollInterval": "Agent poll interval (s)", + "dockerPollIntervalHelp": "How often RustProxy contacts this agent for Docker inventory. Live view only reads RustProxy memory.", + "dockerAgentPollEvery": "Agent poll interval", + "dockerSearch": "Search Docker vhosts", + "dockerSearchPlaceholder": "Search service, host, alias or agent...", + "dockerShowHidden": "Show Hidden", + "dockerHideHidden": "Hide Hidden", + "dockerHideVhost": "Hide from view", + "dockerUnhideVhost": "Show in view", + "dockerCertificate": "Frontend certificate", + "dockerCertificateHelp": "Certificate assigned to every generated vhost for this agent. Default uses normal TLS/SNI certificate selection.", + "dockerDefaultAcl": "Default ACL profile", + "dockerDefaultAclNone": "No default ACL profile", + "dockerDefaultAclHelp": "Inherited by generated Docker vhosts that do not select their own per-app ACL profile.", + "dockerApplyService": "Apply changes", + "dockerAccessControl": "Access control", + "dockerAclSummary": "Optional ACL profile and per-app IP rules", + "dockerAclProfile": "ACL profile", + "dockerAclProfileNone": "No ACL profile", + "dockerAclProfileInherit": "Agent default", + "dockerAclInline": "Use additional per-app ACL", + "dockerAclDefault": "Default ACL action", + "dockerAclRules": "IP/CIDR rules", + "dockerAclHelp": "One rule per line, for example allow 10.0.0.0/8. Profile and per-app ACL are both enforced.", + "dockerProxyTimeouts": "Proxy timeouts", + "dockerProxyTimeoutsHelp": "Explicit per-app limits: connect 10 s, response headers 120 s, upstream read 60 s and request send 60 s by default.", + "dockerHostnameRequired": "Hostname is required before publishing.", + "dockerForceSsl": "Redirect HTTP to HTTPS", + "dockerForceSslUnavailable": "Requires an active plain HTTP listener and one unambiguous TLS listener on this agent.", + "dockerWebsockets": "WebSockets", + "dockerCache": "Cache", + "vhProxyTimeouts": "Proxy timeouts", + "vhProxyConnectTimeout": "Proxy connect timeout (s)", + "vhProxyHeaderTimeout": "Proxy response header timeout (s)", + "vhProxyReadTimeout": "Proxy read timeout (s)", + "vhProxySendTimeout": "Proxy send timeout (s)", + "vhProxyTimeoutsHelp": "Defaults: connect 10 s, response headers 120 s, read 60 s and send 60 s. Read/send are idle-gap timeouts for streaming bodies.", + "notfoundModeBuiltin": "Application-style status page", + "pageBuiltin": "Built-in status page", + "vhOpenBrowser": "Open", + "securityPasswordHint": "Requires the current password and signs out all sessions.", + "securityAccessControl": "ACCESS CONTROL", + "securityPoliciesHint": "Keep the policy list visible; open the editor only when adding or changing a rule set.", + "securityPolicyEditorHint": "Hostname scope, default action and CIDR rules.", + "securityAuditOpen": "Administration audit", + "securityAuditHint": "Recent administration changes are loaded only when this window is opened.", + "dockerConfigureService": "Configure vhost", + "dockerAliasesShort": "aliases", + "dockerAgentDetails": "Agent details", + "dockerServices": "Services", + "dockerOpenApp": "Open app", + "networkInterfacesHistory": "Traffic by interface", + "networkInterfaceRate": "RX / TX", + "networkNoInterfaces": "No non-loopback network interfaces are available.", + "networkInterfacesCount": "{count} interfaces", + "logExpandBuffer": "Increase buffer", + "logExpandedBuffer": "Buffer 4096", + "logBufferExpanded": "Live buffer increased to {count} retained entries.", + "vhIdRenameHelp": "Changing the vhost ID starts a new metrics/log/cache identity and resets its live health/traffic history. Hostnames and configured backend IDs are unchanged.", + "vhIdRenameConfirm": "Change vhost ID from {old} to {new}? Live health/traffic history and metric/cache identity for this vhost will restart.", + "cacheDomainFilter": "Show", + "cacheDomainFilterAll": "All domains", + "cacheDomainFilterEnabled": "Cache enabled", + "cacheDomainFilterDisabled": "Cache disabled", + "cacheNoDomainsFiltered": "No domains match this filter.", + "cacheColdCleanup": "Clean cold cache", + "cacheColdCleanupHelp": "Runs asynchronously and uses the same eviction mechanism as automatic cold-cache GC.", + "cacheColdUnusedSeconds": "Unused for at least (seconds, optional)", + "cacheColdOldestCount": "Remove up to oldest objects", + "cacheColdHitsZero": "Only objects with hits = 0", + "cacheColdCleanupStart": "Start cleanup", + "cacheColdCleanupRunning": "Cold-cache cleanup is running…", + "cacheColdCleanupDone": "Removed {count} objects and freed {bytes}.", + "cacheColdCleanupFailed": "Cold-cache cleanup failed.", + "cacheColdCleanupInvalidJob": "Cold-cache cleanup did not return a job identifier.", + "cacheColdGcInterval": "Cold-cache GC interval (seconds)", + "cacheColdGcMaxDelete": "Maximum deletes per GC pass", + "transformSettings": "Static response transformation", + "transformEnable": "Enable transformation engine globally", + "transformHelp": "Transformation happens after upstream decoding and before cache storage and client compression. Each vhost must opt in separately.", + "transformJavascript": "JavaScript minification", + "transformCss": "CSS minification", + "transformHtml": "HTML minification", + "transformSvg": "SVG optimization", + "transformJson": "JSON compaction", + "transformExtensions": "Allowed file extensions, one per line", + "transformMaxBody": "Maximum transformable response (KiB)", + "transformSafety": "Cache-Control: no-transform is always respected. Transformed representations receive a new ETag and are cached before gzip, Brotli or zstd encoding.", + "saveTransform": "Apply transformation policy", + "vhTransform": "Transform", + "vhTransformEnable": "Enable transformations for this vhost", + "vhTransformExtensionsCustom": "Override global transform extensions", + "vhTransformHelp": "The global engine must also be enabled. Format selectors inherit the global policy unless explicitly enabled or disabled here.", + "dockerTransform": "Transform static responses", + "vhPassive5xxWarning": "Counting HTTP 5xx can quarantine the whole backend after repeated application errors from one endpoint.", + "vhPassiveSlowWarning": "Counting slow responses can quarantine the backend after repeated responses that are correct but exceed the slow-response threshold.", + "transform": "Transform", + "transformTitle": "Static response transformation.", + "transformDescription": "Runtime transformation effectiveness, savings, skips and cache reuse.", + "transformApplied": "Transformed responses", + "transformExamined": "Examined", + "transformCacheHits": "Transformed cache HITs", + "transformCacheHitsHelp": "Served without running the transformer again", + "transformSaved": "Bytes saved before compression", + "transformReduction": "Average reduction", + "transformReductionHelp": "For responses that changed representation", + "transformSkipped": "Skipped / fail-open", + "transformErrors": "Body / transport errors", + "transformAvgTime": "Avg time", + "transformByType": "Transformations by type", + "transformType": "Type", + "transformSkipReasons": "Skip / fail-open reasons", + "transformNoSkips": "No skipped transformations in this scope.", + "transformNoSkipsShort": "No skips", + "transformVhosts": "Vhost effectiveness", + "transformScopeHint": "Counters are in-memory runtime diagnostics. A transformed cache HIT does not execute the transformer again.", + "transformNoData": "No transform activity yet.", + "transformBadge": "transformed", + "cacheLiveChartTitle": "Live cache activity", + "cacheLiveChartHelp": "HIT / MISS / BYPASS per second for the selected scope", + "liveChartSamples": "Samples", + "transformLiveChartTitle": "Live transformation activity", + "transformLiveChartHelp": "Applied / cache HIT / skipped / errors per second for the selected scope", + "transformChartApplied": "Applied", + "transformChartCacheHits": "Cache HIT", + "transformChartSkipped": "Skipped", + "cacheInvalidationTitle": "Cache purge / ban", + "cacheInvalidationOperation": "Operation", + "cacheInvalidationSelector": "Selector", + "cacheInvalidationValue": "Value", + "cacheInvalidationHost": "Host (optional)", + "cacheBanTtl": "BAN TTL (seconds)", + "cacheInvalidationHelp": "The selected vhost limits the operation. Exact and prefix values include the query string. Regex and root-prefix / operations require an additional confirmation.", + "cacheRunInvalidation": "Run operation", + "cacheActiveBans": "Active BAN rules", + "cacheBanExpires": "Expires", + "cacheNoBans": "No active BAN rules.", + "cacheTarget": "Target", + "cacheKeyTags": "Cache key / tags", + "cachePurgeUrl": "Purge URL", + "cacheRemoveBan": "Remove BAN", + "cacheBanCreated": "BAN created", + "cacheConfirmPurgeUrl": "Purge this cached URL from RAM and cold storage?", + "cacheConfirmRemoveBan": "Remove this BAN rule?", + "cacheWideInvalidationConfirm": "This broad invalidation may affect many cached objects. Continue?", + "cacheCollapseWaiters": "waiting", + "cacheCollapsed": "collapsed", + "actions": "Actions", + "cacheCollapseTitle": "Request collapsing", + "cacheCollapseEnable": "Collapse concurrent fills for one cache key", + "cacheCollapseTimeout": "Follower wait timeout (ms)", + "cacheCollapseFallback": "Timeout fallback", + "cacheCollapseFallbackOrigin": "Origin", + "cacheCollapseFallbackStale": "Stale if available", + "cacheStaleErrorTitle": "Stale-if-error policy", + "cacheSieTimeout": "Timeout", + "cacheSieConnection": "Connection failure", + "cacheSieDns": "DNS failure", + "cacheSieTls": "TLS failure", + "cacheSieStatuses": "HTTP 5xx statuses eligible for stale fallback", + "cacheProactiveTitle": "Proactive refresh", + "cacheProactiveEnable": "Refresh sampled cached objects before expiry", + "cacheProactiveBefore": "Refresh before expiry (seconds)", + "cacheProactivePercent": "Sampled objects (%)", + "cacheProactiveInflight": "Maximum concurrent refreshes", + "cacheProactiveHelp": "Uses validator revalidation, deterministic sampling and a global concurrency cap to avoid refresh storms.", + "cacheInvalidationOpen": "Purge / Ban", + "cacheBanListOpen": "Active BANs", + "cacheBanRemoved": "BAN rule removed", + "tunnelsOpen": "Active tunnels", + "tunnelsTitle": "Active WebSocket / tunnels", + "tunnelsReload": "Reload", + "tunnelsEmpty": "No active tunnels.", + "tunnelId": "ID", + "tunnelClient": "Client", + "tunnelHost": "Host", + "tunnelUpstream": "Upstream", + "tunnelProtocol": "Protocol", + "tunnelDuration": "Duration", + "tunnelRx": "RX", + "tunnelTx": "TX", + "tunnelKill": "Kill", + "tunnelKilled": "Tunnel closed by administrator", + "tunnelKillConfirm": "Close this tunnel?", + "tunnelKillReason": "Close reason", + "tunnelKillReasonPlaceholder": "Administrative close", + "tunnelKillReasonDefault": "admin panel", + "listenerH2c": "h2c cleartext HTTP/2 (prior knowledge + HTTP/1.1 Upgrade)", + "vhWebsocketRfc8441": "RFC 8441 Extended CONNECT over HTTP/2", + "wsH1": "H1 Upgrade", + "wsH2Rfc8441": "H2 RFC 8441", + "wsH3Rfc9220": "H3 RFC 9220", + "wsEstablished": "Established", + "httpProtocolMix": "HTTP protocol mix", + "wsProtocolMix": "WebSocket protocol mix", + "vhWebsocketMode": "WebSocket transport", + "vhWebsocketDisabled": "Disabled", + "vhWebsocketH1": "HTTP/1.1 Upgrade", + "vhWebsocketH2Fallback": "HTTP/2 RFC 8441 + H1 fallback", + "vhWebsocketH3Fallback": "HTTP/3 RFC 9220 + H1 fallback", + "vhWebsocketH2H3Fallback": "HTTP/2 + HTTP/3 + H1 fallback", + "vhMaxConcurrentRequests": "Max concurrent requests", + "vhBackendQueueTimeout": "Capacity queue timeout (ms)", + "vhBackendCapacityHelp": "0 concurrent requests means unlimited. A queue timeout of 0 rejects immediately with HTTP 503 when the backend is at capacity.", + "vhLastFailure": "Last failure", + "vhLastRecovery": "Last recovery", + "certificateIssuerName": "Certificate issuer", + "certificateValidFrom": "Valid from", + "certificateDaysRemaining": "Days remaining", + "certificateExpired": "Certificate expired", + "certificateExpiresSoon": "Certificate expires soon", + "certificateRenewalStatus": "Renewal status", + "certificateRenewalError": "Last renewal error", + "vhActiveHealth": "Active health", + "vhPassiveFailures": "Passive failures", + "certificateAdminRestartRequired": "Administration TLS uses a startup-fixed listener; restart RustProxy to refresh that certificate.", + "cacheTabGeneral": "General", + "cacheTabResilience": "Resilience", + "cacheTabCold": "Cold cache", + "cacheTabDiagnostics": "Static & diagnostics", + "certificateApiTitle": "Certificate API", + "certificateApiHelp": "Create separate Key ID + Bearer Token credentials for certificate deploy/reload automation. Tokens cannot read configuration or use other administration APIs.", + "certificateApiKeyId": "Key ID", + "certificateApiToken": "Bearer token (optional custom value)", + "certificateApiTokenHelp": "Leave the field empty to generate a strong token. Each cleartext token is shown only once; RustProxy stores only its hash.", + "certificateApiRotate": "Generate / rotate token", + "certificateApiRevoke": "Revoke token", + "certificateApiOneTimeToken": "Copy this token now", + "certificateApiCurlHelp": "External automation sends the Key ID header and Bearer token to the certificate deploy/reload endpoints over HTTPS (or loopback HTTP).", + "certificateApiUnknown": "Loading certificate API status…", + "certificateApiConfigured": "Certificate API enabled", + "certificateApiDisabled": "Certificate API disabled", + "certificateApiLastUsed": "last used", + "certificateApiRotated": "Certificate API token created / rotated. Copy the token now.", + "certificateApiRevoked": "Certificate API token revoked.", + "certificateApiRevokeConfirm": "Revoke the external certificate API token? Certbot/API deployments using it will stop immediately.", + "certificate_api.invalid_token": "Invalid certificate API Key ID or Bearer Token.", + "certificate_api.https_required": "External certificate API tokens require HTTPS unless the request comes from loopback.", + "certificate_api.invalid_token_format": "Certificate API tokens must be 32–128 URL-safe characters.", + "certificate_api.invalid_key_id": "Invalid certificate API Key ID.", + "vhWebsocketLegacySupport": "Enable WebSocket legacy support", + "vhWebsocketLegacySupportHelp": "For older appliances and embedded servers. WebSocket handshakes use HTTP/1.1 upstream, Host and Origin are aligned to the configured backend authority, and legacy 101 response framing is tolerated. Ordinary HTTP traffic is unchanged.", + "vhWebsocketLegacyBadge": "WebSocket legacy", + "runtimeInformation": "Runtime information", + "runtimeInformationHelp": "Reported by the running RustProxy process and available only from the authenticated administrator status API.", + "runtimePlatform": "Platform", + "statisticsSource": "Statistics source", + "dataDirectory": "Data directory", + "databaseLocation": "Database", + "connectionActivity": "CONNECTION ACTIVITY", + "reloadCertificate": "Reload certificate", + "certificateApiComment": "Comment", + "certificateApiConfiguredCount": "Certificate API tokens: {count} / {max}", + "certificateApiNeverUsed": "never used", + "certificateApiRevokeAll": "Revoke all tokens", + "certificateApiRevokeOneConfirm": "Revoke Certificate API token {key}? Any automation using it will stop immediately.", + "certificateApiRevokeAllConfirm": "Revoke every Certificate API token? All external certificate automation using these tokens will stop immediately.", + "certificate_api.invalid_comment": "Certificate API comments must be at most 256 characters and contain no control characters.", + "certificate_api.limit_reached": "The Certificate API token limit has been reached.", + "certificate_api.not_found": "Certificate API token not found.", + "networkInterfacesVisibleCount": "{visible} / {total} interfaces", + "hideMetric": "Hide", + "showMetric": "Show", + "hiddenInterfaces": "Hidden interfaces", + "networkNoVisibleInterfaces": "All detected interfaces are hidden in this browser.", + "cacheMemorySizingTitle": "Memory sizing", + "cacheMemorySizingHelp": "Presets use effective memory available to RustProxy (host RAM capped by cgroup when present). They tune HOT, transient RAM, object size, entry count and the demotion queue; the COLD disk budget is left unchanged.", + "cachePresetConservative": "Conservative", + "cachePresetRecommended": "Recommended", + "cachePresetPerformance": "Performance", + "cacheTransientLimit": "Transient RAM budget (MiB)", + "cacheEvictionLimit": "LRU removals per admission", + "cacheColdQueueLimit": "COLD queue RAM budget (MiB)", + "cacheMemoryNotDetected": "Memory not detected", + "cacheMemoryPresetUnavailable": "Automatic presets require host or cgroup memory telemetry.", + "cacheMemoryCgroup": "Effective cgroup RAM", + "cacheMemoryDetected": "Detected RAM", + "cacheQueueOverTransientWarning": "COLD queue budget cannot exceed the transient RAM budget.", + "cacheMemoryHighWarning": "Warning: HOT + transient cache budgets are a large share of effective RAM:", + "cachePresetTransientShort": "transient", + "cachePresetObjectShort": "max object", + "cachePresetQueueShort": "COLD queue", + "cacheLogicalShort": "logical", + "cacheLimitShort": "limit", + "cacheMemoryPressureShort": "memory pressure", + "cacheNukeLimitShort": "LRU limit", + "cacheMemoryPresetTooSmall": "Automatic cache presets are disabled below 256 MiB of effective RAM; configure the cache manually if this is intentional.", + "cacheMemoryRecommendedFor": "Recommended for detected memory:", + "cacheMemoryHeadroomWarning": "Warning: the additional cache budget is large compared with currently available host/cgroup memory:", + "cacheObjectOverTransientWarning": "Maximum object exceeds the transient RAM budget; objects near that size will pass through instead of being cached.", + "vhCacheGlobalDisabled": "Global cache is disabled. This vhost cannot enable cache until the global cache is enabled." + } +} diff --git a/langs/pl_pl.json b/langs/pl_pl.json new file mode 100644 index 0000000..ab2a78e --- /dev/null +++ b/langs/pl_pl.json @@ -0,0 +1,1677 @@ +{ + "meta": { + "locale": "pl-PL", + "name": "Polish", + "native_name": "Polski", + "direction": "ltr" + }, + "translations": { + "overview": "Przegląd", + "routing": "Starsze trasy", + "configuration": "Konfiguracja", + "manualConfig": "Konfiguracja ręczna", + "runtime": "ŚRODOWISKO", + "localOnly": "ADMINISTRACJA", + "logout": "Wyloguj", + "firstSlice": "NIEZALEŻNY RDZEŃ / JAWNY DOSTĘP", + "loginTitle": "Administracja", + "unlock": "Odblokuj administrację", + "tokenHelp": "Uruchom polecenie w drugim terminalu i wklej token poniżej.", + "token": "Lokalny token dostępu", + "connect": "Połącz z proxy", + "tokenNote": "Administracja domyślnie używa HTTP na loopback. Przed użyciem adresu spoza loopback włącz HTTPS panelu.", + "requests": "Żądania", + "activeExchanges": "Aktywne żądania", + "bodyLifetime": "Do zakończenia strumienia", + "bandwidth": "Przekazane dane", + "bothDirections": "W obu kierunkach", + "errors": "Błędy", + "errorDescription": "5xx / body / przerwane", + "liveTraffic": "RUCH NA ŻYWO", + "liveTransformed": "Transformowane", + "requestsPerSecond": "Żądania na sekundę", + "live": "NA ŻYWO", + "lastMinute": "Ostatnie próbki / reset po odświeżeniu", + "coreState": "STAN RDZENIA", + "configurationRevision": "Aktywna rewizja", + "backends": "Backendy", + "routes": "Trasy", + "connections": "Otwarte połączenia", + "uptime": "Czas działania", + "activity": "AKTYWNOŚĆ", + "accessLog": "Log dostępu", + "boundedLog": "Ostatnie 50 / bufor 4096", + "time": "Czas", + "request": "Żądanie", + "status": "Status", + "duration": "Czas obsługi", + "outcome": "Wynik", + "noTraffic": "Brak ruchu. Otwórz proxy, aby wysłać pierwsze żądanie.", + "reload": "Wczytaj konfigurację", + "addBackend": "Dodaj / edytuj backend", + "backendUrl": "Adres backendu (HTTP / HTTPS, IP / DNS)", + "allowPrivate": "Jawnie zezwól na prywatny IP / loopback", + "preserveHost": "Zachowaj oryginalny nagłówek Host", + "saveBackend": "Zapisz backend", + "clear": "Wyczyść", + "addRoute": "Dodaj / edytuj trasę", + "hostnames": "Hostname i aliasy, oddzielone przecinkami", + "pathPrefix": "Prefiks ścieżki (bez usuwania)", + "backend": "Backend", + "enabled": "Włączona", + "saveRoute": "Zapisz trasę", + "rawConfig": "Dokument konfiguracji", + "export": "Eksportuj JSON", + "configHelp": "Poprawny zapis jest stosowany atomowo. Błędne lub nieaktualne zmiany nie zastępują działającej konfiguracji.", + "apply": "Sprawdź, zapisz i zastosuj", + "noRestart": "Routing i polityki odpowiedzi działają od razu; zmiana portów wymaga restartu.", + "roadmapHelp": "Zmiany w wydaniach i planowane funkcje opisuje plik ROADMAP.md.", + "overviewTitle": "Ruch pod kontrolą.", + "overviewDescription": "Ruch, cache i pomiary systemu operacyjnego w jednym zwartym panelu.", + "routingTitle": "Połącz właściwe adresy.", + "routingDescription": "Dotychczasowy format backendów i tras pozostaje obsługiwany. Nowe konfiguracje mogą używać vhosta z własnym frontendem i pulą.", + "configurationTitle": "Jedno źródło prawdy.", + "configurationDescription": "Jedna konfiguracja: vhosty, listenery i wspolne polityki.", + "connected": "POŁĄCZONO Z LOKALNYM RDZENIEM", + "disconnected": "RDZEŃ NIEDOSTĘPNY", + "edit": "Edytuj", + "remove": "Usuń", + "privateAllowed": "ZGODA NA PRYWATNY IP", + "configured": "SKONFIGUROWANY / BEZ PROBE", + "disabled": "WYŁĄCZONA", + "emptyBackends": "Brak backendów. Dodaj jawny adres HTTP lub HTTPS.", + "emptyRoutes": "Brak tras. Nieznane hostname zwracają 404.", + "saving": "Zapisywanie…", + "unsavedChanges": "Niezapisane zmiany", + "saved": "Zastosowano konfigurację.", + "confirmDelete": "Usunąć ten element? Zmiana zadziała natychmiast.", + "confirmTitle": "Potwierdź operację", + "confirmationLabel": "POTWIERDZENIE", + "warningLabel": "OSTRZEŻENIE", + "continueAction": "Kontynuuj", + "cancelAction": "Anuluj", + "discardAction": "Odrzuć zmiany", + "errorContext": "{area}: {message}", + "stale": "Konfiguracja zmieniła się w innym miejscu. Wczytaj ją przed zapisem.", + "busy": "Trwa już zapis.", + "loginError": "Nie udało się zalogować.", + "fresh": "Wczytano konfigurację.", + "locked": "Wylogowano i unieważniono sesję.", + "backendUsed": "Backend jest używany przez trasę. Najpierw usuń lub zmień trasę.", + "renaming": "Nie można zmienić ID w formularzu. Użyj edytora JSON i zmień również powiązania.", + "complete": "zakończono", + "cancelled": "przerwano", + "body_error": "błąd body", + "proxy_or_upstream_error": "błąd proxy/backendu", + "security": "Bezpieczeństwo", + "sessionNote": "Lokalna administracja HTTP. Hasła używają Argon2id, a cookies sesji są HttpOnly. Nie udostępniaj panelu publicznie.", + "setupHelp": "Utwórz pierwsze konto administratora. Jednorazowy token odczytasz lokalnie w terminalu.", + "setupToken": "Jednorazowy token instalacyjny", + "username": "Nazwa użytkownika", + "password": "Hasło", + "confirmPassword": "Powtórz hasło", + "setupTitle": "Utwórz administratora", + "signIn": "Zaloguj się", + "createAndSignIn": "Utwórz konto i zaloguj", + "securityTitle": "Dostęp pod kontrolą.", + "securityDescription": "Jeden administrator. Odwoływalne sesje. Jawne polityki hostów i tras.", + "manualBackends": "RĘCZNE BACKENDY", + "routePolicies": "Kontrola dostępu i nagłówki", + "useRouteAcl": "Włącz ACL trasy", + "aclDefault": "Domyślna akcja", + "allow": "Zezwól", + "deny": "Zablokuj", + "aclRules": "Jedna reguła w wierszu: allow/deny oraz CIDR", + "aclHelp": "Podaj CIDR IPv4/IPv6, np. 192.168.1.0/24 lub 10.0.0.5/32. Odmowa ma pierwszeństwo. Dozwolone IP nadal wymaga uwierzytelnienia. Lokalizacja nie omija ACL hosta.", + "routeHeaders": "Nagłówki request / response (JSON)", + "headersHelp": "Operacje: ustaw, dodaj, usuń. Nagłówki transportu, kodowania body i tożsamości klienta są chronione.", + "currentSession": "BIEŻĄCA SESJA", + "recoveryHelp": "Utrata dostępu? Uruchom lokalnie rust-proxy admin reset-password. Dotychczasowe sesje zostaną unieważnione.", + "revokeAll": "Wyloguj wszystkie sesje", + "changePassword": "Zmień hasło", + "currentPassword": "Obecne hasło", + "newPassword": "Nowe hasło (12-128 bajtów UTF-8)", + "savePassword": "Zmień i wyloguj", + "hostPolicies": "Polityki dostępu hostów", + "editHostPolicy": "Dodaj / zastąp politykę hosta", + "savePolicy": "Zastosuj politykę", + "auditRetention": "SQLITE / OSTATNIE 100 / LIMIT 10 000", + "audit": "Audyt administracyjny", + "reloadAudit": "Odśwież", + "actor": "Użytkownik", + "action": "Operacja", + "detail": "Szczegóły", + "noAudit": "Brak wpisów audytu.", + "noPolicies": "Brak ACL hostów. Dostęp mogą ograniczać reguły tras.", + "expires": "Sesja wygaśnie najpóźniej: ", + "passwordChanged": "Zmieniono hasło i unieważniono sesje. Zaloguj się ponownie.", + "passwordMismatch": "Hasła nie są takie same.", + "ruleSyntax": "Wpisz jedną regułę w wierszu, np. allow 127.0.0.1/32", + "confirmRevoke": "Wylogować wszystkie sesje, również bieżącą?", + "streamLive": "SSE na żywo", + "streamFallback": "Odpytywanie awaryjne", + "auth.required": "Sesja wygasla lub zostala odwolana. Zaloguj sie ponownie.", + "auth.invalid_credentials": "Nieprawidłowa nazwa użytkownika lub hasło.", + "auth.password_policy": "Haslo musi miec 12-128 bajtow UTF-8, bez znakow nowego wiersza.", + "auth.invalid_username": "Uzyj 3-64 liter, cyfr, kropek, myslnikow lub podkreslen.", + "auth.invalid_setup_token": "Niepoprawny token instalacyjny. Odczytaj go w lokalnym terminalu.", + "auth.already_initialized": "Administrator juz istnieje. Zaloguj sie.", + "auth.rate_limited": "Zbyt wiele prob. Ponow po 60 sekundach lub uzyj lokalnego resetu hasla.", + "auth.busy": "Trwa weryfikacja innych hasel. Ponow za chwile.", + "auth.csrf": "Niepoprawna ochrona zadania. Odswiez strone i ponow.", + "auth.invalid_host": "Użyj skonfigurowanego adresu administracji i dozwolonej nazwy hosta.", + "auth.invalid_origin": "Zablokowano dostep administracyjny z innego originu.", + "auth.credentials_changed": "Dane logowania zostaly zmienione. Zaloguj sie ponownie.", + "auth.unavailable": "Baza administracyjna jest chwilowo niedostepna.", + "events.too_many_viewers": "Osiagnieto limit odbiorcow SSE; pozostaje odpytywanie.", + "protocol": "Protokół upstream", + "protocolHelp": "HTTP/2 i auto używają authority backendu. Preserve Host jest dostępne tylko z HTTP/1.1. RFC 8441 może użyć jawnego upstreamu HTTP/2; pozostałe transporty WebSocket są mostkowane przez HTTP/1.1.", + "addressAllowlist": "Opcjonalna lista dozwolonych adresów (CIDR, po jednym w wierszu)", + "caPem": "Dodatkowy zaufany CA / certyfikat (PEM, opcjonalnie)", + "backendTlsHelp": "Szyfrowanie TLS pozostaje dostępne, ale weryfikacja certyfikatu i nazwy jest domyślnie wyłączona, dopóki nie włączysz jej jawnie. Prywatne adresy nadal wymagają zgody, także po rozwiązaniu DNS.", + "advancedTransport": "Transport i zaufanie", + "tls": "Certyfikaty", + "tlsTitle": "Szyfrowanie pod kontrolą.", + "tlsDescription": "Dokładne nazwy SNI. Prywatne pliki certyfikatów. Zmiany bez restartu.", + "certificateBindings": "Powiązania certyfikatów", + "addCertificate": "Dodaj certyfikat", + "certificateId": "ID certyfikatu", + "certificateFile": "Nazwa pliku certyfikatu", + "privateKeyFile": "Nazwa pliku klucza prywatnego", + "defaultCertificate": "Użyj jako certyfikatu domyślnego", + "saveCertificate": "Zastosuj certyfikat", + "reloadCertificates": "Wczytaj pliki certyfikatów", + "certificateHelp": "Wgraj parę PEM, wskaż pliki w data/certs albo wdrażaj certyfikat z zewnętrznego wystawcy poleceniem `./dev.sh tls deploy --id ID --cert FULLCHAIN --key PRIVKEY`. Wczytanie ponownie odczytuje skonfigurowane pliki bez zmiany revision.", + "certificateHint": "Hook Certbota: ./dev.sh tls deploy --id shared-cert --cert /etc/letsencrypt/live/example/fullchain.pem --key /etc/letsencrypt/live/example/privkey.pem", + "emptyCertificates": "Brak jawnych powiązań SNI. Aktywny listener TLS używa wygenerowanego certyfikatu domyślnego.", + "certificateSafety": "Certyfikaty samopodpisane służą do testów. Certyfikat nie tworzy trasy. Zmiana dotyczy nowych połączeń TLS.", + "wsActive": "Tunele WebSocket", + "h2Requests": "Żądania HTTP/2", + "h2ActiveConnections": "Aktywne połączenia H2", + "tlsFailures": "Błędy handshake TLS", + "tlsListener": "Listener TLS", + "notEnabled": "Wyłączony", + "websocket_closed": "WebSocket zamknięty", + "defaultLabel": "DOMYŚLNY", + "language": "Język", + "settings": "Ustawienia", + "settingsTitle": "Ustawienia proxy.", + "settingsDescription": "Przelaczaj karty funkcji. Niezapisane zmiany pozostaja w formularzach.", + "cacheHitRatio": "Trafienia cache", + "processMemory": "Pamięć procesu", + "systemLoad": "OBCIĄŻENIE SYSTEMU", + "systemChart": "Ostatnie próbki obciążenia CPU", + "systemMemory": "RAM systemu", + "systemSwap": "Swap systemu", + "systemUptime": "Czas działania systemu", + "loadAverage": "Load 1 / 5 / 15 min", + "cacheOverview": "Cache RAM", + "cacheObjects": "Obiekty / pamięć", + "cacheEvictions": "Usunięte / wygasłe", + "processDiagnostics": "Proces", + "platform": "Platforma", + "processCpu": "CPU (100% = 1 rdzeń)", + "fileDescriptors": "Deskryptory / limit", + "processThreads": "Wątki systemowe", + "processVirtualMemory": "Pamięć wirtualna / limit", + "processStartTime": "Start procesu", + "containerMemory": "RAM cgroup / limit", + "networkDiagnostics": "Sieć i odpowiedzi", + "hostScope": "Zakres systemu", + "networkRate": "RX / TX na sekundę", + "meanLatency": "Średni czas obsługi", + "filterLogs": "Filtruj host lub ścieżkę", + "filterResult": "Filtruj wynik", + "allResults": "Wszystkie", + "routeCache": "Cache dla trasy", + "inheritCache": "Dziedzicz ustawienia globalne", + "bypassCache": "Zawsze pomijaj cache", + "listeners": "Listenery", + "httpConnections": "Połączenia HTTP", + "httpConnectionsHelp": "Cykl życia połączeń i limity transportu dla HTTP/2 oraz HTTP/3.", + "http2Tab": "HTTP/2", + "http3Tab": "HTTP/3", + "http2Settings": "Połączenia HTTP/2", + "http2SettingsHelp": "HTTP/2 włącza się osobno dla każdego listenera. Tutaj ustawisz bezczynność, maksymalny czas życia i graceful drain po GOAWAY.", + "http2IdleTimeout": "Idle timeout (sekundy)", + "http2MaxLifetime": "Maksymalny czas życia połączenia (sekundy, 0 = wyłączony)", + "http2DrainTimeout": "Drain timeout (sekundy)", + "http2DrainHelp": "Te limity dotyczą tego listenera. Idle timeout obejmuje tylko nieużywane połączenia, maksymalny lifetime jest bezwzględny, a drain ogranicza czas aktywnych strumieni po GOAWAY. Zmiany wymagają restartu.", + "http2Save": "Zapisz ustawienia HTTP/2", + "http2NoEnabledListeners": "Żaden listener nie ma włączonego HTTP/2. Najpierw włącz HTTP/2 lub h2c w Ustawienia > Listenery.", + "http2ListenersDirty": "Najpierw zapisz oczekujące zmiany w Listenerach, a następnie edytuj ustawienia HTTP/2.", + "restartLabel": "RESTART", + "listenersHelp": "Nazwane adresy IP i porty. Zmiany wymagają restartu. Panel administracyjny pozostaje osobno.", + "httpAddress": "Adres nasłuchu i port HTTP", + "httpsAddress": "Adres nasłuchu i port HTTPS", + "enableHttps": "Włącz nasłuch HTTPS", + "lowPortsHelp": "Porty uprzywilejowane mogą wymagać CAP_NET_BIND_SERVICE. Nie uruchamiaj całej aplikacji jako root.", + "saveListeners": "Zapisz ustawienia portów", + "inheritListeners": "Użyj ustawień startowych", + "responseSettings": "Odpowiedź do klienta", + "hotApply": "BEZ RESTARTU", + "serverHeader": "Nagłówek Server", + "serverPreserve": "Zachowaj wartość backendu", + "serverHide": "Ukryj całkowicie", + "serverCustom": "Nadpisz / dodaj własną wartość", + "serverValue": "Własna wartość Server", + "compression": "Kompresja strumieniowa", + "compressionMin": "Minimalny rozmiar odpowiedzi (bajty)", + "compressionHelp": "Negocjacja Accept-Encoding. Pomija dane prywatne, cookies, zakresy, SSE i no-transform. Nie kompresuje ponownie zakodowanych odpowiedzi.", + "saveResponse": "Zastosuj ustawienia odpowiedzi", + "cacheSettings": "Cache", + "enableCache": "Włącz cache", + "cacheSafety": "Cache zapisuje kompletne, bezpieczne odpowiedzi GET. Authorization, cookies oraz odpowiedzi private/no-store nadal omijają cache. Pojedynczy zakres bajtów może być obsłużony z pełnego obiektu cache; multipart i nieobsługiwane zakresy przechodzą do originu.", + "cacheMemory": "Limit alokowanej pamięci HOT (MiB)", + "cacheObject": "Limit obiektu (KiB)", + "cacheEntries": "Maksymalna liczba wpisów", + "cacheMaxTtl": "Maksymalny TTL (sekundy)", + "cacheDefaultTtl": "Zapasowy TTL odpowiedzi publicznych (0 = wył.)", + "bypassQuery": "Pomijaj adresy z parametrami query", + "cachePrefixes": "Pomijane prefiksy ścieżek, po jednym w wierszu", + "cacheAgeHelp": "Trafienia zawsze zawierają Age. Liczniki działają również przy ukrytych nagłówkach diagnostycznych. Tylko zmiany wpływające na cache unieważniają niezgodne obiekty; podczas zarządzanej aktualizacji hot cache jest przenoszony do cold storage, jeśli cold tier jest włączony.", + "saveCache": "Zastosuj ustawienia cache", + "purgeCache": "Wyczyść cały cache", + "customHeaders": "Globalne reguły nagłówków", + "globalHeadersHelp": "Request trafia do backendu, response do klienta. Reguły trasy są stosowane po globalnych. Polityka Server ma ostateczne pierwszeństwo.", + "responseHeaders": "Odpowiedź do klienta", + "requestHeaders": "Żądanie do backendu", + "addHeader": "Dodaj regułę nagłówka", + "saveHeaders": "Zastosuj reguły nagłówków", + "unavailable": "Brak danych", + "threads": "wątków", + "realSample": "Pomiar", + "noKnownLimit": "nieznany / bez limitu", + "unlimited": "bez limitu", + "restartPending": "Zapisano zmianę portów. Uruchom ponownie RustProxy, aby ją zastosować.", + "activeListeners": "Aktualnie aktywne", + "headerLimit": "Maksymalnie 32 reguły w każdym kierunku.", + "headerOperation": "Operacja", + "header_set": "Ustaw", + "header_append": "Dodaj", + "header_remove": "Usuń", + "headerName": "Nazwa nagłówka", + "headerValue": "Wartość nagłówka", + "confirmInherit": "Przywrócić ustawienia portów z parametrów startowych i środowiska po restarcie?", + "confirmPurge": "Usunąć wszystkie obiekty cache i anulować zapis trwających pobrań?", + "cachePurged": "Cache wyczyszczony; usunięte wpisy", + "logsDropped": "odrzucone", + "updateAvailable": "Nowa wersja", + "vhosts": "Vhosty", + "vhostsTitle": "Vhosty i backendy", + "vhostsDescription": "Zarządzaj domenami, frontendami, backendami i politykami hostów.", + "vhHealthTitle": "Backendy", + "vhManage": "Zarządzaj vhostami", + "vhHealthHint": "Stan bieżący: aktywność obejmuje całą wymianę; transfer to średnia EWMA danych.", + "vhHealthEmpty": "Brak danych diagnostycznych backendów.", + "vhVhost": "Vhost", + "vhActive": "Aktywne", + "vhRequests": "Żądania", + "vhErrors": "Błędy", + "vhLatency": "Opóźnienie (EWMA)", + "vhTraffic": "Transfer/s", + "vhIntro": "Każdy vhost ma własne adresy IP, ścieżki i backendy. Inne adresy go nie obsługują. Zmiana nasłuchu wymaga restartu.", + "vhList": "Hosty wirtualne", + "vhSearch": "Szukaj vhostów", + "vhSearchPlaceholder": "Szukaj domeny, vhosta lub backendu", + "vhSearchEmpty": "Brak vhostów pasujących do wyszukiwania.", + "vhNew": "Nowy vhost", + "vhMigrate": "Migracja grupy starszych tras", + "vhImport": "Wczytaj do edytora", + "vhMigrationHint": "Migracja jest atomowa. Trasy tych samych hostów i backendu są grupowane; niejednoznaczne przypisanie hostów jest odrzucane.", + "vhEditor": "Konfiguracja vhosta", + "vhReset": "Odrzuć zmiany", + "vhSafetyHint": "Prywatny IP nie zastępuje uwierzytelniania. Zachowaj firewall i ACL. Preload HSTS jest opcjonalny; cookie affinity nie jest sesją logowania.", + "vhId": "Identyfikator vhosta", + "vhHosts": "Nazwy hostów", + "vhEnabled": "Włączony", + "vhFrontends": "Frontendy / adresy nasłuchu", + "vhAddFrontend": "Dodaj frontend", + "vhAddress": "IP:port (IPv6: [adres]:port)", + "vhTls": "TLS włączony", + "vhHttp2": "Preferuj HTTP/2 przez TLS; zachowaj HTTP/1.1", + "vhBackendPool": "Backendy", + "vhAddBackend": "Dodaj backend", + "vhMemberId": "Identyfikator backendu", + "vhUrl": "Adres URL backendu", + "vhWeight": "Waga", + "vhBackup": "Tylko zapasowy", + "vhDrain": "Wstrzymaj nowe sesje", + "vhPrivate": "Zezwól na prywatny IP backendu", + "vhPreserveHost": "Zachowaj Host klienta (tylko HTTP/1.1)", + "vhProtocol": "Protokół backendu", + "vhProtocolHttp1": "HTTP/1.1", + "vhProtocolAuto": "TLS ALPN: HTTP/2 + HTTP/1.1", + "vhProtocolHttp2": "HTTP/2 (TLS: także HTTP/1.1; bez TLS: h2c)", + "vhAddressAllowlist": "Dozwolone IP/CIDR backendu (opcjonalne)", + "vhTlsServerName": "Nazwa serwera TLS / SNI (opcjonalna)", + "vhTlsVerify": "Weryfikuj certyfikat i nazwę hosta backendu", + "vhTlsVerifyHelp": "Przy pominięciu ustawienia weryfikacja jest wyłączona. Włącz ją, gdy RustProxy ma weryfikować łańcuch i tożsamość certyfikatu backendu. Jeśli walidacja nie przechodzi, ustaw nazwę serwera TLS na nazwę DNS obecną w certyfikacie i/lub dodaj PEM prywatnego CA. Nie używaj adresu IP jako SNI.", + "vhCa": "Dodatkowy zaufany CA (PEM, opcjonalny)", + "vhBalancing": "Balansowanie i ponowne użycie połączeń", + "vhAlgorithm": "Algorytm", + "vhRoundRobin": "Płynny ważony round-robin", + "vhLeastConnections": "Najmniej aktywnych wymian, z wagami", + "vhLeastTraffic": "Najmniejszy transfer (EWMA), z wagami", + "vhAdaptive": "Adaptacyjny: aktywność + opóźnienie + transfer", + "vhIpHash": "Skrót IP klienta", + "vhSlowStart": "Łagodny powrót (sekundy)", + "vhRetryMax": "Ponowienia failover", + "vhRetryConnect": "Ponawiaj błędy połączenia", + "vhRetryTimeouts": "Ponawiaj timeout nagłówków odpowiedzi", + "vhRetryStatuses": "Ponawiane statusy HTTP", + "vhRetryHelp": "Retry działa tylko dla bezbody GET, HEAD i OPTIONS, wybiera inny zdrowy backend i nigdy nie ponawia WebSocketów ani żądań zapisu.", + "vhKeepalive": "Używaj ponownie połączeń do backendu (keepalive)", + "vhIdleTimeout": "Limit bezczynności w puli (sekundy)", + "vhMaxIdle": "Maks. bezczynnych połączeń / backend", + "vhTcpKeepalive": "Interwał keepalive TCP / HTTP/2 (sekundy)", + "vhHealth": "Zdrowie backendów", + "vhHealthMode": "Rodzaj testu", + "vhOff": "Wyłączone", + "vhTcp": "Połączenie TCP", + "vhHttp": "Status HTTP + czas odpowiedzi", + "vhHealthPath": "Ścieżka testu HTTP", + "vhHealthHost": "Własny Host testu HTTP (opcjonalny)", + "vhInterval": "Interwał (ms)", + "vhTimeout": "Limit czasu (ms)", + "vhSlow": "Próg wolnej odpowiedzi (ms; 0 = wył.)", + "vhFall": "Niepowodzenia przed wyłączeniem", + "vhRise": "Sukcesy przed przywróceniem", + "vhStatusMin": "Minimalny status HTTP", + "vhStatusMax": "Maksymalny status HTTP", + "vhHealthNote": "Testy HTTP używają HTTP/1.1 i mierzą czas do nagłówków, nie całej treści. Dla usług tylko h2c wybierz TCP. Nowe backendy czekają na udane testy. Aktywny health-check jest niezależny od pasywnego.", + "vhPassiveHealth": "Pasywne zdrowie backendu", + "vhPassiveEnabled": "Pozwól, aby błędy zwykłych requestów oznaczały backend jako niedostępny", + "vhPassiveHelp": "Domyślnie wyłączone. Błędy nadal są logowane i zliczane, ale zwykły ruch użytkowników nie zmienia stanu backendu bez świadomego włączenia.", + "vhPassiveConnect": "Zliczaj błędy połączenia", + "vhPassiveTimeouts": "Zliczaj timeouty", + "vhPassiveIo": "Zliczaj błędy I/O body/odczytu/zapisu", + "vhPassive5xx": "Zliczaj odpowiedzi HTTP 5xx", + "vhPassiveSlow": "Zliczaj wolne odpowiedzi", + "vhPassiveFall": "Pasywne błędy przed wyłączeniem (puste = active fall)", + "vhPassiveRise": "Pasywne sukcesy przed przywróceniem (puste = active rise)", + "vhPassiveWarning": "Włączenie 5xx lub wolnych odpowiedzi może wyłączyć cały backend po serii błędów z pojedynczego endpointu.", + "vhSticky": "Sticky sessions", + "vhStickyMode": "Tryb przypisania", + "vhCookie": "Nieprzezroczyste cookie proxy", + "vhCookieName": "Nazwa cookie (pusta = domyślna per vhost)", + "vhCookieTtl": "Ważność cookie (sekundy)", + "vhStickyNote": "Sticky cookie omija współdzielony cache. Drain zachowuje istniejące sesje cookie. Przypisanie po IP używa zweryfikowanego adresu klienta zgodnie z polityką zaufanych proxy.", + "vhSecurity": "Bezpieczeństwo", + "vhHsts": "Włącz HSTS (tylko odpowiedzi TLS)", + "vhHstsAge": "HSTS max-age (sekundy)", + "vhHstsSub": "HSTS includeSubDomains", + "vhHstsPreload": "HSTS preload (świadome włączenie)", + "vhNosniff": "X-Content-Type-Options: nosniff", + "vhFrame": "X-Frame-Options", + "vhPreserve": "Zachowaj wartość backendu", + "vhDeny": "DENY", + "vhSameorigin": "SAMEORIGIN", + "vhReferrer": "Referrer-Policy (opcjonalne)", + "vhCsp": "Content-Security-Policy (opcjonalne)", + "vhPermissions": "Permissions-Policy (opcjonalne)", + "vhAdvanced": "Zaawansowane: ścieżki, ACL i własne nagłówki", + "vhPaths": "Sciezki (JSON; opcjonalne backend_id przypina sciezke do jednego backendu)", + "vhAcl": "ACL vhosta (JSON lub null)", + "vhHeaders": "Reguły nagłówków żądania / odpowiedzi (JSON)", + "vhEmpty": "Brak vhostów. Dotychczasowe trasy nadal działają.", + "vhUnsaved": "Odrzucić niezapisane zmiany vhosta?", + "vhDelete": "Usunąć vhost? Gniazda nasłuchu zostaną zamknięte po restarcie.", + "vhConflict": "Vhost o takim identyfikatorze już istnieje.", + "vhRowLimit": "Osiągnięto maksymalną liczbę wierszy.", + "vhMigrationAmbiguous": "Hosty pokrywają się z innymi trasami. Uporządkuj przypisanie w JSON przed migracją.", + "vhMigrationPending": "Migracja starszych tras zostanie zatwierdzona przy zapisie.", + "vhMigrationNone": "Wybierz starszą trasę", + "vhJsonInvalid": "Nieprawidłowy JSON lub typ danych w ścieżkach, ACL lub nagłówkach.", + "vhUp": "Działa", + "vhDown": "Niedostępny", + "vhChecking": "Sprawdzanie", + "vhDraining": "Wygaszanie", + "vhDisabled": "Wyłączony", + "vhHealthDisabled": "Testy wyłączone", + "vhHealthUnknown": "Nieznany", + "vhNewLabel": "NOWY", + "vhEditLabel": "EDYCJA", + "vhFrontendNote": "Użyj IP przypisanego do serwera. Wildcard przyjmuje wszystkie IP danej rodziny; routing nadal sprawdza lokalny adres docelowy. Certyfikaty ustaw w zakładce TLS.", + "close": "Zamknij", + "inherit": "Dziedzicz", + "liveLogs": "Logi na żywo", + "logVhost": "Filtruj vhost", + "logAllVhosts": "Wszystkie vhosty", + "logAllMethods": "Wszystkie metody", + "logPause": "Wstrzymaj", + "logPaused": "WSTRZYMANO", + "logResume": "Wznów", + "logPin": "Przypnij", + "logUndock": "Otwórz modalnie", + "logColumns": "Kolumny", + "logColumnsRequired": "Co najmniej jedna kolumna Live Logs musi pozostać widoczna.", + "logLoadHistory": "Zwiększ bufor", + "logHistoryLoaded": "Bufor live zwiększono do {count} zachowanych wpisów.", + "logHost": "Host", + "logBufferHint": "Widok na żywo zaczyna od ostatnich 256 zakończonych wymian. Zwiększenie bufora wczytuje zachowaną historię i dalej dopisuje logi na żywo do 4096 wpisów, aż do zamknięcia okna. Eksport obejmuje aktualnie przefiltrowane wiersze.", + "settingsSecurityHint": "Konta i ACL są globalne. SSL, HSTS, nagłówki i cache konfigurujesz także wewnątrz vhosta.", + "theme": "Motyw", + "themeDark": "Ciemny", + "themeLight": "Jasny", + "density": "Gęstość interfejsu", + "densityCompact": "Kompaktowy", + "densityDense": "Gęsty", + "densityComfortable": "Przestronny", + "globalPages": "Domyślne odpowiedzi", + "pagesHelp": "Strony dla adresu IP, nieznanych hostów i błędów. Pusty status zachowuje kod odpowiedzi. Strony statyczne nie odpytują backendu.", + "pageInherit": "Domyślna / dziedziczona", + "pageWelcome": "Strona gratulacyjna", + "pageEmpty": "Pusta odpowiedź", + "pageHtml": "Własny HTML", + "pageStatus": "Kod odpowiedzi", + "pageOriginalStatus": "Oryginalny status", + "pageHtmlSource": "Kod HTML", + "pageUploadHtml": "Opcjonalny plik HTML (maks. 32 KiB)", + "pageTooLarge": "HTML nie może przekraczać 32 KiB w UTF-8.", + "customErrorPages": "Własne strony błędów", + "pageStaticHost": "Host statyczny / przerwa techniczna", + "pageMissingPath": "Niepasująca ścieżka", + "pageDirectIp": "Wejście bezpośrednio po IP", + "pageUnknownHost": "Nieznany host", + "pagesSafetyHint": "HTML to treść publiczna. ACL nadal działa przed odpowiedzią. Jawne HTTP 200 dla błędu ukrywa kod przed klientami i monitoringiem. Cała konfiguracja ma limit rozmiaru.", + "vhDetailsTab": "Szczegóły", + "vhSsl": "Certyfikaty SSL", + "vhCertificate": "Wybrany certyfikat", + "vhCertificateDefault": "Globalne SNI / certyfikat domyślny", + "vhCertificateFile": "Certyfikat / pełny łańcuch (PEM)", + "vhPrivateKeyFile": "Klucz prywatny (PEM)", + "vhUploadCertificate": "Wgraj i zweryfikuj parę", + "vhSelectCertificateFiles": "Wybierz plik certyfikatu i klucza prywatnego.", + "vhCertificateTooLarge": "Każdy plik PEM ma limit 128 KiB.", + "vhCertificateNeedsHosts": "Najpierw wpisz domeny vhosta w Szczegółach.", + "vhCertificateStaged": "Para zweryfikowana i zapisana. Zapisz vhost, aby aktywować certyfikat.", + "vhCertificateUploadHint": "Upload sprawdza zgodność klucza i domen. Klucz trafia do prywatnego pliku, nie do API. Zapis vhosta aktywuje wybór. Upload nie zapisuje pozostałych zmian. Zmiany listenerów wymagają restartu.", + "vhCache": "Cache", + "vhCacheTtl": "Maks. TTL (sekundy, puste = dziedzicz)", + "vhCacheDefault": "Domyślny TTL public (puste = dziedzicz)", + "vhCacheObject": "Limit obiektu (KiB, puste = dziedzicz)", + "cacheEnabled": "Włącz cache", + "cacheBypassQuery": "Pomijaj adresy z parametrami", + "cacheStatusHeader": "Dodaj X-Cache", + "cacheHitsHeader": "Dodaj X-Cache-Hits", + "cacheRfcHeader": "Dodaj Cache-Status", + "cacheBypassPrefixes": "Pomijane prefiksy ścieżek", + "vhCacheCustomPrefixes": "Nadpisz odziedziczone pomijane ścieżki", + "vhCacheHint": "Polityka hosta może włączyć cache niezależnie od ustawienia globalnego. Vhosty współdzielą limit RAM i wpisów. Sticky cookie, dane prywatne i autoryzacja nadal omijają cache.", + "vhPurgeCache": "Wyczyść cache hosta", + "vhPurgeConfirm": "Wyczyścić cache tylko tego vhosta? Trwające zapisy tego hosta także zostaną unieważnione.", + "vhPages": "Strony błędów", + "vhHeadersOrder": "Kolejność: reguły globalne, vhost, ścieżka, następnie security/Server. Nadal dostępny jest zaawansowany JSON.", + "vhNewHealthHint": "Zapisz vhost, aby rozpocząć obserwację backendów.", + "listenerAdd": "Dodaj listener", + "listenerMigrate": "Zamień domyślne porty na nazwane listenery", + "listenerScopeHint": "Wewnętrzny / publiczny to etykieta, nie firewall. Nowe listenery nie udostępniają starych tras bez jawnego wyboru.", + "listenerLimit": "Maksymalnie 64 nazwane listenery.", + "listenerEndpoint": "Listener", + "listenerName": "Nazwa wyświetlana", + "listenerScope": "Etykieta", + "listenerInternal": "Wewnętrzny", + "listenerPublic": "Publiczny", + "listenerLegacy": "Udostępnij stare trasy", + "listenerInUse": "Listener jest przypisany do vhostów", + "listenerDuplicate": "Identyfikatory listenerów muszą być unikalne.", + "listenerChooseHint": "Wybierz nazwane listenery skonfigurowane w Ustawienia > Listenery. Wyłączony listener nie daje dostępu.", + "listenerMissing": "Brakujący listener", + "http3Settings": "HTTP/3 / QUIC", + "http3SettingsHelp": "HTTP/3 włącza się w Ustawienia > Listenery. Tutaj ustawisz ogłaszanie usługi oraz limity połączeń i strumieni.", + "http3NoTlsListeners": "Brak listenerów TLS. Najpierw dodaj i zapisz listener TLS.", + "http3NoEnabledListeners": "Żaden listener TLS nie ma włączonego HTTP/3. Najpierw włącz HTTP/3 w Ustawienia > Listenery.", + "http3ListenersDirty": "Najpierw zapisz oczekujące zmiany w Listenerach, a następnie edytuj ustawienia HTTP/3.", + "http3Save": "Zapisz ustawienia HTTP/3", + "h2Configure": "Ustaw limity HTTP/2", + "h3Enable": "Włącz HTTP/3 przez QUIC (UDP)", + "h3Configure": "Ustaw limity HTTP/3", + "h3Limits": "Limity HTTP/3 i ogłaszanie usługi", + "h3_alt_svc_max_age": "Ważność Alt-Svc (s)", + "h3_max_concurrent_connections": "Połączenia na listener", + "h3_max_concurrent_bidistream": "Strumienie dwukierunkowe na połączenie", + "h3_max_concurrent_unistream": "Strumienie jednokierunkowe na połączenie", + "h3_max_idle_timeout": "Bezczynność QUIC (s; 0 = bez limitu)", + "h3Help": "HTTP/3 wymaga TLS. Udostępnij TCP i UDP na tym samym porcie; HTTP/1.1 i HTTP/2 pozostają. Zero wyłącza lokalny timeout QUIC. Zmiana wymaga restartu.", + "h3TuningHelp": "Te limity obowiązują tylko wtedy, gdy HTTP/3 jest włączone dla danego listenera TLS. Zmiana wymaga restartu.", + "h3Requests": "Żądania HTTP/3", + "certImport": "Importuj certyfikat", + "certImportSave": "Importuj i zapisz w bibliotece", + "certLibraryHint": "Wgraj certyfikat/fullchain i klucz prywatny PEM. Zapisz raz, następnie przypisz certyfikat do jednego lub wielu vhostów.", + "certCheckDomains": "Domeny do sprawdzenia (po przecinku)", + "certAcmeLater": "Wydane certyfikaty trafiaja do biblioteki i sa przypisywane do vhostow.", + "certAdvanced": "Istniejące pliki / zaawansowane", + "certLegacyNames": "Stare nazwy SNI (opcjonalne; zalecane przypisanie w vhoście)", + "certAssigned": "Przypisane vhosty", + "certUnassigned": "Nieprzypisany", + "certInUse": "Certyfikat jest używany. Zmień przypisania vhostów i certyfikat domyślny przed usunięciem.", + "certDuplicate": "Ten identyfikator certyfikatu istnieje. Do bezpiecznej rotacji użyj nowego.", + "certManage": "Zarządzaj biblioteką certyfikatów", + "certSelectHint": "Wybierz zaimportowany certyfikat. Import i rotacja są w zakładce Certyfikaty. Przypisania są sprawdzane względem nazw vhosta.", + "vhTinyauth": "Uwierzytelnianie i dostęp IP", + "vhTabAuth": "Uwierzytelnianie", + "taEnable": "Włącz ochronę Tinyauth", + "taUrl": "URL Tinyauth / punkt weryfikacji", + "taLogin": "Publiczny URL logowania", + "taRedirectForbidden": "Akceptuj przekierowanie Tinyauth dla 403 (ten sam origin)", + "taConnect": "Limit połączenia (s)", + "taTimeout": "Łączny limit weryfikacji (s)", + "taRequestHeaders": "Nagłówki klienta do Tinyauth", + "taResponseHeaders": "Zweryfikowane nagłówki tożsamości do aplikacji", + "taHelp": "Do adresu serwera dodawane jest /api/auth/nginx. Weryfikacja bez body działa przed cache i WebSocket. Chronione odpowiedzi mają private, no-store. Ustaw w Tinyauth zaufanie do rzeczywistego IP RustProxy.", + "vhResponsePolicy": "Identyfikacja odpowiedzi i diagnostyka", + "vhInherit": "Dziedzicz ustawienia globalne", + "diagnosticHeaders": "Diagnostyczne nagłówki odpowiedzi", + "diagInherit": "Dziedzicz nagłówki diagnostyczne", + "diagRequestId": "Udostępnij X-Request-ID", + "hideMockOrigin": "Ukryj X-Mock-Origin", + "diagHelp": "X-Request-ID jest opcjonalny. Pozostale naglowki odpowiedzi upstreamu kontroluj zwyklymi regulami naglowkow odpowiedzi.", + "forwardingSettings": "Tożsamość klienta / forwarding", + "forwardingHelp": "Domyślnie nagłówki forwarding od klienta są usuwane. Dodaj tylko kontrolowane proxy. Łańcuch jest sprawdzany od prawej do pierwszego niezaufanego adresu.", + "forwardingCanonical": "RustProxy generuje Forwarded, X-Forwarded-For, X-Real-IP, X-Forwarded-Host, X-Forwarded-Proto, X-Forwarded-Port i X-Forwarded-URI. Adres docelowy połączenia nigdy nie pochodzi z nagłówka.", + "trustedProxies": "Zaufane sieci proxy CIDR (po jednej w wierszu; /32 dla IPv4, /128 dla IPv6)", + "trustForwardedProto": "Ufaj X-Forwarded-Proto tylko od zaufanych proxy", + "listenerId": "ID listenera", + "cache": "Cache", + "cacheTitle": "Cache pod kontrolą.", + "cacheDescription": "Obiekty, skuteczność domen i dokładne przyczyny decyzji.", + "cacheRefresh": "Odśwież", + "cacheLive": "Na żywo (3 s)", + "cachePurgeScope": "Wyczyść wybrany zakres", + "cacheObjectCount": "Obiekty w pamięci", + "cacheSaved": "Dane odpowiedzi z RAM", + "cacheCounterScope": "Liczniki domen od zmiany konfiguracji", + "cacheDomains": "Domeny i skuteczność", + "cacheScopeHint": "Limity RAM, zapisy i usunięcia są globalne. Liczniki domen zerują się przy zmianie konfiguracji; czyszczenie obiektów ich nie zeruje.", + "cacheDomain": "Domena", + "cacheReasons": "Liczniki decyzji", + "cacheNoDomains": "Brak decyzji cache. Wyślij żądania do skonfigurowanego hosta.", + "cacheObjectList": "Zapisane obiekty", + "cachePrevious": "Poprzednie", + "cacheNext": "Następne", + "cacheObjectHint": "Najpierw ostatnio używane. Kliknij Target albo Cache key / tags, aby skopiować pełną wartość. Treści i nagłówki żądań nie są ujawniane.", + "cachePath": "Ścieżka", + "cacheBodySize": "Treść", + "cacheAge": "Wiek", + "cacheTtlRemaining": "Pozostały TTL", + "cacheNoObjects": "Brak świeżych obiektów w tym zakresie. Sprawdź powyższe liczniki decyzji.", + "cacheLegacy": "Nieprzypisane", + "cacheGlobalLimit": "Limit globalny", + "cacheFills": "Aktywne zapisy (globalnie)", + "cacheRejected": "Odrzucone zapisy (globalnie)", + "cacheUpdated": "Aktualizacja", + "acmeTitle": "Let's Encrypt / ACME", + "acmeHelp": "Wydaj certyfikat ACME, dodaj go do biblioteki i przypisz do vhosta. Automatyczne odnawianie rozpocznie się po imporcie.", + "acmeEmail": "Email kontaktowy ACME", + "acmeDomains": "Nazwy DNS, rozdzielone przecinkami (wildcard tylko z DNS-01)", + "acmeEnvironment": "Środowisko CA", + "acmeStaging": "Staging - niezaufane przez przeglądarkę", + "acmeProduction": "Produkcja", + "acmeDnsServer": "IP serwera aktualizacji DNS", + "acmeDnsPort": "Port aktualizacji DNS", + "acmeKeyName": "Nazwa klucza TSIG", + "acmeSecret": "Sekret TSIG (base64)", + "acmeAlgorithm": "Algorytm TSIG", + "acmePropagation": "Oczekiwanie na propagację DNS (sekundy)", + "acmeSignQuery": "Podpisuj zapytanie SOA", + "acmeTos": "Akceptuje umowe subskrybenta Let's Encrypt i zezwalam na walidacje domen.", + "acmeConfirmProduction": "Potwierdzam wydanie produkcyjne (obowiązują limity CA).", + "acmeIssue": "Zamów certyfikat", + "acmeIdle": "Brak zadania wydania w tym procesie.", + "acmeImport": "Dodaj wydany certyfikat do biblioteki", + "acmeRenewalHint": "Pierwsze wydanie/import pozostaje ręczne. Profile zarządzanych certyfikatów są następnie sprawdzane automatycznie podczas działania RustProxy; po restarcie zapisany stan odnowień jest odtwarzany z SQLite.", + "acmeReplace": "Zastąpić certyfikat w bibliotece? Przypisania vhostów zostaną zachowane i ponownie sprawdzone.", + "acmeImported": "Certyfikat zapisany. Przypisz go w zakładce SSL vhosta.", + "certFuture": "Automatyczne odnawianie jest dostępne dla obsługiwanych dostawców ACME; obserwacje odnowień są zapisywane w SQLite.", + "cacheReason_cache_hit": "Użyto świeżego obiektu", + "cacheReason_cache_miss": "Brak pasującego świeżego obiektu", + "cacheReason_cache_disabled": "Cache wyłączony", + "cacheReason_route_disabled": "Cache wyłączony na trasie", + "cacheReason_request_cookie": "Cookie aplikacji", + "cacheReason_request_authorization": "Nagłówek Authorization", + "cacheReason_request_cache_control": "Dyrektywa klienta / twarde odświeżenie", + "cacheReason_request_pragma": "Pragma klienta", + "cacheReason_request_method": "Metoda inna niż GET", + "cacheReason_request_body": "Niepusta treść żądania", + "cacheReason_request_range": "Żądanie zakresu", + "cacheReason_request_conditional": "Żądanie warunkowe", + "cacheReason_request_upgrade": "Zmiana protokołu", + "cacheReason_query_excluded": "Wykluczone query", + "cacheReason_path_excluded": "Wykluczona ścieżka", + "cacheReason_request_cache_control_invalid": "Błędne dyrektywy cache klienta", + "cacheReason_response_status": "Odpowiedź inna niż 200", + "cacheReason_response_set_cookie": "Set-Cookie z backendu", + "cacheReason_response_cache_control": "Prywatność / dyrektywa backendu", + "cacheReason_response_cache_control_invalid": "Błędne dyrektywy backendu", + "cacheReason_response_vary": "Niedozwolony wariant Vary", + "cacheReason_response_range": "Odpowiedź częściowa", + "cacheReason_response_trailers": "Trailers odpowiedzi", + "cacheReason_response_ttl_size_or_metadata": "Odrzucony TTL, rozmiar lub metadane", + "cacheReason_response_headers_policy": "Reguła nagłówków blokuje zapis", + "cacheReason_configuration_changed": "Zmieniona konfiguracja", + "cacheReason_not_proxy_response": "Strona lokalna / odrzucone żądanie", + "cacheReason_tinyauth": "Treść chroniona Tinyauth", + "cacheReason_sticky_cookie": "Tryb cookie sticky session", + "acme.working": "Trwa zamawianie certyfikatu.", + "acme.ready_to_import": "Certyfikat sprawdzony; gotowy do dodania do biblioteki.", + "acme.certbot_not_installed": "Brak Certbota w PATH usługi. Zainstaluj Certbota i certbot-dns-rfc2136.", + "acme.certbot_failed_check_private_log": "Błąd Certbota. Sprawdź prywatne logi ACME; dokładna ścieżka jest pokazana przy błędzie operacji.", + "acme.production_confirmation": "Wymagane potwierdzenie wydania produkcyjnego.", + "acme.consent_required": "Zaakceptuj umowę i potwierdź wybrane środowisko CA.", + "acme.provider_timeout": "Przekroczono czas operacji CA. Przed ponowieniem sprawdź logi Certbota wskazane przy błędzie operacji.", + "acme.invalid_key_secret": "Nieprawidłowy sekret TSIG: wymagany base64 kodujący 16-512 bajtów.", + "acme.job_busy": "Inne zadanie certyfikatu już trwa.", + "acme.cancelled": "Zadanie certyfikatu anulowane podczas zamykania.", + "acme.certificate_validation_failed": "Walidacja certyfikatu, klucza lub domen nie powiodła się. Nic nie aktywowano.", + "acme.invalid_request": "Nieprawidłowe żądanie ACME.", + "acme.unknown_field": "Żądanie zawiera nieobsługiwane pole.", + "acme.invalid_id": "Identyfikator certyfikatu: 1-64 litery, cyfry, myślnik lub podkreślenie.", + "acme.invalid_email": "Podaj prawidłowy adres e-mail kontaktu ACME.", + "acme.invalid_domains": "Podaj od 1 do 16 nazw DNS.", + "acme.invalid_domain": "Podaj prawidłową nazwę DNS (ASCII/punycode).", + "acme.dns_names_only": "Certyfikat wymaga nazw DNS, nie adresów IP.", + "acme.invalid_environment": "Wybierz staging albo produkcję.", + "acme.tos_required": "ACME: tos required.", + "acme.invalid_dns_server": "Podaj adres IPv4 lub IPv6 serwera aktualizacji DNS.", + "acme.invalid_limits": "Port musi mieścić się w 1-65535, a czas propagacji w 10-900 s.", + "acme.invalid_key_algorithm": "Dozwolone algorytmy TSIG: HMAC-SHA256, HMAC-SHA384 i HMAC-SHA512.", + "acme.invalid_sign_query": "Opcja podpisywania SOA musi mieć wartość true albo false.", + "acme.unsafe_directory": "Katalog certyfikatów musi należeć do usługi i mieć prawa 0700; bez dowiązań.", + "acme.unsafe_output_path": "Plik wyniku wykracza poza prywatne archiwum Certbota.", + "acme.invalid_pem_size": "Plik PEM jest za duży lub nie jest zwykłym plikiem.", + "acme.invalid_pem": "Certbot nie zwrócił prawidłowego pliku PEM.", + "acme.profile_limit": "Osiągnięto limit 128 profili ACME. Sprawdź nieużywane profile lokalnie.", + "acme.profile_busy": "Ten profil ACME jest już używany przez inny proces.", + "acme.staging_limit": "Osiągnięto limit 128 par ACME. Usuń lokalnie wyłącznie nieużywane pliki.", + "acme.request_too_large": "Żądanie ACME przekracza 16 KiB.", + "acme.local_io_or_json_error": "Błąd lokalnego pliku lub formatu JSON. Sprawdź katalog i uprawnienia.", + "acme.no_certificate_directory": "Brak katalogu certyfikatów w konfiguracji uruchomienia.", + "acme.python_not_available": "Proces usługi nie może uruchomić python3. Sprawdź PATH i uprawnienia.", + "acme.worker_io": "Nie udała się komunikacja z lokalnym procesem ACME.", + "acme.invalid_worker_response": "Proces ACME zwrócił nieprawidłowy wynik.", + "acme.worker_failed": "Proces ACME zakończył się błędem.", + "acme.validation_worker_failed": "Nie udało się zakończyć walidacji pary certyfikatu i klucza.", + "cacheResidentLabel": "Pamięć cache", + "certificateFuture": "Dostawcy ACME: HTTP-01, Cloudflare, OVH i RFC2136. Odnawianie jest ręczne.", + "prometheusTitle": "Prometheus", + "prometheusHelp": "Osobny endpoint metryk, niezależny od sesji administratora. Domyślnie wyłączony.", + "prometheusEnable": "Włącz endpoint Prometheusa", + "prometheusListen": "IP i port eksportera", + "prometheusPath": "Ścieżka metryk", + "prometheusCidrs": "Dozwolone adresy kolektora (CIDR, po jednym w wierszu)", + "prometheusTokenEnv": "Nazwa zmiennej środowiskowej z tokenem", + "prometheusTokenHelp": "Podaj nazwę zmiennej, nie sekret. Token musi mieć co najmniej 32 znaki. Zmiana adresu lub tokenu wymaga restartu.", + "prometheusCollectors": "Grupy metryk", + "prometheusPreview": "Podgląd metryk", + "prometheusPreviewFailed": "Nie udało się pobrać metryk.", + "prometheusActive": "Aktywny endpoint", + "prometheusDisabled": "Endpoint metryk jest wyłączony.", + "certificateProvider": "Dostawca certyfikatu", + "providerHttp01": "Let's Encrypt - walidacja domeny (HTTP-01)", + "providerCloudflare": "Let's Encrypt - Cloudflare API (DNS-01)", + "providerOvh": "Let's Encrypt - OVH API (DNS-01)", + "providerRfc2136": "Let's Encrypt - RFC2136 / TSIG (DNS-01)", + "providerImport": "Import certyfikatu i klucza prywatnego (PEM)", + "providerExisting": "Istniejące pliki na serwerze", + "providerHint_http01": "Walidacja domeny HTTP-01 wymaga publicznego portu 80 prowadzącego do aktywnego listenera HTTP. Nie obsługuje wildcardów.", + "providerHint_cloudflare": "DNS-01 przez API Cloudflare. Użyj tokenu z uprawnieniem Zone:DNS:Edit ograniczonym do odpowiednich stref.", + "providerHint_ovh": "DNS-01 przez API OVH. Podaj klucze aplikacji i klienta z uprawnieniami do rekordów odpowiednich stref DNS.", + "providerHint_rfc2136": "DNS-01 przez dynamiczną aktualizację DNS i klucz TSIG. Serwer musi zezwalać na zmianę rekordów walidacyjnych.", + "providerHint_import": "Wgraj certyfikat lub fullchain i odpowiadający mu klucz prywatny w formacie PEM.", + "providerHint_existing": "Wskaż pliki PEM z katalogu certyfikatów RustProxy. Treść klucza nie trafia do konfiguracji.", + "acmeCfToken": "Token API Cloudflare", + "acmeOvhEndpoint": "Region API OVH", + "acmeOvhApplicationKey": "OVH application key", + "acmeOvhApplicationSecret": "OVH application secret", + "acmeOvhConsumerKey": "OVH consumer key", + "acme.invalid_provider": "Nieobsługiwany dostawca certyfikatu.", + "acme.invalid_api_credential": "Brak klucza API lub niedozwolone znaki w kluczu.", + "acme.invalid_ovh_endpoint": "Wybierz OVH Europa lub Kanada.", + "acme.http01_no_wildcard": "HTTP-01 nie obsługuje wildcardów. Wybierz dostawcę DNS-01.", + "acme.http01_listener_required": "HTTP-01 wymaga aktywnego listenera HTTP bez TLS.", + "cacheScopeLegacy": "Nieprzypisany / poprzedni zakres", + "certificateLibrary": "Certyfikaty", + "certificateWorkspaceHelp": "Zarządzaj zainstalowanymi certyfikatami, automatycznym odnawianiem i generowaniem/importem w osobnych widokach.", + "certificateTabInstalled": "Zainstalowane", + "certificateTabRenewal": "Odnowienie", + "certificateTabGenerate": "Generowanie", + "certificateInstalledTitle": "Zainstalowane certyfikaty", + "certificateInstalledHelp": "Lista certyfikatów dostępnych dla vhostów, redirectów, hostów 404 i panelu administracyjnego.", + "certificateGenerateTitle": "Generowanie i import", + "certificateGenerateHelp": "Wybierz metodę: Let's Encrypt/ACME, import plików PEM albo istniejące pliki z prywatnego katalogu RustProxy.", + "certificateOperationStatus": "Ostatnia operacja ACME", + "certificateNoRenewalOperation": "Brak operacji odnowienia w tym procesie.", + "certificateStateIdle": "Bezczynne", + "certificateStateRunning": "W toku", + "certificateStateReady": "Gotowe", + "certificateStateComplete": "Zakończone", + "certificateStateFailed": "Błąd", + "certbotLogs": "Logi Certbota", + "systemDiagnostics": "System / proces", + "vhWebsockets": "Obsługa WebSocket", + "vhLocations": "Własne lokalizacje", + "locationPath": "Prefiks lokalizacji", + "locationPool": "Backendy vhosta", + "locationMissing": "Brak backendu", + "locationLimit": "Host obsługuje do 64 lokalizacji.", + "locationAccess": "Dostęp do lokalizacji", + "locationHeaders": "Nagłówki lokalizacji", + "locationAddBackend": "Dodaj osobny backend", + "locationAdd": "Dodaj lokalizację", + "locationHelp": "Wygrywa najdłuższy pasujący prefiks. Ścieżka i query pozostają bez zmian. Wybierz backend hosta lub dodaj osobny. Uwierzytelnianie i ACL hosta obowiązują zawsze.", + "aclUse": "Włącz listę dostępu IP", + "aclAllow": "Zezwól", + "aclNetwork": "Sieć IP (CIDR)", + "aclAction": "Akcja", + "aclAdd": "Dodaj regułę IP", + "authMode": "Uwierzytelnianie", + "authNone": "Brak", + "authTinyauth": "Tinyauth", + "authBasic": "HTTP Basic Auth", + "basicRealm": "Realm (nazwa logowania)", + "basicRequireTls": "Wymagaj HTTPS dla Basic Auth", + "basicForward": "Przekaż zweryfikowany Authorization do backendu", + "basicPassword": "Hasło (12-128 bajtów)", + "basicKeepPassword": "Puste pole zachowuje hasło", + "basicHashSaved": "Hasło ustawione", + "basicHashRequired": "Ustaw hasło dla każdego użytkownika.", + "basicAddUser": "Dodaj użytkownika", + "basicHelp": "Zapisywane są wyłącznie skróty Argon2id. Dane logowania nie trafiają do backendu, chyba że włączysz przekazywanie. Używaj go tylko z zaufanym backendem HTTPS lub w izolowanej sieci. Chronione hosty omijają współdzielony cache.", + "basic.too_many_users": "Maksymalnie 64 użytkowników na hosta.", + "basic.invalid_username": "Nieprawidłowa nazwa użytkownika.", + "basic.duplicate_username": "Powtórzona nazwa użytkownika.", + "basic.unsupported_hash": "Użyj skrótu Argon2id wygenerowanego przez RustProxy.", + "basic.invalid_hash": "Nieprawidłowy skrót hasła.", + "basic.invalid_realm": "Nieprawidłowa nazwa realm.", + "basic.users_required": "Basic Auth wymaga przynajmniej jednego użytkownika.", + "basic.busy": "Weryfikacja haseł jest zajęta. Spróbuj ponownie.", + "renewalTitle": "Automatyczne odnawianie", + "renewalHelp": "Certyfikaty ACME są sprawdzane co około 12 godzin, gdy RustProxy działa. Certbot określa termin odnowienia. Poprawne certyfikaty są wdrażane bez restartu listenerów. Zachowaj dane dostawcy i dostępność portu 80 dla HTTP-01.", + "renewalNone": "W bibliotece nie ma zarządzanych certyfikatów ACME.", + "renewalEnabled": "Automatyczne odnawianie włączone", + "renewalDisabled": "Automatyczne odnawianie wyłączone", + "renewalEnable": "Włącz odnawianie", + "renewalDisable": "Wyłącz odnawianie", + "renewalNow": "Sprawdź teraz", + "renewalDryRun": "Sprawdź odnowienie (dry run)", + "renewalLast": "Ostatnia kontrola", + "renewalNext": "Kolejna kontrola", + "renewalExpires": "Ważny do", + "acme.renewing": "Sprawdzanie odnowienia certyfikatu", + "acme.renewed": "Certyfikat odnowiony i wdrożony", + "acme.not_due": "Certyfikat nie wymaga jeszcze odnowienia", + "acme.renewal_test_ok": "Weryfikacja odnowienia poprawna; aktywny certyfikat bez zmian", + "acme.renewal_profile_missing": "Brak dostępu do zapisanego profilu odnowienia. Przywróć prywatny profil ACME.", + "acme.configuration_changed": "Konfiguracja certyfikatu zmieniła się podczas odnowienia. Nie wdrożono zmian.", + "acme.deployment_failed": "Odnowiony certyfikat nie przeszedł walidacji konfiguracji. Poprzedni nadal jest aktywny.", + "acme.not_managed": "Automatycznie można odnawiać tylko zarządzane certyfikaty ACME.", + "acme.certificate_not_found": "Certyfikat już nie istnieje.", + "acme.renewal_disabled": "Automatyczne odnawianie jest wyłączone.", + "acme.openssl_not_installed": "OpenSSL jest wymagany do odczytu dat ważności certyfikatu.", + "cacheReason_basic_auth": "Uwierzytelnianie HTTP Basic", + "locationPoolEligible": "Uwzględnij w domyślnej puli backendów", + "locationId": "Identyfikator lokalizacji", + "vhIpAccess": "Dostęp do vhosta według IP", + "apiDocs": "Dokumentacja API", + "auth.invalid_request": "Nieprawidłowe żądanie. Sprawdź podane pola.", + "locationProxyHeaders": "Nagłówki żądania proxy (do backendu)", + "locationResponseHeaders": "Nagłówki odpowiedzi (do klienta)", + "locationHeadersHelp": "Ustaw nadpisuje nagłówek, dopisz dodaje wartość, usuń kasuje go. Reguły lokalizacji są stosowane po globalnych i vhosta. Wartości są dosłowne (bez zmiennych nginx). Nagłówkami transportu i zweryfikowanej tożsamości zarządza system.", + "headersAdvancedJson": "Zaawansowane reguły nagłówków (JSON)", + "headersInvalid": "Nieprawidłowa reguła nagłówka. Sprawdź nazwę, operację i wartość.", + "headersProtected": "Nie można tu nadpisać nagłówków transportu i tożsamości proxy. Użyj ustawień forwardingu, autentykacji lub odpowiedzi.", + "docsTitle": "Dokumentacja API", + "docsSession": "Korzysta z sesji administratora. Zapis wymaga ochrony CSRF.", + "docsSignIn": "Zaloguj się, aby otworzyć dokumentację", + "docsPanel": "Powrót do panelu", + "docsWrites": "Włącz operacje zapisu", + "docsWriteConfirm": "Ta operacja naprawdę zmieni RustProxy. Kontynuować?", + "docsExpired": "Sesja wygasła. Zaloguj się ponownie.", + "docsError": "Nie udało się wczytać dokumentacji.", + "docsAccount": "Zalogowano jako", + "docsReadOnly": "Domyślnie wykonywane są tylko odczyty. Strumienie SSE oraz logowanie i setup nie są tu uruchamiane.", + "docsLogout": "Wyloguj", + "authPrivateNote": "Dostęp tylko dla uprawnionych administratorów.", + "advanced": "Zaawansowane", + "docsLicenses": "Licencje Swagger UI", + "vhTrafficPolicy": "Polityka ruchu", + "vhHttp3Enabled": "Włącz HTTP/3 dla tego vhosta", + "vhCloudflareClientIp": "Używaj zweryfikowanego IP klienta z Cloudflare", + "vhTimingHeaders": "Udostępniaj diagnostykę Server-Timing", + "vhRateEnabled": "Włącz limit żądań per IP klienta", + "vhRateRequests": "Żądania na okno", + "vhRateWindow": "Okno (sekundy)", + "vhRateBurst": "Dopuszczalny burst", + "vhCountryMode": "Polityka dostępu krajów", + "vhCountryBlock": "Blokuj wskazane kraje", + "vhCountryAllow": "Zezwalaj tylko wskazanym krajom", + "vhCountryList": "Kraje (ISO 3166-1 alpha-2)", + "vhCountryPolicyHelp": "Reguły krajów korzystają z zainstalowanej bazy GeoIP. Tryb „tylko zezwolone” blokuje także klienta, którego kraju nie da się ustalić.", + "vhCountry": "Kraj", + "vhAddCountryRate": "Dodaj limit dla kraju", + "vhCountryRateLimit": "Dozwolone są maksymalnie 64 limity przypisane do krajów.", + "vhRateHelp": "Limit dla kraju nadpisuje podstawowy limit per IP. Przekroczenie daje 429, a zabroniony kraj 403.", + "vhHealthOffWarning": "Aktywne sondy backendu są wyłączone. Passive Backend Health jest konfigurowany osobno i pozostaje wyłączony, dopóki nie zostanie świadomie włączony.", + "vhFrontendSelectionOnly": "Wybierz listenery dla vhosta. Adresy IP, porty oraz dostępność H2/H3 konfiguruje się wyłącznie w Ustawienia > Listenery.", + "vhForceSsl": "Wymuś SSL (przekieruj HTTP do HTTPS)", + "vhForceSslHelp": "Żądania HTTP tego vhosta dostają przekierowanie 308 do HTTPS z zachowaniem ścieżki i parametrów. Wybierz co najmniej jeden listener zwykłego HTTP oraz co najmniej jeden listener TLS. Jeśli wybrano kilka portów TLS, używany jest port 443; w innym przypadku port docelowy TLS musi być jednoznaczny.", + "vhForceSslNeedsTls": "Wymuszenie SSL wymaga co najmniej jednego włączonego listenera TLS dla tego vhosta.", + "vhForceSslPortConflict": "Nie można jednoznacznie wybrać portu docelowego HTTPS: wybrane listenery TLS używają kilku niestandardowych portów. Wybierz jeden port TLS albo listener na porcie 443.", + "vhForwardClientIp": "Przekazuj IP klienta do backendu", + "vhForwardClientIpHelp": "Pozostaw włączone dla standardowego reverse proxy. Wyłącz, aby nie wysyłać do aplikacji nagłówków Forwarded, X-Forwarded-For i X-Real-IP; RustProxy, ACL, limity i Tinyauth nadal korzystają z prawdziwego IP klienta.", + "vhClientIpHidden": "IP klienta ukryte przed backendem", + "listenerMigrateInline": "Istniejący listener vhosta (zostanie zmigrowany)", + "vhDebugOriginPreset": "Zastosuj preset zalecany", + "vhDebugOriginTlsWarning": "Ten preset pozostawia wyłączoną walidację certyfikatu TLS backendu. Połączenie jest szyfrowane, ale CA i nazwa hosta nie są sprawdzane. Kontynuować?", + "vhOriginPresetHelp": "Domyślne ustawienia nowego backendu do debugowania: origin HTTPS po IP, domyślna pula LB ON, prywatny origin ON, HTTP/1.1, zachowanie Host klienta ON i weryfikacja TLS OFF. Włącz weryfikację TLS, gdy certyfikat originu da się poprawnie zweryfikować.", + "vhDebugOriginMismatch": "Ten origin HTTPS po IP nie odpowiada presetowi debugowania (pula ON, prywatny origin ON, HTTP/1.1, zachowanie Host ON, walidacja TLS OFF). Jeśli włączysz walidację, ustaw nazwę serwera TLS na DNS z certyfikatu i w razie potrzeby dodaj jego prywatne CA. Kontynuować z bieżącymi wartościami?", + "vhCacheStaticCookie": "Ignoruj Cookie żądania dla wybranych rozszerzeń statycznych", + "vhCacheStaticCustom": "Użyj własnej listy rozszerzeń statycznych", + "vhCacheStaticExtensions": "Rozszerzenia statyczne (po jednym w linii, bez kropki)", + "vhCacheStaticHelp": "Dziedzicz ustawienie globalne albo nadpisz je dla vhosta. Ignorowanie Cookie stosuj tylko do plików, których treść nie zależy od sesji; Set-Cookie, private/no-store i Vary: Cookie nadal blokują zapis do shared cache.", + "cacheStaticTitle": "Cache statycznych zasobów", + "cacheStaticCookie": "Ignoruj Cookie żądania dla statycznych plików", + "cacheStaticExtensions": "Globalne rozszerzenia statyczne (po jednym w linii, bez kropki)", + "cacheStaticHelp": "Włączaj tylko dla plików, których treść nie zależy od sesji. Cookie żądania nie wpływa wtedy na klucz cache dla podanych rozszerzeń; odpowiedzi z Set-Cookie, private/no-store lub Vary: Cookie nadal nie są zapisywane we współdzielonym cache.", + "vhHttp2Enabled": "Włącz HTTP/2 dla tego vhosta", + "vhFrontendProtocolsHelp": "H2 i H3 są przełącznikami per vhost. Opcja jest aktywna tylko wtedy, gdy co najmniej jeden wybrany listener ma dany protokół włączony w Ustawienia > Listenery.", + "administration": "Panel administracyjny", + "administrationActiveHttp": "Aktywny panel HTTP", + "administrationActiveHttps": "Aktywny panel HTTPS", + "administrationCertificate": "Certyfikat", + "administrationGeneratedCertificate": "Domyślny certyfikat wygenerowany przez aplikację", + "administrationHelp": "HTTPS pozwala wystawić panel administracyjny poza loopback. Zwykły HTTP na porcie HTTPS jest automatycznie przekierowywany. Zmiany listenera i certyfikatu działają po restarcie.", + "administrationHosts": "Dozwolone hosty DNS (opcjonalna ścisła lista)", + "administrationHttps": "Włącz HTTPS dla panelu administracyjnego", + "administrationListen": "Adres nasłuchu", + "cacheOverhead": "Narzut", + "cacheResidentAge": "X-Cache-Resident-Age", + "controlPlane": "PANEL STEROWANIA", + "logging": "Logowanie", + "loggingTitle": "Strukturalne logi dostępu", + "loggingDescription": "Zapisuj konfigurowalne logi dostępu JSON do pliku, Logstash lub Loki.", + "loggingHelp": "Podgląd logów na żywo pozostaje w pamięci. Zewnętrzne wyjścia używają ograniczonej kolejki w tle i nie blokują obsługi żądań.", + "loggingFile": "Plik JSON", + "loggingPath": "Ścieżka pliku", + "loggingAddress": "Adres TCP", + "loggingTokenEnv": "Zmienna środowiskowa z tokenem Bearer", + "loggingLabels": "Etykiety (obiekt JSON)", + "loggingFormat": "Konfigurowalny format JSON", + "loggingFormatHelp": "Mapuj nazwy pól wyjściowych na pola źródłowe. Puste mapowanie zapisuje pełny standardowy wpis.", + "loggingFields": "Mapowanie pól (obiekt JSON)", + "loggingStatic": "Pola stałe (obiekt JSON)", + "loggingJsonInvalid": "Pola konfiguracji logów muszą zawierać poprawne obiekty JSON.", + "loggingQueue": "Kolejka wyjść", + "loggingDropped": "odrzuconych", + "loggingSent": "wysłanych", + "loggingCapacity": "pojemność", + "tls12": "TLS 1.2", + "tls13": "TLS 1.3", + "tlsPolicy": "Polityka TLS", + "tlsPolicyHelp": "To globalna polityka TLS dziedziczona przez każdy host TLS w aplikacji. Ustawienia per-vhost mogą ją tylko zawężać. HTTP/3 wymaga TLS 1.3.", + "tlsCiphers": "Szyfry (jeden na linię; puste = domyślne rustls)", + "tlsAvailableCiphers": "Dostępne szyfry", + "tlsPolicyVersionRequired": "Włącz co najmniej jedną wersję TLS.", + "vhTlsOverride": "Nadpisz globalną politykę TLS", + "vhTlsOverrideHelp": "Użyj tylko, gdy ten vhost wymaga innych wersji TLS lub szyfrów. Pusta lista szyfrów zachowuje domyślne rustls.", + "vhTlsHttp3Conflict": "HTTP/3 korzysta z globalnej polityki TLS. Wyłącz HTTP/3 dla tego vhosta przed użyciem innego nadpisania TLS.", + "vhListeners": "Listenery", + "vhProtocols": "Protokoły", + "loggingMode": "JSON / ASYNC / OGRANICZONA KOLEJKA", + "loggingPushUrl": "Adres Push URL", + "cacheHotObjects": "Obiekty hot", + "cacheHotMemory": "Pamięć hot", + "cacheColdObjects": "Obiekty cold", + "cacheColdDisk": "Dysk cold", + "cacheTierMoves": "Przeniesienia między warstwami", + "cacheColdHits": "trafień cold", + "cacheTier": "Warstwa", + "cacheStorage": "Pamięć / dysk", + "cacheColdTitle": "Trwały cache cold", + "cacheColdEnable": "Włącz trwałą warstwę dyskową", + "cacheColdHelp": "Wybierz, które obiekty z hot RAM mają trafiać na trwały dysk. Trafienie cold jest odczytywane asynchronicznie i promowane z powrotem do RAM. Metadane są w JSON, a body w plikach .body.", + "cacheColdPath": "Ścieżka cold (wewnątrz katalogu danych)", + "cacheColdBytes": "Limit dysku cold (MiB)", + "cacheColdEntries": "Limit obiektów cold", + "cacheColdAfter": "Przenieś po sekundach bezczynności", + "cacheHot": "Hot", + "cacheCold": "Cold", + "cacheReason_request_no_cache": "no-cache klienta / twarde odświeżenie wymaga rewalidacji", + "cacheReason_request_max_age": "max-age klienta wymaga rewalidacji", + "cacheReason_request_min_fresh": "min-fresh klienta wymaga rewalidacji", + "cacheReason_request_pragma_no_cache": "Pragma no-cache klienta / starszy tryb twardego odświeżenia", + "cacheReason_cache_revalidated": "Obiekt z cache potwierdzony przez backend", + "cacheReason_cache_conditional_hit": "Świeży obiekt obsłużył żądanie warunkowe", + "cacheReason_request_no_store": "no-store klienta omija cache", + "cacheReason_request_precondition": "Warunek klienta wymaga backendu", + "loggingPreset": "Preset formatu", + "loggingPresetCustom": "Własny", + "loggingPresetStandard": "Standard RustProxy", + "loggingPresetNginx": "JSON w stylu Nginx", + "loggingPresetEcs": "Nazwy pól w stylu ECS", + "loggingPresetMinimal": "Minimalny", + "loggingBuilder": "Graficzny konfigurator pól", + "loggingBuilderOpen": "Konfiguruj pola", + "loggingAdvancedJson": "Zaawansowane mapowanie JSON", + "loggingBuilderHelp": "Wybierz pola źródłowe i opcjonalnie zmień ich nazwy w JSON. Zastosowanie edytora graficznego normalizuje każde pole źródłowe do maksymalnie jednego klucza wyjściowego; duplikaty aliasów ustawisz w trybie zaawansowanym.", + "loggingSourceField": "Pole źródłowe", + "loggingOutputField": "Klucz JSON na wyjściu", + "loggingPreview": "Podgląd", + "loggingOutputRequired": "Każde włączone pole logu musi mieć klucz JSON na wyjściu.", + "loggingOutputDuplicate": "Klucze JSON na wyjściu muszą być unikalne.", + "aclProfiles": "Profile ACL", + "acl-profiles": "Profile ACL", + "acl-profilesTitle": "Wielokrotnego użytku profile ACL", + "acl-profilesDescription": "Definiuj wspólne reguły dostępu IP i krajów, a następnie przypinaj je do wielu vhostów.", + "aclProfilesIntro": "Profile dostępu można przypinać do wielu vhostów. Indywidualne reguły ACL i krajów w vhoście nadal działają i są egzekwowane razem z profilem.", + "aclProfilesReusable": "WSPÓLNA POLITYKA", + "aclProfileNew": "Nowy profil", + "aclProfileEdit": "Edycja profilu", + "aclProfileEditor": "Konfiguracja profilu", + "aclProfileId": "ID profilu", + "aclProfileName": "Nazwa wyświetlana", + "aclProfileIpEnabled": "Włącz ACL IP", + "aclProfileRules": "Reguły IP — po jednej „allow|deny CIDR” w wierszu", + "aclProfileCombineHelp": "Profil i reguły vhosta są łączone restrykcyjnie: żądanie musi przejść obie polityki.", + "aclProfileIpRulesShort": "reguł IP", + "aclProfileIpOff": "ACL IP wyłączony", + "aclProfileCountriesOff": "Filtrowanie krajów wyłączone", + "aclProfileAttached": "Przypięty do", + "aclProfileInUse": "Profil jest przypięty do vhostów", + "aclProfilesEmpty": "Brak profili ACL wielokrotnego użytku.", + "aclProfileCountryInvalid": "Kody krajów muszą mieć dwuliterowy format ISO.", + "aclProfileDuplicate": "Profil ACL o tym ID już istnieje.", + "vhAclProfile": "Profil ACL", + "vhAclProfileNone": "Bez wspólnego profilu", + "vhAclProfileHelp": "Opcjonalny profil wielokrotnego użytku. Indywidualne ACL i reguły krajów vhosta poniżej nadal są dodatkowo egzekwowane.", + "vhHostAdd": "Dodaj hostname", + "vhHostsHelp": "Naciśnij Enter lub przecinek, aby dodać hostname. Wildcard jest dozwolony tylko jako początkowe *. (np. *.example.com). Dokładny host ma pierwszeństwo przed wildcardem.", + "vhHostInvalid": "Nieprawidłowy hostname lub wzorzec wildcard.", + "vhHostRequired": "Dodaj co najmniej jeden hostname.", + "vhLifetimeChart": "Przebieg ruchu od otwarcia panelu", + "vhReqPerSecond": "żąd./s", + "vhPeak": "max", + "listenerAddresses": "Adresy nasłuchu", + "listenerAddressesInvalid": "Podaj 1–16 unikalnych adresów nasłuchu.", + "listenerAddressesExplicit": "Przy wielu adresach wybierz konkretne IP; 0.0.0.0 i :: nie są dozwolone.", + "ruleSyntaxLine": "Nieprawidłowa reguła ACL w wierszu", + "vhForceSslNeedsHttp": "Wymuszenie SSL wymaga co najmniej jednego wybranego listenera zwykłego HTTP (bez TLS). Listener nazwany „HTTP” może mimo nazwy mieć włączony TLS w Ustawienia > Listenery.", + "aclProfileRuleBuilder": "Reguły IP", + "aclProfileBulkRules": "Wklej / importuj reguły", + "aclProfileBulkHelp": "Wklej po jednej regule „allow|deny CIDR” w wierszu. Niewidoczne odstępy Unicode są normalizowane, a błąd wskazuje dokładny wiersz.", + "aclProfileImportRules": "Importuj reguły", + "aclProfileRulesImported": "Reguły ACL zaimportowano do edytora graficznego.", + "streamHosts": "Hosty stream", + "streamsHelp": "Surowe przekazywanie TCP/UDP na osobnych portach. Ruch stream omija vhosty HTTP, ACL, cache, nagłówki i kompresję.", + "streamAdd": "Dodaj host stream", + "saveStreams": "Zapisz hosty stream", + "streamRestartHelp": "Zmiana listenera stream wymaga restartu RustProxy.", + "streamRestartPending": "Zapisano zmiany streamów. Uruchom ponownie RustProxy, aby je zastosować.", + "streamActive": "Aktualnie aktywne streamy", + "streamNone": "Brak aktywnych hostów stream", + "streamEndpoint": "Endpoint stream", + "streamId": "ID streamu", + "streamName": "Nazwa wyświetlana", + "streamProtocol": "Protokół", + "streamListen": "Adres nasłuchu", + "streamUpstream": "Adres upstream", + "streamConnectTimeout": "Timeout połączenia TCP (sekundy)", + "streamIdleTimeout": "Timeout bezczynności/sesji (sekundy)", + "streamMaxConnections": "Maks. połączeń/sesji", + "streamTcp": "TCP", + "streamUdp": "UDP", + "streamLimit": "Obsługiwane jest maksymalnie 128 hostów stream.", + "streamDuplicate": "ID hostów stream muszą być unikalne.", + "streams": "Streamy", + "streamsTitle": "Proxy TCP / UDP", + "streamsDescription": "Zarządzaj surowymi listenerami Layer 4, upstreamami i statystykami ruchu na żywo.", + "streamsPageIntro": "Streamy TCP/UDP są niezależnymi proxy Layer 4. Każdy stream ma własny listener i upstream oraz omija warstwę vhostów HTTP, ACL i cache.", + "streamLayer4": "LAYER 4 / TCP + UDP", + "streamList": "Streamy", + "streamSearch": "Szukaj streamów", + "streamSearchPlaceholder": "ID, nazwa, listener lub upstream…", + "streamSearchEmpty": "Brak streamów pasujących do wyszukiwania.", + "streamEmpty": "Brak streamów TCP/UDP. Dodaj listener, aby rozpocząć proxowanie ruchu Layer 4.", + "streamNew": "Nowy stream", + "streamAddListener": "Dodaj listener TCP/UDP", + "streamEditor": "Konfiguracja streamu", + "streamEditLabel": "EDYCJA", + "streamNewLabel": "NOWY", + "streamDetailsTab": "Szczegóły", + "streamNetworkTab": "Listener i upstream", + "streamLimitsTab": "Limity", + "streamNetwork": "Listener i upstream", + "streamLimits": "Limity połączeń / sesji", + "streamDedicatedListenerHelp": "Listener streamu jest tworzony razem ze streamem i jest niezależny od listenerów HTTP/HTTPS.", + "streamLimitsHelp": "Limity dotyczą równoległych połączeń TCP lub sesji UDP per klient. Timeout łączenia TCP nie jest używany przez UDP.", + "streamListenAdd": "Dodaj adres nasłuchu", + "streamListenHelp": "Wpisz jeden adres IP:port i naciśnij Enter. Zmiana listenera zacznie działać po restarcie RustProxy.", + "streamListenInvalid": "Adres listenera musi być pojedynczą wartością IP:port bez spacji i przecinków.", + "streamListenRequired": "Dodaj adres nasłuchu.", + "streamDelete": "Usunąć ten stream? Aktywny listener pozostanie do restartu RustProxy.", + "streamPendingRestart": "Oczekuje na restart", + "streamRunning": "Działa", + "streamActiveSessions": "Aktywne połączenia / sesje", + "streamSessionsTotal": "Połączenia / sesje", + "streamErrors": "Błędy", + "streamRejected": "Odrzucone", + "streamTransferred": "Przesłano", + "streamTrafficChart": "Ruch na żywo", + "streamClientToUpstream": "klient → upstream", + "streamUpstreamToClient": "upstream → klient", + "streamNoLiveMetrics": "Statystyki pojawią się, gdy stream będzie aktywny po restarcie.", + "streamIdleShort": "Timeout bezczynności", + "listenerAddressAdd": "Dodaj adres nasłuchu", + "listenerAddressesHelp": "Naciśnij Enter lub przecinek, aby dodać adres. Wiele adresów korzysta z tej samej polityki listenera; wildcard 0.0.0.0/:: nie może być mieszany z konkretnymi adresami.", + "cacheColdOnIdle": "Przenoś stare/nieużywane obiekty do cold storage", + "cacheColdOnLru": "Przenoś obiekty wyrzucane z hot LRU do cold storage", + "cacheColdOnShutdown": "Przy łagodnym restarcie zrzucaj hot cache do cold storage", + "certificateCovers": "Domeny certyfikatu", + "certificateCoverageUnknown": "Brak danych o domenach (DNS SAN). Wczytaj ponownie pliki certyfikatów lub zaimportuj certyfikat ponownie.", + "certificateDetails": "Szczegóły certyfikatu", + "certificateFiles": "Pliki certyfikatu / klucza", + "redirects": "Przekierowania", + "redirectsTitle": "Przekieruj świadomie.", + "redirectsDescription": "Lokalne hosty przekierowań z jawnym dopasowaniem, celem, kodem oraz obsługą ścieżki i query.", + "redirectsPageIntro": "Hosty przekierowań odpowiadają lokalnie, bez otwierania połączenia do backendu. Reguły mogą dopasować protokół i ścieżkę, a następnie przekierować do URL, innego hosta lub ścieżki.", + "redirectLayer": "PRZEKIEROWANIA HTTP", + "redirectList": "Hosty przekierowań", + "redirectSearch": "Szukaj przekierowań", + "redirectSearchPlaceholder": "ID, domena, cel…", + "redirectNew": "Nowe przekierowanie", + "redirectEmpty": "Brak skonfigurowanych hostów przekierowań.", + "redirectSearchEmpty": "Brak przekierowań pasujących do wyszukiwania.", + "redirectEditor": "Konfiguracja przekierowania", + "redirectId": "ID przekierowania", + "redirectName": "Nazwa", + "redirectHosts": "Domeny źródłowe", + "redirectFrontend": "FRONTEND", + "redirectListeners": "Listenery", + "redirectBehavior": "ZACHOWANIE", + "redirectRules": "Reguły", + "redirectAddRule": "Dodaj regułę", + "redirectRulesHelp": "Wygrywa najdłuższa pasująca ścieżka. 307/308 zachowują metodę żądania; 301/302/303 mogą ją zmienić po stronie klienta.", + "redirectSafetyHint": "Host przekierowania nie łączy się z backendem. Globalne ACL hostów nadal obowiązują. Unikaj reguł kierujących bezwarunkowo do samych siebie.", + "redirectRule": "Reguła", + "redirectRuleId": "ID reguły", + "redirectSourceScheme": "Protokół wejściowy", + "redirectSchemeAny": "HTTP lub HTTPS", + "redirectSchemeHttp": "HTTP", + "redirectSchemeHttps": "HTTPS", + "redirectSchemePreserve": "Zachowaj protokół wejściowy", + "redirectMatchType": "Dopasowanie ścieżki", + "redirectMatchPrefix": "Prefiks", + "redirectMatchExact": "Dokładnie", + "redirectSourcePath": "Ścieżka źródłowa", + "redirectTargetType": "Typ celu", + "redirectTargetUrl": "Pełny URL", + "redirectTargetHost": "Host / aplikacja", + "redirectTargetPath": "Ścieżka na tym samym hoście", + "redirectTarget": "Cel", + "redirectTargetScheme": "Protokół celu", + "redirectPathMode": "Obsługa ścieżki", + "redirectPathAppend": "Dopisz pełną ścieżkę źródłową", + "redirectPathStrip": "Usuń dopasowany prefiks i dopisz resztę", + "redirectPathReplace": "Użyj tylko skonfigurowanej ścieżki celu", + "redirectQueryMode": "Obsługa query", + "redirectQueryPreserve": "Zachowaj query źródłowe", + "redirectQueryDrop": "Usuń query źródłowe", + "redirectStatus": "Status HTTP", + "redirect301": "301 Moved Permanently", + "redirect302": "302 Found", + "redirect303": "303 See Other", + "redirect307": "307 Temporary Redirect (zachowaj metodę)", + "redirect308": "308 Permanent Redirect (zachowaj metodę)", + "redirectCacheSeconds": "max-age cache przeglądarki/proxy (sekundy; 0 = no-store)", + "redirectHostsRequired": "Dodaj co najmniej jedną domenę źródłową.", + "redirectListenersRequired": "Wybierz co najmniej jeden listener.", + "redirectRulesRequired": "Dodaj co najmniej jedną regułę przekierowania.", + "redirectDuplicate": "Przekierowanie o tym ID już istnieje.", + "redirectUnsaved": "Odrzucić niezapisane zmiany przekierowania?", + "redirectNewLabel": "NOWE", + "administrationSessionDays": "Czas ważności sesji administratora (dni)", + "administrationSessionDaysHelp": "Sesje przeglądarkowe mają bezwzględny czas ważności 7–30 dni. Zmiana działa od razu; zmiana hasła i „wyloguj wszystkie sesje” nadal natychmiast unieważniają sesje.", + "vhLocalListenerRisk": "Zezwól na lokalny listener RustProxy jako backend (ryzyko)", + "vhLocalListenerRiskHelp": "Ta opcja pojawia się, gdy URL backendu bezpośrednio wskazuje skonfigurowany lub aktywny listener administracyjny RustProxy. localhost, 127.0.0.1 i ::1 są traktowane jako równoważne adresy loopback na tym samym porcie panelu. Jej włączenie może wystawić panel lub utworzyć pętlę proxy; adres prywatny/loopback nadal wymaga opcji „Zezwól na prywatny IP backendu”.", + "vhLocalListenerRiskConfirm": "Ta opcja może kierować żądania do lokalnego listenera RustProxy, w tym portu administracyjnego, oraz utworzyć pętlę lub wystawić control plane. Akceptujesz to ryzyko?", + "cacheResidentHistory": "Historia zajętości RAM cache", + "networkHistory": "Historia RX / TX", + "responseLatencyHistory": "Historia czasu odpowiedzi", + "sourceIp": "IP źródłowe", + "cacheColdOnAge": "Przenoś obiekty do cold po maksymalnym czasie w hot RAM", + "cacheColdMaxHot": "Maksymalny czas w hot RAM (sekundy)", + "cacheColdPolicyHelp": "Czas bezczynności określa, jak długo obiekt nie był używany. Wiek hot określa ciągły czas przebywania w RAM nawet przy dalszych odczytach. Presja LRU i zrzut przy łagodnym zamknięciu są niezależnymi regułami.", + "documentation": "Dokumentacja", + "locationPathPattern": "Ścieżka / wzorzec regex", + "locationMatchType": "Sposób dopasowania", + "locationMatchPrefix": "Prefiks", + "locationMatchPriority": "Priorytetowy prefiks (^~)", + "locationMatchExact": "Dokładna ścieżka (=)", + "locationMatchRegex": "Regex (~)", + "locationMatchRegexI": "Regex bez rozróżniania wielkości liter (~*)", + "locationMatch_prefix": "prefiks", + "locationMatch_prefix_priority": "prefiks priorytetowy", + "locationMatch_exact": "dokładne", + "locationMatch_regex": "regex", + "locationMatch_regex_insensitive": "regex i", + "locationAction": "Akcja", + "locationProxyAction": "Proxy do backendu", + "locationReturnAction": "Własna odpowiedź (return)", + "locationProxyShort": "proxy", + "locationReturnShort": "odpowiedź lokalna", + "locationHandling": "Obsługa żądania", + "locationRewriteMode": "Przepisanie ścieżki do backendu", + "locationRewritePreserve": "Zachowaj oryginalną ścieżkę", + "locationRewriteStrip": "Usuń dopasowany prefiks", + "locationRewriteReplace": "Zastąp dopasowany prefiks", + "locationRewriteRegex": "Zamiana regex", + "locationRewriteValue": "Ścieżka / wyrażenie zastępujące", + "locationRewriteHelp": "Parametry query są zachowywane. Przepisywanie prefiksu działa na znormalizowanej ścieżce routingu. Zamiana regex obsługuje grupy Rust regex, np. $1. Wynik jest walidowany przed wysłaniem do backendu.", + "locationResponseStatus": "Kod odpowiedzi", + "locationResponseType": "Content-Type", + "locationResponseBody": "Treść odpowiedzi", + "headersVisualBuilder": "Graficzny konfigurator", + "locationDocsTitle": "Dokumentacja Custom locations", + "headersDocsTitle": "Dokumentacja reguł nagłówków", + "locationDocsIntro": "Custom locations są oceniane wewnątrz wybranego vhosta po autentykacji i ACL hosta. Nie mogą omijać zabezpieczeń vhosta.", + "locationDocsPrefix": "Prefiks: dopasowuje pełny prefiks segmentów ścieżki. Wygrywa najdłuższy prefiks. Priorytetowy prefiks działa jak nginx ^~ i blokuje pierwszeństwo regexów.", + "locationDocsExact": "Dokładne: pasuje wyłącznie do dokładnej znormalizowanej ścieżki, jak nginx location = /path.", + "locationDocsRegex": "Regex: po obsłudze exact/prioritetowego prefiksu wygrywa pierwszy pasujący regex. Dostępny jest tryb czuły i nieczuły na wielkość liter. Używana jest składnia Rust regex; look-around i backreference w samym wzorcu są celowo niedostępne.", + "locationDocsReturn": "Własna odpowiedź: RustProxy odpowiada lokalnie wybranym kodem HTTP, Content-Type i body bez otwierania połączenia do backendu. Reguły nagłówków odpowiedzi lokalizacji nadal obowiązują.", + "locationDocsRewrite": "Przepisanie ścieżki backendu: zachowanie oryginału, usunięcie/zamiana prefiksu albo zamiana z grupami regex. Oryginalny query string jest zachowany.", + "locationDocsAccess": "ACL IP lokalizacji, nadpisanie cache i reguły nagłówków są łączone z polityką vhosta. Lokalizacja nie może wyłączyć autentykacji ani ACL hosta.", + "locationDocsLimits": "Do 64 lokalizacji na vhost. Regexy i body odpowiedzi mają limity; nagłówki transportu, zweryfikowanej tożsamości i protokołu WebSocket pozostają zarządzane przez system.", + "headersDocsIntro": "Reguły nagłówków są stosowane kolejno: globalne → vhost → pasująca lokalizacja. Do zwykłej konfiguracji użyj graficznego konfiguratora; JSON pozostaje trybem zaawansowanym.", + "headersDocsSet": "Ustaw: zastępuje wszystkie istniejące wartości nagłówka skonfigurowaną wartością dosłowną.", + "headersDocsAppend": "Dopisz: dodaje kolejną wartość bez usuwania istniejących.", + "headersDocsRemove": "Usuń: kasuje nagłówek przed wysłaniem do backendu albo klienta.", + "headersDocsProtected": "Chronionych nagłówków transportu/tożsamości (Host, Connection, Content-Length, Forwarded/X-Forwarded-*, nagłówki dowodu WebSocket itd.) nie można tu nadpisywać.", + "headersDocsOrder": "Wartości są dosłowne; zmienne nginx nie są interpretowane. Reguły request działają przed nałożeniem kanonicznych danych forwardingu, a response przed końcową polityką bezpieczeństwa/diagnostyki RustProxy.", + "cacheColdPolicyTitle": "Polityka hot → cold", + "headerRule": "Reguła nagłówka", + "headerRulesEmpty": "Brak reguł. Dodaj regułę poniżej.", + "headerRuleHelp": "Wybierz Ustaw, Dodaj lub Usuń. Nagłówki transportowe, tożsamości forwardingu i protokołu WebSocket pozostają chronione.", + "locationPreviewTitle": "Podgląd efektywnego działania", + "locationPreviewMatch": "Dopasowanie", + "locationPreviewHandling": "Obsługa", + "locationPreviewRewrite": "Ścieżka do backendu", + "locationPreviewCache": "Cache", + "locationPreviewAccess": "Dostęp", + "locationPreviewHeaders": "Reguły nagłówków", + "locationPreviewDisabled": "Ta lokalizacja jest wyłączona i nie będzie dopasowywać żądań.", + "locationPreviewBackendSkipped": "backend nie jest wywoływany", + "locationPreviewVhostAcl": "Tylko autentykacja i ACL vhosta", + "locationPreviewCustomAcl": "Polityka vhosta + ACL lokalizacji", + "locationPreviewRequestRules": "request", + "locationPreviewResponseRules": "response", + "locationPreviewQueryPreserved": "query string jest zachowywany", + "locationDocsMatchHeading": "1. Dopasowanie i kolejność", + "locationDocsActionHeading": "2. Proxy, rewrite i odpowiedź lokalna", + "locationDocsPolicyHeading": "3. Dostęp, cache i nagłówki", + "locationDocsExamplesHeading": "Popularne przykłady", + "locationDocsExampleExact": "Endpoint health: Dokładna ścieżka (=) /health + Własna odpowiedź 200 odpowiada lokalnie bez łączenia z backendem.", + "locationDocsExampleStatic": "Pliki statyczne: Priorytetowy prefiks (^~) /static/ blokuje przejęcie przez regex; dla tej lokalizacji można osobno włączyć cache.", + "locationDocsExampleRegex": "Wersjonowane API: Regex (~) ^/api/v[0-9]+/ może wybrać backend i używać grup zamiany regex, np. $1.", + "cacheOutcomeHistory": "Aktywność HIT / MISS / BYPASS", + "processCpuHistory": "Historia CPU procesu", + "processRssHistory": "Historia RSS procesu", + "cacheMemoryBreakdown": "Cache hot / pamięć przejściowa", + "cacheTransientBreakdown": "Fill / kolejka cold / I/O cold", + "cacheTransientMemory": "Pamięć przejściowa cache", + "cacheFillShort": "fill", + "cacheQueueShort": "kolejka cold", + "cacheIoShort": "I/O cold", + "processRssTotal": "RSS łącznie", + "processRssAnon": "RSS anonimowy", + "processRssFile": "RSS plików / współdzielony", + "processDataSwap": "Obszar danych / swap", + "memoryAccountingHint": "Kategorie RSS systemu i pomiary cache RustProxy częściowo się pokrywają; nie należy ich sumować.", + "logTransport": "Transport", + "logWebSocket": "WebSocket", + "logUpstreamError": "Błąd upstreamu", + "listenerDefaultPortsHint": "Nowy vhost automatycznie wybiera standardowy listener HTTP :80, jeśli jest dostępny.", + "vhBackendDiagnostics": "Diagnostyka backendu", + "vhBackendHeader": "Udostępniaj wybrany backend (X-RustProxy-Backend)", + "vhBackendTimingHeader": "Udostępniaj czas odpowiedzi backendu (X-RustProxy-Backend-Time)", + "vhBackendDiagnosticsHelp": "Opcjonalne nagłówki odpowiedzi. Czas backendu oznacza czas od wysłania żądania do otrzymania nagłówków odpowiedzi; odpowiedzi obsłużone bez kontaktu z originem nie raportują backendu.", + "authModeHelp": "Wybierz jedną metodę uwierzytelniania. Lista dostępu IP poniżej działa niezależnie i może być używana razem z dowolną metodą.", + "taAdvanced": "Zaawansowane ustawienia Tinyauth", + "notfound": "Hosty 404", + "notfoundTitle": "Hosty 404 pod kontrolą.", + "notfoundDescription": "Rezerwuj domeny, które mają lokalnie zwracać kod, zaślepkę albo własny HTML.", + "notfoundPageIntro": "Hosty 404 rezerwują wybrane domeny i odpowiadają lokalnie bez łączenia z backendem. Mogą używać tych samych listenerów i certyfikatów co vhosty.", + "notfoundLayer": "ODPOWIEDŹ LOKALNA", + "notfoundList": "Hosty 404", + "notfoundSearch": "Szukaj hostów 404", + "notfoundSearchPlaceholder": "ID, nazwa lub domena…", + "notfoundNew": "Nowy host 404", + "notfoundEditor": "Konfiguracja hosta 404", + "notfoundId": "ID hosta", + "notfoundName": "Nazwa wyświetlana", + "notfoundFrontend": "FRONTEND", + "notfoundBehavior": "ODPOWIEDŹ", + "notfoundResponse": "Odpowiedź lokalna", + "notfoundMode": "Typ odpowiedzi", + "notfoundModeWelcome": "Zaślepka RustProxy", + "notfoundModeEmpty": "Tylko status / pusta", + "notfoundModeHtml": "Własny HTML", + "notfoundStatus": "Status HTTP", + "notfoundHtml": "Własny HTML", + "notfoundResponseHelp": "Host nigdy nie otwiera połączenia do backendu. Wbudowana zaślepka ma no-store i celowo nie dodaje opcjonalnych nagłówków diagnostycznych ani Server RustProxy.", + "notfoundHosts": "Domeny", + "notfoundNewLabel": "NOWY", + "notfoundHostsRequired": "Dodaj co najmniej jedną domenę.", + "notfoundListenersRequired": "Wybierz co najmniej jeden listener.", + "notfoundHtmlRequired": "Własny HTML nie może być pusty.", + "notfound204Empty": "Odpowiedzi HTTP 204, 205 i 304 muszą używać trybu Pusta.", + "notfoundDuplicate": "Host 404 o takim ID już istnieje.", + "notfoundUnsaved": "Odrzucić niezapisane zmiany hosta 404?", + "notfoundEmpty": "Brak skonfigurowanych hostów 404.", + "notfoundSearchEmpty": "Brak hostów 404 pasujących do wyszukiwania.", + "prometheusTrustedNoToken": "Zezwól zaufanym CIDR na odczyt bez tokenu", + "prometheusTrustedNoTokenHelp": "Klient nadal musi pasować do listy dozwolonych CIDR. Włączaj tylko w prywatnej, zaufanej sieci monitoringu; nagłówki przekazywane nie są używane do tej decyzji.", + "docker-vhosts": "Docker vhosty", + "docker-vhostsTitle": "Usługi Dockera pod nazwami.", + "docker-vhostsDescription": "Wykrywaj wystawione porty Dockera i mapuj uslugi na nazwy w domenie agenta, wlasne domeny oraz aliasy bez zapisywania obserwowanego stanu do SQLite.", + "dockerVhosts": "Docker vhosty", + "dockerVhostsIntro": "Wystawione porty TCP wykryte przez skonfigurowane agenty Dockera. Dla każdej usługi ustaw krótką nazwę w domenie agenta albo dowolny FQDN, dodaj aliasy, wybierz IP hosta Dockera oraz sprawdzaj i konfiguruj stan backendu na żywo.", + "dockerDiscovery": "WYKRYWANIE DOCKERA", + "dockerRefresh": "Odśwież agentów", + "dockerAgentsSettings": "Ustawienia agentów Dockera", + "dockerAgents": "Agenty Dockera", + "dockerDesiredState": "STAN ŻĄDANY / SQLITE", + "dockerAgentsHelp": "Skonfiguruj osobnego agenta dla każdego hosta Dockera. Domyślnie agent używa HTTPS ze stałym certyfikatem self-signed; wklej jego fingerprint SHA-256, aby przypiąć certyfikat. Każdy agent ma własną domenę bazową i opcjonalne domyślne IP backendu.", + "dockerAgentAdd": "Dodaj agenta Dockera", + "dockerAgentsSave": "Zapisz i połącz agentów", + "dockerAgent": "AGENT DOCKERA", + "dockerAgentNew": "Nowy agent", + "dockerAgentId": "ID agenta", + "dockerAgentName": "Nazwa wyświetlana", + "dockerAgentEndpoint": "Adres agenta", + "dockerAgentToken": "Token Bearer", + "dockerBaseDomain": "Domena bazowa", + "dockerTargetOverride": "Nadpisanie IP docelowego", + "dockerTargetAuto": "automatyczne", + "dockerListeners": "Listenery", + "dockerListenersRequired": "Wybierz co najmniej jeden listener dla agenta Docker.", + "dockerNoAgents": "Brak skonfigurowanych agentów Dockera.", + "dockerNoAgentsConfigured": "Najpierw skonfiguruj agenta Dockera w Ustawieniach.", + "dockerPublish": "Wystaw", + "dockerPreserveHost": "Zachowaj Host", + "dockerServiceMissing": "Brak w ostatnim odczycie", + "dockerConnected": "POŁĄCZONO", + "dockerError": "BŁĄD", + "dockerWaiting": "OCZEKIWANIE", + "dockerAgentDisabled": "Ten agent Dockera jest wyłączony.", + "dockerNoObservation": "Brak odczytu. Odśwież agentów.", + "dockerHost": "Host Dockera", + "dockerDetectedIp": "Wykryte IP", + "dockerEffectiveIp": "IP routingu", + "dockerNoPublishedPorts": "Agent nie zgłosił wystawionych portów TCP.", + "dockerAssignmentsSave": "Zapisz Docker vhosty", + "dockerDefaultTarget": "Domyslne IP backendu", + "dockerDetectedIps": "Wykryte adresy IP hosta", + "dockerHostname": "Nazwa hosta", + "dockerHostnameHelp": "Krotka nazwa uzyje {domain}; pelny FQDN uzyje wlasnej domeny.", + "dockerAliases": "Aliasy", + "dockerAliasAdd": "Dodaj alias", + "dockerAliasesHelp": "Dodawaj aliasy klawiszem Enter lub przecinkiem. Krótkie nazwy użyją domeny bazowej agenta, a pełne FQDN pozostaną bez zmian.", + "dockerBackendIp": "IP hosta backendu", + "dockerBackendIpAuto": "Auto / domyslne agenta", + "dockerBackendScheme": "Protokol backendu", + "dockerBindingIp": "binding Dockera", + "dockerAgentDefaultIp": "domyslne agenta", + "dockerAgentEndpointHelp": "Dla domyślnej konfiguracji agenta użyj https://. Zwykły HTTP pozostaje tylko dla zgodności i jest wyraźnie oznaczony jako niezabezpieczony.", + "dockerTlsFingerprint": "SHA-256 certyfikatu TLS", + "dockerTlsFingerprintHelp": "Dla domyślnego certyfikatu self-signed wklej fingerprint wypisany przez install.sh albo rustproxy-docker-agent --print-fingerprint. Zostaw puste wyłącznie dla certyfikatu zaufanego przez publiczne CA.", + "dockerTlsPinned": "TLS PRZYPIĘTY", + "dockerTlsPublicCa": "TLS / PUBLICZNE CA", + "dockerTlsPlain": "ZWYKŁY HTTP", + "dockerTlsPlainHelp": "Token agenta i dane inventory nie są chronione przez TLS na tym endpointcie.", + "dockerTlsPublicCaHelp": "Łańcuch certyfikatu i nazwa hosta są weryfikowane względem publicznych CA.", + "dockerAgentConnectionFailed": "Nie udało się połączyć z agentem", + "dockerAgentVersion": "Wersja agenta", + "dockerLastChecked": "Ostatnie sprawdzenie", + "dockerNeverChecked": "nigdy", + "dockerJustNow": "przed chwilą", + "dockerSecondsAgo": "{seconds} s temu", + "dockerMinutesAgo": "{minutes} min temu", + "dockerBackendMissing": "BRAK USŁUGI", + "dockerBackendNotActive": "NIEWYSTAWIONY", + "dockerBackendChecking": "SPRAWDZANIE", + "dockerBackendHealthOff": "HEALTH WYŁ.", + "dockerBackendUp": "UP", + "dockerBackendDown": "DOWN", + "dockerBackendDraining": "WYGASZANIE", + "dockerBackendNoMetrics": "Metryki runtime pojawią się po aktywacji dynamicznego backendu.", + "dockerHealthCheck": "Badanie backendu", + "dockerHealthSummary": "Aktywne testy TCP/HTTP; pasywne zdrowie jest konfigurowane osobno", + "dockerPassiveHealth": "Pasywne zdrowie backendu", + "dockerPassiveHealthSummary": "Domyślnie wyłączone; wymaga świadomego użycia zwykłego ruchu do decyzji health", + "dockerHealthMode": "Tryb", + "dockerHealthHttp": "HTTP", + "dockerHealthTcp": "TCP", + "dockerHealthOff": "Wyłączone", + "dockerHealthPath": "Ścieżka HTTP", + "dockerHealthHost": "Nagłówek Host health-checka", + "dockerHealthHostAuto": "host backendu / automatycznie", + "dockerHealthInterval": "Interwał (ms)", + "dockerHealthTimeout": "Timeout (ms)", + "dockerHealthSlow": "Próg wolnej odpowiedzi (ms)", + "dockerHealthFall": "Błędy do oznaczenia DOWN", + "dockerHealthRise": "Sukcesy do oznaczenia UP", + "dockerHealthStatusMin": "Min. status HTTP", + "dockerHealthStatusMax": "Maks. status HTTP", + "dockerHealthLatency": "Opóźnienie", + "dockerHealthResult": "Ostatni probe", + "dockerHealthRequests": "Żądania", + "dockerHealthErrors": "Błędy", + "dockerHealthActive": "Aktywne", + "loggingPerVhost": "Osobny plik per vhost", + "loggingDirectory": "Katalog logów per-vhost", + "loggingPerVhostHelp": "Po włączeniu każdy vhost zapisuje do własnego pliku JSONL. Ruch niedopasowany trafia do _unmatched.jsonl.", + "listenerStartupLocked": "Tego adresu ustawionego przy starcie procesu/systemd nie można zmienić ani usunąć. Pozostałe adresy i opcje listenera nadal można edytować.", + "administrationStartupLocked": "Ten adres jest ustawiony przy starcie procesu/systemd i nie można go tutaj zmienić.", + "notfoundStatusInvalid": "Wybierz obsługiwany status błędu HTTP.", + "tlsEffectiveCiphers": "Aktualnie używane szyfry", + "certificateManaged": "Zarządzany przez ACME", + "certManagedReadOnly": "Certyfikaty zarządzane przez ACME są odnawiane automatycznie i nie można ich edytować jak własnych certyfikatów z plików.", + "vhWebsocketTransport": "Transport WebSocket", + "vhWebsocketHttp1": "HTTP/1.1 Upgrade (zalecane)", + "vhWebsocketRfc9220": "RFC 9220 Extended CONNECT przez HTTP/3", + "vhWebsocketTransportHelp": "Włącz WebSocket osobnym przełącznikiem, a następnie wybierz transport. Tryby H2/H3 używają Extended CONNECT, a HTTP/1.1 Upgrade pozostaje fallbackiem.", + "dockerLiveView": "Widok live", + "dockerPollInterval": "Interwał odpytywania agenta (s)", + "dockerPollIntervalHelp": "Jak często RustProxy pobiera inventory Dockera z tego agenta. Widok live czyta tylko stan z pamięci RustProxy.", + "dockerAgentPollEvery": "Interwał agenta", + "dockerSearch": "Szukaj Docker vhostów", + "dockerSearchPlaceholder": "Szukaj usługi, hosta, aliasu lub agenta...", + "dockerShowHidden": "Pokaż ukryte", + "dockerHideHidden": "Ukryj ukryte", + "dockerHideVhost": "Ukryj z widoku", + "dockerUnhideVhost": "Pokaż w widoku", + "dockerCertificate": "Certyfikat frontendu", + "dockerCertificateHelp": "Certyfikat przypisany do wszystkich generowanych vhostów tego agenta. Domyślna opcja używa standardowego wyboru TLS/SNI.", + "dockerDefaultAcl": "Domyślny profil ACL", + "dockerDefaultAclNone": "Bez domyślnego profilu ACL", + "dockerDefaultAclHelp": "Dziedziczony przez generowane Docker vhosty, które nie mają wybranego własnego profilu ACL per aplikacja.", + "dockerApplyService": "Zastosuj zmiany", + "dockerAccessControl": "Kontrola dostępu", + "dockerAclSummary": "Opcjonalny profil ACL i reguły IP per aplikacja", + "dockerAclProfile": "Profil ACL", + "dockerAclProfileNone": "Bez profilu ACL", + "dockerAclProfileInherit": "Domyślny agenta", + "dockerAclInline": "Użyj dodatkowego ACL per aplikacja", + "dockerAclDefault": "Domyślna akcja ACL", + "dockerAclRules": "Reguły IP/CIDR", + "dockerAclHelp": "Jedna reguła w linii, np. allow 10.0.0.0/8. Profil i ACL per aplikacja są egzekwowane łącznie.", + "dockerProxyTimeouts": "Timeouty proxy", + "dockerProxyTimeoutsHelp": "Jawne limity per aplikacja: domyślnie połączenie 10 s, nagłówki odpowiedzi 120 s, odczyt 60 s i wysyłanie 60 s.", + "dockerHostnameRequired": "Przed wystawieniem wymagany jest hostname.", + "dockerForceSsl": "Przekieruj HTTP do HTTPS", + "dockerForceSslUnavailable": "Wymaga aktywnego listenera zwykłego HTTP oraz jednego jednoznacznego listenera TLS dla tego agenta.", + "dockerWebsockets": "WebSockety", + "dockerCache": "Cache", + "vhProxyTimeouts": "Timeouty proxy", + "vhProxyConnectTimeout": "Proxy connect timeout (s)", + "vhProxyHeaderTimeout": "Proxy response header timeout (s)", + "vhProxyReadTimeout": "Proxy read timeout (s)", + "vhProxySendTimeout": "Proxy send timeout (s)", + "vhProxyTimeoutsHelp": "Domyślnie: połączenie 10 s, nagłówki odpowiedzi 120 s, odczyt 60 s i wysyłanie 60 s. Read/send to timeout bezczynności podczas streamowania body.", + "notfoundModeBuiltin": "Zaślepka aplikacyjna dla statusu", + "pageBuiltin": "Wbudowana strona statusu", + "vhOpenBrowser": "Otwórz", + "securityPasswordHint": "Wymaga obecnego hasła i wyloguje wszystkie sesje.", + "securityAccessControl": "KONTROLA DOSTĘPU", + "securityPoliciesHint": "Lista polityk pozostaje widoczna; edytor otwieraj tylko przy dodawaniu lub zmianie reguł.", + "securityPolicyEditorHint": "Zakres hostów, domyślna akcja i reguły CIDR.", + "securityAuditOpen": "Audyt administracyjny", + "securityAuditHint": "Ostatnie operacje administracyjne są pobierane dopiero po otwarciu tego okna.", + "dockerConfigureService": "Konfiguruj vhost", + "dockerAliasesShort": "aliasów", + "dockerAgentDetails": "Szczegóły agenta", + "dockerServices": "Usługi", + "dockerOpenApp": "Otwórz aplikację", + "networkInterfacesHistory": "Ruch według interfejsów", + "networkInterfaceRate": "RX / TX", + "networkNoInterfaces": "Brak dostępnych interfejsów sieciowych innych niż loopback.", + "networkInterfacesCount": "{count} interfejsów", + "logExpandBuffer": "Zwiększ bufor", + "logExpandedBuffer": "Bufor 4096", + "logBufferExpanded": "Bufor live zwiększono do {count} zachowanych wpisów.", + "vhIdRenameHelp": "Zmiana ID vhosta tworzy nową tożsamość metryk/logów/cache i resetuje bieżącą historię health/traffic. Hostname’y i skonfigurowane ID backendów pozostają bez zmian.", + "vhIdRenameConfirm": "Zmienić ID vhosta z {old} na {new}? Bieżąca historia health/traffic oraz tożsamość metryk/cache tego vhosta zostaną rozpoczęte od nowa.", + "cacheDomainFilter": "Pokaż", + "cacheDomainFilterAll": "Wszystkie domeny", + "cacheDomainFilterEnabled": "Cache włączony", + "cacheDomainFilterDisabled": "Cache wyłączony", + "cacheNoDomainsFiltered": "Brak domen pasujących do tego filtra.", + "cacheColdCleanup": "Wyczyść cold cache", + "cacheColdCleanupHelp": "Operacja działa asynchronicznie i korzysta z tego samego mechanizmu eviction co automatyczny GC cold cache.", + "cacheColdUnusedSeconds": "Nieużywane co najmniej przez (sekundy, opcjonalnie)", + "cacheColdOldestCount": "Usuń maksymalnie najstarszych obiektów", + "cacheColdHitsZero": "Tylko obiekty z hits = 0", + "cacheColdCleanupStart": "Uruchom czyszczenie", + "cacheColdCleanupRunning": "Trwa czyszczenie cold cache…", + "cacheColdCleanupDone": "Usunięto {count} obiektów i zwolniono {bytes}.", + "cacheColdCleanupFailed": "Czyszczenie cold cache nie powiodło się.", + "cacheColdCleanupInvalidJob": "Czyszczenie cold cache nie zwróciło identyfikatora zadania.", + "cacheColdGcInterval": "Interwał GC cold cache (sekundy)", + "cacheColdGcMaxDelete": "Maksymalna liczba usunięć na przebieg GC", + "transformSettings": "Transformacja odpowiedzi statycznych", + "transformEnable": "Włącz globalnie silnik transformacji", + "transformHelp": "Transformacja odbywa się po dekodowaniu upstreamu, a przed zapisem do cache i kompresją dla klienta. Każdy vhost musi zostać włączony osobno.", + "transformJavascript": "Minifikacja JavaScript", + "transformCss": "Minifikacja CSS", + "transformHtml": "Minifikacja HTML", + "transformSvg": "Optymalizacja SVG", + "transformJson": "Kompaktowanie JSON", + "transformExtensions": "Dozwolone rozszerzenia plików, po jednym w linii", + "transformMaxBody": "Maksymalny rozmiar transformowanej odpowiedzi (KiB)", + "transformSafety": "Cache-Control: no-transform jest zawsze respektowane. Przetransformowana reprezentacja otrzymuje nowy ETag i jest zapisywana do cache przed gzip, Brotli lub zstd.", + "saveTransform": "Zastosuj ustawienia transformacji", + "vhTransform": "Transformacja", + "vhTransformEnable": "Włącz transformacje dla tego vhosta", + "vhTransformExtensionsCustom": "Nadpisz globalne rozszerzenia transformacji", + "vhTransformHelp": "Globalny silnik również musi być włączony. Ustawienia formatów dziedziczą politykę globalną, chyba że zostaną jawnie nadpisane.", + "dockerTransform": "Transformuj odpowiedzi statyczne", + "vhPassive5xxWarning": "Zliczanie HTTP 5xx może wyłączyć cały backend po serii błędów aplikacyjnych z pojedynczego endpointu.", + "vhPassiveSlowWarning": "Zliczanie wolnych odpowiedzi może wyłączyć backend po serii poprawnych odpowiedzi przekraczających próg slow-response.", + "transform": "Transformacja", + "transformTitle": "Transformacja odpowiedzi statycznych.", + "transformDescription": "Skuteczność transformacji, oszczędność, pominięcia i ponowne użycie z cache.", + "transformApplied": "Przetransformowane odpowiedzi", + "transformExamined": "Sprawdzone", + "transformCacheHits": "HIT-y transformowanych obiektów", + "transformCacheHitsHelp": "Obsłużone bez ponownego uruchamiania transformacji", + "transformSaved": "Bajty oszczędzone przed kompresją", + "transformReduction": "Średnia redukcja", + "transformReductionHelp": "Dla odpowiedzi, których reprezentacja faktycznie się zmieniła", + "transformSkipped": "Pominięte / fail-open", + "transformErrors": "Błędy body / transportu", + "transformAvgTime": "Średni czas", + "transformByType": "Transformacje według typu", + "transformType": "Typ", + "transformSkipReasons": "Powody pominięcia / fail-open", + "transformNoSkips": "Brak pominiętych transformacji w tym zakresie.", + "transformNoSkipsShort": "Brak pominięć", + "transformVhosts": "Skuteczność per vhost", + "transformScopeHint": "Liczniki są diagnostyką runtime w pamięci. HIT transformowanego obiektu z cache nie uruchamia transformera ponownie.", + "transformNoData": "Brak aktywności transformacji.", + "transformBadge": "transformowany", + "cacheLiveChartTitle": "Aktywność cache na żywo", + "cacheLiveChartHelp": "HIT / MISS / BYPASS na sekundę dla wybranego zakresu", + "liveChartSamples": "Próbki", + "transformLiveChartTitle": "Aktywność transformacji na żywo", + "transformLiveChartHelp": "Zastosowane / HIT cache / pominięte / błędy na sekundę dla wybranego zakresu", + "transformChartApplied": "Zastosowane", + "transformChartCacheHits": "HIT cache", + "transformChartSkipped": "Pominięte", + "cacheInvalidationTitle": "Purge / BAN cache", + "cacheInvalidationOperation": "Operacja", + "cacheInvalidationSelector": "Selektor", + "cacheInvalidationValue": "Wartość", + "cacheInvalidationHost": "Host (opcjonalnie)", + "cacheBanTtl": "TTL BAN (sekundy)", + "cacheInvalidationHelp": "Wybrany vhost ogranicza zakres operacji. Exact i prefix uwzględniają query string. Operacje regex i prefix / wymagają dodatkowego potwierdzenia.", + "cacheRunInvalidation": "Wykonaj operację", + "cacheActiveBans": "Aktywne reguły BAN", + "cacheBanExpires": "Wygasa", + "cacheNoBans": "Brak aktywnych reguł BAN.", + "cacheTarget": "Target", + "cacheKeyTags": "Klucz cache / tagi", + "cachePurgeUrl": "Purge URL", + "cacheRemoveBan": "Usuń BAN", + "cacheBanCreated": "Utworzono BAN", + "cacheConfirmPurgeUrl": "Usunąć ten URL z cache RAM i cold storage?", + "cacheConfirmRemoveBan": "Usunąć tę regułę BAN?", + "cacheWideInvalidationConfirm": "To szeroka operacja unieważniania i może objąć wiele obiektów cache. Kontynuować?", + "cacheCollapseWaiters": "oczekuje", + "cacheCollapsed": "scalone", + "actions": "Akcje", + "cacheCollapseTitle": "Scalanie żądań", + "cacheCollapseEnable": "Scalaj równoczesne fille dla jednego klucza cache", + "cacheCollapseTimeout": "Limit oczekiwania followera (ms)", + "cacheCollapseFallback": "Fallback po timeout", + "cacheCollapseFallbackOrigin": "Origin", + "cacheCollapseFallbackStale": "Stale, jeśli dostępny", + "cacheStaleErrorTitle": "Polityka stale-if-error", + "cacheSieTimeout": "Timeout", + "cacheSieConnection": "Błąd połączenia", + "cacheSieDns": "Błąd DNS", + "cacheSieTls": "Błąd TLS", + "cacheSieStatuses": "Statusy HTTP 5xx dopuszczające stale fallback", + "cacheProactiveTitle": "Proaktywne odświeżanie", + "cacheProactiveEnable": "Odświeżaj próbkę obiektów cache przed wygaśnięciem", + "cacheProactiveBefore": "Odśwież przed wygaśnięciem (sekundy)", + "cacheProactivePercent": "Próbkowane obiekty (%)", + "cacheProactiveInflight": "Maksymalna liczba równoległych odświeżeń", + "cacheProactiveHelp": "Używa rewalidacji validatorów, deterministycznego próbkowania i globalnego limitu współbieżności, aby zapobiegać lawinie odświeżeń.", + "cacheInvalidationOpen": "Purge / Ban", + "cacheBanListOpen": "Aktywne BAN-y", + "cacheBanRemoved": "Reguła BAN została usunięta", + "tunnelsOpen": "Aktywne tunele", + "tunnelsTitle": "Aktywne WebSockety / tunele", + "tunnelsReload": "Odśwież", + "tunnelsEmpty": "Brak aktywnych tuneli.", + "tunnelId": "ID", + "tunnelClient": "Klient", + "tunnelHost": "Host", + "tunnelUpstream": "Upstream", + "tunnelProtocol": "Protokół", + "tunnelDuration": "Czas", + "tunnelRx": "RX", + "tunnelTx": "TX", + "tunnelKill": "Zamknij", + "tunnelKilled": "Tunel zamknięty przez administratora", + "tunnelKillConfirm": "Zamknąć ten tunel?", + "tunnelKillReason": "Powód zamknięcia", + "tunnelKillReasonPlaceholder": "Zamknięcie administracyjne", + "tunnelKillReasonDefault": "panel administracyjny", + "listenerH2c": "h2c HTTP/2 bez TLS (prior knowledge + Upgrade z HTTP/1.1)", + "vhWebsocketRfc8441": "RFC 8441 Extended CONNECT przez HTTP/2", + "wsH1": "H1 Upgrade", + "wsH2Rfc8441": "H2 RFC 8441", + "wsH3Rfc9220": "H3 RFC 9220", + "wsEstablished": "Utworzone", + "httpProtocolMix": "Miks protokołów HTTP", + "wsProtocolMix": "Miks protokołów WebSocket", + "vhWebsocketMode": "Transport WebSocket", + "vhWebsocketDisabled": "Wyłączony", + "vhWebsocketH1": "HTTP/1.1 Upgrade", + "vhWebsocketH2Fallback": "HTTP/2 RFC 8441 + fallback H1", + "vhWebsocketH3Fallback": "HTTP/3 RFC 9220 + fallback H1", + "vhWebsocketH2H3Fallback": "HTTP/2 + HTTP/3 + fallback H1", + "vhMaxConcurrentRequests": "Maks. równoległych żądań", + "vhBackendQueueTimeout": "Timeout kolejki pojemności (ms)", + "vhBackendCapacityHelp": "0 równoległych żądań oznacza brak limitu. Timeout kolejki równy 0 natychmiast zwraca HTTP 503 po osiągnięciu limitu backendu.", + "vhLastFailure": "Ostatni błąd", + "vhLastRecovery": "Ostatni powrót", + "certificateIssuerName": "Wystawca certyfikatu", + "certificateValidFrom": "Ważny od", + "certificateDaysRemaining": "Pozostało dni", + "certificateExpired": "Certyfikat wygasł", + "certificateExpiresSoon": "Certyfikat wkrótce wygaśnie", + "certificateRenewalStatus": "Status odnawiania", + "certificateRenewalError": "Ostatni błąd odnawiania", + "vhActiveHealth": "Active health", + "vhPassiveFailures": "Błędy passive health", + "certificateAdminRestartRequired": "TLS panelu administracyjnego używa konfiguracji ładowanej przy starcie; uruchom ponownie RustProxy, aby odświeżyć ten certyfikat.", + "cacheTabGeneral": "Ogólne", + "cacheTabResilience": "Odporność", + "cacheTabCold": "Cold cache", + "cacheTabDiagnostics": "Statyczne i diagnostyka", + "certificateApiTitle": "API certyfikatów", + "certificateApiHelp": "Twórz osobne poświadczenia Key ID + Bearer Token dla automatyzacji wdrażania i przeładowywania certyfikatów. Tokeny nie dają dostępu do konfiguracji ani pozostałych API administracyjnych.", + "certificateApiKeyId": "Key ID", + "certificateApiToken": "Bearer token (opcjonalna własna wartość)", + "certificateApiTokenHelp": "Pozostaw pole puste, aby wygenerować silny token. Jawna wartość każdego tokenu jest pokazywana tylko raz; RustProxy zapisuje wyłącznie jego hash.", + "certificateApiRotate": "Generuj / obróć token", + "certificateApiRevoke": "Unieważnij token", + "certificateApiOneTimeToken": "Skopiuj ten token teraz", + "certificateApiCurlHelp": "Automatyzacja zewnętrzna wysyła nagłówek Key ID i Bearer Token do endpointów deploy/reload przez HTTPS (lub HTTP po loopback).", + "certificateApiUnknown": "Ładowanie statusu API certyfikatów…", + "certificateApiConfigured": "API certyfikatów włączone", + "certificateApiDisabled": "API certyfikatów wyłączone", + "certificateApiLastUsed": "ostatnio użyty", + "certificateApiRotated": "Token API certyfikatów został utworzony / zmieniony. Skopiuj go teraz.", + "certificateApiRevoked": "Token API certyfikatów został unieważniony.", + "certificateApiRevokeConfirm": "Unieważnić token zewnętrznego API certyfikatów? Certbot i integracje API używające go natychmiast przestaną działać.", + "certificate_api.invalid_token": "Nieprawidłowy Key ID lub Bearer Token API certyfikatów.", + "certificate_api.https_required": "Zewnętrzny token API certyfikatów wymaga HTTPS, chyba że żądanie pochodzi z loopback.", + "certificate_api.invalid_token_format": "Token API certyfikatów musi mieć 32–128 bezpiecznych znaków URL.", + "certificate_api.invalid_key_id": "Nieprawidłowy Key ID API certyfikatów.", + "vhWebsocketLegacySupport": "Włącz zgodność ze starym WebSocket", + "vhWebsocketLegacySupportHelp": "Dla starszych urządzeń i serwerów embedded. Handshake WebSocket do upstreamu używa HTTP/1.1, Host i Origin są dopasowane do skonfigurowanego adresu backendu, a stary format odpowiedzi 101 jest tolerowany. Zwykły ruch HTTP pozostaje bez zmian.", + "vhWebsocketLegacyBadge": "WebSocket legacy", + "runtimeInformation": "Informacje środowiska", + "runtimeInformationHelp": "Wartości raportuje uruchomiony proces RustProxy. Są dostępne tylko w uwierzytelnionym API administratora.", + "runtimePlatform": "Platforma", + "statisticsSource": "Źródło statystyk", + "dataDirectory": "Katalog danych", + "databaseLocation": "Baza danych", + "connectionActivity": "AKTYWNOŚĆ POŁĄCZEŃ", + "reloadCertificate": "Przeładuj certyfikat", + "certificateApiComment": "Komentarz", + "certificateApiConfiguredCount": "Tokeny API certyfikatów: {count} / {max}", + "certificateApiNeverUsed": "nigdy nie użyty", + "certificateApiRevokeAll": "Unieważnij wszystkie tokeny", + "certificateApiRevokeOneConfirm": "Unieważnić token API certyfikatów {key}? Automatyzacja używająca tego tokenu natychmiast przestanie działać.", + "certificateApiRevokeAllConfirm": "Unieważnić wszystkie tokeny API certyfikatów? Cała automatyzacja zewnętrzna korzystająca z tych tokenów natychmiast przestanie działać.", + "certificate_api.invalid_comment": "Komentarz tokenu API certyfikatów może mieć maksymalnie 256 znaków i nie może zawierać znaków sterujących.", + "certificate_api.limit_reached": "Osiągnięto limit tokenów API certyfikatów.", + "certificate_api.not_found": "Nie znaleziono tokenu API certyfikatów.", + "networkInterfacesVisibleCount": "{visible} / {total} interfejsów", + "hideMetric": "Ukryj", + "showMetric": "Pokaż", + "hiddenInterfaces": "Ukryte interfejsy", + "networkNoVisibleInterfaces": "Wszystkie wykryte interfejsy są ukryte w tej przeglądarce.", + "cacheMemorySizingTitle": "Dobór pamięci", + "cacheMemorySizingHelp": "Presety korzystają z efektywnej pamięci dostępnej dla RustProxy (RAM hosta ograniczony limitem cgroup, jeśli występuje). Ustawiają HOT, pamięć przejściową, rozmiar obiektu, liczbę wpisów i kolejkę demotion; limit dysku COLD pozostaje bez zmian.", + "cachePresetConservative": "Oszczędny", + "cachePresetRecommended": "Zalecany", + "cachePresetPerformance": "Wydajnościowy", + "cacheTransientLimit": "Limit pamięci przejściowej (MiB)", + "cacheEvictionLimit": "Maks. usunięć LRU na przyjęcie obiektu", + "cacheColdQueueLimit": "Limit RAM kolejki COLD (MiB)", + "cacheMemoryNotDetected": "Nie wykryto pamięci", + "cacheMemoryPresetUnavailable": "Automatyczne presety wymagają telemetrii RAM hosta lub limitu cgroup.", + "cacheMemoryCgroup": "Efektywny RAM cgroup", + "cacheMemoryDetected": "Wykryty RAM", + "cacheQueueOverTransientWarning": "Limit kolejki COLD nie może przekraczać limitu pamięci przejściowej.", + "cacheMemoryHighWarning": "Ostrzeżenie: budżety HOT + pamięć przejściowa zajmują dużą część efektywnego RAM:", + "cachePresetTransientShort": "przejściowa", + "cachePresetObjectShort": "maks. obiekt", + "cachePresetQueueShort": "kolejka COLD", + "cacheLogicalShort": "logiczne", + "cacheLimitShort": "limit", + "cacheMemoryPressureShort": "presja pamięci", + "cacheNukeLimitShort": "limit LRU", + "cacheMemoryPresetTooSmall": "Automatyczne presety cache są wyłączone poniżej 256 MiB efektywnego RAM; jeśli to celowe, skonfiguruj cache ręcznie.", + "cacheMemoryRecommendedFor": "Zalecane dla wykrytej pamięci:", + "cacheMemoryHeadroomWarning": "Ostrzeżenie: dodatkowy budżet cache jest duży względem aktualnie dostępnej pamięci hosta/cgroup:", + "cacheObjectOverTransientWarning": "Maksymalny rozmiar obiektu przekracza limit pamięci przejściowej; obiekty zbliżone do tego rozmiaru przejdą bez zapisania w cache.", + "vhCacheGlobalDisabled": "Globalny cache jest wyłączony. Ten vhost nie może włączyć cache, dopóki cache globalny pozostaje wyłączony." + } +} diff --git a/make_zip.py b/make_zip.py new file mode 100755 index 0000000..e020b2c --- /dev/null +++ b/make_zip.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Create a deterministic complete source-only release without Git or live state.""" +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import os +from pathlib import Path +import stat +import zipfile + +ROOT = Path(__file__).resolve().parent +MANAGED = [ + 'src', 'tests', 'scripts', 'nagios', 'docker-agent', 'migrations', 'examples', 'docs', 'langs', 'deploy', 'debian', 'grafana', + '.github', '.cargo', 'Cargo.toml', 'Cargo.lock', 'build.rs', 'rust-toolchain.toml', 'UPDATE_CHECK.toml', + 'dev.sh', 'upgrade.sh', 'install.sh', 'install-debian13.sh', 'update.sh', 'README.md', 'ROADMAP.md', + 'CHANGELOG.md', 'AGENTS.md', '.env.example', '.gitignore', '.dockerignore', + 'Dockerfile', 'make_zip.py', +] +EXCLUDED = {'__pycache__', '.DS_Store', 'node_modules', 'target', 'data', '.git', '.upgrade-backup'} +EXECUTABLE_ROOT = {'dev.sh', 'upgrade.sh', 'install.sh', 'install-debian13.sh', 'update.sh', 'make_zip.py'} +ZIP_MIN_EPOCH = 315532800 # 1980-01-01T00:00:00Z +ZIP_MAX_EPOCH = 4354819198 # 2107-12-31T23:59:58Z +DEFAULT_SOURCE_DATE_EPOCH = 946684800 # 2000-01-01T00:00:00Z + + +def files() -> list[Path]: + result: list[Path] = [] + for name in MANAGED: + path = ROOT / name + if not path.exists(): + continue + if path.is_symlink(): + raise ValueError(f'Refusing symlink in source package: {name}') + paths = path.rglob('*') if path.is_dir() else [path] + for file in paths: + rel = file.relative_to(ROOT) + if any(part in EXCLUDED for part in rel.parts) or file.suffix in ('.pyc', '.pyo', '.zip'): + continue + if rel.as_posix() == 'docker-agent/docker-agent': + continue + if file.is_symlink(): + raise ValueError(f'Refusing symlink in source package: {rel}') + if file.is_file(): + result.append(file) + return sorted(set(result), key=lambda path: path.relative_to(ROOT).as_posix()) + + +def release_datetime() -> tuple[int, int, int, int, int, int]: + raw = os.environ.get('SOURCE_DATE_EPOCH') + try: + epoch = int(raw) if raw is not None else DEFAULT_SOURCE_DATE_EPOCH + except ValueError as error: + raise ValueError('SOURCE_DATE_EPOCH must be an integer Unix timestamp') from error + epoch = min(max(epoch, ZIP_MIN_EPOCH), ZIP_MAX_EPOCH) + stamp = dt.datetime.fromtimestamp(epoch, tz=dt.timezone.utc).replace(microsecond=0) + # The classic ZIP timestamp stores seconds in two-second increments. + stamp = stamp.replace(second=stamp.second - stamp.second % 2) + return stamp.year, stamp.month, stamp.day, stamp.hour, stamp.minute, stamp.second + + +def deterministic_mode(rel: Path) -> int: + executable = rel.as_posix() in EXECUTABLE_ROOT or ( + rel.parts and rel.parts[0] == 'scripts' and rel.suffix in {'.py', '.sh'} + ) or ( + rel.parts and rel.parts[0] == 'debian' and (rel.suffix in {'.sh'} or rel.name in {'rules', 'rustproxy.postinst'}) + ) or ( + rel.parts and rel.parts[0] == 'nagios' and (rel.suffix in {'.py', '.sh'} or rel.name.startswith('check_')) + ) or ( + rel.as_posix() == 'docker-agent/install.sh' + ) + return 0o755 if executable else 0o644 + + +def add_file(archive: zipfile.ZipFile, rel: Path, data: bytes, timestamp: tuple[int, int, int, int, int, int]) -> None: + info = zipfile.ZipInfo(f'RustProxy/{rel.as_posix()}', date_time=timestamp) + info.create_system = 3 + info.external_attr = (stat.S_IFREG | deterministic_mode(rel)) << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, data, compress_type=zipfile.ZIP_DEFLATED, compresslevel=9) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('output', nargs='?', type=Path, default=ROOT.parent / 'RustProxy-0.24.5.zip') + output = parser.parse_args().output.resolve() + paths = files() + checksum = ROOT / 'SHA256SUMS' + if output in paths or output == checksum: + raise SystemExit('Output must not replace a source file.') + + sums = ''.join( + f'{hashlib.sha256(path.read_bytes()).hexdigest()} {path.relative_to(ROOT).as_posix()}\n' + for path in paths + ) + #checksum.write_text(sums, encoding='utf-8', newline='\n') + checksum.write_text(sums, encoding='utf-8') + timestamp = release_datetime() + output.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output, 'w') as archive: + for path in paths: + add_file(archive, path.relative_to(ROOT), path.read_bytes(), timestamp) + add_file(archive, Path('SHA256SUMS'), checksum.read_bytes(), timestamp) + print(f'Created {output}; {len(paths) + 1} source files; deterministic SHA256SUMS refreshed.') + + +if __name__ == '__main__': + main() diff --git a/migrations/0002_admin.sql b/migrations/0002_admin.sql new file mode 100644 index 0000000..56d70c0 --- /dev/null +++ b/migrations/0002_admin.sql @@ -0,0 +1,36 @@ +-- Additive migration from 0.0.1. The configuration table is never cleared. +CREATE TABLE IF NOT EXISTS configuration ( + id INTEGER PRIMARY KEY CHECK (id = 1), + revision INTEGER NOT NULL CHECK (revision >= 0), + document TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS administrator ( + id INTEGER PRIMARY KEY CHECK (id = 1), + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + generation INTEGER NOT NULL DEFAULT 1 CHECK (generation > 0), + created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS admin_sessions ( + token_hash TEXT PRIMARY KEY CHECK (length(token_hash) = 64), + csrf_token TEXT NOT NULL CHECK (length(csrf_token) = 64), + generation INTEGER NOT NULL, + created_at INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('browser', 'cli')) +); +CREATE INDEX IF NOT EXISTS admin_sessions_expiry ON admin_sessions(expires_at); +CREATE TABLE IF NOT EXISTS admin_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + time INTEGER NOT NULL, + actor TEXT NOT NULL, + action TEXT NOT NULL, + detail TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS auth_throttle ( + id INTEGER PRIMARY KEY CHECK (id = 1), + window_start INTEGER NOT NULL DEFAULT 0, + attempts INTEGER NOT NULL DEFAULT 0 +); +INSERT OR IGNORE INTO auth_throttle(id) VALUES(1); diff --git a/migrations/0003_certificate_renewals.sql b/migrations/0003_certificate_renewals.sql new file mode 100644 index 0000000..ae2e041 --- /dev/null +++ b/migrations/0003_certificate_renewals.sql @@ -0,0 +1,12 @@ +-- Persist non-secret ACME renewal observations so the dashboard is populated +-- immediately after a restart. Provider credentials remain outside SQLite. +CREATE TABLE IF NOT EXISTS certificate_renewals ( + certificate_id TEXT PRIMARY KEY, + last_checked INTEGER NOT NULL DEFAULT 0, + next_check INTEGER NOT NULL DEFAULT 0, + last_renewed INTEGER, + not_after INTEGER, + failures INTEGER NOT NULL DEFAULT 0 CHECK (failures >= 0), + status TEXT NOT NULL DEFAULT '', + message TEXT NOT NULL DEFAULT '' +); diff --git a/migrations/0004_admin_preferences.sql b/migrations/0004_admin_preferences.sql new file mode 100644 index 0000000..9931afd --- /dev/null +++ b/migrations/0004_admin_preferences.sql @@ -0,0 +1,7 @@ +-- Account-scoped administration UI preferences. These are control-plane view state, +-- separate from the revisioned proxy configuration document. +CREATE TABLE IF NOT EXISTS admin_preferences ( + administrator_id INTEGER PRIMARY KEY CHECK (administrator_id = 1), + document TEXT NOT NULL CHECK (length(document) BETWEEN 2 AND 4096), + FOREIGN KEY (administrator_id) REFERENCES administrator(id) ON DELETE CASCADE +); diff --git a/migrations/0005_certificate_api.sql b/migrations/0005_certificate_api.sql new file mode 100644 index 0000000..be7650d --- /dev/null +++ b/migrations/0005_certificate_api.sql @@ -0,0 +1,9 @@ +-- Scoped credential for externally managed certificate deployment. The cleartext +-- token is never persisted; only a SHA-256 digest and non-secret key id are stored. +CREATE TABLE IF NOT EXISTS certificate_api_credential ( + id INTEGER PRIMARY KEY CHECK (id = 1), + key_id TEXT NOT NULL UNIQUE CHECK (length(key_id) BETWEEN 3 AND 64), + token_hash TEXT NOT NULL CHECK (length(token_hash) = 64), + created_at INTEGER NOT NULL, + last_used_at INTEGER +); diff --git a/migrations/0006_certificate_api_credentials.sql b/migrations/0006_certificate_api_credentials.sql new file mode 100644 index 0000000..1f03b12 --- /dev/null +++ b/migrations/0006_certificate_api_credentials.sql @@ -0,0 +1,16 @@ +-- Multiple scoped credentials for external certificate automation. Existing +-- 0.23/0.24 singleton credentials are copied once and the legacy row is cleared +-- so this idempotent migration can run safely on every startup. +CREATE TABLE IF NOT EXISTS certificate_api_credentials ( + key_id TEXT PRIMARY KEY CHECK (length(key_id) BETWEEN 3 AND 64), + token_hash TEXT NOT NULL CHECK (length(token_hash) = 64), + comment TEXT NOT NULL DEFAULT '' CHECK (length(comment) <= 256), + created_at INTEGER NOT NULL, + last_used_at INTEGER +); + +INSERT OR IGNORE INTO certificate_api_credentials(key_id, token_hash, comment, created_at, last_used_at) +SELECT key_id, token_hash, '', created_at, last_used_at +FROM certificate_api_credential; + +DELETE FROM certificate_api_credential; diff --git a/nagios/README.md b/nagios/README.md new file mode 100644 index 0000000..d9e6c57 --- /dev/null +++ b/nagios/README.md @@ -0,0 +1,58 @@ +# Nagios / Icinga checks + +The scripts in this directory use the authenticated RustProxy administration API. They do not read the SQLite database and do not require access to the proxy process. + +## Checks + +- `check_rustproxy_api` — administration API response time and availability. +- `check_rustproxy_backends` — unavailable/checking backends for all vhosts or selected `--pool` vhost IDs. +- `check_rustproxy_latency` — the highest current backend EWMA/check latency. +- `check_rustproxy_queues` — dropped live-log records and dropped cold-cache queue work. + +All scripts follow the Nagios plugin exit codes (`0 OK`, `1 WARNING`, `2 CRITICAL`, `3 UNKNOWN`) and expose performance data after `|`. + +## Authentication + +Use the HTTPS administration hostname that is already allowed by RustProxy. For frequent checks, persist the browser session so Nagios does not create a new session on every probe: + +```sh +export RUSTPROXY_NAGIOS_USER='nagios' +export RUSTPROXY_NAGIOS_PASSWORD='replace-me' +install -d -m 0700 /var/lib/nagios/rustproxy +./nagios/check_rustproxy_api \ + --url https://proxy-admin.example.net \ + --cookie-file /var/lib/nagios/rustproxy/session.cookies +``` + +The cookie file is written with mode `0600`. Keep the password environment variable in a root/Nagios-readable service environment, not on the command line. Once a valid cookie exists, it is reused until RustProxy expires or revokes the session. + +For an internal CA, use `--ca-file /path/to/ca.pem`. `--insecure` is intended only for temporary diagnostics. + +## Examples + +```sh +# API warning at 150 ms, critical at 500 ms +./nagios/check_rustproxy_api --url https://proxy-admin.example.net \ + --cookie-file /var/lib/nagios/rustproxy/session.cookies \ + --warning-ms 150 --critical-ms 500 + +# Any backend currently down is CRITICAL; checking/unknown is WARNING +./nagios/check_rustproxy_backends --url https://proxy-admin.example.net \ + --cookie-file /var/lib/nagios/rustproxy/session.cookies + +# Only one vhost/backend pool +./nagios/check_rustproxy_backends --url https://proxy-admin.example.net \ + --cookie-file /var/lib/nagios/rustproxy/session.cookies --pool app-example + +# Slowest backend warning/critical thresholds +./nagios/check_rustproxy_latency --url https://proxy-admin.example.net \ + --cookie-file /var/lib/nagios/rustproxy/session.cookies \ + --warning-ms 500 --critical-ms 1500 + +# Cumulative dropped-work counters +./nagios/check_rustproxy_queues --url https://proxy-admin.example.net \ + --cookie-file /var/lib/nagios/rustproxy/session.cookies \ + --warning 1 --critical 20 +``` + +The status API identifies backend pools by vhost ID. Disabled and draining members are intentionally ignored by availability and latency checks. diff --git a/nagios/check_rustproxy.py b/nagios/check_rustproxy.py new file mode 100755 index 0000000..6a28845 --- /dev/null +++ b/nagios/check_rustproxy.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""Nagios/Icinga checks for the authenticated RustProxy administration API.""" +from __future__ import annotations + +import argparse +import http.cookiejar +import json +import os +from pathlib import Path +import ssl +import sys +import time +import urllib.error +import urllib.request + +OK, WARNING, CRITICAL, UNKNOWN = 0, 1, 2, 3 + + +class CheckError(RuntimeError): + pass + + +def plugin_exit(state: int, message: str, perf: list[str] | None = None) -> None: + names = {OK: "OK", WARNING: "WARNING", CRITICAL: "CRITICAL", UNKNOWN: "UNKNOWN"} + suffix = f" | {' '.join(perf)}" if perf else "" + print(f"RUSTPROXY {names[state]} - {message}{suffix}") + raise SystemExit(state) + + +def threshold(value: float, warning: float, critical: float) -> int: + if value >= critical: + return CRITICAL + if value >= warning: + return WARNING + return OK + + +class Client: + def __init__(self, args: argparse.Namespace) -> None: + self.base = args.url.rstrip("/") + self.username = args.user or os.environ.get("RUSTPROXY_NAGIOS_USER") + self.password = os.environ.get(args.password_env) + self.cookie_file = Path(args.cookie_file).expanduser() if args.cookie_file else None + self.jar = http.cookiejar.MozillaCookieJar(str(self.cookie_file)) if self.cookie_file else http.cookiejar.CookieJar() + if self.cookie_file and self.cookie_file.exists(): + try: + self.jar.load(ignore_discard=True, ignore_expires=False) + except (OSError, http.cookiejar.LoadError): + pass + if args.insecure: + context = ssl._create_unverified_context() + else: + context = ssl.create_default_context(cafile=args.ca_file) + self.opener = urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(self.jar), + urllib.request.HTTPSHandler(context=context), + ) + self.timeout = args.timeout + + def _request(self, path: str, payload: dict | None = None) -> tuple[dict, float]: + body = None if payload is None else json.dumps(payload, separators=(",", ":")).encode() + request = urllib.request.Request( + self.base + path, + data=body, + method="GET" if payload is None else "POST", + headers={"Accept": "application/json", "Content-Type": "application/json", "X-RustProxy-Request": "1"}, + ) + started = time.monotonic() + try: + with self.opener.open(request, timeout=self.timeout) as response: + raw = response.read(8 * 1024 * 1024) + except urllib.error.HTTPError: + raise + except (OSError, urllib.error.URLError) as error: + raise CheckError(str(error)) from error + elapsed_ms = (time.monotonic() - started) * 1000.0 + try: + value = json.loads(raw) + except json.JSONDecodeError as error: + raise CheckError("administration API returned invalid JSON") from error + if not isinstance(value, dict): + raise CheckError("administration API returned an unexpected payload") + return value, elapsed_ms + + def login(self) -> None: + if not self.username or self.password is None: + raise CheckError( + "no valid session and no credentials; set --user/RUSTPROXY_NAGIOS_USER and the password environment variable" + ) + try: + self._request("/api/auth/login", {"username": self.username, "password": self.password}) + except urllib.error.HTTPError as error: + raise CheckError(f"login failed with HTTP {error.code}") from error + if self.cookie_file: + self.cookie_file.parent.mkdir(parents=True, exist_ok=True) + self.jar.save(ignore_discard=True, ignore_expires=False) + try: + os.chmod(self.cookie_file, 0o600) + except OSError: + pass + + def status(self) -> tuple[dict, float]: + try: + return self._request("/api/status") + except urllib.error.HTTPError as error: + if error.code != 401: + raise CheckError(f"status endpoint returned HTTP {error.code}") from error + self.login() + try: + return self._request("/api/status") + except urllib.error.HTTPError as error: + raise CheckError(f"status endpoint returned HTTP {error.code} after login") from error + + +def pools(status: dict, selected: list[str]) -> list[dict]: + values = status.get("backend_pools") or [] + if not isinstance(values, list): + raise CheckError("status payload has no backend_pools array") + if not selected: + return values + wanted = set(selected) + found = [pool for pool in values if str(pool.get("id", "")) in wanted] + missing = wanted - {str(pool.get("id", "")) for pool in found} + if missing: + raise CheckError("unknown vhost/backend pool: " + ", ".join(sorted(missing))) + return found + + +def check_api(client: Client, args: argparse.Namespace) -> None: + status, latency = client.status() + state = threshold(latency, args.warning_ms, args.critical_ms) + revision = status.get("revision", "?") + uptime = status.get("uptime_seconds", "?") + plugin_exit(state, f"API {latency:.1f} ms, revision {revision}, uptime {uptime}s", [f"api_ms={latency:.1f}ms;{args.warning_ms};{args.critical_ms};0"]) + + +def check_backends(client: Client, args: argparse.Namespace) -> None: + status, api_ms = client.status() + down: list[str] = [] + checking: list[str] = [] + up = ignored = 0 + for pool in pools(status, args.pool): + pool_id = str(pool.get("id", "?")) + for member in pool.get("members") or []: + state = str(member.get("status", "unknown")) + name = f"{pool_id}/{member.get('id', '?')}" + if state == "down": + down.append(name) + elif state == "checking": + checking.append(name) + elif state == "up": + up += 1 + elif state in {"disabled", "draining"}: + ignored += 1 + else: + checking.append(f"{name}({state})") + state = CRITICAL if down else WARNING if checking else OK + detail = f"up={up}, down={len(down)}, checking={len(checking)}, ignored={ignored}" + if down: + detail += "; down: " + ", ".join(down[:10]) + elif checking: + detail += "; checking: " + ", ".join(checking[:10]) + plugin_exit(state, detail, [f"up={up}", f"down={len(down)}", f"checking={len(checking)}", f"api_ms={api_ms:.1f}ms"]) + + +def check_latency(client: Client, args: argparse.Namespace) -> None: + status, api_ms = client.status() + samples: list[tuple[float, str]] = [] + for pool in pools(status, args.pool): + pool_id = str(pool.get("id", "?")) + for member in pool.get("members") or []: + if str(member.get("status", "")) in {"disabled", "draining"}: + continue + raw = member.get("ewma_latency_ms") or member.get("latency_ms") or 0 + try: + value = float(raw) + except (TypeError, ValueError): + continue + if value > 0: + samples.append((value, f"{pool_id}/{member.get('id', '?')}")) + if not samples: + plugin_exit(WARNING, "no backend latency samples are available", [f"api_ms={api_ms:.1f}ms"]) + value, name = max(samples) + state = threshold(value, args.warning_ms, args.critical_ms) + plugin_exit(state, f"slowest backend {name}: {value:.1f} ms ({len(samples)} sampled)", [f"backend_ms={value:.1f}ms;{args.warning_ms};{args.critical_ms};0", f"api_ms={api_ms:.1f}ms"]) + + +def check_queues(client: Client, args: argparse.Namespace) -> None: + status, api_ms = client.status() + logs = int(status.get("logs_dropped") or 0) + cold = int((status.get("cache") or {}).get("cold_queue_dropped") or 0) + total = logs + cold + state = CRITICAL if total >= args.critical else WARNING if total >= args.warning else OK + plugin_exit(state, f"dropped work: logs={logs}, cold-cache={cold}", [f"logs_dropped={logs};{args.warning};{args.critical};0", f"cold_queue_dropped={cold};{args.warning};{args.critical};0", f"api_ms={api_ms:.1f}ms"]) + + +def parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("mode", choices=["api", "backends", "latency", "queues"]) + p.add_argument("--url", required=True, help="Administration origin, e.g. https://rustproxy-admin.example.net") + p.add_argument("--user", help="Administrator username; defaults to RUSTPROXY_NAGIOS_USER") + p.add_argument("--password-env", default="RUSTPROXY_NAGIOS_PASSWORD", help="environment variable containing the password") + p.add_argument("--cookie-file", help="persistent session cookie jar; recommended for frequent checks") + p.add_argument("--ca-file", help="custom CA bundle for the administration TLS certificate") + p.add_argument("--insecure", action="store_true", help="disable TLS verification (diagnostics only)") + p.add_argument("--timeout", type=float, default=5.0) + p.add_argument("--pool", action="append", default=[], help="limit backend checks to a vhost/backend-pool id; repeatable") + p.add_argument("--warning-ms", type=float, default=250.0) + p.add_argument("--critical-ms", type=float, default=1000.0) + p.add_argument("--warning", type=int, default=1, help="queues: warning total") + p.add_argument("--critical", type=int, default=10, help="queues: critical total") + return p + + +def main() -> None: + args = parser().parse_args() + if args.warning_ms < 0 or args.critical_ms <= args.warning_ms: + plugin_exit(UNKNOWN, "invalid latency thresholds") + if args.warning < 0 or args.critical <= args.warning: + plugin_exit(UNKNOWN, "invalid queue thresholds") + try: + client = Client(args) + {"api": check_api, "backends": check_backends, "latency": check_latency, "queues": check_queues}[args.mode](client, args) + except CheckError as error: + plugin_exit(UNKNOWN, str(error)) + except Exception as error: # Nagios plugins must fail closed with a single line. + plugin_exit(UNKNOWN, f"unexpected check failure: {error}") + + +if __name__ == "__main__": + main() diff --git a/nagios/check_rustproxy_api b/nagios/check_rustproxy_api new file mode 100755 index 0000000..0d8e476 --- /dev/null +++ b/nagios/check_rustproxy_api @@ -0,0 +1,2 @@ +#!/bin/sh +exec "$(dirname "$0")/check_rustproxy.py" api "$@" diff --git a/nagios/check_rustproxy_backends b/nagios/check_rustproxy_backends new file mode 100755 index 0000000..d475086 --- /dev/null +++ b/nagios/check_rustproxy_backends @@ -0,0 +1,2 @@ +#!/bin/sh +exec "$(dirname "$0")/check_rustproxy.py" backends "$@" diff --git a/nagios/check_rustproxy_latency b/nagios/check_rustproxy_latency new file mode 100755 index 0000000..83f961e --- /dev/null +++ b/nagios/check_rustproxy_latency @@ -0,0 +1,2 @@ +#!/bin/sh +exec "$(dirname "$0")/check_rustproxy.py" latency "$@" diff --git a/nagios/check_rustproxy_queues b/nagios/check_rustproxy_queues new file mode 100755 index 0000000..1df4ea7 --- /dev/null +++ b/nagios/check_rustproxy_queues @@ -0,0 +1,2 @@ +#!/bin/sh +exec "$(dirname "$0")/check_rustproxy.py" queues "$@" diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..05e6ca1 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +profile = "minimal" +components = ["rustfmt", "clippy"] diff --git a/scripts/acme_provider.py b/scripts/acme_provider.py new file mode 100755 index 0000000..e1b1878 --- /dev/null +++ b/scripts/acme_provider.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +"""Private Certbot HTTP-01 / DNS-01 multi-provider adapter. JSON input on stdin, no shell. + +This file is also embedded in the Rust binary. Never pass a TSIG secret on the +command line. No network operation happens at import or in --doctor. +""" +from __future__ import annotations +import argparse +import base64 +import datetime +import hashlib +import ipaddress +import json +import os +from pathlib import Path +import re +import shutil +import stat +import subprocess +import sys +import uuid + +DIRECTORIES = { + "staging": "https://acme-staging-v02.api.letsencrypt.org/directory", + "production": "https://acme-v02.api.letsencrypt.org/directory", +} +ALGORITHMS = {"HMAC-SHA256", "HMAC-SHA384", "HMAC-SHA512"} +MAX_INPUT = 16384 +MAX_PEM = 131072 +PROVIDERS = {"http01", "cloudflare", "ovh", "rfc2136"} +SECRET_FIELDS = {"key_secret", "api_token", "application_key", "application_secret", "consumer_key"} +COMMON_FIELDS = {"provider", "certificate_id", "email", "domains", "environment", "agree_tos", "confirm_production", "propagation_seconds"} +PROVIDER_FIELDS = { + "http01": set(), + "cloudflare": {"api_token"}, + "ovh": {"endpoint", "application_key", "application_secret", "consumer_key"}, + "rfc2136": {"dns_server", "dns_port", "key_name", "key_secret", "key_algorithm", "sign_query"}, +} + + +def credential(value: object) -> str: + # All supported API keys are ASCII tokens. Reject whitespace/newlines and + # INI metacharacters; provider input must never add options or sections. + if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_./+\-=]{8,4096}", value): + raise AcmeError("acme.invalid_api_credential") + return value + + +class AcmeError(Exception): + """Messages contain fixed diagnostics, never provider output or input secrets.""" + + +def domain(value: object, wildcard: bool = True, single_label: bool = False) -> str: + if not isinstance(value, str) or not value.isascii() or len(value) > 253: + raise AcmeError("acme.invalid_domain") + value = value.lower().rstrip(".") + check = value[2:] if wildcard and value.startswith("*.") else value + if (not single_label and "." not in check) or any(not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", p) for p in check.split(".")): + raise AcmeError("acme.invalid_domain") + try: + ipaddress.ip_address(check) + except ValueError: + return value + raise AcmeError("acme.dns_names_only") + + +def validate(raw: object) -> dict: + if not isinstance(raw, dict): + raise AcmeError("acme.invalid_request") + provider = raw.get("provider", "rfc2136") + if not isinstance(provider, str) or provider not in PROVIDERS: + raise AcmeError("acme.invalid_provider") + if set(raw) - (COMMON_FIELDS | PROVIDER_FIELDS[provider]): + raise AcmeError("acme.unknown_field") + value = dict(raw, provider=provider) + if not isinstance(value.get("certificate_id"), str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", value["certificate_id"]): + raise AcmeError("acme.invalid_id") + email = value.get("email", "") + if not isinstance(email, str) or not email.isascii() or len(email) > 254 or not re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email): + raise AcmeError("acme.invalid_email") + names = value.get("domains") + if not isinstance(names, list) or not 1 <= len(names) <= 16: + raise AcmeError("acme.invalid_domains") + if provider == "http01" and any(isinstance(d, str) and d.startswith("*.") for d in names): + raise AcmeError("acme.http01_no_wildcard") + value["domains"] = list(dict.fromkeys(domain(d) for d in names)) + value.setdefault("environment", "staging") + if not isinstance(value["environment"], str) or value["environment"] not in DIRECTORIES: + raise AcmeError("acme.invalid_environment") + if value.get("agree_tos") is not True: + raise AcmeError("acme.tos_required") + if value["environment"] == "production" and value.get("confirm_production") is not True: + raise AcmeError("acme.production_confirmation") + propagation = value.get("propagation_seconds", 60) + if type(propagation) is not int or not 10 <= propagation <= 900: + raise AcmeError("acme.invalid_limits") + value["propagation_seconds"] = propagation + if provider == "http01": + return value + if provider == "cloudflare": + value["api_token"] = credential(value.get("api_token")) + return value + if provider == "ovh": + if value.get("endpoint", "ovh-eu") not in ("ovh-eu", "ovh-ca"): + raise AcmeError("acme.invalid_ovh_endpoint") + value.setdefault("endpoint", "ovh-eu") + for name in ("application_key", "application_secret", "consumer_key"): + value[name] = credential(value.get(name)) + return value + if not isinstance(value.get("dns_server"), str): + raise AcmeError("acme.invalid_dns_server") + try: + value["dns_server"] = str(ipaddress.ip_address(value.get("dns_server", ""))) + except ValueError: + raise AcmeError("acme.invalid_dns_server") from None + for name, default, low, high in [("dns_port", 53, 1, 65535), ("propagation_seconds", 60, 10, 900)]: + v = value.get(name, default) + if type(v) is not int or not low <= v <= high: + raise AcmeError("acme.invalid_limits") + value[name] = v + value["key_name"] = domain(value.get("key_name", ""), wildcard=False, single_label=True) + "." + value.setdefault("key_algorithm", "HMAC-SHA256") + if not isinstance(value["key_algorithm"], str) or value["key_algorithm"] not in ALGORITHMS: + raise AcmeError("acme.invalid_key_algorithm") + secret = value.get("key_secret", "") + try: + if not isinstance(secret, str) or len(secret) > 1024 or not 16 <= len(base64.b64decode(secret, validate=True)) <= 512: + raise ValueError() + except (ValueError, TypeError): + raise AcmeError("acme.invalid_key_secret") from None + value.setdefault("sign_query", False) + if type(value["sign_query"]) is not bool: + raise AcmeError("acme.invalid_sign_query") + return value + + +def private_directory(path: Path) -> None: + path.mkdir(mode=0o700, parents=True, exist_ok=True) + info = path.lstat() + if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077: + raise AcmeError("acme.unsafe_directory") + + +def write_new(path: Path, data: bytes) -> None: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600) + with os.fdopen(fd, "wb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + + +def credentials(value: dict) -> bytes: + if value["provider"] == "http01": + return b"" + if value["provider"] == "cloudflare": + return f"dns_cloudflare_api_token = {value['api_token']}\n".encode("ascii") + if value["provider"] == "ovh": + return "".join(f"dns_ovh_{k} = {value[k]}\n" for k in + ("endpoint", "application_key", "application_secret", "consumer_key")).encode("ascii") + lines = { + "server": value["dns_server"], "port": value["dns_port"], "name": value["key_name"], + "secret": value["key_secret"], "algorithm": value["key_algorithm"], + "sign_query": str(value["sign_query"]).lower(), + } + return "".join(f"dns_rfc2136_{k} = {v}\n" for k, v in lines.items()).encode("ascii") + + +def certbot_command(value: dict, work: Path, credential_file: Path, executable: str) -> list[str]: + args = [executable, "certonly", "--non-interactive", "--agree-tos", "--no-eff-email", + "--email", value["email"], "--server", DIRECTORIES[value["environment"]], + "--cert-name", "rustproxy", "--keep-until-expiring", "--key-type", "ecdsa", + "--config-dir", str(work / "config"), "--work-dir", str(work / "work"), + "--logs-dir", str(work / "logs")] + if value["provider"] == "http01": + args += ["--authenticator", "webroot", "--webroot-path", str(work.parent.parent / "acme-http01"), + "--preferred-challenges", "http-01"] + else: + plugin = "dns-" + value["provider"] + args += ["--authenticator", plugin, "--" + plugin + "-credentials", str(credential_file), + "--" + plugin + "-propagation-seconds", str(value["propagation_seconds"])] + for name in value["domains"]: + args += ["-d", name] + return args + + +def read_generated(work: Path, name: str) -> bytes: + # Certbot live entries are symlinks into its private archive. Resolve only + # within that archive, then open without following a second/final symlink. + archive = (work / "config" / "archive").resolve(strict=True) + path = (work / "config" / "live" / "rustproxy" / name).resolve(strict=True) + if not path.is_relative_to(archive): + raise AcmeError("acme.unsafe_output_path") + fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + with os.fdopen(fd, "rb") as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_PEM: + raise AcmeError("acme.invalid_pem_size") + data = stream.read(MAX_PEM + 1) + if len(data) > MAX_PEM or not data.startswith(b"-----BEGIN "): + raise AcmeError("acme.invalid_pem") + return data + + +def issue(raw: dict, certificate_directory: Path, executable: str | None = None) -> dict: + value = validate(raw) + executable = executable or shutil.which("certbot") + if not executable: + raise AcmeError("acme.certbot_not_installed") + certificate_directory = certificate_directory.absolute() + private_directory(certificate_directory) + root = certificate_directory / "acme" + private_directory(root) + # Each identity/environment has isolated CA accounts, DNS credentials and + # Certbot lineages. Both profile IDs and directory paths are operator-owned. + suffix = "" if value["provider"] == "rfc2136" else "-" + value["provider"] + work = root / (value["environment"] + "-" + value["certificate_id"] + suffix) + private_directory(work) + if len(list(root.iterdir())) > 128: + raise AcmeError("acme.profile_limit") + import fcntl + lock = os.open(work / ".lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600) + with os.fdopen(lock, "w") as lock_file: + try: + fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise AcmeError("acme.profile_busy") from None + for folder in ["config", "work", "logs"]: + private_directory(work / folder) + # Keep credentials for this lineage's renewal. Replace atomically; never + # follow an existing credential-file symlink or put secrets in SQLite. + credential_file = work / (value["provider"] + ".ini") + if value["provider"] == "http01": + webroot = certificate_directory / "acme-http01" + for path in (webroot, webroot / ".well-known", webroot / ".well-known" / "acme-challenge"): + private_directory(path) + else: + temporary = work / ("credentials-" + uuid.uuid4().hex) + write_new(temporary, credentials(value)) + os.replace(temporary, credential_file) + profile = {k: v for k, v in value.items() if k not in SECRET_FIELDS} + temporary = work / ("profile-" + uuid.uuid4().hex) + write_new(temporary, json.dumps(profile).encode()) + os.replace(temporary, work / "profile.json") + args = certbot_command(value, work, credential_file, executable) + try: + result = subprocess.run(args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=1200 + value["propagation_seconds"], check=False) + except subprocess.TimeoutExpired: + raise AcmeError("acme.provider_timeout") from None + if result.returncode: + raise AcmeError("acme.certbot_failed_check_private_log") + cert = read_generated(work, "fullchain.pem") + key = read_generated(work, "privkey.pem") + # Immutable copies allow validation/publication without changing the + # active certificate during CA work or a concurrent configuration edit. + stem = "acme-" + uuid.uuid4().hex + cert_path = certificate_directory / (stem + ".pem") + key_path = certificate_directory / (stem + ".key") + if len(list(certificate_directory.glob("acme-*.key"))) >= 128: + raise AcmeError("acme.staging_limit") + try: + write_new(key_path, key) + write_new(cert_path, cert) + except BaseException: + cert_path.unlink(missing_ok=True) + key_path.unlink(missing_ok=True) + raise + return {"provider": value["provider"], "certificate_id": value["certificate_id"], "environment": value["environment"], + "domains": value["domains"], "cert_file": cert_path.name, "key_file": key_path.name, + "staged": True} + + + +def profile_directory(certificate_directory: Path, provider: str, environment: str, certificate_id: str) -> Path: + suffix = "" if provider == "rfc2136" else "-" + provider + return certificate_directory / "acme" / (environment + "-" + certificate_id + suffix) + + +def read_private(path: Path, limit: int = MAX_INPUT) -> bytes: + fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + with os.fdopen(fd, "rb") as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077 or info.st_size > limit: + raise AcmeError("acme.unsafe_profile") + data = stream.read(limit + 1) + if len(data) > limit: + raise AcmeError("acme.unsafe_profile") + return data + + +def certificate_dates(pem: bytes, executable: str | None = None) -> dict: + """Read validity without exposing subject/PEM data or requiring a new Python package.""" + executable = executable or shutil.which("openssl") + if not executable: + raise AcmeError("acme.openssl_not_installed") + try: + result = subprocess.run([executable, "x509", "-noout", "-startdate", "-enddate"], + input=pem, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + timeout=10, check=False, env={**os.environ, "LC_ALL": "C"}) + if result.returncode or len(result.stdout) > 4096: + raise ValueError() + dates = {} + for line in result.stdout.decode("ascii").splitlines(): + name, text = line.split("=", 1) + # OpenSSL's English ASN.1 date print format; LC_ALL is fixed above. + value = datetime.datetime.strptime(text.strip(), "%b %d %H:%M:%S %Y GMT").replace(tzinfo=datetime.timezone.utc) + dates[name] = int(value.timestamp()) + if dates["notAfter"] <= dates["notBefore"]: + raise ValueError() + return {"not_before_unix": dates["notBefore"], "not_after_unix": dates["notAfter"]} + except (ValueError, KeyError, UnicodeError, subprocess.TimeoutExpired): + raise AcmeError("acme.certificate_dates_invalid") from None + + +def renewal_request(raw: object) -> dict: + if not isinstance(raw, dict) or set(raw) - {"action", "certificate_id", "provider", "environment", "cert_file", "key_file", "dry_run"}: + raise AcmeError("acme.invalid_renewal_request") + if raw.get("action") != "renew" or raw.get("provider") not in PROVIDERS or raw.get("environment") not in DIRECTORIES: + raise AcmeError("acme.invalid_renewal_request") + if not isinstance(raw.get("certificate_id"), str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", raw["certificate_id"]): + raise AcmeError("acme.invalid_id") + for name, suffix in [("cert_file", ".pem"), ("key_file", ".key")]: + value = raw.get(name) + if not isinstance(value, str) or not re.fullmatch(r"acme-[A-Za-z0-9_-]{1,80}" + re.escape(suffix), value): + raise AcmeError("acme.invalid_renewal_request") + if type(raw.get("dry_run", False)) is not bool: + raise AcmeError("acme.invalid_renewal_request") + return dict(raw, dry_run=raw.get("dry_run", False)) + + +def renew(raw: dict, certificate_directory: Path, executable: str | None = None) -> dict: + """Renew ONE existing, consented lineage; never force issuance or create a profile. + + Certbot decides when a certificate is due (including its ARI support). A zero + exit status alone does not prove a renewal: compare actual certificate/key + bytes with the installed generation, so crash-after-renewal is recoverable. + Dry-run validates challenge access but never stages/deploys its test output. + """ + value = renewal_request(raw) + executable = executable or shutil.which("certbot") + if not executable: + raise AcmeError("acme.certbot_not_installed") + certificate_directory = certificate_directory.absolute() + private_directory(certificate_directory) + work = profile_directory(certificate_directory, value["provider"], value["environment"], value["certificate_id"]) + # Do not create missing profile paths during renewal. + for path in [certificate_directory / "acme", work, work / "config", work / "work", work / "logs"]: + if not path.exists(): + raise AcmeError("acme.renewal_profile_missing") + private_directory(path) + import fcntl + lock = os.open(work / ".lock", os.O_WRONLY | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600) + with os.fdopen(lock, "w") as lock_file: + try: + fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + raise AcmeError("acme.profile_busy") from None + try: + profile = json.loads(read_private(work / "profile.json")) + except (OSError, ValueError): + raise AcmeError("acme.renewal_profile_missing") from None + if not isinstance(profile, dict) or any(profile.get(k) != value[k] for k in ("provider", "certificate_id", "environment")): + raise AcmeError("acme.renewal_profile_mismatch") + if profile.get("agree_tos") is not True or (value["environment"] == "production" and profile.get("confirm_production") is not True): + raise AcmeError("acme.consent_required") + names = profile.get("domains") + if not isinstance(names, list) or not 1 <= len(names) <= 16: + raise AcmeError("acme.invalid_domains") + names = [domain(n, wildcard=value["provider"] != "http01") for n in names] + # This exact renewal file must have been created by the original issuance. + read_private(work / "config" / "renewal" / "rustproxy.conf", 65536) + if value["provider"] != "http01": + read_private(work / (value["provider"] + ".ini")) + else: + for path in [certificate_directory / "acme-http01", certificate_directory / "acme-http01" / ".well-known", + certificate_directory / "acme-http01" / ".well-known" / "acme-challenge"]: + private_directory(path) + installed_cert = read_private(certificate_directory / value["cert_file"], MAX_PEM) + installed_key = read_private(certificate_directory / value["key_file"], MAX_PEM) + # Check dependencies/installed PEM before invoking a CA operation. + old_dates = certificate_dates(installed_cert) + args = [executable, "renew", "--cert-name", "rustproxy", "--non-interactive", + "--no-random-sleep-on-renew", "--no-directory-hooks", + "--config-dir", str(work / "config"), "--work-dir", str(work / "work"), "--logs-dir", str(work / "logs")] + if value["dry_run"]: + args.append("--dry-run") + try: + result = subprocess.run(args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=2100, check=False) + except subprocess.TimeoutExpired: + raise AcmeError("acme.provider_timeout") from None + if result.returncode: + raise AcmeError("acme.certbot_failed_check_private_log") + output = {k: value[k] for k in ("provider", "certificate_id", "environment")} + output.update(domains=names, changed=False, staged=False, dry_run=value["dry_run"], **old_dates) + if value["dry_run"]: + return output + cert, key = read_generated(work, "fullchain.pem"), read_generated(work, "privkey.pem") + dates = certificate_dates(cert) + now = int(datetime.datetime.now(datetime.timezone.utc).timestamp()) + if dates["not_before_unix"] > now + 300 or dates["not_after_unix"] <= now + 300: + raise AcmeError("acme.certificate_not_valid") + if cert == installed_cert and key == installed_key: + return output + if dates["not_after_unix"] < old_dates["not_after_unix"]: + raise AcmeError("acme.renewal_older_certificate") + # Content-addressed immutable staging makes a retry after a publication + # failure idempotent (no additional orphan PEMs each time). + stem = "acme-" + hashlib.sha256(cert + b"\0" + key).hexdigest()[:48] + paths = [(certificate_directory / (stem + ".pem"), cert), (certificate_directory / (stem + ".key"), key)] + if len(list(certificate_directory.glob("acme-*.key"))) >= 1024 and not paths[1][0].exists(): + raise AcmeError("acme.staging_limit") + for path, content in paths: + try: + write_new(path, content) + except FileExistsError: + if read_private(path, MAX_PEM) != content: + raise AcmeError("acme.immutable_output_conflict") from None + output.update(changed=True, staged=True, cert_file=paths[0][0].name, key_file=paths[1][0].name, **dates) + return output + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cert-dir", type=Path) + parser.add_argument("--doctor", action="store_true") + args = parser.parse_args() + if args.doctor: + print(json.dumps({"python": sys.version.split()[0], "certbot": shutil.which("certbot"), + "providers": sorted(PROVIDERS), "required_plugins": ["certbot-dns-cloudflare", "certbot-dns-ovh", "certbot-dns-rfc2136"], "openssl": shutil.which("openssl"), "automatic_renewal": True, "network_checked": False})) + return + if not args.cert_dir: + parser.error("--cert-dir is required") + os.umask(0o077) + try: + raw = sys.stdin.buffer.read(MAX_INPUT + 1) + if len(raw) > MAX_INPUT: + raise AcmeError("acme.request_too_large") + value = json.loads(raw) + result = renew(value, args.cert_dir) if isinstance(value, dict) and value.get("action") == "renew" else issue(value, args.cert_dir) + print(json.dumps(result)) + except (AcmeError, OSError, ValueError) as error: + # OSError/JSON errors can contain paths/input. Do not serialize them. + message = str(error) if isinstance(error, AcmeError) else "acme.local_io_or_json_error" + print(json.dumps({"error": message})) + raise SystemExit(1) from None + + +if __name__ == "__main__": + main() diff --git a/scripts/acme_rfc2136.py b/scripts/acme_rfc2136.py new file mode 100755 index 0000000..9753f05 --- /dev/null +++ b/scripts/acme_rfc2136.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +"""Compatibility entry point; all issuance uses acme_provider.""" +from acme_provider import * # noqa: F403 +if __name__ == "__main__": + main() diff --git a/scripts/admin_audit.py b/scripts/admin_audit.py new file mode 100755 index 0000000..e0bf58d --- /dev/null +++ b/scripts/admin_audit.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Read-only anonymous admin access probes against a running RustProxy. +No cookie, password or token is sent; response bodies are never printed. +Only HTTP loopback URLs are accepted. Exit 1 on an unexpected disclosure/status. +This is a focused regression probe, NOT a penetration test or full security audit. +""" +from __future__ import annotations +import argparse +import ipaddress +import json +from pathlib import Path +from urllib.parse import urlsplit +from urllib.request import Request, build_opener, ProxyHandler, HTTPRedirectHandler +from urllib.error import HTTPError, URLError + +PRIVATE = ('/api/status','/api/config','/api/backends','/api/cache','/api/transform','/api/logs','/api/audit', + '/api/events','/api/certificates','/api/acme','/api/auth/session','/metrics','/api/metrics', + '/docs/openapi.json','/docs/init.js','/docs/style.css','/docs/swagger-ui-bundle.js', + '/docs/swagger-ui.css','/docs/licenses.txt','/assets/app.js','/assets/langs.json','/assets/vhosts.js', + '/assets/management.js','/assets/integrations.js','/assets/settings.js','/assets/acme.js', + '/assets/cache-panel.js','/assets/transform-panel.js','/api/does-not-exist') + +class NoRedirect(HTTPRedirectHandler): + def redirect_request(self,*args,**kwargs): + return None + +def base_url(value: str) -> str: + url=urlsplit(value) + if url.scheme!='http' or url.username or url.password or url.path not in ('','/') or url.query or url.fragment: + raise argparse.ArgumentTypeError('Use the HTTP loopback administrator origin, e.g. http://127.0.0.1:9090') + try: + loopback=url.hostname=='localhost' or ipaddress.ip_address(url.hostname).is_loopback + if not loopback or not url.port:raise ValueError() + except (ValueError,TypeError):raise argparse.ArgumentTypeError('Explicit loopback address and port required') + return value.rstrip('/') + +def main(): + parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--url',type=base_url,default='http://127.0.0.1:9090');args=parser.parse_args() + opener=build_opener(ProxyHandler({}),NoRedirect());failed=0;count=0 + def check(path,expected,validate): + nonlocal failed,count + count+=1 + try: + request=Request(args.url+path,headers={'Accept':'application/json'}) + try:response=opener.open(request,timeout=5) + except HTTPError as error:response=error + with response: + status=response.code;headers=response.headers;body=response.read(8388609 if path=='/assets/login-langs.json' else 131073) + ok=status==expected and headers.get('Cache-Control')=='no-store' and validate(body,headers) + except (OSError,ValueError,KeyError,TypeError,URLError):status='unavailable';ok=False + if not ok:failed+=1 + print(f'{"PASS" if ok else "FAIL"} {path}: HTTP {status}; expected {expected}') + def denied(body,headers):return json.loads(body)=={'error':'auth.required'} + for path in PRIVATE:check(path,401,denied) + check('/docs',303,lambda b,h:h.get('Location')=='/?next=docs') + check('/docs/',303,lambda b,h:h.get('Location')=='/?next=docs') + check('/healthz',200,lambda b,h:json.loads(b)=={'status':'ok'}) + check('/api/auth/status',200,lambda b,h:isinstance((v:=json.loads(b)).get('setup_required'),bool) and set(v)=={'setup_required'}) + check('/',200,lambda b,h:b'/assets/login.js' in b and all(x not in b for x in [b'/assets/app.js',b'config-editor',b'loginLead'])) + check('/assets/login-langs.json',200,lambda b,h:all(set(p['translations'])<=set(json.loads((Path(__file__).resolve().parents[1]/'src/admin/web/login-keys.json').read_text())) for p in json.loads(b)['languages'])) + print(f'{count-failed}/{count} anonymous probes passed. No authenticated/mutation/TLS checks performed.') + raise SystemExit(bool(failed)) +if __name__=='__main__':main() diff --git a/scripts/backup.py b/scripts/backup.py new file mode 100755 index 0000000..edde281 --- /dev/null +++ b/scripts/backup.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Create a private, consistent SQLite backup, including a live database in WAL mode. +This does not copy source code or accept arbitrary SQL. Restore instructions are in docs/MIGRATION.md. +""" +from __future__ import annotations +import argparse +import hashlib +import json +import os +import re +from pathlib import Path +import sqlite3 +import stat +import time +from urllib.parse import quote + + +def backup(data_dir: Path, destination: Path) -> Path: + os.umask(0o077) + if data_dir.is_symlink() or not data_dir.is_dir(): + raise ValueError("Data directory must be a real directory, not a symlink.") + if os.name == "posix" and stat.S_IMODE(data_dir.stat().st_mode) & 0o077: + raise ValueError("Data directory must have mode 700.") + source = data_dir / "config.sqlite3" + if source.is_symlink() or not source.is_file(): + raise ValueError("config.sqlite3 must be a regular, existing file.") + destination.mkdir(mode=0o700, parents=False, exist_ok=False) + target = destination / "config.sqlite3" + # The online backup API incorporates committed WAL contents; a plain file copy does not. + reader = sqlite3.connect(f"file:{quote(str(source.resolve()), safe='/')}?mode=ro", uri=True, timeout=10) + writer = sqlite3.connect(target) + try: + reader.backup(writer, pages=256, sleep=0.05) + check = writer.execute("PRAGMA quick_check").fetchone()[0] + if check != "ok": + raise RuntimeError(f"SQLite integrity check failed: {check}") + schema = writer.execute("PRAGMA user_version").fetchone()[0] + revision, document = writer.execute("SELECT revision, document FROM configuration WHERE id = 1").fetchone() + config = json.loads(document) + finally: + writer.close() + reader.close() + os.chmod(target, 0o600) + digest = hashlib.sha256() + with target.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + checksum = digest.hexdigest() + certificate_hashes = {} + names = set() + settings = config.get("tls", {}) + for binding in settings.get("certificates", []): + names.update([binding["cert_file"], binding["key_file"]]) + cert_dir = data_dir / "certs" + if cert_dir.exists(): + if cert_dir.is_symlink() or not cert_dir.is_dir(): + raise ValueError("Certificate directory must not be a symlink.") + for name in ["default.pem", "default.key"]: + if (cert_dir / name).exists(): + names.add(name) + if len(names) > 130: + raise ValueError("Too many configured certificate files.") + if names: + (destination / "certs").mkdir(mode=0o700) + for name in sorted(names): + if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9_.-]{1,96}", name) or ".." in name: + raise ValueError("Unsafe certificate filename in the saved configuration.") + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + with os.fdopen(os.open(cert_dir / name, flags), "rb") as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_size > 131072: + raise ValueError("Certificate input must be a regular file up to 128 KiB.") + content = stream.read(131073) + if len(content) > 131072: + raise ValueError("Certificate input grew beyond 128 KiB.") + with (destination / "certs" / name).open("xb") as stream: + stream.write(content) + os.chmod(destination / "certs" / name, 0o600) + certificate_hashes[name] = hashlib.sha256(content).hexdigest() + metadata = {"project":"RustProxy", "created_at_unix":int(time.time()), "schema":schema, + "configuration_revision":revision, "sha256":checksum, "certificate_sha256":certificate_hashes, + "warning":"Contains password hashes, session state and configured TLS private keys. Keep private. Revoke sessions after restoring."} + (destination / "backup.json").write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8") + return target + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--data-dir", type=Path, default=Path("data")) + parser.add_argument("destination", type=Path, help="new directory; existing destinations are never overwritten") + args = parser.parse_args() + try: + print(f"Private SQLite and certificate backup created: {backup(args.data_dir, args.destination)}") + except (OSError, ValueError, sqlite3.Error, RuntimeError) as error: + parser.exit(1, f"Backup failed: {error}\nA partially-created destination must be inspected before reuse.\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/check_source.py b/scripts/check_source.py new file mode 100755 index 0000000..82e0e4e --- /dev/null +++ b/scripts/check_source.py @@ -0,0 +1,1105 @@ +#!/usr/bin/env python3 +"""Static package checks only. Does not compile or validate Rust types/behavior.""" +from pathlib import Path +import ast +import json +import re +import sqlite3 +import subprocess +import tomllib +from preflight import check as preflight + +root = Path(__file__).resolve().parents[1] + +def read_cache_source() -> str: + return "\n".join(path.read_text() for path in sorted((root / "src/cache").glob("*.rs"))) + +preflight(root) +manifest = tomllib.loads((root / "Cargo.toml").read_text()) +assert manifest["package"]["version"] == "0.24.5" +assert manifest["dependencies"]["quinn"]["version"] == "0.11.12" +h3dep = manifest["dependencies"]["h3"] +assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.19" and "quinn" in h3dep["features"] +assert "h3-quinn" not in manifest["dependencies"] +lock_path = root / "Cargo.lock" +if lock_path.exists(): + lock = lock_path.read_text() + assert 'name = "rust-proxy"\nversion = "0.24.5"' in lock + assert 'name = "systemstat"' in lock and 'name = "metrics-process"' in lock and 'name = "sysinfo"' in lock, "Cargo.lock must be resolver-generated after adding cross-platform statistics libraries" +for release_doc, marker in { + "README.md": "# RustProxy 0.24.5", + "CHANGELOG.md": "## 0.24.5", + "ROADMAP.md": "## 0.24.5", + "AGENTS.md": "Current source version: 0.24.5.", + "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.24.5", +}.items(): + assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}" +assert tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] == "stable" +update_defaults = tomllib.loads((root / "UPDATE_CHECK.toml").read_text()) +assert update_defaults == { + "repository_url": "https://git.linuxiarz.pl/gru/RustProxy", + "version_url": "https://git.linuxiarz.pl/gru/RustProxy/raw/branch/master/Cargo.toml", + "interval_seconds": 3600, +} +update_check_source = (root / "src/update_check.rs").read_text() +for marker in [ + 'include_str!("../UPDATE_CHECK.toml")', + 'pub async fn poll_loop', + 'tokio::time::sleep(interval)', + 'Cargo.toml [package] version was not found', + 'MAX_VERSION_DOCUMENT_BYTES', + 'update check failed; retrying on the next interval', +]: + assert marker in update_check_source, marker +assert 'update version document is too large or incomplete: {error}' in update_check_source +assert '.context("update version document is too large or incomplete")' not in update_check_source +for name in ["README.md", "ROADMAP.md", "AGENTS.md", "CHANGELOG.md", "UPDATE_CHECK.toml", "Dockerfile", ".dockerignore", "docs/TEST_REPORT.md", "docs/V0.24.5_AUDIT.md", "docs/V0.24.1_AUDIT.md", "docs/V0.24.0_AUDIT.md", "docs/V0.23.2_AUDIT.md", "docs/V0.23.1_AUDIT.md", "docs/V0.22.3_AUDIT.md", "docs/LIVE_LOGS.md", "docs/README.md", "docs/API.md", "docs/ARCHITECTURE.md", "docs/VHOSTS.md", "docs/STREAMS.md", "docs/REDIRECTS.md", "docs/CONTAINER.md", "docs/NPM_MIGRATION.md", "docs/DOCKER_DISCOVERY.md", "docker-agent/main.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/docker-agent.env.example", "docker-agent/install.sh", "src/docker.rs", "src/admin/web/docker.js", "scripts/migrate_npm.py", "docs/CUSTOM_LOCATIONS.md", "nagios/README.md", "nagios/check_rustproxy.py", "nagios/check_rustproxy_api", "nagios/check_rustproxy_backends", "nagios/check_rustproxy_latency", "nagios/check_rustproxy_queues", "src/admin/web/error_401.html", "src/admin/web/error_404.html", "src/admin/web/streams.js", "src/admin/web/redirects.js", "src/admin/web/host404.js", "docs/NOT_FOUND_HOSTS.md", "debian/control", "debian/rules", "debian/rustproxy.service", "debian/rustproxy.logrotate", "debian/build-package.sh", "debian/build-repository.sh", "grafana/rustproxy-overview.json", "grafana/README.md"]: + assert (root / name).is_file(), name +for path in (root / "examples").glob("*.json"): + data = json.loads(path.read_text()) + assert isinstance(data, dict), path + assert isinstance(data.get("vhosts"), list), path + assert "backends" not in data and "routes" not in data, path + for key in ("backends", "routes", "vhosts"): + assert isinstance(data.get(key, []), list), (path, key) +for path in (root / "src").rglob("*.rs"): + source = path.read_text() + for target in re.findall(r'include_str!\("([^"]+)"\)', source): + assert (path.parent / target).is_file(), (path, target) + lines = source.splitlines() + for index in range(len(lines) - 1): + assert not (lines[index].lstrip().startswith("#[derive(") and lines[index + 1].lstrip().startswith("#[derive(")), f"consecutive derive attributes in {path}:{index + 1}" +html = (root / "src/admin/web/index.html").read_text() +js = "\n".join(p.read_text() for p in (root / "src/admin/web").glob("*.js") if p.name not in ("login.js", "docs.js")) +ids = re.findall(r'id="([^"]+)"', html) +assert len(ids) == len(set(ids)), "duplicate HTML IDs" +for identifier in set(re.findall(r'\$\("([^"]+)"\)', js)): + assert identifier in ids, f"unknown frontend ID: {identifier}" +for path in [root / "make_zip.py", *(root / "scripts").glob("*.py")]: + ast.parse(path.read_text(), str(path)) +subprocess.run(["bash", "-n", str(root / "dev.sh")], check=True) +subprocess.run(["bash", "-n", str(root / "upgrade.sh")], check=True) +for name in ["install.sh", "install-debian13.sh", "update.sh", "debian/build-package.sh", "debian/build-repository.sh", "debian/rustproxy.postinst", "nagios/check_rustproxy_api", "nagios/check_rustproxy_backends", "nagios/check_rustproxy_latency", "nagios/check_rustproxy_queues", "docker-agent/install.sh"]: + subprocess.run(["bash", "-n", str(root / name)], check=True) +bootstrap = (root / "install-debian13.sh").read_text() +for marker in [ + 'MODE="${1:-auto}"', + 'auto|install|update', + 'MODE=update', + 'bash update.sh', + '--prefix "$PREFIX"', + 'python3-certbot-dns-rfc2136', + 'python3-certbot-dns-cloudflare', +]: + assert marker in bootstrap, marker +assert 'curl ' not in bootstrap, 'Debian bootstrap must use wget instead of curl' +dev = (root / "dev.sh").read_text() +assert 'cargo generate-lockfile' in dev and r'version = "0\.9\.0"' not in dev +assert dev.count('cargo generate-lockfile') == 1, "dev.sh must only create Cargo.lock when it is missing" +unit = (root / "deploy/rustproxy.service").read_text() +assert "LogsDirectory=rustproxy" in unit and "/var/log/rustproxy" in unit +assert "Environment=MALLOC_ARENA_MAX=2" in unit +assert "Environment=MALLOC_ARENA_MAX=2" in (root / "debian/rustproxy.service").read_text() +assert "Environment=MALLOC_ARENA_MAX=2" in (root / "scripts/deploy.py").read_text() +assert not re.search(r"\b20\d{2}[-/]\d{1,2}[-/]\d{1,2}\b", (root / "CHANGELOG.md").read_text()), "changelog contains a release date" +subprocess.run(["python3", str(root / "scripts/langs_check.py")], check=True) +dockerfile = (root / "Dockerfile").read_text() +assert "cargo generate-lockfile" in dockerfile and "cargo build --locked --release" in dockerfile +assert "USER rustproxy:rustproxy" in dockerfile +assert 'VOLUME ["/var/lib/rustproxy"]' in dockerfile +assert "COPY . ." in dockerfile +assert "data" in (root / ".dockerignore").read_text().splitlines() +css = (root / "src/admin/web/style.css").read_text() +assert ".logging-grid fieldset" not in css, "logging sinks must reuse shared card styling" +assert ".vhost-chart-line" in css and ".host-chip-editor" in css +assert 'class="vhost-dialog logging-format-dialog"' in html +assert 'data-panel="streams"' in html and 'id="panel-streams"' in html +assert 'data-panel="docker-vhosts"' in html and 'id="panel-docker-vhosts"' in html +assert 'id="settings-tab-docker-agents"' in html and 'id="settings-page-docker-agents"' in html +assert '/assets/docker.js' in html and 'cache-live-toggle' in html +assert 'expose-revision' not in html and 'hideRevision' not in js and 'diagRevision' not in js +for path in ['src/settings.rs', 'src/admin/mod.rs', 'src/cache/mod.rs', 'src/headers.rs', 'src/tinyauth.rs', 'src/admin/web/vhosts.js']: + assert 'x-rust-proxy-revision' not in (root / path).read_text().lower(), f'legacy revision-header logic remains in {path}' +assert './dev.sh admin reset-password' not in html and 'rust-proxy admin reset-password locally' in html +assert 'data-panel="redirects"' in html and 'id="panel-redirects"' in html +assert 'data-panel="configuration"' not in html and 'id="panel-configuration"' not in html +assert 'id="settings-tab-manual-config"' in html and 'id="settings-page-manual-config"' in html +assert 'id="security-audit-open"' in html and 'id="audit-dialog"' in html and 'id="audit-dialog-close"' in html +assert 'id="security-password-editor"' in html and 'id="security-policy-editor"' in html +assert 'docker-service-editor' in (root / "src/admin/web/docker.js").read_text() and 'docker-agent-details' in (root / "src/admin/web/docker.js").read_text() +balance_source = (root / "src/balance.rs").read_text() +assert 'mode: HealthMode::Tcp' in balance_source +for marker in [ + 'pub struct PassiveHealth', 'enabled: false', 'pub passive_health: PassiveHealth', + 'passive_health_failures_total', 'http_5xx_total', 'timeouts_total', + 'connect_errors_total', 'io_errors_total', 'slow_responses_total', + 'passive_health_is_opt_in_and_uses_separate_thresholds', + 'unhealthy_source_hands_off_when_the_original_health_domain_recovers', +]: + assert marker in balance_source, marker +assert 'id="config-editor"' in html and 'id="save-json"' in html and 'id="export-config"' in html +assert 'toast(t("saved"));' in js and 'toast(t("saved") + envelope.revision);' not in js +for lang_name in ["en_US.json", "pl_pl.json"]: + translations = json.loads((root / "langs" / lang_name).read_text())["translations"] + assert translations["saved"] in {"Configuration applied.", "Zastosowano konfigurację."} + assert "revision" not in translations["saved"].lower() and "rewizj" not in translations["saved"].lower() +assert html.index('data-panel="vhosts"') < html.index('data-panel="redirects"') < html.index('data-panel="notfound"') < html.index('data-panel="streams"') < html.index('data-panel="acl-profiles"') +assert 'id="stream-dialog"' in html and 'id="stream-list"' in html and 'id="stream-search"' in html +assert 'id="settings-page-streams"' not in html and 'id="streams-form"' not in html +assert '/assets/streams.js' in html and '/assets/redirects.js' in html and '/assets/host404.js' in html +vhosts_js = (root / "src/admin/web/vhosts.js").read_text() +assert '"force-ssl", "vhForceSsl"' in vhosts_js and 'force_ssl: yes("force-ssl")' in vhosts_js +assert 'vhForceSslNeedsHttp' in vhosts_js and 'tlsPorts.has(443)' in vhosts_js +assert '"forward-client-ip", "vhForwardClientIp"' in vhosts_js and 'forward_client_ip: yes("forward-client-ip")' in vhosts_js +assert 'id="vhost-search"' in html and '$("vhost-search").addEventListener("input", render)' in vhosts_js +assert 'id="admin-session-days"' in html and 'min="7" max="30"' in html +assert 'member-local-risk' in vhosts_js and 'vhLocalListenerRiskConfirm' in vhosts_js +assert 'accept_local_listener_risk: yes("member-local-risk", row)' in vhosts_js +assert 'function administrationListenCandidates()' in vhosts_js and 'function adminListenerBackendCandidate(raw)' in vhosts_js +for marker in ['latestStatus?.administration?.locked_listen', 'latestStatus?.administration?.active?.listen', 'latestStatus?.admin_listen', "value === 'localhost'", '/^127(?:\\.\\d{1,3}){3}$/.test(value)', 'riskGate.hidden = show !== true', 'row.syncLocalRisk = () => syncRisk(false)']: + assert marker in vhosts_js, marker +assert 'const saved = editing ? (envelope?.config.vhosts || []).find(item => item.id === editing) : null;' in vhosts_js +assert 'load(saved ? structuredClone(saved) : defaults());' in vhosts_js +assert 'backends: [{ id: "origin-1", url: ""' in vhosts_js, "new backend drafts must require an explicit origin URL" +assert 'preserve_host: true' in vhosts_js, "new ordinary HTTP/1.1 backends must preserve public Host by default" +for marker in ['vhBackendDiagnostics', 'backend_header: yes("backend-header")', 'backend_timing_header: yes("backend-timing-header")', 'protocol-toggle-row', 'auth-subcard', 'vh-cache-options', 'syncCacheVisibility']: + assert marker in vhosts_js, marker +assert 'cache: { enabled: false }' in vhosts_js, "new vhost drafts must default cache off" +docker_js = (root / "src/admin/web/docker.js").read_text() +assert 'preserve.checked = assignment?.preserve_host !== false' in docker_js, "new Docker backends must preserve public Host by default" +for marker in ['saveSection("docker-agents"', '/api/docker/refresh', 'docker-publish', 'docker-hostname', 'host-chip-editor docker-alias-editor', 'docker-target-ip', 'default_target_ip', 'docker-agent-fingerprint', 'docker-agent-certificate', 'docker-health-mode', 'docker-passive-enabled', 'docker-passive-5xx', 'docker-search', 'docker-show-hidden', 'dockerHideVhost', 'pool_routes', 'setInterval']: + assert marker in docker_js, marker +docker_source = (root / "src/docker.rs").read_text() +for marker in ['pub struct Settings', 'pub struct Observation', 'pub struct HostAddress', 'pub struct Binding', 'aliases: Vec', 'target_ip: Option', 'tls_fingerprint_sha256', 'certificate_id: Option', 'health_check: crate::balance::HealthCheck', 'passive_health: crate::balance::PassiveHealth', 'PinnedCertificateVerifier', 'fingerprint mismatch', 'effective_config', 'poll_loop', 'address_allowlist']: + assert marker in docker_source, marker +agent_source = (root / "docker-agent/main.go").read_text() +for marker in ['/v1/inventory', 'Authorization', '/containers/json?all=0', 'IsLoopback', 'localHostAddresses', 'detectedAddresses', 'Bindings', 'RUSTPROXY_DOCKER_AGENT_TOKEN', 'ensureSelfSignedCertificate', 'ListenAndServeTLS', 'RUSTPROXY_DOCKER_AGENT_TLS', 'CertificateSHA256']: + assert marker in agent_source, marker +assert 'pool-vhost-disabled' in vhosts_js and '.pool-vhost-disabled' in css +streams_js = (root / "src/admin/web/streams.js").read_text() +redirects_js = (root / "src/admin/web/redirects.js").read_text() +for marker in ["rustProxyRedirects", "redirectTargetType", "status_code", "cache_seconds", 'saveSection("redirects"']: + assert marker in redirects_js, marker +for marker in ["rustProxyStreams", "streamTrafficChart", "streamPendingRestart", "stream-listener-add", "chartHistory", "sessions_total", "bytes_in", "bytes_out"]: + assert marker in streams_js, marker +integrations_js = (root / "src/admin/web/integrations.js").read_text() +assert "addressTagEditor" in integrations_js and "listener-address-chip-editor" in integrations_js +assert "stream-host-rows" not in integrations_js +assert 'const http80 = prefer(false, 80);' in integrations_js +assert 'if (http80) autoIds.add(http80.id);' in integrations_js and 'candidates.find(endpoint => !endpoint.binding?.tls)' in integrations_js +assert 'https443' not in integrations_js +assert 'window.rustProxyIntegrations.bindings(holder, selected, !editing)' in redirects_js +assert 'window.rustProxyIntegrations.hostnames(hostHolder' in redirects_js +assert 'id="redirect-hosts-editor"' in html +assert 'class="protocol-toggle-row"' in html +for marker in ['.vh-backend-card', '.auth-subcard', '.vh-cache-options[hidden]', '.protocol-toggle-row', '.listener-choices']: + assert marker in css, marker +openapi = (root / "src/admin/openapi.json").read_text() +assert '"load_balancing"' in openapi and '"LoadBalancingConfig"' in openapi and '"PassiveHealthConfig"' in openapi +assert 'Opt-in health decisions based on ordinary user traffic. Defaults to disabled.' in openapi +assert '"force_ssl"' in openapi and '"forward_client_ip"' in openapi +assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi +assert '"StreamMetrics"' in openapi and '"streams"' in openapi +for marker in ['"version": "0.24.5"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']: + assert marker in openapi, marker +for marker in ['"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']: + assert marker in openapi, marker +assert 'id="panel-notfound"' in html and 'id="notfound-dialog"' in html and 'id="notfound-hosts-editor"' in html +host404_js = (root / "src/admin/web/host404.js").read_text() +for marker in ['rustProxyNotFound', 'saveSection("not_found_hosts"', 'rustProxyIntegrations.hostnames', 'rustProxyIntegrations.bindings', 'BUILTIN_STATUSES', 'ERROR_STATUSES']: + assert marker in host404_js, marker +assert 'for (let code = 200; code <= 599; code++)' not in host404_js +assert 'preserve_host: true' in vhosts_js, "new HTTP/1.1 backend drafts must preserve the public Host" +assert 'id="panel-acl-profiles"' in html and 'id="acl-profile-form"' in html +for marker in ['id="acl-profile-rule-rows"', 'id="acl-profile-rule-add"', 'id="acl-profile-import-rules"']: + assert marker in html, marker +management = (root / "src/admin/web/management.js").read_text() +assert 'collectRuleRows' in management and 'parseRules(rules.value, rules)' in management +assert 'normalizeAclRuleText' in js and 'ruleSyntaxLine' in js +cache_source = read_cache_source() +assert not (root / "src/cache.rs").exists() +for fragment in ["mod.rs", "model.rs", "lifecycle.rs", "cold.rs", "dashboard.rs", "tickets.rs", "lookup.rs", "policy.rs", "fill.rs", "collapse.rs", "range.rs", "invalidation.rs", "tests.rs"]: + assert (root / "src/cache" / fragment).is_file(), fragment +cache_mod = (root / "src/cache/mod.rs").read_text() +assert 'impl Cache {\n include!(' not in cache_mod, "include! cannot be used directly as Cache impl items" +for fragment in ["lifecycle.rs", "cold.rs", "dashboard.rs", "tickets.rs", "lookup.rs"]: + assert (root / "src/cache" / fragment).read_text().lstrip().startswith("impl Cache {"), fragment +assert 'disabled_cache_strips_diagnostics_without_exposing_bypass_headers' in cache_source +assert 'if !self.enabled' in cache_source +proxy_source = (root / "src/proxy/mod.rs").read_text() +assert 'response.extensions().get::().is_none()' in proxy_source +assert '"cdn-cache-control"' in proxy_source and '"x-cache-resident-age"' in proxy_source +for marker in [ + 'cold_policy_can_disable_lru_demotion', + 'cold_policy_can_disable_idle_demotion', + 'cold_policy_can_demote_by_total_hot_residency_age', + 'cold_on_age', + 'cold_max_hot_seconds', + 'graceful_shutdown_flush_moves_hot_objects_and_releases_resident_bytes', + 'graceful_shutdown_flush_can_be_disabled', + 'managed_update_flush_ignores_shutdown_toggle_when_cold_tier_is_enabled', + 'pub async fn flush_hot_to_cold', + 'pub async fn flush_hot_to_cold_for_update', + 'cold_on_shutdown', + 'vary_selects_variants_without_fragmenting_the_primary_hash', + 'vary_schema_change_removes_incompatible_fallbacks', + 'varnish_primary_hash_uses_host_and_url_not_unrelated_request_headers', + 'resident_age_survives_successful_revalidation', + 'COLD_FORMAT_VERSION: u8 = 3', + 'cold_cache_uses_two_hex_character_shards', + 'legacy_flat_cold_cache_is_not_migrated', + 'manual_cold_cleanup_reports_removed_objects_and_freed_bytes', + 'rfc5861_windows_and_revalidation_flags_are_separate_metadata', + 'request_max_stale_is_bounded_and_respects_revalidation_flags', + 'stale_if_error_is_only_available_inside_its_window', + 'transformed_etag_is_never_used_as_an_origin_validator', + 'Deduplicate only the exact cached', + 'matching_hot_key', + 'matching_cold_key', + 'concurrent_miss_single_flight_waits_until_fill_is_published', + 'single_flight_timeout_and_leader_drop_release_the_key', + 'cancelled_single_flight_waiter_releases_waiter_metric', + 'single_flight_retries_after_another_vary_representation_is_published', + 'cached_single_range_returns_206_and_preserves_validators', + 'cached_range_supports_suffix_open_unsatisfied_and_if_range', + 'range_parser_rejects_multipart_and_duplicate_fields_and_if_range_date_allows_older_representation', + 'stale_if_error_policy_is_configurable_by_error_class_and_status', + 'proactive_refresh_guards_deduplicate_and_bound_parallel_work', + 'selective_purge_exact_prefix_tag_cache_key_and_query_are_precise', + 'selective_purge_is_a_barrier_for_a_fill_started_before_the_purge', + 'purge_of_one_vary_variant_removes_the_whole_variant_family', + 'ban_is_lazy_blocks_old_object_allows_new_fill_and_can_be_removed', + 'regex_and_root_prefix_require_explicit_wide_confirmation', + 'selective_purge_removes_matching_cold_object', +]: + assert marker in cache_source, marker +transform_source = (root / "src/transform/mod.rs").read_text() +for marker in [ + 'pub struct Settings', + 'pub struct Overrides', + 'resolved.enabled = global.enabled && self.enabled', + 'pub fn no_transform', + 'OriginValidators', + "fn decode<'a>", + 'Cow::Borrowed(body)', + 'trim_allocator_after_large_transform', + 'drop(decoded);', + 'drop(raw);', + 'rustproxy-', +]: + assert marker in transform_source, marker +for name in ['js.rs', 'css.rs', 'html.rs', 'svg.rs', 'json.rs']: + assert (root / 'src/transform' / name).is_file(), name +for name in ['js.rs', 'css.rs']: + minifier_source = (root / 'src/transform' / name).read_text() + assert 'Vec' not in minifier_source and '.chars().collect' not in minifier_source, name + assert 'into_bytes()' in minifier_source, name +assert 'pub mod transform;' in (root / 'src/lib.rs').read_text() +for marker in ['cache-cold-gc-interval', 'cache-cold-gc-max-delete', 'settings-tab-transform', 'transform-form', 'cache-cleanup-dialog']: + assert marker in html, marker +for marker in ['cache-transient-memory', 'cache-cold-queue-bytes', 'cache-eviction-limit', 'data-cache-memory-preset="recommended"', 'cache-memory-warning']: + assert marker in html, marker +for marker in ['effectiveCacheMemory', 'cacheMemoryHeadroom', 'cachePresetValues', 'applyCacheMemoryPreset', 'cacheMemoryPresetTooSmall', 'cacheObjectOverTransientWarning', 'cgroup_memory_max_bytes', 'transient_max_bytes', 'cold_queue_max_bytes', 'hot_eviction_max_objects']: + assert marker in js, marker +assert 'envelope?.config.cache?.enabled === true && cacheToggle.value !== "off"' in vhosts_js +for marker in ['transform: { enabled: false }', 'vhTransform', 'collectTransform']: + assert marker in vhosts_js, marker +docker_js = (root / 'src/admin/web/docker.js').read_text() +assert 'transform_enabled' in docker_js +cache_panel_js = (root / 'src/admin/web/cache-panel.js').read_text() +for marker in ['/api/cache/cold/cleanup', 'removed_entries', 'freed_bytes', '/api/cache/invalidate', '/api/cache/bans', 'cache-invalidation-operation', 'cache-invalidation-selector', 'cache-ban-ttl', 'confirm_wide', 'cachePurgeUrl']: + assert marker in cache_panel_js or marker in html, marker +transform_panel_js = (root / 'src/admin/web/transform-panel.js').read_text() +for marker in ['/api/transform', 'tp-cache-hits', 'transform-reason-list']: + assert marker in transform_panel_js, marker +for marker in ['id="cache-live-chart"', 'id="cache-live-activity-chart"', 'id="cache-live-hit-line"', 'id="cache-live-miss-line"', 'id="cache-live-bypass-line"', 'id="cache-live-samples"', 'data-i18n="cacheLiveChartTitle"']: + assert marker in html, marker +for marker in ['id="transform-live-chart"', 'id="transform-live-activity-chart"', 'id="transform-live-applied-line"', 'id="transform-live-cache-line"', 'id="transform-live-skipped-line"', 'id="transform-live-error-line"', 'id="transform-live-samples"', 'data-i18n="transformLiveChartTitle"']: + assert marker in html, marker +assert '
' in html and '
' in html +for marker in ['liveChartSamples = { hits: [], misses: [], bypasses: [] }', 'LIVE_CHART_LIMITS = new Set([60, 120, 300, 600])', 'rustproxy.cache.liveChartSamples', 'performance.now()', 'sparklinePoints(liveChartSamples.hits, 900, 180, 10, peak)', 'slice(-liveChartLimit)']: + assert marker in cache_panel_js, marker +for marker in ['liveChartSamples = { applied: [], cache_hits: [], skipped: [], errors: [] }', 'LIVE_CHART_LIMITS = new Set([60, 120, 300, 600])', 'rustproxy.transform.liveChartSamples', 'performance.now()', 'sparklinePoints(liveChartSamples.applied, 900, 180, 10, peak)', 'slice(-liveChartLimit)']: + assert marker in transform_panel_js, marker +admin_cache_source = (root / 'src/admin/mod.rs').read_text() +for marker in ['"transform" => {', 'transform_status', 'cache_cold_cleanup', 'cache_cold_cleanup_job', 'cache_invalidate', 'cache_bans', 'cache_ban_delete', 'selective_cache_invalidation_api_manages_bans_and_rejects_wide_rules_without_confirmation']: + assert marker in admin_cache_source, marker +for marker in [ + 'fill_bytes_active', + 'cold_queue_items', + 'cold_queue_bytes', + 'cold_write_bytes_active', + 'cold_read_bytes_active', + 'transient_bytes', + 'transient_max_bytes', + 'cold_queue_max_bytes', + 'hot_eviction_max_objects', + 'hot_logical_bytes', + 'hot_allocated_bytes', + 'memory_pressure_rejections', + 'struct FillGauge', + 'struct TransientLease', + 'reserve_fill_allocation', + 'release_allocator_memory()', +]: + assert marker in cache_source, marker +assert 'impl Drop for Candidate' not in cache_source, 'fill accounting must not block moving Candidate fields during finish' +redirect_source = (root / "src/redirect.rs").read_text() +for marker in ["pub struct Host", "pub struct Rule", "strip_prefix", "cache_seconds", "unconditional self-redirect"]: + assert marker in redirect_source, marker +proxy_source = (root / "src/proxy/mod.rs").read_text() +assert 'state.cache.purge(None)' not in proxy_source, 'proxy requests must not globally purge cache' +assert 'request.method() == Method::HEAD && cache_ticket.is_some()' in proxy_source +assert 'RangeSelection::Single' in proxy_source and 'request_range(request.method(), request.headers())' in proxy_source +assert 'claim_fill(ticket, cache_settings)' in proxy_source and 'cache_collapse_timeout_origin' in proxy_source +assert 'schedule_proactive_refresh' in proxy_source and 'cache_proactive_refreshed' in proxy_source +assert 'classify_stale_error' in proxy_source and 'allows_stale_error_status' in proxy_source +assert 'Varnish built-in behavior treats non-GET/HEAD methods as pass' in proxy_source +config_source = (root / "src/config.rs").read_text() +assert 'plain HTTP listener' in config_source and 'tls_ports.contains(&443)' in config_source +assert '#[serde(default)]\n pub tls_verify: bool' in config_source +assert 'backend_tls_verification_defaults_off_and_can_be_enabled_explicitly' in config_source +assert 'backend TLS server name must be the DNS name present in the certificate' in config_source +assert 'pub accept_local_listener_risk: bool' in config_source +assert 'accept_local_listener_risk = true' in config_source +for marker in [ + 'fallbacks: HashMap>', + 'id: format!("{}/main", host.id)', + 'custom_locations_override_but_main_vhost_remains_fallback', + 'direct_admin_listener_backend_is_marked_for_guarded_control_plane_use', + 'pub targets_admin_listener: bool', + 'backend_diagnostic_flags_compile_to_frontend', + 'backend_header: host.backend_header', + 'backend_timing_header: host.backend_timing_header', +]: + assert marker in config_source, marker +vhost_source = (root / "src/vhost.rs").read_text() +assert 'is_legacy_main_fallback' in vhost_source +assert 'values.length ? values : [{ id: "root"' not in vhosts_js +for marker in [ + 'OriginBackend', + 'apply_backend_diagnostic_headers', + 'x-rustproxy-backend-time', + 'backend_diagnostics_are_opt_in_and_strip_spoofed_values', + 'admin_session_passthrough', + 'admin_control_plane', + 'Some("admin_control_plane")', + 'restore_admin_cookie', + 'BEFORE returning a successful RFC 9220 response', + 'websocket_phase("tunnel_ready")', +]: + assert marker in proxy_source, marker +generic_headers_source = (root / "src/headers.rs").read_text() +assert '"x-rust-proxy-backend"' in generic_headers_source and '"x-rustproxy-backend-time"' in generic_headers_source +headers_source = (root / "src/proxy/headers.rs").read_text() +for marker in ['pub fn restore_admin_cookie', 'admin_cookie_can_be_restored_only_from_captured_fragment']: + assert marker in headers_source, marker +settings_source = (root / "src/settings.rs").read_text() +for marker in ['pub session_days: u16', 'session_days: 30', '(7..=30).contains(&self.session_days)', 'administration_session_lifetime_is_7_to_30_days_and_hot_apply_only']: + assert marker in settings_source, marker +auth_source = (root / "src/auth/mod.rs").read_text() +auth_store = (root / "src/auth/store.rs").read_text() +for marker in ['30 * 24 * 60 * 60', 'session_cookie(secret: &str, secure: bool, max_age_seconds: i64)', 'browser_session_lifetime_is_configurable_without_sliding_expiry', 'session_cookie_uses_configured_remaining_lifetime']: + assert marker in auth_source, marker +for marker in ['create_browser_session', 'session_at_with_browser_lifetime', 'browser session lifetime must be 7..30 days', "created_at + ?"]: + assert marker in auth_store, marker +upstream_source = (root / "src/upstream.rs").read_text() +for marker in ['pub accept_local_listener_risk: bool', '!self_target || self.accept_local_listener_risk', 'explicit_risk_acceptance_allows_local_listener_but_not_private_policy_bypass']: + assert marker in upstream_source, marker +transport_tests = (root / "tests/transport_config.rs").read_text() +assert 'local_listener_backend_requires_explicit_risk_acceptance' in transport_tests and '127.0.0.1:9090' in transport_tests +runtime_source = (root / "src/runtime.rs").read_text() +for marker in ['fn cache_revision', 'CacheInvalidation::None', 'operational_saves_keep_cache_identity_but_cache_affecting_saves_rotate_it']: + assert marker in runtime_source, marker +admin = (root / "src/admin/mod.rs").read_text() +for marker in [ + "fn admin_request_authority", + "Version::HTTP_2 | Version::HTTP_3", + "request.uri().authority().ok_or(())?", + "administration_https_health_accepts_http2_authority_without_host_header", +]: + assert marker in admin, marker +assert '"/assets/streams.js"' in admin and '"metrics": stream_metrics' in admin +for marker in ['"/api/config/{section}"', 'async fn update_section', 'section_updates_are_revisioned_and_do_not_require_full_config_payloads', 'panel_subpage_routes_survive_browser_refresh', 'fn panel_document_path', 'panel_document_path(&path)', 'certificate_metadata', 'inspected_metadata', 'session_at_with_browser_lifetime', 'session_lifetime_seconds', 'HeaderValue::from_str(&auth::session_cookie(']: + assert marker in admin, marker +for route in ['/overview', '/vhosts', '/redirects', '/404-hosts', '/streams', '/acl-profiles', '/configuration', '/tls', '/cache', '/transform', '/logging', '/settings']: + assert f'.route("{route}", get(public::index))' in admin, route +app_js = (root / "src/admin/web/app.js").read_text() +for marker in ['const PANEL_PATHS', 'history.pushState', 'window.addEventListener("popstate"', 'async function saveSection', 'async function revalidateSession', 'Object.values(PANEL_PATHS), "/configuration"', 'certificateCovers', 'covered_hosts', 'refreshCertificateInventory', 'certificateDetails', 'certificate-domain-pills', 'certificateIssuerName', 'certificateDaysRemaining', 'certificateRenewalStatus']: + assert marker in app_js, marker +for marker in ['.certificate-details', '.certificate-domain-pills', '.certificate-card .pill', '.certificate-operation-card', '.renewal-meta', '#panel-tls .certificate-tabs']: + assert marker in css, marker +acme_js = (root / "src/admin/web/acme.js").read_text() +for marker in ['data-certificate-tab="installed"', 'data-certificate-tab="renewal"', 'data-certificate-tab="generate"', 'id="certificate-page-installed"', 'id="certificate-page-renewal"', 'id="certificate-page-generate"']: + assert marker in html, marker +assert 'id="certificate-dialog"' not in html and 'id="certificate-close"' not in html +for marker in ['function selectTab(name, focus = false, persist = true)', 'log_directories', 'certbotLogs', 'value?.kind?.startsWith("renewal")', 'selectTab("installed")']: + assert marker in acme_js, marker +login_js = (root / "src/admin/web/login.js").read_text() +assert 'const panelPaths = new Set' in login_js and 'panelPaths.has(location.pathname)' in login_js +assert '"/404-hosts"' in login_js +assert 'async function redirectExistingSession' in login_js and '/api/auth/session' in login_js +settings_js = (root / "src/admin/web/settings.js").read_text() +assert '["/settings", "/configuration", "/logging"].includes(location.pathname)' in settings_js +assert 'new URLSearchParams(location.search).get("tab")' in settings_js and 'function syncLocation()' in settings_js and 'history.replaceState' in settings_js +assert 'url.pathname = "/settings"' in settings_js and 'url.searchParams.set("tab", active)' in settings_js +assert 'data-settings-tab="certificate-api"' in html and 'id="settings-page-certificate-api"' in html +for marker in ['data-cache-settings-tab="general"', 'data-cache-settings-tab="resilience"', 'data-cache-settings-tab="cold"', 'data-cache-settings-tab="diagnostics"']: + assert marker in html, marker +for marker in ['data-settings-tab="connections"', 'data-http-settings-tab="http2"', 'data-http-settings-tab="http3"', 'id="http2-form"', 'id="http2-listener-rows"', 'id="http3-form"', 'id="http3-listener-rows"']: + assert marker in html, marker +for marker in ['const tabAliases = { http2: "connections" }', 'data-http-settings-tab', 'selectHttpTab("http2")']: + assert marker in settings_js or marker in html, marker +for marker in ['renderHttp2Settings', 'collectHttp2Listeners', 'http2SettingsControls', 'preservedHttp2', 'listener-h2', 'h2Configure', 'selectHttpTab?.("http2")', 'renderHttp3Settings', 'collectHttp3Listeners', 'http3SettingsControls', 'preservedHttp3', 'listener-h3', 'h3Configure', 'selectHttpTab?.("http3")']: + assert marker in integrations_js, marker +h2_settings_controls = integrations_js.split('function http2SettingsControls', 1)[1].split('const h3Defaults', 1)[0] +assert 'listener-h2' not in h2_settings_controls, 'HTTP/2 enablement belongs to the listener editor, not the tuning page' +h3_settings_controls = integrations_js.split('function http3SettingsControls', 1)[1].split('function addRow', 1)[0] +assert 'h3Enable' not in h3_settings_controls, 'HTTP/3 enablement belongs to the listener editor, not the tuning page' +assert 'data-i18n="processDiagnostics">Process<' in html +for marker in ['function setOptionalMetric', 'setOptionalMetric("process-rss-anon"', 'setOptionalMetric("system-swap"', 'closest(".mini-live-chart").hidden']: + assert marker in app_js, marker +for marker in ['/api/certificate-api', 'certificate-api-form', 'certificate-api-revoke-all', 'certificate-api-credentials', 'data-cache-settings-tab']: + assert marker in settings_js or marker in html, marker +db_source = (root / "src/db.rs").read_text() +acme_source = (root / "src/acme.rs").read_text() +main_source = (root / "src/main.rs").read_text() +logging_source = (root / "src/logging.rs").read_text() +for marker in ['certificate_api_credentials', 'upsert_certificate_api_credential', 'revoke_certificate_api_credential', 'verify_certificate_api_credential', '0005_certificate_api.sql', '0006_certificate_api_credentials.sql']: + assert marker in db_source, marker +for marker in ['/api/certificate-api', 'certificate_api_credentials', 'certificate_api_operation', '/api/live-logs', 'CertificateApiBearer']: + assert marker in admin or marker in openapi, marker +for marker in ['/api/certificates/{certificate_id}/reload', 'certificate_reload_one', 'reload_certificate(&certificate_id)', 'CertificateReloadOneResult', '/api/certificate-api/{key_id}']: + assert marker in admin or marker in openapi, marker +for marker in ['metric-bytes-down', 'metric-bytes-up', 'connection-chart', 'network-interface-hide', 'rp-hidden-network-interfaces']: + assert marker in html or marker in app_js or marker in (root / 'src/admin/web/style.css').read_text(), marker +assert 'valid_certificate_api_key_id' in admin and 'sleep_until(next_auth_check)' in admin +for marker in [ + 'livelogs: bool', 'LiveLogFilters', 'handle_live_command', 'live_logs_command', + 'create_live_log_session', 'command_input_open', 'buffer.extend_from_slice(&data)', + 'sse_parser_tolerates_utf8_split_across_transport_frames', + 'LocalAdminCertificateVerifier', 'detect_local_admin_scheme', + 'local_admin_scheme_from_probe', 'admin.uri("/api/live-logs")', +]: + assert marker in main_source or marker in (root / 'src/auth/store.rs').read_text(), marker +assert '.uri(format!("http://{addr}/api/live-logs"))' not in main_source +assert 'http://{addr}/healthz' in main_source and 'StatusCode::PERMANENT_REDIRECT' in main_source +assert 'Ok(line) if command_tx.send(line).is_ok()' not in main_source, 'do not move String from a match guard in --livelogs stdin handling' +assert 'Ok(line) => {' in main_source and 'command_tx.send(line).is_err()' in main_source +for marker in ['LIVE_STREAM_CAPACITY', 'broadcast::Sender', 'pub fn subscribe']: + assert marker in logging_source, marker +assert 'SCHEMA_VERSION: i64 = 6' in db_source and 'certificate_renewals' in db_source and '0004_admin_preferences.sql' in db_source and '0005_certificate_api.sql' in db_source and '0006_certificate_api_credentials.sql' in db_source +assert 'load_renewal_observations' not in acme_source and 'renewals.json' not in acme_source +for marker in ['pub kind: &\'static str', 'pub log_directory: String', 'pub(crate) fn certbot_log_directory', 'kind: "issue"', '"renewal-dry-run"']: + assert marker in acme_source, marker +assert '"log_directories":log_directories' in admin and 'crate::acme::certbot_log_directory' in admin +assert 'let cert_dir = Some(directory.clone());' in main_source, 'certificate directory must remain available after ValidationPolicy construction' +assert 'accept-remote-admin-risk' in main_source and 'hide = true' in main_source +for marker in [ + 'RUST_PROXY_UPDATE_REPOSITORY_URL', + 'RUST_PROXY_UPDATE_VERSION_URL', + 'RUST_PROXY_UPDATE_CHECK_INTERVAL_SECS', + 'rust_proxy::update_check::Config::embedded()?.with_overrides(', + 'state.start_update_check(update_check);', +]: + assert marker in main_source, marker +runtime_source = (root / "src/runtime.rs").read_text() +assert 'pub software_update: Arc' in runtime_source +assert 'pub fn start_update_check(self: &Arc, config: crate::update_check::Config)' in runtime_source +assert 'payload["software_update"] = json!(state.software_update.snapshot());' in admin +settings_rust = (root / 'src/settings.rs').read_text() +assert 'pub struct StartupLocks' in settings_rust and 'fixed by systemd/startup' in settings_rust +logging_rust = (root / 'src/logging.rs').read_text() +assert 'pub per_vhost: bool' in logging_rust and 'default_vhost_log_directory' in logging_rust and '_unmatched' in logging_rust +pages_rust = (root / 'src/pages.rs').read_text() +assert 'validate_local_response' in pages_rust and '(200..=599).contains(&status)' in pages_rust and '204 | 205 | 304' in pages_rust +assert 'store.load_certificate_renewals().await?' in main_source and 'state.acme.restore_renewals(renewal_observations)' in main_source +assert 'renewals.json' not in main_source +upgrade_source = (root / 'scripts/upgrade.py').read_text() +assert 'legacy = certs_dir / "renewals.json"' in upgrade_source and 'removed_obsolete_runtime_state' in upgrade_source and 'legacy-data' not in upgrade_source and 'certs_dir.is_symlink()' in upgrade_source +assert '.managed-update' in main_source and 'flush_hot_to_cold_for_update' in main_source +tls_source = (root / "src/tls.rs").read_text() +assert 'pub fn validate_certificate_removals' in tls_source and 'administration panel' in tls_source +assert 'pub fn effective_cipher_suites' in tls_source +vhost_source = (root / 'src/vhost.rs').read_text() +assert 'pub websocket_rfc9220: bool' in vhost_source and 'RFC 9220 requires WebSocket support to be enabled' in vhost_source +for marker in ['pub struct ProxyTimeouts', 'connect_timeout_seconds', 'response_header_timeout_seconds', 'read_timeout_seconds', 'send_timeout_seconds']: + assert marker in vhost_source, marker +docker_source = (root / 'src/docker.rs').read_text() +for marker in ['pub poll_interval_seconds: u64', 'pub acl_profile_id: Option', 'pub proxy_timeouts: crate::vhost::ProxyTimeouts', 'refresh(&self, settings: &Settings, force: bool)', 'poll_interval_seconds.saturating_mul(1000)']: + assert marker in docker_source, marker +docker_js = (root / 'src/admin/web/docker.js').read_text() +for marker in ['docker-live-refresh', 'saveAssignment(agentId, row, publish)', 'docker-acl-profile', 'docker-proxy-read', 'poll_interval_seconds']: + assert marker in docker_js, marker +agent_installer = (root / 'docker-agent/install.sh').read_text() +assert 'Bearer token:' in agent_installer and 'RUSTPROXY_DOCKER_AGENT_TOKEN=' in agent_installer +assert 'Kind::Builtin' in pages_rust and 'builtin_application_page' in pages_rust +assert 'pub covered_hosts: Vec' in tls_source and 'fn certificate_dns_names' in tls_source and 'pub fn certificate_covered_hosts' in tls_source and 'legacy_certificate_coverage_can_be_read_from_existing_pem' in tls_source and 'assert_eq!(a.covered_hosts' in tls_source +acl_source = (root / 'src/acl.rs').read_text() +assert '/Ubuntu%2014.04%20LTS/' in acl_source and 'repeated_slashes_are_normalized_for_routing' in acl_source and 'merge_slashes' in acl_source +policies_source = (root / 'tests/policies.rs').read_text() +assert 'percent_encoded_spaces_are_valid_request_paths' in policies_source and 'repeated_slashes_route_normally_but_reach_upstream_unchanged' in policies_source and 'local_location_response_uses_response_rules_without_opening_backend' in policies_source and 'local_location_response_still_respects_route_acl' in policies_source +http3_source = (root / "src/http3.rs").read_text() +drop_impl = http3_source.split('impl Drop for ReceiveBody', 1)[1].split('#[cfg(test)]', 1)[0] +assert 'self.stream.stop_sending(' not in drop_impl +for marker in ['h3::quinn::RecvStream', 'websocket_rfc9220_on(local)', 'run_h3', 'H3_REQUEST_CANCELLED']: + assert marker in http3_source or marker in (root / 'src/proxy/websocket.rs').read_text(), marker +assert 'h3_quinn' not in http3_source and 'h3_quinn' not in (root / 'tests/http3.rs').read_text() +h3_test = (root / 'tests/http3.rs').read_text() +assert 'RFC 9220: Extended CONNECT negotiates WebSocket on the same H3 stream.' in h3_test and '"websocket_rfc9220":true' in h3_test +assert 'Method::CONNECT' in h3_test and 'h3::ext::Protocol' in h3_test and '.send_data(' in h3_test +admin_source = (root / 'src/admin/mod.rs').read_text() +for marker in ['external_admin_authority', 'external_admin_scheme', 'trusted_admin_forwarded_proto', 'reverse_proxy_dns_allowlist_uses_external_port_and_keeps_direct_port_available', 'trusted_https_reverse_proxy_accepts_external_origin_and_refreshes_secure_cookie']: + assert marker in admin_source, marker +ws_source = (root / 'src/proxy/websocket.rs').read_text() +assert 'upstream: Upgraded' in ws_source +assert 'timeout(Duration::from_secs(10), upstream)' not in ws_source +for marker in ['websocket_phase("tunnel_started")', 'websocket_phase("tunnel_closed")', 'h3_reset("H3_REQUEST_CANCELLED")']: + assert marker in ws_source, marker +for marker in ['http_version', 'transport', 'websocket_bootstrap', 'websocket_phase', 'websocket_error', 'h3_reset_code', 'upstream_status']: + assert marker in (root / 'src/logging.rs').read_text(), marker +diagnostics_source = (root / 'src/diagnostics.rs').read_text() + (root / 'src/system_stats/mod.rs').read_text() + (root / 'src/system_stats/linux.rs').read_text() +for marker in ['process_rss_anon_bytes', 'process_rss_file_bytes', 'process_rss_shmem_bytes', 'process_data_bytes', 'process_swap_bytes', 'RssAnon', 'RssFile', 'RssShmem', 'VmData', 'VmSwap']: + assert marker in diagnostics_source, marker +prometheus_source = (root / 'src/prometheus.rs').read_text() +for marker in ['allow_trusted_network_without_token', 'include_docker_vhosts', 'include_streams', 'include_logging', 'rust_proxy_docker_vhost_{name}', 'rust_proxy_docker_backend_{name}', 'rust_proxy_geoip_loaded', 'not_after_timestamp_seconds', 'ewma_latency_seconds', 'passive_failures', 'active_health_failures_total', 'passive_health_failures_total', 'passive_http_5xx_total', 'passive_timeouts_total', 'passive_connect_errors_total', 'passive_io_errors_total', 'passive_slow_total', 'http_5xx_total', 'timeouts_total', 'connect_errors_total', 'io_errors_total', 'slow_responses_total', 'rust_proxy_log_sink_queue_dropped_total', 'rust_proxy_not_found_hosts_configured']: + assert marker in prometheus_source, marker +assert 'if docker_targets.contains_key(&pool.id)' in prometheus_source +assert 'if docker_targets.contains_key(id)' in prometheus_source +assert 'rust_proxy_docker_vhost_exchange_duration_seconds' in prometheus_source +settings_source = (root / 'src/admin/web/settings.js').read_text() +assert 'prom-trusted-no-token' in settings_source and 'include_docker_vhosts' in settings_source and 'include_streams' in settings_source and 'include_logging' in settings_source +for marker in ['cache-memory-breakdown', 'cache-transient-breakdown', 'process-rss-total', 'process-rss-anon', 'process-rss-file', 'process-data-swap', 'memoryAccountingHint']: + assert marker in html, marker +for marker in ['validate_h3', 'H3Tunnel', 'h3_client_to_backend', 'backend_to_h3', 'rfc9220_bootstrap_uses_extended_connect_without_h1_proof_headers']: + assert marker in ws_source, marker +for marker in ['PrefixPriority', 'RegexInsensitive', 'LocationResponse', 'pub rewrite: PathRewrite']: + assert marker in (root / 'src/vhost.rs').read_text(), marker +for marker in ['rewritten_path', 'nginx_style_location_precedence_exact_priority_regex_prefix', 'location_rewrites_are_normalized_and_regex_captures_expand', 'custom_location_response_is_validated']: + assert marker in config_source, marker +for marker in ['local_location_response', 'route.rewritten_path(&path)', 'encoded_origin_path', 'client_ip.to_string()']: + assert marker in proxy_source, marker +assert proxy_source.index('RustProxy: denied by access policy') < proxy_source.index('if let Some(spec) = &route.response') < proxy_source.index('let (backend, backend_lease, affinity_cookie)') +for marker in ['location-help-dialog', 'cache-memory-sparkline', 'cache-hit-sparkline', 'cache-miss-sparkline', 'cache-bypass-sparkline', 'process-cpu-sparkline', 'process-rss-sparkline', 'network-rx-sparkline', 'latency-sparkline', 'sourceIp']: + assert marker in html, marker +for marker in ['locationMatchRegex', 'headersVisualBuilder', 'headersAdvancedJson', 'openLocationHelp', 'locationPreviewTitle', 'location-preview-grid', 'vh-header-rule-fields', 'cloudflare-client-ip', 'timing-headers']: + assert marker in vhosts_js, marker +for marker in ['lastApiLatencyMs', 'cacheMemorySamples', 'cacheHitSamples', 'processCpuSamples', 'processRssSamples', 'networkRxSamples', 'responseLatencySamples', 'client_ip']: + assert marker in app_js, marker +logging_source = (root / 'src/logging.rs').read_text() +assert 'pub client_ip: Option' in logging_source and '"client_ip" => entry.client_ip' in logging_source +for marker in ['check_rustproxy_api', 'response time', 'backend']: + assert marker in (root / 'nagios/README.md').read_text(), marker +stream_source = (root / "src/stream.rs").read_text() +for marker in ["pub struct Metrics", "pub struct MetricsView", "struct ActiveSession", "sessions_total", "sessions_completed", "packets_in", "packets_out", "metrics_view_reports_l4_counters"]: + assert marker in stream_source, marker +transport_doc = (root / "docs/TRANSPORT.md").read_text() +security_doc = (root / "docs/SECURITY.md").read_text() +api_access_doc = (root / "docs/API_ACCESS.md").read_text() +assert 'accept_local_listener_risk: true' in transport_doc and '127.0.0.1:9090' in transport_doc +assert 'accept_local_listener_risk=true' in security_doc and 'including port 9090' in security_doc +assert '7–30' in api_access_doc and 'default 30' in api_access_doc +not_found_source = (root / 'src/not_found.rs').read_text() +for marker in ['pub struct Host', 'pub struct CompiledHost', 'default_status', 'crate::pages::Page']: + assert marker in not_found_source, marker +pages_source = (root / 'src/pages.rs').read_text() +assert 'pub struct MinimalWelcome' in pages_source and 'HeaderValue::from_static("no-store")' in pages_source +assert 'builtin_welcome_is_minimal_and_never_cacheable' in pages_source +assert 'minimal_welcome' in proxy_source and 'cache-control' in proxy_source +make_zip = (root / "make_zip.py").read_text() +for marker in ["SOURCE_DATE_EPOCH", "ZipInfo", "deterministic_mode", "compresslevel=9"]: + assert marker in make_zip, marker +assert "'debian'" in make_zip and "'grafana'" in make_zip and "'docker-agent'" in make_zip +assert "'UPDATE_CHECK.toml'" in make_zip +# Debian package contracts. +debian_service = (root / 'debian/rustproxy.service').read_text() +for marker in ['User=rustproxy', 'AmbientCapabilities=CAP_NET_BIND_SERVICE', 'StateDirectory=rustproxy', 'LogsDirectory=rustproxy', 'ProtectSystem=strict', 'RUST_PROXY_ADMIN_LISTEN']: + if marker.startswith('RUST_PROXY_'): + assert marker in (root / 'debian/rustproxy.environment').read_text(), marker + else: + assert marker in debian_service, marker +logrotate = (root / 'debian/rustproxy.logrotate').read_text() +for marker in ['daily', 'rotate 3', 'maxage 3', 'compresscmd /usr/bin/gzip', 'compressoptions -9', 'copytruncate', 'su rustproxy rustproxy']: + assert marker in logrotate, marker +repo_script = (root / 'debian/build-repository.sh').read_text() +for marker in ['dpkg-scanpackages', 'apt-ftparchive', 'Packages.gz', 'RUSTPROXY_REPO_GPG_KEY', 'rustproxy.linuxiarz.pl']: + assert marker in repo_script, marker +grafana = json.loads((root / 'grafana/rustproxy-overview.json').read_text()) +assert grafana['uid'] == 'rustproxy-0170' and grafana['schemaVersion'] >= 39 +expressions = '\n'.join(target.get('expr','') for panel in grafana['panels'] for target in panel.get('targets',[])) +for marker in ['rust_proxy_process_rss_bytes', 'rust_proxy_cache_transient_bytes', 'rust_proxy_backend_latency_seconds', 'rust_proxy_stream_active', 'rust_proxy_log_sink_queue_dropped_total']: + assert marker in expressions, marker +# 0.19.0 compression/protocol/dashboard UX regressions. +compression_source = (root / "src/compression.rs").read_text() +metrics_source = (root / "src/metrics.rs").read_text() +admin_source = (root / "src/admin/mod.rs").read_text() +style_source = (root / "src/admin/web/style.css").read_text() +docker_ui = (root / "src/admin/web/docker.js").read_text() +for marker in ["pub enum AppliedEncoding", "AppliedEncoding::Gzip", "AppliedEncoding::Brotli", "AppliedEncoding::Zstd", "AppliedEncoding::Deflate"]: + assert marker in compression_source, marker +assert "quality_weights_can_select_each_enabled_codec" in compression_source +diagnostics_source = (root / "src/diagnostics.rs").read_text() + (root / "src/system_stats/mod.rs").read_text() + (root / "src/system_stats/linux.rs").read_text() +for marker in ["pub struct NetworkInterfaceView", "pub network_interfaces: Vec", "sysinfo::Networks::new_with_refreshed_list", "system.network_stats", "collect_network_systemstat", "normalize_network_interfaces", "merge_network_interfaces"]: + assert marker in diagnostics_source, marker +assert "interfaces.truncate(64)" not in diagnostics_source, "network inventory must not silently truncate interfaces" +for marker in ["pub http1_requests: AtomicU64", "pub fn record_compression", "each_codec_has_an_independent_counter", "http1_requests_total"]: + assert marker in metrics_source, marker +assert "state.metrics.record_compression(encoding)" in proxy_source +assert "sparkline-h1" in html and "sparkline-h2" in html and "sparkline-h3" in html +assert 'id="open-proxy"' not in html and '$("open-proxy")' not in app_js +assert "protocolSamples" in app_js and "m.http1_requests" in app_js +assert "renderNetworkInterfaceCharts" in app_js and "networkInterfaceSamples" in app_js +assert 'id="network-interface-charts"' in html +assert 'compressionCountersHelp' not in html +assert "dockerOpenApp" in docker_ui and "proxiedUrl(agent, hostname)" in docker_ui +# 0.19.0 Docker-vhost controls: chip aliases, default agent ACL, redirect/WebSockets/cache. +docker_source = (root / "src/docker.rs").read_text() +management_ui = (root / "src/admin/web/management.js").read_text() +for marker in ["function aliasEditor(", "host-chip-editor docker-alias-editor", ".docker-force-ssl", ".docker-websockets", ".docker-cache-enabled", ".docker-agent-acl-profile"]: + assert marker in docker_ui, marker +for marker in ['"force_ssl": assignment.force_ssl.unwrap_or(false)', '"websockets": assignment.websockets', '"cache": {"enabled": assignment.cache_enabled}', 'assignment.acl_profile_id.clone().or_else(|| agent.acl_profile_id.clone())']: + assert marker in docker_source, marker +assert "agent.acl_profile_id === profile.id" in management_ui +assert 'payload["backend_pool_domains"]' in admin_source and "status.backend_pool_domains" in vhosts_js +assert 'body[data-density="compact"]' in style_source and '--ui-control: 22px' in style_source +assert 'rust_proxy_http1_requests_total' in expressions +vhost_source = (root / "src/vhost.rs").read_text() +for marker in ["DEFAULT_PROXY_CONNECT_TIMEOUT_SECONDS: u64 = 10", "DEFAULT_PROXY_RESPONSE_HEADER_TIMEOUT_SECONDS: u64 = 120", "DEFAULT_PROXY_READ_TIMEOUT_SECONDS: u64 = 60", "DEFAULT_PROXY_SEND_TIMEOUT_SECONDS: u64 = 60", "proxy_timeouts_have_explicit_safe_defaults_but_keep_null_compatibility"]: + assert marker in vhost_source, marker +assert 'defaultProxyTimeouts = () => ({ connect_timeout_seconds: 10, response_header_timeout_seconds: 120, read_timeout_seconds: 60, send_timeout_seconds: 60 })' in vhosts_js +assert 'actions.append(enabledLabel, stateBadge, remove)' in docker_ui +assert 'row._readListeners = listenerControl.read' in docker_ui and '.docker-agent-listeners' not in docker_ui +assert 'docker-agent-status-badge' in style_source and 'justify-content: center' in style_source +assert '.docker-agent-grid>label' in style_source and '#docker-agent-add' in style_source and 'margin-top: 14px' in style_source +assert '
' in html and 'class="mini-live-chart-head"' in html and 'data-i18n="responseLatencyHistory"' in html +assert 'viewBox: "0 0 300 60"' in app_js and 'M0 15H300 M0 30H300 M0 45H300' in app_js +assert '.network-interface-row {' in style_source and 'background: transparent' in style_source +assert 'function checkboxField(key, control)' in docker_ui and 'checkboxField("dockerPreserveHost", preserve)' in docker_ui and 'checkboxField("dockerAclInline", inline)' in docker_ui +assert 'label = el("label", undefined, "check vh-check")' in docker_ui and 'check compact-check' not in docker_ui.split('function checkboxField(key, control)', 1)[1].split('function aliasEditor(', 1)[0] +assert '.docker-route-grid input:not([type="checkbox"])' in style_source and '.docker-checkbox-field .vh-check {' in style_source +assert 'docker-switch-row' in docker_ui and '.docker-switch-row {' in style_source and 'repeat(5, minmax(110px, 160px))' in style_source +assert '.docker-passive-health-editor .docker-health-grid {' in style_source +assert '.docker-health-grid input:not([type="checkbox"])' in style_source and '.docker-passive-health-editor .docker-checkbox-field input[type="checkbox"]' in style_source +assert 'id="sparkline-transform"' in html and 'id="rate-transform"' in html and 'liveTransformed' in html +assert 'payload["transform_metrics"] = json!(state.transform_metrics.total())' in admin_source +assert 'const transformed = s.transform_metrics || {}' in app_js and 'transformSamples.push(rates.transform)' in app_js +assert 'function uptime(value)' in app_js and 'setOptionalMetric("process-uptime", status.uptime_seconds != null' in app_js +assert 'connection-uptime-footer' not in html and 'id="process-uptime"' in html +assert 'id="panel-transform"' in html and 'data-panel="transform"' in html and '/assets/transform-panel.js' in html +assert '/api/transform' in transform_panel_js and 'tp-cache-hits' in transform_panel_js and 'transform-reason-list' in transform_panel_js +assert '--transform-chart:' in css and 'stroke: var(--transform-chart)' in css and 'background: var(--transform-chart)' in css +assert '.cache-transform-badge {' in css and 'border: 1px solid var(--pill-line)' in css and 'border-radius: 3px' in css +assert 'pub transformed: bool' in cache_source and 'annotate_transformed' in cache_source and 'format!("{base}; transformed")' in cache_source +assert 'x-cache-status' in cache_source and 'Cache-Status (RFC 9211)' in html +assert 'generated_vhost_inherits_global_server_header_policy' in docker_source and 'effective.vhosts[0].server_header.is_none()' in docker_source +assert '.insert("x-rustproxy-backend-time", value)' in proxy_source and '.insert("x-rust-proxy-backend-time", value)' not in proxy_source +assert 'x-rustproxy-backend-time' in (root / "src/headers.rs").read_text() +# Expanded upstream connection diagnostics must retain nested Hyper/rustls/io causes. +for marker in [ + "fn format_error_chain(", + "MAX_UPSTREAM_ERROR_DETAIL: usize = 2048", + "error_chain={chain}", + "request_origin={}", + "tls_verify={}", + "upstream_error_chain_keeps_nested_connector_cause", + "tracing::warn!(", +]: + assert marker in proxy_source, marker +assert "pub tls_verify: bool" in config_source and "tls_verify: backend.tls_verify" in config_source +assert 'data-i18n="logUpstreamError"' in html and 'e.upstream_error || "-"' in management_ui +assert '.route("/api/preferences", get(preferences).put(update_preferences))' in admin +assert 'admin_preferences' in (root / "migrations/0004_admin_preferences.sql").read_text() +assert 'id="log-column-options"' in html and 'data-log-column="upstream_error"' in html +assert 'check log-column-option' in management_ui +assert 'input[type="checkbox"],\ninput[type="radio"] {' in style_source and 'width: 15px;' in style_source and '.log-column-option input[type="checkbox"]' not in style_source +assert '.log-tools input:not([type="checkbox"]):not([type="radio"])' in style_source +assert '.log-tools input {\n' not in style_source, "Live Logs text-input rules must not match checkbox/radio controls" +footer_block = app_js.split('function renderFooter(s) {', 1)[1].split('let lastLogs', 1)[0] +assert 'SQLite / HTTP/1.1' not in footer_block and 'coldQ:' not in footer_block and 'streamFallback' not in footer_block +assert '`${apiLatency} / ${t("logsDropped")}: ${s.logs_dropped}`' in footer_block +assert 'update?.update_available && update.latest_version' in footer_block and 'footer-update' in footer_block +assert '.page-footer .footer-update {' in style_source +assert '.logs-dialog {' in style_source and 'overflow: visible' in style_source +assert '/api/preferences' in app_js and 'live_log_columns' in management_ui and 'ADMIN_THEMES = new Set(["dark", "light"])' in app_js +assert 'id="ui-theme"' in html and 'data-i18n="themeLight"' in html +assert 'html[data-theme="light"] {' in style_source and '--field-bg:' in style_source and '--sidebar-bg:' in style_source +assert 'overflow: visible;' in style_source and 'max-height: min(38dvh, 220px)' not in style_source +assert 'body[data-density="compact"] input:not([type="checkbox"]):not([type="radio"])' in style_source +assert 'input:not([type="checkbox"]):not([type="radio"]),\nselect,\ntextarea {' in style_source +assert '#[serde(default = "default_admin_theme")]' in admin_source and 'matches!(preferences.theme.as_str(), "dark" | "light")' in admin_source +# 0.22.3 dashboard/config/cache/log-history regressions. +assert 'bindSettingsForm("pages-form", next => { next.pages = global.read(); }, "pages")' in management_ui +assert '"pages" => {' in admin_source and 'config.pages =' in admin_source +assert 'default_pages_section_accepts_the_standard_value_payload' in admin_source +logging_source = (root / "src/logging.rs").read_text() +assert 'pub const LIVE_WINDOW: usize = 256;' in logging_source +assert 'pub const HISTORY_CAPACITY: usize = 4096;' in logging_source +assert 'retained_history_is_larger_than_the_live_snapshot_but_still_bounded' in logging_source +assert 'id="logs-load-history"' in html and 'api("/api/logs?limit=4096")' in management_ui +assert 'const STATUS_NAMES = { 200: "OK"' in management_ui and '404: "Not Found"' in management_ui and 'statusLabel(code)' in management_ui +assert 'let paused = null, expanded = null' in management_ui and 'function mergeExpanded(data)' in management_ui and 'entries.length >= 4096' in management_ui +assert 'dialog.addEventListener("close", () => { if (pinning) return; expanded = null; paused = null;' in management_ui +cache_source = read_cache_source() +assert 'hash_scheme.as_bytes()' in cache_source and 'cache_key_separates_frontend_scheme' in cache_source +assert 'function rateBytes(value)' in app_js and 'if (value === 0) return "0.0 KiB";' in app_js and 'return "<0.1 KiB";' in app_js +assert 'function backendTrafficRate(' in vhosts_js and 'member.bytes_in' in vhosts_js and 'member.bytes_out' in vhosts_js and 'rateBytes(memberTraffic)' in vhosts_js and 'rateBytes(d.traffic)' in vhosts_js +cache_panel_js = (root / "src/admin/web/cache-panel.js").read_text() +assert 'id="cache-domain-filter"' in html and 'cacheDomainFilterEnabled' in html and 'cacheDomainFilterDisabled' in html +assert 'const visibleDomains = (data, groups) =>' in cache_panel_js and 'scoped?.settings?.enabled ?? data.settings?.enabled ?? false' in cache_panel_js +assert 'groups = d.domains || [], shownGroups = visibleDomains(data, groups)' in cache_panel_js +assert 'replaceChildren(...shownGroups.map(row =>' in cache_panel_js +balance_source = (root / 'src/balance.rs').read_text() +assert 'traffic_rate_drops_to_zero_after_an_idle_sample_window' in balance_source and 'if delta == 0 {' in balance_source and 'sample.rate = 0.0;' in balance_source +all_admin_js = "\n".join(path.read_text() for path in (root / "src/admin/web").glob("*.js")) +assert not re.search(r'(? document.body.classList.remove("auth-loading")' in login_js +assert '"/docker-vhosts"' in login_js and 'settingsTabs = new Set' in login_js and '`/settings?tab=${encodeURIComponent(tab)}`' in login_js +assert 'class="app-loading"' in html and 'document.documentElement.classList.remove("app-loading")' in app_js +assert 'function withFormSubmitState(form, submitter, task)' in app_js and 'button.textContent = t("saving")' in app_js +assert 'function fieldError(target, message)' in app_js and 'function showFieldError(error, form = null)' in app_js +assert 'function authReturnDestination()' in app_js and 'pendingAuthDestination || authReturnDestination()' in app_js +assert '$("login").hidden = navigating' in app_js and 'id="auth-message"' in html +assert 'settings-tabs button.has-unsaved::after' in style_source and 'function syncSettingsDirtyIndicators()' in app_js +assert 'setSettingsDirty("config-editor")' in app_js and '!settingsDirty.has("config-editor")' in app_js +assert 'throw fieldError($("tls-policy-12")' in settings_js +integrations_ui_0194 = (root / 'src/admin/web/integrations.js').read_text() +docker_ui_0194 = (root / 'src/admin/web/docker.js').read_text() +assert 'readAddresses.input' in integrations_ui_0194 +assert 'form.reset(); window.rustProxyCertificates?.close();' in integrations_ui_0194 +assert '$("cert-import-pem").value = ""' not in integrations_ui_0194 and '$("cert-import-key").value = ""' not in integrations_ui_0194 +assert 'throw fieldError(row.querySelector(".docker-agent-listener-choices")' in docker_ui_0194 +assert '#docker-agents-form>.form-actions {' in style_source and 'justify-content: flex-start' in style_source +deploy_source = (root / "scripts/deploy.py").read_text() +assert "SHELL_PATH_PROFILE = Path('/etc/profile.d/rustproxy-path.sh')" in deploy_source +assert 'install_shell_path(prefix)' in deploy_source and 'check_shell_path(prefix)' in deploy_source and "'shell_path_action':'install' if args.mode == 'install' else 'check-only'" in deploy_source +assert 'grid-template-columns: minmax(0, 1fr)' in style_source and '.log-column-option:hover' in style_source +assert '.check>span {' in style_source and 'transform: translateY(-1px)' in style_source and '.cache-toolbar-status .check' in style_source and '.docker-toolbar .check' in style_source and 'grid-column: span 3' in style_source +assert '.docker-alias-field>span:first-child {' in style_source and 'margin-bottom: 0' in style_source and '.docker-alias-editor {' in style_source and 'min-height: 38px' in style_source and 'height: 30px' in style_source +assert 'field.append(box, el("small", t("dockerAliasesHelp")' not in docker_ui +assert 'readOnly: !!editing' not in vhosts_js and 'vhIdRenameConfirm' in vhosts_js and 'v.id === value.id && v.id !== editing' in vhosts_js +assert 'enforce_local_page_no_cache' in proxy_source and 'LocalGeneratedResponse' in proxy_source and 'get::().is_none()' in proxy_source +assert 'locally_generated_pages_remove_cache_metadata' in proxy_source and '"x-cache-reason"' in proxy_source and '"cache-status"' in proxy_source +assert 'local_page_method_rejection_is_still_marked_non_cacheable' in pages_rust and 'response.extensions_mut().insert(PageServed);' in pages_rust +assert "rel.as_posix() == 'docker-agent/docker-agent'" in (root / 'make_zip.py').read_text() +screenshots = {path.name for path in (root / "docs/screenshots").iterdir() if path.is_file()} +assert "README.md" in screenshots, screenshots +assert screenshots <= {"README.md", "login.png", "location-headers.png"}, screenshots +assert 'Historical or version-labelled context' in (root / "docs/README.md").read_text() +assert '' not in html, "duplicate top-right version label returned" +assert '
INDEPENDENT BY DESIGN ' not in html, "release version returned beside the dashboard brand" +assert '
INDEPENDENT BY DESIGN
' in html +# tls_verify=false must not feed legacy appliance certificates back through webpki. +upstream_source = (root / "src/upstream.rs").read_text(encoding="utf-8") +assert "BackendCertificateVerificationDisabled { supported_schemes }" in upstream_source +disabled_verifier = upstream_source.split("impl ServerCertVerifier for BackendCertificateVerificationDisabled", 1)[1].split("pub fn client(", 1)[0] +assert disabled_verifier.count("Ok(HandshakeSignatureValid::assertion())") == 2 +assert "WebPkiServerVerifier" not in disabled_verifier +assert '.docker-route-grid {' in style_source and 'align-items: start' in style_source and '.docker-acl-grid {' in style_source + +# 0.22.3 cache-modal, RFC8441/h2c and active-tunnel contracts. +server_source = (root / "src/server.rs").read_text() +websocket_source = (root / "src/proxy/websocket.rs").read_text() +vhost_source = (root / "src/vhost.rs").read_text() +integrations_0221 = (root / "src/admin/web/integrations.js").read_text() +http2_test = (root / "tests/http2.rs").read_text() +transport_test = (root / "tests/transport_config.rs").read_text() +for marker_0221 in [ + 'id="cache-invalidation-open"', 'id="cache-bans-open"', + 'id="cache-invalidation-dialog"', 'id="cache-bans-dialog"', + 'id="cache-toolbar-operation-status"', 'id="tunnels-dialog"', + 'id="tunnel-kill-reason"', 'id="metric-ws-h1"', 'id="metric-ws-h2"', + 'id="metric-ws-h3"', 'id="metric-ws-total"', +]: + assert marker_0221 in html, marker_0221 +for marker_0221 in [ + 'const operationMessage = (id, value = "", error = false) => {', + 'if (!box) return;', 'cache-toolbar-operation-status', + 'runInvalidation(payload, messageTarget = "cache-invalidation-status")', + 'async function copyText(value)', 'function makeCopyCell(value)', + 'target = makeCopyCell(targetValue)', 'makeCopyCell(keyTags)', +]: + assert marker_0221 in cache_panel_js, marker_0221 +assert html.count('class="stats-grid"') >= 1 +assert 'stats-grid transport-stats' not in html and 'transport-websocket' not in html +assert 'transport-action' not in html and '.transport-grid .transport-action' not in style_source +assert '#panel-cache .cache-copy-cell' in style_source and 'cursor: copy' in style_source +assert '.operation-message' in style_source and '.operation-message.error' in style_source +for marker_0221 in [ + 'api("/api/tunnels")', 'tunnel-kill-reason', 'tunnelKillReasonDefault', + 'api(`/api/tunnels/${encodeURIComponent(item.id)}/kill`', 'startTunnelRefresh()', +]: + assert marker_0221 in app_js, marker_0221 +assert 'pub websocket_rfc8441: bool' in vhost_source +assert 'pub h2c: bool' in vhost_source +assert 'h2c is available only on cleartext listeners' in vhost_source +for marker_0221 in [ + 'websocket_rfc8441_on(&self, local: SocketAddr, tls: bool)', + 'binding.h2c', 'HTTP/2-capable listener', + 'websocket_rfc8441: host.websocket_rfc8441', +]: + assert marker_0221 in config_source, marker_0221 +for marker_0221 in [ + 'HTTP2_PRIOR_KNOWLEDGE_PREFACE', 'h2_prior_knowledge_prefix', + 'builder.enable_connect_protocol()', 'websocket_rfc8441_on(local, false)', + 'websocket_rfc8441_on(local, true)', +]: + assert marker_0221 in server_source, marker_0221 +for marker_0223 in [ + 'StatusCode::SWITCHING_PROTOCOLS', 'H2cUpgradeIo', 'HTTP2-Settings', + 'h2c_upgrade_bootstraps_request_as_http2_stream_one', + 'malformed_h2c_upgrade_is_rejected_before_switching_protocols', +]: + assert marker_0223 in server_source, marker_0223 +for memory_marker_0230 in [ + 'H2ConnectionState', 'H2ActivityIo', 'ActiveHttp2Connection', + 'http2_active_connections', +]: + assert memory_marker_0230 in server_source or memory_marker_0230 in metrics_source, memory_marker_0230 +cache_fill_0230 = (root / 'src/cache/fill.rs').read_text() +assert 'let body_allocation = self.data.capacity().max(self.data.len());' in cache_fill_0230 +assert 'let cost = hot_entry_cost(' in cache_fill_0230 +assert 'fn hot_entry_cost(' in cache_source +for marker_0221 in [ + 'pub fn validate_h2(request: &Request)', 'hyper::ext::Protocol', + 'bridge_h2_to_h1', 'pub struct TunnelInfo', 'pub struct TunnelRegistry', + 'register_pending', 'spawn_h2_idle_watch', 'h2-rfc8441-h1-upstream', + 'h2-rfc8441-h2-upstream', 'h3-rfc9220', +]: + assert marker_0221 in websocket_source, marker_0221 +for marker_0221 in [ + 'websocket_upstream_h2', 'validate_h2_response', 'wrap_h2_request_body', + 'wrap_h2_response_body', 'bridge_h2_to_h1', +]: + assert marker_0221 in proxy_source, marker_0221 +for marker_0221 in [ + '.route("/api/tunnels", get(active_tunnels))', + '"/api/tunnels/{id}/kill"', '"tunnel.kill"', + 'reason.len() > 256', 'state.tunnel_registry.kill(id)', +]: + assert marker_0221 in admin, marker_0221 +for marker_0221 in [ + 'websocket_h1_tunnels_total', 'websocket_h1_tunnels_active', + 'websocket_h2_rfc8441_tunnels_total', 'websocket_h2_rfc8441_tunnels_active', + 'websocket_h3_rfc9220_tunnels_total', 'websocket_h3_rfc9220_tunnels_active', +]: + assert marker_0221 in metrics_source, marker_0221 +for marker_0221 in [ + '"websocket_rfc8441"', '"/api/tunnels"', '"/api/tunnels/{id}/kill"', + '"TunnelInfo"', '"TunnelKillRequest"', +]: + assert marker_0221 in openapi, marker_0221 +for marker_0233 in [ + 'pub websocket_legacy_support: bool', + 'legacy WebSocket compatibility requires WebSocket support to be enabled', +]: + assert marker_0233 in vhost_source, marker_0233 +for marker_0233 in [ + 'pub websocket_legacy_support: bool', + 'websocket_legacy_support: host.websocket_legacy_support', +]: + assert marker_0233 in config_source, marker_0233 +for marker_0233 in [ + 'fn legacy_websocket_origin(', + 'websocket_legacy_support && !websocket_upstream_h2', + 'request.headers_mut().insert("origin", origin)', + 'websocket::validate_response(', +]: + assert marker_0233 in proxy_source, marker_0233 +for marker_0233 in [ + 'legacy_support: bool', + 'legacy 101 response declared a non-zero body', + 'legacy_upstream_response_accepts_old_101_syntax_but_keeps_accept_proof_strict', +]: + assert marker_0233 in websocket_source, marker_0233 +for marker_0233 in [ + 'websocket_legacy_support', 'vhWebsocketLegacySupport', 'vhWebsocketLegacySupportHelp', +]: + assert marker_0233 in js or marker_0233 in openapi, marker_0233 +assert 'hyper::ext::Protocol::from_static(protocol)' in http2_test +assert 'rfc8441_requires_an_h2_capable_listener_and_h2c_is_explicit' in transport_test +assert 'h2c' in integrations_0221 and 'H2C' in integrations_0221 +transport_doc = (root / "docs/TRANSPORT.md").read_text() +assert 'prior knowledge' in transport_doc and 'Upgrade: h2c' in transport_doc and 'stream 1' in transport_doc +assert (root / "docs/V0.24.1_AUDIT.md").is_file() and (root / "docs/V0.24.0_AUDIT.md").is_file() and (root / "docs/V0.23.2_AUDIT.md").is_file() and (root / "docs/V0.23.1_AUDIT.md").is_file() and (root / "docs/V0.23.0_AUDIT.md").is_file() and (root / "docs/V0.22.3_AUDIT.md").is_file() + +# 0.22.3 replay-safe retry and ordinary H2 upstream contracts. +balance_0222 = (root / "src/balance.rs").read_text() +for marker_0222 in [ + "pub struct RetryPolicy", "pub max_retries: u8", "pub retry_statuses: Vec", + "pub fn select_retry", "retry_attempts_total", "retry_successes_total", + "failover_selection_never_reuses_an_attempted_backend", +]: + assert marker_0222 in balance_0222, marker_0222 +for marker_0222 in [ + "fn retry_safe_method", "Method::GET", "Method::HEAD", "Method::OPTIONS", + "request_body_empty", "route.backend_id.is_none()", "select_retry_wait(", + "upstream_request_version", "retrying replay-safe request on another backend", + "explicit_http2_backend_uses_http2_request_version", +]: + assert marker_0222 in proxy_source, marker_0222 +upstream_0222 = (root / "src/upstream.rs").read_text() +assert "explicit_h2c_backend_uses_http2_prior_knowledge" in upstream_0222 +vhosts_0222 = (root / "src/admin/web/vhosts.js").read_text() +for marker_0222 in ["retry-max", "retry-connect", "retry-timeouts", "retry-statuses"]: + assert marker_0222 in vhosts_0222, marker_0222 +for marker_0222 in [ + '"max_retries"', '"retry_connect_errors"', '"retry_timeouts"', '"retry_statuses"' +]: + assert marker_0222 in openapi, marker_0222 +for marker_0222 in [ + "max_concurrent_requests", "queue_timeout_ms", "capacity_rejections_total", + "dns_errors_total", "last_failure_detail", "last_recovered_ms", + "backend_concurrency_limit_queues_then_rejects_and_recovers", +]: + assert marker_0222 in balance_0222, marker_0222 +assert "let mut item = serde_json::Map::new();" in balance_0222 +assert 'json!({"id":m.definition.id, "url":m.definition.url' not in balance_0222 +for marker_0222 in ["member-max-concurrent", "member-queue-timeout", "vhLastFailure", "vhLastRecovery"]: + assert marker_0222 in vhosts_0222 or marker_0222 in html, marker_0222 +for marker_0222 in ["CertificateMetadata", "certificate_public_metadata", "x509_time_parser_handles_utc_and_generalized_time_with_offsets"]: + assert marker_0222 in (root / "src/tls.rs").read_text(), marker_0222 +for marker_0222 in [ + '"max_concurrent_requests"', '"queue_timeout_ms"', '"certificate_issuer"', + '"days_remaining"', '"expiry_status"', '"last_renewal_error"' +]: + assert marker_0222 in openapi, marker_0222 + +for path in (root / "src/admin/web").glob("*.js"): + subprocess.run(["node", "--check", str(path)], check=True) +connection = sqlite3.connect(":memory:") +connection.executescript((root / "migrations/0002_admin.sql").read_text()) +connection.executescript((root / "migrations/0003_certificate_renewals.sql").read_text()) +connection.executescript((root / "migrations/0004_admin_preferences.sql").read_text()) +connection.executescript((root / "migrations/0005_certificate_api.sql").read_text()) +connection.executescript((root / "migrations/0006_certificate_api_credentials.sql").read_text()) +connection.execute("PRAGMA user_version = 6") +assert connection.execute("PRAGMA quick_check").fetchone()[0] == "ok" +assert connection.execute("SELECT COUNT(*) FROM auth_throttle WHERE id=1").fetchone()[0] == 1 +assert connection.execute("SELECT COUNT(*) FROM certificate_renewals").fetchone()[0] == 0 +assert connection.execute("SELECT COUNT(*) FROM admin_preferences").fetchone()[0] == 0 +assert connection.execute("PRAGMA user_version").fetchone()[0] == 6 +connection.close() +for marker_0223 in [ + '"/api/certificates/reload"', 'certificate_reload', + '"/api/certificates/deploy"', 'certificate_deploy', 'certificate.deploy', + 'certificate.reload', 'reload_certificates().await', + '"operation": "unchanged"', 'config.tls.certificates.push(crate::tls::Certificate', +]: + assert marker_0223 in admin, marker_0223 +runtime_0223 = (root / 'src/runtime.rs').read_text() +for marker_0223 in ['pub async fn reload_certificates', 'configured certificates reloaded from disk']: + assert marker_0223 in runtime_0223, marker_0223 +main_0223 = (root / 'src/main.rs').read_text() +for marker_0223 in ['TlsCommand::Deploy', '/api/certificates/deploy', '/api/certificates/reload', 'read_external_pem']: + assert marker_0223 in main_0223, marker_0223 +tls_0223 = (root / 'src/tls.rs').read_text() +for marker_0223 in ['pub fn stage_external_pair', 'pub fn reconcile_external_pair', 'ExternalPairDeployment::Unchanged', 'external-', 'remove_external_pair']: + assert marker_0223 in tls_0223, marker_0223 +for marker_0223 in ['"/api/certificates/reload"', '"CertificateReloadResult"', '"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"']: + assert marker_0223 in openapi, marker_0223 +assert 'api("/api/certificates/reload"' in app_js +assert 'certificateAdminRestartRequired' in app_js + +# 0.24.0 H2 lifecycle contracts retained by 0.24.1. +server_0240 = (root / "src/server.rs").read_text() +settings_0240 = (root / "src/settings.rs").read_text() +config_0240 = (root / "src/config.rs").read_text() +for marker_0240 in [ + "H2ConnectionState", "H2StreamLease", "H2TrackedBody", + "wait_for_h2_idle", "graceful_shutdown()", "H2ShutdownCause::Lifetime", + "h2_idle_connection_sends_goaway_before_closing", + "h2_lifetime_goaway_is_bounded_by_drain_timeout", + "h2_lifetime_allows_active_stream_to_finish_during_drain", +]: + assert marker_0240 in server_0240, marker_0240 +for marker_0240 in ["Http2Connections", "idle_timeout_seconds", "max_connection_lifetime_seconds", "drain_timeout_seconds"]: + assert marker_0240 in settings_0240, marker_0240 +assert "pub http2: crate::settings::Http2Connections" in config_0240 +assert "pub http2_settings: Option" in (root / "src/vhost.rs").read_text() +assert "serve_frontend_with_http2" in main_source and "binding.http2_settings.unwrap_or_default()" in main_source +assert 'renderHttp2Settings' in integrations_js and 'collectHttp2Listeners' in integrations_js +assert '"Http2ConnectionSettings"' in openapi and '"max_connection_lifetime_seconds"' in openapi + +# 0.24.1 shared statistics/runtime-info/settings-navigation contracts. +system_stats_0241 = (root / "src/system_stats/mod.rs").read_text() +linux_stats_0241 = (root / "src/system_stats/linux.rs").read_text() +main_0241 = (root / "src/main.rs").read_text() +admin_0241 = (root / "src/admin/mod.rs").read_text() +settings_js_0241 = (root / "src/admin/web/settings.js").read_text() +assert not (root / "src/system_stats/bsd.rs").exists(), "per-BSD parser backend must not return" +assert manifest["target"]['cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly", target_os = "macos", target_os = "windows"))']["dependencies"]["systemstat"] == "=0.2.7" +metrics_dep = manifest["dependencies"]["metrics-process"] +assert metrics_dep["version"] == "=2.4.3" and metrics_dep["default-features"] is False +assert metrics_dep["features"] == ["dummy"] +sysinfo_dep = manifest["dependencies"]["sysinfo"] +assert sysinfo_dep["version"] == "=0.38.4" and sysinfo_dep["default-features"] is False +assert sysinfo_dep["features"] == ["network"] +for marker_0241 in [ + "systemstat::{Platform, System}", "metrics_process::collector::collect", + "sysinfo::Networks::new_with_refreshed_list", "collect_network_systemstat", "merge_network_interfaces", + "system_swap_total_bytes", "system_uptime_seconds", "process_virtual_memory_bytes", + "process_start_time_seconds", "max_fds", "FreeBSD", "OpenBSD", "NetBSD", "DragonFly BSD", +]: + assert marker_0241 in system_stats_0241, marker_0241 +for marker_0241 in ["/proc/stat", "/proc/meminfo", "RssAnon", "VmData", "VmSwap", "pub(crate) fn augment"]: + assert marker_0241 in linux_stats_0241, marker_0241 +for forbidden in ["sysctl", "netstat", "Command::new"]: + assert forbidden not in system_stats_0241, f"shared statistics adapter must not shell/parse per platform: {forbidden}" +for marker_0241 in ["installed_data_dir_candidates", '"/var/lib/rustproxy"', '"/var/db/rustproxy"', "resolve_installed_data_dir"]: + assert marker_0241 in main_0241, marker_0241 +for marker_0241 in ['"runtime"', '"platform"', '"statistics_source"', '"data_directory"', '"database_path"']: + assert marker_0241 in admin_0241 or marker_0241 in openapi, marker_0241 +assert 'id="process-platform"' in html and 'd.platform' in app_js +assert 'id="panel-logging"' not in html +assert 'id="settings-tab-logging"' in html and 'id="settings-page-logging"' in html and 'id="logging-form"' in html +assert 'location.pathname === "/logging"' in settings_js_0241 +assert '"RuntimeInformation"' in openapi and '"version": "0.24.5"' in openapi + +# 0.24.1 compiler and hostname-cardinality regressions. +tls_0241 = (root / "src/tls.rs").read_text() +vhost_0241 = (root / "src/vhost.rs").read_text() +redirect_0241 = (root / "src/redirect.rs").read_text() +not_found_0241 = (root / "src/not_found.rs").read_text() +docker_0241 = (root / "src/docker.rs").read_text() +integrations_0241 = (root / "src/admin/web/integrations.js").read_text() +docker_js_0241 = (root / "src/admin/web/docker.js").read_text() +assert "future::Future" not in server_0240 +assert "uptime.as_secs()" in system_stats_0241 and "whole_seconds()" not in system_stats_0241 +for forbidden_0241 in [ + "cert.hosts.len() <= 32", "settings.certificates.len() <= 64", + "hosts.len() <= 32", "domains.len() <= 16", +]: + assert forbidden_0241 not in tls_0241, forbidden_0241 +for source_0241 in [vhost_0241, redirect_0241, not_found_0241]: + assert "hosts.len() <= 16" not in source_0241 +assert "allowed_hosts.len() <= 32" not in settings_0240 +assert "MAX_ALIASES" not in docker_0241 and "aliases.len() <= 32" not in docker_0241 +assert "const add = (raw, changed = true) => { const value = normalize(raw); if (!value || values.includes(value)) return; values.push(value);" in integrations_0241 +assert "values.length >= 32" not in docker_js_0241 +for marker_0241 in [ + "certificate_hostname_bindings_are_not_artificially_capped", + "acme_validation_has_no_rustproxy_domain_count_ceiling", +]: + assert marker_0241 in tls_0241, marker_0241 +assert "vhost_hostname_count_is_not_artificially_capped" in vhost_0241 +assert "administration_hostname_count_is_not_artificially_capped" in settings_0240 +assert "docker_alias_count_is_not_artificially_capped" in docker_0241 +openapi_0241 = json.loads(openapi) +for schema_name, property_path in [ + ("CertificateUpload", ("hosts",)), + ("AcmeIssue", ("domains",)), + ("DockerAssignment", ("aliases",)), +]: + node = openapi_0241["components"]["schemas"][schema_name]["properties"] + for part in property_path: + node = node[part] + assert "maxItems" not in node, (schema_name, property_path) +for schema_name in ["RedirectHost", "NotFoundHost"]: + node = openapi_0241["components"]["schemas"][schema_name]["properties"]["frontend"]["properties"]["hosts"] + assert "maxItems" not in node, schema_name + +print("PASS: 0.24.5 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, Debian/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v6 migrations.") +print("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.") diff --git a/scripts/deploy.py b/scripts/deploy.py new file mode 100755 index 0000000..16e1cda --- /dev/null +++ b/scripts/deploy.py @@ -0,0 +1,770 @@ +#!/usr/bin/env python3 +"""Install/update a local release binary on Linux/systemd. No builds. + +First install downloads the PDDL user-country GeoIP CSV files from the +sapics/ip-location-db GitHub releases and verifies publisher SHA-256 files. +Updates compare the installed GeoIP files with current publisher checksums and +stage only stale assets before service downtime. Updates stop the service before +copying all private data, refresh the managed systemd unit, switch an immutable +release symlink, check health/version and restore code, data and unit on failure. +Use --plan to inspect operations without changing the host. +""" +from __future__ import annotations +import argparse +import fcntl +import hashlib +import http.client +import ipaddress +import json +import os +from pathlib import Path +import pwd +import re +import shutil +import sqlite3 +import ssl +import stat +import subprocess +import sys +import time +import uuid +import urllib.request +import urllib.parse + +ROOT = Path(__file__).resolve().parents[1] +UNIT_PATH = Path('/etc/systemd/system/rustproxy.service') +SHELL_PATH_PROFILE = Path('/etc/profile.d/rustproxy-path.sh') +SERVICE = 'rustproxy.service' +GEOIP_ASSETS = ( + 'user-country-ipv4.csv', + 'user-country-ipv6.csv', +) +GEOIP_RELEASE = 'https://github.com/sapics/ip-location-db/releases/download/latest/' +GEOIP_CHECKSUM = 'https://github.com/sapics/ip-location-db/releases/download/checksum/' +GEOIP_MAX_BYTES = 256 * 1024 * 1024 + + +def run(args: list[str], **kwargs) -> subprocess.CompletedProcess: + return subprocess.run(args, check=True, stdin=subprocess.DEVNULL, **kwargs) + + +def safe_path(raw: str) -> Path: + if not re.fullmatch(r'/[A-Za-z0-9_./-]+', raw) or '..' in raw.split('/') or raw in ('/', '/etc', '/var', '/opt', '/usr', '/var/lib'): + raise ValueError('Use a dedicated absolute path with no spaces, traversal or shell characters.') + p = Path(raw) + for part in (p, *p.parents): + if part.is_symlink(): + raise ValueError(f'Symlinked installation/data path is not allowed: {part}') + return p + + +def address(raw: str, loopback: bool = False, allow_remote: bool = False) -> str: + if raw.startswith('['): + host, port = raw[1:].split(']:', 1) + else: + host, port = raw.rsplit(':', 1) + ip = ipaddress.ip_address(host) + if not port.isdecimal() or not 1 <= int(port) <= 65535: + raise ValueError('Invalid listener IP/port.') + if loopback and not ip.is_loopback and not allow_remote: + raise ValueError('Administration must use loopback unless remote administration is explicitly accepted.') + return f'[{ip}]:{int(port)}' if ip.version == 6 else f'{ip}:{int(port)}' + + +def listener_ip(raw: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address: + host = raw[1:].split(']:', 1)[0] if raw.startswith('[') else raw.rsplit(':', 1)[0] + return ipaddress.ip_address(host) + + + + +def admin_address(raw: str, allow_remote: bool = False) -> tuple[str, bool]: + value = address(raw) + ip = listener_ip(value) + remote = not ip.is_loopback + if remote and not allow_remote: + raise ValueError('Remote administration requires --allow-remote-admin.') + return value, remote + +def digest(path: Path) -> str: + with path.open('rb') as stream: + result = hashlib.sha256() + for block in iter(lambda: stream.read(1024 * 1024), b''): + result.update(block) + return result.hexdigest() + + +def fetch_https(url: str, max_bytes: int) -> bytes: + if urllib.parse.urlparse(url).scheme != 'https': + raise ValueError('GeoIP downloads require HTTPS.') + request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer/0.24.5'}) + with urllib.request.urlopen(request, timeout=30) as response: + if urllib.parse.urlparse(response.geturl()).scheme != 'https': + raise ValueError('GeoIP download redirected away from HTTPS.') + length = response.headers.get('Content-Length') + if length and int(length) > max_bytes: + raise ValueError('GeoIP download is larger than the configured safety limit.') + result = bytearray() + while True: + block = response.read(min(1024 * 1024, max_bytes + 1 - len(result))) + if not block: + break + result.extend(block) + if len(result) > max_bytes: + raise ValueError('GeoIP download exceeded the configured safety limit.') + return bytes(result) + + +def publisher_geoip_checksums() -> dict[str, str]: + expected: dict[str, str] = {} + for name in GEOIP_ASSETS: + checksum = fetch_https(GEOIP_CHECKSUM + name + '.sha256', 4096).decode('ascii', 'strict').strip() + matched = re.search(r'\b([0-9a-fA-F]{64})\b', checksum) + if not matched: + raise ValueError(f'Invalid publisher checksum for {name}.') + expected[name] = matched.group(1).lower() + return expected + + +def geoip_file_digest(path: Path) -> str | None: + try: + st = path.lstat() + except FileNotFoundError: + return None + if not stat.S_ISREG(st.st_mode) or path.is_symlink(): + return None + return digest(path) + + +def prepare_geoip_update(data: Path, prefix: Path) -> tuple[Path | None, list[str]]: + """Verify local GeoIP files against current publisher checksums. + + Network/download work happens before the service is stopped. Changed files are + staged under the code prefix and are copied into the private data transaction + only after its backup exists. + """ + expected = publisher_geoip_checksums() + directory = data / 'geoip' + if directory.exists() and directory.is_symlink(): + raise ValueError('GeoIP directory cannot be a symlink.') + stale = [name for name in GEOIP_ASSETS if geoip_file_digest(directory / name) != expected[name]] + if not stale: + print('GeoIP database is current (publisher SHA-256 matches).') + return None, [] + staging = prefix / ('.geoip-stage-' + uuid.uuid4().hex) + staging.mkdir(mode=0o700) + try: + for name in stale: + payload = fetch_https(GEOIP_RELEASE + name, GEOIP_MAX_BYTES) + actual = hashlib.sha256(payload).hexdigest() + if actual != expected[name]: + raise ValueError(f'GeoIP SHA-256 mismatch for {name}.') + write_private(staging / name, payload, 0o600) + print('GeoIP update prepared: ' + ', '.join(stale)) + return staging, stale + except BaseException: + shutil.rmtree(staging, ignore_errors=True) + raise + + +def copy_private(source: Path, target: Path, mode: int = 0o600) -> None: + temporary = target.with_name(target.name + '.' + uuid.uuid4().hex + '.tmp') + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode) + try: + with source.open('rb') as input_stream, os.fdopen(fd, 'wb') as output_stream: + shutil.copyfileobj(input_stream, output_stream, 1024 * 1024) + output_stream.flush(); os.fsync(output_stream.fileno()) + os.replace(temporary, target) + directory = os.open(target.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + temporary.unlink(missing_ok=True) + + +def apply_geoip_update(data: Path, staging: Path | None, names: list[str], uid: int, gid: int) -> None: + if not names: + return + directory = data / 'geoip' + if directory.exists() and directory.is_symlink(): + raise ValueError('GeoIP directory cannot be a symlink.') + directory.mkdir(mode=0o700, exist_ok=True) + os.chmod(directory, 0o700) + os.chown(directory, uid, gid) + if staging is None: + raise ValueError('GeoIP staging directory is missing.') + for name in names: + source = staging / name + target = directory / name + copy_private(source, target, 0o600) + os.chown(target, uid, gid) + print('GeoIP database updated: ' + ', '.join(names)) + + +def download_geoip(data: Path, uid: int, gid: int) -> None: + # First install has no code-prefix staging transaction, so download and verify + # each asset directly before publishing it atomically into the private data dir. + expected = publisher_geoip_checksums() + directory = data / 'geoip' + if directory.exists() and directory.is_symlink(): + raise ValueError('GeoIP directory cannot be a symlink.') + directory.mkdir(mode=0o700, exist_ok=True) + os.chmod(directory, 0o700) + os.chown(directory, uid, gid) + for name in GEOIP_ASSETS: + payload = fetch_https(GEOIP_RELEASE + name, GEOIP_MAX_BYTES) + actual = hashlib.sha256(payload).hexdigest() + if actual != expected[name]: + raise ValueError(f'GeoIP SHA-256 mismatch for {name}.') + target = directory / name + write_private(target, payload, 0o600) + os.chown(target, uid, gid) + + +def write_private(path: Path, value: bytes, mode: int = 0o600) -> None: + temporary = path.with_name(path.name + '.' + uuid.uuid4().hex + '.tmp') + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode) + try: + with os.fdopen(fd, 'wb') as stream: + stream.write(value); stream.flush(); os.fsync(stream.fileno()) + os.replace(temporary, path) + directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + temporary.unlink(missing_ok=True) + + +def shell_path_profile(prefix: Path) -> bytes: + binary_dir = prefix / 'current' + return ( + '# Managed by the RustProxy installer. New login shells can invoke rust-proxy directly.\n' + 'case ":${PATH:-}:" in\n' + f' *":{binary_dir}:"*) ;;\n' + f' *) export PATH="{binary_dir}${{PATH:+:$PATH}}" ;;\n' + 'esac\n' + ).encode() + + +def install_shell_path(prefix: Path) -> None: + write_private(SHELL_PATH_PROFILE, shell_path_profile(prefix), 0o644) + + +def check_shell_path(prefix: Path) -> bool: + expected = shell_path_profile(prefix) + try: + st = SHELL_PATH_PROFILE.lstat() + valid = stat.S_ISREG(st.st_mode) and st.st_uid == 0 and not (st.st_mode & 0o022) and SHELL_PATH_PROFILE.read_bytes() == expected + except OSError: + valid = False + if not valid: + print( + f'WARNING: shell PATH setup is missing or outdated: {SHELL_PATH_PROFILE}. ' + f'Expected {prefix}/current in PATH; update mode only checks this file and does not modify it.', + file=sys.stderr, + ) + return valid + + +def read_record(prefix: Path) -> dict: + path = prefix / 'install.json' + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) + with os.fdopen(fd, 'rb') as stream: + st = os.fstat(stream.fileno()) + if not stat.S_ISREG(st.st_mode) or st.st_uid != 0 or st.st_mode & 0o077 or st.st_size > 16384: + raise ValueError('Unsafe or invalid root-owned installation record.') + value = json.load(stream) + if value.get('schema') != 1 or value.get('prefix') != str(prefix): + raise ValueError('Installation record mismatch.') + safe_path(value['data_dir']); validate_user(value['user']) + remote_admin = bool(value.get('remote_admin', False)) + admin, checked_remote = admin_address(value['admin'], remote_admin) + if checked_remote != remote_admin: + raise ValueError('Installation record remote administration flag does not match its address.') + value['admin'] = admin + value['remote_admin'] = remote_admin + return value + + +def validate_user(name: str) -> None: + if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,30}', name) or name in ('root', 'nobody'): + raise ValueError('Use a dedicated non-root service account.') + + +def service_unit(record: dict) -> str: + prefix, data, user = record['prefix'], record['data_dir'], record['user'] + args = f'{prefix}/current/rust-proxy --data-dir {data} --admin-listen {record["admin"]} serve' + args += f' --proxy-listen {record["http"]}' + if record.get('https'): + args += f' --tls-listen {record["https"]}' + if record.get('remote_admin'): + args += ' --allow-remote-admin' + return f'''[Unit] +Description=RustProxy reverse proxy +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +User={user} +Group={record['group']} +WorkingDirectory={data} +UMask=0077 +Environment=RUST_LOG=rust_proxy=info,warn +Environment=MALLOC_ARENA_MAX=2 +Environment=PATH=/opt/rustproxy-certbot/bin:/usr/local/bin:/usr/bin:/bin +EnvironmentFile=-/etc/rustproxy/environment +ExecStart={args} +Restart=on-failure +RestartSec=3 +KillSignal=SIGINT +KillMode=control-group +TimeoutStopSec=120 +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +LogsDirectory=rustproxy +LogsDirectoryMode=0770 +ReadWritePaths={data} /var/log/rustproxy +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +LockPersonality=true + +[Install] +WantedBy=multi-user.target +''' + + +def checked_tree(source: Path) -> None: + """Accept only regular files/directories and relative Certbot-internal symlinks.""" + boundary = source.resolve(strict=True) + if source.is_symlink(): + raise ValueError('Data root cannot be a symlink.') + for p in source.rglob('*'): + st = p.lstat() + if stat.S_ISLNK(st.st_mode): + target = os.readlink(p) + if os.path.isabs(target) or not p.resolve(strict=True).is_relative_to(boundary): + raise ValueError('Data backup contains an external or absolute symlink.') + elif not (stat.S_ISDIR(st.st_mode) or stat.S_ISREG(st.st_mode)): + raise ValueError('Data backup contains a special file.') + + +def copy_data(source: Path, target: Path) -> None: + checked_tree(source) + shutil.copytree(source, target, symlinks=True) + os.chmod(target, 0o700) + + +def chown_tree(path: Path, uid: int, gid: int) -> None: + os.chown(path, uid, gid) + for child in path.rglob('*'): + os.chown(child, uid, gid, follow_symlinks=False) + + +def ensure_service_data_access(data: Path, account) -> None: + """Repair the managed data-root metadata and verify service-user traversal. + + systemd applies WorkingDirectory after dropping to User=/Group=. A stale + root-owned 0700 data directory therefore fails as status=200/CHDIR before + RustProxy itself can emit diagnostics. Repair only the managed data root; + never chmod/chown parent directories. Then fail before service downtime if a + parent directory still prevents traversal. + """ + try: + st = data.lstat() + except FileNotFoundError as error: + raise ValueError(f'Data directory is missing: {data}') from error + if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode): + raise ValueError(f'Data directory must be a real directory: {data}') + + os.chown(data, account.pw_uid, account.pw_gid) + os.chmod(data, 0o700) + + probe = subprocess.run( + ['runuser', '-u', account.pw_name, '--', 'test', '-x', str(data)], + check=False, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + if probe.returncode != 0: + raise ValueError( + f'Service user {account.pw_name} cannot traverse data directory {data}; ' + 'check execute/search permissions on its parent directories (for example with namei -l).' + ) + + +def switch(prefix: Path, release: str) -> None: + if not re.fullmatch(r'[A-Za-z0-9_.-]+', release) or '..' in release: + raise ValueError('Invalid release name.') + temporary = prefix / ('current-' + uuid.uuid4().hex) + os.symlink('releases/' + release, temporary) + os.replace(temporary, prefix / 'current') + + +def running_release_matches(record: dict) -> bool: + """Check the actual systemd process executable without publishing a version API. + + This installer targets Linux/systemd. Fail closed if /proc or MainPID cannot + establish that the service runs the exact staged, version-checked binary. + """ + try: + result = subprocess.run(['systemctl', 'show', '--property=MainPID', '--value', SERVICE], + check=False, capture_output=True, text=True, timeout=3) + if result.returncode != 0 or not result.stdout.strip().isdigit(): + return False + pid = int(result.stdout.strip()) + if pid <= 1: + return False + expected = Path(record['prefix']) / 'releases' / record['release'] / 'rust-proxy' + return Path(f'/proc/{pid}/exe').samefile(expected) + except (OSError, ValueError, KeyError, subprocess.SubprocessError): + return False + + +def admin_health_endpoint(record: dict) -> tuple[str, bool, str]: + """Resolve the active persisted administration listener and scheme. + + The installer record contains only the startup CLI address. Since 0.9.x the + dashboard can persist both a different admin bind and HTTPS in config.sqlite3. + Update health checks must follow that active intent or a healthy HTTPS panel is + mistaken for a failed release and rollback can fail for the same reason. + """ + raw = record['admin'] + tls = False + allowed_hosts: list[str] = [] + database = Path(record['data_dir']) / 'config.sqlite3' + try: + connection = sqlite3.connect(f'file:{database}?mode=ro', uri=True, timeout=2) + try: + row = connection.execute('SELECT document FROM configuration WHERE id = 1').fetchone() + finally: + connection.close() + if row: + document = json.loads(row[0]) + administration = document.get('administration') or {} + if isinstance(administration, dict): + if administration.get('listen'): + raw = address(str(administration['listen'])) + tls = bool(administration.get('tls', False)) + values = administration.get('allowed_hosts') or [] + if isinstance(values, list): + allowed_hosts = [str(value).strip() for value in values if str(value).strip()] + except (OSError, sqlite3.Error, ValueError, TypeError, json.JSONDecodeError): + # Old releases and damaged/uninitialized databases used the startup HTTP + # listener. The real startup diagnostics remain authoritative if this + # compatibility read cannot resolve persisted settings. + pass + + host, port = (raw[1:].split(']:') if raw.startswith('[') else raw.rsplit(':', 1)) + ip = ipaddress.ip_address(host) + connect_host = ('::1' if ip.version == 6 else '127.0.0.1') if ip.is_unspecified else host + authority_host = allowed_hosts[0] if allowed_hosts else connect_host + try: + authority_ip = ipaddress.ip_address(authority_host) + authority = f'[{authority_ip}]:{port}' if authority_ip.version == 6 else f'{authority_ip}:{port}' + except ValueError: + authority = f'{authority_host}:{port}' + connect_raw = f'[{connect_host}]:{port}' if ':' in connect_host else f'{connect_host}:{port}' + return connect_raw, tls, authority + + +def healthy(record: dict, version: str, attempts: int = 30) -> bool: + raw, tls, health_host = admin_health_endpoint(record) + host, port = (raw[1:].split(']:') if raw.startswith('[') else raw.rsplit(':', 1)) + for _ in range(attempts): + if tls: + # Local deployment health is process identity/liveness, not PKI validation. + # The configured admin certificate may intentionally be self-signed or for + # a DNS name while we connect to the local socket. + conn = http.client.HTTPSConnection(host, int(port), timeout=2, context=ssl._create_unverified_context()) + else: + conn = http.client.HTTPConnection(host, int(port), timeout=2) + try: + conn.request('GET', '/healthz', headers={'Host': health_host}) + response = conn.getresponse(); content = response.read(4097) + active = subprocess.run(['systemctl', 'is-active', '--quiet', SERVICE], check=False, stdout=subprocess.DEVNULL).returncode == 0 + if (active and response.status == 200 and len(content) <= 4096 + and json.loads(content).get('status') == 'ok' and record.get('version') == version + and running_release_matches(record)): + return True + except (OSError, ValueError, http.client.HTTPException, ssl.SSLError): + pass + finally: + conn.close() + time.sleep(1) + return False + + +def stage(binary: Path, prefix: Path, user: str, sha: str) -> tuple[str, str]: + release = 'build-' + sha[:24] + releases = prefix / 'releases'; releases.mkdir(mode=0o755, exist_ok=True); os.chmod(releases, 0o755) + directory = releases / release + if not directory.exists(): + directory.mkdir(mode=0o755); os.chmod(directory, 0o755) + try: + shutil.copyfile(binary, directory / 'rust-proxy') + os.chmod(directory / 'rust-proxy', 0o755) + except BaseException: + shutil.rmtree(directory); raise + if directory.is_symlink() or (directory / 'rust-proxy').is_symlink() or digest(directory / 'rust-proxy') != sha: + raise ValueError('Staged binary checksum mismatch.') + # Never execute the supplied binary as root, even for its version banner. + result = run(['runuser', '-u', user, '--', str(directory / 'rust-proxy'), '--version'], capture_output=True, text=True, timeout=15) + matched = re.fullmatch(r'(?:rust-proxy|RustProxy) (\d+\.\d+\.\d+(?:[-+][\w.-]+)?)\s*', result.stdout) + if not matched: + raise ValueError('Binary did not return a RustProxy version.') + return release, matched[1] + + +def backup_stopped(record: dict, prefix: Path) -> Path: + # Service stop must happen before this call: ACME files and SQLite form one backup. + data = Path(record['data_dir']) + lock_fd = os.open(data / 'process.lock', os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600) + try: + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + account = pwd.getpwnam(record['user']); os.fchown(lock_fd, account.pw_uid, account.pw_gid) + backups = prefix / 'backups'; backups.mkdir(mode=0o700, exist_ok=True) + backup = backups / ('before-' + record['version'] + '-' + uuid.uuid4().hex[:12]); backup.mkdir(mode=0o700) + copy_data(data, backup / 'data') + write_private(backup / 'install.json', json.dumps(record, indent=2).encode()) + if UNIT_PATH.exists(): + shutil.copyfile(UNIT_PATH, backup / 'rustproxy.service'); os.chmod(backup / 'rustproxy.service', 0o600) + return backup + finally: + os.close(lock_fd) + + +def restore_service_unit(backup: Path) -> None: + saved = backup / 'rustproxy.service' + if saved.exists(): + copy_private(saved, UNIT_PATH, 0o644) + else: + UNIT_PATH.unlink(missing_ok=True) + run(['systemctl', 'daemon-reload']) + + +def reset_saved_config(data: Path) -> None: + """Development-only reset of desired config while preserving auth/certs/audit data.""" + database = data / 'config.sqlite3' + if database.is_symlink() or not database.is_file(): + raise ValueError('Cannot reset configuration: config.sqlite3 is missing or unsafe.') + connection = sqlite3.connect(f'file:{database}?mode=rw', uri=True, timeout=5) + try: + row = connection.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='configuration'").fetchone() + if row is None: + raise ValueError('Cannot reset configuration: configuration table is missing.') + connection.execute('DELETE FROM configuration WHERE id = 1') + connection.commit() + finally: + connection.close() + print('Stored vhost/settings configuration cleared; account, certificates and other private data were preserved.') + + +def service_failure_details() -> str: + chunks: list[str] = [] + commands = ( + ['systemctl', 'show', SERVICE, '--property=ActiveState,SubState,ExecMainStatus', '--no-pager'], + ['journalctl', '-u', SERVICE, '-n', '25', '--no-pager', '--output=cat'], + ) + for command in commands: + try: + result = subprocess.run(command, check=False, capture_output=True, text=True, timeout=5) + output = ((result.stdout or '') + (result.stderr or '')).strip() + if output: + chunks.append(output) + except (OSError, subprocess.SubprocessError): + pass + return '\n'.join(chunks)[-8000:] + + +def stop_for_managed_update(data: Path) -> None: + """Ask RustProxy 0.14.0+ to force hot cache into the enabled cold tier before exit. + + Older installed versions ignore the marker and still follow their ordinary graceful + shutdown policy, so the first upgrade to 0.14.0 remains backward compatible. + The marker is removed before backup so it never becomes persisted application data. + """ + marker = data / '.managed-update' + marker.unlink(missing_ok=True) + write_private(marker, b'1\n', 0o644) + try: + run(['systemctl', 'stop', SERVICE]) + finally: + marker.unlink(missing_ok=True) + + +def update(record: dict, binary: Path, sha: str, start: bool, refresh_geoip: bool = False, reset_config: bool = False) -> dict: + prefix = Path(record['prefix']); data = Path(record['data_dir']); account = pwd.getpwnam(record['user']) + # Updates deliberately do not rewrite shell environment. Check the installer- + # managed PATH profile early and report the exact remediation target instead. + check_shell_path(prefix) + # Do this before staging/network work and, critically, before stopping the + # currently healthy service. It also repairs stale root:root/0700 metadata + # left by manual maintenance while preserving private 0700 semantics. + ensure_service_data_access(data, account) + release, version = stage(binary, prefix, record['user'], sha) + geoip_stage: Path | None = None + geoip_names: list[str] = [] + if refresh_geoip: + geoip_stage, geoip_names = prepare_geoip_update(data, prefix) + active = subprocess.run(['systemctl', 'is-active', '--quiet', SERVICE], check=False, stdout=subprocess.DEVNULL).returncode == 0 + stop_for_managed_update(data) + try: + backup = backup_stopped(record, prefix) + except BaseException: + if active: run(['systemctl', 'start', SERVICE]) + if geoip_stage is not None: + shutil.rmtree(geoip_stage, ignore_errors=True) + raise + replacement = dict(record, release=release, version=version, sha256=sha) + try: + if reset_config: + reset_saved_config(data) + apply_geoip_update(data, geoip_stage, geoip_names, account.pw_uid, account.pw_gid) + switch(prefix, release) + write_private(UNIT_PATH, service_unit(replacement).encode(), 0o644) + run(['systemctl', 'daemon-reload']) + if start: + run(['systemctl', 'start', SERVICE]) + if not healthy(replacement, version): + raise RuntimeError('New release failed the local health/version check.') + write_private(prefix / 'install.json', json.dumps(replacement, indent=2).encode()) + except BaseException as error: + details = service_failure_details() + if details: + print('New release diagnostics:\n' + details, file=sys.stderr) + stop_for_managed_update(data) + failed = data.with_name(data.name + '.failed-' + uuid.uuid4().hex[:12]) + data.rename(failed) # keep the failed generation for diagnosis; do not delete it + copy_data(backup / 'data', data); chown_tree(data, account.pw_uid, account.pw_gid) + switch(prefix, record['release']) + restore_service_unit(backup) + write_private(prefix / 'install.json', json.dumps(record, indent=2).encode()) + if active: + run(['systemctl', 'start', SERVICE]) + if not healthy(record, record['version']): + raise RuntimeError(f'Update failed: {error}. Rollback restored code/data/unit but previous service health also failed. Backup: {backup}; failed state: {failed}') from error + raise RuntimeError(f'Update failed: {error}. Previous code, private data and service unit were restored. Installation is still present; do not run install.sh. Fix the cause and rerun update.sh. Backup: {backup}; failed state: {failed}') from error + finally: + if geoip_stage is not None: + shutil.rmtree(geoip_stage, ignore_errors=True) + print(f'Backup (contains private keys): {backup}') + return replacement + + +def install(record: dict, binary: Path, sha: str, start: bool, install_geoip: bool = False) -> dict: + import grp + prefix = Path(record['prefix']); data = Path(record['data_dir']) + if (prefix / 'install.json').exists() or (prefix / 'current').exists() or UNIT_PATH.exists(): + raise ValueError('Installation/service already exists. A failed update rolls back to the previous installed release, so rerun update.sh after fixing the reported cause. install.sh never overwrites an existing installation.') + if data.exists() and any(data.iterdir()): + raise ValueError('First installation requires an empty data directory; existing data will never be reset.') + try: + account = pwd.getpwnam(record['user']) + if account.pw_uid == 0 or account.pw_shell not in ('/usr/sbin/nologin', '/sbin/nologin', '/bin/false'): + raise ValueError('Existing service account must be non-root with a non-login shell.') + except KeyError: + run(['useradd', '--system', '--user-group', '--home-dir', str(data), '--no-create-home', '--shell', '/usr/sbin/nologin', record['user']]) + account = pwd.getpwnam(record['user']) + record['group'] = grp.getgrgid(account.pw_gid).gr_name + prefix.mkdir(parents=True, mode=0o755, exist_ok=True); os.chmod(prefix, 0o755) + data.mkdir(parents=True, mode=0o700, exist_ok=True); os.chmod(data, 0o700); os.chown(data, account.pw_uid, account.pw_gid) + if install_geoip: + download_geoip(data, account.pw_uid, account.pw_gid) + release, version = stage(binary, prefix, record['user'], sha) + record.update(release=release, version=version, sha256=sha) + switch(prefix, release) + install_shell_path(prefix) + # Record is written even if initial startup fails, so an update can repair it. + write_private(prefix / 'install.json', json.dumps(record, indent=2).encode()) + write_private(UNIT_PATH, service_unit(record).encode(), 0o644) + run(['systemctl', 'daemon-reload']); run(['systemctl', 'enable', SERVICE]) + if start: + run(['systemctl', 'start', SERVICE]) + if not healthy(record, version): + run(['systemctl', 'stop', SERVICE]) + raise RuntimeError('Installed but startup failed. Data was retained; inspect journalctl -u rustproxy.') + return record + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('mode', choices=['install', 'update']) + parser.add_argument('--binary', type=Path, default=ROOT / 'target/release/rust-proxy') + parser.add_argument('--prefix', default='/opt/rustproxy') + parser.add_argument('--data-dir', default='/var/lib/rustproxy') + parser.add_argument('--user', default='rustproxy') + parser.add_argument('--http', default='0.0.0.0:80') + tls = parser.add_mutually_exclusive_group() + tls.add_argument('--https', default='0.0.0.0:443', help='TLS address (default: 0.0.0.0:443); first certificate is self-signed') + tls.add_argument('--no-https', action='store_true', help='Disable the default 443 TLS listener') + parser.add_argument('--admin', default='127.0.0.1:9090') + parser.add_argument('--allow-remote-admin', action='store_true', help='Allow --admin on 0.0.0.0/:: instead of loopback') + parser.add_argument('--sha256', help='Expected SHA-256 of the supplied binary') + parser.add_argument('--no-start', action='store_true', help='Leave the service stopped after install/update') + parser.add_argument('--plan', action='store_true', help='Print a plan without root privileges, builds or host changes') + parser.add_argument('--skip-geoip', action='store_true', help='Skip GeoIP install/update verification (country allow lists may use stale/missing data)') + parser.add_argument('--reset-config', action='store_true', help='UPDATE ONLY, development recovery: discard saved vhosts/settings after backup while preserving account/certificates/private files') + args = parser.parse_args() + os.umask(0o077) + try: + prefix, data = safe_path(args.prefix), safe_path(args.data_dir) + if args.reset_config and args.mode != 'update': + raise ValueError('--reset-config is only valid with update.sh.') + if prefix == data or prefix in data.parents or data in prefix.parents: + raise ValueError('Code and private data must use separate non-nested directories.') + validate_user(args.user) + admin, remote_admin = admin_address(args.admin, args.allow_remote_admin) + record = dict(schema=1, prefix=str(prefix), data_dir=str(data), user=args.user, http=address(args.http), + https=None if args.no_https else address(args.https), admin=admin, remote_admin=remote_admin) + ports = [x.rsplit(':', 1)[1] for x in (record['http'], record['https'], record['admin']) if x] + if len(set(ports)) != len(ports): + raise ValueError('HTTP, TLS and administration ports must differ.') + binary = args.binary.absolute() + if binary.is_symlink() or not binary.is_file() or not os.access(binary, os.X_OK): + raise ValueError('Build the release binary as an ordinary user first (bash dev.sh release); provide --binary.') + sha = digest(binary) + if args.sha256 and args.sha256.lower() != sha: + raise ValueError('Binary SHA-256 mismatch; no changes made.') + if args.plan: + print(json.dumps({'operation':args.mode, 'binary':str(binary), 'sha256':sha, 'install_defaults':record, + 'update_uses_saved_settings':args.mode == 'update', 'backup_before_update':True, 'rollback_code_and_data':True, + 'start_service':not args.no_start, + 'geoip_action':'skip' if args.skip_geoip else ('download-and-verify' if args.mode == 'install' else 'verify-and-refresh-if-stale'), + 'shell_path_profile':str(SHELL_PATH_PROFILE), + 'shell_path_action':'install' if args.mode == 'install' else 'check-only', + 'reset_saved_config':bool(args.reset_config), 'changes_made':False}, indent=2)); return + if sys.platform != 'linux' or os.geteuid() != 0 or not shutil.which('systemctl') or not shutil.which('runuser'): + raise ValueError('Installation requires root on Linux with systemd and runuser. Use --plan for inspection.') + prefix.mkdir(parents=True, mode=0o755, exist_ok=True) + if prefix.stat().st_uid != 0 or prefix.stat().st_mode & 0o022: + raise ValueError('Installation prefix must be root-owned and not group/world-writable.') + fd = os.open(prefix / '.deploy.lock', os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, 'w') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + result = install(record, binary, sha, not args.no_start, not args.skip_geoip) if args.mode == 'install' else update( + read_record(prefix), binary, sha, not args.no_start, not args.skip_geoip, args.reset_config + ) + print(f'RustProxy {result["version"]} installed. Administration: http://{result["admin"]}') + print(f'Generate setup token: sudo -u {result["user"]} {prefix}/current/rust-proxy --data-dir {result["data_dir"]} admin setup-token') + print(f'Initialize account: sudo -u {result["user"]} {prefix}/current/rust-proxy --data-dir {result["data_dir"]} admin init') + if result.get('remote_admin'): + print('WARNING: remote administration is plain HTTP. Restrict it with a firewall/private network; a protected tunnel remains safer.') + else: + print('Use an SSH tunnel for remote administration.') + print('No demo backend or firewall changes were installed.') + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError, KeyError) as error: + parser.exit(1, f'Deployment failed: {error}\n') + + +if __name__ == '__main__': + main() diff --git a/scripts/langs_check.py b/scripts/langs_check.py new file mode 100755 index 0000000..e33cd49 --- /dev/null +++ b/scripts/langs_check.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Validate language-pack content and emit the same-shaped bundle used by UI QA. +Rust build.rs performs the actual compile-time discovery and embedding. +""" +from pathlib import Path +import argparse +import json +import re +ROOT = Path(__file__).resolve().parents[1] +def bundle(root=ROOT): + languages=[];locales=set() + for path in sorted((root/'langs').glob('*.json')): + assert re.fullmatch(r'[A-Za-z0-9_-]{1,64}',path.stem),path + assert path.stat().st_size<=262144,path + pack=json.loads(path.read_text(encoding='utf-8'));assert set(pack)=={'meta','translations'},f'unexpected top-level language-pack keys: {set(pack)-{"meta","translations"}} in {path}';meta=pack['meta'];translations=pack['translations'] + assert all(isinstance(meta.get(key),str) and 0 Any: + if raw is None or raw == "": + return fallback + if isinstance(raw, (list, dict)): + return raw + try: + return json.loads(raw) + except (TypeError, json.JSONDecodeError): + return fallback + + +def bool_value(raw: Any) -> bool: + if isinstance(raw, str): + return raw.strip().lower() in {"1", "true", "yes", "on"} + return bool(raw) + + +def normalize_host_pattern(value: str) -> str: + value = value.strip().lower().rstrip(".") + wildcard = value.startswith("*.") + base = value[2:] if wildcard else value + if not base or len(base) > 253 or "*" in base or ":" in base or "/" in base: + raise MigrationError(f"invalid hostname {value!r}") + labels = base.split(".") + if wildcard and len(labels) < 2: + raise MigrationError(f"invalid wildcard hostname {value!r}") + for label in labels: + if not label or len(label) > 63 or label.startswith("-") or label.endswith("-"): + raise MigrationError(f"invalid hostname {value!r}") + if not re.fullmatch(r"[a-z0-9-]+", label): + raise MigrationError(f"hostname must be ASCII/punycode: {value!r}") + return ("*." if wildcard else "") + base + + +def sql_columns(connection: sqlite3.Connection, table: str) -> set[str]: + return {str(row[1]) for row in connection.execute(f"PRAGMA table_info({table})")} + + +def load_npm_hosts(database: Path) -> list[NpmProxyHost]: + if database.is_symlink() or not database.is_file(): + raise MigrationError(f"NPM SQLite database not found or unsafe: {database}") + uri = f"file:{urllib.parse.quote(str(database.resolve()))}?mode=ro" + connection = sqlite3.connect(uri, uri=True) + connection.row_factory = sqlite3.Row + try: + tables = {row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")} + if "proxy_host" not in tables: + raise MigrationError("NPM database does not contain the proxy_host table") + columns = sql_columns(connection, "proxy_host") + required = {"id", "domain_names", "forward_scheme", "forward_host", "forward_port"} + missing = sorted(required - columns) + if missing: + raise MigrationError("NPM proxy_host table is missing required columns: " + ", ".join(missing)) + wanted = [ + "id", "domain_names", "forward_scheme", "forward_host", "forward_port", + "allow_websocket_upgrade", "http2_support", "enabled", "certificate_id", + "ssl_forced", "access_list_id", "advanced_config", "locations", + "caching_enabled", "block_exploits", "meta", "is_deleted", + ] + select = ", ".join(name for name in wanted if name in columns) + where = " WHERE is_deleted = 0" if "is_deleted" in columns else "" + rows = connection.execute(f"SELECT {select} FROM proxy_host{where} ORDER BY id").fetchall() + finally: + connection.close() + + result: list[NpmProxyHost] = [] + for row in rows: + data = dict(row) + domains_raw = json_value(data.get("domain_names"), []) + if not isinstance(domains_raw, list): + domains_raw = [] + domains: list[str] = [] + for item in domains_raw: + if isinstance(item, str) and item.strip(): + domains.append(item.strip()) + locations = json_value(data.get("locations"), []) + if not isinstance(locations, list): + locations = [] + meta = json_value(data.get("meta"), {}) + if not isinstance(meta, dict): + meta = {} + try: + port = int(data.get("forward_port")) + except (TypeError, ValueError): + port = 0 + result.append(NpmProxyHost( + id=int(data["id"]), + domain_names=domains, + forward_scheme=str(data.get("forward_scheme") or "").strip().lower(), + forward_host=str(data.get("forward_host") or "").strip(), + forward_port=port, + allow_websocket_upgrade=bool_value(data.get("allow_websocket_upgrade", False)), + http2_support=bool_value(data.get("http2_support", True)), + enabled=bool_value(data.get("enabled", True)), + certificate_id=int(data.get("certificate_id") or 0), + ssl_forced=bool_value(data.get("ssl_forced", False)), + access_list_id=int(data.get("access_list_id") or 0), + advanced_config=str(data.get("advanced_config") or ""), + locations=locations, + caching_enabled=bool_value(data.get("caching_enabled", False)), + block_exploits=bool_value(data.get("block_exploits", False)), + meta=meta, + )) + return result + + +def backend_url(item: NpmProxyHost) -> str: + if item.forward_scheme not in {"http", "https"}: + raise MigrationError(f"unsupported upstream scheme {item.forward_scheme!r}") + if not 1 <= item.forward_port <= 65535: + raise MigrationError(f"invalid upstream port {item.forward_port}") + host = item.forward_host.strip() + if not host or any(ch.isspace() for ch in host) or "/" in host or "@" in host: + raise MigrationError(f"invalid upstream host {host!r}") + if host.startswith("[") and host.endswith("]"): + rendered = host + else: + try: + ip = ipaddress.ip_address(host) + rendered = f"[{ip}]" if ip.version == 6 else str(ip) + except ValueError: + if ":" in host: + raise MigrationError(f"invalid upstream host {host!r}") + rendered = host + return f"{item.forward_scheme}://{rendered}:{item.forward_port}" + + +def slug(value: str) -> str: + value = value.lower().replace("*.", "wildcard-") + value = re.sub(r"[^a-z0-9_-]+", "-", value).strip("-_") + return value[:40] or "host" + + +def unique_id(base: str, used: set[str]) -> str: + base = re.sub(r"[^A-Za-z0-9_-]+", "-", base)[:64].strip("-_") or "npm" + candidate = base + index = 2 + while candidate in used: + suffix = f"-{index}" + candidate = base[:64 - len(suffix)] + suffix + index += 1 + used.add(candidate) + return candidate + + +def listener_inventory(config: dict[str, Any]) -> tuple[list[str], list[str]]: + http_ids: list[str] = [] + https_ids: list[str] = [] + for entry in ((config.get("listeners") or {}).get("endpoints") or []): + if not isinstance(entry, dict) or not entry.get("enabled", True): + continue + listener_id = str(entry.get("id") or "").strip() + binding = entry.get("binding") or {} + if not listener_id or not isinstance(binding, dict): + continue + (https_ids if binding.get("tls") else http_ids).append(listener_id) + return http_ids, https_ids + + +def selected_tls_ports(config: dict[str, Any], listener_ids: list[str]) -> set[int]: + selected = set(listener_ids) + ports: set[int] = set() + for entry in ((config.get("listeners") or {}).get("endpoints") or []): + if not isinstance(entry, dict) or str(entry.get("id") or "") not in selected: + continue + binding = entry.get("binding") or {} + if not isinstance(binding, dict) or not binding.get("tls"): + continue + for address in [binding.get("address"), *(entry.get("additional_addresses") or [])]: + if not isinstance(address, str) or ":" not in address: + raise MigrationError(f"invalid RustProxy listener address for Force SSL: {address!r}") + try: + port = int(address.rsplit(":", 1)[1]) + except (TypeError, ValueError) as error: + raise MigrationError(f"invalid RustProxy listener address for Force SSL: {address!r}") from error + if not 1 <= port <= 65535: + raise MigrationError(f"invalid RustProxy listener port for Force SSL: {address!r}") + ports.add(port) + return ports + + +def existing_host_owners(config: dict[str, Any]) -> dict[str, str]: + result: dict[str, str] = {} + for vhost in config.get("vhosts") or []: + if not isinstance(vhost, dict): + continue + vid = str(vhost.get("id") or "?") + frontend = vhost.get("frontend") or {} + for raw in frontend.get("hosts") or []: + if isinstance(raw, str): + try: + result[normalize_host_pattern(raw)] = vid + except MigrationError: + pass + return result + + +def warnings_for(item: NpmProxyHost, tls_verify: bool) -> list[str]: + warnings: list[str] = [] + if item.access_list_id: + warnings.append(f"NPM access_list_id={item.access_list_id} was not imported; attach an ACL Profile manually") + if item.advanced_config.strip(): + warnings.append("NPM advanced_config was not imported") + if item.locations: + warnings.append(f"{len(item.locations)} NPM custom location(s) were not imported") + if item.caching_enabled: + warnings.append("NPM caching_enabled was not mapped; configure RustProxy cache policy separately") + if item.block_exploits: + warnings.append("NPM block_exploits was not mapped") + if item.ssl_forced: + warnings.append("NPM ssl_forced is mapped to RustProxy Force SSL (HTTP -> HTTPS 308 redirect)") + if item.forward_scheme == "https" and tls_verify: + warnings.append("HTTPS upstream verification was explicitly enabled; provide a matching DNS name/private CA when needed") + if item.meta.get("nginx_err"): + warnings.append("NPM meta.nginx_err is set; review the source host before cutover") + return warnings + + +def build_migration( + npm_hosts: list[NpmProxyHost], + config: dict[str, Any], + *, + http_listener_ids: list[str] | None = None, + https_listener_ids: list[str] | None = None, + certificate_id: str | None = None, + force_https: bool = False, + npm_compatible_backend_tls: bool = False, +) -> tuple[dict[str, Any], list[Decision]]: + new_config = json.loads(json.dumps(config)) + new_config.setdefault("vhosts", []) + discovered_http, discovered_https = listener_inventory(new_config) + http_ids = http_listener_ids if http_listener_ids is not None else discovered_http[:1] + https_ids = https_listener_ids if https_listener_ids is not None else discovered_https[:1] + unknown_http = [value for value in http_ids if value not in discovered_http] + unknown_https = [value for value in https_ids if value not in discovered_https] + if unknown_http: + raise MigrationError("HTTP listener ID is missing, disabled or TLS-enabled: " + ", ".join(unknown_http)) + if unknown_https: + raise MigrationError("HTTPS listener ID is missing, disabled or not TLS-enabled: " + ", ".join(unknown_https)) + if not http_ids and not https_ids: + raise MigrationError("RustProxy config has no enabled HTTP/TLS listener to attach imported vhosts") + https_ports = selected_tls_ports(new_config, https_ids) + if certificate_id: + known_certificates = {str(item.get("id")) for item in ((new_config.get("tls") or {}).get("certificates") or []) if isinstance(item, dict)} + if certificate_id not in known_certificates: + raise MigrationError(f"RustProxy certificate ID not found: {certificate_id}") + + owners = existing_host_owners(new_config) + used_ids = {str(v.get("id")) for v in new_config.get("vhosts") or [] if isinstance(v, dict)} + decisions: list[Decision] = [] + + for item in npm_hosts: + warnings = warnings_for(item, False) + if not item.domain_names: + decisions.append(Decision(item.id, "skip", [], warnings=warnings, reason="no domain_names")) + continue + try: + normalized = [] + seen = set() + for raw in item.domain_names: + host = normalize_host_pattern(raw) + if host not in seen: + normalized.append(host) + seen.add(host) + origin = backend_url(item) + except MigrationError as error: + decisions.append(Decision(item.id, "skip", item.domain_names, warnings=warnings, reason=str(error))) + continue + + conflicts = [(host, owners[host]) for host in normalized if host in owners] + if conflicts: + reason = "hostname already owned in RustProxy: " + ", ".join(f"{host} ({owner})" for host, owner in conflicts) + decisions.append(Decision(item.id, "skip", normalized, backend=origin, warnings=warnings, reason=reason)) + continue + + force_ssl = item.ssl_forced or force_https + source_tls = item.certificate_id > 0 or force_ssl + if force_ssl and not https_ids: + decisions.append(Decision( + item.id, "skip", normalized, backend=origin, warnings=warnings, + reason="NPM Force SSL requires an enabled RustProxy TLS listener", + )) + continue + if force_ssl and len(https_ports) != 1: + decisions.append(Decision( + item.id, "skip", normalized, backend=origin, warnings=warnings, + reason="RustProxy Force SSL requires the selected TLS listeners to use one HTTPS port", + )) + continue + if source_tls: + listeners = list(dict.fromkeys([*http_ids, *https_ids])) + else: + listeners = list(http_ids or https_ids) + if source_tls and not https_ids: + warnings.append("source host used TLS but RustProxy has no enabled TLS listener; imported on HTTP only") + listeners = list(http_ids) + if force_ssl and not http_ids: + warnings.append("Force SSL is preserved, but no HTTP listener is selected so there is currently no cleartext request to redirect") + if not listeners: + decisions.append(Decision(item.id, "skip", normalized, backend=origin, warnings=warnings, reason="no compatible RustProxy listener")) + continue + + created_ids: list[str] = [] + base = f"npm-{item.id}-{slug(normalized[0])}" + vid = unique_id(base, used_ids) + vhost: dict[str, Any] = { + "id": vid, + "enabled": item.enabled, + "force_ssl": force_ssl, + "forward_client_ip": True, + "websockets": item.allow_websocket_upgrade, + "http2_enabled": item.http2_support, + "http3_enabled": False, + "frontend": {"hosts": normalized, "listener_ids": listeners}, + "backends": [{ + "id": "origin", + "url": origin, + "allow_private": True, + "preserve_host": True, + "tls_verify": False, + }], + "load_balancing": {"health_check": {"mode": "off"}}, + "routes": [{"id": "root", "path_prefix": "/"}], + } + if source_tls and certificate_id: + vhost["certificate_id"] = certificate_id + new_config["vhosts"].append(vhost) + created_ids.append(vid) + for host in normalized: + owners[host] = vid + decisions.append(Decision(item.id, "import", normalized, backend=origin, rustproxy_ids=created_ids, warnings=warnings)) + return new_config, decisions + + +class RustProxyClient: + def __init__(self, base_url: str, *, ca_file: Path | None = None, insecure_tls: bool = False): + parsed = urllib.parse.urlsplit(base_url.rstrip("/")) + if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.path not in {"", "/"}: + raise MigrationError("--rustproxy must be an http(s) origin, e.g. https://rustproxy.example:9090") + if parsed.query or parsed.fragment or parsed.username or parsed.password: + raise MigrationError("--rustproxy must not contain credentials, query or fragment") + self.base_url = urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, "", "", "")) + jar = http.cookiejar.CookieJar() + handlers: list[Any] = [urllib.request.HTTPCookieProcessor(jar)] + if parsed.scheme == "https": + if insecure_tls: + context = ssl._create_unverified_context() # Explicit operator opt-in for local/self-signed admin TLS. + else: + context = ssl.create_default_context(cafile=str(ca_file) if ca_file else None) + handlers.append(urllib.request.HTTPSHandler(context=context)) + self.opener = urllib.request.build_opener(*handlers) + self.csrf = "" + + def request(self, method: str, path: str, body: dict[str, Any] | None = None, *, authenticated_write: bool = False) -> Any: + data = None if body is None else json.dumps(body, separators=(",", ":")).encode() + headers = {"Accept": "application/json", "X-RustProxy-Request": "1"} + if data is not None: + headers["Content-Type"] = "application/json" + if method != "GET": + headers["Origin"] = self.base_url + if authenticated_write: + headers["X-CSRF-Token"] = self.csrf + request = urllib.request.Request(self.base_url + path, data=data, headers=headers, method=method) + try: + with self.opener.open(request, timeout=10) as response: + payload = response.read(1024 * 1024) + except urllib.error.HTTPError as error: + payload = error.read(65536) + try: + detail = json.loads(payload).get("error", payload.decode("utf-8", "replace")) + except Exception: + detail = payload.decode("utf-8", "replace") + raise MigrationError(f"RustProxy API {method} {path} failed with HTTP {error.code}: {detail}") from error + except (OSError, urllib.error.URLError) as error: + raise MigrationError(f"RustProxy API connection failed: {error}") from error + try: + return json.loads(payload) + except json.JSONDecodeError as error: + raise MigrationError(f"RustProxy API returned invalid JSON for {path}") from error + + def login(self, username: str, password: str) -> None: + info = self.request("POST", "/api/auth/login", {"username": username, "password": password}) + token = info.get("csrf_token") if isinstance(info, dict) else None + if not isinstance(token, str) or not token: + session = self.request("GET", "/api/auth/session") + token = session.get("csrf_token") if isinstance(session, dict) else None + if not isinstance(token, str) or not token: + raise MigrationError("RustProxy login succeeded but no CSRF token was returned") + self.csrf = token + + def config(self) -> dict[str, Any]: + value = self.request("GET", "/api/config") + if not isinstance(value, dict) or not isinstance(value.get("revision"), int) or not isinstance(value.get("config"), dict): + raise MigrationError("unexpected RustProxy /api/config response") + return value + + def apply(self, revision: int, config: dict[str, Any]) -> dict[str, Any]: + value = self.request("PUT", "/api/config", {"expected_revision": revision, "config": config}, authenticated_write=True) + if not isinstance(value, dict): + raise MigrationError("unexpected RustProxy PUT /api/config response") + return value + + + +def write_private_json(path: Path, value: dict[str, Any]) -> None: + path = path.expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + if path.is_symlink(): + raise MigrationError(f"refusing to write migration plan through symlink: {path}") + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + fd = os.open(path, flags, 0o600) + try: + os.fchmod(fd, 0o600) + payload = (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8") + with os.fdopen(fd, "wb", closefd=False) as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + finally: + os.close(fd) + +def split_ids(values: list[str] | None) -> list[str] | None: + if values is None: + return None + out: list[str] = [] + for value in values: + for item in value.split(","): + item = item.strip() + if item and item not in out: + out.append(item) + return out + + +def print_report(decisions: list[Decision]) -> None: + imported = sum(1 for d in decisions if d.action == "import") + skipped = sum(1 for d in decisions if d.action == "skip") + print(f"NPM proxy hosts: {len(decisions)}; import: {imported}; skip: {skipped}") + for decision in decisions: + print(f"\n[{decision.action.upper()}] NPM #{decision.npm_id}") + if decision.hosts: + print(" hosts: " + ", ".join(decision.hosts)) + if decision.backend: + print(" backend: " + decision.backend) + if decision.rustproxy_ids: + print(" ids: " + ", ".join(decision.rustproxy_ids)) + if decision.reason: + print(" reason: " + decision.reason) + for warning in decision.warnings or []: + print(" warning: " + warning) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--database", type=Path, default=Path("/data/database.sqlite"), help="NPM SQLite database (default: /data/database.sqlite)") + parser.add_argument("--rustproxy", required=True, help="RustProxy admin origin, e.g. https://rustproxy.example:9090") + parser.add_argument("--username", default=os.environ.get("RUSTPROXY_USERNAME"), help="RustProxy administrator username (or RUSTPROXY_USERNAME)") + parser.add_argument("--password-env", default="RUSTPROXY_PASSWORD", help="environment variable containing the RustProxy password") + parser.add_argument("--ca-file", type=Path, help="CA file for RustProxy administration HTTPS") + parser.add_argument("--insecure-admin-tls", action="store_true", help="disable certificate verification for the RustProxy admin HTTPS connection") + parser.add_argument("--http-listener", action="append", help="RustProxy HTTP listener ID; repeat or comma-separate. Default: first enabled HTTP listener") + parser.add_argument("--https-listener", action="append", help="RustProxy TLS listener ID; repeat or comma-separate. Default: first enabled TLS listener") + parser.add_argument("--certificate-id", help="named RustProxy certificate for imported TLS hosts; omit to use the application default certificate") + parser.add_argument("--force-https", action="store_true", help="enable RustProxy Force SSL for every imported host and attach it to HTTP+TLS listeners") + parser.add_argument("--npm-compatible-backend-tls", action="store_true", help="legacy no-op: backend tls_verify already defaults to false in RustProxy 0.22.1") + parser.add_argument("--plan-out", type=Path, default=Path("npm-migration-plan.json"), help="write the generated plan/config JSON here") + parser.add_argument("--apply", action="store_true", help="perform the revisioned PUT /api/config; without this flag the run is read-only") + args = parser.parse_args() + + if args.ca_file and args.insecure_admin_tls: + parser.error("choose either --ca-file or --insecure-admin-tls") + username = args.username or input("RustProxy username: ").strip() + if not username: + parser.error("RustProxy username is required") + password = os.environ.get(args.password_env) + if password is None: + password = getpass.getpass("RustProxy password: ") + + try: + npm_hosts = load_npm_hosts(args.database) + client = RustProxyClient(args.rustproxy, ca_file=args.ca_file, insecure_tls=args.insecure_admin_tls) + client.login(username, password) + envelope = client.config() + merged, decisions = build_migration( + npm_hosts, + envelope["config"], + http_listener_ids=split_ids(args.http_listener), + https_listener_ids=split_ids(args.https_listener), + certificate_id=args.certificate_id, + force_https=args.force_https, + npm_compatible_backend_tls=args.npm_compatible_backend_tls, + ) + config_bytes = len(json.dumps(merged, separators=(",", ":"), ensure_ascii=False).encode("utf-8")) + if config_bytes > MAX_CONFIG_BYTES: + raise MigrationError(f"merged RustProxy config is {config_bytes} bytes; API limit is {MAX_CONFIG_BYTES} bytes") + plan = { + "source": {"type": "nginx-proxy-manager-sqlite", "database": str(args.database)}, + "rustproxy": {"origin": args.rustproxy, "expected_revision": envelope["revision"], "config_bytes": config_bytes}, + "apply_requested": bool(args.apply), + "decisions": [asdict(item) for item in decisions], + "config": merged, + } + write_private_json(args.plan_out, plan) + print_report(decisions) + print(f"\nPlan written: {args.plan_out}") + if args.apply: + imported = sum(1 for item in decisions if item.action == "import") + if imported == 0: + raise MigrationError("nothing to import; configuration was not written") + applied = client.apply(envelope["revision"], merged) + print(f"Applied RustProxy configuration revision {applied.get('revision', '?')} ({imported} NPM proxy host records imported).") + else: + print("Dry run only. Review the plan and rerun with --apply to write RustProxy configuration.") + except (MigrationError, OSError, sqlite3.Error) as error: + parser.exit(1, f"Migration failed: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/mock_auth.py b/scripts/mock_auth.py new file mode 100755 index 0000000..88dd5b5 --- /dev/null +++ b/scripts/mock_auth.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""LOOPBACK-ONLY fake Tinyauth protocol peer. NEVER use this as real authentication. +A cookie named mock_auth with value allow approves a request. Any client can mint it. +This exists to test RustProxy auth_request, identity forwarding and fail-closed paths. +""" +from __future__ import annotations +import argparse +from collections import deque +from http.cookies import SimpleCookie +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import ipaddress +import json +import threading +import time +from urllib.parse import urlsplit + +class State: + def __init__(self): + self.lock = threading.Lock() + self.status = None + self.delay_ms = 0 + self.location = None + self.calls = 0 + self.recent = deque(maxlen=16) + +class Server(ThreadingHTTPServer): + daemon_threads = True + def __init__(self, address, state=None): + if not ipaddress.ip_address(address[0]).is_loopback: + raise ValueError("The fake auth server may bind only a numeric loopback address") + self.state = state or State() + super().__init__(address, Handler) + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + server_version = "RustProxy-Mock-Auth/0.4.0" + sys_version = "" + def log_message(self, *_): + pass + def setup(self): + super().setup() + self.connection.settimeout(5) + def reply(self, status, value=None, headers=()): + data = b"" if value is None else json.dumps(value, ensure_ascii=True).encode() + self.send_response(status) + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Type", "application/json") + for key, value in headers: + self.send_header(key, value) + self.end_headers() + if self.command != "HEAD": + try: + self.wfile.write(data) + except (BrokenPipeError, ConnectionResetError): + pass + def do_GET(self): + path = urlsplit(self.path).path + if self.headers.get("Transfer-Encoding") or self.headers.get("Content-Length", "0") != "0": + self.close_connection = True + return self.reply(400, {"error":"GET verification requests must have no body"}) + state = self.server.state + if path == "/api/auth/nginx": + cookie = SimpleCookie() + try: + cookie.load(self.headers.get("Cookie", "")) + except Exception: + pass + allowed = "mock_auth" in cookie and cookie["mock_auth"].value == "allow" + with state.lock: + state.calls += 1 + # Do not store Cookie, Authorization, API keys, or query strings. + state.recent.append({"method":self.headers.get("X-Original-Method"), + "client":self.headers.get("X-Real-IP"), "host":self.headers.get("X-Forwarded-Host"), + "proto":self.headers.get("X-Forwarded-Proto"), + "forwarded":self.headers.get("Forwarded"),"xff":self.headers.get("X-Forwarded-For")}) + status = state.status if state.status is not None else (204 if allowed else 401) + delay_ms, location = state.delay_ms, state.location + time.sleep(delay_ms / 1000) + headers = [] + if 200 <= status < 300: + headers = [("Remote-User", "mock-user"), ("Remote-Email", "mock@example.test"), ("Remote-Groups", "testers")] + if location: + headers.append(("X-Tinyauth-Location", location)) + return self.reply(status, headers=headers) + if path == "/state": + with state.lock: + return self.reply(200, {"calls":state.calls,"status":state.status,"delay_ms":state.delay_ms,"recent":list(state.recent)}) + if path == "/login": + return self.reply(200, {"warning":"Fake login fixture, not authentication", "test_cookie":"mock_auth=allow", "instructions":"Send this cookie to the protected local test vhost."}) + self.reply(404, {"error":"not found"}) + def do_POST(self): + if urlsplit(self.path).path != "/control": + self.close_connection = True + return self.reply(405, {"error":"only POST /control is supported"}) + try: + if self.headers.get("Transfer-Encoding") or len(self.headers.get_all("Content-Length", [])) != 1: + raise ValueError("one Content-Length is required") + size = int(self.headers["Content-Length"]) + if not 0 <= size <= 4096: + raise ValueError("body limit is 4096 bytes") + data = self.rfile.read(size) + if len(data) != size: + raise ValueError("incomplete request") + payload = json.loads(data) + if not isinstance(payload, dict) or set(payload) - {"status", "delay_ms", "location", "reset"}: + raise ValueError("invalid fields") + status = payload.get("status") + if status is not None and (type(status) is not int or status not in (200, 204, 401, 403, 500, 503)): + raise ValueError("unsupported verification status") + delay = payload.get("delay_ms", 0) + if type(delay) is not int or not 0 <= delay <= 15000: + raise ValueError("delay must be 0..15000 ms") + location = payload.get("location") + if location is not None and (not isinstance(location, str) or len(location) > 2048 + or any(ord(c) < 32 or ord(c) > 126 for c in location)): + raise ValueError("invalid redirect header") + with self.server.state.lock: + state = self.server.state + if payload.get("reset"): + state.calls = 0 + state.recent.clear() + state.status, state.delay_ms, state.location = status, delay, location + except (ValueError, KeyError, TimeoutError, OSError) as error: + self.close_connection = True + return self.reply(400, {"error":str(error)}) + self.reply(200, {"updated":True}) + def do_HEAD(self): + self.do_GET() + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", type=int, default=9300) + args = parser.parse_args() + if not 1 <= args.port <= 65535: + parser.error("port must be 1..65535") + server = Server(("127.0.0.1", args.port)) + print(f"FAKE AUTH - LOCAL TESTING ONLY: http://127.0.0.1:{args.port}/api/auth/nginx", flush=True) + print("Approve with Cookie: mock_auth=allow. /control configures failures/delays. Ctrl+C stops.", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() +if __name__ == "__main__": + main() diff --git a/scripts/mock_server.py b/scripts/mock_server.py new file mode 100755 index 0000000..a11879e --- /dev/null +++ b/scripts/mock_server.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Local HTTP/1.1 origin for manual cache, health, compression and pool tests. +Only Python's standard library is used. This is not a production application. +""" +from __future__ import annotations +import argparse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import ipaddress +import json +import secrets +import threading +import time +from urllib.parse import parse_qs, urlsplit + +MAX_BODY = 2 * 1024 * 1024 +MAX_WORKERS = 32 + +class State: + def __init__(self, name="origin-a", ttl=30): + self.name, self.ttl = name, ttl + self.healthy, self.delay_ms, self.sequence = True, 0, 0 + self.lock = threading.Lock() + +class Server(ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = True + def __init__(self, address, state): + self.state = state + self.slots = threading.BoundedSemaphore(MAX_WORKERS) + super().__init__(address, Handler) + def process_request(self, request, client_address): + if not self.slots.acquire(blocking=False): + self.shutdown_request(request) + return + try: + super().process_request(request, client_address) + except BaseException: + self.slots.release() + raise + def process_request_thread(self, request, client_address): + try: + super().process_request_thread(request, client_address) + finally: + self.slots.release() + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + server_version = "RustProxy-Mock/0.3.0" + def setup(self): + super().setup() + self.connection.settimeout(15) + def log_message(self, *_): + pass + def send(self, status, body=b"", content_type="application/json; charset=utf-8", control="no-store", extra=None): + if isinstance(body, dict): + body = (json.dumps(body, ensure_ascii=False) + "\n").encode() + if isinstance(body, str): + body = body.encode() + if status in (204, 304): + body = b"" + self.send_response(status) + self.send_header("Content-Type", content_type) + if status != 204: + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", control) + self.send_header("X-Mock-Origin", self.server.state.name) + for name, value in (extra or {}).items(): + self.send_header(name, value) + self.end_headers() + if self.command != "HEAD": + try: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError, TimeoutError): + self.close_connection = True + def integer(self, query, key, default, maximum): + value = int(query.get(key, [str(default)])[0]) + if not 0 <= value <= maximum: + raise ValueError(key) + return value + def do_HEAD(self): + self.do_GET() + def do_GET(self): + target = urlsplit(self.path) + q = parse_qs(target.query) + state = self.server.state + try: + delay = self.integer(q, "ms", 800 if target.path == "/slow" else 0, 10000) + size = self.integer(q, "size", 65536, MAX_BODY) + ttl = self.integer(q, "ttl", state.ttl, 604800) + except ValueError: + return self.send(400, {"error":"invalid query parameter"}) + with state.lock: + state.sequence += 1 + sequence, healthy = state.sequence, state.healthy + delay += state.delay_ms + if delay: + time.sleep(min(delay, 10000) / 1000) + payload = {"origin":state.name, "sequence":sequence, "generated_at_ns":time.time_ns(), + "nonce":secrets.token_hex(16), "path":target.path, + "accept_language":self.headers.get("Accept-Language", ""), "healthy":healthy} + if target.path == "/health": + return self.send(200 if healthy else 503, payload) + if target.path == "/state": + return self.send(200, {**payload, "delay_ms":state.delay_ms, "ttl":state.ttl}) + if target.path == "/": + return self.send(200, """RustProxy mock origin +

RustProxy test origin

Each uncached request generates a new sequence and nonce.

+ +

POST /control with JSON {"healthy":false,"delay_ms":0} to test failover/recovery.

""", "text/html; charset=utf-8") + if target.path == "/large": + prefix = (json.dumps(payload) + "\n").encode() + body = (prefix + b"RustProxy compression and cache fixture.\n" * (size // 30 + 1))[:size] + return self.send(200, body, "text/plain; charset=utf-8", f"public, max-age={ttl}") + if target.path.startswith("/status/"): + try: + status = int(target.path.rsplit("/", 1)[1]) + if status not in [200, 204, 400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504]: + raise ValueError() + except ValueError: + return self.send(400, {"error":"unsupported status"}) + return self.send(status, payload, extra={"WWW-Authenticate":'Basic realm="mock"'} if status == 401 else None) + if target.path == "/cache": + return self.send(200, payload, control=f"public, max-age={ttl}", extra={"Vary":"Accept-Language"}) + if target.path == "/private": + return self.send(200, payload, control="private, max-age=60") + if target.path == "/cookie": + return self.send(200, payload, control=f"public, max-age={ttl}", extra={"Set-Cookie":"mock-session=demo; HttpOnly; SameSite=Lax; Path=/"}) + self.send(200 if target.path in ("/nocache", "/slow") else 404, payload) + def do_POST(self): + # Always consume or close a body before reusing an HTTP/1.1 connection. + if self.headers.get("Transfer-Encoding") or len(self.headers.get_all("Content-Length", [])) != 1: + self.close_connection = True + return self.send(411, {"error":"one Content-Length is required"}) + try: + length = int(self.headers["Content-Length"]) + if not 0 <= length <= 4096: + raise ValueError() + raw = self.rfile.read(length) + if len(raw) != length: + raise ValueError() + data = json.loads(raw) + if self.path != "/control" or not isinstance(data, dict) or set(data) - {"healthy", "delay_ms"}: + raise ValueError() + if "healthy" in data and not isinstance(data["healthy"], bool): + raise ValueError() + if "delay_ms" in data and (type(data["delay_ms"]) is not int or not 0 <= data["delay_ms"] <= 10000): + raise ValueError() + except (ValueError, TimeoutError): + self.close_connection = True + return self.send(400, {"error":"expected healthy: boolean and/or delay_ms: 0..10000"}) + with self.server.state.lock: + for key, value in data.items(): + setattr(self.server.state, key, value) + self.send(200, {"updated":True}) + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=9001) + parser.add_argument("--name", default="origin-a") + parser.add_argument("--ttl", type=int, default=30) + parser.add_argument("--allow-remote", action="store_true", help="Explicitly expose this development-only server on a non-loopback IPv4 address") + args = parser.parse_args() + try: + ip = ipaddress.ip_address(args.host) + except ValueError: + parser.error("--host must be an IPv4 address") + if ip.version != 4: + parser.error("this development helper accepts IPv4 bindings only") + if not ip.is_loopback and not args.allow_remote: + parser.error("non-loopback binding requires --allow-remote; /control is unauthenticated") + if not 0 <= args.port <= 65535 or not 0 <= args.ttl <= 604800: + parser.error("invalid port or TTL") + if not args.name or len(args.name) > 64 or any(ord(c) < 32 or ord(c) > 126 for c in args.name): + parser.error("--name must contain 1..64 printable ASCII characters") + server = Server((args.host, args.port), State(args.name, args.ttl)) + print(f"Mock origin {args.name}: http://{args.host}:{server.server_port} (development only)", flush=True) + print("Endpoints: /cache /nocache /private /cookie /large /slow /health /status/503; POST /control", flush=True) + try: + server.serve_forever(poll_interval=0.2) + except KeyboardInterrupt: + pass + finally: + server.server_close() + +if __name__ == "__main__": + main() diff --git a/scripts/os-acme-client/README.md b/scripts/os-acme-client/README.md new file mode 100644 index 0000000..2ec624b --- /dev/null +++ b/scripts/os-acme-client/README.md @@ -0,0 +1,107 @@ +# OPNsense os-acme-client -> RustProxy + +A simple mechanism for automatically sending a certificate generated by `os-acme-client` on OPNsense to RustProxy via API. + +## Files + +- `opnsense-rustproxy-cert-deploy.sh` - sends the certificate and private key to RustProxy. +- `rustproxy-acme.conf` - connection settings, API token, and certificate file paths. +- `actions_rustproxy_cert.conf` - optional `configd` action that allows the script to be executed from Cron in OPNsense. + +## 1. Installation + +```sh +cp opnsense-rustproxy-cert-deploy.sh /usr/local/sbin/ +chmod 750 /usr/local/sbin/opnsense-rustproxy-cert-deploy.sh + +cp rustproxy-acme.conf.example /usr/local/etc/rustproxy-acme.conf +chmod 600 /usr/local/etc/rustproxy-acme.conf +``` + +## 2. Configuration + +Edit: + +```sh +vi /usr/local/etc/rustproxy-acme.conf +``` + +Important parameters: + +```sh +RUSTPROXY_URL='https://127.0.0.1:9090' +RUSTPROXY_KEY_ID='opnsense-acme' +RUSTPROXY_TOKEN='RUSTPROXY_TOKEN' + +CERTIFICATE_ID='shared-cert' + +CERT_FILE='/var/etc/acme-client/certs/CERT_UUID/fullchain.pem' +KEY_FILE='/var/etc/acme-client/keys/CERT_UUID/private.key' +``` + +If `os-acme-client` exports the certificate to a custom directory, simply set the correct paths: + +```sh +CERT_FILE='/path/to/fullchain.pem' +KEY_FILE='/path/to/private.key' +``` + +`CERTIFICATE_ID` is the stable external certificate id. If it does not exist, RustProxy creates a manual certificate entry. If it already exists as a manual certificate, RustProxy rotates it in place. RustProxy-managed ACME ids are rejected to prevent two renewal owners for one lineage. + +## 3. RustProxy API + +Create a certificate API key in RustProxy and enter its values in the configuration: + +```text +RUSTPROXY_KEY_ID +RUSTPROXY_TOKEN +``` + +The script sends the certificate to: + +```text +POST /api/certificates/deploy +``` + +The connection from OPNsense to RustProxy should use HTTPS. + +## 4. Test + +Run the certificate reconciliation: + +```sh +/usr/local/sbin/opnsense-rustproxy-cert-deploy.sh +``` + +On success, you should see a message similar to: + +```text +RustProxy certificate reconcile succeeded (HTTP 200, id=shared-cert) +``` + +## 5. OPNsense Automation + +Optionally install the `configd` action: + +```sh +cp actions_rustproxy_cert.conf /usr/local/opnsense/service/conf/actions.d/ +service configd restart +``` + +Then go to **System -> Settings -> Cron** and add a scheduled execution of the `Deploy ACME certificate to RustProxy` action. + +The script deliberately reconciles on every run instead of trusting a local cache. The server is the source of truth: an identical RustProxy-owned certificate generation is a no-op, a missing id is created, and changed material is rotated atomically. This also repairs drift when a certificate entry or generation was removed outside OPNsense. + +## Additional Options + +If RustProxy uses a private CA: + +```sh +RUSTPROXY_CACERT='/usr/local/etc/ssl/rustproxy-ca.pem' +``` + +If you want to connect to a specific LAN address while keeping proper TLS hostname verification: + +```sh +RUSTPROXY_RESOLVE='rustproxy.lan:8443:192.168.1.20' +``` diff --git a/scripts/os-acme-client/opnsense-rustproxy-cert-deploy.sh b/scripts/os-acme-client/opnsense-rustproxy-cert-deploy.sh new file mode 100755 index 0000000..a35727a --- /dev/null +++ b/scripts/os-acme-client/opnsense-rustproxy-cert-deploy.sh @@ -0,0 +1,137 @@ +#!/bin/sh +set -eu + +# Deploy an externally issued OPNsense/os-acme-client certificate to RustProxy 0.23.x. +# Configuration is kept separately so the API token does not need to live in this script. + +CONFIG_FILE="${RUSTPROXY_ACME_CONFIG:-/usr/local/etc/rustproxy-acme.conf}" + +log() { + logger -t rustproxy-acme -- "$*" 2>/dev/null || true + printf '%s\n' "$*" +} + +fail() { + log "ERROR: $*" + exit 1 +} + +[ -r "$CONFIG_FILE" ] || fail "Cannot read config: $CONFIG_FILE" +# shellcheck disable=SC1090 +. "$CONFIG_FILE" + +: "${RUSTPROXY_URL:?Set RUSTPROXY_URL in $CONFIG_FILE}" +: "${RUSTPROXY_KEY_ID:?Set RUSTPROXY_KEY_ID in $CONFIG_FILE}" +: "${RUSTPROXY_TOKEN:?Set RUSTPROXY_TOKEN in $CONFIG_FILE}" +: "${CERTIFICATE_ID:?Set CERTIFICATE_ID in $CONFIG_FILE}" +: "${CERT_FILE:?Set CERT_FILE in $CONFIG_FILE}" +: "${KEY_FILE:?Set KEY_FILE in $CONFIG_FILE}" + +CURL="${CURL:-$(command -v curl || true)}" +OPENSSL="${OPENSSL:-$(command -v openssl || true)}" +[ -n "$CURL" ] || fail "curl not found" +[ -n "$OPENSSL" ] || fail "openssl not found" + +case "$CERTIFICATE_ID" in + ''|*[!A-Za-z0-9_-]*) fail "CERTIFICATE_ID may contain only A-Z, a-z, 0-9, _ and -" ;; +esac +[ "${#CERTIFICATE_ID}" -le 64 ] || fail "CERTIFICATE_ID is longer than 64 characters" + +[ -r "$CERT_FILE" ] || fail "Certificate not readable: $CERT_FILE" +[ -r "$KEY_FILE" ] || fail "Private key not readable: $KEY_FILE" + +# Validate certificate syntax and make sure the certificate and private key belong together. +"$OPENSSL" x509 -in "$CERT_FILE" -noout >/dev/null 2>&1 \ + || fail "Invalid certificate PEM: $CERT_FILE" +"$OPENSSL" pkey -in "$KEY_FILE" -noout >/dev/null 2>&1 \ + || fail "Invalid private key PEM: $KEY_FILE" + +CERT_PUB=$( + "$OPENSSL" x509 -in "$CERT_FILE" -pubkey -noout 2>/dev/null \ + | "$OPENSSL" pkey -pubin -outform DER 2>/dev/null \ + | "$OPENSSL" dgst -sha256 2>/dev/null +) +KEY_PUB=$( + "$OPENSSL" pkey -in "$KEY_FILE" -pubout -outform DER 2>/dev/null \ + | "$OPENSSL" dgst -sha256 2>/dev/null +) +[ -n "$CERT_PUB" ] && [ "$CERT_PUB" = "$KEY_PUB" ] \ + || fail "Certificate and private key do not match" + +# Do not publish an already-expired certificate. +"$OPENSSL" x509 -in "$CERT_FILE" -checkend 0 -noout >/dev/null 2>&1 \ + || fail "Certificate is already expired" + +# Always reconcile with RustProxy. The deploy endpoint is idempotent: an identical +# RustProxy-owned generation is a server-side no-op, while a missing certificate id +# is created and a changed manual certificate is rotated atomically. This avoids +# trusting stale local state when the RustProxy configuration was changed separately. + +umask 077 +TMP_JSON=$(mktemp "${TMPDIR:-/tmp}/rustproxy-cert.XXXXXX") || fail "mktemp failed" +TMP_CURL=$(mktemp "${TMPDIR:-/tmp}/rustproxy-curl.XXXXXX") || { rm -f "$TMP_JSON"; fail "mktemp failed"; } +TMP_RESPONSE=$(mktemp "${TMPDIR:-/tmp}/rustproxy-response.XXXXXX") || { rm -f "$TMP_JSON" "$TMP_CURL"; fail "mktemp failed"; } +trap 'rm -f "$TMP_JSON" "$TMP_CURL" "$TMP_RESPONSE"' EXIT HUP INT TERM + +# PEM is line-oriented Base64 plus BEGIN/END markers. Escape it as a JSON string +# without needing jq/python on OPNsense. +pem_json() { + awk '{ + gsub(/\\/, "\\\\"); + gsub(/\"/, "\\\""); + sub(/\r$/, ""); + printf "%s\\n", $0; + }' "$1" +} + +{ + printf '{"certificate_id":"%s","cert_pem":"' "$CERTIFICATE_ID" + pem_json "$CERT_FILE" + printf '","key_pem":"' + pem_json "$KEY_FILE" + printf '"}\n' +} > "$TMP_JSON" + +# Keep the Bearer token out of curl's process arguments by using a mode-0600 config file. +cat > "$TMP_CURL" <> "$TMP_CURL" +fi + +# Optional curl --resolve equivalent, e.g. rustproxy.lan:8443:10.10.10.20. +# This lets the URL use the certificate DNS name while traffic goes to a fixed LAN IP. +if [ -n "${RUSTPROXY_RESOLVE:-}" ]; then + printf 'resolve = "%s"\n' "$RUSTPROXY_RESOLVE" >> "$TMP_CURL" +fi + +HTTP_CODE=$("$CURL" --config "$TMP_CURL") || fail "HTTPS request to RustProxy failed" + +case "$HTTP_CODE" in + 2??) + log "RustProxy certificate reconcile succeeded (HTTP $HTTP_CODE, id=$CERTIFICATE_ID)" + cat "$TMP_RESPONSE" + printf '\n' + ;; + *) + log "RustProxy certificate deploy failed (HTTP $HTTP_CODE)" + cat "$TMP_RESPONSE" >&2 + printf '\n' >&2 + exit 1 + ;; +esac diff --git a/scripts/preflight.py b/scripts/preflight.py new file mode 100755 index 0000000..8bc26b5 --- /dev/null +++ b/scripts/preflight.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Detect mixed source trees and preserve the known obsolete 0.0.1 auth module.""" +from __future__ import annotations +import argparse +import hashlib +from pathlib import Path +import sys + +LEGACY_AUTH = "4df9d473c9214365d91c83072f97aafab6ede4edcf4e1369b5c3679ded5cff85" + + +def check(root: Path, repair_known: bool = False) -> list[str]: + messages: list[str] = [] + legacy = root / "src/auth.rs" + if legacy.exists() and (root / "src/auth/mod.rs").is_file(): + if legacy.is_symlink() or hashlib.sha256(legacy.read_bytes()).hexdigest() != LEGACY_AUTH: + raise ValueError("Both src/auth.rs and src/auth/mod.rs exist. The single-file module is not the known 0.0.1 file. Preserve your edits and move it outside src before rebuilding. See docs/MIGRATION.md.") + if not repair_known: + raise ValueError("Known obsolete src/auth.rs from 0.0.1 found. Run ./dev.sh repair or start ./dev.sh to preserve it in .upgrade-backup/legacy-auth/.") + directory = root / ".upgrade-backup/legacy-auth" + directory.mkdir(parents=True, exist_ok=True) + target = directory / "auth.rs.0.0.1" + if target.exists(): + if hashlib.sha256(target.read_bytes()).hexdigest() != LEGACY_AUTH: + raise ValueError("Legacy backup path contains a different file; refusing to overwrite it.") + legacy.unlink() # Byte-identical content was already preserved. + else: + legacy.rename(target) + messages.append("Preserved obsolete src/auth.rs as .upgrade-backup/legacy-auth/auth.rs.0.0.1") + for module in (root / "src").rglob("mod.rs"): + flat = module.parent.with_suffix(".rs") + if flat.is_file(): + raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}") + for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/upstream.rs", "src/tls.rs", "src/proxy/websocket.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/management.js", "src/pages.rs", "scripts/mock_server.py", "scripts/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "scripts/acme_provider.py", "src/basic_auth.rs", "scripts/deploy.py", "scripts/migrate_npm.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "debian/control", "debian/rules", "debian/rustproxy.service", "debian/rustproxy.logrotate", "grafana/rustproxy-overview.json"]: + if not (root / path).is_file(): + raise ValueError(f"Incomplete RustProxy 0.24.5 source tree: missing {path}") + text = (root / "src/lib.rs").read_text(encoding="utf-8") + if '#[path = "auth/mod.rs"]' not in text: + raise ValueError("src/lib.rs is not the 0.24.5 entry point; extract the complete package, not individual files.") + return messages + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--repair-known", action="store_true") + args = parser.parse_args() + try: + for line in check(args.root.resolve(), args.repair_known): + print(line, file=sys.stderr) + except (OSError, ValueError) as error: + parser.exit(2, f"Source preflight failed: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/release_smoke.py b/scripts/release_smoke.py new file mode 100755 index 0000000..0699dab --- /dev/null +++ b/scripts/release_smoke.py @@ -0,0 +1,1067 @@ +#!/usr/bin/env python3 +"""Offline release checks that do not require Rust/Cargo or network access. + +Exercises the shipped SQLite migration/backup and stopped-source upgrade path, +then checks the 0.24.1 shared-statistics/runtime/settings contracts and retained legacy-WebSocket and retained control-plane/cache-UX/live-log contracts plus retained transport contracts. +""" +from __future__ import annotations + +import hashlib +import json +import os +import re +from pathlib import Path +import sqlite3 +import subprocess +import tempfile +import tomllib +import zipfile + +import backup as backup_module +import deploy +import migrate_npm +import upgrade as upgrade_module + +ROOT = Path(__file__).resolve().parents[1] + +def read_cache_source() -> str: + return "\n".join(path.read_text(encoding="utf-8") for path in sorted((ROOT / "src/cache").glob("*.rs"))) + + +def sql_and_backup_smoke(tmp: Path) -> None: + data = tmp / "data" + data.mkdir(mode=0o700, parents=True) + db = data / "config.sqlite3" + connection = sqlite3.connect(db) + try: + connection.executescript((ROOT / "migrations/0002_admin.sql").read_text(encoding="utf-8")) + connection.executescript((ROOT / "migrations/0003_certificate_renewals.sql").read_text(encoding="utf-8")) + document = json.dumps({"listeners": {"endpoints": []}, "vhosts": []}) + connection.execute( + "INSERT INTO configuration(id, revision, document) VALUES(1, 4, ?)", + (document,), + ) + connection.execute( + "INSERT INTO administrator(id,username,password_hash,generation,created_at) VALUES(1,'owner','test-hash',1,1)" + ) + connection.execute( + "INSERT INTO certificate_renewals(certificate_id,last_checked,next_check,last_renewed,not_after,failures,status,message) VALUES(?,?,?,?,?,?,?,?)", + ("site-cert", 100, 200, 50, 500, 1, "retry", "saved observation"), + ) + connection.execute("PRAGMA user_version = 3") + connection.commit() + # Simulate additive startup migrations through the current schema-v6. + connection.executescript((ROOT / "migrations/0004_admin_preferences.sql").read_text(encoding="utf-8")) + connection.executescript((ROOT / "migrations/0005_certificate_api.sql").read_text(encoding="utf-8")) + connection.executescript((ROOT / "migrations/0006_certificate_api_credentials.sql").read_text(encoding="utf-8")) + connection.execute( + "INSERT INTO admin_preferences(administrator_id,document) VALUES(1,?)", + (json.dumps({"live_log_columns": ["time", "status"]}),), + ) + connection.execute("PRAGMA user_version = 6") + connection.commit() + assert connection.execute("PRAGMA quick_check").fetchone()[0] == "ok" + tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + } + for table in ("configuration", "administrator", "admin_sessions", "admin_audit", "auth_throttle", "certificate_renewals", "admin_preferences", "certificate_api_credential", "certificate_api_credentials"): + assert table in tables, table + finally: + connection.close() + os.chmod(db, 0o600) + + destination = tmp / "backup" + copied = backup_module.backup(data, destination) + assert copied.is_file() + metadata = json.loads((destination / "backup.json").read_text(encoding="utf-8")) + assert metadata["project"] == "RustProxy" + assert metadata["schema"] == 6 + assert metadata["configuration_revision"] == 4 + restored = sqlite3.connect(copied) + try: + assert restored.execute("PRAGMA quick_check").fetchone()[0] == "ok" + assert restored.execute("SELECT revision FROM configuration WHERE id=1").fetchone()[0] == 4 + assert restored.execute("SELECT status, message FROM certificate_renewals WHERE certificate_id='site-cert'").fetchone() == ("retry", "saved observation") + assert json.loads(restored.execute("SELECT document FROM admin_preferences WHERE administrator_id=1").fetchone()[0]) == {"live_log_columns": ["time", "status"]} + assert restored.execute("SELECT username FROM administrator WHERE id=1").fetchone()[0] == "owner" + finally: + restored.close() + + +def upgrade_smoke(tmp: Path) -> None: + target = tmp / "installed" + target.mkdir(parents=True) + (target / "Cargo.toml").write_text( + '[package]\nname = "rust-proxy"\nversion = "0.8.1"\n', encoding="utf-8" + ) + (target / "src").mkdir() + (target / "src/obsolete.rs").write_text("obsolete\n", encoding="utf-8") + (target / "langs").mkdir() + custom_pack = {"meta": {"code": "zz_ZZ", "name": "Site test"}, "strings": {}} + (target / "langs/site.json").write_text(json.dumps(custom_pack), encoding="utf-8") + (target / "data").mkdir(mode=0o700) + (target / "data/keep.txt").write_text("preserve\n", encoding="utf-8") + legacy = target / "data/certs/renewals.json" + legacy.parent.mkdir(parents=True) + legacy.write_text('{"legacy":true}\n', encoding="utf-8") + (target / ".env").write_text("RUST_PROXY_DEMO=0\n", encoding="utf-8") + + backup = upgrade_module.upgrade(ROOT, target, stopped=True) + assert backup.is_dir() + assert (backup / "src/obsolete.rs").is_file() + assert not (target / "src/obsolete.rs").exists() + assert 'version = "0.24.5"' in (target / "Cargo.toml").read_text(encoding="utf-8") + assert (target / "data/keep.txt").read_text(encoding="utf-8") == "preserve\n" + assert (target / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n" + assert (target / "langs/site.json").is_file() + assert (target / "docker-agent/main.go").is_file() + manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8")) + assert manifest["to_version"] == "0.24.5" + assert manifest["removed_obsolete_runtime_state"] is True + assert not legacy.exists() + assert not (backup / "legacy-data").exists() + + # Legacy cleanup must never follow a symlinked certs directory outside the data root. + unsafe = tmp / "unsafe-installed" + unsafe.mkdir() + (unsafe / "Cargo.toml").write_text('[package]\nname = "rust-proxy"\nversion = "0.8.1"\n', encoding="utf-8") + (unsafe / "src").mkdir(); (unsafe / "src/old.rs").write_text("old\n", encoding="utf-8") + (unsafe / "data").mkdir() + outside = tmp / "outside-certs"; outside.mkdir() + outside_legacy = outside / "renewals.json"; outside_legacy.write_text('{"outside":true}\n', encoding="utf-8") + (unsafe / "data/certs").symlink_to(outside, target_is_directory=True) + try: + upgrade_module.upgrade(ROOT, unsafe, stopped=True) + except ValueError as error: + assert "must not be a symlink" in str(error) + else: + raise AssertionError("symlinked certs directory was accepted during legacy cleanup") + assert outside_legacy.read_text(encoding="utf-8") == '{"outside":true}\n' + assert 'version = "0.8.1"' in (unsafe / "Cargo.toml").read_text(encoding="utf-8") + assert (unsafe / "src/old.rs").is_file() + + +def deployment_gate_smoke(tmp: Path) -> None: + assert deploy.admin_address("127.0.0.1:9090") == ("127.0.0.1:9090", False) + assert deploy.admin_address("0.0.0.0:9090", True) == ("0.0.0.0:9090", True) + try: + deploy.admin_address("0.0.0.0:9090", False) + except ValueError: + pass + else: + raise AssertionError("remote administration bind accepted without --allow-remote-admin") + assert deploy.admin_address("192.0.2.10:9090", True) == ("192.0.2.10:9090", True) + + remote_unit = deploy.service_unit({"prefix":"/opt/rustproxy", "data_dir":"/var/lib/rustproxy", "user":"rustproxy", "group":"rustproxy", "admin":"0.0.0.0:9090", "remote_admin":True, "http":"0.0.0.0:80", "https":"0.0.0.0:443"}) + assert "ExecStart=/opt/rustproxy/current/rust-proxy --data-dir /var/lib/rustproxy --admin-listen 0.0.0.0:9090 serve --proxy-listen 0.0.0.0:80 --tls-listen 0.0.0.0:443 --allow-remote-admin" in remote_unit + assert "ReadWritePaths=/var/lib/rustproxy /var/log/rustproxy" in remote_unit + assert "TimeoutStopSec=120" in remote_unit + deploy_source = (ROOT / "scripts/deploy.py").read_text(encoding="utf-8") + assert "def stop_for_managed_update" in deploy_source and ".managed-update" in deploy_source + assert "systemctl', 'stop', SERVICE" in deploy_source + assert "def ensure_service_data_access" in deploy_source + assert "status=200/CHDIR" in deploy_source + assert "runuser', '-u', account.pw_name, '--', 'test', '-x'" in deploy_source + assert deploy_source.index("ensure_service_data_access(data, account)") < deploy_source.index("stop_for_managed_update(data)") + assert deploy_source.index("stop_for_managed_update(data)") < deploy_source.index("backup = backup_stopped(record, prefix)") + + data = tmp / "admin-health" + data.mkdir(parents=True) + database = data / "config.sqlite3" + connection = sqlite3.connect(database) + connection.execute("CREATE TABLE configuration(id INTEGER PRIMARY KEY, revision INTEGER NOT NULL, document TEXT NOT NULL)") + connection.execute( + "INSERT INTO configuration VALUES(1, 7, ?)", + (json.dumps({"administration": {"listen": "0.0.0.0:9443", "tls": True, "allowed_hosts": ["panel.example.test"]}}),), + ) + connection.commit(); connection.close() + record = {"admin": "127.0.0.1:9090", "data_dir": str(data)} + assert deploy.admin_health_endpoint(record) == ("127.0.0.1:9443", True, "panel.example.test:9443") + + +def npm_migration_smoke(tmp: Path) -> None: + database = tmp / "npm.sqlite" + connection = sqlite3.connect(database) + connection.execute( + "CREATE TABLE proxy_host(" + "id INTEGER, domain_names TEXT, forward_scheme TEXT, forward_host TEXT, forward_port INTEGER, " + "allow_websocket_upgrade INTEGER, http2_support INTEGER, enabled INTEGER, certificate_id INTEGER, " + "ssl_forced INTEGER, access_list_id INTEGER, advanced_config TEXT, locations TEXT, " + "caching_enabled INTEGER, block_exploits INTEGER, meta TEXT)" + ) + connection.execute( + "INSERT INTO proxy_host VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + (12, json.dumps(["App.Example.test", "*.apps.example.test"]), "http", "10.20.30.40", 8080, + 1, 1, 1, 9, 0, 4, "proxy_read_timeout 90s;", "[]", 1, 0, "{}"), + ) + connection.execute( + "INSERT INTO proxy_host VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + (13, json.dumps(["secure.example.test"]), "http", "10.20.30.41", 8081, + 1, 1, 1, 10, 1, 0, "", "[]", 0, 0, "{}"), + ) + connection.commit(); connection.close() + rows = migrate_npm.load_npm_hosts(database) + config = { + "listeners": {"endpoints": [ + {"id": "public-http", "enabled": True, "binding": {"address": "0.0.0.0:80", "tls": False}}, + {"id": "public-https", "enabled": True, "binding": {"address": "0.0.0.0:443", "tls": True}}, + ]}, + "vhosts": [], + } + merged, decisions = migrate_npm.build_migration(rows, config) + assert len(decisions) == 2 and all(item.action == "import" for item in decisions) + migrated = merged["vhosts"][0] + assert migrated["frontend"]["hosts"] == ["app.example.test", "*.apps.example.test"] + assert migrated["frontend"]["listener_ids"] == ["public-http", "public-https"] + assert migrated["backends"][0]["url"] == "http://10.20.30.40:8080" + assert migrated["backends"][0]["allow_private"] is True + assert migrated["websockets"] is True + assert migrated["force_ssl"] is False + assert migrated["forward_client_ip"] is True + assert any("access_list_id=4" in warning for warning in decisions[0].warnings or []) + assert any("advanced_config" in warning for warning in decisions[0].warnings or []) + secure = merged["vhosts"][1] + assert secure["force_ssl"] is True + assert secure["frontend"]["listener_ids"] == ["public-http", "public-https"] + assert any("Force SSL" in warning for warning in decisions[1].warnings or []) + + +def ui_contract_smoke() -> None: + html = (ROOT / "src/admin/web/index.html").read_text(encoding="utf-8") + app = (ROOT / "src/admin/web/app.js").read_text(encoding="utf-8") + settings = (ROOT / "src/admin/web/settings.js").read_text(encoding="utf-8") + vhosts = (ROOT / "src/admin/web/vhosts.js").read_text(encoding="utf-8") + streams = (ROOT / "src/admin/web/streams.js").read_text(encoding="utf-8") + redirects = (ROOT / "src/admin/web/redirects.js").read_text(encoding="utf-8") + host404 = (ROOT / "src/admin/web/host404.js").read_text(encoding="utf-8") + integrations = (ROOT / "src/admin/web/integrations.js").read_text(encoding="utf-8") + acme_ui = (ROOT / "src/admin/web/acme.js").read_text(encoding="utf-8") + cache = (ROOT / "src/admin/web/cache-panel.js").read_text(encoding="utf-8") + transform_panel = (ROOT / "src/admin/web/transform-panel.js").read_text(encoding="utf-8") + css = (ROOT / "src/admin/web/style.css").read_text(encoding="utf-8") + required_html = [ + 'id="settings-tab-logging"', + 'id="settings-page-logging"', + 'id="logging-form"', + 'id="settings-tab-administration"', + 'id="settings-tab-tls-policy"', + 'id="admin-tls"', + 'id="tls-policy-12"', + 'id="tls-policy-13"', + 'id="cache-resident-age-header"', + 'id="cache-cold-enabled"', + 'id="cache-transient-memory"', + 'id="cache-cold-queue-bytes"', + 'id="cache-eviction-limit"', + 'data-cache-memory-preset="recommended"', + 'id="cache-cold-path"', + 'id="cache-cold-bytes"', + 'id="cache-cold-entries"', + 'id="cache-cold-after"', + 'id="log-format-preset"', + 'id="log-format-open"', + 'id="logging-format-dialog"', + 'id="logging-field-rows"', + 'id="panel-acl-profiles"', + 'id="acl-profile-form"', + 'id="acl-profile-list"', + 'id="acl-profile-rule-rows"', + 'id="acl-profile-rule-add"', + 'id="acl-profile-import-rules"', + 'id="vhost-search"', + 'id="panel-redirects"', + 'id="redirect-search"', + 'id="redirect-list"', + 'id="redirect-new"', + 'id="redirect-dialog"', + 'id="redirect-form"', + 'id="redirect-rules"', + 'id="redirect-rule-add"', + 'id="panel-notfound"', + 'id="notfound-search"', + 'id="notfound-list"', + 'id="notfound-new"', + 'id="notfound-dialog"', + 'id="notfound-form"', + 'id="notfound-hosts-editor"', + 'id="notfound-listeners"', + 'id="notfound-certificate"', + 'id="notfound-mode"', + 'id="notfound-status"', + 'id="panel-streams"', + 'id="stream-search"', + 'id="stream-list"', + 'id="stream-new"', + 'id="stream-listener-add"', + 'id="stream-dialog"', + 'id="stream-form"', + 'class="vhost-dialog logging-format-dialog"', + ] + for marker in required_html: + assert marker in html, marker + for marker in ['id="settings-tab-manual-config"', 'id="settings-page-manual-config"', 'id="config-editor"', 'id="save-json"', 'id="export-config"']: + assert marker in html, marker + assert 'data-panel="configuration"' not in html and 'id="panel-configuration"' not in html + assert html.index('data-panel="vhosts"') < html.index('data-panel="redirects"') < html.index('data-panel="notfound"') < html.index('data-panel="streams"') < html.index('data-panel="acl-profiles"') + assert "matching_resident_bytes" in cache and "matching_body_bytes" in cache + for marker in ["cache-memory-breakdown", "cache-transient-breakdown", "process-rss-total", "process-rss-anon", "process-rss-file", "process-data-swap", "memoryAccountingHint"]: + assert marker in html, marker + for marker in ["transient_bytes", "fill_bytes_active", "cold_queue_bytes", "cold_write_bytes_active", "cold_read_bytes_active", "process_rss_anon_bytes", "process_rss_file_bytes"]: + assert marker in app or marker in cache, marker + assert "matching_cold_bytes" in cache and "cold_promotions" in cache and "hot_demotions" in cache + assert "next.logging" in app and "logstash:" in app and "loki:" in app and "file:" in app + assert "LOGGING_PRESETS" in app and "selectedLoggingMapping" in app and "renderLoggingPreview" in app + assert "administration" in settings and "tls-policy" in settings + assert "additional_addresses" in integrations + assert "addressTagEditor" in integrations and "listener-address-chip-editor" in integrations + assert 'const h2 = field(grid, "listener-h2", "vhHttp2"' in integrations + assert 'h2Configure' in integrations and 'selectHttpTab?.("http2")' in integrations + assert 'renderHttp2Settings' in integrations and 'collectHttp2Listeners' in integrations + assert 'const h3 = field(grid, "listener-h3", "h3Enable"' in integrations + assert 'h3Configure' in integrations and 'selectHttpTab?.("http3")' in integrations + h3_tuning = integrations.split('function http3SettingsControls', 1)[1].split('function addRow', 1)[0] + assert 'h3Enable' not in h3_tuning + assert 'data-i18n="processDiagnostics">Process<' in html + for marker in ['function setOptionalMetric', 'setOptionalMetric("process-rss-anon"', 'setOptionalMetric("system-swap"', 'setOptionalMetric("system-network"']: + assert marker in app, marker + assert 'const http80 = prefer(false, 80);' in integrations + assert 'if (http80) autoIds.add(http80.id);' in integrations and 'candidates.find(endpoint => !endpoint.binding?.tls)' in integrations + assert 'https443' not in integrations + assert 'window.rustProxyIntegrations.bindings(holder, selected, !editing)' in redirects + assert 'window.rustProxyIntegrations.hostnames(hostHolder' in redirects + assert 'function hostnames(parent, initial = [], options = {})' in integrations + assert 'class="protocol-toggle-row"' in html + assert "stream-host-rows" not in integrations and 'id="settings-page-streams"' not in html and 'id="streams-form"' not in html + for marker in ["rustProxyStreams", "streamTrafficChart", "streamPendingRestart", "chartHistory", "sessions_total", "bytes_in", "bytes_out"]: + assert marker in streams, marker + assert "/assets/streams.js" in html + assert "/assets/redirects.js" in html + assert "/assets/host404.js" in html + for marker in ["rustProxyRedirects", 'saveSection("redirects"', "redirectTargetType", "redirectPathMode", "redirectQueryMode", "redirectCacheSeconds"]: + assert marker in redirects, marker + for marker in ["rustProxyNotFound", 'saveSection("not_found_hosts"', "rustProxyIntegrations.hostnames", "rustProxyIntegrations.bindings", "notfound-mode", "notfound-status", "BUILTIN_STATUSES", "ERROR_STATUSES"]: + assert marker in host404, marker + assert 'for (let code = 200; code <= 599; code++)' not in host404 + assert 'id="docker-search"' in html and 'id="docker-show-hidden"' in html + assert 'preserve_host: true' in vhosts + assert "tls_policy" in vhosts and "vhost-live-grid" in vhosts + assert '"force-ssl", "vhForceSsl"' in vhosts and 'force_ssl: yes("force-ssl")' in vhosts + assert '"forward-client-ip", "vhForwardClientIp"' in vhosts and 'forward_client_ip: yes("forward-client-ip")' in vhosts + assert "vhForceSslNeedsTls" in vhosts and "vhForceSslNeedsHttp" in vhosts and "vhForceSslPortConflict" in vhosts + assert "tlsPorts.has(443)" in vhosts + assert '$("vhost-search").addEventListener("input", render)' in vhosts and "vhSearchEmpty" in vhosts + assert "pool-vhost-disabled" in vhosts + for marker in ["locationMatchRegex", "headersVisualBuilder", "headersAdvancedJson", "openLocationHelp", "cloudflare-client-ip", "timing-headers"]: + assert marker in vhosts, marker + for marker in ["location-help-dialog", "cache-memory-sparkline", "network-rx-sparkline", "latency-sparkline", "sourceIp"]: + assert marker in html, marker + for marker in ["lastApiLatencyMs", "cacheMemorySamples", "networkRxSamples", "responseLatencySamples"]: + assert marker in app, marker + assert '
' in html + assert 'class="mini-live-chart-head"' in html and 'data-i18n="responseLatencyHistory"' in html + assert 'viewBox: "0 0 300 60"' in app and 'M0 15H300 M0 30H300 M0 45H300' in app + assert '.network-interface-row {' in css and 'background: transparent' in css + assert "hostTagEditor" in vhosts and "vhost-chart-line" in vhosts and "vhLifetimeChart" in vhosts + for marker in ["refreshCertificateInventory", "certificateDetails", "certificate-domain-pills", "certificateCoverageUnknown", "async function revalidateSession", 'Object.values(PANEL_PATHS), "/configuration"']: + assert marker in app, marker + for marker in ['data-certificate-tab="installed"', 'data-certificate-tab="renewal"', 'data-certificate-tab="generate"', 'id="certificate-page-installed"', 'id="certificate-page-renewal"', 'id="certificate-page-generate"']: + assert marker in html, marker + assert 'id="certificate-dialog"' not in html and 'id="certificate-close"' not in html + for marker in ['function selectTab(name, focus = false, persist = true)', 'log_directories', 'certbotLogs', 'value?.kind?.startsWith("renewal")']: + assert marker in acme_ui, marker + for marker in ['.certificate-operation-card', '.renewal-meta', '#panel-tls .certificate-tabs']: + assert marker in css, marker + login = (ROOT / "src/admin/web/login.js").read_text(encoding="utf-8") + settings_ui = (ROOT / "src/admin/web/settings.js").read_text(encoding="utf-8") + assert "async function redirectExistingSession" in login and "/api/auth/session" in login + assert '["/settings", "/configuration", "/logging"].includes(location.pathname)' in settings_ui + assert 'new URLSearchParams(location.search).get("tab")' in settings_ui and 'function syncLocation()' in settings_ui and 'history.replaceState' in settings_ui + assert '"/docker-vhosts"' in login and 'settingsTabs = new Set' in login and '`/settings?tab=${encodeURIComponent(tab)}`' in login + login_html = (ROOT / 'src/admin/web/login.html').read_text(encoding='utf-8') + assert 'class="auth-page auth-loading"' in login_html and 'document.body.classList.remove("auth-loading")' in login + assert 'class="app-loading"' in html and 'document.documentElement.classList.remove("app-loading")' in app + assert 'url.pathname = "/settings"' in settings_ui and 'url.searchParams.set("tab", active)' in settings_ui + assert 'function withFormSubmitState(form, submitter, task)' in app and 'button.textContent = t("saving")' in app + assert 'function fieldError(target, message)' in app and 'function showFieldError(error, form = null)' in app + assert 'function authReturnDestination()' in app and 'pendingAuthDestination || authReturnDestination()' in app + assert '$("login").hidden = navigating' in app and 'id="auth-message"' in html + assert 'settings-tabs button.has-unsaved::after' in css and 'function syncSettingsDirtyIndicators()' in app + assert 'setSettingsDirty("config-editor")' in app and '!settingsDirty.has("config-editor")' in app + assert 'throw fieldError($("tls-policy-12")' in settings_ui + assert 'input[type="checkbox"],\ninput[type="radio"] {' in css and 'width: 15px;' in css and '.log-column-option input[type="checkbox"]' not in css and 'overflow: visible' in css and 'max-height: min(38dvh, 220px)' not in css + assert '.log-tools input:not([type="checkbox"]):not([type="radio"])' in css + assert '.log-tools input {\n' not in css + footer = app.split('function renderFooter(s) {', 1)[1].split('let lastLogs', 1)[0] + assert 'SQLite / HTTP/1.1' not in footer and 'coldQ:' not in footer and 'streamFallback' not in footer + assert '`${apiLatency} / ${t("logsDropped")}: ${s.logs_dropped}`' in footer + assert 'update?.update_available && update.latest_version' in footer and 'footer-update' in footer + assert '#docker-agents-form>.form-actions {' in css and 'justify-content: flex-start' in css + for marker in ['prom-trusted-no-token', 'prom-streams', 'prom-logging']: + assert marker in html and marker in settings_ui, marker + for marker in [".certificate-details", ".certificate-domain-pills", ".certificate-card .pill"]: + assert marker in css, marker + integrations_0194 = (ROOT / "src/admin/web/integrations.js").read_text(encoding="utf-8") + docker_0194 = (ROOT / "src/admin/web/docker.js").read_text(encoding="utf-8") + assert 'readAddresses.input' in integrations_0194 + assert 'form.reset(); window.rustProxyCertificates?.close();' in integrations_0194 + assert '$("cert-import-pem").value = ""' not in integrations_0194 and '$("cert-import-key").value = ""' not in integrations_0194 + assert 'throw fieldError(row.querySelector(".docker-agent-listener-choices")' in docker_0194 + management = (ROOT / "src/admin/web/management.js").read_text(encoding="utf-8") + assert "rustProxyAclProfiles" in management and "acl_profiles" in management + assert "collectRuleRows" in management and "parseRules(rules.value, rules)" in management + assert "normalizeAclRuleText" in app and "ruleSyntaxLine" in app + assert ".settings-tabs" in css and "grid-template-columns" in css and "width: 100%" in css + assert ".logging-grid fieldset" not in css + assert ".pool-vhost-disabled" in css and ".acl-profile-rule" in css + assert ".stream-live-grid" in css and ".stream-chart-rx" in css and ".listener-address-chip-editor" in css + for marker in ['.vh-backend-card', '.auth-subcard', '.vh-cache-options[hidden]', '.protocol-toggle-row', '.listener-choices']: + assert marker in css, marker + assert html.count('

= 3 + + +def source_contract_smoke() -> None: + config = (ROOT / "src/config.rs").read_text(encoding="utf-8") + tls = (ROOT / "src/tls.rs").read_text(encoding="utf-8") + main = (ROOT / "src/main.rs").read_text(encoding="utf-8") + server = (ROOT / "src/server.rs").read_text(encoding="utf-8") + logging = (ROOT / "src/logging.rs").read_text(encoding="utf-8") + cache = read_cache_source() + settings = (ROOT / "src/settings.rs").read_text(encoding="utf-8") + websocket = (ROOT / "src/proxy/websocket.rs").read_text(encoding="utf-8") + proxy = (ROOT / "src/proxy/mod.rs").read_text(encoding="utf-8") + assert '.insert("x-rustproxy-backend-time", value)' in proxy and '.insert("x-rust-proxy-backend-time", value)' not in proxy + pages = (ROOT / "src/pages.rs").read_text(encoding="utf-8") + admin = (ROOT / "src/admin/mod.rs").read_text(encoding="utf-8") + stream = (ROOT / "src/stream.rs").read_text(encoding="utf-8") + runtime = (ROOT / "src/runtime.rs").read_text(encoding="utf-8") + acl = (ROOT / "src/acl.rs").read_text(encoding="utf-8") + policies = (ROOT / "tests/policies.rs").read_text(encoding="utf-8") + http3 = (ROOT / "src/http3.rs").read_text(encoding="utf-8") + redirect = (ROOT / "src/redirect.rs").read_text(encoding="utf-8") + not_found = (ROOT / "src/not_found.rs").read_text(encoding="utf-8") + prometheus = (ROOT / "src/prometheus.rs").read_text(encoding="utf-8") + auth = (ROOT / "src/auth/mod.rs").read_text(encoding="utf-8") + auth_store = (ROOT / "src/auth/store.rs").read_text(encoding="utf-8") + upstream = (ROOT / "src/upstream.rs").read_text(encoding="utf-8") + vhosts_js = (ROOT / "src/admin/web/vhosts.js").read_text(encoding="utf-8") + docker_source = (ROOT / "src/docker.rs").read_text(encoding="utf-8") + docker_js = (ROOT / "src/admin/web/docker.js").read_text(encoding="utf-8") + docker_agent = (ROOT / "docker-agent/main.go").read_text(encoding="utf-8") + html = (ROOT / "src/admin/web/index.html").read_text(encoding="utf-8") + cache_panel_ui = (ROOT / "src/admin/web/cache-panel.js").read_text(encoding="utf-8") + transform_panel = (ROOT / "src/admin/web/transform-panel.js").read_text(encoding="utf-8") + app_js = (ROOT / "src/admin/web/app.js").read_text(encoding="utf-8") + + # 0.24.1: shared library statistics, authenticated runtime metadata and Logging in Settings. + manifest = tomllib.loads((ROOT / "Cargo.toml").read_text(encoding="utf-8")) + system_stats = (ROOT / "src/system_stats/mod.rs").read_text(encoding="utf-8") + linux_stats = (ROOT / "src/system_stats/linux.rs").read_text(encoding="utf-8") + settings_ui = (ROOT / "src/admin/web/settings.js").read_text(encoding="utf-8") + openapi_0241 = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8") + assert not (ROOT / "src/system_stats/bsd.rs").exists() + metrics_process = manifest["dependencies"]["metrics-process"] + assert metrics_process["version"] == "=2.4.3" + assert metrics_process["default-features"] is False + assert metrics_process["features"] == ["dummy"] + sysinfo_network = manifest["dependencies"]["sysinfo"] + assert sysinfo_network["version"] == "=0.38.4" + assert sysinfo_network["default-features"] is False + assert sysinfo_network["features"] == ["network"] + target_key = 'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly", target_os = "macos", target_os = "windows"))' + assert manifest["target"][target_key]["dependencies"]["systemstat"] == "=0.2.7" + for marker in [ + "systemstat::{Platform, System}", + "metrics_process::collector::collect", + "sysinfo::Networks::new_with_refreshed_list", + "collect_network_systemstat", + "merge_network_interfaces", + "system_swap_total_bytes", + "system_uptime_seconds", + "process_virtual_memory_bytes", + "process_start_time_seconds", + "max_fds", + ]: + assert marker in system_stats, marker + for forbidden in ["sysctl", "netstat", "Command::new"]: + assert forbidden not in system_stats, forbidden + for marker in ["/proc/stat", "/proc/meminfo", "RssAnon", "VmData", "VmSwap", "pub(crate) fn augment"]: + assert marker in linux_stats, marker + for marker in ["installed_data_dir_candidates", "resolve_installed_data_dir", '"/var/lib/rustproxy"', '"/var/db/rustproxy"']: + assert marker in main, marker + for marker in ['payload["runtime"]', '"statistics_source"', '"data_directory"', '"database_path"']: + assert marker in admin, marker + anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0] + assert '"/api/status"' not in anonymous_block + assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.24.5"' in openapi_0241 + assert 'id="panel-logging"' not in html + for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"']: + assert marker in html, marker + assert 'location.pathname === "/logging"' in settings_ui + + # 0.22.3 h2c Upgrade and externally owned certificate deployment contracts. + for marker in [ + 'StatusCode::SWITCHING_PROTOCOLS', + 'struct H2cUpgradeIo', + 'HTTP2-Settings', + 'http1_h2c_upgrade_bootstraps_request_as_http2_stream_one', + 'malformed_h2c_upgrade_is_rejected_before_switching_protocols', + ]: + assert marker in server, marker + for marker in [ + '"/api/certificates/reload"', + 'certificate_reload', + '"/api/certificates/deploy"', + 'certificate_deploy', + 'certificate.deploy', + 'certificate.reload', + 'reload_certificates().await', + '"operation": "unchanged"', + 'config.tls.certificates.push(crate::tls::Certificate', + ]: + assert marker in admin, marker + for marker in ['pub async fn reload_certificates', 'configured certificates reloaded from disk']: + assert marker in runtime, marker + for marker in ['TlsCommand::Deploy', '/api/certificates/deploy', '/api/certificates/reload', 'read_external_pem']: + assert marker in main, marker + for marker in ['pub fn stage_external_pair', 'pub fn reconcile_external_pair', 'ExternalPairDeployment::Unchanged', 'pub fn remove_external_pair', 'external_pair_stages_generation_and_rolls_back_cleanly', 'external_pair_reconcile_is_idempotent_and_repairs_missing_generation']: + assert marker in tls, marker + assert 'api("/api/certificates/reload"' in app_js + transport_0223 = (ROOT / "docs/TRANSPORT.md").read_text(encoding="utf-8") + assert 'Upgrade: h2c' in transport_0223 and 'stream 1' in transport_0223 + assert 'legacy HTTP/1.1 Upgrade-to-h2c is not implemented' not in transport_0223 + audit_0233 = (ROOT / "docs/V0.24.0_AUDIT.md").read_text(encoding="utf-8") + audit_0232 = (ROOT / "docs/V0.23.2_AUDIT.md").read_text(encoding="utf-8") + audit_0223 = (ROOT / "docs/V0.22.3_AUDIT.md").read_text(encoding="utf-8") + for marker in ["legacy websocket", "origin", "http/1.1", "sec-websocket-accept", "ilo4"]: + assert marker.lower() in audit_0233.lower(), marker + for marker in ["600 samples", "308", "HTTPS", "/certs", "--livelogs"]: + assert marker.lower() in audit_0232.lower(), marker + assert 'external certificate ownership model' in audit_0223.lower() and 'release gate' in audit_0223.lower() + + update_check = (ROOT / "src/update_check.rs").read_text(encoding="utf-8") + update_defaults = tomllib.loads((ROOT / "UPDATE_CHECK.toml").read_text(encoding="utf-8")) + assert update_defaults["repository_url"] == "https://git.linuxiarz.pl/gru/RustProxy" + assert update_defaults["version_url"].endswith("/raw/branch/master/Cargo.toml") + assert update_defaults["interval_seconds"] == 3600 + for marker in ['include_str!("../UPDATE_CHECK.toml")', 'pub async fn poll_loop', 'tokio::time::sleep(interval)', 'package_version(&document)', 'MAX_VERSION_DOCUMENT_BYTES']: + assert marker in update_check, marker + assert 'update version document is too large or incomplete: {error}' in update_check + assert '.context("update version document is too large or incomplete")' not in update_check + for marker in ['RUST_PROXY_UPDATE_REPOSITORY_URL', 'RUST_PROXY_UPDATE_VERSION_URL', 'RUST_PROXY_UPDATE_CHECK_INTERVAL_SECS', 'state.start_update_check(update_check);']: + assert marker in main, marker + assert 'pub software_update: Arc' in runtime + assert 'payload["software_update"] = json!(state.software_update.snapshot());' in admin + + for marker in [ + "a persisted remote administration listener requires administration TLS", + "validate_narrower_than", + "tls_policy", + "AclProfile", + "normalize_host_pattern", + "profile_country_access", + "https_redirect_port", + "forward_client_ip", + "plain HTTP listener", + "tls_ports.contains(&443)", + "backend_tls_verification_defaults_off_and_can_be_enabled_explicitly", + "pub accept_local_listener_risk: bool", + "backend_diagnostic_flags_compile_to_frontend", + "backend_header: host.backend_header", + "backend_timing_header: host.backend_timing_header", + ]: + assert marker in config, marker + for marker in ["compile_admin", "ProtocolPolicy", "policy_only_configuration_does_not_require_certificate_directory", "certificate_covered_hosts", "legacy_certificate_coverage_can_be_read_from_existing_pem"]: + assert marker in tls, marker + assert "compile_admin" in main and "serve_tls_with_plain_redirect" in main + assert "TlsMode::Fixed" in server and "TlsMode::Frontend" in server and "plaintext_router" in server + assert "additional_addresses" in settings and "multi-address listeners require explicit IPs" in settings + for marker in ["pub session_days: u16", "session_days: 30", "(7..=30).contains(&self.session_days)", "administration_session_lifetime_is_7_to_30_days_and_hot_apply_only"]: + assert marker in settings, marker + for marker in ["30 * 24 * 60 * 60", "browser_session_lifetime_is_configurable_without_sliding_expiry", "session_cookie_uses_configured_remaining_lifetime"]: + assert marker in auth, marker + for marker in ["create_browser_session", "session_at_with_browser_lifetime", "browser session lifetime must be 7..30 days"]: + assert marker in auth_store, marker + for marker in ["accept_local_listener_risk", "!self_target || self.accept_local_listener_risk", "explicit_risk_acceptance_allows_local_listener_but_not_private_policy_bypass"]: + assert marker in upstream, marker + assert "member-local-risk" in vhosts_js and "vhLocalListenerRiskConfirm" in vhosts_js + assert 'function administrationListenCandidates()' in vhosts_js and 'function adminListenerBackendCandidate(raw)' in vhosts_js + for marker in ['latestStatus?.administration?.locked_listen', 'latestStatus?.administration?.active?.listen', 'latestStatus?.admin_listen', "value === 'localhost'", '/^127(?:\\.\\d{1,3}){3}$/.test(value)', 'riskGate.hidden = show !== true', 'row.syncLocalRisk = () => syncRisk(false)']: + assert marker in vhosts_js, marker + assert 'const saved = editing ? (envelope?.config.vhosts || []).find(item => item.id === editing) : null;' in vhosts_js + assert 'load(saved ? structuredClone(saved) : defaults());' in vhosts_js + assert 'backends: [{ id: "origin-1", url: ""' in vhosts_js + assert 'preserve_host: true' in vhosts_js + for marker in ['vhBackendDiagnostics', 'backend_header: yes("backend-header")', 'backend_timing_header: yes("backend-timing-header")', 'protocol-toggle-row', 'auth-subcard', 'vh-cache-options', 'syncCacheVisibility', 'cache: { enabled: false }']: + assert marker in vhosts_js, marker + assert 'values.length ? values : [{ id: "root"' not in vhosts_js + for marker in ["PinnedCertificateVerifier", "tls_fingerprint_sha256", "health_check: crate::balance::HealthCheck", "certificate fingerprint mismatch", "generated_id"]: + assert marker in docker_source, marker + for marker in ["docker-agent-fingerprint", "docker-agent-certificate", "docker-health-mode", "docker-search", "docker-show-hidden", "pool_routes", "dockerBackendUp", "setInterval"]: + assert marker in docker_js, marker + assert 'function checkboxField(key, control)' in docker_js + assert 'checkboxField("dockerPreserveHost", preserve)' in docker_js and 'checkboxField("dockerAclInline", inline)' in docker_js + assert 'label = el("label", undefined, "check vh-check")' in docker_js + ui_css = (ROOT / "src/admin/web/style.css").read_text(encoding="utf-8") + assert '.docker-route-grid input:not([type="checkbox"])' in ui_css and '.docker-checkbox-field .vh-check {' in ui_css + assert 'docker-switch-row' in docker_js and '.docker-switch-row {' in ui_css and 'repeat(5, minmax(110px, 160px))' in ui_css + assert '.docker-passive-health-editor .docker-health-grid {' in ui_css + assert '.docker-health-grid input:not([type="checkbox"])' in ui_css and '.docker-passive-health-editor .docker-checkbox-field input[type="checkbox"]' in ui_css + assert 'id="sparkline-transform"' in html and 'id="rate-transform"' in html + assert 'id="panel-transform"' in html and 'data-panel="transform"' in html and '/assets/transform-panel.js' in html + assert '/api/transform' in transform_panel and 'tp-cache-hits' in transform_panel + assert '
' in html + assert '
' in html + for marker in ['cache-live-hit-line', 'cache-live-miss-line', 'cache-live-bypass-line']: + assert marker in html and marker in cache_panel_ui, marker + for marker in ['transform-live-applied-line', 'transform-live-cache-line', 'transform-live-skipped-line', 'transform-live-error-line']: + assert marker in html and marker in transform_panel, marker + assert 'sparklinePoints(liveChartSamples.hits, 900, 180, 10, peak)' in cache_panel_ui + assert 'sparklinePoints(liveChartSamples.applied, 900, 180, 10, peak)' in transform_panel + assert '.function-live-chart svg {' in ui_css and 'height: clamp(180px, 22vw, 270px)' in ui_css + assert '--transform-chart:' in ui_css and 'stroke: var(--transform-chart)' in ui_css and 'background: var(--transform-chart)' in ui_css + assert '.cache-transform-badge {' in ui_css and 'border: 1px solid var(--pill-line)' in ui_css and 'border-radius: 3px' in ui_css + html = (ROOT / 'src/admin/web/index.html').read_text(encoding='utf-8') + cache_panel_js = (ROOT / 'src/admin/web/cache-panel.js').read_text(encoding='utf-8') + management_js = (ROOT / 'src/admin/web/management.js').read_text(encoding='utf-8') + app_js = (ROOT / 'src/admin/web/app.js').read_text(encoding='utf-8') + vhosts_js = (ROOT / 'src/admin/web/vhosts.js').read_text(encoding='utf-8') + cache_rs = read_cache_source() + admin_rs = (ROOT / 'src/admin/mod.rs').read_text(encoding='utf-8') + logging_rs = (ROOT / 'src/logging.rs').read_text(encoding='utf-8') + assert 'bindSettingsForm("pages-form", next => { next.pages = global.read(); }, "pages")' in management_js + assert '"pages" => {' in admin_rs and 'default_pages_section_accepts_the_standard_value_payload' in admin_rs + assert 'pub const LIVE_WINDOW: usize = 256;' in logging_rs and 'pub const HISTORY_CAPACITY: usize = 4096;' in logging_rs + assert 'api("/api/logs?limit=4096")' in management_js + assert 'const STATUS_NAMES = { 200: "OK"' in management_js and '404: "Not Found"' in management_js and 'statusLabel(code)' in management_js + assert 'let paused = null, expanded = null' in management_js and 'function mergeExpanded(data)' in management_js and 'entries.length >= 4096' in management_js + assert 'dialog.addEventListener("close", () => { if (pinning) return; expanded = null; paused = null;' in management_js + assert 'hash_scheme.as_bytes()' in cache_rs and 'cache_key_separates_frontend_scheme' in cache_rs + assert not (ROOT / 'src/cache.rs').exists() and (ROOT / 'src/cache/mod.rs').is_file() + cache_mod = (ROOT / 'src/cache/mod.rs').read_text(encoding='utf-8') + assert 'impl Cache {\n include!(' not in cache_mod + for fragment in ['lifecycle.rs', 'cold.rs', 'dashboard.rs', 'tickets.rs', 'lookup.rs']: + assert (ROOT / 'src/cache' / fragment).read_text(encoding='utf-8').lstrip().startswith('impl Cache {') + assert 'payload["transform_metrics"] = json!(state.transform_metrics.total())' in admin_rs + assert 'const transformed = s.transform_metrics || {}' in app_js and 'transformSamples.push(rates.transform)' in app_js + assert 'function uptime(value)' in app_js and 'setOptionalMetric("process-uptime", status.uptime_seconds != null' in app_js + assert 'connection-uptime-footer' not in html and 'id="process-uptime"' in html + assert 'function rateBytes(value)' in app_js and 'if (value === 0) return "0.0 KiB";' in app_js and 'return "<0.1 KiB";' in app_js + assert 'function backendTrafficRate(' in vhosts_js and 'member.bytes_in' in vhosts_js and 'member.bytes_out' in vhosts_js and 'rateBytes(memberTraffic)' in vhosts_js and 'rateBytes(d.traffic)' in vhosts_js + assert 'id="cache-domain-filter"' in html and 'cacheDomainFilterEnabled' in html and 'cacheDomainFilterDisabled' in html + assert 'const visibleDomains = (data, groups) =>' in cache_panel_js and 'scoped?.settings?.enabled ?? data.settings?.enabled ?? false' in cache_panel_js + for marker in ['effectiveCacheMemory', 'cacheMemoryHeadroom', 'cachePresetValues', 'applyCacheMemoryPreset', 'cacheMemoryPresetTooSmall', 'cacheObjectOverTransientWarning', 'cgroup_memory_max_bytes', 'transient_max_bytes', 'cold_queue_max_bytes', 'hot_eviction_max_objects']: + assert marker in app_js, marker + assert 'envelope?.config.cache?.enabled === true && cacheToggle.value !== "off"' in vhosts_js + assert 'groups = d.domains || [], shownGroups = visibleDomains(data, groups)' in cache_panel_js and 'replaceChildren(...shownGroups.map(row =>' in cache_panel_js + balance_rs = (ROOT / 'src/balance.rs').read_text(encoding='utf-8') + assert 'traffic_rate_drops_to_zero_after_an_idle_sample_window' in balance_rs and 'if delta == 0 {' in balance_rs and 'sample.rate = 0.0;' in balance_rs + all_admin_js = "\n".join(path.read_text(encoding='utf-8') for path in (ROOT / 'src/admin/web').glob('*.js')) + assert not re.search(r'(?span {' in ui_css and 'transform: translateY(-1px)' in ui_css + assert '.cache-toolbar-status .check' in ui_css and '.docker-toolbar .check' in ui_css and '.docker-alias-field>span:first-child {' in ui_css and 'margin-bottom: 0' in ui_css and '.docker-alias-editor {' in ui_css and 'min-height: 38px' in ui_css and 'height: 30px' in ui_css + assert 'readOnly: !!editing' not in vhosts_js and 'vhIdRenameConfirm' in vhosts_js and 'v.id === value.id && v.id !== editing' in vhosts_js + assert 'enforce_local_page_no_cache' in proxy and 'LocalGeneratedResponse' in proxy and 'locally_generated_pages_remove_cache_metadata' in proxy + assert 'local_page_method_rejection_is_still_marked_non_cacheable' in pages and 'response.extensions_mut().insert(PageServed);' in pages + screenshots = {path.name for path in (ROOT / 'docs/screenshots').iterdir() if path.is_file()} + assert 'README.md' in screenshots + assert screenshots <= {'README.md', 'login.png', 'location-headers.png'} + assert 'generated_vhost_inherits_global_server_header_policy' in (ROOT / 'src/docker.rs').read_text(encoding='utf-8') + assert '.docker-route-grid {' in ui_css and 'align-items: start' in ui_css + assert '.docker-acl-grid {' in ui_css and 'height: 38px' in ui_css + assert 'preserve.checked = assignment?.preserve_host !== false' in docker_js + for marker in ["ensureSelfSignedCertificate", "ListenAndServeTLS", "CertificateSHA256", "RUSTPROXY_DOCKER_AGENT_TLS"]: + assert marker in docker_agent, marker + for marker in [ + 'fallbacks: HashMap>', + 'id: format!("{}/main", host.id)', + 'custom_locations_override_but_main_vhost_remains_fallback', + 'direct_admin_listener_backend_is_marked_for_guarded_control_plane_use', + 'pub targets_admin_listener: bool', + ]: + assert marker in config, marker + vhost_source = (ROOT / "src/vhost.rs").read_text(encoding="utf-8") + assert 'is_legacy_main_fallback' in vhost_source + assert "benign_tunnel_close" in websocket and "ConnectionReset" in websocket + assert 'StatusCode::UNAUTHORIZED => Some(include_str!("admin/web/error_401.html"))' in pages + assert 'StatusCode::NOT_FOUND => Some(include_str!("admin/web/error_404.html"))' in pages + assert 'pub struct MinimalWelcome' in pages and 'builtin_welcome_is_minimal_and_never_cacheable' in pages and 'HeaderValue::from_static("no-store")' in pages + for marker in [ + "fn admin_request_authority", + "Version::HTTP_2 | Version::HTTP_3", + "request.uri().authority().ok_or(())?", + "administration_https_health_accepts_http2_authority_without_host_header", + ]: + assert marker in admin, marker + assert "fn panel_document_path" in admin and "panel_document_path(&path)" in admin and "panel_subpage_routes_survive_browser_refresh" in admin + assert 'response.extensions().get::().is_none()' in proxy + assert '"cdn-cache-control"' in proxy and '"x-cache-resident-age"' in proxy + for marker in ["OriginBackend", "apply_backend_diagnostic_headers", "x-rustproxy-backend-time", "backend_diagnostics_are_opt_in_and_strip_spoofed_values"]: + assert marker in proxy, marker + for marker in ["force_ssl_redirect", "force_ssl_location", "StatusCode::PERMANENT_REDIRECT", "https_redirect_port", "apply_upstream"]: + assert marker in proxy, marker + forwarding = (ROOT / "src/forwarding.rs").read_text(encoding="utf-8") + for marker in ["pub fn apply_upstream", 'headers.remove("x-forwarded-for")', 'headers.remove("x-real-ip")', "upstream_can_withhold_client_ip_without_losing_origin_metadata"]: + assert marker in forwarding, marker + for marker in ["SINK_QUEUE", "FileSink", "LogstashSink", "LokiSink", "Limited::new"]: + assert marker in logging, marker + for marker in [ + "matching_body_bytes", "matching_resident_bytes", "matching_cold_bytes", + "expose_resident_age", "x-cache-resident-age", "COLD_QUEUE", + "ColdFile", "body_sha256", "get_tiered", "revalidation_tiered", + "cold_queue_dropped_total", "cold_after_seconds", "cold_on_age", "cold_max_hot_seconds", "cold_policy_can_demote_by_total_hot_residency_age", + "lru_demotion_persists_and_survives_restart", + "managed_update_flush_ignores_shutdown_toggle_when_cold_tier_is_enabled", + "flush_hot_to_cold_for_update", + "disabled_cache_strips_diagnostics_without_exposing_bypass_headers", + "vary_selects_variants_without_fragmenting_the_primary_hash", + "vary_schema_change_removes_incompatible_fallbacks", + "varnish_primary_hash_uses_host_and_url_not_unrelated_request_headers", + "resident_age_survives_successful_revalidation", + "COLD_FORMAT_VERSION: u8 = 3", + "cold_cache_uses_two_hex_character_shards", + "legacy_flat_cold_cache_is_not_migrated", + "manual_cold_cleanup_reports_removed_objects_and_freed_bytes", + "rfc5861_windows_and_revalidation_flags_are_separate_metadata", + "request_max_stale_is_bounded_and_respects_revalidation_flags", + "max_stale_can_reuse_an_object_after_normal_ttl_expiry", + "request_max_age_only_revalidates_when_cached_age_exceeds_limit", + "transformed_plan_uses_transformed_content_length", + "transformed_representation_is_stored_and_reused", + "every_enabled_transform_kind_can_fill_and_reuse_the_cached_representation", + "query_strings_remain_distinct_and_bypass_query_is_only_a_request_policy", + "transformed_client_conditionals_use_the_representation_etag_not_the_origin_etag", + "no_transform_does_not_disable_shared_storage", + "duplicate_and_argument_immutable_do_not_invalidate_cache_control", + "fill_incomplete", "transform_length_mismatch", "object_too_large", + "cold_cleanup_skips_an_entry_while_a_read_is_pinned", + "stale_if_error_is_only_available_inside_its_window", + "matching_hot_key", + "matching_cold_key", + ]: + assert marker in cache, marker + transform = (ROOT / "src/transform/mod.rs").read_text(encoding="utf-8") + for marker in [ + "pub struct Settings", "pub struct Overrides", + "resolved.enabled = global.enabled && self.enabled", + "pub fn no_transform", "OriginValidators", "fn decode<'a>", + "Cow::Borrowed(body)", "trim_allocator_after_large_transform", "drop(decoded);", "drop(raw);", + "rustproxy-", + "unchanged_compressed_body_is_canonicalized_to_identity", + "origin_no_transform_blocks_body_changes_and_keeps_validator", + "final_no_transform_blocks_body_changes", + "compressed_no_transform_preserves_origin_encoding_and_bytes", + "empty_head_body_with_origin_length_keeps_origin_representation_headers", + "CanonicalIdentity", + ]: + assert marker in transform, marker + assert "pub mod transform;" in (ROOT / "src/lib.rs").read_text(encoding="utf-8") + for name in ["js.rs", "css.rs"]: + minifier = (ROOT / "src/transform" / name).read_text(encoding="utf-8") + assert "Vec" not in minifier and ".chars().collect" not in minifier + assert "into_bytes()" in minifier + balance = (ROOT / "src/balance.rs").read_text(encoding="utf-8") + for marker in [ + "pub struct PassiveHealth", "enabled: false", "pub passive_health: PassiveHealth", + "passive_health_is_opt_in_and_uses_separate_thresholds", + "legacy_settings_without_passive_health_default_to_off", + "slow_5xx_counts_both_observability_classes_when_passive_is_off", + "unhealthy_source_hands_off_when_the_original_health_domain_recovers", + "active_health_failures_total", "passive_health_failures_total", + "http_5xx_total", "timeouts_total", "connect_errors_total", "io_errors_total", "slow_responses_total", + ]: + assert marker in balance, marker + for marker in [ + 'fill_bytes_active', 'cold_queue_items', 'cold_queue_bytes', + 'cold_write_bytes_active', 'cold_read_bytes_active', 'transient_bytes', + 'transient_max_bytes', 'cold_queue_max_bytes', 'hot_eviction_max_objects', + 'hot_logical_bytes', 'hot_allocated_bytes', 'memory_pressure_rejections', + 'struct FillGauge', 'struct TransientLease', 'reserve_fill_allocation', + 'release_allocator_memory()', 'fn hot_entry_cost(', + ]: + assert marker in cache, marker + assert 'impl Drop for Candidate' not in cache + diagnostics = (ROOT / "src/diagnostics.rs").read_text(encoding="utf-8") + (ROOT / "src/system_stats/mod.rs").read_text(encoding="utf-8") + (ROOT / "src/system_stats/linux.rs").read_text(encoding="utf-8") + for marker in ['process_rss_anon_bytes', 'process_rss_file_bytes', 'process_rss_shmem_bytes', 'process_data_bytes', 'process_swap_bytes', 'RssAnon', 'RssFile', 'RssShmem', 'VmData', 'VmSwap']: + assert marker in diagnostics, marker + for marker in ['admin_session_passthrough', 'admin_control_plane', 'restore_admin_cookie', 'BEFORE returning a successful RFC 9220 response', 'websocket_phase("tunnel_ready")']: + assert marker in proxy, marker + headers_source = (ROOT / "src/proxy/headers.rs").read_text(encoding="utf-8") + assert 'pub fn restore_admin_cookie' in headers_source and 'admin_cookie_can_be_restored_only_from_captured_fragment' in headers_source + assert "state.cache.purge(None)" not in proxy + assert "request.method() == Method::HEAD && cache_ticket.is_some()" in proxy + for marker in ["cache_effective_enabled", "transform_effective_enabled", "Some(route.id.clone())"]: + assert marker in proxy, marker + for marker in ["pub struct Metrics", "pub struct MetricsView", "struct ActiveSession", "sessions_total", "sessions_completed", "bytes_in", "bytes_out", "packets_in", "packets_out", "metrics_view_reports_l4_counters"]: + assert marker in stream, marker + for marker in ["pub enum TargetType", "pub enum PathMode", "pub enum QueryMode", "StatusCode::from_u16", "cache_seconds", "unconditional self-redirect"]: + assert marker in redirect, marker + assert "pub redirects: Vec" in config and "redirect_on" in config + assert "pub not_found_hosts: Vec" in config and "not_found_host_on" in config + for marker in ["pub struct Host", "pub struct CompiledHost", "crate::pages::Page"]: + assert marker in not_found, marker + assert "RedirectResponse" in proxy and "snapshot.redirect_on" in proxy + assert "#[serde(default)]\n pub tls_verify: bool" in config + upstream = (ROOT / "src/upstream.rs").read_text(encoding="utf-8") + assert "BackendCertificateVerificationDisabled { supported_schemes }" in upstream + disabled_verifier = upstream.split("impl ServerCertVerifier for BackendCertificateVerificationDisabled", 1)[1].split("pub fn client(", 1)[0] + assert disabled_verifier.count("Ok(HandshakeSignatureValid::assertion())") == 2 + assert "WebPkiServerVerifier" not in disabled_verifier + assert "stream_metrics" in runtime and "stream_metrics" in main + for marker in ["PrefixPriority", "RegexInsensitive", "LocationResponse", "pub rewrite: PathRewrite"]: + assert marker in (ROOT / "src/vhost.rs").read_text(encoding="utf-8"), marker + for marker in ["rewritten_path", "nginx_style_location_precedence_exact_priority_regex_prefix", "location_rewrites_are_normalized_and_regex_captures_expand", "custom_location_response_is_validated"]: + assert marker in config, marker + for marker in ["local_location_response", "route.rewritten_path(&path)", "encoded_origin_path", "client_ip.to_string()"]: + assert marker in proxy, marker + assert proxy.index("RustProxy: denied by access policy") < proxy.index("if let Some(spec) = &route.response") < proxy.index("let (backend, backend_lease, affinity_cookie)") + assert "pub client_ip: Option" in logging and "client_ip" in logging + assert '"metrics": stream_metrics' in admin and '"/assets/streams.js"' in admin and 'include_str!("web/streams.js")' in admin + assert '.route("/404-hosts", get(public::index))' in admin and '"/assets/host404.js"' in admin and 'include_str!("web/host404.js")' in admin and '"not_found_hosts"' in admin + assert "session_at_with_browser_lifetime" in admin and "session_lifetime_seconds" in admin + assert '/api/preferences' in admin and 'live_log_columns' in admin + assert 'default_admin_theme' in admin and '"dark" | "light"' in admin + html = (ROOT / 'src/admin/web/index.html').read_text(encoding='utf-8') + assert 'id="ui-theme"' in html and 'html[data-theme="light"] {' in ui_css + assert 'body[data-density="compact"] input:not([type="checkbox"]):not([type="radio"])' in ui_css + assert '
INDEPENDENT BY DESIGN ' not in html + assert '
INDEPENDENT BY DESIGN
' in html + assert "inspected_metadata" in admin and "certificate_metadata" in admin + balance = (ROOT / "src/balance.rs").read_text(encoding="utf-8") + assert "max_concurrent_requests" in balance and "queue_timeout_ms" in balance and "select_retry_wait" in balance + for marker in ["dns_errors_total", "tls_errors_total", "response_header_timeouts_total", "last_failure_detail", "last_recovered_ms"]: + assert marker in balance, marker + tls_source = (ROOT / "src/tls.rs").read_text(encoding="utf-8") + for marker in ["CertificateMetadata", "not_before_unix", "not_after_unix", "days_remaining", "expiry_status"]: + assert marker in tls_source, marker + assert '"/assets/redirects.js"' in admin and 'include_str!("web/redirects.js")' in admin and '.route("/redirects", get(public::index))' in admin + assert '/Ubuntu%2014.04%20LTS/' in acl and 'repeated_slashes_are_normalized_for_routing' in acl and 'percent_encoded_spaces_are_valid_request_paths' in policies and 'repeated_slashes_route_normally_but_reach_upstream_unchanged' in policies and 'local_location_response_uses_response_rules_without_opening_backend' in policies and 'local_location_response_still_respects_route_acl' in policies + db_source = (ROOT / "src/db.rs").read_text(encoding="utf-8") + acme_source = (ROOT / "src/acme.rs").read_text(encoding="utf-8") + assert "SCHEMA_VERSION: i64 = 6" in db_source and "certificate_renewals" in db_source and "0004_admin_preferences.sql" in db_source and "0005_certificate_api.sql" in db_source and "0006_certificate_api_credentials.sql" in db_source + assert "load_renewal_observations" not in acme_source and "renewals.json" not in acme_source + for marker in ["pub kind: &'static str", "pub log_directory: String", "pub(crate) fn certbot_log_directory", 'kind: "issue"', '"renewal-dry-run"']: + assert marker in acme_source, marker + assert '"log_directories":log_directories' in admin and 'crate::acme::certbot_log_directory' in admin + assert "store.load_certificate_renewals().await?" in main and "state.acme.restore_renewals(renewal_observations)" in main + assert 'accept-remote-admin-risk' in main and 'hide = true' in main + settings_rust = (ROOT / 'src/settings.rs').read_text(encoding='utf-8') + assert 'pub struct StartupLocks' in settings_rust and 'fixed by systemd/startup' in settings_rust + logging_rust = (ROOT / 'src/logging.rs').read_text(encoding='utf-8') + assert 'pub per_vhost: bool' in logging_rust and 'default_vhost_log_directory' in logging_rust + pages_rust = (ROOT / 'src/pages.rs').read_text(encoding='utf-8') + assert 'validate_local_response' in pages_rust and '(200..=599).contains(&status)' in pages_rust + assert 'Kind::Builtin' in pages_rust and 'builtin_application_page' in pages_rust + docker_rust = (ROOT / 'src/docker.rs').read_text(encoding='utf-8') + for marker in ['pub poll_interval_seconds: u64', 'pub acl_profile_id: Option', 'pub proxy_timeouts: crate::vhost::ProxyTimeouts', 'refresh(&self, settings: &Settings, force: bool)']: + assert marker in docker_rust, marker + docker_ui = (ROOT / 'src/admin/web/docker.js').read_text(encoding='utf-8') + for marker in ['docker-live-refresh', 'saveAssignment(agentId, row, publish)', 'docker-acl-profile', 'docker-proxy-read', 'dockerOpenApp', 'proxiedUrl(agent, hostname)']: + assert marker in docker_ui, marker + compression_source = (ROOT / 'src/compression.rs').read_text(encoding='utf-8') + metrics_source = (ROOT / 'src/metrics.rs').read_text(encoding='utf-8') + style_source = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8') + app_source = (ROOT / 'src/admin/web/app.js').read_text(encoding='utf-8') + vhosts_ui = (ROOT / 'src/admin/web/vhosts.js').read_text(encoding='utf-8') + for marker in ['pub enum AppliedEncoding', 'AppliedEncoding::Gzip', 'AppliedEncoding::Brotli', 'AppliedEncoding::Zstd', 'AppliedEncoding::Deflate']: + assert marker in compression_source, marker + assert 'quality_weights_can_select_each_enabled_codec' in compression_source + diagnostics_source = (ROOT / 'src/diagnostics.rs').read_text(encoding='utf-8') + (ROOT / 'src/system_stats/mod.rs').read_text(encoding='utf-8') + (ROOT / 'src/system_stats/linux.rs').read_text(encoding='utf-8') + for marker in ['pub struct NetworkInterfaceView', 'pub network_interfaces: Vec', 'sysinfo::Networks::new_with_refreshed_list', 'system.network_stats', 'collect_network_systemstat', 'normalize_network_interfaces', 'merge_network_interfaces']: + assert marker in diagnostics_source, marker + assert 'interfaces.truncate(64)' not in diagnostics_source, 'network inventory must not silently truncate interfaces' + for marker in ['pub http1_requests: AtomicU64', 'pub fn record_compression', 'each_codec_has_an_independent_counter', 'http1_requests_total']: + assert marker in metrics_source, marker + assert 'state.metrics.record_compression(encoding)' in proxy + assert 'protocolSamples' in app_source and 'm.http1_requests' in app_source and '$("open-proxy")' not in app_source + assert 'renderNetworkInterfaceCharts' in app_source and 'networkInterfaceSamples' in app_source + assert 'rp-hidden-network-interfaces' in app_source and 'network-interface-hide' in style_source + ui_html = (ROOT / 'src/admin/web/index.html').read_text(encoding='utf-8') + for marker in ['metric-bytes-down', 'metric-bytes-up', 'connection-chart', 'certificate-api-credentials']: + assert marker in ui_html, marker + assert 'id="network-interface-charts"' in ui_html + assert 'compressionCountersHelp' not in ui_html + assert 'sparkline-h1' in (ROOT / 'src/admin/web/index.html').read_text(encoding='utf-8') + assert 'body[data-density="compact"]' in style_source and '--ui-control: 22px' in style_source + assert 'status.backend_pool_domains' in vhosts_ui and 'payload["backend_pool_domains"]' in admin + docker_source = (ROOT / 'src/docker.rs').read_text(encoding='utf-8') + management_ui = (ROOT / 'src/admin/web/management.js').read_text(encoding='utf-8') + for marker in ['function aliasEditor(', 'host-chip-editor docker-alias-editor', '.docker-force-ssl', '.docker-websockets', '.docker-cache-enabled', '.docker-passive-enabled', '.docker-passive-5xx', '.docker-agent-acl-profile']: + assert marker in docker_ui, marker + for marker in ['"force_ssl": assignment.force_ssl.unwrap_or(false)', '"websockets": assignment.websockets', '"cache": {"enabled": assignment.cache_enabled}', '"passive_health": assignment.passive_health.clone()', 'assignment.acl_profile_id.clone().or_else(|| agent.acl_profile_id.clone())']: + assert marker in docker_source, marker + assert 'agent.acl_profile_id === profile.id' in management_ui + vhost_source = (ROOT / 'src/vhost.rs').read_text(encoding='utf-8') + for marker in ['DEFAULT_PROXY_CONNECT_TIMEOUT_SECONDS: u64 = 10', 'DEFAULT_PROXY_RESPONSE_HEADER_TIMEOUT_SECONDS: u64 = 120', 'DEFAULT_PROXY_READ_TIMEOUT_SECONDS: u64 = 60', 'DEFAULT_PROXY_SEND_TIMEOUT_SECONDS: u64 = 60', 'proxy_timeouts_have_explicit_safe_defaults_but_keep_null_compatibility']: + assert marker in vhost_source, marker + assert 'defaultProxyTimeouts = () => ({ connect_timeout_seconds: 10, response_header_timeout_seconds: 120, read_timeout_seconds: 60, send_timeout_seconds: 60 })' in vhosts_ui + assert 'actions.append(enabledLabel, stateBadge, remove)' in docker_ui + assert 'row._readListeners = listenerControl.read' in docker_ui and '.docker-agent-listeners' not in docker_ui + assert 'docker-agent-status-badge' in style_source and 'justify-content: center' in style_source + assert '.docker-agent-grid>label' in style_source and '#docker-agent-add' in style_source and 'margin-top: 14px' in style_source + assert 'Bearer token:' in (ROOT / 'docker-agent/install.sh').read_text(encoding='utf-8') + tls_rust = (ROOT / 'src/tls.rs').read_text(encoding='utf-8') + assert 'pub fn validate_certificate_removals' in tls_rust and 'pub fn effective_cipher_suites' in tls_rust + assert "renewals.json" not in main + upgrade_source = (ROOT / "scripts/upgrade.py").read_text(encoding="utf-8") + assert "removed_obsolete_runtime_state" in upgrade_source and "legacy-data" not in upgrade_source and "certs_dir.is_symlink()" in upgrade_source + assert '.managed-update' in main and "flush_hot_to_cold_for_update" in main + openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8") + for marker in ['"version": "0.24.5"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']: + assert marker in openapi, marker + assert '"software_update"' in openapi and '"update_available"' in openapi + for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']: + assert marker in openapi, marker + for marker in ['"/api/certificate-api"', '"/api/certificate-api/{key_id}"', '"/api/certificates/{certificate_id}/reload"', '"CertificateReloadOneResult"', '"CertificateApiBearer"', '"CertificateApiKey"', '"/api/live-logs"']: + assert marker in openapi, marker + websocket_source = (ROOT / 'src/proxy/websocket.rs').read_text(encoding='utf-8') + proxy_source = (ROOT / 'src/proxy/mod.rs').read_text(encoding='utf-8') + for marker in ['websocket_legacy_support', 'vhWebsocketLegacySupport', 'vhWebsocketLegacySupportHelp']: + assert marker in vhosts_ui or marker in openapi, marker + for marker in ['fn legacy_websocket_origin(', 'request.headers_mut().insert("origin", origin)', 'websocket_legacy_support && !websocket_upstream_h2']: + assert marker in proxy_source, marker + for marker in ['legacy_support: bool', 'legacy 101 response declared a non-zero body', 'legacy_upstream_response_accepts_old_101_syntax_but_keeps_accept_proof_strict']: + assert marker in websocket_source, marker + for marker in ['certificate_api_credentials', 'upsert_certificate_api_credential', 'revoke_certificate_api_credential', 'verify_certificate_api_credential']: + assert marker in db_source, marker + logging_source = (ROOT / 'src/logging.rs').read_text(encoding='utf-8') + for marker in ['LIVE_STREAM_CAPACITY', 'broadcast::Sender', 'pub fn subscribe']: + assert marker in logging_source, marker + for marker in ['livelogs: bool', 'LiveLogFilters', 'live_logs_command', 'LocalAdminCertificateVerifier', 'detect_local_admin_scheme', 'admin.uri("/api/live-logs")']: + assert marker in main, marker + assert '.uri(format!("http://{addr}/api/live-logs"))' not in main + for marker in [ + "allow_trusted_network_without_token", "include_docker_vhosts", "include_streams", "include_logging", + "rust_proxy_docker_vhost_{name}", "rust_proxy_docker_backend_{name}", "rust_proxy_geoip_loaded", "not_after_timestamp_seconds", + "ewma_latency_seconds", "passive_failures", "active_health_failures_total", + "passive_health_failures_total", "http_5xx_total", "timeouts_total", + "connect_errors_total", "io_errors_total", "slow_responses_total", + "rust_proxy_log_sink_queue_dropped_total", "rust_proxy_not_found_hosts_configured", + ]: + assert marker in prometheus, marker + assert "if docker_targets.contains_key(&pool.id)" in prometheus + assert "if docker_targets.contains_key(id)" in prometheus + assert "rust_proxy_docker_vhost_exchange_duration_seconds" in prometheus + assert "PeerAddr" in prometheus and "Forwarded headers never participate in this decision" in prometheus + transport_doc = (ROOT / "docs/TRANSPORT.md").read_text(encoding="utf-8") + assert '127.0.0.1:9090' in transport_doc and 'accept_local_listener_risk: true' in transport_doc + drop_impl = http3.split('impl Drop for ReceiveBody', 1)[1].split('#[cfg(test)]', 1)[0] + assert 'self.stream.stop_sending(' not in drop_impl + websocket_source = (ROOT / 'src/proxy/websocket.rs').read_text(encoding='utf-8') + for marker in ['h3::quinn::RecvStream', 'websocket_rfc9220_on(local)', 'run_h3', 'H3_REQUEST_CANCELLED']: + assert marker in http3 or marker in websocket_source, marker + assert 'upstream: Upgraded' in websocket_source + assert 'timeout(Duration::from_secs(10), upstream)' not in websocket_source + for marker in ['websocket_phase("tunnel_started")', 'websocket_phase("tunnel_closed")', 'h3_reset("H3_REQUEST_CANCELLED")']: + assert marker in websocket_source, marker + logging_source = (ROOT / 'src/logging.rs').read_text(encoding='utf-8') + for marker in ['http_version', 'transport', 'websocket_bootstrap', 'websocket_phase', 'websocket_error', 'h3_reset_code', 'upstream_status']: + assert marker in logging_source, marker + assert 'h3_quinn' not in http3 and 'h3_quinn' not in (ROOT / 'tests/http3.rs').read_text(encoding='utf-8') + h3_test = (ROOT / 'tests/http3.rs').read_text(encoding='utf-8') + assert 'RFC 9220: Extended CONNECT negotiates WebSocket on the same H3 stream.' in h3_test + assert 'Method::CONNECT' in h3_test and 'h3::ext::Protocol' in h3_test and '.send_data(' in h3_test + for marker in ['external_admin_authority', 'external_admin_scheme', 'trusted_admin_forwarded_proto', 'reverse_proxy_dns_allowlist_uses_external_port_and_keeps_direct_port_available']: + assert marker in admin, marker + + +def container_contract_smoke() -> None: + dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") + unit = (ROOT / "deploy/rustproxy.service").read_text(encoding="utf-8") + assert "LogsDirectory=rustproxy" in unit and "LogsDirectoryMode=0770" in unit + assert "Environment=MALLOC_ARENA_MAX=2" in unit + assert "TimeoutStopSec=120" in unit + assert "ReadWritePaths=/var/lib/rustproxy /var/log/rustproxy" in unit + generated_unit = deploy.service_unit({"prefix":"/opt/rustproxy", "data_dir":"/var/lib/rustproxy", "user":"rustproxy", "group":"rustproxy", "admin":"127.0.0.1:9090", "remote_admin":False, "http":"0.0.0.0:80", "https":"0.0.0.0:443"}) + assert "LogsDirectory=rustproxy" in generated_unit and "/var/log/rustproxy" in generated_unit + assert "Environment=MALLOC_ARENA_MAX=2" in generated_unit + for marker in [ + "FROM rust:1.88-trixie AS build", + "cargo generate-lockfile", + "cargo build --locked --release", + "FROM debian:trixie-slim AS runtime", + "USER rustproxy:rustproxy", + 'VOLUME ["/var/lib/rustproxy"]', + ]: + assert marker in dockerfile, marker + ignore = set((ROOT / ".dockerignore").read_text(encoding="utf-8").splitlines()) + for marker in {"data", "target", ".env", "*.pem", "*.key"}: + assert marker in ignore, marker + assert (ROOT / "docs/CONTAINER.md").is_file() + +def debian_grafana_contract_smoke() -> None: + control = (ROOT / "debian/control").read_text(encoding="utf-8") + rules = (ROOT / "debian/rules").read_text(encoding="utf-8") + service = (ROOT / "debian/rustproxy.service").read_text(encoding="utf-8") + environment = (ROOT / "debian/rustproxy.environment").read_text(encoding="utf-8") + logrotate = (ROOT / "debian/rustproxy.logrotate").read_text(encoding="utf-8") + postinst = (ROOT / "debian/rustproxy.postinst").read_text(encoding="utf-8") + repo = (ROOT / "debian/build-repository.sh").read_text(encoding="utf-8") + dashboard = json.loads((ROOT / "grafana/rustproxy-overview.json").read_text(encoding="utf-8")) + assert "debhelper-compat (= 13)" in control and "cargo" in control and "rustc" in control + assert "cargo build --release --locked" in rules and "cargo test --locked" in rules + for marker in ["User=rustproxy", "Group=rustproxy", "Environment=MALLOC_ARENA_MAX=2", "AmbientCapabilities=CAP_NET_BIND_SERVICE", "StateDirectory=rustproxy", "LogsDirectory=rustproxy", "ProtectSystem=strict", "ReadWritePaths=/var/lib/rustproxy /var/log/rustproxy"]: + assert marker in service, marker + assert "RUST_PROXY_ADMIN_LISTEN=127.0.0.1:9090" in environment + for marker in ["daily", "rotate 3", "maxage 3", "compresscmd /usr/bin/gzip", "compressoptions -9", "copytruncate", "su rustproxy rustproxy"]: + assert marker in logrotate, marker + assert "/var/log/rustproxy" in postinst and "-o rustproxy -g rustproxy" in postinst + assert "rustproxy.linuxiarz.pl" in repo and "dpkg-scanpackages" in repo and "gzip -9 -n" in repo + assert dashboard.get("uid") == "rustproxy-0170" + queries = json.dumps(dashboard) + for marker in ["rust_proxy_process_rss_bytes", "rust_proxy_cache_transient_bytes", "rust_proxy_backend_ewma_latency_seconds", "rust_proxy_certificate_renewal_not_after_timestamp_seconds", "rust_proxy_not_found_hosts_configured", "rust_proxy_http1_requests_total"]: + assert marker in queries, marker + + +def reproducible_archive_smoke(tmp: Path) -> None: + first = tmp / "first.zip" + second = tmp / "second.zip" + env = dict(os.environ, SOURCE_DATE_EPOCH="1700000000") + subprocess.run(["python3", str(ROOT / "make_zip.py"), str(first)], check=True, env=env, stdout=subprocess.DEVNULL) + subprocess.run(["python3", str(ROOT / "make_zip.py"), str(second)], check=True, env=env, stdout=subprocess.DEVNULL) + first_bytes, second_bytes = first.read_bytes(), second.read_bytes() + assert hashlib.sha256(first_bytes).digest() == hashlib.sha256(second_bytes).digest() + with zipfile.ZipFile(first) as archive: + names = archive.namelist() + assert "RustProxy/SHA256SUMS" in names + assert "RustProxy/src/admin/web/error_401.html" in names + assert "RustProxy/src/admin/web/error_404.html" in names + assert "RustProxy/docs/CUSTOM_LOCATIONS.md" in names + assert "RustProxy/nagios/check_rustproxy.py" in names + assert "RustProxy/nagios/check_rustproxy_api" in names + assert "RustProxy/src/admin/web/host404.js" in names + assert "RustProxy/docs/NOT_FOUND_HOSTS.md" in names + assert "RustProxy/debian/control" in names + assert "RustProxy/debian/rustproxy.service" in names + assert "RustProxy/debian/rustproxy.logrotate" in names + assert "RustProxy/grafana/rustproxy-overview.json" in names + assert "RustProxy/UPDATE_CHECK.toml" in names + assert "RustProxy/src/update_check.rs" in names + assert "RustProxy/docker-agent/docker-agent" not in names + assert all("/data/" not in name and "/target/" not in name for name in names) + timestamps = {item.date_time for item in archive.infolist()} + assert len(timestamps) == 1 + + +def main() -> None: + with tempfile.TemporaryDirectory(prefix="rustproxy-release-smoke-") as directory: + tmp = Path(directory) + sql_and_backup_smoke(tmp / "sql") + upgrade_smoke(tmp / "upgrade") + reproducible_archive_smoke(tmp / "archives") + deployment_gate_smoke(tmp) + npm_migration_smoke(tmp) + ui_contract_smoke() + source_contract_smoke() + container_contract_smoke() + debian_grafana_contract_smoke() + print("PASS: 0.24.5 SQLite/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, Debian packaging, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.") + + +if __name__ == "__main__": + main() diff --git a/scripts/routeros-oci-build/.env b/scripts/routeros-oci-build/.env new file mode 100644 index 0000000..8c40ca4 --- /dev/null +++ b/scripts/routeros-oci-build/.env @@ -0,0 +1,39 @@ +# Zot / OCI registry. Do not include a path in REGISTRY. +REGISTRY=zot.linuxiarz.pl +IMAGE_NAME=rustproxy + +# Alternative shorthand instead of REGISTRY + IMAGE_NAME: +# OCI_IMAGE=zot.example.net/network/rustproxy + +# Zot Basic Auth / htpasswd or token credentials. +OCI_USERNAME=admin +OCI_PASSWORD= +# OCI_TOKEN= + +# RouterOS targets used by this helper. +BUILD_PLATFORMS=linux/amd64,linux/arm64 +#BUILD_PLATFORMS=linux/amd64 +BUILDER=rustproxy + +# RustProxy currently requires Rust 1.88. +RUST_VERSION=1.88 +DEBIAN_SUITE=trixie + +# Empty OCI_TAG means the script uses Cargo.toml package version. +OCI_TAG= +OCI_LATEST=1 +OCI_EXTRA_TAGS= + +# Build behavior. +OCI_PULL=1 +OCI_NO_CACHE=0 +OCI_VERIFY=1 +OCI_PROGRESS=plain + +# 1 stores BuildKit cache in Zot as IMAGE_NAME:buildcache. +# Recommended for CI/ephemeral buildx builders. +OCI_REGISTRY_CACHE=0 + +# Optional BuildKit config, useful for an internal Zot with a custom CA. +# BUILDER_CONFIG=/etc/buildkit/buildkitd.toml + diff --git a/scripts/routeros-oci-build/.env.routeros.example b/scripts/routeros-oci-build/.env.routeros.example new file mode 100644 index 0000000..9c91c88 --- /dev/null +++ b/scripts/routeros-oci-build/.env.routeros.example @@ -0,0 +1,40 @@ +# Zot / OCI registry. Do not include a path in REGISTRY. +REGISTRY=zot.example.net +IMAGE_NAME=network/rustproxy + +# Alternative shorthand instead of REGISTRY + IMAGE_NAME: +# OCI_IMAGE=zot.example.net/network/rustproxy + +# Zot Basic Auth / htpasswd or token credentials. +OCI_USERNAME= +OCI_PASSWORD= +# OCI_TOKEN= + +# RouterOS targets used by this helper. +BUILD_PLATFORMS=linux/amd64,linux/arm64 +BUILDER=rustproxy-routeros + +# RustProxy currently requires Rust 1.88. +RUST_VERSION=1.88 +DEBIAN_SUITE=trixie + +# Empty OCI_TAG means the script uses Cargo.toml package version. +OCI_TAG= +OCI_LATEST=1 +OCI_EXTRA_TAGS=routeros + +# Build behavior. +OCI_PULL=1 +OCI_NO_CACHE=0 +OCI_VERIFY=1 +OCI_PROGRESS=plain + +# 1 stores BuildKit cache in Zot as IMAGE_NAME:buildcache. +# Recommended for CI/ephemeral buildx builders. +OCI_REGISTRY_CACHE=0 + +# Optional BuildKit config, useful for an internal Zot with a custom CA. +# BUILDER_CONFIG=/etc/buildkit/buildkitd.toml + +# Optional manual repository root override. +# RUSTPROXY_PROJECT_ROOT=/home/user/RustProxy diff --git a/scripts/routeros-oci-build/Dockerfile.routeros b/scripts/routeros-oci-build/Dockerfile.routeros new file mode 100644 index 0000000..c0a541a --- /dev/null +++ b/scripts/routeros-oci-build/Dockerfile.routeros @@ -0,0 +1,51 @@ +# syntax=docker/dockerfile:1 +FROM rust:1.88-trixie AS build + +WORKDIR /build +COPY . . + +RUN if [ ! -f Cargo.lock ]; then cargo generate-lockfile; fi \ + && cargo build --locked --release + + +FROM debian:trixie-slim AS runtime-trixie + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + python3 \ + certbot \ + openssl \ + gosu \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 10001 rustproxy \ + && useradd --system \ + --uid 10001 \ + --gid rustproxy \ + --home /var/lib/rustproxy \ + --shell /usr/sbin/nologin \ + rustproxy \ + && install -d -o rustproxy -g rustproxy -m 0700 /var/lib/rustproxy \ + && install -d -o rustproxy -g rustproxy -m 0750 /var/log/rustproxy + +COPY --from=build /build/target/release/rust-proxy /usr/local/bin/rust-proxy +COPY scripts/routeros-oci-build/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh + +RUN chmod 0755 \ + /usr/local/bin/rust-proxy \ + /usr/local/bin/docker-entrypoint.sh + +ENV RUST_LOG=rust_proxy=info,warn \ + RUST_PROXY_DATA_DIR=/var/lib/rustproxy \ + RUST_PROXY_ADMIN_LISTEN=0.0.0.0:9090 \ + RUST_PROXY_PROXY_LISTEN=0.0.0.0:8080 \ + RUST_PROXY_TLS_LISTEN=0.0.0.0:8443 + +VOLUME ["/var/lib/rustproxy"] + +EXPOSE 8080/tcp +EXPOSE 8443/tcp +EXPOSE 9090/tcp + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +CMD ["serve", "--allow-remote-admin"] \ No newline at end of file diff --git a/scripts/routeros-oci-build/Dockerfile.routeros.dockerignore b/scripts/routeros-oci-build/Dockerfile.routeros.dockerignore new file mode 100644 index 0000000..4c36d2a --- /dev/null +++ b/scripts/routeros-oci-build/Dockerfile.routeros.dockerignore @@ -0,0 +1,25 @@ +.git +.gitignore +.github +.idea +.vscode +target +**/target +node_modules +**/node_modules +*.zip +*.tar +*.tar.gz +*.tgz +*.pkg +*.deb +*.rpm +.env +**/.env +*.pem +*.key +*.p12 +*.pfx +*.sqlite +*.sqlite3 +*.db diff --git a/scripts/routeros-oci-build/README.md b/scripts/routeros-oci-build/README.md new file mode 100644 index 0000000..d345066 --- /dev/null +++ b/scripts/routeros-oci-build/README.md @@ -0,0 +1,137 @@ +# RustProxy RouterOS OCI builder - Debian Trixie + +Multi-arch build/push helper for RustProxy images intended for RouterOS Container. +It publishes one OCI manifest for `linux/amd64` and `linux/arm64`. + +## Why ARM64 builds are faster + +The Rust build stage is pinned to Docker BuildKit's `BUILDPLATFORM`: + +```dockerfile +FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-trixie AS build +``` + +On an amd64 build host: + +- amd64 uses native `rustc` + `gcc`, +- arm64 uses the same native amd64 `rustc` + `aarch64-linux-gnu-gcc`. + +The expensive Cargo/Rust/C compilation is therefore not executed by an ARM CPU emulator. +The final Debian arm64 runtime stage can still use binfmt/QEMU briefly for `apt/dpkg` if the build host is amd64; this is normally a small part of the total build and is cacheable. + +## Repository layout + +Copy this directory as: + +```text +RustProxy/ + Cargo.toml + Cargo.lock + scripts/ + routeros-oci-build/ + build-routeros-oci.sh + Dockerfile.routeros + Dockerfile.routeros.dockerignore + .env +``` + +Create `.env`: + +```bash +cp scripts/routeros-oci-build/.env.example \ + scripts/routeros-oci-build/.env +``` + +Then edit the Zot address and credentials. + +## Zot example + +```env +REGISTRY=zot.example.net +IMAGE_NAME=network/rustproxy +OCI_USERNAME=builder +OCI_PASSWORD=secret + +BUILD_PLATFORMS=linux/amd64,linux/arm64 +RUST_VERSION=1.88 +DEBIAN_SUITE=trixie +``` + +`OCI_TOKEN` can be used instead of `OCI_PASSWORD`. +Credentials are sent to `docker login` using `--password-stdin`. + +## Check configuration + +```bash +ENV_FILE=scripts/routeros-oci-build/.env \ + bash scripts/routeros-oci-build/build-routeros-oci.sh config +``` + +The repository root is detected using Git. If that is unavailable, the script walks upward until it finds `Cargo.toml`. + +## Build and push multi-arch image + +```bash +ENV_FILE=scripts/routeros-oci-build/.env \ + bash scripts/routeros-oci-build/build-routeros-oci.sh push +``` + +With RustProxy version `0.24.5` and the defaults this publishes: + +```text +zot.example.net/network/rustproxy:0.24.5 +zot.example.net/network/rustproxy:latest +zot.example.net/network/rustproxy:routeros +``` + +Each tag points to the same amd64 + arm64 OCI manifest. + +## Local single-architecture build + +```bash +ENV_FILE=scripts/routeros-oci-build/.env \ + bash scripts/routeros-oci-build/build-routeros-oci.sh load amd64 +``` + +or: + +```bash +ENV_FILE=scripts/routeros-oci-build/.env \ + bash scripts/routeros-oci-build/build-routeros-oci.sh load arm64 +``` + +`load arm64` loads an ARM64 image into Docker; it does not attempt to start the container. + +## Cache + +BuildKit cache mounts are used automatically for Cargo registry/git/target data. +For short-lived CI builders, enable registry cache: + +```env +OCI_REGISTRY_CACHE=1 +``` + +It is stored in Zot as: + +```text +zot.example.net/network/rustproxy:buildcache +``` + +## Custom/self-signed Zot CA + +Point `BUILDER_CONFIG` to a BuildKit configuration that trusts the registry CA, for example: + +```env +BUILDER_CONFIG=/etc/buildkit/buildkitd.toml +``` + +Docker itself must also trust the registry CA for `docker login` and manifest inspection. + +## Requirements + +- Docker Engine +- Docker Buildx +- access to the Zot/OCI registry +- an existing RustProxy `Cargo.lock` + +Do not add an ARM-native Rust builder stage. Doing that would put `rustc` and Cargo back under QEMU on an amd64 host. diff --git a/scripts/routeros-oci-build/build-routeros-oci.sh b/scripts/routeros-oci-build/build-routeros-oci.sh new file mode 100755 index 0000000..bbdfb0f --- /dev/null +++ b/scripts/routeros-oci-build/build-routeros-oci.sh @@ -0,0 +1,272 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# RustProxy multi-arch OCI builder for RouterOS. +# Rust compilation runs on BUILDPLATFORM. Target binaries are cross-compiled, +# so rustc/cargo do not run through QEMU for arm64 builds. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +resolve_project_root() { + if [[ -n "${RUSTPROXY_PROJECT_ROOT:-}" ]]; then + (cd "$RUSTPROXY_PROJECT_ROOT" && pwd) + return + fi + + if command -v git >/dev/null 2>&1 && git -C "$SCRIPT_DIR" rev-parse --show-toplevel >/dev/null 2>&1; then + git -C "$SCRIPT_DIR" rev-parse --show-toplevel + return + fi + + local dir="$SCRIPT_DIR" + while [[ "$dir" != "/" ]]; do + if [[ -f "$dir/Cargo.toml" ]]; then + printf '%s\n' "$dir" + return + fi + dir="$(dirname "$dir")" + done + + return 1 +} + +PROJECT_ROOT="$(resolve_project_root)" || { + echo "Cannot find repository root (Cargo.toml)." >&2 + echo "Set RUSTPROXY_PROJECT_ROOT=/path/to/RustProxy." >&2 + exit 1 +} + +[[ -f "$PROJECT_ROOT/Cargo.toml" ]] || { + echo "Missing $PROJECT_ROOT/Cargo.toml" >&2 + exit 1 +} + +# Supports the invocation: +# ENV_FILE=scripts/routeros-oci-build/.env bash scripts/routeros-oci-build/build-routeros-oci.sh push +ENV_FILE="${ROUTEROS_OCI_ENV_FILE:-${ENV_FILE:-$SCRIPT_DIR/.env}}" +if [[ "$ENV_FILE" != /* ]]; then + ENV_FILE="$PROJECT_ROOT/$ENV_FILE" +fi + +[[ -f "$ENV_FILE" ]] || { + echo "Missing environment file: $ENV_FILE" >&2 + echo "Copy $SCRIPT_DIR/.env.example to $SCRIPT_DIR/.env and edit it." >&2 + exit 1 +} + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +ACTION="${1:-push}" + +VERSION="$(awk ' + /^\[package\][[:space:]]*$/ { in_package=1; next } + /^\[/ && in_package { exit } + in_package && /^[[:space:]]*version[[:space:]]*=/ { + line=$0 + sub(/^[^=]*=[[:space:]]*"/, "", line) + sub(/".*/, "", line) + print line + exit + } +' "$PROJECT_ROOT/Cargo.toml")" +[[ -n "$VERSION" ]] || { echo "Cannot determine package version from Cargo.toml" >&2; exit 1; } + +# Registry/image. Preferred format: REGISTRY + IMAGE_NAME. +# OCI_IMAGE is kept as a compatible shorthand. +if [[ -n "${OCI_IMAGE:-}" ]]; then + IMAGE_REPO="${OCI_IMAGE%/}" + REGISTRY="${OCI_REGISTRY:-${IMAGE_REPO%%/*}}" +else + : "${REGISTRY:?Set REGISTRY in $ENV_FILE}" + : "${IMAGE_NAME:?Set IMAGE_NAME in $ENV_FILE}" + REGISTRY="${REGISTRY#https://}" + REGISTRY="${REGISTRY#http://}" + REGISTRY="${REGISTRY%/}" + IMAGE_REPO="${REGISTRY}/${IMAGE_NAME#/}" +fi + +REGISTRY="${REGISTRY#https://}" +REGISTRY="${REGISTRY#http://}" +REGISTRY="${REGISTRY%/}" +IMAGE_REPO="${IMAGE_REPO#https://}" +IMAGE_REPO="${IMAGE_REPO#http://}" +IMAGE_REPO="${IMAGE_REPO%/}" + +BUILD_PLATFORMS="${BUILD_PLATFORMS:-linux/amd64,linux/arm64}" +BUILDER="${BUILDER:-rustproxy-routeros}" +RUST_VERSION="${RUST_VERSION:-1.88}" +DEBIAN_SUITE="${DEBIAN_SUITE:-trixie}" +DOCKERFILE="${DOCKERFILE:-$SCRIPT_DIR/Dockerfile.routeros}" +OCI_TAG="${OCI_TAG:-$VERSION}" +OCI_LATEST="${OCI_LATEST:-1}" +OCI_EXTRA_TAGS="${OCI_EXTRA_TAGS:-routeros}" +OCI_PULL="${OCI_PULL:-1}" +OCI_NO_CACHE="${OCI_NO_CACHE:-0}" +OCI_VERIFY="${OCI_VERIFY:-1}" +OCI_REGISTRY_CACHE="${OCI_REGISTRY_CACHE:-0}" +OCI_PROGRESS="${OCI_PROGRESS:-plain}" + +IMAGE_TAG="${IMAGE_REPO}:${OCI_TAG}" + +[[ -f "$DOCKERFILE" ]] || { echo "Missing Dockerfile: $DOCKERFILE" >&2; exit 1; } + +REGISTRY_USERNAME="${OCI_USERNAME:-${REGISTRY_USERNAME:-}}" +REGISTRY_SECRET="${OCI_TOKEN:-${OCI_PASSWORD:-${REGISTRY_PASSWORD:-}}}" + +registry_login() { + if [[ -z "$REGISTRY_USERNAME" && -z "$REGISTRY_SECRET" ]]; then + return 0 + fi + if [[ -z "$REGISTRY_USERNAME" || -z "$REGISTRY_SECRET" ]]; then + echo "Set both OCI_USERNAME and OCI_PASSWORD/OCI_TOKEN, or neither." >&2 + exit 1 + fi + printf '%s' "$REGISTRY_SECRET" | docker login "$REGISTRY" \ + --username "$REGISTRY_USERNAME" \ + --password-stdin +} + +ensure_builder() { + command -v docker >/dev/null 2>&1 || { echo "docker is required" >&2; exit 127; } + docker buildx version >/dev/null 2>&1 || { echo "docker buildx is required" >&2; exit 127; } + if docker buildx inspect "$BUILDER" >/dev/null 2>&1; then + docker buildx use "$BUILDER" + else + local args=(create --name "$BUILDER" --driver docker-container --use) + [[ -z "${BUILDER_CONFIG:-}" ]] || args+=(--config "$BUILDER_CONFIG") + docker buildx "${args[@]}" >/dev/null + fi + docker buildx inspect --bootstrap >/dev/null +} + +revision="unknown" +if command -v git >/dev/null 2>&1 && git -C "$PROJECT_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + revision="$(git -C "$PROJECT_ROOT" rev-parse --short=12 HEAD 2>/dev/null || printf 'unknown')" +fi +created="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + +common_build_args() { + BUILD_ARGS=( + build + --builder "$BUILDER" + --file "$DOCKERFILE" + --target runtime-trixie + --build-arg "BUILD_VERSION=$VERSION" + --build-arg "RUST_VERSION=$RUST_VERSION" + --build-arg "DEBIAN_SUITE=$DEBIAN_SUITE" + --provenance=false + --sbom=false + --progress "$OCI_PROGRESS" + --label "org.opencontainers.image.title=RustProxy" + --label "org.opencontainers.image.version=$VERSION" + --label "org.opencontainers.image.revision=$revision" + --label "org.opencontainers.image.created=$created" + ) + + [[ "$OCI_PULL" == "1" ]] && BUILD_ARGS+=(--pull) + [[ "$OCI_NO_CACHE" == "1" ]] && BUILD_ARGS+=(--no-cache) + + if [[ "$OCI_REGISTRY_CACHE" == "1" ]]; then + BUILD_ARGS+=( + --cache-from "type=registry,ref=${IMAGE_REPO}:buildcache" + --cache-to "type=registry,ref=${IMAGE_REPO}:buildcache,mode=max,image-manifest=true,oci-mediatypes=true" + ) + fi +} + +build_push() { + registry_login + ensure_builder + common_build_args + + BUILD_ARGS+=( + --platform "$BUILD_PLATFORMS" + --tag "$IMAGE_TAG" + ) + + if [[ "$OCI_LATEST" == "1" && "$OCI_TAG" != "latest" ]]; then + BUILD_ARGS+=(--tag "${IMAGE_REPO}:latest") + fi + + if [[ -n "$OCI_EXTRA_TAGS" ]]; then + local tag + IFS=',' read -r -a extra_tags <<< "$OCI_EXTRA_TAGS" + for tag in "${extra_tags[@]}"; do + tag="${tag//[[:space:]]/}" + [[ -n "$tag" ]] && BUILD_ARGS+=(--tag "${IMAGE_REPO}:${tag}") + done + fi + + BUILD_ARGS+=(--push "$PROJECT_ROOT") + + cat < RustProxy RouterOS OCI build + image: $IMAGE_TAG + platforms: $BUILD_PLATFORMS + Rust: $RUST_VERSION + runtime: debian:$DEBIAN_SUITE-slim + builder: $BUILDER + project: $PROJECT_ROOT + Dockerfile: $DOCKERFILE + +Rust compilation runs on BUILDPLATFORM; arm64 is cross-compiled. +INFO + + docker buildx "${BUILD_ARGS[@]}" + + if [[ "$OCI_VERIFY" == "1" ]]; then + echo + echo "==> Published manifest" + docker buildx imagetools inspect "$IMAGE_TAG" + fi +} + +build_load() { + local arch="${2:-amd64}" platform + case "$arch" in + amd64|x86_64) platform="linux/amd64"; arch="amd64" ;; + arm64|aarch64) platform="linux/arm64"; arch="arm64" ;; + *) echo "Usage: $0 load [amd64|arm64]" >&2; exit 2 ;; + esac + + ensure_builder + common_build_args + BUILD_ARGS+=( + --platform "$platform" + --tag "${IMAGE_REPO}:local-${arch}" + --load + "$PROJECT_ROOT" + ) + + docker buildx "${BUILD_ARGS[@]}" + printf 'Loaded %s:local-%s (%s)\n' "$IMAGE_REPO" "$arch" "$platform" +} + +show_config() { + cat <&2 + exit 2 + ;; +esac diff --git a/scripts/routeros-oci-build/docker-entrypoint.sh b/scripts/routeros-oci-build/docker-entrypoint.sh new file mode 100755 index 0000000..af3f09d --- /dev/null +++ b/scripts/routeros-oci-build/docker-entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +DATA_DIR="${RUST_PROXY_DATA_DIR:-/var/lib/rustproxy}" +RUSTPROXY_UID="${RUSTPROXY_UID:-10001}" +RUSTPROXY_GID="${RUSTPROXY_GID:-10001}" + +if [ "$(id -u)" = "0" ]; then + mkdir -p "$DATA_DIR" + + chown "$RUSTPROXY_UID:$RUSTPROXY_GID" "$DATA_DIR" + chmod 0700 "$DATA_DIR" + + exec gosu "$RUSTPROXY_UID:$RUSTPROXY_GID" \ + /usr/local/bin/rust-proxy "$@" +fi + +exec /usr/local/bin/rust-proxy "$@" \ No newline at end of file diff --git a/scripts/swagger_assets.py b/scripts/swagger_assets.py new file mode 100755 index 0000000..11f1449 --- /dev/null +++ b/scripts/swagger_assets.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""Prepare pinned Swagger assets for build.rs. No Cargo config or runtime CDN needed. + +Only the upstream tag archive (or an operator-supplied local copy of that tag) is +accepted. ZIP paths are never extracted: bounded, exact members are read and +written to fixed output names. No application data or credentials are accessed. +""" +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import re +import sys +import tempfile +import time +from urllib.parse import urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener, url2pathname +import zipfile +import zlib + +VERSION = "5.32.11" +DOWNLOAD_URL = f"https://github.com/swagger-api/swagger-ui/archive/refs/tags/v{VERSION}.zip" +PREFIX = f"swagger-ui-{VERSION}/" +MAX_ARCHIVE = 64 * 1024 * 1024 +ASSETS = { + "swagger-ui-bundle.js": ("dist/swagger-ui-bundle.js", 16 * 1024 * 1024), + "swagger-ui.css": ("dist/swagger-ui.css", 2 * 1024 * 1024), +} +LICENSE_MEMBERS = ("LICENSE", "NOTICE", "dist/swagger-ui-bundle.js.LICENSE.txt") +HELP = ("Install Python 3.10+ and allow HTTPS access to github.com/codeload.github.com, " + "or set SWAGGER_UI_DOWNLOAD_URL=file:///absolute/path/swagger-ui-5.32.11.zip " + "to a reviewed copy of the same tag archive. See docs/API_ACCESS.md.") + + +class AssetError(ValueError): + """A controlled, actionable source/archive error (never an implicit fallback).""" + + +def source_value(value: str | None) -> str | Path: + """Unset/empty means the pin, never the adapter's historical default.""" + if value is None or not value.strip(): + return DOWNLOAD_URL + if any(c in value for c in "\r\n\0"): + raise AssetError("Swagger source contains invalid control characters.") + value = value.strip() + if value == DOWNLOAD_URL: + return value + try: + parts = urlsplit(value) + if (parts.scheme != "file" or parts.netloc not in ("", "localhost") + or parts.query or parts.fragment): + raise ValueError() + # url2pathname decodes percent escapes (including spaces) on this platform. + path = Path(url2pathname(parts.path)) + if not path.is_absolute() or any(c in str(path) for c in "\r\n\0"): + raise ValueError() + return path + except (ValueError, OSError): + raise AssetError("Swagger source must be the pinned HTTPS URL or an absolute file:// URL.") from None + + +def expected_digest(value: str | None) -> str | None: + if value is None or not value.strip(): + return None + value = value.strip().lower() + if not re.fullmatch(r"[0-9a-f]{64}", value): + raise AssetError("SWAGGER_UI_ARCHIVE_SHA256 must contain exactly 64 hexadecimal digits.") + return value + + +class HttpsRedirects(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + target = urlsplit(newurl) + if (target.scheme != "https" or target.hostname not in ("github.com", "codeload.github.com") + or target.username or target.password or target.port not in (None, 443)): + raise AssetError("Swagger download refused an unexpected redirect.") + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +def download(destination: Path) -> None: + """TLS verification uses Python's default trust store; never executes a shell.""" + started = time.monotonic() + opener = build_opener(HttpsRedirects()) + request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build/0.24.5"}) + with opener.open(request, timeout=30) as response, destination.open("wb") as output: + length = response.headers.get("Content-Length") + if length and int(length) > MAX_ARCHIVE: + raise AssetError("Swagger archive exceeds the 64 MiB limit.") + received = 0 + while True: + block = response.read(128 * 1024) + if not block: + break + received += len(block) + if received > MAX_ARCHIVE or time.monotonic() - started > 180: + raise AssetError("Swagger download exceeded the size or time limit.") + output.write(block) + + +def read_assets(path: Path, checksum: str | None) -> tuple[dict[str, bytes], str]: + if not path.is_file() or not 0 < path.stat().st_size <= MAX_ARCHIVE: + raise AssetError("Swagger archive must be a nonempty regular file of at most 64 MiB.") + with path.open("rb") as stream: + snapshot = stream.read(MAX_ARCHIVE + 1) + if not 0 < len(snapshot) <= MAX_ARCHIVE: + raise AssetError("Swagger archive exceeds the 64 MiB limit.") + # Parse exactly the same bounded bytes that were hashed, not a reopened file + # that could have changed between verification and ZIP member reads. + digest = hashlib.sha256(snapshot).hexdigest() + if checksum and digest != checksum: + raise AssetError("Swagger archive SHA-256 mismatch; no assets were published.") + with zipfile.ZipFile(io.BytesIO(snapshot)) as archive: + names = archive.namelist() + if len(names) > 20000: + raise AssetError("Swagger archive contains too many entries.") + + def member(relative: str, limit: int, *, required: bool = True) -> bytes: + name = PREFIX + relative + count = names.count(name) + if count == 0 and not required: + return b"" + if count != 1: + raise AssetError(f"Swagger archive must contain exactly one {name}.") + info = archive.getinfo(name) + mode = (info.external_attr >> 16) & 0o170000 + if info.is_dir() or mode not in (0, 0o100000) or not 0 < info.file_size <= limit: + raise AssetError(f"Swagger archive member is not a bounded regular file: {name}.") + # Bounded read as well as metadata validation; CRC checked by ZipFile. + with archive.open(info) as stream: + data = stream.read(limit + 1) + if not 0 < len(data) <= limit or len(data) != info.file_size: + raise AssetError(f"Swagger archive member has an invalid size: {name}.") + data.decode("utf-8") + return data + + package = json.loads(member("package.json", 256 * 1024)) + if not isinstance(package, dict) or package.get("name") != "swagger-ui" or package.get("version") != VERSION: + raise AssetError(f"Swagger archive must be swagger-ui {VERSION}; older versions are not a fallback.") + assets = {name: member(source, limit) for name, (source, limit) in ASSETS.items()} + licenses = [] + for name in LICENSE_MEMBERS: + text = member(name, 512 * 1024, required=name == "LICENSE") + if text: + licenses.append(name.encode() + b"\n" + b"-" * 40 + b"\n" + text) + assets["licenses.txt"] = b"Swagger UI " + VERSION.encode() + b"\n\n" + b"\n\n".join(licenses) + return assets, digest + + +def atomic_write(path: Path, data: bytes) -> None: + if path.is_file() and path.read_bytes() == data: + return # Preserve mtime for Cargo's rerun-if-changed tracking. + fd, name = tempfile.mkstemp(prefix=".swagger-", dir=path.parent) + temporary = Path(name) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(data) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def prepare(out_dir: Path, source: str | Path, checksum: str | None = None, + *, offline: bool = False) -> list[Path]: + out_dir.mkdir(parents=True, exist_ok=True) + cache = out_dir / f"swagger-ui-{VERSION}.zip" + if isinstance(source, Path): + archive = source + assets, digest = read_assets(archive, checksum) + elif source == DOWNLOAD_URL: + archive = cache + if cache.exists(): + assets, digest = read_assets(cache, checksum) + else: + if offline: + raise AssetError("Swagger assets are not cached and CARGO_NET_OFFLINE is set. " + HELP) + fd, name = tempfile.mkstemp(prefix=".swagger-download-", suffix=".zip", dir=out_dir) + os.close(fd) + temporary = Path(name) + try: + download(temporary) + assets, digest = read_assets(temporary, checksum) + os.replace(temporary, cache) # Cache only an already-validated archive. + finally: + temporary.unlink(missing_ok=True) + else: + raise AssetError("Unrecognized Swagger source.") + destination = out_dir / "swagger-ui" + destination.mkdir(exist_ok=True) + outputs = [] + for name, data in assets.items(): + target = destination / name + atomic_write(target, data) + outputs.append(target) + receipt = { + "version": VERSION, "source": str(source), "archive_sha256": digest, + "assets_sha256": {key: hashlib.sha256(data).hexdigest() for key, data in assets.items()}, + } + receipt_path = destination / "source.json" + atomic_write(receipt_path, (json.dumps(receipt, indent=2, sort_keys=True) + "\n").encode()) + # Watch both a supplied symlink and its resolved target, plus cached/generated files. + inputs = [archive.absolute(), archive.resolve(), *outputs, receipt_path] + return list(dict.fromkeys(inputs)) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out-dir", required=True, type=Path) + args = parser.parse_args() + if sys.version_info < (3, 10): + parser.error("Python 3.10+ is required to prepare the embedded Swagger UI.") + try: + source = source_value(os.environ.get("SWAGGER_UI_DOWNLOAD_URL")) + checksum = expected_digest(os.environ.get("SWAGGER_UI_ARCHIVE_SHA256")) + offline = os.environ.get("CARGO_NET_OFFLINE", "").lower() in ("1", "true") + paths = prepare(args.out_dir.resolve(), source, checksum, offline=offline) + for path in paths: + # These values are Cargo directives, so never allow extra output lines. + if any(c in str(path) for c in "\r\n\0"): + raise AssetError("Build input/output path contains an invalid control character.") + print(f"cargo:rerun-if-changed={path}") + except (OSError, ValueError, RuntimeError, zipfile.BadZipFile, zlib.error) as error: + print(f"Swagger UI asset preparation failed: {error}\n{HELP}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_compression.py b/scripts/test_compression.py new file mode 100755 index 0000000..c653290 --- /dev/null +++ b/scripts/test_compression.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 + +import argparse +import gzip +import hashlib +import io +import time +import zlib + +import brotli +import requests +import zstandard + + +TESTS = { + "identity": "identity", + "gzip": "gzip", + "deflate": "deflate", + "brotli": "br", + "zstd": "zstd", + "all": "zstd, br, gzip, deflate", +} + + +def decompress(data, content_encoding): + encodings = [ + e.strip().lower() + for e in content_encoding.split(",") + if e.strip() and e.strip().lower() != "identity" + ] + + # Content-Encoding usuwa się w odwrotnej kolejności. + for encoding in reversed(encodings): + if encoding == "gzip": + data = gzip.decompress(data) + + elif encoding == "deflate": + try: + data = zlib.decompress(data) + except zlib.error: + # Niektóre serwery wysyłają raw DEFLATE. + data = zlib.decompress(data, -zlib.MAX_WBITS) + + elif encoding == "br": + data = brotli.decompress(data) + + elif encoding == "zstd": + dctx = zstandard.ZstdDecompressor() + with dctx.stream_reader(io.BytesIO(data)) as reader: + data = reader.read() + + else: + raise RuntimeError(f"Nieznany Content-Encoding: {encoding}") + + return data + + +def human_size(size): + if size < 1024: + return f"{size} B" + if size < 1024 * 1024: + return f"{size / 1024:.1f} KB" + return f"{size / 1024 / 1024:.2f} MB" + + +def request_test(url, accept_encoding, verify_ssl=True, timeout=15): + headers = { + "Accept-Encoding": accept_encoding, + "User-Agent": "compression-tester/1.0", + "Cache-Control": "no-cache", + } + + start = time.perf_counter() + + r = requests.get( + url, + headers=headers, + timeout=timeout, + verify=verify_ssl, + stream=True, + allow_redirects=True, + ) + + r.raw.decode_content = False + raw = r.raw.read() + + elapsed = time.perf_counter() - start + + content_encoding = r.headers.get("Content-Encoding", "identity") + + try: + decoded = decompress(raw, content_encoding) + error = None + except Exception as exc: + decoded = b"" + error = str(exc) + + return { + "status": r.status_code, + "requested": accept_encoding, + "received": content_encoding, + "raw_size": len(raw), + "decoded_size": len(decoded), + "time": elapsed, + "decoded": decoded, + "error": error, + "headers": r.headers, + "final_url": r.url, + } + + +def main(): + parser = argparse.ArgumentParser( + description="Tester HTTP Content-Encoding: zstd/br/gzip/deflate" + ) + + parser.add_argument("url", help="URL do przetestowania") + parser.add_argument( + "--insecure", + action="store_true", + help="Wyłącz weryfikację certyfikatu SSL", + ) + parser.add_argument( + "--timeout", + type=int, + default=15, + help="Timeout HTTP w sekundach", + ) + + args = parser.parse_args() + + if args.insecure: + requests.packages.urllib3.disable_warnings() + + print() + print(f"URL: {args.url}") + print("=" * 100) + + results = {} + + for name, accept_encoding in TESTS.items(): + try: + result = request_test( + args.url, + accept_encoding, + verify_ssl=not args.insecure, + timeout=args.timeout, + ) + results[name] = result + except Exception as exc: + results[name] = { + "error": f"REQUEST ERROR: {exc}" + } + + identity_body = None + + if "identity" in results: + identity_body = results["identity"].get("decoded") + + print( + f"{'TEST':<10}" + f"{'HTTP':<7}" + f"{'REQUEST':<27}" + f"{'RESPONSE':<12}" + f"{'RAW':<12}" + f"{'DECODED':<12}" + f"{'RATIO':<10}" + f"{'TIME':<10}" + f"{'BODY'}" + ) + + print("-" * 120) + + failed = False + + for name, result in results.items(): + if result.get("error"): + print(f"{name:<10} ERROR: {result['error']}") + failed = True + continue + + raw_size = result["raw_size"] + decoded_size = result["decoded_size"] + + if decoded_size: + ratio = raw_size / decoded_size + ratio_text = f"{ratio * 100:.1f}%" + else: + ratio_text = "-" + + if identity_body is not None and result["decoded"]: + if result["decoded"] == identity_body: + body_status = "OK" + else: + body_status = "DIFF" + else: + body_status = "-" + + print( + f"{name:<10}" + f"{result['status']:<7}" + f"{result['requested']:<27}" + f"{result['received']:<12}" + f"{human_size(raw_size):<12}" + f"{human_size(decoded_size):<12}" + f"{ratio_text:<10}" + f"{result['time']:.3f}s " + f"{body_status}" + ) + + print() + print("=" * 100) + print("Szczegóły nagłówków:") + print() + + for name, result in results.items(): + if result.get("error"): + continue + + headers = result["headers"] + + print(f"[{name}]") + print(f" URL finalny: {result['final_url']}") + print(f" HTTP status: {result['status']}") + print(f" Content-Encoding: {headers.get('Content-Encoding', '-')}") + print(f" Content-Type: {headers.get('Content-Type', '-')}") + print(f" Content-Length: {headers.get('Content-Length', '-')}") + print(f" Vary: {headers.get('Vary', '-')}") + print(f" Cache-Control: {headers.get('Cache-Control', '-')}") + print(f" ETag: {headers.get('ETag', '-')}") + print(f" Server: {headers.get('Server', '-')}") + print() + + print("SHA256 po dekompresji:") + print() + + for name, result in results.items(): + body = result.get("decoded") + + if body: + sha = hashlib.sha256(body).hexdigest() + print(f"{name:<10} {sha}") + + if failed: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/upgrade.py b/scripts/upgrade.py new file mode 100755 index 0000000..a4ea8f4 --- /dev/null +++ b/scripts/upgrade.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Replace a stopped RustProxy source tree, preserving old code, data and .env.""" +from __future__ import annotations +import argparse +import json +import os +from pathlib import Path +import shutil +import sys +import uuid + +MANAGED = ["build.rs", "langs", "deploy", "debian", "grafana", "make_zip.py", "src", "tests", "scripts", "nagios", "docker-agent", "migrations", "examples", "docs", ".github", ".cargo", + "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "dev.sh", "upgrade.sh", "install.sh", "update.sh", + "README.md", "ROADMAP.md", "CHANGELOG.md", "AGENTS.md", "SHA256SUMS", + ".env.example", ".gitignore"] + + +def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None = None) -> Path: + source = source.resolve() + if target.is_symlink(): + raise ValueError("Target must not be a symlink.") + target = target.resolve() + if not stopped: + raise ValueError("Stop RustProxy and pass --stopped. Source replacement is not a live-update operation.") + if target == source or target in source.parents or source in target.parents: + raise ValueError("Source and destination must be separate, non-nested directories.") + manifest = target / "Cargo.toml" + if not manifest.is_file() or 'name = "rust-proxy"' not in manifest.read_text(encoding="utf-8"): + raise ValueError("Target is not an existing RustProxy source directory.") + locks = [] + try: + if os.name == "posix": + import fcntl + for directory in {target / "data", data_dir or target / "data"}: + path = directory / "process.lock" + if path.exists(): + if path.is_symlink(): + raise ValueError("Refusing a symlinked process lock.") + stream = path.open("r+") + try: + fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + stream.close() + raise ValueError("A RustProxy server still owns the data directory. Stop it first.") from None + locks.append(stream) + for name in MANAGED: + if (target / name).is_symlink(): + raise ValueError(f"Managed target {name} is a symlink; refusing to replace it.") + backup = target / ".upgrade-backup" / ("before-0.24.5-" + uuid.uuid4().hex[:12]) + backup.mkdir(parents=True, mode=0o700) + installed: list[str] = [] + moved: list[str] = [] + try: + for name in MANAGED: + old, new = target / name, source / name + if old.exists(): + old.rename(backup / name) + moved.append(name) + if new.is_dir(): + installed.append(name) + shutil.copytree(new, old) + if name == "langs" and (backup / name).is_dir(): + # Preserve additional site language packs; bundled packs are + # updated, with their previous bytes retained in the backup. + for pack in (backup / name).glob("*.json"): + if pack.is_symlink(): + raise ValueError("Language packs must not be symlinks.") + if not (old / pack.name).exists(): + shutil.copy2(pack, old / pack.name) + elif new.is_file(): + installed.append(name) + shutil.copy2(new, old) + # Old renewal observations were diagnostic scheduler state only; + # certificates, accounts and renewal profiles live elsewhere. 0.19.1 + # has no runtime JSON importer. Validate the obsolete sidecar now, + # write the completed source-upgrade manifest, then remove the file + # as the final fallible operation. SQLite observations are rebuilt by + # the normal renewal scheduler. No second legacy copy is retained. + runtime_data = (data_dir or (target / "data")).resolve() + certs_dir = runtime_data / "certs" + if certs_dir.is_symlink(): + raise ValueError("Certificate data directory must not be a symlink during legacy cleanup.") + legacy = certs_dir / "renewals.json" + remove_legacy = legacy.exists() + if remove_legacy and (legacy.is_symlink() or not legacy.is_file()): + raise ValueError("Obsolete renewal observation sidecar must be a regular file.") + (backup / "upgrade.json").write_text(json.dumps({"from_directory":str(target), "to_version":"0.24.5", "managed_paths":MANAGED, "removed_obsolete_runtime_state": remove_legacy}, indent=2)+"\n", encoding="utf-8") + if remove_legacy: + legacy.unlink() + except BaseException: + for name in reversed(installed): + path = target / name + if path.is_dir(): + shutil.rmtree(path) + elif path.exists(): + path.unlink() + for name in moved: + (backup / name).rename(target / name) + raise + return backup + finally: + for stream in locks: + stream.close() + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("target", type=Path) + parser.add_argument("--stopped", action="store_true") + parser.add_argument("--data-dir", type=Path, help="also check the process lock when data lives outside target/data") + args = parser.parse_args() + os.umask(0o077) + data_dir = args.data_dir or (Path(os.environ["RUST_PROXY_DATA_DIR"]) if os.environ.get("RUST_PROXY_DATA_DIR") else None) + try: + backup = upgrade(Path(__file__).resolve().parents[1], args.target, stopped=args.stopped, data_dir=data_dir) + except (OSError, ValueError) as error: + parser.exit(2, f"Upgrade refused or rolled back: {error}\n") + print(f"Installed RustProxy 0.24.5. Previous managed source files: {backup}") + print("Preserved data, .env, .git, target and other unmanaged files. No database migration was run.") + print(f"Next: cd {args.target}; ./dev.sh test; ./dev.sh") + + +if __name__ == "__main__": + main() diff --git a/src/acl.rs b/src/acl.rs new file mode 100644 index 0000000..22178aa --- /dev/null +++ b/src/acl.rs @@ -0,0 +1,240 @@ +//! Explicit network ACLs. Peer IP is default; only the central explicit trusted-proxy policy may resolve a forwarded client IP. +use anyhow::{ensure, Context, Result}; +use ipnet::IpNet; +use serde::{Deserialize, Serialize}; +use std::net::IpAddr; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Action { + Allow, + Deny, +} +fn deny() -> Action { + Action::Deny +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Rule { + pub network: String, + pub action: Action, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Acl { + #[serde(default = "deny")] + pub default: Action, + #[serde(default)] + pub rules: Vec, +} +#[derive(Debug, Clone)] +pub struct CompiledAcl { + default: Action, + rules: Vec<(IpNet, Action)>, +} +impl Acl { + pub fn compile(&self) -> Result { + ensure!(self.rules.len() <= 128, "ACL supports at most 128 rules"); + let mut rules = Vec::with_capacity(self.rules.len()); + for rule in &self.rules { + ensure!(rule.network.len() <= 64, "ACL network is too long"); + let network: IpNet = rule + .network + .parse() + .context("ACL requires an IPv4/IPv6 CIDR")?; + if let IpNet::V6(net) = network { + ensure!( + net.addr().to_ipv4_mapped().is_none(), + "use IPv4 CIDR instead of IPv4-mapped IPv6 in ACL rules" + ); + } + rules.push((network, rule.action)); + } + Ok(CompiledAcl { + default: self.default, + rules, + }) + } +} +impl CompiledAcl { + /// Explicit deny beats all matching allow rules. Otherwise, allow or the default applies. + /// Host policy and route policy are both evaluated, so route allow cannot override host deny. + pub fn allows(&self, peer: IpAddr) -> bool { + let peer = match peer { + IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(peer), + _ => peer, + }; + let mut allowed = false; + for (network, action) in &self.rules { + if network.contains(&peer) { + if *action == Action::Deny { + return false; + } + allowed = true; + } + } + allowed || self.default == Action::Allow + } +} +/// Validate the already-decoded routing representation used internally after +/// matching/rewriting. Encoded spaces from a valid request are literal spaces +/// at this stage; reserved separators, percent and dot-segment ambiguity stay +/// forbidden. +pub fn validate_decoded_routing_path(path: &str) -> Result<()> { + ensure!( + path.starts_with('/') && path.len() <= 8192, + "invalid path length or form" + ); + ensure!( + !path + .bytes() + .any(|b| b < 32 || b == 127 || matches!(b, b'\\' | b'%' | b'?' | b'#' | b';')), + "invalid decoded path character" + ); + ensure!( + !path.split('/').any(|part| part == "." || part == ".."), + "ambiguous path segments" + ); + Ok(()) +} + +/// Routing/ACL use one decoded representation; upstream still receives the original path. +/// Reject encoded separators, percent (double decoding), controls, ambiguous dot segments, +/// matrix parameters and malformed UTF-8. Repeated literal separators are +/// collapsed for routing/ACL decisions (matching nginx's default merge_slashes +/// behavior), while the upstream request still retains its original target. +/// No silent fallback to a less-restrictive route. +pub fn routing_path(raw: &str) -> Result { + ensure!( + raw.starts_with('/') && raw.len() <= 8192, + "invalid path length or form" + ); + let mut out = Vec::with_capacity(raw.len()); + let bytes = raw.as_bytes(); + let mut index = 0; + while index < bytes.len() { + let b = bytes[index]; + if b == b'%' { + ensure!(index + 2 < bytes.len(), "truncated path escape"); + let digit = |value: u8| (value as char).to_digit(16).map(|v| v as u8); + let high = digit(bytes[index + 1]).context("invalid path escape")?; + let low = digit(bytes[index + 2]).context("invalid path escape")?; + let decoded = (high << 4) | low; + ensure!( + decoded >= 32 + && decoded != 127 + && !matches!(decoded, b'/' | b'\\' | b'%' | b'?' | b'#' | b';'), + "ambiguous encoded path character" + ); + out.push(decoded); + index += 3; + } else { + ensure!( + b > 32 && b != 127 && !matches!(b, b'\\' | b'?' | b'#' | b';'), + "invalid path character" + ); + if b != b'/' || out.last().copied() != Some(b'/') { + out.push(b); + } + index += 1; + } + } + let path = String::from_utf8(out).context("invalid path UTF-8")?; + ensure!( + !path.split('/').any(|part| part == "." || part == ".."), + "ambiguous path segments" + ); + Ok(path) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn deny_overrides_allow_and_cidr_boundaries_hold() { + let acl = Acl { + default: Action::Deny, + rules: vec![ + Rule { + network: "10.0.0.0/8".into(), + action: Action::Allow, + }, + Rule { + network: "10.2.0.0/16".into(), + action: Action::Deny, + }, + ], + } + .compile() + .unwrap(); + assert!(acl.allows("10.1.0.1".parse().unwrap())); + assert!(!acl.allows("10.2.3.4".parse().unwrap())); + assert!(!acl.allows("11.0.0.1".parse().unwrap())); + assert!(acl.allows("::ffff:10.1.0.1".parse().unwrap())); + } + #[test] + fn ipv6_and_invalid_networks() { + let acl = Acl { + default: Action::Deny, + rules: vec![Rule { + network: "fd00::/8".into(), + action: Action::Allow, + }], + } + .compile() + .unwrap(); + assert!(acl.allows("fd00::1".parse().unwrap())); + assert!(!acl.allows("fe00::1".parse().unwrap())); + let bad = Acl { + default: Action::Allow, + rules: vec![Rule { + network: "not-cidr".into(), + action: Action::Allow, + }], + }; + assert!(bad.compile().is_err()); + } + #[test] + fn repeated_slashes_are_normalized_for_routing() { + assert_eq!( + routing_path("/static/js//events.js").unwrap(), + "/static/js/events.js" + ); + assert_eq!(routing_path("//admin///users").unwrap(), "/admin/users"); + } + #[test] + fn decoded_rewrite_paths_keep_encoded_spaces_but_reject_ambiguity() { + assert!(validate_decoded_routing_path("/Ubuntu 14.04 LTS/").is_ok()); + for path in ["/a/../b", "/a%2fb", "/a;b", "/a?b", "/a\\b"] { + assert!(validate_decoded_routing_path(path).is_err(), "{path}"); + } + } + #[test] + fn paths_cannot_escape_a_restricted_prefix() { + assert_eq!(routing_path("/%61dmin/users").unwrap(), "/admin/users"); + assert_eq!(routing_path("/caf%C3%A9").unwrap(), "/caf\u{e9}"); + assert_eq!( + routing_path("/Ubuntu%2014.04%20LTS/").unwrap(), + "/Ubuntu 14.04 LTS/" + ); + assert_eq!( + routing_path("/static/js//events.js").unwrap(), + "/static/js/events.js" + ); + assert_eq!(routing_path("//admin///users").unwrap(), "/admin/users"); + for path in [ + "/api/../admin", + "/api/%2e%2e/admin", + "/%2fadmin", + "/%5cadmin", + "/%2561dmin", + "/%00", + "/%ff", + "/%q0", + "/a\\b", + "/admin;session=x", + "/admin%3buser=x", + ] { + assert!(routing_path(path).is_err(), "{path}"); + } + } +} diff --git a/src/acme.rs b/src/acme.rs new file mode 100644 index 0000000..e474ea9 --- /dev/null +++ b/src/acme.rs @@ -0,0 +1,1172 @@ +//! Bounded control-plane adapter for Certbot HTTP-01/DNS-01 providers. Never on traffic paths. +//! Provider credentials enter via stdin, not argv, logs, SQLite or API responses. +use crate::{ + runtime::{ApiError, App}, + tls::Certificate, +}; +use http::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{ + path::PathBuf, + process::Stdio, + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::{io::AsyncWriteExt, process::Command, sync::Semaphore}; +use zeroize::{Zeroize, Zeroizing}; + +#[derive(Debug, Clone, Serialize)] +pub struct Job { + pub id: String, + pub certificate_id: String, + pub status: &'static str, + pub kind: &'static str, + pub provider: String, + pub domains: Vec, + pub environment: String, + pub message: String, + pub log_directory: String, + pub certificate: Option, +} + +pub(crate) fn certbot_log_directory(directory: &std::path::Path, provider: &str, environment: &str, certificate_id: &str) -> String { + let suffix = if provider == "rfc2136" { String::new() } else { format!("-{provider}") }; + directory + .join("acme") + .join(format!("{environment}-{certificate_id}{suffix}")) + .join("logs") + .to_string_lossy() + .into_owned() +} +pub struct Manager { + slot: Arc, + job: Mutex>, + renewals: Mutex>, +} +impl Manager { + pub fn new() -> Arc { + Arc::new(Self { + slot: Arc::new(Semaphore::new(1)), + job: Mutex::new(None), + renewals: Mutex::new(Default::default()), + }) + } + pub fn view(&self) -> Option { + self.job.lock().unwrap_or_else(|e| e.into_inner()).clone() + } + pub async fn start( + state: Arc, + mut request: Value, + actor: String, + ) -> Result { + let directory = state.policy.cert_dir.clone().ok_or_else(|| { + ApiError(StatusCode::CONFLICT, "acme.no_certificate_directory".into()) + })?; + let id = request + .get("certificate_id") + .and_then(Value::as_str) + .filter(|v| crate::config::valid_id(v)) + .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_id".into()))? + .to_owned(); + let environment = request + .get("environment") + .and_then(Value::as_str) + .unwrap_or("staging") + .to_owned(); + if !matches!(environment.as_str(), "staging" | "production") + || request.get("agree_tos") != Some(&Value::Bool(true)) + || (environment == "production" + && request.get("confirm_production") != Some(&Value::Bool(true))) + { + return Err(ApiError( + StatusCode::BAD_REQUEST, + "acme.consent_required".into(), + )); + } + let provider = request + .get("provider") + .and_then(Value::as_str) + .unwrap_or("rfc2136") + .to_owned(); + if !matches!( + provider.as_str(), + "http01" | "cloudflare" | "ovh" | "rfc2136" + ) { + return Err(ApiError( + StatusCode::BAD_REQUEST, + "acme.invalid_provider".into(), + )); + } + let names = request + .get("domains") + .and_then(Value::as_array) + .filter(|names| !names.is_empty() && names.len() <= 16) + .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_domains".into()))?; + let mut domains = Vec::new(); + for name in names { + let raw = name + .as_str() + .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_domain".into()))?; + if provider == "http01" && raw.starts_with("*.") { + return Err(ApiError( + StatusCode::BAD_REQUEST, + "acme.http01_no_wildcard".into(), + )); + } + let normalized = + crate::config::normalize_host(raw.strip_prefix("*.").unwrap_or(raw)) + .map_err(|_| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_domain".into()))?; + if !normalized.contains('.') || normalized.parse::().is_ok() { + return Err(ApiError( + StatusCode::BAD_REQUEST, + "acme.dns_names_only".into(), + )); + } + domains.push(normalized); + } + if provider == "http01" && !state.active_listeners.iter().any(|l| !l.tls) { + return Err(ApiError( + StatusCode::CONFLICT, + "acme.http01_listener_required".into(), + )); + } + let slot = state + .acme + .slot + .clone() + .try_acquire_owned() + .map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "acme.job_busy".into()))?; + let raw = Zeroizing::new(serde_json::to_string(&request).map_err(crate::auth::internal)?); + for key in [ + "key_secret", + "api_token", + "application_key", + "application_secret", + "consumer_key", + ] { + if let Some(Value::String(secret)) = request.get_mut(key) { + secret.zeroize(); + } + } + if raw.len() > 16384 { + return Err(ApiError( + StatusCode::PAYLOAD_TOO_LARGE, + "acme.request_too_large".into(), + )); + } + let log_directory = certbot_log_directory(&directory, &provider, &environment, &id); + let job = Job { + id: format!("{:032x}", rand::random::()), + certificate_id: id, + status: "running", + kind: "issue", + provider, + domains, + environment, + message: "acme.working".into(), + log_directory, + certificate: None, + }; + state + .store + .audit(&actor, "acme.issue", &job.id) + .await + .map_err(crate::auth::internal)?; + *state.acme.job.lock().unwrap_or_else(|e| e.into_inner()) = Some(job.clone()); + let task_job = job.clone(); + let tasks = state.tunnels.clone(); + tasks.spawn(async move { + let _slot = slot; + let result = execute(&state, directory, raw).await; + let mut finished = task_job; + match result { + Ok(cert) => { + finished.status = "ready"; + finished.message = "acme.ready_to_import".into(); + finished.certificate = Some(cert); + } + Err(message) => { + finished.status = "failed"; + finished.message = message; + } + } + // Never write provider stderr, request JSON or TSIG material to audit. + let _ = state + .store + .audit( + &actor, + "acme.result", + &format!("{} {}", finished.id, finished.status), + ) + .await; + *state.acme.job.lock().unwrap_or_else(|e| e.into_inner()) = Some(finished); + }); + Ok(job) + } +} +#[derive(Deserialize)] +struct Output { + provider: String, + environment: String, + certificate_id: String, + domains: Vec, + cert_file: String, + key_file: String, +} + +/// Kill the entire private process group if execution is cancelled/abandoned. +/// Certbot inherits the group, so it cannot outlive a stopped RustProxy worker. +struct ProcessGroup(Option); +impl Drop for ProcessGroup { + fn drop(&mut self) { + #[cfg(unix)] + if let Some(id) = self.0 { + // SAFETY: this is the process group created solely for this child. + unsafe { + libc::kill(-(id as i32), libc::SIGKILL); + } + } + } +} +async fn worker(state: &App, directory: PathBuf, raw: Zeroizing) -> Result { + let mut command = Command::new("python3"); + command + .arg("-I") + .arg("-c") + .arg(include_str!("../scripts/acme_provider.py")) + .arg("--cert-dir") + .arg(&directory) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.as_std_mut().process_group(0); + } + let mut child = command + .spawn() + .map_err(|_| "acme.python_not_available".to_owned())?; + let mut group = ProcessGroup(child.id()); + let stdin = child + .stdin + .take() + .ok_or_else(|| "acme.worker_io".to_owned())?; + let work = async move { + let mut stdin = stdin; + stdin + .write_all(raw.as_bytes()) + .await + .map_err(|_| "acme.worker_io".to_owned())?; + stdin + .shutdown() + .await + .map_err(|_| "acme.worker_io".to_owned())?; + drop(stdin); + drop(raw); + child + .wait_with_output() + .await + .map_err(|_| "acme.worker_io".to_owned()) + }; + let output = tokio::select! { + _ = state.shutdown.cancelled() => return Err("acme.cancelled".into()), + result = tokio::time::timeout(Duration::from_secs(2160), work) => result.map_err(|_| "acme.provider_timeout".to_owned())??, + }; + group.0 = None; // the process has been reaped, never kill a reused PID + if output.stdout.len() > 16384 { + return Err("acme.invalid_worker_response".into()); + } + let value: Value = serde_json::from_slice(&output.stdout) + .map_err(|_| "acme.invalid_worker_response".to_owned())?; + if !output.status.success() { + let reason = value.get("error").and_then(Value::as_str).filter(|s| { + s.starts_with("acme.") + && s.len() < 96 + && s.bytes() + .all(|c| c.is_ascii_lowercase() || c == b'_' || c == b'.') + }); + return Err(reason.unwrap_or("acme.worker_failed").to_owned()); + } + Ok(value) +} +async fn execute( + state: &App, + directory: PathBuf, + raw: Zeroizing, +) -> Result { + let value = worker(state, directory.clone(), raw).await?; + let data: Output = + serde_json::from_value(value).map_err(|_| "acme.invalid_worker_response".to_owned())?; + tokio::task::spawn_blocking(move || { + let result = crate::tls::validate_acme( + &directory, + data.certificate_id, + data.cert_file.clone(), + data.key_file.clone(), + &data.domains, + ) + .map(|mut cert| { + cert.issuer = Some(crate::tls::Issuer { + provider: data.provider, + environment: data.environment, + auto_renew: true, + }); + cert + }); + if result.is_err() { + for name in [data.cert_file, data.key_file] { + if name.starts_with("acme-") && crate::tls::safe_name(&name).is_ok() { + let _ = std::fs::remove_file(directory.join(name)); + } + } + } + result.map_err(|_| "acme.certificate_validation_failed".to_owned()) + }) + .await + .map_err(|_| "acme.validation_worker_failed".to_owned())? +} + +/// A reserved validation path: only an active HTTP-01 job and its exact DNS +/// names can read bounded tokens. No origin, cache, auth or custom pages here. +pub async fn challenge( + axum::extract::State(state): axum::extract::State>, + request: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + use axum::{body::Body, response::IntoResponse}; + use tokio::io::AsyncReadExt; + const PREFIX: &str = "/.well-known/acme-challenge/"; + let Some(token) = request.uri().path().strip_prefix(PREFIX) else { + return next.run(request).await; + }; + let denied = || { + ( + StatusCode::NOT_FOUND, + [("cache-control", "no-store")], + "ACME challenge not found\n", + ) + .into_response() + }; + if token.is_empty() + || token.len() > 256 + || !token + .bytes() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-')) + || request.uri().query().is_some() + || request + .extensions() + .get::() + .is_some_and(|t| t.tls) + { + return denied(); + } + let host = request + .headers() + .get(http::header::HOST) + .and_then(|h| h.to_str().ok()) + .and_then(|h| crate::config::request_host(h).ok()); + let permitted = state.acme.view().is_some_and(|job| { + job.status == "running" + && job.provider == "http01" + && host.is_some_and(|h| job.domains.contains(&h)) + }); + if !permitted { + return denied(); + } + if request.method() != http::Method::GET && request.method() != http::Method::HEAD { + return ( + StatusCode::METHOD_NOT_ALLOWED, + [("allow", "GET, HEAD"), ("cache-control", "no-store")], + ) + .into_response(); + } + let Some(directory) = &state.policy.cert_dir else { + return denied(); + }; + let path = directory + .join("acme-http01/.well-known/acme-challenge") + .join(token); + let mut options = tokio::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + options.custom_flags(libc::O_NOFOLLOW); + } + let Ok(file) = options.open(path).await else { + return denied(); + }; + let Ok(metadata) = file.metadata().await else { + return denied(); + }; + if !metadata.is_file() || metadata.len() > 4096 { + return denied(); + } + let mut data = Vec::new(); + if file.take(4097).read_to_end(&mut data).await.is_err() + || data.len() > 4096 + || data.is_empty() + || !data + .iter() + .all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'_' | b'-' | b'.')) + { + return denied(); + } + let length = data.len().to_string(); + let mut response = ( + [ + ("content-type", "application/octet-stream"), + ("cache-control", "no-store"), + ("x-content-type-options", "nosniff"), + ], + data, + ) + .into_response(); + if let Ok(value) = length.parse() { + response.headers_mut().insert("content-length", value); + } + if request.method() == http::Method::HEAD { + *response.body_mut() = Body::empty(); + } + response +} + +#[cfg(test)] +mod challenge_tests { + use super::*; + use axum::{body::Body, Extension}; + use http::Request; + use http_body_util::BodyExt; + use tower::ServiceExt; + async fn fixture() -> (Arc, tempfile::TempDir) { + let directory = tempfile::tempdir().unwrap(); + let certs = directory.path().join("certs"); + let root = certs.join("acme-http01/.well-known/acme-challenge"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("sample_token"), b"sample_token.thumbprint").unwrap(); + let config = crate::config::Config::default(); + let policy = crate::config::ValidationPolicy { + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + tls_addr: None, + cert_dir: Some(certs), + data_dir: None, + }; + let store = crate::db::Store::open(&directory.path().join("db"), &config) + .await + .unwrap(); + let auth = crate::auth::Auth::new(store.clone(), directory.path().to_owned()) + .await + .unwrap(); + let snapshot = crate::config::compile(config, 0, &policy).unwrap(); + let app = App::new( + store, + snapshot, + policy, + auth, + 8, + Duration::from_secs(2), + Duration::from_secs(2), + Duration::from_secs(30), + ); + *app.acme.job.lock().unwrap() = Some(Job { + id: "test".into(), + certificate_id: "test".into(), + status: "running", + kind: "issue", + provider: "http01".into(), + domains: vec!["example.org".into()], + environment: "staging".into(), + message: "test".into(), + log_directory: "test/logs".into(), + certificate: None, + }); + (app, directory) + } + #[tokio::test] + async fn http01_serves_only_current_job_tokens_and_correct_head() { + let (app, _directory) = fixture().await; + let router = crate::proxy::router(app.clone()); + let req = |method, host, path| { + Request::builder() + .method(method) + .uri(path) + .header("host", host) + .body(Body::empty()) + .unwrap() + }; + let response = router + .clone() + .oneshot(req( + "GET", + "example.org", + "/.well-known/acme-challenge/sample_token", + )) + .await + .unwrap(); + assert_eq!(response.status(), 200); + assert_eq!(response.headers()["cache-control"], "no-store"); + assert_eq!( + response + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .as_ref(), + b"sample_token.thumbprint" + ); + let response = router + .clone() + .oneshot(req( + "HEAD", + "example.org", + "/.well-known/acme-challenge/sample_token", + )) + .await + .unwrap(); + assert_eq!(response.status(), 200); + assert_eq!(response.headers()["content-length"], "23"); + assert!(response + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .is_empty()); + for path in [ + "/.well-known/acme-challenge/../sample_token", + "/.well-known/acme-challenge/sample_token?x=1", + "/.well-known/acme-challenge/%2e%2e", + "/.well-known/acme-challenge/missing", + ] { + assert_eq!( + router + .clone() + .oneshot(req("GET", "example.org", path)) + .await + .unwrap() + .status(), + 404 + ); + } + assert_eq!( + router + .clone() + .oneshot(req( + "GET", + "unrelated.example.org", + "/.well-known/acme-challenge/sample_token" + )) + .await + .unwrap() + .status(), + 404 + ); + assert_eq!( + router + .clone() + .oneshot(req( + "POST", + "example.org", + "/.well-known/acme-challenge/sample_token" + )) + .await + .unwrap() + .status(), + 405 + ); + let tls = router + .clone() + .layer(Extension(crate::server::Transport { tls: true })); + assert_eq!( + tls.oneshot(req( + "GET", + "example.org", + "/.well-known/acme-challenge/sample_token" + )) + .await + .unwrap() + .status(), + 404 + ); + app.acme.job.lock().unwrap().as_mut().unwrap().status = "ready"; + assert_eq!( + router + .oneshot(req( + "GET", + "example.org", + "/.well-known/acme-challenge/sample_token" + )) + .await + .unwrap() + .status(), + 404 + ); + app.store.close().await; + } +} + +/// Public diagnostics contain no account/credential material. Desired renewal +/// permission lives with the certificate; observations never overwrite it. +#[derive(Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct RenewalState { + pub last_checked: i64, + pub next_check: i64, + pub last_renewed: Option, + pub not_after: Option, + pub failures: u32, + pub status: String, + pub message: String, +} +fn private_json(path: &std::path::Path, limit: u64) -> anyhow::Result { + use std::io::Read; + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let file = options.open(path)?; + let meta = file.metadata()?; + anyhow::ensure!( + meta.is_file() && meta.len() <= limit, + "invalid private metadata" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + anyhow::ensure!( + meta.permissions().mode() & 0o077 == 0, + "private metadata permissions" + ); + } + let mut bytes = Vec::new(); + file.take(limit + 1).read_to_end(&mut bytes)?; + anyhow::ensure!(bytes.len() as u64 <= limit, "private metadata too large"); + Ok(serde_json::from_slice(&bytes)?) +} +fn profile_domains(directory: &std::path::Path, cert: &Certificate) -> anyhow::Result> { + let issuer = cert + .issuer + .as_ref() + .ok_or_else(|| anyhow::anyhow!("no issuer"))?; + anyhow::ensure!( + crate::config::valid_id(&cert.id) + && matches!( + issuer.provider.as_str(), + "http01" | "rfc2136" | "cloudflare" | "ovh" + ) + && matches!(issuer.environment.as_str(), "production" | "staging"), + "invalid profile identity" + ); + let suffix = if issuer.provider == "rfc2136" { + String::new() + } else { + format!("-{}", issuer.provider) + }; + let profile = private_json( + &directory + .join("acme") + .join(format!("{}-{}{}", issuer.environment, cert.id, suffix)) + .join("profile.json"), + 16384, + )?; + anyhow::ensure!( + profile.get("certificate_id").and_then(Value::as_str) == Some(cert.id.as_str()) + && profile.get("provider").and_then(Value::as_str) == Some(issuer.provider.as_str()) + && profile.get("environment").and_then(Value::as_str) + == Some(issuer.environment.as_str()), + "profile identity mismatch" + ); + let names: Vec = serde_json::from_value(profile["domains"].clone())?; + anyhow::ensure!( + !names.is_empty() && names.len() <= 16, + "invalid profile names" + ); + for name in &names { + crate::config::normalize_host(name.strip_prefix("*.").unwrap_or(name))?; + } + Ok(names) +} +impl Manager { + pub fn renewal_view(&self) -> std::collections::BTreeMap { + self.renewals + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + } + pub fn restore_renewals(&self, values: std::collections::BTreeMap) { + let mut observations = self.renewals.lock().unwrap_or_else(|e| e.into_inner()); + *observations = values + .into_iter() + .filter(|(id, _)| crate::config::valid_id(id)) + .take(64) + .collect(); + } + async fn persist(&self, store: &crate::db::Store) { + let value = self.renewal_view(); + if let Err(error) = store.replace_certificate_renewals(&value).await { + tracing::warn!(%error, "certificate renewal observation could not be saved to SQLite"); + } + } + pub async fn renew( + state: Arc, + id: &str, + actor: String, + dry_run: bool, + automatic: bool, + ) -> Result { + let directory = state.policy.cert_dir.clone().ok_or_else(|| { + ApiError(StatusCode::CONFLICT, "acme.no_certificate_directory".into()) + })?; + let cert = state + .config + .load() + .envelope + .config + .tls + .certificates + .iter() + .find(|c| c.id == id) + .cloned() + .ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "acme.certificate_not_found".into()))?; + let issuer = cert + .issuer + .clone() + .ok_or_else(|| ApiError(StatusCode::CONFLICT, "acme.not_managed".into()))?; + if automatic && !issuer.auto_renew { + return Err(ApiError( + StatusCode::CONFLICT, + "acme.renewal_disabled".into(), + )); + } + let slot = state + .acme + .slot + .clone() + .try_acquire_owned() + .map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "acme.job_busy".into()))?; + let copy = cert.clone(); + let dir = directory.clone(); + let domains = tokio::task::spawn_blocking(move || profile_domains(&dir, ©)) + .await + .map_err(crate::auth::internal)? + .map_err(|_| ApiError(StatusCode::CONFLICT, "acme.renewal_profile_missing".into()))?; + if issuer.provider == "http01" && !state.active_listeners.iter().any(|l| !l.tls) { + return Err(ApiError( + StatusCode::CONFLICT, + "acme.http01_listener_required".into(), + )); + } + let job = Job { + id: format!("{:032x}", rand::random::()), + certificate_id: cert.id.clone(), + status: "running", + kind: if dry_run { "renewal-dry-run" } else { "renewal" }, + log_directory: certbot_log_directory(&directory, &issuer.provider, &issuer.environment, &cert.id), + provider: issuer.provider.clone(), + domains, + environment: issuer.environment.clone(), + message: "acme.renewing".into(), + certificate: None, + }; + state + .store + .audit( + &actor, + if dry_run { + "acme.renew_dry_run" + } else { + "acme.renew" + }, + &cert.id, + ) + .await + .map_err(crate::auth::internal)?; + *state.acme.job.lock().unwrap_or_else(|e| e.into_inner()) = Some(job.clone()); + let request = serde_json::json!({"action":"renew","certificate_id":cert.id,"provider":issuer.provider, + "environment":issuer.environment,"cert_file":cert.cert_file,"key_file":cert.key_file,"dry_run":dry_run}); + let raw = Zeroizing::new(request.to_string()); + let task_job = job.clone(); + let tasks = state.tunnels.clone(); + tasks.spawn(async move { + let _slot = slot; + let result = async { + let output = worker(&state, directory.clone(), raw).await?; + if !dry_run && output.get("changed") == Some(&Value::Bool(true)) { + deploy_renewal(&state, &cert, &output, automatic).await?; + } + Ok::<_, String>(output) + } + .await; + let mut finished = task_job; + let now = crate::auth::now(); + { + let mut observations = state + .acme + .renewals + .lock() + .unwrap_or_else(|e| e.into_inner()); + let entry = observations.entry(cert.id.clone()).or_default(); + entry.last_checked = now; + match result { + Ok(output) => { + let changed = !dry_run && output.get("changed") == Some(&Value::Bool(true)); + finished.status = "complete"; + finished.message = if dry_run { + "acme.renewal_test_ok" + } else if changed { + "acme.renewed" + } else { + "acme.not_due" + } + .into(); + // A dry run must not postpone a due production renewal. + if !dry_run { + entry.failures = 0; + entry.next_check = now + 43200 + (rand::random::() as i64 % 1800); + } + entry.not_after = output + .get("not_after_unix") + .and_then(Value::as_i64) + .or(entry.not_after); + if changed { + entry.last_renewed = Some(now); + } + } + Err(message) => { + finished.status = "failed"; + finished.message = message; + entry.failures = entry.failures.saturating_add(1); + if !dry_run { + entry.next_check = now + 3600 * i64::from(entry.failures.min(6)); + } + } + } + entry.status = finished.status.into(); + entry.message = finished.message.clone(); + } + *state.acme.job.lock().unwrap_or_else(|e| e.into_inner()) = Some(finished.clone()); + state.acme.persist(&state.store).await; + let _ = state + .store + .audit( + &actor, + "acme.renew_result", + &format!("{} {}", cert.id, finished.message), + ) + .await; + }); + Ok(job) + } +} +async fn deploy_renewal( + state: &Arc, + original: &Certificate, + value: &Value, + automatic: bool, +) -> Result<(), String> { + let data: Output = serde_json::from_value(value.clone()) + .map_err(|_| "acme.invalid_worker_response".to_owned())?; + if data.certificate_id != original.id + || original + .issuer + .as_ref() + .is_none_or(|i| i.provider != data.provider || i.environment != data.environment) + { + return Err("acme.renewal_profile_mismatch".into()); + } + let dir = state + .policy + .cert_dir + .clone() + .ok_or("acme.no_certificate_directory")?; + let mut renewed = tokio::task::spawn_blocking(move || { + crate::tls::validate_acme( + &dir, + data.certificate_id, + data.cert_file, + data.key_file, + &data.domains, + ) + }) + .await + .map_err(|_| "acme.validation_worker_failed".to_owned())? + .map_err(|_| "acme.certificate_validation_failed".to_owned())?; + // CAS retries preserve edits to other parts of the document. A simultaneous + // change of this certificate wins over the worker; never silently overwrite it. + for _ in 0..3 { + let snapshot = state.config.load_full(); + let Some(current) = snapshot + .envelope + .config + .tls + .certificates + .iter() + .find(|c| c.id == original.id) + else { + return Err("acme.configuration_changed".into()); + }; + if current.cert_file != original.cert_file + || current.key_file != original.key_file + || current.issuer.as_ref().is_none_or(|i| { + original.issuer.as_ref().is_none_or(|old| { + i.provider != old.provider || i.environment != old.environment + }) || (automatic && !i.auto_renew) + }) + { + return Err("acme.configuration_changed".into()); + } + renewed.hosts = current.hosts.clone(); + renewed.issuer = current.issuer.clone(); + let mut config = snapshot.envelope.config.clone(); + let slot = config + .tls + .certificates + .iter_mut() + .find(|c| c.id == original.id) + .ok_or("acme.configuration_changed")?; + *slot = renewed.clone(); + let update = crate::config::Update { + expected_revision: snapshot.envelope.revision, + config, + }; + match state + .update_as(update, "system:certificate-renewal".into()) + .await + { + Ok(_) => return Ok(()), + Err(ApiError(StatusCode::CONFLICT, _)) => continue, + Err(_) => return Err("acme.deployment_failed".into()), + } + } + Err("acme.configuration_changed".into()) +} +/// One scheduler per process, sharing the issuance semaphore. It does not run +/// its own shell hooks or stop listeners; validated snapshots rotate TCP and QUIC TLS. +pub async fn renewal_loop( + weak: std::sync::Weak, + shutdown: tokio_util::sync::CancellationToken, +) { + // Observations are restored from SQLite before listeners are exposed. If an + // older installation has no SQLite observations, the normal scheduler will + // safely reconstruct them on its first check; certificate/key state is not + // derived from historical observation sidecars. + let delay = 300 + (rand::random::() as u64 % 300); + tokio::select! { _ = shutdown.cancelled() => return, _ = tokio::time::sleep(Duration::from_secs(delay)) => {} } + loop { + let Some(state) = weak.upgrade() else { + return; + }; + let certificates = state.config.load().envelope.config.tls.certificates.clone(); + { + let mut observations = state + .acme + .renewals + .lock() + .unwrap_or_else(|e| e.into_inner()); + observations.retain(|id, _| certificates.iter().any(|c| &c.id == id)); + } + let view = state.acme.renewal_view(); + let now = crate::auth::now(); + for cert in certificates + .iter() + .filter(|c| c.issuer.as_ref().is_some_and(|i| i.auto_renew)) + { + if view + .get(&cert.id) + .is_some_and(|r| r.next_check > now && r.next_check <= now + 86400) + { + continue; + } + match Manager::renew( + state.clone(), + &cert.id, + "system:certificate-renewal".into(), + false, + true, + ) + .await + { + Ok(_) => break, + Err(ApiError(StatusCode::TOO_MANY_REQUESTS, _)) => break, + Err(error) => { + state + .acme + .renewals + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert( + cert.id.clone(), + RenewalState { + last_checked: now, + next_check: now + 3600, + failures: 1, + status: "failed".into(), + message: error.1, + ..Default::default() + }, + ); + state.acme.persist(&state.store).await; + } + } + } + drop(state); + tokio::select! { _ = shutdown.cancelled() => return, _ = tokio::time::sleep(Duration::from_secs(60)) => {} } + } +} + +#[cfg(test)] +mod renewal_regressions { + use super::*; + use crate::{ + auth::Auth, + config::{self, Config, ValidationPolicy}, + db::Store, + }; + async fn configured(automatic: bool) -> (Arc, tempfile::TempDir, Certificate, Value) { + let directory = tempfile::tempdir().unwrap(); + let cert_dir = directory.path().join("certs"); + crate::tls::initialize_default(&cert_dir).unwrap(); + for name in ["acme-old", "acme-next"] { + crate::tls::generate(&cert_dir, name, vec!["example.org".into()]).unwrap(); + } + let cert = Certificate { + id: "site".into(), + covered_hosts: Vec::new(), + hosts: vec!["example.org".into()], + cert_file: "acme-old.pem".into(), + key_file: "acme-old.key".into(), + issuer: Some(crate::tls::Issuer { + provider: "cloudflare".into(), + environment: "staging".into(), + auto_renew: automatic, + }), + }; + let mut config = Config::default(); + config.tls.certificates.push(cert.clone()); + config.tls.default_certificate = Some(cert.id.clone()); + let policy = ValidationPolicy { + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + tls_addr: None, + cert_dir: Some(cert_dir), + data_dir: None, + }; + let store = Store::open(&directory.path().join("db"), &config) + .await + .unwrap(); + let auth = Auth::new(store.clone(), directory.path().to_owned()) + .await + .unwrap(); + let snapshot = config::compile(config, 0, &policy).unwrap(); + let app = App::new( + store, + snapshot, + policy, + auth, + 8, + Duration::from_secs(2), + Duration::from_secs(2), + Duration::from_secs(30), + ); + let output = serde_json::json!({"provider":"cloudflare", "environment":"staging", "certificate_id":"site", + "domains":["example.org"], "cert_file":"acme-next.pem", "key_file":"acme-next.key", "changed":true}); + (app, directory, cert, output) + } + async fn stop(app: Arc) { + app.shutdown.cancel(); + app.tunnels.close(); + app.tunnels.wait().await; + app.store.close().await; + } + #[tokio::test] + async fn renewed_pair_publishes_one_revision_and_preserves_bindings_and_old_files() { + let (app, _directory, original, output) = configured(true).await; + deploy_renewal(&app, &original, &output, true) + .await + .unwrap(); + let snapshot = app.config.load_full(); + assert_eq!(snapshot.envelope.revision, 1); + let applied = &snapshot.envelope.config.tls.certificates[0]; + assert_eq!(applied.cert_file, "acme-next.pem"); + assert_eq!(applied.hosts, original.hosts); + assert!(applied.issuer.as_ref().unwrap().auto_renew); + assert_eq!( + app.store.load().await.unwrap().config.tls.certificates[0].cert_file, + "acme-next.pem" + ); + assert!(app + .policy + .cert_dir + .as_ref() + .unwrap() + .join("acme-old.key") + .exists()); + stop(app).await; + } + #[tokio::test] + async fn invalid_renewed_domain_never_changes_active_configuration() { + let (app, _directory, original, mut output) = configured(true).await; + output["domains"] = serde_json::json!(["unrelated.example.org"]); + assert!(deploy_renewal(&app, &original, &output, true) + .await + .is_err()); + assert_eq!(app.config.load().envelope.revision, 0); + assert_eq!( + app.store.load().await.unwrap().config.tls.certificates[0].cert_file, + original.cert_file + ); + stop(app).await; + } + #[tokio::test] + async fn operator_opt_out_wins_over_an_older_automatic_job() { + let (app, _directory, mut original, output) = configured(false).await; + original.issuer.as_mut().unwrap().auto_renew = true; + assert_eq!( + deploy_renewal(&app, &original, &output, true) + .await + .unwrap_err(), + "acme.configuration_changed" + ); + assert_eq!(app.config.load().envelope.revision, 0); + // Explicit Check now remains allowed when scheduled checks are disabled. + deploy_renewal(&app, &original, &output, false) + .await + .unwrap(); + assert!( + !app.config.load().envelope.config.tls.certificates[0] + .issuer + .as_ref() + .unwrap() + .auto_renew + ); + stop(app).await; + } + #[tokio::test] + async fn saved_renewal_observations_are_persisted_in_sqlite_without_provider_credentials() { + let (app, _directory, _original, _output) = configured(true).await; + app.acme.renewals.lock().unwrap().insert( + "site".into(), + RenewalState { + last_checked: 100, + next_check: 43200, + status: "complete".into(), + message: "acme.not_due".into(), + ..Default::default() + }, + ); + app.acme.persist(&app.store).await; + let saved = app.store.load_certificate_renewals().await.unwrap(); + let state = saved.get("site").unwrap(); + assert_eq!(state.next_check, 43200); + assert_eq!(state.status, "complete"); + assert_eq!(state.message, "acme.not_due"); + stop(app).await; + } +} diff --git a/src/admin/documentation.rs b/src/admin/documentation.rs new file mode 100644 index 0000000..6ecfb9d --- /dev/null +++ b/src/admin/documentation.rs @@ -0,0 +1,89 @@ +//! Authenticated OpenAPI/Swagger UI, exclusively on the admin listener. +//! Only these allowlisted vendor assets can be read; no filesystem or wildcard route. +use axum::{ + response::{Html, IntoResponse, Response}, + Json, +}; +use http::StatusCode; +use serde_json::Value; +use std::sync::OnceLock; + +pub(super) async fn index() -> Html<&'static str> { + Html(include_str!("web/docs.html")) +} +pub(super) async fn script() -> impl IntoResponse { + ( + [("content-type", "text/javascript; charset=utf-8")], + include_str!("web/docs.js"), + ) +} +pub(super) async fn style() -> impl IntoResponse { + ( + [("content-type", "text/css; charset=utf-8")], + include_str!("web/docs.css"), + ) +} +pub(super) async fn schema() -> Json { + static DOCUMENT: OnceLock = OnceLock::new(); + Json( + DOCUMENT + .get_or_init(|| { + let mut value: Value = serde_json::from_str(include_str!("openapi.json")) + .expect("validated embedded OpenAPI"); + value["info"]["version"] = Value::String(env!("CARGO_PKG_VERSION").into()); + value + }) + .clone(), + ) +} +pub(super) fn asset(name: &'static str) -> Response { + // Bytes are embedded for debug AND release, and always served through the + // authenticated router. Never read a file or fetch a CDN on an HTTP request. + let (content_type, bytes): (&'static str, &'static [u8]) = match name { + "swagger-ui-bundle.js" => ( + "text/javascript; charset=utf-8", + include_bytes!(concat!(env!("OUT_DIR"), "/swagger-ui/swagger-ui-bundle.js")), + ), + "swagger-ui.css" => ( + "text/css; charset=utf-8", + include_bytes!(concat!(env!("OUT_DIR"), "/swagger-ui/swagger-ui.css")), + ), + "licenses.txt" => ( + "text/plain; charset=utf-8", + include_bytes!(concat!(env!("OUT_DIR"), "/swagger-ui/licenses.txt")), + ), + _ => return StatusCode::NOT_FOUND.into_response(), + }; + ([("content-type", content_type)], bytes).into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn fixed_vendor_names_return_embedded_assets_with_expected_types() { + for (name, expected_type) in [ + ("swagger-ui-bundle.js", "text/javascript; charset=utf-8"), + ("swagger-ui.css", "text/css; charset=utf-8"), + ("licenses.txt", "text/plain; charset=utf-8"), + ] { + let response = asset(name); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["content-type"], expected_type); + let bytes = axum::body::to_bytes(response.into_body(), 16 * 1024 * 1024) + .await + .unwrap(); + assert!(!bytes.is_empty(), "{name}"); + } + for name in [ + "../Cargo.toml", + "index.html", + "openapi.json", + "source.json", + "unknown.js", + ] { + assert_eq!(asset(name).status(), StatusCode::NOT_FOUND); + } + } +} diff --git a/src/admin/mod.rs b/src/admin/mod.rs new file mode 100644 index 0000000..3b1451e --- /dev/null +++ b/src/admin/mod.rs @@ -0,0 +1,4249 @@ +//! Local control plane. Never mounted on the public proxy listener. +mod documentation; +mod public; +use crate::{ + auth::{self, Session}, + config::Update, + runtime::{ApiError, App}, + server::{PeerAddr, Transport}, +}; +use axum::{ + extract::{DefaultBodyLimit, Path, Query, Request, State}, + middleware::{self, Next}, + response::{ + sse::{Event, KeepAlive, Sse}, + IntoResponse, Response, + }, + routing::{delete, get, post, put}, + Extension, Json, Router, +}; +use http::{ + header::{AUTHORIZATION, HOST, ORIGIN, SET_COOKIE}, + HeaderMap, HeaderValue, Method, StatusCode, Version, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{ + convert::Infallible, + net::IpAddr, + sync::{atomic::Ordering, Arc}, + time::Duration, +}; + +pub fn router(state: Arc) -> Router { + let authentication = Router::new() + .route("/api/auth/status", get(auth_status)) + .route("/api/auth/setup", post(setup)) + .route("/api/auth/login", post(login)) + .route("/api/auth/session", get(session)) + .route("/api/auth/logout", post(logout)) + .route("/api/auth/logout-all", post(logout_all)) + .route("/api/auth/password", post(password)) + .layer(DefaultBodyLimit::max(4096)); + Router::new() + .merge(authentication) + .route("/", get(public::index)) + .route("/overview", get(public::index)) + .route("/vhosts", get(public::index)) + .route("/docker-vhosts", get(public::index)) + .route("/redirects", get(public::index)) + .route("/404-hosts", get(public::index)) + .route("/streams", get(public::index)) + .route("/acl-profiles", get(public::index)) + .route("/configuration", get(public::index)) + .route("/tls", get(public::index)) + .route("/cache", get(public::index)) + .route("/transform", get(public::index)) + .route("/logging", get(public::index)) + .route("/settings", get(public::index)) + .route( + "/assets/login.js", + get(|| async { + ( + [("content-type", "text/javascript; charset=utf-8")], + include_str!("web/login.js"), + ) + }), + ) + .route( + "/assets/login-langs.json", + get(|| async { + ( + [("content-type", "application/json; charset=utf-8")], + include_str!(concat!(env!("OUT_DIR"), "/login-langs.json")), + ) + }), + ) + .route("/docs", get(documentation::index)) + .route("/docs/", get(documentation::index)) + .route("/docs/openapi.json", get(documentation::schema)) + .route("/docs/init.js", get(documentation::script)) + .route("/docs/style.css", get(documentation::style)) + .route( + "/docs/swagger-ui-bundle.js", + get(|| async { documentation::asset("swagger-ui-bundle.js") }), + ) + .route( + "/docs/swagger-ui.css", + get(|| async { documentation::asset("swagger-ui.css") }), + ) + .route( + "/docs/licenses.txt", + get(|| async { documentation::asset("licenses.txt") }), + ) + .route( + "/assets/langs.json", + get(|| async { + ( + [("content-type", "application/json; charset=utf-8")], + include_str!(concat!(env!("OUT_DIR"), "/langs.json")), + ) + }), + ) + .route( + "/assets/app.js", + get(|| async { + ( + [("content-type", "text/javascript; charset=utf-8")], + include_str!("web/app.js"), + ) + }), + ) + .route( + "/assets/management.js", + get(|| async { + ( + [("content-type", "text/javascript; charset=utf-8")], + include_str!("web/management.js"), + ) + }), + ) + .route( + "/assets/integrations.js", + get(|| async { + ( + [("content-type", "text/javascript; charset=utf-8")], + include_str!("web/integrations.js"), + ) + }), + ) + .route( + "/assets/cache-panel.js", + get(|| async { + ( + [("content-type", "text/javascript; charset=utf-8")], + include_str!("web/cache-panel.js"), + ) + }), + ) + .route( + "/assets/transform-panel.js", + get(|| async { + ( + [("content-type", "text/javascript; charset=utf-8")], + include_str!("web/transform-panel.js"), + ) + }), + ) + .route( + "/assets/settings.js", + get(|| async { + ( + [("content-type", "text/javascript; charset=utf-8")], + include_str!("web/settings.js"), + ) + }), + ) + .route( + "/assets/acme.js", + get(|| async { + ( + [("content-type", "text/javascript; charset=utf-8")], + include_str!("web/acme.js"), + ) + }), + ) + .route( + "/assets/vhosts.js", + get(|| async { + ( + [("content-type", "text/javascript; charset=utf-8")], + include_str!("web/vhosts.js"), + ) + }), + ) + .route( + "/assets/docker.js", + get(|| async { + ( + [("content-type", "text/javascript; charset=utf-8")], + include_str!("web/docker.js"), + ) + }), + ) + .route( + "/assets/streams.js", + get(|| async { + ( + [("content-type", "text/javascript; charset=utf-8")], + include_str!("web/streams.js"), + ) + }), + ) + .route( + "/assets/redirects.js", + get(|| async { + ( + [("content-type", "text/javascript; charset=utf-8")], + include_str!("web/redirects.js"), + ) + }), + ) + .route( + "/assets/host404.js", + get(|| async { + ( + [("content-type", "text/javascript; charset=utf-8")], + include_str!("web/host404.js"), + ) + }), + ) + .route( + "/assets/style.css", + get(|| async { + ( + [("content-type", "text/css; charset=utf-8")], + include_str!("web/style.css"), + ) + }), + ) + .route( + "/favicon.svg", + get(|| async { + ( + [("content-type", "image/svg+xml")], + include_str!("web/favicon.svg"), + ) + }), + ) + .route("/healthz", get(|| async { Json(json!({"status":"ok"})) })) + .route("/api/status", get(status)) + .route("/api/backends", get(backend_status)) + .route("/api/docker", get(docker_status)) + .route("/api/docker/refresh", post(docker_refresh)) + .route("/api/certificates", get(certificate_inventory)) + .route( + "/api/basic-auth/hash", + post(basic_hash).layer(DefaultBodyLimit::max(4096)), + ) + .route("/api/acme", get(acme_status)) + .route( + "/api/acme/renew", + post(acme_renew).layer(DefaultBodyLimit::max(4096)), + ) + .route( + "/api/acme/issue", + post(acme_issue).layer(DefaultBodyLimit::max(16384)), + ) + .route( + "/api/certificates/upload", + post(certificate_upload).layer(DefaultBodyLimit::max(300 * 1024)), + ) + .route( + "/api/certificates/deploy", + post(certificate_deploy).layer(DefaultBodyLimit::max(300 * 1024)), + ) + .route( + "/api/certificates/reload", + post(certificate_reload).layer(DefaultBodyLimit::max(4096)), + ) + .route( + "/api/certificates/{certificate_id}/reload", + post(certificate_reload_one).layer(DefaultBodyLimit::max(4096)), + ) + .route( + "/api/certificate-api", + get(certificate_api_status) + .put(certificate_api_rotate) + .delete(certificate_api_revoke_all) + .layer(DefaultBodyLimit::max(4096)), + ) + .route( + "/api/certificate-api/{key_id}", + delete(certificate_api_revoke).layer(DefaultBodyLimit::max(4096)), + ) + .route("/api/cache", get(cache_status)) + .route("/api/transform", get(transform_status)) + .route("/api/cache/purge", post(cache_purge)) + .route( + "/api/cache/invalidate", + post(cache_invalidate).layer(DefaultBodyLimit::max(16 * 1024)), + ) + .route("/api/cache/bans", get(cache_bans)) + .route("/api/cache/bans/{id}", delete(cache_ban_delete)) + .route("/api/tunnels", get(active_tunnels)) + .route( + "/api/tunnels/{id}/kill", + post(kill_tunnel).layer(DefaultBodyLimit::max(4096)), + ) + .route( + "/api/cache/cold/cleanup", + post(cache_cold_cleanup).layer(DefaultBodyLimit::max(4096)), + ) + .route("/api/cache/cold/cleanup/{id}", get(cache_cold_cleanup_job)) + .route("/api/config", get(configuration).merge(put(update))) + .route("/api/config/{section}", put(update_section)) + .route("/api/logs", get(logs)) + .route("/api/live-logs", get(live_logs_stream)) + .route("/api/preferences", get(preferences).put(update_preferences)) + .route("/api/audit", get(audit)) + .route("/api/events", get(events)) + .route("/metrics", get(metrics)) + .route("/api/metrics", get(metrics)) + .fallback(|| async { reject(StatusCode::NOT_FOUND, "not_found") }) + .layer(DefaultBodyLimit::max( + crate::config::MAX_CONFIG_BYTES + 1024, + )) + .layer(middleware::from_fn_with_state(state.clone(), guard)) + .layer(middleware::from_fn_with_state(state.clone(), observe_admin)) + .with_state(state) +} +// Separate from proxy exchanges: measure only time to admin response headers, +// including access denials. SSE stream lifetime is intentionally not included. +async fn observe_admin(State(state): State>, request: Request, next: Next) -> Response { + use std::sync::atomic::Ordering; + state.metrics.admin_requests.fetch_add(1, Ordering::Relaxed); + let started = std::time::Instant::now(); + let response = next.run(request).await; + let class = (response.status().as_u16() as usize / 100).clamp(1, 5) - 1; + state.metrics.admin_status[class].fetch_add(1, Ordering::Relaxed); + state.metrics.admin_header_duration_us.fetch_add( + started.elapsed().as_micros().min(u64::MAX as u128) as u64, + Ordering::Relaxed, + ); + state + .metrics + .admin_response_headers + .fetch_add(1, Ordering::Relaxed); + response +} +fn reject(status: StatusCode, message: &str) -> Response { + (status, Json(json!({"error":message}))).into_response() +} +fn secure(mut response: Response) -> Response { + let h = response.headers_mut(); + h.insert( + "cache-control", + HeaderValue::from_static("private, no-store, no-cache"), + ); + h.insert( + "x-content-type-options", + HeaderValue::from_static("nosniff"), + ); + h.insert("referrer-policy", HeaderValue::from_static("no-referrer")); + h.insert("x-frame-options", HeaderValue::from_static("DENY")); + h.insert("content-security-policy", HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")); + h.insert( + "permissions-policy", + HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), + ); + // Do not let downstream middleware add implementation/version fingerprints. + h.remove("server"); + h.remove("x-powered-by"); + response +} +// This is a method-and-path allowlist, NOT a path prefix. Everything else, +// including unknown routes, schema, metrics, assets and method mismatches, +// requires an active administrator session before extractors read a body. +fn panel_document_path(path: &str) -> bool { + matches!( + path, + "/" | "/overview" + | "/vhosts" + | "/docker-vhosts" + | "/redirects" + | "/404-hosts" + | "/streams" + | "/acl-profiles" + | "/configuration" + | "/tls" + | "/cache" + | "/transform" + | "/logging" + | "/settings" + ) +} +fn anonymous_allowed(method: &Method, path: &str) -> bool { + if method == Method::GET || method == Method::HEAD { + panel_document_path(path) + || matches!( + path, + "/assets/login.js" + | "/assets/login-langs.json" + | "/assets/style.css" + | "/favicon.svg" + | "/healthz" + | "/api/auth/status" + ) + } else { + method == Method::POST && matches!(path, "/api/auth/setup" | "/api/auth/login") + } +} + +const CERTIFICATE_API_KEY_HEADER: &str = "x-rustproxy-certificate-key"; + +fn certificate_api_operation(method: &Method, path: &str) -> bool { + if method != Method::POST { + return false; + } + matches!(path, "/api/certificates/deploy" | "/api/certificates/reload") + || path + .strip_prefix("/api/certificates/") + .and_then(|tail| tail.strip_suffix("/reload")) + .is_some_and(crate::config::valid_id) +} + +fn valid_certificate_api_token(value: &str) -> bool { + (32..=128).contains(&value.len()) + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') + }) +} + +fn valid_certificate_api_key_id(value: &str) -> bool { + (3..=64).contains(&value.len()) && crate::config::valid_id(value) +} + +fn certificate_api_credentials(headers: &HeaderMap) -> Result, ()> { + let authorization_present = headers.contains_key(AUTHORIZATION); + let key_present = headers.contains_key(CERTIFICATE_API_KEY_HEADER); + if !authorization_present && !key_present { + return Ok(None); + } + if !authorization_present || !key_present { + return Err(()); + } + let authorization = single_header(headers, AUTHORIZATION.as_str()).ok_or(())?; + let token = authorization.strip_prefix("Bearer ").ok_or(())?; + if !valid_certificate_api_token(token) { + return Err(()); + } + let key_id = single_header(headers, CERTIFICATE_API_KEY_HEADER).ok_or(())?; + if !valid_certificate_api_key_id(key_id) { + return Err(()); + } + Ok(Some((key_id.to_owned(), token.to_owned()))) +} + +fn single_header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + let all = headers.get_all(name); + let mut values = all.iter(); + let value = values.next()?.to_str().ok()?; + if values.next().is_some() { + None + } else { + Some(value) + } +} +fn unauthorized(request: &Request) -> Response { + // Only top-level documentation navigation gets a fixed, same-origin login + // redirect. API/spec/assets ALWAYS return a small 401, never a login HTML body. + if (request.method() == Method::GET || request.method() == Method::HEAD) + && matches!(request.uri().path(), "/docs" | "/docs/") + { + axum::response::Redirect::to("/?next=docs").into_response() + } else { + reject(StatusCode::UNAUTHORIZED, "auth.required") + } +} +fn admin_request_authority(request: &Request, tls: bool) -> Result { + let mut host_values = request.headers().get_all(HOST).iter(); + let header_host = match (host_values.next(), host_values.next()) { + (None, None) => None, + (Some(value), None) => Some(value.to_str().map_err(|_| ())?), + (None, Some(_)) | (Some(_), Some(_)) => return Err(()), + }; + + if matches!(request.version(), Version::HTTP_2 | Version::HTTP_3) { + // Hyper exposes :authority through the request URI for H2/H3. Browsers do + // not have to send a legacy Host field, so requiring one here rejects + // perfectly valid administration requests negotiated over ALPN h2. + let authority = request.uri().authority().ok_or(())?.as_str(); + if let Some(scheme) = request.uri().scheme_str() { + if scheme != if tls { "https" } else { "http" } { + return Err(()); + } + } + if let Some(host) = header_host { + if !host.eq_ignore_ascii_case(authority) { + return Err(()); + } + } + if authority.contains('@') { + return Err(()); + } + HeaderValue::from_str(authority).map_err(|_| ())?; + return Ok(authority.to_owned()); + } + + // The administration listener is an origin server, never a forward proxy. + // HTTP/1.x therefore keeps the existing exact single-Host requirement and + // rejects absolute-form targets. + if request.uri().scheme().is_some() || request.uri().authority().is_some() { + return Err(()); + } + let authority = header_host.ok_or(())?; + if authority.contains('@') { + return Err(()); + } + HeaderValue::from_str(authority).map_err(|_| ())?; + Ok(authority.to_owned()) +} + +fn admin_host_allowed( + raw_host: &str, + configured_addr: std::net::SocketAddr, + tls: bool, + allowed_hosts: &[String], +) -> bool { + raw_host + .parse::() + .ok() + .is_some_and(|a| { + let host = a.host().trim_start_matches('[').trim_end_matches(']'); + let configured = configured_addr.ip(); + let dns_without_allowlist = tls + && allowed_hosts.is_empty() + && host.parse::().is_err() + && crate::config::normalize_host(host).is_ok(); + let direct_host = (host.eq_ignore_ascii_case("localhost") + && (configured.is_loopback() || configured.is_unspecified())) + || (!configured.is_unspecified() && host == configured.to_string()) + || (configured.is_unspecified() + && host + .parse::() + .ok() + .is_some_and(|ip| !ip.is_unspecified())); + let explicit_allowed = allowed_hosts + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(host)); + let port = a.port_u16().unwrap_or(if tls { 443 } else { 80 }); + + !a.as_str().contains('@') + && ((direct_host && port == configured_addr.port()) + // An explicit DNS allow-list describes the browser-facing + // authority. A reverse proxy is therefore free to terminate + // on 80/443 (or another external port) while forwarding to + // the private administration socket on its configured port. + || explicit_allowed + // Preserve the historical direct-TLS convenience when no + // strict DNS allow-list exists; unlike the explicit list it + // remains bound to the actual administration listener port. + || (dns_without_allowlist && port == configured_addr.port())) + }) +} + +#[derive(Debug, Clone, Copy)] +struct AdminOrigin { + secure: bool, +} + +fn default_origin_port(scheme: &str) -> Option { + match scheme { + "http" => Some(80), + "https" => Some(443), + _ => None, + } +} + +fn authority_matches_origin(actual: &http::uri::Authority, expected: &str, scheme: &str) -> bool { + let Ok(expected) = expected.parse::() else { + return false; + }; + let Some(default_port) = default_origin_port(scheme) else { + return false; + }; + actual.host().eq_ignore_ascii_case(expected.host()) + && actual.port_u16().unwrap_or(default_port) == expected.port_u16().unwrap_or(default_port) +} + +fn admin_origin_matches(value: &str, scheme: &str, authority: &str) -> bool { + let Ok(origin) = value.parse::() else { + return false; + }; + if origin.scheme_str() != Some(scheme) + || origin.query().is_some() + || !matches!(origin.path(), "" | "/") + { + return false; + } + origin + .authority() + .is_some_and(|actual| authority_matches_origin(actual, authority, scheme)) +} + +fn trusted_admin_proxy(state: &App, peer: Option) -> bool { + let Some(peer) = peer else { + return false; + }; + let ip = peer.0.ip(); + ip.is_loopback() || state.config.load().forwarding.trusted(ip) +} + +fn trusted_admin_forwarded_proto(state: &App, peer: Option) -> bool { + let Some(peer) = peer else { + return false; + }; + let ip = peer.0.ip(); + ip.is_loopback() || state.config.load().forwarding.trusted_forwarded_proto(ip) +} + +fn external_admin_authority( + state: &App, + headers: &HeaderMap, + peer: Option, + raw_host: &str, + secure: bool, +) -> Result { + if trusted_admin_proxy(state, peer) && headers.contains_key("x-forwarded-host") { + let forwarded = single_header(headers, "x-forwarded-host").ok_or(())?; + if admin_host_allowed( + forwarded, + state.policy.admin_addr, + secure, + &state.active_administration.allowed_hosts, + ) { + return Ok(forwarded.to_owned()); + } + return Err(()); + } + if admin_host_allowed( + raw_host, + state.policy.admin_addr, + secure, + &state.active_administration.allowed_hosts, + ) { + Ok(raw_host.to_owned()) + } else { + Err(()) + } +} + +fn external_admin_scheme( + state: &App, + headers: &HeaderMap, + peer: Option, + transport: Transport, +) -> Result<&'static str, ()> { + if transport.tls { + return Ok("https"); + } + if !trusted_admin_forwarded_proto(state, peer) || !headers.contains_key("x-forwarded-proto") { + return Ok("http"); + } + match single_header(headers, "x-forwarded-proto") { + Some("https") => Ok("https"), + Some("http") => Ok("http"), + _ => Err(()), + } +} + +/// Plain HTTP requests accepted on an HTTPS administration socket are redirected +/// to the same authority/path. The TLS server only selects this router after it +/// has positively identified a non-TLS first byte, so admin content is never +/// served over this path. +pub fn https_redirect_router() -> Router { + Router::new().fallback(|request: Request| async move { + let raw_host = request + .headers() + .get(HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + let authority = raw_host.parse::(); + if request.headers().get_all(HOST).iter().count() != 1 + || authority.as_ref().is_err() + || raw_host.contains('@') + { + return reject(StatusCode::BAD_REQUEST, "auth.invalid_host"); + } + let path = request + .uri() + .path_and_query() + .map(|value| value.as_str()) + .unwrap_or("/"); + let location = format!("https://{raw_host}{path}"); + match HeaderValue::from_str(&location) { + Ok(location) => { + let mut response = StatusCode::PERMANENT_REDIRECT.into_response(); + response + .headers_mut() + .insert(http::header::LOCATION, location); + response.headers_mut().insert( + http::header::CACHE_CONTROL, + HeaderValue::from_static("no-store"), + ); + response + } + Err(_) => reject(StatusCode::BAD_REQUEST, "auth.invalid_host"), + } + }) +} + +async fn guard(State(state): State>, mut request: Request, next: Next) -> Response { + let transport = request + .extensions() + .get::() + .copied() + .unwrap_or_default(); + let peer = request.extensions().get::().copied(); + let raw_host = match admin_request_authority(&request, transport.tls) { + Ok(value) => value, + Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")), + }; + let headers = request.headers(); + let scheme = match external_admin_scheme(&state, headers, peer, transport) { + Ok(value) => value, + Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin")), + }; + let external_authority = + match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") { + Ok(value) => value, + Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")), + }; + if headers.get_all(ORIGIN).iter().count() > 1 + || headers.get(ORIGIN).is_some_and(|h| { + h.to_str() + .ok() + .is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority)) + }) + { + return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin")); + } + if headers + .get("sec-fetch-site") + .is_some_and(|h| h == "cross-site") + { + return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin")); + } + let path = request.uri().path().to_owned(); + let public = anonymous_allowed(request.method(), &path); + let unsafe_method = request.method() != Method::GET + && request.method() != Method::HEAD + && request.method() != Method::OPTIONS; + let secret = auth::cookie_secret(headers); + let certificate_api_session = if certificate_api_operation(request.method(), &path) { + match certificate_api_credentials(headers) { + Ok(None) => None, + Ok(Some((key_id, token))) => { + if peer.is_some_and(|peer| !peer.0.ip().is_loopback()) && scheme != "https" { + return secure(reject(StatusCode::FORBIDDEN, "certificate_api.https_required")); + } + let token_hash = auth::secret_hash(&token); + match state + .store + .verify_certificate_api_credential(&key_id, &token_hash) + .await + { + Ok(true) => Some(Session { + token_hash, + csrf: String::new(), + username: format!("certificate-api:{key_id}"), + expires_at: auth::now().saturating_add(60), + generation: 0, + }), + Ok(false) => { + return secure(reject( + StatusCode::UNAUTHORIZED, + "certificate_api.invalid_token", + )) + } + Err(error) => return secure(auth::internal(error).into_response()), + } + } + Err(()) => { + return secure(reject( + StatusCode::UNAUTHORIZED, + "certificate_api.invalid_token", + )) + } + } + } else { + None + }; + let external_certificate_api = certificate_api_session.is_some(); + let session_seconds = state + .config + .load() + .envelope + .config + .administration + .session_seconds(); + // Panel document routes optionally authenticate so a direct refresh can choose + // the application shell when the browser still has a valid cookie. Public static + // login resources and capability discovery never load operational state. + let session = if certificate_api_session.is_some() { + certificate_api_session + } else if !public || panel_document_path(&path) { + match secret.as_deref() { + Some(secret) => match state + .store + .session_at_with_browser_lifetime(secret, auth::now(), session_seconds) + .await + { + Ok(value) => value, + Err(error) => return secure(auth::internal(error).into_response()), + }, + None => None, + } + } else { + None + }; + if !public && session.is_none() { + return secure(unauthorized(&request)); + } + if unsafe_method && !external_certificate_api { + if single_header(headers, "origin") + .is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority)) + || single_header(headers, "x-rustproxy-request") != Some("1") + { + return secure(reject(StatusCode::FORBIDDEN, "auth.csrf")); + } + if !public + && !session.as_ref().is_some_and(|session| { + single_header(headers, "x-csrf-token") + .is_some_and(|provided| auth::equal_secret(provided, &session.csrf)) + }) + { + return secure(reject(StatusCode::FORBIDDEN, "auth.csrf")); + } + } + if let Some(session) = session.clone() { + request.extensions_mut().insert(session); + } + let admin_origin = AdminOrigin { + secure: scheme == "https", + }; + request.extensions_mut().insert(admin_origin); + request.extensions_mut().insert(transport); + let mut response = secure(next.run(request).await); + let refresh_session_cookie = !matches!( + path.as_str(), + "/api/auth/logout" | "/api/auth/logout-all" | "/api/auth/password" + ); + if refresh_session_cookie { + if let (Some(secret), Some(session)) = (secret.as_deref(), session.as_ref()) { + let remaining = session.expires_at.saturating_sub(auth::now()); + if remaining > 0 { + if let Ok(cookie) = HeaderValue::from_str(&auth::session_cookie( + secret, + admin_origin.secure, + remaining, + )) { + response.headers_mut().append(SET_COOKIE, cookie); + } + } + } + } + if matches!(path.as_str(), "/docs" | "/docs/") && response.status().is_success() { + // Swagger UI sets inline layout styles. This exception is limited to the + // authenticated documentation document; scripts remain self-only/no-eval. + response.headers_mut().insert("content-security-policy", HeaderValue::from_static( + "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")); + } + response +} +async fn auth_status(State(state): State>) -> Result, ApiError> { + // The one bit needed to render first-account setup; no version, paths, names, + // listeners, provider state, metrics or configuration before authentication. + Ok(Json( + json!({"setup_required":state.store.admin_record().await.map_err(auth::internal)?.is_none()}), + )) +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Setup { + token: String, + username: String, + password: String, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Login { + username: String, + password: String, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PasswordChange { + current_password: String, + new_password: String, +} +async fn setup( + State(state): State>, + input: Result, axum::extract::rejection::JsonRejection>, +) -> Result, ApiError> { + let Json(input) = input.map_err(public::invalid_json)?; + state + .auth + .setup(&input.token, &input.username, input.password) + .await?; + Ok(Json(json!({"initialized":true}))) +} +fn session_payload(session: &Session, session_seconds: i64) -> Value { + json!({"username":session.username, "csrf_token":session.csrf, "expires_at":session.expires_at, + "idle_timeout_seconds":session_seconds, "session_lifetime_seconds":session_seconds}) +} +async fn login( + State(state): State>, + Extension(origin): Extension, + headers: HeaderMap, + input: Result, axum::extract::rejection::JsonRejection>, +) -> Result { + let Json(input) = input.map_err(public::invalid_json)?; + let previous = auth::cookie_secret(&headers); + let session_seconds = state + .config + .load() + .envelope + .config + .administration + .session_seconds(); + let issued = state + .auth + .login( + &input.username, + input.password, + previous.as_deref(), + session_seconds, + ) + .await?; + let mut response = Json(session_payload(&issued.session, session_seconds)).into_response(); + response.headers_mut().insert( + SET_COOKIE, + auth::session_cookie(&issued.secret, origin.secure, session_seconds) + .parse() + .map_err(auth::internal)?, + ); + Ok(response) +} +async fn session( + State(state): State>, + Extension(session): Extension, +) -> Json { + let session_seconds = state + .config + .load() + .envelope + .config + .administration + .session_seconds(); + Json(session_payload(&session, session_seconds)) +} + +const LIVE_LOG_COLUMNS: &[&str] = &[ + "time", + "request", + "host", + "source_ip", + "vhost", + "backend", + "status", + "duration", + "cache", + "outcome", + "transport", + "websocket", + "upstream_error", +]; + +fn default_admin_theme() -> String { + "dark".to_owned() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct AdminPreferences { + live_log_columns: Vec, + #[serde(default = "default_admin_theme")] + theme: String, +} + +impl Default for AdminPreferences { + fn default() -> Self { + Self { + live_log_columns: LIVE_LOG_COLUMNS + .iter() + .map(|value| (*value).to_owned()) + .collect(), + theme: default_admin_theme(), + } + } +} + +fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiError> { + if preferences.live_log_columns.is_empty() + || preferences.live_log_columns.len() > LIVE_LOG_COLUMNS.len() + { + return Err(ApiError( + StatusCode::UNPROCESSABLE_ENTITY, + "at least one live-log column must be visible".into(), + )); + } + let mut seen = std::collections::HashSet::new(); + for column in &preferences.live_log_columns { + if !LIVE_LOG_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) { + return Err(ApiError( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid live-log column preference".into(), + )); + } + } + if !matches!(preferences.theme.as_str(), "dark" | "light") { + return Err(ApiError( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid administration theme preference".into(), + )); + } + Ok(()) +} + +async fn preferences( + State(state): State>, + Extension(session): Extension, +) -> Result, ApiError> { + let stored = state + .store + .admin_preferences(&session.username) + .await + .map_err(auth::internal)?; + let preferences = match stored { + Some(document) => match serde_json::from_str::(&document) { + Ok(preferences) if validate_admin_preferences(&preferences).is_ok() => preferences, + Ok(_) | Err(_) => { + tracing::warn!(user = %session.username, "invalid admin view preferences ignored"); + AdminPreferences::default() + } + }, + None => AdminPreferences::default(), + }; + Ok(Json(preferences)) +} + +async fn update_preferences( + State(state): State>, + Extension(session): Extension, + input: Result, axum::extract::rejection::JsonRejection>, +) -> Result, ApiError> { + let Json(preferences) = input.map_err(public::invalid_json)?; + validate_admin_preferences(&preferences)?; + let document = serde_json::to_string(&preferences).map_err(auth::internal)?; + if !state + .store + .save_admin_preferences(&session.username, &document) + .await + .map_err(auth::internal)? + { + return Err(ApiError( + StatusCode::UNAUTHORIZED, + "auth.invalid_credentials".into(), + )); + } + Ok(Json(preferences)) +} +fn logged_out(secure_cookie: bool) -> Response { + let mut response = Json(json!({"logged_out":true})).into_response(); + response.headers_mut().insert( + SET_COOKIE, + HeaderValue::from_str(&auth::expired_cookie(secure_cookie)) + .expect("static cookie attributes"), + ); + response +} +async fn logout( + State(state): State>, + Extension(origin): Extension, + Extension(session): Extension, +) -> Result { + state + .store + .end_session(&session) + .await + .map_err(auth::internal)?; + Ok(logged_out(origin.secure)) +} +async fn logout_all( + State(state): State>, + Extension(origin): Extension, + Extension(session): Extension, +) -> Result { + state + .store + .revoke_all_sessions(&session.username) + .await + .map_err(auth::internal)?; + Ok(logged_out(origin.secure)) +} +async fn password( + State(state): State>, + Extension(origin): Extension, + Extension(session): Extension, + Json(input): Json, +) -> Result { + state + .auth + .change_password(&session, input.current_password, input.new_password) + .await?; + Ok(logged_out(origin.secure)) +} +fn status_payload(state: &App) -> Value { + let snapshot = state.config.load_full(); + let listeners = json!({ + "default_http":state.startup_listeners.0.to_string(), + "default_https":state.startup_listeners.1.map(|v| v.to_string()), + "locked_http":state.startup_locks.http.map(|v| v.to_string()), + "locked_https":state.startup_locks.https.map(|v| v.to_string()), + "active_endpoints":state.active_listeners, + "desired_endpoints":snapshot.listeners, + "named_endpoints":snapshot.envelope.config.listeners.endpoints, + "restart_required":snapshot.listeners != state.active_listeners + }); + let mut features = serde_json::Map::new(); + for name in [ + "http1", + "streaming", + "hot_reload", + "sqlite", + "admin_accounts", + "acl", + "headers", + "live_sse", + "http2", + "tls", + "websocket", + "dns_backends", + "https_backends", + "cache", + "compression", + "system_diagnostics", + "language_packs", + "vhosts", + "redirect_hosts", + "not_found_hosts", + "ip_bindings", + "backend_pools", + "health_checks", + "sticky_sessions", + "security_headers", + "http3", + "tinyauth", + "trusted_forwarding", + "named_listeners", + "stream_proxy", + "certificate_library", + "vhost_server_header", + "diagnostic_header_controls", + "cache_dashboard", + "transform_dashboard", + "cache_reasons", + "acme_http01", + "acme_cloudflare", + "acme_ovh", + "prometheus_exporter", + "vhosts_only", + "acme_rfc2136", + "certificate_upload", + "vhost_cache", + "selective_purge", + "custom_pages", + "rate_limit", + "country_access", + "cloudflare_client_ip", + "server_timing", + "static_cookie_cache", + "per_vhost_http3", + "structured_log_sinks", + "admin_https", + "tls_policy", + "cache_resident_age", + ] { + features.insert(name.into(), Value::Bool(true)); + } + features.insert( + "tls_enabled".into(), + Value::Bool(state.active_listeners.iter().any(|l| l.tls)), + ); + features.insert("docker".into(), Value::Bool(true)); + let mut payload = json!({"version":env!("CARGO_PKG_VERSION"), "project":"RustProxy", "mode":"standalone", + "auth":"argon2id_cookie_session", "uptime_seconds":state.started.elapsed().as_secs(), + "revision":snapshot.envelope.revision, "proxy_listen":state.policy.proxy_addr.to_string(), + "admin_listen":state.policy.admin_addr.to_string(), "tls_listen":state.policy.tls_addr.map(|v| v.to_string()), + "certificate_bindings":snapshot.envelope.config.tls.certificates.len(), + "vhosts":snapshot.envelope.config.vhosts.len(), + "redirect_hosts":snapshot.envelope.config.redirects.len(), + "not_found_hosts":snapshot.envelope.config.not_found_hosts.len()}); + payload["listeners"] = listeners; + let stream_metrics = state + .active_streams + .iter() + .filter_map(|stream| { + state + .stream_metrics + .get(&stream.id) + .map(|metrics| (stream.id.clone(), metrics.view())) + }) + .collect::>(); + payload["streams"] = json!({ + "active": state.active_streams, + "desired": snapshot.envelope.config.streams, + "restart_required": snapshot.envelope.config.streams != state.active_streams, + "metrics": stream_metrics + }); + payload["prometheus"] = json!({"active": state.active_prometheus, + "desired": snapshot.envelope.config.prometheus, + "restart_required": snapshot.envelope.config.prometheus.restart_differs(&state.active_prometheus)}); + let mut desired_admin = snapshot.envelope.config.administration.clone(); + desired_admin.listen = Some(desired_admin.resolved_listen(state.policy.admin_addr)); + if let Some(id) = desired_admin.certificate_id.as_deref() { + if let Some(certificate) = snapshot + .envelope + .config + .tls + .certificates + .iter() + .find(|certificate| certificate.id == id) + { + desired_admin + .allowed_hosts + .extend(certificate.hosts.clone()); + desired_admin.allowed_hosts.sort(); + desired_admin.allowed_hosts.dedup(); + } + } + let desired_admin_certificate_files = desired_admin + .certificate_id + .as_deref() + .and_then(|id| { + snapshot + .envelope + .config + .tls + .certificates + .iter() + .find(|certificate| certificate.id == id) + }) + .map(|certificate| (certificate.cert_file.clone(), certificate.key_file.clone())); + let administration_restart_required = desired_admin + .restart_differs(&state.active_administration) + || (state.active_administration.tls + && snapshot.envelope.config.tls.policy != state.active_administration_tls_policy) + || (state.active_administration.tls + && desired_admin_certificate_files != state.active_administration_certificate_files); + payload["administration"] = json!({ + "active": state.active_administration, + "desired": desired_admin, + "restart_required": administration_restart_required, + "listen_locked": state.startup_locks.administration.is_some(), + "locked_listen": state.startup_locks.administration.map(|value| value.to_string()) + }); + payload["features"] = Value::Object(features); + payload["cache"] = json!(state.cache.view()); + payload["geoip"] = json!(state.geoip.view()); + let system = state.diagnostics.view(); + payload["runtime"] = json!({ + "platform": system.platform, + "statistics_source": system.source, + "data_directory": state.policy.data_dir.as_ref().map(|path| path.display().to_string()), + "database_path": state.policy.data_dir.as_ref().map(|path| path.join("config.sqlite3").display().to_string()) + }); + payload["system"] = json!(system); + payload["backend_pools"] = json!(snapshot.pools.iter().map(|p| p.view()).collect::>()); + let mut backend_pool_domains = std::collections::BTreeMap::new(); + for agent in &snapshot.envelope.config.docker_agents.agents { + for assignment in agent + .assignments + .iter() + .filter(|assignment| assignment.enabled) + { + if let Ok(hosts) = crate::docker::assignment_hosts(assignment, &agent.base_domain) { + if let Some(host) = hosts.first() { + backend_pool_domains.insert( + crate::docker::generated_id(&agent.id, &assignment.service_key), + host.clone(), + ); + } + } + } + } + payload["backend_pool_domains"] = json!(backend_pool_domains); + payload["backends"] = json!(snapshot + .envelope + .config + .vhosts + .iter() + .map(|h| h.backends.len()) + .sum::()); + payload["routes"] = json!(snapshot + .envelope + .config + .vhosts + .iter() + .map(|h| h.routes.len()) + .sum::()); + payload["enabled_routes"] = json!(snapshot + .envelope + .config + .vhosts + .iter() + .filter(|h| h.enabled) + .map(|h| h.routes.iter().filter(|r| r.enabled).count()) + .sum::()); + payload["metrics"] = json!(state.metrics.view()); + payload["transform_metrics"] = json!(state.transform_metrics.total()); + payload["logs_dropped"] = json!(state.logs.dropped.load(Ordering::Relaxed)); + payload["software_update"] = json!(state.software_update.snapshot()); + payload["logging"] = + json!({"settings": &snapshot.envelope.config.logging, "sinks": state.logs.stats()}); + payload["tls"] = json!({ + "policy": &snapshot.envelope.config.tls.policy, + "cipher_suites": crate::tls::cipher_suite_names(), + "effective_cipher_suites": snapshot.envelope.config.tls.policy.effective_cipher_suites() + }); + payload +} +async fn status(State(state): State>) -> Json { + Json(status_payload(&state)) +} +async fn backend_status(State(state): State>) -> Json { + let snapshot = state.config.load_full(); + Json( + json!({"pools":snapshot.pools.iter().map(|p| p.view()).collect::>(), + "active_frontends":state.active_listeners, "desired_frontends":snapshot.listeners}), + ) +} +fn docker_runtime_views(state: &Arc) -> (Vec, Vec) { + let snapshot = state.config.load_full(); + let mut routes = Vec::new(); + let mut ids = std::collections::HashSet::new(); + for agent in &snapshot.envelope.config.docker_agents.agents { + for assignment in &agent.assignments { + let pool_id = crate::docker::generated_id(&agent.id, &assignment.service_key); + ids.insert(pool_id.clone()); + routes.push(json!({"agent_id": &agent.id, "service_key": &assignment.service_key, "pool_id": pool_id})); + } + } + let pools = snapshot + .pools + .iter() + .filter(|pool| ids.contains(&pool.id)) + .map(|pool| pool.view()) + .collect(); + (pools, routes) +} +async fn docker_status(State(state): State>) -> Json { + let (pools, pool_routes) = docker_runtime_views(&state); + Json( + json!({"observations": state.docker.view().await, "pools": pools, "pool_routes": pool_routes}), + ) +} +async fn docker_refresh(State(state): State>) -> Result, ApiError> { + let observations = state.refresh_docker().await?; + let (pools, pool_routes) = docker_runtime_views(&state); + Ok(Json( + json!({"observations": observations, "pools": pools, "pool_routes": pool_routes}), + )) +} +async fn certificate_inventory(State(state): State>) -> Json { + let snapshot = state.config.load_full(); + let config = &snapshot.envelope.config; + let configured_certificates = config.tls.certificates.clone(); + let certificate_directory = state.policy.cert_dir.clone(); + // Inspect only public certificate files on a blocking worker; never read keys. + // Live PEM coverage wins so in-place renewals are reflected immediately; stored + // metadata is only a fallback when the public certificate file cannot be read. + let inspected_metadata = tokio::task::spawn_blocking(move || { + configured_certificates + .into_iter() + .filter_map(|cert| { + certificate_directory.as_deref().and_then(|directory| { + crate::tls::certificate_metadata(directory, &cert.cert_file) + .ok() + .map(|metadata| (cert.id, metadata)) + }) + }) + .collect::>() + }) + .await + .unwrap_or_default(); + let renewals = state.acme.renewal_view(); + let entries: Vec<_> = config.tls.certificates.iter().map(|cert| { + let assigned: Vec<_> = config.vhosts.iter().filter(|v|v.certificate_id.as_deref()==Some(cert.id.as_str())) + .map(|v|json!({"id":v.id,"hosts":v.frontend.hosts})).collect(); + let metadata = inspected_metadata.get(&cert.id); + let covered_hosts = metadata.map(|value| value.covered_hosts.clone()).unwrap_or_else(|| cert.covered_hosts.clone()); + let renewal = renewals.get(&cert.id); + json!({"id":cert.id,"hosts":cert.hosts,"covered_hosts":covered_hosts,"cert_file":cert.cert_file,"key_file":cert.key_file, + "issuer":cert.issuer,"certificate_issuer":metadata.and_then(|value| value.issuer.clone()), + "not_before":metadata.and_then(|value| value.not_before.clone()),"not_after":metadata.and_then(|value| value.not_after.clone()), + "not_before_unix":metadata.and_then(|value| value.not_before_unix),"not_after_unix":metadata.and_then(|value| value.not_after_unix), + "days_remaining":metadata.and_then(|value| value.days_remaining),"expiry_status":metadata.map(|value| value.expiry_status).unwrap_or("unknown"), + "renewal":renewal,"renewal_status":renewal.map(|value| value.status.as_str()), + "last_renewal_attempt":renewal.map(|value| value.last_checked).filter(|value| *value > 0), + "last_renewal_error":renewal.filter(|value| value.status == "failed").map(|value| value.message.as_str()), + "default":config.tls.default_certificate.as_deref()==Some(cert.id.as_str()),"assigned_vhosts":assigned}) + }).collect(); + Json( + json!({"revision":snapshot.envelope.revision,"certificates":entries,"acme":true, "providers":["http01", "cloudflare", "ovh", "rfc2136"]}), + ) +} +async fn configuration(State(state): State>) -> Json { + Json(state.config.load().envelope.clone()) +} +async fn update( + State(state): State>, + Extension(session): Extension, + Json(input): Json, +) -> Result, ApiError> { + state.update_as(input, session.username).await.map(Json) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SectionUpdate { + expected_revision: u64, + value: Value, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ResponseSection { + server_header: crate::settings::ServerHeader, + diagnostic_headers: crate::settings::DiagnosticHeaders, + compression: crate::compression::Settings, +} + +fn invalid_section(section: &str, error: serde_json::Error) -> ApiError { + ApiError( + StatusCode::BAD_REQUEST, + format!("invalid {section} configuration: {error}"), + ) +} + +async fn update_section( + State(state): State>, + Extension(session): Extension, + Path(section): Path, + Json(input): Json, +) -> Result, ApiError> { + let current = state.config.load_full(); + if current.envelope.revision != input.expected_revision { + return Err(ApiError( + StatusCode::CONFLICT, + "configuration changed; reload before saving".into(), + )); + } + let mut config = current.envelope.config.clone(); + let value = input.value; + match section.as_str() { + "listeners" => { + config.listeners = + serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? + } + "docker-agents" => { + config.docker_agents = + serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? + } + "http2" => { + config.http2 = + serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? + } + "response" => { + let part: ResponseSection = + serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?; + config.server_header = part.server_header; + config.diagnostic_headers = part.diagnostic_headers; + config.compression = part.compression; + } + "pages" => { + config.pages = + serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? + } + "cache" => { + config.cache = + serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? + } + "transform" => { + config.transform = + serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? + } + "logging" => { + config.logging = + serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? + } + "forwarding" => { + config.forwarding = + serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? + } + "headers" => { + config.headers = + serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? + } + "prometheus" => { + config.prometheus = + serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? + } + "administration" => { + config.administration = + serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? + } + "tls" => { + config.tls = serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? + } + "vhosts" => { + config.vhosts = + serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? + } + "redirects" => { + config.redirects = + serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? + } + "not_found_hosts" => { + config.not_found_hosts = + serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? + } + "streams" => { + config.streams = + serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? + } + "acl-profiles" => { + config.acl_profiles = + serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? + } + "host-policies" => { + config.host_policies = + serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))? + } + _ => { + return Err(ApiError( + StatusCode::NOT_FOUND, + "unknown configuration section".into(), + )) + } + } + state + .update_as( + Update { + expected_revision: input.expected_revision, + config, + }, + session.username, + ) + .await + .map(Json) +} +fn logs_payload(state: &App) -> Value { + json!({ + "entries": state.logs.recent_limit(crate::logging::LIVE_WINDOW), + "capacity": crate::logging::HISTORY_CAPACITY, + "window": crate::logging::LIVE_WINDOW, + "dropped": state.logs.dropped.load(Ordering::Relaxed) + }) +} +#[derive(Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct LogFilter { + vhost: Option, + q: Option, + status: Option, + cache: Option, + limit: Option, +} +async fn logs( + State(state): State>, + Query(filter): Query, +) -> Result, ApiError> { + if filter.q.as_ref().is_some_and(|q| q.len() > 256) + || filter + .vhost + .as_ref() + .is_some_and(|v| !crate::config::valid_id(v)) + || filter + .cache + .as_ref() + .is_some_and(|v| !matches!(v.as_str(), "HIT" | "MISS" | "BYPASS" | "-")) + { + return Err(ApiError( + StatusCode::BAD_REQUEST, + "invalid log filter".into(), + )); + } + let q = filter.q.unwrap_or_default().to_ascii_lowercase(); + let limit = filter + .limit + .unwrap_or(crate::logging::LIVE_WINDOW) + .clamp(1, crate::logging::HISTORY_CAPACITY); + let entries: Vec<_> = state + .logs + .recent() + .into_iter() + .filter(|e| { + filter + .vhost + .as_ref() + .is_none_or(|v| e.vhost.as_ref() == Some(v)) + && filter.status.is_none_or(|v| e.status == v) + && filter.cache.as_ref().is_none_or(|v| e.cache == v.as_str()) + && (q.is_empty() + || format!( + "{} {} {} {}", + e.method, + e.host, + e.path, + e.backend.as_deref().unwrap_or("") + ) + .to_ascii_lowercase() + .contains(&q)) + }) + .take(limit) + .collect(); + Ok(Json( + json!({ + "entries": entries, + "capacity": crate::logging::HISTORY_CAPACITY, + "window": crate::logging::LIVE_WINDOW, + "dropped": state.logs.dropped.load(Ordering::Relaxed) + }), + )) +} + +async fn live_logs_stream( + State(state): State>, + headers: HeaderMap, +) -> Result { + let secret = auth::cookie_secret(&headers) + .ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "auth.required".into()))?; + let permit = state + .viewer_slots + .clone() + .try_acquire_owned() + .map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "events.too_many_viewers".into()))?; + let receiver = state.logs.subscribe(); + let next_auth_check = tokio::time::Instant::now() + Duration::from_secs(10); + let stream = futures_util::stream::unfold( + (state, secret, permit, receiver, next_auth_check), + |(state, secret, permit, mut receiver, mut next_auth_check)| async move { + if tokio::time::Instant::now() >= next_auth_check { + let session_seconds = state + .config + .load() + .envelope + .config + .administration + .session_seconds(); + match state + .store + .session_at_with_browser_lifetime(&secret, auth::now(), session_seconds) + .await + { + Ok(Some(_)) => { + next_auth_check = tokio::time::Instant::now() + Duration::from_secs(10); + } + _ => return None, + } + } + let event = tokio::select! { + received = receiver.recv() => match received { + Ok(entry) => Event::default() + .event("log") + .data(serde_json::to_string(&entry).unwrap_or_else(|_| "{}".into())), + Err(tokio::sync::broadcast::error::RecvError::Lagged(dropped)) => { + Event::default().event("gap").data(json!({"dropped": dropped}).to_string()) + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => return None, + }, + _ = tokio::time::sleep_until(next_auth_check) => { + let session_seconds = state + .config + .load() + .envelope + .config + .administration + .session_seconds(); + match state + .store + .session_at_with_browser_lifetime(&secret, auth::now(), session_seconds) + .await + { + Ok(Some(_)) => { + next_auth_check = tokio::time::Instant::now() + Duration::from_secs(10); + Event::default().event("heartbeat").data("{}") + } + _ => return None, + } + } + }; + Some(( + Ok::(event), + (state, secret, permit, receiver, next_auth_check), + )) + }, + ); + Ok(Sse::new(stream) + .keep_alive(KeepAlive::new().interval(Duration::from_secs(5))) + .into_response()) +} + +async fn audit(State(state): State>) -> Result, ApiError> { + Ok(Json( + json!({"entries":state.store.recent_audit().await.map_err(auth::internal)?,"capacity":crate::db::AUDIT_CAPACITY}), + )) +} +async fn metrics(State(state): State>) -> Response { + crate::prometheus::response(&state) +} +async fn events(State(state): State>, headers: HeaderMap) -> Result { + let secret = auth::cookie_secret(&headers) + .ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "auth.required".into()))?; + let permit = state + .viewer_slots + .clone() + .try_acquire_owned() + .map_err(|_| { + ApiError( + StatusCode::TOO_MANY_REQUESTS, + "events.too_many_viewers".into(), + ) + })?; + // One bounded snapshot, produced only when the consumer polls. No per-client queue. + // Absolute expiry/revocation is rechecked on every snapshot; no immortal SSE sessions. + let stream = futures_util::stream::unfold( + (state, secret, permit, false), + |(state, secret, permit, delayed)| async move { + if delayed { + tokio::time::sleep(Duration::from_secs(2)).await; + } + let session_seconds = state + .config + .load() + .envelope + .config + .administration + .session_seconds(); + match state + .store + .session_at_with_browser_lifetime(&secret, auth::now(), session_seconds) + .await + { + Ok(Some(_)) => {} + _ => return None, + } + let payload = + json!({"status":status_payload(&state), "logs":logs_payload(&state)}).to_string(); + let event = Event::default().event("snapshot").data(payload); + Some(( + Ok::(event), + (state, secret, permit, true), + )) + }, + ); + Ok(Sse::new(stream) + .keep_alive(KeepAlive::new().interval(Duration::from_secs(10))) + .into_response()) +} +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + config::{self, Config, ValidationPolicy}, + db::Store, + }; + use axum::body::Body; + use http::Request; + use tower::ServiceExt; + async fn fixture() -> (Router, Store, tempfile::TempDir, String, String) { + fixture_with_config(Config::default()).await + } + async fn fixture_with_config( + config: Config, + ) -> (Router, Store, tempfile::TempDir, String, String) { + let dir = tempfile::tempdir().unwrap(); + let policy = ValidationPolicy { + tls_addr: None, + cert_dir: None, + data_dir: None, + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + }; + let store = Store::open(&dir.path().join("db"), &config).await.unwrap(); + store + .initialize_admin("owner", "unused-test-hash", "test") + .await + .unwrap(); + let auth = auth::Auth::new(store.clone(), dir.path().to_path_buf()) + .await + .unwrap(); + let issued = store + .create_session(1, "browser", None) + .await + .unwrap() + .unwrap(); + let snapshot = config::compile(config, 0, &policy).unwrap(); + let state = App::new( + store.clone(), + snapshot, + policy, + auth, + 10, + Duration::from_secs(30), + Duration::from_secs(30), + Duration::from_secs(300), + ); + ( + router(state), + store, + dir, + issued.secret, + issued.session.csrf, + ) + } + async fn fixture_with_certificate_storage( + config: Config, + ) -> (Router, Store, tempfile::TempDir, String, String) { + let dir = tempfile::tempdir().unwrap(); + let certificate_directory = dir.path().join("certs"); + crate::tls::initialize_default(&certificate_directory).unwrap(); + let policy = ValidationPolicy { + tls_addr: None, + cert_dir: Some(certificate_directory), + data_dir: None, + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + }; + let store = Store::open(&dir.path().join("db"), &config).await.unwrap(); + store + .initialize_admin("owner", "unused-test-hash", "test") + .await + .unwrap(); + let auth = auth::Auth::new(store.clone(), dir.path().to_path_buf()) + .await + .unwrap(); + let issued = store + .create_session(1, "browser", None) + .await + .unwrap() + .unwrap(); + let snapshot = config::compile(config, 0, &policy).unwrap(); + let state = App::new( + store.clone(), + snapshot, + policy, + auth, + 10, + Duration::from_secs(30), + Duration::from_secs(30), + Duration::from_secs(300), + ); + ( + router(state), + store, + dir, + issued.secret, + issued.session.csrf, + ) + } + fn request(path: &str) -> http::request::Builder { + Request::builder() + .uri(path) + .header("host", "127.0.0.1:9090") + } + #[test] + fn administration_authority_uses_http2_pseudo_authority_without_legacy_host() { + let request = Request::builder() + .version(Version::HTTP_2) + .uri("https://192.0.2.10:9090/healthz") + .body(Body::empty()) + .unwrap(); + assert_eq!( + admin_request_authority(&request, true).unwrap(), + "192.0.2.10:9090" + ); + + let conflicting = Request::builder() + .version(Version::HTTP_2) + .uri("https://192.0.2.10:9090/healthz") + .header(HOST, "panel.example.test:9090") + .body(Body::empty()) + .unwrap(); + assert!(admin_request_authority(&conflicting, true).is_err()); + + let h1_absolute = Request::builder() + .uri("https://192.0.2.10:9090/healthz") + .header(HOST, "192.0.2.10:9090") + .body(Body::empty()) + .unwrap(); + assert!(admin_request_authority(&h1_absolute, true).is_err()); + } + #[tokio::test] + async fn administration_https_health_accepts_http2_authority_without_host_header() { + let (router, _store, _dir, _secret, _csrf) = fixture().await; + let request = Request::builder() + .version(Version::HTTP_2) + .uri("https://127.0.0.1:9090/healthz") + .extension(Transport { tls: true }) + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.oneshot(request).await.unwrap().status(), + StatusCode::OK + ); + } + #[test] + fn tls_admin_accepts_dns_without_allowlist_but_keeps_explicit_lists_strict() { + let wildcard: std::net::SocketAddr = "0.0.0.0:9090".parse().unwrap(); + assert!(admin_host_allowed("127.0.0.1:9090", wildcard, true, &[])); + assert!(admin_host_allowed("192.0.2.10:9090", wildcard, true, &[])); + assert!(admin_host_allowed( + "example.test:9090", + wildcard, + true, + &["example.test".into()] + )); + assert!(admin_host_allowed("example.test:9090", wildcard, true, &[])); + assert!(!admin_host_allowed( + "example.test:9090", + wildcard, + false, + &[] + )); + assert!(!admin_host_allowed("0.0.0.0:9091", wildcard, true, &[])); + assert!(!admin_host_allowed("0.0.0.0:9090", wildcard, true, &[])); + assert!(admin_host_allowed("localhost:9090", wildcard, true, &[])); + + let concrete: std::net::SocketAddr = "192.0.2.10:9090".parse().unwrap(); + assert!(admin_host_allowed("localhost:9090", concrete, true, &[])); + assert!(admin_host_allowed( + "localhost:9090", + concrete, + true, + &["localhost".into()] + )); + assert!(!admin_host_allowed( + "other.test:9090", + concrete, + true, + &["localhost".into()] + )); + } + + #[test] + fn reverse_proxy_dns_allowlist_uses_external_port_and_keeps_direct_port_available() { + let wildcard: std::net::SocketAddr = "0.0.0.0:9090".parse().unwrap(); + let allowed = vec!["panel.example.test".to_owned()]; + assert!(admin_host_allowed( + "panel.example.test", + wildcard, + false, + &allowed + )); + assert!(admin_host_allowed( + "panel.example.test:443", + wildcard, + true, + &allowed + )); + assert!(admin_host_allowed( + "panel.example.test:8443", + wildcard, + true, + &allowed + )); + assert!(admin_host_allowed( + "192.0.2.10:9090", + wildcard, + false, + &allowed + )); + assert!(admin_host_allowed( + "localhost:9090", + wildcard, + false, + &allowed + )); + assert!(!admin_host_allowed( + "192.0.2.10:443", + wildcard, + true, + &allowed + )); + assert!(!admin_host_allowed( + "other.example.test:443", + wildcard, + true, + &allowed + )); + } + + #[test] + fn origin_matching_normalizes_default_ports_without_weakening_host_checks() { + assert!(admin_origin_matches( + "https://panel.example.test", + "https", + "panel.example.test:443" + )); + assert!(admin_origin_matches( + "https://PANEL.EXAMPLE.TEST:443/", + "https", + "panel.example.test" + )); + assert!(!admin_origin_matches( + "https://panel.example.test:8443", + "https", + "panel.example.test" + )); + assert!(!admin_origin_matches( + "http://panel.example.test", + "https", + "panel.example.test" + )); + } + + #[tokio::test] + async fn trusted_https_reverse_proxy_accepts_external_origin_and_refreshes_secure_cookie() { + let mut config = Config::default(); + config.administration.allowed_hosts = vec!["panel.example.test".into()]; + let (router, store, _dir, secret, _) = fixture_with_config(config).await; + let response = router + .oneshot( + Request::builder() + .uri("/api/status") + .header("host", "127.0.0.1:9090") + .header("x-forwarded-host", "panel.example.test") + .header("x-forwarded-proto", "https") + .header("origin", "https://panel.example.test") + .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) + .extension(PeerAddr("127.0.0.1:45000".parse().unwrap())) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let refreshed = response + .headers() + .get_all(SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .find(|value| value.starts_with(auth::COOKIE_NAME)) + .expect("session cookie refreshed"); + assert!(refreshed.contains("; Secure")); + store.close().await; + } + + #[tokio::test] + async fn https_redirect_router_preserves_authority_path_and_query() { + let request = Request::builder() + .uri("/api/status?view=compact") + .header("host", "panel.example.test:9090") + .body(Body::empty()) + .unwrap(); + let response = https_redirect_router().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT); + assert_eq!( + response.headers().get(http::header::LOCATION).unwrap(), + "https://panel.example.test:9090/api/status?view=compact" + ); + assert_eq!( + response.headers().get(http::header::CACHE_CONTROL).unwrap(), + "no-store" + ); + } + + #[tokio::test] + async fn rejects_legacy_token_rebinding_and_cross_origin() { + let (router, store, _dir, secret, _) = fixture().await; + let old = request("/api/status") + .header("authorization", format!("Bearer {}", "a".repeat(64))) + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.clone().oneshot(old).await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); + let evil = Request::builder() + .uri("/healthz") + .header("host", "evil.test:9090") + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.clone().oneshot(evil).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + let req = request("/api/status") + .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) + .header("origin", "http://evil.test") + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.clone().oneshot(req).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + let req = request("/api/status") + .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) + .body(Body::empty()) + .unwrap(); + assert_eq!(router.oneshot(req).await.unwrap().status(), StatusCode::OK); + store.close().await; + } + #[tokio::test] + async fn login_bundle_is_public_but_cache_mutations_require_session_and_csrf() { + let (router, store, _dir, secret, csrf) = fixture().await; + let public = request("/assets/login-langs.json") + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.clone().oneshot(public).await.unwrap().status(), + StatusCode::OK + ); + let anonymous = request("/api/cache").body(Body::empty()).unwrap(); + assert_eq!( + router.clone().oneshot(anonymous).await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); + let anonymous = request("/api/transform").body(Body::empty()).unwrap(); + assert_eq!( + router.clone().oneshot(anonymous).await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + for valid in [false, true] { + let req = request("/api/cache/purge") + .method("POST") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header( + "x-csrf-token", + if valid { csrf.as_str() } else { "invalid" }, + ) + .body(Body::empty()) + .unwrap(); + let expected = if valid { + StatusCode::OK + } else { + StatusCode::FORBIDDEN + }; + assert_eq!( + router.clone().oneshot(req).await.unwrap().status(), + expected + ); + } + let cleanup = request("/api/cache/cold/cleanup") + .method("POST") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", "invalid") + .header("content-type", "application/json") + .body(Body::from(r#"{"oldest":1,"hits_zero":false}"#)) + .unwrap(); + assert_eq!( + router.clone().oneshot(cleanup).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + let cleanup_status = request("/api/cache/cold/cleanup/1") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.clone().oneshot(cleanup_status).await.unwrap().status(), + StatusCode::NOT_FOUND + ); + store.close().await; + } + #[tokio::test] + async fn selective_cache_invalidation_api_manages_bans_and_rejects_wide_rules_without_confirmation() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let mutate = |path: &str, method: &str, body: Body| { + request(path) + .method(method) + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(body) + .unwrap() + }; + + let create = router + .clone() + .oneshot(mutate( + "/api/cache/invalidate", + "POST", + Body::from( + r#"{"operation":"ban","selector":"exact","value":"/asset.js?v=1","host":"example.test","ttl_seconds":60}"#, + ), + )) + .await + .unwrap(); + assert_eq!(create.status(), StatusCode::OK); + let created = json_body(create).await; + let ban_id = created["result"]["ban_id"].as_u64().expect("ban id"); + assert_eq!(created["result"]["status"], "active"); + assert_eq!(created["bans"].as_array().unwrap().len(), 1); + + let list = router + .clone() + .oneshot( + request("/api/cache/bans") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(list.status(), StatusCode::OK); + assert_eq!(json_body(list).await["bans"].as_array().unwrap().len(), 1); + + let wide = router + .clone() + .oneshot(mutate( + "/api/cache/invalidate", + "POST", + Body::from(r#"{"operation":"purge","selector":"regex","value":".*"}"#), + )) + .await + .unwrap(); + assert_eq!(wide.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let remove = router + .clone() + .oneshot(mutate( + &format!("/api/cache/bans/{ban_id}"), + "DELETE", + Body::empty(), + )) + .await + .unwrap(); + assert_eq!(remove.status(), StatusCode::OK); + assert!(json_body(remove).await["bans"].as_array().unwrap().is_empty()); + + let missing = router + .clone() + .oneshot(mutate( + "/api/cache/bans/999999", + "DELETE", + Body::empty(), + )) + .await + .unwrap(); + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + store.close().await; + } + + #[tokio::test] + async fn section_updates_are_revisioned_and_do_not_require_full_config_payloads() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let logging = Config::default().logging; + let body = serde_json::to_vec(&json!({ + "expected_revision": 0, + "value": logging, + })) + .unwrap(); + let response = router + .clone() + .oneshot( + request("/api/config/logging") + .method("PUT") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(json_body(response).await["revision"], 1); + + let http2 = crate::settings::Http2Connections { + idle_timeout_seconds: 120, + max_connection_lifetime_seconds: 7_200, + drain_timeout_seconds: 15, + }; + let response = router + .clone() + .oneshot( + request("/api/config/http2") + .method("PUT") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from( + serde_json::to_vec(&json!({"expected_revision":1,"value":http2})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let updated = json_body(response).await; + assert_eq!(updated["revision"], 2); + assert_eq!(updated["config"]["http2"]["max_connection_lifetime_seconds"], 7_200); + + let stale = router + .clone() + .oneshot( + request("/api/config/logging") + .method("PUT") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from( + serde_json::to_vec( + &json!({"expected_revision":0,"value":Config::default().logging}), + ) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(stale.status(), StatusCode::CONFLICT); + + let unknown = router + .clone() + .oneshot( + request("/api/config/not-a-section") + .method("PUT") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from(r#"{"expected_revision":2,"value":{}}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unknown.status(), StatusCode::NOT_FOUND); + store.close().await; + } + + #[tokio::test] + async fn default_pages_section_accepts_the_standard_value_payload() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let pages = Config::default().pages; + let response = router + .clone() + .oneshot( + request("/api/config/pages") + .method("PUT") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from( + serde_json::to_vec(&json!({ + "expected_revision": 0, + "value": pages, + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(json_body(response).await["revision"], 1); + store.close().await; + } + + #[tokio::test] + async fn panel_subpage_routes_survive_browser_refresh() { + let (router, store, _dir, secret, _) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + for path in [ + "/overview", + "/vhosts", + "/docker-vhosts", + "/redirects", + "/404-hosts", + "/streams", + "/acl-profiles", + "/configuration", + "/tls", + "/cache", + "/transform", + "/logging", + "/settings", + ] { + let public = router + .clone() + .oneshot(request(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(public.status(), StatusCode::OK, "anonymous {path}"); + let public_body = axum::body::to_bytes(public.into_body(), 100_000) + .await + .unwrap(); + assert!(std::str::from_utf8(&public_body) + .unwrap() + .contains("/assets/login.js")); + + let private = router + .clone() + .oneshot( + request(path) + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(private.status(), StatusCode::OK, "authenticated {path}"); + let private_body = axum::body::to_bytes(private.into_body(), 2 * 1024 * 1024) + .await + .unwrap(); + assert!(std::str::from_utf8(&private_body) + .unwrap() + .contains("/assets/app.js")); + } + store.close().await; + } + #[tokio::test] + async fn csrf_required_and_logout_revokes_cookie() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let req = request("/api/auth/logout") + .method("POST") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.clone().oneshot(req).await.unwrap().status(), + StatusCode::FORBIDDEN + ); + let req = request("/api/auth/logout") + .method("POST") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", csrf) + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.clone().oneshot(req).await.unwrap().status(), + StatusCode::OK + ); + let req = request("/api/status") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.oneshot(req).await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); + store.close().await; + } + #[tokio::test] + async fn upload_and_scoped_queries_keep_control_plane_guards() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + for (path, body) in [ + ( + "/api/certificates/upload", + r#"{"cert_pem":"test","key_pem":"test","hosts":["app.localhost"]}"#, + ), + ( + "/api/certificates/deploy", + r#"{"certificate_id":"shared","cert_pem":"test","key_pem":"test"}"#, + ), + ] { + for (authenticated, valid_csrf, expected) in + [(false, false, 401), (true, false, 403), (true, true, 409)] + { + let mut req = request(path) + .method("POST") + .header("content-type", "application/json") + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1"); + if authenticated { + req = req.header("cookie", &cookie); + } + if valid_csrf { + req = req.header("x-csrf-token", &csrf); + } + assert_eq!( + router + .clone() + .oneshot(req.body(Body::from(body)).unwrap()) + .await + .unwrap() + .status() + .as_u16(), + expected + ); + } + } + for (path, expected) in [ + ("/api/logs?vhost=demo&limit=8", 200), + ("/api/logs?cache=BAD", 400), + ("/api/cache", 200), + ("/api/transform", 200), + ] { + let req = request(path) + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.clone().oneshot(req).await.unwrap().status().as_u16(), + expected + ); + } + let req = request("/api/cache/purge?vhost=missing") + .method("POST") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.oneshot(req).await.unwrap().status(), + StatusCode::NOT_FOUND + ); + store.close().await; + } + + #[tokio::test] + async fn certificate_reload_keeps_configuration_revision_stable() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let response = router + .clone() + .oneshot( + request("/api/certificates/reload") + .method("POST") + .header("content-type", "application/json") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let payload = json_body(response).await; + assert_eq!(payload["reloaded"], true); + assert_eq!(payload["revision"], 0); + + let configuration = router + .oneshot( + request("/api/config") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(json_body(configuration).await["revision"], 0); + store.close().await; + } + #[tokio::test] + async fn manual_certificate_can_be_reloaded_by_id_without_revision_change() { + let mut config = Config::default(); + config.tls.certificates.push(crate::tls::Certificate { + issuer: None, + id: "manual-file".into(), + covered_hosts: Vec::new(), + hosts: Vec::new(), + cert_file: "default.pem".into(), + key_file: "default.key".into(), + }); + let (router, store, _dir, secret, csrf) = fixture_with_certificate_storage(config).await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let response = router + .clone() + .oneshot( + request("/api/certificates/manual-file/reload") + .method("POST") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let payload = json_body(response).await; + assert_eq!(payload["certificate_id"], "manual-file"); + assert_eq!(payload["revision"], 0); + assert_eq!(payload["reloaded"], true); + + let api_token = "c".repeat(64); + store + .upsert_certificate_api_credential( + "file-renewer", + &crate::auth::secret_hash(&api_token), + "external file renewal hook", + "owner", + ) + .await + .unwrap(); + let external = router + .clone() + .oneshot( + request("/api/certificates/manual-file/reload") + .method("POST") + .header("authorization", format!("Bearer {api_token}")) + .header("x-rustproxy-certificate-key", "file-renewer") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(external.status(), StatusCode::OK); + assert_eq!(json_body(external).await["certificate_id"], "manual-file"); + store.close().await; + } + + #[tokio::test] + async fn certificate_api_tokens_are_scoped_commented_and_individually_revocable() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let token = "a".repeat(64); + let token_two = "b".repeat(64); + let invalid_key = router + .clone() + .oneshot( + request("/api/certificate-api") + .method("PUT") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .header("content-type", "application/json") + .body(Body::from(json!({"key_id":"x","token":token}).to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(invalid_key.status(), StatusCode::UNPROCESSABLE_ENTITY); + + for (key_id, value, comment) in [ + ("certbot-main", token.as_str(), "production certbot"), + ("deploy-hook", token_two.as_str(), "secondary deploy hook"), + ] { + let rotated = router + .clone() + .oneshot( + request("/api/certificate-api") + .method("PUT") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .header("content-type", "application/json") + .body(Body::from( + json!({"key_id":key_id,"token":value,"comment":comment}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(rotated.status(), StatusCode::OK); + let rotated = json_body(rotated).await; + assert_eq!(rotated["configured"], true); + assert_eq!(rotated["credential"]["key_id"], key_id); + assert_eq!(rotated["credential"]["comment"], comment); + assert_eq!(rotated["token"], value); + } + + let status = router + .clone() + .oneshot( + request("/api/certificate-api") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = json_body(status).await; + assert_eq!(status["configured"], true); + assert_eq!(status["credentials"].as_array().unwrap().len(), 2); + assert!(status.get("token").is_none()); + + let external = |key_id: &'static str, value: String| { + request("/api/certificates/reload") + .method("POST") + .header("authorization", format!("Bearer {value}")) + .header("x-rustproxy-certificate-key", key_id) + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap() + }; + let response = router + .clone() + .oneshot(external("certbot-main", token.clone())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let response = router + .clone() + .oneshot(external("deploy-hook", token_two.clone())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let missing_key = router + .clone() + .oneshot( + request("/api/certificates/reload") + .method("POST") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(missing_key.status(), StatusCode::UNAUTHORIZED); + + let forbidden_scope = router + .clone() + .oneshot( + request("/api/config") + .header("authorization", format!("Bearer {token}")) + .header("x-rustproxy-certificate-key", "certbot-main") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(forbidden_scope.status(), StatusCode::UNAUTHORIZED); + + let revoked = router + .clone() + .oneshot( + request("/api/certificate-api/certbot-main") + .method("DELETE") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(revoked.status(), StatusCode::OK); + let response = router + .clone() + .oneshot(external("certbot-main", token.clone())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + let response = router + .clone() + .oneshot(external("deploy-hook", token_two.clone())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let revoked_all = router + .clone() + .oneshot( + request("/api/certificate-api") + .method("DELETE") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(revoked_all.status(), StatusCode::OK); + let response = router + .oneshot(external("deploy-hook", token_two)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + store.close().await; + } + + #[tokio::test] + async fn admin_preferences_are_account_scoped_and_csrf_guarded() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + + let response = router + .clone() + .oneshot( + request("/api/preferences") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let initial = json_body(response).await; + assert_eq!( + initial["live_log_columns"].as_array().unwrap().len(), + LIVE_LOG_COLUMNS.len() + ); + assert_eq!(initial["theme"], "dark"); + + assert!(store + .save_admin_preferences("owner", r#"{"live_log_columns":["time","status"]}"#) + .await + .unwrap()); + let response = router + .clone() + .oneshot( + request("/api/preferences") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let legacy = json_body(response).await; + assert_eq!(legacy["live_log_columns"], json!(["time", "status"])); + assert_eq!(legacy["theme"], "dark"); + + let selected = json!({ + "live_log_columns":["time","request","status","upstream_error"], + "theme":"light" + }); + let response = router + .clone() + .oneshot( + request("/api/preferences") + .method("PUT") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .header("content-type", "application/json") + .body(Body::from(selected.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(json_body(response).await, selected); + + let saved: Value = + serde_json::from_str(&store.admin_preferences("owner").await.unwrap().unwrap()) + .unwrap(); + assert_eq!(saved, selected); + + let response = router + .clone() + .oneshot( + request("/api/preferences") + .method("PUT") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("content-type", "application/json") + .body(Body::from(selected.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + let response = router + .clone() + .oneshot( + request("/api/preferences") + .method("PUT") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .header("content-type", "application/json") + .body(Body::from(r#"{"live_log_columns":[]}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + + let response = router + .oneshot( + request("/api/preferences") + .method("PUT") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .header("content-type", "application/json") + .body(Body::from( + r#"{"live_log_columns":["time"],"theme":"sepia"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + store.close().await; + } + + #[tokio::test] + async fn acme_and_cache_inspection_require_existing_admin_guards() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + for path in ["/api/acme", "/api/cache?offset=0&limit=2"] { + let req = request(path).body(Body::empty()).unwrap(); + assert_eq!( + router.clone().oneshot(req).await.unwrap().status(), + StatusCode::UNAUTHORIZED + ); + let req = request(path) + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.clone().oneshot(req).await.unwrap().status(), + StatusCode::OK + ); + } + for (authenticated, valid_csrf, expected) in + [(false, false, 401), (true, false, 403), (true, true, 409)] + { + let mut req = request("/api/acme/issue") + .method("POST") + .header("content-type", "application/json") + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1"); + if authenticated { + req = req.header("cookie", &cookie); + } + if valid_csrf { + req = req.header("x-csrf-token", &csrf); + } + // This fixture has no certificate directory: valid auth reaches the + // 409 precondition, never a child process, DNS update or CA request. + let response = router + .clone() + .oneshot(req.body(Body::from("{}")).unwrap()) + .await + .unwrap(); + assert_eq!(response.status().as_u16(), expected); + } + let req = request("/api/cache?vhost=missing") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(); + assert_eq!( + router.oneshot(req).await.unwrap().status(), + StatusCode::NOT_FOUND + ); + store.close().await; + } + + async fn json_body(response: Response) -> Value { + let bytes = axum::body::to_bytes(response.into_body(), 2 * 1024 * 1024) + .await + .unwrap(); + serde_json::from_slice(&bytes).unwrap() + } + #[tokio::test] + async fn external_certificate_deploy_creates_updates_and_noops() { + let (router, store, dir, secret, csrf) = + fixture_with_certificate_storage(Config::default()).await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let first = rcgen::generate_simple_self_signed(vec!["app.example.test".into()]).unwrap(); + let first_body = json!({ + "certificate_id": "opnsense-cert", + "cert_pem": first.cert.pem(), + "key_pem": first.key_pair.serialize_pem(), + }) + .to_string(); + + let deploy = |body: String| { + request("/api/certificates/deploy") + .method("POST") + .header("content-type", "application/json") + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("cookie", &cookie) + .header("x-csrf-token", &csrf) + .body(Body::from(body)) + .unwrap() + }; + + let response = router.clone().oneshot(deploy(first_body.clone())).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let created = json_body(response).await; + assert_eq!(created["operation"], "created"); + assert_eq!(created["created"], true); + assert_eq!(created["changed"], true); + let first_revision = created["revision"].as_u64().unwrap(); + let first_cert_file = created["cert_file"].as_str().unwrap().to_owned(); + let first_key_file = created["key_file"].as_str().unwrap().to_owned(); + assert!(dir.path().join("certs").join(&first_cert_file).is_file()); + assert!(dir.path().join("certs").join(&first_key_file).is_file()); + + let response = router.clone().oneshot(deploy(first_body)).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let unchanged = json_body(response).await; + assert_eq!(unchanged["operation"], "unchanged"); + assert_eq!(unchanged["created"], false); + assert_eq!(unchanged["changed"], false); + assert_eq!(unchanged["revision"].as_u64(), Some(first_revision)); + assert_eq!(unchanged["cert_file"].as_str(), Some(first_cert_file.as_str())); + assert_eq!(unchanged["key_file"].as_str(), Some(first_key_file.as_str())); + + let second = rcgen::generate_simple_self_signed(vec!["app.example.test".into()]).unwrap(); + let second_body = json!({ + "certificate_id": "opnsense-cert", + "cert_pem": second.cert.pem(), + "key_pem": second.key_pair.serialize_pem(), + }) + .to_string(); + let response = router.oneshot(deploy(second_body)).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let updated = json_body(response).await; + assert_eq!(updated["operation"], "updated"); + assert_eq!(updated["created"], false); + assert_eq!(updated["changed"], true); + assert!(updated["revision"].as_u64().unwrap() > first_revision); + assert_ne!(updated["cert_file"].as_str(), Some(first_cert_file.as_str())); + assert_ne!(updated["key_file"].as_str(), Some(first_key_file.as_str())); + assert!(!dir.path().join("certs").join(&first_cert_file).exists()); + assert!(!dir.path().join("certs").join(&first_key_file).exists()); + store.close().await; + } + #[tokio::test] + async fn anonymous_operational_paths_fail_closed_before_extractors() { + let (router, store, _dir, _, _) = fixture().await; + for path in [ + "/api/status", + "/api/config", + "/api/backends", + "/api/docker", + "/api/cache?limit=invalid", + "/api/logs?limit=invalid", + "/api/live-logs", + "/api/certificate-api", + "/api/preferences", + "/api/audit", + "/api/events", + "/api/certificates", + "/api/acme", + "/api/auth/session", + "/api/metrics", + "/metrics", + "/docs/openapi.json", + "/docs/init.js", + "/docs/style.css", + "/docs/swagger-ui-bundle.js", + "/docs/swagger-ui.css", + "/docs/licenses.txt", + "/assets/langs.json", + "/assets/app.js", + "/assets/management.js", + "/assets/integrations.js", + "/assets/cache-panel.js", + "/assets/transform-panel.js", + "/assets/vhosts.js", + "/assets/docker.js", + "/assets/streams.js", + "/assets/redirects.js", + "/assets/host404.js", + "/assets/settings.js", + "/assets/acme.js", + "/api/not-a-route", + "/not-a-route", + ] { + let response = router + .clone() + .oneshot(request(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{path}"); + assert_eq!(response.headers()["cache-control"], "no-store"); + assert!(!response + .headers() + .contains_key("access-control-allow-origin")); + assert!(!response.headers().contains_key("server")); + assert_eq!( + json_body(response).await, + json!({"error":"auth.required"}), + "{path}" + ); + } + for (path, method) in [ + ("/api/config", "PUT"), + ("/api/config/cache", "PUT"), + ("/api/acme/issue", "POST"), + ("/api/acme/renew", "POST"), + ("/api/docker/refresh", "POST"), + ("/api/cache/purge", "POST"), + ("/api/cache/invalidate", "POST"), + ("/api/cache/bans", "GET"), + ("/api/cache/bans/1", "DELETE"), + ("/api/tunnels", "GET"), + ("/api/tunnels/1/kill", "POST"), + ("/api/cache/cold/cleanup", "POST"), + ("/api/cache/cold/cleanup/1", "GET"), + ("/api/certificates/upload", "POST"), + ("/api/certificates/deploy", "POST"), + ("/api/certificates/reload", "POST"), + ("/api/certificates/manual-file/reload", "POST"), + ("/api/certificate-api", "PUT"), + ("/api/certificate-api/certbot-main", "DELETE"), + ("/api/certificate-api", "DELETE"), + ("/api/basic-auth/hash", "POST"), + ("/api/auth/password", "POST"), + ("/api/auth/logout", "POST"), + ("/api/auth/logout-all", "POST"), + ("/api/config", "OPTIONS"), + ("/api/config", "DELETE"), + ("/api/auth/login", "GET"), + ("/api/auth/setup", "GET"), + ("/healthz", "POST"), + ("/docs/openapi.json", "HEAD"), + ] { + let response = router + .clone() + .oneshot( + request(path) + .method(method) + .header("content-type", "application/json") + .body(Body::from("malformed secret payload")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "{method} {path}" + ); + if method != "HEAD" { + assert_eq!(json_body(response).await, json!({"error":"auth.required"})); + } + } + store.close().await; + } + #[tokio::test] + async fn public_document_and_discovery_contain_only_login_information() { + let (router, store, _dir, _, _) = fixture().await; + for (path, expected) in [ + ("/healthz", json!({"status":"ok"})), + ("/api/auth/status", json!({"setup_required":false})), + ] { + let response = router + .clone() + .oneshot(request(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(json_body(response).await, expected); + } + let response = router + .clone() + .oneshot(request("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + let bytes = axum::body::to_bytes(response.into_body(), 100_000) + .await + .unwrap(); + let text = std::str::from_utf8(&bytes).unwrap(); + assert!(text.contains("/assets/login.js")); + for absent in [ + "/assets/app.js", + "id=\"workspace\"", + "loginLead", + "config-editor", + "version", + ] { + assert!(!text.contains(absent), "unexpected public content {absent}"); + } + let response = router + .clone() + .oneshot( + request("/assets/login-langs.json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let bundle = json_body(response).await; + let keys: Vec = serde_json::from_str(include_str!("web/login-keys.json")).unwrap(); + for language in bundle["languages"].as_array().unwrap() { + assert!(language["translations"] + .as_object() + .unwrap() + .keys() + .all(|key| keys.contains(key))); + assert!(language["translations"].get("cacheTitle").is_none()); + } + store.close().await; + } + #[tokio::test] + async fn docs_require_the_same_session_and_do_not_redirect_schema_fetches() { + let (router, store, _dir, secret, _) = fixture().await; + for path in ["/docs", "/docs/"] { + let response = router + .clone() + .oneshot(request(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers()["location"], "/?next=docs"); + } + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + for path in [ + "/", + "/docs", + "/docs/", + "/docs/init.js", + "/docs/style.css", + "/docs/swagger-ui-bundle.js", + "/docs/swagger-ui.css", + "/docs/licenses.txt", + "/assets/app.js", + ] { + let response = router + .clone() + .oneshot( + request(path) + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{path}"); + assert_eq!(response.headers()["x-frame-options"], "DENY"); + assert_eq!(response.headers()["cache-control"], "no-store"); + } + let response = router + .clone() + .oneshot( + request("/docs/openapi.json") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let spec = json_body(response).await; + assert_eq!(spec["openapi"], "3.0.3"); + assert_eq!(spec["info"]["version"], env!("CARGO_PKG_VERSION")); + assert_eq!( + spec["components"]["securitySchemes"]["AdminSession"]["in"], + "cookie" + ); + assert!(spec["paths"].get("/api/config").is_some()); + store.close().await; + } + #[tokio::test] + async fn credentials_in_query_or_duplicate_cookies_cannot_authorize() { + let (router, store, _dir, secret, _) = fixture().await; + for cookie in [ + String::new(), + format!("{}=invalid", auth::COOKIE_NAME), + format!( + "{}={secret}; {}={secret}", + auth::COOKIE_NAME, + auth::COOKIE_NAME + ), + ] { + let path = format!("/api/config?token={secret}&session={secret}"); + let response = router + .clone() + .oneshot( + request(&path) + .header("cookie", cookie) + .header("authorization", format!("Bearer {secret}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + store.close().await; + } + #[tokio::test] + async fn duplicate_csrf_or_marker_is_rejected_and_session_survives() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + for duplicate in ["x-csrf-token", "x-rustproxy-request"] { + let response = router + .clone() + .oneshot( + request("/api/auth/logout") + .method("POST") + .header("cookie", &cookie) + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &csrf) + .header( + duplicate, + if duplicate == "x-csrf-token" { + csrf.as_str() + } else { + "1" + }, + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + let response = router + .oneshot( + request("/api/auth/session") + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + store.close().await; + } + #[tokio::test] + async fn malformed_anonymous_json_never_reflects_submitted_values() { + let (router, store, _dir, _, _) = fixture().await; + for path in ["/api/auth/login", "/api/auth/setup"] { + let response = router + .clone() + .oneshot( + request(path) + .method("POST") + .header("origin", "http://127.0.0.1:9090") + .header("x-rustproxy-request", "1") + .header("content-type", "application/json") + .body(Body::from( + r#"{"username":{"untrusted":"DO_NOT_REFLECT_ME"}}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + json_body(response).await, + json!({"error":"auth.invalid_request"}) + ); + } + store.close().await; + } +} + +#[derive(Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct CacheQuery { + vhost: Option, + offset: Option, + limit: Option, +} +async fn cache_status( + State(state): State>, + Query(query): Query, +) -> Result, ApiError> { + let snapshot = state.config.load_full(); + if query + .vhost + .as_ref() + .is_some_and(|id| !snapshot.envelope.config.vhosts.iter().any(|v| &v.id == id)) + { + return Err(ApiError( + StatusCode::NOT_FOUND, + "unknown vhost cache scope".into(), + )); + } + let scopes: Vec<_> = snapshot.envelope.config.vhosts.iter().map(|vhost| { + json!({"id":vhost.id, "hosts":vhost.frontend.hosts, "metrics":state.cache.scope_view(&vhost.id), + "settings":vhost.cache.resolve(&snapshot.envelope.config.cache).ok()}) + }).collect(); + let dashboard = state.cache.dashboard( + query.vhost.as_deref(), + query.offset.unwrap_or(0).min(16384), + query.limit.unwrap_or(50), + ); + Ok(Json( + json!({"settings":snapshot.envelope.config.cache, "metrics":state.cache.view(), "vhosts":scopes, "dashboard":dashboard, "bans":state.cache.bans()}), + )) +} + +async fn transform_status(State(state): State>) -> Json { + let snapshot = state.config.load_full(); + let global = &snapshot.envelope.config.transform; + let vhosts: Vec<_> = snapshot + .envelope + .config + .vhosts + .iter() + .map(|vhost| { + json!({ + "id": vhost.id, + "hosts": vhost.frontend.hosts, + "settings": vhost.transform.resolve(global).ok() + }) + }) + .collect(); + Json(json!({ + "settings": global, + "metrics": state.transform_metrics.view(), + "vhosts": vhosts + })) +} + +#[derive(Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct PurgeScope { + vhost: Option, +} +async fn cache_purge( + State(state): State>, + Extension(session): Extension, + Query(scope): Query, +) -> Result, ApiError> { + if let Some(id) = &scope.vhost { + if !state + .config + .load() + .envelope + .config + .vhosts + .iter() + .any(|host| &host.id == id) + { + return Err(ApiError( + StatusCode::NOT_FOUND, + "unknown vhost cache scope".into(), + )); + } + } + let detail = format!( + "scope={}; in-flight fills invalidated", + scope.vhost.as_deref().unwrap_or("all") + ); + state + .store + .audit(&session.username, "cache.purge", &detail) + .await + .map_err(auth::internal)?; + let purged = match &scope.vhost { + Some(id) => state.cache.purge_scope(id), + None => state.cache.purge(None), + }; + Ok(Json( + json!({"purged_entries":purged, "vhost":scope.vhost, "metrics":state.cache.view()}), + )) +} + + +async fn cache_invalidate( + State(state): State>, + Extension(session): Extension, + input: Result, axum::extract::rejection::JsonRejection>, +) -> Result, ApiError> { + let Json(request) = input.map_err(public::invalid_json)?; + if let Some(id) = &request.vhost { + if !state + .config + .load() + .envelope + .config + .vhosts + .iter() + .any(|host| &host.id == id) + { + return Err(ApiError( + StatusCode::NOT_FOUND, + "unknown vhost cache scope".into(), + )); + } + } + let detail = format!( + "operation={:?}; selector={:?}; vhost={}; host={}; value={:?}", + request.operation, + request.selector, + request.vhost.as_deref().unwrap_or("all"), + request.host.as_deref().unwrap_or("any"), + request.value + ); + state + .store + .audit(&session.username, "cache.invalidate", &detail) + .await + .map_err(auth::internal)?; + let result = state + .cache + .invalidate(request) + .map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, error.to_string()))?; + Ok(Json(json!({ + "result": result, + "metrics": state.cache.view(), + "bans": state.cache.bans() + }))) +} + +async fn cache_bans(State(state): State>) -> Json { + Json(json!({"bans": state.cache.bans()})) +} + +async fn cache_ban_delete( + State(state): State>, + Extension(session): Extension, + Path(id): Path, +) -> Result, ApiError> { + if !state.cache.remove_ban(id) { + return Err(ApiError(StatusCode::NOT_FOUND, "unknown cache BAN rule".into())); + } + state + .store + .audit(&session.username, "cache.ban.remove", &format!("ban_id={id}")) + .await + .map_err(auth::internal)?; + Ok(Json(json!({"removed": id, "bans": state.cache.bans()}))) +} + +async fn active_tunnels(State(state): State>) -> Json { + Json(json!({"tunnels": state.tunnel_registry.list()})) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct KillTunnelRequest { + #[serde(default)] + reason: Option, +} + +async fn kill_tunnel( + State(state): State>, + Extension(session): Extension, + Path(id): Path, + input: Result, axum::extract::rejection::JsonRejection>, +) -> Result, ApiError> { + let Json(input) = input.map_err(public::invalid_json)?; + let reason = input + .reason + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("administrator request"); + if reason.len() > 256 || reason.chars().any(char::is_control) { + return Err(ApiError( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid tunnel close reason".into(), + )); + } + let Some(tunnel) = state.tunnel_registry.kill(id) else { + return Err(ApiError(StatusCode::NOT_FOUND, "unknown active tunnel".into())); + }; + state + .store + .audit( + &session.username, + "tunnel.kill", + &format!( + "tunnel_id={}; client={}; host={}; upstream={}; protocol={}; reason={}", + tunnel.id, tunnel.client, tunnel.host, tunnel.upstream, tunnel.protocol, reason + ), + ) + .await + .map_err(auth::internal)?; + Ok(Json(json!({"closed": tunnel, "reason": reason}))) +} + +async fn cache_cold_cleanup( + State(state): State>, + Extension(session): Extension, + input: Result, axum::extract::rejection::JsonRejection>, +) -> Result<(StatusCode, Json), ApiError> { + let Json(request) = input.map_err(public::invalid_json)?; + let detail = format!( + "unused_for_seconds={:?}; oldest={:?}; hits_zero={}", + request.unused_for_seconds, request.oldest, request.hits_zero + ); + let job = state + .cache + .start_cold_cleanup(request) + .map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, error.to_string()))?; + state + .store + .audit(&session.username, "cache.cold_cleanup", &detail) + .await + .map_err(auth::internal)?; + Ok((StatusCode::ACCEPTED, Json(json!({"job": job})))) +} + +async fn cache_cold_cleanup_job( + State(state): State>, + Path(id): Path, +) -> Result, ApiError> { + let job = state + .cache + .cold_cleanup_job(id) + .ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "unknown cold-cache cleanup job".into()))?; + Ok(Json(json!({"job": job}))) +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CertificateUpload { + cert_pem: String, + key_pem: String, + hosts: Vec, +} +async fn certificate_upload( + State(state): State>, + Extension(session): Extension, + Json(input): Json, +) -> Result, ApiError> { + let key = zeroize::Zeroizing::new(input.key_pem); + let directory = state.policy.cert_dir.clone().ok_or_else(|| { + ApiError( + StatusCode::CONFLICT, + "certificate storage is unavailable; configure the certificate directory".into(), + ) + })?; + let permit = state + .certificate_slots + .clone() + .try_acquire_owned() + .map_err(|_| { + ApiError( + StatusCode::TOO_MANY_REQUESTS, + "certificate upload queue is full".into(), + ) + })?; + let certificate = tokio::task::spawn_blocking(move || { + let _permit = permit; + crate::tls::upload( + &directory, + input.cert_pem.as_bytes(), + key.as_bytes(), + input.hosts, + ) + }) + .await + .map_err(|_| { + ApiError( + StatusCode::INTERNAL_SERVER_ERROR, + "certificate upload task failed".into(), + ) + })? + .map_err(|e| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{e:#}")))?; + state + .store + .audit(&session.username, "certificate.upload", &certificate.id) + .await + .map_err(auth::internal)?; + // Never serialize the PEM request or any key material, including on success. + Ok(Json(json!({"certificate":certificate, "staged":true}))) +} + + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CertificateApiRotate { + #[serde(default)] + key_id: Option, + #[serde(default)] + token: Option, + #[serde(default)] + comment: Option, +} + +fn valid_certificate_api_comment(value: &str) -> bool { + value.len() <= 256 && !value.chars().any(char::is_control) +} + +fn certificate_api_status_value(values: Vec) -> Value { + let legacy = values.first(); + json!({ + "configured": !values.is_empty(), + "key_id": legacy.map(|value| value.key_id.clone()), + "created_at": legacy.map(|value| value.created_at), + "last_used_at": legacy.and_then(|value| value.last_used_at), + "credentials": values, + "max_credentials": crate::db::CERTIFICATE_API_CREDENTIAL_CAPACITY, + "scopes": ["certificates:deploy", "certificates:reload"], + }) +} + +async fn certificate_api_status(State(state): State>) -> Result, ApiError> { + let values = state + .store + .certificate_api_credentials() + .await + .map_err(auth::internal)?; + Ok(Json(certificate_api_status_value(values))) +} + +async fn certificate_api_rotate( + State(state): State>, + Extension(session): Extension, + Json(input): Json, +) -> Result, ApiError> { + let generated = input.token.is_none(); + let token = input.token.unwrap_or_else(auth::random_secret); + if !valid_certificate_api_token(&token) { + return Err(ApiError( + StatusCode::UNPROCESSABLE_ENTITY, + "certificate_api.invalid_token_format".into(), + )); + } + let key_id = input.key_id.unwrap_or_else(|| { + let random = auth::random_secret(); + format!("certapi-{}", &random[..12]) + }); + if !valid_certificate_api_key_id(&key_id) { + return Err(ApiError( + StatusCode::UNPROCESSABLE_ENTITY, + "certificate_api.invalid_key_id".into(), + )); + } + let comment = input.comment.unwrap_or_default().trim().to_owned(); + if !valid_certificate_api_comment(&comment) { + return Err(ApiError( + StatusCode::UNPROCESSABLE_ENTITY, + "certificate_api.invalid_comment".into(), + )); + } + let token_hash = auth::secret_hash(&token); + let credential = state + .store + .upsert_certificate_api_credential( + &key_id, + &token_hash, + &comment, + &session.username, + ) + .await + .map_err(|error| { + if error.to_string().contains("credential limit reached") { + ApiError(StatusCode::CONFLICT, "certificate_api.limit_reached".into()) + } else { + auth::internal(error) + } + })?; + let values = state + .store + .certificate_api_credentials() + .await + .map_err(auth::internal)?; + let mut value = certificate_api_status_value(values); + value["credential"] = serde_json::to_value(&credential).map_err(auth::internal)?; + value["token"] = Value::String(token); + value["generated"] = Value::Bool(generated); + Ok(Json(value)) +} + +async fn certificate_api_revoke( + State(state): State>, + Extension(session): Extension, + Path(key_id): Path, +) -> Result, ApiError> { + if !valid_certificate_api_key_id(&key_id) { + return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into())); + } + let revoked = state + .store + .revoke_certificate_api_credential(&key_id, &session.username) + .await + .map_err(auth::internal)?; + if !revoked { + return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into())); + } + Ok(Json(json!({"revoked": true, "key_id": key_id}))) +} + +async fn certificate_api_revoke_all( + State(state): State>, + Extension(session): Extension, +) -> Result, ApiError> { + let revoked = state + .store + .revoke_all_certificate_api_credentials(&session.username) + .await + .map_err(auth::internal)?; + Ok(Json(json!({"configured": false, "revoked": revoked}))) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CertificateDeploy { + certificate_id: String, + cert_pem: String, + key_pem: String, +} + +async fn certificate_deploy( + State(state): State>, + Extension(session): Extension, + Json(input): Json, +) -> Result, ApiError> { + let directory = state.policy.cert_dir.clone().ok_or_else(|| { + ApiError( + StatusCode::CONFLICT, + "certificate storage is unavailable; configure the certificate directory".into(), + ) + })?; + let CertificateDeploy { + certificate_id, + cert_pem, + key_pem, + } = input; + let certificate_id = certificate_id.trim().to_owned(); + if !crate::config::valid_id(&certificate_id) { + return Err(ApiError( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid certificate id".into(), + )); + } + + let snapshot = state.config.load_full(); + let expected_revision = snapshot.envelope.revision; + let mut config = snapshot.envelope.config.clone(); + let existing_index = config + .tls + .certificates + .iter() + .position(|certificate| certificate.id == certificate_id); + let previous_pair = if let Some(index) = existing_index { + let selected = &config.tls.certificates[index]; + if selected.issuer.is_some() { + return Err(ApiError( + StatusCode::CONFLICT, + format!( + "certificate {certificate_id} is managed by RustProxy ACME; use a separate manual certificate id for an external issuer" + ), + )); + } + Some((selected.cert_file.clone(), selected.key_file.clone())) + } else { + None + }; + + let key = zeroize::Zeroizing::new(key_pem); + let permit = state + .certificate_slots + .clone() + .try_acquire_owned() + .map_err(|_| { + ApiError( + StatusCode::TOO_MANY_REQUESTS, + "certificate deployment queue is full".into(), + ) + })?; + let id_for_stage = certificate_id.clone(); + let current_pair = previous_pair.clone(); + let deployment = tokio::task::spawn_blocking(move || { + let _permit = permit; + crate::tls::reconcile_external_pair( + &directory, + &id_for_stage, + current_pair + .as_ref() + .map(|(cert_file, key_file)| (cert_file.as_str(), key_file.as_str())), + cert_pem.as_bytes(), + key.as_bytes(), + ) + }) + .await + .map_err(|_| { + ApiError( + StatusCode::INTERNAL_SERVER_ERROR, + "certificate deployment task failed".into(), + ) + })? + .map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{error:#}")))?; + + match deployment { + crate::tls::ExternalPairDeployment::Unchanged { covered_hosts } => { + let index = existing_index.expect("unchanged deployment requires an existing pair"); + let cert_file = config.tls.certificates[index].cert_file.clone(); + let key_file = config.tls.certificates[index].key_file.clone(); + + if config.tls.certificates[index].covered_hosts == covered_hosts { + // A no-op does not pass through update_as(), so explicitly retain + // the same optimistic-concurrency guarantee as a real publish. + if state.config.load().envelope.revision != expected_revision { + return Err(ApiError( + StatusCode::CONFLICT, + "configuration changed during certificate deployment; retry".into(), + )); + } + return Ok(Json(json!({ + "deployed": true, + "operation": "unchanged", + "created": false, + "changed": false, + "certificate_id": certificate_id, + "revision": expected_revision, + "cert_file": cert_file, + "key_file": key_file, + "covered_hosts": covered_hosts, + "public_tls": true, + "http3_new_handshakes": state.config.load().quic_tls.is_some(), + "administration_restart_required": false + }))); + } + + config.tls.certificates[index].covered_hosts = covered_hosts.clone(); + let updated = state + .update_as( + Update { + expected_revision, + config, + }, + session.username.clone(), + ) + .await?; + state + .store + .audit( + &session.username, + "certificate.deploy", + &format!( + "certificate_id={certificate_id}; operation=metadata-updated; cert_file={cert_file}" + ), + ) + .await + .map_err(auth::internal)?; + return Ok(Json(json!({ + "deployed": true, + "operation": "metadata-updated", + "created": false, + "changed": true, + "certificate_id": certificate_id, + "revision": updated.revision, + "cert_file": cert_file, + "key_file": key_file, + "covered_hosts": covered_hosts, + "public_tls": true, + "http3_new_handshakes": state.config.load().quic_tls.is_some(), + "administration_restart_required": false + }))); + } + crate::tls::ExternalPairDeployment::Staged { + cert_file, + key_file, + covered_hosts, + } => { + let created = existing_index.is_none(); + if let Some(index) = existing_index { + let selected = &mut config.tls.certificates[index]; + selected.cert_file = cert_file.clone(); + selected.key_file = key_file.clone(); + selected.covered_hosts = covered_hosts.clone(); + } else { + config.tls.certificates.push(crate::tls::Certificate { + issuer: None, + id: certificate_id.clone(), + covered_hosts: covered_hosts.clone(), + hosts: Vec::new(), + cert_file: cert_file.clone(), + key_file: key_file.clone(), + }); + } + + let administration_restart_required = config.administration.tls + && config.administration.certificate_id.as_deref() + == Some(certificate_id.as_str()); + let result = state + .update_as( + Update { + expected_revision, + config, + }, + session.username.clone(), + ) + .await; + let updated = match result { + Ok(updated) => updated, + Err(error) => { + let rollback_directory = state.policy.cert_dir.clone(); + let rollback_cert = cert_file.clone(); + let rollback_key = key_file.clone(); + if let Some(rollback_directory) = rollback_directory { + match tokio::task::spawn_blocking(move || { + crate::tls::remove_external_pair( + &rollback_directory, + &rollback_cert, + &rollback_key, + ) + }) + .await + { + Err(join_error) => tracing::warn!(%join_error, "external certificate rollback task failed"), + Ok(Err(rollback_error)) => tracing::warn!(%rollback_error, "external certificate rollback failed"), + Ok(Ok(())) => {} + } + } + return Err(error); + } + }; + + if let Some(previous_pair) = previous_pair { + let previous_still_referenced = + updated.config.tls.certificates.iter().any(|certificate| { + certificate.cert_file == previous_pair.0 + || certificate.key_file == previous_pair.1 + }); + if !previous_still_referenced + && previous_pair.0.starts_with("external-") + && previous_pair.1.starts_with("external-") + { + if let Some(cleanup_directory) = state.policy.cert_dir.clone() { + let cleanup_cert = previous_pair.0.clone(); + let cleanup_key = previous_pair.1.clone(); + match tokio::task::spawn_blocking(move || { + crate::tls::remove_external_pair( + &cleanup_directory, + &cleanup_cert, + &cleanup_key, + ) + }) + .await + { + Err(join_error) => tracing::warn!(%join_error, "previous external certificate cleanup task failed"), + Ok(Err(cleanup_error)) => tracing::warn!(%cleanup_error, "previous external certificate cleanup failed"), + Ok(Ok(())) => {} + } + } + } + } + + let operation = if created { "created" } else { "updated" }; + state + .store + .audit( + &session.username, + "certificate.deploy", + &format!( + "certificate_id={certificate_id}; operation={operation}; cert_file={cert_file}" + ), + ) + .await + .map_err(auth::internal)?; + Ok(Json(json!({ + "deployed": true, + "operation": operation, + "created": created, + "changed": true, + "certificate_id": certificate_id, + "revision": updated.revision, + "cert_file": cert_file, + "key_file": key_file, + "covered_hosts": covered_hosts, + "public_tls": true, + "http3_new_handshakes": state.config.load().quic_tls.is_some(), + "administration_restart_required": administration_restart_required + }))) + } + } +} + +async fn certificate_reload( + State(state): State>, + Extension(session): Extension, +) -> Result, ApiError> { + let revision = state.reload_certificates().await?; + let snapshot = state.config.load_full(); + let admin_certificate_restart_required = snapshot + .envelope + .config + .administration + .tls + && snapshot.envelope.config.administration.certificate_id.is_some(); + state + .store + .audit( + &session.username, + "certificate.reload", + "re-read configured certificate files", + ) + .await + .map_err(auth::internal)?; + Ok(Json(json!({ + "reloaded": true, + "revision": revision, + "public_tls": true, + "http3_new_handshakes": snapshot.quic_tls.is_some(), + "administration_restart_required": admin_certificate_restart_required + }))) +} + + +async fn certificate_reload_one( + State(state): State>, + Extension(session): Extension, + Path(certificate_id): Path, +) -> Result, ApiError> { + if !crate::config::valid_id(&certificate_id) { + return Err(ApiError(StatusCode::NOT_FOUND, "certificate not found".into())); + } + let revision = state.reload_certificate(&certificate_id).await?; + let snapshot = state.config.load_full(); + let admin_certificate_restart_required = snapshot + .envelope + .config + .administration + .tls + && snapshot.envelope.config.administration.certificate_id.as_deref() + == Some(certificate_id.as_str()); + state + .store + .audit( + &session.username, + "certificate.reload_one", + &format!("certificate_id={certificate_id}"), + ) + .await + .map_err(auth::internal)?; + Ok(Json(json!({ + "reloaded": true, + "certificate_id": certificate_id, + "revision": revision, + "public_tls": true, + "http3_new_handshakes": snapshot.quic_tls.is_some(), + "administration_restart_required": admin_certificate_restart_required + }))) +} + +async fn acme_status(State(state): State>) -> Json { + let snapshot = state.config.load(); + let log_directories: std::collections::BTreeMap = state + .policy + .cert_dir + .as_deref() + .map(|directory| { + snapshot + .envelope + .config + .tls + .certificates + .iter() + .filter_map(|certificate| { + certificate.issuer.as_ref().map(|issuer| { + ( + certificate.id.clone(), + crate::acme::certbot_log_directory( + directory, + &issuer.provider, + &issuer.environment, + &certificate.id, + ), + ) + }) + }) + .collect() + }) + .unwrap_or_default(); + Json(json!({ + "job":state.acme.view(), + "providers":["http01","cloudflare","ovh","rfc2136"], + "automatic_renewal":true, + "interval_seconds":43200, + "renewals":state.acme.renewal_view(), + "log_directories":log_directories + })) +} +async fn acme_issue( + State(state): State>, + Extension(session): Extension, + Json(input): Json, +) -> Result { + let job = crate::acme::Manager::start(state, input, session.username).await?; + Ok((StatusCode::ACCEPTED, Json(json!({"job":job}))).into_response()) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BasicHash { + username: String, + password: String, +} +async fn basic_hash( + State(state): State>, + Json(input): Json, +) -> Result, ApiError> { + crate::basic_auth::validate_username(&input.username) + .map_err(|_| ApiError(StatusCode::BAD_REQUEST, "basic.invalid_username".into()))?; + // The semaphore is held by the blocking worker even if the HTTP client disconnects. + let permit = state + .basic_workers + .clone() + .try_acquire_owned() + .map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "basic.busy".into()))?; + let password = zeroize::Zeroizing::new(input.password); + auth::validate_password(&password) + .map_err(|_| ApiError(StatusCode::BAD_REQUEST, "auth.password_policy".into()))?; + if password.chars().any(char::is_control) { + return Err(ApiError( + StatusCode::BAD_REQUEST, + "auth.password_policy".into(), + )); + } + let hash = tokio::task::spawn_blocking(move || { + let _permit = permit; + crate::auth::hash_sync(&password) + }) + .await + .map_err(auth::internal)? + .map_err(auth::internal)?; + Ok(Json( + json!({"username":input.username,"password_hash":hash}), + )) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RenewRequest { + certificate_id: String, + #[serde(default)] + dry_run: bool, +} +async fn acme_renew( + State(state): State>, + Extension(session): Extension, + Json(input): Json, +) -> Result { + let job = crate::acme::Manager::renew( + state, + &input.certificate_id, + session.username, + input.dry_run, + false, + ) + .await?; + Ok((StatusCode::ACCEPTED, Json(json!({"job":job}))).into_response()) +} diff --git a/src/admin/openapi.json b/src/admin/openapi.json new file mode 100644 index 0000000..ba4223e --- /dev/null +++ b/src/admin/openapi.json @@ -0,0 +1,8200 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "RustProxy Administration API", + "version": "0.24.5", + "description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust." + }, + "servers": [ + { + "url": "/", + "description": "This administrator origin only" + } + ], + "security": [ + { + "AdminSession": [] + } + ], + "components": { + "securitySchemes": { + "AdminSession": { + "type": "apiKey", + "in": "cookie", + "name": "rustproxy_session", + "description": "Sign in at the panel. Browser sends the HttpOnly cookie automatically; do not paste it into Swagger UI." + }, + "CsrfToken": { + "type": "apiKey", + "in": "header", + "name": "X-CSRF-Token", + "description": "Obtain from /api/auth/session. Swagger sends it automatically for allowed writes." + }, + "CertificateApiBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "opaque", + "description": "One of the scoped external certificate deployment/reload tokens configured in Settings > Certificate API. Tokens are identified by X-RustProxy-Certificate-Key and are accepted only for certificate deploy/reload operations." + }, + "CertificateApiKey": { + "type": "apiKey", + "in": "header", + "name": "X-RustProxy-Certificate-Key", + "description": "Non-secret Key ID paired with the scoped certificate Bearer token." + } + }, + "schemas": { + "Error": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "error" + ] + }, + "Health": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok" + ] + } + }, + "additionalProperties": false, + "required": [ + "status" + ] + }, + "AuthStatus": { + "type": "object", + "properties": { + "setup_required": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "setup_required" + ] + }, + "Login": { + "type": "object", + "properties": { + "username": { + "type": "string", + "minLength": 3, + "maxLength": 64 + }, + "password": { + "type": "string", + "format": "password", + "writeOnly": true, + "maxLength": 128 + } + }, + "additionalProperties": false, + "required": [ + "username", + "password" + ] + }, + "Setup": { + "type": "object", + "properties": { + "token": { + "type": "string", + "writeOnly": true, + "minLength": 64, + "maxLength": 64 + }, + "username": { + "type": "string" + }, + "password": { + "type": "string", + "writeOnly": true, + "format": "password", + "minLength": 12, + "maxLength": 128 + } + }, + "additionalProperties": false, + "required": [ + "token", + "username", + "password" + ] + }, + "PasswordChange": { + "type": "object", + "properties": { + "current_password": { + "type": "string", + "format": "password", + "writeOnly": true + }, + "new_password": { + "type": "string", + "format": "password", + "writeOnly": true, + "minLength": 12, + "maxLength": 128 + } + }, + "additionalProperties": false, + "required": [ + "current_password", + "new_password" + ] + }, + "Session": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "csrf_token": { + "type": "string", + "readOnly": true, + "description": "Session-bound CSRF token. Never store in a URL or persistent browser storage." + }, + "expires_at": { + "type": "integer", + "format": "int64" + }, + "idle_timeout_seconds": { + "type": "integer", + "format": "int64", + "description": "Compatibility field equal to the current browser-session lifetime. RustProxy uses absolute expiry rather than a shorter idle timeout." + }, + "session_lifetime_seconds": { + "type": "integer", + "format": "int64", + "description": "Current configured absolute browser-session lifetime." + } + }, + "additionalProperties": false, + "required": [ + "username", + "csrf_token", + "expires_at", + "idle_timeout_seconds", + "session_lifetime_seconds" + ] + }, + "HeaderRule": { + "type": "object", + "properties": { + "op": { + "type": "string", + "enum": [ + "set", + "append", + "remove" + ] + }, + "name": { + "type": "string", + "maxLength": 128 + }, + "value": { + "type": "string", + "maxLength": 8192, + "description": "Required for set/append; omit for remove. Literal, no variable substitution." + } + }, + "additionalProperties": false, + "required": [ + "op", + "name" + ] + }, + "HeaderRules": { + "type": "object", + "properties": { + "request": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HeaderRule" + }, + "maxItems": 32 + }, + "response": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HeaderRule" + }, + "maxItems": 32 + } + }, + "additionalProperties": false + }, + "Acl": { + "type": "object", + "properties": { + "default": { + "type": "string", + "enum": [ + "allow", + "deny" + ] + }, + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "allow", + "deny" + ] + } + }, + "additionalProperties": false, + "required": [ + "network", + "action" + ] + } + } + }, + "additionalProperties": false + }, + "Location": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "path_prefix": { + "type": "string", + "description": "Path prefix/exact path or Rust regex pattern, depending on match_type." + }, + "backend_id": { + "type": "string", + "nullable": true + }, + "enabled": { + "type": "boolean" + }, + "cache": { + "type": "boolean", + "nullable": true + }, + "acl": { + "allOf": [ + { + "$ref": "#/components/schemas/Acl" + } + ], + "nullable": true + }, + "headers": { + "$ref": "#/components/schemas/HeaderRules" + }, + "match_type": { + "type": "string", + "enum": [ + "prefix", + "prefix_priority", + "exact", + "regex", + "regex_insensitive" + ], + "default": "prefix", + "description": "Nginx-style location matching: prefix, priority prefix (^~), exact (=), regex (~), or case-insensitive regex (~*)." + }, + "rewrite": { + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { + "type": "string", + "enum": [ + "preserve", + "strip_prefix", + "replace_prefix", + "regex" + ], + "default": "preserve" + }, + "value": { + "type": "string", + "maxLength": 2048, + "default": "", + "description": "Replacement prefix or regex replacement. Regex replacements support $1-style capture expansion." + } + }, + "description": "Optional upstream-path rewrite. Public cache identity remains based on the original client URL." + }, + "response": { + "type": "object", + "nullable": true, + "additionalProperties": false, + "properties": { + "status": { + "type": "integer", + "minimum": 200, + "maximum": 599, + "default": 200 + }, + "content_type": { + "type": "string", + "maxLength": 256, + "default": "text/plain; charset=utf-8" + }, + "body": { + "type": "string", + "maxLength": 65536, + "default": "" + } + }, + "description": "Local response returned by RustProxy without opening a backend connection." + } + }, + "additionalProperties": false, + "required": [ + "id" + ] + }, + "Vhost": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "force_ssl": { + "type": "boolean", + "description": "Redirect cleartext requests for this vhost to the selected HTTPS listener using HTTP 308." + }, + "forward_client_ip": { + "type": "boolean", + "default": true, + "description": "Forward the effective client IP to the application using Forwarded, X-Forwarded-For and X-Real-IP. When false, RustProxy still uses the real client IP internally and for Tinyauth, but withholds those three headers from the backend." + }, + "websockets": { + "type": "boolean" + }, + "frontend": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "backends": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VhostBackend" + } + }, + "routes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Location" + } + }, + "headers": { + "$ref": "#/components/schemas/HeaderRules" + }, + "acl": { + "allOf": [ + { + "$ref": "#/components/schemas/Acl" + } + ], + "nullable": true + }, + "basic_auth": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "tinyauth": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "cache": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "load_balancing": { + "$ref": "#/components/schemas/LoadBalancingConfig" + }, + "certificate_id": { + "type": "string", + "nullable": true + }, + "http2_enabled": { + "type": "boolean" + }, + "http3_enabled": { + "type": "boolean" + }, + "tls_policy": { + "type": "object", + "properties": { + "tls12": { + "type": "boolean" + }, + "tls13": { + "type": "boolean" + }, + "cipher_suites": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "nullable": true + }, + "acl_profile_id": { + "type": "string", + "nullable": true, + "description": "Optional reusable ACL profile; profile and inline vhost/location policies are cumulative." + }, + "cloudflare_client_ip": { + "type": "boolean", + "default": false, + "description": "Use a verified Cloudflare connecting-IP header only when the immediate peer is in the configured trusted Cloudflare ranges." + }, + "timing_headers": { + "type": "boolean", + "default": false, + "description": "Expose Server-Timing diagnostics for this vhost." + }, + "backend_header": { + "type": "boolean", + "default": false, + "description": "Expose the selected backend ID as X-RustProxy-Backend on responses that contacted an origin. Responses served without origin contact do not report a backend." + }, + "backend_timing_header": { + "type": "boolean", + "default": false, + "description": "Expose origin response-header latency as X-RustProxy-Backend-Time. Responses served without origin contact do not report an origin time." + }, + "websocket_rfc9220": { + "type": "boolean", + "default": false, + "description": "Experimental RFC 9220 WebSocket Extended CONNECT over HTTP/3. Classic HTTP/1.1 Upgrade remains the default when WebSocket support is enabled." + }, + "websocket_legacy_support": { + "type": "boolean", + "default": false, + "description": "Opt-in compatibility for legacy WebSocket origins. Forces classic HTTP/1.1 upstream handshakes, uses the configured backend authority as the WebSocket Host, aligns Origin to the matching backend scheme + authority, and tolerates legacy 101 response framing without weakening Sec-WebSocket-Accept validation. Ordinary HTTP is unchanged." + }, + "proxy_timeouts": { + "$ref": "#/components/schemas/ProxyTimeouts" + }, + "transform": { + "$ref": "#/components/schemas/TransformOverrides" + }, + "websocket_rfc8441": { + "type": "boolean", + "default": false, + "description": "RFC 8441 WebSocket Extended CONNECT over HTTP/2. Independent opt-in alongside classic HTTP/1.1 Upgrade; requires WebSocket support and an H2-capable selected listener." + } + }, + "additionalProperties": true, + "required": [ + "id", + "frontend", + "backends" + ] + }, + "Config": { + "type": "object", + "properties": { + "vhosts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Vhost" + } + }, + "listeners": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "http2": { + "description": "Legacy HTTP/2 lifecycle fallback for listeners that do not define binding.http2_settings. Prefer listener-scoped settings in config.listeners.", + "allOf": [ + { + "$ref": "#/components/schemas/Http2ConnectionSettings" + } + ] + }, + "streams": { + "type": "array", + "maxItems": 128, + "items": { + "$ref": "#/components/schemas/StreamHost" + } + }, + "tls": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "cache": { + "$ref": "#/components/schemas/CacheSettings" + }, + "compression": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "headers": { + "$ref": "#/components/schemas/HeaderRules" + }, + "server_header": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "diagnostic_headers": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "forwarding": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "pages": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "prometheus": { + "$ref": "#/components/schemas/PrometheusSettings" + }, + "host_policies": { + "type": "array", + "items": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + "administration": { + "type": "object", + "properties": { + "listen": { + "type": "string", + "nullable": true, + "description": "Administration listen socket. Listener changes require restart." + }, + "tls": { + "type": "boolean", + "default": false + }, + "certificate_id": { + "type": "string", + "nullable": true + }, + "allowed_hosts": { + "type": "array", + "items": { + "type": "string" + } + }, + "session_days": { + "type": "integer", + "minimum": 7, + "maximum": 30, + "default": 30, + "description": "Absolute browser administrator-session lifetime in days. Applies immediately and does not require restart." + } + }, + "additionalProperties": false + }, + "logging": { + "type": "object", + "additionalProperties": true, + "properties": { + "file": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "path": { + "type": "string", + "default": "/var/log/rustproxy/access.jsonl" + }, + "per_vhost": { + "type": "boolean", + "default": false, + "description": "Write each vhost to a separate JSONL file instead of the combined file." + }, + "directory": { + "type": "string", + "default": "/var/log/rustproxy/vhosts", + "description": "Directory used when per_vhost is enabled." + } + } + }, + "logstash": { + "type": "object", + "additionalProperties": true + }, + "loki": { + "type": "object", + "additionalProperties": true + }, + "format": { + "type": "object", + "additionalProperties": true + } + } + }, + "acl_profiles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AclProfile" + } + }, + "redirects": { + "type": "array", + "maxItems": 64, + "items": { + "$ref": "#/components/schemas/RedirectHost" + } + }, + "not_found_hosts": { + "type": "array", + "maxItems": 64, + "items": { + "$ref": "#/components/schemas/NotFoundHost" + } + }, + "docker_agents": { + "$ref": "#/components/schemas/DockerSettings" + }, + "transform": { + "$ref": "#/components/schemas/TransformSettings" + } + }, + "additionalProperties": true + }, + "Envelope": { + "type": "object", + "properties": { + "revision": { + "type": "integer", + "format": "int64" + }, + "config": { + "$ref": "#/components/schemas/Config" + } + }, + "additionalProperties": false, + "required": [ + "revision", + "config" + ] + }, + "Update": { + "type": "object", + "properties": { + "expected_revision": { + "type": "integer", + "format": "int64" + }, + "config": { + "$ref": "#/components/schemas/Config" + } + }, + "additionalProperties": false, + "required": [ + "expected_revision", + "config" + ] + }, + "CertificateUpload": { + "type": "object", + "properties": { + "cert_pem": { + "type": "string" + }, + "key_pem": { + "type": "string", + "writeOnly": true, + "description": "Private key PEM; never returned by this endpoint." + }, + "hosts": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + } + }, + "additionalProperties": false, + "required": [ + "cert_pem", + "key_pem", + "hosts" + ] + }, + "BasicHash": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string", + "writeOnly": true, + "format": "password", + "minLength": 12, + "maxLength": 128 + } + }, + "additionalProperties": false, + "required": [ + "username", + "password" + ] + }, + "HashedUser": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password_hash": { + "type": "string", + "readOnly": true + } + }, + "additionalProperties": false, + "required": [ + "username", + "password_hash" + ] + }, + "RenewRequest": { + "type": "object", + "properties": { + "certificate_id": { + "type": "string" + }, + "dry_run": { + "type": "boolean", + "default": false + } + }, + "additionalProperties": false, + "required": [ + "certificate_id" + ] + }, + "AcmeIssue": { + "type": "object", + "properties": { + "certificate_id": { + "type": "string" + }, + "provider": { + "type": "string", + "enum": [ + "http01", + "cloudflare", + "ovh", + "rfc2136" + ] + }, + "domains": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "email": { + "type": "string" + }, + "environment": { + "type": "string", + "enum": [ + "staging", + "production" + ], + "default": "staging" + }, + "agree_tos": { + "type": "boolean" + }, + "confirm_production": { + "type": "boolean" + }, + "api_token": { + "type": "string", + "writeOnly": true + }, + "application_key": { + "type": "string", + "writeOnly": true + }, + "application_secret": { + "type": "string", + "writeOnly": true + }, + "consumer_key": { + "type": "string", + "writeOnly": true + }, + "key_secret": { + "type": "string", + "writeOnly": true + } + }, + "additionalProperties": true, + "required": [ + "certificate_id", + "domains", + "email", + "agree_tos" + ] + }, + "Status": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "project": { + "type": "string" + }, + "runtime": { + "$ref": "#/components/schemas/RuntimeInformation" + }, + "uptime_seconds": { + "type": "integer", + "format": "int64" + }, + "revision": { + "type": "integer", + "format": "int64" + }, + "listeners": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "metrics": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "transform_metrics": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "cache": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "system": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "backend_pools": { + "type": "array", + "items": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + "administration": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "logging": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "tls": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "streams": { + "type": "object", + "properties": { + "active": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StreamHost" + } + }, + "desired": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StreamHost" + } + }, + "restart_required": { + "type": "boolean" + }, + "metrics": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/StreamMetrics" + } + } + }, + "required": [ + "active", + "desired", + "restart_required", + "metrics" + ], + "additionalProperties": false, + "description": "Startup-active and desired TCP/UDP streams plus fixed-cardinality runtime counters." + }, + "backend_pool_domains": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Configured Docker runtime pool IDs mapped to their primary proxied hostname." + }, + "software_update": { + "type": "object", + "properties": { + "current_version": { + "type": "string" + }, + "latest_version": { + "type": "string", + "nullable": true + }, + "update_available": { + "type": "boolean" + }, + "repository_url": { + "type": "string", + "format": "uri", + "nullable": true + } + }, + "required": [ + "current_version", + "latest_version", + "update_available", + "repository_url" + ], + "additionalProperties": false, + "description": "Fail-soft release-check state. A failed check leaves the previous successful state unchanged." + } + }, + "additionalProperties": true + }, + "Object": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "Logs": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + "capacity": { + "type": "integer", + "format": "int64", + "description": "Number of completed requests retained in the in-memory access ring." + }, + "window": { + "type": "integer", + "format": "int64", + "description": "Default live snapshot size." + }, + "dropped": { + "type": "integer", + "format": "int64" + } + }, + "additionalProperties": false + }, + "Audit": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + "capacity": { + "type": "integer", + "format": "int64" + } + }, + "additionalProperties": false + }, + "Cache": { + "type": "object", + "properties": { + "settings": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "metrics": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "vhosts": { + "type": "array", + "items": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + "dashboard": { + "type": "object", + "properties": {}, + "additionalProperties": true + }, + "bans": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CacheBan" + } + } + }, + "additionalProperties": false + }, + "AclProfile": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "acl": { + "allOf": [ + { + "$ref": "#/components/schemas/Acl" + } + ], + "nullable": true + }, + "country_access": { + "type": "object", + "properties": {}, + "additionalProperties": true + } + }, + "additionalProperties": false, + "required": [ + "id", + "name" + ] + }, + "StreamHost": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,64}$" + }, + "name": { + "type": "string", + "maxLength": 96 + }, + "enabled": { + "type": "boolean" + }, + "protocol": { + "type": "string", + "enum": [ + "tcp", + "udp" + ] + }, + "listen": { + "type": "string", + "description": "Dedicated TCP/UDP listener as IP:port." + }, + "upstream": { + "type": "string", + "description": "Single raw upstream as IP:port." + }, + "connect_timeout_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 300 + }, + "idle_timeout_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 86400 + }, + "max_connections": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "id", + "protocol", + "listen", + "upstream" + ], + "additionalProperties": false, + "description": "Layer-4 stream host. It bypasses the HTTP vhost/ACL/cache/header/compression pipeline." + }, + "StreamMetrics": { + "type": "object", + "properties": { + "sessions_total": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "sessions_completed": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "active": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "rejected": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "errors": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "bytes_in": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "bytes_out": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "packets_in": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "packets_out": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + "additionalProperties": false, + "description": "Monotonic Layer-4 counters for one startup-active stream. active is a gauge." + }, + "ConfigSectionUpdate": { + "type": "object", + "properties": { + "expected_revision": { + "type": "integer", + "format": "int64", + "description": "Revision returned by the latest GET /api/config." + }, + "value": { + "description": "Complete value of the selected configuration section. This is a section replacement, not a merge patch." + } + }, + "additionalProperties": false, + "required": [ + "expected_revision", + "value" + ] + }, + "CacheSettings": { + "type": "object", + "description": "Shared hot/cold cache settings. Idle age, maximum hot-tier age, LRU eviction and graceful shutdown can independently demote objects to disk.", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "max_bytes": { + "type": "integer", + "format": "int64", + "minimum": 65536, + "default": 268435456, + "description": "Hard HOT RAM budget charged by retained body allocation plus entry metadata." + }, + "max_object_bytes": { + "type": "integer", + "format": "int64", + "minimum": 1024, + "default": 16777216, + "description": "Maximum logical response body accepted by one cache fill." + }, + "max_entries": { + "type": "integer", + "minimum": 1, + "maximum": 16384 + }, + "transient_max_bytes": { + "type": "integer", + "format": "int64", + "minimum": 65536, + "default": 67108864, + "description": "Hard byte budget for in-progress fills and full bodies moving between HOT and COLD." + }, + "cold_queue_max_bytes": { + "type": "integer", + "format": "int64", + "minimum": 65536, + "default": 33554432, + "description": "Byte ceiling for demoted objects waiting for the COLD writer. Must not exceed transient_max_bytes." + }, + "hot_eviction_max_objects": { + "type": "integer", + "minimum": 1, + "maximum": 4096, + "default": 64, + "description": "Maximum LRU removals attempted to make room for one HOT admission." + }, + "cold_enabled": { + "type": "boolean", + "default": false + }, + "cold_path": { + "type": "string", + "default": "cache/cold" + }, + "cold_max_bytes": { + "type": "integer", + "format": "int64", + "minimum": 65536 + }, + "cold_max_entries": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "cold_after_seconds": { + "type": "integer", + "minimum": 5, + "maximum": 604800, + "default": 300 + }, + "cold_on_idle": { + "type": "boolean", + "default": true, + "description": "Move objects idle for cold_after_seconds from hot RAM to cold storage." + }, + "cold_on_lru": { + "type": "boolean", + "default": true, + "description": "Move objects evicted by the hot LRU/budget to cold storage." + }, + "cold_on_shutdown": { + "type": "boolean", + "default": true, + "description": "Best-effort flush of remaining hot objects to cold storage during graceful shutdown/restart." + }, + "default_ttl_seconds": { + "type": "integer", + "minimum": 0, + "maximum": 604800 + }, + "max_ttl_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 604800 + }, + "bypass_query": { + "type": "boolean" + }, + "bypass_prefixes": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 64 + }, + "static_ignore_cookies": { + "type": "boolean" + }, + "static_extensions": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 64 + }, + "expose_status": { + "type": "boolean" + }, + "expose_hits": { + "type": "boolean" + }, + "expose_cache_status": { + "type": "boolean" + }, + "expose_resident_age": { + "type": "boolean" + }, + "cold_max_hot_seconds": { + "type": "integer", + "minimum": 5, + "maximum": 2592000, + "default": 3600, + "description": "Maximum total RAM residency before age-based demotion when cold_on_age is enabled." + }, + "cold_on_age": { + "type": "boolean", + "default": false, + "description": "Move objects to cold storage after cold_max_hot_seconds even when they are still being accessed." + }, + "cold_gc_interval_seconds": { + "type": "integer", + "minimum": 5, + "maximum": 86400, + "default": 60, + "description": "Automatic cold-cache GC interval." + }, + "cold_gc_max_delete": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "default": 256, + "description": "Maximum objects removed by one automatic GC pass." + }, + "collapse_enabled": { + "type": "boolean", + "default": true, + "description": "Collapse concurrent foreground fills for the same cache key." + }, + "collapse_wait_timeout_ms": { + "type": "integer", + "minimum": 10, + "maximum": 60000, + "default": 1500 + }, + "collapse_timeout_fallback": { + "type": "string", + "enum": [ + "origin", + "stale_if_available" + ], + "default": "origin" + }, + "stale_if_error_timeout": { + "type": "boolean", + "default": true + }, + "stale_if_error_connection": { + "type": "boolean", + "default": true + }, + "stale_if_error_dns": { + "type": "boolean", + "default": true + }, + "stale_if_error_tls": { + "type": "boolean", + "default": true + }, + "stale_if_error_statuses": { + "type": "array", + "maxItems": 32, + "items": { + "type": "integer", + "minimum": 500, + "maximum": 599 + }, + "default": [ + 500, + 502, + 503, + 504 + ] + }, + "proactive_refresh_enabled": { + "type": "boolean", + "default": false + }, + "proactive_refresh_before_expiry_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 604800, + "default": 15 + }, + "proactive_refresh_percent": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "default": 10 + }, + "proactive_refresh_max_inflight": { + "type": "integer", + "minimum": 1, + "maximum": 64, + "default": 4 + } + }, + "additionalProperties": false + }, + "VhostBackend": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string", + "description": "HTTP(S) origin URL without path/query/fragment." + }, + "weight": { + "type": "integer", + "minimum": 1, + "maximum": 256 + }, + "max_concurrent_requests": { + "type": "integer", + "minimum": 0, + "maximum": 1000000, + "default": 0, + "description": "Maximum number of complete proxy exchanges assigned to this backend. Zero means unlimited." + }, + "queue_timeout_ms": { + "type": "integer", + "minimum": 0, + "maximum": 60000, + "default": 0, + "description": "Maximum time to wait for backend request capacity before returning HTTP 503. Zero rejects immediately." + }, + "enabled": { + "type": "boolean" + }, + "pool_eligible": { + "type": "boolean" + }, + "backup": { + "type": "boolean" + }, + "drain": { + "type": "boolean" + }, + "allow_private": { + "type": "boolean" + }, + "preserve_host": { + "type": "boolean", + "default": true + }, + "protocol": { + "type": "string", + "enum": [ + "http1", + "http2", + "auto" + ], + "default": "http1" + }, + "address_allowlist": { + "type": "array", + "items": { + "type": "string" + } + }, + "tls_server_name": { + "type": "string", + "nullable": true, + "description": "DNS name used for TLS SNI/certificate identity when it differs from the connection target. Must not be an IP address." + }, + "tls_verify": { + "type": "boolean", + "default": false, + "description": "For HTTPS origins, validate the certificate chain and hostname when explicitly enabled. Disabled when omitted; TLS encryption still applies and backend address/DNS protections remain active." + }, + "tls_ca_pem": { + "type": "string", + "nullable": true, + "description": "Additional private CA PEM used only when tls_verify=true." + }, + "accept_local_listener_risk": { + "type": "boolean", + "default": false, + "description": "Explicitly allow this backend to resolve to a local RustProxy listener, including a listener using port 9090. This can create a proxy loop or expose the administration plane. Loopback/private targets still require allow_private=true." + } + }, + "additionalProperties": true, + "required": [ + "id", + "url" + ] + }, + "CertificateInventoryEntry": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "hosts": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Active/configured SNI bindings." + }, + "covered_hosts": { + "type": "array", + "items": { + "type": "string" + }, + "description": "DNS SubjectAltName values extracted from the leaf certificate. Inventory inspects the configured public certificate PEM so in-place renewals are reflected immediately, falling back to persisted upload/ACME metadata only if the file cannot be read. Empty when the certificate contains no DNS SANs or coverage is unavailable." + }, + "cert_file": { + "type": "string" + }, + "key_file": { + "type": "string" + }, + "issuer": { + "type": "object", + "nullable": true, + "additionalProperties": true + }, + "certificate_issuer": { + "type": "string", + "nullable": true, + "description": "Issuer distinguished name parsed from the current leaf certificate." + }, + "not_before": { + "type": "string", + "nullable": true, + "description": "Certificate notBefore value normalized as an ISO-like timestamp." + }, + "not_after": { + "type": "string", + "nullable": true, + "description": "Certificate notAfter value normalized as an ISO-like timestamp." + }, + "not_before_unix": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "not_after_unix": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "days_remaining": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "expiry_status": { + "type": "string", + "enum": [ + "ok", + "warning", + "expired", + "unknown" + ] + }, + "renewal": { + "nullable": true + }, + "renewal_status": { + "type": "string", + "nullable": true + }, + "last_renewal_attempt": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "last_renewal_error": { + "type": "string", + "nullable": true + }, + "default": { + "type": "boolean" + }, + "assigned_vhosts": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "additionalProperties": false, + "required": [ + "id", + "hosts", + "covered_hosts", + "cert_file", + "key_file", + "default", + "assigned_vhosts", + "certificate_issuer", + "not_before", + "not_after", + "not_before_unix", + "not_after_unix", + "days_remaining", + "expiry_status", + "renewal_status", + "last_renewal_attempt", + "last_renewal_error" + ] + }, + "CertificateInventory": { + "type": "object", + "properties": { + "revision": { + "type": "integer", + "format": "int64" + }, + "certificates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CertificateInventoryEntry" + } + }, + "acme": { + "type": "boolean" + }, + "providers": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "required": [ + "revision", + "certificates", + "acme", + "providers" + ] + }, + "StagedCertificate": { + "type": "object", + "properties": { + "issuer": { + "type": "object", + "nullable": true, + "additionalProperties": true + }, + "id": { + "type": "string" + }, + "covered_hosts": { + "type": "array", + "items": { + "type": "string" + } + }, + "hosts": { + "type": "array", + "items": { + "type": "string" + } + }, + "cert_file": { + "type": "string" + }, + "key_file": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "id", + "covered_hosts", + "hosts", + "cert_file", + "key_file" + ] + }, + "CertificateUploadResult": { + "type": "object", + "properties": { + "certificate": { + "$ref": "#/components/schemas/StagedCertificate" + }, + "staged": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "certificate", + "staged" + ] + }, + "RedirectRule": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,64}$" + }, + "enabled": { + "type": "boolean", + "default": true + }, + "source_scheme": { + "type": "string", + "enum": [ + "any", + "http", + "https" + ], + "default": "any" + }, + "match_type": { + "type": "string", + "enum": [ + "prefix", + "exact" + ], + "default": "prefix" + }, + "path_prefix": { + "type": "string", + "default": "/" + }, + "target_type": { + "type": "string", + "enum": [ + "url", + "host", + "path" + ], + "default": "url" + }, + "target": { + "type": "string", + "maxLength": 2048 + }, + "target_scheme": { + "type": "string", + "enum": [ + "preserve", + "http", + "https" + ], + "default": "preserve", + "description": "Used by target_type=host." + }, + "path_mode": { + "type": "string", + "enum": [ + "append", + "strip_prefix", + "replace" + ], + "default": "append" + }, + "query_mode": { + "type": "string", + "enum": [ + "preserve", + "drop" + ], + "default": "preserve" + }, + "status_code": { + "type": "integer", + "enum": [ + 301, + 302, + 303, + 307, + 308 + ], + "default": 308 + }, + "cache_seconds": { + "type": "integer", + "minimum": 0, + "maximum": 31536000, + "default": 0 + } + }, + "required": [ + "id", + "target" + ] + }, + "RedirectHost": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,64}$" + }, + "name": { + "type": "string", + "maxLength": 96 + }, + "enabled": { + "type": "boolean", + "default": true + }, + "http2_enabled": { + "type": "boolean", + "default": true + }, + "http3_enabled": { + "type": "boolean", + "default": true + }, + "frontend": { + "type": "object", + "additionalProperties": false, + "properties": { + "hosts": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "listener_ids": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "items": { + "type": "string" + } + } + }, + "required": [ + "hosts", + "listener_ids" + ] + }, + "certificate_id": { + "type": "string", + "nullable": true + }, + "rules": { + "type": "array", + "maxItems": 64, + "items": { + "$ref": "#/components/schemas/RedirectRule" + } + } + }, + "required": [ + "id", + "frontend" + ] + }, + "LocalResponsePage": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "type": "string", + "enum": [ + "welcome", + "empty", + "html" + ], + "default": "welcome" + }, + "status": { + "type": "integer", + "nullable": true, + "description": "Local HTTP response status for a 404 Host. Final application statuses 200..599 are supported; 204, 205 and 304 require empty mode.", + "minimum": 200, + "maximum": 599 + }, + "html": { + "type": "string", + "maxLength": 32768, + "default": "" + } + } + }, + "NotFoundHost": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,64}$" + }, + "name": { + "type": "string", + "maxLength": 96 + }, + "enabled": { + "type": "boolean", + "default": true + }, + "http2_enabled": { + "type": "boolean", + "default": true + }, + "http3_enabled": { + "type": "boolean", + "default": true + }, + "frontend": { + "type": "object", + "additionalProperties": false, + "properties": { + "hosts": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "listener_ids": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "items": { + "type": "string" + } + } + }, + "required": [ + "hosts", + "listener_ids" + ] + }, + "certificate_id": { + "type": "string", + "nullable": true + }, + "page": { + "$ref": "#/components/schemas/LocalResponsePage" + } + }, + "required": [ + "id", + "frontend" + ] + }, + "PrometheusSettings": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "listen": { + "type": "string", + "default": "127.0.0.1:9191" + }, + "path": { + "type": "string", + "default": "/metrics", + "pattern": "^/" + }, + "allowed_cidrs": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "type": "string" + }, + "default": [ + "127.0.0.0/8", + "::1/128" + ], + "description": "Matched against the actual transport peer. Forwarded headers are ignored." + }, + "bearer_token_env": { + "type": "string", + "nullable": true, + "description": "Environment-variable name only; the token value is never stored in configuration." + }, + "allow_trusted_network_without_token": { + "type": "boolean", + "default": false, + "description": "Allow an actual peer already matched by allowed_cidrs to scrape without a bearer token. Explicit /0 CIDRs are rejected when enabled." + }, + "include_system": { + "type": "boolean", + "default": true + }, + "include_vhosts": { + "type": "boolean", + "default": true + }, + "include_backends": { + "type": "boolean", + "default": true + }, + "include_docker_vhosts": { + "type": "boolean", + "default": true, + "description": "Export Docker discovery/vhost/backend metrics under the dedicated rust_proxy_docker_* metric families." + }, + "include_cache_domains": { + "type": "boolean", + "default": true + }, + "include_streams": { + "type": "boolean", + "default": true + }, + "include_logging": { + "type": "boolean", + "default": true + } + } + }, + "DockerAssignment": { + "type": "object", + "additionalProperties": false, + "properties": { + "service_key": { + "type": "string", + "maxLength": 256 + }, + "hostname": { + "type": "string", + "maxLength": 253, + "description": "Primary Docker vhost name. A single DNS label is expanded under the agent base_domain; an FQDN is used as-is." + }, + "aliases": { + "type": "array", + "items": { + "type": "string", + "maxLength": 253 + }, + "description": "Additional labels or FQDN aliases for the same dynamic vhost." + }, + "target_ip": { + "type": "string", + "nullable": true, + "description": "Optional per-service Docker host IP. When unset, the agent default or automatic binding/address selection is used." + }, + "enabled": { + "type": "boolean", + "default": true + }, + "scheme": { + "type": "string", + "enum": [ + "http", + "https" + ], + "default": "http" + }, + "preserve_host": { + "type": "boolean", + "default": true + }, + "force_ssl": { + "type": "boolean", + "nullable": true, + "default": true, + "description": "Per-app HTTP to HTTPS redirect. New dashboard assignments default to true; null preserves the pre-0.18.8 behavior for assignments saved before this switch existed." + }, + "websockets": { + "type": "boolean", + "default": true, + "description": "Enable classic HTTP/1.1 WebSocket Upgrade forwarding for this generated Docker vhost." + }, + "cache_enabled": { + "type": "boolean", + "default": false, + "description": "Enable the normal RustProxy cache policy for this generated Docker vhost." + }, + "health_check": { + "$ref": "#/components/schemas/DockerHealthCheck" + }, + "passive_health": { + "$ref": "#/components/schemas/PassiveHealthConfig", + "description": "Passive backend health is opt-in and defaults to disabled." + }, + "acl_profile_id": { + "type": "string", + "nullable": true, + "description": "Optional reusable ACL profile. Profile and inline ACL are cumulative." + }, + "acl": { + "allOf": [ + { + "$ref": "#/components/schemas/Acl" + } + ], + "nullable": true + }, + "proxy_timeouts": { + "$ref": "#/components/schemas/ProxyTimeouts" + }, + "transform_enabled": { + "type": "boolean", + "default": false, + "description": "Enable the configured static-response transform engine for the generated Docker vhost. Global transform must also be enabled." + } + }, + "required": [ + "service_key", + "hostname" + ] + }, + "DockerAgent": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "maxLength": 64 + }, + "name": { + "type": "string", + "maxLength": 96 + }, + "enabled": { + "type": "boolean", + "default": true + }, + "endpoint": { + "type": "string", + "format": "uri", + "description": "Base HTTP(S) URL of the standalone RustProxy Docker Agent." + }, + "token": { + "type": "string", + "minLength": 24, + "maxLength": 512, + "description": "Bearer token shared only with this Docker agent." + }, + "base_domain": { + "type": "string", + "description": "Per-agent DNS suffix such as docker1.lab.local. Short Docker vhost names are expanded below this domain; full FQDNs may use any operator-controlled domain." + }, + "default_target_ip": { + "type": "string", + "nullable": true, + "description": "Optional default Docker host IPv4/IPv6 address. A Docker vhost can override this per service." + }, + "listener_ids": { + "type": "array", + "maxItems": 16, + "items": { + "type": "string" + } + }, + "assignments": { + "type": "array", + "maxItems": 128, + "items": { + "$ref": "#/components/schemas/DockerAssignment" + } + }, + "tls_fingerprint_sha256": { + "type": "string", + "nullable": true, + "minLength": 64, + "maxLength": 95, + "description": "Optional SHA-256 pin for an HTTPS agent certificate. The default self-signed Docker agent certificate should be pinned here; leave empty only when normal public-CA validation should be used." + }, + "poll_interval_seconds": { + "type": "integer", + "minimum": 5, + "maximum": 3600, + "default": 30, + "description": "How often RustProxy polls this agent for Docker inventory. Manual refresh bypasses this interval." + }, + "certificate_id": { + "type": "string", + "nullable": true, + "maxLength": 64, + "description": "Optional configured TLS certificate assigned to every generated vhost for this Docker agent." + }, + "acl_profile_id": { + "type": "string", + "nullable": true, + "maxLength": 64, + "description": "Optional default ACL profile inherited by generated Docker vhosts whose assignment does not select its own ACL profile." + } + }, + "required": [ + "id", + "name", + "endpoint", + "token", + "base_domain", + "listener_ids" + ] + }, + "DockerSettings": { + "type": "object", + "additionalProperties": false, + "properties": { + "agents": { + "type": "array", + "maxItems": 16, + "items": { + "$ref": "#/components/schemas/DockerAgent" + } + } + } + }, + "DockerService": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string" + }, + "technical_name": { + "type": "string" + }, + "container_id": { + "type": "string" + }, + "container_name": { + "type": "string" + }, + "image": { + "type": "string" + }, + "private_port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "protocol": { + "type": "string" + }, + "bindings": { + "type": "array", + "maxItems": 32, + "items": { + "$ref": "#/components/schemas/DockerBinding" + } + } + }, + "required": [ + "key", + "technical_name", + "container_id", + "container_name", + "image", + "private_port", + "protocol", + "bindings" + ] + }, + "DockerInventory": { + "type": "object", + "additionalProperties": false, + "properties": { + "agent_version": { + "type": "string" + }, + "hostname": { + "type": "string" + }, + "public_ip": { + "type": "string", + "description": "Preferred/primary host address retained for compatibility and automatic fallback." + }, + "public_ip_source": { + "type": "string", + "description": "Discovery source of the preferred/primary host address." + }, + "observed_at": { + "type": "string" + }, + "services": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DockerService" + } + }, + "addresses": { + "type": "array", + "maxItems": 64, + "items": { + "$ref": "#/components/schemas/DockerHostAddress" + }, + "description": "Usable addresses detected on the Docker host, including interface, routed, public-API and explicit advertised addresses." + }, + "certificate_sha256": { + "type": "string", + "description": "SHA-256 fingerprint of the certificate used by the agent HTTPS listener. Empty only when the agent is intentionally running without TLS." + } + }, + "required": [ + "agent_version", + "hostname", + "public_ip", + "public_ip_source", + "certificate_sha256", + "addresses", + "observed_at", + "services" + ] + }, + "DockerObservation": { + "type": "object", + "additionalProperties": false, + "properties": { + "agent_id": { + "type": "string" + }, + "ok": { + "type": "boolean" + }, + "checked_at_unix_ms": { + "type": "integer", + "format": "int64" + }, + "error": { + "type": "string", + "nullable": true + }, + "inventory": { + "allOf": [ + { + "$ref": "#/components/schemas/DockerInventory" + } + ], + "nullable": true + } + }, + "required": [ + "agent_id", + "ok", + "checked_at_unix_ms" + ] + }, + "DockerObservationResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "observations": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DockerObservation" + } + }, + "pools": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DockerRuntimePool" + } + }, + "pool_routes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DockerPoolRoute" + } + } + }, + "required": [ + "observations", + "pools", + "pool_routes" + ] + }, + "DockerBinding": { + "type": "object", + "additionalProperties": false, + "properties": { + "bind_ip": { + "type": "string", + "description": "Docker published bind address. 0.0.0.0/:: means wildcard on that address family." + }, + "public_port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": [ + "bind_ip", + "public_port" + ] + }, + "DockerHostAddress": { + "type": "object", + "additionalProperties": false, + "properties": { + "ip": { + "type": "string" + }, + "source": { + "type": "string", + "description": "Discovery sources, for example interface, route, public_api or override." + }, + "interface": { + "type": "string" + }, + "scope": { + "type": "string", + "description": "Address scope reported by the agent, for example private or public." + } + }, + "required": [ + "ip", + "source", + "interface", + "scope" + ] + }, + "DockerHealthCheck": { + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { + "type": "string", + "enum": [ + "off", + "tcp", + "http" + ], + "default": "tcp" + }, + "path": { + "type": "string", + "default": "/" + }, + "host": { + "type": "string", + "nullable": true + }, + "interval_ms": { + "type": "integer", + "minimum": 250, + "maximum": 300000, + "default": 5000 + }, + "timeout_ms": { + "type": "integer", + "minimum": 50, + "maximum": 60000, + "default": 2000 + }, + "slow_response_ms": { + "type": "integer", + "minimum": 0, + "maximum": 60000, + "default": 1500 + }, + "fall": { + "type": "integer", + "minimum": 1, + "maximum": 20, + "default": 3 + }, + "rise": { + "type": "integer", + "minimum": 1, + "maximum": 20, + "default": 2 + }, + "expected_status_min": { + "type": "integer", + "minimum": 100, + "maximum": 599, + "default": 200 + }, + "expected_status_max": { + "type": "integer", + "minimum": 100, + "maximum": 599, + "default": 399 + } + } + }, + "DockerPoolRoute": { + "type": "object", + "additionalProperties": false, + "properties": { + "agent_id": { + "type": "string" + }, + "service_key": { + "type": "string" + }, + "pool_id": { + "type": "string" + } + }, + "required": [ + "agent_id", + "service_key", + "pool_id" + ] + }, + "DockerRuntimePool": { + "type": "object", + "description": "Live backend-pool diagnostics for a generated Docker vhost. Shape matches entries returned by /api/backends.", + "additionalProperties": true, + "properties": { + "id": { + "type": "string" + }, + "health_mode": { + "type": "string", + "enum": [ + "off", + "tcp", + "http" + ] + }, + "members": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "required": [ + "id", + "members" + ] + }, + "ProxyTimeouts": { + "type": "object", + "additionalProperties": false, + "properties": { + "connect_timeout_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 300, + "nullable": true, + "default": 10, + "description": "Backend TCP/TLS connect timeout. Missing values default to 10 seconds; explicit null inherits the runtime fallback." + }, + "response_header_timeout_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 86400, + "nullable": true, + "default": 120, + "description": "Deadline until upstream response headers arrive. Missing values default to 120 seconds; explicit null inherits the startup value." + }, + "read_timeout_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 86400, + "nullable": true, + "default": 60, + "description": "Maximum idle gap while reading the upstream response body." + }, + "send_timeout_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 86400, + "nullable": true, + "default": 60, + "description": "Maximum idle gap while streaming the request body upstream." + } + } + }, + "AdminPreferences": { + "type": "object", + "additionalProperties": false, + "properties": { + "live_log_columns": { + "type": "array", + "minItems": 1, + "maxItems": 13, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "time", + "request", + "host", + "source_ip", + "vhost", + "backend", + "status", + "duration", + "cache", + "outcome", + "transport", + "websocket", + "upstream_error" + ] + }, + "description": "Account-scoped Live Logs columns shown in the dashboard. JSON log export still contains every field present in each entry." + }, + "theme": { + "type": "string", + "enum": [ + "dark", + "light" + ], + "default": "dark", + "description": "Account-scoped administration interface theme. Existing accounts and omitted values default to the current dark theme." + } + }, + "required": [ + "live_log_columns" + ] + }, + "TransformSettings": { + "type": "object", + "additionalProperties": false, + "description": "Global static-response transformation settings. The global engine and the individual vhost must both opt in.", + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "javascript": { + "type": "boolean", + "default": true + }, + "css": { + "type": "boolean", + "default": true + }, + "html": { + "type": "boolean", + "default": false + }, + "svg": { + "type": "boolean", + "default": false + }, + "json": { + "type": "boolean", + "default": false + }, + "extensions": { + "type": "array", + "maxItems": 64, + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9]{1,16}$" + }, + "default": [ + "js", + "mjs", + "cjs", + "css", + "html", + "htm", + "svg", + "json" + ] + }, + "max_body_bytes": { + "type": "integer", + "minimum": 1024, + "maximum": 33554432, + "default": 2097152 + } + } + }, + "TransformOverrides": { + "type": "object", + "additionalProperties": false, + "description": "Per-vhost transform opt-in. enabled defaults to false even when the global engine is enabled; format fields inherit when null/omitted.", + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "javascript": { + "type": "boolean", + "nullable": true + }, + "css": { + "type": "boolean", + "nullable": true + }, + "html": { + "type": "boolean", + "nullable": true + }, + "svg": { + "type": "boolean", + "nullable": true + }, + "json": { + "type": "boolean", + "nullable": true + }, + "extensions": { + "type": "array", + "nullable": true, + "maxItems": 64, + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9]{1,16}$" + } + } + } + }, + "ColdCleanupRequest": { + "type": "object", + "additionalProperties": false, + "description": "Select cold-cache objects with the same eviction machinery used by automatic GC. At least one criterion is required.", + "properties": { + "unused_for_seconds": { + "type": "integer", + "format": "int64", + "minimum": 1, + "maximum": 31536000, + "nullable": true + }, + "oldest": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "nullable": true, + "description": "Maximum number of oldest matching objects to remove." + }, + "hits_zero": { + "type": "boolean", + "default": false + } + } + }, + "ColdCleanupJob": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "string", + "enum": [ + "queued", + "running", + "done", + "failed" + ] + }, + "removed_entries": { + "type": "integer", + "format": "int64" + }, + "freed_bytes": { + "type": "integer", + "format": "int64" + }, + "error": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "status", + "removed_entries", + "freed_bytes" + ] + }, + "LoadBalancingConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "algorithm": { + "type": "string", + "enum": [ + "round_robin", + "least_connections", + "least_traffic", + "adaptive", + "ip_hash" + ], + "default": "least_connections" + }, + "health_check": { + "$ref": "#/components/schemas/DockerHealthCheck", + "description": "Active TCP/HTTP health-check policy. Independent from passive health." + }, + "passive_health": { + "$ref": "#/components/schemas/PassiveHealthConfig", + "description": "Opt-in health decisions based on ordinary user traffic. Defaults to disabled." + }, + "keepalive": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "idle_timeout_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 3600, + "default": 30 + }, + "max_idle_per_host": { + "type": "integer", + "minimum": 1, + "maximum": 64, + "default": 4 + }, + "tcp_keepalive_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 3600, + "default": 30 + } + } + }, + "retry": { + "type": "object", + "additionalProperties": false, + "description": "Safe failover retry policy. Applies only to body-free GET/HEAD/OPTIONS requests and always selects a different healthy pool member.", + "properties": { + "max_retries": { + "type": "integer", + "minimum": 0, + "maximum": 3, + "default": 1 + }, + "retry_connect_errors": { + "type": "boolean", + "default": true + }, + "retry_timeouts": { + "type": "boolean", + "default": true + }, + "retry_statuses": { + "type": "array", + "maxItems": 8, + "items": { + "type": "integer", + "minimum": 500, + "maximum": 599 + }, + "default": [ + 502, + 503, + 504 + ] + } + } + }, + "sticky": { + "type": "object", + "additionalProperties": false, + "properties": { + "mode": { + "type": "string", + "enum": [ + "off", + "cookie", + "ip_hash" + ], + "default": "off" + }, + "cookie_name": { + "type": "string", + "nullable": true + }, + "ttl_seconds": { + "type": "integer", + "minimum": 60, + "maximum": 604800, + "default": 3600 + } + } + }, + "slow_start_seconds": { + "type": "integer", + "minimum": 0, + "maximum": 300, + "default": 10 + } + } + }, + "PassiveHealthConfig": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Opt-in switch. When false, ordinary user traffic never changes shared backend healthy/unhealthy state." + }, + "count_connect_errors": { + "type": "boolean", + "default": true + }, + "count_timeouts": { + "type": "boolean", + "default": true + }, + "count_io_errors": { + "type": "boolean", + "default": true + }, + "count_http_5xx": { + "type": "boolean", + "default": true + }, + "count_slow_responses": { + "type": "boolean", + "default": false + }, + "fall": { + "type": "integer", + "minimum": 1, + "maximum": 20, + "nullable": true, + "description": "Optional passive failure threshold override; null inherits health_check.fall." + }, + "rise": { + "type": "integer", + "minimum": 1, + "maximum": 20, + "nullable": true, + "description": "Optional passive recovery threshold override; null inherits health_check.rise." + } + } + }, + "TransformCounters": { + "type": "object", + "additionalProperties": false, + "properties": { + "examined": { + "type": "integer", + "minimum": 0 + }, + "applied": { + "type": "integer", + "minimum": 0 + }, + "skipped": { + "type": "integer", + "minimum": 0 + }, + "errors": { + "type": "integer", + "minimum": 0 + }, + "cache_hits": { + "type": "integer", + "minimum": 0 + }, + "original_bytes": { + "type": "integer", + "minimum": 0 + }, + "transformed_bytes": { + "type": "integer", + "minimum": 0 + }, + "saved_bytes": { + "type": "integer", + "minimum": 0 + }, + "duration_us": { + "type": "integer", + "minimum": 0 + }, + "by_kind": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + }, + "reasons": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + } + }, + "required": [ + "examined", + "applied", + "skipped", + "errors", + "cache_hits", + "original_bytes", + "transformed_bytes", + "saved_bytes", + "duration_us", + "by_kind", + "reasons" + ] + }, + "TransformScopeCounters": { + "type": "object", + "additionalProperties": false, + "properties": { + "vhost": { + "type": "string" + }, + "examined": { + "type": "integer", + "minimum": 0 + }, + "applied": { + "type": "integer", + "minimum": 0 + }, + "skipped": { + "type": "integer", + "minimum": 0 + }, + "errors": { + "type": "integer", + "minimum": 0 + }, + "cache_hits": { + "type": "integer", + "minimum": 0 + }, + "original_bytes": { + "type": "integer", + "minimum": 0 + }, + "transformed_bytes": { + "type": "integer", + "minimum": 0 + }, + "saved_bytes": { + "type": "integer", + "minimum": 0 + }, + "duration_us": { + "type": "integer", + "minimum": 0 + }, + "by_kind": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + }, + "reasons": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + } + }, + "required": [ + "vhost", + "examined", + "applied", + "skipped", + "errors", + "cache_hits", + "original_bytes", + "transformed_bytes", + "saved_bytes", + "duration_us", + "by_kind", + "reasons" + ] + }, + "TransformStatus": { + "type": "object", + "additionalProperties": false, + "properties": { + "settings": { + "$ref": "#/components/schemas/TransformSettings" + }, + "metrics": { + "type": "object", + "additionalProperties": false, + "properties": { + "total": { + "$ref": "#/components/schemas/TransformCounters" + }, + "scopes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TransformScopeCounters" + } + } + }, + "required": [ + "total", + "scopes" + ] + }, + "vhosts": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "hosts": { + "type": "array", + "items": { + "type": "string" + } + }, + "settings": { + "$ref": "#/components/schemas/TransformSettings" + } + }, + "required": [ + "id", + "hosts" + ] + } + } + }, + "required": [ + "settings", + "metrics", + "vhosts" + ] + }, + "CacheInvalidationRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "operation", + "selector", + "value" + ], + "properties": { + "operation": { + "type": "string", + "enum": [ + "purge", + "ban" + ] + }, + "selector": { + "type": "string", + "enum": [ + "exact", + "prefix", + "tag", + "regex", + "cache_key" + ] + }, + "value": { + "type": "string", + "maxLength": 8192 + }, + "host": { + "type": "string", + "maxLength": 253, + "nullable": true + }, + "vhost": { + "type": "string", + "maxLength": 64, + "nullable": true + }, + "ttl_seconds": { + "type": "integer", + "minimum": 1, + "maximum": 604800, + "nullable": true, + "description": "BAN only; defaults to 3600 seconds." + }, + "confirm_wide": { + "type": "boolean", + "default": false, + "description": "Required for every regex operation and every root-prefix / operation." + } + }, + "description": "Selective cache invalidation. exact/prefix accept either /path?query or an absolute HTTP(S) URL. Regex is length- and compiled-size-bounded." + }, + "CacheInvalidationResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "operation", + "selector", + "value", + "affected_entries", + "elapsed_ms", + "status" + ], + "properties": { + "operation": { + "type": "string", + "enum": [ + "purge", + "ban" + ] + }, + "selector": { + "type": "string", + "enum": [ + "exact", + "prefix", + "tag", + "regex", + "cache_key" + ] + }, + "value": { + "type": "string" + }, + "host": { + "type": "string", + "nullable": true + }, + "vhost": { + "type": "string", + "nullable": true + }, + "affected_entries": { + "type": "integer", + "minimum": 0 + }, + "ban_id": { + "type": "integer", + "minimum": 1, + "nullable": true + }, + "elapsed_ms": { + "type": "integer", + "minimum": 0 + }, + "status": { + "type": "string", + "enum": [ + "completed", + "active" + ] + } + } + }, + "CacheBan": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "selector", + "value", + "created_at_ms", + "expires_at_ms" + ], + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "selector": { + "type": "string", + "enum": [ + "exact", + "prefix", + "tag", + "regex", + "cache_key" + ] + }, + "value": { + "type": "string" + }, + "scheme": { + "type": "string", + "nullable": true + }, + "host": { + "type": "string", + "nullable": true + }, + "vhost": { + "type": "string", + "nullable": true + }, + "created_at_ms": { + "type": "integer", + "minimum": 0 + }, + "expires_at_ms": { + "type": "integer", + "minimum": 0 + } + } + }, + "CacheInvalidationResponse": { + "type": "object", + "additionalProperties": false, + "required": [ + "result", + "metrics", + "bans" + ], + "properties": { + "result": { + "$ref": "#/components/schemas/CacheInvalidationResult" + }, + "metrics": { + "type": "object", + "additionalProperties": true + }, + "bans": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CacheBan" + } + } + } + }, + "CacheBansResponse": { + "type": "object", + "additionalProperties": false, + "required": [ + "bans" + ], + "properties": { + "bans": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CacheBan" + } + } + } + }, + "TunnelInfo": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "client", + "host", + "upstream", + "protocol", + "duration_ms", + "rx_bytes", + "tx_bytes" + ], + "properties": { + "id": { + "type": "integer", + "format": "uint64" + }, + "client": { + "type": "string" + }, + "host": { + "type": "string" + }, + "upstream": { + "type": "string" + }, + "protocol": { + "type": "string", + "description": "Frontend/upstream tunnel transport, for example h1-upgrade, h2-rfc8441-h1-upstream, h2-rfc8441-h2-upstream or h3-rfc9220." + }, + "duration_ms": { + "type": "integer", + "format": "uint64" + }, + "rx_bytes": { + "type": "integer", + "format": "uint64", + "description": "Bytes from client toward upstream." + }, + "tx_bytes": { + "type": "integer", + "format": "uint64", + "description": "Bytes from upstream toward client." + } + } + }, + "TunnelListResponse": { + "type": "object", + "additionalProperties": false, + "required": [ + "tunnels" + ], + "properties": { + "tunnels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TunnelInfo" + } + } + } + }, + "TunnelKillRequest": { + "type": "object", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "maxLength": 256, + "nullable": true + } + } + }, + "TunnelKillResponse": { + "type": "object", + "additionalProperties": false, + "required": [ + "closed", + "reason" + ], + "properties": { + "closed": { + "$ref": "#/components/schemas/TunnelInfo" + }, + "reason": { + "type": "string" + } + } + }, + "CertificateReloadResult": { + "type": "object", + "properties": { + "reloaded": { + "type": "boolean" + }, + "revision": { + "type": "integer", + "minimum": 0 + }, + "public_tls": { + "type": "boolean" + }, + "http3_new_handshakes": { + "type": "boolean" + }, + "administration_restart_required": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "reloaded", + "revision", + "public_tls", + "http3_new_handshakes", + "administration_restart_required" + ] + }, + "CertificateDeploy": { + "type": "object", + "properties": { + "certificate_id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "cert_pem": { + "type": "string" + }, + "key_pem": { + "type": "string", + "writeOnly": true, + "description": "Private key PEM; never returned or persisted in API/audit payloads." + } + }, + "additionalProperties": false, + "required": [ + "certificate_id", + "cert_pem", + "key_pem" + ] + }, + "CertificateDeployResult": { + "type": "object", + "properties": { + "deployed": { + "type": "boolean" + }, + "operation": { + "type": "string", + "enum": [ + "created", + "updated", + "metadata-updated", + "unchanged" + ] + }, + "created": { + "type": "boolean" + }, + "changed": { + "type": "boolean" + }, + "certificate_id": { + "type": "string" + }, + "revision": { + "type": "integer", + "minimum": 0 + }, + "cert_file": { + "type": "string" + }, + "key_file": { + "type": "string" + }, + "covered_hosts": { + "type": "array", + "items": { + "type": "string" + } + }, + "public_tls": { + "type": "boolean" + }, + "http3_new_handshakes": { + "type": "boolean" + }, + "administration_restart_required": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "deployed", + "operation", + "created", + "changed", + "certificate_id", + "revision", + "cert_file", + "key_file", + "covered_hosts", + "public_tls", + "http3_new_handshakes", + "administration_restart_required" + ] + }, + "CertificateApiStatus": { + "type": "object", + "additionalProperties": false, + "properties": { + "configured": { + "type": "boolean" + }, + "key_id": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "integer", + "nullable": true + }, + "last_used_at": { + "type": "integer", + "nullable": true + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "credentials": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CertificateApiCredential" + } + }, + "max_credentials": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "configured", + "key_id", + "created_at", + "last_used_at", + "scopes", + "credentials", + "max_credentials" + ] + }, + "CertificateApiRotate": { + "type": "object", + "additionalProperties": false, + "properties": { + "key_id": { + "type": "string", + "maxLength": 64, + "nullable": true, + "minLength": 3 + }, + "token": { + "type": "string", + "minLength": 32, + "maxLength": 128, + "writeOnly": true, + "nullable": true + }, + "comment": { + "type": "string", + "maxLength": 256, + "nullable": true, + "description": "Human-readable non-secret note identifying the automation or owner." + } + } + }, + "CertificateApiRotateResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "configured": { + "type": "boolean" + }, + "key_id": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "integer", + "nullable": true + }, + "last_used_at": { + "type": "integer", + "nullable": true + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "token": { + "type": "string", + "description": "Returned only once after rotation.", + "readOnly": true + }, + "generated": { + "type": "boolean", + "readOnly": true + }, + "credentials": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CertificateApiCredential" + } + }, + "max_credentials": { + "type": "integer", + "minimum": 1 + }, + "credential": { + "$ref": "#/components/schemas/CertificateApiCredential" + } + }, + "required": [ + "configured", + "key_id", + "created_at", + "last_used_at", + "scopes", + "token", + "generated", + "credentials", + "max_credentials", + "credential" + ] + }, + "Http2ConnectionSettings": { + "type": "object", + "description": "Per-listener HTTP/2/H2C connection lifecycle limits. Idle and lifetime both use graceful GOAWAY -> drain -> TCP close. The top-level config.http2 object is retained only as a compatibility fallback for listeners without explicit binding.http2_settings.", + "properties": { + "idle_timeout_seconds": { + "type": "integer", + "format": "int64", + "minimum": 1, + "maximum": 86400, + "default": 300, + "description": "Idle timeout for an unused connection. Active streams or frame traffic postpone idle shutdown." + }, + "max_connection_lifetime_seconds": { + "type": "integer", + "format": "int64", + "minimum": 0, + "maximum": 2592000, + "default": 0, + "description": "Absolute connection lifetime. Zero disables the lifetime limit." + }, + "drain_timeout_seconds": { + "type": "integer", + "format": "int64", + "minimum": 1, + "maximum": 3600, + "default": 10, + "description": "Maximum drain period after GOAWAY before the TCP transport is closed." + } + }, + "required": [ + "idle_timeout_seconds", + "max_connection_lifetime_seconds", + "drain_timeout_seconds" + ], + "additionalProperties": false + }, + "RuntimeInformation": { + "type": "object", + "description": "Low-risk runtime metadata returned only by the authenticated administrator status endpoint.", + "properties": { + "platform": { + "type": "string" + }, + "statistics_source": { + "type": "string" + }, + "data_directory": { + "type": "string", + "nullable": true + }, + "database_path": { + "type": "string", + "nullable": true + } + }, + "required": [ + "platform", + "statistics_source", + "data_directory", + "database_path" + ] + }, + "CertificateApiCredential": { + "type": "object", + "additionalProperties": false, + "properties": { + "key_id": { + "type": "string" + }, + "comment": { + "type": "string", + "maxLength": 256 + }, + "created_at": { + "type": "integer" + }, + "last_used_at": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "key_id", + "comment", + "created_at", + "last_used_at" + ] + }, + "CertificateReloadOneResult": { + "type": "object", + "additionalProperties": false, + "properties": { + "reloaded": { + "type": "boolean" + }, + "certificate_id": { + "type": "string" + }, + "revision": { + "type": "integer", + "minimum": 0 + }, + "public_tls": { + "type": "boolean" + }, + "http3_new_handshakes": { + "type": "boolean" + }, + "administration_restart_required": { + "type": "boolean" + } + }, + "required": [ + "reloaded", + "certificate_id", + "revision", + "public_tls", + "http3_new_handshakes", + "administration_restart_required" + ] + } + } + }, + "paths": { + "/healthz": { + "get": { + "summary": "Minimal liveness", + "tags": [ + "Public bootstrap" + ], + "operationId": "get_healthz", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Health" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [] + } + }, + "/api/auth/status": { + "get": { + "summary": "First-account setup requirement only", + "tags": [ + "Public bootstrap" + ], + "operationId": "get_api_auth_status", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthStatus" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [] + } + }, + "/api/auth/setup": { + "post": { + "summary": "Create the first administrator", + "tags": [ + "Authentication" + ], + "operationId": "post_api_auth_setup", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Object" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting revision or job state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request exceeds the endpoint body limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Configuration/input validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "description": "One-time local capability required. Rejected after initialization. Not executable from Swagger UI.", + "security": [], + "parameters": [ + { + "name": "Origin", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "Exact http://HOST:ADMIN_PORT. Set automatically by same-origin browser POST/PUT." + }, + { + "name": "X-RustProxy-Request", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Setup" + } + } + } + } + } + }, + "/api/auth/login": { + "post": { + "summary": "Create an administrator session", + "tags": [ + "Authentication" + ], + "operationId": "post_api_auth_login", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting revision or job state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request exceeds the endpoint body limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Configuration/input validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7–30 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.", + "security": [], + "parameters": [ + { + "name": "Origin", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "Exact http://HOST:ADMIN_PORT. Set automatically by same-origin browser POST/PUT." + }, + { + "name": "X-RustProxy-Request", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Login" + } + } + } + } + } + }, + "/api/auth/session": { + "get": { + "summary": "Read this session and its CSRF token", + "tags": [ + "Authentication" + ], + "operationId": "get_api_auth_session", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/auth/logout": { + "post": { + "summary": "Revoke this session", + "tags": [ + "Authentication" + ], + "operationId": "post_api_auth_logout", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Object" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting revision or job state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request exceeds the endpoint body limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Configuration/input validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "Origin", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "Exact http://HOST:ADMIN_PORT. Set automatically by same-origin browser POST/PUT." + }, + { + "name": "X-RustProxy-Request", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + } + ], + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + } + ] + } + }, + "/api/auth/logout-all": { + "post": { + "summary": "Revoke all administrator sessions", + "tags": [ + "Authentication" + ], + "operationId": "post_api_auth_logout_all", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Object" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting revision or job state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request exceeds the endpoint body limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Configuration/input validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "Origin", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "Exact http://HOST:ADMIN_PORT. Set automatically by same-origin browser POST/PUT." + }, + { + "name": "X-RustProxy-Request", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + } + ], + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + } + ] + } + }, + "/api/auth/password": { + "post": { + "summary": "Change password and revoke sessions", + "tags": [ + "Authentication" + ], + "operationId": "post_api_auth_password", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Object" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting revision or job state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request exceeds the endpoint body limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Configuration/input validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "Origin", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "Exact http://HOST:ADMIN_PORT. Set automatically by same-origin browser POST/PUT." + }, + { + "name": "X-RustProxy-Request", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + } + ], + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasswordChange" + } + } + } + } + } + }, + "/api/status": { + "get": { + "summary": "Application, listener, system and traffic status", + "tags": [ + "Monitoring" + ], + "operationId": "get_api_status", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Status" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/backends": { + "get": { + "summary": "Backend pool health and active/desired frontends", + "tags": [ + "Monitoring" + ], + "operationId": "get_api_backends", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Object" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/config": { + "get": { + "summary": "Read the revisioned configuration", + "tags": [ + "Configuration" + ], + "operationId": "get_api_config", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Envelope" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "put": { + "summary": "Replace the entire configuration atomically", + "tags": [ + "Configuration" + ], + "operationId": "put_api_config", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Envelope" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting revision or job state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request exceeds the endpoint body limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Configuration/input validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "description": "NOT a patch or merge. First GET /api/config, edit config and PUT with the current expected_revision. On conflict reload instead of overwriting. Header rules live in config.vhosts[].routes[].headers (global -> vhost -> location). Protocol and verified identity policies run last. Only the longest matching prefix applies. Full-document PUT remains supported for atomic multi-section changes; prefer /api/config/{section} for a normal settings-page save so unrelated sections are not resent.", + "parameters": [ + { + "name": "Origin", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "Exact http://HOST:ADMIN_PORT. Set automatically by same-origin browser POST/PUT." + }, + { + "name": "X-RustProxy-Request", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + } + ], + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Update" + } + } + } + } + } + }, + "/api/certificates": { + "get": { + "summary": "Certificate inventory and renewal state", + "tags": [ + "Certificates" + ], + "operationId": "get_api_certificates", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificateInventory" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "description": "Returns active SNI bindings and covered_hosts metadata. Public certificate PEM files are inspected on a bounded blocking worker so current DNS SAN coverage is shown even for older/manual entries or in-place renewals; private keys are never read for inventory." + } + }, + "/api/certificates/upload": { + "post": { + "summary": "Validate and stage a PEM pair", + "tags": [ + "Certificates" + ], + "operationId": "post_api_certificates_upload", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificateUploadResult" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting revision or job state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request exceeds the endpoint body limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Configuration/input validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "description": "Maximum body 300 KiB. Staging does not assign a vhost; publish the returned certificate through a revisioned config update. Private key material is never returned. The returned certificate includes covered_hosts extracted from the staged leaf certificate DNS SubjectAltName extension.", + "parameters": [ + { + "name": "Origin", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "Exact http://HOST:ADMIN_PORT. Set automatically by same-origin browser POST/PUT." + }, + { + "name": "X-RustProxy-Request", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + } + ], + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificateUpload" + } + } + } + } + } + }, + "/api/acme": { + "get": { + "summary": "Latest ACME job and renewal observations", + "tags": [ + "Certificates" + ], + "operationId": "get_api_acme", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Object" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/acme/issue": { + "post": { + "summary": "Start a bounded issuance job", + "tags": [ + "Certificates" + ], + "operationId": "post_api_acme_issue", + "responses": { + "202": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Object" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting revision or job state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request exceeds the endpoint body limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Configuration/input validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "description": "Maximum body 16 KiB. Staging by default. Production requires explicit consent. Provider fields depend on the selected provider; see docs/ACME_PROVIDERS.md. Poll GET /api/acme.", + "parameters": [ + { + "name": "Origin", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "Exact http://HOST:ADMIN_PORT. Set automatically by same-origin browser POST/PUT." + }, + { + "name": "X-RustProxy-Request", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + } + ], + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AcmeIssue" + } + } + } + } + } + }, + "/api/acme/renew": { + "post": { + "summary": "Check/renew a managed certificate", + "tags": [ + "Certificates" + ], + "operationId": "post_api_acme_renew", + "responses": { + "202": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Object" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting revision or job state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request exceeds the endpoint body limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Configuration/input validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "Origin", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "Exact http://HOST:ADMIN_PORT. Set automatically by same-origin browser POST/PUT." + }, + { + "name": "X-RustProxy-Request", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + } + ], + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RenewRequest" + } + } + } + } + } + }, + "/api/basic-auth/hash": { + "post": { + "summary": "Hash credentials for a vhost", + "tags": [ + "Configuration" + ], + "operationId": "post_api_basic_auth_hash", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HashedUser" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting revision or job state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request exceeds the endpoint body limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Configuration/input validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "description": "Maximum body 4 KiB. Plaintext password is input-only; store the returned Argon2id hash in vhost configuration, not this request.", + "parameters": [ + { + "name": "Origin", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "Exact http://HOST:ADMIN_PORT. Set automatically by same-origin browser POST/PUT." + }, + { + "name": "X-RustProxy-Request", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + } + ], + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BasicHash" + } + } + } + } + } + }, + "/api/cache": { + "get": { + "summary": "Cache metrics, domains and paginated objects", + "tags": [ + "Cache" + ], + "operationId": "get_api_cache", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Cache" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "vhost", + "in": "query", + "required": false, + "description": "Configured vhost ID. Omit to select all scopes.", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "integer", + "minimum": 1, + "default": 50 + } + } + ] + } + }, + "/api/transform": { + "get": { + "summary": "Transformation runtime metrics and per-vhost effectiveness", + "tags": [ + "Transform" + ], + "operationId": "get_api_transform", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TransformStatus" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/cache/purge": { + "post": { + "summary": "Purge all cache entries or one vhost", + "tags": [ + "Cache" + ], + "operationId": "post_api_cache_purge", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Object" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting revision or job state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request exceeds the endpoint body limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Configuration/input validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "description": "Invalidates stored entries and pending fills in the selected scope. Select the vhost in the QUERY STRING, not the body.", + "parameters": [ + { + "name": "Origin", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "Exact http://HOST:ADMIN_PORT. Set automatically by same-origin browser POST/PUT." + }, + { + "name": "X-RustProxy-Request", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + }, + { + "name": "vhost", + "in": "query", + "required": false, + "description": "Configured vhost ID. Omit to select all scopes.", + "schema": { + "type": "string" + } + } + ], + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + } + ] + } + }, + "/api/logs": { + "get": { + "summary": "Recent completed requests, filtered", + "tags": [ + "Monitoring" + ], + "operationId": "get_api_logs", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Logs" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "vhost", + "in": "query", + "required": false, + "description": "Configured vhost ID. Omit to select all scopes.", + "schema": { + "type": "string" + } + }, + { + "name": "q", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "string", + "maxLength": 256 + } + }, + { + "name": "status", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "integer", + "minimum": 100, + "maximum": 599 + } + }, + { + "name": "cache", + "in": "query", + "required": false, + "description": "", + "schema": { + "type": "string", + "enum": [ + "HIT", + "MISS", + "BYPASS", + "-" + ] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Number of retained completed requests to return. Live UI snapshots use 256; an expanded Live Logs window can request up to 4096.", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 4096, + "default": 256 + } + } + ] + } + }, + "/api/audit": { + "get": { + "summary": "Recent administrator audit events", + "tags": [ + "Monitoring" + ], + "operationId": "get_api_audit", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Audit" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/events": { + "get": { + "summary": "Live SSE status and log snapshots", + "tags": [ + "Monitoring" + ], + "operationId": "get_api_events", + "responses": { + "200": { + "description": "Successful response", + "content": { + "text/event-stream": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "description": "Bounded snapshot every approximately 2 seconds. Session/revocation rechecked before every snapshot. Not executed in Swagger because it is an unbounded stream." + } + }, + "/metrics": { + "get": { + "summary": "Authenticated Prometheus text exposition", + "tags": [ + "Monitoring" + ], + "operationId": "get_metrics", + "responses": { + "200": { + "description": "Successful response", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "description": "Requires the admin session on THIS listener. The optional dedicated exporter uses independent CIDR/bearer policy; it is disabled by default." + } + }, + "/api/metrics": { + "get": { + "summary": "Authenticated Prometheus text exposition", + "tags": [ + "Monitoring" + ], + "operationId": "get_api_metrics", + "responses": { + "200": { + "description": "Successful response", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "description": "Requires the admin session on THIS listener. The optional dedicated exporter uses independent CIDR/bearer policy; it is disabled by default." + } + }, + "/docs/openapi.json": { + "get": { + "summary": "This protected OpenAPI document", + "tags": [ + "Documentation" + ], + "operationId": "get_docs_openapi.json", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Object" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/config/{section}": { + "put": { + "summary": "Replace one configuration section atomically", + "tags": [ + "Configuration" + ], + "operationId": "put_api_config_section", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Envelope" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting revision or job state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request exceeds the endpoint body limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Configuration/input validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "description": "Preferred UI/control-plane save path. Replaces only the selected section while preserving every other section from the current revision. This is not a merge patch. Cache identity is independent from the global configuration revision: logging, listeners, TLS inventory, metrics and administration saves do not purge cached objects. Changes which can alter origin requests or cached responses (cache/transform/response/forwarding/vhosts) rotate the cache compatibility identity and purge incompatible hot/cold objects. Supported section names include docker-agents, vhosts, redirects, not_found_hosts, streams, listeners, http2, tls, pages, cache, transform, logging, forwarding, headers, prometheus, administration, acl-profiles, host-policies and response.", + "parameters": [ + { + "name": "section", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "listeners", + "docker-agents", + "response", + "pages", + "cache", + "transform", + "logging", + "forwarding", + "headers", + "prometheus", + "administration", + "http2", + "tls", + "vhosts", + "redirects", + "not_found_hosts", + "streams", + "acl-profiles", + "host-policies" + ] + }, + "description": "Configuration section to replace." + }, + { + "name": "Origin", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "Exact http://HOST:ADMIN_PORT. Set automatically by same-origin browser POST/PUT." + }, + { + "name": "X-RustProxy-Request", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + } + ], + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfigSectionUpdate" + } + } + } + } + } + }, + "/api/docker": { + "get": { + "summary": "Read cached Docker agent observations", + "tags": [ + "Docker" + ], + "operationId": "get_api_docker", + "responses": { + "200": { + "description": "Current Docker agent observations", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DockerObservationResponse" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "AdminSession": [] + } + ] + } + }, + "/api/docker/refresh": { + "post": { + "summary": "Poll Docker agents now and rebuild dynamic Docker vhosts", + "description": "Observed inventory remains in memory. Only configured Docker assignments are persisted; a refresh does not create a configuration revision.", + "tags": [ + "Docker" + ], + "operationId": "post_api_docker_refresh", + "responses": { + "200": { + "description": "Current Docker agent observations", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DockerObservationResponse" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Docker observation cannot be reconciled into the active routing snapshot", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "parameters": [ + { + "name": "Origin", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-RustProxy-Request", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + } + ], + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + } + ] + } + }, + "/api/preferences": { + "get": { + "summary": "Read administrator view preferences", + "tags": [ + "Administration" + ], + "operationId": "get_api_preferences", + "responses": { + "200": { + "description": "Current account-scoped view preferences", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPreferences" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "put": { + "summary": "Save administrator view preferences", + "description": "Persists dashboard-only preferences for the signed-in administrator. This does not change the proxy configuration revision.", + "tags": [ + "Administration" + ], + "operationId": "put_api_preferences", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPreferences" + } + } + } + }, + "responses": { + "200": { + "description": "Saved view preferences", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminPreferences" + } + } + } + }, + "422": { + "description": "Invalid preference value", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/cache/cold/cleanup": { + "post": { + "summary": "Start selective asynchronous Cold Cache cleanup", + "tags": [ + "Cache" + ], + "operationId": "post_api_cache_cold_cleanup", + "responses": { + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Conflicting revision or job state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request exceeds the endpoint body limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Configuration/input validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "202": { + "description": "Cleanup job accepted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "job": { + "$ref": "#/components/schemas/ColdCleanupJob" + } + }, + "required": [ + "job" + ] + } + } + } + } + }, + "description": "Queues selective cleanup using the same eviction selector as automatic cold-cache GC. Existing full purge endpoints remain unchanged.", + "parameters": [ + { + "name": "Origin", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "Exact http://HOST:ADMIN_PORT. Set automatically by same-origin browser POST/PUT." + }, + { + "name": "X-RustProxy-Request", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + } + ], + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ColdCleanupRequest" + } + } + } + } + } + }, + "/api/cache/cold/cleanup/{id}": { + "get": { + "summary": "Read selective Cold Cache cleanup job status", + "tags": [ + "Cache" + ], + "operationId": "get_api_cache_cold_cleanup_job", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "Cleanup job", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "job": { + "$ref": "#/components/schemas/ColdCleanupJob" + } + }, + "required": [ + "job" + ] + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Unknown cleanup job", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "AdminSession": [] + } + ] + } + }, + "/api/cache/invalidate": { + "post": { + "summary": "Selectively PURGE cached objects or add a lazy BAN rule", + "tags": [ + "Cache" + ], + "operationId": "post_api_cache_invalidate", + "description": "PURGE removes matching RAM and cold-cache variants immediately. Every selective PURGE also blocks any fill that started before the operation from publishing afterward; matching single-flight waiters are released. BAN registers a TTL-bounded rule and is evaluated lazily during lookup/GC without synchronously scanning storage. Regex operations and prefix / require confirm_wide=true.", + "parameters": [ + { + "name": "Origin", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "Exact http://HOST:ADMIN_PORT. Set automatically by same-origin browser POST/PUT/DELETE." + }, + { + "name": "X-RustProxy-Request", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + } + ], + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CacheInvalidationRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Invalidation accepted/completed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CacheInvalidationResponse" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request exceeds the endpoint body limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Configuration/input validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/cache/bans": { + "get": { + "summary": "List active cache BAN rules", + "tags": [ + "Cache" + ], + "operationId": "get_api_cache_bans", + "responses": { + "200": { + "description": "Active BAN rules", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CacheBansResponse" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/cache/bans/{id}": { + "delete": { + "summary": "Remove one cache BAN rule", + "tags": [ + "Cache" + ], + "operationId": "delete_api_cache_ban", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "name": "Origin", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "Exact http://HOST:ADMIN_PORT. Set automatically by same-origin browser POST/PUT/DELETE." + }, + { + "name": "X-RustProxy-Request", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + } + ], + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + } + ], + "responses": { + "200": { + "description": "BAN removed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CacheBansResponse" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "BAN rule does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/tunnels": { + "get": { + "summary": "List active WebSocket/Extended CONNECT tunnels", + "tags": [ + "Monitoring" + ], + "operationId": "get_api_tunnels", + "responses": { + "200": { + "description": "Active WebSocket/Extended CONNECT tunnels", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TunnelListResponse" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/tunnels/{id}/kill": { + "post": { + "summary": "Cancel one active WebSocket/Extended CONNECT tunnel", + "tags": [ + "Monitoring" + ], + "operationId": "post_api_tunnel_kill", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "minimum": 1 + } + }, + { + "name": "Origin", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "Exact http://HOST:ADMIN_PORT. Set automatically by same-origin browser POST/PUT/DELETE." + }, + { + "name": "X-RustProxy-Request", + "in": "header", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + } + ], + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TunnelKillRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Tunnel cancellation requested and audited", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TunnelKillResponse" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Tunnel is no longer active", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Invalid close reason", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded worker/session/rate limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/certificates/reload": { + "post": { + "summary": "Reload configured certificate files from disk", + "description": "Rebuilds and hot-publishes the public TLS routing snapshot from the currently configured certificate/key files without changing the configuration revision. New public TLS/H2/H3 handshakes use the refreshed keys. Administration TLS remains startup-fixed and reports when a restart is required. The operation can be authenticated either by the normal administrator session+CSRF flow or by the scoped Certificate API Key ID + Bearer Token. External token use requires HTTPS unless the request originates from loopback.", + "tags": [ + "Certificates" + ], + "operationId": "post_api_certificates_reload", + "responses": { + "200": { + "description": "Certificates revalidated and hot-reloaded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificateReloadResult" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Configured certificate files failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + }, + { + "CertificateApiBearer": [], + "CertificateApiKey": [] + } + ] + } + }, + "/api/certificates/deploy": { + "post": { + "summary": "Reconcile an externally issued pair with a manual certificate id", + "description": "Maximum body 300 KiB. Convergently reconciles an externally issued PEM pair: creates a missing manual certificate id, atomically rotates an existing manual id through immutable generation-specific files, or returns a no-op when the selected RustProxy-owned generation already contains the exact pair. All vhosts already assigned to that id use changed material for new public TLS/H2/H3 handshakes. RustProxy-managed ACME ids are rejected. The operation can be authenticated either by the normal administrator session+CSRF flow or by the scoped Certificate API Key ID + Bearer Token. External token use requires HTTPS unless the request originates from loopback.", + "tags": [ + "Certificates" + ], + "operationId": "post_api_certificates_deploy", + "parameters": [ + { + "name": "Origin", + "in": "header", + "required": false, + "schema": { + "type": "string" + }, + "description": "Exact http://HOST:ADMIN_PORT. Set automatically by same-origin browser/CLI mutation clients." + }, + { + "name": "X-RustProxy-Request", + "in": "header", + "required": false, + "schema": { + "type": "string", + "enum": [ + "1" + ] + } + } + ], + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + }, + { + "CertificateApiBearer": [], + "CertificateApiKey": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificateDeploy" + } + } + } + }, + "responses": { + "200": { + "description": "Certificate generation staged and published", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificateDeployResult" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Authentication required or revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin or CSRF validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Configured certificate id does not exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Certificate is RustProxy-managed or the configuration changed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Request exceeds the endpoint body limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Certificate/key/configuration validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Bounded certificate/configuration worker queue is full", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/certificate-api": { + "get": { + "summary": "Inspect scoped external certificate API tokens", + "tags": [ + "Certificates" + ], + "operationId": "get_api_certificate_api", + "security": [ + { + "AdminSession": [] + } + ], + "responses": { + "200": { + "description": "Configured token metadata (never cleartext secrets)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificateApiStatus" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "put": { + "summary": "Generate, add, or rotate a scoped external certificate API token", + "tags": [ + "Certificates" + ], + "operationId": "put_api_certificate_api", + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificateApiRotate" + } + } + } + }, + "responses": { + "200": { + "description": "Token added/rotated; cleartext token is returned once", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificateApiRotateResult" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "CSRF/origin validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Invalid Key ID, token, or comment format", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Certificate API token capacity reached", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "summary": "Revoke all scoped external certificate API tokens", + "tags": [ + "Certificates" + ], + "operationId": "delete_api_certificate_api", + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + } + ], + "responses": { + "200": { + "description": "All configured certificate API tokens revoked" + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "CSRF/origin validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/live-logs": { + "get": { + "summary": "Stream live access-log entries as authenticated SSE", + "description": "Bounded live stream used by rust-proxy --livelogs. Events are named log; a gap event reports how many entries a slow receiver lost. Browser/CLI session revocation and expiry are revalidated periodically.", + "tags": [ + "Logging" + ], + "operationId": "get_api_live_logs", + "security": [ + { + "AdminSession": [] + } + ], + "responses": { + "200": { + "description": "text/event-stream of complete access-log JSON entries", + "content": { + "text/event-stream": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Too many live viewers", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/certificate-api/{key_id}": { + "delete": { + "summary": "Revoke one scoped external certificate API token", + "tags": [ + "Certificates" + ], + "operationId": "delete_api_certificate_api_key_id", + "parameters": [ + { + "name": "key_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 3, + "maxLength": 64 + } + } + ], + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + } + ], + "responses": { + "200": { + "description": "Token revoked" + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "CSRF/origin validation failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Token Key ID not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/certificates/{certificate_id}/reload": { + "post": { + "summary": "Reload one manual/file-backed certificate from disk", + "description": "Targets one configured certificate ID that is not ACME-managed. RustProxy revalidates and atomically republishes its immutable TLS snapshot without changing the configuration revision. The scoped Certificate API token is accepted for this operation, making it suitable for external certificate renewal hooks. Managed ACME certificate IDs are rejected because their renewal pipeline owns those files.", + "tags": [ + "Certificates" + ], + "operationId": "post_api_certificates_certificate_id_reload", + "parameters": [ + { + "name": "certificate_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Certificate files validated and TLS snapshot republished", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CertificateReloadOneResult" + } + } + } + }, + "401": { + "description": "Authentication required or token revoked", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Host, origin, CSRF, or external HTTP restriction failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Certificate ID not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Certificate is application-managed and cannot be manually reloaded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Certificate/key files failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Control-plane storage/worker unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + }, + "security": [ + { + "AdminSession": [], + "CsrfToken": [] + }, + { + "CertificateApiBearer": [], + "CertificateApiKey": [] + } + ] + } + } + } +} diff --git a/src/admin/public.rs b/src/admin/public.rs new file mode 100644 index 0000000..27177cd --- /dev/null +++ b/src/admin/public.rs @@ -0,0 +1,21 @@ +//! Deliberately small anonymous surface. Operational templates/assets are private. +use crate::{auth::Session, runtime::ApiError}; +use axum::{extract::rejection::JsonRejection, response::Html, Extension}; +use http::StatusCode; + +pub(super) async fn index(session: Option>) -> Html<&'static str> { + if session.is_some() { + Html(include_str!("web/index.html")) + } else { + Html(include_str!("web/login.html")) + } +} +pub(super) fn invalid_json(error: JsonRejection) -> ApiError { + // Keep useful protocol statuses without returning serde's input excerpts. + let status = match error.status() { + StatusCode::PAYLOAD_TOO_LARGE => StatusCode::PAYLOAD_TOO_LARGE, + StatusCode::UNSUPPORTED_MEDIA_TYPE => StatusCode::UNSUPPORTED_MEDIA_TYPE, + _ => StatusCode::BAD_REQUEST, + }; + ApiError(status, "auth.invalid_request".into()) +} diff --git a/src/admin/web/acme.js b/src/admin/web/acme.js new file mode 100644 index 0000000..7fa6e04 --- /dev/null +++ b/src/admin/web/acme.js @@ -0,0 +1,184 @@ +"use strict"; +(() => { + let job = null, pending = false, loading = false, renewalLogs = {}; + const picker = $("certificate-provider"); + const secrets = ["acme-secret", "acme-cf-token", "acme-ovh-application-key", "acme-ovh-application-secret", "acme-ovh-consumer-key"]; + const tabs = [...document.querySelectorAll("[data-certificate-tab]")]; + const requested = typeof location !== "undefined" && location.pathname === "/tls" ? new URLSearchParams(location.search).get("tab") : null; + let activeTab = tabs.some(button => button.dataset.certificateTab === requested) ? requested : "installed"; + + function syncLocation() { + if (typeof location === "undefined" || location.pathname !== "/tls" || !["http:", "https:"].includes(location.protocol)) return; + const url = new URL(location.href); url.search = ""; url.searchParams.set("tab", activeTab); + history.replaceState({ ...(history.state || {}), panel: "tls", certificateTab: activeTab }, "", `${url.pathname}${url.search}${url.hash}`); + } + function selectTab(name, focus = false, persist = true) { + if (!tabs.some(button => button.dataset.certificateTab === name)) return; + activeTab = name; + for (const button of tabs) { + const selected = button.dataset.certificateTab === name; + button.setAttribute("aria-selected", String(selected)); button.tabIndex = selected ? 0 : -1; + if (selected && focus) button.focus(); + } + document.querySelectorAll("[data-certificate-page]").forEach(page => { page.hidden = page.dataset.certificatePage !== name; }); + if (persist) syncLocation(); + } + for (const button of tabs) { + button.addEventListener("click", () => selectTab(button.dataset.certificateTab)); + button.addEventListener("keydown", event => { + const index = tabs.indexOf(button); let next; + if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % tabs.length; + else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + tabs.length - 1) % tabs.length; + else if (event.key === "Home") next = 0; + else if (event.key === "End") next = tabs.length - 1; + else return; + event.preventDefault(); selectTab(tabs[next].dataset.certificateTab, true); + }); + } + + const clearSecrets = () => { for (const id of secrets) $(id).value = ""; }; + function syncProvider() { + const provider = picker.value, acme = !["import", "existing"].includes(provider); + $("certificate-acme-editor").hidden = !acme; + $("certificate-upload-form").hidden = provider !== "import"; + $("certificate-advanced").hidden = provider !== "existing"; + $("certificate-provider-hint").textContent = t(`providerHint_${provider}`); + for (const node of document.querySelectorAll("[data-acme-provider]")) { + const visible = acme && (node.dataset.acmeProvider === provider || (node.dataset.acmeProvider === "dns" && provider !== "http01")); + node.hidden = !visible; + for (const input of node.querySelectorAll("input,select")) input.disabled = !visible; + } + $("acme-domains").placeholder = provider === "http01" ? "example.org, www.example.org" : "example.org, *.example.org"; + } + function open(provider = "http01") { + selectTab("generate"); picker.value = provider; + if (provider !== "existing") { $("acme-environment").value = "production"; $("acme-confirm-production").required = true; } + syncProvider(); + } + function close() { clearSecrets(); selectTab("installed"); } + picker.addEventListener("change", () => { clearSecrets(); syncProvider(); }); + $("certificate-new").addEventListener("click", () => open()); + + const stateKey = value => ({ running: "certificateStateRunning", ready: "certificateStateReady", complete: "certificateStateComplete", failed: "certificateStateFailed" }[value?.status] || "certificateStateIdle"); + function renderOperation(cardId, messageId, stateId, logId, value, idleKey) { + const card = $(cardId), message = $(messageId), state = $(stateId), log = $(logId); + card.dataset.state = value?.status || "idle"; + state.textContent = t(stateKey(value)); + if (!value) message.textContent = t(idleKey); + else message.textContent = `${value.certificate_id} · ${value.provider} / ${value.environment} · ${t(value.message)}`; + const showLog = value?.status === "failed" && !!value.log_directory; + log.hidden = !showLog; + log.textContent = showLog ? `${t("certbotLogs")}: ${value.log_directory}` : ""; + } + function render(value) { + job = value; $("acme-submit").disabled = pending || value?.status === "running"; + const issue = value?.kind === "issue" ? value : null; + const renewal = value?.kind?.startsWith("renewal") ? value : null; + renderOperation("acme-issue-status-card", "acme-job", "acme-issue-state", "acme-log-path", issue, "acmeIdle"); + renderOperation("acme-renewal-status-card", "acme-renewal-job", "acme-renewal-state", "acme-renewal-log", renewal, "certificateNoRenewalOperation"); + const installed = issue?.certificate && envelope?.config.tls?.certificates?.some(c => c.id === issue.certificate.id && c.cert_file === issue.certificate.cert_file && c.key_file === issue.certificate.key_file); + $("acme-import").hidden = issue?.status !== "ready" || !issue.certificate || !!installed; + } + function renewalMeta(parent, key, value) { + const item = el("div", undefined, "renewal-meta-item"); + item.append(el("span", t(key), "small-label"), el("strong", value)); parent.append(item); + } + function renderRenewals(values = {}, logs = {}) { + renewalLogs = logs || {}; + const list = $("renewal-list"); list.replaceChildren(); + const certificates = envelope?.config.tls?.certificates || []; + const stamp = value => value ? new Date(value * 1000).toLocaleString(locale()) : "-"; + for (const cert of certificates.filter(c => c.issuer)) { + const state = values[cert.id], enabled = cert.issuer.auto_renew !== false; + const entry = el("div", undefined, "renewal-entry"); entry.dataset.state = state?.status || "idle"; + const head = el("div", undefined, "renewal-entry-head"), identity = el("div", undefined, "renewal-entry-identity"); + identity.append(el("strong", cert.id), el("span", `${cert.issuer.provider} / ${cert.issuer.environment}`, "fine")); + const mode = el("span", t(enabled ? "renewalEnabled" : "renewalDisabled"), "pill"); head.append(identity, mode); entry.append(head); + const meta = el("div", undefined, "renewal-meta"); + renewalMeta(meta, "renewalLast", stamp(state?.last_checked)); + renewalMeta(meta, "renewalNext", enabled ? stamp(state?.next_check) : "-"); + renewalMeta(meta, "renewalExpires", stamp(state?.not_after)); + entry.append(meta); + if (state?.message) { + const note = el("p", t(state.message), `fine renewal-message${state.status === "failed" ? " status-error" : ""}`); entry.append(note); + if (state.status === "failed" && renewalLogs[cert.id]) entry.append(el("p", `${t("certbotLogs")}: ${renewalLogs[cert.id]}`, "fine certificate-log-path")); + } + const controls = el("div", undefined, "renewal-actions"); + const b = (key, handler) => { const node = el("button", t(key), "quiet"); node.type = "button"; node.dataset.action = key; node.addEventListener("click", handler); controls.append(node); return node; }; + b(enabled ? "renewalDisable" : "renewalEnable", async () => { + if (!envelope) return; const next = structuredClone(envelope.config), target = next.tls.certificates.find(c => c.id === cert.id); if (!target?.issuer) return; + target.issuer.auto_renew = !enabled; if (await saveSection("tls", next.tls || {})) void refresh(); + }); + for (const [key, dry_run] of [["renewalNow", false], ["renewalDryRun", true]]) { + const node = b(key, async () => { + node.disabled = true; const epoch = authEpoch; + try { + const data = await api("/api/acme/renew", { method: "POST", body: JSON.stringify({ certificate_id: cert.id, dry_run }) }); + if (epoch === authEpoch && authenticated) { render(data.job); selectTab("renewal"); toast(t("acme.renewing")); } + } catch (error) { + if (epoch === authEpoch) toast(error.message, true); + } finally { node.disabled = false; } + }); + node.disabled = job?.status === "running"; + } + entry.append(controls); list.append(entry); + } + if (!list.children.length) list.append(el("p", t("renewalNone"), "empty")); + } + + async function refresh() { + if (!authenticated || currentPanel !== "tls" || loading) return; loading = true; const epoch = authEpoch; + try { + const data = await api("/api/acme"); + if (authenticated && epoch === authEpoch) { render(data.job); renderRenewals(data.renewals, data.log_directories); } + } catch (error) { + if (epoch === authEpoch) { toast(error.message, true); if (error.status === 401) lock(); } + } finally { loading = false; } + } + $("acme-environment").addEventListener("change", () => { $("acme-confirm-production").required = $("acme-environment").value === "production"; }); + $("acme-form").addEventListener("submit", async event => { + event.preventDefault(); if (!authenticated || pending || job?.status === "running") return; + const provider = picker.value, input = { + provider, certificate_id: $("acme-name").value.trim(), email: $("acme-email").value.trim(), domains: $("acme-domains").value.split(/[,\n]/).map(v => v.trim()).filter(Boolean), environment: $("acme-environment").value, + agree_tos: $("acme-tos").checked, confirm_production: $("acme-confirm-production").checked + }; + if (provider === "rfc2136") Object.assign(input, { dns_server: $("acme-dns").value.trim(), dns_port: Number($("acme-port").value), key_name: $("acme-key-name").value.trim(), key_secret: $("acme-secret").value, key_algorithm: $("acme-algorithm").value, sign_query: $("acme-sign-query").checked }); + else if (provider === "cloudflare") input.api_token = $("acme-cf-token").value; + else if (provider === "ovh") Object.assign(input, { endpoint: $("acme-ovh-endpoint").value, application_key: $("acme-ovh-application-key").value, application_secret: $("acme-ovh-application-secret").value, consumer_key: $("acme-ovh-consumer-key").value }); + else if (provider !== "http01") return; + if (provider !== "http01") input.propagation_seconds = Number($("acme-propagation").value); + if (provider === "http01" && input.domains.some(d => d.startsWith("*."))) { toast(t("acme.http01_no_wildcard"), true); return; } + if (input.environment === "production" && !input.confirm_production) { toast(t("acme.production_confirmation"), true); return; } + pending = true; $("acme-submit").disabled = true; const epoch = authEpoch; + try { + const value = await api("/api/acme/issue", { method: "POST", body: JSON.stringify(input) }); + if (epoch === authEpoch && authenticated) { render(value.job); selectTab("generate"); toast(t("acme.working")); } + } catch (error) { if (epoch === authEpoch) toast(error.message, true); } + finally { + for (const key of ["key_secret", "api_token", "application_key", "application_secret", "consumer_key"]) delete input[key]; + clearSecrets(); pending = false; $("acme-submit").disabled = job?.status === "running"; + } + }); + $("acme-refresh").addEventListener("click", () => void refresh()); + $("acme-import").addEventListener("click", async () => { + if (!envelope || !authenticated || !job?.certificate) return; + const cert = structuredClone(job.certificate), next = structuredClone(envelope.config); next.tls ||= {}; next.tls.certificates ||= []; + const index = next.tls.certificates.findIndex(c => c.id === cert.id); + if (index >= 0) { if (!(await appConfirm(t("acmeReplace"), { danger: true }))) return; cert.hosts = next.tls.certificates[index].hosts || []; next.tls.certificates[index] = cert; } + else next.tls.certificates.push(cert); + if (await saveSection("tls", next.tls || {})) { toast(t("acmeImported")); $("acme-import").hidden = true; selectTab("installed"); } + }); + setInterval(() => { if (!document.hidden) void refresh(); }, 3000); + window.rustProxyAcme = { + refresh, + select: selectTab, + syncLocation, + current: () => activeTab, + reset() { + job = null; renewalLogs = {}; $("renewal-list").replaceChildren(); $("acme-form").reset(); $("acme-environment").value = "production"; $("acme-confirm-production").required = true; + render(null); selectTab("installed", false, false); + } + }; + window.rustProxyCertificates = { open, close, select: selectTab, reset() { clearSecrets(); picker.value = "http01"; syncProvider(); selectTab("installed", false, false); } }; + syncProvider(); selectTab(activeTab, false, false); +})(); diff --git a/src/admin/web/app.js b/src/admin/web/app.js new file mode 100644 index 0000000..529a047 --- /dev/null +++ b/src/admin/web/app.js @@ -0,0 +1,1005 @@ +"use strict"; +const $ = (id) => document.getElementById(id); +let language = null; +try { language = localStorage.getItem("rp-language"); } catch { } +let csrf = "", authenticated = false, setupRequired = false, sessionInfo = null; +const ADMIN_THEMES = new Set(["dark", "light"]); +let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(); +function normalizeAdminTheme(value) { return ADMIN_THEMES.has(value) ? value : "dark"; } +function applyAdminTheme(value, cache = true) { + const theme = normalizeAdminTheme(value); + document.documentElement.dataset.theme = theme; + const select = $("ui-theme"); if (select) select.value = theme; + if (cache) { try { localStorage.setItem("rp-theme", theme); } catch { } } + return theme; +} +try { applyAdminTheme(localStorage.getItem("rp-theme") || "dark", false); } catch { applyAdminTheme("dark", false); } +async function loadAdminPreferences(force = false) { + if (!authenticated) return adminPreferences; + if (adminPreferences && !force) return adminPreferences; + if (adminPreferencesLoading) return adminPreferencesLoading; + const epoch = authEpoch; + adminPreferencesLoading = (async () => { + const value = await api("/api/preferences"); + if (!authenticated || epoch !== authEpoch) return adminPreferences; + adminPreferences = { ...value, theme: applyAdminTheme(value?.theme) }; + return adminPreferences; + })(); + try { return await adminPreferencesLoading; } finally { adminPreferencesLoading = null; } +} +function updateAdminPreferences(patch) { + const epoch = authEpoch; + const task = adminPreferencesWrite.then(async () => { + if (!authenticated || epoch !== authEpoch) throw new Error(t("auth.required")); + const current = await loadAdminPreferences(); + if (!current || !authenticated || epoch !== authEpoch) throw new Error(t("auth.required")); + const next = { ...current, ...patch }; next.theme = normalizeAdminTheme(next.theme); + const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) }); + if (!authenticated || epoch !== authEpoch) return adminPreferences; + adminPreferences = { ...saved, theme: applyAdminTheme(saved?.theme) }; + return adminPreferences; + }); + adminPreferencesWrite = task.catch(() => undefined); + return task; +} +function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesWrite = Promise.resolve(); } +window.rustProxyPreferences = { load: loadAdminPreferences, update: updateAdminPreferences, current: () => adminPreferences, applyTheme: applyAdminTheme }; +const PANEL_PATHS = { overview: "/overview", vhosts: "/vhosts", "docker-vhosts": "/docker-vhosts", redirects: "/redirects", notfound: "/404-hosts", streams: "/streams", "acl-profiles": "/acl-profiles", tls: "/tls", cache: "/cache", transform: "/transform", settings: "/settings" }; +function panelFromLocation() { if (typeof location === "undefined") return "overview"; if (location.pathname === "/configuration" || location.pathname === "/logging") return "settings"; const found = Object.entries(PANEL_PATHS).find(([, path]) => path === location.pathname); return found?.[0] || "overview"; } +let envelope = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null; +let samples = [], protocolSamples = { h1: [], h2: [], h3: [] }, websocketProtocolSamples = { h1: [], h2: [], h3: [] }, transformSamples = [], polling = false, toastTimer, editingBackend = null, editingRoute = null; +let liveEvents = null, liveConnected = false, authEpoch = 0, pendingAuthDestination = null; +let lastApiLatencyMs = null; +let adminAuthChannel = null; try { adminAuthChannel = new BroadcastChannel("rustproxy-admin-session"); adminAuthChannel.addEventListener("message", event => { if (event.data === "locked" && authenticated) lock(false); }); } catch { } +// Language content is discovered by build.rs from langs/*.json, never compiled +// into a two-language switch here. Locale metadata also drives Intl and RTL. +let languagePacks = new Map(), defaultLanguage = null; +function activePack() { return languagePacks.get(language) || languagePacks.get(defaultLanguage); } +function locale() { try { return Intl.getCanonicalLocales(activePack()?.meta.locale || "und")[0]; } catch { return "und"; } } +function t(key) { const pack = activePack()?.translations; const fallback = languagePacks.get(defaultLanguage)?.translations; return (pack && Object.hasOwn(pack, key) ? pack[key] : fallback?.[key]) ?? key; } +async function loadLanguages() { + const bundle = await api("/assets/langs.json"); + if (!Array.isArray(bundle.languages) || !bundle.languages.length) throw new Error("i18n.invalid_bundle"); + languagePacks = new Map(bundle.languages.map(pack => [pack.id, pack])); defaultLanguage = bundle.default; + if (!languagePacks.has(defaultLanguage)) throw new Error("i18n.missing_fallback"); + const candidates = [language, ...(navigator.languages || [navigator.language])].filter(Boolean).map(v => v.replaceAll("_", "-").toLowerCase()); + const packs = [...languagePacks.values()]; + const selected = candidates.map(value => packs.find(p => p.id.replaceAll("_", "-").toLowerCase() === value || p.meta.locale.toLowerCase() === value) || packs.find(p => p.meta.locale.toLowerCase().split("-")[0] === value.split("-")[0])).find(Boolean); + language = selected?.id || defaultLanguage; + $("language").replaceChildren(...packs.map(pack => { const option = el("option", pack.meta.native_name); option.value = pack.id; return option; })); + $("language").value = language; +} +let latestStatus = null, certificateInventory = new Map(); +function translate() { + clearTimeout(toastTimer); hideToast(); + document.documentElement.lang = locale(); document.documentElement.dir = activePack()?.meta.direction || "ltr"; + document.querySelectorAll("[data-i18n]").forEach(e => e.textContent = t(e.dataset.i18n)); + document.querySelectorAll("[data-i18n-placeholder]").forEach(e => e.placeholder = t(e.dataset.i18nPlaceholder)); + document.querySelectorAll("[data-i18n-aria]").forEach(e => e.setAttribute("aria-label", t(e.dataset.i18nAria))); + $("language").value = language; setPanel(currentPanel, false); syncAuthForm(); renderSession(); + $("connection-label").textContent = t(connected ? "connected" : "disconnected"); + if (envelope) renderConfig(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); } if (lastLogs) renderLogs(lastLogs); + syncSettingsDirtyIndicators(); +} +function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; } +function raiseToast() { + const node = $("toast"); if (node.hidden) return; + const modal = [...document.querySelectorAll("dialog[open]")].at(-1), parent = modal || document.body; + // A popover outside a modal is still inert. Keep it inside the active dialog, + // then lift it to the top layer so its backdrop cannot blur or cover it. + if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); + if (node.parentElement !== parent) parent.append(node); + if (typeof node.showPopover === "function") node.showPopover(); +} +function toast(message, error = false) { clearTimeout(toastTimer); const node = $("toast"); node.textContent = message; node.classList.toggle("error", error); node.hidden = false; raiseToast(); toastTimer = setTimeout(hideToast, error ? 10000 : 4000); } +function authMessage(message = "") { const node = $("auth-message"); if (!node) return; node.textContent = message; node.classList.toggle("error", Boolean(message)); } +function fieldError(target, message) { const error = new Error(message); error.fieldTarget = target; return error; } +function clearFieldError(target) { + if (!target) return; target.classList?.remove("field-invalid"); target.removeAttribute?.("aria-invalid"); +} +function clearFormFieldErrors(form) { form?.querySelectorAll(".field-invalid,[aria-invalid=true]").forEach(clearFieldError); } +function showFieldError(error, form = null) { + let target = error?.fieldTarget; if (typeof target === "string") target = $(target); + if (!target || (form && !form.contains(target))) return false; + const focusTarget = target.matches?.("input,select,textarea,button") ? target : target.querySelector?.("input,select,textarea,button"); + target.classList?.add("field-invalid"); target.setAttribute?.("aria-invalid", "true"); + const clear = () => clearFieldError(target); target.addEventListener?.("input", clear, { once: true }); target.addEventListener?.("change", clear, { once: true }); + target.scrollIntoView?.({ block: "center", behavior: "smooth" }); focusTarget?.focus?.({ preventScroll: true }); + return true; +} +async function withFormSubmitState(form, submitter, task) { + const button = submitter || form?.querySelector('button[type="submit"]'); + const originalText = button?.textContent ?? "", i18nKey = button?.dataset?.i18n || ""; + clearFormFieldErrors(form); + if (button) { button.disabled = true; button.classList.add("is-saving"); button.textContent = t("saving"); } + try { return await task(); } + catch (error) { showFieldError(error, form); throw error; } + finally { if (button) { button.disabled = false; button.classList.remove("is-saving"); button.textContent = i18nKey ? t(i18nKey) : originalText; } } +} +function appConfirm(message, options = {}) { + const title = options.title || t("confirmTitle"), confirmLabel = options.confirmLabel || t("continueAction"), cancelLabel = options.cancelLabel || t("cancelAction"); + return new Promise(resolve => { + const dialog = el("dialog", undefined, "app-confirm-dialog"); dialog.setAttribute("aria-modal", "true"); + const card = el("div", undefined, "card app-confirm-card"), heading = el("div", undefined, "card-heading"), headingText = el("div"); + headingText.append(el("span", options.danger ? t("warningLabel") : t("confirmationLabel"), `small-label${options.danger ? " status-error" : ""}`), el("h2", title)); + heading.append(headingText); card.append(heading, el("p", String(message ?? ""), "app-confirm-message")); + const actions = el("div", undefined, "form-actions app-confirm-actions"), cancel = el("button", cancelLabel, "quiet"), accept = el("button", confirmLabel, options.danger ? "outline app-confirm-danger" : "primary"); + cancel.type = "button"; accept.type = "button"; actions.append(cancel, accept); card.append(actions); dialog.append(card); document.body.append(dialog); + let settled = false; + const finish = value => { if (settled) return; settled = true; if (dialog.open) dialog.close(); dialog.remove(); resolve(value); }; + cancel.addEventListener("click", () => finish(false)); accept.addEventListener("click", () => finish(true)); + dialog.addEventListener("cancel", event => { event.preventDefault(); finish(false); }); + dialog.addEventListener("close", () => finish(false)); + dialog.showModal(); cancel.focus(); + }); +} +function apiArea(path) { + const section = path.match(/^\/api\/config\/([^/?]+)/)?.[1]; + if (section) return ({ "listeners": "settings", "http2": "settings", "docker-agents": "dockerVhosts", "response": "settings", "pages": "settings", "cache": "cache", "transform": "settings", "logging": "settings", "forwarding": "settings", "headers": "settings", "prometheus": "settings", "administration": "administration", "tls": "tls", "vhosts": "vhosts", "redirects": "redirects", "not_found_hosts": "notfound", "streams": "streams", "acl-profiles": "aclProfiles", "host-policies": "security" })[section] || "configuration"; + if (path === "/api/config") return "configuration"; + if (path.startsWith("/api/cache")) return "cache"; + if (path.startsWith("/api/tunnels")) return "overview"; + if (path.startsWith("/api/transform")) return "transform"; + if (path.startsWith("/api/certificates") || path.startsWith("/api/acme")) return "tls"; + if (path.startsWith("/api/certificate-api")) return "settings"; + if (path.startsWith("/api/docker")) return "dockerVhosts"; + if (path.startsWith("/api/logs")) return "logging"; + if (path.startsWith("/api/audit") || path.startsWith("/api/preferences") || path.startsWith("/api/auth/password") || path.startsWith("/api/auth/logout")) return "security"; + return null; +} +function apiErrorMessage(path, response, value) { + const code = typeof value?.error === "string" ? value.error : ""; + const translated = code ? t(code) : ""; + const detail = response.status === 409 ? t("stale") : (translated && translated !== code ? translated : (code || `HTTP ${response.status}`)); + const area = apiArea(path); + return area ? t("errorContext").replace("{area}", t(area)).replace("{message}", detail) : detail; +} +new MutationObserver(records => { if (!records.some(r => r.target.tagName === "DIALOG")) return; const node = $("toast"); if (!node.hidden) raiseToast(); }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ["open"] }); +async function api(path, options = {}) { + const started = performance.now(), trackLatency = path.startsWith("/api/"); + try { + const response = await fetch(path, { ...options, cache: "no-store", credentials: "same-origin", headers: { "Content-Type": "application/json", "X-RustProxy-Request": "1", ...(csrf ? { "X-CSRF-Token": csrf } : {}), ...options.headers } }); + const text = await response.text(); let value; try { value = JSON.parse(text); } catch { value = { error: text }; } + if (!response.ok) { if (response.status === 401 && authenticated) lock(); const error = new Error(apiErrorMessage(path, response, value)); error.status = response.status; error.code = value.error; throw error; } return value; + } finally { + if (trackLatency) { lastApiLatencyMs = performance.now() - started; if (latestStatus) renderFooter(latestStatus); } + } +} +function setPanel(name, navigate = true) { + if (name === "security") { setPanel("settings", navigate); window.rustProxySettings?.select("security"); return; } + if (!Object.hasOwn(PANEL_PATHS, name)) name = "overview"; + currentPanel = name; + document.querySelectorAll(".nav").forEach(e => e.classList.toggle("active", e.dataset.panel === name)); + document.querySelectorAll(".panel").forEach(e => e.hidden = e.id !== `panel-${name}`); + $("page-label").textContent = t(name); $("page-title").textContent = t(`${name}Title`); $("page-description").textContent = t(`${name}Description`); + if (navigate && typeof location !== "undefined" && ["http:", "https:"].includes(location.protocol) && location.pathname !== PANEL_PATHS[name]) history.pushState({ panel: name }, "", PANEL_PATHS[name]); + if (name === "settings") window.rustProxySettings?.syncLocation?.(); + if (name === "tls") window.rustProxyAcme?.syncLocation?.(); + if (name === "cache") void window.rustProxyCachePanel?.refresh(); if (name === "transform") void window.rustProxyTransformPanel?.refresh(); if (name === "docker-vhosts") void window.rustProxyDocker?.refresh(false); if (name === "tls") { void window.rustProxyAcme?.refresh(); void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); }); } +} +function bytes(value) { if (value < 1024) return `${value} B`; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { value /= 1024; if (value < 1024 || unit === "TiB") return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`; } } +function uptime(value) { + let seconds = Math.max(0, Math.floor(Number(value) || 0)); + if (seconds < 60) return `${seconds}s`; + const units = [[365 * 86400, "y"], [86400, "d"], [3600, "h"], [60, "m"]], parts = []; + for (const [size, suffix] of units) { + const count = Math.floor(seconds / size); + if (count) { parts.push(`${count}${suffix}`); seconds %= size; } + if (parts.length === 2) break; + } + return parts.join(" "); +} +function rateBytes(value) { value = Math.max(0, Number(value) || 0); if (value === 0) return "0.0 KiB"; value /= 1024; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { if (value < 1024 || unit === "TiB") { if (unit === "KiB" && value < 0.1) return "<0.1 KiB"; const digits = value < 10 ? 1 : 0; return `${Number(value.toFixed(digits))} ${unit}`; } value /= 1024; } } +function el(tag, text, cls) { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (cls) e.className = cls; return e; } +function action(label, fn) { const b = el("button", label, "quiet"); b.type = "button"; b.addEventListener("click", fn); return b; } +function renderStatus(s) { + const firstStatus = latestStatus === null; latestStatus = s; window.rustProxySettings?.render(); if (firstStatus) renderSettings(); + connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); const m = s.metrics; + $("metric-ws").textContent = m.websocket_active || 0; + $("metric-ws-h1").textContent = m.websocket_h1_active || 0; + $("metric-ws-h2").textContent = m.websocket_h2_active || 0; + $("metric-ws-h3").textContent = m.websocket_h3_active || 0; + $("metric-ws-total").textContent = (m.websocket_total || 0).toLocaleString(locale()); + $("metric-h2").textContent = m.http2_requests || 0; $("metric-h2-active").textContent = m.http2_active_connections || 0; $("metric-tls-errors").textContent = m.tls_handshake_errors || 0; + $("metric-requests").textContent = m.requests.toLocaleString(locale()); $("metric-active").textContent = m.active; $("metric-bytes").textContent = bytes(m.bytes_in + m.bytes_out); $("metric-bytes-down").textContent = bytes(m.bytes_out); $("metric-bytes-up").textContent = bytes(m.bytes_in); $("metric-errors").textContent = m.errors; + const openConnections = Math.max(0, Number(m.active_connections) || 0), activeExchanges = Math.max(0, Number(m.active) || 0); + connectionOpenSamples.push(openConnections); connectionOpenSamples = connectionOpenSamples.slice(-30); + connectionExchangeSamples.push(activeExchanges); connectionExchangeSamples = connectionExchangeSamples.slice(-30); + const connectionPeak = Math.max(1, ...connectionOpenSamples, ...connectionExchangeSamples); + const connectionPoints = values => values.map((value, index) => `${index * 300 / Math.max(1, values.length - 1)},${95 - value / connectionPeak * 85}`).join(" "); + $("connection-open-sparkline").setAttribute("points", connectionPoints(connectionOpenSamples)); + $("connection-exchange-sparkline").setAttribute("points", connectionPoints(connectionExchangeSamples)); + $("connection-current").textContent = fmt(openConnections); $("connection-open-value").textContent = fmt(openConnections); $("connection-exchange-value").textContent = fmt(activeExchanges); $("connection-backends").textContent = fmt(s.backends); + const now = performance.now(); + const transformed = s.transform_metrics || {}; + const counters = { total: Number(m.requests) || 0, h1: Number(m.http1_requests) || 0, h2: Number(m.http2_requests) || 0, h3: Number(m.http3_requests) || 0, transform: (Number(transformed.applied) || 0) + (Number(transformed.cache_hits) || 0) }; + const rates = { total: 0, h1: 0, h2: 0, h3: 0, transform: 0 }; + if (previous) { + const elapsed = Math.max(.001, (now - previous.time) / 1000); + for (const key of ["total", "h1", "h2", "h3", "transform"]) if (counters[key] >= previous[key]) rates[key] = (counters[key] - previous[key]) / elapsed; + } + previous = { time: now, ...counters }; + samples.push(rates.total); samples = samples.slice(-30); + for (const key of ["h1", "h2", "h3"]) { protocolSamples[key].push(rates[key]); protocolSamples[key] = protocolSamples[key].slice(-30); } + websocketProtocolSamples.h1.push(Number(m.websocket_h1_active) || 0); websocketProtocolSamples.h1 = websocketProtocolSamples.h1.slice(-30); + websocketProtocolSamples.h2.push(Number(m.websocket_h2_active) || 0); websocketProtocolSamples.h2 = websocketProtocolSamples.h2.slice(-30); + websocketProtocolSamples.h3.push(Number(m.websocket_h3_active) || 0); websocketProtocolSamples.h3 = websocketProtocolSamples.h3.slice(-30); + transformSamples.push(rates.transform); transformSamples = transformSamples.slice(-30); + const peak = Math.max(1, ...samples, ...transformSamples); + const points = values => values.map((n, i) => `${i * 600 / Math.max(1, values.length - 1)},${120 - n / peak * 105}`).join(" "); + $("sparkline").setAttribute("points", points(samples)); + $("sparkline-h1").setAttribute("points", points(protocolSamples.h1)); + $("sparkline-h2").setAttribute("points", points(protocolSamples.h2)); + $("sparkline-h3").setAttribute("points", points(protocolSamples.h3)); + $("sparkline-transform").setAttribute("points", points(transformSamples)); + $("request-rate").textContent = `${rates.total.toFixed(1)} req/s`; + $("rate-total").textContent = `${rates.total.toFixed(1)}/s`; $("rate-h1").textContent = `${rates.h1.toFixed(1)}/s`; $("rate-h2").textContent = `${rates.h2.toFixed(1)}/s`; $("rate-h3").textContent = `${rates.h3.toFixed(1)}/s`; $("rate-transform").textContent = `${rates.transform.toFixed(1)}/s`; + $("metric-http-proto-total").textContent = `${rates.total.toFixed(1)}/s`; + $("metric-http-proto-h1").textContent = `${rates.h1.toFixed(1)}/s`; + $("metric-http-proto-h2").textContent = `${rates.h2.toFixed(1)}/s`; + $("metric-http-proto-h3").textContent = `${rates.h3.toFixed(1)}/s`; + $("http-protocol-h1").setAttribute("points", sparklinePoints(protocolSamples.h1, 300, 60, 5)); + $("http-protocol-h2").setAttribute("points", sparklinePoints(protocolSamples.h2, 300, 60, 5)); + $("http-protocol-h3").setAttribute("points", sparklinePoints(protocolSamples.h3, 300, 60, 5)); + const wsTotalActive = (Number(m.websocket_h1_active) || 0) + (Number(m.websocket_h2_active) || 0) + (Number(m.websocket_h3_active) || 0); + $("metric-ws-proto-total").textContent = wsTotalActive.toLocaleString(locale()); + $("metric-ws-proto-h1").textContent = (Number(m.websocket_h1_active) || 0).toLocaleString(locale()); + $("metric-ws-proto-h2").textContent = (Number(m.websocket_h2_active) || 0).toLocaleString(locale()); + $("metric-ws-proto-h3").textContent = (Number(m.websocket_h3_active) || 0).toLocaleString(locale()); + $("ws-protocol-h1").setAttribute("points", sparklinePoints(websocketProtocolSamples.h1, 300, 60, 5)); + $("ws-protocol-h2").setAttribute("points", sparklinePoints(websocketProtocolSamples.h2, 300, 60, 5)); + $("ws-protocol-h3").setAttribute("points", sparklinePoints(websocketProtocolSamples.h3, 300, 60, 5)); + $("chart-peak").textContent = `max ${Math.max(0, ...samples).toFixed(1)} req/s`; + renderFooter(s); renderDiagnostics(s); window.rustProxyVhosts?.renderStatus(s); window.rustProxyStreams?.renderStatus(s); +} +function renderFooter(s) { + const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`; + const footer = $("footer-status"); + footer.replaceChildren(document.createTextNode(`${apiLatency} / ${t("logsDropped")}: ${s.logs_dropped}`)); + const update = s.software_update; + if (update?.update_available && update.latest_version) { + footer.append(document.createTextNode(" / ")); + const label = `${t("updateAvailable")}: ${update.latest_version}`; + if (update.repository_url) { + const link = el("a", label, "footer-update"); + link.href = update.repository_url; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + footer.append(link); + } else { + footer.append(document.createTextNode(label)); + } + } +} +let lastLogs = null; +function renderLogs(data) { lastLogs = data; window.rustProxyLogs?.render(data); } +async function refresh(force = false) { if (!authenticated || polling || (!force && liveConnected)) return; polling = true; const epoch = authEpoch; try { const [status, logs] = await Promise.all([api("/api/status"), api("/api/logs")]); if (!authenticated || epoch !== authEpoch) return; renderStatus(status); renderLogs(logs); } catch (e) { connected = false; $("connection-label").textContent = t("disconnected"); if (e.status === 401) lock(); } finally { polling = false; } } +function stopLive() { if (liveEvents) liveEvents.close(); liveEvents = null; liveConnected = false; } +function startLive() { stopLive(); if (!authenticated || document.hidden || typeof EventSource === "undefined") return; const epoch = authEpoch; const stream = new EventSource("/api/events"); liveEvents = stream; stream.addEventListener("snapshot", event => { if (!authenticated || epoch !== authEpoch) return; try { const payload = JSON.parse(event.data); liveConnected = true; renderStatus(payload.status); renderLogs(payload.logs); } catch { liveConnected = false; } }); stream.onerror = () => { if (epoch !== authEpoch) return; liveConnected = false; void refresh(); }; } +async function refreshCertificateInventory() { + if (!authenticated) return; + const epoch = authEpoch; + try { + const value = await api("/api/certificates"); + if (!authenticated || epoch !== authEpoch) return; + certificateInventory = new Map((value.certificates || []).map(cert => [cert.id, cert])); + } catch (e) { + if (e.status !== 401 && authenticated && epoch === authEpoch) certificateInventory = new Map(); + } +} +async function loadConfig() { const epoch = authEpoch; const value = await api("/api/config"); if (!authenticated || epoch !== authEpoch) return; envelope = value; await refreshCertificateInventory(); if (!authenticated || epoch !== authEpoch) return; renderConfig(); } +function rulesText(acl) { return (acl?.rules || []).map(r => `${r.action} ${r.network}`).join("\n"); } +function normalizeAclRuleText(value) { + return String(value ?? "").normalize("NFKC") + .replace(/[\u200B-\u200D\u2060\uFEFF]/g, "") + .replace(/[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/g, " "); +} +function parseRules(text, target = null) { + return normalizeAclRuleText(text).split(/\r?\n/).map((value, index) => ({ line: value.trim(), number: index + 1 })).filter(item => item.line).map(item => { + const match = item.line.match(/^(allow|deny)\s+(\S+)$/i); + if (!match) throw fieldError(target, `${t("ruleSyntaxLine")} ${item.number}: ${item.line}`); + return { action: match[1].toLowerCase(), network: match[2] }; + }); +} +function renderConfig() { + if (!envelope) return; window.rustProxyCachePanel?.configure(); renderCertificates(); renderSettings(); window.rustProxyPages?.render(); window.rustProxyAclProfiles?.render(); window.rustProxyVhosts?.render(); window.rustProxyDocker?.render(); window.rustProxyRedirects?.render(); window.rustProxyNotFound?.render(); window.rustProxyStreams?.render(); const config = envelope.config; if (!settingsDirty.has("config-editor")) $("config-editor").value = JSON.stringify(config, null, 2); + $("host-policy-list").replaceChildren(...(config.host_policies || []).map((policy, index) => { const box = el("div", undefined, "entity"); box.append(el("span", policy.hosts.join(", "), "entity-title"), el("p", `${t("aclDefault")}: ${t(policy.acl.default || "deny")}\n${rulesText(policy.acl)}`, "entity-details")); const buttons = el("div", undefined, "entity-actions"); buttons.append(action(t("edit"), () => { $("policy-hosts").value = policy.hosts.join(", "); $("policy-default").value = policy.acl.default || "deny"; $("policy-rules").value = rulesText(policy.acl); const editor = $("security-policy-editor"); if (editor) { editor.open = true; editor.scrollIntoView({ block: "nearest" }); } }), action(t("remove"), async () => { if (await appConfirm(t("confirmDelete"), { danger: true })) { const next = structuredClone(config); next.host_policies.splice(index, 1); await saveSection("host-policies", next.host_policies); } })); box.append(buttons); return box; })); + if (!config.host_policies?.length) $("host-policy-list").append(el("p", t("noPolicies"), "empty")); + if ($("security-policy-count")) $("security-policy-count").textContent = String((config.host_policies || []).length); +} +async function saveRequest(path, payload) { if (busy) { toast(t("busy"), true); return false; } if (!authenticated || !envelope) return false; busy = true; const epoch = authEpoch; try { const value = await api(path, { method: "PUT", body: JSON.stringify(payload) }); if (!authenticated || epoch !== authEpoch) return false; envelope = value; if (path === "/api/config" || path === "/api/config/tls") await refreshCertificateInventory(); if (!authenticated || epoch !== authEpoch || !envelope) return false; renderConfig(); toast(t("saved")); await refresh(true); return true; } catch (e) { toast(e.message, true); if (e.status === 401) lock(); return false; } finally { busy = false; } } +async function save(config) { return saveRequest("/api/config", { expected_revision: envelope?.revision, config }); } +async function saveSection(section, value) { return saveRequest(`/api/config/${section}`, { expected_revision: envelope?.revision, value }); } +function syncAuthForm() { $("setup-fields").hidden = !setupRequired; $("confirm-field").hidden = !setupRequired; $("setup-token").required = setupRequired; $("password-confirm").required = setupRequired; $("password").minLength = setupRequired ? 12 : 1; $("password").autocomplete = setupRequired ? "new-password" : "current-password"; $("auth-title").textContent = t(setupRequired ? "setupTitle" : "signIn"); $("login-submit").textContent = t(setupRequired ? "createAndSignIn" : "signIn"); $("auth-badge").textContent = setupRequired ? "SETUP" : "LOCAL"; } +function renderSession() { $("session-user").textContent = sessionInfo?.username || ""; $("session-expiry").textContent = sessionInfo ? t("expires") + new Date(sessionInfo.expires_at * 1000).toLocaleString(locale()) : ""; } +function authReturnDestination() { + if (typeof location === "undefined") return "/"; + if (location.pathname === "/logging") return "/settings?tab=logging"; + const allowed = new Set([...Object.values(PANEL_PATHS), "/configuration"]); + const path = allowed.has(location.pathname) ? location.pathname : "/"; + if (path !== "/settings") return path; + const tab = window.rustProxySettings?.current?.() || new URLSearchParams(location.search).get("tab"); + return tab ? `/settings?tab=${encodeURIComponent(tab)}` : "/settings"; +} +function lock(notify = true) { + const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:"); + pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); $("logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage(""); + for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); } + $("workspace").querySelectorAll("form").forEach(form => form.reset()); + $("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; }); + document.querySelectorAll("dialog").forEach(dialog => { dialog.close(); dialog.querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => input.value = ""); }); + renderSession(); syncAuthForm(); + // Replace the authenticated document with the minimal public login document. + // Keep the exact panel/settings tab so re-authentication resumes in place. + if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); } +} + +async function activate(info) { pendingAuthDestination = null; authMessage(""); $("api-docs").hidden = false; $("logs-open").hidden = false; authEpoch++; sessionInfo = info; csrf = info.csrf_token; authenticated = true; const preferences = await loadAdminPreferences(); window.rustProxyLogs?.applyPreferences(preferences); await loadConfig(); $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("login").hidden = true; $("workspace").hidden = false; $("logout").hidden = false; renderSession(); await refresh(true); startLive(); document.documentElement.classList.remove("app-loading"); } +async function loadAudit() { if (!authenticated) return; const epoch = authEpoch; try { const data = await api("/api/audit"); if (!authenticated || epoch !== authEpoch) return; const rows = data.entries.map(item => { const tr = el("tr"); for (const value of [new Date(item.time * 1000).toLocaleString(locale()), item.actor, item.action, item.detail]) { const td = el("td", value); td.title = value; tr.append(td); } return tr; }); $("audit-rows").replaceChildren(...rows); $("empty-audit").hidden = rows.length > 0; } catch (e) { toast(e.message, true); } } +let tunnelRefreshTimer = null; +function tunnelMessage(value = "", error = false) { const node = $("tunnels-status"); if (!node) return; node.textContent = value; node.classList.toggle("error", !!error); } +async function loadTunnels() { + if (!authenticated) return; + const epoch = authEpoch; + try { + const data = await api("/api/tunnels"); + if (!authenticated || epoch !== authEpoch) return; + const rows = (data.tunnels || []).map(item => { + const tr = el("tr"); + for (const value of [item.id, item.client, item.host, item.upstream, item.protocol, `${fmt((Number(item.duration_ms) || 0) / 1000, 1)} s`, fmtBytes(item.rx_bytes), fmtBytes(item.tx_bytes)]) { + const td = el("td", String(value)); td.title = String(value); tr.append(td); + } + const actions = el("td"); + const kill = el("button", t("tunnelKill"), "quiet"); kill.type = "button"; + kill.addEventListener("click", async () => { + if (!(await appConfirm(t("tunnelKillConfirm"), { danger: true }))) return; + kill.disabled = true; tunnelMessage(); + try { + const reason = $("tunnel-kill-reason")?.value.trim() || t("tunnelKillReasonDefault"); + await api(`/api/tunnels/${encodeURIComponent(item.id)}/kill`, { method: "POST", body: JSON.stringify({ reason }) }); + tunnelMessage(t("tunnelKilled")); + await loadTunnels(); + } catch (error) { tunnelMessage(error.message, true); } + finally { kill.disabled = false; } + }); + actions.append(kill); tr.append(actions); return tr; + }); + $("tunnels-rows").replaceChildren(...rows); $("tunnels-empty").hidden = rows.length > 0; + } catch (error) { if (authenticated && epoch === authEpoch) tunnelMessage(error.message, true); } +} +function stopTunnelRefresh() { if (tunnelRefreshTimer) clearInterval(tunnelRefreshTimer); tunnelRefreshTimer = null; } +function startTunnelRefresh() { stopTunnelRefresh(); tunnelRefreshTimer = setInterval(() => { if ($("tunnels-dialog")?.open) void loadTunnels(); }, 2000); } +$("login-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; authMessage(""); try { await withFormSubmitState(form, event.submitter, async () => { const username = $("username").value.trim(); const password = $("password").value; if (setupRequired) { if (password !== $("password-confirm").value) throw fieldError("password-confirm", t("passwordMismatch")); await api("/api/auth/setup", { method: "POST", body: JSON.stringify({ token: $("setup-token").value.trim(), username, password }) }); setupRequired = false; syncAuthForm(); } const info = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); await activate(info); clearTimeout(toastTimer); hideToast(); }); } catch (e) { if (e.code === "auth.already_initialized") { setupRequired = false; syncAuthForm(); } $("password").value = ""; $("password-confirm").value = ""; authMessage(e.message); } }); +$("logout").addEventListener("click", async () => { try { await api("/api/auth/logout", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { if (e.status === 401) lock(); toast(e.message, true); } }); +$("language").addEventListener("change", () => { language = $("language").value; try { localStorage.setItem("rp-language", language); } catch { } translate(); }); +$("ui-theme").addEventListener("change", async () => { + const select = $("ui-theme"), previousTheme = adminPreferences?.theme || document.documentElement.dataset.theme || "dark", nextTheme = normalizeAdminTheme(select.value); + applyAdminTheme(nextTheme); select.disabled = true; + try { await updateAdminPreferences({ theme: nextTheme }); } + catch (error) { applyAdminTheme(previousTheme); if (error.status !== 401) toast(error.message, true); } + finally { select.disabled = false; } +}); +document.querySelectorAll(".nav").forEach(button => button.addEventListener("click", () => setPanel(button.dataset.panel))); +window.addEventListener("popstate", () => setPanel(panelFromLocation(), false)); +$("host-policy-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const hosts = [...new Set($("policy-hosts").value.split(",").map(h => h.trim().toLowerCase()).filter(Boolean))]; const acl = { default: $("policy-default").value, rules: parseRules($("policy-rules").value, $("policy-rules")) }; const next = structuredClone(envelope.config); next.host_policies = (next.host_policies || []).map(p => ({ ...p, hosts: p.hosts.filter(h => !hosts.includes(h)) })).filter(p => p.hosts.length); next.host_policies.push({ hosts, acl }); if (await saveSection("host-policies", next.host_policies)) { form.reset(); if ($("security-policy-editor")) $("security-policy-editor").open = false; } }); } catch (e) { toast(e.message, true); } }); +$("password-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; try { await withFormSubmitState(form, event.submitter, async () => { if ($("new-password").value !== $("new-password-confirm").value) throw fieldError("new-password-confirm", t("passwordMismatch")); await api("/api/auth/password", { method: "POST", body: JSON.stringify({ current_password: $("current-password").value, new_password: $("new-password").value }) }); lock(); toast(t("passwordChanged")); }); } catch (e) { toast(e.message, true); } }); +$("revoke-sessions").addEventListener("click", async () => { if (!(await appConfirm(t("confirmRevoke"), { danger: true }))) return; try { await api("/api/auth/logout-all", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { toast(e.message, true); } }); +$("security-audit-open").addEventListener("click", () => { const dialog = $("audit-dialog"); if (!dialog.open) dialog.showModal(); void loadAudit(); }); +$("audit-dialog-close").addEventListener("click", () => $("audit-dialog").close()); +$("reload-audit").addEventListener("click", () => void loadAudit()); +$("tunnels-open").addEventListener("click", () => { const dialog = $("tunnels-dialog"); tunnelMessage(); if (!dialog.open) dialog.showModal(); void loadTunnels(); startTunnelRefresh(); }); +$("tunnels-close").addEventListener("click", () => { stopTunnelRefresh(); $("tunnels-dialog").close(); }); +$("tunnels-reload").addEventListener("click", () => void loadTunnels()); +$("tunnels-dialog").addEventListener("close", stopTunnelRefresh); +$("config-editor").addEventListener("input", () => setSettingsDirty("config-editor")); +$("save-json").addEventListener("click", async event => { const editor = $("config-editor"), page = editor.closest("[data-settings-page]"); if (!envelope || busy) return; try { await withFormSubmitState(page, event.currentTarget, async () => { let config; try { config = JSON.parse(editor.value); } catch { throw fieldError(editor, t("vhJsonInvalid")); } if (await save(config)) setSettingsDirty("config-editor", false); }); } catch (e) { toast(e.message, true); } }); +$("export-config").addEventListener("click", () => { const url = URL.createObjectURL(new Blob([JSON.stringify(envelope.config, null, 2) + "\n"], { type: "application/json" })); const a = el("a"); a.href = url; a.download = `RustProxy-config-r${envelope.revision}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }); +async function revalidateSession() { + const epoch = authEpoch; + try { + const info = await api("/api/auth/session"); + if (epoch !== authEpoch) return; + if (!authenticated) { await activate(info); return; } + sessionInfo = info; csrf = info.csrf_token; renderSession(); await refresh(true); startLive(); + } catch (error) { if (error.status !== 401) toast(error.message, true); } +} +window.addEventListener("pageshow", event => { if (event.persisted) void revalidateSession(); }); +document.addEventListener("visibilitychange", () => { if (document.hidden) stopLive(); else if (authenticated) { void revalidateSession(); } }); +setInterval(() => { if (!document.hidden) void refresh(); }, 5000); +(async () => { try { await loadLanguages(); translate(); const status = await api("/api/auth/status"); setupRequired = status.setup_required; syncAuthForm(); if (!setupRequired) { try { await activate(await api("/api/auth/session")); } catch (e) { if (e.status === 401) { location.replace(`${location.pathname}${location.search}`); return; } throw e; } } } catch (e) { $("login").hidden = false; document.documentElement.classList.remove("app-loading"); toast(e.message, true); } })(); + +function renderCertificates() { + if (!envelope) return; + const settings = envelope.config.tls || {}; + const entries = (settings.certificates || []).map(cert => { + const inventory = certificateInventory.get(cert.id); + const coverage = (inventory?.covered_hosts?.length ? inventory.covered_hosts : (cert.covered_hosts || [])); + const bindings = cert.hosts || []; + const used = []; + for (const vhost of envelope.config.vhosts || []) if (vhost.certificate_id === cert.id) used.push(`vhost:${vhost.id}`); + for (const redirect of envelope.config.redirects || []) if (redirect.certificate_id === cert.id) used.push(`redirect:${redirect.id}`); + for (const host of envelope.config.not_found_hosts || []) if (host.certificate_id === cert.id) used.push(`404:${host.id}`); + if (envelope.config.administration?.certificate_id === cert.id) used.push(t("administration")); + if (settings.default_certificate === cert.id) used.push(t("defaultLabel")); + const managed = !!cert.issuer, inUse = used.length > 0; + const box = el("div", undefined, "entity certificate-card"); + + const head = el("div", undefined, "certificate-card-head"); + const identity = el("div", undefined, "certificate-card-identity"); + identity.append(el("span", cert.id, "entity-title")); + const statePills = el("div", undefined, "vhost-policy-pills certificate-state-pills"); + if (managed) statePills.append(el("small", `${t("certificateManaged")} · ${cert.issuer.provider} / ${cert.issuer.environment}`, "pill")); + if (settings.default_certificate === cert.id) statePills.append(el("small", t("defaultLabel"), "pill")); + if (inventory?.expiry_status === "expired") statePills.append(el("small", t("certificateExpired"), "pill status-error")); + else if (inventory?.expiry_status === "warning") statePills.append(el("small", t("certificateExpiresSoon"), "pill status-error")); + if (statePills.children.length) identity.append(statePills); + head.append(identity); + box.append(head); + + const coverageBlock = el("div", undefined, "certificate-coverage"); + coverageBlock.append(el("span", t("certificateCovers"), "small-label")); + const domainPills = el("div", undefined, "vhost-policy-pills certificate-domain-pills"); + if (coverage.length) { + for (const host of coverage) domainPills.append(el("small", host, "pill")); + } else { + domainPills.append(el("small", t("certificateCoverageUnknown"), "certificate-coverage-unknown")); + } + coverageBlock.append(domainPills); + box.append(coverageBlock); + + const details = el("details", undefined, "certificate-details"); + details.append(el("summary", t("certificateDetails"))); + const detailBody = el("div", undefined, "certificate-details-body"); + const stamp = value => Number(value) ? new Date(Number(value) * 1000).toLocaleString(locale()) : "-"; + detailBody.append( + el("p", `${t("certificateBindings")}: ${bindings.join(", ") || "-"}`, "entity-details"), + el("p", `${t("certificateFiles")}: ${cert.cert_file} / ${cert.key_file}`, "entity-details"), + el("p", `${t("certAssigned")}: ${used.join(", ") || t("certUnassigned")}`, "entity-details"), + el("p", `${t("certificateIssuerName")}: ${inventory?.certificate_issuer || "-"}`, "entity-details"), + el("p", `${t("certificateValidFrom")}: ${stamp(inventory?.not_before_unix)}`, "entity-details"), + el("p", `${t("renewalExpires")}: ${stamp(inventory?.not_after_unix)}`, "entity-details"), + el("p", `${t("certificateDaysRemaining")}: ${inventory?.days_remaining ?? "-"}`, "entity-details"), + el("p", `${t("certificateRenewalStatus")}: ${inventory?.renewal_status || "-"}`, "entity-details"), + el("p", `${t("renewalLast")}: ${stamp(inventory?.last_renewal_attempt)}`, "entity-details"), + el("p", `${t("certificateRenewalError")}: ${inventory?.last_renewal_error ? t(inventory.last_renewal_error) : "-"}`, "entity-details"), + ); + details.append(detailBody); + box.append(details); + + const buttons = el("div", undefined, "entity-actions"); + if (!managed) { + buttons.append(action(t("reloadCertificate"), async () => { + if (busy) return; + try { + busy = true; + const result = await api(`/api/certificates/${encodeURIComponent(cert.id)}/reload`, { method: "POST", body: "{}" }); + await refreshCertificateInventory(); + renderCertificates(); + toast(`${t("reloadCertificate")}: ${result.reloaded ? "OK" : "-"}`); + if (result.administration_restart_required) toast(t("certificateAdminRestartRequired"), true); + } catch (error) { toast(error.message, true); } finally { busy = false; } + })); + buttons.append(action(t("edit"), () => { window.rustProxyCertificates?.open("existing"); $("certificate-advanced").open = true; $("certificate-id").value = cert.id; $("certificate-hosts").value = cert.hosts.join(", "); $("certificate-file").value = cert.cert_file; $("certificate-key").value = cert.key_file; $("certificate-default").checked = settings.default_certificate === cert.id; })); + } + const remove = action(t("remove"), async () => { if (inUse) { toast(t("certInUse"), true); return; } if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.tls.certificates = next.tls.certificates.filter(v => v.id !== cert.id); await saveSection("tls", next.tls); }); + remove.disabled = inUse; remove.title = inUse ? t("certInUse") : ""; buttons.append(remove); + box.append(buttons); + return box; + }); + $("certificate-list").replaceChildren(...entries); + if (!entries.length) $("certificate-list").append(el("p", t("emptyCertificates"), "empty")); +} +$("certificate-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const next = structuredClone(envelope.config); next.tls = next.tls || {}; next.tls.certificates = next.tls.certificates || []; const cert = { id: $("certificate-id").value.trim(), hosts: $("certificate-hosts").value.split(",").map(v => v.trim()).filter(Boolean), cert_file: $("certificate-file").value.trim(), key_file: $("certificate-key").value.trim() }; const index = next.tls.certificates.findIndex(v => v.id === cert.id); if (index >= 0 && next.tls.certificates[index].issuer) throw fieldError($("certificate-id"), t("certManagedReadOnly")); if (index < 0) next.tls.certificates.push(cert); else { const old = next.tls.certificates[index]; const sameFiles = old.cert_file === cert.cert_file && old.key_file === cert.key_file; next.tls.certificates[index] = { ...old, ...cert, covered_hosts: sameFiles ? (old.covered_hosts || []) : [], issuer: sameFiles ? old.issuer : null }; } if ($("certificate-default").checked) next.tls.default_certificate = cert.id; else if (next.tls.default_certificate === cert.id) delete next.tls.default_certificate; if (await saveSection("tls", next.tls)) { form.reset(); window.rustProxyCertificates?.close(); } }); } catch (error) { toast(error.message, true); } }); +$("reload-certificates").addEventListener("click", async () => { + if (!envelope || busy) return; + try { + busy = true; + const result = await api("/api/certificates/reload", { method: "POST", body: "{}" }); + await refreshCertificateInventory(); + renderCertificates(); + toast(`${t("reloadCertificates")}: ${result.reloaded ? "OK" : "-"}`); + if (result.administration_restart_required) toast(t("certificateAdminRestartRequired"), true); + } catch (error) { toast(error.message, true); } + finally { busy = false; } +}); +$("reset-certificate").addEventListener("click", () => $("certificate-form").reset()); + +// Dashboard metrics are actual server samples. Null means unavailable, not zero. +let systemSamples = [], connectionOpenSamples = [], connectionExchangeSamples = [], cacheMemorySamples = [], cacheHitSamples = [], cacheMissSamples = [], cacheBypassSamples = [], lastCacheCounters = null, + processCpuSamples = [], processRssSamples = [], networkRxSamples = [], networkTxSamples = [], responseLatencySamples = [], networkInterfaceSamples = new Map(), settingsDirty = new Set(); +const hiddenNetworkInterfaces = (() => { + try { + const parsed = JSON.parse(localStorage.getItem("rp-hidden-network-interfaces") || "[]"); + return new Set(Array.isArray(parsed) ? parsed.filter(value => typeof value === "string") : []); + } catch { return new Set(); } +})(); +function saveHiddenNetworkInterfaces() { + try { localStorage.setItem("rp-hidden-network-interfaces", JSON.stringify([...hiddenNetworkInterfaces].sort())); } catch { } +} +function syncSettingsDirtyIndicators() { + const dirtyTabs = new Set(); + for (const id of settingsDirty) { + const page = $(id)?.closest?.("[data-settings-page]"); if (page?.dataset.settingsPage) dirtyTabs.add(page.dataset.settingsPage); + } + document.querySelectorAll("[data-settings-tab]").forEach(button => { + const dirty = dirtyTabs.has(button.dataset.settingsTab); button.classList.toggle("has-unsaved", dirty); + if (dirty) button.title = t("unsavedChanges"); else button.removeAttribute("title"); + }); + window.rustProxyIntegrations?.syncHttp2Availability?.(); + window.rustProxyIntegrations?.syncHttp3Availability?.(); +} +function setSettingsDirty(id, dirty = true) { if (dirty) settingsDirty.add(id); else settingsDirty.delete(id); syncSettingsDirtyIndicators(); } +function clearSettingsDirty() { settingsDirty.clear(); syncSettingsDirtyIndicators(); } +const fmt = (value, digits = 0) => value == null ? t("unavailable") : Number(value).toLocaleString(locale(), { maximumFractionDigits: digits }); +const fmtBytes = value => value == null ? t("unavailable") : bytes(value); +function setOptionalMetric(id, supported, text) { + const node = $(id); if (!node) return; + const row = node.closest("div"); if (row) row.hidden = !supported; + if (supported) node.textContent = text; +} +function knownPair(left, right, format, zeroRight = null) { + if (left == null && right == null) return null; + if (left == null) return format(right); + if (right == null) return format(left); + const rightText = right === 0 && zeroRight ? zeroRight : format(right); + return `${format(left)} / ${rightText}`; +} +function sparklinePoints(values, width = 300, height = 60, padding = 5, peakOverride = null) { + const peak = Math.max(1, peakOverride ?? Math.max(0, ...values)); + return values.map((value, index) => `${index * width / Math.max(1, values.length - 1)},${height - padding - Math.max(0, value) / peak * (height - padding * 2)}`).join(" "); +} +function svgNode(tag, attrs = {}) { + const node = document.createElementNS("http://www.w3.org/2000/svg", tag); + for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value); + return node; +} +function renderNetworkInterfaceCharts(interfaces, appendSample) { + const current = Array.isArray(interfaces) ? interfaces.slice().sort((a, b) => String(a.name || "").localeCompare(String(b.name || ""))) : []; + const names = new Set(current.map(item => item.name)); + for (const name of [...networkInterfaceSamples.keys()]) if (!names.has(name)) networkInterfaceSamples.delete(name); + if (appendSample) { + for (const item of current) { + if (!item?.name) continue; + const history = networkInterfaceSamples.get(item.name) || { rx: [], tx: [] }; + const rx = Number(item.rx_bytes_per_second), tx = Number(item.tx_bytes_per_second); + if (Number.isFinite(rx)) { history.rx.push(rx); history.rx = history.rx.slice(-60); } + if (Number.isFinite(tx)) { history.tx.push(tx); history.tx = history.tx.slice(-60); } + networkInterfaceSamples.set(item.name, history); + } + } + const target = $("network-interface-charts"); + if (!target) return; + target.replaceChildren(); + const visible = current.filter(item => item?.name && !hiddenNetworkInterfaces.has(item.name)); + $("network-interface-chart-value").textContent = t("networkInterfacesVisibleCount").replace("{visible}", visible.length).replace("{total}", current.length); + if (!current.length) { target.append(el("p", t("networkNoInterfaces"), "empty network-interface-empty")); return; } + for (const item of visible) { + const history = networkInterfaceSamples.get(item.name) || { rx: [], tx: [] }; + const peak = Math.max(1, ...history.rx, ...history.tx); + const row = el("div", undefined, "network-interface-row"); + const head = el("div", undefined, "mini-live-chart-head network-interface-row-head"); + const rate = el("span", `${t("networkInterfaceRate")}: ${fmtBytes(item.rx_bytes_per_second)} / ${fmtBytes(item.tx_bytes_per_second)}`, "fine"); + const hide = action(t("hideMetric"), () => { hiddenNetworkInterfaces.add(item.name); saveHiddenNetworkInterfaces(); renderNetworkInterfaceCharts(current, false); }); + hide.classList.add("network-interface-hide"); + const actions = el("span", undefined, "network-interface-head-actions"); actions.append(rate, hide); + head.append(el("b", item.name || "-", "mono"), actions); + const svg = svgNode("svg", { viewBox: "0 0 300 60", role: "img", "aria-label": `${item.name} ${t("networkInterfacesHistory")}` }); + svg.append(svgNode("path", { class: "chart-grid", d: "M0 15H300 M0 30H300 M0 45H300" })); + svg.append(svgNode("polyline", { class: "diagnostic-line network-rx-line", points: sparklinePoints(history.rx, 300, 60, 5, peak) || "0,55 300,55" })); + svg.append(svgNode("polyline", { class: "diagnostic-line network-tx-line", points: sparklinePoints(history.tx, 300, 60, 5, peak) || "0,55 300,55" })); + row.append(head, svg); target.append(row); + } + const hidden = current.filter(item => item?.name && hiddenNetworkInterfaces.has(item.name)); + if (hidden.length) { + const hiddenRow = el("div", undefined, "network-interface-hidden"); + hiddenRow.append(el("span", t("hiddenInterfaces"), "fine")); + for (const item of hidden) { + const show = action(`${t("showMetric")} ${item.name}`, () => { hiddenNetworkInterfaces.delete(item.name); saveHiddenNetworkInterfaces(); renderNetworkInterfaceCharts(current, false); }); + hiddenRow.append(show); + } + target.append(hiddenRow); + } else if (!visible.length) { + target.append(el("p", t("networkNoVisibleInterfaces"), "empty network-interface-empty")); + } +} +function renderDiagnostics(status, appendSample = true) { + const d = status.system || {}, c = status.cache || {}, m = status.metrics || {}; + $("metric-h3").textContent = fmt(m.http3_requests); + $("metric-cache-ratio").textContent = c.hit_ratio == null ? "-" : `${fmt(c.hit_ratio * 100, 1)}%`; + $("cache-summary").textContent = `${fmt(c.hits)} HIT / ${fmt(c.misses)} MISS`; + $("metric-rss").closest("article.stat").hidden = d.process_rss_bytes == null; + if (d.process_rss_bytes != null) $("metric-rss").textContent = fmtBytes(d.process_rss_bytes); + $("process-summary").hidden = d.process_threads == null; + if (d.process_threads != null) $("process-summary").textContent = `${fmt(d.process_threads)} ${t("threads")}`; + const systemCpuSupported = d.cpu_percent != null; + setOptionalMetric("system-cpu", systemCpuSupported, systemCpuSupported ? `${fmt(d.cpu_percent, 1)}%` : ""); + $("system-chart").hidden = !systemCpuSupported; + const sourceSupported = !!d.source && d.source !== "unsupported"; + $("system-source").hidden = !sourceSupported; + if (sourceSupported) $("system-source").textContent = t("realSample"); + if (appendSample && d.cpu_percent != null) { systemSamples.push(d.cpu_percent); systemSamples = systemSamples.slice(-30); } + $("system-sparkline").setAttribute("points", systemSamples.map((n, i) => `${i * 300 / Math.max(1, systemSamples.length - 1)},${95 - Math.max(0, Math.min(100, n)) * .9}`).join(" ")); + const systemMemory = knownPair(d.memory_used_bytes, d.memory_total_bytes, value => fmtBytes(value)); + setOptionalMetric("system-memory", systemMemory != null, systemMemory); + $("memory-meter").hidden = systemMemory == null; + if (d.memory_used_bytes != null && d.memory_total_bytes > 0) $("memory-meter").value = d.memory_used_bytes / d.memory_total_bytes * 100; else $("memory-meter").removeAttribute("value"); + const systemSwap = knownPair(d.system_swap_used_bytes, d.system_swap_total_bytes, value => fmtBytes(value)); + setOptionalMetric("system-swap", systemSwap != null, systemSwap); + const loadSupported = Array.isArray(d.load_average) && d.load_average.length > 0; + setOptionalMetric("system-load", loadSupported, loadSupported ? d.load_average.map(n => fmt(n, 2)).join(" / ") : ""); + setOptionalMetric("system-uptime", d.system_uptime_seconds != null, d.system_uptime_seconds == null ? "" : uptime(d.system_uptime_seconds)); + $("cache-state").textContent = t(envelope?.config.cache?.enabled ? "enabled" : "notEnabled"); + $("cache-counters").textContent = [c.hits, c.misses, c.bypasses].map(n => fmt(n)).join(" / "); + $("cache-objects").textContent = `${fmt(c.entries)} / ${fmtBytes(c.resident_bytes)}`; + $("cache-memory-breakdown").textContent = `${fmtBytes(c.resident_bytes)} / ${fmtBytes(c.transient_bytes)}`; + $("cache-transient-breakdown").textContent = `${fmtBytes(c.fill_bytes_active)} / ${fmtBytes(c.cold_queue_bytes)} / ${fmtBytes((Number(c.cold_write_bytes_active) || 0) + (Number(c.cold_read_bytes_active) || 0))}`; + $("cache-evictions").textContent = `${fmt(c.evictions)} / ${fmt(c.expired)}`; + if (appendSample) { + if (c.resident_bytes != null) { cacheMemorySamples.push(Number(c.resident_bytes)); cacheMemorySamples = cacheMemorySamples.slice(-60); } + const currentCounters = [c.hits, c.misses, c.bypasses].map(Number); + if (currentCounters.every(Number.isFinite)) { + const delta = lastCacheCounters ? currentCounters.map((value, index) => value >= lastCacheCounters[index] ? value - lastCacheCounters[index] : 0) : [0, 0, 0]; + cacheHitSamples.push(delta[0]); cacheMissSamples.push(delta[1]); cacheBypassSamples.push(delta[2]); + cacheHitSamples = cacheHitSamples.slice(-60); cacheMissSamples = cacheMissSamples.slice(-60); cacheBypassSamples = cacheBypassSamples.slice(-60); + lastCacheCounters = currentCounters; + } + if (d.process_cpu_percent != null) { processCpuSamples.push(Number(d.process_cpu_percent)); processCpuSamples = processCpuSamples.slice(-60); } + if (d.process_rss_bytes != null) { processRssSamples.push(Number(d.process_rss_bytes)); processRssSamples = processRssSamples.slice(-60); } + } + const cacheOutcomePeak = Math.max(1, ...cacheHitSamples, ...cacheMissSamples, ...cacheBypassSamples); + $("cache-hit-sparkline").setAttribute("points", sparklinePoints(cacheHitSamples, 300, 60, 5, cacheOutcomePeak)); + $("cache-miss-sparkline").setAttribute("points", sparklinePoints(cacheMissSamples, 300, 60, 5, cacheOutcomePeak)); + $("cache-bypass-sparkline").setAttribute("points", sparklinePoints(cacheBypassSamples, 300, 60, 5, cacheOutcomePeak)); + const latestCacheDelta = [cacheHitSamples.at(-1), cacheMissSamples.at(-1), cacheBypassSamples.at(-1)]; + $("cache-outcome-chart-value").textContent = latestCacheDelta.every(value => value != null) ? latestCacheDelta.map(value => fmt(value)).join(" / ") : "-"; + $("cache-memory-sparkline").setAttribute("points", sparklinePoints(cacheMemorySamples)); + $("cache-chart-value").textContent = fmtBytes(c.resident_bytes); + setOptionalMetric("process-platform", !!d.platform && d.platform !== "Unsupported", d.platform || ""); + setOptionalMetric("process-cpu", d.process_cpu_percent != null, `${fmt(d.process_cpu_percent, 1)}%`); + const fds = knownPair(d.open_fds, d.max_fds, value => fmt(value), t("unlimited")); + setOptionalMetric("process-fds", fds != null, fds); + setOptionalMetric("process-threads", d.process_threads != null, fmt(d.process_threads)); + const virtualMemory = knownPair(d.process_virtual_memory_bytes, d.process_virtual_memory_max_bytes, value => fmtBytes(value), t("unlimited")); + setOptionalMetric("process-virtual-memory", virtualMemory != null, virtualMemory); + setOptionalMetric("process-start-time", d.process_start_time_seconds != null, d.process_start_time_seconds == null ? "" : new Date(Number(d.process_start_time_seconds) * 1000).toLocaleString(locale())); + setOptionalMetric("process-uptime", status.uptime_seconds != null, status.uptime_seconds == null ? "" : uptime(status.uptime_seconds)); + setOptionalMetric("process-rss-total", d.process_rss_bytes != null, fmtBytes(d.process_rss_bytes)); + setOptionalMetric("process-rss-anon", d.process_rss_anon_bytes != null, fmtBytes(d.process_rss_anon_bytes)); + const rssFile = knownPair(d.process_rss_file_bytes, d.process_rss_shmem_bytes, value => fmtBytes(value)); + setOptionalMetric("process-rss-file", rssFile != null, rssFile); + const dataSwap = knownPair(d.process_data_bytes, d.process_swap_bytes, value => fmtBytes(value)); + setOptionalMetric("process-data-swap", dataSwap != null, dataSwap); + const cgroupMemory = knownPair(d.cgroup_memory_current_bytes, d.cgroup_memory_max_bytes, value => fmtBytes(value), t("unlimited")); + setOptionalMetric("cgroup-memory", cgroupMemory != null, cgroupMemory); + $("process-cpu-sparkline").setAttribute("points", sparklinePoints(processCpuSamples)); + $("process-cpu-chart-value").textContent = d.process_cpu_percent == null ? "" : `${fmt(d.process_cpu_percent, 1)}%`; + $("process-rss-sparkline").setAttribute("points", sparklinePoints(processRssSamples)); + $("process-rss-chart-value").textContent = d.process_rss_bytes == null ? "" : fmtBytes(d.process_rss_bytes); + $("process-cpu-sparkline").closest(".mini-live-chart").hidden = d.process_cpu_percent == null; + $("process-rss-sparkline").closest(".mini-live-chart").hidden = d.process_rss_bytes == null; + updateCacheMemorySizing(); + const networkRateSupported = d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null; + setOptionalMetric("system-network", networkRateSupported, networkRateSupported ? `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}` : ""); + setOptionalMetric("mean-latency", m.mean_duration_ms != null, m.mean_duration_ms == null ? "" : `${fmt(m.mean_duration_ms, 2)} ms`); + if (appendSample) { + if (d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null) { + networkRxSamples.push(Number(d.network_rx_bytes_per_second)); networkTxSamples.push(Number(d.network_tx_bytes_per_second)); + networkRxSamples = networkRxSamples.slice(-60); networkTxSamples = networkTxSamples.slice(-60); + } + if (m.mean_duration_ms != null) { responseLatencySamples.push(Number(m.mean_duration_ms)); responseLatencySamples = responseLatencySamples.slice(-60); } + } + const networkPeak = Math.max(1, ...networkRxSamples, ...networkTxSamples); + $("network-rx-sparkline").setAttribute("points", sparklinePoints(networkRxSamples, 300, 60, 5, networkPeak)); + $("network-tx-sparkline").setAttribute("points", sparklinePoints(networkTxSamples, 300, 60, 5, networkPeak)); + $("network-chart-value").textContent = networkRateSupported ? `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}` : ""; + $("network-rx-sparkline").closest(".mini-live-chart").hidden = !networkRateSupported; + renderNetworkInterfaceCharts(d.network_interfaces || [], appendSample); + $("latency-sparkline").setAttribute("points", sparklinePoints(responseLatencySamples)); + $("latency-chart-value").textContent = m.mean_duration_ms == null ? "" : `${fmt(m.mean_duration_ms, 2)} ms`; + $("latency-sparkline").closest(".mini-live-chart").hidden = m.mean_duration_ms == null; + $("compression-counters").textContent = [m.compression_gzip, m.compression_br, m.compression_zstd, m.compression_deflate].map(n => fmt(n)).join(" / "); + const age = d.sampled_at_ms ? Math.max(0, Math.floor((Date.now() - d.sampled_at_ms) / 1000)) : null; + $("system-sample-age").hidden = age == null; + if (age != null) $("system-sample-age").textContent = `${fmt(age)} s`; + $("system-sample-age").classList.toggle("status-error", age != null && age > 10); + const l = status.listeners || {}; + $("listener-pending").hidden = !l.restart_required; + $("listener-pending").textContent = t("restartPending"); + $("listener-active").textContent = `${t("activeListeners")}: ${l.active_endpoints?.map(e => `${e.tls ? "HTTPS" : "HTTP"} ${e.address}${e.http3 ? " + H3/UDP" : ""}`).join(" | ") || `HTTP ${status.proxy_listen} / HTTPS ${status.tls_listen || t("notEnabled")}`}`; +} +function renderHeaderRules(direction, rules) { + const target = $(`${direction}-header-rules`); target.replaceChildren(); + for (const rule of rules) appendHeaderRule(direction, rule, false); +} +function appendHeaderRule(direction, rule = { op: "set", name: "", value: "" }, mark = true) { + const target = $(`${direction}-header-rules`); if (target.children.length >= 32) { toast(t("headerLimit"), true); return; } + const row = el("div", undefined, "header-rule"); + const op = el("select"); op.className = "header-op"; op.setAttribute("aria-label", t("headerOperation")); + for (const value of ["set", "append", "remove"]) { const option = el("option", t(`header_${value}`)); option.value = value; op.append(option); } op.value = rule.op; + const name = el("input"); name.className = "header-name"; name.value = rule.name; name.required = true; name.maxLength = 128; name.placeholder = t("headerName"); name.setAttribute("aria-label", t("headerName")); name.autocomplete = "off"; name.spellcheck = false; + const value = el("input"); value.className = "header-value"; value.value = rule.value ?? ""; value.maxLength = 8192; value.placeholder = t("headerValue"); value.setAttribute("aria-label", t("headerValue")); value.autocomplete = "off"; value.spellcheck = false; + function sync() { value.disabled = op.value === "remove"; } sync(); op.addEventListener("change", sync); + const remove = action(t("remove"), () => { row.remove(); setSettingsDirty("headers-form"); }); + row.append(op, name, value, remove); target.append(row); if (mark) setSettingsDirty("headers-form"); +} +function collectHeaderRules(direction) { + return [...$(`${direction}-header-rules`).children].map(row => { + const op = row.querySelector(".header-op").value, name = row.querySelector(".header-name").value.trim(), value = row.querySelector(".header-value").value; + return op === "remove" ? { op, name } : { op, name, value }; + }); +} +function renderSettings() { + if (!envelope) return; const config = envelope.config; window.rustProxySettings?.render(); + if (!settingsDirty.has("listeners-form")) window.rustProxyIntegrations?.renderListeners(); + if (!settingsDirty.has("http2-form")) window.rustProxyIntegrations?.renderHttp2Settings(); + if (!settingsDirty.has("http3-form")) window.rustProxyIntegrations?.renderHttp3Settings(); + if (!settingsDirty.has("response-form")) { + const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {}; + $("expose-request-id").checked = !!diagnostics.request_id; + $("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.24.5"; + for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name]; + $("compress-min").value = compression.min_bytes ?? 1024; syncServerMode(); + } + if (!settingsDirty.has("cache-form")) { + const c = config.cache || {}; $("cache-enabled").checked = !!c.enabled; $("cache-memory").value = (c.max_bytes ?? 268435456) / 1048576; + $("cache-transient-memory").value = (c.transient_max_bytes ?? 67108864) / 1048576; + $("cache-object").value = (c.max_object_bytes ?? 16777216) / 1024; $("cache-entries").value = c.max_entries ?? 4096; + $("cache-eviction-limit").value = c.hot_eviction_max_objects ?? 64; + $("cache-cold-enabled").checked = !!c.cold_enabled; $("cache-cold-path").value = c.cold_path || "cache/cold"; + $("cache-cold-bytes").value = (c.cold_max_bytes ?? 4294967296) / 1048576; $("cache-cold-entries").value = c.cold_max_entries ?? 50000; $("cache-cold-after").value = c.cold_after_seconds ?? 300; + $("cache-cold-queue-bytes").value = (c.cold_queue_max_bytes ?? 33554432) / 1048576; + $("cache-cold-gc-interval").value = c.cold_gc_interval_seconds ?? 60; $("cache-cold-gc-max-delete").value = c.cold_gc_max_delete ?? 256; + $("cache-cold-on-idle").checked = c.cold_on_idle !== false; $("cache-cold-on-age").checked = c.cold_on_age === true; $("cache-cold-on-lru").checked = c.cold_on_lru !== false; $("cache-cold-on-shutdown").checked = c.cold_on_shutdown !== false; $("cache-cold-max-hot").value = c.cold_max_hot_seconds ?? 3600; + $("cache-ttl").value = c.max_ttl_seconds ?? 86400; $("cache-default-ttl").value = c.default_ttl_seconds ?? 0; + $("cache-collapse-enabled").checked = c.collapse_enabled !== false; $("cache-collapse-timeout").value = c.collapse_wait_timeout_ms ?? 1500; $("cache-collapse-fallback").value = c.collapse_timeout_fallback || "origin"; + $("cache-sie-timeout").checked = c.stale_if_error_timeout !== false; $("cache-sie-connection").checked = c.stale_if_error_connection !== false; $("cache-sie-dns").checked = c.stale_if_error_dns !== false; $("cache-sie-tls").checked = c.stale_if_error_tls !== false; $("cache-sie-statuses").value = (c.stale_if_error_statuses || [500, 502, 503, 504]).join(", "); + $("cache-proactive-enabled").checked = c.proactive_refresh_enabled === true; $("cache-proactive-before").value = c.proactive_refresh_before_expiry_seconds ?? 15; $("cache-proactive-percent").value = c.proactive_refresh_percent ?? 10; $("cache-proactive-inflight").value = c.proactive_refresh_max_inflight ?? 4; + $("cache-query").checked = !!c.bypass_query; $("cache-prefixes").value = (c.bypass_prefixes || []).join("\n"); + $("cache-static-cookie").checked = c.static_ignore_cookies !== false; $("cache-static-extensions").value = (c.static_extensions || []).join("\n"); + $("cache-status-header").checked = !!c.expose_status; $("cache-hits-header").checked = !!c.expose_hits; $("cache-rfc-header").checked = !!c.expose_cache_status; $("cache-resident-age-header").checked = !!c.expose_resident_age; + syncColdCachePolicy(); + syncAdvancedCachePolicy(); + updateCacheMemorySizing(); + } + if (!settingsDirty.has("transform-form")) { + const tr = config.transform || {}; + $("transform-enabled").checked = !!tr.enabled; + $("transform-javascript").checked = tr.javascript !== false; + $("transform-css").checked = tr.css !== false; + $("transform-html").checked = !!tr.html; + $("transform-svg").checked = !!tr.svg; + $("transform-json").checked = !!tr.json; + $("transform-extensions").value = (tr.extensions || ["js", "mjs", "css", "html", "htm", "svg", "json"]).join("\n"); + $("transform-max-body").value = (tr.max_body_bytes ?? 2097152) / 1024; + } + if (!settingsDirty.has("logging-form")) { + const l = config.logging || {}, file = l.file || {}, logstash = l.logstash || {}, loki = l.loki || {}, format = l.format || {}; + $("log-file-enabled").checked = !!file.enabled; $("log-file-per-vhost").checked = !!file.per_vhost; $("log-file-path").value = file.path || "/var/log/rustproxy/access.jsonl"; $("log-file-directory").value = file.directory || "/var/log/rustproxy/vhosts"; syncLoggingFileMode(); + $("log-logstash-enabled").checked = !!logstash.enabled; $("log-logstash-address").value = logstash.address || "127.0.0.1:5044"; + $("log-loki-enabled").checked = !!loki.enabled; $("log-loki-url").value = loki.url || "http://127.0.0.1:3100/loki/api/v1/push"; $("log-loki-token").value = loki.bearer_token_env || ""; + $("log-loki-labels").value = JSON.stringify(loki.labels || { job: "rustproxy" }, null, 2); $("log-format-fields").value = JSON.stringify(format.fields || {}, null, 2); $("log-format-static").value = JSON.stringify(format.static_fields || {}, null, 2); + $("log-format-preset").value = detectLoggingPreset(format.fields || {}); + } + const sink = latestStatus?.logging?.sinks; if (sink) $("logging-status").textContent = `${t("loggingQueue")}: ${fmt(sink.queue_dropped)} ${t("loggingDropped")} / ${fmt(sink.sent)} ${t("loggingSent")} / ${fmt(sink.errors)} ${t("errors")} · ${t("loggingCapacity")}: ${fmt(sink.queue_capacity)}`; + if (!settingsDirty.has("forwarding-form")) { $("trusted-proxies").value = (config.forwarding?.trusted_proxies || []).join("\n"); $("trusted-proto").checked = !!config.forwarding?.trust_forwarded_proto; } + if (!settingsDirty.has("headers-form")) { for (const direction of ["request", "response"]) renderHeaderRules(direction, config.headers?.[direction] || []); } +} +function effectiveCacheMemory() { + const d = latestStatus?.system || {}, host = Number(d.memory_total_bytes), cgroup = Number(d.cgroup_memory_max_bytes); + const candidates = [host, cgroup].filter(value => Number.isFinite(value) && value > 0); + if (!candidates.length) return null; + const bytes = Math.min(...candidates); + return { bytes, cgroupLimited: Number.isFinite(cgroup) && cgroup > 0 && (!Number.isFinite(host) || cgroup < host) }; +} +function cacheMemoryHeadroom() { + const d = latestStatus?.system || {}; + const hostTotal = Number(d.memory_total_bytes), hostUsed = Number(d.memory_used_bytes); + const cgroupMax = Number(d.cgroup_memory_max_bytes), cgroupCurrent = Number(d.cgroup_memory_current_bytes); + const candidates = []; + if (Number.isFinite(hostTotal) && hostTotal > 0 && Number.isFinite(hostUsed) && hostUsed >= 0) candidates.push(Math.max(0, hostTotal - hostUsed)); + if (Number.isFinite(cgroupMax) && cgroupMax > 0 && Number.isFinite(cgroupCurrent) && cgroupCurrent >= 0) candidates.push(Math.max(0, cgroupMax - cgroupCurrent)); + return candidates.length ? Math.min(...candidates) : null; +} +function cachePresetValues(kind, bytes) { + const totalMiB = bytes / 1048576, profile = { + conservative: { ratio: .125, objectMiB: 8, eviction: 32 }, + recommended: { ratio: .20, objectMiB: 16, eviction: 64 }, + performance: { ratio: .30, objectMiB: 32, eviction: 128 } + }[kind] || { ratio: .20, objectMiB: 16, eviction: 64 }; + const round16 = value => Math.max(16, Math.floor(value / 16) * 16); + const hot = Math.max(64, Math.min(round16(totalMiB * profile.ratio), round16(totalMiB * .5))); + const transient = Math.max(16, round16(hot * .25)); + const queue = Math.max(8, Math.floor((transient * .5) / 8) * 8); + const objectMiB = Math.max(1, Math.min(profile.objectMiB, Math.floor(hot / 16))); + const entries = Math.max(1024, Math.min(16384, Math.round(hot * 16))); + return { hot, transient, queue, objectKiB: objectMiB * 1024, entries, eviction: profile.eviction }; +} +function updateCacheMemorySizing() { + const detected = effectiveCacheMemory(), badge = $("cache-memory-detected"), warning = $("cache-memory-warning"); + if (!badge || !warning) return; + const buttons = document.querySelectorAll("[data-cache-memory-preset]"); + if (!detected) { + badge.textContent = t("cacheMemoryNotDetected"); buttons.forEach(button => { button.disabled = true; }); + $("cache-memory-preset-summary").textContent = t("cacheMemoryPresetUnavailable"); warning.hidden = true; return; + } + if (detected.bytes < 256 * 1048576) { + badge.textContent = `${detected.cgroupLimited ? t("cacheMemoryCgroup") : t("cacheMemoryDetected")}: ${fmtBytes(detected.bytes)}`; + buttons.forEach(button => { button.disabled = true; }); + $("cache-memory-preset-summary").textContent = t("cacheMemoryPresetTooSmall"); + warning.hidden = false; warning.textContent = t("cacheMemoryPresetTooSmall"); return; + } + buttons.forEach(button => { button.disabled = false; }); + badge.textContent = `${detected.cgroupLimited ? t("cacheMemoryCgroup") : t("cacheMemoryDetected")}: ${fmtBytes(detected.bytes)}`; + const recommended = cachePresetValues("recommended", detected.bytes); + $("cache-memory-preset-summary").textContent = `${t("cacheMemoryRecommendedFor")} HOT ${recommended.hot} MiB · ${t("cachePresetTransientShort")} ${recommended.transient} MiB · ${t("cachePresetObjectShort")} ${recommended.objectKiB / 1024} MiB · ${t("cachePresetQueueShort")} ${recommended.queue} MiB`; + const hot = Number($("cache-memory")?.value) * 1048576, transient = Number($("cache-transient-memory")?.value) * 1048576; + const queue = Number($("cache-cold-queue-bytes")?.value) * 1048576; + const object = Number($("cache-object")?.value) * 1024; + const high = Number.isFinite(hot + transient) && hot + transient > detected.bytes * .35; + const invalidQueue = Number.isFinite(queue) && Number.isFinite(transient) && queue > transient; + const objectOverTransient = Number.isFinite(object) && Number.isFinite(transient) && object > transient; + const headroom = cacheMemoryHeadroom(); + const currentCache = (Number(latestStatus?.cache?.hot_allocated_bytes ?? latestStatus?.cache?.resident_bytes) || 0) + (Number(latestStatus?.cache?.transient_bytes) || 0); + const additionalBudget = Math.max(0, hot + transient - currentCache); + const lowHeadroom = headroom != null && Number.isFinite(additionalBudget) && additionalBudget > headroom * .75; + warning.hidden = !(high || invalidQueue || objectOverTransient || lowHeadroom); + if (invalidQueue) warning.textContent = t("cacheQueueOverTransientWarning"); + else if (objectOverTransient) warning.textContent = t("cacheObjectOverTransientWarning"); + else if (lowHeadroom) warning.textContent = `${t("cacheMemoryHeadroomWarning")} ${fmtBytes(additionalBudget)} / ${fmtBytes(headroom)}.`; + else if (high) warning.textContent = `${t("cacheMemoryHighWarning")} ${fmtBytes(hot + transient)} / ${fmtBytes(detected.bytes)}.`; +} +function applyCacheMemoryPreset(kind) { + const detected = effectiveCacheMemory(); if (!detected || detected.bytes < 256 * 1048576) return; + const preset = cachePresetValues(kind, detected.bytes); + $("cache-memory").value = preset.hot; $("cache-transient-memory").value = preset.transient; + $("cache-object").value = preset.objectKiB; $("cache-entries").value = preset.entries; + $("cache-eviction-limit").value = preset.eviction; $("cache-cold-queue-bytes").value = preset.queue; + setSettingsDirty("cache-form"); updateCacheMemorySizing(); +} +function syncServerMode() { $("server-value").disabled = $("server-mode").value !== "custom"; $("server-value").required = !$("server-value").disabled; } +function syncLoggingFileMode() { + const enabled = $("log-file-enabled").checked, perVhost = $("log-file-per-vhost").checked; + $("log-file-per-vhost").disabled = !enabled; + $("log-file-path").disabled = !enabled || perVhost; + $("log-file-directory").disabled = !enabled || !perVhost; +} +function syncColdCachePolicy() { + const enabled = $("cache-cold-enabled").checked; + for (const id of ["cache-cold-path", "cache-cold-bytes", "cache-cold-entries", "cache-cold-queue-bytes", "cache-cold-gc-interval", "cache-cold-gc-max-delete", "cache-cold-on-idle", "cache-cold-on-age", "cache-cold-on-lru", "cache-cold-on-shutdown"]) $(id).disabled = !enabled; + $("cache-cold-after").disabled = !enabled || !$("cache-cold-on-idle").checked; + $("cache-cold-max-hot").disabled = !enabled || !$("cache-cold-on-age").checked; +} +function syncAdvancedCachePolicy() { + const collapse = $("cache-collapse-enabled").checked; + $("cache-collapse-timeout").disabled = !collapse; + $("cache-collapse-fallback").disabled = !collapse; + const proactive = $("cache-proactive-enabled").checked; + for (const id of ["cache-proactive-before", "cache-proactive-percent", "cache-proactive-inflight"]) $(id).disabled = !proactive; +} +function bindSettingsForm(id, apply, section, selectValue = next => next[section]) { + const form = $(id); for (const event of ["input", "change"]) form.addEventListener(event, () => setSettingsDirty(id)); + form.addEventListener("submit", async event => { + event.preventDefault(); if (!envelope || busy) return; + try { + await withFormSubmitState(form, event.submitter, async () => { + const next = structuredClone(envelope.config); apply(next); + if (await saveSection(section, selectValue(next))) { setSettingsDirty(id, false); renderSettings(); } + }); + } catch (error) { toast(error.message, true); } + }); +} +bindSettingsForm("listeners-form", next => { next.listeners = window.rustProxyIntegrations.collectListeners(); }, "listeners"); +bindSettingsForm("http2-form", next => { + if (settingsDirty.has("listeners-form")) throw new Error(t("http2ListenersDirty")); + next.listeners = window.rustProxyIntegrations.collectHttp2Listeners(); +}, "listeners", next => next.listeners); +bindSettingsForm("http3-form", next => { + if (settingsDirty.has("listeners-form")) throw new Error(t("http3ListenersDirty")); + next.listeners = window.rustProxyIntegrations.collectHttp3Listeners(); +}, "listeners", next => next.listeners); +bindSettingsForm("response-form", next => { + next.server_header = { mode: $("server-mode").value, value: $("server-value").value }; + next.diagnostic_headers = { request_id: $("expose-request-id").checked }; + next.compression = { min_bytes: Number($("compress-min").value) }; for (const name of ["gzip", "br", "zstd", "deflate"]) next.compression[name] = $(`compress-${name}`).checked; +}, "response", next => ({ server_header: next.server_header, diagnostic_headers: next.diagnostic_headers, compression: next.compression })); +bindSettingsForm("cache-form", next => { + next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), cold_enabled: $("cache-cold-enabled").checked, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked }; +}, "cache"); +bindSettingsForm("transform-form", next => { + next.transform = { + enabled: $("transform-enabled").checked, + javascript: $("transform-javascript").checked, + css: $("transform-css").checked, + html: $("transform-html").checked, + svg: $("transform-svg").checked, + json: $("transform-json").checked, + extensions: $("transform-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), + max_body_bytes: Math.round(Number($("transform-max-body").value) * 1024) + }; +}, "transform"); +const LOGGING_SOURCE_FIELDS = ["vhost", "route", "cache_effective_enabled", "transform_effective_enabled", "backend", "id", "time_ms", "method", "host", "path", "http_version", "transport", "websocket_bootstrap", "websocket_phase", "websocket_error", "h3_reset_code", "upstream_status", "upstream_error", "client_ip", "status", "duration_ms", "request_bytes", "response_bytes", "outcome", "cache", "cache_reason"]; +const LOGGING_PRESETS = { + standard: {}, + minimal: { time_ms: "time_ms", method: "method", host: "host", path: "path", status: "status", duration_ms: "duration_ms" }, + nginx: { time_ms: "time_ms", remote_addr: "client_ip", request_method: "method", host: "host", request_uri: "path", status: "status", request_time_ms: "duration_ms", request_length: "request_bytes", bytes_sent: "response_bytes", upstream: "backend", cache: "cache", cache_reason: "cache_reason" }, + ecs: { "event.created_ms": "time_ms", "client.address": "client_ip", "http.request.method": "method", "server.address": "host", "url.path": "path", "http.response.status_code": "status", "event.duration_ms": "duration_ms", "http.request.body.bytes": "request_bytes", "http.response.body.bytes": "response_bytes", "service.target.name": "backend", "event.outcome": "outcome", "cache.result": "cache", "cache.reason": "cache_reason" } +}; +const LOGGING_PREVIEW_VALUES = { vhost: "app", route: "root", cache_effective_enabled: true, transform_effective_enabled: false, backend: "origin-1", id: 1842, time_ms: 1769328123456, method: "CONNECT", host: "app.example", path: "/socket.io/", http_version: "3", transport: "h3", websocket_bootstrap: "h3_extended_connect", websocket_phase: "tunnel_closed", websocket_error: null, h3_reset_code: null, upstream_status: 101, client_ip: "203.0.113.24", status: 200, duration_ms: 12, request_bytes: 0, response_bytes: 4821, outcome: "websocket_closed", cache: "BYPASS", cache_reason: "request_upgrade" }; +function parseJsonObjectValue(value, target = null) { let parsed; try { parsed = JSON.parse(value || "{}"); } catch { throw fieldError(target, t("loggingJsonInvalid")); } if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw fieldError(target, t("loggingJsonInvalid")); return parsed; } +function loggingMappingKey(mapping) { return JSON.stringify(Object.entries(mapping || {}).sort(([a], [b]) => a.localeCompare(b))); } +function detectLoggingPreset(mapping) { const key = loggingMappingKey(mapping); for (const [name, preset] of Object.entries(LOGGING_PRESETS)) if (loggingMappingKey(preset) === key) return name; return "custom"; } +function selectedLoggingMapping() { + const rows = [...$("logging-field-rows").querySelectorAll("tr")], mapping = {}; + for (const row of rows) { + const enabled = row.querySelector("input[type=checkbox]").checked; + const source = row.dataset.source; + const output = row.querySelector("input[type=text]").value.trim(); + if (enabled) { + if (!output) throw new Error(t("loggingOutputRequired")); + if (Object.hasOwn(mapping, output)) throw new Error(t("loggingOutputDuplicate")); + mapping[output] = source; + } + } + if (Object.keys(mapping).length === LOGGING_SOURCE_FIELDS.length && LOGGING_SOURCE_FIELDS.every(name => mapping[name] === name)) return {}; + return mapping; +} +function renderLoggingPreview() { + let mapping; + try { mapping = selectedLoggingMapping(); } catch (error) { $("logging-format-preview").textContent = error.message; return; } + let staticFields = {}; try { staticFields = parseJsonObjectValue($("log-format-static").value, $("log-format-static")); } catch { } + const source = Object.keys(mapping).length ? mapping : Object.fromEntries(LOGGING_SOURCE_FIELDS.map(name => [name, name])); + const preview = { ...staticFields }; + for (const [output, name] of Object.entries(source)) preview[output] = LOGGING_PREVIEW_VALUES[name]; + $("logging-format-preview").textContent = JSON.stringify(preview, null, 2); +} +function openLoggingBuilder() { + let mapping; try { mapping = parseJsonObjectValue($("log-format-fields").value, $("log-format-fields")); } catch (error) { toast(error.message, true); return; } + const identity = !Object.keys(mapping).length; + const bySource = new Map(); + for (const [output, source] of Object.entries(mapping)) if (!bySource.has(source)) bySource.set(source, output); + const rows = LOGGING_SOURCE_FIELDS.map(source => { + const row = el("tr"); row.dataset.source = source; + const checkCell = el("td"), sourceCell = el("td", source), outputCell = el("td"); + const check = document.createElement("input"); check.type = "checkbox"; check.checked = identity || bySource.has(source); + const output = document.createElement("input"); output.type = "text"; output.maxLength = 128; output.value = bySource.get(source) || source; output.disabled = !check.checked; + check.addEventListener("change", () => { output.disabled = !check.checked; renderLoggingPreview(); }); + output.addEventListener("input", renderLoggingPreview); + checkCell.append(check); outputCell.append(output); row.append(checkCell, sourceCell, outputCell); return row; + }); + $("logging-field-rows").replaceChildren(...rows); renderLoggingPreview(); + const dialog = $("logging-format-dialog"); if (!dialog.open) dialog.showModal(); +} +function applyLoggingPreset(name) { + if (name === "custom") return; + $("log-format-fields").value = JSON.stringify(LOGGING_PRESETS[name], null, 2); + setSettingsDirty("logging-form"); +} +bindSettingsForm("logging-form", next => { + const parseObject = id => parseJsonObjectValue($(id).value, $(id)); + next.logging = { + file: { enabled: $("log-file-enabled").checked, path: $("log-file-path").value.trim(), per_vhost: $("log-file-per-vhost").checked, directory: $("log-file-directory").value.trim() }, + logstash: { enabled: $("log-logstash-enabled").checked, address: $("log-logstash-address").value.trim() }, + loki: { enabled: $("log-loki-enabled").checked, url: $("log-loki-url").value.trim(), bearer_token_env: $("log-loki-token").value.trim() || null, labels: parseObject("log-loki-labels") }, + format: { fields: parseObject("log-format-fields"), static_fields: parseObject("log-format-static") } + }; +}, "logging"); +bindSettingsForm("forwarding-form", next => { next.forwarding = { trusted_proxies: $("trusted-proxies").value.split(/[\n,]/).map(s => s.trim()).filter(Boolean), trust_forwarded_proto: $("trusted-proto").checked }; }, "forwarding"); +bindSettingsForm("headers-form", next => { next.headers = { request: collectHeaderRules("request"), response: collectHeaderRules("response") }; }, "headers"); +$("log-format-preset").addEventListener("change", event => applyLoggingPreset(event.target.value)); +$("log-format-open").addEventListener("click", openLoggingBuilder); +$("logging-format-close").addEventListener("click", () => $("logging-format-dialog").close()); +$("logging-format-cancel").addEventListener("click", () => $("logging-format-dialog").close()); +$("logging-format-apply").addEventListener("click", () => { try { const mapping = selectedLoggingMapping(); $("log-format-fields").value = JSON.stringify(mapping, null, 2); $("log-format-preset").value = detectLoggingPreset(mapping); setSettingsDirty("logging-form"); $("logging-format-dialog").close(); } catch (error) { toast(error.message, true); } }); +$("server-mode").addEventListener("change", syncServerMode); +$("log-file-enabled").addEventListener("change", syncLoggingFileMode); +$("log-file-per-vhost").addEventListener("change", syncLoggingFileMode); +for (const id of ["cache-cold-enabled", "cache-cold-on-idle", "cache-cold-on-age"]) $(id).addEventListener("change", syncColdCachePolicy); +document.querySelectorAll("[data-cache-memory-preset]").forEach(button => button.addEventListener("click", () => applyCacheMemoryPreset(button.dataset.cacheMemoryPreset))); +for (const id of ["cache-memory", "cache-transient-memory", "cache-object", "cache-cold-queue-bytes"]) $(id).addEventListener("input", updateCacheMemorySizing); +for (const id of ["cache-collapse-enabled", "cache-proactive-enabled"]) $(id).addEventListener("change", syncAdvancedCachePolicy); +syncColdCachePolicy(); +syncAdvancedCachePolicy(); +$("purge-cache").addEventListener("click", async () => { if (!(await appConfirm(t("confirmPurge"), { danger: true }))) return; const button = $("purge-cache"); button.disabled = true; try { const result = await api("/api/cache/purge", { method: "POST", body: "{}" }); toast(`${t("cachePurged")}: ${result.purged_entries}`); await refresh(true); } catch (error) { toast(error.message, true); } finally { button.disabled = false; } }); +$("add-response-header").addEventListener("click", () => appendHeaderRule("response")); +$("add-request-header").addEventListener("click", () => appendHeaderRule("request")); +window.addEventListener("beforeunload", event => { if (settingsDirty.size) { event.preventDefault(); event.returnValue = ""; } }); diff --git a/src/admin/web/cache-panel.js b/src/admin/web/cache-panel.js new file mode 100644 index 0000000..33e4e17 --- /dev/null +++ b/src/admin/web/cache-panel.js @@ -0,0 +1,309 @@ +"use strict"; +// Dedicated cache inspector. Entry bodies and request headers are never loaded. +(() => { + let loading = false, offset = 0, generation = 0; + let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null; + const liveChartSamples = { hits: [], misses: [], bypasses: [] }; + const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]); + const LIVE_CHART_STORAGE_KEY = "rustproxy.cache.liveChartSamples"; + let liveChartLimit = 60; + const scope = () => $("cache-scope").value; + const domainFilter = () => $("cache-domain-filter").value; + const number = v => fmt(v ?? 0); + const operationMessage = (id, value = "", error = false) => { + const box = $(id); + if (!box) return; + box.textContent = value; + box.classList.toggle("error", Boolean(value) && error); + }; + async function copyText(value) { + if (navigator.clipboard?.writeText) { + try { await navigator.clipboard.writeText(value); return; } catch { } + } + const area = document.createElement("textarea"); + area.value = value; + area.setAttribute("readonly", ""); + area.style.position = "fixed"; + area.style.opacity = "0"; + document.body.append(area); + area.select(); + document.execCommand("copy"); + area.remove(); + } + function makeCopyCell(value) { + const td = el("td", value, "cache-copy-cell"); + td.title = value; + td.tabIndex = 0; + td.setAttribute("role", "button"); + const copy = () => void copyText(value); + td.addEventListener("click", copy); + td.addEventListener("keydown", event => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + copy(); + }); + return td; + } + const cacheEnabledFor = (data, row) => { + const scoped = (data.vhosts || []).find(vhost => vhost.id === row.vhost); + return scoped?.settings?.enabled ?? data.settings?.enabled ?? false; + }; + const visibleDomains = (data, groups) => { + const filter = domainFilter(); + if (filter === "all") return groups; + const wanted = filter === "enabled"; + return groups.filter(row => cacheEnabledFor(data, row) === wanted); + }; + const reason = value => { const key = `cacheReason_${value}`, text = t(key); return text === key ? value : text; }; + function resetLiveChart(selected = scope()) { + liveChartScope = selected; liveChartPrevious = null; liveChartPreviousAt = null; + for (const values of Object.values(liveChartSamples)) values.length = 0; + for (const id of ["cache-live-hit-line", "cache-live-miss-line", "cache-live-bypass-line"]) $(id)?.setAttribute("points", "0,170 900,170"); + for (const id of ["cache-live-hit-rate", "cache-live-miss-rate", "cache-live-bypass-rate"]) if ($(id)) $(id).textContent = "0.0/s"; + if ($("cache-live-chart-value")) $("cache-live-chart-value").textContent = "-"; + } + function redrawLiveChart() { + const peak = Math.max(1, ...liveChartSamples.hits, ...liveChartSamples.misses, ...liveChartSamples.bypasses); + $("cache-live-hit-line")?.setAttribute("points", sparklinePoints(liveChartSamples.hits, 900, 180, 10, peak)); + $("cache-live-miss-line")?.setAttribute("points", sparklinePoints(liveChartSamples.misses, 900, 180, 10, peak)); + $("cache-live-bypass-line")?.setAttribute("points", sparklinePoints(liveChartSamples.bypasses, 900, 180, 10, peak)); + } + function setLiveChartLimit(value, persist = true) { + const parsed = Number(value); + liveChartLimit = LIVE_CHART_LIMITS.has(parsed) ? parsed : 60; + for (const key of Object.keys(liveChartSamples)) liveChartSamples[key] = liveChartSamples[key].slice(-liveChartLimit); + if ($("cache-live-samples")) $("cache-live-samples").value = String(liveChartLimit); + if (persist) { try { localStorage.setItem(LIVE_CHART_STORAGE_KEY, String(liveChartLimit)); } catch { } } + redrawLiveChart(); + } + function initializeLiveChartLimit() { + let saved = 60; + try { saved = Number(localStorage.getItem(LIVE_CHART_STORAGE_KEY) || 60); } catch { } + setLiveChartLimit(saved, false); + } + function renderLiveChart(counters) { + const selected = scope(); + if (liveChartScope !== selected) resetLiveChart(selected); + const now = performance.now(), current = { + hits: Math.max(0, Number(counters.hits) || 0), + misses: Math.max(0, Number(counters.misses) || 0), + bypasses: Math.max(0, Number(counters.bypasses) || 0) + }, rates = { hits: 0, misses: 0, bypasses: 0 }; + if (liveChartPrevious && liveChartPreviousAt != null) { + const elapsed = Math.max(.001, (now - liveChartPreviousAt) / 1000); + for (const key of Object.keys(rates)) if (current[key] >= liveChartPrevious[key]) rates[key] = (current[key] - liveChartPrevious[key]) / elapsed; + } + liveChartPrevious = current; liveChartPreviousAt = now; + for (const key of Object.keys(rates)) { liveChartSamples[key].push(rates[key]); liveChartSamples[key] = liveChartSamples[key].slice(-liveChartLimit); } + redrawLiveChart(); + $("cache-live-hit-rate").textContent = `${rates.hits.toFixed(1)}/s`; + $("cache-live-miss-rate").textContent = `${rates.misses.toFixed(1)}/s`; + $("cache-live-bypass-rate").textContent = `${rates.bypasses.toFixed(1)}/s`; + $("cache-live-chart-value").textContent = `Σ ${(rates.hits + rates.misses + rates.bypasses).toFixed(1)}/s`; + } + function configure() { + const selected = scope(); const items = [el("option", t("logAllVhosts"))]; items[0].value = "*"; + for (const host of envelope?.config.vhosts || []) { const item = el("option", `${host.id} / ${host.frontend.hosts.join(", ")}`); item.value = host.id; items.push(item); } + $("cache-scope").replaceChildren(...items); $("cache-scope").value = items.some(e => e.value === selected) ? selected : "*"; + } + function render(data) { + const m = data.metrics || {}, d = data.dashboard || { domains: [], objects: [], matching_objects: 0 }, groups = d.domains || [], shownGroups = visibleDomains(data, groups); + const total = field => groups.reduce((n, row) => n + (row[field] || 0), 0), all = scope() === "*"; + const hits = total("hits"), misses = total("misses"); + const hotEntries = all ? (m.entries || 0) : total("hot_entries"), coldEntries = all ? (m.cold_entries || 0) : total("cold_entries"); + $("cp-entries").textContent = number(hotEntries); + $("cp-capacity").textContent = `${t("cacheGlobalLimit")}: ${number(data.settings?.max_entries)}`; + const resident = all ? (m.hot_allocated_bytes ?? m.resident_bytes ?? 0) : total("resident_bytes"); + const logical = all ? (m.hot_logical_bytes ?? 0) : total("logical_bytes"); + const hotLimit = m.hot_limit_bytes ?? data.settings?.max_bytes ?? 0; + $("cp-bytes").textContent = fmtBytes(resident); $("cp-memory").value = resident / Math.max(1, hotLimit) * 100; + $("cp-memory-detail").textContent = `${t("cacheLogicalShort")}: ${fmtBytes(logical)} · ${t("cacheLimitShort")}: ${fmtBytes(hotLimit)}`; + $("cp-transient").textContent = `${fmtBytes(m.transient_bytes)} / ${fmtBytes(m.transient_limit_bytes ?? data.settings?.transient_max_bytes)}`; + $("cp-transient-detail").textContent = `${t("cacheFillShort")}: ${fmtBytes(m.fill_bytes_active)} · ${t("cacheQueueShort")}: ${fmtBytes(m.cold_queue_bytes)} / ${fmtBytes(m.cold_queue_limit_bytes ?? data.settings?.cold_queue_max_bytes)} · ${t("cacheIoShort")}: ${fmtBytes((Number(m.cold_write_bytes_active) || 0) + (Number(m.cold_read_bytes_active) || 0))}`; + const coldBytes = all ? (m.cold_bytes || 0) : total("cold_bytes"); + $("cp-cold-entries").textContent = number(coldEntries); $("cp-cold-capacity").textContent = `${t("cacheGlobalLimit")}: ${number(data.settings?.cold_max_entries || 0)}`; + $("cp-cold-bytes").textContent = fmtBytes(coldBytes); $("cp-cold-disk").value = coldBytes / Math.max(1, data.settings?.cold_max_bytes || 1) * 100; + $("cp-ratio").textContent = `${fmt((all ? (m.hit_ratio || 0) : (hits + misses ? hits / (hits + misses) : 0)) * 100, 1)}%`; + $("cp-requests").textContent = `${number(all ? m.hits : hits)} HIT / ${number(all ? m.misses : misses)} MISS`; + const bypasses = all ? (Number(m.bypasses) || 0) : total("bypasses"); + $("cp-bypass").textContent = number(bypasses); + renderLiveChart({ hits: all ? (Number(m.hits) || 0) : hits, misses: all ? (Number(m.misses) || 0) : misses, bypasses }); + $("cp-fills").textContent = `${t("cacheFills")}: ${number(m.fills_active)} · ${t("cacheCollapseWaiters")}: ${number(m.collapse_waiters)} · ${t("cacheCollapsed")}: ${number(m.collapsed_fills)}`; + $("cp-saved").textContent = fmtBytes(total("saved_body_bytes")); + $("cp-tier-moves").textContent = `${number(m.hot_demotions)} ↓ / ${number(m.cold_promotions)} ↑`; + $("cp-tier-errors").textContent = `${number(m.cold_hits)} ${t("cacheColdHits")} · ${number(m.cold_errors)} ${t("errors")} · ${number(m.cold_queue_dropped)} ${t("loggingDropped")}`; + $("cp-expired").textContent = `${number(m.evictions)} / ${number(m.expired)}`; + $("cp-rejected").textContent = `${t("cacheRejected")}: ${number(m.rejected_fills)} · ${t("cacheMemoryPressureShort")}: ${number(m.memory_pressure_rejections)} · ${t("cacheNukeLimitShort")}: ${number(m.hot_eviction_limited)}`; + $("cache-domain-rows").replaceChildren(...shownGroups.map(row => { + const tr = el("tr"); for (const value of [row.vhost || t("cacheLegacy"), row.domain, number(row.hot_entries), number(row.cold_entries), fmtBytes(row.resident_bytes), fmtBytes(row.cold_bytes), `${number(row.hits)} / ${number(row.misses)} / ${number(row.bypasses)}`, `${fmt((row.hit_ratio || 0) * 100, 1)}%`]) tr.append(el("td", value)); + const td = el("td", undefined, "cache-reasons"); for (const [key, count] of Object.entries(row.reasons || {}).sort((a, b) => b[1] - a[1])) { const tag = el("span", `${reason(key)}: ${number(count)}`, "cache-reason"); tag.title = key; td.append(tag); } tr.append(td); return tr; + })); + const domainEmpty = $("cache-domains-empty"); + domainEmpty.textContent = groups.length ? t("cacheNoDomainsFiltered") : t("cacheNoDomains"); + domainEmpty.hidden = !!shownGroups.length; + $("cache-object-rows").replaceChildren(...(d.objects || []).map(row => { + const tr = el("tr"), resident = Number(row.resident_bytes) || 0, disk = Number(row.storage_bytes) || 0, body = Number(row.body_bytes) || 0, storage = resident || disk; + tr.append(el("td", row.vhost || t("cacheLegacy")), el("td", row.domain)); + const targetValue = row.target || row.path || "-", target = makeCopyCell(targetValue); + if (row.transformed) target.append(el("span", t("transformBadge"), "cache-transform-badge")); + tr.append(target); + const keyTags = `${row.cache_key || "-"}${row.tags?.length ? ` · ${row.tags.join(", ")}` : ""}`; + tr.append(makeCopyCell(keyTags)); + for (const value of [row.tier === "cold" ? t("cacheCold") : t("cacheHot"), fmtBytes(body), fmtBytes(storage), fmtBytes(Math.max(0, storage - body)), number(row.hits), `${number(row.age_seconds)} s`, `${number(row.remaining_ttl_seconds)} s`]) tr.append(el("td", value)); + const action = el("td"), button = el("button", t("cachePurgeUrl"), "quiet"); button.type = "button"; button.addEventListener("click", () => void purgeObject(row)); action.append(button); tr.append(action); return tr; + })); + const bans = data.bans || []; + $("cache-ban-rows").replaceChildren(...bans.map(rule => { + const tr = el("tr"); + for (const value of [String(rule.id), rule.selector, rule.value, rule.host || "*", rule.vhost || "*", new Date(rule.expires_at_ms).toLocaleString(locale())]) tr.append(el("td", value)); + const action = el("td"), button = el("button", t("cacheRemoveBan"), "quiet"); button.type = "button"; button.addEventListener("click", () => void removeBan(rule.id)); action.append(button); tr.append(action); return tr; + })); + $("cache-bans-empty").hidden = !!bans.length; + const bodyBytes = Number(d.matching_body_bytes) || 0, residentBytes = Number(d.matching_resident_bytes) || 0, diskBytes = Number(d.matching_cold_bytes) || 0; + $("cache-object-summary").textContent = `${number(d.matching_objects)} ${t("cacheObjectCount")} · ${t("cacheBodySize")}: ${fmtBytes(bodyBytes)} · ${t("cacheHotMemory")}: ${fmtBytes(residentBytes)} · ${t("cacheColdDisk")}: ${fmtBytes(diskBytes)}`; + $("cache-objects-empty").hidden = !!d.objects?.length; + $("cache-page").textContent = `${d.matching_objects ? offset + 1 : 0}-${Math.min(offset + (d.objects?.length || 0), d.matching_objects || 0)} / ${number(d.matching_objects)}`; + $("cache-prev").disabled = offset === 0; $("cache-next").disabled = offset + 50 >= (d.matching_objects || 0); + $("cache-panel-state").textContent = `${t("cacheUpdated")}: ${new Date().toLocaleTimeString(locale())}`; + $("cache-panel-cleanup").disabled = !data.settings?.cold_enabled; + } + async function runInvalidation(payload, messageTarget = "cache-invalidation-status") { + const result = await api("/api/cache/invalidate", { method: "POST", body: JSON.stringify(payload) }); + const detail = result.result || {}; + const message = detail.operation === "ban" + ? `${t("cacheBanCreated")}: ${detail.ban_id}` + : `${t("cachePurged")}: ${number(detail.affected_entries)}`; + operationMessage(messageTarget, message); + offset = 0; generation++; await refresh(); + } + async function purgeObject(row) { + if (!authenticated || !(await appConfirm(t("cacheConfirmPurgeUrl"), { danger: true }))) return; + operationMessage("cache-toolbar-operation-status"); + try { + await runInvalidation( + { operation: "purge", selector: "exact", value: row.target || row.path, host: row.authority || row.domain, vhost: row.vhost || null, ttl_seconds: null, confirm_wide: false }, + "cache-toolbar-operation-status" + ); + } catch (error) { operationMessage("cache-toolbar-operation-status", error.message, true); } + } + async function removeBan(id) { + if (!authenticated || !(await appConfirm(t("cacheConfirmRemoveBan"), { danger: true }))) return; + operationMessage("cache-bans-status"); + try { + await api(`/api/cache/bans/${encodeURIComponent(id)}`, { method: "DELETE" }); + operationMessage("cache-bans-status", t("cacheBanRemoved")); + generation++; await refresh(); + } catch (error) { operationMessage("cache-bans-status", error.message, true); } + } + async function refresh() { + if (!authenticated || !envelope || currentPanel !== "cache" || loading) return; + loading = true; const epoch = authEpoch, token = generation; const query = new URLSearchParams({ offset: String(offset), limit: "50" }); if (scope() !== "*") query.set("vhost", scope()); + try { const data = await api(`/api/cache?${query}`); if (epoch !== authEpoch || token !== generation || !authenticated) return; render(data); } + catch (error) { if (epoch === authEpoch) { $("cache-panel-state").textContent = error.message; if (error.status === 401) lock(); } } + finally { loading = false; if (token !== generation && authenticated && currentPanel === "cache") void refresh(); } + } + initializeLiveChartLimit(); + $("cache-live-samples")?.addEventListener("change", event => setLiveChartLimit(event.currentTarget.value)); + $("cache-scope").addEventListener("change", () => { offset = 0; resetLiveChart(); generation++; void refresh(); }); + $("cache-domain-filter").addEventListener("change", () => { generation++; void refresh(); }); + $("cache-refresh").addEventListener("click", () => void refresh()); + $("cache-prev").addEventListener("click", () => { offset = Math.max(0, offset - 50); generation++; void refresh(); }); + $("cache-next").addEventListener("click", () => { offset += 50; generation++; void refresh(); }); + $("cache-panel-settings").addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("cache"); $("cache-form").scrollIntoView({ block: "start" }); }); + const cleanupDialog = $("cache-cleanup-dialog"); + const closeCleanup = () => { if (cleanupDialog.open) cleanupDialog.close(); }; + $("cache-panel-cleanup").addEventListener("click", () => { + $("cache-cleanup-status").textContent = ""; + cleanupDialog.showModal(); + $("cache-cleanup-unused").focus(); + }); + $("cache-cleanup-close").addEventListener("click", closeCleanup); + $("cache-cleanup-cancel").addEventListener("click", closeCleanup); + $("cache-cleanup-form").addEventListener("submit", async event => { + event.preventDefault(); + if (!authenticated) return; + const form = event.currentTarget, submit = event.submitter, epoch = authEpoch; + const unusedValue = $("cache-cleanup-unused").value.trim(), oldestValue = $("cache-cleanup-oldest").value.trim(); + const payload = { + unused_for_seconds: unusedValue ? Number(unusedValue) : null, + oldest: oldestValue ? Number(oldestValue) : null, + hits_zero: $("cache-cleanup-hits-zero").checked + }; + submit.disabled = true; + try { + const started = await api("/api/cache/cold/cleanup", { method: "POST", body: JSON.stringify(payload) }); + const id = started.job?.id; + if (!id) throw new Error(t("cacheColdCleanupInvalidJob")); + $("cache-cleanup-status").textContent = t("cacheColdCleanupRunning"); + for (; ;) { + await new Promise(resolve => setTimeout(resolve, 300)); + if (epoch !== authEpoch || !authenticated) return; + const current = await api(`/api/cache/cold/cleanup/${encodeURIComponent(id)}`); + const job = current.job || {}; + if (job.status === "done") { + const message = t("cacheColdCleanupDone") + .replace("{count}", number(job.removed_entries)) + .replace("{bytes}", fmtBytes(job.freed_bytes)); + $("cache-cleanup-status").textContent = message; toast(message); + generation++; await refresh(); + break; + } + if (job.status === "failed") throw new Error(job.error || t("cacheColdCleanupFailed")); + } + } catch (error) { + if (epoch === authEpoch) { $("cache-cleanup-status").textContent = error.message; toast(error.message, true); } + } finally { submit.disabled = false; } + }); + const invalidationDialog = $("cache-invalidation-dialog"), bansDialog = $("cache-bans-dialog"); + const closeInvalidation = () => { if (invalidationDialog.open) invalidationDialog.close(); }; + const closeBans = () => { if (bansDialog.open) bansDialog.close(); }; + $("cache-invalidation-open").addEventListener("click", () => { + operationMessage("cache-invalidation-status"); + $("cache-invalidation-operation").value = "purge"; + $("cache-ban-ttl").disabled = true; + if (!invalidationDialog.open) invalidationDialog.showModal(); + $("cache-invalidation-value").focus(); + }); + $("cache-bans-open").addEventListener("click", () => { + operationMessage("cache-bans-status"); + if (!bansDialog.open) bansDialog.showModal(); + generation++; void refresh(); + }); + $("cache-invalidation-close").addEventListener("click", closeInvalidation); + $("cache-bans-close").addEventListener("click", closeBans); + $("cache-invalidation-operation").addEventListener("change", () => { + operationMessage("cache-invalidation-status"); + $("cache-ban-ttl").disabled = $("cache-invalidation-operation").value !== "ban"; + }); + $("cache-invalidation-form").addEventListener("submit", async event => { + event.preventDefault(); if (!authenticated) return; + const operation = $("cache-invalidation-operation").value, selector = $("cache-invalidation-selector").value, value = $("cache-invalidation-value").value.trim(), host = $("cache-invalidation-host").value.trim(); + if (!value) return; + const selected = scope(), wide = selector === "regex" || (selector === "prefix" && value === "/"); + if (wide && !(await appConfirm(t("cacheWideInvalidationConfirm"), { danger: true }))) return; + const submit = event.submitter; submit.disabled = true; operationMessage("cache-invalidation-status"); + try { + await runInvalidation({ operation, selector, value, host: host || null, vhost: selected === "*" ? null : selected, ttl_seconds: operation === "ban" ? Number($("cache-ban-ttl").value || 3600) : null, confirm_wide: wide }); + } catch (error) { operationMessage("cache-invalidation-status", error.message, true); } finally { submit.disabled = false; } + }); + $("cache-panel-purge").addEventListener("click", async () => { + if (!authenticated || !(await appConfirm(t("confirmPurge"), { danger: true }))) return; const button = $("cache-panel-purge"), epoch = authEpoch, selected = scope(); button.disabled = true; + operationMessage("cache-toolbar-operation-status"); + try { + const path = selected === "*" ? "/api/cache/purge" : `/api/cache/purge?vhost=${encodeURIComponent(selected)}`; + const result = await api(path, { method: "POST", body: "{}" }); + if (epoch !== authEpoch) return; + operationMessage("cache-toolbar-operation-status", `${t("cachePurged")}: ${result.purged_entries}`); + offset = 0; generation++; await refresh(); + } catch (error) { if (epoch === authEpoch) operationMessage("cache-toolbar-operation-status", error.message, true); } finally { button.disabled = false; } + }); + setInterval(() => { if (!document.hidden && $("cache-live").checked) void refresh(); }, 3000); + window.rustProxyCachePanel = { + refresh, configure, reason, reset() { + generation++; offset = 0; resetLiveChart(); closeInvalidation(); closeBans(); + $("cache-domain-rows").replaceChildren(); $("cache-object-rows").replaceChildren(); $("cache-ban-rows").replaceChildren(); + $("cache-panel-state").textContent = ""; operationMessage("cache-invalidation-status"); operationMessage("cache-bans-status"); operationMessage("cache-toolbar-operation-status"); + } + }; + if (envelope) configure(); +})(); diff --git a/src/admin/web/demo.html b/src/admin/web/demo.html new file mode 100644 index 0000000..c275c4a --- /dev/null +++ b/src/admin/web/demo.html @@ -0,0 +1,37 @@ + + + + + + + RustProxy + + + + +
+

RustProxy is running.

+

This address has no application configured. Contact the administrator for access.

+
+ + + \ No newline at end of file diff --git a/src/admin/web/docker.js b/src/admin/web/docker.js new file mode 100644 index 0000000..c5fb27c --- /dev/null +++ b/src/admin/web/docker.js @@ -0,0 +1,360 @@ +"use strict"; +(() => { + let observations = [], pools = [], poolRoutes = [], poolIndex = new Map(), showHidden = false; + const LIVE_KEY = "rustproxy.docker.live-view"; + const HIDDEN_KEY = "rustproxy.docker.hidden-vhosts"; + const cfg = () => envelope?.config?.docker_agents || { agents: [] }; + const obs = id => observations.find(item => item.agent_id === id); + const firstListener = () => envelope?.config?.listeners?.endpoints?.find(item => item.enabled !== false)?.id || "http"; + const defaultHealth = () => ({ mode: "tcp", path: "/", host: null, interval_ms: 5000, timeout_ms: 2000, slow_response_ms: 1500, fall: 3, rise: 2, expected_status_min: 200, expected_status_max: 399 }); + const defaultPassiveHealth = () => ({ enabled: false, count_connect_errors: true, count_timeouts: true, count_io_errors: true, count_http_5xx: true, count_slow_responses: false, fall: null, rise: null }); + const defaultProxyTimeouts = () => ({ connect_timeout_seconds: 10, response_header_timeout_seconds: 120, read_timeout_seconds: 60, send_timeout_seconds: 60 }); + const liveEnabled = () => localStorage.getItem(LIVE_KEY) === "1"; + function loadHidden() { try { const value = JSON.parse(localStorage.getItem(HIDDEN_KEY) || "[]"); return new Set(Array.isArray(value) ? value.filter(item => typeof item === "string") : []); } catch { return new Set(); } } + let hiddenVhosts = loadHidden(); + const viewKey = (agentId, serviceKey) => `${agentId}|${serviceKey}`; + function saveHidden() { try { localStorage.setItem(HIDDEN_KEY, JSON.stringify([...hiddenVhosts].sort())); } catch { } } + function expandedHost(value, baseDomain) { const host = String(value || "").trim().replace(/\.$/, "").toLowerCase(); return host && host.includes(".") ? host : host ? `${host}.${baseDomain}` : ""; } + function publicListener(agent) { + const endpoints = envelope?.config?.listeners?.endpoints || [], wanted = new Set(agent?.listener_ids || []), candidates = endpoints.filter(item => item.enabled !== false && wanted.has(item.id)); + return candidates.find(item => item.tls) || candidates[0] || null; + } + function proxiedUrl(agent, hostname) { + const host = expandedHost(hostname, agent.base_domain); if (!host) return ""; + const listener = publicListener(agent), secure = !!listener?.tls, scheme = secure ? "https" : "http", address = String(listener?.address || ""); + const match = address.match(/:(\d+)$/), port = match ? Number(match[1]) : (secure ? 443 : 80), suffix = port === (secure ? 443 : 80) ? "" : `:${port}`; + return `${scheme}://${host}${suffix}/`; + } + function selectedListenerBindings(agent) { + const wanted = new Set(agent?.listener_ids || []); + return (envelope?.config?.listeners?.endpoints || []).filter(item => item.enabled !== false && wanted.has(item.id)).flatMap(item => { + const addresses = [item.binding?.address, ...(item.additional_addresses || [])].filter(Boolean); + return addresses.map(address => ({ address, tls: !!item.binding?.tls })); + }); + } + function canForceSsl(agent) { + const bindings = selectedListenerBindings(agent), tlsPorts = new Set(); let hasHttp = false; + for (const binding of bindings) { + if (!binding.tls) { hasHttp = true; continue; } + const match = String(binding.address || "").match(/:(\d+)$/); if (match) tlsPorts.add(Number(match[1])); + } + return hasHttp && tlsPorts.size > 0 && (tlsPorts.has(443) || tlsPorts.size === 1); + } + + function input(value = "", type = "text", cls = "") { const node = el("input"); node.type = type; node.value = value ?? ""; if (cls) node.className = cls; return node; } + function labeled(key, control, help = "") { const label = el("label", undefined, "docker-field"); label.append(el("span", t(key)), control); if (help) label.append(el("small", help, "fine")); return label; } + function checkboxField(key, control) { const field = el("div", undefined, "docker-field docker-checkbox-field"), label = el("label", undefined, "check vh-check"); label.append(control, el("span", t("enabled"))); field.append(el("span", t(key)), label); return field; } + function aliasEditor(initial = [], onChange = () => { }) { + const field = el("div", undefined, "docker-field docker-alias-field host-chip-field"), box = el("div", undefined, "host-chip-editor docker-alias-editor"), inputNode = input("", "text", "docker-alias-input"); + field.append(el("span", t("dockerAliases"))); inputNode.maxLength = 253; inputNode.placeholder = "alias1 / app.example.com"; inputNode.setAttribute("aria-label", t("dockerAliasAdd")); + const values = []; + const normalize = raw => { + const value = String(raw || "").trim().toLowerCase().replace(/\.$/, ""); if (!value) return null; + if (value.length > 253 || !/^[a-z0-9.-]+$/.test(value) || value.startsWith(".") || value.endsWith(".") || value.split(".").some(part => !part || part.length > 63 || part.startsWith("-") || part.endsWith("-"))) throw new Error(t("vhHostInvalid")); + return value; + }; + const sync = () => { + box.querySelectorAll(".host-chip").forEach(node => node.remove()); + for (const value of values) { + const chip = el("span", undefined, "host-chip"); chip.append(el("span", value)); const remove = el("button", "×"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${value}`); + remove.addEventListener("click", () => { values.splice(values.indexOf(value), 1); sync(); onChange(); }); chip.append(remove); box.insertBefore(chip, inputNode); + } + }; + const add = (raw, changed = true) => { const value = normalize(raw); if (!value || values.includes(value)) return; values.push(value); sync(); if (changed) onChange(); }; + const consume = raw => { for (const part of String(raw || "").split(/[\s,]+/).filter(Boolean)) add(part); inputNode.value = ""; }; + inputNode.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { consume(inputNode.value); } catch (error) { toast(error.message, true); } } }); + inputNode.addEventListener("blur", () => { if (!inputNode.value.trim()) return; try { consume(inputNode.value); } catch (error) { toast(error.message, true); } }); + inputNode.addEventListener("paste", event => { const value = event.clipboardData?.getData("text") || ""; if (!/[\s,]/.test(value.trim())) return; event.preventDefault(); try { consume(value); } catch (error) { toast(error.message, true); } }); + box.append(inputNode); field.append(box); field.title = t("dockerAliasesHelp"); inputNode.title = t("dockerAliasesHelp"); for (const value of initial) { try { add(value, false); } catch { } } + return { node: field, read() { if (inputNode.value.trim()) consume(inputNode.value); return [...values]; }, count: () => values.length, input: inputNode }; + } + function legacyHostname(assignment) { return assignment?.hostname || assignment?.subdomain || ""; } + function serviceBindings(service) { + if (!service) return []; + if (Array.isArray(service.bindings) && service.bindings.length) return service.bindings; + if (service.public_port) return [{ bind_ip: service.bind_ip || "", public_port: service.public_port }]; + return []; + } + function ipFamily(ip) { return String(ip || "").includes(":") ? 6 : 4; } + function bindingSupports(binding, ip) { + const bind = String(binding?.bind_ip || "").trim(); + if (!bind) return true; + if (bind === "0.0.0.0") return ipFamily(ip) === 4; + if (bind === "::") return ipFamily(ip) === 6; + return bind === ip; + } + function targetOptions(inventory, service, assignment, agent) { + const byIp = new Map(); + const add = (ip, label) => { if (ip && !byIp.has(ip)) byIp.set(ip, label || ip); }; + for (const address of inventory?.addresses || []) { + const details = [address.interface, address.scope, address.source].filter(Boolean).join(" · "); + if (!service || serviceBindings(service).some(binding => bindingSupports(binding, address.ip))) add(address.ip, details ? `${address.ip} (${details})` : address.ip); + } + for (const binding of serviceBindings(service)) { + const ip = String(binding.bind_ip || ""); + if (ip && ip !== "0.0.0.0" && ip !== "::" && !ip.startsWith("127.") && ip !== "::1") add(ip, `${ip} (${t("dockerBindingIp")})`); + } + if (agent?.default_target_ip) add(agent.default_target_ip, `${agent.default_target_ip} (${t("dockerAgentDefaultIp")})`); + if (assignment?.target_ip) add(assignment.target_ip, assignment.target_ip); + return [...byIp.entries()]; + } + function prettyFingerprint(value) { const compact = String(value || "").replace(/[^a-fA-F0-9]/g, "").toUpperCase(); return compact.length === 64 ? compact.match(/.{1,2}/g).join(":") : (value || "-"); } + function checkedAgo(value) { + if (!value) return t("dockerNeverChecked"); + const seconds = Math.max(0, Math.round((Date.now() - Number(value)) / 1000)); + if (seconds < 2) return t("dockerJustNow"); + if (seconds < 60) return t("dockerSecondsAgo").replace("{seconds}", seconds); + return t("dockerMinutesAgo").replace("{minutes}", Math.floor(seconds / 60)); + } + function securityInfo(agent, state) { + const https = String(agent?.endpoint || "").toLowerCase().startsWith("https://"); + const pinned = !!agent?.tls_fingerprint_sha256; + if (!https) return { key: "dockerTlsPlain", cls: "warn", detail: t("dockerTlsPlainHelp") }; + if (pinned) return { key: "dockerTlsPinned", cls: "live", detail: `${t("dockerTlsFingerprint")}: ${prettyFingerprint(agent.tls_fingerprint_sha256)}` }; + if (state?.ok) return { key: "dockerTlsPublicCa", cls: "live", detail: t("dockerTlsPublicCaHelp") }; + return { key: "dockerTlsPublicCa", cls: "", detail: t("dockerTlsPublicCaHelp") }; + } + function updateSecurityBadge(row) { + const endpoint = row.querySelector(".docker-agent-endpoint")?.value || "", fingerprint = row.querySelector(".docker-agent-fingerprint")?.value || ""; + const info = securityInfo({ endpoint, tls_fingerprint_sha256: fingerprint }, null), badge = row.querySelector(".docker-agent-security-badge"); + if (badge) { badge.textContent = t(info.key); badge.className = `pill docker-agent-security-badge ${info.cls}`.trim(); } + const hint = row.querySelector(".docker-agent-security-hint"); if (hint) hint.textContent = info.detail; + } + + function agentSettingsRow(agent = null) { + const value = agent || { id: "", name: "", enabled: true, endpoint: "https://127.0.0.1:9876", token: "", tls_fingerprint_sha256: null, base_domain: "docker1.lab.local", poll_interval_seconds: 30, certificate_id: null, acl_profile_id: null, default_target_ip: null, listener_ids: [firstListener()], assignments: [] }; + const row = el("div", undefined, "docker-agent-settings entity"); row.dataset.originalId = agent?.id || ""; row._assignments = structuredClone(value.assignments || []); + const head = el("div", undefined, "card-heading"), title = el("div"); title.append(el("span", t("dockerAgent"), "small-label"), el("h3", value.name || value.id || t("dockerAgentNew"))); + const enabled = input("", "checkbox", "docker-agent-enabled"); enabled.checked = value.enabled !== false; + const enabledLabel = el("label", undefined, "check"); enabledLabel.append(enabled, el("span", t("enabled"))); + const state = agent ? obs(agent.id) : null, stateBadge = el("span", state?.ok ? t("dockerConnected") : state ? t("dockerError") : t("dockerWaiting"), `pill docker-agent-status-badge ${state?.ok ? "live" : ""}`.trim()); + const remove = action(t("remove"), () => { row.remove(); setSettingsDirty("docker-agents-form"); }), actions = el("div", undefined, "entity-actions docker-agent-heading-actions"); actions.append(enabledLabel, stateBadge, remove); head.append(title, actions); + const id = input(value.id, "text", "docker-agent-id"); id.maxLength = 64; id.required = true; id.pattern = "[A-Za-z0-9_-]{1,64}"; if (agent) id.readOnly = true; + const name = input(value.name, "text", "docker-agent-name"); name.maxLength = 96; name.required = true; + const endpoint = input(value.endpoint, "url", "docker-agent-endpoint"); endpoint.required = true; endpoint.placeholder = "https://10.0.0.20:9876"; + const token = input(value.token, "password", "docker-agent-token"); token.minLength = 24; token.maxLength = 512; token.required = true; token.autocomplete = "new-password"; + const fingerprint = input(value.tls_fingerprint_sha256 || "", "text", "docker-agent-fingerprint mono"); fingerprint.maxLength = 128; fingerprint.placeholder = "SHA-256"; fingerprint.spellcheck = false; + const domain = input(value.base_domain, "text", "docker-agent-domain"); domain.required = true; domain.placeholder = "docker1.lab.local"; + const poll = input(value.poll_interval_seconds ?? 30, "number", "docker-agent-poll"); poll.min = 5; poll.max = 3600; poll.required = true; + const target = input(value.default_target_ip || value.target_ip_override || "", "text", "docker-agent-target"); target.placeholder = t("dockerTargetAuto"); + const certificate = el("select", undefined, "docker-agent-certificate"), defaultCertificate = el("option", t("vhCertificateDefault")); defaultCertificate.value = ""; certificate.append(defaultCertificate); + for (const cert of envelope?.config?.tls?.certificates || []) { const item = el("option", cert.id); item.value = cert.id; certificate.append(item); } certificate.value = value.certificate_id || ""; + const defaultAcl = el("select", undefined, "docker-agent-acl-profile"), noAcl = el("option", t("dockerDefaultAclNone")); noAcl.value = ""; defaultAcl.append(noAcl); + for (const profile of envelope?.config?.acl_profiles || []) { const item = el("option", profile.name || profile.id); item.value = profile.id; defaultAcl.append(item); } defaultAcl.value = value.acl_profile_id || ""; + const grid = el("div", undefined, "field-grid docker-agent-grid"); + grid.append(labeled("dockerAgentId", id), labeled("dockerAgentName", name), labeled("dockerAgentEndpoint", endpoint, t("dockerAgentEndpointHelp")), labeled("dockerAgentToken", token), labeled("dockerTlsFingerprint", fingerprint, t("dockerTlsFingerprintHelp")), labeled("dockerBaseDomain", domain), labeled("dockerPollInterval", poll, t("dockerPollIntervalHelp")), labeled("dockerDefaultTarget", target), labeled("dockerCertificate", certificate, t("dockerCertificateHelp")), labeled("dockerDefaultAcl", defaultAcl, t("dockerDefaultAclHelp"))); + const listenerField = el("div", undefined, "docker-agent-listener-field"); listenerField.append(el("span", t("dockerListeners"), "docker-agent-field-label")); + const listenerControl = window.rustProxyIntegrations.bindings(listenerField, value.listener_ids || [], !agent); listenerField.querySelector(".listener-choices")?.classList.add("docker-agent-listener-choices"); row._readListeners = listenerControl.read; grid.append(listenerField); + const security = el("div", undefined, "docker-agent-security"), secBadge = el("span", "", "pill docker-agent-security-badge"), secHint = el("span", "", "fine docker-agent-security-hint"); security.append(secBadge, secHint); + endpoint.addEventListener("input", () => updateSecurityBadge(row)); fingerprint.addEventListener("input", () => updateSecurityBadge(row)); row.append(head, security, grid); updateSecurityBadge(row); return row; + } + function renderSettings() { + const target = $("docker-agent-settings-list"); if (!target || !envelope || settingsDirty.has("docker-agents-form")) return; + target.replaceChildren(...(cfg().agents || []).map(agentSettingsRow)); if (!target.children.length) target.append(el("p", t("dockerNoAgents"), "empty")); + } + function collectSettings() { + const rows = [...$("docker-agent-settings-list").querySelectorAll(".docker-agent-settings")]; + return { + agents: rows.map(row => { + const listenerIds = row._readListeners?.() || []; if (!listenerIds.length) throw fieldError(row.querySelector(".docker-agent-listener-choices") || row, t("dockerListenersRequired")); + return { + id: row.querySelector(".docker-agent-id").value.trim(), name: row.querySelector(".docker-agent-name").value.trim(), enabled: row.querySelector(".docker-agent-enabled").checked, + endpoint: row.querySelector(".docker-agent-endpoint").value.trim().replace(/\/$/, ""), token: row.querySelector(".docker-agent-token").value, + tls_fingerprint_sha256: row.querySelector(".docker-agent-fingerprint").value.trim() || null, base_domain: row.querySelector(".docker-agent-domain").value.trim().toLowerCase(), + poll_interval_seconds: Number(row.querySelector(".docker-agent-poll").value), certificate_id: row.querySelector(".docker-agent-certificate").value || null, acl_profile_id: row.querySelector(".docker-agent-acl-profile").value || null, default_target_ip: row.querySelector(".docker-agent-target").value.trim() || null, + listener_ids: listenerIds, assignments: structuredClone(row._assignments || []) + }; + }) + }; + } + + function runtimeFor(agentId, serviceKey) { const route = poolRoutes.find(item => item.agent_id === agentId && item.service_key === serviceKey); return route ? poolIndex.get(route.pool_id) : null; } + function healthStatus(runtime, assignment, stale) { + if (stale) return { key: "dockerBackendMissing", cls: "warn" }; if (!assignment || assignment.enabled === false) return { key: "dockerBackendNotActive", cls: "" }; if (!runtime) return { key: "dockerBackendChecking", cls: "" }; + const member = runtime.members?.[0]; if (!member) return { key: "dockerBackendChecking", cls: "" }; if (runtime.health_mode === "off") return { key: "dockerBackendHealthOff", cls: "" }; + const map = { up: "dockerBackendUp", down: "dockerBackendDown", checking: "dockerBackendChecking", draining: "dockerBackendDraining", disabled: "dockerBackendNotActive" }; + return { key: map[member.status] || "dockerBackendChecking", cls: member.status === "up" ? "live" : member.status === "down" ? "danger" : "" }; + } + function runtimeSummary(runtime, assignment, stale) { + const wrap = el("div", undefined, "docker-runtime"), status = healthStatus(runtime, assignment, stale); wrap.append(el("span", t(status.key), `pill ${status.cls}`.trim())); + const member = runtime?.members?.[0]; if (!member) { wrap.append(el("span", t("dockerBackendNoMetrics"), "fine")); return wrap; } + const latency = Number(member.latency_ms || member.ewma_latency_ms || 0), metrics = [`${t("dockerHealthLatency")}: ${latency > 0 ? `${latency.toFixed(1)} ms` : "-"}`, `${t("dockerHealthResult")}: ${member.last_result || "-"}`, `${t("dockerHealthRequests")}: ${Number(member.requests || 0).toLocaleString()}`, `${t("dockerHealthErrors")}: ${Number(member.errors || 0).toLocaleString()}`, `${t("dockerHealthActive")}: ${Number(member.active || 0).toLocaleString()}`]; + wrap.append(el("span", metrics.join(" · "), "fine")); return wrap; + } + function selectHealthMode(value) { const control = el("select", undefined, "docker-health-mode"); for (const [name, key] of [["http", "dockerHealthHttp"], ["tcp", "dockerHealthTcp"], ["off", "dockerHealthOff"]]) { const option = el("option", t(key)); option.value = name; control.append(option); } control.value = value; return control; } + function numberInput(value, cls, min, max, optional = false) { const node = input(value ?? "", "number", cls); node.min = min; node.max = max; if (!optional) node.required = true; return node; } + function syncHealthFields(details) { const http = details.querySelector(".docker-health-mode")?.value === "http"; details.querySelectorAll("[data-http-only]").forEach(node => node.hidden = !http); } + function healthEditor(assignment) { + const health = { ...defaultHealth(), ...(assignment?.health_check || {}) }, details = el("details", undefined, "docker-health-editor"), summary = el("summary"); summary.append(el("span", t("dockerHealthCheck")), el("small", t("dockerHealthSummary"), "fine")); details.append(summary); + const grid = el("div", undefined, "field-grid docker-health-grid"), mode = selectHealthMode(health.mode || "tcp"), path = input(health.path || "/", "text", "docker-health-path"), host = input(health.host || "", "text", "docker-health-host"); path.maxLength = 1024; host.maxLength = 253; host.placeholder = t("dockerHealthHostAuto"); + const interval = numberInput(health.interval_ms, "docker-health-interval", 250, 300000), timeout = numberInput(health.timeout_ms, "docker-health-timeout", 50, 60000), slow = numberInput(health.slow_response_ms, "docker-health-slow", 0, 60000), fall = numberInput(health.fall, "docker-health-fall", 1, 20), rise = numberInput(health.rise, "docker-health-rise", 1, 20), statusMin = numberInput(health.expected_status_min, "docker-health-status-min", 100, 599), statusMax = numberInput(health.expected_status_max, "docker-health-status-max", 100, 599); + const httpFields = [labeled("dockerHealthPath", path), labeled("dockerHealthHost", host), labeled("dockerHealthStatusMin", statusMin), labeled("dockerHealthStatusMax", statusMax)]; httpFields.forEach(node => node.dataset.httpOnly = "1"); + grid.append(labeled("dockerHealthMode", mode), ...httpFields, labeled("dockerHealthInterval", interval), labeled("dockerHealthTimeout", timeout), labeled("dockerHealthSlow", slow), labeled("dockerHealthFall", fall), labeled("dockerHealthRise", rise)); details.append(grid); mode.addEventListener("change", () => syncHealthFields(details)); syncHealthFields(details); return details; + } + function passiveHealthEditor(assignment) { + const passive = { ...defaultPassiveHealth(), ...(assignment?.passive_health || {}) }, details = el("details", undefined, "docker-health-editor docker-passive-health-editor"), summary = el("summary"); + summary.append(el("span", t("dockerPassiveHealth")), el("small", t("dockerPassiveHealthSummary"), "fine")); details.append(summary); + const grid = el("div", undefined, "field-grid docker-health-grid"); + const enabled = input("", "checkbox", "docker-passive-enabled"); enabled.checked = passive.enabled === true; + const connect = input("", "checkbox", "docker-passive-connect"); connect.checked = passive.count_connect_errors !== false; + const timeouts = input("", "checkbox", "docker-passive-timeouts"); timeouts.checked = passive.count_timeouts !== false; + const io = input("", "checkbox", "docker-passive-io"); io.checked = passive.count_io_errors !== false; + const http5xx = input("", "checkbox", "docker-passive-5xx"); http5xx.checked = passive.count_http_5xx !== false; + const slow = input("", "checkbox", "docker-passive-slow"); slow.checked = passive.count_slow_responses === true; + const fall = numberInput(passive.fall, "docker-passive-fall", 1, 20, true), rise = numberInput(passive.rise, "docker-passive-rise", 1, 20, true); + const optional = [connect, timeouts, io, http5xx, slow, fall, rise]; + const optionalFields = [checkboxField("vhPassiveConnect", connect), checkboxField("vhPassiveTimeouts", timeouts), checkboxField("vhPassiveIo", io), checkboxField("vhPassive5xx", http5xx), checkboxField("vhPassiveSlow", slow), labeled("vhPassiveFall", fall), labeled("vhPassiveRise", rise)]; + const warning5xx = el("p", t("vhPassive5xxWarning"), "fine docker-passive-5xx-warning"); + const warningSlow = el("p", t("vhPassiveSlowWarning"), "fine docker-passive-slow-warning"); + grid.append(checkboxField("vhPassiveEnabled", enabled), ...optionalFields, warning5xx, warningSlow); details.append(grid); + const sync = () => { + optional.forEach(control => { control.disabled = !enabled.checked; }); + optionalFields.forEach(field => { field.hidden = !enabled.checked; }); + warning5xx.hidden = !enabled.checked || !http5xx.checked; + warningSlow.hidden = !enabled.checked || !slow.checked; + }; + enabled.addEventListener("change", sync); http5xx.addEventListener("change", sync); slow.addEventListener("change", sync); sync(); return details; + } + function collectPassiveHealth(row) { + const optional = cls => { const value = row.querySelector(cls)?.value?.trim() || ""; return value === "" ? null : Number(value); }; + return { + enabled: row.querySelector(".docker-passive-enabled")?.checked === true, + count_connect_errors: row.querySelector(".docker-passive-connect")?.checked !== false, + count_timeouts: row.querySelector(".docker-passive-timeouts")?.checked !== false, + count_io_errors: row.querySelector(".docker-passive-io")?.checked !== false, + count_http_5xx: row.querySelector(".docker-passive-5xx")?.checked !== false, + count_slow_responses: row.querySelector(".docker-passive-slow")?.checked === true, + fall: optional(".docker-passive-fall"), + rise: optional(".docker-passive-rise") + }; + } + function aclEditor(assignment, agent) { + const details = el("details", undefined, "docker-acl-editor"), summary = el("summary"); summary.append(el("span", t("dockerAccessControl")), el("small", t("dockerAclSummary"), "fine")); details.append(summary); + const grid = el("div", undefined, "field-grid docker-acl-grid"), profile = el("select", undefined, "docker-acl-profile"); + const inheritedProfile = agent?.acl_profile_id ? ((envelope?.config?.acl_profiles || []).find(item => item.id === agent.acl_profile_id)?.name || agent.acl_profile_id) : ""; + const none = el("option", inheritedProfile ? `${t("dockerAclProfileInherit")}: ${inheritedProfile}` : t("dockerAclProfileNone")); none.value = ""; profile.append(none); + for (const item of envelope?.config?.acl_profiles || []) { const option = el("option", item.name || item.id); option.value = item.id; profile.append(option); } profile.value = assignment?.acl_profile_id || ""; + const inline = input("", "checkbox", "docker-acl-inline"); inline.checked = !!assignment?.acl; const inlineLabel = checkboxField("dockerAclInline", inline); + const defaultAction = el("select", undefined, "docker-acl-default"); for (const value of ["deny", "allow"]) { const option = el("option", t(value)); option.value = value; defaultAction.append(option); } defaultAction.value = assignment?.acl?.default || "deny"; + const rules = document.createElement("textarea"); rules.className = "docker-acl-rules"; rules.rows = 4; rules.maxLength = 32768; rules.spellcheck = false; rules.value = rulesText(assignment?.acl); rules.placeholder = "allow 10.0.0.0/8\ndeny 0.0.0.0/0"; + const sync = () => { defaultAction.disabled = !inline.checked; rules.disabled = !inline.checked; }; inline.addEventListener("change", sync); sync(); + grid.append(labeled("dockerAclProfile", profile), inlineLabel, labeled("dockerAclDefault", defaultAction), labeled("dockerAclRules", rules, t("dockerAclHelp"))); details.append(grid); return details; + } + function timeoutEditor(assignment) { + const value = { ...defaultProxyTimeouts(), ...(assignment?.proxy_timeouts || {}) }, details = el("details", undefined, "docker-timeout-editor"), summary = el("summary"); summary.append(el("span", t("dockerProxyTimeouts")), el("small", t("dockerProxyTimeoutsHelp"), "fine")); details.append(summary); + const grid = el("div", undefined, "field-grid docker-timeout-grid"); + grid.append(labeled("vhProxyConnectTimeout", numberInput(value.connect_timeout_seconds ?? 10, "docker-proxy-connect", 1, 300)), labeled("vhProxyHeaderTimeout", numberInput(value.response_header_timeout_seconds ?? 120, "docker-proxy-header", 1, 86400)), labeled("vhProxyReadTimeout", numberInput(value.read_timeout_seconds ?? 60, "docker-proxy-read", 1, 86400)), labeled("vhProxySendTimeout", numberInput(value.send_timeout_seconds ?? 60, "docker-proxy-send", 1, 86400))); + details.append(grid); return details; + } + function collectHealth(row) { const n = cls => Number(row.querySelector(cls).value); return { mode: row.querySelector(".docker-health-mode").value, path: row.querySelector(".docker-health-path").value.trim() || "/", host: row.querySelector(".docker-health-host").value.trim() || null, interval_ms: n(".docker-health-interval"), timeout_ms: n(".docker-health-timeout"), slow_response_ms: n(".docker-health-slow"), fall: n(".docker-health-fall"), rise: n(".docker-health-rise"), expected_status_min: n(".docker-health-status-min"), expected_status_max: n(".docker-health-status-max") }; } + function collectAssignment(row) { + const inline = row.querySelector(".docker-acl-inline")?.checked; + return { + service_key: row.dataset.key, hostname: row.querySelector(".docker-hostname").value.trim().toLowerCase(), aliases: row._readAliases?.() || [], target_ip: row.querySelector(".docker-target-ip").value || null, + enabled: true, scheme: row.querySelector(".docker-scheme").value, preserve_host: row.querySelector(".docker-preserve-host").checked, force_ssl: row.querySelector(".docker-force-ssl").checked, websockets: row.querySelector(".docker-websockets").checked, cache_enabled: row.querySelector(".docker-cache-enabled").checked, transform_enabled: row.querySelector(".docker-transform-enabled").checked, + acl_profile_id: row.querySelector(".docker-acl-profile")?.value || null, acl: inline ? { default: row.querySelector(".docker-acl-default").value, rules: parseRules(row.querySelector(".docker-acl-rules").value) } : null, + proxy_timeouts: { connect_timeout_seconds: Number(row.querySelector(".docker-proxy-connect").value), response_header_timeout_seconds: Number(row.querySelector(".docker-proxy-header").value), read_timeout_seconds: Number(row.querySelector(".docker-proxy-read").value), send_timeout_seconds: Number(row.querySelector(".docker-proxy-send").value) }, + health_check: collectHealth(row), passive_health: collectPassiveHealth(row) + }; + } + async function saveAssignment(agentId, row, publish) { + const current = structuredClone(cfg()), agent = current.agents.find(item => item.id === agentId); if (!agent) return false; + const index = (agent.assignments || []).findIndex(item => item.service_key === row.dataset.key); + if (publish) { const assignment = collectAssignment(row); if (!assignment.hostname) throw new Error(t("dockerHostnameRequired")); if (index >= 0) agent.assignments[index] = assignment; else agent.assignments.push(assignment); } + else if (index >= 0) agent.assignments.splice(index, 1); + const saved = await saveSection("docker-agents", current); if (saved) row.classList.remove("is-dirty"); return saved; + } + + function assignmentRow(agent, inventory, service, assignment, stale = false) { + const key = assignment?.service_key || service.key, row = el("article", undefined, "docker-service-row"); row.dataset.key = key; row.dataset.viewKey = viewKey(agent.id, key); + const top = el("div", undefined, "docker-service-head"), publish = input("", "checkbox", "docker-publish"); publish.checked = !!assignment && assignment.enabled !== false; + const publishLabel = el("label", undefined, "check docker-publish-field"); publishLabel.append(publish, el("span", t("dockerPublish"))); + const meta = el("div", undefined, "docker-service-meta"), technical = service?.technical_name || assignment?.service_key || "-", bindings = serviceBindings(service).map(binding => `${binding.bind_ip || "*"}:${binding.public_port}`).join(", "); + meta.append(el("b", technical), el("span", stale ? t("dockerServiceMissing") : `${service.container_name} · ${service.image}`), el("span", stale ? assignment.service_key : `${service.private_port}/tcp → ${bindings || "-"}`)); + const runtime = runtimeFor(agent.id, key), visibility = el("button", "", "quiet docker-view-toggle"); visibility.type = "button"; + const syncVisibility = () => { visibility.textContent = t(hiddenVhosts.has(row.dataset.viewKey) ? "dockerUnhideVhost" : "dockerHideVhost"); }; syncVisibility(); visibility.addEventListener("click", () => { const key = row.dataset.viewKey; if (hiddenVhosts.has(key)) hiddenVhosts.delete(key); else hiddenVhosts.add(key); saveHidden(); syncVisibility(); applyViewFilters(); }); + const openApp = el("a", t("dockerOpenApp"), "outline docker-open-app"); openApp.target = "_blank"; openApp.rel = "noopener noreferrer"; + const syncOpenApp = hostname => { const url = proxiedUrl(agent, hostname); openApp.href = url || "#"; openApp.hidden = !publish.checked || !url || stale; openApp.title = url || ""; }; + syncOpenApp(legacyHostname(assignment)); + const headActions = el("div", undefined, "docker-service-head-actions"); headActions.append(runtimeSummary(runtime, assignment, stale), openApp, visibility); top.append(publishLabel, meta, headActions); row.append(top); + + const editor = el("details", undefined, "docker-service-editor"), editorSummary = el("summary"), editorTitle = el("span", t("dockerConfigureService")), editorHint = el("small", "", "fine docker-config-hint"); editorSummary.append(editorTitle, editorHint); editor.append(editorSummary); + const route = el("div", undefined, "docker-route-grid"), host = input(legacyHostname(assignment) || service?.technical_name || "", "text", "docker-hostname"); host.maxLength = 253; host.placeholder = service?.technical_name || "app1"; + let aliases; const aliasChanged = () => { updateHint?.(); row.dispatchEvent(new Event("input", { bubbles: true })); }; aliases = aliasEditor(assignment?.aliases || [], aliasChanged); row._readAliases = aliases.read; + const target = el("select", undefined, "docker-target-ip"), auto = el("option", agent.default_target_ip ? `${t("dockerBackendIpAuto")} (${agent.default_target_ip})` : t("dockerBackendIpAuto")); auto.value = ""; target.append(auto); for (const [ip, label] of targetOptions(inventory, service, assignment, agent)) { const option = el("option", label); option.value = ip; target.append(option); } target.value = assignment?.target_ip || ""; + const scheme = el("select", undefined, "docker-scheme"); for (const name of ["http", "https"]) { const option = el("option", name.toUpperCase()); option.value = name; scheme.append(option); } scheme.value = assignment?.scheme || "http"; + const preserve = input("", "checkbox", "docker-preserve-host"); preserve.checked = assignment?.preserve_host !== false; const preserveLabel = checkboxField("dockerPreserveHost", preserve); preserveLabel.classList.add("docker-preserve-field"); + const forceSsl = input("", "checkbox", "docker-force-ssl"), forceCapable = canForceSsl(agent); forceSsl.checked = assignment ? assignment.force_ssl === true : forceCapable; forceSsl.disabled = !forceCapable; const forceSslField = checkboxField("dockerForceSsl", forceSsl); if (!forceCapable) forceSslField.append(el("small", t("dockerForceSslUnavailable"), "fine")); + const websockets = input("", "checkbox", "docker-websockets"); websockets.checked = assignment?.websockets !== false; const websocketField = checkboxField("dockerWebsockets", websockets); + const cacheEnabled = input("", "checkbox", "docker-cache-enabled"); cacheEnabled.checked = assignment?.cache_enabled === true; const cacheField = checkboxField("dockerCache", cacheEnabled); + const transformEnabled = input("", "checkbox", "docker-transform-enabled"); transformEnabled.checked = assignment?.transform_enabled === true; const transformField = checkboxField("dockerTransform", transformEnabled); + const switches = el("div", undefined, "docker-switch-row"); switches.append(preserveLabel, forceSslField, websocketField, cacheField, transformField); + route.append(labeled("dockerHostname", host, t("dockerHostnameHelp").replace("{domain}", agent.base_domain)), aliases.node, labeled("dockerBackendIp", target), labeled("dockerBackendScheme", scheme), switches); + editor.append(route, aclEditor(assignment, agent), timeoutEditor(assignment), healthEditor(assignment), passiveHealthEditor(assignment)); + const actions = el("div", undefined, "docker-service-actions"), apply = el("button", t("dockerApplyService"), "outline docker-apply"); apply.type = "button"; apply.disabled = !publish.checked; apply.addEventListener("click", async () => { apply.disabled = true; try { await saveAssignment(agent.id, row, true); } catch (error) { toast(error.message, true); } finally { if (row.isConnected) apply.disabled = !publish.checked; } }); actions.append(apply); editor.append(actions); row.append(editor); + function updateHint() { const targetLabel = target.selectedOptions?.[0]?.textContent || t("dockerBackendIpAuto"), aliasCount = aliases.count(); editorHint.textContent = `${host.value.trim() || technical} → ${scheme.value.toUpperCase()} · ${targetLabel}${aliasCount ? ` · +${aliasCount} ${t("dockerAliasesShort")}` : ""}`; } updateHint(); + for (const control of [host, aliases.input, target, scheme]) { control.addEventListener("input", updateHint); control.addEventListener("change", updateHint); } + host.addEventListener("input", () => syncOpenApp(host.value)); + row.addEventListener("input", event => { if (!event.target.classList.contains("docker-publish")) row.classList.add("is-dirty"); }); row.addEventListener("change", event => { if (!event.target.classList.contains("docker-publish")) row.classList.add("is-dirty"); }); + publish.addEventListener("change", async () => { + const wanted = publish.checked; publish.disabled = true; apply.disabled = true; + try { const saved = await saveAssignment(agent.id, row, wanted); if (!saved) publish.checked = !wanted; } catch (error) { publish.checked = !wanted; toast(error.message, true); } + finally { if (row.isConnected) { publish.disabled = false; apply.disabled = !publish.checked; row.classList.toggle("is-unpublished", !publish.checked); syncOpenApp(host.value); } } + }); + row.classList.toggle("is-unpublished", !publish.checked); return row; + } + + function addressSummary(inventory) { const addresses = inventory.addresses || []; if (!addresses.length && inventory.public_ip) return inventory.public_ip; return addresses.map(address => { const details = [address.interface, address.scope, address.source].filter(Boolean).join("/"); return details ? `${address.ip} (${details})` : address.ip; }).join(" · "); } + function renderDiscovery() { + const target = $("docker-discovery-list"); if (!target || !envelope) return; target.replaceChildren(); const agents = cfg().agents || []; + if (!agents.length) { target.append(el("p", t("dockerNoAgentsConfigured"), "card empty")); applyViewFilters(); return; } + for (const agent of agents) { + const state = obs(agent.id), box = el("article", undefined, "card docker-agent-card"); box.dataset.agentId = agent.id; box.dataset.searchText = `${agent.id} ${agent.name || ""} ${agent.base_domain || ""}`.toLowerCase(); const head = el("div", undefined, "card-heading"), left = el("div"); left.append(el("span", agent.id, "small-label"), el("h2", agent.name)); + const badges = el("div", undefined, "entity-actions"), connection = el("span", state?.ok ? t("dockerConnected") : state ? t("dockerError") : t("dockerWaiting"), `pill ${state?.ok ? "live" : ""}`), sec = securityInfo(agent, state); badges.append(el("span", t(sec.key), `pill ${sec.cls}`.trim()), connection); head.append(left, badges); box.append(head); + if (!agent.enabled) { box.append(el("p", t("dockerAgentDisabled"), "fine")); target.append(box); continue; } + if (!state?.ok || !state.inventory) { const error = el("div", undefined, "docker-agent-error"); error.append(el("b", t("dockerAgentConnectionFailed")), el("p", state?.error || t("dockerNoObservation"), "fine"), el("small", sec.detail, "fine")); box.append(error); target.append(box); continue; } + const inv = state.inventory, defaultIp = agent.default_target_ip || agent.target_ip_override || t("dockerTargetAuto"), serviceCount = (inv.services || []).length, overview = el("div", undefined, "docker-agent-overview"); box.dataset.searchText += ` ${inv.hostname || ""}`.toLowerCase(); + overview.append(el("span", `${t("dockerHost")}: ${inv.hostname}`), el("span", `${t("dockerServices")}: ${serviceCount}`), el("span", `${t("dockerLastChecked")}: ${checkedAgo(state.checked_at_unix_ms)}`), el("span", `*.${agent.base_domain}`)); box.append(overview); + const agentDetails = el("details", undefined, "docker-agent-details"), detailsSummary = el("summary", t("dockerAgentDetails")), summary = el("div", undefined, "docker-agent-summary"); + const defaultAclName = agent.acl_profile_id ? ((envelope?.config?.acl_profiles || []).find(profile => profile.id === agent.acl_profile_id)?.name || agent.acl_profile_id) : t("dockerDefaultAclNone"); + summary.append(el("span", `${t("dockerAgentVersion")}: ${inv.agent_version}`), el("span", `${t("dockerAgentPollEvery")}: ${agent.poll_interval_seconds ?? 30}s`), el("span", `${t("dockerCertificate")}: ${agent.certificate_id || t("vhCertificateDefault")}`), el("span", `${t("dockerDefaultAcl")}: ${defaultAclName}`), el("span", `${t("dockerDetectedIps")}: ${addressSummary(inv) || t("unavailable")}`), el("span", `${t("dockerDefaultTarget")}: ${defaultIp}`), el("span", `${t("dockerBaseDomain")}: *.${agent.base_domain}`)); if (inv.certificate_sha256) summary.append(el("span", `${t("dockerTlsFingerprint")}: ${prettyFingerprint(inv.certificate_sha256)}`, "mono")); agentDetails.append(detailsSummary, summary); box.append(agentDetails); + const rows = el("div", undefined, "docker-service-list"), assignments = new Map((agent.assignments || []).map(item => [item.service_key, item])); for (const service of inv.services || []) rows.append(assignmentRow(agent, inv, service, assignments.get(service.key), false)); for (const item of agent.assignments || []) if (!(inv.services || []).some(service => service.key === item.service_key)) rows.append(assignmentRow(agent, inv, null, item, true)); if (!rows.children.length) rows.append(el("p", t("dockerNoPublishedPorts"), "empty")); box.append(rows); target.append(box); + } + applyViewFilters(); + } + function applyViewFilters() { + const query = ($("docker-search")?.value || "").trim().toLowerCase(); + for (const card of document.querySelectorAll("#docker-discovery-list .docker-agent-card")) { + const agentMatch = !!query && (card.dataset.searchText || "").includes(query); + const rows = [...card.querySelectorAll(".docker-service-row")]; let visible = 0; + for (const row of rows) { + const viewHidden = hiddenVhosts.has(row.dataset.viewKey); + const values = [...row.querySelectorAll("input, select")].map(control => control.value || "").join(" "); + const matches = !query || agentMatch || `${row.textContent} ${values}`.toLowerCase().includes(query); + row.classList.toggle("is-view-hidden", viewHidden); + row.hidden = (!showHidden && viewHidden) || !matches; + if (!row.hidden) visible += 1; + } + card.hidden = rows.length ? visible === 0 : (!!query && !(card.dataset.searchText || "").includes(query)); + } + const toggle = $("docker-show-hidden"); if (toggle) { const key = showHidden ? "dockerHideHidden" : "dockerShowHidden"; toggle.dataset.i18n = key; toggle.textContent = t(key); } + } + function syncLiveSwitch() { const control = $("docker-live-refresh"); if (control) control.checked = liveEnabled(); } + function canLiveRender() { const target = $("docker-discovery-list"); return !target?.querySelector(".docker-service-row.is-dirty, .docker-service-editor[open], .docker-agent-details[open]") && !document.activeElement?.closest?.(".docker-service-row"); } + async function refresh(force = false) { + if (!authenticated) return; const button = $("docker-refresh"); if (button && force) button.disabled = true; + try { const data = await api(force ? "/api/docker/refresh" : "/api/docker", force ? { method: "POST", body: "{}" } : {}); observations = data.observations || []; pools = data.pools || []; poolRoutes = data.pool_routes || []; poolIndex = new Map(pools.map(pool => [pool.id, pool])); renderSettings(); if (force || canLiveRender()) renderDiscovery(); } + catch (error) { if (force) toast(error.message, true); } finally { if (button) button.disabled = false; } + } + + $("docker-refresh")?.addEventListener("click", () => void refresh(true)); + $("docker-live-refresh")?.addEventListener("change", event => { localStorage.setItem(LIVE_KEY, event.target.checked ? "1" : "0"); if (event.target.checked) void refresh(false); }); + $("docker-settings-open")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("docker-agents"); }); + $("docker-search")?.addEventListener("input", applyViewFilters); + $("docker-show-hidden")?.addEventListener("click", () => { showHidden = !showHidden; applyViewFilters(); }); + $("docker-agent-add")?.addEventListener("click", () => { const target = $("docker-agent-settings-list"); target.querySelector(".empty")?.remove(); target.append(agentSettingsRow()); setSettingsDirty("docker-agents-form"); }); + $("docker-agents-form")?.addEventListener("input", () => setSettingsDirty("docker-agents-form")); $("docker-agents-form")?.addEventListener("change", () => setSettingsDirty("docker-agents-form")); + $("docker-agents-form")?.addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { if (await saveSection("docker-agents", collectSettings())) { setSettingsDirty("docker-agents-form", false); await refresh(true); } }); } catch (error) { toast(error.message, true); } }); + setInterval(() => { if (authenticated && liveEnabled() && typeof currentPanel !== "undefined" && currentPanel === "docker-vhosts" && canLiveRender()) void refresh(false); }, 5000); + window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); }, refresh, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); showHidden = false; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } }; +})(); diff --git a/src/admin/web/docs.css b/src/admin/web/docs.css new file mode 100644 index 0000000..6413d8a --- /dev/null +++ b/src/admin/web/docs.css @@ -0,0 +1,178 @@ +/* A deliberately self-contained documentation layout. */ +* { + box-sizing: border-box +} + +body { + margin: 0; + background: #f5f6f7; + font: 14px system-ui, sans-serif; + color: #222b36 +} + +.docs-bar { + display: flex; + align-items: center; + gap: 22px; + padding: 14px 22px; + background: #101923; + color: #fff; + border-bottom: 3px solid #d97743 +} + +.docs-name { + font-weight: 750; + font-size: 20px +} + +.docs-bar h1 { + font-size: 16px; + margin: 0; + flex: 1 +} + +.docs-bar a { + color: inherit; + text-decoration: none +} + +.docs-bar button { + background: transparent; + color: inherit; + border: 1px solid #65717e; + padding: 7px 12px; + cursor: pointer +} + +main { + max-width: 1400px; + margin: auto; + padding: 16px 22px +} + +.docs-session { + background: #fff; + border: 1px solid #d7dde3; + padding: 12px 16px +} + +.docs-session p { + margin: 5px 0 10px +} + +.docs-session label { + display: inline-flex; + align-items: center; + gap: 7px +} + +#docs-error:empty { + display: none +} + +#docs-error { + padding: 12px; + border: 1px solid #9a4932 +} + +.swagger-ui .auth-wrapper { + display: none +} + +.swagger-ui .info { + margin: 22px 0 +} + +.swagger-ui .opblock, +.swagger-ui .btn, +.swagger-ui select, +.swagger-ui input, +.swagger-ui textarea, +.swagger-ui .dialog-ux .modal-ux { + border-radius: 0 +} + +.swagger-ui .opblock .opblock-summary { + padding: 5px +} + +.swagger-ui .info .title { + font-size: 24px +} + +.swagger-ui .wrapper { + padding: 0 +} + +.swagger-ui .opblock-tag { + font-size: 19px +} + +.swagger-ui .response-col_description { + min-width: 0 +} + +@media(max-width:680px) { + .docs-bar { + flex-wrap: wrap; + gap: 12px; + padding: 12px + } + + .docs-bar h1 { + flex-basis: 60% + } + + main { + padding: 12px + } + + .swagger-ui .opblock .opblock-summary-path { + font-size: 12px + } + + .swagger-ui .opblock-summary-description { + display: none + } +} + +.docs-confirm { + width: min(480px, calc(100vw - 28px)); + border: 1px solid #65717e; + background: #fff; + color: #222b36; + padding: 18px +} + +.docs-confirm::backdrop { + background: rgb(16 25 35 / .62) +} + +.docs-confirm h2 { + margin: 0 0 10px; + font-size: 18px +} + +.docs-confirm p { + margin: 0; + white-space: pre-line +} + +.docs-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 18px +} + +.docs-confirm-actions button { + border: 1px solid #65717e; + background: transparent; + padding: 8px 12px; + cursor: pointer +} + +.docs-confirm-actions .docs-confirm-primary { + background: #101923; + color: #fff +} \ No newline at end of file diff --git a/src/admin/web/docs.html b/src/admin/web/docs.html new file mode 100644 index 0000000..1a888fd --- /dev/null +++ b/src/admin/web/docs.html @@ -0,0 +1,36 @@ + + + + + + + RustProxy / API + + + + + + + + +
RustProxy +

API documentation

+ Back to panel +
+
+
+

+

Uses your administrator session. Writes require CSRF protection.

+ +

Read-only execution by default. SSE streams and login/setup are not executed + here.

+
+ +
+

Swagger UI licenses

+
+ + + \ No newline at end of file diff --git a/src/admin/web/docs.js b/src/admin/web/docs.js new file mode 100644 index 0000000..86e44fe --- /dev/null +++ b/src/admin/web/docs.js @@ -0,0 +1,75 @@ +"use strict"; +// Fixed same-origin schema + browser session; never query-configurable/CDN-based. +(() => { + const element = id => document.getElementById(id); let session = null, spec = null, ui = null, translations = {}, fallback = {}, epoch = 0; + let channel = null; try { channel = new BroadcastChannel("rustproxy-admin-session"); } catch { } + const t = key => translations[key] ?? fallback[key] ?? key; + function docsConfirm(message) { + return new Promise(resolve => { + const dialog = document.createElement("dialog"); dialog.className = "docs-confirm"; + const title = document.createElement("h2"); title.textContent = t("confirmTitle"); + const body = document.createElement("p"); body.textContent = message; + const actions = document.createElement("div"); actions.className = "docs-confirm-actions"; + const cancel = document.createElement("button"); cancel.type = "button"; cancel.textContent = t("cancelAction"); + const accept = document.createElement("button"); accept.type = "button"; accept.className = "docs-confirm-primary"; accept.textContent = t("continueAction"); + actions.append(cancel, accept); dialog.append(title, body, actions); document.body.append(dialog); + let settled = false; const finish = value => { if (settled) return; settled = true; if (dialog.open) dialog.close(); dialog.remove(); resolve(value); }; + cancel.addEventListener("click", () => finish(false)); accept.addEventListener("click", () => finish(true)); dialog.addEventListener("cancel", event => { event.preventDefault(); finish(false); }); dialog.addEventListener("close", () => finish(false)); + dialog.showModal(); cancel.focus(); + }); + } + function clear() { epoch++; session = null; spec = null; ui = null; element("docs-writes").checked = false; element("swagger-ui").replaceChildren(); element("docs-session").textContent = ""; } + function expired() { clear(); element("docs-error").replaceChildren(); const link = document.createElement("a"); link.href = "/?next=docs"; link.textContent = t("docsExpired"); element("docs-error").append(link); } + channel?.addEventListener("message", event => { if (event.data === "locked") expired(); }); + async function get(path) { const response = await fetch(path, { credentials: "same-origin", cache: "no-store", redirect: "error" }); if (!response.ok) { if (response.status === 401) expired(); throw new Error(t("docsError")); } return response.json(); } + async function checkSession() { const mine = epoch; const info = await get("/api/auth/session"); if (mine !== epoch || !session || info.csrf_token !== session.csrf_token || info.username !== session.username) { expired(); throw new Error(t("docsExpired")); } return info; } + async function requestInterceptor(request) { + if (!session || !spec) throw new Error(t("docsExpired")); + const url = new URL(request.url, location.origin), method = (request.method || "GET").toLowerCase(); + if (url.origin !== location.origin || url.username || url.password || !Object.hasOwn(spec.paths, url.pathname) || !Object.hasOwn(spec.paths[url.pathname], method)) throw new Error("Only documented same-origin operations are allowed."); + if (["/api/auth/login", "/api/auth/setup", "/api/events"].includes(url.pathname)) throw new Error(t("docsReadOnly")); + await checkSession(); + request.credentials = "same-origin"; request.headers = { ...request.headers, "X-RustProxy-Request": "1" }; request.cache = "no-store"; request.redirect = "error"; + if (!["get", "head"].includes(method)) { + if (!element("docs-writes").checked || !(await docsConfirm(t("docsWriteConfirm")))) throw new Error(t("docsReadOnly")); + request.headers["X-CSRF-Token"] = session.csrf_token; + } + return request; + } + function render() { + if (!session || !spec) return; + // Disable the online validator and URL/config overrides. No persisted auth or + // externally supplied schema can send cookies or CSRF tokens elsewhere. + // These mandatory wire headers are supplied by the browser/interceptor, not + // typed into Swagger fields. Keep the downloadable specification accurate. + const displaySpec = structuredClone(spec); + for (const path of Object.values(displaySpec.paths)) for (const [method, operation] of Object.entries(path)) { + if (["get", "post", "put", "delete", "patch", "head", "options"].includes(method) && operation.parameters) + operation.parameters = operation.parameters.filter(p => !(p.in === "header" && ["origin", "x-rustproxy-request"].includes(p.name.toLowerCase()))); + } + ui = SwaggerUIBundle({ + spec: displaySpec, dom_id: "#swagger-ui", layout: "BaseLayout", presets: [SwaggerUIBundle.presets.apis], + validatorUrl: null, queryConfigEnabled: false, persistAuthorization: false, withCredentials: true, + supportedSubmitMethods: element("docs-writes").checked ? ["get", "post", "put"] : ["get"], + docExpansion: "none", filter: true, displayRequestDuration: true, tryItOutEnabled: false, showMutatedRequest: false, + requestInterceptor, responseInterceptor: response => { if (response.status === 401) expired(); return response; } + }); + } + element("docs-writes").addEventListener("change", render); + element("docs-logout").addEventListener("click", async () => { if (!session) return; try { await checkSession(); const response = await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin", cache: "no-store", redirect: "error", headers: { "X-RustProxy-Request": "1", "X-CSRF-Token": session.csrf_token } }); if (!response.ok && response.status !== 401) throw new Error(t("docsError")); channel?.postMessage("locked"); expired(); } catch (error) { element("docs-error").textContent = error.message; } }); + async function boot() { + const mine = ++epoch; try { + const info = await get("/api/auth/session"); if (mine !== epoch) return; + const [document, bundle] = await Promise.all([get("/docs/openapi.json"), get("/assets/langs.json")]); if (mine !== epoch) return; + const packs = bundle.languages || []; fallback = packs.find(p => p.id === bundle.default)?.translations || {}; let selected = null; try { selected = localStorage.getItem("rp-language"); } catch { } + const pack = packs.find(p => p.id === selected) || packs.find(p => p.id === bundle.default); translations = pack?.translations || {}; + window.document.documentElement.lang = pack?.meta.locale || "und"; window.document.documentElement.dir = pack?.meta.direction || "ltr"; + window.document.querySelectorAll("[data-i18n]").forEach(n => n.textContent = t(n.dataset.i18n)); + session = info; spec = document; element("docs-session").textContent = `${t("docsAccount")}: ${session.username}`; element("docs-error").textContent = ""; render(); + } catch (error) { if (mine === epoch) { clear(); element("docs-error").textContent = error.message; } } + } + document.addEventListener("visibilitychange", () => { if (!document.hidden && session) void checkSession().catch(() => expired()); }); + window.addEventListener("pageshow", event => { if (event.persisted) { clear(); void boot(); } }); + setInterval(() => { if (!document.hidden && session) void checkSession().catch(() => expired()); }, 30000); + void boot(); +})(); diff --git a/src/admin/web/error_401.html b/src/admin/web/error_401.html new file mode 100644 index 0000000..e26b9e0 --- /dev/null +++ b/src/admin/web/error_401.html @@ -0,0 +1,37 @@ + + + + + + + 401 - Authentication required - RustProxy + + + + +
+

401 - Authentication required

+

Authentication is required to access this resource.

+
+ + + \ No newline at end of file diff --git a/src/admin/web/error_403.html b/src/admin/web/error_403.html new file mode 100644 index 0000000..400ed48 --- /dev/null +++ b/src/admin/web/error_403.html @@ -0,0 +1,37 @@ + + + + + + + 403 - Access denied - RustProxy + + + + +
+

403 - Access denied

+

This request is not permitted by the site's access policy.

+
+ + + \ No newline at end of file diff --git a/src/admin/web/error_404.html b/src/admin/web/error_404.html new file mode 100644 index 0000000..cf0ddb2 --- /dev/null +++ b/src/admin/web/error_404.html @@ -0,0 +1,37 @@ + + + + + + + 404 - Not found - RustProxy + + + + +
+

404 - Not found

+

The requested resource was not found.

+
+ + + diff --git a/src/admin/web/error_429.html b/src/admin/web/error_429.html new file mode 100644 index 0000000..899307f --- /dev/null +++ b/src/admin/web/error_429.html @@ -0,0 +1,37 @@ + + + + + + + 429 - Too many requests - RustProxy + + + + +
+

429 - Too many requests

+

The request rate limit was exceeded. Please try again after the indicated delay.

+
+ + + \ No newline at end of file diff --git a/src/admin/web/error_503.html b/src/admin/web/error_503.html new file mode 100644 index 0000000..cc2a071 --- /dev/null +++ b/src/admin/web/error_503.html @@ -0,0 +1,37 @@ + + + + + + + 503 - Service unavailable - RustProxy + + + + +
+

503 - Service unavailable

+

The upstream service is temporarily unavailable. Please try again shortly.

+
+ + + \ No newline at end of file diff --git a/src/admin/web/error_504.html b/src/admin/web/error_504.html new file mode 100644 index 0000000..b05d35d --- /dev/null +++ b/src/admin/web/error_504.html @@ -0,0 +1,37 @@ + + + + + + + 504 - Gateway timeout - RustProxy + + + + +
+

504 - Gateway timeout

+

The upstream service did not respond in time. Please try again shortly.

+
+ + + \ No newline at end of file diff --git a/src/admin/web/favicon.svg b/src/admin/web/favicon.svg new file mode 100644 index 0000000..35d94f6 --- /dev/null +++ b/src/admin/web/favicon.svg @@ -0,0 +1 @@ + diff --git a/src/admin/web/host404.js b/src/admin/web/host404.js new file mode 100644 index 0000000..e457790 --- /dev/null +++ b/src/admin/web/host404.js @@ -0,0 +1,70 @@ +"use strict"; +(() => { + const form = $("notfound-form"), dialog = $("notfound-dialog"), list = $("notfound-list"); + if (!form || !dialog || !list) return; + let editing = null, revision = null, dirty = false, readBindings = () => [], readHosts = () => []; + const ERROR_STATUSES = [400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504]; + const BUILTIN_STATUSES = [401, 403, 404, 429, 503, 504]; + const STATUS_NAMES = { 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 408: "Request Timeout", 413: "Payload Too Large", 429: "Too Many Requests", 500: "Internal Server Error", 502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout" }; + function populateStatuses(selected = null) { + const select = $("notfound-status"); if (!select) return; + const codes = $("notfound-mode")?.value === "builtin" ? BUILTIN_STATUSES : ERROR_STATUSES; + const wanted = Number(selected ?? select.value ?? 404); + select.replaceChildren(...codes.map(code => { const node = el("option", `${code} ${STATUS_NAMES[code]}`); node.value = String(code); return node; })); + select.value = codes.includes(wanted) ? String(wanted) : "404"; + } + const value = id => $(id).value.trim(), yes = id => $(id).checked; + const defaults = () => ({ id: "", name: "", enabled: true, http2_enabled: true, http3_enabled: true, frontend: { hosts: [], listener_ids: [] }, certificate_id: null, page: { kind: "welcome", status: 404, html: "" } }); + const option = (value, text) => { const node = el("option", text); node.value = value; return node; }; + const changed = () => { dirty = true; }; + const allowDiscard = async () => !dirty || await appConfirm(t("notfoundUnsaved"), { danger: true, confirmLabel: t("discardAction") }); + function certificateChoices(selected) { + const select = $("notfound-certificate"); select.replaceChildren(option("", t("vhCertificateDefault"))); + for (const cert of envelope?.config.tls?.certificates || []) select.append(option(cert.id, cert.id)); + select.value = selected || ""; + } + function listenerChoices(selected = []) { + const holder = $("notfound-listeners"); holder.replaceChildren(); + const control = window.rustProxyIntegrations.bindings(holder, selected, !editing); readBindings = control.read; + const h2 = $("notfound-http2"), h3 = $("notfound-http3"); + control.onChange(() => { const caps = control.capabilities(); h2.disabled = !caps.http2; h3.disabled = !caps.http3; if (!caps.http2) h2.checked = false; if (!caps.http3) h3.checked = false; }); + } + function syncMode() { + const mode = $("notfound-mode").value; + populateStatuses(); + $("notfound-html-field").hidden = mode !== "html"; + } + function load(item = defaults()) { + editing = item.id || null; revision = envelope?.revision ?? null; dirty = false; + $("notfound-editing").textContent = editing || t("notfoundNewLabel"); + $("notfound-id").value = item.id || ""; $("notfound-id").disabled = Boolean(editing); $("notfound-name").value = item.name || ""; + $("notfound-enabled").checked = item.enabled !== false; $("notfound-http2").checked = item.http2_enabled !== false; $("notfound-http3").checked = item.http3_enabled !== false; + const hostHolder = $("notfound-hosts-editor"); hostHolder.replaceChildren(); readHosts = window.rustProxyIntegrations.hostnames(hostHolder, item.frontend?.hosts || [], { labelKey: "notfoundHosts", helpKey: "vhHostsHelp", requiredKey: "notfoundHostsRequired", onChange: changed }); + listenerChoices(item.frontend?.listener_ids || []); certificateChoices(item.certificate_id); + $("notfound-mode").value = item.page?.kind || "welcome"; populateStatuses(item.page?.status ?? 404); $("notfound-html").value = item.page?.html || ""; syncMode(); dialog.showModal(); + } + function collect() { + const id = value("notfound-id"), name = value("notfound-name") || id, hosts = readHosts(), listener_ids = readBindings(), kind = value("notfound-mode"), status = Number(value("notfound-status")), html = $("notfound-html").value; + if (!listener_ids.length) throw new Error(t("notfoundListenersRequired")); + const allowed = kind === "builtin" ? BUILTIN_STATUSES : ERROR_STATUSES; + if (!Number.isInteger(status) || !allowed.includes(status)) throw new Error(t("notfoundStatusInvalid")); + if (kind === "html" && !html.trim()) throw new Error(t("notfoundHtmlRequired")); + return { id, name, enabled: yes("notfound-enabled"), http2_enabled: yes("notfound-http2"), http3_enabled: yes("notfound-http3"), frontend: { hosts, listener_ids }, certificate_id: value("notfound-certificate") || null, page: { kind, status, html: kind === "html" ? html : "" } }; + } + function render() { + if (!envelope) return; const query = ($("notfound-search").value || "").trim().toLowerCase(); + const entries = (envelope.config.not_found_hosts || []).filter(item => !query || [item.id, item.name, ...(item.frontend?.hosts || [])].join(" ").toLowerCase().includes(query)); + list.replaceChildren(...entries.map(item => { + const card = el("article", undefined, "vhost-card redirect-card"), head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line"); + title.append(el("strong", item.name || item.id, "entity-title")); if (item.enabled === false) title.append(el("span", t("vhDisabled"), "pill")); identity.append(title, el("div", (item.frontend?.hosts || []).join(" · "), "vhost-hosts")); head.append(identity); + const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => load(structuredClone(item))), action(t("remove"), async () => { if (busy || !(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = (envelope.config.not_found_hosts || []).filter(value => value.id !== item.id); if (await saveSection("not_found_hosts", next)) { if (editing === item.id) dialog.close(); render(); } })); head.append(actions); card.append(head); + const detail = el("div", undefined, "vhost-detail-strip"); for (const [key, text] of [["notfoundStatus", String(item.page?.status ?? 404)], ["notfoundMode", t(`notfoundMode${(item.page?.kind || "welcome").replace(/^./, c => c.toUpperCase())}`)], ["redirectListeners", (item.frontend?.listener_ids || []).join(", ")], ["vhCertificate", item.certificate_id || t("vhCertificateDefault")]]) { const node = el("div", undefined, "vhost-detail"); node.append(el("span", t(key), "small-label"), el("b", text)); detail.append(node); } card.append(detail); return card; + })); + if (!entries.length) list.append(el("p", t(query ? "notfoundSearchEmpty" : "notfoundEmpty"), "fine")); + } + form.addEventListener("input", changed); form.addEventListener("change", changed); $("notfound-mode").addEventListener("change", syncMode); $("notfound-status").addEventListener("change", syncMode); + form.addEventListener("submit", async event => { event.preventDefault(); if (!envelope || busy) return; const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { invalid.reportValidity(); return; } if (revision !== envelope.revision) { toast(t("stale"), true); return; } try { const item = collect(), next = structuredClone(envelope.config.not_found_hosts || []); if (!editing && next.some(value => value.id === item.id)) throw new Error(t("notfoundDuplicate")); const index = next.findIndex(value => value.id === editing); if (index < 0) next.push(item); else next[index] = item; if (await saveSection("not_found_hosts", next)) { dirty = false; editing = item.id; dialog.close(); render(); } } catch (error) { toast(error.message, true); } }); + $("notfound-new").addEventListener("click", async () => { if (await allowDiscard()) load(); }); $("notfound-search").addEventListener("input", render); $("notfound-close").addEventListener("click", async () => { if (await allowDiscard()) dialog.close(); }); $("notfound-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.not_found_hosts || []).find(item => item.id === editing) || defaults()) : defaults()); }); dialog.addEventListener("cancel", event => { if (!dirty) return; event.preventDefault(); void allowDiscard().then(ok => { if (ok) dialog.close(); }); }); + window.rustProxyNotFound = { render, reset() { dialog.close(); editing = null; revision = null; dirty = false; list.replaceChildren(); $("notfound-search").value = ""; } }; + if (envelope) render(); +})(); diff --git a/src/admin/web/index.html b/src/admin/web/index.html new file mode 100644 index 0000000..c88301f --- /dev/null +++ b/src/admin/web/index.html @@ -0,0 +1,2426 @@ + + + + + + + + RustProxy / Control room + + + + + + + + + + + + + + + + + +
+ +
+
CONTROL ROOM / Overview +
+
+ + +
+
+ +
+
+
CACHE +

Cache purge / ban

+
+
+
+
+
+
+
+
+
+
+
+

The selected vhost limits the operation. Exact and + prefix values include the query string. Regex and root-prefix / operations require an additional + confirmation.

+ +
+
+
+
+ +
+
+
CACHE +

Active BAN rules

+
+
+ +
+ + + + + + + + + + + + + +
IDSelectorValueHostVhostExpiresActions
+
+

No active BAN rules.

+
+
+ +
+
+
Persistent cold cache +

Clean cold cache

+
+ +
+

Runs asynchronously and uses the same eviction mechanism as + automatic cold-cache GC.

+
+
+
+
+
+ +

+
+
+
+ +
+
WEBSOCKET / CONNECT +

Active WebSocket / tunnels

+
+
+
+
+
+
+
+ +
+ + + + + + + + + + + + + + + +
IDClientHostUpstreamProtocolDurationRXTXActions
+
+

No active tunnels.

+
+
+ +
+
SQLITE / LAST 100 / RETAINED 10,000 +

Administration audit

+
+
+
+
+

Recent administration changes are loaded only when this window + is opened.

+
+ + + + + + + + + + +
TimeActorActionDetail
+
+

No audit entries.

+
+
+ +
+

Live access logs

+
+
+
+
+
ACTIVITY +

Access log

+
+
+
+ Columns +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + +
TimeRequestHostSource IPVhostBackendStatusDurationCacheOutcomeTransportWebSocketUpstream error
+
+

No traffic yet. Open the proxy to make your first + request.

+
+

Live view starts with the latest 256 completed exchanges. + Increase buffer to keep accumulating live entries up to 4096 until the log window is closed.

+
+ +
+
+

Vhost configuration

NEW +
+
+ +
+
+

Private IP binding is not authentication. Keep firewall/ACL rules. + HSTS preload is opt-in; affinity cookies are not login sessions.

+
+
+ +
+

+ +
+
+
+ + +
+
+

Redirect configuration

NEW +
+
+ + + +
+ +
+
+
+
FRONTEND +

Listeners

+
+
+
+
+
+
+
+
BEHAVIOR +

Rules

+
+
+

Longest matching path wins. 307/308 preserve the request + method; 301/302/303 may change it in clients.

+
+
+
+

Redirect hosts do not contact a backend. Global host ACLs + still apply. Avoid self-referential rules that can create browser redirect loops.

+
+
+ + +
+
+

404 host configuration

NEW +
+
+ + + +
+ +
+
+
+
FRONTEND +

Listeners

+
+
+
+
+
+
+
+
RESPONSE +

Local response

+
+
+
+ + + +
+

The host never opens an upstream connection. The + built-in placeholder is no-store and intentionally omits optional RustProxy diagnostic/server + headers.

+
+
+
+
+ + +
+
+

Stream configuration

NEW +
+
+ +
+
+

Changing a stream listener requires a RustProxy restart.

+
+
+ + +
+
+

Visual field builder

CONFIGURABLE JSON +
+

Select source fields and optionally rename their + JSON keys. Changes are written to the advanced JSON mapping when applied.

+
+
+

Fields

+
+ + + + + + + + + +
EnabledSource fieldOutput JSON key
+
+
+
+

Preview

+

+                
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/admin/web/integrations.js b/src/admin/web/integrations.js new file mode 100644 index 0000000..24f4200 --- /dev/null +++ b/src/admin/web/integrations.js @@ -0,0 +1,261 @@ +"use strict"; +// Shared controls for named listeners, HTTP/2/HTTP/3 and the certificate inventory. +// All labels are resolved from discovered language packs. +(() => { + let uploadBusy = false; + const label = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; }; + function field(parent, name, key, value, type = "text", options = {}) { + const wrapper = el("label", undefined, type === "checkbox" ? "check" : "vh-field"); + const input = el(type === "select" ? "select" : type === "textarea" ? "textarea" : "input"); input.name = name; + if (type === "select") for (const [value, key] of options.values || []) { const option = label("option", key); option.value = value; input.append(option); } + else if (type !== "textarea") input.type = type; + if (type === "checkbox") input.checked = !!value; else input.value = value ?? ""; + for (const key of ["min", "max", "maxLength", "required", "pattern", "rows"]) if (Object.hasOwn(options, key)) input[key] = options[key]; + if (type === "checkbox") wrapper.append(input, label("span", key)); else wrapper.append(label("span", key), input); + parent.append(wrapper); return input; + } + const button = (parent, key, run) => { const node = label("button", key, "quiet"); node.type = "button"; node.addEventListener("click", run); parent.append(node); return node; }; + function hostnames(parent, initial = [], options = {}) { + const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); + wrapper.append(label("span", options.labelKey || "vhHosts")); + const box = el("div", undefined, "host-chip-editor"), input = document.createElement("input"); + input.type = "text"; input.maxLength = 253; input.placeholder = "app.example.com / *.example.com"; + input.setAttribute("aria-label", t(options.addKey || "vhHostAdd")); + const values = []; + const notify = () => { if (typeof options.onChange === "function") options.onChange(); }; + const normalize = raw => { + let value = raw.trim().toLowerCase().replace(/\.$/, ""); if (!value) return null; + const wildcard = value.startsWith("*."); const base = wildcard ? value.slice(2) : value; + if (base.length > 253 || !/^[a-z0-9.-]+$/.test(base) || base.startsWith(".") || base.endsWith(".") || base.split(".").some(part => !part || part.length > 63 || part.startsWith("-") || part.endsWith("-"))) throw new Error(t("vhHostInvalid")); + if (wildcard && !base.includes(".")) throw new Error(t("vhHostInvalid")); + return wildcard ? `*.${base}` : base; + }; + const sync = () => { + box.querySelectorAll(".host-chip").forEach(node => node.remove()); + for (const value of values) { + const chip = el("span", undefined, "host-chip"); chip.append(el("span", value)); + const remove = el("button", "×"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${value}`); + remove.addEventListener("click", () => { values.splice(values.indexOf(value), 1); sync(); notify(); }); + chip.append(remove); box.insertBefore(chip, input); + } + }; + const add = (raw, changed = true) => { const value = normalize(raw); if (!value || values.includes(value)) return; values.push(value); sync(); if (changed) notify(); }; + const consume = raw => { for (const part of raw.split(/[\s,]+/).filter(Boolean)) add(part); input.value = ""; }; + input.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { consume(input.value); } catch (error) { toast(error.message, true); } } }); + input.addEventListener("blur", () => { if (!input.value.trim()) return; try { consume(input.value); } catch (error) { toast(error.message, true); } }); + input.addEventListener("paste", event => { const textValue = event.clipboardData?.getData("text") || ""; if (!/[\s,]/.test(textValue.trim())) return; event.preventDefault(); try { consume(textValue); } catch (error) { toast(error.message, true); } }); + box.append(input); wrapper.append(box, label("p", options.helpKey || "vhHostsHelp", "fine")); parent.append(wrapper); + for (const host of initial) { try { add(host, false); } catch { } } + return () => { if (input.value.trim()) consume(input.value); if (!values.length) throw new Error(t(options.requiredKey || "vhHostRequired")); return [...values]; }; + } + function addressTagEditor(parent, initial = [], lockedValues = []) { + const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(label("span", "listenerAddresses")); + const box = el("div", undefined, "host-chip-editor listener-address-chip-editor"), input = document.createElement("input"); + input.type = "text"; input.maxLength = 128; input.placeholder = "127.0.0.1:8080 / [::1]:8080"; input.setAttribute("aria-label", t("listenerAddressAdd")); + const values = [], locked = new Set(lockedValues); + const normalize = raw => { const value = raw.trim(); if (!value) return null; if (value.length > 128 || /[\s,]/.test(value)) throw fieldError(input, t("listenerAddressesInvalid")); return value; }; + const sync = () => { box.querySelectorAll(".host-chip").forEach(node => node.remove()); for (const value of values) { const chip = el("span", undefined, "host-chip"); chip.append(el("span", value)); const remove = el("button", "×"); remove.type = "button"; const fixed = locked.has(value); remove.disabled = fixed; remove.title = fixed ? t("listenerStartupLocked") : ""; remove.setAttribute("aria-label", `${t("remove")} ${value}`); remove.addEventListener("click", () => { if (fixed) return; values.splice(values.indexOf(value), 1); sync(); setSettingsDirty("listeners-form"); }); chip.append(remove); box.insertBefore(chip, input); } }; + const add = (raw, dirty = true) => { const value = normalize(raw); if (!value || values.includes(value)) return; if (values.length >= 16) throw fieldError(input, t("listenerAddressesInvalid")); values.push(value); sync(); if (dirty) setSettingsDirty("listeners-form"); }; + const consume = raw => { for (const part of raw.split(/[\s,]+/).filter(Boolean)) add(part); input.value = ""; }; + input.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { consume(input.value); } catch (error) { toast(error.message, true); } } }); + input.addEventListener("blur", () => { if (!input.value.trim()) return; try { consume(input.value); } catch (error) { toast(error.message, true); } }); + input.addEventListener("paste", event => { const value = event.clipboardData?.getData("text") || ""; if (!/[\s,]/.test(value.trim())) return; event.preventDefault(); try { consume(value); } catch (error) { toast(error.message, true); } }); + box.append(input); wrapper.append(box, label("p", locked.size ? "listenerStartupLocked" : "listenerAddressesHelp", "fine")); parent.append(wrapper); + for (const value of initial) { try { add(value, false); } catch { } } + const read = () => { if (input.value.trim()) consume(input.value); return [...values]; }; read.input = input; return read; + } + const h2Defaults = { idle_timeout_seconds: 300, max_connection_lifetime_seconds: 0, drain_timeout_seconds: 10 }; + const effectiveHttp2Settings = binding => ({ ...h2Defaults, ...(envelope?.config.http2 || {}), ...(binding?.http2_settings || {}) }); + function http2SettingsControls(parent, binding) { + const box = el("div", undefined, "h3-editor vh-wide"); parent.append(box); + const grid = el("div", undefined, "vh-grid"); box.append(grid); + const current = effectiveHttp2Settings(binding), inputs = {}; + for (const [name, min, max, key] of [ + ["idle_timeout_seconds", 1, 86400, "http2IdleTimeout"], + ["max_connection_lifetime_seconds", 0, 2592000, "http2MaxLifetime"], + ["drain_timeout_seconds", 1, 3600, "http2DrainTimeout"], + ]) inputs[name] = field(grid, name, key, current[name], "number", { min, max, required: true }); + box.append(label("p", "http2DrainHelp", "fine")); + const sync = (blocked = false) => { for (const input of Object.values(inputs)) input.disabled = blocked; }; + const read = () => { + const value = {}; + for (const [name, input] of Object.entries(inputs)) { + value[name] = Number(input.value); + if (!Number.isSafeInteger(value[name])) throw fieldError(input, t("vhJsonInvalid")); + } + return value; + }; + return { read, sync }; + } + const h3Defaults = { alt_svc_max_age: 3600, max_concurrent_connections: 512, max_concurrent_bidistream: 100, max_concurrent_unistream: 100, max_idle_timeout: 10 }; + function http3SettingsControls(parent, binding) { + const box = el("div", undefined, "h3-editor vh-wide"); parent.append(box); + const grid = el("div", undefined, "vh-grid"); box.append(grid); const inputs = {}; + for (const [name, min, max] of [["alt_svc_max_age", 0, 604800], ["max_concurrent_connections", 1, 4096], ["max_concurrent_bidistream", 1, 1024], ["max_concurrent_unistream", 3, 1024], ["max_idle_timeout", 0, 86400]]) inputs[name] = field(grid, name, `h3_${name}`, binding.http3?.[name] ?? h3Defaults[name], "number", { min, max, required: true }); + box.append(label("p", "h3TuningHelp", "fine")); + const sync = (blocked = false) => { for (const input of Object.values(inputs)) input.disabled = blocked; }; + sync(false); + const read = () => { const value = {}; for (const [name, input] of Object.entries(inputs)) { value[name] = Number(input.value); if (!Number.isSafeInteger(value[name])) throw fieldError(input, t("vhJsonInvalid")); } return value; }; + return { read, sync }; + } + function addRow(item = { id: `listener-${$("named-listener-rows").children.length + 1}`, name: "", scope: "internal", enabled: true, binding: { address: "127.0.0.1:8181", tls: false, http2: false, http2_settings: null, h2c: false, http3: null } }) { + if ($("named-listener-rows").children.length >= 64) { toast(t("listenerLimit"), true); return; } + const row = el("fieldset", undefined, "named-listener-row"); row.append(label("legend", "listenerEndpoint")); const grid = el("div", undefined, "vh-grid"); row.append(grid); const b = item.binding || {}; + const id = field(grid, "listener-id", "listenerId", item.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); + const name = field(grid, "listener-name", "listenerName", item.name, "text", { maxLength: 96 }); + const scope = field(grid, "listener-scope", "listenerScope", item.scope || "internal", "select", { values: [["internal", "listenerInternal"], ["public", "listenerPublic"]] }); + const currentAddresses = [b.address, ...(item.additional_addresses || [])].filter(Boolean); + const startupAddress = b.tls ? latestStatus?.listeners?.locked_https : latestStatus?.listeners?.locked_http; + const startupLocked = !!startupAddress && currentAddresses.includes(startupAddress); + const readAddresses = addressTagEditor(grid, currentAddresses, startupLocked ? [startupAddress] : []); + const enabled = field(grid, "listener-enabled", "vhEnabled", item.enabled !== false, "checkbox"); + const tls = field(grid, "listener-tls", "vhTls", !!b.tls, "checkbox"); const h2 = field(grid, "listener-h2", "vhHttp2", b.tls && b.http2 !== false, "checkbox"); const h2c = field(grid, "listener-h2c", "listenerH2c", !b.tls && !!b.h2c, "checkbox"); + const preservedHttp2 = effectiveHttp2Settings(b); + const h2Settings = button(row, "h2Configure", () => { + window.rustProxySettings?.select("connections"); + window.rustProxySettings?.selectHttpTab?.("http2"); + document.querySelector('[data-http-settings-page="http2"]')?.scrollIntoView({ block: "start" }); + }); + h2Settings.classList.add("listener-h2-settings-link"); + const preservedHttp3 = Object.hasOwn(b, "http3") ? b.http3 : (b.tls ? { ...h3Defaults } : null); + const h3 = field(grid, "listener-h3", "h3Enable", b.tls && !!preservedHttp3, "checkbox"); + const h3Settings = button(row, "h3Configure", () => { + window.rustProxySettings?.select("connections"); + window.rustProxySettings?.selectHttpTab?.("http3"); + document.querySelector('[data-http-settings-page="http3"]')?.scrollIntoView({ block: "start" }); + }); + h3Settings.classList.add("listener-h3-settings-link"); + const sync = () => { + h2.disabled = !tls.checked; h2c.disabled = tls.checked; h3.disabled = !tls.checked; + h2Settings.hidden = !((tls.checked && h2.checked) || (!tls.checked && h2c.checked)); + h3Settings.hidden = !tls.checked || !h3.checked; + }; + for (const input of [tls, h2, h2c, h3]) input.addEventListener("change", sync); + sync(); + const removeButton = button(row, "remove", () => { if (startupLocked) return; const users = (envelope?.config.vhosts || []).filter(v => v.frontend.listener_ids?.includes(id.value.trim())); if (users.length) { toast(`${t("listenerInUse")}: ${users.map(v => v.id).join(", ")}`, true); return; } row.remove(); setSettingsDirty("listeners-form"); }); + if (startupLocked) { + row.classList.add("listener-startup-locked"); + enabled.disabled = true; + tls.disabled = true; + removeButton.disabled = true; + removeButton.title = t("listenerStartupLocked"); + row.append(label("p", "listenerStartupLocked", "fine")); + } + row.read = () => { + const addresses = readAddresses(); + if (!addresses.length || addresses.length > 16 || new Set(addresses).size !== addresses.length) throw fieldError(readAddresses.input, t("listenerAddressesInvalid")); + if (addresses.length > 1 && addresses.some(value => /^0\.0\.0\.0:/.test(value) || /^\[?::\]?:/.test(value))) throw fieldError(readAddresses.input, t("listenerAddressesExplicit")); + const http2Enabled = (tls.checked && h2.checked) || (!tls.checked && h2c.checked); + return { id: id.value.trim(), name: name.value.trim(), scope: scope.value, enabled: enabled.checked, binding: { address: addresses[0], tls: tls.checked, http2: tls.checked && h2.checked, http2_settings: http2Enabled ? preservedHttp2 : null, h2c: !tls.checked && h2c.checked, http3: tls.checked && h3.checked ? (preservedHttp3 || { ...h3Defaults }) : null }, additional_addresses: addresses.slice(1) }; + }; + $("named-listener-rows").append(row); + } + $("add-named-listener").addEventListener("click", () => { addRow(); setSettingsDirty("listeners-form"); }); + function renderListeners() { const items = envelope?.config.listeners?.endpoints || []; $("named-listener-rows").replaceChildren(); for (const item of items) addRow(item); } + function collectListeners() { const rows = [...$("named-listener-rows").children], endpoints = rows.map(row => row.read()), seen = new Set(); for (let index = 0; index < endpoints.length; index++) { const id = endpoints[index].id; if (seen.has(id)) throw fieldError(rows[index].querySelector('[name="listener-id"]'), t("listenerDuplicate")); seen.add(id); } return { endpoints }; } + function renderHttp2Settings() { + const root = $("http2-listener-rows"); if (!root) return; root.replaceChildren(); + const items = (envelope?.config.listeners?.endpoints || []).filter(endpoint => { + const binding = endpoint.binding || {}; + return (binding.tls && binding.http2 !== false) || (!binding.tls && !!binding.h2c); + }); + const empty = $("http2-no-listeners"); empty.hidden = items.length !== 0; + for (const endpoint of items) { + const row = el("fieldset", undefined, "named-listener-row http2-listener-row"); row.dataset.listenerId = endpoint.id; + const addresses = [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean).join(", "); + const legend = el("legend", `${endpoint.name || endpoint.id} · ${addresses}`); row.append(legend); + const mode = endpoint.binding?.tls ? "H2 / TLS" : "h2c"; + const meta = el("p", `${endpoint.id} · ${mode}${endpoint.enabled === false ? ` · ${t("vhDisabled")}` : ""}`, "fine"); row.append(meta); + const controls = http2SettingsControls(row, endpoint.binding || {}); row.read = controls.read; row.sync = controls.sync; root.append(row); + } + syncHttp2Availability(); + } + function collectHttp2Listeners() { + const listeners = structuredClone(envelope?.config.listeners || { endpoints: [] }); + const rows = new Map([...$("http2-listener-rows").children].map(row => [row.dataset.listenerId, row])); + for (const endpoint of listeners.endpoints || []) { + const binding = endpoint.binding || {}, row = rows.get(endpoint.id); + const enabled = (binding.tls && binding.http2 !== false) || (!binding.tls && !!binding.h2c); + if (!row || !enabled) continue; + endpoint.binding.http2_settings = row.read(); + } + return listeners; + } + function syncHttp2Availability() { + const root = $("http2-listener-rows"), note = $("http2-listeners-dirty"), save = $("http2-save"); if (!root || !note || !save) return; + const blocked = settingsDirty.has("listeners-form"); note.hidden = !blocked; + for (const row of root.children) row.sync?.(blocked); + save.disabled = blocked || root.children.length === 0; + } + function renderHttp3Settings() { + const root = $("http3-listener-rows"); if (!root) return; root.replaceChildren(); + const tlsItems = (envelope?.config.listeners?.endpoints || []).filter(endpoint => !!endpoint.binding?.tls); + const items = tlsItems.filter(endpoint => !!endpoint.binding?.http3); + const empty = $("http3-no-listeners"); + empty.hidden = items.length !== 0; + if (!items.length) { empty.textContent = t(tlsItems.length ? "http3NoEnabledListeners" : "http3NoTlsListeners"); delete empty.dataset.i18n; } + for (const endpoint of items) { + const row = el("fieldset", undefined, "named-listener-row http3-listener-row"); row.dataset.listenerId = endpoint.id; + const addresses = [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean).join(", "); + const legend = el("legend", `${endpoint.name || endpoint.id} · ${addresses}`); row.append(legend); + const meta = el("p", `${endpoint.id}${endpoint.enabled === false ? ` · ${t("vhDisabled")}` : ""}`, "fine"); row.append(meta); + const controls = http3SettingsControls(row, endpoint.binding || {}); row.read = controls.read; row.sync = controls.sync; root.append(row); + } + syncHttp3Availability(); + } + function collectHttp3Listeners() { + const listeners = structuredClone(envelope?.config.listeners || { endpoints: [] }); + const rows = new Map([...$("http3-listener-rows").children].map(row => [row.dataset.listenerId, row])); + for (const endpoint of listeners.endpoints || []) { const row = rows.get(endpoint.id); if (!row || !endpoint.binding?.tls || !endpoint.binding?.http3) continue; endpoint.binding.http3 = row.read(); } + return listeners; + } + function syncHttp3Availability() { + const root = $("http3-listener-rows"), note = $("http3-listeners-dirty"), save = $("http3-save"); if (!root || !note || !save) return; + const blocked = settingsDirty.has("listeners-form"); note.hidden = !blocked; + for (const row of root.children) row.sync?.(blocked); + save.disabled = blocked || root.children.length === 0; + } + function bindings(parent, selected = [], selectDefault = false) { + const choices = new Map(), listeners = new Map(), watchers = [], box = el("div", undefined, "listener-choices vh-wide"); parent.append(box); box.append(label("p", "listenerChooseHint", "fine")); + const items = envelope?.config.listeners?.endpoints || [], autoIds = new Set(); + if (selectDefault && !selected.length) { + const hasPort = (endpoint, port) => [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean).some(address => String(address).match(/:(\d+)$/)?.[1] === String(port)); + const candidates = items.filter(endpoint => endpoint.enabled !== false); + const prefer = (tls, port) => candidates.find(endpoint => endpoint.scope === "public" && !!endpoint.binding?.tls === tls && hasPort(endpoint, port)) || candidates.find(endpoint => !!endpoint.binding?.tls === tls && hasPort(endpoint, port)); + const http80 = prefer(false, 80); + if (http80) autoIds.add(http80.id); + if (!autoIds.size) { const fallback = candidates.find(endpoint => !endpoint.binding?.tls) || candidates[0]; if (fallback) autoIds.add(fallback.id); } + } + const notify = () => watchers.forEach(run => run()); + for (const endpoint of items) { + const checked = selected.includes(endpoint.id) || autoIds.has(endpoint.id); const input = field(box, `bind-${endpoint.id.replace(/[^A-Za-z0-9_-]/g, "-")}`, "listenerEndpoint", checked, "checkbox"); + const binding = endpoint.binding || {}, protocols = [binding.tls ? "HTTPS" : "HTTP"]; if (binding.tls && binding.http2 !== false) protocols.push("H2"); if (!binding.tls && binding.h2c) protocols.push("H2C"); if (binding.tls && (!Object.hasOwn(binding, "http3") || binding.http3 !== null)) protocols.push("H3"); + const addresses = [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean).join(", "); + input.nextElementSibling.textContent = `${endpoint.name || endpoint.id} / ${protocols.join("+")} ${addresses} / ${t(endpoint.scope === "public" ? "listenerPublic" : "listenerInternal")}${endpoint.enabled === false ? ` / ${t("vhDisabled")}` : ""}`; delete input.nextElementSibling.dataset.i18n; choices.set(endpoint.id, input); listeners.set(endpoint.id, endpoint); input.addEventListener("change", notify); + } + for (const id of selected) if (!items.some(e => e.id === id)) { const input = field(box, `bind-${id}`, "listenerMissing", true, "checkbox"); input.nextElementSibling.textContent = `${t("listenerMissing")}: ${id}`; choices.set(id, input); input.addEventListener("change", notify); } + const read = () => [...choices].filter(([, input]) => input.checked).map(([id]) => id); + const capabilities = () => { const selectedIds = new Set(read()); let http2 = false, http3 = false; for (const [id, endpoint] of listeners) { if (!selectedIds.has(id) || endpoint.enabled === false) continue; const binding = endpoint.binding || {}; http2 ||= !!((binding.tls && binding.http2 !== false) || (!binding.tls && binding.h2c)); http3 ||= !!(binding.tls && (!Object.hasOwn(binding, "http3") || binding.http3 !== null)); } return { http2, http3 }; }; + return { read, capabilities, onChange(run) { watchers.push(run); run(); } }; + } + $("certificate-upload-form").addEventListener("submit", async event => { + event.preventDefault(); if (uploadBusy || !envelope || !authenticated || busy) return; + const form = event.currentTarget, epoch = authEpoch, revision = envelope.revision; let keyPem = ""; uploadBusy = true; + try { + await withFormSubmitState(form, event.submitter, async () => { + const cert = $("cert-import-pem").files[0], key = $("cert-import-key").files[0], id = $("cert-import-name").value.trim(); + if ((envelope.config.tls?.certificates || []).some(c => c.id === id)) throw fieldError($("cert-import-name"), t("certDuplicate")); + if (!cert) throw fieldError($("cert-import-pem"), t("vhSelectCertificateFiles")); + if (!key) throw fieldError($("cert-import-key"), t("vhSelectCertificateFiles")); + if (cert.size > 131072) throw fieldError($("cert-import-pem"), t("vhCertificateTooLarge")); + if (key.size > 131072) throw fieldError($("cert-import-key"), t("vhCertificateTooLarge")); + keyPem = await key.text(); const uploaded = await api("/api/certificates/upload", { method: "POST", body: JSON.stringify({ cert_pem: await cert.text(), key_pem: keyPem, hosts: $("cert-import-domains").value.split(/[\n,]/).map(s => s.trim()).filter(Boolean) }) }); + if (!authenticated || epoch !== authEpoch) return; if (envelope.revision !== revision) throw new Error(t("stale")); + const next = structuredClone(envelope.config); next.tls ||= {}; next.tls.certificates ||= []; next.tls.certificates.push({ ...uploaded.certificate, id, hosts: [] }); if ($("cert-import-default").checked) next.tls.default_certificate = id; + if (await saveSection("tls", next.tls || {})) { form.reset(); window.rustProxyCertificates?.close(); } + }); + } catch (error) { if (epoch === authEpoch) toast(error.message, true); } finally { keyPem = ""; uploadBusy = false; } + }); + window.rustProxyIntegrations = { bindings, hostnames, renderListeners, collectListeners, renderHttp2Settings, collectHttp2Listeners, syncHttp2Availability, renderHttp3Settings, collectHttp3Listeners, syncHttp3Availability, reset() { $("certificate-upload-form").reset(); $("named-listener-rows").replaceChildren(); $("http2-listener-rows")?.replaceChildren(); $("http3-listener-rows")?.replaceChildren(); } }; + if (envelope) renderSettings(); +})(); diff --git a/src/admin/web/login-keys.json b/src/admin/web/login-keys.json new file mode 100644 index 0000000..2b569b9 --- /dev/null +++ b/src/admin/web/login-keys.json @@ -0,0 +1,27 @@ +[ + "auth.already_initialized", + "auth.busy", + "auth.csrf", + "auth.invalid_credentials", + "auth.invalid_host", + "auth.invalid_origin", + "auth.invalid_request", + "auth.invalid_setup_token", + "auth.invalid_username", + "auth.password_policy", + "auth.rate_limited", + "auth.required", + "auth.unavailable", + "authPrivateNote", + "confirmPassword", + "createAndSignIn", + "language", + "loginTitle", + "password", + "passwordMismatch", + "setupHelp", + "setupTitle", + "setupToken", + "signIn", + "username" +] \ No newline at end of file diff --git a/src/admin/web/login.html b/src/admin/web/login.html new file mode 100644 index 0000000..c2b9854 --- /dev/null +++ b/src/admin/web/login.html @@ -0,0 +1,47 @@ + + + + + + + RustProxy / Sign in + + + + + + +
r/pRustProxy
+
+ +
+ + + \ No newline at end of file diff --git a/src/admin/web/login.js b/src/admin/web/login.js new file mode 100644 index 0000000..f30fca4 --- /dev/null +++ b/src/admin/web/login.js @@ -0,0 +1,79 @@ +"use strict"; +// No operational API or dashboard module is loaded on this document. +(() => { + const node = id => document.getElementById(id); + const themes = new Set(["dark", "light"]); + try { const cached = localStorage.getItem("rp-theme"); document.documentElement.dataset.theme = themes.has(cached) ? cached : "dark"; } catch { document.documentElement.dataset.theme = "dark"; } + const panelPaths = new Set(["/overview", "/vhosts", "/docker-vhosts", "/redirects", "/404-hosts", "/streams", "/acl-profiles", "/configuration", "/tls", "/cache", "/transform", "/logging", "/settings"]); + const settingsTabs = new Set(["listeners", "connections", "docker-agents", "response", "cache", "transform", "headers", "forwarding", "pages", "logging", "prometheus", "administration", "tls-policy", "manual-config", "security"]); + let packs = new Map(), fallback = null, language = null, setupRequired = false, ready = false; + try { language = localStorage.getItem("rp-language"); } catch { } + const t = key => packs.get(language)?.translations?.[key] ?? packs.get(fallback)?.translations?.[key] ?? key; + const reveal = () => document.body.classList.remove("auth-loading"); + function message(value) { const box = node("auth-message"); box.textContent = value; box.classList.toggle("error", Boolean(value)); } + function panelDestination() { + const params = new URLSearchParams(location.search); + if (params.get("next") === "docs") return "/docs"; + if (!panelPaths.has(location.pathname)) return "/"; + if (location.pathname === "/logging") return "/settings?tab=logging"; + if (location.pathname === "/settings") { + const tab = params.get("tab"); + if (settingsTabs.has(tab)) return `/settings?tab=${encodeURIComponent(tab)}`; + } + return location.pathname; + } + async function request(path, body) { + const response = await fetch(path, { method: body ? "POST" : "GET", credentials: "same-origin", cache: "no-store", redirect: "error", headers: { "Content-Type": "application/json", "X-RustProxy-Request": "1" }, ...(body ? { body: JSON.stringify(body) } : {}) }); + let value; try { value = await response.json(); } catch { value = { error: "auth.unavailable" }; } + if (!response.ok) { const error = new Error(t(value.error || "auth.unavailable")); error.code = value.error; throw error; } return value; + } + function translate() { + const pack = packs.get(language) || packs.get(fallback); document.documentElement.lang = pack?.meta.locale || "und"; document.documentElement.dir = pack?.meta.direction || "ltr"; + document.querySelectorAll("[data-i18n]").forEach(n => n.textContent = t(n.dataset.i18n)); + document.querySelectorAll("[data-i18n-aria]").forEach(n => n.setAttribute("aria-label", t(n.dataset.i18nAria))); + node("setup-fields").hidden = !setupRequired; node("confirm-field").hidden = !setupRequired; + node("setup-token").required = setupRequired; node("password-confirm").required = setupRequired; + node("password").minLength = setupRequired ? 12 : 1; node("password").autocomplete = setupRequired ? "new-password" : "current-password"; + node("auth-title").textContent = t(setupRequired ? "setupTitle" : "signIn"); node("login-submit").textContent = t(setupRequired ? "createAndSignIn" : "signIn"); + node("auth-badge").textContent = setupRequired ? "SETUP" : "LOCAL"; + } + function selectLanguage(bundle) { + if (!Array.isArray(bundle.languages) || !bundle.languages.length) throw new Error("auth.unavailable"); + packs = new Map(bundle.languages.map(p => [p.id, p])); fallback = bundle.default; if (!packs.has(fallback)) throw new Error("auth.unavailable"); + const values = [...packs.values()], candidates = [language, ...(navigator.languages || [navigator.language])].filter(Boolean).map(s => s.replaceAll("_", "-").toLowerCase()); + language = candidates.map(v => values.find(p => p.id.replaceAll("_", "-").toLowerCase() === v || p.meta.locale.toLowerCase() === v) || values.find(p => p.meta.locale.toLowerCase().split("-")[0] === v.split("-")[0])).find(Boolean)?.id || fallback; + node("language").replaceChildren(...values.map(p => { const option = document.createElement("option"); option.value = p.id; option.textContent = p.meta.native_name; return option; })); node("language").value = language; + } + node("language").addEventListener("change", () => { language = node("language").value; try { localStorage.setItem("rp-language", language); } catch { } message(""); translate(); }); + node("login-submit").disabled = true; + node("login-form").addEventListener("submit", async event => { + event.preventDefault(); if (!ready) return; const button = node("login-submit"); button.disabled = true; message(""); + try { + const username = node("username").value.trim(), password = node("password").value; + if (setupRequired) { if (password !== node("password-confirm").value) throw new Error(t("passwordMismatch")); await request("/api/auth/setup", { token: node("setup-token").value.trim(), username, password }); setupRequired = false; translate(); } + await request("/api/auth/login", { username, password }); + node("password").value = ""; node("password-confirm").value = ""; node("setup-token").value = ""; + // Never accept arbitrary return URLs (including scheme-relative redirects). + // A direct panel route is an exact local allowlist entry, so an expired + // session can sign back in without losing the requested subpage. + location.replace(panelDestination()); + } catch (error) { node("password").value = ""; node("password-confirm").value = ""; if (error.code === "auth.already_initialized") { setupRequired = false; translate(); } message(error.message); } + finally { button.disabled = false; } + }); + async function redirectExistingSession() { + try { await request("/api/auth/session"); location.replace(panelDestination()); return true; } + catch (error) { if (error.code === "auth.required") return false; throw error; } + } + window.addEventListener("pageshow", event => { + node("password").value = ""; node("password-confirm").value = ""; node("setup-token").value = ""; + if (event.persisted && ready && !setupRequired) void redirectExistingSession().catch(() => message(t("auth.unavailable"))); + }); + (async () => { + try { + const [bundle, status] = await Promise.all([request("/assets/login-langs.json"), request("/api/auth/status")]); + selectLanguage(bundle); setupRequired = status.setup_required === true; translate(); + if (!setupRequired && await redirectExistingSession()) return; + ready = true; node("login-submit").disabled = false; reveal(); + } catch (error) { reveal(); message(t("auth.unavailable")); } + })(); +})(); diff --git a/src/admin/web/management.js b/src/admin/web/management.js new file mode 100644 index 0000000..3cebe7b --- /dev/null +++ b/src/admin/web/management.js @@ -0,0 +1,295 @@ +"use strict"; +// Shared, safe controls: HTML is edited as text, never injected into the admin DOM. +(() => { + const label = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; }; + const option = (value, key) => { const node = label("option", key); node.value = value; return node; }; + const codes = [400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504]; + const STATUS_NAMES = { 200: "OK", 204: "No Content", 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden", 404: "Not Found", 408: "Request Timeout", 410: "Gone", 413: "Payload Too Large", 429: "Too Many Requests", 500: "Internal Server Error", 502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout" }; + const statusLabel = code => `${code} ${STATUS_NAMES[code] || ""}`.trim(); + function pageEditor(parent, key, value = null) { + const row = el("div", undefined, "page-editor"), heading = label("strong", key, "page-label"); row.append(heading); + const mode = el("select"), status = el("select"), html = el("textarea"), file = el("input"); + mode.setAttribute("aria-label", t(key)); mode.className = "page-mode"; + mode.append(option("", "pageInherit"), option("welcome", "pageWelcome"), option("builtin", "pageBuiltin"), option("empty", "pageEmpty"), option("html", "pageHtml")); + mode.value = value?.kind || ""; + status.className = "page-status"; status.setAttribute("aria-label", t("pageStatus")); + status.append(option("", "pageOriginalStatus")); + for (const code of [200, 204, 400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504]) { const o = el("option", statusLabel(code)); o.value = code; status.append(o); } + status.value = value?.status ?? ""; html.value = value?.html || ""; html.rows = 5; html.maxLength = 32768; html.spellcheck = false; html.className = "page-html"; html.setAttribute("aria-label", t("pageHtmlSource")); + file.type = "file"; file.accept = ".html,.htm,text/html,text/plain"; file.setAttribute("aria-label", t("pageUploadHtml")); + const source = el("div", undefined, "page-source"); source.append(label("span", "pageUploadHtml", "fine"), file, html); + function sync() { status.disabled = !mode.value; source.hidden = mode.value !== "html"; if (status.value === "204" && mode.value !== "empty") status.value = ""; } + for (const event of ["change", "input"]) { mode.addEventListener(event, sync); status.addEventListener(event, sync); } + file.addEventListener("change", async () => { try { const chosen = file.files[0]; if (!chosen) return; if (chosen.size > 32768) throw new Error(t("pageTooLarge")); html.value = await chosen.text(); html.dispatchEvent(new Event("input", { bubbles: true })); } catch (error) { toast(error.message, true); } finally { file.value = ""; } }); + row.append(mode, status, source); parent.append(row); sync(); + return { row, read() { if (!mode.value) return null; const markup = mode.value === "html" ? html.value : ""; if (new TextEncoder().encode(markup).length > 32768) throw new Error(t("pageTooLarge")); return { kind: mode.value, status: status.value ? Number(status.value) : null, html: markup }; } }; + } + function errorsEditor(parent, values = {}) { + const details = el("details", undefined, "pages-errors"); details.append(label("summary", "customErrorPages")); parent.append(details); + const rows = new Map(); for (const code of codes) { const group = el("div"); details.append(group); const editor = pageEditor(group, "customErrorPages", values[code]); editor.row.querySelector("strong").removeAttribute("data-i18n"); editor.row.querySelector("strong").textContent = statusLabel(code); rows.set(code, editor); } + return { read() { return Object.fromEntries([...rows].map(([code, editor]) => [code, editor.read()]).filter(([, page]) => page)); } }; + } + function vhostEditor(parent, values = {}) { + const response = pageEditor(parent, "pageStaticHost", values.response), fallback = pageEditor(parent, "pageMissingPath", values.fallback), errors = errorsEditor(parent, values.errors); + parent.append(label("p", "pagesSafetyHint", "fine")); return { read: () => ({ response: response.read(), fallback: fallback.read(), errors: errors.read() }) }; + } + let global = null; + function render() { if (!envelope || settingsDirty.has("pages-form")) return; const values = envelope.config.pages || {}, parent = $("global-pages-fields"); parent.replaceChildren(); const ip = pageEditor(parent, "pageDirectIp", values.direct_ip), unknown = pageEditor(parent, "pageUnknownHost", values.unknown_host), errors = errorsEditor(parent, values.errors); global = { read: () => ({ direct_ip: ip.read(), unknown_host: unknown.read(), errors: errors.read() }) }; } + window.rustProxyPages = { render, vhostEditor }; + bindSettingsForm("pages-form", next => { next.pages = global.read(); }, "pages"); + $("settings-security-open").addEventListener("click", () => setPanel("security")); + $("settings-vhost-open").addEventListener("click", () => setPanel("vhosts")); + const density = $("ui-density"); let chosen = "compact"; try { chosen = localStorage.getItem("rp-density") || chosen; } catch { } + if (!["compact", "dense", "comfortable"].includes(chosen)) chosen = "compact"; density.value = chosen; document.body.dataset.density = chosen; + density.addEventListener("change", () => { document.body.dataset.density = density.value; try { localStorage.setItem("rp-density", density.value); } catch { } }); + if (envelope) render(); +})(); + +(() => { + const dialog = $("logs-dialog"); let paused = null, expanded = null, docked = false, pinning = false, preferencesLoaded = false, preferencesLoading = null, preferenceSaveRunning = false, preferenceSaveQueued = false; + const filters = ["log-vhost", "log-method", "log-status", "log-outcome", "log-filter"]; + const columns = [ + { id: "time", label: "time", value: e => new Date(e.time_ms).toLocaleTimeString(locale()) }, + { id: "request", label: "request", value: e => `${e.method} ${e.path}` }, + { id: "host", label: "logHost", value: e => e.host }, + { id: "source_ip", label: "sourceIp", value: e => e.client_ip || "-" }, + { id: "vhost", label: "vhVhost", value: e => e.vhost || "-" }, + { id: "backend", label: "backend", value: e => e.backend || "-" }, + { id: "status", label: "status", value: e => e.status }, + { id: "duration", label: "duration", value: e => `${e.duration_ms} ms` }, + { id: "cache", label: "cache", value: e => e.cache || "-" }, + { id: "outcome", label: "outcome", value: e => t(e.outcome) }, + { id: "transport", label: "logTransport", value: e => e.transport ? `${e.transport}${e.http_version ? ` / HTTP ${e.http_version}` : ""}` : "-" }, + { id: "websocket", label: "logWebSocket", value: e => [e.websocket_bootstrap, e.websocket_phase, e.websocket_error, e.h3_reset_code].filter(Boolean).join(" / ") || "-" }, + { id: "upstream_error", label: "logUpstreamError", value: e => e.upstream_error || "-" }, + ]; + const defaultColumns = () => new Set(columns.map(column => column.id)); + let visibleColumns = defaultColumns(); + function source() { return paused || expanded || lastLogs || { entries: [] }; } + function mergeExpanded(data) { + if (!expanded || paused || !data?.entries) return; + const seen = new Set(); + const entries = []; + for (const entry of [...data.entries, ...(expanded.entries || [])]) { + const key = entry.id ?? `${entry.time_ms}:${entry.method}:${entry.host}:${entry.path}:${entry.status}`; + if (seen.has(key)) continue; + seen.add(key); entries.push(entry); + if (entries.length >= 4096) break; + } + expanded = { ...expanded, ...data, entries, capacity: 4096, window: 4096 }; + } + function syncExpandedButton() { + const button = $("logs-load-history"); + button.textContent = t(expanded ? "logExpandedBuffer" : "logExpandBuffer"); + button.dataset.i18n = expanded ? "logExpandedBuffer" : "logExpandBuffer"; + button.disabled = Boolean(expanded); + } + function filtered() { + const query = $("log-filter").value.trim().toLowerCase(), outcome = $("log-outcome").value; + return source().entries.filter(e => (!$("log-vhost").value || e.vhost === $("log-vhost").value) + && (!$("log-method").value || e.method === $("log-method").value) + && (!$("log-status").value || String(e.status).startsWith($("log-status").value)) + && (outcome === "all" || (outcome === "errors" ? e.status >= 400 : e.cache === outcome)) + && `${e.method} ${e.path} ${e.host} ${e.client_ip || ""} ${e.vhost || ""} ${e.backend || ""} ${e.transport || ""} ${e.http_version || ""} ${e.websocket_bootstrap || ""} ${e.websocket_phase || ""} ${e.websocket_error || ""} ${e.h3_reset_code || ""} ${e.upstream_error || ""}`.toLowerCase().includes(query)); + } + function syncHeaders() { + dialog.querySelectorAll("th[data-log-column]").forEach(header => { header.hidden = !visibleColumns.has(header.dataset.logColumn); }); + } + function syncColumnPicker() { + const picker = $("log-column-options"); + picker.replaceChildren(...columns.map(column => { + const label = el("label", undefined, "check log-column-option"); + const input = document.createElement("input"); input.type = "checkbox"; input.checked = visibleColumns.has(column.id); input.dataset.column = column.id; + const text = el("span", t(column.label)); text.dataset.i18n = column.label; + input.addEventListener("change", () => { + if (!input.checked && visibleColumns.size === 1) { input.checked = true; toast(t("logColumnsRequired"), true); return; } + if (input.checked) visibleColumns.add(column.id); else visibleColumns.delete(column.id); + syncHeaders(); render(); void savePreferences(); + }); + label.append(input, text); return label; + })); + } + function applyPreferences(preferences) { + if (!preferences) return; + const selected = Array.isArray(preferences.live_log_columns) ? preferences.live_log_columns.filter(id => columns.some(column => column.id === id)) : []; + visibleColumns = selected.length ? new Set(selected) : defaultColumns(); preferencesLoaded = true; + syncColumnPicker(); syncHeaders(); render(); + } + async function loadPreferences(force = false) { + if (!authenticated || (preferencesLoaded && !force)) return; + if (preferencesLoading) return preferencesLoading; + preferencesLoading = (async () => { + try { applyPreferences(await window.rustProxyPreferences.load(force)); } + catch (error) { if (error.status !== 401) toast(error.message, true); } + finally { preferencesLoading = null; } + })(); + return preferencesLoading; + } + async function savePreferences() { + if (!authenticated) return; + preferenceSaveQueued = true; + if (preferenceSaveRunning) return; + preferenceSaveRunning = true; + try { + while (preferenceSaveQueued && authenticated) { + preferenceSaveQueued = false; + const selected = columns.filter(column => visibleColumns.has(column.id)).map(column => column.id); + const preferences = await window.rustProxyPreferences.update({ live_log_columns: selected }); + if (!authenticated) return; + if (!preferenceSaveQueued) applyPreferences(preferences); + } + } catch (error) { if (error.status !== 401) toast(error.message, true); } + finally { preferenceSaveRunning = false; } + } + function render(data = null) { + if (data) mergeExpanded(data); + if (!dialog.open) return; + const selector = $("log-vhost"), selected = selector.value, ids = new Set([...(envelope?.config.vhosts || []).map(v => v.id), ...source().entries.map(e => e.vhost).filter(Boolean)]); + const all = el("option", t("logAllVhosts")); all.value = ""; selector.replaceChildren(all, ...[...ids].sort().map(id => { const o = el("option", id); o.value = id; return o; })); if (ids.has(selected)) selector.value = selected; + const entries = filtered(), rows = entries.map(e => { + const row = el("tr"); const hints = []; + if (e.cache_reason) hints.push(window.rustProxyCachePanel?.reason(e.cache_reason) || e.cache_reason); + if (e.upstream_error) hints.push(`upstream: ${e.upstream_error}`); + row.title = hints.join(" · "); + for (const column of columns) { + if (!visibleColumns.has(column.id)) continue; + const value = column.value(e), cell = el("td", String(value)); cell.title = String(value); + if (column.id === "status") cell.className = e.status < 400 ? "status-good" : "status-error"; + if (column.id === "cache" && e.cache === "HIT") cell.className = "status-good"; + row.append(cell); + } + return row; + }); + syncHeaders(); $("logs").replaceChildren(...rows); $("empty-logs").hidden = rows.length > 0; $("log-count").textContent = `${entries.length} / ${source().entries.length} | ${t(paused ? "logPaused" : "live")}`; + $("logs-pause").textContent = t(paused ? "logResume" : "logPause"); $("logs-pin").textContent = t(docked ? "logUndock" : "logPin"); syncExpandedButton(); + } + function open(vhost) { + if (!authenticated) return; + if (!preferencesLoaded) void loadPreferences(); + if (vhost) { const opt = el("option", vhost); opt.value = vhost; $("log-vhost").append(opt); $("log-vhost").value = vhost; } + if (!dialog.open) { if (docked) dialog.show(); else dialog.showModal(); } + render(); + } + syncColumnPicker(); syncHeaders(); + $("logs-open").addEventListener("click", () => open()); $("logs-close").addEventListener("click", () => dialog.close()); + $("logs-load-history").addEventListener("click", async () => { + const button = $("logs-load-history"); button.disabled = true; + try { + const snapshot = await api("/api/logs?limit=4096"); + expanded = { ...snapshot, capacity: 4096, window: 4096 }; render(); + toast(t("logBufferExpanded").replace("{count}", String(snapshot.entries?.length || 0))); + } catch (error) { toast(error.message, true); } + finally { button.disabled = Boolean(expanded); } + }); + $("logs-pause").addEventListener("click", () => { paused = paused ? null : structuredClone(source()); render(); }); + $("logs-pin").addEventListener("click", () => { pinning = true; dialog.close(); docked = !docked; dialog.classList.toggle("docked", docked); if (docked) dialog.show(); else dialog.showModal(); pinning = false; render(); }); + dialog.addEventListener("close", () => { if (pinning) return; expanded = null; paused = null; syncExpandedButton(); }); + for (const id of filters) $(id).addEventListener(id === "log-filter" ? "input" : "change", render); + $("export-logs").addEventListener("click", () => { const url = URL.createObjectURL(new Blob([JSON.stringify({ entries: filtered(), capacity: source().capacity, filtered: true }, null, 2) + "\n"], { type: "application/json" })); const a = el("a"); a.href = url; a.download = "RustProxy-access-log.json"; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }); + window.rustProxyLogs = { render, open, loadPreferences, applyPreferences, reset() { dialog.close(); paused = null; expanded = null; preferencesLoaded = false; preferencesLoading = null; preferenceSaveQueued = false; visibleColumns = defaultColumns(); syncColumnPicker(); syncHeaders(); for (const id of filters) $(id).value = id === "log-outcome" ? "all" : ""; syncExpandedButton(); } }; +})(); + +// Reusable ACL profiles. They are intentionally composed with, not substituted +// for, the per-vhost policy so attaching a profile cannot weaken a vhost rule. +(() => { + let editing = null, dirty = false; + const form = $("acl-profile-form"); + const split = value => value.split(/[\s,]+/).map(v => v.trim()).filter(Boolean); + const ipEnabled = $("acl-profile-ip-enabled"), aclDefault = $("acl-profile-default"), rules = $("acl-profile-rules"), ruleRows = $("acl-profile-rule-rows"); + const addButton = $("acl-profile-rule-add"), importButton = $("acl-profile-import-rules"); + const i18n = (tag, key) => { const node = el(tag, t(key)); node.dataset.i18n = key; return node; }; + function addRule(rule = { action: "allow", network: "" }, mark = true) { + if (ruleRows.children.length >= 128) { toast(t("vhRowLimit"), true); return; } + const row = el("div", undefined, "acl-rule acl-profile-rule"); + const actionLabel = el("label"); actionLabel.append(i18n("span", "aclAction")); + const actionSelect = el("select"); actionSelect.className = "acl-profile-rule-action"; + for (const value of ["allow", "deny"]) { const option = el("option", t(value === "allow" ? "aclAllow" : "vhDeny")); option.value = value; actionSelect.append(option); } + actionSelect.value = rule.action || "allow"; actionLabel.append(actionSelect); + const networkLabel = el("label"); networkLabel.append(i18n("span", "aclNetwork")); + const network = el("input"); network.className = "acl-profile-rule-network"; network.type = "text"; network.required = true; network.maxLength = 64; network.autocomplete = "off"; network.spellcheck = false; network.placeholder = "10.0.0.0/8"; network.value = rule.network || ""; networkLabel.append(network); + const remove = action(t("remove"), () => { row.remove(); syncRulesText(); dirty = true; syncIp(); }); remove.classList.add("acl-profile-rule-remove"); + row.append(actionLabel, networkLabel, remove); ruleRows.append(row); + if (mark) { syncRulesText(); dirty = true; } + syncIp(); + } + function collectRuleRows() { + return [...ruleRows.children].map(row => ({ + action: row.querySelector(".acl-profile-rule-action").value, + network: row.querySelector(".acl-profile-rule-network").value.trim(), + })); + } + function syncRulesText() { rules.value = collectRuleRows().map(rule => `${rule.action} ${rule.network}`.trimEnd()).join("\n"); } + function renderRuleRows(values) { ruleRows.replaceChildren(); for (const rule of values || []) addRule(rule, false); syncRulesText(); syncIp(); } + function importRules() { + try { renderRuleRows(parseRules(rules.value, rules)); dirty = true; toast(t("aclProfileRulesImported")); } + catch (error) { toast(error.message, true); } + } + function syncIp() { + const disabled = !ipEnabled.checked; + aclDefault.disabled = disabled; rules.disabled = disabled; addButton.disabled = disabled; importButton.disabled = disabled; + for (const node of ruleRows.querySelectorAll("input,select,button")) node.disabled = disabled; + } + function clear() { + editing = null; dirty = false; form.reset(); ipEnabled.checked = false; aclDefault.value = "deny"; $("acl-profile-country-mode").value = "off"; $("acl-profile-id").readOnly = false; $("acl-profile-editing").textContent = t("aclProfileNew"); renderRuleRows([]); rules.value = ""; syncIp(); + } + function edit(profile) { + editing = profile.id; dirty = false; $("acl-profile-id").value = profile.id; $("acl-profile-id").readOnly = true; $("acl-profile-name").value = profile.name || profile.id; + ipEnabled.checked = !!profile.acl; aclDefault.value = profile.acl?.default || "deny"; renderRuleRows(profile.acl?.rules || []); + $("acl-profile-country-mode").value = profile.country_access?.mode || "off"; $("acl-profile-countries").value = (profile.country_access?.countries || []).join("\n"); + $("acl-profile-editing").textContent = t("aclProfileEdit"); dirty = false; syncIp(); + } + function render() { + if (!envelope) return; + const profiles = envelope.config.acl_profiles || [], list = $("acl-profile-list"); + const cards = profiles.map(profile => { + const box = el("div", undefined, "entity"); + const top = el("div", undefined, "entity-top"); top.append(el("span", profile.name || profile.id, "entity-title"), el("small", profile.id)); box.append(top); + const pieces = []; + if (profile.acl) pieces.push(`${t("aclDefault")}: ${t(profile.acl.default || "deny")} · ${(profile.acl.rules || []).length} ${t("aclProfileIpRulesShort")}`); + else pieces.push(t("aclProfileIpOff")); + const countries = profile.country_access?.countries || []; + pieces.push(profile.country_access?.mode && profile.country_access.mode !== "off" ? `${t("vhCountryMode")}: ${t(profile.country_access.mode === "block" ? "vhCountryBlock" : "vhCountryAllow")} · ${countries.join(", ") || "-"}` : t("aclProfileCountriesOff")); + const users = (envelope.config.vhosts || []).filter(v => v.acl_profile_id === profile.id).map(v => v.id); + for (const agent of envelope.config.docker_agents?.agents || []) { + if (agent.acl_profile_id === profile.id) users.push(`docker-agent:${agent.id}`); + for (const assignment of agent.assignments || []) if (assignment.acl_profile_id === profile.id) users.push(`docker:${agent.id}/${assignment.hostname || assignment.service_key}`); + } + if (users.length) pieces.push(`${t("aclProfileAttached")}: ${users.join(", ")}`); + box.append(el("p", pieces.join("\n"), "entity-details")); + const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => edit(profile)), action(t("remove"), async () => { + if (users.length) { toast(`${t("aclProfileInUse")}: ${users.join(", ")}`, true); return; } + if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.acl_profiles = (next.acl_profiles || []).filter(item => item.id !== profile.id); if (await saveSection("acl-profiles", next.acl_profiles || [])) clear(); + })); box.append(actions); return box; + }); + list.replaceChildren(...(cards.length ? cards : [el("p", t("aclProfilesEmpty"), "empty")])); + if (!dirty && editing) { const current = profiles.find(p => p.id === editing); if (current) edit(current); else clear(); } + } + form.addEventListener("input", () => { dirty = true; }); form.addEventListener("change", () => { dirty = true; }); + ruleRows.addEventListener("input", syncRulesText); ruleRows.addEventListener("change", syncRulesText); + ipEnabled.addEventListener("change", () => { if (ipEnabled.checked && !ruleRows.children.length) addRule(); syncIp(); }); + addButton.addEventListener("click", () => addRule()); importButton.addEventListener("click", importRules); + form.addEventListener("submit", async event => { + event.preventDefault(); if (!envelope || busy) return; + try { + await withFormSubmitState(form, event.submitter, async () => { + const id = $("acl-profile-id").value.trim(), name = $("acl-profile-name").value.trim(); + const countries = [...new Set(split($("acl-profile-countries").value).map(v => v.toUpperCase()))]; + if (countries.some(v => !/^[A-Z]{2}$/.test(v))) throw fieldError($("acl-profile-countries"), t("aclProfileCountryInvalid")); + // If the bulk textarea was edited directly, apply it before saving instead of + // silently ignoring pasted content. The parser reports the exact bad line. + const visualText = collectRuleRows().map(rule => `${rule.action} ${rule.network}`.trimEnd()).join("\n"); + if (ipEnabled.checked && normalizeAclRuleText(rules.value).trim() !== normalizeAclRuleText(visualText).trim()) renderRuleRows(parseRules(rules.value, rules)); + const aclRules = collectRuleRows(); + if (ipEnabled.checked && aclRules.some(rule => !rule.network)) { const input = [...ruleRows.querySelectorAll(".acl-profile-rule-network")].find(node => !node.value.trim()); throw fieldError(input || rules, t("ruleSyntax")); } + const profile = { id, name, acl: ipEnabled.checked ? { default: aclDefault.value, rules: aclRules } : null, country_access: { mode: $("acl-profile-country-mode").value, countries } }; + const next = structuredClone(envelope.config); next.acl_profiles ||= []; + const index = next.acl_profiles.findIndex(item => item.id === editing); + if (index < 0) { if (next.acl_profiles.some(item => item.id === id)) throw fieldError($("acl-profile-id"), t("aclProfileDuplicate")); next.acl_profiles.push(profile); } else next.acl_profiles[index] = profile; + if (await saveSection("acl-profiles", next.acl_profiles || [])) { editing = id; dirty = false; render(); } + }); + } catch (error) { toast(error.message, true); } + }); + $("acl-profile-new").addEventListener("click", clear); $("acl-profile-reset").addEventListener("click", clear); + window.rustProxyAclProfiles = { render, reset: clear }; + clear(); if (envelope) render(); +})(); diff --git a/src/admin/web/redirects.js b/src/admin/web/redirects.js new file mode 100644 index 0000000..eddd25e --- /dev/null +++ b/src/admin/web/redirects.js @@ -0,0 +1,108 @@ +"use strict"; +(() => { + const form = $("redirect-form"), dialog = $("redirect-dialog"), list = $("redirect-list"), rules = $("redirect-rules"); + if (!form || !dialog || !list || !rules) return; + let editing = null, revision = null, dirty = false, readRedirectBindings = () => [], readRedirectHosts = () => []; + const yes = id => $(id).checked; + const value = id => $(id).value.trim(); + const option = (value, key) => { const node = el("option", t(key)); node.value = value; return node; }; + const defaults = () => ({ + id: "", name: "", enabled: true, http2_enabled: true, http3_enabled: true, + frontend: { hosts: [], listener_ids: [] }, certificate_id: null, + rules: [{ id: "root", enabled: true, source_scheme: "any", match_type: "prefix", path_prefix: "/", target_type: "url", target: "https://example.com/", target_scheme: "preserve", path_mode: "append", query_mode: "preserve", status_code: 308, cache_seconds: 0 }] + }); + + function addRule(rule = {}, index = rules.children.length) { + const data = { ...defaults().rules[0], ...rule }; + const box = el("fieldset", undefined, "redirect-rule"); box.dataset.redirectRule = "1"; + const legend = el("legend", `${t("redirectRule")} ${index + 1}`); box.append(legend); + const grid = el("div", undefined, "vh-grid"); + const input = (labelKey, name, type = "text") => { const label = el("label"); label.append(el("span", t(labelKey))); const node = el("input"); node.type = type; node.dataset.field = name; label.append(node); grid.append(label); return node; }; + const select = (labelKey, name, values) => { const label = el("label"); label.append(el("span", t(labelKey))); const node = el("select"); node.dataset.field = name; for (const [v, k] of values) node.append(option(v, k)); label.append(node); grid.append(label); return node; }; + const check = (labelKey, name) => { const label = el("label", undefined, "check"); const node = el("input"); node.type = "checkbox"; node.dataset.field = name; label.append(node, el("span", t(labelKey))); grid.append(label); return node; }; + const id = input("redirectRuleId", "id"); id.required = true; id.maxLength = 64; id.pattern = "[A-Za-z0-9_-]{1,64}"; id.value = data.id; + const enabled = check("enabled", "enabled"); enabled.checked = data.enabled !== false; + const sourceScheme = select("redirectSourceScheme", "source_scheme", [["any", "redirectSchemeAny"], ["http", "redirectSchemeHttp"], ["https", "redirectSchemeHttps"]]); sourceScheme.value = data.source_scheme; + const matchType = select("redirectMatchType", "match_type", [["prefix", "redirectMatchPrefix"], ["exact", "redirectMatchExact"]]); matchType.value = data.match_type; + const prefix = input("redirectSourcePath", "path_prefix"); prefix.required = true; prefix.maxLength = 512; prefix.value = data.path_prefix; + const targetType = select("redirectTargetType", "target_type", [["url", "redirectTargetUrl"], ["host", "redirectTargetHost"], ["path", "redirectTargetPath"]]); targetType.value = data.target_type; + const target = input("redirectTarget", "target"); target.required = true; target.maxLength = 2048; target.value = data.target; + const targetScheme = select("redirectTargetScheme", "target_scheme", [["preserve", "redirectSchemePreserve"], ["http", "redirectSchemeHttp"], ["https", "redirectSchemeHttps"]]); targetScheme.value = data.target_scheme; + const pathMode = select("redirectPathMode", "path_mode", [["append", "redirectPathAppend"], ["strip_prefix", "redirectPathStrip"], ["replace", "redirectPathReplace"]]); pathMode.value = data.path_mode; + const queryMode = select("redirectQueryMode", "query_mode", [["preserve", "redirectQueryPreserve"], ["drop", "redirectQueryDrop"]]); queryMode.value = data.query_mode; + const status = select("redirectStatus", "status_code", [["301", "redirect301"], ["302", "redirect302"], ["303", "redirect303"], ["307", "redirect307"], ["308", "redirect308"]]); status.value = String(data.status_code); + const cache = input("redirectCacheSeconds", "cache_seconds", "number"); cache.min = "0"; cache.max = "31536000"; cache.step = "1"; cache.value = String(data.cache_seconds || 0); + const remove = el("button", t("remove"), "quiet"); remove.type = "button"; remove.addEventListener("click", () => { box.remove(); renumber(); changed(); }); + const actions = el("div", undefined, "form-actions"); actions.append(remove); grid.append(actions); box.append(grid); rules.append(box); syncRule(box); targetType.addEventListener("change", () => syncRule(box)); + } + function syncRule(box) { + const kind = box.querySelector('[data-field="target_type"]').value; + const scheme = box.querySelector('[data-field="target_scheme"]').closest("label"); scheme.hidden = kind !== "host"; + const target = box.querySelector('[data-field="target"]'); target.placeholder = kind === "url" ? "https://app.example.com/" : kind === "host" ? "app.example.com:8443" : "/new-path"; + } + function renumber() { [...rules.children].forEach((box, i) => { const legend = box.querySelector("legend"); if (legend) legend.textContent = `${t("redirectRule")} ${i + 1}`; }); } + function changed() { dirty = true; } + async function allowDiscard() { return !dirty || await appConfirm(t("redirectUnsaved"), { danger: true, confirmLabel: t("discardAction") }); } + function listenerChoices(selected = []) { + const holder = $("redirect-listeners"); holder.replaceChildren(); + const control = window.rustProxyIntegrations.bindings(holder, selected, !editing); readRedirectBindings = control.read; + const h2 = $("redirect-http2"), h3 = $("redirect-http3"); + control.onChange(() => { + const capabilities = control.capabilities(); + h2.disabled = !capabilities.http2; h3.disabled = !capabilities.http3; + if (!capabilities.http2) h2.checked = false; + if (!capabilities.http3) h3.checked = false; + }); + } + function certificateChoices(selected) { + const select = $("redirect-certificate"); select.replaceChildren(option("", "vhCertificateDefault")); + for (const cert of envelope?.config.tls?.certificates || []) { const node = el("option", cert.id); node.value = cert.id; select.append(node); } + select.value = selected || ""; + } + function load(item = defaults()) { + editing = item.id || null; revision = envelope?.revision ?? null; dirty = false; + $("redirect-editing").textContent = editing ? editing : t("redirectNewLabel"); + $("redirect-id").value = item.id || ""; $("redirect-id").disabled = Boolean(editing); + $("redirect-name").value = item.name || ""; + const hostHolder = $("redirect-hosts-editor"); hostHolder.replaceChildren(); readRedirectHosts = window.rustProxyIntegrations.hostnames(hostHolder, item.frontend?.hosts || [], { labelKey: "redirectHosts", helpKey: "vhHostsHelp", requiredKey: "redirectHostsRequired", onChange: changed }); + $("redirect-enabled").checked = item.enabled !== false; $("redirect-http2").checked = item.http2_enabled !== false; $("redirect-http3").checked = item.http3_enabled !== false; + listenerChoices(item.frontend?.listener_ids || []); certificateChoices(item.certificate_id); + rules.replaceChildren(); for (const [i, rule] of (item.rules?.length ? item.rules : defaults().rules).entries()) addRule(rule, i); + dialog.showModal(); + } + function readRule(box) { + const get = name => box.querySelector(`[data-field="${name}"]`); + return { id: get("id").value.trim(), enabled: get("enabled").checked, source_scheme: get("source_scheme").value, match_type: get("match_type").value, path_prefix: get("path_prefix").value.trim(), target_type: get("target_type").value, target: get("target").value.trim(), target_scheme: get("target_scheme").value, path_mode: get("path_mode").value, query_mode: get("query_mode").value, status_code: Number(get("status_code").value), cache_seconds: Number(get("cache_seconds").value) || 0 }; + } + function collect() { + const id = value("redirect-id"), name = value("redirect-name") || id, hosts = readRedirectHosts(); + const listener_ids = readRedirectBindings(); + if (!hosts.length) throw new Error(t("redirectHostsRequired")); if (!listener_ids.length) throw new Error(t("redirectListenersRequired")); + const values = [...rules.querySelectorAll('[data-redirect-rule="1"]')].map(readRule); if (!values.length) throw new Error(t("redirectRulesRequired")); + return { id, name, enabled: yes("redirect-enabled"), http2_enabled: yes("redirect-http2"), http3_enabled: yes("redirect-http3"), frontend: { hosts, listener_ids }, certificate_id: value("redirect-certificate") || null, rules: values }; + } + function render() { + if (!envelope) return; const query = ($("redirect-search").value || "").trim().toLowerCase(); + const entries = (envelope.config.redirects || []).filter(item => !query || [item.id, item.name, ...(item.frontend?.hosts || []), ...(item.rules || []).map(rule => rule.target)].join(" ").toLowerCase().includes(query)); + list.replaceChildren(...entries.map(item => { + const card = el("article", undefined, "vhost-card redirect-card"); const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line"); + title.append(el("strong", item.name || item.id, "entity-title")); if (item.enabled === false) title.append(el("span", t("vhDisabled"), "pill")); identity.append(title, el("div", (item.frontend?.hosts || []).join(" · "), "vhost-hosts")); head.append(identity); + const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => load(structuredClone(item))), action(t("remove"), async () => { if (busy || !(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = (envelope.config.redirects || []).filter(value => value.id !== item.id); if (await saveSection("redirects", next)) { if (editing === item.id) dialog.close(); render(); } })); head.append(actions); card.append(head); + const targets = [...new Set((item.rules || []).filter(rule => rule.enabled !== false).map(rule => rule.target).filter(Boolean))]; + const targetSummary = targets.slice(0, 2).join(" · ") + (targets.length > 2 ? ` · +${targets.length - 2}` : ""); + const detail = el("div", undefined, "vhost-detail-strip"); for (const [k, v] of [["redirectTarget", targetSummary || "-"], ["redirectRules", String((item.rules || []).length)], ["redirectListeners", (item.frontend?.listener_ids || []).join(", ")], ["vhCertificate", item.certificate_id || t("vhCertificateDefault")]]) { const node = el("div", undefined, "vhost-detail"); node.append(el("span", t(k), "small-label"), el("b", v)); detail.append(node); } card.append(detail); return card; + })); + if (!entries.length) list.append(el("p", t(query ? "redirectSearchEmpty" : "redirectEmpty"), "fine")); + } + form.addEventListener("input", changed); form.addEventListener("change", changed); + form.addEventListener("submit", async event => { + event.preventDefault(); if (!envelope || busy) return; const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { invalid.reportValidity(); return; } + if (revision !== envelope.revision) { toast(t("stale"), true); return; } + try { const item = collect(), next = structuredClone(envelope.config.redirects || []); if (!editing && next.some(value => value.id === item.id)) throw new Error(t("redirectDuplicate")); const index = next.findIndex(value => value.id === editing); if (index < 0) next.push(item); else next[index] = item; if (await saveSection("redirects", next)) { dirty = false; editing = item.id; dialog.close(); render(); } } catch (error) { toast(error.message, true); } + }); + $("redirect-new").addEventListener("click", async () => { if (await allowDiscard()) load(); }); $("redirect-search").addEventListener("input", render); $("redirect-rule-add").addEventListener("click", () => { addRule({ id: `rule-${rules.children.length + 1}` }); changed(); }); + $("redirect-close").addEventListener("click", async () => { if (await allowDiscard()) dialog.close(); }); $("redirect-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.redirects || []).find(item => item.id === editing) || defaults()) : defaults()); }); + dialog.addEventListener("cancel", event => { if (!dirty) return; event.preventDefault(); void allowDiscard().then(ok => { if (ok) dialog.close(); }); }); + window.rustProxyRedirects = { render, reset() { dialog.close(); editing = null; revision = null; dirty = false; list.replaceChildren(); rules.replaceChildren(); $("redirect-search").value = ""; } }; + if (envelope) render(); +})(); diff --git a/src/admin/web/settings.js b/src/admin/web/settings.js new file mode 100644 index 0000000..96cb041 --- /dev/null +++ b/src/admin/web/settings.js @@ -0,0 +1,197 @@ +"use strict"; +// Persistent tab DOM: switching tabs never discards unsaved form values. +(() => { + const buttons = [...document.querySelectorAll("[data-settings-tab]")]; + const defaultTab = (typeof location !== "undefined" && location.pathname === "/configuration") ? "manual-config" : "listeners"; + const requestedTab = typeof location !== "undefined" && location.pathname === "/logging" + ? "logging" + : (typeof location !== "undefined" && location.pathname === "/settings" ? new URLSearchParams(location.search).get("tab") : null); + const tabAliases = { http2: "connections" }; + const normalizedRequestedTab = tabAliases[requestedTab] || requestedTab; + let active = buttons.some(button => button.dataset.settingsTab === normalizedRequestedTab) ? normalizedRequestedTab : defaultTab; + function syncLocation() { + if (typeof location === "undefined" || !["/settings", "/configuration", "/logging"].includes(location.pathname) || !["http:", "https:"].includes(location.protocol)) return; + const url = new URL(location.href); const alreadyCanonical = url.pathname === "/settings" && url.searchParams.get("tab") === active; if (alreadyCanonical) return; + url.pathname = "/settings"; url.search = ""; url.searchParams.set("tab", active); + history.replaceState({ ...(history.state || {}), panel: "settings", settingsTab: active }, "", `${url.pathname}${url.search}${url.hash}`); + } + function select(name, focus = false, persist = true) { + if (!buttons.some(b => b.dataset.settingsTab === name)) return; active = name; + for (const b of buttons) { const chosen = b.dataset.settingsTab === name; b.setAttribute("aria-selected", String(chosen)); b.tabIndex = chosen ? 0 : -1; if (chosen && focus) b.focus(); } + document.querySelectorAll("[data-settings-page]").forEach(p => p.hidden = p.dataset.settingsPage !== name); + if (persist) syncLocation(); + } + for (const b of buttons) { + b.addEventListener("click", () => select(b.dataset.settingsTab)); b.addEventListener("keydown", event => { + const index = buttons.indexOf(b); let next; + if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % buttons.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + buttons.length - 1) % buttons.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = buttons.length - 1; else return; + event.preventDefault(); select(buttons[next].dataset.settingsTab, true); + }); + } + const cacheTabs = [...document.querySelectorAll("[data-cache-settings-tab]")]; + function selectCacheTab(name, focus = false) { + if (!cacheTabs.some(button => button.dataset.cacheSettingsTab === name)) return; + for (const button of cacheTabs) { const chosen = button.dataset.cacheSettingsTab === name; button.setAttribute("aria-selected", String(chosen)); button.tabIndex = chosen ? 0 : -1; if (chosen && focus) button.focus(); } + document.querySelectorAll("[data-cache-settings-page]").forEach(page => page.hidden = page.dataset.cacheSettingsPage !== name); + } + for (const button of cacheTabs) { + button.addEventListener("click", () => selectCacheTab(button.dataset.cacheSettingsTab)); + button.addEventListener("keydown", event => { const index = cacheTabs.indexOf(button); let next; if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % cacheTabs.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + cacheTabs.length - 1) % cacheTabs.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = cacheTabs.length - 1; else return; event.preventDefault(); selectCacheTab(cacheTabs[next].dataset.cacheSettingsTab, true); }); + } + selectCacheTab("general"); + const httpTabs = [...document.querySelectorAll("[data-http-settings-tab]")]; + function selectHttpTab(name, focus = false) { + if (!httpTabs.some(button => button.dataset.httpSettingsTab === name)) return; + for (const button of httpTabs) { const chosen = button.dataset.httpSettingsTab === name; button.setAttribute("aria-selected", String(chosen)); button.tabIndex = chosen ? 0 : -1; if (chosen && focus) button.focus(); } + document.querySelectorAll("[data-http-settings-page]").forEach(page => page.hidden = page.dataset.httpSettingsPage !== name); + } + for (const button of httpTabs) { + button.addEventListener("click", () => selectHttpTab(button.dataset.httpSettingsTab)); + button.addEventListener("keydown", event => { const index = httpTabs.indexOf(button); let next; if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % httpTabs.length; else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + httpTabs.length - 1) % httpTabs.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = httpTabs.length - 1; else return; event.preventDefault(); selectHttpTab(httpTabs[next].dataset.httpSettingsTab, true); }); + } + selectHttpTab("http2"); + let certificateApiState = null, certificateApiLoading = false; + function certificateApiCredentials() { + const values = certificateApiState?.credentials; + if (Array.isArray(values)) return values; + if (certificateApiState?.configured && certificateApiState?.key_id) return [{ key_id: certificateApiState.key_id, comment: "", created_at: certificateApiState.created_at, last_used_at: certificateApiState.last_used_at }]; + return []; + } + function certificateApiExample() { + const key = $("certificate-api-key-id").value.trim() || certificateApiCredentials()[0]?.key_id || "certbot-main"; + $("certificate-api-curl").textContent = `curl --fail --show-error --request POST \ + --header 'Authorization: Bearer $RUSTPROXY_CERT_TOKEN' \ + --header 'X-RustProxy-Certificate-Key: ${key}' \ + --header 'Content-Type: application/json' \ + --data @deploy.json \ + ${location.origin}/api/certificates/deploy + +# Reload one manual/file-backed certificate without changing its configuration: +curl --fail --show-error --request POST \ + --header 'Authorization: Bearer $RUSTPROXY_CERT_TOKEN' \ + --header 'X-RustProxy-Certificate-Key: ${key}' \ + ${location.origin}/api/certificates/CERTIFICATE_ID/reload`; + } + function renderCertificateApi() { + const state = certificateApiState, target = $("certificate-api-credentials"); + if (!state) { $("certificate-api-status").textContent = t("certificateApiUnknown"); if (target) target.replaceChildren(); certificateApiExample(); return; } + const credentials = certificateApiCredentials(), max = Number(state.max_credentials) || 32; + $("certificate-api-status").textContent = credentials.length + ? t("certificateApiConfiguredCount").replace("{count}", credentials.length).replace("{max}", max) + : t("certificateApiDisabled"); + $("certificate-api-revoke-all").disabled = !credentials.length; + if (target) { + target.replaceChildren(); + for (const credential of credentials) { + const row = el("div", undefined, "certificate-api-credential-row"); + const info = el("div", undefined, "certificate-api-credential-info"); + info.append(el("b", credential.key_id || "-", "mono")); + if (credential.comment) info.append(el("span", credential.comment, "fine")); + const lastUsed = credential.last_used_at ? new Date(Number(credential.last_used_at) * 1000).toLocaleString() : t("certificateApiNeverUsed"); + info.append(el("span", `${t("certificateApiLastUsed")}: ${lastUsed}`, "fine")); + const remove = action(t("certificateApiRevoke"), async () => { + if (!await appConfirm(t("certificateApiRevokeOneConfirm").replace("{key}", credential.key_id), { danger: true })) return; + remove.disabled = true; + try { + await api(`/api/certificate-api/${encodeURIComponent(credential.key_id)}`, { method: "DELETE" }); + certificateApiState.credentials = certificateApiCredentials().filter(value => value.key_id !== credential.key_id); + certificateApiState.configured = certificateApiState.credentials.length > 0; + renderCertificateApi(); toast(t("certificateApiRevoked")); + } catch (error) { toast(error.message, true); remove.disabled = false; } + }); + remove.classList.add("danger"); + row.append(info, remove); target.append(row); + } + } + certificateApiExample(); + } + async function loadCertificateApi(force = false) { + if (!authenticated || certificateApiLoading || (certificateApiState && !force)) return; + certificateApiLoading = true; + try { certificateApiState = await api("/api/certificate-api"); renderCertificateApi(); } + catch (error) { $("certificate-api-status").textContent = error.message; } + finally { certificateApiLoading = false; } + } + const defaults = { enabled: false, listen: "127.0.0.1:9191", path: "/metrics", allowed_cidrs: ["127.0.0.0/8", "::1/128"], bearer_token_env: null, allow_trusted_network_without_token: false, include_system: true, include_vhosts: true, include_backends: true, include_docker_vhosts: true, include_cache_domains: true, include_streams: true, include_logging: true }; + function example() { + const addr = $("prom-listen").value.trim(), path = $("prom-path").value.trim(), env = $("prom-token-env").value.trim(); + $("prom-scrape-example").textContent = `scrape_configs:\n - job_name: rustproxy\n metrics_path: ${JSON.stringify(path)}\n static_configs:\n - targets: [${JSON.stringify(addr)}]${env ? "\n authorization:\n credentials_file: /etc/prometheus/rustproxy.token" : ""}`; + } + function render() { + if (!envelope) return; const p = { ...defaults, ...envelope.config.prometheus }; + if (!settingsDirty.has("prometheus-form")) { + $("prom-enabled").checked = p.enabled; $("prom-listen").value = p.listen; $("prom-path").value = p.path; + $("prom-cidrs").value = p.allowed_cidrs.join("\n"); $("prom-token-env").value = p.bearer_token_env || ""; $("prom-trusted-no-token").checked = !!p.allow_trusted_network_without_token; + for (const [id, key] of [["system", "include_system"], ["vhosts", "include_vhosts"], ["backends", "include_backends"], ["docker-vhosts", "include_docker_vhosts"], ["domains", "include_cache_domains"], ["streams", "include_streams"], ["logging", "include_logging"]]) $(`prom-${id}`).checked = p[key]; + } + const status = latestStatus?.prometheus; + $("prom-status").textContent = status?.restart_required ? t("restartPending") : status?.active?.enabled ? `${t("prometheusActive")}: ${status.active.listen}${p.path}` : t("prometheusDisabled"); example(); + + const admin = envelope.config.administration || {}, adminStatus = latestStatus?.administration; + if (!settingsDirty.has("administration-form")) { + $("admin-listen").value = adminStatus?.locked_listen || admin.listen || adminStatus?.desired?.listen || latestStatus?.admin_listen || "127.0.0.1:9090"; + $("admin-tls").checked = !!admin.tls; + const current = admin.certificate_id || "", generated = document.createElement("option"); generated.value = ""; generated.textContent = t("administrationGeneratedCertificate"); + $("admin-certificate").replaceChildren(generated, ...(envelope.config.tls?.certificates || []).map(cert => { const o = document.createElement("option"); o.value = cert.id; o.textContent = cert.id; return o; })); $("admin-certificate").value = current; + $("admin-hosts").value = (admin.allowed_hosts || []).join("\n"); + $("admin-session-days").value = Number(admin.session_days || 30); + } + const listenLocked = !!adminStatus?.listen_locked; + $("admin-listen").disabled = listenLocked; $("admin-listen-lock-note").hidden = !listenLocked; + const syncAdminTls = () => { $("admin-certificate").disabled = !$("admin-tls").checked; }; syncAdminTls(); + $("admin-status").textContent = adminStatus?.restart_required ? t("restartPending") : adminStatus?.active?.tls ? `${t("administrationActiveHttps")}: ${adminStatus.active.listen}` : `${t("administrationActiveHttp")}: ${adminStatus?.active?.listen || latestStatus?.admin_listen || "-"}`; + const runtime = latestStatus?.runtime || {}; + for (const [id, key] of [["runtime-platform", "platform"], ["runtime-statistics-source", "statistics_source"], ["runtime-data-directory", "data_directory"], ["runtime-database-path", "database_path"]]) { + const node = $(id); if (node) node.textContent = runtime[key] || t("unavailable"); + } + + const policy = envelope.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] }; + if (!settingsDirty.has("tls-policy-form")) { $("tls-policy-12").checked = policy.tls12 !== false; $("tls-policy-13").checked = policy.tls13 !== false; $("tls-policy-ciphers").value = (policy.cipher_suites || []).join("\n"); } + const available = latestStatus?.tls?.cipher_suites || []; + const effective = latestStatus?.tls?.effective_cipher_suites || []; + $("tls-policy-effective").textContent = effective.length ? `${t("tlsEffectiveCiphers")}: ${effective.join(", ")}` : ""; + $("tls-policy-available").textContent = available.length ? `${t("tlsAvailableCiphers")}: ${available.join(", ")}` : ""; + renderCertificateApi(); loadCertificateApi(); + } + bindSettingsForm("prometheus-form", next => { + next.prometheus = { + enabled: $("prom-enabled").checked, listen: $("prom-listen").value.trim(), path: $("prom-path").value.trim(), allowed_cidrs: $("prom-cidrs").value.split(/[\n,]/).map(v => v.trim()).filter(Boolean), bearer_token_env: $("prom-token-env").value.trim() || null, allow_trusted_network_without_token: $("prom-trusted-no-token").checked, + include_system: $("prom-system").checked, include_vhosts: $("prom-vhosts").checked, include_backends: $("prom-backends").checked, include_docker_vhosts: $("prom-docker-vhosts").checked, include_cache_domains: $("prom-domains").checked, include_streams: $("prom-streams").checked, include_logging: $("prom-logging").checked + }; + }, "prometheus"); + bindSettingsForm("administration-form", next => { + const locked = !!latestStatus?.administration?.listen_locked; + next.administration = { listen: locked ? null : $("admin-listen").value.trim(), tls: $("admin-tls").checked, certificate_id: $("admin-tls").checked ? ($("admin-certificate").value || null) : null, allowed_hosts: $("admin-hosts").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean), session_days: Number($("admin-session-days").value) }; + }, "administration"); + bindSettingsForm("tls-policy-form", next => { + if (!$("tls-policy-12").checked && !$("tls-policy-13").checked) throw fieldError($("tls-policy-12"), t("tlsPolicyVersionRequired")); + next.tls ||= {}; next.tls.policy = { tls12: $("tls-policy-12").checked, tls13: $("tls-policy-13").checked, cipher_suites: $("tls-policy-ciphers").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean) }; + }, "tls"); + $("certificate-api-form").addEventListener("submit", async event => { + event.preventDefault(); const button = event.submitter || $("certificate-api-form").querySelector("button[type=submit]"); button.disabled = true; + try { + const keyId = $("certificate-api-key-id").value.trim(), token = $("certificate-api-token").value.trim(), comment = $("certificate-api-comment").value.trim(); + const payload = await api("/api/certificate-api", { method: "PUT", body: JSON.stringify({ key_id: keyId || null, token: token || null, comment }) }); + const clearToken = payload.token || ""; certificateApiState = { ...payload }; delete certificateApiState.token; delete certificateApiState.credential; + $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = clearToken; $("certificate-api-secret-wrap").hidden = !clearToken; renderCertificateApi(); toast(t("certificateApiRotated")); + } catch (error) { toast(error.message, true); } finally { button.disabled = false; } + }); + $("certificate-api-revoke-all").addEventListener("click", async () => { + if (!await appConfirm(t("certificateApiRevokeAllConfirm"), { danger: true })) return; + const button = $("certificate-api-revoke-all"); button.disabled = true; + try { await api("/api/certificate-api", { method: "DELETE" }); certificateApiState = { configured: false, credentials: [], max_credentials: certificateApiState?.max_credentials || 32, scopes: certificateApiState?.scopes || [] }; $("certificate-api-secret-wrap").hidden = true; $("certificate-api-secret").textContent = ""; renderCertificateApi(); toast(t("certificateApiRevoked")); } + catch (error) { toast(error.message, true); } finally { button.disabled = false; } + }); + $("certificate-api-key-id").addEventListener("input", certificateApiExample); + $("admin-tls").addEventListener("change", () => { $("admin-certificate").disabled = !$("admin-tls").checked; }); + $("prometheus-form").addEventListener("input", example); + $("prom-preview").addEventListener("click", async () => { + const epoch = authEpoch, button = $("prom-preview"); button.disabled = true; + try { + const response = await fetch("/api/metrics", { credentials: "same-origin", headers: { "Accept": "text/plain" } }); if (!response.ok) throw new Error(`${t("prometheusPreviewFailed")} (${response.status})`); + const text = await response.text(); if (epoch !== authEpoch || !authenticated) return; $("prom-preview-output").textContent = text.slice(0, 262144); $("prom-preview-output").hidden = false; + } catch (error) { if (epoch === authEpoch) toast(error.message, true); } finally { button.disabled = false; } + }); + window.rustProxySettings = { select, selectHttpTab, syncLocation, render, current: () => active, reset() { select("listeners", false, false); selectCacheTab("general"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = ""; $("certificate-api-credentials").replaceChildren(); $("certificate-api-secret-wrap").hidden = true; $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } }; + select(active); render(); +})(); diff --git a/src/admin/web/streams.js b/src/admin/web/streams.js new file mode 100644 index 0000000..c27ed17 --- /dev/null +++ b/src/admin/web/streams.js @@ -0,0 +1,177 @@ +"use strict"; +// Layer-4 stream management mirrors the vhost workflow while keeping TCP/UDP +// listeners separate from the HTTP policy stack. +(() => { + let editing = null, revision = null, dirty = false, initialized = false; + const chartHistory = new Map(); + const dialog = $("stream-dialog"), form = $("stream-form"), container = $("stream-fields"); + const text = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; }; + const button = (key, callback, cls = "quiet") => { const node = text("button", key, cls); node.type = "button"; node.addEventListener("click", callback); return node; }; + const field = (parent, name, key, value, type = "text", options = {}) => { + const label = el("label", undefined, type === "checkbox" ? "check vh-check" : "vh-field"); + const input = document.createElement(type === "select" ? "select" : "input"); input.name = name; + if (type === "select") for (const [val, optionKey] of options.values || []) { const option = text("option", optionKey); option.value = val; input.append(option); } + else input.type = type; + if (type === "checkbox") input.checked = Boolean(value); else input.value = value ?? ""; + for (const attr of ["min", "max", "step", "maxLength", "required", "pattern", "readOnly"]) if (Object.hasOwn(options, attr)) input[attr] = options[attr]; + if (type === "checkbox") label.append(input, text("span", key)); else label.append(text("span", key), input); + parent.append(label); return input; + }; + const group = (key, tab) => { const box = el("fieldset", undefined, "vh-group"); box.dataset.tab = tab; box.append(text("legend", key)); const body = el("div", undefined, "vh-grid"); box.append(body); container.append(box); return body; }; + const note = (parent, key) => parent.append(text("p", key, "fine vh-wide")); + const read = name => form.querySelector(`[name="${name}"]`); + const str = name => read(name).value.trim(); + const num = name => { const value = Number(str(name)); if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; }; + const yes = name => read(name).checked; + const changed = () => { dirty = true; }; + const defaults = () => ({ id: "", name: "", enabled: true, protocol: "tcp", listen: "0.0.0.0:10000", upstream: "127.0.0.1:10001", connect_timeout_seconds: 5, idle_timeout_seconds: 300, max_connections: 1024 }); + const allowDiscard = async () => { if (!dirty) return true; if (!(await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") }))) return false; dirty = false; return true; }; + + function activateTab(name) { + document.querySelectorAll("#stream-tabs button").forEach(node => { node.classList.toggle("active", node.dataset.tab === name); node.setAttribute("aria-pressed", String(node.dataset.tab === name)); }); + container.querySelectorAll(".vh-group").forEach(groupNode => groupNode.hidden = groupNode.dataset.tab !== name); + } + function initTabs() { + const tabs = $("stream-tabs"); tabs.replaceChildren(); + for (const [name, key] of [["details", "streamDetailsTab"], ["network", "streamNetworkTab"], ["limits", "streamLimitsTab"]]) { const node = button(key, () => activateTab(name)); node.dataset.tab = name; tabs.append(node); } + activateTab("details"); + } + function socketChipField(parent, value) { + const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(text("span", "streamListen")); + const box = el("div", undefined, "host-chip-editor stream-socket-editor"), input = document.createElement("input"); input.type = "text"; input.maxLength = 128; input.placeholder = "0.0.0.0:10000 / [::]:10000"; input.setAttribute("aria-label", t("streamListenAdd")); + let current = value || ""; + const sync = () => { box.querySelectorAll(".host-chip").forEach(node => node.remove()); if (current) { const chip = el("span", undefined, "host-chip"); chip.append(el("span", current)); const remove = el("button", "×"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${current}`); remove.addEventListener("click", () => { current = ""; sync(); changed(); input.focus(); }); chip.append(remove); box.insertBefore(chip, input); } input.hidden = Boolean(current); }; + const commit = () => { const candidate = input.value.trim(); if (!candidate) return; if (candidate.length > 128 || /[\s,]/.test(candidate)) throw new Error(t("streamListenInvalid")); current = candidate; input.value = ""; sync(); changed(); }; + input.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { commit(); } catch (error) { toast(error.message, true); } } }); + input.addEventListener("blur", () => { if (!input.value.trim()) return; try { commit(); } catch (error) { toast(error.message, true); } }); + box.append(input); wrapper.append(box, text("p", "streamListenHelp", "fine")); parent.append(wrapper); sync(); + return () => { if (input.value.trim()) commit(); if (!current) throw new Error(t("streamListenRequired")); return current; }; + } + + let readListen = () => ""; + function load(value = defaults(), show = true) { + editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true; container.replaceChildren(); initTabs(); + $("stream-editing").textContent = t(editing ? "streamEditLabel" : "streamNewLabel"); + let g = group("streamEditor", "details"); + field(g, "id", "streamId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}", readOnly: Boolean(editing) }); + field(g, "name", "streamName", value.name, "text", { maxLength: 96 }); + const protocol = field(g, "protocol", "streamProtocol", value.protocol || "tcp", "select", { values: [["tcp", "streamTcp"], ["udp", "streamUdp"]] }); + field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox"); + note(g, "streamsHelp"); + + g = group("streamNetwork", "network"); + readListen = socketChipField(g, value.listen || "0.0.0.0:10000"); + field(g, "upstream", "streamUpstream", value.upstream || "127.0.0.1:10001", "text", { required: true, maxLength: 128 }); + note(g, "streamDedicatedListenerHelp"); + + g = group("streamLimits", "limits"); + const connectTimeout = field(g, "connect-timeout", "streamConnectTimeout", value.connect_timeout_seconds ?? 5, "number", { min: 1, max: 300, required: true }); + field(g, "idle-timeout", "streamIdleTimeout", value.idle_timeout_seconds ?? 300, "number", { min: 1, max: 86400, required: true }); + field(g, "max-connections", "streamMaxConnections", value.max_connections ?? 1024, "number", { min: 1, max: 65535, required: true }); + const sync = () => { connectTimeout.disabled = protocol.value !== "tcp"; }; protocol.addEventListener("change", sync); sync(); + note(g, "streamLimitsHelp"); + if (show) { activateTab("details"); if (!dialog.open) dialog.showModal(); } + $("stream-live-summary").textContent = editing ? t("streamNoLiveMetrics") : ""; + if (latestStatus) renderStatus(latestStatus); + dirty = false; + } + + function collect() { + return { + id: str("id"), name: str("name"), enabled: yes("enabled"), protocol: str("protocol"), + listen: readListen(), upstream: str("upstream"), connect_timeout_seconds: Number(str("connect-timeout") || 5), + idle_timeout_seconds: num("idle-timeout"), max_connections: num("max-connections") + }; + } + async function closeEditor() { if (!(await allowDiscard())) return false; dialog.close(); return true; } + + function render() { + if (!envelope) return; + if (!initialized) load(defaults(), false); + const configured = envelope.config.streams || []; + const configuredIds = new Set(configured.map(stream => stream.id)); + for (const id of chartHistory.keys()) if (!configuredIds.has(id)) chartHistory.delete(id); + const query = ($("stream-search")?.value || "").trim().toLowerCase(); + const streams = configured.filter(stream => !query || [stream.id, stream.name, stream.protocol, stream.listen, stream.upstream].filter(Boolean).join(" ").toLowerCase().includes(query)); + const cards = streams.map(stream => { + const box = el("article", undefined, "vhost-card stream-card"); box.dataset.streamCard = stream.id; + const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line"); + title.append(el("strong", stream.name || stream.id, "entity-title"), el("span", String(stream.protocol || "tcp").toUpperCase(), "pill live")); + if (stream.enabled === false) title.append(el("span", t("vhDisabled"), "pill")); + identity.append(title, el("div", stream.id, "vhost-hosts")); head.append(identity); + const actions = el("div", undefined, "entity-actions"); + actions.append(button("edit", async () => { if (await allowDiscard()) load(structuredClone(stream)); }), button("remove", async () => { + if (busy || !(await appConfirm(t("streamDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.streams = (next.streams || []).filter(item => item.id !== stream.id); if (await saveSection("streams", next.streams || [])) { if (editing === stream.id) load(); render(); } + })); + head.append(actions); box.append(head); + + const details = el("div", undefined, "vhost-detail-strip"); + for (const [key, value] of [["streamProtocol", String(stream.protocol || "tcp").toUpperCase()], ["streamListen", stream.listen], ["streamUpstream", stream.upstream], ["streamIdleShort", `${stream.idle_timeout_seconds}s`], ["streamMaxConnections", String(stream.max_connections)]]) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); } + box.append(details); + + const live = el("div", undefined, "vhost-live-grid stream-live-grid"); + const metric = (key, suffix) => { const item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = stream.id; live.append(item); }; + metric("status", "streamState"); metric("streamActiveSessions", "streamActive"); metric("streamSessionsTotal", "streamSessions"); metric("streamErrors", "streamErrors"); metric("streamRejected", "streamRejected"); metric("streamTransferred", "streamTransferred"); box.append(live); + + const chart = el("div", undefined, "vhost-chart stream-chart"); const chartHead = el("div", undefined, "vhost-chart-head"); chartHead.append(text("span", "streamTrafficChart", "small-label")); const chartValue = el("span", "-", "fine"); chartValue.dataset.streamChartValue = stream.id; chartHead.append(chartValue); chart.append(chartHead); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 320 54"); svg.setAttribute("preserveAspectRatio", "none"); svg.dataset.streamChart = stream.id; svg.setAttribute("aria-label", t("streamTrafficChart")); + for (const cls of ["stream-chart-rx", "stream-chart-tx"]) { const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); polyline.setAttribute("class", cls); svg.append(polyline); } + chart.append(svg); const legend = el("div", undefined, "stream-chart-legend"); legend.append(el("span", `RX · ${t("streamClientToUpstream")}`), el("span", `TX · ${t("streamUpstreamToClient")}`)); chart.append(legend); box.append(chart); + return box; + }); + $("stream-list").replaceChildren(...(cards.length ? cards : [text("p", query ? "streamSearchEmpty" : "streamEmpty", "fine")])); + if (latestStatus) renderStatus(latestStatus); + } + + function addPoint(history, rx, tx) { + history.pendingRx += rx; history.pendingTx += tx; history.pendingCount += 1; + if (history.pendingCount >= history.bucketSize) { history.points.push([history.pendingRx / history.pendingCount, history.pendingTx / history.pendingCount]); history.pendingRx = 0; history.pendingTx = 0; history.pendingCount = 0; } + if (history.points.length >= 120) { const compacted = []; for (let i = 0; i < history.points.length; i += 2) { const a = history.points[i], b = history.points[i + 1] || a; compacted.push([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]); } history.points = compacted; history.bucketSize *= 2; } + } + function renderStatus(status) { + const streamStatus = status.streams || {}, metrics = streamStatus.metrics || {}, active = new Map((streamStatus.active || []).map(item => [item.id, item])); + $("stream-restart-banner").hidden = !streamStatus.restart_required; $("stream-restart-banner").textContent = t("streamRestartPending"); + const activeList = (streamStatus.active || []).filter(item => item.enabled !== false).map(item => `${String(item.protocol || "tcp").toUpperCase()} ${item.listen} → ${item.upstream}`); + $("stream-active-summary").textContent = `${t("streamActive")}: ${activeList.join(" | ") || t("streamNone")}`; + const now = performance.now(); + for (const desired of envelope?.config.streams || []) { + const id = desired.id, live = active.get(id), data = metrics[id]; + const same = live && JSON.stringify(live) === JSON.stringify(desired); + const state = desired.enabled === false ? t("vhDisabled") : !live || !same ? t("streamPendingRestart") : t("streamRunning"); + const set = (suffix, value) => document.querySelectorAll(`[data-${suffix.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}="${CSS.escape(id)}"]`).forEach(node => node.textContent = value); + set("streamState", state); set("streamActive", data ? fmt(data.active) : "-"); set("streamSessions", data ? fmt(data.sessions_total) : "-"); set("streamErrors", data ? fmt(data.errors) : "-"); set("streamRejected", data ? fmt(data.rejected) : "-"); set("streamTransferred", data ? bytes((Number(data.bytes_in) || 0) + (Number(data.bytes_out) || 0)) : "-"); + if (!data) continue; + const history = chartHistory.get(id) || { lastTime: now, lastIn: Number(data.bytes_in) || 0, lastOut: Number(data.bytes_out) || 0, points: [], bucketSize: 1, pendingRx: 0, pendingTx: 0, pendingCount: 0 }; + const elapsed = Math.max(.001, (now - history.lastTime) / 1000), inBytes = Number(data.bytes_in) || 0, outBytes = Number(data.bytes_out) || 0; + const rx = inBytes >= history.lastIn ? (inBytes - history.lastIn) / elapsed : 0, tx = outBytes >= history.lastOut ? (outBytes - history.lastOut) / elapsed : 0; + history.lastTime = now; history.lastIn = inBytes; history.lastOut = outBytes; addPoint(history, rx, tx); chartHistory.set(id, history); + const points = history.pendingCount ? [...history.points, [history.pendingRx / history.pendingCount, history.pendingTx / history.pendingCount]] : history.points; + const peak = Math.max(1, ...points.flat()); const toPoints = index => points.map((pair, pointIndex) => `${pointIndex * 320 / Math.max(1, points.length - 1)},${50 - pair[index] / peak * 44}`).join(" "); + const svg = document.querySelector(`[data-stream-chart="${CSS.escape(id)}"]`); svg?.querySelector(".stream-chart-rx")?.setAttribute("points", toPoints(0)); svg?.querySelector(".stream-chart-tx")?.setAttribute("points", toPoints(1)); + const label = document.querySelector(`[data-stream-chart-value="${CSS.escape(id)}"]`); if (label) label.textContent = `RX ${bytes(Math.round(rx))}/s · TX ${bytes(Math.round(tx))}/s`; + } + if (editing) { const data = metrics[editing]; $("stream-live-summary").textContent = data ? `${t("streamActiveSessions")}: ${fmt(data.active)} · RX ${bytes(data.bytes_in)} · TX ${bytes(data.bytes_out)} · ${t("streamErrors")}: ${fmt(data.errors)}` : t("streamNoLiveMetrics"); } + } + + form.addEventListener("input", changed); form.addEventListener("change", changed); + form.addEventListener("submit", async event => { + event.preventDefault(); if (!envelope || busy) return; + const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { activateTab(invalid.closest(".vh-group")?.dataset.tab || "details"); invalid.reportValidity(); return; } + if (revision !== envelope.revision) { toast(t("stale"), true); return; } + try { + const value = collect(), next = structuredClone(envelope.config); next.streams ||= []; + if (!editing && next.streams.some(stream => stream.id === value.id)) throw new Error(t("streamDuplicate")); + if (!editing && next.streams.length >= 128) throw new Error(t("streamLimit")); + const index = next.streams.findIndex(stream => stream.id === editing); if (index < 0) next.streams.push(value); else next.streams[index] = value; + if (await saveSection("streams", next.streams || [])) { dirty = false; load(value, false); dialog.close(); render(); } + } catch (error) { toast(error.message, true); } + }); + $("stream-new").addEventListener("click", async () => { if (await allowDiscard()) load(); }); + $("stream-listener-add").addEventListener("click", async () => { if (await allowDiscard()) load(); }); + $("stream-search").addEventListener("input", render); + $("stream-close").addEventListener("click", closeEditor); + $("stream-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.streams || []).find(item => item.id === editing) || defaults()) : defaults()); }); + dialog.addEventListener("cancel", event => { if (!dirty) return; event.preventDefault(); void allowDiscard().then(ok => { if (ok) dialog.close(); }); }); + window.rustProxyStreams = { render, renderStatus, reset() { dialog.close(); editing = null; revision = null; dirty = false; initialized = false; chartHistory.clear(); container.replaceChildren(); $("stream-search").value = ""; $("stream-list").replaceChildren(); } }; + if (envelope) render(); if (latestStatus) renderStatus(latestStatus); +})(); diff --git a/src/admin/web/style.css b/src/admin/web/style.css new file mode 100644 index 0000000..714d16f --- /dev/null +++ b/src/admin/web/style.css @@ -0,0 +1,7354 @@ +:root { + color-scheme: dark; + --bg: #131615; + --panel: #1b1f1d; + --panel2: #202622; + --line: #323a34; + --muted: #a0aaa0; + --text: #edf0e9; + --accent: #f69b74; + --green: #bdd293; + --red: #ffaaa2; + --sidebar-bg: #101310; + --brand-secondary: #cfe0ac; + --nav-active-bg: #242b23; + --nav-hover-bg: #20251f; + --text-subtle: #879082; + --separator: #596454; + --border-hover: #89917c; + --field-bg: #131813; + --field-line: #47523f; + --placeholder: #778070; + --row-line: #2b332c; + --outline-line: #717b67; + --chart-grid: #333c33; + --code-bg: #101810; + --code-alt-bg: #101922; + --code-alt-text: #d3e4ec; + --nav-selected-bg: #34412a; + --accent-ink: #221810; + --accent-hover: #ffb090; + --selected-ink: #10251e; + --notice-text: #c9b99c; + --notice-line: #554833; + --notice-bg: #29251c; + --pill-line: #6d5a45; + --pill-live-line: #576847; + --toast-bg: #d0dfb4; + --toast-text: #1c2818; + --toast-line: #ebf2de; + --toast-error-bg: #ffd2bd; + --toast-error-line: #ff9f7a; + --toast-error-text: #42241b; + --danger-line: #644a47; + --danger-line-strong: #79514e; + --warn-line: #786647; + --blue: #8ebcff; + --transform-chart: #d7adff; + --backdrop: rgb(0 0 0 / .68); + --backdrop-soft: #070d12b8; + --shadow-mid: #0006; + --shadow-strong: #0009; + --neutral-soft: rgba(127, 127, 127, .05); + --mono: ui-monospace, SFMono-Regular, Consolas, monospace; + --sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif +} + +html[data-theme="light"] { + color-scheme: light; + --bg: #f4f6f2; + --panel: #ffffff; + --panel2: #edf1eb; + --line: #cbd3c7; + --muted: #5e685f; + --text: #1d241f; + --accent: #b85b38; + --green: #5f783f; + --red: #b64a43; + --sidebar-bg: #eef2ec; + --brand-secondary: #6b8249; + --nav-active-bg: #e1e8dc; + --nav-hover-bg: #e8ede6; + --text-subtle: #687369; + --separator: #9ba69b; + --border-hover: #839083; + --field-bg: #ffffff; + --field-line: #b7c1b5; + --placeholder: #7a867b; + --row-line: #e0e5dd; + --outline-line: #98a497; + --chart-grid: #d4dbd1; + --code-bg: #edf3e9; + --code-alt-bg: #eef3f6; + --code-alt-text: #2d4049; + --nav-selected-bg: #e1e9db; + --accent-ink: #ffffff; + --accent-hover: #a84f30; + --selected-ink: #ffffff; + --notice-text: #685b45; + --notice-line: #d4c2a5; + --notice-bg: #fff9ef; + --pill-line: #cbb28f; + --pill-live-line: #a8ba8d; + --danger-line: #d7a29d; + --danger-line-strong: #c98f89; + --warn-line: #c6b27e; + --blue: #4f77a9; + --transform-chart: #7d4fa3; + --shadow-mid: rgb(31 39 33 / .16); + --shadow-strong: rgb(31 39 33 / .26); + --neutral-soft: rgba(70, 78, 72, .05) +} + +* { + box-sizing: border-box +} + +html.app-loading { + background: var(--bg) +} + +html.app-loading body { + visibility: hidden +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--sans); + font-size: 14px; + line-height: 1.5 +} + +button, +input, +select, +textarea { + font: inherit +} + +button, +a, +input, +textarea, +select { + -webkit-tap-highlight-color: transparent +} + +button, +a { + touch-action: manipulation +} + +button { + cursor: pointer +} + +button:disabled { + opacity: .5; + cursor: wait +} + +[hidden] { + display: none !important +} + +a { + color: inherit; + text-decoration: none +} + +h1, +h2, +h3, +p { + margin: 0 +} + +button:focus-visible, +a:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 4px +} + +button { + border: 0 +} + +h2 { + font-size: 20px; + letter-spacing: -.5px +} + +h3 { + font-size: 17px; + margin-bottom: 16px +} + +.shell { + display: grid; + grid-template-columns: 218px minmax(0, 1fr); + min-height: 100vh +} + +.sidebar { + position: sticky; + top: 0; + height: 100vh; + background: var(--sidebar-bg); + border-right: 1px solid var(--line); + padding: 34px 22px; + display: flex; + flex-direction: column +} + +.brand { + display: flex; + gap: 12px; + align-items: center; + font-weight: 780; + letter-spacing: 2px; + font-size: 17px; + line-height: 1.1 +} + +.brand-mark { + font: 700 33px var(--mono); + letter-spacing: -5px; + width: 63px; + height: 61px; + border: 1px solid var(--accent); + color: var(--accent); + display: grid; + place-content: center; + position: relative +} + +.brand-mark span { + position: absolute; + font-size: 22px; + left: 29px; + top: 19px; + color: var(--brand-secondary) +} + +.edition { + font: 9px/1.6 var(--mono); + color: var(--muted); + letter-spacing: 1px; + margin: 22px 0 52px +} + +.edition span { + display: block; + color: var(--accent); + margin-top: 5px +} + +nav { + display: flex; + flex-direction: column; + gap: 9px +} + +.nav { + display: flex; + align-items: center; + gap: 13px; + text-align: left; + padding: 13px 12px; + background: transparent; + color: var(--muted); + border-left: 2px solid transparent +} + +.nav span { + font: 11px var(--mono); + opacity: .65 +} + +.nav b { + font-weight: 530 +} + +.nav.active { + background: var(--nav-active-bg); + color: var(--green); + border-left-color: var(--green) +} + +.nav:hover { + background: var(--nav-hover-bg) +} + +.sidebar-bottom { + margin-top: auto; + padding-top: 32px +} + +.small-label { + font: 10px var(--mono); + letter-spacing: 1.6px; + color: var(--muted) +} + +.sidebar-bottom p { + font-size: 11px; + line-height: 1.9; + color: var(--text-subtle); + margin: 13px 0 26px +} + +.local-tag { + font: 9px var(--mono); + color: var(--green); + display: flex; + gap: 8px; + align-items: center +} + +.local-tag i, +.signal { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--green) +} + +main { + min-width: 0; + padding: 0 44px; + max-width: 1700px; + width: 100%; + margin: auto +} + +.topbar { + height: 86px; + border-bottom: 1px solid var(--line); + display: flex; + justify-content: space-between; + align-items: center; + gap: 14px +} + +.breadcrumb { + font: 10px var(--mono); + color: var(--muted); + letter-spacing: 1px +} + +.breadcrumb span { + margin: 0 13px; + color: var(--separator) +} + +.breadcrumb b { + font-weight: 400; + color: var(--text) +} + +.top-actions { + display: flex; + gap: 12px; + align-items: center +} + +.version { + font: 10px var(--mono); + color: var(--muted) +} + +.quiet { + background: transparent; + color: var(--muted); + border: 1px solid var(--line); + padding: 8px 13px; + border-radius: 3px +} + +.quiet:hover { + color: var(--text); + border-color: var(--border-hover) +} + +.primary { + background: var(--accent); + color: var(--accent-ink); + border: 1px solid var(--accent); + font-weight: 650; + border-radius: 3px; + padding: 11px 18px; + white-space: nowrap +} + +.primary:hover { + background: var(--accent-hover) +} + +.outline { + display: inline-flex; + justify-content: center; + align-items: center; + border: 1px solid var(--outline-line); + background: transparent; + color: var(--text); + border-radius: 3px; + padding: 10px 16px; + font-size: 12px; + white-space: nowrap +} + +.outline:hover { + border-color: var(--accent) +} + +.eyebrow { + font: 10px var(--mono); + letter-spacing: 2px; + color: var(--green); + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 16px +} + +h1 { + font-size: clamp(32px, 3.6vw, 52px); + line-height: 1.1; + font-weight: 520; + letter-spacing: -2px +} + +.lead { + font-size: 13px; + line-height: 1.7; + color: var(--muted); + margin-top: 15px; + max-width: 690px +} + +.page-intro { + padding: 38px 0 28px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 25px +} + +.notice { + display: flex; + gap: 13px; + padding: 12px 15px; + font-size: 11px; + line-height: 1.65; + color: var(--notice-text); + border: 1px solid var(--notice-line); + background: var(--notice-bg); + margin-bottom: 25px; + border-radius: 3px +} + +.notice b { + font-family: var(--mono); + color: var(--accent) +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 15px; + margin-bottom: 21px +} + +.stat { + padding: 21px 22px; + border: 1px solid var(--line); + background: var(--panel); + position: relative +} + +.stat:first-child { + border-top: 2px solid var(--accent) +} + +.stat>span { + color: var(--muted); + font-size: 12px +} + +.stat strong { + display: block; + font-family: var(--mono); + font-size: clamp(24px, 2.8vw, 36px); + font-weight: 450; + letter-spacing: -1px; + margin: 10px 0 8px +} + +.stat small { + color: var(--muted); + font-size: 10px +} + +.stat:first-child small { + color: var(--green) +} + +.overview-grid { + display: grid; + grid-template-columns: minmax(0, 1.8fr) minmax(255px, 1fr); + gap: 21px; + margin-bottom: 23px +} + +.card { + border: 1px solid var(--line); + background: var(--panel); + padding: 23px; + min-width: 0 +} + +.card-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 15px; + margin-bottom: 20px +} + +.card-heading .small-label { + display: block; + margin-bottom: 8px +} + +.pill { + padding: 4px 8px; + border: 1px solid var(--pill-line); + color: var(--accent); + font: 9px var(--mono); + letter-spacing: 1px +} + +.pill.live { + border-color: var(--pill-live-line); + color: var(--green) +} + +#traffic-chart { + width: 100%; + height: 144px; + margin: 8px 0 5px +} + +.chart-grid { + stroke: var(--chart-grid); + stroke-width: 1; + fill: none; + stroke-dasharray: 2 5 +} + +#sparkline { + stroke: var(--accent); + stroke-width: 2; + fill: none; + vector-effect: non-scaling-stroke +} + +.chart-footer { + display: flex; + justify-content: space-between; + color: var(--muted); + font: 9px var(--mono); + gap: 10px +} + +.runtime-card dl { + margin: 18px 0 0 +} + +.runtime-card dl div { + display: flex; + justify-content: space-between; + gap: 12px; + margin-top: 13px; + padding-bottom: 11px; + border-bottom: 1px solid var(--line) +} + +.runtime-card dl div:last-child { + border-bottom: 0; + padding-bottom: 0 +} + +.runtime-card dt { + color: var(--muted); + font-size: 11px +} + +.runtime-card dd { + font: 12px var(--mono); + margin: 0 +} + +.table-scroll { + overflow: auto +} + +table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 11px; + white-space: nowrap +} + +th { + font: 10px var(--mono); + color: var(--muted); + font-weight: 400; + padding: 9px 12px 12px; + border-bottom: 1px solid var(--line) +} + +td { + padding: 12px; + border-bottom: 1px solid var(--row-line); + font-family: var(--mono); + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis +} + +td:first-child { + color: var(--muted) +} + +.status-good { + color: var(--green) +} + +.status-error { + color: var(--red) +} + +.meta, +.fine { + font-size: 11px; + line-height: 1.7; + color: var(--muted) +} + +.empty { + padding: 26px 4px; + color: var(--muted); + font-size: 12px +} + +.section-heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin: 25px 0 17px +} + +.section-heading h2 { + margin-top: 7px +} + +.config-columns { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); + gap: 20px +} + +.entity { + padding: 17px 0; + border-bottom: 1px solid var(--line) +} + +.entity:first-child { + padding-top: 0 +} + +.entity:last-child { + border-bottom: 0; + padding-bottom: 0 +} + +.entity-top { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px +} + +.entity-title { + font-weight: 650; + font-size: 14px +} + +.entity-details { + font: 11px/1.8 var(--mono); + color: var(--muted); + margin: 10px 0; + overflow-wrap: anywhere +} + +.entity-actions { + display: flex; + gap: 8px; + flex-wrap: wrap +} + +.entity-actions button { + padding: 5px 9px; + font-size: 10px +} + +.entity small { + font: 9px var(--mono); + color: var(--green) +} + +.edit-form label, +.login-card label { + display: block; + color: var(--muted); + font-size: 11px; + margin: 16px 0 7px +} + +input:not([type="checkbox"]):not([type="radio"]), +select, +textarea { + display: block; + width: 100%; + background: var(--field-bg); + border: 1px solid var(--field-line); + border-radius: 3px; + padding: 11px 12px; + color: var(--text); + font-size: 12px; + min-width: 0 +} + +input:not([type="checkbox"]):not([type="radio"]):focus, +select:focus, +textarea:focus { + outline: 1px solid var(--green); + border-color: var(--green) +} + +input::placeholder { + color: var(--placeholder) +} + +input[type="checkbox"], +input[type="radio"] { + display: inline-block; + width: 15px; + height: 15px; + min-width: 15px; + min-height: 15px; + padding: 0; + margin: 0; + accent-color: var(--green); + vertical-align: middle +} + +input.field-invalid, +select.field-invalid, +textarea.field-invalid { + border-color: var(--red) !important; + outline: 1px solid rgb(255 170 162 / .72); +} + +.listener-choices.field-invalid, +.host-chip-editor.field-invalid { + border-color: var(--red) !important; + box-shadow: 0 0 0 1px rgb(255 170 162 / .38); +} + +.settings-tabs button.has-unsaved::after { + content: ""; + display: inline-block; + width: 6px; + height: 6px; + margin-left: 7px; + border-radius: 50%; + background: currentColor; + vertical-align: middle; + opacity: .78; +} + +button.is-saving { + cursor: progress; +} + +.check { + display: flex !important; + align-items: center; + gap: 9px; + margin-top: 13px !important +} + +.form-actions { + display: flex; + align-items: center; + gap: 13px; + flex-wrap: wrap; + margin-top: 20px +} + +#config-editor { + font: 12px/1.7 var(--mono); + resize: vertical; + min-height: 330px; + margin-top: 16px; + tab-size: 2; + white-space: pre +} + +#settings-page-manual-config>p { + margin-top: 20px +} + +.page-footer { + display: flex; + justify-content: space-between; + gap: 15px; + padding: 30px 0; + margin-top: 30px; + border-top: 1px solid var(--line); + font: 9px var(--mono); + color: var(--muted); + letter-spacing: .5px +} + +.page-footer b { + color: var(--accent); + font-weight: 400; + margin-left: 9px +} + +.page-footer .footer-update { + color: var(--green); + text-decoration: underline; + text-underline-offset: 2px +} + +.page-footer .footer-update:hover { + color: var(--text) +} + +.login-section { + padding: 62px 0 40px; + max-width: 720px +} + +.login-section h1 { + font-size: clamp(42px, 5vw, 68px); + letter-spacing: -3px; + max-width: 640px +} + +.login-section .lead { + font-size: 15px; + max-width: 570px +} + +.login-card { + margin-top: 32px; + max-width: 470px; + background: var(--panel); + border: 1px solid var(--line); + padding: 27px +} + +.login-card>p { + font-size: 12px; + color: var(--muted); + line-height: 1.7 +} + +.command { + display: block; + padding: 12px 15px; + background: var(--code-bg); + border-left: 2px solid var(--green); + font-size: 13px; + color: var(--green); + margin: 15px 0 20px +} + +.login-card .primary { + width: 100%; + margin-top: 18px +} + +.login-card .fine { + font-size: 10px; + margin-top: 16px +} + +.login-footer { + font: 9px var(--mono); + letter-spacing: 1.7px; + color: var(--muted); + margin-top: 30px; + line-height: 2 +} + +.login-footer span { + margin: 0 11px; + color: var(--accent) +} + +.toast { + position: fixed; + bottom: 26px; + right: 26px; + max-width: min(580px, calc(100vw - 40px)); + background: var(--toast-bg); + color: var(--toast-text); + border: 1px solid var(--toast-line); + padding: 14px 20px; + font-size: 13px; + z-index: 50; + box-shadow: 0 6px 35px var(--shadow-mid); + white-space: pre-wrap; + overflow-wrap: anywhere +} + +.toast.error { + background: var(--toast-error-bg); + border-color: var(--toast-error-line); + color: var(--toast-error-text) +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0 +} + +@media(min-width:1600px) { + main { + padding: 0 58px + } +} + +@media(max-width:1150px) { + .shell { + grid-template-columns: 184px minmax(0, 1fr) + } + + .sidebar { + padding: 29px 17px + } + + main { + padding: 0 25px + } + + .stats-grid { + gap: 10px + } + + .stat { + padding: 18px 15px + } + + .card { + padding: 20px + } + + .overview-grid { + grid-template-columns: minmax(0, 1.5fr) minmax(210px, 1fr) + } +} + +@media(max-width:850px) { + .shell { + display: block + } + + .sidebar { + position: static; + height: auto; + flex-direction: row; + align-items: center; + padding: 16px 22px; + border-right: 0; + border-bottom: 1px solid var(--line); + gap: 24px + } + + .brand { + font-size: 11px; + letter-spacing: 1px; + gap: 9px + } + + .brand-mark { + width: 39px; + height: 38px; + font-size: 24px; + letter-spacing: -4px + } + + .brand-mark span { + font-size: 15px; + left: 18px; + top: 12px + } + + .edition, + .sidebar-bottom { + display: none + } + + nav { + flex-direction: row; + margin-left: auto; + gap: 5px + } + + .nav { + padding: 9px 12px; + gap: 7px; + font-size: 11px; + border-left: 0; + border-bottom: 2px solid transparent + } + + .nav.active { + border-bottom-color: var(--green) + } + + .topbar { + height: 65px + } + + .page-intro { + padding-top: 29px + } + + h1 { + font-size: 36px + } + + .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) + } + + .overview-grid { + grid-template-columns: 1fr 1fr + } + + .config-columns { + grid-template-columns: 1fr 1fr + } + + .login-section { + padding-top: 42px + } +} + +@media(max-width:600px) { + main { + padding: 0 17px + } + + .sidebar { + padding: 13px 15px; + gap: 10px + } + + .nav { + padding: 9px 7px; + font-size: 10px + } + + .nav span { + display: none + } + + .brand { + font-size: 10px + } + + .brand-mark { + width: 33px; + height: 33px; + font-size: 21px + } + + .brand-mark span { + left: 15px; + top: 11px + } + + .topbar { + height: 58px + } + + .breadcrumb { + font-size: 8px; + letter-spacing: .4px + } + + .breadcrumb span { + margin: 0 5px + } + + .top-actions { + gap: 6px + } + + .version { + display: none + } + + .quiet { + font-size: 11px; + padding: 7px 9px + } + + .page-intro { + display: block; + padding-top: 25px + } + + .page-intro .outline { + margin-top: 19px + } + + .eyebrow { + font-size: 9px; + letter-spacing: 1.2px + } + + h1 { + font-size: 34px; + letter-spacing: -1.4px + } + + .lead { + font-size: 12px + } + + .notice { + font-size: 10px + } + + .stats-grid { + gap: 10px + } + + .stat strong { + font-size: 30px + } + + .overview-grid, + .config-columns { + grid-template-columns: 1fr; + gap: 16px + } + + .card { + padding: 17px + } + + .card-heading { + gap: 10px + } + + .card-heading h2 { + font-size: 18px + } + + .meta { + font-size: 9px; + max-width: 106px; + text-align: right + } + + .section-heading h2 { + font-size: 20px + } + + .chart-footer { + font-size: 8px + } + + .page-footer { + font-size: 8px; + flex-wrap: wrap + } + + .login-section h1 { + font-size: 47px; + letter-spacing: -2px + } + + .login-card { + padding: 20px + } + + .login-footer { + font-size: 8px + } + + .login-footer span { + margin: 0 5px + } + + .toast { + right: 17px; + bottom: 17px + } + + #config-editor { + font-size: 11px + } +} + +@media(prefers-reduced-motion:reduce) { + * { + scroll-behavior: auto !important + } +} + +.brand-mark { + display: flex; + align-items: center; + justify-content: center; + letter-spacing: -3px +} + +.brand-mark span { + position: static +} + +.brand { + letter-spacing: -.6px; + font-size: 17px; + gap: 8px +} + +.brand-mark { + flex-shrink: 0; + width: 47px; + height: 47px; + font-size: 29px +} + +.route-advanced { + margin-top: 20px; + border-top: 1px solid var(--line); + padding-top: 13px +} + +.route-advanced summary { + cursor: pointer; + color: var(--green); + font-size: 12px +} + +.route-advanced textarea, +#policy-rules { + font: 11px/1.6 var(--mono); + resize: vertical +} + +.route-advanced .fine { + margin-top: 8px +} + +.session-user { + margin: 17px 0 12px +} + +.security-note { + margin: 22px 0 +} + +.audit-card { + margin-top: 24px +} + +.entity-details { + white-space: pre-wrap +} + +.login-card { + max-width: 510px +} + +.signal.status-error { + background: var(--red) +} + +@media(max-width:850px) { + .brand { + font-size: 14px + } + + .brand-mark { + width: 34px; + height: 34px; + font-size: 22px + } + + .sidebar { + gap: 10px + } + + .nav { + padding: 9px 8px + } +} + +@media(max-width:600px) { + .sidebar { + flex-wrap: wrap + } + + .brand { + font-size: 16px + } + + .sidebar nav { + width: 100%; + justify-content: space-between; + margin-top: 5px + } + + .nav { + font-size: 11px; + padding: 10px 9px; + flex: 1; + justify-content: center + } + + .sidebar { + padding: 12px 16px + } + + .login-section { + padding-top: 28px + } + + .login-section h1 { + font-size: 40px + } + + .login-card .primary { + white-space: normal + } + + .security-note { + overflow-wrap: anywhere + } +} + +@media(max-width:360px) { + .nav { + font-size: 10px; + padding: 8px 4px + } + + .sidebar { + padding-left: 12px; + padding-right: 12px + } +} + +.transport-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin: 0 0 22px +} + +.transport-grid>div { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; + border: 1px solid var(--line, #d8dfdb); + border-radius: 10px; + min-width: 0 +} + +.transport-grid span { + font-size: 12px; + opacity: .7 +} + +.transport-grid b { + font-size: 18px; + overflow-wrap: anywhere +} + +.transport-grid .transport-meta { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + margin-top: 1px +} + +.transport-grid .transport-meta small, +.transport-grid .transport-footer small { + font-size: 9px; + opacity: .7; + white-space: nowrap +} + +.transport-grid .transport-meta span, +.transport-grid .transport-footer span { + font-size: inherit; + opacity: 1 +} + +.transport-grid .transport-meta strong, +.transport-grid .transport-footer strong { + font-size: 10px; + font-weight: 700 +} + +.transport-grid .transport-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: auto +} + +.transport-details { + margin-top: 14px +} + +.transport-details>label { + display: block; + margin-top: 14px +} + +.transport-details>select, +.transport-details>textarea { + width: 100% +} + +.command { + white-space: normal; + overflow-wrap: anywhere +} + +#panel-tls .section-heading { + flex-wrap: wrap; + gap: 12px +} + +@media(max-width:600px) { + .sidebar nav { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 4px + } + + .sidebar nav .nav { + min-width: 0; + justify-content: center; + padding: 9px 5px + } + + .transport-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px + } + + .transport-grid>div { + padding: 12px + } + + .transport-grid b { + font-size: 16px + } +} + +main { + padding: 0 24px; + max-width: none +} + +.topbar { + height: 58px +} + +.sidebar { + padding-top: 24px; + padding-bottom: 22px +} + +.edition { + margin: 17px 0 28px +} + +.nav { + padding: 9px 10px +} + +nav { + gap: 5px +} + +.top-actions { + gap: 8px +} + +.language-select { + width: auto; + max-width: 160px; + padding: 6px 9px; + font-size: 11px +} + +.page-intro { + padding: 21px 0 16px; + gap: 16px +} + +.page-intro h1 { + font-size: 29px; + letter-spacing: -1px +} + +.page-intro .eyebrow { + margin-bottom: 8px; + font-size: 9px +} + +.page-intro .lead { + margin-top: 7px; + font-size: 12px +} + +.notice { + padding: 8px 11px; + margin-bottom: 14px; + font-size: 10px +} + +.stats-grid { + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 12px +} + +.stat { + padding: 12px 13px +} + +.stat>span { + font-size: 11px +} + +.stat strong { + font-size: 25px; + margin: 5px 0 3px; + line-height: 1.3 +} + +.stat small { + font-size: 9px +} + +.transport-grid { + margin-bottom: 12px !important; + gap: 10px +} + +.transport-grid>div { + padding: 9px 12px !important +} + +.overview-grid { + grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr) minmax(190px, .85fr); + gap: 12px; + margin-bottom: 12px +} + +.card { + padding: 15px +} + +h2 { + font-size: 17px +} + +h3 { + font-size: 14px; + margin-bottom: 10px +} + +.card-heading { + margin-bottom: 10px; + gap: 10px +} + +.card-heading .small-label { + margin-bottom: 5px +} + +.small-label { + font-size: 9px; + letter-spacing: 1.2px +} + +#traffic-chart { + height: 107px; + margin: 3px 0 +} + +.runtime-card dl { + margin: 8px 0 0 +} + +.runtime-card dl div { + margin-top: 7px; + padding-bottom: 6px +} + +.runtime-card dt { + font-size: 10px +} + +.runtime-card dd { + font-size: 11px +} + +.system-card h2 { + font: 23px var(--mono) +} + +.system-card h2 small { + font: 10px var(--mono); + color: var(--muted) +} + +#system-chart { + display: block; + width: 100%; + height: 65px +} + +#system-sparkline { + fill: none; + stroke: var(--green); + stroke-width: 2; + vector-effect: non-scaling-stroke +} + +.memory-line { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 9px; + color: var(--muted); + margin-top: 4px +} + +.memory-line b { + font-weight: 400; + font-family: var(--mono) +} + +progress { + display: block; + width: 100%; + height: 5px; + accent-color: var(--green); + margin: 6px 0 9px +} + +.diagnostics-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 12px +} + +.mini-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px +} + +.mini-heading h3 { + font-size: 12px; + margin: 0 +} + +.mini-metrics { + margin: 8px 0 0 +} + +.mini-metrics div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-top: 6px +} + +.mini-metrics dt { + font-size: 10px; + color: var(--muted) +} + +.mini-metrics dd { + font: 10px var(--mono); + margin: 0; + text-align: end; + overflow-wrap: anywhere +} + +.mini-live-chart-stack { + display: grid; + gap: 8px; + margin-top: 10px +} + +.mini-live-chart { + min-width: 0; + margin-top: 10px; + border-top: 1px solid var(--line); + padding-top: 8px +} + +.mini-live-chart-stack .mini-live-chart { + margin-top: 0 +} + +.mini-live-chart-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + margin-bottom: 5px; + color: var(--muted); + font: 10px var(--mono) +} + +.mini-live-chart-head b { + color: var(--text); + font-weight: 600; + text-align: right +} + +.mini-live-chart svg { + display: block; + width: 100%; + height: 60px; + overflow: visible +} + +.diagnostic-line { + fill: none; + stroke: var(--accent); + stroke-width: 2; + vector-effect: non-scaling-stroke +} + +.network-tx-line { + stroke: var(--muted) +} + +.cache-hit-line { + stroke: var(--green) +} + +.cache-miss-line { + stroke: var(--accent) +} + +.cache-bypass-line { + stroke: var(--muted) +} + +.process-rss-line { + stroke: var(--green) +} + +.mini-chart-legend { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 3px; + font: 9px var(--mono); + color: var(--muted) +} + +.mini-chart-legend span::before { + content: ""; + display: inline-block; + width: 12px; + height: 2px; + margin-right: 4px; + vertical-align: middle; + background: currentColor +} + +.cache-hit-key { + color: var(--green) +} + +.cache-miss-key { + color: var(--accent) +} + +.cache-bypass-key { + color: var(--muted) +} + +.log-column-picker { + position: relative; + font-size: 10px +} + +.log-column-picker>summary { + list-style: none; + cursor: pointer; + user-select: none; + padding: 6px 9px; + border: 1px solid var(--line); + border-radius: 4px; + min-height: 30px; + display: flex; + align-items: center +} + +.log-column-picker>summary::-webkit-details-marker { + display: none +} + +.log-column-picker[open]>summary { + border-color: var(--green) +} + +.log-column-options { + position: absolute; + z-index: 80; + inset-inline-end: 0; + top: calc(100% + 4px); + width: min(190px, calc(100vw - 24px)); + overflow: visible; + padding: 4px; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1px; + background: var(--panel2); + border: 1px solid var(--line); + border-radius: 5px; + box-shadow: 0 10px 26px var(--shadow-mid) +} + +.log-column-option { + min-width: 0; + min-height: 22px; + margin: 0 !important; + padding: 3px 5px; + gap: 7px; + border-radius: 3px; + font-size: 10px; + line-height: 1.2; + white-space: nowrap +} + +.log-column-option:hover { + background: var(--panel) +} + +@media(max-height:620px) { + .log-column-options { + top: auto; + bottom: calc(100% + 4px); + width: min(360px, calc(100vw - 24px)); + grid-template-columns: repeat(2, minmax(0, 1fr)) + } +} + +.logs-card .table-scroll { + max-height: 260px +} + +.logs-card th { + position: sticky; + top: 0; + background: var(--panel) +} + +th { + padding: 7px 8px +} + +td { + padding: 8px +} + +.page-footer { + padding: 16px 0; + margin-top: 18px +} + +.config-columns, +.settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + align-items: start +} + +.settings-grid>.card { + height: 100% +} + +.edit-form label { + margin: 10px 0 5px +} + +.edit-form input:not([type="checkbox"]):not([type="radio"]), +.edit-form select, +.edit-form textarea { + padding: 8px 10px +} + +.edit-form .check { + margin-top: 9px !important +} + +.form-actions { + gap: 9px; + margin-top: 13px +} + +.primary { + padding: 9px 14px; + font-size: 12px +} + +.quiet { + padding: 7px 10px +} + +.outline { + padding: 8px 12px +} + +.entity { + padding: 12px 0 +} + +.entity-details { + margin: 6px 0; + line-height: 1.6 +} + +.entity-actions { + gap: 6px +} + +.entity-title { + font-size: 13px +} + +.section-heading { + margin: 16px 0 12px +} + +.section-heading h2 { + margin-top: 4px +} + +.subheading { + margin-top: 17px; + margin-bottom: 7px !important +} + +.field-grid, +.switch-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 12px +} + +.pending-note { + font-size: 11px; + color: var(--accent); + padding: 9px 0 +} + +.header-rule { + display: grid; + grid-template-columns: 86px minmax(90px, 1fr) minmax(80px, 1.2fr) auto; + gap: 6px; + margin: 8px 0 +} + +.header-rule input, +.header-rule select { + padding: 7px !important; + font-size: 11px +} + +.header-rule button { + font-size: 10px; + padding: 6px +} + +.header-rules { + margin-bottom: 9px +} + +.edit-form>.fine { + margin-top: 8px +} + +.empty { + padding: 17px 3px +} + +.login-section { + padding-top: 40px +} + +.login-card { + margin-top: 23px +} + +.chart-footer { + line-height: 1.5 +} + +.fine, +.meta { + overflow-wrap: anywhere +} + +.mini-metrics dd, +.chart-footer>span { + min-width: 0 +} + +.cache-status-good { + color: var(--green) +} + +[dir="rtl"] .nav { + text-align: right; + border-left: 0; + border-right: 2px solid transparent +} + +[dir="rtl"] .nav.active { + border-right-color: var(--green) +} + +[dir="rtl"] table { + text-align: right +} + +[dir="rtl"] .entity-details, +[dir="rtl"] input[type="url"], +[dir="rtl"] #config-editor { + direction: ltr; + text-align: left +} + +@media(min-width:1800px) { + .settings-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) + } + + .overview-grid { + grid-template-columns: 1.6fr 1.1fr 1fr + } +} + +@media(max-width:1200px) { + main { + padding: 0 18px + } + + .stats-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)) + } + + .overview-grid { + grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr) + } + + .runtime-card { + grid-column: 1/-1 + } + + .runtime-card dl { + display: flex; + flex-wrap: wrap; + gap: 18px; + margin-top: 4px + } + + .runtime-card dl div { + border: 0; + padding: 0; + flex: 1; + min-width: 90px + } + + .diagnostics-grid { + gap: 10px + } + + .header-rule { + grid-template-columns: 80px 1fr auto + } + + .header-rule .header-value { + grid-column: 2; + grid-row: 2 + } + + .header-rule button { + grid-column: 3; + grid-row: 1/3 + } +} + +@media(max-width:850px) { + .sidebar { + padding: 13px 18px; + gap: 18px + } + + .sidebar nav { + flex-wrap: wrap; + gap: 4px + } + + .nav { + padding: 8px + } + + .page-intro { + padding-top: 18px + } + + .settings-grid, + .config-columns { + gap: 12px + } + + .diagnostics-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) + } + + .diagnostics-grid>.card:last-child { + grid-column: 1/-1 + } + + .topbar { + height: 54px + } +} + +@media(max-width:620px) { + main { + padding: 0 12px + } + + .sidebar { + align-items: flex-start; + flex-direction: column; + gap: 12px + } + + .sidebar nav { + width: 100%; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)) + } + + .nav { + padding: 8px 7px; + min-width: 0 + } + + .nav b { + font-size: 11px + } + + .nav span { + display: none + } + + .top-actions { + gap: 5px + } + + .breadcrumb { + font-size: 9px; + letter-spacing: 0 + } + + .breadcrumb span { + margin: 0 5px + } + + .topbar .version { + display: none + } + + .language-select { + max-width: 110px; + font-size: 10px + } + + .page-intro h1 { + font-size: 25px + } + + .page-intro { + align-items: flex-start + } + + .page-intro .lead { + font-size: 11px + } + + .page-intro .outline { + font-size: 10px; + padding: 7px; + white-space: normal; + max-width: 95px + } + + .page-intro .eyebrow { + letter-spacing: .5px; + font-size: 8px + } + + .notice { + font-size: 10px; + gap: 8px + } + + .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px + } + + .stat { + padding: 10px + } + + .stat strong { + font-size: 24px + } + + .overview-grid, + .diagnostics-grid, + .settings-grid, + .config-columns { + grid-template-columns: minmax(0, 1fr) + } + + .runtime-card, + .diagnostics-grid>.card:last-child { + grid-column: auto + } + + .runtime-card dl { + display: block + } + + .runtime-card dl div { + margin-top: 8px + } + + .logs-card>.card-heading { + align-items: flex-start; + flex-direction: column + } + + .log-tools { + width: 100% + } + + .log-tools input:not([type="checkbox"]):not([type="radio"]) { + flex: 1; + min-width: 95px; + width: 95px + } + + .log-tools select { + width: 95px + } + + .log-tools button { + white-space: normal; + max-width: 85px + } + + .card { + padding: 13px + } + + .page-footer { + flex-direction: column; + gap: 7px + } + + .field-grid { + gap: 0 9px + } + + .header-rule { + grid-template-columns: 76px minmax(0, 1fr) auto + } + + .header-rule button { + max-width: 65px; + overflow-wrap: anywhere + } + + .switch-grid { + grid-template-columns: minmax(0, 1fr) + } + + .form-actions { + gap: 8px + } + + .primary, + .outline { + white-space: normal + } + + .settings-grid h2 { + font-size: 16px + } + + .mini-metrics { + margin-top: 9px + } + + .memory-line { + font-size: 10px + } + + .login-section { + padding-top: 25px + } + + .login-card { + padding: 18px + } + + .topbar { + gap: 7px + } + + .breadcrumb b { + display: none + } +} + +.vhost-layout { + display: grid; + grid-template-columns: minmax(220px, .7fr) minmax(0, 2fr); + gap: 12px; + align-items: start +} + +.vh-form, +.vhost-layout>.card { + min-width: 0 +} + +.vh-group { + border: 1px solid var(--line); + border-radius: 6px; + margin: 0 0 10px; + padding: 10px; + min-width: 0 +} + +.vh-group legend, +.vh-group>summary { + font-size: 12px; + font-weight: 600; + padding: 0 5px; + color: var(--text) +} + +.vh-group>summary { + cursor: pointer; + margin-bottom: 6px +} + +.vh-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + align-items: start +} + +.vh-field { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + margin: 0; + font-size: 11px; + color: var(--muted) +} + +.vh-field input, +.vh-field select, +.vh-field textarea { + width: 100%; + min-width: 0; + max-width: 100%; + padding: 6px 7px; + font-size: 12px; + margin: 0; + box-sizing: border-box +} + +.vh-field textarea { + resize: vertical; + font-family: var(--mono) +} + +.vh-check { + font-size: 11px; + min-height: 28px; + margin: 0; + align-items: center; + gap: 6px +} + +.vh-check input { + flex: 0 0 auto +} + +.vh-wide { + grid-column: 1/-1; + min-width: 0 +} + +.vh-frontends, +.vh-members { + display: grid; + gap: 8px +} + +.vh-row { + padding: 8px; + border: 1px solid var(--line); + border-radius: 5px; + min-width: 0; + background: var(--bg) +} + +.vh-frontend { + display: grid; + grid-template-columns: minmax(140px, 1fr) auto minmax(120px, 1fr) auto; + align-items: center; + gap: 8px +} + +.vh-member>.quiet { + margin-top: 6px +} + +.vh-member details { + font-size: 11px +} + +.vh-member details>summary { + cursor: pointer; + color: var(--muted); + padding: 3px 0 +} + +.vh-migration { + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid var(--line); + display: grid; + gap: 6px +} + +.vh-migration label { + font-size: 11px +} + +.vh-migration select { + width: 100%; + min-width: 0 +} + +.pool-card { + margin-bottom: 12px +} + +.pool-card .fine { + margin: 0 0 8px +} + +.pool-table { + font-size: 11px; + width: 100%; + min-width: 640px +} + +.pool-table th, +.pool-table td { + padding: 7px 9px; + white-space: nowrap +} + +.pool-table td:nth-child(n+4) { + font-variant-numeric: tabular-nums; + text-align: right +} + +.pool-table th:nth-child(n+4) { + text-align: right +} + +.pool-table td:first-child, +.pool-table td:nth-child(2) { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis +} + +.vhost-layout .entity-details { + overflow-wrap: anywhere +} + +.vh-form .form-actions { + position: sticky; + bottom: 0; + background: var(--panel); + padding: 10px 0 6px; + z-index: 2 +} + +.vh-form input:disabled { + opacity: .5 +} + +@media(max-width:1150px) { + .vhost-layout { + grid-template-columns: minmax(190px, .6fr) minmax(0, 1.6fr) + } + + .vh-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) + } + + .vh-frontend { + grid-template-columns: minmax(0, 1fr) auto + } + + .vh-frontend .vh-field { + grid-column: 1/-1 + } +} + +@media(max-width:800px) { + .vhost-layout { + grid-template-columns: minmax(0, 1fr) + } + + .vh-form .form-actions { + position: static + } + + .vh-group { + padding: 8px + } + + .pool-table { + min-width: 620px + } +} + +@media(max-width:420px) { + .vh-grid { + grid-template-columns: minmax(0, 1fr) + } + + .vh-frontend { + grid-template-columns: minmax(0, 1fr) + } + + .vh-group { + padding: 6px + } + + .vh-form .form-actions { + flex-wrap: wrap + } + + .vh-field input, + .vh-field select, + .vh-field textarea { + font-size: 12px + } +} + +body { + --ui-gap: 5px; + --ui-card: 8px; + --ui-font: 12px; + --ui-control: 27px; + font-size: var(--ui-font) +} + +body[data-density="dense"] { + --ui-gap: 4px; + --ui-card: 7px; + --ui-font: 11.5px; + --ui-control: 25px +} + +body[data-density="comfortable"] { + --ui-gap: 13px; + --ui-card: 17px; + --ui-font: 14px; + --ui-control: 36px +} + +.shell { + grid-template-columns: 182px minmax(0, 1fr) +} + +main { + padding: 0 16px +} + +.sidebar { + padding: 20px 13px +} + +.sidebar nav { + margin-top: 22px +} + +.nav { + padding: 9px 10px +} + +.topbar { + height: 48px +} + +.page-intro { + padding: 13px 0 11px; + gap: 12px +} + +.page-intro h1 { + font-size: 25px; + letter-spacing: -.7px +} + +.page-intro .eyebrow { + margin-bottom: 4px +} + +.page-intro .lead { + font-size: 11px; + margin-top: 4px +} + +.notice { + margin-bottom: var(--ui-gap); + padding: 5px 9px +} + +.card { + padding: var(--ui-card) +} + +.card-heading { + margin-bottom: 8px +} + +.card-heading h2 { + font-size: 16px +} + +.card-heading .small-label { + font-size: 8px; + margin-bottom: 3px +} + +.overview-grid, +.diagnostics-grid, +.settings-grid, +.config-columns { + gap: var(--ui-gap); + margin-bottom: var(--ui-gap) +} + +.stats-grid, +.stats, +.metrics-grid { + gap: var(--ui-gap); + margin-bottom: var(--ui-gap) +} + +.stat { + padding: 11px +} + +.stat-value { + font-size: 25px +} + +.stat p, +.stat small { + font-size: 10px +} + +.transport-grid { + gap: var(--ui-gap); + margin-bottom: var(--ui-gap) !important +} + +.transport-grid>div { + padding: 6px 10px !important; + gap: 3px; + border-radius: 4px +} + +.transport-grid span { + font-size: 10px +} + +.transport-grid b { + font-size: 14px +} + +.activity-card svg { + height: 105px +} + +.system-card svg { + height: 80px +} + +.mini-metrics div { + margin-top: 4px +} + +.runtime-card dl div { + margin-top: 5px; + padding-bottom: 4px +} + +.pool-card { + margin-bottom: var(--ui-gap) +} + +.pool-table th, +.pool-table td { + padding: 5px 7px +} + +.pool-card>.fine { + font-size: 10px +} + +.pool-card .table-scroll { + max-height: 205px +} + +.page-footer { + padding: 12px 0; + margin-top: 8px +} + +.edit-form label { + margin-top: 9px +} + +.edit-form input:not([type="checkbox"]):not([type="radio"]), +.edit-form select, +.edit-form textarea, +input:not([type="checkbox"]):not([type="radio"]), +select, +textarea { + padding: 6px 8px; + font-size: 12px; + min-height: var(--ui-control) +} + + +button, +.primary, +.outline, +.quiet { + padding: 6px 10px; + font-size: 11px; + min-height: var(--ui-control) +} + +.check { + font-size: 11px +} + +.settings-shortcuts { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--line); + margin-bottom: var(--ui-gap); + flex-wrap: wrap +} + +.settings-shortcuts>div { + flex: 1; + min-width: 170px +} + +.ui-preference-control { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px +} + +.ui-preference-control select { + width: auto +} + +.vhost-layout { + display: block +} + +.vhost-row { + display: grid; + grid-template-columns: minmax(120px, 1fr) minmax(130px, 1.3fr) minmax(100px, 1fr); + gap: 3px 14px; + align-items: center; + padding: 11px +} + +.vhost-row .entity-details { + margin: 0; + font-size: 11px +} + +.vhost-row .entity-actions { + grid-column: 1/-1; + display: flex; + justify-content: flex-end; + gap: 5px; + margin-top: 3px +} + +.vh-health-summary { + grid-column: 1/-1; + color: var(--muted); + font: 10px var(--mono); + overflow-wrap: anywhere +} + +.vh-migration { + max-width: 570px +} + +.vhost-dialog, +.logs-dialog, +.audit-dialog { + color: var(--text); + background: var(--bg); + border: 1px solid var(--line); + padding: 0; + max-width: calc(100vw - 24px); + max-height: calc(100dvh - 28px); + box-shadow: 0 18px 70px var(--shadow-strong); + border-radius: 7px +} + +.vhost-dialog { + width: 960px; + overflow: auto +} + +.vhost-dialog::backdrop, +.logs-dialog::backdrop, +.audit-dialog::backdrop { + background: var(--shadow-strong); + backdrop-filter: blur(2px) +} + +.vhost-dialog .vh-form { + border: 0; + padding: 14px +} + +.vh-form>.card-heading { + margin-bottom: 5px; + position: sticky; + top: -14px; + z-index: 3; + background: var(--panel); + padding: 9px 0 +} + +.vh-form>.card-heading h2 { + flex: 1 +} + +.vh-live-summary { + font: 10px var(--mono); + line-height: 1.7; + overflow-wrap: anywhere; + color: var(--muted); + margin: 5px 0 +} + +.vh-tabs { + display: flex; + flex-direction: row; + margin-inline-start: 0; + gap: 3px; + flex-wrap: wrap; + position: sticky; + top: 29px; + z-index: 2; + background: var(--panel); + padding: 5px 0 8px; + border-bottom: 1px solid var(--line); + margin-bottom: 9px +} + +.vh-tabs button { + width: auto; + flex: 0 1 auto; + font-size: 10px; + padding: 6px 9px +} + +.vh-tabs button.active { + color: var(--text); + background: var(--nav-selected-bg); + border-color: var(--green) +} + +.vh-group { + margin: 8px 0; + padding: 9px +} + +.vh-group[hidden] { + display: none +} + +.vh-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px +} + +.vh-pages-grid { + display: block +} + +.vh-group legend, +.vh-group>summary { + font-size: 11px +} + +.vh-form .form-actions { + position: sticky; + bottom: -14px; + background: var(--panel); + padding: 10px 0; + border-top: 1px solid var(--line); + z-index: 2 +} + +.vh-header-row { + display: grid; + grid-template-columns: 88px minmax(110px, 1fr) minmax(140px, 2fr) auto; + gap: 5px; + align-items: end; + margin: 6px 0 +} + +.vh-header-row label span { + font-size: 10px +} + +.vh-header-row button { + margin-bottom: 0 +} + +.vh-header-rows+h3 { + margin-top: 10px +} + +.page-editor { + display: grid; + grid-template-columns: minmax(105px, .9fr) minmax(115px, 1fr) minmax(105px, 1fr); + gap: 6px; + align-items: center; + padding: 7px 0; + border-bottom: 1px solid var(--line) +} + +.page-label { + font-size: 11px; + font-weight: 500 +} + +.page-source { + grid-column: 1/-1 +} + +.page-source input { + font-size: 11px; + margin: 6px 0 +} + +.page-source textarea { + font: 11px var(--mono); + min-height: 100px +} + +.page-source[hidden] { + display: none +} + +.pages-errors { + margin: 12px 0 +} + +.pages-errors>summary { + font-size: 12px; + cursor: pointer +} + +.logs-dialog { + width: 1180px; + overflow: visible +} + +.logs-dialog .logs-card { + border: 0; + padding: 0 12px; + background: var(--bg) +} + +.window-heading { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-bottom: 1px solid var(--line) +} + +.window-heading h2 { + font-size: 16px +} + +.window-tools { + margin-inline-start: auto; + display: flex; + gap: 5px +} + +.logs-dialog .logs-card>.card-heading { + display: block; + padding: 8px 0; + margin: 0 +} + +.logs-dialog .logs-card>.card-heading>div:first-child { + display: none +} + +.log-tools { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + width: 100% +} + +.log-tools select, +.log-tools input:not([type="checkbox"]):not([type="radio"]) { + width: auto; + max-width: 180px; + min-height: 30px; + font-size: 11px +} + +.log-tools input:not([type="checkbox"]):not([type="radio"]) { + flex: 1; + min-width: 110px +} + +.log-tools button { + font-size: 10px; + padding: 6px 9px +} + +.logs-dialog .table-scroll { + max-height: min(60dvh, 580px); + overflow: auto; + border-top: 1px solid var(--line) +} + +.logs-dialog table { + font: 11px var(--mono) +} + +.logs-dialog th, +.logs-dialog td { + padding: 6px 7px; + max-width: 360px; + overflow: hidden; + text-overflow: ellipsis +} + +.logs-dialog th { + position: sticky; + top: 0; + background: var(--panel) +} + +.log-footnote { + padding: 8px 12px; + font-size: 10px +} + +.logs-dialog.docked { + position: fixed; + inset: auto 12px 10px auto; + margin: 0; + width: min(1180px, calc(100vw - 24px)); + max-height: 46dvh; + z-index: 20 +} + +.logs-dialog.docked .table-scroll { + max-height: calc(46dvh - 160px) +} + +#toast { + z-index: 1000 +} + +@media(min-width:1400px) { + .overview-grid { + grid-template-columns: 1.55fr 1fr .85fr + } + + .settings-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) + } +} + +@media(max-width:1000px) { + .shell { + grid-template-columns: 166px minmax(0, 1fr) + } + + .sidebar { + padding: 18px 10px + } + + .overview-grid { + grid-template-columns: 1.3fr 1fr + } + + .runtime-card { + grid-column: 1/-1 + } + + .runtime-card dl { + display: flex; + gap: 12px + } + + .vh-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) + } +} + +@media(max-width:850px) { + .shell { + display: block + } + + .sidebar { + height: auto; + position: static; + flex-direction: row; + flex-wrap: wrap; + padding: 10px 12px + } + + .sidebar nav { + margin-top: 0; + display: flex; + flex-wrap: wrap; + gap: 2px + } + + .nav { + padding: 7px + } + + .edition, + .sidebar-bottom { + display: none + } + + .topbar { + height: 46px + } + + .vhost-dialog { + max-width: calc(100vw - 12px) + } +} + +@media(max-width:620px) { + main { + padding: 0 10px + } + + .top-actions { + gap: 4px + } + + .topbar .breadcrumb { + font-size: 8px + } + + .top-actions .quiet { + padding: 5px; + font-size: 10px + } + + .page-intro h1 { + font-size: 23px + } + + .overview-grid, + .diagnostics-grid, + .settings-grid, + .config-columns { + grid-template-columns: minmax(0, 1fr) + } + + .runtime-card, + .diagnostics-grid>.card:last-child { + grid-column: auto + } + + .runtime-card dl { + display: block + } + + .sidebar nav { + width: 100%; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)) + } + + .nav b { + font-size: 10px + } + + .vhost-row { + grid-template-columns: 1fr 1fr + } + + .vhost-row .entity-actions { + justify-content: flex-start; + flex-wrap: wrap + } + + .vh-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) + } + + .vh-header-row { + grid-template-columns: 85px 1fr + } + + .vh-header-row>:nth-child(3) { + grid-column: 1/-1 + } + + .vhost-dialog .vh-form { + padding: 9px + } + + .vh-form>.card-heading { + top: -9px + } + + .vh-tabs { + top: 29px + } + + .window-heading { + flex-wrap: wrap; + gap: 5px + } + + .window-heading h2 { + font-size: 14px + } + + .window-tools { + margin-inline-start: 0 + } + + .logs-dialog .table-scroll { + max-height: 50dvh + } + + .log-tools select { + max-width: 140px; + flex: 1 + } + + .logs-dialog.docked { + max-height: 55dvh + } + + .logs-dialog.docked .table-scroll { + max-height: calc(55dvh - 230px) + } + + .settings-shortcuts { + align-items: flex-start + } + + .ui-preference-control { + flex-wrap: wrap + } + + .page-editor { + grid-template-columns: 1fr 1fr + } + + .page-label { + grid-column: 1/-1 + } + + .page-source { + grid-column: 1/-1 + } +} + +@media(max-width:380px) { + .vh-grid { + grid-template-columns: minmax(0, 1fr) + } + + .vhost-row { + grid-template-columns: minmax(0, 1fr) + } + + .sidebar nav { + grid-template-columns: repeat(3, minmax(0, 1fr)) + } + + .topbar .breadcrumb { + display: none + } + + .topbar { + justify-content: flex-end + } + + .vh-tabs button { + font-size: 10px; + padding: 5px 7px + } +} + +main { + margin: 0 auto +} + +.stat strong { + font-size: clamp(20px, 1.8vw, 26px); + margin: 4px 0 +} + +body[data-density="dense"] .stat strong { + font-size: 22px; + margin: 2px 0 +} + +#toast[popover] { + position: fixed; + inset: auto 18px 18px auto; + margin: 0; + width: max-content; + max-width: min(520px, calc(100vw - 28px)); + padding: 12px 16px; + color: var(--text); + background: var(--panel); + border: 1px solid var(--green); + border-radius: 6px; + box-shadow: 0 10px 40px var(--shadow-strong); + overflow-wrap: anywhere +} + +#toast[popover].error { + border-color: #d57867 +} + +#toast[popover]::backdrop { + display: none +} + +.named-listener-row { + border: 1px solid var(--line); + padding: 10px; + margin: 10px 0; + min-width: 0 +} + +.named-listener-row legend { + font-size: 11px +} + +.named-listener-row .vh-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) +} + +.h3-editor { + grid-column: 1/-1; + min-width: 0 +} + +.h3-editor>label { + margin-top: 4px +} + +.h3-editor details { + padding: 7px 0 +} + +.h3-editor summary { + font-size: 11px; + cursor: pointer +} + +.h3-editor .vh-grid { + margin-top: 7px +} + +.h3-editor .fine { + margin-top: 7px +} + +.vh-frontend { + flex-wrap: wrap; + display: grid; + grid-template-columns: 2fr 1fr 1fr auto +} + +.vh-frontend>.h3-editor { + grid-row: 2; + grid-column: 1/-1 +} + +.vh-frontend>button { + grid-column: 4; + grid-row: 1 +} + +.listener-choices { + display: flex; + flex-wrap: wrap; + gap: 7px +} + +.listener-choices>.fine { + flex-basis: 100% +} + +.listener-choices>label { + border: 1px solid var(--line); + padding: 7px; + border-radius: 4px; + font-size: 11px +} + +.certificate-editors { + min-width: 0; + display: grid; + align-content: start; + gap: 12px +} + +.certificate-editors input[type="file"] { + max-width: 100% +} + +.certificate-editors summary { + cursor: pointer; + font-size: 12px +} + +.transport-grid { + grid-template-columns: repeat(5, minmax(0, 1fr)) +} + +@media(max-width:620px) { + .vh-frontend { + grid-template-columns: 1fr 1fr + } + + .vh-frontend>label:first-child { + grid-column: 1/-1 + } + + .vh-frontend>.h3-editor { + grid-row: auto + } + + .vh-frontend>button { + grid-column: auto; + grid-row: auto + } + + .transport-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) + } + + .named-listener-row .vh-grid { + grid-template-columns: 1fr + } + + .h3-editor .vh-grid { + grid-template-columns: 1fr 1fr + } +} + +.settings-grid>.card { + height: auto +} + +#named-listener-rows { + overflow: visible +} + +.cache-toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 12px +} + +.cache-toolbar label { + margin: 0; + display: flex; + gap: 7px; + align-items: center +} + +.cache-toolbar select { + max-width: 300px; + min-width: 130px; + margin: 0 +} + +.cache-domain-filter { + display: flex; + align-items: center; + gap: 7px; + margin: 0; + color: var(--muted); + font-size: 11px +} + +.cache-domain-filter select { + width: auto; + min-width: 150px; + padding: 7px 28px 7px 9px; + margin: 0 +} + +.cache-stats .stat { + min-width: 0 +} + +.cache-stats strong { + font-size: 22px +} + +.cache-stats progress { + width: 100%; + height: 5px; + margin: 6px 0 +} + +.cache-table { + font-size: 12px +} + +.cache-table td { + max-width: 260px; + overflow-wrap: anywhere; + vertical-align: top +} + +.cache-reasons { + min-width: 190px +} + +.cache-reason { + display: inline-block; + border: 1px solid var(--line); + border-radius: 4px; + padding: 2px 5px; + margin: 1px 4px 2px 0; + font-size: 10px +} + +.acme-card { + margin-bottom: 14px +} + +.acme-card .vh-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px +} + +.acme-card label { + margin: 0 +} + +.acme-result { + margin-top: 10px; + overflow-wrap: anywhere +} + +@media(max-width:1000px) { + .acme-card .vh-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) + } +} + +@media(max-width:620px) { + .acme-card .vh-grid { + grid-template-columns: minmax(0, 1fr) + } + + .cache-toolbar>* { + max-width: 100% + } + + .cache-toolbar label { + flex-wrap: wrap + } + + .cache-toolbar select { + max-width: 100% + } + + .cache-stats strong { + font-size: 19px + } +} + +#cache-domain-scroll { + max-height: 260px; + overflow: auto; + scrollbar-gutter: stable +} + +#cache-object-scroll { + max-height: min(49vh, 520px); + overflow: auto; + scrollbar-gutter: stable +} + +#panel-cache thead th { + position: sticky; + top: 0; + background: var(--panel); + z-index: 1 +} + +#panel-cache .cache-table .cache-reasons { + white-space: normal; + min-width: 210px; + max-width: 300px +} + +#panel-cache .cache-copy-cell { + cursor: copy +} + +.settings-tabs { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 4px; + width: 100%; + padding: 8px; + margin-bottom: 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel) +} + +.settings-tabs button { + width: 100%; + min-width: 0; + padding: 8px 10px; + font-size: 12px; + white-space: normal; + text-align: center; + border: 1px solid transparent; + background: transparent; + border-radius: 8px +} + +.settings-tabs button[aria-selected="true"] { + background: var(--accent); + color: var(--selected-ink); + border-color: var(--accent); + font-weight: 700 +} + +.settings-pages { + min-width: 0 +} + +.settings-page { + width: 100%; + max-width: none +} + +.settings-page[hidden] { + display: none !important +} + +.settings-page>.edit-form { + width: 100%; + max-width: none +} + +.settings-page>.card { + padding: var(--ui-card) +} + +.settings-page .security-grid { + align-items: start +} + +.certificate-dialog { + width: min(820px, calc(100vw - 28px)); + max-height: 90dvh; + padding: 0; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--bg); + color: var(--text); + overflow: auto +} + +.certificate-dialog::backdrop { + background: var(--backdrop-soft); + backdrop-filter: blur(5px) +} + +.certificate-dialog>.window-heading { + position: sticky; + top: 0; + z-index: 2; + background: var(--bg); + padding: 14px 18px; + border-bottom: 1px solid var(--line) +} + +.certificate-dialog .provider-picker { + display: grid; + gap: 6px; + padding: 14px 18px 0; + font-size: 12px; + font-weight: 600 +} + +.provider-hint { + padding: 0 18px +} + +.certificate-dialog .card { + border: 0; + box-shadow: none; + margin: 0; + padding: 16px 18px +} + +.certificate-dialog [hidden] { + display: none !important +} + +.certificate-dialog .acme-card { + max-width: none +} + +.certificate-dialog .vh-grid { + gap: 10px 14px +} + +.certificate-dialog .vh-grid label { + min-width: 0 +} + +.acme-result { + display: flex; + align-items: center; + gap: 12px; + justify-content: space-between; + margin-bottom: 12px +} + +.acme-result p { + margin: 0; + overflow-wrap: anywhere +} + +.metrics-example, +.metrics-preview { + font: 11px/1.55 ui-monospace, SFMono-Regular, monospace; + max-width: 100%; + overflow: auto; + white-space: pre; + padding: 12px; + background: var(--code-alt-bg); + border-radius: 8px; + color: var(--code-alt-text) +} + +.metrics-preview { + max-height: 360px +} + +.settings-shortcuts { + margin-bottom: 10px +} + +@media(max-width:680px) { + .settings-tabs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)) + } + + .settings-tabs button { + white-space: normal; + text-align: start; + font-size: 11px + } + + .certificate-dialog { + width: calc(100vw - 16px); + max-height: 94dvh + } + + .acme-result { + align-items: start; + flex-direction: column + } + + .settings-page>.card { + padding: var(--ui-card) + } +} + +#certificate-dialog .dialog-head button:last-child { + margin-inline-start: auto +} + +dialog.vhost-dialog, +dialog.logs-dialog, +dialog.audit-dialog, +dialog.certificate-dialog { + border-radius: 0; + background: var(--bg); + color: var(--text); + border: 1px solid var(--line); + padding: 0; + box-shadow: 0 18px 70px var(--shadow-strong) +} + +dialog::backdrop { + background: var(--shadow-strong) !important; + backdrop-filter: blur(2px) !important +} + +dialog .card, +dialog .vh-form, +dialog .window-heading { + border-radius: 0 +} + +.settings-tabs, +.settings-tabs button, +.settings-page>.card { + border-radius: 0 +} + +.certificate-dialog>.window-heading { + padding: 10px 12px +} + +.certificate-dialog .provider-picker { + padding: 10px 12px 0 +} + +.certificate-provider-hint { + padding: 0 12px +} + +.certificate-dialog .card { + padding: 12px +} + + +.cache-operation-dialog>.card { + border: 0; + padding: 14px +} + +.cache-operation-dialog .cache-dialog-heading { + margin-bottom: 12px; + padding: 9px 0 +} + +.cache-operation-dialog .cache-dialog-heading>div:first-child { + min-width: 0 +} + +.cache-operation-dialog .cache-dialog-heading button { + margin-inline-start: auto; + flex: 0 0 auto +} + +.vh-location { + border: 1px solid var(--line); + background: var(--panel); + min-width: 0 +} + +.vh-locations { + display: grid; + gap: 9px +} + +.location-toolbar, +.location-header-toolbar, +.location-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 7px +} + +.location-toolbar { + justify-content: space-between; + margin-bottom: 8px +} + +.location-toolbar-bottom { + justify-content: flex-start; + margin: 9px 0 0 +} + +.location-summary { + cursor: pointer; + padding: 10px 12px; + font: 600 11px/1.45 var(--mono); + overflow-wrap: anywhere; + color: var(--text) +} + +.location-summary:hover { + background: var(--panel-strong) +} + +.location-body { + border-top: 1px solid var(--line); + padding: 12px +} + +.location-section { + border: 1px solid var(--line); + min-width: 0 +} + +.location-section>summary { + cursor: pointer; + padding: 9px 10px; + color: var(--muted); + font: 600 10px var(--mono); + letter-spacing: .35px +} + +.location-section>div { + border-top: 1px solid var(--line); + padding: 10px +} + +.location-header-toolbar { + justify-content: flex-start +} + +.location-actions { + justify-content: flex-end; + padding-top: 4px +} + +.location-return-box { + padding-top: 3px +} + +#location-help-dialog { + width: min(760px, calc(100vw - 28px)); + max-height: min(82vh, 760px) +} + +#location-help-body { + display: grid; + gap: 8px; + padding: 14px 16px 18px; + overflow: auto +} + +#location-help-body p { + margin: 0; + line-height: 1.55 +} + +.acl-rule, +.basic-user { + display: grid; + grid-template-columns: minmax(100px, 1fr) minmax(140px, 1fr) auto; + align-items: end; + gap: 7px; + margin-block: 5px +} + +.basic-user { + grid-template-columns: 1fr 1fr auto auto +} + +.basic-user .fine { + align-self: center; + font-size: 10px +} + +.renewal-entry { + display: grid; + grid-template-columns: 1fr 2fr auto; + gap: 8px; + padding: 9px 0; + border-bottom: 1px solid var(--line); + align-items: center +} + +.renewal-actions { + display: flex; + flex-wrap: wrap; + gap: 5px +} + +.renewal-entry .fine { + grid-column: 1/-1 +} + +@media(max-width:640px) { + + .basic-user, + .acl-rule { + grid-template-columns: 1fr 1fr + } + + .renewal-entry { + grid-template-columns: 1fr + } + + .renewal-entry .fine { + grid-column: auto + } +} + +#toast[popover] { + border-radius: 0; + pointer-events: none +} + +.app-confirm-dialog { + width: min(520px, calc(100vw - 28px)); + max-width: none; + padding: 0; + border: 1px solid var(--line); + border-radius: 4px; + background: var(--panel); + color: var(--text) +} + +.app-confirm-dialog::backdrop { + background: var(--backdrop); + backdrop-filter: blur(2px) +} + +.app-confirm-card { + margin: 0; + border: 0; + padding: 18px +} + +.app-confirm-message { + margin-top: 14px; + color: var(--text); + white-space: pre-line; + overflow-wrap: anywhere +} + +.app-confirm-actions { + justify-content: flex-end; + margin-top: 18px +} + +.app-confirm-danger { + border-color: var(--red); + color: var(--red) +} + +.app-confirm-danger:hover { + border-color: var(--red); + background: rgb(255 170 162 / .08) +} + +#panel-tls>.config-columns { + display: block; + margin-top: 12px +} + +#certificate-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: 10px +} + +#certificate-list>.entity { + border: 1px solid var(--line); + padding: 12px; + min-width: 0 +} + +.certificate-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px +} + +.certificate-card-identity { + min-width: 0 +} + +.certificate-state-pills { + margin-top: 8px +} + +.certificate-card .pill { + color: var(--accent) +} + +.certificate-coverage { + margin-top: 14px +} + +.certificate-coverage>.small-label { + display: block; + margin-bottom: 7px +} + +.certificate-domain-pills { + margin-top: 0 +} + +.certificate-domain-pills .pill { + max-width: 100%; + overflow-wrap: anywhere +} + +.certificate-coverage-unknown { + color: var(--muted) !important; + font: 10px/1.5 var(--mono) !important +} + +.certificate-details { + margin-top: 12px; + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line) +} + +.certificate-details summary { + cursor: pointer; + padding: 9px 0; + color: var(--muted); + font: 10px var(--mono); + letter-spacing: .7px +} + +.certificate-details summary:hover { + color: var(--text) +} + +.certificate-details-body { + padding: 0 0 8px +} + +.certificate-details .entity-details { + margin: 4px 0 +} + +.certificate-card>.entity-actions { + margin-top: 10px +} + +.auth-page.auth-loading .auth-top, +.auth-page.auth-loading .auth-main { + visibility: hidden +} + +.auth-page { + min-height: 100vh +} + +.auth-top { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 24px; + border-bottom: 1px solid var(--line) +} + +.auth-main { + max-width: 560px; + margin: auto; + padding: 28px 20px +} + +.auth-main .login-section { + padding: 0; + max-width: none +} + +.auth-main .login-card { + width: 100%; + max-width: none +} + +.auth-main h1 { + font-size: 1.65rem; + margin-bottom: 18px +} + +.login-section #auth-message:empty { + display: none +} + +.login-section #auth-message { + margin-top: 14px; + padding: 11px 12px; + border: 1px solid var(--line); + background: var(--panel2); + color: var(--text); + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere +} + +.login-section #auth-message.error { + border-color: rgb(255 170 162 / .58); + background: rgb(255 170 162 / .07); + color: var(--red) +} + +.location-header-editor { + min-width: 0 +} + +.location-header-editor h3 { + font-size: 12px; + margin: 8px 0 +} + +.location-header-editor .vh-header-row { + grid-template-columns: 100px minmax(120px, 1fr) minmax(120px, 1.3fr) auto +} + +@media(max-width:650px) { + .location-header-editor .vh-header-row { + grid-template-columns: 1fr 1fr + } + + .location-toolbar, + .location-actions { + align-items: stretch + } + + .location-toolbar>button, + .location-actions>button { + flex: 1 1 150px + } + + .auth-top { + padding: 14px + } + + .auth-main { + padding: 22px 14px + } +} + +.location-header-direction { + border: 1px solid var(--line); + background: var(--panel2); + padding: 10px; + min-width: 0 +} + +.location-header-direction-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px +} + +.location-header-direction-head h3, +.location-header-direction-head p { + margin: 0 +} + +.location-header-direction-head p { + max-width: 56ch; + text-align: right +} + +.vh-header-empty { + margin: 8px 0; + padding: 9px 10px; + border: 1px dashed var(--line) +} + +.location-header-editor .vh-header-rows { + display: grid; + gap: 8px +} + +.location-header-editor .vh-header-row { + display: block; + border: 1px solid var(--line); + background: var(--panel); + padding: 9px +} + +.vh-header-rule-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px +} + +.vh-header-rule-title { + font: 600 10px var(--mono); + color: var(--muted); + text-transform: uppercase; + letter-spacing: .45px +} + +.vh-header-rule-fields { + display: grid; + grid-template-columns: minmax(100px, .55fr) minmax(140px, 1fr) minmax(180px, 1.35fr); + align-items: end; + gap: 8px +} + +.vh-header-row.is-remove .vh-header-rule-fields { + grid-template-columns: minmax(100px, .55fr) minmax(140px, 1fr) minmax(180px, 1.35fr) +} + +.location-add-header { + margin-top: 8px +} + +.location-preview { + border: 1px solid var(--line); + background: var(--panel2); + padding: 11px 12px +} + +.location-preview h4 { + margin: 0 0 8px; + font-size: 12px +} + +.location-preview-disabled { + margin: 0 0 8px; + color: var(--red) +} + +.location-preview-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px 14px; + margin: 0 +} + +.location-preview-grid>div { + min-width: 0 +} + +.location-preview-grid dt { + color: var(--muted); + font: 10px var(--mono); + margin-bottom: 2px +} + +.location-preview-grid dd { + margin: 0; + font-size: 11px; + overflow-wrap: anywhere +} + +.location-doc-intro { + padding: 10px 12px; + border: 1px solid var(--line); + background: var(--panel2) +} + +.location-doc-section { + border-top: 1px solid var(--line); + padding-top: 10px +} + +.location-doc-section h3 { + margin: 0 0 7px; + font-size: 12px +} + +.location-doc-section p+p { + margin-top: 7px !important +} + +.location-doc-examples p { + font-family: var(--mono); + font-size: 10px; + padding: 8px 9px; + background: var(--panel2); + border-left: 2px solid var(--accent) +} + +@media(max-width:650px) { + .location-header-direction-head { + display: block + } + + .location-header-direction-head p { + text-align: left; + margin-top: 5px + } + + .vh-header-rule-fields, + .vh-header-row.is-remove .vh-header-rule-fields, + .location-preview-grid { + grid-template-columns: 1fr + } +} + +.settings-tabs { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); + width: 100%; + gap: 5px +} + +.settings-tabs button { + width: 100%; + min-width: 0; + white-space: normal; + text-align: center +} + +.settings-pages, +.settings-page, +.settings-page>.edit-form { + width: 100%; + max-width: none +} + +.logging-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin: 14px 0 +} + +.logging-grid label { + min-width: 0 +} + +#settings-page-logging>.edit-form { + width: 100%; + max-width: none +} + +#vhost-list { + display: grid; + gap: 10px +} + +#panel-vhosts .card-heading { + flex-wrap: wrap +} + +.vhost-list-tools { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto +} + +#vhost-search { + width: min(260px, 32vw); + min-width: 180px +} + +.vhost-card { + border: 1px solid var(--line); + background: var(--panel); + padding: 14px; + min-width: 0 +} + +.vhost-card-head { + display: flex; + justify-content: space-between; + gap: 14px; + align-items: flex-start +} + +.vhost-card-identity { + min-width: 0 +} + +.vhost-title-line, +.vhost-policy-pills { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px +} + +.vhost-hosts { + margin-top: 4px; + font: 11px/1.45 var(--mono); + color: var(--muted); + overflow-wrap: anywhere +} + +.vhost-detail-strip { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1px; + margin-top: 12px; + border: 1px solid var(--line) +} + +.vhost-detail { + min-width: 0; + padding: 9px 10px; + background: var(--bg) +} + +.vhost-detail span, +.vhost-detail b { + display: block +} + +.vhost-detail b { + margin-top: 3px; + font-size: 11px; + font-weight: 600; + overflow-wrap: anywhere +} + +.vhost-live-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 8px; + margin-top: 10px +} + +.vhost-live-metric { + min-width: 0; + padding: 9px 10px; + border: 1px solid var(--line) +} + +.vhost-live-metric span { + display: block; + color: var(--muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: .04em +} + +.vhost-live-metric strong { + display: block; + margin-top: 4px; + font: 600 13px var(--mono); + overflow-wrap: anywhere +} + +.vhost-card .vh-health-summary { + margin: 9px 0 0; + grid-column: auto +} + +.vhost-policy-pills { + margin-top: 8px +} + +@media(max-width:980px) { + .logging-grid { + grid-template-columns: 1fr + } + + .vhost-live-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)) + } +} + +@media(max-width:640px) { + .settings-tabs { + grid-template-columns: repeat(2, minmax(0, 1fr)) + } + + .vhost-card-head { + flex-direction: column + } + + #panel-vhosts .card-heading { + align-items: stretch; + flex-direction: column + } + + .vhost-list-tools { + width: 100%; + margin-left: 0 + } + + #vhost-search { + flex: 1 1 auto; + width: auto; + min-width: 0 + } + + .vhost-card-head .entity-actions { + width: 100%; + justify-content: flex-start + } + + .vhost-live-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) + } +} + +.acl-profile-layout { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(320px, .85fr); + gap: 14px; + align-items: start +} + +.acl-profile-layout>.card, +.logging-grid>.card { + border-radius: 0 +} + +.host-chip-field>span:first-child { + display: block; + color: var(--muted); + font-size: 11px; + margin-bottom: 7px +} + +.host-chip-editor { + min-height: 43px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 6px; + border: 1px solid var(--field-line); + background: var(--field-bg) +} + +.host-chip-editor:focus-within { + outline: 1px solid var(--green); + border-color: var(--green) +} + +.host-chip-editor input { + flex: 1 1 210px; + width: auto; + min-width: 180px; + border: 0; + outline: 0; + padding: 5px 6px; + background: transparent +} + +.host-chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 5px 4px 8px; + border: 1px solid var(--line); + background: var(--panel); + font: 10px var(--mono) +} + +.host-chip button { + border: 0; + background: transparent; + color: var(--muted); + padding: 0 3px; + font-size: 15px; + line-height: 1 +} + +.host-chip button:hover { + color: var(--red) +} + +.vhost-chart { + margin-top: 9px; + border: 1px solid var(--line); + background: var(--bg) +} + +.vhost-chart-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 7px 10px 0 +} + +.vhost-chart svg { + display: block; + width: 100%; + height: 54px +} + +.vhost-chart-line { + fill: none; + stroke: var(--accent); + stroke-width: 2; + vector-effect: non-scaling-stroke +} + +.logging-format-actions { + display: grid; + grid-template-columns: minmax(220px, 1fr) auto; + gap: 14px; + align-items: end +} + +.logging-builder-action { + display: grid; + gap: 7px; + justify-items: stretch; + min-width: 180px +} + +.logging-builder-action .small-label { + text-align: right +} + +.logging-builder-action button { + width: 100% +} + +.logging-builder-layout { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(260px, .85fr); + gap: 14px; + min-height: 0 +} + +.logging-builder-layout>section { + min-width: 0; + border: 1px solid var(--line); + padding: 12px; + background: var(--bg) +} + +.logging-preview pre { + margin: 0; + max-height: 420px; + overflow: auto +} + +@media(max-width:900px) { + + .acl-profile-layout, + .logging-builder-layout, + .logging-format-actions { + grid-template-columns: 1fr + } + + .logging-builder-action .small-label { + text-align: left + } +} + +.acl-profile-rules-editor { + margin-block: 10px 4px +} + +.acl-profile-rules-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 6px +} + +.acl-profile-rules-heading>span { + color: var(--muted); + font-size: 11px +} + +.acl-profile-rule-rows:empty::before { + content: "—"; + display: block; + color: var(--muted); + padding: 8px 0 +} + +.acl-profile-rule { + grid-template-columns: minmax(120px, .65fr) minmax(180px, 1.35fr) auto; + padding: 6px 0; + border-bottom: 1px solid var(--line) +} + +.acl-profile-rule label>span { + display: block; + color: var(--muted); + font-size: 10px; + margin-bottom: 4px +} + +.acl-profile-bulk { + margin-top: 9px; + border-top: 1px solid var(--line); + padding-top: 8px +} + +.acl-profile-bulk>summary { + cursor: pointer; + color: var(--muted) +} + +.pool-vhost-disabled { + opacity: .55; + background: var(--neutral-soft) +} + +.pool-vhost-disabled td { + color: var(--muted) +} + +@media(max-width:640px) { + .acl-profile-rule { + grid-template-columns: 1fr + } +} + +#stream-list { + display: grid; + gap: 10px +} + +#panel-streams .card-heading { + flex-wrap: wrap +} + +#stream-search { + width: min(260px, 32vw); + min-width: 180px +} + +.stream-page-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 12px; + margin: 0 0 10px +} + +.stream-card .vhost-hosts { + text-transform: none +} + +.stream-live-grid { + grid-template-columns: repeat(6, minmax(0, 1fr)) +} + +.stream-chart-rx, +.stream-chart-tx { + fill: none; + stroke-width: 2; + vector-effect: non-scaling-stroke +} + +.stream-chart-rx { + stroke: var(--accent) +} + +.stream-chart-tx { + stroke: var(--green) +} + +.stream-chart-legend { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: 12px; + padding: 0 10px 7px; + color: var(--muted); + font: 10px var(--mono) +} + +.stream-socket-editor input[hidden] { + display: none +} + +.listener-address-chip-editor { + min-height: 52px +} + +@media(max-width:980px) { + .stream-live-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)) + } +} + +@media(max-width:640px) { + #panel-streams .card-heading { + align-items: stretch; + flex-direction: column + } + + #panel-streams .vhost-list-tools { + width: 100%; + margin-left: 0 + } + + #stream-search { + flex: 1 1 auto; + width: auto; + min-width: 0 + } + + .stream-live-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)) + } +} + +.redirect-listeners { + display: grid; + gap: .55rem; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)) +} + +.redirect-listeners .check { + margin: 0 +} + +.redirect-rules { + display: grid; + gap: 1rem +} + +.redirect-rule { + border: 1px solid var(--line); + border-radius: 12px; + padding: 1rem; + margin: 0; + min-width: 0 +} + +.redirect-rule legend { + padding: 0 .4rem; + font-weight: 700 +} + +.redirect-card .vhost-detail-strip { + grid-template-columns: repeat(3, minmax(0, 1fr)) +} + +@media(max-width:760px) { + .redirect-card .vhost-detail-strip { + grid-template-columns: 1fr + } + + .redirect-rule .vh-grid { + grid-template-columns: 1fr + } +} + +.vh-tabs { + flex-wrap: nowrap; + overflow-x: auto; + overscroll-behavior-x: contain; + scrollbar-width: thin +} + +.vh-tabs button { + white-space: nowrap +} + +.protocol-toggle-row { + display: flex; + flex-wrap: wrap; + gap: 8px 18px; + align-items: center; + min-width: 0 +} + +.protocol-toggle-row .check, +.protocol-toggle-row .vh-check { + margin: 0; + min-width: min(280px, 100%) +} + +.listener-choices { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 8px +} + +.listener-choices>.fine { + grid-column: 1/-1; + margin: 0 0 2px +} + +.listener-choices>label { + display: flex; + min-height: 44px; + align-items: center; + margin: 0 +} + +.vh-members { + gap: 12px +} + +.vh-backend-card { + padding: 0; + overflow: hidden; + background: var(--panel) +} + +.vh-backend-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border-bottom: 1px solid var(--line) +} + +.vh-backend-identity { + display: grid; + gap: 2px; + min-width: 0 +} + +.vh-backend-title { + font-size: 13px +} + +.vh-backend-endpoint { + overflow-wrap: anywhere +} + +.vh-backend-main { + padding: 12px +} + +.vh-backend-switches { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + padding-top: 2px +} + +.vh-backend-switches .vh-check { + margin: 0; + min-height: 26px +} + +.vh-backend-connection { + padding-top: 8px; + border-top: 1px dashed var(--line) +} + +.vh-backend-advanced { + margin: 0 12px 10px; + padding-top: 8px; + border-top: 1px solid var(--line) +} + +.vh-backend-advanced>summary, +.auth-advanced>summary { + cursor: pointer; + color: var(--muted); + font-size: 11px; + font-weight: 600; + padding: 4px 0 8px +} + +.vh-backend-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 12px; + padding: 0 12px 12px +} + +.vh-backend-actions .fine { + flex: 1 1 320px; + margin: 0 +} + +.vh-local-risk-gate { + border: 1px solid var(--line); + border-radius: 5px; + padding: 8px +} + +.vh-local-risk-gate .fine { + margin: 5px 0 0 +} + +.auth-subcard { + border: 1px solid var(--line); + border-radius: 6px; + padding: 10px; + display: grid; + gap: 8px +} + +.auth-subcard>h3 { + margin: 0; + font-size: 12px +} + +.auth-ip-card { + margin-top: 2px +} + +.auth-advanced { + border-top: 1px solid var(--line); + padding-top: 4px +} + +.vh-cache-options[hidden] { + display: none +} + +#redirect-listeners>.listener-choices { + width: 100% +} + +@media(max-width:760px) { + .protocol-toggle-row { + display: grid; + grid-template-columns: 1fr + } + + .protocol-toggle-row .check, + .protocol-toggle-row .vh-check { + min-width: 0 + } + + .listener-choices { + grid-template-columns: 1fr + } + + .vh-backend-head { + align-items: flex-start + } +} + +.docker-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.toolbar-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.docker-agent-observations, +.docker-agent-settings-list { + display: grid; + gap: 16px; +} + +.docker-agent-card, +.docker-agent-settings { + min-width: 0; +} + +.docker-agent-heading-actions { + align-items: center; +} + +.docker-agent-status-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 88px; + text-align: center; +} + +.docker-agent-summary { + display: flex; + flex-wrap: wrap; + gap: 7px 18px; + margin: 8px 0 16px; + padding: 10px 12px; + border: 1px solid var(--line); + background: var(--panel2); + font-size: 12px; + color: var(--muted); +} + +.docker-agent-security { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 11px; + margin: 4px 0 10px; + border-left: 2px solid var(--line); + background: var(--panel2); + min-width: 0; +} + +.docker-agent-security .fine { + overflow-wrap: anywhere; +} + +.docker-agent-error { + display: grid; + gap: 5px; + padding: 12px; + border: 1px solid var(--danger-line); + background: rgba(255, 170, 162, .04); +} + +.docker-service-list { + display: grid; + gap: 10px; +} + +.docker-service-row { + display: grid; + gap: 12px; + padding: 13px; + border: 1px solid var(--line); + background: var(--panel2); + min-width: 0; +} + +.docker-service-row.is-unpublished { + opacity: .72; +} + +.docker-service-row.is-view-hidden { + border-style: dashed; + opacity: .58; +} + +.docker-service-head { + display: grid; + grid-template-columns: auto minmax(220px, 1fr) minmax(300px, 1.25fr); + align-items: center; + gap: 14px; + min-width: 0; +} + +.docker-service-meta { + display: grid; + gap: 2px; + min-width: 0; +} + +.docker-service-meta span { + color: var(--muted); + font-size: 12px; + overflow-wrap: anywhere; +} + +.docker-runtime { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + min-width: 0; + text-align: right; +} + +.docker-runtime .fine { + overflow-wrap: anywhere; +} + +.docker-route-grid { + display: grid; + grid-template-columns: minmax(190px, 1.1fr) minmax(190px, 1fr) minmax(210px, 1fr) 100px auto; + align-items: end; + gap: 10px; +} + +.docker-route-grid>label:not(.check), +.docker-health-grid>label { + display: grid; + gap: 5px; + min-width: 0; +} + +.docker-route-grid>label:not(.check)>span, +.docker-health-grid>label>span { + color: var(--muted); + font-size: 11px; +} + +.docker-route-grid input:not([type="checkbox"]), +.docker-route-grid select, +.docker-health-grid input:not([type="checkbox"]), +.docker-health-grid select { + min-width: 0; + width: 100%; +} + +.docker-preserve-field { + align-self: center; + padding-bottom: 9px; + white-space: nowrap; +} + +.docker-health-editor { + border-top: 1px solid var(--line); + padding-top: 9px; +} + +.docker-health-editor>summary { + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + user-select: none; +} + +.docker-health-editor>summary::marker { + color: var(--muted); +} + +.docker-health-grid { + display: grid; + grid-template-columns: repeat(5, minmax(120px, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.docker-passive-health-editor .docker-health-grid { + grid-template-columns: repeat(4, minmax(180px, 1fr)); + align-items: start; +} + +.docker-passive-health-editor .docker-checkbox-field { + min-width: 0; +} + +.docker-passive-health-editor .docker-checkbox-field .vh-check { + width: max-content; + max-width: 100%; +} + +.docker-passive-health-editor .docker-checkbox-field input[type="checkbox"] { + width: auto; +} + +.docker-passive-health-editor .docker-passive-5xx-warning, +.docker-passive-health-editor .docker-passive-slow-warning { + grid-column: span 2; + margin: 0; + min-width: 0; +} + +.docker-agent-grid { + margin-top: 10px; +} + +.docker-agent-listener-field { + grid-column: 1/-1; + display: grid; + gap: 6px; + min-width: 0; +} + +.docker-agent-field-label { + font-size: 11px; + color: var(--muted); +} + +.docker-agent-listener-choices { + margin: 0; +} + +.docker-agent-listener-choices>label { + min-height: 38px; +} + +.docker-agent-grid .mono, +.docker-agent-summary .mono { + font-family: var(--mono); + font-size: 11px; +} + +.pill.warn { + border-color: var(--warn-line); + color: var(--accent); +} + +.pill.danger { + border-color: var(--danger-line-strong); + color: var(--red); +} + +.docker-toolbar input[type="search"] { + width: min(280px, 38vw); +} + +.cache-toolbar-status { + margin-left: auto; + display: flex; + align-items: center; + gap: 12px; + white-space: nowrap; +} + +@media (max-width: 1320px) { + .docker-service-head { + grid-template-columns: auto 1fr; + } + + .docker-runtime { + grid-column: 2; + justify-content: flex-start; + text-align: left; + flex-wrap: wrap; + } + + .docker-route-grid { + grid-template-columns: 1fr 1fr 1fr; + } + + .docker-preserve-field { + padding-bottom: 0; + } + + .docker-health-grid { + grid-template-columns: repeat(3, minmax(130px, 1fr)); + } + + .docker-passive-health-editor .docker-health-grid { + grid-template-columns: repeat(2, minmax(180px, 1fr)); + } +} + +@media (max-width: 760px) { + .docker-service-head { + grid-template-columns: 1fr; + align-items: stretch; + } + + .docker-runtime { + grid-column: auto; + justify-content: flex-start; + text-align: left; + } + + .docker-route-grid, + .docker-health-grid { + grid-template-columns: 1fr; + } + + .docker-passive-health-editor .docker-health-grid { + grid-template-columns: 1fr; + } + + .docker-passive-health-editor .docker-passive-5xx-warning, + .docker-passive-health-editor .docker-passive-slow-warning { + grid-column: auto; + } + + .docker-toolbar { + align-items: flex-start; + flex-direction: column; + } + + .docker-agent-security { + align-items: flex-start; + flex-direction: column; + } + + .docker-toolbar input[type="search"] { + width: 100%; + } + + .cache-toolbar-status { + margin-left: 0; + width: 100%; + justify-content: flex-start; + } +} + +#panel-settings:not([hidden]) { + display: grid; + grid-template-columns: minmax(180px, 220px) minmax(0, 1fr); + gap: 12px; + align-items: start +} + +#panel-settings .settings-shortcuts { + grid-column: 1 / -1; + width: 100% +} + +#panel-settings .settings-tabs { + display: flex; + flex-direction: column; + position: sticky; + top: 12px; + margin: 0; + gap: 2px +} + +#panel-settings .settings-tabs button { + width: 100%; + text-align: left; + white-space: normal +} + +#panel-settings .settings-pages { + min-width: 0 +} + +@media (max-width: 900px) { + #panel-settings:not([hidden]) { + display: block; + } + + #panel-settings .settings-shortcuts { + margin-bottom: 10px; + } + + #panel-settings .settings-tabs { + display: flex; + flex-direction: row; + position: static; + overflow-x: auto; + overscroll-behavior-inline: contain; + scrollbar-width: thin; + margin-bottom: 12px + } + + #panel-settings .settings-tabs button { + flex: 0 0 auto; + width: auto; + min-width: 120px; + white-space: nowrap; + text-align: center + } +} + +.docker-live-switch { + align-self: center; + white-space: nowrap; +} + +.docker-service-row.is-dirty { + border-color: var(--accent); + box-shadow: inset 3px 0 0 var(--accent); +} + +.docker-service-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.docker-acl-editor, +.docker-timeout-editor { + border-top: 1px solid var(--line); + padding-top: 9px; +} + +.docker-acl-editor>summary, +.docker-timeout-editor>summary { + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + user-select: none; +} + +.docker-acl-grid, +.docker-timeout-grid { + display: grid; + grid-template-columns: repeat(4, minmax(140px, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.docker-acl-grid>label:not(.check), +.docker-timeout-grid>label { + display: grid; + gap: 5px; + min-width: 0; +} + +.docker-acl-grid textarea { + width: 100%; + min-height: 86px; + font-family: var(--mono); +} + +@media (max-width: 1000px) { + + .docker-acl-grid, + .docker-timeout-grid { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 760px) { + + .docker-acl-grid, + .docker-timeout-grid { + grid-template-columns: 1fr; + } + + .docker-service-actions { + justify-content: stretch; + } + + .docker-service-actions button { + width: 100%; + } +} + + +.security-page-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 14px; +} + +.security-page-heading h2 { + margin: 3px 0 4px; +} + +.security-page-heading p { + margin: 0; + max-width: 760px; +} + +.security-stack { + display: grid; + gap: 14px; +} + +.security-account-card .session-user { + margin: 4px 0 0; +} + +.security-account-card .security-note { + margin: 10px 0 14px; +} + +.security-editor { + border-top: 1px solid var(--line); + margin-top: 14px; + padding-top: 10px; +} + +.security-editor>summary { + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + gap: 14px; + user-select: none; +} + +.security-editor>summary>span { + font-weight: 700; +} + +.security-inline-form { + margin-top: 14px; + padding: 0; + border: 0; + background: transparent; +} + +.security-policy-card #host-policy-list { + display: grid; + gap: 8px; +} + +.audit-dialog { + width: min(1040px, calc(100vw - 24px)); + max-height: calc(100dvh - 28px); + overflow: hidden; +} + +.audit-dialog-card { + border: 0; + padding: 12px; + background: var(--bg); +} + +.audit-dialog .table-scroll { + max-height: min(68dvh, 680px); + overflow: auto; + border-top: 1px solid var(--line); +} + +.audit-dialog table { + font: 11px var(--mono); +} + +.audit-dialog th, +.audit-dialog td { + padding: 7px 8px; + max-width: 440px; + overflow: hidden; + text-overflow: ellipsis; +} + +.audit-dialog th { + position: sticky; + top: 0; + background: var(--panel); +} + +.docker-agent-overview { + display: flex; + flex-wrap: wrap; + gap: 7px 18px; + margin: 8px 0 10px; + color: var(--muted); + font-size: 12px; +} + +.docker-agent-details { + margin: 0 0 12px; + border-top: 1px solid var(--line); + padding-top: 8px; +} + +.docker-agent-details>summary { + cursor: pointer; + color: var(--muted); + font-size: 11px; + user-select: none; +} + +.docker-agent-details .docker-agent-summary { + margin: 9px 0 0; +} + +.docker-service-row { + gap: 8px; + padding: 10px 12px; +} + +.docker-service-head { + grid-template-columns: auto minmax(260px, 1fr) minmax(300px, 1.25fr); + gap: 12px; +} + +.docker-service-head-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + min-width: 0; +} + +.docker-service-head-actions .docker-runtime { + flex: 1 1 auto; +} + +.docker-service-head-actions .docker-view-toggle { + flex: 0 0 auto; +} + +.docker-service-editor { + border-top: 1px solid var(--line); + padding-top: 8px; +} + +.docker-service-editor>summary { + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + user-select: none; +} + +.docker-service-editor>summary>span { + font-weight: 700; + font-size: 12px; +} + +.docker-service-editor .docker-config-hint { + text-align: right; + overflow-wrap: anywhere; +} + +.docker-service-editor[open]>summary { + margin-bottom: 12px; +} + +.docker-service-actions { + padding-top: 10px; + border-top: 1px solid var(--line); +} + +@media (max-width: 1320px) { + .docker-service-head { + grid-template-columns: auto 1fr; + } + + .docker-service-head-actions { + grid-column: 2; + justify-content: flex-start; + flex-wrap: wrap; + } +} + +@media (max-width: 760px) { + .security-page-heading { + flex-direction: column; + } + + .security-page-heading>button { + width: 100%; + } + + .security-editor>summary, + .docker-service-editor>summary { + align-items: flex-start; + flex-direction: column; + } + + .docker-service-head { + grid-template-columns: 1fr; + } + + .docker-service-head-actions { + grid-column: auto; + align-items: flex-start; + flex-direction: column; + } + + .docker-service-editor .docker-config-hint { + text-align: left; + } +} + +.traffic-line { + fill: none; + vector-effect: non-scaling-stroke; +} + +.traffic-line-total { + stroke: var(--muted); + stroke-width: 1.25; + opacity: .72; +} + +.traffic-line-h1 { + stroke: var(--accent); + stroke-width: 2; +} + +.traffic-line-h2 { + stroke: var(--green); + stroke-width: 2; +} + +.traffic-line-h3 { + stroke: var(--blue); + stroke-width: 2; +} + +.traffic-line-transform { + stroke: var(--transform-chart); + stroke-width: 2.4; + stroke-dasharray: 5 3; +} + +.traffic-protocol-legend { + display: flex; + flex-wrap: wrap; + gap: 7px 16px; + margin: 2px 0 7px; + color: var(--muted); + font: 9px var(--mono); +} + +.traffic-protocol-legend span { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.traffic-protocol-legend i { + width: 12px; + height: 2px; + display: inline-block; + background: var(--muted); +} + +.traffic-protocol-legend .legend-h1 i { + background: var(--accent); +} + +.traffic-protocol-legend .legend-h2 i { + background: var(--green); +} + +.traffic-protocol-legend .legend-h3 i { + background: var(--blue); +} + +.traffic-protocol-legend .legend-transform i { + background: var(--transform-chart); +} + +.traffic-protocol-legend b { + color: var(--text); + font-weight: 600; +} + +body[data-density="compact"] { + --ui-gap: 2px; + --ui-card: 5px; + --ui-font: 10.75px; + --ui-control: 22px; + line-height: 1.32; +} + +body[data-density="compact"] .shell { + grid-template-columns: 164px minmax(0, 1fr); +} + +body[data-density="compact"] main { + padding: 0 10px; +} + +body[data-density="compact"] .sidebar { + padding: 14px 9px; +} + +body[data-density="compact"] .sidebar nav { + margin-top: 14px; +} + +body[data-density="compact"] .nav { + padding: 6px 7px; +} + +body[data-density="compact"] .topbar { + height: 42px; +} + +body[data-density="compact"] .page-intro { + padding: 7px 0 6px; + gap: 7px; +} + +body[data-density="compact"] .page-intro h1 { + font-size: 20px; +} + +body[data-density="compact"] .page-intro .lead { + font-size: 10px; + margin-top: 2px; +} + +body[data-density="compact"] .card-heading { + margin-bottom: 5px; +} + +body[data-density="compact"] .card-heading h2 { + font-size: 14px; +} + +body[data-density="compact"] .stat { + padding: 6px 7px; +} + +body[data-density="compact"] .stat strong { + font-size: 18px; + margin: 1px 0; +} + +body[data-density="compact"] .stat p, +body[data-density="compact"] .stat small { + font-size: 9px; +} + +body[data-density="compact"] .transport-grid>div { + padding: 4px 7px !important; +} + +body[data-density="compact"] .transport-grid span { + font-size: 9px; +} + +body[data-density="compact"] .transport-grid b { + font-size: 12px; +} + +body[data-density="compact"] .activity-card svg { + height: 84px; +} + +body[data-density="compact"] .system-card svg { + height: 66px; +} + +body[data-density="compact"] .runtime-card dl { + margin-top: 4px; +} + +body[data-density="compact"] .runtime-card dl div { + margin-top: 2px; + padding-bottom: 2px; +} + +body[data-density="compact"] .mini-metrics div { + margin-top: 2px; +} + +body[data-density="compact"] .pool-table th, +body[data-density="compact"] .pool-table td { + padding: 3px 5px; + font-size: 10px; +} + +body[data-density="compact"] .pool-card .table-scroll { + max-height: 170px; +} + +body[data-density="compact"] input:not([type="checkbox"]):not([type="radio"]), +body[data-density="compact"] select, +body[data-density="compact"] textarea, +body[data-density="compact"] button, +body[data-density="compact"] .primary, +body[data-density="compact"] .outline, +body[data-density="compact"] .quiet { + padding: 3px 7px; + font-size: 10px; +} + +body[data-density="compact"] .vhost-row { + padding: 6px 7px; + gap: 1px 9px; +} + +body[data-density="compact"] .docker-service-row { + padding: 6px 8px; + gap: 4px; +} + +body[data-density="compact"] .docker-service-head { + gap: 7px; +} + +body[data-density="compact"] .docker-agent-overview { + gap: 4px 12px; + margin: 4px 0 6px; + font-size: 10px; +} + +body[data-density="compact"] .docker-agent-details { + margin-bottom: 6px; + padding-top: 5px; +} + +body[data-density="compact"] .docker-service-editor { + padding-top: 5px; +} + +body[data-density="compact"] .docker-service-editor[open]>summary { + margin-bottom: 7px; +} + +body[data-density="compact"] .security-stack { + gap: 7px; +} + +body[data-density="compact"] .security-editor { + margin-top: 8px; + padding-top: 6px; +} + +body[data-density="compact"] .page-footer { + padding: 7px 0; + margin-top: 4px; +} + +#sparkline.traffic-line-total { + stroke: var(--muted); + stroke-width: 1.25; + opacity: .72; +} + +#sparkline-h1.traffic-line-h1 { + stroke: var(--accent); +} + +#sparkline-h2.traffic-line-h2 { + stroke: var(--green); +} + +#sparkline-h3.traffic-line-h3 { + stroke: var(--blue); +} + +.docker-open-app { + white-space: nowrap; +} + +@media (max-width: 850px) { + body[data-density="compact"] .sidebar nav { + margin-top: 0; + } + + body[data-density="compact"] .sidebar { + padding: 8px 9px; + } + + body[data-density="compact"] .topbar { + height: 42px; + } +} + +.metric-explainer { + margin: 8px 0 2px; + line-height: 1.45 +} + +.network-interface-charts { + display: grid; + gap: 8px; + max-height: 260px; + overflow: auto; + padding-right: 2px +} + +.network-interface-row { + min-width: 0; + padding: 7px 8px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--panel2) +} + +.network-interface-row-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-bottom: 3px +} + +.network-interface-row-head b { + min-width: 0; + overflow-wrap: anywhere; + color: var(--text); + font-size: 10px +} + +.network-interface-row-head .fine { + flex: 0 0 auto; + text-align: right; + white-space: nowrap +} + +.network-interface-row svg { + display: block; + width: 100%; + height: 48px; + overflow: visible +} + +.network-interface-empty { + margin: 0; + padding: 8px 0 +} + +.docker-agent-grid>label { + display: grid; + align-content: start; + gap: 6px; + min-width: 0; + margin: 0 !important +} + +.docker-agent-grid>label>span:first-child, +.docker-agent-field-label { + min-height: 14px; + line-height: 14px +} + +.docker-agent-grid input:not([type="checkbox"]), +.docker-agent-grid select { + width: 100%; + min-width: 0; + min-height: 38px; + margin: 0; + box-sizing: border-box +} + +.docker-agent-grid>label>small { + margin: 0; + line-height: 1.35 +} + +.docker-agent-heading-actions { + display: flex; + align-items: center; + gap: 9px +} + +.docker-agent-heading-actions .check { + margin: 0 !important; + min-height: 32px +} + +.docker-agent-heading-actions>.quiet, +.docker-agent-status-badge { + min-height: 32px; + box-sizing: border-box +} + +.docker-agent-listener-field { + margin-top: 2px; + padding-top: 4px; + border-top: 1px solid var(--line) +} + +#docker-agent-add { + display: inline-flex; + margin-top: 14px +} + +#docker-agents-form>.form-actions { + justify-content: flex-start; + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid var(--line) +} + +@media (max-width: 760px) { + .network-interface-row-head { + align-items: flex-start; + flex-direction: column; + gap: 2px + } + + .network-interface-row-head .fine { + white-space: normal; + text-align: left + } + + .docker-agent-heading-actions { + flex-wrap: wrap; + justify-content: flex-start + } + + #docker-agents-form>.form-actions { + justify-content: flex-start + } +} + +.mini-live-chart-collapsible>summary { + cursor: pointer; + list-style: none; + user-select: none +} + +.mini-live-chart-collapsible>summary::-webkit-details-marker { + display: none +} + +.mini-live-chart-collapsible>summary::before { + content: "▸"; + flex: 0 0 auto; + color: var(--muted); + margin-right: 1px +} + +.mini-live-chart-collapsible[open]>summary::before { + content: "▾" +} + +.mini-live-chart-collapsible>summary>span { + flex: 1 1 auto +} + +.network-interface-charts { + gap: 8px; + padding-right: 0 +} + +.network-interface-row { + min-width: 0; + padding: 0; + border: 0; + border-radius: 0; + background: transparent +} + +.network-interface-row+.network-interface-row { + border-top: 1px solid var(--line); + padding-top: 8px +} + +.network-interface-row-head { + margin-bottom: 5px +} + +.network-interface-row-head b { + color: var(--text); + font: 600 10px var(--mono) +} + +.network-interface-row-head .fine { + flex: 0 0 auto; + text-align: right; + white-space: nowrap; + font: 10px var(--mono) +} + +.network-interface-row svg { + display: block; + width: 100%; + height: 60px; + overflow: visible +} + +.docker-route-grid { + align-items: start +} + +.docker-field { + display: grid; + align-content: start; + gap: 5px; + min-width: 0; + margin: 0 !important +} + +.docker-field>span:first-child { + min-height: 14px; + line-height: 14px; + color: var(--muted); + font-size: 11px +} + +.docker-route-grid .docker-field input:not([type="checkbox"]), +.docker-route-grid .docker-field select, +.docker-acl-grid .docker-field input:not([type="checkbox"]), +.docker-acl-grid .docker-field select { + width: 100%; + min-width: 0; + min-height: 38px; + height: 38px; + align-self: start; + box-sizing: border-box +} + +.docker-checkbox-field .vh-check { + min-height: 28px; + margin: 0 !important; + align-items: center +} + +.check>span { + display: inline-flex; + align-items: center; + min-height: 15px; + line-height: 1.25 +} + +.check input[type="checkbox"] { + flex: 0 0 auto; + align-self: center; + transform: translateY(-1px) +} + +.cache-toolbar-status .check, +.docker-toolbar .check { + margin: 0 !important +} + +.docker-switch-row { + grid-column: span 3; + display: grid; + grid-template-columns: repeat(5, minmax(110px, 160px)); + justify-content: start; + align-items: start; + gap: 8px; + min-width: 0; + overflow-x: auto; + padding-bottom: 2px +} + +.transform-diagnostics { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.transform-reason-list { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.cache-transform-badge { + display: inline-flex; + align-items: center; + margin-left: 6px; + padding: 1px 5px; + border: 1px solid var(--pill-line); + border-radius: 3px; + color: var(--accent); + font: 9px var(--mono); + text-transform: uppercase; + letter-spacing: .35px; +} + +.docker-switch-row .docker-checkbox-field { + min-width: 110px; + max-width: 160px +} + +.docker-preserve-field { + align-self: start; + padding-bottom: 0; + white-space: nowrap +} + +.docker-acl-grid { + align-items: start +} + +.docker-acl-grid>.docker-field { + align-self: start +} + +.docker-acl-grid textarea { + align-self: start; + box-sizing: border-box +} + +@media (max-width: 760px) { + .network-interface-row-head { + align-items: flex-start; + flex-direction: column; + gap: 2px + } + + .network-interface-row-head .fine { + white-space: normal; + text-align: left + } +} + +.mini-live-chart-collapsible:not([open])>summary { + margin-bottom: 0 +} + +.docker-route-grid { + grid-template-columns: minmax(190px, 1fr) minmax(250px, 1.35fr) minmax(210px, 1fr) minmax(120px, .65fr) +} + +.docker-alias-field { + grid-column: span 2 +} + +.docker-alias-field>span:first-child { + margin-bottom: 0 +} + +.docker-alias-editor { + min-height: 38px; + padding: 3px 5px; + gap: 4px; + box-sizing: border-box +} + +.docker-route-grid .docker-alias-editor input:not([type="checkbox"]) { + flex: 1 1 180px; + width: auto; + min-width: 150px; + min-height: 30px; + height: 30px; + padding: 2px 4px +} + +.docker-alias-field>small, +.docker-checkbox-field>small { + margin: 0; + line-height: 1.35 +} + +@media (max-width: 1320px) { + .docker-route-grid { + grid-template-columns: 1fr 1fr + } + + .docker-alias-field, + .docker-switch-row { + grid-column: 1 / -1 + } +} + +@media (max-width: 760px) { + .docker-route-grid { + grid-template-columns: 1fr + } + + .docker-alias-field { + grid-column: auto + } + + .transform-diagnostics { + grid-template-columns: 1fr; + } +} + +/* Certificates workspace (0.20.0): use the same tab language as Settings. */ +#panel-tls .certificate-workspace-heading { + align-items: flex-start; + margin-bottom: 12px +} + +#panel-tls .certificate-workspace-heading>div, +#panel-tls .certificate-subheading>div { + min-width: 0 +} + +#panel-tls .certificate-workspace-heading p, +#panel-tls .certificate-subheading p, +#panel-tls .certificate-section-intro p { + margin: 5px 0 0 +} + +#panel-tls .certificate-tabs { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 16px +} + +#panel-tls .certificate-page[hidden] { + display: none !important +} + +#panel-tls .certificate-section-intro { + margin: 2px 0 12px +} + +#panel-tls .certificate-section-intro h3, +#panel-tls .certificate-subheading h3 { + margin: 0 +} + +#panel-tls .certificate-safety { + margin: 8px 0 12px +} + +#panel-tls .certificate-inventory-card { + padding: 14px +} + +#panel-tls .certificate-library-footer { + display: grid; + gap: 8px; + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--line) +} + +#panel-tls .certificate-library-footer p { + margin: 0 +} + +#panel-tls .certificate-operation-card { + display: grid; + gap: 10px; + margin-bottom: 12px; + border-left: 3px solid var(--line) +} + +#panel-tls .certificate-operation-card[data-state="running"] { + border-left-color: var(--accent) +} + +#panel-tls .certificate-operation-card[data-state="ready"], +#panel-tls .certificate-operation-card[data-state="complete"] { + border-left-color: var(--green) +} + +#panel-tls .certificate-operation-card[data-state="failed"] { + border-left-color: var(--red) +} + +#panel-tls .certificate-operation-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px +} + +#panel-tls .certificate-operation-heading>div { + display: grid; + gap: 6px; + min-width: 0 +} + +#panel-tls .certificate-operation-heading strong { + overflow-wrap: anywhere +} + +#panel-tls .certificate-operation-card[data-state="failed"] .certificate-operation-heading strong, +#panel-tls .certificate-operation-card[data-state="failed"] .pill { + color: var(--red) +} + +#panel-tls .certificate-operation-card[data-state="ready"] .pill, +#panel-tls .certificate-operation-card[data-state="complete"] .pill { + color: var(--green) +} + +#panel-tls .certificate-log-path { + margin: 0; + padding: 9px 10px; + border: 1px solid var(--line); + background: var(--bg); + font-family: var(--mono); + overflow-wrap: anywhere +} + +#panel-tls .certificate-renewal-card { + padding: 12px +} + +#panel-tls .renewal-entry { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + padding: 13px; + margin-bottom: 10px; + border: 1px solid var(--line); + background: var(--bg); + align-items: stretch +} + +#panel-tls .renewal-entry:last-child { + margin-bottom: 0 +} + +#panel-tls .renewal-entry[data-state="failed"] { + border-left: 3px solid var(--red) +} + +#panel-tls .renewal-entry-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px +} + +#panel-tls .renewal-entry-identity { + display: grid; + gap: 3px; + min-width: 0 +} + +#panel-tls .renewal-entry-identity strong { + overflow-wrap: anywhere +} + +#panel-tls .renewal-entry .fine { + grid-column: auto; + margin: 0 +} + +#panel-tls .renewal-meta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px +} + +#panel-tls .renewal-meta-item { + display: grid; + gap: 5px; + padding: 9px; + border: 1px solid var(--line) +} + +#panel-tls .renewal-meta-item strong { + font-size: 12px; + font-weight: 600; + overflow-wrap: anywhere +} + +#panel-tls .renewal-message { + padding-top: 2px +} + +#panel-tls .renewal-actions { + justify-content: flex-end; + padding-top: 2px +} + +#panel-tls .certificate-generator-card { + padding: 14px +} + +#panel-tls .certificate-generator-card>.provider-picker { + display: grid; + gap: 6px; + font-size: 12px; + font-weight: 600 +} + +#panel-tls .certificate-generator-card>.provider-hint { + padding: 0; + margin: 7px 0 12px +} + +#panel-tls .certificate-editor-card { + max-width: none; + margin: 0; + padding: 14px 0 0; + border: 0; + border-top: 1px solid var(--line); + box-shadow: none +} + +#panel-tls .certificate-editor-card[hidden] { + display: none !important +} + +#panel-tls details.certificate-editor-card>summary { + padding: 0 0 12px +} + +#panel-tls .certificate-editor-card .vh-grid { + gap: 10px 14px +} + +@media(max-width:700px) { + + #panel-tls .certificate-workspace-heading, + #panel-tls .certificate-subheading, + #panel-tls .certificate-operation-heading, + #panel-tls .renewal-entry-head { + align-items: stretch; + flex-direction: column + } + + #panel-tls .certificate-workspace-heading>button, + #panel-tls .certificate-subheading>button { + width: 100% + } + + #panel-tls .certificate-tabs { + grid-template-columns: 1fr + } + + #panel-tls .renewal-meta { + grid-template-columns: 1fr + } + + #panel-tls .renewal-actions { + justify-content: stretch + } + + #panel-tls .renewal-actions button { + flex: 1 1 100% + } +} + +/* Inline operation feedback mirrors the login feedback pattern and remains local to the action. */ +.operation-message:empty { + display: none; +} + +.operation-message { + margin: 14px 0 0; + padding: 11px 12px; + border: 1px solid var(--line); + background: var(--panel2); + color: var(--text); + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.operation-message.error { + border-color: rgb(255 170 162 / .58); + background: rgb(255 170 162 / .07); + color: var(--red); +} + +.cache-toolbar-message { + flex-basis: 100%; + margin-top: 0; +} + +.cache-operation-dialog { + width: min(920px, calc(100vw - 32px)); +} + +.cache-operation-dialog .card { + margin: 0; +} + +.stat-with-action { + padding-right: 116px; +} + +.stat-corner-action { + position: absolute; + top: 10px; + right: 10px; + margin: 0; +} + +.stat-chart strong { + margin-bottom: 3px; +} + +.stat-chart svg { + display: block; + width: 100%; + height: 26px; + margin-top: 2px; +} + +.stat-chart .stat-chart-legend { + display: flex; + flex-wrap: nowrap; + gap: 6px 10px; + margin-top: 2px; + white-space: nowrap; +} + +.stat-chart .stat-chart-legend span { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.stat-chart .stat-chart-legend i { + width: 8px; + height: 8px; + border-radius: 999px; + display: inline-block; + background: currentColor; +} + +.stat-chart .stat-chart-legend b { + font-family: var(--mono); + font-weight: 500; +} + +.proto-h1-line { + stroke: var(--traffic-h1); + fill: none; + stroke-width: 2; +} + +.proto-h2-line { + stroke: var(--traffic-h2); + fill: none; + stroke-width: 2; +} + +.proto-h3-line { + stroke: var(--traffic-h3); + fill: none; + stroke-width: 2; +} + +.stat-chart .chart-grid { + opacity: .28; +} + +.function-live-chart { + width: 100%; + padding: 0; + overflow: hidden +} + +.function-live-chart-summary { + display: flex; + align-items: center; + gap: 12px; + min-height: 52px; + padding: 13px 15px; + cursor: pointer; + list-style: none; + user-select: none +} + +.function-live-chart-summary::-webkit-details-marker { + display: none +} + +.function-live-chart-summary::before { + content: "▸"; + flex: 0 0 auto; + color: var(--muted); + font: 12px var(--mono) +} + +.function-live-chart[open]>.function-live-chart-summary::before { + content: "▾" +} + +.function-live-chart-heading { + display: grid; + gap: 3px; + min-width: 0; + flex: 1 1 auto +} + +.function-live-chart-heading strong { + font-size: 13px; + font-weight: 650 +} + +.function-live-chart-heading small { + color: var(--muted); + font: 9px var(--mono) +} + +.function-live-chart-summary>b { + flex: 0 0 auto; + color: var(--text); + font: 11px var(--mono); + font-weight: 650; + white-space: nowrap +} + +.function-live-chart-body { + border-top: 1px solid var(--line); + padding: 14px 15px 12px +} + +.function-live-chart svg { + display: block; + width: 100%; + height: clamp(180px, 22vw, 270px); + overflow: visible +} + +.function-live-line { + fill: none; + stroke-width: 2.2; + vector-effect: non-scaling-stroke +} + +.transform-applied-line, +.transform-applied-key { + stroke: var(--transform-chart); + color: var(--transform-chart) +} + +.transform-cache-line, +.transform-cache-key { + stroke: var(--green); + color: var(--green) +} + +.transform-skipped-line, +.transform-skipped-key { + stroke: var(--muted); + color: var(--muted) +} + +.transform-error-line, +.transform-error-key { + stroke: var(--red); + color: var(--red) +} + +.function-live-chart-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px 18px; + margin-top: 8px +} + +.function-live-chart-samples { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--muted); + font: 9px var(--mono); + white-space: nowrap +} + +.function-live-chart-samples select { + min-width: 116px; + padding: 5px 25px 5px 8px; + font: 10px var(--mono) +} + +.function-live-chart-legend { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 7px 16px; + color: var(--muted); + font: 9px var(--mono) +} + +.function-live-chart-legend span { + display: inline-flex; + align-items: center; + gap: 5px +} + +.function-live-chart-legend i { + display: inline-block; + width: 14px; + height: 2px; + background: currentColor +} + +.function-live-chart-legend b { + color: var(--text); + font-weight: 650 +} + +@media (max-width: 760px) { + .function-live-chart-summary { + align-items: flex-start + } + + .function-live-chart-heading small { + white-space: normal + } + + .function-live-chart svg { + height: 190px + } + + .function-live-chart-footer { + align-items: flex-start; + flex-direction: column + } + + .function-live-chart-legend { + justify-content: flex-start + } +} + +.runtime-information-card { + margin-top: 12px; +} + +.runtime-information-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} + +.runtime-information-grid>div { + min-width: 0; + display: grid; + gap: 5px; +} + +.runtime-information-grid code { + overflow-wrap: anywhere; + white-space: normal; +} + +/* 0.24.3 dashboard refinements */ +.payload-split { + display: flex !important; + align-items: center; + gap: 10px; + font: 9px var(--mono) !important; +} + +.payload-split span { + display: inline-flex; + align-items: center; + gap: 3px; + white-space: nowrap; +} + +.payload-split b, +.connection-legend b, +.connection-card .chart-footer b { + color: var(--text); + font-weight: 650; +} + +.connection-card h2 { + display: flex; + align-items: baseline; + gap: 6px; +} + +.connection-card h2 small { + color: var(--muted); + font: 9px var(--mono); + font-weight: 400; +} + +.connection-card svg { + display: block; + width: 100%; + height: 100px; + overflow: visible; +} + +.connection-open-line, +.connection-exchange-line { + fill: none; + vector-effect: non-scaling-stroke; + stroke-width: 2; +} + +.connection-open-line { + stroke: var(--accent); +} + +.connection-exchange-line { + stroke: var(--green); +} + +.connection-legend { + display: flex; + flex-wrap: wrap; + gap: 7px 14px; + margin: 4px 0 8px; + color: var(--muted); + font: 9px var(--mono); +} + +.connection-legend span { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.connection-legend i { + display: inline-block; + width: 14px; + height: 2px; + background: currentColor; +} + +.connection-open-key { + color: var(--accent) !important; +} + +.connection-exchange-key { + color: var(--green) !important; +} + +.connection-legend span span { + color: var(--muted); +} + +.network-interface-row-head { + align-items: center; +} + +.network-interface-head-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 7px; + min-width: 0; +} + +.network-interface-hide { + min-height: 24px; + padding: 3px 7px; + font: 9px var(--mono); +} + +.network-interface-hidden { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding-top: 8px; + border-top: 1px solid var(--line); + color: var(--muted); + font: 9px var(--mono); +} + +.network-interface-hidden .quiet { + min-height: 24px; + padding: 3px 7px; + font: 9px var(--mono); +} + +.certificate-api-credentials { + display: grid; + gap: 7px; + margin-top: 10px; +} + +.certificate-api-credential-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 9px; + border: 1px solid var(--line); + border-radius: 4px; + background: var(--panel2); +} + +.certificate-api-credential-info { + min-width: 0; + display: grid; + gap: 3px; +} + +.certificate-api-credential-info code, +.certificate-api-credential-info small { + overflow-wrap: anywhere; +} + +@media (max-width: 760px) { + + .certificate-api-credential-row, + .network-interface-row-head { + align-items: flex-start; + } + + .certificate-api-credential-row { + flex-direction: column; + } +} + +.cache-memory-sizing { + margin: 12px 0; + padding: 12px; + border: 1px solid var(--line); + border-radius: 0px; + background: var(--panel2); +} + +.cache-memory-sizing .card-heading { + margin-bottom: 6px; +} + +.cache-memory-sizing .form-actions { + flex-wrap: wrap; +} + +#cache-memory-warning { + color: var(--notice-text); + font-weight: 600; +} \ No newline at end of file diff --git a/src/admin/web/transform-panel.js b/src/admin/web/transform-panel.js new file mode 100644 index 0000000..6e17455 --- /dev/null +++ b/src/admin/web/transform-panel.js @@ -0,0 +1,170 @@ +"use strict"; +(() => { + let loading = false, generation = 0, lastData = null; + let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null; + const liveChartSamples = { applied: [], cache_hits: [], skipped: [], errors: [] }; + const LIVE_CHART_LIMITS = new Set([60, 120, 300, 600]); + const LIVE_CHART_STORAGE_KEY = "rustproxy.transform.liveChartSamples"; + let liveChartLimit = 60; + const scope = () => $("transform-scope")?.value || "*"; + const number = value => fmt(Number(value) || 0); + const percent = counters => { + const original = Number(counters?.original_bytes) || 0; + const saved = Number(counters?.saved_bytes) || 0; + return original > 0 ? `${fmt(saved / original * 100, 1)}%` : "0.0%"; + }; + const averageMs = counters => { + const examined = Number(counters?.examined) || 0; + const duration = Number(counters?.duration_us) || 0; + return examined ? `${fmt(duration / examined / 1000, 2)} ms` : "0.00 ms"; + }; + const emptyCounters = () => ({ examined: 0, applied: 0, skipped: 0, errors: 0, cache_hits: 0, original_bytes: 0, transformed_bytes: 0, saved_bytes: 0, duration_us: 0, by_kind: {}, reasons: {} }); + function resetLiveChart(selected = scope()) { + liveChartScope = selected; liveChartPrevious = null; liveChartPreviousAt = null; + for (const values of Object.values(liveChartSamples)) values.length = 0; + for (const id of ["transform-live-applied-line", "transform-live-cache-line", "transform-live-skipped-line", "transform-live-error-line"]) $(id)?.setAttribute("points", "0,170 900,170"); + for (const id of ["transform-live-applied-rate", "transform-live-cache-rate", "transform-live-skipped-rate", "transform-live-error-rate"]) if ($(id)) $(id).textContent = "0.0/s"; + if ($("transform-live-chart-value")) $("transform-live-chart-value").textContent = "-"; + } + function redrawLiveChart() { + const peak = Math.max(1, ...liveChartSamples.applied, ...liveChartSamples.cache_hits, ...liveChartSamples.skipped, ...liveChartSamples.errors); + $("transform-live-applied-line")?.setAttribute("points", sparklinePoints(liveChartSamples.applied, 900, 180, 10, peak)); + $("transform-live-cache-line")?.setAttribute("points", sparklinePoints(liveChartSamples.cache_hits, 900, 180, 10, peak)); + $("transform-live-skipped-line")?.setAttribute("points", sparklinePoints(liveChartSamples.skipped, 900, 180, 10, peak)); + $("transform-live-error-line")?.setAttribute("points", sparklinePoints(liveChartSamples.errors, 900, 180, 10, peak)); + } + function setLiveChartLimit(value, persist = true) { + const parsed = Number(value); + liveChartLimit = LIVE_CHART_LIMITS.has(parsed) ? parsed : 60; + for (const key of Object.keys(liveChartSamples)) liveChartSamples[key] = liveChartSamples[key].slice(-liveChartLimit); + if ($("transform-live-samples")) $("transform-live-samples").value = String(liveChartLimit); + if (persist) { try { localStorage.setItem(LIVE_CHART_STORAGE_KEY, String(liveChartLimit)); } catch {} } + redrawLiveChart(); + } + function initializeLiveChartLimit() { + let saved = 60; + try { saved = Number(localStorage.getItem(LIVE_CHART_STORAGE_KEY) || 60); } catch {} + setLiveChartLimit(saved, false); + } + function renderLiveChart(counters) { + const selected = scope(); + if (liveChartScope !== selected) resetLiveChart(selected); + const now = performance.now(), current = { + applied: Math.max(0, Number(counters.applied) || 0), + cache_hits: Math.max(0, Number(counters.cache_hits) || 0), + skipped: Math.max(0, Number(counters.skipped) || 0), + errors: Math.max(0, Number(counters.errors) || 0) + }, rates = { applied: 0, cache_hits: 0, skipped: 0, errors: 0 }; + if (liveChartPrevious && liveChartPreviousAt != null) { + const elapsed = Math.max(.001, (now - liveChartPreviousAt) / 1000); + for (const key of Object.keys(rates)) if (current[key] >= liveChartPrevious[key]) rates[key] = (current[key] - liveChartPrevious[key]) / elapsed; + } + liveChartPrevious = current; liveChartPreviousAt = now; + for (const key of Object.keys(rates)) { liveChartSamples[key].push(rates[key]); liveChartSamples[key] = liveChartSamples[key].slice(-liveChartLimit); } + redrawLiveChart(); + $("transform-live-applied-rate").textContent = `${rates.applied.toFixed(1)}/s`; + $("transform-live-cache-rate").textContent = `${rates.cache_hits.toFixed(1)}/s`; + $("transform-live-skipped-rate").textContent = `${rates.skipped.toFixed(1)}/s`; + $("transform-live-error-rate").textContent = `${rates.errors.toFixed(1)}/s`; + $("transform-live-chart-value").textContent = `Σ ${(rates.applied + rates.cache_hits + rates.skipped + rates.errors).toFixed(1)}/s`; + } + function countersFor(data) { + if (scope() === "*") return data?.metrics?.total || emptyCounters(); + return data?.metrics?.scopes?.find(row => row.vhost === scope()) || emptyCounters(); + } + function configure(data) { + const selected = scope(), labels = new Map(); + for (const vhost of data?.vhosts || []) { + const hosts = Array.isArray(vhost.hosts) ? vhost.hosts.join(", ") : ""; + labels.set(vhost.id, hosts ? `${vhost.id} / ${hosts}` : vhost.id); + } + for (const row of data?.metrics?.scopes || []) if (!labels.has(row.vhost)) labels.set(row.vhost, row.vhost); + const items = [el("option", t("logAllVhosts"))]; items[0].value = "*"; + for (const [id, label] of [...labels].sort((a, b) => a[0].localeCompare(b[0]))) { + const option = el("option", label); option.value = id; items.push(option); + } + $("transform-scope").replaceChildren(...items); + $("transform-scope").value = items.some(item => item.value === selected) ? selected : "*"; + } + function renderTypes(counters) { + const kinds = [ + ["javascript", "JavaScript"], ["css", "CSS"], ["html", "HTML"], ["svg", "SVG"], ["json", "JSON"] + ]; + $("transform-type-rows").replaceChildren(...kinds.map(([key, label]) => { + const tr = el("tr"); tr.append(el("td", label), el("td", number(counters?.by_kind?.[key] || 0))); return tr; + })); + } + function renderReasons(counters) { + const entries = Object.entries(counters?.reasons || {}).sort((a, b) => b[1] - a[1]); + const target = $("transform-reason-list"); target.replaceChildren(...entries.map(([key, count]) => { + const tag = el("span", `${key}: ${number(count)}`, "cache-reason"); tag.title = key; return tag; + })); + $("transform-reasons-empty").hidden = entries.length > 0; + $("tp-skip-top").textContent = entries.length ? entries.slice(0, 2).map(([key, count]) => `${key}: ${number(count)}`).join(" · ") : t("transformNoSkipsShort"); + } + function renderVhosts(data) { + const wanted = scope(); + const rows = (data?.metrics?.scopes || []).filter(row => wanted === "*" || row.vhost === wanted); + $("transform-vhost-rows").replaceChildren(...rows.map(row => { + const tr = el("tr"); + for (const value of [ + row.vhost, + number(row.applied), + number(row.cache_hits), + number(row.skipped), + number(row.errors), + fmtBytes(row.saved_bytes || 0), + percent(row), + averageMs(row) + ]) tr.append(el("td", value)); + return tr; + })); + $("transform-vhosts-empty").hidden = rows.length > 0; + } + function render(data) { + lastData = data; configure(data); + const counters = countersFor(data); + renderLiveChart(counters); + $("tp-applied").textContent = number(counters.applied); + $("tp-examined").textContent = `${t("transformExamined")}: ${number(counters.examined)}`; + $("tp-cache-hits").textContent = number(counters.cache_hits); + $("tp-saved").textContent = fmtBytes(counters.saved_bytes || 0); + $("tp-byte-flow").textContent = `${fmtBytes(counters.original_bytes || 0)} → ${fmtBytes(counters.transformed_bytes || 0)}`; + $("tp-reduction").textContent = percent(counters); + $("tp-skipped").textContent = number(counters.skipped); + $("tp-errors").textContent = number(counters.errors); + $("tp-duration").textContent = `${t("transformAvgTime")}: ${averageMs(counters)}`; + renderTypes(counters); renderReasons(counters); renderVhosts(data); + $("transform-panel-state").textContent = `${t("cacheUpdated")}: ${new Date().toLocaleTimeString(locale())}`; + } + async function refresh() { + if (!authenticated || currentPanel !== "transform" || loading) return; + loading = true; const epoch = authEpoch, token = generation; + try { + const data = await api("/api/transform"); + if (epoch !== authEpoch || token !== generation || !authenticated) return; + render(data); + } catch (error) { + if (epoch === authEpoch) { $("transform-panel-state").textContent = error.message; if (error.status === 401) lock(); } + } finally { + loading = false; + if (token !== generation && authenticated && currentPanel === "transform") void refresh(); + } + } + initializeLiveChartLimit(); + $("transform-live-samples")?.addEventListener("change", event => setLiveChartLimit(event.currentTarget.value)); + $("transform-scope")?.addEventListener("change", () => { resetLiveChart(); if (lastData) render(lastData); }); + $("transform-refresh")?.addEventListener("click", () => { generation++; void refresh(); }); + $("transform-panel-settings")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("transform"); $("transform-form")?.scrollIntoView({ block: "start" }); }); + setInterval(() => { if (!document.hidden && $("transform-live")?.checked) void refresh(); }, 3000); + window.rustProxyTransformPanel = { + refresh, + reset() { + generation++; lastData = null; resetLiveChart(); + $("transform-type-rows")?.replaceChildren(); + $("transform-vhost-rows")?.replaceChildren(); + $("transform-reason-list")?.replaceChildren(); + if ($("transform-panel-state")) $("transform-panel-state").textContent = ""; + } + }; +})(); diff --git a/src/admin/web/vhosts.js b/src/admin/web/vhosts.js new file mode 100644 index 0000000..e78c33e --- /dev/null +++ b/src/admin/web/vhosts.js @@ -0,0 +1,899 @@ +"use strict"; +// Vhost UI: language content comes from langs/*.json, never a locale switch. +// Drafts are isolated from SSE/config rendering; writes share revision+CSRF CAS. +(() => { + let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0; + const chartHistory = new Map(); + const backendTrafficHistory = new Map(); + const dialog = $("vhost-dialog"); + const form = $("vhost-form"), container = $("vhost-fields"); + const text = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; }; + const field = (parent, name, key, value, type = "text", options = {}) => { + const label = el("label", undefined, type === "checkbox" ? "check vh-check" : "vh-field"); + const input = document.createElement(type === "textarea" ? "textarea" : type === "select" ? "select" : "input"); + input.name = name; + if (type === "select") for (const [val, key] of options.values || []) { const option = text("option", key); option.value = val; input.append(option); } + else if (type !== "textarea") input.type = type; + if (type === "checkbox") input.checked = Boolean(value); else input.value = value ?? ""; + for (const attr of ["min", "max", "step", "maxLength", "rows", "required", "pattern", "readOnly"]) if (Object.hasOwn(options, attr)) input[attr] = options[attr]; + if (type === "textarea") input.spellcheck = false; + if (type === "checkbox") label.append(input, text("span", key)); else label.append(text("span", key), input); + parent.append(label); return input; + }; + const group = (key, advanced = false) => { + const box = el(advanced ? "details" : "fieldset", undefined, "vh-group"); + box.dataset.tab = ({ vhEditor: "details", vhFrontends: "details", vhBackendPool: "backends", vhBackendDiagnostics: "backends", vhProxyTimeouts: "backends", vhBalancing: "backends", vhSticky: "backends", vhHealth: "health", vhPassiveHealth: "health", vhSecurity: "security", vhTrafficPolicy: "security", vhTinyauth: "auth", vhResponsePolicy: "security", vhHeaders: "security", vhAdvanced: "advanced", vhLocations: "locations", vhSsl: "ssl", vhCache: "cache", vhTransform: "transform", vhPages: "pages" })[key] || "advanced"; box.append(text(advanced ? "summary" : "legend", key)); + const body = el("div", undefined, "vh-grid"); box.append(body); container.append(box); return body; + }; + const note = (parent, key) => { const node = text("p", key, "fine vh-wide"); parent.append(node); return node; }; + const button = (key, callback, cls = "quiet") => { const b = text("button", key, cls); b.type = "button"; b.addEventListener("click", callback); return b; }; + const read = (name, scope = form) => scope.querySelector(`[name="${name}"]`); + const str = (name, scope) => read(name, scope).value.trim(); + const num = (name, scope) => { const value = Number(str(name, scope)); if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; }; + const yes = (name, scope) => read(name, scope).checked; + const lines = value => value.split(/[\n,]+/).map(s => s.trim()).filter(Boolean); + const nullable = (name, scope) => str(name, scope) || null; + const defaultProxyTimeouts = () => ({ connect_timeout_seconds: 10, response_header_timeout_seconds: 120, read_timeout_seconds: 60, send_timeout_seconds: 60 }); + const changed = () => { dirty = true; }; + const allowDiscard = async () => { if (!dirty) return true; if (!(await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") }))) return false; dirty = false; return true; }; + const defaults = () => ({ + id: "", enabled: true, force_ssl: false, forward_client_ip: true, websockets: false, websocket_legacy_support: false, websocket_rfc8441: false, websocket_rfc9220: false, http2_enabled: true, http3_enabled: true, cloudflare_client_ip: false, timing_headers: false, backend_header: false, backend_timing_header: false, + frontend: { hosts: [], listener_ids: [] }, + backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }], + cache: { enabled: false }, transform: { enabled: false }, rate_limit: {}, routes: [], headers: {}, acl_profile_id: null, acl: null, tls_policy: null, proxy_timeouts: defaultProxyTimeouts() + }); + + function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); } + function initTabs() { const tabs = $("vhost-tabs"); tabs.replaceChildren(); tabs.setAttribute("role", "tablist"); for (const [name, key] of [["details", "vhDetailsTab"], ["backends", "vhBackendPool"], ["health", "vhHealth"], ["locations", "vhLocations"], ["auth", "vhTabAuth"], ["ssl", "vhSsl"], ["cache", "vhCache"], ["transform", "vhTransform"], ["security", "vhSecurity"], ["pages", "vhPages"], ["advanced", "advanced"]]) { const b = button(key, () => activateTab(name)); b.dataset.tab = name; b.setAttribute("role", "tab"); tabs.append(b); } activateTab("details"); } + function collectCache() { const tri = name => str(name) === "" ? null : str(name) === "on", number = (name, mul = 1) => { if (str(name) === "") return null; const value = Number(str(name)) * mul; if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; }; return { enabled: tri("vh-cache-enabled"), max_ttl_seconds: number("vh-cache-ttl"), default_ttl_seconds: number("vh-cache-default"), max_object_bytes: number("vh-cache-object", 1024), bypass_query: tri("vh-cache-query"), static_ignore_cookies: tri("vh-cache-static-cookie"), static_extensions: yes("vh-cache-static-custom") ? lines(str("vh-cache-static-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null, expose_status: tri("vh-cache-status"), expose_hits: tri("vh-cache-hits"), expose_cache_status: tri("vh-cache-rfc"), expose_resident_age: tri("vh-cache-resident-age"), bypass_prefixes: yes("vh-cache-prefix-custom") ? lines(str("vh-cache-prefixes")) : null }; } + function collectTransform() { + const tri = name => str(name) === "" ? null : str(name) === "on"; + return { + enabled: yes("vh-transform-enabled"), + javascript: tri("vh-transform-javascript"), + css: tri("vh-transform-css"), + html: tri("vh-transform-html"), + svg: tri("vh-transform-svg"), + json: tri("vh-transform-json"), + extensions: yes("vh-transform-extensions-custom") ? lines(str("vh-transform-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null + }; + } + async function purge(id) { if (!(await appConfirm(t("vhPurgeConfirm"), { danger: true }))) return; try { const result = await api(`/api/cache/purge?vhost=${encodeURIComponent(id)}`, { method: "POST", body: "{}" }); toast(`${t("cachePurged")}: ${result.purged_entries}`); await refresh(true); } catch (error) { toast(error.message, true); } } + function validateHeaders(value) { + const fail = () => { throw new Error(t("headersInvalid")); }; + if (!value || Array.isArray(value) || typeof value !== "object" || Object.keys(value).some(k => !["request", "response"].includes(k))) fail(); + for (const direction of ["request", "response"]) { + const rules = value[direction] || []; if (!Array.isArray(rules) || rules.length > 32) fail(); for (const rule of rules) { + if (!rule || typeof rule !== "object" || Object.keys(rule).some(k => !["op", "name", "value"].includes(k)) || !["set", "append", "remove"].includes(rule.op) || typeof rule.name !== "string" || rule.name.length > 128 || !/^[-!#$%&'*+.^_`|~0-9A-Za-z]+$/.test(rule.name)) fail(); + const name = rule.name.toLowerCase(); if (["host", "connection", "content-length", "transfer-encoding", "te", "trailer", "content-encoding", "content-range", "upgrade", "keep-alive", "proxy-connection", "proxy-authorization", "proxy-authenticate", "forwarded", "x-real-ip", "x-request-id", "x-rust-proxy-backend", "x-rust-proxy-backend-time", "x-rustproxy-backend-time"].includes(name) || name.startsWith("x-forwarded-") || name.startsWith("sec-websocket-")) throw new Error(t("headersProtected")); + if (rule.op === "remove") { if (Object.hasOwn(rule, "value") && rule.value !== null) fail(); } else if (typeof rule.value !== "string" || rule.value.length > 8192 || /[\r\n\0]/.test(rule.value)) fail(); + } + } return value; + } + function headerEditors(parent, initial, target = read("headers"), onChange = changed) { + const holders = {}, adders = {}, emptyStates = {}; + const renumber = direction => { + const rows = holders[direction]; + [...rows.children].forEach((row, index) => { + const title = row.querySelector(".vh-header-rule-title"); + if (title) title.textContent = `${t("headerRule")} ${index + 1}`; + }); + if (emptyStates[direction]) emptyStates[direction].hidden = rows.children.length > 0; + }; + const sync = () => { + const result = {}; + for (const direction of ["request", "response"]) result[direction] = [...holders[direction].children].map(row => { + const op = str("rule-op", row), name = str("rule-name", row), value = read("rule-value", row).value; + return op === "remove" ? { op, name } : { op, name, value }; + }); + target.value = JSON.stringify(result, null, 2); target.setCustomValidity(""); onChange(); + }; + for (const direction of ["request", "response"]) { + const block = el("section", undefined, "vh-wide location-header-direction"); block.dataset.headerDirection = direction; + const heading = el("div", undefined, "location-header-direction-head"); + heading.append(text("h3", direction === "response" ? "locationResponseHeaders" : "locationProxyHeaders"), text("p", "headerRuleHelp", "fine")); + block.append(heading); + const empty = text("p", "headerRulesEmpty", "fine vh-header-empty"); emptyStates[direction] = empty; block.append(empty); + const rows = el("div", undefined, "vh-header-rows"); holders[direction] = rows; block.append(rows); parent.append(block); + const add = (value = { op: "set", name: "", value: "" }) => { + if (rows.children.length >= 32) { toast(t("headerLimit"), true); return; } + const row = el("article", undefined, "vh-header-row"); + const rowHead = el("div", undefined, "vh-header-rule-head"), rowTitle = el("strong", "", "vh-header-rule-title"); + rowHead.append(rowTitle, button("remove", () => { row.remove(); renumber(direction); sync(); })); row.append(rowHead); + const fields = el("div", undefined, "vh-header-rule-fields"); + field(fields, "rule-op", "headerOperation", value.op, "select", { values: [["set", "header_set"], ["append", "header_append"], ["remove", "header_remove"]] }); + const name = field(fields, "rule-name", "headerName", value.name, "text", { maxLength: 128 }); name.placeholder = "X-App-Header"; + const ruleValue = field(fields, "rule-value", "headerValue", value.value ?? "", "text", { maxLength: 8192 }); ruleValue.placeholder = "value"; + row.append(fields); + const enabled = () => { ruleValue.disabled = str("rule-op", row) === "remove"; row.classList.toggle("is-remove", ruleValue.disabled); }; + row.addEventListener("change", () => { enabled(); sync(); }); row.addEventListener("input", sync); rows.append(row); enabled(); renumber(direction); + }; + adders[direction] = add; for (const rule of initial[direction] || []) add(rule); renumber(direction); + block.append(button("addHeader", () => { add(); sync(); }, "quiet location-add-header")); + } + target.addEventListener("input", () => { + try { + const value = validateHeaders(JSON.parse(target.value)); + for (const direction of ["request", "response"]) { + holders[direction].replaceChildren(); + for (const rule of value[direction] || []) adders[direction](rule); + renumber(direction); + } + target.setCustomValidity(""); onChange(); + } catch (error) { target.setCustomValidity(error.message || t("headersInvalid")); } + }); + note(parent, "locationHeadersHelp"); + return { read: () => validateHeaders(JSON.parse(target.value)) }; + } + async function closeEditor() { if (!(await allowDiscard())) return false; draftEpoch++; form.querySelectorAll('[type="password"]').forEach(input => input.value = ""); dialog.close(); return true; } + $("vhost-close").addEventListener("click", () => void closeEditor()); dialog.addEventListener("cancel", event => { event.preventDefault(); void closeEditor(); }); + function administrationListenCandidates() { + return [ + envelope?.config.administration?.listen, + latestStatus?.administration?.locked_listen, + latestStatus?.administration?.desired?.listen, + latestStatus?.administration?.active?.listen, + latestStatus?.admin_listen, + ].map(value => String(value || "").trim()).filter((value, index, values) => value && values.indexOf(value) === index); + } + function adminListenerBackendCandidate(raw) { + const splitSocket = value => { + const textValue = String(value || "").trim(); + if (!textValue) return null; + if (textValue.startsWith("[")) { + const match = textValue.match(/^\[([^\]]+)\]:(\d+)$/); return match ? { host: match[1].toLowerCase(), port: Number(match[2]) } : null; + } + const at = textValue.lastIndexOf(":"); if (at <= 0) return null; + const port = Number(textValue.slice(at + 1)); return Number.isInteger(port) ? { host: textValue.slice(0, at).toLowerCase(), port } : null; + }; + const adminListeners = administrationListenCandidates().map(splitSocket).filter(Boolean); + if (!adminListeners.length) return null; + let parsed; try { parsed = new URL(String(raw || "").trim()); } catch { return false; } + if (!['http:', 'https:'].includes(parsed.protocol)) return false; + const port = parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80; + const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase(); + const loopback = value => value === 'localhost' || value === '::1' || /^127(?:\.\d{1,3}){3}$/.test(value); + return adminListeners.some(admin => { + if (port !== admin.port) return false; + const wildcard = admin.host === '0.0.0.0' || admin.host === '::' || admin.host === '[::]'; + return host === admin.host || (wildcard && loopback(host)) || (loopback(admin.host) && loopback(host)); + }); + } + + function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`, url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }) { + if (parent.children.length >= 32) { toast(t("vhRowLimit"), true); return; } + const row = el("article", undefined, "vh-member vh-row vh-backend-card"); + const head = el("div", undefined, "vh-backend-head"), identity = el("div", undefined, "vh-backend-identity"); + const title = el("strong", value.id || t("backend"), "vh-backend-title"), endpoint = el("span", value.url || "-", "fine vh-backend-endpoint"); + identity.append(title, endpoint); head.append(identity); const remove = button("remove", () => { row.remove(); changed(); }); head.append(remove); row.append(head); + + const main = el("div", undefined, "vh-grid vh-backend-main"); row.append(main); + const id = field(main, "member-id", "vhMemberId", value.id, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 }); + const url = field(main, "member-url", "vhUrl", value.url, "url", { required: true, maxLength: 512 }); + field(main, "member-weight", "vhWeight", value.weight ?? 1, "number", { required: true, min: 1, max: 256 }); + const protocol = field(main, "member-protocol", "vhProtocol", value.protocol || "http1", "select", { values: [["http1", "vhProtocolHttp1"], ["auto", "vhProtocolAuto"], ["http2", "vhProtocolHttp2"]] }); + + const routing = el("div", undefined, "vh-wide vh-backend-switches"); main.append(routing); + const controls = {}; + for (const [name, key, checked] of [["member-enabled", "vhEnabled", value.enabled !== false], ["member-pool", "locationPoolEligible", value.pool_eligible !== false], ["member-backup", "vhBackup", value.backup], ["member-drain", "vhDrain", value.drain]]) controls[name] = field(routing, name, key, checked, "checkbox"); + const connection = el("div", undefined, "vh-wide vh-backend-switches vh-backend-connection"); main.append(connection); + controls["member-private"] = field(connection, "member-private", "vhPrivate", value.allow_private, "checkbox"); + const preserve = field(connection, "member-host", "vhPreserveHost", value.preserve_host === true, "checkbox"); + const syncProtocol = () => { preserve.disabled = protocol.value !== "http1"; if (preserve.disabled) preserve.checked = false; }; protocol.addEventListener("change", syncProtocol); syncProtocol(); + + const advanced = el("details", undefined, "vh-wide vh-backend-advanced"), extra = el("div", undefined, "vh-grid"); advanced.append(text("summary", "advanced"), extra); + field(extra, "member-allowlist", "vhAddressAllowlist", (value.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 }); + const riskGate = el("div", undefined, "vh-wide vh-local-risk-gate"); extra.append(riskGate); + const localRisk = field(riskGate, "member-local-risk", "vhLocalListenerRisk", value.accept_local_listener_risk === true, "checkbox"); + localRisk.addEventListener("change", async () => { if (localRisk.checked && !(await appConfirm(t("vhLocalListenerRiskConfirm"), { danger: true }))) localRisk.checked = false; }); + note(riskGate, "vhLocalListenerRiskHelp"); + field(extra, "member-tls-name", "vhTlsServerName", value.tls_server_name || "", "text", { maxLength: 253, placeholder: "backend.example.internal" }); + const tlsVerify = field(extra, "member-tls-verify", "vhTlsVerify", value.tls_verify === true, "checkbox"); note(extra, "vhTlsVerifyHelp"); + field(extra, "member-ca", "vhCa", value.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 }); + field(extra, "member-max-concurrent", "vhMaxConcurrentRequests", value.max_concurrent_requests ?? 0, "number", { required: true, min: 0, max: 1000000 }); + field(extra, "member-queue-timeout", "vhBackendQueueTimeout", value.queue_timeout_ms ?? 0, "number", { required: true, min: 0, max: 60000 }); + note(extra, "vhBackendCapacityHelp"); + row.append(advanced); + + const actions = el("div", undefined, "vh-backend-actions"); + actions.append(button("vhDebugOriginPreset", async () => { + if (!(await appConfirm(t("vhDebugOriginTlsWarning"), { danger: true }))) return; + if (!url.value.trim()) url.value = "https://127.0.0.1:9000"; + controls["member-pool"].checked = true; controls["member-private"].checked = true; + protocol.value = "http1"; preserve.checked = true; tlsVerify.checked = false; syncProtocol(); syncRisk(); changed(); + })); + actions.append(text("span", "vhOriginPresetHelp", "fine")); row.append(actions); + + const syncIdentity = () => { title.textContent = id.value.trim() || t("backend"); endpoint.textContent = url.value.trim() || "-"; }; + const syncRisk = (clearWhenHidden = false) => { + const show = adminListenerBackendCandidate(url.value); + riskGate.hidden = show !== true; + if (clearWhenHidden && show === false) localRisk.checked = false; + }; + id.addEventListener("input", syncIdentity); url.addEventListener("input", () => { syncIdentity(); syncRisk(true); }); url.addEventListener("change", () => syncRisk(true)); + row.syncLocalRisk = () => syncRisk(false); + syncIdentity(); syncRisk(false); parent.append(row); + } + function countryRateEditor(parent, values = []) { + const rows = el("div", undefined, "vh-wide vh-country-rates"); parent.append(rows); + const add = (value = {}) => { + if (rows.children.length >= 64) { toast(t("vhCountryRateLimit"), true); return; } + const row = el("div", undefined, "vh-row vh-grid"); + field(row, "country-rate-country", "vhCountry", value.country || "", "text", { required: true, maxLength: 2, pattern: "[A-Za-z]{2}" }); + field(row, "country-rate-requests", "vhRateRequests", value.requests ?? 120, "number", { required: true, min: 1, max: 1000000 }); + field(row, "country-rate-window", "vhRateWindow", value.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 }); + field(row, "country-rate-burst", "vhRateBurst", value.burst ?? 30, "number", { required: true, min: 0, max: 1000000 }); + row.append(button("remove", () => { row.remove(); changed(); })); rows.append(row); + }; + for (const value of values) add(value); + parent.append(button("vhAddCountryRate", () => { add(); changed(); })); + return () => [...rows.children].map(row => ({ + country: str("country-rate-country", row).toUpperCase(), requests: num("country-rate-requests", row), + window_seconds: num("country-rate-window", row), burst: num("country-rate-burst", row) + })); + } + function debugOriginIssues(members) { + return members.filter(member => { + let parsed; try { parsed = new URL(member.url); } catch { return false; } + const host = parsed.hostname.replace(/^\[|\]$/g, ""), ipHost = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || host.includes(":"); + if (parsed.protocol !== "https:" || !ipHost) return false; + return !member.pool_eligible || !member.allow_private || member.protocol !== "http1" || !member.preserve_host || member.tls_verify; + }); + } + function aclEditor(parent, initial, onChange = () => { }) { + const enabled = field(parent, "acl-use", "aclUse", !!initial, "checkbox"), body = el("div", undefined, "vh-wide vh-grid"); parent.append(body); + const policy = field(body, "acl-default", "aclDefault", initial?.default || "deny", "select", { values: [["deny", "vhDeny"], ["allow", "aclAllow"]] }), rows = el("div", undefined, "vh-wide"); body.append(rows); + function add(value = { network: "", action: "allow" }) { const row = el("div", undefined, "acl-rule"); field(row, "network", "aclNetwork", value.network, "text", { maxLength: 64 }); field(row, "action", "aclAction", value.action, "select", { values: [["allow", "aclAllow"], ["deny", "vhDeny"]] }); row.append(button("remove", () => { row.remove(); onChange(); })); rows.append(row); } + body.append(button("aclAdd", () => { add(); onChange(); })); for (const rule of initial?.rules || []) add(rule); + const sync = () => { body.hidden = !enabled.checked; onChange(); }; enabled.addEventListener("change", sync); body.addEventListener("input", onChange); body.addEventListener("change", onChange); body.hidden = !enabled.checked; + note(parent, "aclHelp"); return { read: () => enabled.checked ? { default: policy.value, rules: [...rows.children].map(r => ({ network: str("network", r), action: str("action", r) })) } : null }; + } + function openLocationHelp(topic = "locations") { + const dialog = $("location-help-dialog"), title = $("location-help-title"), body = $("location-help-body"); + if (!dialog || !title || !body) return; + title.textContent = t(topic === "headers" ? "headersDocsTitle" : "locationDocsTitle"); + const section = (headingKey, keys, cls = "") => { + const box = el("section", undefined, `location-doc-section ${cls}`.trim()); + if (headingKey) box.append(text("h3", headingKey)); + for (const key of keys) box.append(el("p", t(key))); + return box; + }; + if (topic === "headers") { + body.replaceChildren( + el("p", t("headersDocsIntro"), "location-doc-intro"), + section("locationHeaders", ["headersDocsSet", "headersDocsAppend", "headersDocsRemove"]), + section("advanced", ["headersDocsProtected", "headersDocsOrder"]) + ); + } else { + body.replaceChildren( + el("p", t("locationDocsIntro"), "location-doc-intro"), + section("locationDocsMatchHeading", ["locationDocsPrefix", "locationDocsExact", "locationDocsRegex"]), + section("locationDocsActionHeading", ["locationDocsReturn", "locationDocsRewrite"]), + section("locationDocsPolicyHeading", ["locationDocsAccess", "locationDocsLimits"]), + section("locationDocsExamplesHeading", ["locationDocsExampleExact", "locationDocsExampleStatic", "locationDocsExampleRegex"], "location-doc-examples") + ); + } + dialog.showModal(); + } + function locationsEditor(parent, values) { + const toolbar = el("div", undefined, "vh-wide location-toolbar"), rows = el("div", undefined, "vh-wide vh-locations"); + const addTop = button("locationAdd", () => { add(); sync(); }, "primary"); + toolbar.append(addTop, button("documentation", () => openLocationHelp("locations"))); + parent.append(toolbar); note(parent, "locationHelp"); parent.append(rows); let reloading = false; + function readRows() { + return [...rows.children].map(r => { + const action = str("location-action", r), rewriteMode = str("location-rewrite-mode", r); + return { + id: str("location-id", r), path_prefix: str("location-path", r), match_type: str("location-match", r), + backend_id: action === "proxy" ? (str("location-backend", r) || null) : null, + enabled: yes("location-enabled", r), cache: action === "proxy" ? (str("location-cache", r) === "" ? null : str("location-cache", r) === "on") : false, + acl: r.readAcl(), headers: r.readHeaders(), + rewrite: { mode: action === "proxy" ? rewriteMode : "preserve", value: action === "proxy" && rewriteMode !== "preserve" ? read("location-rewrite-value", r).value.trim() : "" }, + response: action === "return" ? { status: Number(str("location-response-status", r)), content_type: str("location-response-type", r), body: read("location-response-body", r).value } : null + }; + }); + } + function sync() { if (reloading) return; try { read("paths").value = JSON.stringify(readRows(), null, 2); changed(); } catch { } } + function memberOptions(select, chosen = select.value) { const base = text("option", "locationPool"); base.value = ""; select.replaceChildren(base, ...[...form.querySelectorAll(".vh-member")].map(m => { const id = str("member-id", m), o = el("option", `${id} / ${str("member-url", m)}`); o.value = id; return o; })); if (chosen && !Array.from(select.options).some(o => o.value === chosen)) { const o = el("option", `${chosen} / ${t("locationMissing")}`); o.value = chosen; select.append(o); } select.value = chosen; } + function add(value = {}) { + if (rows.children.length >= 64) { toast(t("locationLimit"), true); return; } + const row = el("details", undefined, "vh-location"); row.open = true; + const summary = el("summary", undefined, "location-summary"), body = el("div", undefined, "vh-grid location-body"); row.append(summary, body); + const used = new Set([...rows.children].map(r => str("location-id", r))); let suffix = 1; while (used.has(`location-${suffix}`)) suffix++; + field(body, "location-id", "locationId", value.id || `location-${suffix}`, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 }); + const match = field(body, "location-match", "locationMatchType", value.match_type || "prefix", "select", { values: [["prefix", "locationMatchPrefix"], ["prefix_priority", "locationMatchPriority"], ["exact", "locationMatchExact"], ["regex", "locationMatchRegex"], ["regex_insensitive", "locationMatchRegexI"]] }); + const path = field(body, "location-path", "locationPathPattern", value.path_prefix || "/", "text", { required: true, maxLength: 1024 }); path.placeholder = "/api or ^/api/v[0-9]+/"; + const action = field(body, "location-action", "locationAction", value.response ? "return" : "proxy", "select", { values: [["proxy", "locationProxyAction"], ["return", "locationReturnAction"]] }); + const backend = field(body, "location-backend", "backend", value.backend_id || "", "select"); memberOptions(backend, value.backend_id || ""); + field(body, "location-enabled", "enabled", value.enabled !== false, "checkbox"); + const cache = field(body, "location-cache", "cacheEnabled", value.cache === true ? "on" : value.cache === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] }); + + const handling = el("details", undefined, "vh-wide location-section"), handlingBody = el("div", undefined, "vh-grid"); handling.append(text("summary", "locationHandling"), handlingBody); body.append(handling); + const rewriteMode = field(handlingBody, "location-rewrite-mode", "locationRewriteMode", value.rewrite?.mode || "preserve", "select", { values: [["preserve", "locationRewritePreserve"], ["strip_prefix", "locationRewriteStrip"], ["replace_prefix", "locationRewriteReplace"], ["regex", "locationRewriteRegex"]] }); + const rewriteValue = field(handlingBody, "location-rewrite-value", "locationRewriteValue", value.rewrite?.value || "", "text", { maxLength: 2048 }); + const returnBox = el("div", undefined, "vh-wide vh-grid location-return-box"); handlingBody.append(returnBox); + field(returnBox, "location-response-status", "locationResponseStatus", value.response?.status ?? 200, "number", { min: 200, max: 599, required: true }); + field(returnBox, "location-response-type", "locationResponseType", value.response?.content_type || "text/plain; charset=utf-8", "text", { maxLength: 256, required: true }); + field(returnBox, "location-response-body", "locationResponseBody", value.response?.body || "", "textarea", { rows: 5, maxLength: 65536 }); + note(handlingBody, "locationRewriteHelp"); + + const extra = el("details", undefined, "vh-wide location-section"), accessBody = el("div", undefined, "vh-grid"); extra.append(text("summary", "locationAccess"), accessBody); body.append(extra); const acl = aclEditor(accessBody, value.acl, sync); row.readAcl = () => acl.read(); + const headerBox = el("details", undefined, "vh-wide location-section location-header-editor"), headerBody = el("div", undefined, "vh-grid"); headerBox.append(text("summary", "locationHeaders"), headerBody); body.append(headerBox); + const headerToolbar = el("div", undefined, "vh-wide location-header-toolbar"), visualPane = el("div", undefined, "vh-wide vh-grid"), jsonPane = el("div", undefined, "vh-wide vh-grid"); jsonPane.hidden = true; + const visualButton = button("headersVisualBuilder", () => { visualPane.hidden = false; jsonPane.hidden = true; }), jsonButton = button("headersAdvancedJson", () => { visualPane.hidden = true; jsonPane.hidden = false; }), docsButton = button("documentation", () => openLocationHelp("headers")); + headerToolbar.append(visualButton, jsonButton, docsButton); headerBody.append(headerToolbar, visualPane, jsonPane); + const raw = field(jsonPane, "location-headers", "headersAdvancedJson", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 8, maxLength: 65536 }); + const editor = headerEditors(visualPane, value.headers || {}, raw, sync); row.readHeaders = () => editor.read(); + + const preview = el("section", undefined, "vh-wide location-preview"); + preview.append(text("h4", "locationPreviewTitle")); + const previewStatus = text("p", "locationPreviewDisabled", "fine location-preview-disabled"); preview.append(previewStatus); + const previewGrid = el("dl", undefined, "location-preview-grid"), previewValues = {}; + for (const [name, key] of [["match", "locationPreviewMatch"], ["handling", "locationPreviewHandling"], ["rewrite", "locationPreviewRewrite"], ["cache", "locationPreviewCache"], ["access", "locationPreviewAccess"], ["headers", "locationPreviewHeaders"]]) { + const item = el("div"); item.append(text("dt", key)); const output = el("dd", "-"); previewValues[name] = output; item.append(output); previewGrid.append(item); + } + preview.append(previewGrid); body.append(preview); + + const actions = el("div", undefined, "vh-wide location-actions"); + const dedicated = button("locationAddBackend", () => { const members = form.querySelector(".vh-members"); let i = 1; const ids = new Set([...members.children].map(r => str("member-id", r))); while (ids.has(`location-origin-${i}`)) i++; const id = `location-origin-${i}`; memberRow(members, { id, url: "http://127.0.0.1:9001", weight: 1, enabled: true, pool_eligible: false, allow_private: false }); memberOptions(backend, id); sync(); activateTab("backends"); }); + actions.append(dedicated, button("remove", () => { row.remove(); sync(); })); body.append(actions); + + const syncUi = () => { + const isReturn = action.value === "return", isRegex = match.value === "regex" || match.value === "regex_insensitive"; + backend.disabled = isReturn; cache.disabled = isReturn; dedicated.disabled = isReturn; + returnBox.hidden = !isReturn; rewriteMode.disabled = isReturn; rewriteValue.disabled = isReturn || rewriteMode.value === "preserve" || rewriteMode.value === "strip_prefix"; + for (const option of rewriteMode.options) if (option.value === "regex") option.disabled = !isRegex; + if (!isRegex && rewriteMode.value === "regex") rewriteMode.value = "preserve"; + path.placeholder = isRegex ? "^/api/v[0-9]+/" : "/api/"; + const id = str("location-id", row) || t("locationId"), pattern = str("location-path", row) || "/"; + summary.textContent = `${id} · ${t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : "locationProxyShort")}`; + + previewStatus.hidden = yes("location-enabled", row); + previewValues.match.textContent = `${t(`locationMatch_${match.value}`)} · ${pattern}`; + if (isReturn) { + const status = str("location-response-status", row) || "200", type = str("location-response-type", row) || "text/plain"; + previewValues.handling.textContent = `${t("locationReturnAction")}: HTTP ${status} · ${type} · ${t("locationPreviewBackendSkipped")}`; + previewValues.rewrite.textContent = "-"; + previewValues.cache.textContent = t("notEnabled"); + } else { + const selected = backend.selectedOptions?.[0]?.textContent || t("locationPool"); + previewValues.handling.textContent = `${t("locationProxyAction")}: ${selected}`; + const rewriteLabels = { preserve: "locationRewritePreserve", strip_prefix: "locationRewriteStrip", replace_prefix: "locationRewriteReplace", regex: "locationRewriteRegex" }; + const replacement = read("location-rewrite-value", row).value.trim(); + previewValues.rewrite.textContent = `${t(rewriteLabels[rewriteMode.value] || "locationRewritePreserve")}${replacement && !["preserve", "strip_prefix"].includes(rewriteMode.value) ? `: ${replacement}` : ""} · ${t("locationPreviewQueryPreserved")}`; + previewValues.cache.textContent = t(cache.value === "on" ? "enabled" : cache.value === "off" ? "notEnabled" : "inherit"); + } + previewValues.access.textContent = yes("acl-use", row) ? t("locationPreviewCustomAcl") : t("locationPreviewVhostAcl"); + try { + const headerValue = editor.read(), requestRules = headerValue.request?.length || 0, responseRules = headerValue.response?.length || 0; + previewValues.headers.textContent = `${requestRules} ${t("locationPreviewRequestRules")} / ${responseRules} ${t("locationPreviewResponseRules")}`; + } catch { previewValues.headers.textContent = t("headersInvalid"); } + }; + row.addEventListener("input", () => { syncUi(); sync(); }); row.addEventListener("change", () => { syncUi(); sync(); }); + rows.append(row); syncUi(); + } + // The main vhost backend is an internal fallback, not a Custom location. + // An empty location list must stay empty so the editor cannot accidentally + // delete or recreate the main route as user configuration. + for (const value of values) add(value); + const bottom = el("div", undefined, "vh-wide location-toolbar location-toolbar-bottom"); bottom.append(button("locationAdd", () => { add(); sync(); }, "primary")); parent.append(bottom); + form.querySelector(".vh-members").addEventListener("input", () => { for (const select of rows.querySelectorAll('[name="location-backend"]')) memberOptions(select); }); + // Advanced JSON remains an explicit escape hatch; edits rebuild visual rows. + read("paths").addEventListener("change", () => { try { const data = JSON.parse(str("paths")); if (!Array.isArray(data) || data.length > 64) return; reloading = true; rows.replaceChildren(); for (const value of data) add(value); } catch { } finally { reloading = false; } }); + sync(); return readRows; + } + function basicAuthEditor(parent, settings = {}) { + const realm = field(parent, "basic-realm", "basicRealm", settings.realm || "Restricted", "text", { maxLength: 128 }); + const secure = field(parent, "basic-tls", "basicRequireTls", settings.require_tls !== false, "checkbox"), forward = field(parent, "basic-forward", "basicForward", settings.forward_credentials, "checkbox"); + const rows = el("div", undefined, "vh-wide basic-users"); parent.append(rows); let sequence = 0; + function add(user = {}) { + if (rows.children.length >= 64) { toast(t("basic.too_many_users"), true); return; } const row = el("div", undefined, "basic-user"); + const username = field(row, "basic-username", "username", user.username || "", "text", { maxLength: 128 }); + const password = field(row, "basic-password", "basicPassword", "", "password", { maxLength: 128 }); password.autocomplete = "new-password"; password.placeholder = user.password_hash ? t("basicKeepPassword") : t("basicPassword"); + const state = el("span", user.password_hash ? t("basicHashSaved") : t("basicHashRequired"), "fine"); row.append(state, button("remove", () => { row.remove(); changed(); })); row.userHash = user.password_hash || ""; row.sequence = ++sequence; rows.append(row); + row.prepare = async () => { if (!password.value) { if (!row.userHash) throw new Error(t("basicHashRequired")); return; } const name = username.value.trim(), secret = password.value, epoch = draftEpoch; const result = await api("/api/basic-auth/hash", { method: "POST", body: JSON.stringify({ username: name, password: secret }) }); if (epoch !== draftEpoch || !row.isConnected || password.value !== secret || username.value.trim() !== name) throw new Error(t("vhUnsaved")); row.userHash = result.password_hash; password.value = ""; password.placeholder = t("basicKeepPassword"); state.textContent = t("basicHashSaved"); }; + } + for (const user of settings.users || []) add(user); parent.append(button("basicAddUser", () => { add(); changed(); })); note(parent, "basicHelp"); + return { async prepare() { for (const row of rows.children) await row.prepare(); }, read: () => ({ enabled: str("auth-mode") === "basic", realm: realm.value.trim(), require_tls: secure.checked, forward_credentials: forward.checked, users: [...rows.children].map(row => ({ username: str("basic-username", row), password_hash: row.userHash })) }) }; + } + + function hostTagEditor(parent, hosts = []) { + const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(text("span", "vhHosts")); + const hidden = document.createElement("textarea"); hidden.name = "hosts"; hidden.hidden = true; + const box = el("div", undefined, "host-chip-editor"), input = document.createElement("input"); input.type = "text"; input.maxLength = 253; input.placeholder = "app.example.com / *.example.com"; input.setAttribute("aria-label", t("vhHostAdd")); + const values = []; + const normalize = raw => { + let value = raw.trim().toLowerCase().replace(/\.$/, ""); if (!value) return null; const wildcard = value.startsWith("*."); const base = wildcard ? value.slice(2) : value; + if (base.length > 253 || !/^[a-z0-9.-]+$/.test(base) || base.startsWith(".") || base.endsWith(".") || base.split(".").some(part => !part || part.length > 63 || part.startsWith("-") || part.endsWith("-"))) throw new Error(t("vhHostInvalid")); + if (wildcard && !base.includes(".")) throw new Error(t("vhHostInvalid")); return wildcard ? `*.${base}` : base; + }; + const sync = () => { hidden.value = values.join("\n"); box.querySelectorAll(".host-chip").forEach(node => node.remove()); for (const value of values) { const chip = el("span", undefined, "host-chip"); chip.append(el("span", value)); const remove = el("button", "×"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${value}`); remove.addEventListener("click", () => { values.splice(values.indexOf(value), 1); sync(); changed(); }); chip.append(remove); box.insertBefore(chip, input); } }; + const add = raw => { const value = normalize(raw); if (!value || values.includes(value)) return; values.push(value); sync(); changed(); }; + const consume = raw => { for (const part of raw.split(/[\s,]+/).filter(Boolean)) add(part); input.value = ""; }; + input.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { consume(input.value); } catch (error) { toast(error.message, true); } } }); + input.addEventListener("blur", () => { if (!input.value.trim()) return; try { consume(input.value); } catch (error) { toast(error.message, true); } }); + input.addEventListener("paste", event => { const textValue = event.clipboardData?.getData("text") || ""; if (!/[\s,]/.test(textValue.trim())) return; event.preventDefault(); try { consume(textValue); } catch (error) { toast(error.message, true); } }); + box.append(input); wrapper.append(hidden, box, text("p", "vhHostsHelp", "fine")); parent.append(wrapper); for (const host of hosts) { try { add(host); } catch { } } dirty = false; + return () => { if (input.value.trim()) consume(input.value); if (!values.length) throw new Error(t("vhHostRequired")); return [...values]; }; + } + + function load(value = defaults(), migrationIds = [], show = true) { + draftEpoch++; stagedCertificates = []; uploading = false; + editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true; + container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel"); + let g = group("vhEditor"); field(g, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(g, "vhIdRenameHelp"); + readHosts = hostTagEditor(g, value.frontend?.hosts || []); field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox"); + const websocketEnabled = field(g, "websockets", "vhWebsockets", value.websockets !== false, "checkbox"); + const websocketModeValue = value.websocket_rfc8441 === true && value.websocket_rfc9220 === true ? "h2h3" : value.websocket_rfc8441 === true ? "h2" : value.websocket_rfc9220 === true ? "h3" : "h1"; + const websocketMode = field(g, "websocket-mode", "vhWebsocketMode", websocketModeValue, "select", { values: [["h1", "vhWebsocketH1"], ["h2", "vhWebsocketH2Fallback"], ["h3", "vhWebsocketH3Fallback"], ["h2h3", "vhWebsocketH2H3Fallback"]] }); + note(g, "vhWebsocketTransportHelp"); + const websocketLegacy = field(g, "websocket-legacy-support", "vhWebsocketLegacySupport", value.websocket_legacy_support === true, "checkbox"); + note(g, "vhWebsocketLegacySupportHelp"); + g = group("vhFrontends"); + const bindingControl = window.rustProxyIntegrations.bindings(g, value.frontend?.listener_ids || [], !editing); readBindings = bindingControl.read; + note(g, "listenerDefaultPortsHint"); + field(g, "force-ssl", "vhForceSsl", value.force_ssl === true, "checkbox"); + note(g, "vhForceSslHelp"); + field(g, "forward-client-ip", "vhForwardClientIp", value.forward_client_ip !== false, "checkbox"); + note(g, "vhForwardClientIpHelp"); + field(g, "cloudflare-client-ip", "vhCloudflareClientIp", value.cloudflare_client_ip === true, "checkbox"); + const protocolRow = el("div", undefined, "vh-wide protocol-toggle-row"); g.append(protocolRow); + const h2Enabled = field(protocolRow, "http2-enabled", "vhHttp2Enabled", value.http2_enabled !== false, "checkbox"); + const h3Enabled = field(protocolRow, "http3-enabled", "vhHttp3Enabled", value.http3_enabled !== false, "checkbox"); + note(g, "vhFrontendProtocolsHelp"); note(g, "vhFrontendSelectionOnly"); + const syncFrontendTransports = () => { + const capabilities = bindingControl.capabilities(); + h2Enabled.disabled = !capabilities.http2; h3Enabled.disabled = !capabilities.http3; + if (!capabilities.http2) h2Enabled.checked = false; + if (!capabilities.http3) h3Enabled.checked = false; + const h2Available = capabilities.http2 && h2Enabled.checked; + const h3Available = capabilities.http3 && h3Enabled.checked; + websocketMode.disabled = !websocketEnabled.checked; + websocketLegacy.disabled = !websocketEnabled.checked; + const optionH2 = websocketMode.querySelector('option[value="h2"]'); + const optionH3 = websocketMode.querySelector('option[value="h3"]'); + const optionH2H3 = websocketMode.querySelector('option[value="h2h3"]'); + optionH2.disabled = !h2Available; + optionH3.disabled = !h3Available; + optionH2H3.disabled = !h2Available || !h3Available; + const selected = websocketMode.value; + if ((selected === "h2" && !h2Available) || (selected === "h3" && !h3Available) || (selected === "h2h3" && (!h2Available || !h3Available))) { + websocketMode.value = selected === "h2h3" && h2Available ? "h2" : selected === "h2h3" && h3Available ? "h3" : "h1"; + } + }; + websocketEnabled.addEventListener("change", syncFrontendTransports); + bindingControl.onChange(syncFrontendTransports); + h2Enabled.addEventListener("change", syncFrontendTransports); + h3Enabled.addEventListener("change", syncFrontendTransports); + syncFrontendTransports(); + g = group("vhBackendPool"); const members = el("div", undefined, "vh-wide vh-members"); g.append(members); + (value.backends || []).forEach(m => memberRow(members, m)); g.append(button("vhAddBackend", () => { memberRow(members); changed(); })); + g = group("vhBackendDiagnostics"); + field(g, "timing-headers", "vhTimingHeaders", value.timing_headers === true, "checkbox"); + field(g, "backend-header", "vhBackendHeader", value.backend_header === true, "checkbox"); + field(g, "backend-timing-header", "vhBackendTimingHeader", value.backend_timing_header === true, "checkbox"); + note(g, "vhBackendDiagnosticsHelp"); + const proxyTimeouts = { ...defaultProxyTimeouts(), ...(value.proxy_timeouts || {}) }; + g = group("vhProxyTimeouts"); + field(g, "proxy-connect-timeout", "vhProxyConnectTimeout", proxyTimeouts.connect_timeout_seconds ?? 10, "number", { required: true, min: 1, max: 300 }); + field(g, "proxy-header-timeout", "vhProxyHeaderTimeout", proxyTimeouts.response_header_timeout_seconds ?? 120, "number", { required: true, min: 1, max: 86400 }); + field(g, "proxy-read-timeout", "vhProxyReadTimeout", proxyTimeouts.read_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 }); + field(g, "proxy-send-timeout", "vhProxySendTimeout", proxyTimeouts.send_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 }); + note(g, "vhProxyTimeoutsHelp"); + const settings = value.load_balancing || {}, health = settings.health_check || {}, keepalive = settings.keepalive || {}, retry = settings.retry || {}, sticky = settings.sticky || {}; + g = group("vhBalancing"); field(g, "algorithm", "vhAlgorithm", settings.algorithm || "least_connections", "select", { values: [["round_robin", "vhRoundRobin"], ["least_connections", "vhLeastConnections"], ["least_traffic", "vhLeastTraffic"], ["adaptive", "vhAdaptive"], ["ip_hash", "vhIpHash"]] }); + field(g, "slow-start", "vhSlowStart", settings.slow_start_seconds ?? 10, "number", { required: true, min: 0, max: 300 }); + field(g, "retry-max", "vhRetryMax", retry.max_retries ?? 1, "number", { required: true, min: 0, max: 3 }); + field(g, "retry-connect", "vhRetryConnect", retry.retry_connect_errors !== false, "checkbox"); + field(g, "retry-timeouts", "vhRetryTimeouts", retry.retry_timeouts !== false, "checkbox"); + field(g, "retry-statuses", "vhRetryStatuses", (retry.retry_statuses || [502, 503, 504]).join(", "), "text", { maxLength: 64 }); + note(g, "vhRetryHelp"); + field(g, "keepalive", "vhKeepalive", keepalive.enabled !== false, "checkbox"); + field(g, "idle-timeout", "vhIdleTimeout", keepalive.idle_timeout_seconds ?? 30, "number", { required: true, min: 1, max: 3600 }); + field(g, "max-idle", "vhMaxIdle", keepalive.max_idle_per_host ?? 4, "number", { required: true, min: 1, max: 64 }); + field(g, "tcp-keepalive", "vhTcpKeepalive", keepalive.tcp_keepalive_seconds ?? 30, "number", { required: true, min: 1, max: 3600 }); + g = group("vhHealth"); const healthMode = field(g, "health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] }); + const healthOffWarning = note(g, "vhHealthOffWarning"); const syncHealthWarning = () => { healthOffWarning.hidden = healthMode.value !== "off"; }; healthMode.addEventListener("change", syncHealthWarning); syncHealthWarning(); + field(g, "health-path", "vhHealthPath", health.path || "/", "text", { required: true, maxLength: 1024 }); + field(g, "health-host", "vhHealthHost", health.host || "", "text", { maxLength: 260 }); + for (const [name, key, val, min, max] of [["interval", "vhInterval", health.interval_ms ?? 5000, 250, 300000], ["timeout", "vhTimeout", health.timeout_ms ?? 2000, 50, 60000], ["slow", "vhSlow", health.slow_response_ms ?? 1500, 0, 60000], ["fall", "vhFall", health.fall ?? 3, 1, 20], ["rise", "vhRise", health.rise ?? 2, 1, 20], ["status-min", "vhStatusMin", health.expected_status_min ?? 200, 100, 599], ["status-max", "vhStatusMax", health.expected_status_max ?? 399, 100, 599]]) field(g, name, key, val, "number", { required: true, min, max }); note(g, "vhHealthNote"); + const passive = settings.passive_health || {}; + g = group("vhPassiveHealth"); + const passiveEnabled = field(g, "passive-enabled", "vhPassiveEnabled", passive.enabled === true, "checkbox"); + note(g, "vhPassiveHelp"); + const passiveFields = [ + field(g, "passive-connect", "vhPassiveConnect", passive.count_connect_errors !== false, "checkbox"), + field(g, "passive-timeouts", "vhPassiveTimeouts", passive.count_timeouts !== false, "checkbox"), + field(g, "passive-io", "vhPassiveIo", passive.count_io_errors !== false, "checkbox"), + field(g, "passive-5xx", "vhPassive5xx", passive.count_http_5xx !== false, "checkbox"), + field(g, "passive-slow", "vhPassiveSlow", passive.count_slow_responses === true, "checkbox"), + field(g, "passive-fall", "vhPassiveFall", passive.fall ?? "", "number", { min: 1, max: 20 }), + field(g, "passive-rise", "vhPassiveRise", passive.rise ?? "", "number", { min: 1, max: 20 }) + ]; + const passive5xx = passiveFields[3], passiveSlow = passiveFields[4]; + const passive5xxWarning = note(g, "vhPassive5xxWarning"); + const passiveSlowWarning = note(g, "vhPassiveSlowWarning"); + const syncPassive = () => { + passiveFields.forEach(control => { control.disabled = !passiveEnabled.checked; const label = control.closest("label"); if (label) label.hidden = !passiveEnabled.checked; }); + passive5xxWarning.hidden = !passiveEnabled.checked || !passive5xx.checked; + passiveSlowWarning.hidden = !passiveEnabled.checked || !passiveSlow.checked; + }; + passiveEnabled.addEventListener("change", syncPassive); + passive5xx.addEventListener("change", syncPassive); + passiveSlow.addEventListener("change", syncPassive); + syncPassive(); + g = group("vhSticky"); field(g, "sticky-mode", "vhStickyMode", sticky.mode || "off", "select", { values: [["off", "vhOff"], ["cookie", "vhCookie"], ["ip_hash", "vhIpHash"]] }); + field(g, "cookie-name", "vhCookieName", sticky.cookie_name || "", "text", { maxLength: 96, pattern: "[A-Za-z0-9_-]*" }); + field(g, "cookie-ttl", "vhCookieTtl", sticky.ttl_seconds ?? 3600, "number", { required: true, min: 60, max: 604800 }); note(g, "vhStickyNote"); + const rate = value.rate_limit || {}, access = rate.country_access || {}; + g = group("vhTrafficPolicy"); + const aclProfile = field(g, "acl-profile-id", "vhAclProfile", value.acl_profile_id || "", "select"); + const noProfile = text("option", "vhAclProfileNone"); noProfile.value = ""; aclProfile.append(noProfile, ...(envelope?.config.acl_profiles || []).map(profile => { const option = el("option", profile.name || profile.id); option.value = profile.id; return option; })); aclProfile.value = value.acl_profile_id || ""; note(g, "vhAclProfileHelp"); + field(g, "rate-enabled", "vhRateEnabled", rate.enabled === true, "checkbox"); + field(g, "rate-requests", "vhRateRequests", rate.requests ?? 120, "number", { required: true, min: 1, max: 1000000 }); + field(g, "rate-window", "vhRateWindow", rate.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 }); + field(g, "rate-burst", "vhRateBurst", rate.burst ?? 30, "number", { required: true, min: 0, max: 1000000 }); + field(g, "country-mode", "vhCountryMode", access.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] }); + field(g, "country-list", "vhCountryList", (access.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 }); + note(g, "vhCountryPolicyHelp"); countryRatesReader = countryRateEditor(g, rate.country_limits || []); note(g, "vhRateHelp"); + const security = value.security_headers || {}, hsts = security.hsts || {}; + g = group("vhSecurity"); field(g, "hsts", "vhHsts", hsts.enabled, "checkbox"); field(g, "hsts-age", "vhHstsAge", hsts.max_age_seconds ?? 31536000, "number", { required: true, min: 0, max: 63072000 }); + field(g, "hsts-sub", "vhHstsSub", hsts.include_subdomains, "checkbox"); field(g, "hsts-preload", "vhHstsPreload", hsts.preload, "checkbox"); field(g, "nosniff", "vhNosniff", security.nosniff, "checkbox"); + field(g, "frame", "vhFrame", security.frame_options || "preserve", "select", { values: [["preserve", "vhPreserve"], ["deny", "vhDeny"], ["sameorigin", "vhSameorigin"]] }); + field(g, "referrer", "vhReferrer", security.referrer_policy || "", "text", { maxLength: 8192 }); + field(g, "csp", "vhCsp", security.content_security_policy || "", "textarea", { rows: 2, maxLength: 8192 }); + field(g, "permissions", "vhPermissions", security.permissions_policy || "", "text", { maxLength: 8192 }); + g = group("vhAdvanced", true); field(g, "paths", "vhPaths", JSON.stringify(value.routes || [], null, 2), "textarea", { rows: 5, maxLength: 131072 }); + field(g, "acl", "vhAcl", JSON.stringify(value.acl ?? null, null, 2), "textarea", { rows: 5, maxLength: 32768 }); + field(g, "headers", "vhHeaders", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 5, maxLength: 65536 }); + g = group("vhLocations"); locationsReader = locationsEditor(g, value.routes || []); + g = group("vhSsl"); + const selector = field(g, "certificate-id", "vhCertificate", value.certificate_id || "", "select"); + const fillCertificates = () => { const selected = selector.value || value.certificate_id || ""; const inherit = text("option", "vhCertificateDefault"); inherit.value = ""; selector.replaceChildren(inherit, ...[...(envelope?.config.tls?.certificates || []), ...stagedCertificates].map(c => { const o = el("option", c.id); o.value = c.id; return o; })); selector.value = selected; }; fillCertificates(); + g.append(button("certManage", async () => { if (!(await closeEditor())) return; setPanel("tls"); })); note(g, "certSelectHint"); + const inheritedTlsPolicy = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] }; + const tlsPolicy = value.tls_policy || inheritedTlsPolicy; + const customTlsPolicy = field(g, "tls-policy-custom", "vhTlsOverride", value.tls_policy != null, "checkbox"); + const tls12 = field(g, "tls-policy-12", "tls12", tlsPolicy.tls12 !== false, "checkbox"); + const tls13 = field(g, "tls-policy-13", "tls13", tlsPolicy.tls13 !== false, "checkbox"); + const tlsCiphers = field(g, "tls-policy-ciphers", "tlsCiphers", (tlsPolicy.cipher_suites || []).join("\n"), "textarea", { rows: 7, maxLength: 8192 }); + const syncTlsPolicy = () => { for (const input of [tls12, tls13, tlsCiphers]) input.disabled = !customTlsPolicy.checked; }; + customTlsPolicy.addEventListener("change", syncTlsPolicy); syncTlsPolicy(); note(g, "vhTlsOverrideHelp"); + g = group("vhTinyauth"); const auth = value.tinyauth || {}, authRoot = g; + const authMode = field(g, "auth-mode", "authMode", value.basic_auth?.enabled ? "basic" : auth.enabled ? "tinyauth" : "none", "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["basic", "authBasic"]] }); + note(g, "authModeHelp"); + + const aclBody = el("section", undefined, "vh-wide auth-subcard auth-ip-card"); authRoot.append(aclBody); + aclBody.append(text("h3", "vhIpAccess")); + aclControl = aclEditor(aclBody, value.acl, () => { if (aclControl) read("acl").value = JSON.stringify(aclControl.read(), null, 2); }); + + const taBody = el("section", undefined, "vh-wide auth-subcard"), basicBody = el("section", undefined, "vh-wide auth-subcard"); authRoot.append(taBody, basicBody); + taBody.append(text("h3", "authTinyauth")); + const taPrimary = el("div", undefined, "vh-grid"); taBody.append(taPrimary); + const authEnable = field(taPrimary, "auth-enabled", "taEnable", auth.enabled, "checkbox"); + const authUrl = field(taPrimary, "auth-url", "taUrl", auth.url || "", "url", { maxLength: 2048 }); + const loginUrl = field(taPrimary, "auth-login", "taLogin", auth.login_url || "", "url", { maxLength: 2048 }); + authUrl.placeholder = "http://10.87.7.99:3000"; loginUrl.placeholder = "https://auth.example.com/login"; + field(taPrimary, "auth-private", "vhPrivate", auth.allow_private, "checkbox"); + field(taPrimary, "auth-redirect-forbidden", "taRedirectForbidden", auth.redirect_forbidden !== false, "checkbox"); + authEnable.closest("label").hidden = true; + note(taBody, "taHelp"); + + const taAdvanced = el("details", undefined, "vh-wide auth-advanced"); taAdvanced.append(text("summary", "taAdvanced")); + const taAdvancedGrid = el("div", undefined, "vh-grid"); taAdvanced.append(taAdvancedGrid); taBody.append(taAdvanced); + field(taAdvancedGrid, "auth-connect", "taConnect", auth.connect_timeout_seconds ?? 5, "number", { required: true, min: 1, max: 30 }); + field(taAdvancedGrid, "auth-timeout", "taTimeout", auth.timeout_seconds ?? 10, "number", { required: true, min: 1, max: 60 }); + field(taAdvancedGrid, "auth-request-headers", "taRequestHeaders", (auth.request_headers || ["cookie", "authorization", "x-api-key", "user-agent", "accept"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 }); + field(taAdvancedGrid, "auth-response-headers", "taResponseHeaders", (auth.response_headers || ["remote-user", "remote-email"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 }); + field(taAdvancedGrid, "auth-allowlist", "vhAddressAllowlist", (auth.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 }); + field(taAdvancedGrid, "auth-ca", "vhCa", auth.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 }); + + basicBody.append(text("h3", "authBasic")); + const basicGrid = el("div", undefined, "vh-grid"); basicBody.append(basicGrid); + basicEditor = basicAuthEditor(basicGrid, value.basic_auth || {}); + const syncAuth = () => { + authEnable.checked = authMode.value === "tinyauth"; + taBody.hidden = !authEnable.checked; + basicBody.hidden = authMode.value !== "basic"; + authUrl.required = loginUrl.required = authEnable.checked; + }; + authMode.addEventListener("change", syncAuth); syncAuth(); + g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers; + const serverMode = field(g, "server-mode", "serverHeader", server?.mode || "inherit", "select", { values: [["inherit", "vhInherit"], ["preserve", "serverPreserve"], ["hide", "serverHide"], ["custom", "serverCustom"]] }); + const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.24.5", "text", { maxLength: 256 }); + const syncServer = () => { serverValue.disabled = serverMode.value !== "custom"; serverValue.required = !serverValue.disabled; }; serverMode.addEventListener("change", syncServer); syncServer(); + const inheritDiagnostics = field(g, "diag-inherit", "diagInherit", !diagnostics, "checkbox"), diagInputs = []; + for (const [name, key] of [["request_id", "diagRequestId"]]) diagInputs.push(field(g, `diag-${name}`, key, diagnostics?.[name] ?? envelope?.config.diagnostic_headers?.[name], "checkbox")); + const syncDiag = () => { for (const input of diagInputs) input.disabled = inheritDiagnostics.checked; }; inheritDiagnostics.addEventListener("change", syncDiag); syncDiag(); note(g, "diagHelp"); + g = group("vhCache"); const cache = value.cache || {}; + const tri = (parent, name, key, current) => field(parent, name, key, current === true ? "on" : current === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] }); + const cacheToggle = tri(g, "vh-cache-enabled", "cacheEnabled", cache.enabled); + if (envelope?.config.cache?.enabled !== true) note(g, "vhCacheGlobalDisabled"); + const cacheOptions = el("div", undefined, "vh-wide vh-grid vh-cache-options"); g.append(cacheOptions); + field(cacheOptions, "vh-cache-ttl", "vhCacheTtl", cache.max_ttl_seconds ?? "", "number", { min: 1, max: 604800 }); + field(cacheOptions, "vh-cache-default", "vhCacheDefault", cache.default_ttl_seconds ?? "", "number", { min: 0, max: 604800 }); + field(cacheOptions, "vh-cache-object", "vhCacheObject", cache.max_object_bytes == null ? "" : cache.max_object_bytes / 1024, "number", { min: 1, step: "any", max: (envelope?.config.cache?.max_object_bytes ?? 1048576) / 1024 }); + tri(cacheOptions, "vh-cache-query", "cacheBypassQuery", cache.bypass_query); + tri(cacheOptions, "vh-cache-static-cookie", "vhCacheStaticCookie", cache.static_ignore_cookies); + const customStatic = field(cacheOptions, "vh-cache-static-custom", "vhCacheStaticCustom", cache.static_extensions != null, "checkbox"); + const staticExtensions = field(cacheOptions, "vh-cache-static-extensions", "vhCacheStaticExtensions", (cache.static_extensions || envelope?.config.cache?.static_extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 }); + const syncStatic = () => { staticExtensions.disabled = !customStatic.checked; }; customStatic.addEventListener("change", syncStatic); syncStatic(); note(cacheOptions, "vhCacheStaticHelp"); + tri(cacheOptions, "vh-cache-status", "cacheStatusHeader", cache.expose_status); tri(cacheOptions, "vh-cache-hits", "cacheHitsHeader", cache.expose_hits); tri(cacheOptions, "vh-cache-rfc", "cacheRfcHeader", cache.expose_cache_status); tri(cacheOptions, "vh-cache-resident-age", "cacheResidentAge", cache.expose_resident_age); + field(cacheOptions, "vh-cache-prefix-custom", "vhCacheCustomPrefixes", cache.bypass_prefixes != null, "checkbox"); + field(cacheOptions, "vh-cache-prefixes", "cacheBypassPrefixes", (cache.bypass_prefixes || []).join("\n"), "textarea", { rows: 2, maxLength: 32768 }); note(cacheOptions, "vhCacheHint"); + if (editing) cacheOptions.append(button("vhPurgeCache", () => purge(editing))); + const syncCacheVisibility = () => { + const enabled = envelope?.config.cache?.enabled === true && cacheToggle.value !== "off"; + cacheOptions.hidden = !enabled; + }; + cacheToggle.addEventListener("change", syncCacheVisibility); syncCacheVisibility(); + g = group("vhTransform"); const transform = value.transform || { enabled: false }; + const transformEnabled = field(g, "vh-transform-enabled", "vhTransformEnable", transform.enabled === true, "checkbox"); + const transformOptions = el("div", undefined, "vh-wide vh-grid vh-transform-options"); g.append(transformOptions); + for (const [name, key] of [["javascript", "transformJavascript"], ["css", "transformCss"], ["html", "transformHtml"], ["svg", "transformSvg"], ["json", "transformJson"]]) tri(transformOptions, `vh-transform-${name}`, key, transform[name]); + const customTransformExtensions = field(transformOptions, "vh-transform-extensions-custom", "vhTransformExtensionsCustom", transform.extensions != null, "checkbox"); + const transformExtensions = field(transformOptions, "vh-transform-extensions", "transformExtensions", (transform.extensions || envelope?.config.transform?.extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 }); + const syncTransform = () => { + transformOptions.hidden = !transformEnabled.checked; + transformExtensions.disabled = !customTransformExtensions.checked; + }; + transformEnabled.addEventListener("change", syncTransform); customTransformExtensions.addEventListener("change", syncTransform); syncTransform(); + note(transformOptions, "vhTransformHelp"); + g = group("vhHeaders"); headerEditors(g, value.headers || {}); + g = group("vhPages"); g.classList.add("vh-pages-grid"); pagesEditor = window.rustProxyPages.vhostEditor(g, value.pages || {}); + initTabs(); dirty = false; if (latestStatus) renderStatus(latestStatus); if (show && !dialog.open) dialog.showModal(); + + } + function collect() { + locationsReader(); // Validate every visual row; never silently save an earlier JSON value. + let paths, acl, headers; + try { + paths = JSON.parse(str("paths")); acl = JSON.parse(str("acl")); headers = JSON.parse(str("headers")); + if (!Array.isArray(paths) || (acl !== null && (typeof acl !== "object" || Array.isArray(acl))) || !headers || typeof headers !== "object" || Array.isArray(headers)) throw Error(); + } catch { throw new Error(t("vhJsonInvalid")); } + const members = [...form.querySelectorAll(".vh-member")].map(row => ({ id: str("member-id", row), url: str("member-url", row), weight: num("member-weight", row), enabled: yes("member-enabled", row), pool_eligible: yes("member-pool", row), backup: yes("member-backup", row), drain: yes("member-drain", row), allow_private: yes("member-private", row), accept_local_listener_risk: yes("member-local-risk", row), preserve_host: yes("member-host", row), protocol: str("member-protocol", row), address_allowlist: lines(str("member-allowlist", row)), tls_server_name: nullable("member-tls-name", row), tls_verify: yes("member-tls-verify", row), tls_ca_pem: nullable("member-ca", row), max_concurrent_requests: num("member-max-concurrent", row), queue_timeout_ms: num("member-queue-timeout", row) })); + const bindingSelection = readBindings(); + if (!bindingSelection.length || !members.length) throw new Error(t("vhJsonInvalid")); + if (yes("force-ssl")) { + const tlsPorts = new Set(); let tlsSelected = false, httpSelected = false; + for (const id of bindingSelection) { + const endpoint = envelope?.config.listeners?.endpoints?.find(item => item.id === id); + if (!endpoint || endpoint.enabled === false) continue; + if (!endpoint.binding?.tls) { httpSelected = true; continue; } + tlsSelected = true; + for (const address of [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean)) { + const match = String(address).match(/:(\d+)$/); if (match) tlsPorts.add(Number(match[1])); + } + } + if (!httpSelected) throw new Error(t("vhForceSslNeedsHttp")); + if (!tlsSelected || !tlsPorts.size) throw new Error(t("vhForceSslNeedsTls")); + if (!tlsPorts.has(443) && tlsPorts.size !== 1) throw new Error(t("vhForceSslPortConflict")); + } + if (yes("tls-policy-custom") && !yes("tls-policy-12") && !yes("tls-policy-13")) throw new Error(t("tlsPolicyVersionRequired")); + const selectedUsesHttp3 = bindingSelection.some(id => { + const endpoint = envelope?.config.listeners?.endpoints?.find(item => item.id === id); + return !!endpoint?.binding?.http3; + }); + if (yes("http3-enabled") && selectedUsesHttp3 && yes("tls-policy-custom")) { + const selected = { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")).sort() }; + const inherited = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] }; + const globalPolicy = { tls12: inherited.tls12 !== false, tls13: inherited.tls13 !== false, cipher_suites: [...(inherited.cipher_suites || [])].sort() }; + if (JSON.stringify(selected) !== JSON.stringify(globalPolicy)) throw new Error(t("vhTlsHttp3Conflict")); + } + return { + id: str("id"), enabled: yes("enabled"), force_ssl: yes("force-ssl"), forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"), + proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") }, + rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader() }, + server_header: str("server-mode") === "inherit" ? null : { mode: str("server-mode"), value: str("server-value") }, + diagnostic_headers: yes("diag-inherit") ? null : { request_id: yes("diag-request_id") }, + tinyauth: { enabled: yes("auth-enabled"), url: str("auth-url"), login_url: str("auth-login"), allow_private: yes("auth-private"), redirect_forbidden: yes("auth-redirect-forbidden"), connect_timeout_seconds: num("auth-connect"), timeout_seconds: num("auth-timeout"), request_headers: lines(str("auth-request-headers")), response_headers: lines(str("auth-response-headers")), address_allowlist: lines(str("auth-allowlist")), tls_ca_pem: nullable("auth-ca") }, backends: members, routes: paths, acl, headers, + load_balancing: { + algorithm: str("algorithm"), slow_start_seconds: num("slow-start"), keepalive: { enabled: yes("keepalive"), idle_timeout_seconds: num("idle-timeout"), max_idle_per_host: num("max-idle"), tcp_keepalive_seconds: num("tcp-keepalive") }, + retry: { max_retries: num("retry-max"), retry_connect_errors: yes("retry-connect"), retry_timeouts: yes("retry-timeouts"), retry_statuses: str("retry-statuses").split(/[\s,]+/).filter(Boolean).map(value => Number(value)) }, + health_check: { mode: str("health-mode"), path: str("health-path"), host: nullable("health-host"), interval_ms: num("interval"), timeout_ms: num("timeout"), slow_response_ms: num("slow"), fall: num("fall"), rise: num("rise"), expected_status_min: num("status-min"), expected_status_max: num("status-max") }, + passive_health: { + enabled: yes("passive-enabled"), + count_connect_errors: yes("passive-connect"), + count_timeouts: yes("passive-timeouts"), + count_io_errors: yes("passive-io"), + count_http_5xx: yes("passive-5xx"), + count_slow_responses: yes("passive-slow"), + fall: str("passive-fall") === "" ? null : num("passive-fall"), + rise: str("passive-rise") === "" ? null : num("passive-rise") + }, + sticky: { mode: str("sticky-mode"), cookie_name: nullable("cookie-name"), ttl_seconds: num("cookie-ttl") } + }, + security_headers: { hsts: { enabled: yes("hsts"), max_age_seconds: num("hsts-age"), include_subdomains: yes("hsts-sub"), preload: yes("hsts-preload") }, nosniff: yes("nosniff"), frame_options: str("frame"), referrer_policy: nullable("referrer"), content_security_policy: nullable("csp"), permissions_policy: nullable("permissions") } + }; + } + function render() { + if (!envelope) return; + if (!initialized) load(defaults(), [], false); + const config = envelope.config; + const query = ($("vhost-search")?.value || "").trim().toLowerCase(); + const visibleVhosts = (config.vhosts || []).filter(v => { + if (!query) return true; + const haystack = [ + v.id, + ...(v.frontend?.hosts || []), + ...(v.backends || []).flatMap(backend => [backend.id, backend.url]), + ].filter(Boolean).join(" ").toLowerCase(); + return haystack.includes(query); + }); + const cards = visibleVhosts.map(v => { + const box = el("article", undefined, "vhost-card"); box.dataset.vhostCard = v.id; + const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"); + const title = el("div", undefined, "vhost-title-line"); title.append(el("strong", v.id, "entity-title")); + const state = el("span", t(v.enabled === false ? "vhDisabled" : "vhEnabled"), `pill ${v.enabled === false ? "" : "live"}`); title.append(state); + identity.append(title, el("div", (v.frontend?.hosts || []).join(", ") || "-", "vhost-hosts")); head.append(identity); + const actions = el("div", undefined, "entity-actions"); + const configuredHosts = (v.frontend?.hosts || []).filter(Boolean); + const browseHost = configuredHosts.find(host => !host.startsWith("*.")) || configuredHosts.find(host => host.startsWith("*."))?.slice(2) || null; + const configuredEndpoints = (v.frontend?.listener_ids || []).map(id => config.listeners?.endpoints?.find(endpoint => endpoint.id === id)).filter(Boolean); + const browseTls = configuredEndpoints.some(endpoint => endpoint.binding?.tls === true); + const openButton = button("vhOpenBrowser", () => { if (browseHost) window.open(`${browseTls ? "https" : "http"}://${browseHost}/`, "_blank", "noopener,noreferrer"); }); + openButton.disabled = !browseHost || v.enabled === false; + actions.append(openButton, button("edit", async () => { if (await allowDiscard()) load(structuredClone(v)); }), button("remove", async () => { + if (busy || !(await appConfirm(t("vhDelete"), { danger: true }))) return; + const next = structuredClone(envelope.config); next.vhosts = (next.vhosts || []).filter(item => item.id !== v.id); + if (await saveSection("vhosts", next.vhosts || [])) { if (editing === v.id) load(); render(); } + }), button("liveLogs", () => window.rustProxyLogs.open(v.id)), button("vhPurgeCache", () => purge(v.id))); head.append(actions); box.append(head); + + const details = el("div", undefined, "vhost-detail-strip"); + const selectedEndpoints = (v.frontend?.listener_ids || []).map(id => + config.listeners?.endpoints?.find(e => e.id === id) || { id } + ); + const listeners = selectedEndpoints.map(endpoint => { + const addresses = [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean).join(", "); + return `${endpoint.name || endpoint.id} · ${addresses || t("listenerMissing")}`; + }); + const supportsH2 = selectedEndpoints.some(endpoint => endpoint.binding?.http2); + const supportsH3 = selectedEndpoints.some(endpoint => endpoint.binding?.http3); + const protocols = [ + "HTTP/1.1", + v.http2_enabled !== false && supportsH2 ? "H2" : null, + v.http3_enabled !== false && supportsH3 ? "H3" : null, + ].filter(Boolean).join(" + "); + const tlsPolicy = v.tls_policy ? [v.tls_policy.tls12 ? "TLS 1.2" : null, v.tls_policy.tls13 ? "TLS 1.3" : null].filter(Boolean).join(" + ") : t("inherit"); + const detailItems = [ + ["vhListeners", listeners.join(" | ") || "-"], + ["vhProtocols", protocols], + ["tlsPolicy", tlsPolicy], + ["vhCertificate", v.certificate_id || t("vhCertificateDefault")], + ["vhAclProfile", v.acl_profile_id ? ((config.acl_profiles || []).find(profile => profile.id === v.acl_profile_id)?.name || v.acl_profile_id) : t("vhAclProfileNone")], + ["cache", v.cache?.enabled === true ? t("enabled") : v.cache?.enabled === false ? t("notEnabled") : t("inherit")], + ["vhBackendPool", `${(v.backends || []).length}`], + ]; + for (const [key, value] of detailItems) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); } + box.append(details); + + const live = el("div", undefined, "vhost-live-grid"); + const metric = (key, suffix) => { const item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = v.id; live.append(item); }; + metric("status", "vhostHealth"); metric("vhRequests", "vhostRequests"); metric("vhActive", "vhostActive"); metric("vhErrors", "vhostErrors"); metric("vhLatency", "vhostLatency"); metric("vhTraffic", "vhostTraffic"); + box.append(live); + const chart = el("div", undefined, "vhost-chart"); const chartHead = el("div", undefined, "vhost-chart-head"); chartHead.append(text("span", "vhLifetimeChart", "small-label")); const chartValue = el("span", "-", "fine"); chartValue.dataset.chartValueFor = v.id; chartHead.append(chartValue); chart.append(chartHead); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 320 54"); svg.setAttribute("preserveAspectRatio", "none"); svg.dataset.chartFor = v.id; svg.setAttribute("aria-label", t("vhLifetimeChart")); const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); polyline.setAttribute("class", "vhost-chart-line"); svg.append(polyline); chart.append(svg); box.append(chart); + const health = el("p", t("vhHealthEmpty"), "vh-health-summary"); health.dataset.healthFor = v.id; box.append(health); + const policy = el("div", undefined, "vhost-policy-pills"); + if (v.basic_auth?.enabled) policy.append(el("small", t("authBasic"), "pill")); + if (v.tinyauth?.enabled) policy.append(el("small", "Tinyauth", "pill")); + if (v.rate_limit?.enabled) policy.append(el("small", t("vhRateEnabled"), "pill")); + if (v.acl_profile_id) policy.append(el("small", `${t("vhAclProfile")}: ${v.acl_profile_id}`, "pill")); + if (v.force_ssl) policy.append(el("small", t("vhForceSsl"), "pill")); + if (v.forward_client_ip === false) policy.append(el("small", t("vhClientIpHidden"), "pill")); + if (v.websockets) { + const transports = ["H1"]; + if (v.websocket_rfc8441) transports.push("H2/RFC8441"); + if (v.websocket_rfc9220) transports.push("H3/RFC9220"); + policy.append(el("small", `WebSocket · ${transports.join("+")}`, "pill")); + if (v.websocket_legacy_support) policy.append(el("small", t("vhWebsocketLegacyBadge"), "pill")); + } + if (policy.children.length) box.append(policy); + return box; + }); + $("vhost-list").replaceChildren(...(cards.length ? cards : [text("p", query ? "vhSearchEmpty" : "vhEmpty", "fine")])); + $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel"); + if (latestStatus) renderStatus(latestStatus); + } + // SSE snapshots arrive slower than the backend sampler, so a short burst can be zeroed before the UI sees it. + // Fall back to the byte-counter delta for this dashboard interval when the reported instantaneous rate is zero. + function backendTrafficRate(poolId, member, snapshot, now, seen) { + const key = `${poolId}\u0000${member.id}`; seen.add(key); + const total = Math.max(0, Number(member.bytes_in) || 0) + Math.max(0, Number(member.bytes_out) || 0); + const reported = Math.max(0, Number(member.traffic_bytes_per_second) || 0); + const previous = backendTrafficHistory.get(key); + if (previous?.snapshot === snapshot) return previous.rate; + const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0; + const observed = previous && total >= previous.total ? (total - previous.total) / elapsed : 0; + const rate = reported > 0 ? reported : observed; + backendTrafficHistory.set(key, { total, at: now, snapshot, rate }); + return rate; + } + function renderStatus(status) { + form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.()); + const rows = [], summaries = new Map(), aggregate = new Map(); + const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(); + const statusKeys = { up: "vhUp", down: "vhDown", checking: "vhChecking", draining: "vhDraining", disabled: "vhDisabled" }; + const configuredVhosts = new Map((envelope?.config.vhosts || []).map(vhost => [vhost.id, vhost])); + for (const pool of status.backend_pools || []) { + const members = pool.members || [], vhostDisabled = configuredVhosts.get(pool.id)?.enabled === false; + let up = 0, active = 0, requests = 0, errors = 0, traffic = 0, weightedLatency = 0, latencyWeight = 0; + for (const member of members) { + const memberTraffic = backendTrafficRate(pool.id, member, trafficSnapshot, trafficNow, seenTraffic); + const label = vhostDisabled ? t("vhDisabled") : (pool.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown")); + const activeLimit = Number(member.max_concurrent_requests) > 0 ? `${member.active ?? 0}/${member.max_concurrent_requests}` : `${member.active ?? 0}/∞`; + const lastFailure = Number(member.last_failure_at_ms) > 0 ? `${member.last_failure_class || "-"} · ${new Date(member.last_failure_at_ms).toLocaleTimeString(locale())}` : "-"; + const lastRecovery = Number(member.last_recovered_ms) > 0 ? new Date(member.last_recovered_ms).toLocaleTimeString(locale()) : "-"; + const healthDetail = `${t("vhActiveHealth")}: ${member.last_result || "-"} · ${t("vhPassiveFailures")}: ${member.passive_failures ?? 0} (${member.last_passive_result || "-"})`; + const row = el("tr"); if (vhostDisabled) row.classList.add("pool-vhost-disabled"); const poolDomain = status.backend_pool_domains?.[pool.id]; const poolLabel = poolDomain ? `${pool.id} (${poolDomain})` : pool.id; const cells = [poolLabel, `${member.id}${member.backup ? " / " + t("vhBackup") : ""}`, label, activeLimit, member.requests, member.errors, + member.ewma_latency_ms > 0 ? `${member.ewma_latency_ms.toFixed(1)} ms` : "-", `${rateBytes(memberTraffic)}/s`, lastFailure, lastRecovery]; + cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); cell.title = index === 2 ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : index === 8 ? (member.last_failure_detail || lastFailure) : String(value ?? "-"); if (index === 2 && !vhostDisabled) cell.className = member.status === "down" ? "status-error" : member.status === "up" ? "status-good" : ""; if (index === 8 && member.last_failure_at_ms) cell.classList.add("backend-last-failure"); row.append(cell); }); rows.push(row); + if (!vhostDisabled && member.status === "up") up++; active += Number(member.active) || 0; requests += Number(member.requests) || 0; errors += Number(member.errors) || 0; traffic += memberTraffic; + const latency = Number(member.ewma_latency_ms || member.latency_ms) || 0; if (latency > 0) { const weight = Math.max(1, Number(member.requests) || 1); weightedLatency += latency * weight; latencyWeight += weight; } + } + const summary = vhostDisabled ? t("vhDisabled") : members.map(member => `${member.id}: ${pool.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown")} / ${Number(member.latency_ms ?? member.ewma_latency_ms) > 0 ? `${Number(member.latency_ms ?? member.ewma_latency_ms).toFixed(1)} ms` : "-"} / ${member.active ?? 0} ${t("vhActive")}`).join(" | "); + summaries.set(pool.id, summary || t("vhHealthEmpty")); + aggregate.set(pool.id, { + disabled: vhostDisabled, + healthMode: pool.health_mode, + up, + total: members.length, + active, + requests, + errors, + traffic, + latency: latencyWeight ? weightedLatency / latencyWeight : 0, + }); + } + for (const key of backendTrafficHistory.keys()) if (!seenTraffic.has(key)) backendTrafficHistory.delete(key); + $("pool-rows").replaceChildren(...rows); $("pool-empty").hidden = rows.length > 0; + document.querySelectorAll("[data-health-for]").forEach(node => node.textContent = summaries.get(node.dataset.healthFor) || t("vhHealthEmpty")); + const fill = (attr, format) => document.querySelectorAll(`[data-${attr.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}]`).forEach(node => { const data = aggregate.get(node.dataset[attr]); node.textContent = data ? format(data) : "-"; }); + fill("vhostHealth", d => d.disabled ? t("vhDisabled") : d.healthMode === "off" ? t("vhHealthDisabled") : `${d.up}/${d.total} ${t("vhUp")}`); + fill("vhostRequests", d => fmt(d.requests)); fill("vhostActive", d => fmt(d.active)); fill("vhostErrors", d => fmt(d.errors)); + fill("vhostLatency", d => d.latency > 0 ? `${fmt(d.latency, 1)} ms` : "-"); fill("vhostTraffic", d => `${rateBytes(d.traffic)}/s`); + const now = performance.now(); + for (const [id, data] of aggregate) { + const history = chartHistory.get(id) || { lastTime: now, lastRequests: data.requests, points: [], bucketSize: 1, pendingSum: 0, pendingCount: 0 }; + const elapsed = Math.max(.001, (now - history.lastTime) / 1000); const rate = data.requests >= history.lastRequests ? (data.requests - history.lastRequests) / elapsed : 0; history.lastTime = now; history.lastRequests = data.requests; + history.pendingSum += rate; history.pendingCount += 1; + if (history.pendingCount >= history.bucketSize) { history.points.push(history.pendingSum / history.pendingCount); history.pendingSum = 0; history.pendingCount = 0; } + if (history.points.length >= 120) { const compacted = []; for (let i = 0; i < history.points.length; i += 2) compacted.push((history.points[i] + history.points[i + 1]) / 2); history.points = compacted; history.bucketSize *= 2; } + chartHistory.set(id, history); + const displayPoints = history.pendingCount ? [...history.points, history.pendingSum / history.pendingCount] : history.points; const actualPeak = Math.max(0, ...displayPoints), scalePeak = Math.max(1, actualPeak); const points = displayPoints.map((value, index) => `${index * 320 / Math.max(1, displayPoints.length - 1)},${50 - value / scalePeak * 44}`).join(" "); + const svg = document.querySelector(`[data-chart-for="${CSS.escape(id)}"]`); svg?.querySelector("polyline")?.setAttribute("points", points); const label = document.querySelector(`[data-chart-value-for="${CSS.escape(id)}"]`); if (label) label.textContent = `${rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${actualPeak.toFixed(1)}`; + } + $("vhost-health-live").textContent = editing ? (summaries.get(editing) || t("vhHealthEmpty")) : t("vhNewHealthHint"); + } + form.addEventListener("input", changed); form.addEventListener("change", changed); + form.addEventListener("submit", async event => { + event.preventDefault(); if (!envelope || busy || uploading) return; + const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { activateTab(invalid.closest(".vh-group")?.dataset.tab || "details"); const details = invalid.closest("details"); if (details) details.open = true; invalid.reportValidity(); return; } + if (revision !== envelope.revision) { toast(t("stale"), true); return; } + try { + const epoch = draftEpoch; uploading = true; + try { await basicEditor.prepare(); } finally { uploading = false; } + if (epoch !== draftEpoch || revision !== envelope.revision || !dialog.open) throw new Error(t("stale")); + const value = collect(), next = structuredClone(envelope.config); next.vhosts ||= []; + const debugIssues = debugOriginIssues(value.backends); if (debugIssues.length && !(await appConfirm(`${t("vhDebugOriginMismatch")}\n\n${debugIssues.map(member => member.id).join(", ")}`, { danger: true }))) return; + if (next.vhosts.some(v => v.id === value.id && v.id !== editing)) throw new Error(t("vhConflict")); + if (editing && value.id !== editing && !(await appConfirm(t("vhIdRenameConfirm").replace("{old}", editing).replace("{new}", value.id), { danger: true }))) return; + const addsCertificate = stagedCertificates.some(c => c.id === value.certificate_id); + if (addsCertificate) { next.tls ||= {}; next.tls.certificates ||= []; for (const cert of stagedCertificates.filter(c => c.id === value.certificate_id)) if (!next.tls.certificates.some(c => c.id === cert.id)) next.tls.certificates.push(cert); } + const index = next.vhosts.findIndex(v => v.id === editing); if (index < 0) next.vhosts.push(value); else next.vhosts[index] = value; + const saved = addsCertificate ? await save(next) : await saveSection("vhosts", next.vhosts || []); + if (saved) { load(value, [], false); dialog.close(); render(); } + } catch (error) { toast(error.message, true); } + }); + $("vhost-new").addEventListener("click", async () => { if (await allowDiscard()) load(); }); + $("vhost-search").addEventListener("input", render); + $("vhost-reset").addEventListener("click", async () => { + if (!(await allowDiscard())) return; + const saved = editing ? (envelope?.config.vhosts || []).find(item => item.id === editing) : null; + load(saved ? structuredClone(saved) : defaults()); + }); + $("pool-open").addEventListener("click", () => setPanel("vhosts")); + $("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close()); + window.rustProxyVhosts = { render, renderStatus, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; chartHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); } }; + if (envelope) render(); if (latestStatus) renderStatus(latestStatus); +})(); diff --git a/src/auth/files.rs b/src/auth/files.rs new file mode 100644 index 0000000..06b13ab --- /dev/null +++ b/src/auth/files.rs @@ -0,0 +1,143 @@ +//! Local filesystem authority. The process owner can initialize or recover the sole admin. +use anyhow::{ensure, Context, Result}; +use fs2::FileExt; +use rand::{rngs::OsRng, RngCore}; +use std::{ + fs::{self, File, OpenOptions}, + io::Write, + path::Path, +}; + +fn private_options() -> OpenOptions { + let mut options = OpenOptions::new(); + options.write(true).read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + options +} +pub fn prepare_directory(path: &Path) -> Result<()> { + if !path.exists() { + fs::create_dir_all(path).context("cannot create data directory")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + } + } + let meta = fs::symlink_metadata(path)?; + ensure!( + meta.is_dir() && !meta.file_type().is_symlink(), + "data directory must not be a symlink" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + ensure!( + meta.permissions().mode() & 0o077 == 0, + "data directory must be private: chmod 700 {}", + path.display() + ); + } + Ok(()) +} +pub fn lock_directory(path: &Path) -> Result { + let file = private_options() + .create(true) + .truncate(false) + .open(path.join("process.lock"))?; + file.try_lock_exclusive() + .context("another RustProxy process already owns this data directory")?; + Ok(file) +} +pub fn load_setup_token(dir: &Path) -> Result { + let path = dir.join("setup-token"); + let meta = fs::symlink_metadata(&path).context("setup token does not exist")?; + ensure!( + meta.is_file() && !meta.file_type().is_symlink(), + "token must be a regular file" + ); + ensure!(meta.len() <= 128, "invalid setup token file"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + ensure!( + meta.permissions().mode() & 0o077 == 0, + "token permissions must be 600" + ); + } + let token = fs::read_to_string(path)?.trim().to_owned(); + ensure!( + token.len() == 64 && token.bytes().all(|b| b.is_ascii_hexdigit()), + "invalid setup token" + ); + Ok(token) +} +pub fn load_or_create_setup_token(dir: &Path) -> Result { + let path = dir.join("setup-token"); + if path.try_exists()? { + return load_setup_token(dir); + } + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + let token: String = bytes.iter().map(|b| format!("{b:02x}")).collect(); + let mut file = match private_options().create_new(true).open(&path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + return load_setup_token(dir); + } + Err(error) => return Err(error.into()), + }; + writeln!(file, "{token}")?; + file.sync_all()?; + Ok(token) +} + +/// Validate database/WAL paths before opening SQLite. The private parent directory is +/// the local CLI authorization boundary; files owned by the same OS user are trusted. +pub fn prepare_database(path: &Path) -> Result<()> { + if !path.try_exists()? { + private_options().create_new(true).open(path)?.sync_all()?; + } + for suffix in ["", "-wal", "-shm"] { + let mut name = path.as_os_str().to_os_string(); + name.push(suffix); + let candidate = std::path::PathBuf::from(name); + match fs::symlink_metadata(&candidate) { + Ok(meta) => { + ensure!( + meta.is_file() && !meta.file_type().is_symlink(), + "database files must be regular files" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + ensure!( + meta.permissions().mode() & 0o077 == 0, + "database files must be private (chmod 600)" + ); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + } + Ok(()) +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn setup_token_can_be_generated_before_database_or_server() { + let dir = tempfile::tempdir().unwrap(); + let first = load_or_create_setup_token(dir.path()).unwrap(); + assert_eq!(first.len(), 64); + assert!(first.bytes().all(|b| b.is_ascii_hexdigit())); + assert!(!dir.path().join("config.sqlite3").exists()); + assert_eq!(load_or_create_setup_token(dir.path()).unwrap(), first); + assert_eq!(load_setup_token(dir.path()).unwrap(), first); + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..de71556 --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,555 @@ +//! Password authentication is confined to the control plane. +//! Expensive Argon2 work runs on bounded blocking workers, not Tokio network workers. +mod files; +mod store; +use crate::{db::Store, runtime::ApiError}; +use anyhow::{ensure, Context, Result}; +use argon2::{ + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Algorithm, Argon2, Params, Version, +}; +pub use files::{ + load_or_create_setup_token, load_setup_token, lock_directory, prepare_database, + prepare_directory, +}; +use http::{header::COOKIE, HeaderMap, StatusCode}; +use rand::{rngs::OsRng, RngCore}; +use sha2::{Digest, Sha256}; +use std::{ + path::PathBuf, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; +pub use store::AdminRecord; +use subtle::ConstantTimeEq; +use tokio::sync::Semaphore; +use zeroize::Zeroizing; + +pub const COOKIE_NAME: &str = "rustproxy_session"; +pub const SESSION_SECONDS: i64 = 30 * 24 * 60 * 60; +pub const IDLE_SECONDS: i64 = SESSION_SECONDS; +#[derive(Clone)] +pub struct Session { + pub token_hash: String, + pub csrf: String, + pub username: String, + pub expires_at: i64, + pub generation: i64, +} +pub struct SessionToken { + pub secret: String, + pub session: Session, +} +pub struct Auth { + pub store: Store, + data_dir: PathBuf, + setup_token: Option, + dummy_hash: String, + workers: Arc, +} +pub fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .min(i64::MAX as u64) as i64 +} +pub fn random_secret() -> String { + let mut bytes = [0_u8; 32]; + OsRng.fill_bytes(&mut bytes); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} +pub fn secret_hash(secret: &str) -> String { + format!("{:x}", Sha256::digest(secret.as_bytes())) +} +pub fn equal_secret(candidate: &str, expected: &str) -> bool { + candidate.len() == expected.len() && bool::from(candidate.as_bytes().ct_eq(expected.as_bytes())) +} +pub fn valid_secret(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit()) +} +pub fn cookie_secret(headers: &HeaderMap) -> Option { + let mut found = None; + for value in headers.get_all(COOKIE).iter() { + for part in value.to_str().ok()?.split(';') { + let Some((key, value)) = part.trim().split_once('=') else { + continue; + }; + if key == COOKIE_NAME { + if found.is_some() || !valid_secret(value) { + return None; + } + found = Some(value.to_owned()); + } + } + } + found +} +pub fn session_cookie(secret: &str, secure: bool, max_age_seconds: i64) -> String { + let max_age_seconds = max_age_seconds.max(1); + format!( + "{COOKIE_NAME}={secret}; Path=/; HttpOnly; SameSite=Strict; Max-Age={max_age_seconds}{}", + if secure { "; Secure" } else { "" } + ) +} +pub fn expired_cookie(secure: bool) -> String { + format!( + "{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0{}", + if secure { "; Secure" } else { "" } + ) +} +pub fn username(value: &str) -> Result { + let value = value.trim().to_ascii_lowercase(); + ensure!( + (3..=64).contains(&value.len()) + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.')), + "auth.invalid_username" + ); + Ok(value) +} +pub fn validate_password(value: &str) -> Result<()> { + ensure!( + (12..=128).contains(&value.len()) && !value.contains(['\n', '\r', '\0']), + "auth.password_policy" + ); + Ok(()) +} +pub(crate) fn hash_sync(password: &str) -> Result { + let params = Params::new(19_456, 2, 1, None).context("Argon2 parameters")?; + let salt = SaltString::generate(&mut OsRng); + Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params) + .hash_password(password.as_bytes(), &salt)? + .to_string()) +} +fn verify_sync(password: &str, encoded: &str) -> bool { + PasswordHash::new(encoded).ok().is_some_and(|hash| { + Argon2::default() + .verify_password(password.as_bytes(), &hash) + .is_ok() + }) +} +pub async fn hash_password_offline(password: String) -> Result { + validate_password(&password)?; + let password = Zeroizing::new(password); + tokio::task::spawn_blocking(move || hash_sync(&password)) + .await + .context("password worker failed")? +} +pub fn internal(error: impl std::fmt::Display) -> ApiError { + tracing::error!(%error, "admin operation failed"); + ApiError(StatusCode::SERVICE_UNAVAILABLE, "auth.unavailable".into()) +} +fn failure(status: StatusCode, code: &str) -> ApiError { + ApiError(status, code.into()) +} +impl Auth { + pub async fn new(store: Store, data_dir: PathBuf) -> Result> { + let setup_token = if store.admin_record().await?.is_none() { + Some(load_or_create_setup_token(&data_dir)?) + } else { + None + }; + // Generate valid dummy work for unknown usernames. This happens only at startup. + let dummy_hash = hash_password_offline(random_secret()).await?; + Ok(Arc::new(Self { + store, + data_dir, + setup_token, + dummy_hash, + workers: Arc::new(Semaphore::new(2)), + })) + } + async fn attempt(&self) -> Result<(), ApiError> { + if !self + .store + .consume_auth_attempt(now()) + .await + .map_err(internal)? + { + return Err(failure(StatusCode::TOO_MANY_REQUESTS, "auth.rate_limited")); + } + Ok(()) + } + async fn hash(&self, password: String) -> Result { + validate_password(&password) + .map_err(|_| failure(StatusCode::UNPROCESSABLE_ENTITY, "auth.password_policy"))?; + let permit = self + .workers + .clone() + .try_acquire_owned() + .map_err(|_| failure(StatusCode::TOO_MANY_REQUESTS, "auth.busy"))?; + let password = Zeroizing::new(password); + tokio::task::spawn_blocking(move || { + let _permit = permit; + hash_sync(&password) + }) + .await + .map_err(internal)? + .map_err(internal) + } + async fn verify(&self, password: String, encoded: String) -> Result { + if password.len() > 128 { + return Ok(false); + } + let permit = self + .workers + .clone() + .try_acquire_owned() + .map_err(|_| failure(StatusCode::TOO_MANY_REQUESTS, "auth.busy"))?; + let password = Zeroizing::new(password); + tokio::task::spawn_blocking(move || { + let _permit = permit; + verify_sync(&password, &encoded) + }) + .await + .map_err(internal) + } + pub async fn setup( + &self, + supplied_token: &str, + name: &str, + password: String, + ) -> Result<(), ApiError> { + self.attempt().await?; + if self.store.admin_record().await.map_err(internal)?.is_some() { + return Err(failure(StatusCode::CONFLICT, "auth.already_initialized")); + } + if !self + .setup_token + .as_ref() + .is_some_and(|expected| equal_secret(supplied_token, expected)) + { + self.store + .audit("anonymous", "setup.denied", "invalid setup capability") + .await + .map_err(internal)?; + return Err(failure(StatusCode::FORBIDDEN, "auth.invalid_setup_token")); + } + let name = username(name) + .map_err(|_| failure(StatusCode::UNPROCESSABLE_ENTITY, "auth.invalid_username"))?; + let hash = self.hash(password).await?; + if !self + .store + .initialize_admin(&name, &hash, "browser setup") + .await + .map_err(internal)? + { + return Err(failure(StatusCode::CONFLICT, "auth.already_initialized")); + } + self.remove_bootstrap_files().await; + Ok(()) + } + pub async fn remove_bootstrap_files(&self) { + for name in ["setup-token", "admin-token"] { + match tokio::fs::remove_file(self.data_dir.join(name)).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + tracing::warn!(%error, file = name, "unused bootstrap file could not be removed; it has no authority after setup") + } + } + } + } + pub async fn login( + &self, + name: &str, + password: String, + previous: Option<&str>, + session_seconds: i64, + ) -> Result { + self.attempt().await?; + let record = self.store.admin_record().await.map_err(internal)?; + let candidate = username(name).unwrap_or_default(); + let matches = record.as_ref().is_some_and(|r| r.username == candidate); + let hash = record + .as_ref() + .filter(|_| matches) + .map(|r| r.password_hash.clone()) + .unwrap_or_else(|| self.dummy_hash.clone()); + let verified = self.verify(password, hash).await?; + if !verified || !matches { + self.store + .audit("anonymous", "login.denied", "invalid credentials") + .await + .map_err(internal)?; + return Err(failure( + StatusCode::UNAUTHORIZED, + "auth.invalid_credentials", + )); + } + let record = + record.ok_or_else(|| failure(StatusCode::UNAUTHORIZED, "auth.invalid_credentials"))?; + self.store + .create_browser_session(record.generation, previous, session_seconds) + .await + .map_err(internal)? + .ok_or_else(|| failure(StatusCode::UNAUTHORIZED, "auth.credentials_changed")) + } + pub async fn change_password( + &self, + session: &Session, + current: String, + replacement: String, + ) -> Result<(), ApiError> { + self.attempt().await?; + let record = self + .store + .admin_record() + .await + .map_err(internal)? + .ok_or_else(|| failure(StatusCode::UNAUTHORIZED, "auth.required"))?; + if record.generation != session.generation + || !self.verify(current, record.password_hash).await? + { + return Err(failure( + StatusCode::UNAUTHORIZED, + "auth.invalid_credentials", + )); + } + let hash = self.hash(replacement).await?; + if !self + .store + .reset_admin_password(&hash, session.generation, "browser password change") + .await + .map_err(internal)? + { + return Err(failure(StatusCode::CONFLICT, "auth.credentials_changed")); + } + Ok(()) + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn accepts_independent_argon2id_fixture() { + let fixture: serde_json::Value = + serde_json::from_str(include_str!("../../tests/fixtures/argon2id.json")).unwrap(); + assert!(verify_sync( + fixture["password"].as_str().unwrap(), + fixture["hash"].as_str().unwrap() + )); + assert!(!verify_sync( + "incorrect fixture password", + fixture["hash"].as_str().unwrap() + )); + } + #[test] + fn password_and_username_policy() { + assert_eq!(username(" Owner.Name ").unwrap(), "owner.name"); + assert!(username("a").is_err()); + assert!(username("a b").is_err()); + assert!(validate_password("a sufficiently long password").is_ok()); + assert!(validate_password("admin").is_err()); + assert!(validate_password(&"a".repeat(129)).is_err()); + } + #[test] + fn cookies_reject_ambiguity_and_tokens_are_hashed() { + let s = random_secret(); + let mut headers = HeaderMap::new(); + headers.insert(COOKIE, format!("{COOKIE_NAME}={s}").parse().unwrap()); + assert_eq!(cookie_secret(&headers), Some(s.clone())); + headers.append(COOKIE, format!("{COOKIE_NAME}={s}").parse().unwrap()); + assert!(cookie_secret(&headers).is_none()); + assert_ne!(secret_hash(&s), s); + assert_eq!(secret_hash(&s).len(), 64); + } + #[tokio::test] + async fn passwords_are_argon2id_salted_and_verifiable() { + let password = "correct horse battery staple"; + let a = hash_password_offline(password.into()).await.unwrap(); + let b = hash_password_offline(password.into()).await.unwrap(); + assert!(a.starts_with("$argon2id$v=19$m=19456,t=2,p=1$")); + assert_ne!(a, b); + assert!(verify_sync(password, &a)); + assert!(!verify_sync("incorrect", &a)); + } + #[tokio::test] + async fn setup_race_creates_exactly_one_admin() { + let dir = tempfile::tempdir().unwrap(); + let store = Store::open(&dir.path().join("db"), &crate::config::Config::default()) + .await + .unwrap(); + let (a, b) = tokio::join!( + store.initialize_admin("one", "test-hash", "test"), + store.initialize_admin("two", "test-hash", "test") + ); + assert_eq!(a.unwrap() as u8 + b.unwrap() as u8, 1); + assert_eq!(store.recent_audit().await.unwrap().len(), 1); + store.close().await; + } + #[tokio::test] + async fn sessions_expire_rotate_and_reset_blocks_old_login_generation() { + let dir = tempfile::tempdir().unwrap(); + let store = Store::open(&dir.path().join("db"), &crate::config::Config::default()) + .await + .unwrap(); + store + .initialize_admin("owner", "test-hash", "test") + .await + .unwrap(); + let first = store + .create_session(1, "browser", None) + .await + .unwrap() + .unwrap(); + assert!(store + .session_at(&first.secret, now()) + .await + .unwrap() + .is_some()); + let next = store + .create_session(1, "browser", Some(&first.secret)) + .await + .unwrap() + .unwrap(); + assert!(store + .session_at(&first.secret, now()) + .await + .unwrap() + .is_none()); + assert!(store + .session_at(&next.secret, now() + IDLE_SECONDS + 1) + .await + .unwrap() + .is_none()); + let alive = store + .create_session(1, "browser", None) + .await + .unwrap() + .unwrap(); + assert!(store + .reset_admin_password("new-hash", 1, "test") + .await + .unwrap()); + assert!(store + .session_at(&alive.secret, now()) + .await + .unwrap() + .is_none()); + assert!(store + .create_session(1, "browser", None) + .await + .unwrap() + .is_none()); + assert!(store + .create_session(2, "browser", None) + .await + .unwrap() + .is_some()); + store.close().await; + } + #[test] + fn session_cookie_uses_configured_remaining_lifetime() { + let cookie = session_cookie("abc", true, 7 * 24 * 60 * 60); + assert!(cookie.contains("Max-Age=604800")); + assert!(cookie.ends_with("; Secure")); + let short = session_cookie("abc", false, 0); + assert!(short.contains("Max-Age=1")); + assert!(!short.contains("; Secure")); + } + + #[tokio::test] + async fn browser_session_lifetime_is_configurable_without_sliding_expiry() { + const DAY: i64 = 24 * 60 * 60; + let dir = tempfile::tempdir().unwrap(); + let store = Store::open(&dir.path().join("db"), &crate::config::Config::default()) + .await + .unwrap(); + store + .initialize_admin("owner", "test-hash", "test") + .await + .unwrap(); + + assert!(store + .create_browser_session(1, None, 6 * DAY) + .await + .is_err()); + assert!(store + .create_browser_session(1, None, 31 * DAY) + .await + .is_err()); + + let issued = store + .create_browser_session(1, None, 7 * DAY) + .await + .unwrap() + .unwrap(); + let seven_day_expiry = issued.session.expires_at; + let extended = store + .session_at_with_browser_lifetime(&issued.secret, now(), 30 * DAY) + .await + .unwrap() + .unwrap(); + assert_eq!(extended.expires_at, seven_day_expiry + 23 * DAY); + + let checked_again = store + .session_at_with_browser_lifetime(&issued.secret, seven_day_expiry + DAY, 30 * DAY) + .await + .unwrap() + .unwrap(); + assert_eq!(checked_again.expires_at, extended.expires_at); + assert!(store + .session_at_with_browser_lifetime(&issued.secret, extended.expires_at, 30 * DAY,) + .await + .unwrap() + .is_none()); + store.close().await; + } + + #[tokio::test] + async fn throttle_is_persistent_bounded_and_recovers() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("db"); + let store = Store::open(&file, &crate::config::Config::default()) + .await + .unwrap(); + for _ in 0..10 { + assert!(store.consume_auth_attempt(1000).await.unwrap()); + } + assert!(!store.consume_auth_attempt(1001).await.unwrap()); + store.close().await; + let store = Store::open(&file, &crate::config::Config::default()) + .await + .unwrap(); + assert!(!store.consume_auth_attempt(1020).await.unwrap()); + assert!(store.consume_auth_attempt(1060).await.unwrap()); + store.close().await; + } + #[tokio::test] + async fn browser_session_count_is_bounded() { + let dir = tempfile::tempdir().unwrap(); + let store = Store::open(&dir.path().join("db"), &crate::config::Config::default()) + .await + .unwrap(); + store + .initialize_admin("owner", "test-hash", "test") + .await + .unwrap(); + let first = store + .create_session(1, "browser", None) + .await + .unwrap() + .unwrap(); + for _ in 0..20 { + store + .create_session(1, "browser", None) + .await + .unwrap() + .unwrap(); + } + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM admin_sessions") + .fetch_one(&store.pool) + .await + .unwrap(); + assert_eq!(count, 16); + assert!(store + .session_at(&first.secret, now()) + .await + .unwrap() + .is_none()); + store.close().await; + } +} diff --git a/src/auth/store.rs b/src/auth/store.rs new file mode 100644 index 0000000..9ece01d --- /dev/null +++ b/src/auth/store.rs @@ -0,0 +1,256 @@ +//! Transactional administrator operations. Login/reset races are guarded by generation CAS. +use super::{now, random_secret, secret_hash, Session, SessionToken, SESSION_SECONDS}; +use crate::db::{audit_tx, Store}; +use anyhow::{ensure, Result}; +use sqlx::Row; + +#[derive(Debug, Clone)] +pub struct AdminRecord { + pub username: String, + pub password_hash: String, + pub generation: i64, +} +impl Store { + pub async fn admin_record(&self) -> Result> { + sqlx::query("SELECT username, password_hash, generation FROM administrator WHERE id = 1") + .fetch_optional(&self.pool) + .await? + .map(|row| { + Ok(AdminRecord { + username: row.try_get("username")?, + password_hash: row.try_get("password_hash")?, + generation: row.try_get("generation")?, + }) + }) + .transpose() + } + pub async fn initialize_admin(&self, username: &str, hash: &str, source: &str) -> Result { + let mut tx = self.pool.begin().await?; + let inserted = sqlx::query("INSERT OR IGNORE INTO administrator(id, username, password_hash, generation, created_at) VALUES(1, ?, ?, 1, ?)") + .bind(username).bind(hash).bind(now()).execute(&mut *tx).await?; + if inserted.rows_affected() != 1 { + return Ok(false); + } + audit_tx(&mut tx, username, "admin.initialize", source).await?; + tx.commit().await?; + Ok(true) + } + /// One persisted global bucket for the single administration listener. + /// Fixed upper bound: 10 attempts per 60-second window, including successful attempts. + /// Unknown usernames use the same bucket and password work as known usernames. + pub async fn consume_auth_attempt(&self, time: i64) -> Result { + let changed = sqlx::query("UPDATE auth_throttle SET attempts = CASE WHEN window_start <= ? THEN 1 ELSE attempts + 1 END, window_start = CASE WHEN window_start <= ? THEN ? ELSE window_start END WHERE id = 1 AND (window_start <= ? OR attempts < 10)") + .bind(time - 60).bind(time - 60).bind(time).bind(time - 60) + .execute(&self.pool).await?; + Ok(changed.rows_affected() == 1) + } + /// The first UPDATE obtains SQLite's write lock and validates the credential generation. + /// A reset committed during password verification prevents insertion of a stale session. + pub async fn create_session( + &self, + generation: i64, + kind: &str, + previous: Option<&str>, + ) -> Result> { + self.create_session_with_lifetime(generation, kind, previous, SESSION_SECONDS, 60) + .await + } + pub async fn create_browser_session( + &self, + generation: i64, + previous: Option<&str>, + session_seconds: i64, + ) -> Result> { + ensure!( + (7 * 24 * 60 * 60..=30 * 24 * 60 * 60).contains(&session_seconds), + "browser session lifetime must be 7..30 days" + ); + self.create_session_with_lifetime(generation, "browser", previous, session_seconds, 60) + .await + } + pub async fn create_live_log_session( + &self, + generation: i64, + ) -> Result> { + self.create_session_with_lifetime(generation, "cli", None, SESSION_SECONDS, 8 * 60 * 60) + .await + } + async fn create_session_with_lifetime( + &self, + generation: i64, + kind: &str, + previous: Option<&str>, + browser_lifetime: i64, + cli_lifetime: i64, + ) -> Result> { + let time = now(); + let secret = random_secret(); + let hash = secret_hash(&secret); + let csrf = random_secret(); + let lifetime = if kind == "cli" { cli_lifetime } else { browser_lifetime }; + let mut tx = self.pool.begin().await?; + let valid = sqlx::query( + "UPDATE administrator SET generation = generation WHERE id = 1 AND generation = ?", + ) + .bind(generation) + .execute(&mut *tx) + .await?; + if valid.rows_affected() == 0 { + return Ok(None); + } + let username: String = + sqlx::query_scalar("SELECT username FROM administrator WHERE id = 1") + .fetch_one(&mut *tx) + .await?; + sqlx::query("DELETE FROM admin_sessions WHERE expires_at <= ? OR (kind = 'browser' AND created_at + ? <= ?)") + .bind(time) + .bind(browser_lifetime) + .bind(time) + .execute(&mut *tx) + .await?; + if let Some(previous) = previous { + sqlx::query("DELETE FROM admin_sessions WHERE token_hash = ?") + .bind(secret_hash(previous)) + .execute(&mut *tx) + .await?; + } + // Independent caps avoid a local CLI burst evicting all browser sessions. + let cap = if kind == "cli" { 8_i64 } else { 16_i64 }; + sqlx::query("DELETE FROM admin_sessions WHERE token_hash IN (SELECT token_hash FROM admin_sessions WHERE kind = ? ORDER BY created_at DESC, rowid DESC LIMIT -1 OFFSET ?)") + .bind(kind).bind(cap - 1).execute(&mut *tx).await?; + sqlx::query("INSERT INTO admin_sessions(token_hash, csrf_token, generation, created_at, last_seen, expires_at, kind) VALUES(?, ?, ?, ?, ?, ?, ?)") + .bind(&hash).bind(&csrf).bind(generation).bind(time).bind(time).bind(time + lifetime) + .bind(kind).execute(&mut *tx).await?; + audit_tx(&mut tx, &username, "session.create", kind).await?; + tx.commit().await?; + Ok(Some(SessionToken { + secret, + session: Session { + token_hash: hash, + csrf, + username, + expires_at: time + lifetime, + generation, + }, + })) + } + pub async fn session_at(&self, secret: &str, time: i64) -> Result> { + self.session_at_with_browser_lifetime(secret, time, SESSION_SECONDS) + .await + } + pub async fn session_at_with_browser_lifetime( + &self, + secret: &str, + time: i64, + browser_lifetime: i64, + ) -> Result> { + ensure!( + (7 * 24 * 60 * 60..=30 * 24 * 60 * 60).contains(&browser_lifetime), + "browser session lifetime must be 7..30 days" + ); + let hash = secret_hash(secret); + // A browser session is bounded by the currently configured lifetime. If the + // setting was increased while an older still-valid session exists, extend its + // absolute expiry from its original creation time without turning it into a + // sliding/immortal session. CLI capabilities keep the fixed expiry chosen + // when they are created (60 seconds for one-shot admin commands, longer + // for the bounded local live-log viewer). + let row = sqlx::query("UPDATE admin_sessions SET last_seen = ?, expires_at = CASE WHEN kind = 'browser' THEN created_at + ? ELSE expires_at END WHERE token_hash = ? AND expires_at > ? AND (kind <> 'browser' OR created_at + ? > ?) AND generation = (SELECT generation FROM administrator WHERE id = 1) RETURNING csrf_token, expires_at, generation, (SELECT username FROM administrator WHERE id = 1) AS username") + .bind(time) + .bind(browser_lifetime) + .bind(&hash) + .bind(time) + .bind(browser_lifetime) + .bind(time) + .fetch_optional(&self.pool).await?; + row.map(|row| { + Ok(Session { + token_hash: hash, + csrf: row.try_get("csrf_token")?, + username: row.try_get("username")?, + expires_at: row.try_get("expires_at")?, + generation: row.try_get("generation")?, + }) + }) + .transpose() + } + + pub async fn admin_preferences(&self, username: &str) -> Result> { + sqlx::query_scalar( + "SELECT preferences.document FROM admin_preferences AS preferences JOIN administrator AS admin ON admin.id = preferences.administrator_id WHERE admin.id = 1 AND admin.username = ?", + ) + .bind(username) + .fetch_optional(&self.pool) + .await + .map_err(Into::into) + } + pub async fn save_admin_preferences(&self, username: &str, document: &str) -> Result { + ensure!( + (2..=4096).contains(&document.len()), + "administrator preferences document is too large" + ); + let result = sqlx::query( + "INSERT INTO admin_preferences(administrator_id, document) SELECT id, ? FROM administrator WHERE id = 1 AND username = ? ON CONFLICT(administrator_id) DO UPDATE SET document = excluded.document", + ) + .bind(document) + .bind(username) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() == 1) + } + pub async fn end_session(&self, session: &Session) -> Result<()> { + let mut tx = self.pool.begin().await?; + sqlx::query("DELETE FROM admin_sessions WHERE token_hash = ?") + .bind(&session.token_hash) + .execute(&mut *tx) + .await?; + audit_tx( + &mut tx, + &session.username, + "session.logout", + "current session", + ) + .await?; + tx.commit().await?; + Ok(()) + } + /// expected_generation protects browser password changes against simultaneous resets. + /// CLI passes the generation it read, so even two offline recoveries cannot silently win. + pub async fn reset_admin_password( + &self, + hash: &str, + expected_generation: i64, + source: &str, + ) -> Result { + let mut tx = self.pool.begin().await?; + let result = sqlx::query("UPDATE administrator SET password_hash = ?, generation = generation + 1 WHERE id = 1 AND generation = ? AND generation < 9223372036854775807") + .bind(hash).bind(expected_generation).execute(&mut *tx).await?; + if result.rows_affected() == 0 { + return Ok(false); + } + let username: String = + sqlx::query_scalar("SELECT username FROM administrator WHERE id = 1") + .fetch_one(&mut *tx) + .await?; + sqlx::query("DELETE FROM admin_sessions") + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE auth_throttle SET window_start = 0, attempts = 0 WHERE id = 1") + .execute(&mut *tx) + .await?; + audit_tx(&mut tx, &username, "admin.password_reset", source).await?; + tx.commit().await?; + Ok(true) + } + pub async fn revoke_all_sessions(&self, actor: &str) -> Result<()> { + let mut tx = self.pool.begin().await?; + sqlx::query("UPDATE administrator SET generation = generation + 1 WHERE id = 1 AND generation < 9223372036854775807") + .execute(&mut *tx).await?; + sqlx::query("DELETE FROM admin_sessions") + .execute(&mut *tx) + .await?; + audit_tx(&mut tx, actor, "session.revoke_all", "all sessions").await?; + tx.commit().await?; + Ok(()) + } +} diff --git a/src/balance.rs b/src/balance.rs new file mode 100644 index 0000000..1d7d5d6 --- /dev/null +++ b/src/balance.rs @@ -0,0 +1,2195 @@ +//! Bounded backend pools: active health checks, passive failures, weighted +//! balancing, opaque affinity and leases held through the complete exchange. +use crate::{config::CompiledBackend, vhost::PoolMember}; +use anyhow::{ensure, Result}; +use axum::body::Body; +use http::{HeaderMap, HeaderValue, Request}; +use rand::{distributions::Alphanumeric, Rng}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::{ + net::IpAddr, + sync::{ + atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering}, + Arc, Mutex, MutexGuard, Weak, + }, + time::{Duration, Instant}, +}; +use tokio::task::JoinSet; +use tokio_util::sync::CancellationToken; + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(|e| e.into_inner()) +} +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Algorithm { + RoundRobin, + #[default] + LeastConnections, + LeastTraffic, + Adaptive, + IpHash, +} +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HealthMode { + Off, + #[default] + Tcp, + Http, +} +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StickyMode { + #[default] + Off, + Cookie, + IpHash, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Keepalive { + pub enabled: bool, + pub idle_timeout_seconds: u64, + pub max_idle_per_host: usize, + pub tcp_keepalive_seconds: u64, +} +impl Default for Keepalive { + fn default() -> Self { + Self { + enabled: true, + idle_timeout_seconds: 30, + max_idle_per_host: 4, + tcp_keepalive_seconds: 30, + } + } +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct RetryPolicy { + pub max_retries: u8, + pub retry_connect_errors: bool, + pub retry_timeouts: bool, + pub retry_statuses: Vec, +} +impl Default for RetryPolicy { + fn default() -> Self { + Self { + max_retries: 1, + retry_connect_errors: true, + retry_timeouts: true, + retry_statuses: vec![502, 503, 504], + } + } +} +impl RetryPolicy { + pub fn retries_status(&self, status: u16) -> bool { + self.max_retries > 0 && self.retry_statuses.contains(&status) + } +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct HealthCheck { + pub mode: HealthMode, + pub path: String, + pub host: Option, + pub interval_ms: u64, + pub timeout_ms: u64, + pub slow_response_ms: u64, + pub fall: u16, + pub rise: u16, + pub expected_status_min: u16, + pub expected_status_max: u16, +} +impl Default for HealthCheck { + fn default() -> Self { + Self { + mode: HealthMode::Tcp, + path: "/".into(), + host: None, + interval_ms: 5000, + timeout_ms: 2000, + slow_response_ms: 1500, + fall: 3, + rise: 2, + expected_status_min: 200, + expected_status_max: 399, + } + } +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct PassiveHealth { + pub enabled: bool, + pub count_connect_errors: bool, + pub count_timeouts: bool, + pub count_io_errors: bool, + pub count_http_5xx: bool, + pub count_slow_responses: bool, + pub fall: Option, + pub rise: Option, +} +impl Default for PassiveHealth { + fn default() -> Self { + Self { + enabled: false, + count_connect_errors: true, + count_timeouts: true, + count_io_errors: true, + count_http_5xx: true, + count_slow_responses: false, + fall: None, + rise: None, + } + } +} +impl PassiveHealth { + fn fall(&self, active: &HealthCheck) -> u16 { + self.fall.unwrap_or(active.fall) + } + fn rise(&self, active: &HealthCheck) -> u16 { + self.rise.unwrap_or(active.rise) + } + fn counts(&self, reason: &str) -> bool { + match reason { + "connect_error" => self.count_connect_errors, + "timeout" => self.count_timeouts, + "io_error" => self.count_io_errors, + "http_5xx" => self.count_http_5xx, + "slow_response" => self.count_slow_responses, + _ => false, + } + } +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Sticky { + pub mode: StickyMode, + pub cookie_name: Option, + pub ttl_seconds: u64, +} +impl Default for Sticky { + fn default() -> Self { + Self { + mode: StickyMode::Off, + cookie_name: None, + ttl_seconds: 3600, + } + } +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + pub algorithm: Algorithm, + pub health_check: HealthCheck, + pub passive_health: PassiveHealth, + pub keepalive: Keepalive, + pub retry: RetryPolicy, + pub sticky: Sticky, + pub slow_start_seconds: u64, +} +impl Default for Settings { + fn default() -> Self { + Self { + algorithm: Algorithm::default(), + health_check: HealthCheck::default(), + passive_health: PassiveHealth::default(), + keepalive: Keepalive::default(), + retry: RetryPolicy::default(), + sticky: Sticky::default(), + slow_start_seconds: 10, + } + } +} +impl Settings { + pub fn validate(&self) -> Result<()> { + let h = &self.health_check; + let k = &self.keepalive; + ensure!( + (250..=300_000).contains(&h.interval_ms), + "health interval_ms must be 250..300000" + ); + ensure!( + (50..=60_000).contains(&h.timeout_ms) && h.timeout_ms <= h.interval_ms, + "health timeout_ms must be 50..60000 and <= interval_ms" + ); + ensure!( + h.slow_response_ms <= h.timeout_ms, + "slow_response_ms must be 0 (off) or <= timeout_ms" + ); + ensure!( + (1..=20).contains(&h.fall) && (1..=20).contains(&h.rise), + "health fall/rise must be 1..20" + ); + ensure!( + h.expected_status_min >= 100 + && h.expected_status_max <= 599 + && h.expected_status_min <= h.expected_status_max, + "invalid health HTTP status range" + ); + ensure!( + h.path.starts_with('/') + && !h.path.starts_with("//") + && h.path.len() <= 1024 + && !h.path.contains('#') + && !h.path.bytes().any(|b| b <= 32 || b == 127), + "invalid health HTTP path" + ); + if let Some(host) = &h.host { + crate::config::request_host(host)?; + let _: HeaderValue = host.parse()?; + } + if let Some(fall) = self.passive_health.fall { + ensure!((1..=20).contains(&fall), "passive health fall must be 1..20"); + } + if let Some(rise) = self.passive_health.rise { + ensure!((1..=20).contains(&rise), "passive health rise must be 1..20"); + } + ensure!( + (1..=3600).contains(&k.idle_timeout_seconds) + && (1..=3600).contains(&k.tcp_keepalive_seconds) + && (1..=64).contains(&k.max_idle_per_host), + "invalid keepalive limits" + ); + ensure!(self.retry.max_retries <= 3, "retry max_retries must be 0..3"); + ensure!( + self.retry.retry_statuses.len() <= 8 + && self + .retry + .retry_statuses + .iter() + .all(|status| (500..=599).contains(status)), + "retry_statuses must contain at most 8 HTTP 5xx status codes" + ); + ensure!( + self.slow_start_seconds <= 300, + "slow_start_seconds must be 0..300" + ); + ensure!( + (60..=604_800).contains(&self.sticky.ttl_seconds), + "sticky ttl_seconds must be 60..604800" + ); + if let Some(name) = &self.sticky.cookie_name { + ensure!(!name.is_empty() && name.len() <= 96 && name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b,b'_'|b'-')), + "affinity cookie name must use 1..96 ASCII letters, numbers, underscores or hyphens"); + ensure!( + !name.eq_ignore_ascii_case(crate::auth::COOKIE_NAME), + "affinity cannot use the administrator session cookie name" + ); + } + Ok(()) + } +} +#[derive(Debug)] +struct Health { + healthy: bool, + active_healthy: bool, + passive_healthy: bool, + checked: bool, + failures: u16, + passive_failures: u16, + successes: u16, + passive_successes: u16, + next_probe: Instant, + probing: bool, + healthy_since: Instant, + result: &'static str, + passive_result: &'static str, + last_probe_ms: u64, + latency_us: u64, + ewma_us: u64, + transitions: u64, + unhealthy_source: &'static str, + unhealthy_reason: &'static str, + active_unhealthy_source: &'static str, + active_unhealthy_reason: &'static str, + passive_unhealthy_source: &'static str, + passive_unhealthy_reason: &'static str, +} +#[derive(Debug)] +struct Traffic { + at: Instant, + total: u64, + rate: f64, +} +#[derive(Debug, Clone)] +struct FailureObservation { + at_ms: u64, + class: &'static str, + detail: String, +} +impl Default for FailureObservation { + fn default() -> Self { + Self { + at_ms: 0, + class: "none", + detail: String::new(), + } + } +} +#[derive(Debug)] +pub struct BackendState { + health: Mutex, + traffic: Mutex, + last_failure: Mutex, + pub active: AtomicU64, + pub requests: AtomicU64, + pub errors: AtomicU64, + pub bytes_in: AtomicU64, + pub bytes_out: AtomicU64, + pub last_status: AtomicU16, + pub active_health_failures_total: AtomicU64, + pub passive_health_failures_total: AtomicU64, + pub http_5xx_total: AtomicU64, + pub timeouts_total: AtomicU64, + pub connect_errors_total: AtomicU64, + pub io_errors_total: AtomicU64, + pub slow_responses_total: AtomicU64, + pub retry_attempts_total: AtomicU64, + pub retry_successes_total: AtomicU64, + pub capacity_rejections_total: AtomicU64, + pub queue_timeouts_total: AtomicU64, + pub dns_errors_total: AtomicU64, + pub tls_errors_total: AtomicU64, + pub response_header_timeouts_total: AtomicU64, + pub read_timeouts_total: AtomicU64, + pub send_timeouts_total: AtomicU64, + pub last_ejected_ms: AtomicU64, + pub last_recovered_ms: AtomicU64, +} +impl BackendState { + fn new(mode: HealthMode) -> Self { + let now = Instant::now(); + Self { + health: Mutex::new(Health { + healthy: mode == HealthMode::Off, + active_healthy: mode == HealthMode::Off, + passive_healthy: true, + checked: false, + failures: 0, + passive_failures: 0, + successes: 0, + passive_successes: 0, + next_probe: now, + probing: false, + healthy_since: now, + result: if mode == HealthMode::Off { + "disabled" + } else { + "checking" + }, + passive_result: "none", + last_probe_ms: 0, + latency_us: 0, + ewma_us: 0, + transitions: 0, + unhealthy_source: if mode == HealthMode::Off { "none" } else { "active" }, + unhealthy_reason: if mode == HealthMode::Off { "none" } else { "checking" }, + active_unhealthy_source: if mode == HealthMode::Off { "none" } else { "active" }, + active_unhealthy_reason: if mode == HealthMode::Off { "none" } else { "checking" }, + passive_unhealthy_source: "none", + passive_unhealthy_reason: "none", + }), + traffic: Mutex::new(Traffic { + at: now, + total: 0, + rate: 0.0, + }), + last_failure: Mutex::new(FailureObservation::default()), + active: AtomicU64::new(0), + requests: AtomicU64::new(0), + errors: AtomicU64::new(0), + bytes_in: AtomicU64::new(0), + bytes_out: AtomicU64::new(0), + last_status: AtomicU16::new(0), + active_health_failures_total: AtomicU64::new(0), + passive_health_failures_total: AtomicU64::new(0), + http_5xx_total: AtomicU64::new(0), + timeouts_total: AtomicU64::new(0), + connect_errors_total: AtomicU64::new(0), + io_errors_total: AtomicU64::new(0), + slow_responses_total: AtomicU64::new(0), + retry_attempts_total: AtomicU64::new(0), + retry_successes_total: AtomicU64::new(0), + capacity_rejections_total: AtomicU64::new(0), + queue_timeouts_total: AtomicU64::new(0), + dns_errors_total: AtomicU64::new(0), + tls_errors_total: AtomicU64::new(0), + response_header_timeouts_total: AtomicU64::new(0), + read_timeouts_total: AtomicU64::new(0), + send_timeouts_total: AtomicU64::new(0), + last_ejected_ms: AtomicU64::new(0), + last_recovered_ms: AtomicU64::new(0), + } + } + fn has_capacity(&self, limit: u32) -> bool { + limit == 0 || self.active.load(Ordering::Relaxed) < u64::from(limit) + } + fn try_acquire_active(&self, limit: u32) -> bool { + if limit == 0 { + self.active.fetch_add(1, Ordering::Relaxed); + return true; + } + let limit = u64::from(limit); + let mut current = self.active.load(Ordering::Relaxed); + loop { + if current >= limit { + return false; + } + match self.active.compare_exchange_weak( + current, + current + 1, + Ordering::AcqRel, + Ordering::Relaxed, + ) { + Ok(_) => return true, + Err(next) => current = next, + } + } + } + pub fn healthy(&self) -> bool { + lock(&self.health).healthy + } + fn weight(&self, configured: u16, slow_start: u64) -> f64 { + let h = lock(&self.health); + if slow_start == 0 { + return configured as f64; + } + let ratio = (h.healthy_since.elapsed().as_secs_f64() / slow_start as f64).clamp(0.05, 1.0); + (configured as f64 * ratio).max(0.05) + } + fn latency(&self) -> f64 { + lock(&self.health).ewma_us.max(1000) as f64 / 1000.0 + } + fn traffic_rate(&self) -> f64 { + lock(&self.traffic).rate + } + fn sample_traffic(&self) { + let mut sample = lock(&self.traffic); + let elapsed = sample.at.elapsed().as_secs_f64(); + if elapsed < 1.0 { + return; + } + let total = self + .bytes_in + .load(Ordering::Relaxed) + .saturating_add(self.bytes_out.load(Ordering::Relaxed)); + let delta = total.saturating_sub(sample.total); + if delta == 0 { + // No bytes moved in this sampling window: report an actual idle backend + // instead of keeping a decaying EWMA tail that looks like live traffic. + sample.rate = 0.0; + } else { + let rate = delta as f64 / elapsed; + sample.rate = sample.rate * 0.5 + rate * 0.5; + } + sample.total = total; + sample.at = Instant::now(); + } + fn claim_probe(&self, h: &HealthCheck) -> bool { + if h.mode == HealthMode::Off { + return false; + } + let mut state = lock(&self.health); + if state.probing || Instant::now() < state.next_probe { + return false; + } + state.probing = true; + state.next_probe = Instant::now() + Duration::from_millis(h.interval_ms); + true + } + fn update_latency(h: &mut Health, elapsed: Duration) { + let latency = elapsed.as_micros().min(u64::MAX as u128) as u64; + h.latency_us = latency; + h.ewma_us = if h.ewma_us == 0 { + latency + } else { + h.ewma_us.saturating_mul(7).saturating_add(latency) / 8 + }; + } + + fn finish_transition(&self, h: &mut Health, previous: bool) { + h.healthy = h.active_healthy && h.passive_healthy; + if previous != h.healthy { + h.transitions = h.transitions.saturating_add(1); + if h.healthy { + self.last_recovered_ms + .store(crate::logging::now_ms(), Ordering::Relaxed); + h.healthy_since = Instant::now(); + h.unhealthy_source = "none"; + h.unhealthy_reason = "none"; + return; + } else { + self.last_ejected_ms + .store(crate::logging::now_ms(), Ordering::Relaxed); + } + } + if h.healthy { + return; + } + + let current_source_is_still_unhealthy = + (h.unhealthy_source.starts_with("active") && !h.active_healthy) + || (h.unhealthy_source.starts_with("passive") && !h.passive_healthy); + if current_source_is_still_unhealthy { + return; + } + if !h.active_healthy { + h.unhealthy_source = h.active_unhealthy_source; + h.unhealthy_reason = h.active_unhealthy_reason; + } else if !h.passive_healthy { + h.unhealthy_source = h.passive_unhealthy_source; + h.unhealthy_reason = h.passive_unhealthy_reason; + } + } + + fn observe_active(&self, settings: &HealthCheck, result: &'static str, elapsed: Duration) { + if settings.mode == HealthMode::Off { + return; + } + let mut h = lock(&self.health); + let previous = h.healthy; + Self::update_latency(&mut h, elapsed); + h.probing = false; + h.checked = true; + h.last_probe_ms = crate::logging::now_ms(); + h.result = result; + if result == "ok" { + h.failures = 0; + h.successes = h.successes.saturating_add(1); + if h.successes >= settings.rise { + h.active_healthy = true; + } + } else { + self.active_health_failures_total.fetch_add(1, Ordering::Relaxed); + h.successes = 0; + h.failures = h.failures.saturating_add(1); + if h.failures >= settings.fall { + h.active_healthy = false; + } + } + let source = match settings.mode { + HealthMode::Tcp => "active_tcp", + HealthMode::Http => "active_http", + HealthMode::Off => "active", + }; + if result != "ok" { + self.record_failure("active_health", &format!("{source}:{result}")); + } + if h.active_healthy { + h.active_unhealthy_source = "none"; + h.active_unhealthy_reason = "none"; + } else { + h.active_unhealthy_source = source; + h.active_unhealthy_reason = result; + if previous || h.unhealthy_source == "active" || h.unhealthy_source == "none" { + h.unhealthy_source = source; + h.unhealthy_reason = result; + } + } + self.finish_transition(&mut h, previous); + } + + fn record_failure(&self, class: &'static str, detail: &str) { + match class { + "dns" => { + self.dns_errors_total.fetch_add(1, Ordering::Relaxed); + } + "tls" => { + self.tls_errors_total.fetch_add(1, Ordering::Relaxed); + } + "response_header_timeout" => { + self.response_header_timeouts_total + .fetch_add(1, Ordering::Relaxed); + } + "read_timeout" => { + self.read_timeouts_total.fetch_add(1, Ordering::Relaxed); + } + "send_timeout" => { + self.send_timeouts_total.fetch_add(1, Ordering::Relaxed); + } + _ => {} + } + let mut last = lock(&self.last_failure); + last.at_ms = crate::logging::now_ms(); + last.class = class; + last.detail.clear(); + last.detail.extend(detail.chars().take(1024)); + } + + fn record_passive_observation(&self, reason: &'static str) { + match reason { + "http_5xx" => { self.http_5xx_total.fetch_add(1, Ordering::Relaxed); } + "timeout" => { self.timeouts_total.fetch_add(1, Ordering::Relaxed); } + "connect_error" => { self.connect_errors_total.fetch_add(1, Ordering::Relaxed); } + "io_error" => { self.io_errors_total.fetch_add(1, Ordering::Relaxed); } + "slow_response" => { self.slow_responses_total.fetch_add(1, Ordering::Relaxed); } + _ => {} + } + } + + fn observe_passive( + &self, + active: &HealthCheck, + passive: &PassiveHealth, + reason: &'static str, + elapsed: Duration, + ) { + self.record_passive_observation(reason); + { + let mut h = lock(&self.health); + Self::update_latency(&mut h, elapsed); + h.passive_result = reason; + } + if !passive.enabled { + return; + } + let mut h = lock(&self.health); + let previous = h.healthy; + if reason == "ok" { + h.passive_failures = 0; + h.passive_successes = h.passive_successes.saturating_add(1); + if h.passive_successes >= passive.rise(active) { + h.passive_healthy = true; + h.passive_unhealthy_source = "none"; + h.passive_unhealthy_reason = "none"; + } + self.finish_transition(&mut h, previous); + return; + } + if !passive.counts(reason) { + return; + } + self.passive_health_failures_total.fetch_add(1, Ordering::Relaxed); + h.passive_successes = 0; + h.passive_failures = h.passive_failures.saturating_add(1); + if h.passive_failures >= passive.fall(active) { + h.passive_healthy = false; + } + let source = match reason { + "connect_error" => "passive_connect_error", + "timeout" => "passive_timeout", + "io_error" => "passive_io_error", + "http_5xx" => "passive_http_5xx", + "slow_response" => "passive_slow", + _ => "passive", + }; + if !h.passive_healthy { + h.passive_unhealthy_source = source; + h.passive_unhealthy_reason = reason; + if previous { + h.unhealthy_source = source; + h.unhealthy_reason = reason; + } + } + self.finish_transition(&mut h, previous); + } + +} +#[derive(Debug)] +pub struct Member { + pub backend: Arc, + pub definition: PoolMember, + pub state: Arc, + token: String, +} +#[derive(Debug)] +pub struct Pool { + pub id: String, + pub settings: Settings, + pub members: Vec>, + pub fingerprint: Vec, + cookie_name: String, + cursor: AtomicU64, + scheduling: Mutex>, +} +#[derive(Debug)] +pub struct Lease { + pub member: Arc, + health: HealthCheck, + passive_health: PassiveHealth, + error_recorded: AtomicBool, +} +impl Lease { + pub fn started(&self) { + self.member.state.requests.fetch_add(1, Ordering::Relaxed); + } + pub fn response(&self, status: u16, elapsed: Duration, request_body_empty: bool) { + self.member + .state + .last_status + .store(status, Ordering::Relaxed); + let is_5xx = status >= 500; + let is_slow = request_body_empty + && self.health.slow_response_ms > 0 + && elapsed > Duration::from_millis(self.health.slow_response_ms); + if (is_5xx || is_slow) && !self.error_recorded.swap(true, Ordering::Relaxed) { + self.member.state.errors.fetch_add(1, Ordering::Relaxed); + } + if is_5xx { + self.member + .state + .record_failure("http_5xx", &format!("status={status}")); + } + let reason = if is_5xx + && (!is_slow + || !self.passive_health.enabled + || self.passive_health.count_http_5xx + || !self.passive_health.count_slow_responses) + { + if is_slow { + self.member.state.record_passive_observation("slow_response"); + } + "http_5xx" + } else if is_slow { + if is_5xx { + self.member.state.record_passive_observation("http_5xx"); + } + "slow_response" + } else { + "ok" + }; + let before = self.member.state.healthy(); + self.member + .state + .observe_passive(&self.health, &self.passive_health, reason, elapsed); + let after = self.member.state.healthy(); + if before != after { + tracing::info!( + backend = %self.member.definition.id, + healthy = after, + source = "passive", + reason, + "backend health changed" + ); + } + } + pub fn error(&self, reason: &'static str, elapsed: Duration) { + self.error_detail(reason, reason, elapsed); + } + pub fn error_detail(&self, class: &'static str, detail: &str, elapsed: Duration) { + if !self.error_recorded.swap(true, Ordering::Relaxed) { + self.member.state.errors.fetch_add(1, Ordering::Relaxed); + } + self.member.state.record_failure(class, detail); + let passive_reason = match class { + "connect" | "dns" | "tls" => "connect_error", + "timeout" | "body_timeout" | "response_header_timeout" | "read_timeout" + | "send_timeout" => "timeout", + _ => "io_error", + }; + let before = self.member.state.healthy(); + self.member.state.observe_passive( + &self.health, + &self.passive_health, + passive_reason, + elapsed, + ); + let after = self.member.state.healthy(); + if before != after { + tracing::info!( + backend = %self.member.definition.id, + healthy = after, + source = "passive", + reason = passive_reason, + "backend health changed" + ); + } + } + pub fn bytes(&self, count: u64, incoming: bool) { + if incoming { + self.member + .state + .bytes_in + .fetch_add(count, Ordering::Relaxed); + } else { + self.member + .state + .bytes_out + .fetch_add(count, Ordering::Relaxed); + } + } + pub fn retry_attempted(&self) { + self.member + .state + .retry_attempts_total + .fetch_add(1, Ordering::Relaxed); + } + pub fn retry_succeeded(&self) { + self.member + .state + .retry_successes_total + .fetch_add(1, Ordering::Relaxed); + } +} +impl Drop for Lease { + fn drop(&mut self) { + self.member.state.active.fetch_sub(1, Ordering::Relaxed); + } +} +pub struct Selection { + pub lease: Arc, + pub cookie: Option, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SelectionFailure { + Unavailable, + Capacity, +} +impl Pool { + pub fn new( + id: String, + settings: Settings, + definitions: &[PoolMember], + backends: Vec>, + ) -> Result { + ensure!( + definitions.len() == backends.len() && !backends.is_empty(), + "invalid compiled pool" + ); + let fingerprint = Sha256::digest(serde_json::to_vec(&(definitions, &settings))?).to_vec(); + let cookie_name = settings + .sticky + .cookie_name + .clone() + .unwrap_or_else(|| format!("RP_AFFINITY_{id}")); + let members = definitions + .iter() + .cloned() + .zip(backends) + .map(|(definition, backend)| { + Arc::new(Member { + state: Arc::new(BackendState::new(settings.health_check.mode)), + definition, + backend, + token: rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(32) + .map(char::from) + .collect(), + }) + }) + .collect(); + Ok(Self { + id, + settings, + members, + fingerprint, + cookie_name, + cursor: AtomicU64::new(0), + scheduling: Mutex::new(std::collections::HashMap::new()), + }) + } + pub fn reuse_observations(&mut self, previous: &Pool) { + for member in &mut self.members { + let Some(old) = previous + .members + .iter() + .find(|old| old.definition.id == member.definition.id) + else { + continue; + }; + let same_origin = serde_json::to_vec(&member.definition.backend()).ok() + == serde_json::to_vec(&old.definition.backend()).ok(); + let same_health = serde_json::to_vec(&( + &self.settings.health_check, + &self.settings.passive_health, + )) + .ok() + == serde_json::to_vec(&( + &previous.settings.health_check, + &previous.settings.passive_health, + )) + .ok(); + if same_origin && same_health && (old.definition.enabled || !member.definition.enabled) + { + // This pool has not been published or cloned into routes yet. + if let Some(member) = Arc::get_mut(member) { + member.state = old.state.clone(); + member.token = old.token.clone(); + } + } + } + } + fn candidates_excluding(&self, excluded: &[String]) -> Vec<&Arc> { + let all: Vec<_> = self + .members + .iter() + .filter(|m| { + m.definition.pool_eligible + && m.definition.enabled + && !m.definition.drain + && m.state.healthy() + && m.state.has_capacity(m.definition.max_concurrent_requests) + && !excluded.iter().any(|id| id == &m.definition.id) + }) + .collect(); + let primary = all.iter().any(|m| !m.definition.backup); + all.into_iter() + .filter(|m| !primary || !m.definition.backup) + .collect() + } + pub fn ready(&self) -> bool { + !self.eligible_healthy_excluding(&[]).is_empty() + } + pub fn cookie_affinity(&self) -> bool { + self.settings.sticky.mode == StickyMode::Cookie + } + fn affinity<'a>(&self, headers: &'a HeaderMap) -> Option<&'a str> { + let mut values = headers + .get_all("cookie") + .iter() + .filter_map(|v| v.to_str().ok()) + .flat_map(|v| v.split(';')) + .filter_map(|pair| pair.trim().split_once('=')) + .filter(|(name, _)| *name == self.cookie_name) + .map(|(_, value)| value); + let first = values.next()?; + if values.next().is_some() { + None + } else { + Some(first) + } + } + pub fn strip_affinity_response(&self, headers: &mut HeaderMap) { + if !self.cookie_affinity() { + return; + } + let retained: Vec<_> = headers + .get_all("set-cookie") + .iter() + .filter(|value| { + value + .to_str() + .ok() + .and_then(|v| v.split_once('=')) + .is_none_or(|(name, _)| name.trim() != self.cookie_name) + }) + .cloned() + .collect(); + headers.remove("set-cookie"); + for value in retained { + headers.append("set-cookie", value); + } + } + pub fn strip_affinity(&self, headers: &mut HeaderMap) { + if !self.cookie_affinity() { + return; + } + let values: Vec = headers + .get_all("cookie") + .iter() + .filter_map(|value| { + let Ok(text) = value.to_str() else { + return Some(value.clone()); + }; + let clean = text + .split(';') + .filter(|part| { + part.trim() + .split_once('=') + .is_none_or(|(n, _)| n != self.cookie_name) + }) + .map(str::trim) + .collect::>() + .join("; "); + if clean.is_empty() { + None + } else { + clean.parse().ok() + } + }) + .collect(); + headers.remove("cookie"); + for value in values { + headers.append("cookie", value); + } + } + pub fn select_member(&self, id: &str) -> Option { + let member = self.members.iter().find(|m| { + m.definition.id == id + && m.definition.enabled + && !m.definition.drain + && m.state.healthy() + })?; + if !member + .state + .try_acquire_active(member.definition.max_concurrent_requests) + { + return None; + } + Some(Selection { + lease: Arc::new(Lease { + member: member.clone(), + health: self.settings.health_check.clone(), + passive_health: self.settings.passive_health.clone(), + error_recorded: AtomicBool::new(false), + }), + cookie: None, + }) + } + fn select_excluding( + &self, + peer: IpAddr, + headers: &HeaderMap, + tls: bool, + excluded: &[String], + ) -> Option { + let mut scheduling = lock(&self.scheduling); + let members = self.candidates_excluding(excluded); + let existing = if self.cookie_affinity() { + self.affinity(headers) + } else { + None + }; + // Drain stops new assignments while existing cookie-affine clients may + // finish their session. Disabled/unhealthy members never receive traffic. + let matched = existing.and_then(|token| { + self.members.iter().find(|m| { + m.token == token + && m.definition.pool_eligible + && m.definition.enabled + && m.state.healthy() + && m.state.has_capacity(m.definition.max_concurrent_requests) + && !excluded.iter().any(|id| id == &m.definition.id) + }) + }); + if members.is_empty() && matched.is_none() { + return None; + } + let cursor = self.cursor.fetch_add(1, Ordering::Relaxed); + let selected = if let Some(member) = matched { + member + } else { + let weights: Vec = members + .iter() + .map(|m| { + m.state + .weight(m.definition.weight, self.settings.slow_start_seconds) + }) + .collect(); + if self.settings.algorithm == Algorithm::RoundRobin + && self.settings.sticky.mode != StickyMode::IpHash + { + // Smooth weighted round-robin: distribute weighted requests, + // rather than sending long consecutive bursts to one member. + let mut chosen = members[0]; + let mut best = f64::NEG_INFINITY; + let mut total = 0.0; + for (member, weight) in members.iter().zip(&weights) { + let current = scheduling + .entry(member.definition.id.clone()) + .or_insert(0.0); + *current += *weight; + total += *weight; + if *current > best { + best = *current; + chosen = *member; + } + } + if let Some(current) = scheduling.get_mut(&chosen.definition.id) { + *current -= total; + } + chosen + } else { + let mut chosen = members[(cursor as usize) % members.len()]; + let mut best = f64::INFINITY; + for offset in 0..members.len() { + let index = ((cursor as usize) % members.len() + offset) % members.len(); + let member = members[index]; + let active = member.state.active.load(Ordering::Relaxed) as f64; + let algorithm = if self.settings.sticky.mode == StickyMode::IpHash { + Algorithm::IpHash + } else { + self.settings.algorithm + }; + let score = match algorithm { + Algorithm::IpHash => { + let digest = Sha256::digest( + format!( + "{}|{}|{}", + self.id, + crate::vhost::canonical_ip(peer), + member.definition.id + ) + .as_bytes(), + ); + let n = u64::from_be_bytes([ + digest[0], digest[1], digest[2], digest[3], digest[4], digest[5], + digest[6], digest[7], + ]); + // Weighted rendezvous: stable across requests, no client-controlled forwarding headers. + let unit = (n as f64 + 1.0) / (u64::MAX as f64 + 2.0); + -unit.max(f64::MIN_POSITIVE).ln() / member.definition.weight as f64 + } + Algorithm::LeastTraffic => { + (member.state.traffic_rate() + (active + 1.0) * 1024.0) / weights[index] + } + Algorithm::Adaptive => { + ((active + 1.0) * member.state.latency() + + member.state.traffic_rate() / 16_384.0) + / weights[index] + } + _ => (active + 1.0) / weights[index], + }; + if score < best { + best = score; + chosen = member; + } + } + chosen + } + }; + if !selected + .state + .try_acquire_active(selected.definition.max_concurrent_requests) + { + return None; + } + let cookie = if self.cookie_affinity() && existing != Some(selected.token.as_str()) { + format!( + "{}={}; Path=/; Max-Age={}; HttpOnly; SameSite=Lax{}", + self.cookie_name, + selected.token, + self.settings.sticky.ttl_seconds, + if tls { "; Secure" } else { "" } + ) + .parse() + .ok() + } else { + None + }; + Some(Selection { + lease: Arc::new(Lease { + member: selected.clone(), + health: self.settings.health_check.clone(), + passive_health: self.settings.passive_health.clone(), + error_recorded: AtomicBool::new(false), + }), + cookie, + }) + } + pub fn select(&self, peer: IpAddr, headers: &HeaderMap, tls: bool) -> Option { + self.select_excluding(peer, headers, tls, &[]) + } + pub fn select_retry( + &self, + peer: IpAddr, + headers: &HeaderMap, + tls: bool, + excluded: &[String], + ) -> Option { + self.select_excluding(peer, headers, tls, excluded) + } + fn eligible_healthy_excluding(&self, excluded: &[String]) -> Vec<&Arc> { + let all: Vec<_> = self + .members + .iter() + .filter(|m| { + m.definition.pool_eligible + && m.definition.enabled + && !m.definition.drain + && m.state.healthy() + && !excluded.iter().any(|id| id == &m.definition.id) + }) + .collect(); + let primary = all.iter().any(|m| !m.definition.backup); + all.into_iter() + .filter(|m| !primary || !m.definition.backup) + .collect() + } + fn capacity_wait_ms(&self, excluded: &[String]) -> Option { + let members = self.eligible_healthy_excluding(excluded); + if members.is_empty() { + return None; + } + Some( + members + .iter() + .map(|m| m.definition.queue_timeout_ms) + .max() + .unwrap_or(0), + ) + } + fn note_capacity_rejection(&self, excluded: &[String], timed_out: bool) { + if let Some(member) = self + .eligible_healthy_excluding(excluded) + .into_iter() + .min_by_key(|m| m.state.active.load(Ordering::Relaxed)) + { + member + .state + .capacity_rejections_total + .fetch_add(1, Ordering::Relaxed); + if timed_out { + member + .state + .queue_timeouts_total + .fetch_add(1, Ordering::Relaxed); + } + } + } + pub async fn select_wait( + &self, + peer: IpAddr, + headers: &HeaderMap, + tls: bool, + ) -> Result { + self.select_retry_wait(peer, headers, tls, &[]).await + } + pub async fn select_member_wait(&self, id: &str) -> Result { + let Some(member) = self.members.iter().find(|m| { + m.definition.id == id + && m.definition.enabled + && !m.definition.drain + && m.state.healthy() + }) else { + return Err(SelectionFailure::Unavailable); + }; + let wait_ms = member.definition.queue_timeout_ms; + let started = Instant::now(); + loop { + if !member.definition.enabled || member.definition.drain || !member.state.healthy() { + return Err(SelectionFailure::Unavailable); + } + if let Some(selection) = self.select_member(id) { + return Ok(selection); + } + if wait_ms == 0 || started.elapsed() >= Duration::from_millis(wait_ms) { + member + .state + .capacity_rejections_total + .fetch_add(1, Ordering::Relaxed); + if wait_ms > 0 { + member + .state + .queue_timeouts_total + .fetch_add(1, Ordering::Relaxed); + } + return Err(SelectionFailure::Capacity); + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + } + pub async fn select_retry_wait( + &self, + peer: IpAddr, + headers: &HeaderMap, + tls: bool, + excluded: &[String], + ) -> Result { + if let Some(selection) = self.select_retry(peer, headers, tls, excluded) { + return Ok(selection); + } + let Some(wait_ms) = self.capacity_wait_ms(excluded) else { + return Err(SelectionFailure::Unavailable); + }; + let started = Instant::now(); + loop { + if self.eligible_healthy_excluding(excluded).is_empty() { + return Err(SelectionFailure::Unavailable); + } + if let Some(selection) = self.select_retry(peer, headers, tls, excluded) { + return Ok(selection); + } + if wait_ms == 0 || started.elapsed() >= Duration::from_millis(wait_ms) { + self.note_capacity_rejection(excluded, wait_ms > 0); + return Err(SelectionFailure::Capacity); + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + } + pub fn view(&self) -> Value { + let members = self + .members + .iter() + .map(|m| { + let h = lock(&m.state.health); + let failure = lock(&m.state.last_failure); + let status = if !m.definition.enabled { + "disabled" + } else if m.definition.drain { + "draining" + } else if h.healthy { + "up" + } else if !h.checked && self.settings.health_check.mode != HealthMode::Off { + "checking" + } else { + "down" + }; + + // Keep this as explicit Map construction instead of one very large + // json! invocation. serde_json's json_internal! macro is recursive + // and the diagnostics payload is large enough to hit rustc's default + // macro recursion limit on supported stable toolchains. + let mut item = serde_json::Map::new(); + item.insert("id".into(), json!(m.definition.id)); + item.insert("url".into(), json!(m.definition.url)); + item.insert("weight".into(), json!(m.definition.weight)); + item.insert("backup".into(), json!(m.definition.backup)); + item.insert( + "max_concurrent_requests".into(), + json!(m.definition.max_concurrent_requests), + ); + item.insert("queue_timeout_ms".into(), json!(m.definition.queue_timeout_ms)); + item.insert("status".into(), json!(status)); + item.insert("healthy".into(), json!(h.healthy)); + item.insert("active".into(), json!(m.state.active.load(Ordering::Relaxed))); + item.insert( + "requests".into(), + json!(m.state.requests.load(Ordering::Relaxed)), + ); + item.insert("errors".into(), json!(m.state.errors.load(Ordering::Relaxed))); + item.insert( + "bytes_in".into(), + json!(m.state.bytes_in.load(Ordering::Relaxed)), + ); + item.insert( + "bytes_out".into(), + json!(m.state.bytes_out.load(Ordering::Relaxed)), + ); + item.insert("traffic_bytes_per_second".into(), json!(m.state.traffic_rate())); + item.insert("latency_ms".into(), json!(h.latency_us as f64 / 1000.0)); + item.insert("ewma_latency_ms".into(), json!(h.ewma_us as f64 / 1000.0)); + item.insert("last_probe_ms".into(), json!(h.last_probe_ms)); + item.insert("last_result".into(), json!(h.result)); + item.insert("last_passive_result".into(), json!(h.passive_result)); + item.insert("failures".into(), json!(h.failures)); + item.insert("passive_failures".into(), json!(h.passive_failures)); + item.insert("successes".into(), json!(h.successes)); + item.insert("passive_successes".into(), json!(h.passive_successes)); + item.insert("transitions".into(), json!(h.transitions)); + item.insert("unhealthy_source".into(), json!(h.unhealthy_source)); + item.insert("unhealthy_reason".into(), json!(h.unhealthy_reason)); + item.insert( + "http_5xx_total".into(), + json!(m.state.http_5xx_total.load(Ordering::Relaxed)), + ); + item.insert( + "timeouts_total".into(), + json!(m.state.timeouts_total.load(Ordering::Relaxed)), + ); + item.insert( + "connect_errors_total".into(), + json!(m.state.connect_errors_total.load(Ordering::Relaxed)), + ); + item.insert( + "io_errors_total".into(), + json!(m.state.io_errors_total.load(Ordering::Relaxed)), + ); + item.insert( + "slow_responses_total".into(), + json!(m.state.slow_responses_total.load(Ordering::Relaxed)), + ); + item.insert( + "retry_attempts_total".into(), + json!(m.state.retry_attempts_total.load(Ordering::Relaxed)), + ); + item.insert( + "retry_successes_total".into(), + json!(m.state.retry_successes_total.load(Ordering::Relaxed)), + ); + item.insert( + "capacity_rejections_total".into(), + json!(m.state.capacity_rejections_total.load(Ordering::Relaxed)), + ); + item.insert( + "queue_timeouts_total".into(), + json!(m.state.queue_timeouts_total.load(Ordering::Relaxed)), + ); + item.insert( + "dns_errors_total".into(), + json!(m.state.dns_errors_total.load(Ordering::Relaxed)), + ); + item.insert( + "tls_errors_total".into(), + json!(m.state.tls_errors_total.load(Ordering::Relaxed)), + ); + item.insert( + "response_header_timeouts_total".into(), + json!(m.state.response_header_timeouts_total.load(Ordering::Relaxed)), + ); + item.insert( + "read_timeouts_total".into(), + json!(m.state.read_timeouts_total.load(Ordering::Relaxed)), + ); + item.insert( + "send_timeouts_total".into(), + json!(m.state.send_timeouts_total.load(Ordering::Relaxed)), + ); + item.insert("last_failure_at_ms".into(), json!(failure.at_ms)); + item.insert("last_failure_class".into(), json!(failure.class)); + item.insert("last_failure_detail".into(), json!(failure.detail.as_str())); + item.insert( + "last_ejected_ms".into(), + json!(m.state.last_ejected_ms.load(Ordering::Relaxed)), + ); + item.insert( + "last_recovered_ms".into(), + json!(m.state.last_recovered_ms.load(Ordering::Relaxed)), + ); + item.insert( + "active_health_failures_total".into(), + json!(m.state.active_health_failures_total.load(Ordering::Relaxed)), + ); + item.insert( + "passive_health_failures_total".into(), + json!(m.state.passive_health_failures_total.load(Ordering::Relaxed)), + ); + item.insert( + "last_status".into(), + json!(m.state.last_status.load(Ordering::Relaxed)), + ); + Value::Object(item) + }) + .collect::>(); + + json!({ + "id": self.id, + "algorithm": self.settings.algorithm, + "ready": self.ready(), + "health_mode": self.settings.health_check.mode, + "passive_health": self.settings.passive_health, + "retry": self.settings.retry, + "sticky_mode": self.settings.sticky.mode, + "keepalive": self.settings.keepalive, + "members": members, + }) + } +} +impl Pool { + pub fn prometheus(&self) -> String { + use std::fmt::Write; + let mut text = String::new(); + for member in &self.members { + let state = &member.state; + let labels = format!("vhost=\"{}\",backend=\"{}\"", self.id, member.definition.id); + for (metric, value) in [ + ( + "up", + u64::from(state.healthy() && member.definition.enabled), + ), + ("draining", u64::from(member.definition.drain)), + ("active_exchanges", state.active.load(Ordering::Relaxed)), + ("requests_total", state.requests.load(Ordering::Relaxed)), + ("errors_total", state.errors.load(Ordering::Relaxed)), + ("bytes_in_total", state.bytes_in.load(Ordering::Relaxed)), + ("bytes_out_total", state.bytes_out.load(Ordering::Relaxed)), + ] { + let _ = writeln!(text, "rust_proxy_backend_{metric}{{{labels}}} {value}"); + } + for (metric, value) in [ + ("active_health_failures_total", state.active_health_failures_total.load(Ordering::Relaxed)), + ("passive_health_failures_total", state.passive_health_failures_total.load(Ordering::Relaxed)), + ("http_5xx_total", state.http_5xx_total.load(Ordering::Relaxed)), + ("passive_http_5xx_total", state.http_5xx_total.load(Ordering::Relaxed)), + ("timeouts_total", state.timeouts_total.load(Ordering::Relaxed)), + ("passive_timeouts_total", state.timeouts_total.load(Ordering::Relaxed)), + ("connect_errors_total", state.connect_errors_total.load(Ordering::Relaxed)), + ("passive_connect_errors_total", state.connect_errors_total.load(Ordering::Relaxed)), + ("io_errors_total", state.io_errors_total.load(Ordering::Relaxed)), + ("passive_io_errors_total", state.io_errors_total.load(Ordering::Relaxed)), + ("slow_responses_total", state.slow_responses_total.load(Ordering::Relaxed)), + ("passive_slow_total", state.slow_responses_total.load(Ordering::Relaxed)), + ("retry_attempts_total", state.retry_attempts_total.load(Ordering::Relaxed)), + ("retry_successes_total", state.retry_successes_total.load(Ordering::Relaxed)), + ("capacity_rejections_total", state.capacity_rejections_total.load(Ordering::Relaxed)), + ("queue_timeouts_total", state.queue_timeouts_total.load(Ordering::Relaxed)), + ("dns_errors_total", state.dns_errors_total.load(Ordering::Relaxed)), + ("tls_errors_total", state.tls_errors_total.load(Ordering::Relaxed)), + ("response_header_timeouts_total", state.response_header_timeouts_total.load(Ordering::Relaxed)), + ("read_timeouts_total", state.read_timeouts_total.load(Ordering::Relaxed)), + ("send_timeouts_total", state.send_timeouts_total.load(Ordering::Relaxed)), + ] { + let _ = writeln!(text, "rust_proxy_backend_{metric}{{{labels}}} {value}"); + } + for (metric, value) in [ + ("last_ejected_timestamp_milliseconds", state.last_ejected_ms.load(Ordering::Relaxed)), + ("last_recovered_timestamp_milliseconds", state.last_recovered_ms.load(Ordering::Relaxed)), + ] { + let _ = writeln!(text, "rust_proxy_backend_{metric}{{{labels}}} {value}"); + } + let _ = writeln!( + text, + "rust_proxy_backend_traffic_bytes_per_second{{{labels}}} {:.3}", + state.traffic_rate() + ); + let _ = writeln!( + text, + "rust_proxy_backend_latency_milliseconds{{{labels}}} {:.3}", + state.latency() + ); + } + text + } +} +async fn probe(member: Arc, settings: HealthCheck) { + let start = Instant::now(); + let result = tokio::time::timeout(Duration::from_millis(settings.timeout_ms), async { + match settings.mode { + HealthMode::Tcp => { + crate::upstream::tcp_probe(&member.backend.origin, &member.backend.address_policy) + .await + .map(|_| "ok") + .unwrap_or("connect") + } + HealthMode::Http => { + let request = Request::builder() + .uri(format!( + "{}{}", + member.backend.request_origin, settings.path + )) + .header( + "host", + settings + .host + .as_deref() + .unwrap_or_else(|| member.backend.host_header.to_str().unwrap_or("")), + ) + .header("connection", "close") + .header( + "user-agent", + concat!("RustProxy-health/", env!("CARGO_PKG_VERSION")), + ) + .body(Body::empty()); + match request { + Ok(request) => match member.backend.websocket_client.request(request).await { + Ok(response) => { + if (settings.expected_status_min..=settings.expected_status_max) + .contains(&response.status().as_u16()) + { + "ok" + } else { + "http_status" + } + } + Err(_) => "connect", + }, + Err(_) => "request", + } + } + HealthMode::Off => "ok", + } + }) + .await + .unwrap_or("timeout"); + let elapsed = start.elapsed(); + let result = if result == "ok" + && settings.slow_response_ms > 0 + && elapsed > Duration::from_millis(settings.slow_response_ms) + { + "slow" + } else { + result + }; + let before = member.state.healthy(); + member.state.observe_active(&settings, result, elapsed); + if before != member.state.healthy() { + tracing::info!(backend = %member.definition.id, healthy = member.state.healthy(), reason = result, "backend health changed"); + } +} +/// One supervisor, at most 16 probes, at most one outstanding probe per member. +/// DNS workers have their own shared bound and never resolve arbitrary URLs. +pub async fn health_loop(app: Weak, shutdown: CancellationToken) { + let mut interval = tokio::time::interval(Duration::from_millis(250)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut checks = JoinSet::new(); + let mut cursor = 0usize; + loop { + tokio::select! { + biased; + _ = shutdown.cancelled() => break, + _ = checks.join_next(), if !checks.is_empty() => {}, + _ = interval.tick() => { + let Some(app) = app.upgrade() else { break; }; + let snapshot = app.config.load_full(); + let members: Vec<_> = snapshot.pools.iter().flat_map(|pool| + pool.members.iter().map(move |member| (member, &pool.settings.health_check))).collect(); + for (member, _) in &members { member.state.sample_traffic(); } + // Rotate the bounded probe window: slow first members must not + // starve later pools when all intervals equal their timeouts. + let start = cursor; + for offset in 0..members.len() { + if checks.len() >= 16 { break; } + let index = (start + offset) % members.len(); + let (member, health) = members[index]; + if member.definition.enabled && member.state.claim_probe(health) { + checks.spawn(probe(member.clone(), health.clone())); + cursor = (index + 1) % members.len(); + } + } + }, + } + } + checks.abort_all(); + while checks.join_next().await.is_some() {} +} + +#[cfg(test)] +mod tests { + use super::*; + fn settings(mode: HealthMode) -> HealthCheck { + HealthCheck { + mode, + fall: 2, + rise: 2, + ..Default::default() + } + } + fn pool(algorithm: Algorithm, sticky: StickyMode) -> Arc { + let cfg = serde_json::from_value(serde_json::json!({ + "listeners":{"endpoints":[{"id":"http","binding":{"address":"127.0.0.1:8080"}}]}, + "vhosts":[{ + "id":"app","frontend":{"hosts":["app.localhost"],"listener_ids":["http"]}, + "backends":[{"id":"a","url":"http://127.0.0.1:9000","allow_private":true,"weight":3}, + {"id":"b","url":"http://127.0.0.1:9001","allow_private":true,"weight":1}], + "load_balancing":{"algorithm":algorithm,"sticky":{"mode":sticky},"slow_start_seconds":0,"health_check":{"mode":"off"}} + }]})).unwrap(); + let policy = crate::config::ValidationPolicy { + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + tls_addr: None, + cert_dir: None, + data_dir: None, + }; + crate::config::compile(cfg, 0, &policy).unwrap().pools[0].clone() + } + fn capacity_pool(limit: u32, queue_timeout_ms: u64) -> Arc { + let cfg = serde_json::from_value(serde_json::json!({ + "listeners":{"endpoints":[{"id":"http","binding":{"address":"127.0.0.1:8080"}}]}, + "vhosts":[{ + "id":"limited","frontend":{"hosts":["limited.localhost"],"listener_ids":["http"]}, + "backends":[{ + "id":"only","url":"http://127.0.0.1:9000","allow_private":true, + "max_concurrent_requests":limit,"queue_timeout_ms":queue_timeout_ms + }], + "load_balancing":{"health_check":{"mode":"off"}} + }] + })).unwrap(); + let policy = crate::config::ValidationPolicy { + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + tls_addr: None, + cert_dir: None, + data_dir: None, + }; + crate::config::compile(cfg, 0, &policy).unwrap().pools[0].clone() + } + fn select(pool: &Pool) -> Selection { + pool.select("127.0.0.1".parse().unwrap(), &HeaderMap::new(), false) + .unwrap() + } + #[test] + fn default_health_check_uses_tcp() { + assert_eq!(HealthMode::default(), HealthMode::Tcp); + assert_eq!(HealthCheck::default().mode, HealthMode::Tcp); + } + #[test] + fn legacy_settings_without_passive_health_default_to_off() { + let parsed: Settings = serde_json::from_value(serde_json::json!({ + "health_check": { "mode": "off" } + })) + .unwrap(); + assert!(!parsed.passive_health.enabled); + assert!(parsed.passive_health.count_connect_errors); + assert!(parsed.passive_health.count_timeouts); + assert!(parsed.passive_health.count_io_errors); + assert!(parsed.passive_health.count_http_5xx); + assert!(!parsed.passive_health.count_slow_responses); + assert_eq!(parsed.retry.max_retries, 1); + assert!(parsed.retry.retry_connect_errors); + assert!(parsed.retry.retry_timeouts); + assert_eq!(parsed.retry.retry_statuses, vec![502, 503, 504]); + } + + #[test] + fn retry_policy_rejects_unbounded_or_non_5xx_configuration() { + let mut settings = Settings::default(); + settings.retry.max_retries = 4; + assert!(settings.validate().is_err()); + settings.retry.max_retries = 1; + settings.retry.retry_statuses = vec![429]; + assert!(settings.validate().is_err()); + } + + #[test] + fn traffic_rate_drops_to_zero_after_an_idle_sample_window() { + let state = BackendState::new(HealthMode::Off); + state.bytes_out.store(2048, std::sync::atomic::Ordering::Relaxed); + lock(&state.traffic).at = std::time::Instant::now() - std::time::Duration::from_secs(2); + state.sample_traffic(); + assert!(state.traffic_rate() > 0.0); + lock(&state.traffic).at = std::time::Instant::now() - std::time::Duration::from_secs(2); + state.sample_traffic(); + assert_eq!(state.traffic_rate(), 0.0); + } + #[test] + fn active_failures_are_independent_from_passive_user_traffic() { + let state = BackendState::new(HealthMode::Http); + let settings = settings(HealthMode::Http); + let passive = PassiveHealth::default(); + let fast = Duration::from_millis(5); + assert!(!state.healthy()); + state.observe_active(&settings, "ok", fast); + assert!(!state.healthy()); + state.observe_active(&settings, "ok", fast); + assert!(state.healthy()); + state.observe_active(&settings, "status", fast); + state.observe_passive(&settings, &passive, "ok", fast); + state.observe_active(&settings, "status", fast); + assert!(!state.healthy()); + state.observe_passive(&settings, &passive, "http_5xx", fast); + assert!(!state.healthy()); + state.observe_active(&settings, "ok", fast); + assert!(!state.healthy()); + state.observe_active(&settings, "ok", fast); + assert!(state.healthy()); + } + + #[test] + fn passive_health_is_opt_in_and_uses_separate_thresholds() { + let state = BackendState::new(HealthMode::Off); + let active = settings(HealthMode::Off); + let fast = Duration::from_millis(5); + for _ in 0..10 { + state.observe_passive(&active, &PassiveHealth::default(), "http_5xx", fast); + } + assert!(state.healthy()); + assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 10); + assert_eq!(state.passive_health_failures_total.load(Ordering::Relaxed), 0); + + let passive = PassiveHealth { + enabled: true, + fall: Some(2), + rise: Some(2), + ..Default::default() + }; + state.observe_passive(&active, &passive, "http_5xx", fast); + assert!(state.healthy()); + state.observe_passive(&active, &passive, "http_5xx", fast); + assert!(!state.healthy()); + state.observe_passive(&active, &passive, "ok", fast); + assert!(!state.healthy()); + state.observe_passive(&active, &passive, "ok", fast); + assert!(state.healthy()); + } + #[test] + fn passive_http_5xx_can_be_explicitly_ignored_when_passive_health_is_on() { + let state = BackendState::new(HealthMode::Off); + let active = settings(HealthMode::Off); + let passive = PassiveHealth { + enabled: true, + count_http_5xx: false, + fall: Some(1), + ..Default::default() + }; + for _ in 0..100 { + state.observe_passive( + &active, + &passive, + "http_5xx", + Duration::from_millis(5), + ); + } + assert!(state.healthy()); + assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 100); + assert_eq!(state.passive_health_failures_total.load(Ordering::Relaxed), 0); + } + + #[test] + fn passive_connect_errors_use_the_explicit_fall_threshold() { + let state = BackendState::new(HealthMode::Off); + let active = settings(HealthMode::Off); + let passive = PassiveHealth { + enabled: true, + count_connect_errors: true, + fall: Some(3), + ..Default::default() + }; + for _ in 0..2 { + state.observe_passive( + &active, + &passive, + "connect_error", + Duration::from_millis(5), + ); + assert!(state.healthy()); + } + state.observe_passive( + &active, + &passive, + "connect_error", + Duration::from_millis(5), + ); + assert!(!state.healthy()); + assert_eq!(state.connect_errors_total.load(Ordering::Relaxed), 3); + assert_eq!(state.passive_health_failures_total.load(Ordering::Relaxed), 3); + } + + #[test] + fn active_health_can_stay_healthy_while_passive_health_is_off() { + let state = BackendState::new(HealthMode::Tcp); + let active = settings(HealthMode::Tcp); + let passive = PassiveHealth::default(); + let fast = Duration::from_millis(5); + state.observe_active(&active, "ok", fast); + state.observe_active(&active, "ok", fast); + assert!(state.healthy()); + for _ in 0..20 { + state.observe_passive(&active, &passive, "http_5xx", fast); + } + assert!(state.healthy()); + assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 20); + assert_eq!(state.passive_health_failures_total.load(Ordering::Relaxed), 0); + } + + #[test] + fn unhealthy_source_hands_off_when_the_original_health_domain_recovers() { + let state = BackendState::new(HealthMode::Tcp); + let active = HealthCheck { + fall: 1, + rise: 1, + ..settings(HealthMode::Tcp) + }; + let passive = PassiveHealth { + enabled: true, + count_http_5xx: true, + fall: Some(1), + rise: Some(1), + ..Default::default() + }; + let fast = Duration::from_millis(5); + + state.observe_active(&active, "ok", fast); + state.observe_active(&active, "connect_error", fast); + { + let health = lock(&state.health); + assert_eq!(health.unhealthy_source, "active_tcp"); + } + state.observe_passive(&active, &passive, "http_5xx", fast); + state.observe_active(&active, "ok", fast); + { + let health = lock(&state.health); + assert!(!health.healthy); + assert_eq!(health.unhealthy_source, "passive_http_5xx"); + assert_eq!(health.unhealthy_reason, "http_5xx"); + } + state.observe_passive(&active, &passive, "ok", fast); + assert!(state.healthy()); + } + + #[test] + fn disabled_checks_never_quarantine_and_probes_are_single_flight() { + let off = BackendState::new(HealthMode::Off); + let settings = settings(HealthMode::Off); + for _ in 0..5 { + off.observe_passive(&settings, &PassiveHealth::default(), "connect_error", Duration::ZERO); + } + assert!(off.healthy()); + assert!(!off.claim_probe(&settings)); + let on = BackendState::new(HealthMode::Http); + let settings = super::tests::settings(HealthMode::Http); + assert!(on.claim_probe(&settings)); + assert!(!on.claim_probe(&settings)); + on.observe_active(&settings, "ok", Duration::ZERO); + assert!(!on.claim_probe(&settings)); + } + #[tokio::test] + async fn weighted_round_robin_distributes_three_to_one_without_body_collection() { + let pool = pool(Algorithm::RoundRobin, StickyMode::Off); + let mut counts = [0, 0]; + for _ in 0..40 { + let selection = select(&pool); + counts[usize::from(selection.lease.member.definition.id == "b")] += 1; + } + assert_eq!(counts, [30, 10]); + assert_eq!(pool.members[0].state.active.load(Ordering::Relaxed), 0); + assert_eq!(pool.members[0].state.requests.load(Ordering::Relaxed), 0); // Mere cache selection isn't origin traffic. + } + #[tokio::test] + async fn least_connections_counts_a_lease_until_its_last_owner_drops() { + let pool = pool(Algorithm::LeastConnections, StickyMode::Off); + let mut held = Vec::new(); + for _ in 0..3 { + let selection = select(&pool); + assert_eq!(selection.lease.member.definition.id, "a"); + held.push(selection.lease); + } + let other = select(&pool); + assert_eq!(other.lease.member.definition.id, "b"); + let clone = held[0].clone(); + held.clear(); + assert_eq!(pool.members[0].state.active.load(Ordering::Relaxed), 1); + drop(clone); + assert_eq!(pool.members[0].state.active.load(Ordering::Relaxed), 0); + } + #[tokio::test] + async fn failover_selection_never_reuses_an_attempted_backend() { + let pool = pool(Algorithm::LeastConnections, StickyMode::Off); + let first = select(&pool); + let first_id = first.lease.member.definition.id.clone(); + drop(first); + let retry = pool + .select_retry( + "127.0.0.1".parse().unwrap(), + &HeaderMap::new(), + false, + std::slice::from_ref(&first_id), + ) + .unwrap(); + assert_ne!(retry.lease.member.definition.id, first_id); + } + #[tokio::test] + async fn backend_concurrency_limit_queues_then_rejects_and_recovers() { + let pool = capacity_pool(1, 10); + let first = select(&pool); + let headers = HeaderMap::new(); + let saturated = pool + .select_wait("127.0.0.1".parse().unwrap(), &headers, false) + .await; + assert!(matches!(saturated, Err(SelectionFailure::Capacity))); + let view = pool.view(); + let member = &view["members"][0]; + assert_eq!(member["capacity_rejections_total"], 1); + assert_eq!(member["queue_timeouts_total"], 1); + assert_eq!(member["active"], 1); + drop(first); + assert!(pool + .select_wait("127.0.0.1".parse().unwrap(), &headers, false) + .await + .is_ok()); + } + + #[tokio::test] + async fn zero_capacity_queue_timeout_rejects_without_counting_a_queue_timeout() { + let pool = capacity_pool(1, 0); + let first = select(&pool); + let headers = HeaderMap::new(); + let saturated = pool + .select_wait("127.0.0.1".parse().unwrap(), &headers, false) + .await; + assert!(matches!(saturated, Err(SelectionFailure::Capacity))); + let view = pool.view(); + let member = &view["members"][0]; + assert_eq!(member["capacity_rejections_total"], 1); + assert_eq!(member["queue_timeouts_total"], 0); + drop(first); + } + + #[test] + fn detailed_backend_failures_are_exposed_without_losing_aggregate_counters() { + let pool = capacity_pool(0, 0); + let selection = select(&pool); + selection + .lease + .error_detail("dns", "lookup backend.example failed", Duration::from_millis(7)); + let view = pool.view(); + let member = &view["members"][0]; + assert_eq!(member["dns_errors_total"], 1); + assert_eq!(member["connect_errors_total"], 1); + assert_eq!(member["last_failure_class"], "dns"); + assert_eq!(member["last_failure_detail"], "lookup backend.example failed"); + assert!(member["last_failure_at_ms"].as_u64().unwrap_or_default() > 0); + } + #[tokio::test] + async fn traffic_and_adaptive_selection_use_observed_cost_and_weight() { + for algorithm in [Algorithm::LeastTraffic, Algorithm::Adaptive] { + let pool = pool(algorithm, StickyMode::Off); + lock(&pool.members[0].state.traffic).rate = 50_000_000.0; + lock(&pool.members[0].state.health).ewma_us = 1_000_000; + assert_eq!(select(&pool).lease.member.definition.id, "b"); + } + } + #[tokio::test] + async fn ip_hash_is_stable_and_cannot_be_spoofed_with_forwarded_headers() { + let pool = pool(Algorithm::IpHash, StickyMode::Off); + let original = select(&pool).lease.member.definition.id.clone(); + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", "10.0.0.99".parse().unwrap()); + for ip in ["127.0.0.1", "::ffff:127.0.0.1"] { + for _ in 0..8 { + assert_eq!( + pool.select(ip.parse().unwrap(), &headers, false) + .unwrap() + .lease + .member + .definition + .id, + original + ); + } + } + } + #[tokio::test] + async fn opaque_cookie_is_removed_from_origin_requests_and_protected_on_responses() { + let pool = pool(Algorithm::RoundRobin, StickyMode::Cookie); + let first = select(&pool); + let cookie = first.cookie.unwrap().to_str().unwrap().to_owned(); + assert!( + cookie.contains("HttpOnly") + && cookie.contains("SameSite=Lax") + && !cookie.contains("Secure") + ); + let cookie = cookie.split(';').next().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("cookie", format!("{cookie}; app=ok").parse().unwrap()); + let next = pool + .select("10.0.0.1".parse().unwrap(), &headers, false) + .unwrap(); + assert_eq!( + next.lease.member.definition.id, + first.lease.member.definition.id + ); + assert!(next.cookie.is_none()); + pool.strip_affinity(&mut headers); + assert_eq!(headers["cookie"], "app=ok"); + headers.append( + "set-cookie", + format!("{}=origin-spoof", pool.cookie_name) + .parse() + .unwrap(), + ); + headers.append("set-cookie", "app=ok".parse().unwrap()); + pool.strip_affinity_response(&mut headers); + assert_eq!(headers.get_all("set-cookie").iter().count(), 1); + assert_eq!(headers["set-cookie"], "app=ok"); + let secure = pool + .select("127.0.0.1".parse().unwrap(), &HeaderMap::new(), true) + .unwrap(); + assert!(secure + .cookie + .unwrap() + .to_str() + .unwrap() + .contains("; Secure")); + } + #[tokio::test] + async fn failed_sticky_member_is_replaced_and_duplicate_cookies_are_ignored() { + let pool = pool(Algorithm::RoundRobin, StickyMode::Cookie); + let first = select(&pool); + let cookie = first + .cookie + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned(); + let mut headers = HeaderMap::new(); + headers.insert("cookie", format!("{cookie}; {cookie}").parse().unwrap()); + assert!(pool.affinity(&headers).is_none()); + headers.insert("cookie", cookie.parse().unwrap()); + lock(&first.lease.member.state.health).healthy = false; + let next = pool + .select("127.0.0.1".parse().unwrap(), &headers, false) + .unwrap(); + assert_ne!( + next.lease.member.definition.id, + first.lease.member.definition.id + ); + assert!(next.cookie.is_some()); + lock(&next.lease.member.state.health).healthy = false; + assert!(pool + .select("127.0.0.1".parse().unwrap(), &headers, false) + .is_none()); + } + #[tokio::test] + async fn slow_start_ramps_weight_and_error_is_counted_once_per_exchange() { + let pool = pool(Algorithm::LeastConnections, StickyMode::Off); + let selected = select(&pool); + let state = &selected.lease.member.state; + assert!(state.weight(100, 100) < 100.0); + assert_eq!(state.weight(100, 0), 100.0); + selected.lease.started(); + selected.lease.bytes(50, true); + selected.lease.bytes(100, false); + selected.lease.error("timeout", Duration::from_secs(1)); + selected.lease.error("body", Duration::from_secs(2)); + selected.lease.retry_attempted(); + selected.lease.retry_succeeded(); + assert_eq!(state.requests.load(Ordering::Relaxed), 1); + assert_eq!(state.errors.load(Ordering::Relaxed), 1); + assert_eq!(state.timeouts_total.load(Ordering::Relaxed), 1); + assert_eq!(state.io_errors_total.load(Ordering::Relaxed), 1); + assert_eq!(state.bytes_in.load(Ordering::Relaxed), 50); + assert_eq!(state.bytes_out.load(Ordering::Relaxed), 100); + assert_eq!(state.retry_attempts_total.load(Ordering::Relaxed), 1); + assert_eq!(state.retry_successes_total.load(Ordering::Relaxed), 1); + assert!(pool + .prometheus() + .contains("rust_proxy_backend_requests_total{vhost=\"app\",backend=\"a\"} 1")); + assert!(pool + .prometheus() + .contains("rust_proxy_backend_retry_attempts_total{vhost=\"app\",backend=\"a\"} 1")); + } + #[tokio::test] + async fn slow_5xx_counts_both_observability_classes_when_passive_is_off() { + let pool = pool(Algorithm::LeastConnections, StickyMode::Off); + let selected = select(&pool); + selected.lease.response(500, Duration::from_secs(20), true); + let state = &selected.lease.member.state; + assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 1); + assert_eq!(state.slow_responses_total.load(Ordering::Relaxed), 1); + assert_eq!(state.passive_health_failures_total.load(Ordering::Relaxed), 0); + assert!(state.healthy()); + } + + #[tokio::test] + async fn client_upload_duration_does_not_trigger_passive_slow_failure() { + let pool = pool(Algorithm::LeastConnections, StickyMode::Off); + let upload = select(&pool); + upload.lease.response(200, Duration::from_secs(20), false); + assert_eq!(upload.lease.member.state.errors.load(Ordering::Relaxed), 0); + let read = select(&pool); + read.lease.response(200, Duration::from_secs(20), true); + assert_eq!(read.lease.member.state.errors.load(Ordering::Relaxed), 1); + } + #[tokio::test] + async fn backup_is_only_new_assignment_when_no_primary_is_eligible() { + let mut pool = pool(Algorithm::RoundRobin, StickyMode::Off); + Arc::get_mut(&mut Arc::get_mut(&mut pool).unwrap().members[1]) + .unwrap() + .definition + .backup = true; + for _ in 0..8 { + assert_eq!(select(&pool).lease.member.definition.id, "a"); + } + lock(&pool.members[0].state.health).healthy = false; + assert_eq!(select(&pool).lease.member.definition.id, "b"); + lock(&pool.members[0].state.health).healthy = true; + assert_eq!(select(&pool).lease.member.definition.id, "a"); + } + #[tokio::test] + async fn drain_rejects_new_sessions_but_preserves_valid_existing_affinity() { + let mut pool = pool(Algorithm::RoundRobin, StickyMode::Cookie); + Arc::get_mut(&mut Arc::get_mut(&mut pool).unwrap().members[0]) + .unwrap() + .definition + .drain = true; + assert_eq!(select(&pool).lease.member.definition.id, "b"); + let mut headers = HeaderMap::new(); + headers.insert( + "cookie", + format!("{}={}", pool.cookie_name, pool.members[0].token) + .parse() + .unwrap(), + ); + let existing = pool + .select("127.0.0.1".parse().unwrap(), &headers, false) + .unwrap(); + assert_eq!(existing.lease.member.definition.id, "a"); + lock(&pool.members[0].state.health).healthy = false; + assert_eq!( + pool.select("127.0.0.1".parse().unwrap(), &headers, false) + .unwrap() + .lease + .member + .definition + .id, + "b" + ); + } +} diff --git a/src/basic_auth.rs b/src/basic_auth.rs new file mode 100644 index 0000000..662aa56 --- /dev/null +++ b/src/basic_auth.rs @@ -0,0 +1,329 @@ +//! Per-vhost RFC 7617 gate. Only password hashes enter desired configuration. +//! ACLs run first; credentials are never cached with content or written to logs. +use anyhow::{ensure, Result}; +use argon2::{ + password_hash::{PasswordHash, PasswordVerifier}, + Argon2, +}; +use axum::response::{IntoResponse, Response}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use http::{HeaderMap, HeaderValue, StatusCode}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + net::IpAddr, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; +use tokio::sync::Semaphore; +use zeroize::Zeroizing; + +fn realm() -> String { + "Restricted".into() +} +fn yes() -> bool { + true +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + pub enabled: bool, + pub realm: String, + pub require_tls: bool, + pub forward_credentials: bool, + pub users: Vec, +} +impl Default for Settings { + fn default() -> Self { + Self { + enabled: false, + realm: realm(), + require_tls: yes(), + forward_credentials: false, + users: vec![], + } + } +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct User { + pub username: String, + pub password_hash: String, +} +impl std::fmt::Debug for User { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("User") + .field("username", &self.username) + .finish_non_exhaustive() + } +} +#[derive(Debug, Clone)] +pub struct Compiled { + settings: Settings, + challenge: HeaderValue, +} +impl Settings { + pub fn compile(&self) -> Result> { + ensure!( + !self.realm.is_empty() + && self.realm.len() <= 128 + && self + .realm + .bytes() + .all(|c| c >= 32 && c < 127 && c != b'"' && c != b'\\'), + "basic.invalid_realm" + ); + ensure!(self.users.len() <= 64, "basic.too_many_users"); + let mut names = std::collections::HashSet::new(); + for user in &self.users { + validate_username(&user.username)?; + ensure!(names.insert(&user.username), "basic.duplicate_username"); + // Bound KDF cost before accepting imported hashes. No arbitrary PHC parameters. + ensure!( + user.password_hash.len() <= 256 + && user + .password_hash + .starts_with("$argon2id$v=19$m=19456,t=2,p=1$"), + "basic.unsupported_hash" + ); + let hash = PasswordHash::new(&user.password_hash) + .map_err(|_| anyhow::anyhow!("basic.invalid_hash"))?; + ensure!( + hash.salt.is_some_and(|s| s.as_str().len() >= 16) + && hash.hash.is_some_and(|h| h.len() == 32), + "basic.invalid_hash" + ); + } + if !self.enabled { + return Ok(None); + } + ensure!(!self.users.is_empty(), "basic.users_required"); + Ok(Some(Compiled { + settings: self.clone(), + challenge: format!("Basic realm=\"{}\", charset=\"UTF-8\"", self.realm).parse()?, + })) + } +} +pub fn validate_username(name: &str) -> Result<()> { + ensure!( + !name.is_empty() + && name.len() <= 128 + && !name.contains(':') + && !name.chars().any(char::is_control), + "basic.invalid_username" + ); + Ok(()) +} +#[derive(Default)] +pub struct Limits { + failures: Mutex>, +} +impl Limits { + fn blocked(&self, ip: IpAddr) -> bool { + let mut map = self.failures.lock().unwrap_or_else(|e| e.into_inner()); + map.retain(|_, (time, _)| time.elapsed() < Duration::from_secs(60)); + map.get(&ip).is_some_and(|(_, count)| *count >= 10) + || (map.len() >= 4096 && !map.contains_key(&ip)) + } + fn failed(&self, ip: IpAddr) { + let mut map = self.failures.lock().unwrap_or_else(|e| e.into_inner()); + map.retain(|_, (time, _)| time.elapsed() < Duration::from_secs(60)); + if map.len() < 4096 || map.contains_key(&ip) { + let entry = map.entry(ip).or_insert((Instant::now(), 0)); + entry.1 = entry.1.saturating_add(1); + } + } +} +fn denied(status: StatusCode, challenge: Option<&HeaderValue>) -> Response { + let mut response = (status, "RustProxy: authentication required\n").into_response(); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("private, no-store"), + ); + if let Some(challenge) = challenge { + response + .headers_mut() + .insert("www-authenticate", challenge.clone()); + } + if status == StatusCode::TOO_MANY_REQUESTS { + response + .headers_mut() + .insert("retry-after", HeaderValue::from_static("60")); + } + // Custom error pages must not turn an authentication failure into success. + response + .extensions_mut() + .insert(crate::tinyauth::AuthenticationFailure); + response +} +fn credentials(headers: &HeaderMap) -> Option<(String, Zeroizing, HeaderValue)> { + if headers.get_all("authorization").iter().count() != 1 { + return None; + } + let value = headers.get("authorization")?; + let raw = value.to_str().ok()?; + if raw.len() > 1024 { + return None; + } + let (scheme, token) = raw.split_once(' ')?; + if !scheme.eq_ignore_ascii_case("basic") + || token.is_empty() + || token.contains(char::is_whitespace) + { + return None; + } + let bytes = Zeroizing::new(STANDARD.decode(token).ok()?); + let decoded = std::str::from_utf8(&bytes).ok()?; + let (name, password) = decoded.split_once(':')?; + if validate_username(name).is_err() + || password.len() > 128 + || password.chars().any(char::is_control) + { + return None; + } + Some(( + name.to_owned(), + Zeroizing::new(password.to_owned()), + value.clone(), + )) +} +impl Compiled { + pub async fn authorize( + &self, + headers: &mut HeaderMap, + tls: bool, + ip: IpAddr, + workers: Arc, + limits: &Limits, + ) -> Result, Response> { + let input = credentials(headers); + headers.remove("authorization"); + headers.remove("remote-user"); + headers.remove("remote-email"); + if self.settings.require_tls && !tls { + return Err(denied(StatusCode::FORBIDDEN, None)); + } + if limits.blocked(ip) { + return Err(denied(StatusCode::TOO_MANY_REQUESTS, None)); + } + let Some((name, password, original)) = input else { + // A browser's first request normally has no Authorization; do not count that as a bad password. + return Err(denied(StatusCode::UNAUTHORIZED, Some(&self.challenge))); + }; + let found = self.settings.users.iter().find(|u| u.username == name); + let known = found.is_some(); + let encoded = found + .unwrap_or(&self.settings.users[0]) + .password_hash + .clone(); + let permit = workers + .try_acquire_owned() + .map_err(|_| denied(StatusCode::SERVICE_UNAVAILABLE, None))?; + let verified = tokio::task::spawn_blocking(move || { + let _permit = permit; + PasswordHash::new(&encoded).ok().is_some_and(|hash| { + Argon2::default() + .verify_password(password.as_bytes(), &hash) + .is_ok() + }) + }) + .await + .unwrap_or(false); + if !known || !verified { + limits.failed(ip); + return Err(denied(StatusCode::UNAUTHORIZED, Some(&self.challenge))); + } + Ok(self.settings.forward_credentials.then_some(original)) + } + pub fn apply_upstream(&self, headers: &mut HeaderMap, verified: Option) { + headers.remove("authorization"); + headers.remove("remote-user"); + headers.remove("remote-email"); + if self.settings.forward_credentials { + if let Some(value) = verified { + headers.insert("authorization", value); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rejects_ambiguous_headers_and_injection() { + let mut h = HeaderMap::new(); + h.insert( + "authorization", + HeaderValue::from_static("Basic dXNlcjpwYXNz"), + ); + assert_eq!(credentials(&h).unwrap().0, "user"); + h.append( + "authorization", + HeaderValue::from_static("Basic dXNlcjpwYXNz"), + ); + assert!(credentials(&h).is_none()); + assert!(Settings { + realm: "bad\r\nheader".into(), + ..Default::default() + } + .compile() + .is_err()); + assert!(Settings { + enabled: true, + ..Default::default() + } + .compile() + .is_err()); + } + #[tokio::test] + async fn validates_and_strips_or_forwards_only_verified_credentials() { + let hash = crate::auth::hash_password_offline("sufficiently-long-password".into()) + .await + .unwrap(); + let mut settings = Settings { + enabled: true, + users: vec![User { + username: "alice".into(), + password_hash: hash, + }], + ..Default::default() + }; + for forward in [false, true] { + settings.forward_credentials = forward; + let auth = settings.compile().unwrap().unwrap(); + let mut h = HeaderMap::new(); + let raw = HeaderValue::from_str(&format!( + "Basic {}", + STANDARD.encode("alice:sufficiently-long-password") + )) + .unwrap(); + h.insert("authorization", raw.clone()); + h.insert("remote-user", HeaderValue::from_static("spoof")); + let verified = auth + .authorize( + &mut h, + true, + "127.0.0.1".parse().unwrap(), + Arc::new(Semaphore::new(2)), + &Limits::default(), + ) + .await + .unwrap(); + assert!(!h.contains_key("authorization")); + auth.apply_upstream(&mut h, verified); + assert_eq!(h.get("authorization"), forward.then_some(&raw)); + assert!(!h.contains_key("remote-user")); + } + } + #[test] + fn bounds_ip_limiter() { + let limits = Limits::default(); + let ip = "127.0.0.1".parse().unwrap(); + for _ in 0..10 { + limits.failed(ip); + } + assert!(limits.blocked(ip)); + assert!(!limits.blocked("127.0.0.2".parse().unwrap())); + } +} diff --git a/src/cache/cold.rs b/src/cache/cold.rs new file mode 100644 index 0000000..143f9a8 --- /dev/null +++ b/src/cache/cold.rs @@ -0,0 +1,974 @@ +impl Cache { + fn cold_root_for(&self, settings: &Settings) -> Option { + settings + .cold_enabled + .then(|| { + self.data_root + .as_ref() + .map(|root| root.join(&settings.cold_path)) + }) + .flatten() + } + fn load_cold_startup(&self) { + let settings = self + .tier_settings + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + let Some(root) = self.cold_root_for(&settings) else { + return; + }; + let revision = self + .store + .lock() + .unwrap_or_else(|e| e.into_inner()) + .revision; + let Ok(read_dir) = std::fs::read_dir(&root) else { + return; + }; + + // v0.21 intentionally does not migrate the legacy flat cold-cache layout. + // Remove only known cache artifacts from the old root and rebuild naturally. + let mut shard_dirs = Vec::new(); + for item in read_dir.flatten() { + let path = item.path(); + if path.is_file() { + let extension = path.extension().and_then(|v| v.to_str()).unwrap_or_default(); + if matches!(extension, "json" | "body" | "tmp") { + let _ = std::fs::remove_file(path); + } + continue; + } + let Some(name) = path.file_name().and_then(|v| v.to_str()) else { + continue; + }; + if name.len() == 2 && name.bytes().all(|b| b.is_ascii_hexdigit()) { + shard_dirs.push(path); + } + } + + let mut loaded = Vec::new(); + for shard in shard_dirs { + let Some(shard_name) = shard.file_name().and_then(|v| v.to_str()) else { + continue; + }; + let Ok(items) = std::fs::read_dir(&shard) else { + continue; + }; + for item in items.flatten() { + let path = item.path(); + if path.extension().and_then(|v| v.to_str()) != Some("json") { + continue; + } + let Ok(bytes) = std::fs::read(&path) else { + self.counters.cold_errors.fetch_add(1, Ordering::Relaxed); + continue; + }; + let Ok(file) = serde_json::from_slice::(&bytes) else { + self.counters.cold_errors.fetch_add(1, Ordering::Relaxed); + continue; + }; + let body_path = shard.join(&file.body_file); + if file.format != COLD_FORMAT_VERSION || file.revision != revision { + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&body_path); + continue; + } + let Some(key) = parse_key_hex(&file.key) else { + continue; + }; + if !file.key[..2].eq_ignore_ascii_case(shard_name) { + continue; + } + let Some(base_key) = parse_key_hex(&file.base_key) else { + continue; + }; + let mut vary = file.vary.clone(); + if vary.len() > 64 + || vary + .iter() + .any(|name| name.parse::().is_err()) + { + continue; + } + vary.sort(); + vary.dedup(); + if path.file_stem().and_then(|v| v.to_str()) != Some(file.key.as_str()) { + continue; + } + let Some(headers) = pairs_to_headers(&file.headers) else { + continue; + }; + if body_path.parent() != Some(shard.as_path()) { + continue; + } + let Ok(body_meta) = std::fs::metadata(&body_path) else { + continue; + }; + if body_meta.len() != file.body_bytes as u64 + || file.body_sha256.len() != 64 + || !file.body_sha256.bytes().all(|b| b.is_ascii_hexdigit()) + { + continue; + } + let origin_etag = file + .origin_etag + .as_deref() + .and_then(|v| HeaderValue::from_str(v).ok()); + let origin_last_modified = file + .origin_last_modified + .as_deref() + .and_then(|v| HeaderValue::from_str(v).ok()); + let stored_at = system_time_ms(file.stored_at_ms); + let current_age = file + .age_at_store_seconds + .saturating_add(stored_at.elapsed().unwrap_or_default().as_secs()); + if current_age + >= retention_deadline( + file.ttl, + file.stale_while_revalidate, + file.stale_if_error, + origin_etag.is_some() || origin_last_modified.is_some(), + ) + { + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&body_path); + continue; + } + loaded.push(ColdEntry { + key, + base_key, + vary, + revision, + scope: file.scope, + scheme: file.scheme, + authority: if file.authority.is_empty() { file.domain.clone() } else { file.authority }, + domain: file.domain, + path: file.path.clone(), + target: if file.target.is_empty() { file.path } else { file.target }, + tags: file.tags, + created_at: if file.created_at_ms == 0 { stored_at } else { system_time_ms(file.created_at_ms) }, + headers, + body_path, + meta_path: path, + body_bytes: file.body_bytes, + body_sha256: file.body_sha256, + disk_bytes: body_meta.len().saturating_add(bytes.len() as u64), + stored_at, + last_access: if file.last_access_ms == 0 { + stored_at + } else { + system_time_ms(file.last_access_ms) + }, + age_at_store_seconds: file.age_at_store_seconds, + resident_age_at_store_seconds: file.resident_age_at_store_seconds, + ttl: file.ttl, + stale_while_revalidate: file.stale_while_revalidate, + stale_if_error: file.stale_if_error, + must_revalidate: file.must_revalidate, + proxy_revalidate: file.proxy_revalidate, + origin_etag, + origin_last_modified, + hits: file.hits, + transformed: file.transformed, + sequence: 0, + generation: 0, + skip_compression: file.skip_compression, + requires_revalidation: file.requires_revalidation, + pinned_reads: 0, + }); + } + } + loaded.sort_by_key(|entry| entry.last_access); + let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + for mut entry in loaded { + for key in incompatible_cold_variants(&cold, &entry.base_key, &entry.vary) { + if let Some(old) = cold.remove(&key) { + let _ = std::fs::remove_file(old.meta_path); + let _ = std::fs::remove_file(old.body_path); + } + } + while cold.entries.len() >= settings.cold_max_entries + || cold.bytes.saturating_add(entry.disk_bytes) > settings.cold_max_bytes + { + let Some((_, key)) = cold.lru.first_key_value().map(|(seq, key)| (*seq, *key)) + else { + break; + }; + if let Some(old) = cold.remove(&key) { + let _ = std::fs::remove_file(old.meta_path); + let _ = std::fs::remove_file(old.body_path); + } + } + entry.sequence = cold.next_sequence(); + entry.generation = entry.sequence; + cold.bytes = cold.bytes.saturating_add(entry.disk_bytes); + cold.lru.insert(entry.sequence, entry.key); + cold.index_variant(entry.base_key, entry.key); + cold.entries.insert(entry.key, entry); + } + } + async fn handle_cold_command(&self, command: ColdCommand) { + match command { + ColdCommand::Store(draft) => { + let bytes = draft.entry.cost as u64; + self.cold_queue_items.fetch_sub(1, Ordering::Relaxed); + self.cold_queue_bytes.fetch_sub(bytes, Ordering::Relaxed); + self.persist_cold(draft).await; + } + ColdCommand::Delete(paths) => { + for path in paths { + let _ = tokio::fs::remove_file(path).await; + } + } + ColdCommand::PurgeDirectory(path) => { + if let Err(error) = tokio::fs::remove_dir_all(&path).await { + if error.kind() != std::io::ErrorKind::NotFound { + self.counters.cold_errors.fetch_add(1, Ordering::Relaxed); + } + } + } + } + } + async fn cold_worker( + weak: std::sync::Weak, + mut rx: mpsc::Receiver, + shutdown: CancellationToken, + ) { + loop { + let command = tokio::select! { + _ = shutdown.cancelled() => { + // The listener shutdown token is shared with the cache. Drain + // already accepted demotions before exiting so a restart does + // not discard buffers that were already handed to cold storage. + rx.close(); + while let Ok(command) = rx.try_recv() { + let Some(cache) = weak.upgrade() else { return; }; + cache.handle_cold_command(command).await; + } + break; + }, + command = rx.recv() => command, + }; + let Some(command) = command else { + break; + }; + let Some(cache) = weak.upgrade() else { + break; + }; + cache.handle_cold_command(command).await; + } + } + async fn persist_cold(&self, draft: ColdDraft) { + let bytes = draft.entry.cost as u64; + self.cold_write_bytes_active + .fetch_add(bytes, Ordering::Relaxed); + self.persist_cold_inner(draft).await; + self.cold_write_bytes_active + .fetch_sub(bytes, Ordering::Relaxed); + self.cold_gc_pass().await; + // The draft body is out of scope before trimming. `Bytes` has released its + // reference and glibc is explicitly asked to return releasable pages. + release_allocator_memory(); + } + async fn persist_cold_inner(&self, draft: ColdDraft) { + let current = { + let store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + store.revision == draft.revision + && store.generation == draft.generation + && !store.entries.contains_key(&draft.key) + && incompatible_hot_variants(&store, &draft.entry.base_key, &draft.entry.vary) + .is_empty() + && store + .scope_generations + .get(&draft.entry.scope) + .copied() + .unwrap_or(0) + == draft.scope_generation + && store + .key_generations + .get(&draft.entry.base_key) + .copied() + .unwrap_or(0) + == draft.key_generation + }; + if !current { + return; + } + let Some(headers) = cold_header_pairs(&draft.entry.headers) else { + self.counters.cold_errors.fetch_add(1, Ordering::Relaxed); + return; + }; + let settings = self + .tier_settings + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + if !settings.cold_enabled || self.cold_root_for(&settings).as_ref() != Some(&draft.root) { + return; + } + let key = key_hex(&draft.key); + let body_name = format!("{key}.body"); + let (body_path, meta_path, body_tmp, meta_tmp) = cold_paths(&draft.root, &draft.key); + let Some(shard) = body_path.parent() else { return; }; + if tokio::fs::create_dir_all(shard).await.is_err() { + self.counters.cold_errors.fetch_add(1, Ordering::Relaxed); + return; + } + let age_at_store = draft + .entry + .initial_age + .saturating_add(draft.entry.received.elapsed()) + .as_secs(); + let resident_age_at_store = hot_resident_age(&draft.entry).as_secs(); + let file = ColdFile { + format: COLD_FORMAT_VERSION, + key: key.clone(), + base_key: key_hex(&draft.entry.base_key), + vary: draft.entry.vary.clone(), + revision: draft.revision, + scope: draft.entry.scope.clone(), + scheme: draft.entry.scheme.clone(), + authority: draft.entry.authority.clone(), + domain: draft.entry.domain.clone(), + path: draft.entry.path.clone(), + target: draft.entry.target.clone(), + tags: draft.entry.tags.clone(), + created_at_ms: draft.entry.created_at.duration_since(UNIX_EPOCH).unwrap_or_default().as_millis().min(u64::MAX as u128) as u64, + headers, + body_file: body_name, + body_bytes: draft.entry.body.len(), + body_sha256: body_sha256(&draft.entry.body), + stored_at_ms: now_ms(), + age_at_store_seconds: age_at_store, + resident_age_at_store_seconds: resident_age_at_store, + ttl: draft.entry.ttl, + stale_while_revalidate: draft.entry.stale_while_revalidate, + stale_if_error: draft.entry.stale_if_error, + must_revalidate: draft.entry.must_revalidate, + proxy_revalidate: draft.entry.proxy_revalidate, + origin_etag: draft.entry.origin_etag.as_ref().and_then(|v| v.to_str().ok()).map(str::to_owned), + origin_last_modified: draft.entry.origin_last_modified.as_ref().and_then(|v| v.to_str().ok()).map(str::to_owned), + last_access_ms: now_ms(), + hits: draft.entry.hits, + transformed: draft.entry.transformed, + skip_compression: draft.entry.skip_compression, + requires_revalidation: draft.entry.requires_revalidation, + }; + let Ok(metadata) = serde_json::to_vec_pretty(&file) else { + self.counters.cold_errors.fetch_add(1, Ordering::Relaxed); + return; + }; + let write_ok = tokio::fs::write(&body_tmp, &draft.entry.body).await.is_ok() + && tokio::fs::write(&meta_tmp, &metadata).await.is_ok() + && tokio::fs::rename(&body_tmp, &body_path).await.is_ok() + && tokio::fs::rename(&meta_tmp, &meta_path).await.is_ok(); + if !write_ok { + let _ = tokio::fs::remove_file(&body_tmp).await; + let _ = tokio::fs::remove_file(&meta_tmp).await; + self.counters.cold_errors.fetch_add(1, Ordering::Relaxed); + return; + } + let still_current = { + let store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + store.revision == draft.revision + && store.generation == draft.generation + && !store.entries.contains_key(&draft.key) + && incompatible_hot_variants(&store, &draft.entry.base_key, &draft.entry.vary) + .is_empty() + && store + .scope_generations + .get(&draft.entry.scope) + .copied() + .unwrap_or(0) + == draft.scope_generation + && store + .key_generations + .get(&draft.entry.base_key) + .copied() + .unwrap_or(0) + == draft.key_generation + }; + if !still_current { + let _ = tokio::fs::remove_file(&body_path).await; + let _ = tokio::fs::remove_file(&meta_path).await; + return; + } + let disk_bytes = draft.entry.body.len() as u64 + metadata.len() as u64; + let mut delete = Vec::new(); + { + let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + for key in incompatible_cold_variants(&cold, &draft.entry.base_key, &draft.entry.vary) { + if let Some(old) = cold.remove(&key) { + delete.extend([old.meta_path, old.body_path]); + } + } + if let Some(old) = cold.remove(&draft.key) { + if old.meta_path != meta_path { + delete.push(old.meta_path); + } + if old.body_path != body_path { + delete.push(old.body_path); + } + } + while cold.entries.len() >= settings.cold_max_entries + || cold.bytes.saturating_add(disk_bytes) > settings.cold_max_bytes + { + let Some((_, key)) = cold.lru.first_key_value().map(|(seq, key)| (*seq, *key)) + else { + break; + }; + if let Some(old) = cold.remove(&key) { + delete.extend([old.meta_path, old.body_path]); + self.counters.cold_evictions.fetch_add(1, Ordering::Relaxed); + } + } + if disk_bytes > settings.cold_max_bytes { + delete.extend([meta_path.clone(), body_path.clone()]); + } else { + let sequence = cold.next_sequence(); + let entry = ColdEntry { + key: draft.key, + base_key: draft.entry.base_key, + vary: draft.entry.vary, + revision: draft.revision, + scope: draft.entry.scope, + scheme: draft.entry.scheme, + authority: draft.entry.authority, + domain: draft.entry.domain, + path: draft.entry.path, + target: draft.entry.target, + tags: draft.entry.tags, + created_at: draft.entry.created_at, + headers: draft.entry.headers, + body_path: body_path.clone(), + meta_path: meta_path.clone(), + body_bytes: draft.entry.body.len(), + body_sha256: file.body_sha256, + disk_bytes, + stored_at: SystemTime::now(), + age_at_store_seconds: age_at_store, + resident_age_at_store_seconds: resident_age_at_store, + ttl: draft.entry.ttl, + stale_while_revalidate: draft.entry.stale_while_revalidate, + stale_if_error: draft.entry.stale_if_error, + must_revalidate: draft.entry.must_revalidate, + proxy_revalidate: draft.entry.proxy_revalidate, + origin_etag: draft.entry.origin_etag, + origin_last_modified: draft.entry.origin_last_modified, + last_access: SystemTime::now(), + hits: draft.entry.hits, + transformed: draft.entry.transformed, + sequence, + generation: sequence, + skip_compression: draft.entry.skip_compression, + requires_revalidation: draft.entry.requires_revalidation, + pinned_reads: 0, + }; + cold.bytes = cold.bytes.saturating_add(disk_bytes); + cold.lru.insert(sequence, draft.key); + cold.index_variant(entry.base_key, draft.key); + cold.entries.insert(draft.key, entry); + self.counters.hot_demotions.fetch_add(1, Ordering::Relaxed); + } + } + for path in delete { + let _ = tokio::fs::remove_file(path).await; + } + } + fn enqueue_demotion( + &self, + key: Key, + entry: Entry, + revision: u64, + generation: u64, + scope_generation: u64, + key_generation: u64, + ) -> bool { + let settings = self + .tier_settings + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + let Some(root) = self.cold_root_for(&settings) else { + return false; + }; + let Some(tx) = &self.cold_tx else { + return false; + }; + let queued_bytes = entry.cost as u64; + let queue_limit = u64::try_from(settings.cold_queue_max_bytes).unwrap_or(u64::MAX); + loop { + let current = self.cold_queue_bytes.load(Ordering::Relaxed); + let Some(next) = current.checked_add(queued_bytes) else { + self.counters + .cold_queue_dropped + .fetch_add(1, Ordering::Relaxed); + return false; + }; + if next > queue_limit { + self.counters + .cold_queue_dropped + .fetch_add(1, Ordering::Relaxed); + return false; + } + if self + .cold_queue_bytes + .compare_exchange_weak(current, next, Ordering::AcqRel, Ordering::Relaxed) + .is_ok() + { + break; + } + } + let Some(memory_lease) = self.try_reserve_transient(entry.cost, &settings) else { + self.cold_queue_bytes + .fetch_sub(queued_bytes, Ordering::Relaxed); + self.counters + .cold_queue_dropped + .fetch_add(1, Ordering::Relaxed); + return false; + }; + self.cold_queue_items.fetch_add(1, Ordering::Relaxed); + if tx + .try_send(ColdCommand::Store(ColdDraft { + key, + revision, + generation, + scope_generation, + key_generation, + entry, + root, + _memory_lease: memory_lease, + })) + .is_err() + { + self.cold_queue_items.fetch_sub(1, Ordering::Relaxed); + self.cold_queue_bytes + .fetch_sub(queued_bytes, Ordering::Relaxed); + self.counters + .cold_queue_dropped + .fetch_add(1, Ordering::Relaxed); + return false; + } + true + } + fn demote_idle(&self) { + let settings = self + .tier_settings + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + if !settings.cold_enabled + || (!settings.cold_on_idle && !settings.cold_on_age) + || self.cold_root_for(&settings).is_none() + { + return; + } + let available = self.cold_tx.as_ref().map(|tx| tx.capacity()).unwrap_or(0); + if available == 0 { + return; + } + let idle_cutoff = Duration::from_secs(settings.cold_after_seconds); + let age_cutoff = Duration::from_secs(settings.cold_max_hot_seconds); + let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + let revision = store.revision; + let generation = store.generation; + let keys: Vec<_> = store + .entries + .iter() + .filter(|(_, entry)| { + (settings.cold_on_idle && entry.last_access.elapsed() >= idle_cutoff) + || (settings.cold_on_age && entry.resident_since.elapsed() >= age_cutoff) + }) + .map(|(key, _)| *key) + .take(available) + .collect(); + for key in keys { + let Some(entry) = store.remove(&key) else { + continue; + }; + let scope_generation = store + .scope_generations + .get(&entry.scope) + .copied() + .unwrap_or(0); + let key_generation = store.key_generations.get(&entry.base_key).copied().unwrap_or(0); + if !self.enqueue_demotion(key, entry, revision, generation, scope_generation, key_generation) { + self.counters.evictions.fetch_add(1, Ordering::Relaxed); + } + } + drop(store); + release_allocator_memory(); + } + /// Best-effort graceful shutdown handoff from RAM to the persistent cold + /// tier. The cold worker shares the process shutdown token, so this path + /// writes directly instead of depending on the background queue. + async fn flush_hot_to_cold_inner(&self, force_shutdown_policy: bool) -> usize { + let settings = self + .tier_settings + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + let Some(root) = self.cold_root_for(&settings) else { + return 0; + }; + if !force_shutdown_policy && !settings.cold_on_shutdown { + return 0; + } + let mut moved = 0usize; + loop { + let draft = { + let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + let revision = store.revision; + let generation = store.generation; + let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) + else { + break; + }; + let Some(entry) = store.remove(&key) else { + continue; + }; + let scope_generation = store + .scope_generations + .get(&entry.scope) + .copied() + .unwrap_or(0); + let key_generation = store.key_generations.get(&entry.base_key).copied().unwrap_or(0); + let bytes = entry.cost; + let memory_lease = self.try_reserve_transient(bytes, &settings).unwrap_or_else(|| { + let bytes = bytes as u64; + self.transient_bytes_active.fetch_add(bytes, Ordering::Relaxed); + TransientLease(Arc::new(TransientLeaseInner { + counter: self.transient_bytes_active.clone(), + bytes, + })) + }); + ColdDraft { + key, + revision, + generation, + scope_generation, + key_generation, + entry, + root: root.clone(), + _memory_lease: memory_lease, + } + }; + self.persist_cold_inner(draft).await; + moved += 1; + } + release_allocator_memory(); + moved + } + pub async fn flush_hot_to_cold(&self) -> usize { + self.flush_hot_to_cold_inner(false).await + } + /// Managed software updates explicitly request a hot-to-cold handoff. The + /// cold tier must still be enabled, but the ordinary cold_on_shutdown toggle + /// does not disable update preservation. + pub async fn flush_hot_to_cold_for_update(&self) -> usize { + self.flush_hot_to_cold_inner(true).await + } + async fn sweep_cold(&self) { + const BATCH: usize = 256; + let mut after_sequence = 0u64; + loop { + let batch: Vec<_> = { + let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + cold.lru + .range(( + std::ops::Bound::Excluded(after_sequence), + std::ops::Bound::Unbounded, + )) + .take(BATCH) + .map(|(sequence, key)| (*sequence, *key)) + .collect() + }; + if batch.is_empty() { + break; + } + after_sequence = batch.last().map(|value| value.0).unwrap_or(after_sequence); + let mut delete = Vec::new(); + let mut expired = 0u64; + { + let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + for (_, key) in &batch { + let should_remove = cold.entries.get(key).is_some_and(|entry| { + entry.pinned_reads == 0 + && cold_age(entry) + >= retention_deadline( + entry.ttl, + entry.stale_while_revalidate, + entry.stale_if_error, + entry.origin_etag.is_some() + || entry.origin_last_modified.is_some(), + ) + }); + if should_remove { + if let Some(entry) = cold.remove(key) { + delete.extend([entry.meta_path, entry.body_path]); + expired = expired.saturating_add(1); + } + } + } + } + if expired > 0 { + self.counters.expired.fetch_add(expired, Ordering::Relaxed); + } + self.enqueue_cold_delete(delete); + tokio::task::yield_now().await; + } + } + + fn enqueue_cold_delete(&self, paths: Vec) { + if paths.is_empty() { + return; + } + if let Some(tx) = &self.cold_tx { + if tx.try_send(ColdCommand::Delete(paths.clone())).is_ok() { + return; + } + } + // Queue saturation must not block request processing. The index is already + // updated; a best-effort detached delete keeps physical cleanup asynchronous. + tokio::spawn(async move { + for path in paths { + let _ = tokio::fs::remove_file(path).await; + } + }); + } + + async fn remove_cold_candidates( + &self, + request: Option<&ColdCleanupRequest>, + target_bytes: Option, + target_entries: Option, + max_delete: usize, + ) -> ColdCleanupResult { + const SNAPSHOT_BATCH: usize = 256; + const DELETE_BATCH: usize = 32; + + let now = SystemTime::now(); + let mut candidates = Vec::new(); + let mut after_sequence = 0u64; + + // Copy candidate metadata in bounded chunks. Request-path lookups only + // contend with this mutex for a small snapshot, never for sorting or I/O. + loop { + let batch: Vec<_> = { + let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + cold.lru + .range((std::ops::Bound::Excluded(after_sequence), std::ops::Bound::Unbounded)) + .take(SNAPSHOT_BATCH) + .filter_map(|(sequence, key)| { + cold.entries.get(key).map(|entry| { + ( + *sequence, + *key, + entry.hits, + entry.last_access, + entry.stored_at, + ) + }) + }) + .collect() + }; + if batch.is_empty() { + break; + } + after_sequence = batch.last().map(|value| value.0).unwrap_or(after_sequence); + for (sequence, key, hits, last_access, stored_at) in batch { + let matches = request.is_none_or(|request| { + (!request.hits_zero || hits == 0) + && request.unused_for_seconds.is_none_or(|seconds| { + now.duration_since(last_access) + .unwrap_or_default() + .as_secs() + >= seconds + }) + }); + if matches { + candidates.push((sequence, key, hits, last_access, stored_at)); + } + } + tokio::task::yield_now().await; + } + + if request.is_some() { + candidates.sort_by_key(|(_, _, hits, last_access, stored_at)| { + (*stored_at, *last_access, *hits) + }); + } else { + // Automatic GC prefers rarely used, least recently used, oldest data. + candidates.sort_by_key(|(_, _, hits, last_access, stored_at)| { + (*hits, *last_access, *stored_at) + }); + } + + let requested_limit = request + .and_then(|value| value.oldest) + .unwrap_or(max_delete) + .min(max_delete); + let mut result = ColdCleanupResult::default(); + let mut delete = Vec::new(); + + for chunk in candidates.chunks(DELETE_BATCH) { + if result.removed_entries >= requested_limit { + break; + } + { + let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + for (sequence, key, _, _, _) in chunk { + if result.removed_entries >= requested_limit { + break; + } + if let Some(target) = target_bytes { + if cold.bytes <= target + && target_entries.is_none_or(|entries| cold.entries.len() <= entries) + { + break; + } + } else if let Some(entries) = target_entries { + if cold.entries.len() <= entries { + break; + } + } + if !cold + .entries + .get(key) + .is_some_and(|entry| entry.sequence == *sequence && entry.pinned_reads == 0) + { + continue; + } + if let Some(entry) = cold.remove(key) { + result.removed_entries += 1; + result.freed_bytes = result.freed_bytes.saturating_add(entry.disk_bytes); + delete.extend([entry.meta_path, entry.body_path]); + } + } + } + tokio::task::yield_now().await; + } + + if result.removed_entries > 0 { + self.counters + .cold_evictions + .fetch_add(result.removed_entries as u64, Ordering::Relaxed); + } + self.enqueue_cold_delete(delete); + result + } + + async fn cold_gc_pass(&self) -> ColdCleanupResult { + let settings = self + .tier_settings + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + if !settings.cold_enabled { + return ColdCleanupResult::default(); + } + let (bytes, entries) = { + let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + (cold.bytes, cold.entries.len()) + }; + let high_bytes = settings.cold_max_bytes.saturating_mul(90) / 100; + let target_bytes = settings.cold_max_bytes.saturating_mul(80) / 100; + let high_entries = settings + .cold_max_entries + .saturating_mul(90) + .saturating_add(99) + / 100; + let target_entries = settings.cold_max_entries.saturating_mul(80) / 100; + if bytes < high_bytes && entries < high_entries { + return ColdCleanupResult::default(); + } + self.remove_cold_candidates( + None, + Some(target_bytes), + Some(target_entries), + settings.cold_gc_max_delete, + ) + .await + } + + pub fn start_cold_cleanup( + self: &Arc, + request: ColdCleanupRequest, + ) -> Result { + request.validate()?; + let settings = self + .tier_settings + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + ensure!(settings.cold_enabled, "cold cache is disabled"); + let id = self.cleanup_sequence.fetch_add(1, Ordering::Relaxed).saturating_add(1); + let job = ColdCleanupJob { + id, + status: "queued", + removed_entries: 0, + freed_bytes: 0, + error: None, + }; + { + let mut jobs = self.cleanup_jobs.lock().unwrap_or_else(|e| e.into_inner()); + if jobs.len() >= 64 { + let mut ids: Vec<_> = jobs.keys().copied().collect(); + ids.sort_unstable(); + for old in ids.into_iter().take(jobs.len().saturating_sub(63)) { + jobs.remove(&old); + } + } + jobs.insert(id, job.clone()); + } + let cache = self.clone(); + tokio::spawn(async move { + { + let mut jobs = cache.cleanup_jobs.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(job) = jobs.get_mut(&id) { + job.status = "running"; + } + } + let settings = cache + .tier_settings + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + let result = cache + .remove_cold_candidates( + Some(&request), + None, + None, + settings.cold_gc_max_delete, + ) + .await; + let mut jobs = cache.cleanup_jobs.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(job) = jobs.get_mut(&id) { + job.status = "done"; + job.removed_entries = result.removed_entries; + job.freed_bytes = result.freed_bytes; + } + }); + Ok(job) + } + + pub fn cold_cleanup_job(&self, id: u64) -> Option { + self.cleanup_jobs + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&id) + .cloned() + } + fn take_cold(&self, key: &Key) -> Option { + self.cold + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(key) + } + fn delete_cold_entry(&self, entry: ColdEntry) { + if let Some(tx) = &self.cold_tx { + let _ = tx.try_send(ColdCommand::Delete(vec![entry.meta_path, entry.body_path])); + } + } +} diff --git a/src/cache/collapse.rs b/src/cache/collapse.rs new file mode 100644 index 0000000..14691bc --- /dev/null +++ b/src/cache/collapse.rs @@ -0,0 +1,257 @@ +struct FillFlight { + id: u64, + scope: String, + scheme: String, + authority: String, + target: String, + done: tokio::sync::watch::Sender, +} + +pub enum FillClaim { + Disabled, + Leader(FillLeaderGuard), + Waited, + Timeout, +} + +pub struct FillLeaderGuard { + cache: std::sync::Weak, + key: Key, + id: u64, + done: tokio::sync::watch::Sender, +} + +struct CollapseWaiterGuard { + cache: std::sync::Weak, +} + +impl Drop for CollapseWaiterGuard { + fn drop(&mut self) { + if let Some(cache) = self.cache.upgrade() { + cache.collapse_waiters.fetch_sub(1, Ordering::Relaxed); + } + } +} + +impl Drop for FillLeaderGuard { + fn drop(&mut self) { + if let Some(cache) = self.cache.upgrade() { + let mut flights = cache + .fill_flights + .lock() + .unwrap_or_else(|e| e.into_inner()); + let current = flights + .get(&self.key) + .is_some_and(|flight| flight.id == self.id); + if current { + flights.remove(&self.key); + } + } + // Publish completion even when a purge already removed this flight from the map. + self.done.send_replace(true); + } +} + +impl Cache { + pub async fn claim_fill( + self: &Arc, + ticket: &Ticket, + settings: &Settings, + ) -> FillClaim { + if !settings.collapse_enabled { + return FillClaim::Disabled; + } + let existing = { + let mut flights = self + .fill_flights + .lock() + .unwrap_or_else(|e| e.into_inner()); + if let Some(flight) = flights.get(&ticket.key) { + Some(flight.clone()) + } else { + let id = self + .fill_flight_sequence + .fetch_add(1, Ordering::Relaxed) + .wrapping_add(1); + let (done, _) = tokio::sync::watch::channel(false); + flights.insert( + ticket.key, + Arc::new(FillFlight { + id, + scope: ticket.scope.clone(), + scheme: ticket.scheme.clone(), + authority: ticket.authority.clone(), + target: ticket.target.clone(), + done: done.clone(), + }), + ); + return FillClaim::Leader(FillLeaderGuard { + cache: Arc::downgrade(self), + key: ticket.key, + id, + done, + }); + } + }; + let Some(flight) = existing else { + unreachable!("existing fill resolved above") + }; + // A watch channel retains the completed state, so a leader finishing between + // map lookup and follower polling cannot lose the wakeup. + let mut done = flight.done.subscribe(); + self.collapse_waiters.fetch_add(1, Ordering::Relaxed); + let _waiter = CollapseWaiterGuard { + cache: Arc::downgrade(self), + }; + let waited = if *done.borrow() { + Ok(()) + } else { + tokio::time::timeout( + Duration::from_millis(settings.collapse_wait_timeout_ms), + done.changed(), + ) + .await + .map(|_| ()) + }; + match waited { + Ok(()) => { + self.counters + .collapsed_fills + .fetch_add(1, Ordering::Relaxed); + FillClaim::Waited + } + Err(_) => { + self.counters + .collapse_timeouts + .fetch_add(1, Ordering::Relaxed); + FillClaim::Timeout + } + } + } + + fn cancel_all_fill_flights(&self) { + let flights = { + let mut flights = self + .fill_flights + .lock() + .unwrap_or_else(|e| e.into_inner()); + flights.drain().map(|(_, flight)| flight).collect::>() + }; + for flight in flights { + flight.done.send_replace(true); + } + } + + fn cancel_scope_fill_flights(&self, scope: &str) { + let removed = { + let mut flights = self + .fill_flights + .lock() + .unwrap_or_else(|e| e.into_inner()); + let keys = flights + .iter() + .filter(|(_, flight)| flight.scope == scope) + .map(|(key, _)| *key) + .collect::>(); + keys.into_iter() + .filter_map(|key| flights.remove(&key)) + .collect::>() + }; + for flight in removed { + flight.done.send_replace(true); + } + } +} + +pub struct ProactiveScheduleGuard { + cache: std::sync::Weak, + key: Key, +} +impl Drop for ProactiveScheduleGuard { + fn drop(&mut self) { + if let Some(cache) = self.cache.upgrade() { + cache + .proactive_scheduled_keys + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&self.key); + } + } +} + +pub struct ProactiveInflightGuard { + cache: std::sync::Weak, +} +impl Drop for ProactiveInflightGuard { + fn drop(&mut self) { + if let Some(cache) = self.cache.upgrade() { + cache.proactive_inflight.fetch_sub(1, Ordering::Relaxed); + } + } +} + +impl Cache { + pub fn begin_proactive_schedule( + self: &Arc, + ticket: &Ticket, + settings: &Settings, + ) -> Option { + if !settings.proactive_refresh_enabled || settings.proactive_refresh_percent == 0 { + return None; + } + let sample = u16::from_be_bytes([ticket.key[0], ticket.key[1]]) % 100; + if sample >= settings.proactive_refresh_percent as u16 { + self.counters + .proactive_skipped + .fetch_add(1, Ordering::Relaxed); + return None; + } + let mut scheduled = self + .proactive_scheduled_keys + .lock() + .unwrap_or_else(|e| e.into_inner()); + if !scheduled.insert(ticket.key) { + return None; + } + self.counters + .proactive_scheduled + .fetch_add(1, Ordering::Relaxed); + Some(ProactiveScheduleGuard { + cache: Arc::downgrade(self), + key: ticket.key, + }) + } + + pub fn try_begin_proactive_refresh( + self: &Arc, + settings: &Settings, + ) -> Option { + let limit = settings.proactive_refresh_max_inflight as u64; + loop { + let current = self.proactive_inflight.load(Ordering::Relaxed); + if current >= limit { + self.counters + .proactive_skipped + .fetch_add(1, Ordering::Relaxed); + return None; + } + if self + .proactive_inflight + .compare_exchange_weak( + current, + current + 1, + Ordering::AcqRel, + Ordering::Relaxed, + ) + .is_ok() + { + self.counters + .proactive_started + .fetch_add(1, Ordering::Relaxed); + return Some(ProactiveInflightGuard { + cache: Arc::downgrade(self), + }); + } + } + } +} diff --git a/src/cache/dashboard.rs b/src/cache/dashboard.rs new file mode 100644 index 0000000..bdf107a --- /dev/null +++ b/src/cache/dashboard.rs @@ -0,0 +1,429 @@ +impl Cache { + pub fn view(&self) -> View { + let settings = self + .tier_settings + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + self.counters + .expired + .fetch_add(store.sweep() as u64, Ordering::Relaxed); + let n = |value: &AtomicU64| value.load(Ordering::Relaxed); + let hits = n(&self.counters.hits); + let misses = n(&self.counters.misses); + let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + let fill_bytes_active = n(&self.fill_bytes_active); + let cold_queue_bytes = n(&self.cold_queue_bytes); + let cold_write_bytes_active = n(&self.cold_write_bytes_active); + let cold_read_bytes_active = n(&self.cold_read_bytes_active); + let transient_bytes = n(self.transient_bytes_active.as_ref()); + View { + hits, + misses, + bypasses: n(&self.counters.bypasses), + stored: n(&self.counters.stored), + evictions: n(&self.counters.evictions), + expired: n(&self.counters.expired), + rejected_fills: n(&self.counters.rejected_fills), + memory_pressure_rejections: n(&self.counters.memory_pressure_rejections), + hot_eviction_limited: n(&self.counters.hot_eviction_limited), + purges: n(&self.counters.purges), + entries: store.entries.len(), + resident_bytes: store.bytes, + hot_logical_bytes: store.logical_bytes, + hot_allocated_bytes: store.bytes, + hot_limit_bytes: settings.max_bytes, + transient_limit_bytes: settings.transient_max_bytes, + cold_queue_limit_bytes: settings.cold_queue_max_bytes, + cold_entries: cold.entries.len(), + cold_bytes: cold.bytes, + cold_hits: n(&self.counters.cold_hits), + cold_promotions: n(&self.counters.cold_promotions), + hot_demotions: n(&self.counters.hot_demotions), + cold_evictions: n(&self.counters.cold_evictions), + cold_errors: n(&self.counters.cold_errors), + cold_queue_dropped: n(&self.counters.cold_queue_dropped), + collapsed_fills: n(&self.counters.collapsed_fills), + collapse_timeouts: n(&self.counters.collapse_timeouts), + collapse_waiters: n(&self.collapse_waiters), + purge_entries: n(&self.counters.purge_entries), + bans_created: n(&self.counters.bans_created), + bans_removed: n(&self.counters.bans_removed), + ban_matches: n(&self.counters.ban_matches), + active_bans: self.active_bans().len(), + proactive_scheduled: n(&self.counters.proactive_scheduled), + proactive_started: n(&self.counters.proactive_started), + proactive_skipped: n(&self.counters.proactive_skipped), + proactive_inflight: n(&self.proactive_inflight), + fills_active: FILL_SLOTS - self.fills.available_permits(), + fill_bytes_active, + cold_queue_items: n(&self.cold_queue_items), + cold_queue_bytes, + cold_write_bytes_active, + cold_read_bytes_active, + transient_bytes, + hit_ratio: if hits + misses > 0 { + hits as f64 / (hits + misses) as f64 + } else { + 0.0 + }, + } + } + pub fn prometheus(&self) -> String { + let view = self.view(); + let mut text = String::new(); + for (name, kind, value) in [ + ("hits_total", "counter", view.hits), + ("misses_total", "counter", view.misses), + ("bypasses_total", "counter", view.bypasses), + ("stored_total", "counter", view.stored), + ("evictions_total", "counter", view.evictions), + ("expired_total", "counter", view.expired), + ("rejected_fills_total", "counter", view.rejected_fills), + ( + "memory_pressure_rejections_total", + "counter", + view.memory_pressure_rejections, + ), + ( + "hot_eviction_limited_total", + "counter", + view.hot_eviction_limited, + ), + ("purges_total", "counter", view.purges), + ("purge_entries_total", "counter", view.purge_entries), + ("collapsed_fills_total", "counter", view.collapsed_fills), + ("collapse_timeouts_total", "counter", view.collapse_timeouts), + ("collapse_waiters", "gauge", view.collapse_waiters), + ("bans_created_total", "counter", view.bans_created), + ("bans_removed_total", "counter", view.bans_removed), + ("ban_matches_total", "counter", view.ban_matches), + ("active_bans", "gauge", view.active_bans as u64), + ("proactive_scheduled_total", "counter", view.proactive_scheduled), + ("proactive_started_total", "counter", view.proactive_started), + ("proactive_skipped_total", "counter", view.proactive_skipped), + ("proactive_inflight", "gauge", view.proactive_inflight), + ("entries", "gauge", view.entries as u64), + ("resident_bytes", "gauge", view.resident_bytes as u64), + ("hot_logical_bytes", "gauge", view.hot_logical_bytes as u64), + ("hot_allocated_bytes", "gauge", view.hot_allocated_bytes as u64), + ("hot_limit_bytes", "gauge", view.hot_limit_bytes as u64), + ( + "transient_limit_bytes", + "gauge", + view.transient_limit_bytes as u64, + ), + ( + "cold_queue_limit_bytes", + "gauge", + view.cold_queue_limit_bytes as u64, + ), + ("cold_entries", "gauge", view.cold_entries as u64), + ("cold_bytes", "gauge", view.cold_bytes), + ("cold_hits_total", "counter", view.cold_hits), + ("cold_promotions_total", "counter", view.cold_promotions), + ("hot_demotions_total", "counter", view.hot_demotions), + ("cold_evictions_total", "counter", view.cold_evictions), + ("cold_errors_total", "counter", view.cold_errors), + ( + "cold_queue_dropped_total", + "counter", + view.cold_queue_dropped, + ), + ("fills_active", "gauge", view.fills_active as u64), + ("fill_bytes_active", "gauge", view.fill_bytes_active), + ("cold_queue_items", "gauge", view.cold_queue_items), + ("cold_queue_bytes", "gauge", view.cold_queue_bytes), + ( + "cold_write_bytes_active", + "gauge", + view.cold_write_bytes_active, + ), + ( + "cold_read_bytes_active", + "gauge", + view.cold_read_bytes_active, + ), + ("transient_bytes", "gauge", view.transient_bytes), + ] { + text.push_str(&format!( + "# TYPE rust_proxy_cache_{name} {kind}\nrust_proxy_cache_{name} {value}\n" + )); + } + text.push_str(&format!( + "# TYPE rust_proxy_cache_hit_ratio gauge\nrust_proxy_cache_hit_ratio {:.6}\n", + view.hit_ratio + )); + text + } + pub fn bypass(&self) { + self.counters.bypasses.fetch_add(1, Ordering::Relaxed); + } + /// Clear all keys and cancel publication of older in-flight fills. + pub fn purge(&self, revision: Option) -> usize { + let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + let mut count = store.entries.len(); + store.entries.clear(); + store.variants.clear(); + store.lru.clear(); + store.bytes = 0; + store.logical_bytes = 0; + store.generation = store.generation.wrapping_add(1); + store.scope_generations.clear(); + store.key_generations.clear(); + if let Some(revision) = revision { + store.revision = revision; + self.revision.store(revision, Ordering::Relaxed); + store.domains.clear(); + } + drop(store); + self.cancel_all_fill_flights(); + let settings = self + .tier_settings + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + { + let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + count += cold.entries.len(); + cold.entries.clear(); + cold.variants.clear(); + cold.lru.clear(); + cold.bytes = 0; + cold.sequence = 0; + } + if let (Some(root), Some(tx)) = (self.cold_root_for(&settings), &self.cold_tx) { + let _ = tx.try_send(ColdCommand::PurgeDirectory(root)); + } + self.counters.purges.fetch_add(1, Ordering::Relaxed); + self.counters.purge_entries.fetch_add(count as u64, Ordering::Relaxed); + count + } + /// Purge one vhost without evicting other hosts. Generation bumps also stop + /// an already streaming MISS from repopulating the just-purged scope. + pub fn purge_scope(&self, scope: &str) -> usize { + let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + let keys: Vec<_> = store + .entries + .iter() + .filter(|(_, e)| e.scope == scope) + .map(|(k, _)| *k) + .collect(); + let mut count = keys.len(); + for key in keys { + store.remove(&key); + } + let generation = store.scope_generations.entry(scope.to_owned()).or_default(); + *generation = generation.wrapping_add(1); + drop(store); + self.cancel_scope_fill_flights(scope); + let mut delete = Vec::new(); + { + let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + let keys: Vec<_> = cold + .entries + .iter() + .filter(|(_, entry)| entry.scope == scope) + .map(|(key, _)| *key) + .collect(); + count += keys.len(); + for key in keys { + if let Some(entry) = cold.remove(&key) { + delete.extend([entry.meta_path, entry.body_path]); + } + } + } + if let (Some(tx), false) = (&self.cold_tx, delete.is_empty()) { + let _ = tx.try_send(ColdCommand::Delete(delete)); + } + self.counters.purges.fetch_add(1, Ordering::Relaxed); + self.counters.purge_entries.fetch_add(count as u64, Ordering::Relaxed); + count + } + pub fn scope_view(&self, scope: &str) -> ScopeView { + let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + self.counters + .expired + .fetch_add(store.sweep() as u64, Ordering::Relaxed); + let mut view = ScopeView { + entries: 0, + resident_bytes: 0, + cold_entries: 0, + cold_bytes: 0, + }; + for entry in store.entries.values().filter(|entry| entry.scope == scope) { + view.entries += 1; + view.resident_bytes += entry.cost; + } + drop(store); + let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + for entry in cold.entries.values().filter(|entry| entry.scope == scope) { + view.cold_entries += 1; + view.cold_bytes = view.cold_bytes.saturating_add(entry.disk_bytes); + } + view + } + pub fn record(&self, scope: &str, domain: &str, outcome: &str, reason: &'static str) { + let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(c) = store.domain(scope, domain) { + match outcome { + "HIT" => c.hits += 1, + "MISS" => c.misses += 1, + _ => c.bypasses += 1, + } + *c.reasons.entry(reason.into()).or_default() += 1; + } + } + pub fn record_reason(&self, scope: &str, domain: &str, reason: &'static str) { + let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(c) = store.domain(scope, domain) { + *c.reasons.entry(reason.into()).or_default() += 1; + } + } + pub fn dashboard(&self, scope: Option<&str>, offset: usize, limit: usize) -> Dashboard { + let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + self.counters + .expired + .fetch_add(store.sweep() as u64, Ordering::Relaxed); + let revision = store.revision; + let mut groups: BTreeMap<(String, String), DomainView> = store + .domains + .iter() + .filter(|((vhost, _), _)| scope.is_none_or(|s| s == vhost)) + .map(|((vhost, domain), counters)| { + ( + (vhost.clone(), domain.clone()), + DomainView { + vhost: vhost.clone(), + domain: domain.clone(), + entries: 0, + logical_bytes: 0, + resident_bytes: 0, + hot_entries: 0, + cold_entries: 0, + cold_bytes: 0, + hit_ratio: if counters.hits + counters.misses > 0 { + counters.hits as f64 / (counters.hits + counters.misses) as f64 + } else { + 0.0 + }, + counters: counters.clone(), + }, + ) + }) + .collect(); + let mut objects = Vec::new(); + for key in store.lru.values().rev() { + let Some(e) = store.entries.get(key) else { + continue; + }; + let age = e.initial_age.saturating_add(e.received.elapsed()).as_secs(); + if age >= e.ttl || !scope.is_none_or(|s| s == e.scope) { + continue; + } + let row = groups + .entry((e.scope.clone(), e.domain.clone())) + .or_insert_with(|| DomainView { + vhost: e.scope.clone(), + domain: e.domain.clone(), + entries: 0, + logical_bytes: 0, + resident_bytes: 0, + hot_entries: 0, + cold_entries: 0, + cold_bytes: 0, + hit_ratio: 0.0, + counters: DomainCounters::default(), + }); + row.entries += 1; + row.hot_entries += 1; + row.logical_bytes += e.body.len(); + row.resident_bytes += e.cost; + objects.push(ObjectView { + vhost: e.scope.clone(), + scheme: e.scheme.clone(), + authority: e.authority.clone(), + domain: e.domain.clone(), + path: e.path.clone(), + target: e.target.clone(), + cache_key: key_hex(key), + tags: e.tags.clone(), + transformed: e.transformed, + body_bytes: e.body.len(), + resident_bytes: e.cost, + storage_bytes: 0, + tier: "hot", + hits: e.hits, + age_seconds: age, + remaining_ttl_seconds: e.ttl.saturating_sub(age), + }); + } + drop(store); + let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + for key in cold.lru.values().rev() { + let Some(e) = cold.entries.get(key) else { + continue; + }; + if e.revision != revision || !scope.is_none_or(|s| s == e.scope) { + continue; + } + let age = cold_age(e); + if age >= e.ttl && !e.requires_revalidation { + continue; + } + let row = groups + .entry((e.scope.clone(), e.domain.clone())) + .or_insert_with(|| DomainView { + vhost: e.scope.clone(), + domain: e.domain.clone(), + entries: 0, + logical_bytes: 0, + resident_bytes: 0, + hot_entries: 0, + cold_entries: 0, + cold_bytes: 0, + hit_ratio: 0.0, + counters: DomainCounters::default(), + }); + row.entries += 1; + row.cold_entries += 1; + row.cold_bytes = row.cold_bytes.saturating_add(e.disk_bytes); + objects.push(ObjectView { + vhost: e.scope.clone(), + scheme: e.scheme.clone(), + authority: e.authority.clone(), + domain: e.domain.clone(), + path: e.path.clone(), + target: e.target.clone(), + cache_key: key_hex(key), + tags: e.tags.clone(), + transformed: e.transformed, + body_bytes: e.body_bytes, + resident_bytes: 0, + storage_bytes: e.disk_bytes, + tier: "cold", + hits: e.hits, + age_seconds: age, + remaining_ttl_seconds: e.ttl.saturating_sub(age), + }); + } + let total = objects.len(); + let matching_body_bytes = objects.iter().map(|entry| entry.body_bytes).sum(); + let matching_resident_bytes = objects.iter().map(|entry| entry.resident_bytes).sum(); + let matching_cold_bytes = objects.iter().map(|entry| entry.storage_bytes).sum(); + let limit = limit.clamp(1, 200); + let objects = objects.into_iter().skip(offset).take(limit).collect(); + Dashboard { + domains: groups.into_values().collect(), + objects, + matching_objects: total, + matching_body_bytes, + matching_resident_bytes, + matching_cold_bytes, + offset, + limit, + statistics_revision: revision, + } + } +} diff --git a/src/cache/fill.rs b/src/cache/fill.rs new file mode 100644 index 0000000..febd05e --- /dev/null +++ b/src/cache/fill.rs @@ -0,0 +1,343 @@ +struct ByteGaugeGuard<'a> { + counter: &'a AtomicU64, + bytes: u64, +} +impl<'a> ByteGaugeGuard<'a> { + fn new(counter: &'a AtomicU64, bytes: u64) -> Self { + counter.fetch_add(bytes, Ordering::Relaxed); + Self { counter, bytes } + } +} +impl Drop for ByteGaugeGuard<'_> { + fn drop(&mut self) { + self.counter.fetch_sub(self.bytes, Ordering::Relaxed); + } +} + +struct FillGauge { + cache: Arc, + bytes: u64, + leases: Vec, +} +impl FillGauge { + fn new(cache: Arc, bytes: usize, settings: &Settings) -> Option { + let lease = cache.reserve_fill_allocation(bytes, settings)?; + Some(Self { + cache, + bytes: bytes as u64, + leases: vec![lease], + }) + } + + fn grow(&mut self, bytes: usize, settings: &Settings) -> bool { + if bytes == 0 { + return true; + } + let Some(lease) = self.cache.reserve_fill_allocation(bytes, settings) else { + return false; + }; + self.bytes = self.bytes.saturating_add(bytes as u64); + self.leases.push(lease); + true + } + + fn release(&mut self) { + if self.bytes != 0 { + self.cache + .fill_bytes_active + .fetch_sub(self.bytes, Ordering::Relaxed); + self.bytes = 0; + } + self.leases.clear(); + } +} +impl Drop for FillGauge { + fn drop(&mut self) { + self.release(); + } +} + +struct Candidate { + cache: Arc, + ticket: Ticket, + plan: Plan, + headers: HeaderMap, + data: Vec, + fill_gauge: FillGauge, + settings: Settings, + skip_compression: bool, + origin_etag: Option, + origin_last_modified: Option, + transformed: bool, + _permit: OwnedSemaphorePermit, + _fill_leader: Option, +} +impl Candidate { + fn finish(mut self) { + if self + .plan + .expected_length + .is_some_and(|length| self.data.len() != length) + { + let reason = if self.transformed { + "transform_length_mismatch" + } else { + "fill_incomplete" + }; + self.cache + .record_reason(&self.ticket.scope, &self.ticket.domain, reason); + self.cache + .counters + .rejected_fills + .fetch_add(1, Ordering::Relaxed); + return; + } + if !self.headers.contains_key("date") { + if let Ok(value) = httpdate::fmt_http_date(SystemTime::now()).parse() { + self.headers.insert("date", value); + } + } + // Each downstream exchange receives a new ID, never a cached origin ID. + for name in [ + "x-request-id", + "x-cache", + "x-cache-hits", + "x-cache-status", + "x-cache-reason", + "cache-status", + ] { + self.headers.remove(name); + } + let vary = std::mem::take(&mut self.plan.vary); + // Keep the actual backing capacity as the HOT charge. Vec::reserve_exact + // avoids geometric multi-MiB overshoot while Bytes::from(Vec) transfers + // the allocation without copying. + let body_allocation = self.data.capacity().max(self.data.len()); + let body = Bytes::from(std::mem::take(&mut self.data)); + let scope = self.ticket.scope.clone(); + let scheme = self.ticket.scheme.clone(); + let authority = self.ticket.authority.clone(); + let domain = self.ticket.domain.clone(); + let path = self.ticket.path.clone(); + let target = self.ticket.target.clone(); + let tags = cache_tags(&self.headers); + let cost = hot_entry_cost( + body_allocation, + &self.headers, + &vary, + &scope, + &scheme, + &authority, + &domain, + &path, + &target, + &tags, + self.origin_etag.as_ref(), + self.origin_last_modified.as_ref(), + ); + let created_at = self.ticket.created_at; + let base_key = self.ticket.key; + self.cache.insert( + self.ticket, + Entry { + base_key, + vary, + scope, + scheme, + authority, + domain, + path, + target, + tags, + created_at, + body, + body_allocated: body_allocation, + headers: self.headers, + received: self.plan.received, + last_access: Instant::now(), + resident_since: Instant::now(), + resident_age_base: Duration::ZERO, + initial_age: self.plan.initial_age, + ttl: self.plan.ttl, + stale_while_revalidate: self.plan.stale_while_revalidate, + stale_if_error: self.plan.stale_if_error, + must_revalidate: self.plan.must_revalidate, + proxy_revalidate: self.plan.proxy_revalidate, + origin_etag: self.origin_etag, + origin_last_modified: self.origin_last_modified, + cost, + sequence: 0, + hits: 0, + transformed: self.transformed, + skip_compression: self.skip_compression, + requires_revalidation: self.plan.requires_revalidation, + }, + &self.settings, + ); + } +} + +struct FillBody { + inner: Body, + candidate: Option, + done: bool, +} +impl HttpBody for FillBody { + type Data = Bytes; + type Error = axum::Error; + fn poll_frame( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + let this = self.get_mut(); + match Pin::new(&mut this.inner).poll_frame(cx) { + Poll::Ready(Some(Ok(frame))) => { + if let Some(candidate) = &mut this.candidate { + if let Some(data) = frame.data_ref() { + let needed = candidate.data.len().saturating_add(data.len()); + if needed <= candidate.settings.max_object_bytes { + let before = candidate.data.capacity(); + let required_growth = needed.saturating_sub(before); + if required_growth > 0 + && !candidate + .fill_gauge + .grow(required_growth, &candidate.settings) + { + candidate.cache.record_reason( + &candidate.ticket.scope, + &candidate.ticket.domain, + "hot_memory_pressure", + ); + candidate + .cache + .counters + .rejected_fills + .fetch_add(1, Ordering::Relaxed); + this.candidate = None; + } else if required_growth > 0 + && candidate + .data + .try_reserve_exact(needed.saturating_sub(candidate.data.len())) + .is_err() + { + candidate.cache.record_reason( + &candidate.ticket.scope, + &candidate.ticket.domain, + "fill_allocation_failed", + ); + candidate + .cache + .counters + .rejected_fills + .fetch_add(1, Ordering::Relaxed); + this.candidate = None; + } else { + let after = candidate.data.capacity(); + let unbudgeted = after.saturating_sub(before.saturating_add(required_growth)); + if unbudgeted > 0 + && !candidate.fill_gauge.grow(unbudgeted, &candidate.settings) + { + candidate.cache.record_reason( + &candidate.ticket.scope, + &candidate.ticket.domain, + "fill_allocator_overshoot", + ); + candidate + .cache + .counters + .rejected_fills + .fetch_add(1, Ordering::Relaxed); + this.candidate = None; + } else { + candidate.data.extend_from_slice(data); + } + } + } else { + candidate.cache.record_reason( + &candidate.ticket.scope, + &candidate.ticket.domain, + "object_too_large", + ); + candidate + .cache + .counters + .rejected_fills + .fetch_add(1, Ordering::Relaxed); + this.candidate = None; + } + } else { + candidate.cache.record_reason( + &candidate.ticket.scope, + &candidate.ticket.domain, + "fill_incomplete", + ); + candidate + .cache + .counters + .rejected_fills + .fetch_add(1, Ordering::Relaxed); + this.candidate = None; + } // Do not cache responses with trailers. + } + if this.inner.is_end_stream() { + this.done = true; + if let Some(candidate) = this.candidate.take() { + candidate.finish(); + } + } + Poll::Ready(Some(Ok(frame))) + } + Poll::Ready(None) => { + this.done = true; + if let Some(candidate) = this.candidate.take() { + candidate.finish(); + } + Poll::Ready(None) + } + Poll::Ready(Some(Err(error))) => { + if let Some(candidate) = &this.candidate { + candidate.cache.record_reason( + &candidate.ticket.scope, + &candidate.ticket.domain, + "fill_incomplete", + ); + candidate + .cache + .counters + .rejected_fills + .fetch_add(1, Ordering::Relaxed); + } + this.candidate = None; + this.done = true; + Poll::Ready(Some(Err(error))) + } + Poll::Pending => Poll::Pending, + } + } + fn is_end_stream(&self) -> bool { + self.done + } + fn size_hint(&self) -> SizeHint { + self.inner.size_hint() + } +} +impl Drop for FillBody { + fn drop(&mut self) { + if self.done { + return; + } + if let Some(candidate) = self.candidate.take() { + candidate.cache.record_reason( + &candidate.ticket.scope, + &candidate.ticket.domain, + "fill_incomplete", + ); + candidate + .cache + .counters + .rejected_fills + .fetch_add(1, Ordering::Relaxed); + } + } +} + diff --git a/src/cache/invalidation.rs b/src/cache/invalidation.rs new file mode 100644 index 0000000..1c92d66 --- /dev/null +++ b/src/cache/invalidation.rs @@ -0,0 +1,584 @@ +use anyhow::Context as _; + +const MAX_BAN_RULES: usize = 1024; +const DEFAULT_BAN_TTL_SECONDS: u64 = 3600; +const MAX_BAN_TTL_SECONDS: u64 = 604_800; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum InvalidationOperation { + Purge, + Ban, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum InvalidationSelector { + Exact, + Prefix, + Tag, + Regex, + CacheKey, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InvalidationRequest { + pub operation: InvalidationOperation, + pub selector: InvalidationSelector, + pub value: String, + #[serde(default)] + pub host: Option, + #[serde(default)] + pub vhost: Option, + #[serde(default)] + pub ttl_seconds: Option, + #[serde(default)] + pub confirm_wide: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct InvalidationResult { + pub operation: InvalidationOperation, + pub selector: InvalidationSelector, + pub value: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub vhost: Option, + pub affected_entries: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub ban_id: Option, + pub elapsed_ms: u64, + pub status: &'static str, +} + +#[derive(Debug, Clone, Serialize)] +pub struct BanView { + pub id: u64, + pub selector: InvalidationSelector, + pub value: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub scheme: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub vhost: Option, + pub created_at_ms: u64, + pub expires_at_ms: u64, +} + +#[derive(Clone)] +struct SelectorMatcher { + selector: InvalidationSelector, + value: String, + scheme: Option, + host: Option, + vhost: Option, + regex: Option, +} + +#[derive(Clone)] +struct BanRule { + id: u64, + matcher: SelectorMatcher, + created_at: SystemTime, + expires_at: SystemTime, +} + +fn canonical_url(scheme: &str, authority: &str, target: &str) -> String { + format!("{scheme}://{authority}{target}") +} + +fn normalized_matcher(request: &InvalidationRequest) -> Result { + ensure!(!request.value.is_empty(), "invalidation value is required"); + ensure!(request.value.len() <= 8192, "invalidation value is too long"); + if let Some(vhost) = &request.vhost { + ensure!(!vhost.is_empty() && vhost.len() <= 64, "invalid vhost selector"); + } + if let Some(host) = &request.host { + ensure!(!host.is_empty() && host.len() <= 253, "invalid host selector"); + } + let mut value = request.value.trim().to_owned(); + let mut scheme = None; + let mut host = request.host.as_ref().map(|value| value.to_ascii_lowercase()); + let regex = match request.selector { + InvalidationSelector::Exact | InvalidationSelector::Prefix => { + if value.starts_with("http://") || value.starts_with("https://") { + let url = url::Url::parse(&value).context("invalid purge URL")?; + ensure!(url.username().is_empty() && url.password().is_none(), "purge URL must not contain credentials"); + ensure!(url.fragment().is_none(), "purge URL must not contain a fragment"); + scheme = Some(url.scheme().to_ascii_lowercase()); + let authority = match url.port() { + Some(port) => format!("{}:{port}", url.host_str().context("purge URL host missing")?), + None => url.host_str().context("purge URL host missing")?.to_owned(), + }; + host = Some(authority.to_ascii_lowercase()); + value = url.path().to_owned(); + if let Some(query) = url.query() { + value.push('?'); + value.push_str(query); + } + } + ensure!(value.starts_with('/'), "exact/prefix invalidation must use an absolute URL or /path target"); + None + } + InvalidationSelector::Tag => { + ensure!(value.len() <= 128, "cache tag is too long"); + ensure!(value.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b':' | b'/')), "invalid cache tag"); + None + } + InvalidationSelector::Regex => { + ensure!(value.len() <= 512, "regex is too long"); + Some( + regex::RegexBuilder::new(&value) + .size_limit(1 << 20) + .build() + .context("invalid or too expensive regex")?, + ) + } + InvalidationSelector::CacheKey => { + ensure!(parse_key_hex(&value).is_some(), "cache_key must be 64 hexadecimal characters"); + value.make_ascii_lowercase(); + None + } + }; + Ok(SelectorMatcher { + selector: request.selector, + value, + scheme, + host, + vhost: request.vhost.clone(), + regex, + }) +} + +fn requires_wide_confirmation(matcher: &SelectorMatcher) -> bool { + match matcher.selector { + InvalidationSelector::Prefix => matcher.value == "/", + InvalidationSelector::Regex => true, + _ => false, + } +} + +fn host_matches(matcher: &SelectorMatcher, scheme: &str, authority: &str, domain: &str) -> bool { + if matcher + .scheme + .as_ref() + .is_some_and(|wanted| !wanted.eq_ignore_ascii_case(scheme)) + { + return false; + } + matcher.host.as_ref().is_none_or(|wanted| { + wanted.eq_ignore_ascii_case(authority) || wanted.eq_ignore_ascii_case(domain) + }) +} + +fn matcher_matches( + matcher: &SelectorMatcher, + storage_key: &Key, + base_key: &Key, + scope: &str, + scheme: &str, + authority: &str, + domain: &str, + target: &str, + tags: &[String], +) -> bool { + if matcher.vhost.as_ref().is_some_and(|wanted| wanted != scope) { + return false; + } + match matcher.selector { + InvalidationSelector::Exact => { + host_matches(matcher, scheme, authority, domain) && target == matcher.value + } + InvalidationSelector::Prefix => { + host_matches(matcher, scheme, authority, domain) && target.starts_with(&matcher.value) + } + InvalidationSelector::Tag => { + host_matches(matcher, scheme, authority, domain) + && tags.iter().any(|tag| tag == &matcher.value) + }, + InvalidationSelector::Regex => { + if !host_matches(matcher, scheme, authority, domain) { + return false; + } + matcher + .regex + .as_ref() + .is_some_and(|regex| regex.is_match(&canonical_url(scheme, authority, target))) + } + InvalidationSelector::CacheKey => { + host_matches(matcher, scheme, authority, domain) + && (key_hex(storage_key) == matcher.value || key_hex(base_key) == matcher.value) + } + } +} + +impl BanRule { + fn expired(&self, now: SystemTime) -> bool { + now >= self.expires_at + } + + fn matches_hot(&self, key: &Key, entry: &Entry, now: SystemTime) -> bool { + !self.expired(now) + && entry.created_at <= self.created_at + && matcher_matches( + &self.matcher, + key, + &entry.base_key, + &entry.scope, + &entry.scheme, + &entry.authority, + &entry.domain, + &entry.target, + &entry.tags, + ) + } + + fn matches_cold(&self, key: &Key, entry: &ColdEntry, now: SystemTime) -> bool { + !self.expired(now) + && entry.created_at <= self.created_at + && matcher_matches( + &self.matcher, + key, + &entry.base_key, + &entry.scope, + &entry.scheme, + &entry.authority, + &entry.domain, + &entry.target, + &entry.tags, + ) + } + + fn view(&self) -> BanView { + BanView { + id: self.id, + selector: self.matcher.selector, + value: self.matcher.value.clone(), + scheme: self.matcher.scheme.clone(), + host: self.matcher.host.clone(), + vhost: self.matcher.vhost.clone(), + created_at_ms: self + .created_at + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u64::MAX as u128) as u64, + expires_at_ms: self + .expires_at + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u64::MAX as u128) as u64, + } + } +} + +impl Cache { + fn active_bans(&self) -> Vec { + let now = SystemTime::now(); + let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner()); + bans.retain(|rule| !rule.expired(now)); + bans.clone() + } + + fn hot_entry_banned(&self, key: &Key, entry: &Entry) -> bool { + let now = SystemTime::now(); + let banned = self + .active_bans() + .iter() + .any(|rule| rule.matches_hot(key, entry, now)); + if banned { + self.counters.ban_matches.fetch_add(1, Ordering::Relaxed); + } + banned + } + + fn cold_entry_banned(&self, key: &Key, entry: &ColdEntry) -> bool { + let now = SystemTime::now(); + let banned = self + .active_bans() + .iter() + .any(|rule| rule.matches_cold(key, entry, now)); + if banned { + self.counters.ban_matches.fetch_add(1, Ordering::Relaxed); + } + banned + } + + fn entry_banned_before_insert(&self, key: &Key, entry: &Entry) -> bool { + let now = SystemTime::now(); + self.active_bans() + .iter() + .any(|rule| rule.matches_hot(key, entry, now)) + } + + pub fn bans(&self) -> Vec { + self.active_bans().iter().map(BanRule::view).collect() + } + + pub fn remove_ban(&self, id: u64) -> bool { + let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner()); + let before = bans.len(); + bans.retain(|rule| rule.id != id); + let removed = bans.len() != before; + if removed { + self.counters.bans_removed.fetch_add(1, Ordering::Relaxed); + } + removed + } + + pub fn invalidate(&self, request: InvalidationRequest) -> Result { + let started = Instant::now(); + let matcher = normalized_matcher(&request)?; + if requires_wide_confirmation(&matcher) { + ensure!(request.confirm_wide, "wide invalidation requires confirm_wide=true"); + } + match request.operation { + InvalidationOperation::Ban => { + let ttl = request.ttl_seconds.unwrap_or(DEFAULT_BAN_TTL_SECONDS); + ensure!((1..=MAX_BAN_TTL_SECONDS).contains(&ttl), "BAN ttl_seconds must be 1..604800"); + let now = SystemTime::now(); + let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner()); + bans.retain(|rule| !rule.expired(now)); + ensure!(bans.len() < MAX_BAN_RULES, "active BAN rule limit reached"); + let id = self.ban_sequence.fetch_add(1, Ordering::Relaxed).wrapping_add(1); + bans.push(BanRule { + id, + matcher: matcher.clone(), + created_at: now, + expires_at: now + .checked_add(Duration::from_secs(ttl)) + .unwrap_or(now), + }); + self.counters.bans_created.fetch_add(1, Ordering::Relaxed); + Ok(InvalidationResult { + operation: request.operation, + selector: request.selector, + value: matcher.value, + host: matcher.host, + vhost: matcher.vhost, + affected_entries: 0, + ban_id: Some(id), + elapsed_ms: started.elapsed().as_millis().min(u64::MAX as u128) as u64, + status: "active", + }) + } + InvalidationOperation::Purge => { + // A selective purge is also a publication barrier for every fill that + // started before this operation. Existing unrelated objects remain + // reusable, but an old in-flight response cannot repopulate a target + // that was not yet identifiable (for example Cache-Tag before headers). + { + let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + store.generation = store.generation.wrapping_add(1); + } + let affected = self.purge_matcher(&matcher); + self.counters.purges.fetch_add(1, Ordering::Relaxed); + self.counters + .purge_entries + .fetch_add(affected as u64, Ordering::Relaxed); + Ok(InvalidationResult { + operation: request.operation, + selector: request.selector, + value: matcher.value, + host: matcher.host, + vhost: matcher.vhost, + affected_entries: affected, + ban_id: None, + elapsed_ms: started.elapsed().as_millis().min(u64::MAX as u128) as u64, + status: "completed", + }) + } + } + } + + fn purge_matcher(&self, matcher: &SelectorMatcher) -> usize { + let mut base_keys = HashSet::new(); + { + let store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + for (key, entry) in &store.entries { + if matcher_matches( + matcher, + key, + &entry.base_key, + &entry.scope, + &entry.scheme, + &entry.authority, + &entry.domain, + &entry.target, + &entry.tags, + ) { + base_keys.insert(entry.base_key); + } + } + } + { + let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + for (key, entry) in &cold.entries { + if matcher_matches( + matcher, + key, + &entry.base_key, + &entry.scope, + &entry.scheme, + &entry.authority, + &entry.domain, + &entry.target, + &entry.tags, + ) { + base_keys.insert(entry.base_key); + } + } + } + let matching_flights = { + let flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner()); + flights + .iter() + .filter_map(|(key, flight)| { + let empty_tags: &[String] = &[]; + matcher_matches( + matcher, + key, + key, + &flight.scope, + &flight.scheme, + &flight.authority, + &crate::config::request_host(&flight.authority) + .unwrap_or_else(|_| flight.authority.clone()), + &flight.target, + empty_tags, + ) + .then_some((*key, flight.id)) + }) + .collect::>() + }; + for (key, _) in &matching_flights { + base_keys.insert(*key); + } + if !base_keys.is_empty() { + let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + for key in &base_keys { + let generation = store.key_generations.entry(*key).or_default(); + *generation = generation.wrapping_add(1); + } + } + + let hot_removed = { + let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + let keys = store + .entries + .iter() + .filter_map(|(key, entry)| base_keys.contains(&entry.base_key).then_some(*key)) + .collect::>(); + for key in &keys { + store.remove(key); + } + keys.len() + }; + let mut delete = Vec::new(); + let cold_removed = { + let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + let keys = cold + .entries + .iter() + .filter_map(|(key, entry)| base_keys.contains(&entry.base_key).then_some(*key)) + .collect::>(); + for key in &keys { + if let Some(entry) = cold.remove(key) { + delete.extend([entry.meta_path, entry.body_path]); + } + } + keys.len() + }; + if !delete.is_empty() { + if let Some(tx) = &self.cold_tx { + let _ = tx.try_send(ColdCommand::Delete(delete)); + } + } + let flights = { + let mut flights = self.fill_flights.lock().unwrap_or_else(|e| e.into_inner()); + matching_flights + .into_iter() + .filter_map(|(key, id)| { + if flights.get(&key).is_some_and(|flight| flight.id == id) { + flights.remove(&key) + } else { + None + } + }) + .collect::>() + }; + for flight in flights { + flight.done.send_replace(true); + } + if hot_removed > 0 { + release_allocator_memory(); + } + hot_removed + cold_removed + } + + pub fn gc_banned(&self, limit: usize) -> usize { + if limit == 0 { + return 0; + } + let rules = self.active_bans(); + if rules.is_empty() { + return 0; + } + let now = SystemTime::now(); + let mut removed = 0usize; + { + let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + let keys = store + .entries + .iter() + .filter_map(|(key, entry)| { + (removed < limit && rules.iter().any(|rule| rule.matches_hot(key, entry, now))) + .then_some(*key) + }) + .take(limit) + .collect::>(); + for key in keys { + if store.remove(&key).is_some() { + removed += 1; + } + } + } + let mut delete = Vec::new(); + if removed < limit { + let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + let keys = cold + .entries + .iter() + .filter_map(|(key, entry)| { + rules + .iter() + .any(|rule| rule.matches_cold(key, entry, now)) + .then_some(*key) + }) + .take(limit - removed) + .collect::>(); + for key in keys { + if let Some(entry) = cold.remove(&key) { + delete.extend([entry.meta_path, entry.body_path]); + removed += 1; + } + } + } + if !delete.is_empty() { + if let Some(tx) = &self.cold_tx { + let _ = tx.try_send(ColdCommand::Delete(delete)); + } + } + if removed > 0 { + self.counters + .ban_matches + .fetch_add(removed as u64, Ordering::Relaxed); + release_allocator_memory(); + } + removed + } +} diff --git a/src/cache/lifecycle.rs b/src/cache/lifecycle.rs new file mode 100644 index 0000000..d510f74 --- /dev/null +++ b/src/cache/lifecycle.rs @@ -0,0 +1,281 @@ +impl Cache { + pub fn new(revision: u64) -> Arc { + Arc::new(Self { + store: Arc::new(Mutex::new(Store { + revision, + ..Default::default() + })), + revision: AtomicU64::new(revision), + cold: Arc::new(Mutex::new(ColdStore::default())), + tier_settings: Mutex::new(Settings::default()), + data_root: None, + cold_tx: None, + fills: Arc::new(Semaphore::new(FILL_SLOTS)), + fill_bytes_active: AtomicU64::new(0), + transient_bytes_active: Arc::new(AtomicU64::new(0)), + cold_queue_items: AtomicU64::new(0), + cold_queue_bytes: AtomicU64::new(0), + cold_write_bytes_active: AtomicU64::new(0), + cold_read_bytes_active: AtomicU64::new(0), + revalidating: Mutex::new(HashSet::new()), + fill_flights: Mutex::new(HashMap::new()), + fill_flight_sequence: AtomicU64::new(0), + collapse_waiters: AtomicU64::new(0), + bans: Mutex::new(Vec::new()), + ban_sequence: AtomicU64::new(0), + proactive_scheduled_keys: Mutex::new(HashSet::new()), + proactive_inflight: AtomicU64::new(0), + cleanup_jobs: Mutex::new(HashMap::new()), + cleanup_sequence: AtomicU64::new(0), + counters: Counters::default(), + }) + } + pub fn start( + revision: u64, + data_root: Option, + settings: Settings, + shutdown: CancellationToken, + ) -> Arc { + let (tx, rx) = mpsc::channel(COLD_QUEUE); + let cache = Arc::new(Self { + store: Arc::new(Mutex::new(Store { + revision, + ..Default::default() + })), + revision: AtomicU64::new(revision), + cold: Arc::new(Mutex::new(ColdStore::default())), + tier_settings: Mutex::new(settings), + data_root, + cold_tx: Some(tx), + fills: Arc::new(Semaphore::new(FILL_SLOTS)), + fill_bytes_active: AtomicU64::new(0), + transient_bytes_active: Arc::new(AtomicU64::new(0)), + cold_queue_items: AtomicU64::new(0), + cold_queue_bytes: AtomicU64::new(0), + cold_write_bytes_active: AtomicU64::new(0), + cold_read_bytes_active: AtomicU64::new(0), + revalidating: Mutex::new(HashSet::new()), + fill_flights: Mutex::new(HashMap::new()), + fill_flight_sequence: AtomicU64::new(0), + collapse_waiters: AtomicU64::new(0), + bans: Mutex::new(Vec::new()), + ban_sequence: AtomicU64::new(0), + proactive_scheduled_keys: Mutex::new(HashSet::new()), + proactive_inflight: AtomicU64::new(0), + cleanup_jobs: Mutex::new(HashMap::new()), + cleanup_sequence: AtomicU64::new(0), + counters: Counters::default(), + }); + cache.load_cold_startup(); + let weak = Arc::downgrade(&cache); + let worker_shutdown = shutdown.clone(); + tokio::spawn(async move { Self::cold_worker(weak, rx, worker_shutdown).await }); + let weak = Arc::downgrade(&cache); + let maintenance_shutdown = shutdown.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(Duration::from_secs(5)); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + _ = maintenance_shutdown.cancelled() => break, + _ = tick.tick() => { + let Some(cache) = weak.upgrade() else { break; }; + cache.demote_idle(); + cache.sweep_cold().await; + cache.gc_banned(256); + } + } + } + }); + let weak = Arc::downgrade(&cache); + tokio::spawn(async move { + loop { + let Some(cache) = weak.upgrade() else { break; }; + let interval = cache + .tier_settings + .lock() + .unwrap_or_else(|e| e.into_inner()) + .cold_gc_interval_seconds; + drop(cache); + tokio::select! { + _ = shutdown.cancelled() => break, + _ = tokio::time::sleep(Duration::from_secs(interval)) => { + let Some(cache) = weak.upgrade() else { break; }; + cache.cold_gc_pass().await; + } + } + } + }); + cache + } + fn fill_headroom_bytes(max_bytes: usize) -> usize { + // Allocation rounding is allowed a small global cushion, never a whole + // max_object allocation. Use roughly 1/64 of HOT, cap it at 1 MiB and + // never let the cushion itself exceed 1/8 of a very small HOT budget. + let proportional = (max_bytes / 64).clamp(4 * 1024, 1024 * 1024); + proportional.min((max_bytes / 8).max(1)) + } + + fn try_reserve_transient(&self, bytes: usize, settings: &Settings) -> Option { + let bytes = u64::try_from(bytes).ok()?; + let limit = u64::try_from(settings.transient_max_bytes).unwrap_or(u64::MAX); + loop { + let current = self.transient_bytes_active.load(Ordering::Relaxed); + let next = current.checked_add(bytes)?; + if next > limit { + self.counters + .memory_pressure_rejections + .fetch_add(1, Ordering::Relaxed); + return None; + } + if self + .transient_bytes_active + .compare_exchange_weak(current, next, Ordering::AcqRel, Ordering::Relaxed) + .is_ok() + { + return Some(TransientLease(Arc::new(TransientLeaseInner { + counter: self.transient_bytes_active.clone(), + bytes, + }))); + } + } + } + + /// Reserve real allocation for an in-progress fill before growing its buffer. + /// Existing HOT objects are LRU-evicted/demoted first so HOT + active fill + /// allocation cannot race arbitrarily above the configured HOT budget. + fn reserve_fill_allocation(&self, bytes: usize, settings: &Settings) -> Option { + if bytes == 0 { + return self.try_reserve_transient(0, settings); + } + let lease = self.try_reserve_transient(bytes, settings)?; + let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + let fill_now = usize::try_from(self.fill_bytes_active.load(Ordering::Relaxed)) + .unwrap_or(usize::MAX); + let allowed = settings + .max_bytes + .saturating_add(Self::fill_headroom_bytes(settings.max_bytes)); + let revision = store.revision; + let generation = store.generation; + let mut removed = 0usize; + let mut released_hot = false; + while store + .bytes + .saturating_add(fill_now) + .saturating_add(bytes) + > allowed + { + if removed >= settings.hot_eviction_max_objects { + self.counters + .hot_eviction_limited + .fetch_add(1, Ordering::Relaxed); + break; + } + let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) + else { + break; + }; + let Some(old) = store.remove(&key) else { + continue; + }; + removed = removed.saturating_add(1); + released_hot = true; + let scope_generation = store + .scope_generations + .get(&old.scope) + .copied() + .unwrap_or(0); + let key_generation = store + .key_generations + .get(&old.base_key) + .copied() + .unwrap_or(0); + if !(settings.cold_enabled + && settings.cold_on_lru + && self.enqueue_demotion( + key, + old, + revision, + generation, + scope_generation, + key_generation, + )) + { + self.counters.evictions.fetch_add(1, Ordering::Relaxed); + } + } + let fits = store + .bytes + .saturating_add(fill_now) + .saturating_add(bytes) + <= allowed; + if fits { + self.fill_bytes_active + .fetch_add(bytes as u64, Ordering::Relaxed); + } + drop(store); + if released_hot { + release_allocator_memory(); + } + if fits { + Some(lease) + } else { + self.counters + .memory_pressure_rejections + .fetch_add(1, Ordering::Relaxed); + drop(lease); + None + } + } + + pub fn configure(&self, settings: Settings) { + *self.tier_settings.lock().unwrap_or_else(|e| e.into_inner()) = settings.clone(); + // Hot-applied limit reductions take effect immediately for published HOT + // objects. Demotion is best effort and remains bounded by transient/COLD + // queue budgets; otherwise the LRU object is dropped. + let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + let revision = store.revision; + let generation = store.generation; + let mut released_hot = false; + while store.entries.len() > settings.max_entries || store.bytes > settings.max_bytes { + let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) + else { + break; + }; + let Some(old) = store.remove(&key) else { + continue; + }; + released_hot = true; + let scope_generation = store + .scope_generations + .get(&old.scope) + .copied() + .unwrap_or(0); + let key_generation = store + .key_generations + .get(&old.base_key) + .copied() + .unwrap_or(0); + if !(settings.cold_enabled + && settings.cold_on_lru + && self.enqueue_demotion( + key, + old, + revision, + generation, + scope_generation, + key_generation, + )) + { + self.counters.evictions.fetch_add(1, Ordering::Relaxed); + } + } + drop(store); + if released_hot { + release_allocator_memory(); + } + } + pub fn revision(&self) -> u64 { + self.revision.load(Ordering::Relaxed) + } +} diff --git a/src/cache/lookup.rs b/src/cache/lookup.rs new file mode 100644 index 0000000..3daea2e --- /dev/null +++ b/src/cache/lookup.rs @@ -0,0 +1,644 @@ +impl Cache { + fn get_hot(&self, ticket: &Ticket, count_miss: bool) -> Option { + let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + let expired = store.sweep(); + self.counters + .expired + .fetch_add(expired as u64, Ordering::Relaxed); + if store.revision != ticket.revision + || store.generation != ticket.generation + || store + .scope_generations + .get(&ticket.scope) + .copied() + .unwrap_or(0) + != ticket.scope_generation + || store + .key_generations + .get(&ticket.key) + .copied() + .unwrap_or(0) + != ticket.key_generation + { + return None; + } + if let Some(storage_key) = matching_hot_key(&store, ticket) { + let Some(mut entry) = store.remove(&storage_key) else { + return None; + }; + if self.hot_entry_banned(&storage_key, &entry) { + if count_miss { + self.counters.misses.fetch_add(1, Ordering::Relaxed); + } + drop(store); + release_allocator_memory(); + return None; + } + let resident_age_seconds = hot_resident_age(&entry).as_secs(); + let age = entry + .initial_age + .saturating_add(entry.received.elapsed()) + .as_secs(); + let state = cache_state( + age, + entry.ttl, + entry.stale_while_revalidate, + entry.must_revalidate, + entry.proxy_revalidate, + entry.requires_revalidation, + &ticket.request_headers, + ); + if state.servable() { + entry.hits = entry.hits.saturating_add(1); + entry.last_access = Instant::now(); + entry.sequence = store.next_sequence(); + let mut response = Response::new(Body::from(entry.body.clone())); + *response.headers_mut() = entry.headers.clone(); + if entry.transformed { + response + .extensions_mut() + .insert(crate::transform::CachedTransformed); + } + if entry.skip_compression { + response.extensions_mut().insert(crate::compression::Skip); + } + response.headers_mut().insert( + "age", + HeaderValue::from_str(&age.to_string()).expect("integer header"), + ); + let result = Hit { + response, + body: entry.body.clone(), + hits: entry.hits, + transformed: entry.transformed, + remaining_ttl: entry.ttl.saturating_sub(age), + resident_age_seconds, + stale: state.stale(), + background_revalidate: state.background_revalidate(), + }; + if let Some(c) = store.domain(&entry.scope, &entry.domain) { + c.saved_body_bytes = c.saved_body_bytes.saturating_add(entry.body.len() as u64); + } + store.bytes = store.bytes.saturating_add(entry.cost); + store.logical_bytes = store.logical_bytes.saturating_add(entry.body.len()); + store.lru.insert(entry.sequence, storage_key); + store.index_variant(entry.base_key, storage_key); + store.entries.insert(storage_key, entry); + self.counters.hits.fetch_add(1, Ordering::Relaxed); + return Some(result); + } + store.bytes = store.bytes.saturating_add(entry.cost); + store.logical_bytes = store.logical_bytes.saturating_add(entry.body.len()); + store.lru.insert(entry.sequence, storage_key); + store.index_variant(entry.base_key, storage_key); + store.entries.insert(storage_key, entry); + } + if count_miss { + self.counters.misses.fetch_add(1, Ordering::Relaxed); + } + None + } + pub fn get(&self, ticket: &Ticket) -> Option { + self.get_hot(ticket, true) + } + async fn get_cold(&self, ticket: &Ticket, settings: &Settings) -> Option { + if !self.ticket_current(ticket) { + return None; + } + let banned = { + let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + matching_cold_key(&cold, ticket).and_then(|storage_key| { + let banned = cold + .entries + .get(&storage_key) + .is_some_and(|entry| self.cold_entry_banned(&storage_key, entry)); + banned.then(|| cold.remove(&storage_key)).flatten() + }) + }; + if let Some(entry) = banned { + self.delete_cold_entry(entry); + return None; + } + let (storage_key, entry) = { + let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + let storage_key = matching_cold_key(&cold, ticket)?; + let old_sequence = { + let entry = cold.entries.get(&storage_key)?; + if entry.revision != ticket.revision || entry.scope != ticket.scope { + return None; + } + let state = cache_state( + cold_age(entry), + entry.ttl, + entry.stale_while_revalidate, + entry.must_revalidate, + entry.proxy_revalidate, + entry.requires_revalidation, + &ticket.request_headers, + ); + if !state.servable() { + return None; + } + entry.sequence + }; + let sequence = cold.next_sequence(); + cold.lru.remove(&old_sequence); + cold.lru.insert(sequence, storage_key); + let entry = cold.entries.get_mut(&storage_key)?; + entry.sequence = sequence; + entry.last_access = SystemTime::now(); + entry.pinned_reads = entry.pinned_reads.saturating_add(1); + (storage_key, entry.clone()) + }; + let _pin = ColdReadGuard { + cache: self, + key: storage_key, + generation: entry.generation, + }; + if entry.body_bytes > settings.max_object_bytes { + self.counters + .memory_pressure_rejections + .fetch_add(1, Ordering::Relaxed); + return None; + } + let Some(read_lease) = self.try_reserve_transient(entry.body_bytes, settings) else { + return None; + }; + let _cold_read = ByteGaugeGuard::new(&self.cold_read_bytes_active, entry.body_bytes as u64); + let body_vec = match tokio::fs::read(&entry.body_path).await { + Ok(body) if cold_body_matches(&entry, &body) => body, + _ => { + self.counters.cold_errors.fetch_add(1, Ordering::Relaxed); + if let Some(entry) = self.take_cold(&storage_key) { + self.delete_cold_entry(entry); + } + return None; + } + }; + let body_allocated = body_vec.capacity().max(body_vec.len()); + let extra = body_allocated.saturating_sub(entry.body_bytes); + let extra_lease = if extra > 0 { + self.try_reserve_transient(extra, settings) + } else { + None + }; + if extra > 0 && extra_lease.is_none() { + return None; + } + let _cold_read_extra = extra_lease + .as_ref() + .map(|_| ByteGaugeGuard::new(&self.cold_read_bytes_active, extra as u64)); + let body = Bytes::from(body_vec); + if !self.ticket_current(ticket) { + return None; + } + let age = cold_age(&entry); + let state = cache_state( + age, + entry.ttl, + entry.stale_while_revalidate, + entry.must_revalidate, + entry.proxy_revalidate, + entry.requires_revalidation, + &ticket.request_headers, + ); + if !state.servable() { + return None; + } + let hits = entry.hits.saturating_add(1); + let cost = hot_entry_cost( + body_allocated, + &entry.headers, + &entry.vary, + &entry.scope, + &entry.scheme, + &entry.authority, + &entry.domain, + &entry.path, + &entry.target, + &entry.tags, + entry.origin_etag.as_ref(), + entry.origin_last_modified.as_ref(), + ); + let resident_age_seconds = cold_resident_age(&entry); + let hot_entry = Entry { + base_key: entry.base_key, + vary: entry.vary.clone(), + scope: entry.scope.clone(), + scheme: entry.scheme.clone(), + authority: entry.authority.clone(), + domain: entry.domain.clone(), + path: entry.path.clone(), + target: entry.target.clone(), + tags: entry.tags.clone(), + created_at: entry.created_at, + headers: entry.headers.clone(), + body: body.clone(), + body_allocated, + received: Instant::now(), + last_access: Instant::now(), + resident_since: Instant::now(), + resident_age_base: Duration::from_secs(resident_age_seconds), + initial_age: Duration::from_secs(age), + ttl: entry.ttl, + stale_while_revalidate: entry.stale_while_revalidate, + stale_if_error: entry.stale_if_error, + must_revalidate: entry.must_revalidate, + proxy_revalidate: entry.proxy_revalidate, + origin_etag: entry.origin_etag.clone(), + origin_last_modified: entry.origin_last_modified.clone(), + cost, + sequence: 0, + hits, + transformed: entry.transformed, + skip_compression: entry.skip_compression, + requires_revalidation: entry.requires_revalidation, + }; + if !self.insert_inner(ticket.clone(), hot_entry, settings, false) { + return None; + } + let mut response = Response::new(Body::from(body.clone())); + *response.headers_mut() = entry.headers; + if entry.transformed { + response + .extensions_mut() + .insert(crate::transform::CachedTransformed); + } + if entry.skip_compression { + response.extensions_mut().insert(crate::compression::Skip); + } + response.headers_mut().insert( + "age", + HeaderValue::from_str(&age.to_string()).expect("integer header"), + ); + { + let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(c) = store.domain(&ticket.scope, &ticket.domain) { + c.saved_body_bytes = c.saved_body_bytes.saturating_add(entry.body_bytes as u64); + } + } + self.counters.hits.fetch_add(1, Ordering::Relaxed); + self.counters.cold_hits.fetch_add(1, Ordering::Relaxed); + self.counters + .cold_promotions + .fetch_add(1, Ordering::Relaxed); + Some(Hit { + response, + body, + hits, + transformed: entry.transformed, + remaining_ttl: entry.ttl.saturating_sub(age), + resident_age_seconds, + stale: state.stale(), + background_revalidate: state.background_revalidate(), + }) + } + pub async fn get_tiered(&self, ticket: &Ticket, settings: &Settings) -> Option { + if let Some(hit) = self.get_hot(ticket, false) { + return Some(hit); + } + if let Some(hit) = self.get_cold(ticket, settings).await { + return Some(hit); + } + self.counters.misses.fetch_add(1, Ordering::Relaxed); + None + } + pub fn publish_revalidated( + &self, + ticket: Ticket, + cached: &Revalidation, + mut headers: HeaderMap, + validation_headers: &HeaderMap, + settings: &Settings, + response_delay: Duration, + ) -> Option { + let plan = plan(StatusCode::OK, &headers, settings, response_delay)?; + if cached.body.len() > settings.max_object_bytes { + return None; + } + if !headers.contains_key("date") { + if let Ok(value) = httpdate::fmt_http_date(SystemTime::now()).parse() { + headers.insert("date", value); + } + } + for name in [ + "x-request-id", + "x-cache", + "x-cache-hits", + "x-cache-status", + "x-cache-reason", + "cache-status", + ] { + headers.remove(name); + } + let hits = cached.hits.saturating_add(1); + let age = plan.initial_age.as_secs(); + let remaining_ttl = plan.ttl.saturating_sub(age); + let vary = plan.vary.clone(); + let tags = cache_tags(&headers); + let origin_etag = validation_headers + .get("etag") + .cloned() + .or_else(|| cached.origin_etag.clone()); + let origin_last_modified = validation_headers + .get("last-modified") + .cloned() + .or_else(|| cached.origin_last_modified.clone()); + let cost = hot_entry_cost( + cached.body_allocated, + &headers, + &vary, + &ticket.scope, + &ticket.scheme, + &ticket.authority, + &ticket.domain, + &ticket.path, + &ticket.target, + &tags, + origin_etag.as_ref(), + origin_last_modified.as_ref(), + ); + let storage_key = variant_key(&ticket.key, &vary, &ticket.request_headers); + let resident_age = cached.resident_age_at_lookup.saturating_add(response_delay); + let entry = Entry { + base_key: ticket.key, + vary, + scope: ticket.scope.clone(), + scheme: ticket.scheme.clone(), + authority: ticket.authority.clone(), + domain: ticket.domain.clone(), + path: ticket.path.clone(), + target: ticket.target.clone(), + tags, + created_at: ticket.created_at, + headers: headers.clone(), + body: cached.body.clone(), + body_allocated: cached.body_allocated, + received: plan.received, + last_access: Instant::now(), + resident_since: Instant::now(), + resident_age_base: resident_age, + initial_age: plan.initial_age, + ttl: plan.ttl, + stale_while_revalidate: plan.stale_while_revalidate, + stale_if_error: plan.stale_if_error, + must_revalidate: plan.must_revalidate, + proxy_revalidate: plan.proxy_revalidate, + origin_etag, + origin_last_modified, + cost, + sequence: 0, + hits, + transformed: cached.transformed, + skip_compression: cached.skip_compression, + requires_revalidation: plan.requires_revalidation, + }; + if !self.insert(ticket, entry, settings) { + return None; + } + if cached.storage_key != storage_key { + self.discard_storage_key(&cached.storage_key); + } + self.counters.hits.fetch_add(1, Ordering::Relaxed); + let mut response = cached.response(headers); + response.headers_mut().insert( + "age", + HeaderValue::from_str(&age.to_string()).expect("integer header"), + ); + Some(Hit { + response, + body: cached.body.clone(), + hits, + transformed: cached.transformed, + remaining_ttl, + resident_age_seconds: resident_age.as_secs(), + stale: false, + background_revalidate: false, + }) + } + + pub fn wrap( + self: &Arc, + response: Response, + ticket: Ticket, + plan: Plan, + settings: &Settings, + ) -> Response { + self.wrap_with_fill(response, ticket, plan, settings, None) + } + + pub fn wrap_with_fill( + self: &Arc, + response: Response, + ticket: Ticket, + plan: Plan, + settings: &Settings, + fill_leader: Option, + ) -> Response { + let fill_size = plan.expected_length.unwrap_or(settings.max_object_bytes); + let permits = if fill_size > 64 * 1024 * 1024 { + FILL_SLOTS as u32 + } else if fill_size > 16 * 1024 * 1024 { + 4 + } else if fill_size > 4 * 1024 * 1024 { + 2 + } else { + 1 + }; + let permit = self.fills.clone().try_acquire_many_owned(permits); + let Ok(permit) = permit else { + self.counters.rejected_fills.fetch_add(1, Ordering::Relaxed); + return response; + }; + let (parts, body) = response.into_parts(); + let headers = parts.headers.clone(); + let origin_validators = parts + .extensions + .get::() + .cloned(); + let origin_etag = match origin_validators.as_ref() { + Some(validators) => validators.etag.clone(), + None => headers.get("etag").cloned(), + }; + let origin_last_modified = match origin_validators.as_ref() { + Some(validators) => validators.last_modified.clone(), + None => headers.get("last-modified").cloned(), + }; + let initial_capacity = fill_size.min(64 * 1024); + let Some(mut fill_gauge) = FillGauge::new(self.clone(), initial_capacity, settings) else { + self.counters.rejected_fills.fetch_add(1, Ordering::Relaxed); + self.record_reason(&ticket.scope, &ticket.domain, "hot_memory_pressure"); + return Response::from_parts(parts, body); + }; + let data = Vec::with_capacity(initial_capacity); + let actual_capacity = data.capacity(); + if actual_capacity > initial_capacity + && !fill_gauge.grow(actual_capacity - initial_capacity, settings) + { + self.counters.rejected_fills.fetch_add(1, Ordering::Relaxed); + self.record_reason(&ticket.scope, &ticket.domain, "fill_allocator_overshoot"); + return Response::from_parts(parts, body); + } + let candidate = Candidate { + cache: self.clone(), + ticket, + plan, + headers, + data, + fill_gauge, + settings: settings.clone(), + skip_compression: parts.extensions.get::().is_some(), + origin_etag, + origin_last_modified, + transformed: parts.extensions.get::().is_some(), + _permit: permit, + _fill_leader: fill_leader, + }; + Response::from_parts( + parts, + Body::new(FillBody { + inner: body, + candidate: Some(candidate), + done: false, + }), + ) + } + fn insert(&self, ticket: Ticket, entry: Entry, settings: &Settings) -> bool { + self.insert_inner(ticket, entry, settings, true) + } + fn insert_inner( + &self, + ticket: Ticket, + mut entry: Entry, + settings: &Settings, + count_stored: bool, + ) -> bool { + entry.base_key = ticket.key; + let storage_key = variant_key(&ticket.key, &entry.vary, &ticket.request_headers); + let vary_schema = entry.vary.clone(); + if self.entry_banned_before_insert(&storage_key, &entry) { + self.counters.ban_matches.fetch_add(1, Ordering::Relaxed); + return false; + } + if entry.cost > settings.max_bytes + || (!entry.requires_revalidation + && entry.initial_age.saturating_add(entry.received.elapsed()).as_secs() + >= retention_deadline( + entry.ttl, + entry.stale_while_revalidate, + entry.stale_if_error, + entry.origin_etag.is_some() || entry.origin_last_modified.is_some(), + )) + { + return false; + } + let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + if store.revision != ticket.revision + || store.generation != ticket.generation + || store + .scope_generations + .get(&ticket.scope) + .copied() + .unwrap_or(0) + != ticket.scope_generation + || store + .key_generations + .get(&ticket.key) + .copied() + .unwrap_or(0) + != ticket.key_generation + { + return false; + } + // Do not keep objects for incompatible Vary schemas under the same + // Varnish-style host+URL hash. A newer representation policy replaces + // the older schema while preserving sibling values of the same Vary set. + for key in incompatible_hot_variants(&store, &ticket.key, &vary_schema) { + store.remove(&key); + } + store.remove(&storage_key); + self.counters + .expired + .fetch_add(store.sweep() as u64, Ordering::Relaxed); + let revision = store.revision; + let generation = store.generation; + let mut released_hot = false; + let mut removed = 0usize; + while store.entries.len() >= settings.max_entries + || store.bytes.saturating_add(entry.cost) > settings.max_bytes + { + if removed >= settings.hot_eviction_max_objects { + self.counters + .hot_eviction_limited + .fetch_add(1, Ordering::Relaxed); + break; + } + let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) else { + break; + }; + if let Some(old) = store.remove(&key) { + removed = removed.saturating_add(1); + released_hot = true; + let scope_generation = store + .scope_generations + .get(&old.scope) + .copied() + .unwrap_or(0); + let key_generation = store.key_generations.get(&old.base_key).copied().unwrap_or(0); + if !(settings.cold_enabled + && settings.cold_on_lru + && self.enqueue_demotion(key, old, revision, generation, scope_generation, key_generation)) + { + self.counters.evictions.fetch_add(1, Ordering::Relaxed); + } + } + } + if store.entries.len() >= settings.max_entries + || store.bytes.saturating_add(entry.cost) > settings.max_bytes + { + self.counters + .memory_pressure_rejections + .fetch_add(1, Ordering::Relaxed); + drop(store); + if released_hot { + release_allocator_memory(); + } + return false; + } + if count_stored { + if let Some(c) = store.domain(&entry.scope, &entry.domain) { + c.stored += 1; + } + } + entry.last_access = Instant::now(); + entry.sequence = store.next_sequence(); + store.bytes = store.bytes.saturating_add(entry.cost); + store.logical_bytes = store.logical_bytes.saturating_add(entry.body.len()); + store.lru.insert(entry.sequence, storage_key); + store.index_variant(entry.base_key, storage_key); + store.entries.insert(storage_key, entry); + drop(store); + if released_hot { + release_allocator_memory(); + } + let mut cold_delete = Vec::new(); + { + let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + for key in incompatible_cold_variants(&cold, &ticket.key, &vary_schema) { + if let Some(old) = cold.remove(&key) { + cold_delete.push(old); + } + } + if let Some(old) = cold.remove(&storage_key) { + cold_delete.push(old); + } + } + for old in cold_delete { + self.delete_cold_entry(old); + } + if count_stored { + self.counters.stored.fetch_add(1, Ordering::Relaxed); + } + true + } +} diff --git a/src/cache/mod.rs b/src/cache/mod.rs new file mode 100644 index 0000000..a024f23 --- /dev/null +++ b/src/cache/mod.rs @@ -0,0 +1,18 @@ +//! Bounded, conservative shared two-tier cache. Fresh objects live in a fast RAM +//! hot tier; optionally, idle/LRU objects are persisted in a bounded cold tier. +//! Bodies are streamed while a bounded copy is collected; only complete successful +//! bodies are published. The implementation is split by responsibility so storage, +//! policy and streaming-fill code can be reviewed independently. + +include!("model.rs"); +include!("lifecycle.rs"); +include!("cold.rs"); +include!("dashboard.rs"); +include!("tickets.rs"); +include!("collapse.rs"); +include!("range.rs"); +include!("invalidation.rs"); +include!("lookup.rs"); +include!("policy.rs"); +include!("fill.rs"); +include!("tests.rs"); diff --git a/src/cache/model.rs b/src/cache/model.rs new file mode 100644 index 0000000..2f90e87 --- /dev/null +++ b/src/cache/model.rs @@ -0,0 +1,1668 @@ +use anyhow::{ensure, Result}; +use axum::{body::Body, response::Response}; +use bytes::Bytes; +use http::{HeaderMap, HeaderValue, Method, StatusCode}; +use http_body::{Body as HttpBody, Frame, SizeHint}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + path::{Component, Path, PathBuf}, + pin::Pin, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, + }, + task::{Context, Poll}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use tokio::sync::{mpsc, OwnedSemaphorePermit, Semaphore}; +use tokio_util::sync::CancellationToken; + +const MAX_HEADER_BYTES: usize = 32 * 1024; +const FILL_SLOTS: usize = 8; +// Cold writes can contain complete response bodies. Keep the queue deliberately +// small so demotion cannot become a second, hidden RAM cache while disk is slow. +const COLD_QUEUE: usize = 16; +const COLD_FORMAT_VERSION: u8 = 3; +type Key = [u8; 32]; + +/// `Bytes` releases its allocation when the last reference is dropped, but +/// glibc can keep large freed arenas mapped. After bulk cache demotions/purges, +/// return releasable pages to the OS instead of letting RSS stay artificially +/// high. This is intentionally a no-op on other allocators/platforms. +fn release_allocator_memory() { + #[cfg(all(target_os = "linux", target_env = "gnu"))] + unsafe { + libc::malloc_trim(0); + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CollapseTimeoutFallback { + Origin, + StaleIfAvailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StaleErrorClass { + Timeout, + Connection, + Dns, + Tls, +} + +impl Default for CollapseTimeoutFallback { + fn default() -> Self { + Self::Origin + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + pub enabled: bool, + /// Hard RAM budget for published HOT entries, charged by backing allocation + /// plus directly owned metadata rather than only logical body length. + pub max_bytes: usize, + /// Maximum logical size of a single cacheable object body. + pub max_object_bytes: usize, + /// Hot tier object count. + pub max_entries: usize, + /// Bounded Varnish-style transient RAM used by in-progress fills and full + /// bodies moving between HOT and COLD. This is independent of `max_bytes`. + pub transient_max_bytes: usize, + /// Byte ceiling for demoted objects waiting in the COLD writer queue. + pub cold_queue_max_bytes: usize, + /// Maximum number of LRU objects removed during one admission attempt. + /// Reaching the limit makes the new object pass through uncached. + pub hot_eviction_max_objects: usize, + /// Persistent cold tier. Disabled by default to avoid implicit disk writes. + pub cold_enabled: bool, + /// Relative to the RustProxy data directory. + pub cold_path: String, + pub cold_max_bytes: u64, + pub cold_max_entries: usize, + /// Interval between asynchronous cold-cache GC checks. + pub cold_gc_interval_seconds: u64, + /// Maximum number of objects removed by one GC pass. + pub cold_gc_max_delete: usize, + /// Hot objects not touched for this long are demoted to disk. + pub cold_after_seconds: u64, + /// Demote objects which have been idle for `cold_after_seconds`. + pub cold_on_idle: bool, + /// Demote objects after this much continuous residency in the hot tier + /// when `cold_on_age` is enabled, even when they are still being read. + pub cold_max_hot_seconds: u64, + /// Apply `cold_max_hot_seconds` as a separate hot-tier age policy. + pub cold_on_age: bool, + /// Preserve objects evicted from the hot LRU in the cold tier. + pub cold_on_lru: bool, + /// Best-effort flush of hot objects to cold storage on graceful shutdown. + pub cold_on_shutdown: bool, + /// Only used for responses explicitly marked public and without origin TTL. + pub default_ttl_seconds: u64, + pub max_ttl_seconds: u64, + pub bypass_query: bool, + pub bypass_prefixes: Vec, + /// For explicitly selected static extensions, ignore request Cookie when + /// deciding cache eligibility and when building the shared cache key. + pub static_ignore_cookies: bool, + pub static_extensions: Vec, + /// Collapse concurrent foreground cache MISS/revalidation fills for the same public cache key. + pub collapse_enabled: bool, + /// Maximum time a follower waits for the current fill before applying the configured fallback. + pub collapse_wait_timeout_ms: u64, + pub collapse_timeout_fallback: CollapseTimeoutFallback, + /// Fine-grained stale-if-error classes. Defaults preserve the 0.21 behavior. + pub stale_if_error_timeout: bool, + pub stale_if_error_connection: bool, + pub stale_if_error_dns: bool, + pub stale_if_error_tls: bool, + pub stale_if_error_statuses: Vec, + /// Schedule bounded validator refresh before normal freshness expires. + pub proactive_refresh_enabled: bool, + pub proactive_refresh_before_expiry_seconds: u64, + pub proactive_refresh_percent: u8, + pub proactive_refresh_max_inflight: usize, + pub expose_status: bool, + pub expose_hits: bool, + pub expose_cache_status: bool, + pub expose_resident_age: bool, +} +impl Default for Settings { + fn default() -> Self { + Self { + enabled: true, + max_bytes: 256 * 1024 * 1024, + max_object_bytes: 16 * 1024 * 1024, + max_entries: 4096, + transient_max_bytes: 64 * 1024 * 1024, + cold_queue_max_bytes: 32 * 1024 * 1024, + hot_eviction_max_objects: 64, + cold_enabled: false, + cold_path: "cache/cold".into(), + cold_max_bytes: 4 * 1024 * 1024 * 1024, + cold_max_entries: 50_000, + cold_gc_interval_seconds: 60, + cold_gc_max_delete: 256, + cold_after_seconds: 300, + cold_on_idle: true, + cold_max_hot_seconds: 3600, + cold_on_age: false, + cold_on_lru: true, + cold_on_shutdown: true, + default_ttl_seconds: 0, + max_ttl_seconds: 86_400, + bypass_query: false, + bypass_prefixes: Vec::new(), + // Static assets are normally representation-identical for logged-in and + // anonymous visitors. Keep this enabled by default once the shared cache + // itself is enabled; response-side privacy guards still reject Set-Cookie, + // private/no-store and Vary: Cookie before anything is stored. + static_ignore_cookies: true, + static_extensions: vec![ + // styles / scripts + "css", "js", "mjs", "cjs", "wasm", // images + "png", "jpg", "jpeg", "jfif", "jxl", "gif", "webp", "avif", "apng", "svg", "ico", + "cur", "bmp", "tif", "tiff", "heic", "heif", // fonts + "woff", "woff2", "ttf", "otf", "eot", // video + "mp4", "webm", "ogv", "mov", "m4v", // audio + "mp3", "ogg", "oga", "wav", "m4a", "aac", "flac", "opus", + // common static downloads + "pdf", "txt", "zip", "gz", "tgz", "bz2", "xz", "7z", "rar", "tar", + ] + .into_iter() + .map(str::to_owned) + .collect(), + collapse_enabled: true, + collapse_wait_timeout_ms: 1500, + collapse_timeout_fallback: CollapseTimeoutFallback::Origin, + stale_if_error_timeout: true, + stale_if_error_connection: true, + stale_if_error_dns: true, + stale_if_error_tls: true, + stale_if_error_statuses: vec![500, 502, 503, 504], + proactive_refresh_enabled: false, + proactive_refresh_before_expiry_seconds: 15, + proactive_refresh_percent: 10, + proactive_refresh_max_inflight: 4, + expose_status: false, + expose_hits: false, + expose_cache_status: false, + expose_resident_age: false, + } + } +} +impl Settings { + pub fn validate(&self) -> Result<()> { + ensure!( + self.max_bytes >= 65_536, + "cache.max_bytes must be at least 64 KiB" + ); + ensure!( + self.max_object_bytes >= 1024 && self.max_object_bytes <= self.max_bytes, + "cache.max_object_bytes must be at least 1 KiB and no larger than max_bytes" + ); + ensure!( + (1..=16_384).contains(&self.max_entries), + "cache.max_entries must be 1..16384" + ); + ensure!( + self.transient_max_bytes >= 65_536, + "cache.transient_max_bytes must be at least 64 KiB" + ); + ensure!( + self.cold_queue_max_bytes >= 65_536 + && self.cold_queue_max_bytes <= self.transient_max_bytes, + "cache.cold_queue_max_bytes must be at least 64 KiB and no larger than transient_max_bytes" + ); + ensure!( + (1..=4096).contains(&self.hot_eviction_max_objects), + "cache.hot_eviction_max_objects must be 1..4096" + ); + ensure!( + (65_536..=1024_u64 * 1024 * 1024 * 1024).contains(&self.cold_max_bytes), + "cache.cold_max_bytes must be 64 KiB..1 TiB" + ); + ensure!( + (1..=1_000_000).contains(&self.cold_max_entries), + "cache.cold_max_entries must be 1..1000000" + ); + ensure!( + (5..=86_400).contains(&self.cold_gc_interval_seconds), + "cache.cold_gc_interval_seconds must be 5..86400" + ); + ensure!( + (1..=10_000).contains(&self.cold_gc_max_delete), + "cache.cold_gc_max_delete must be 1..10000" + ); + ensure!( + (5..=604_800).contains(&self.cold_after_seconds), + "cache.cold_after_seconds must be 5..604800" + ); + ensure!( + (5..=2_592_000).contains(&self.cold_max_hot_seconds), + "cache.cold_max_hot_seconds must be 5..2592000" + ); + ensure!( + valid_relative_cache_path(&self.cold_path), + "cache.cold_path must be a simple relative path inside the data directory" + ); + ensure!( + (1..=604_800).contains(&self.max_ttl_seconds) + && self.default_ttl_seconds <= self.max_ttl_seconds, + "cache TTL must be at most seven days; default TTL cannot exceed maximum" + ); + ensure!( + self.bypass_prefixes.len() <= 64, + "at most 64 cache bypass prefixes" + ); + for prefix in &self.bypass_prefixes { + ensure!( + prefix.len() <= 512 + && prefix.starts_with('/') + && crate::acl::routing_path(prefix)? == *prefix, + "cache bypass prefixes must be normalized absolute paths" + ); + } + ensure!( + self.static_extensions.len() <= 64, + "at most 64 static cache extensions" + ); + let mut extensions = std::collections::HashSet::new(); + for extension in &self.static_extensions { + ensure!( + !extension.is_empty() + && extension.len() <= 16 + && extension.bytes().all(|b| b.is_ascii_alphanumeric()), + "static cache extensions must be 1..16 ASCII letters or numbers without a dot" + ); + ensure!( + extensions.insert(extension.to_ascii_lowercase()), + "duplicate static cache extension" + ); + } + ensure!( + (10..=60_000).contains(&self.collapse_wait_timeout_ms), + "cache.collapse_wait_timeout_ms must be 10..60000" + ); + ensure!( + self.stale_if_error_statuses.len() <= 32, + "cache.stale_if_error_statuses supports at most 32 statuses" + ); + let mut statuses = std::collections::HashSet::new(); + for status in &self.stale_if_error_statuses { + ensure!( + (500..=599).contains(status), + "cache.stale_if_error_statuses must contain HTTP 5xx status codes" + ); + ensure!(statuses.insert(*status), "duplicate stale-if-error HTTP status"); + } + ensure!( + (1..=604_800).contains(&self.proactive_refresh_before_expiry_seconds), + "cache.proactive_refresh_before_expiry_seconds must be 1..604800" + ); + ensure!( + self.proactive_refresh_percent <= 100, + "cache.proactive_refresh_percent must be 0..100" + ); + ensure!( + (1..=64).contains(&self.proactive_refresh_max_inflight), + "cache.proactive_refresh_max_inflight must be 1..64" + ); + Ok(()) + } + pub fn active_for(&self, route: Option) -> bool { + self.enabled && route != Some(false) + } + pub fn allows_stale_error_class(&self, class: StaleErrorClass) -> bool { + match class { + StaleErrorClass::Timeout => self.stale_if_error_timeout, + StaleErrorClass::Connection => self.stale_if_error_connection, + StaleErrorClass::Dns => self.stale_if_error_dns, + StaleErrorClass::Tls => self.stale_if_error_tls, + } + } + pub fn allows_stale_error_status(&self, status: StatusCode) -> bool { + self.stale_if_error_statuses.contains(&status.as_u16()) + } + pub fn ignore_cookie_for_path(&self, path: &str) -> bool { + if !self.static_ignore_cookies { + return false; + } + let segment = path.rsplit('/').next().unwrap_or(path); + let Some((_, extension)) = segment.rsplit_once('.') else { + return false; + }; + !extension.is_empty() + && self + .static_extensions + .iter() + .any(|item| item.eq_ignore_ascii_case(extension)) + } + pub fn annotate(&self, headers: &mut HeaderMap, outcome: &'static str, hits: u64, ttl: u64) { + // Origin values must never masquerade as this proxy's diagnostics. + for name in [ + "x-cache", + "x-cache-hits", + "x-cache-status", + "cache-status", + "x-cache-reason", + "x-cache-resident-age", + ] { + headers.remove(name); + } + // A vhost that explicitly disables cache must look like a normal reverse proxy. + // Keep counters internally, but never expose cache diagnostics for that response. + if !self.enabled { + return; + } + if self.expose_status { + headers.insert("x-cache", HeaderValue::from_static(outcome)); + } + if self.expose_hits { + headers.insert( + "x-cache-hits", + HeaderValue::from_str(&hits.to_string()).expect("integer header"), + ); + } + if self.expose_cache_status { + let value = match outcome { + "HIT" => format!("RustProxy; hit; ttl={ttl}"), + "MISS" => "RustProxy; fwd=uri-miss".into(), + _ => "RustProxy; fwd=bypass".into(), + }; + headers.insert( + "cache-status", + HeaderValue::from_str(&value).expect("generated cache status"), + ); + } + } + pub fn annotate_resident_age(&self, headers: &mut HeaderMap, resident_age: u64) { + headers.remove("x-cache-resident-age"); + if !self.enabled { + return; + } + if self.expose_resident_age { + headers.insert( + "x-cache-resident-age", + HeaderValue::from_str(&resident_age.to_string()).expect("integer header"), + ); + } + } + pub fn annotate_transformed(&self, headers: &mut HeaderMap, transformed: bool) { + if !self.enabled || !self.expose_cache_status || !transformed { + return; + } + let base = headers + .get("cache-status") + .and_then(|value| value.to_str().ok()) + .unwrap_or("RustProxy"); + if base + .split(';') + .any(|part| part.trim().eq_ignore_ascii_case("transformed")) + { + return; + } + if let Ok(value) = format!("{base}; transformed").parse() { + headers.insert("cache-status", value); + } + } + /// Reasons are fixed internal tokens: never echo request values or secrets. + pub fn annotate_reason( + &self, + headers: &mut HeaderMap, + outcome: &'static str, + hits: u64, + ttl: u64, + reason: &'static str, + ) { + self.annotate(headers, outcome, hits, ttl); + if !self.enabled { + return; + } + if self.expose_status { + headers.insert("x-cache-reason", HeaderValue::from_static(reason)); + } + if self.expose_cache_status { + let base = headers + .get("cache-status") + .and_then(|v| v.to_str().ok()) + .unwrap_or("RustProxy"); + if let Ok(value) = format!("{base}; detail=\"{reason}\"").parse() { + headers.insert("cache-status", value); + } + } + } +} +fn valid_relative_cache_path(value: &str) -> bool { + if value.is_empty() || value.len() > 256 { + return false; + } + let path = Path::new(value); + !path.is_absolute() + && path + .components() + .all(|part| matches!(part, Component::Normal(_))) +} + +/// Per-vhost behavior, not independent memory pools. None inherits the global +/// default; the global resident-byte/entry/object ceilings remain hard limits. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Overrides { + pub enabled: Option, + pub default_ttl_seconds: Option, + pub max_ttl_seconds: Option, + pub max_object_bytes: Option, + pub bypass_query: Option, + pub bypass_prefixes: Option>, + pub static_ignore_cookies: Option, + pub static_extensions: Option>, + pub expose_status: Option, + pub expose_hits: Option, + pub expose_cache_status: Option, + pub expose_resident_age: Option, +} +impl Overrides { + pub fn resolve(&self, global: &Settings) -> Result { + let mut result = global.clone(); + // A vhost may narrow the global policy, but can never re-enable a cache + // that the administrator disabled globally. + result.enabled = global.enabled && self.enabled.unwrap_or(true); + if let Some(value) = self.default_ttl_seconds { + result.default_ttl_seconds = value; + } + if let Some(value) = self.max_ttl_seconds { + result.max_ttl_seconds = value; + } + if let Some(value) = self.max_object_bytes { + ensure!( + value <= global.max_object_bytes, + "vhost object limit cannot exceed the global ceiling" + ); + result.max_object_bytes = value; + } + if let Some(value) = self.bypass_query { + result.bypass_query = value; + } + if let Some(value) = &self.bypass_prefixes { + result.bypass_prefixes = value.clone(); + } + if let Some(value) = self.static_ignore_cookies { + result.static_ignore_cookies = value; + } + if let Some(value) = &self.static_extensions { + result.static_extensions = value.clone(); + } + if let Some(value) = self.expose_status { + result.expose_status = value; + } + if let Some(value) = self.expose_hits { + result.expose_hits = value; + } + if let Some(value) = self.expose_cache_status { + result.expose_cache_status = value; + } + if let Some(value) = self.expose_resident_age { + result.expose_resident_age = value; + } + result.validate()?; + Ok(result) + } +} +#[derive(Debug, Clone, Serialize)] +pub struct ScopeView { + pub entries: usize, + pub resident_bytes: usize, + pub cold_entries: usize, + pub cold_bytes: u64, +} + + +#[derive(Default)] +struct Counters { + hits: AtomicU64, + misses: AtomicU64, + bypasses: AtomicU64, + stored: AtomicU64, + evictions: AtomicU64, + expired: AtomicU64, + rejected_fills: AtomicU64, + memory_pressure_rejections: AtomicU64, + hot_eviction_limited: AtomicU64, + purges: AtomicU64, + cold_hits: AtomicU64, + cold_promotions: AtomicU64, + hot_demotions: AtomicU64, + cold_evictions: AtomicU64, + cold_errors: AtomicU64, + cold_queue_dropped: AtomicU64, + collapsed_fills: AtomicU64, + collapse_timeouts: AtomicU64, + purge_entries: AtomicU64, + bans_created: AtomicU64, + bans_removed: AtomicU64, + ban_matches: AtomicU64, + proactive_scheduled: AtomicU64, + proactive_started: AtomicU64, + proactive_skipped: AtomicU64, +} +#[derive(Debug, Clone, Serialize)] +pub struct View { + pub hits: u64, + pub misses: u64, + pub bypasses: u64, + pub stored: u64, + pub evictions: u64, + pub expired: u64, + pub rejected_fills: u64, + pub memory_pressure_rejections: u64, + pub hot_eviction_limited: u64, + pub purges: u64, + pub entries: usize, + /// Compatibility alias for HOT allocated/charged bytes. + pub resident_bytes: usize, + pub hot_logical_bytes: usize, + pub hot_allocated_bytes: usize, + pub hot_limit_bytes: usize, + pub transient_limit_bytes: usize, + pub cold_queue_limit_bytes: usize, + pub cold_entries: usize, + pub cold_bytes: u64, + pub cold_hits: u64, + pub cold_promotions: u64, + pub hot_demotions: u64, + pub cold_evictions: u64, + pub cold_errors: u64, + pub cold_queue_dropped: u64, + pub collapsed_fills: u64, + pub collapse_timeouts: u64, + pub collapse_waiters: u64, + pub purge_entries: u64, + pub bans_created: u64, + pub bans_removed: u64, + pub ban_matches: u64, + pub active_bans: usize, + pub proactive_scheduled: u64, + pub proactive_started: u64, + pub proactive_skipped: u64, + pub proactive_inflight: u64, + pub fills_active: usize, + /// Bytes currently allocated by in-progress response cache fills. + pub fill_bytes_active: u64, + /// Demoted objects accepted by the bounded cold-writer queue. + pub cold_queue_items: u64, + pub cold_queue_bytes: u64, + /// Body bytes currently owned by the cold writer after leaving the queue. + pub cold_write_bytes_active: u64, + /// Full cold bodies currently read into RAM for promotion. + pub cold_read_bytes_active: u64, + /// RustProxy-accounted cache RAM outside the published hot store. + pub transient_bytes: u64, + pub hit_ratio: f64, +} +#[derive(Debug, Clone, Default, Serialize)] +pub struct DomainCounters { + pub hits: u64, + pub misses: u64, + pub bypasses: u64, + pub stored: u64, + pub saved_body_bytes: u64, + pub reasons: BTreeMap, +} +#[derive(Debug, Clone, Serialize)] +pub struct DomainView { + pub vhost: String, + pub domain: String, + pub entries: usize, + pub logical_bytes: usize, + pub resident_bytes: usize, + pub hot_entries: usize, + pub cold_entries: usize, + pub cold_bytes: u64, + pub hit_ratio: f64, + #[serde(flatten)] + pub counters: DomainCounters, +} +#[derive(Debug, Clone, Serialize)] +pub struct ObjectView { + pub vhost: String, + pub scheme: String, + pub authority: String, + pub domain: String, + pub path: String, + pub target: String, + pub cache_key: String, + pub tags: Vec, + pub transformed: bool, + pub body_bytes: usize, + pub resident_bytes: usize, + pub storage_bytes: u64, + pub tier: &'static str, + pub hits: u64, + pub age_seconds: u64, + pub remaining_ttl_seconds: u64, +} +#[derive(Debug, Clone, Serialize)] +pub struct Dashboard { + pub domains: Vec, + pub objects: Vec, + pub matching_objects: usize, + pub matching_body_bytes: usize, + pub matching_resident_bytes: usize, + pub matching_cold_bytes: u64, + pub offset: usize, + pub limit: usize, + pub statistics_revision: u64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ColdCleanupRequest { + /// Remove objects not used for at least this many seconds. + pub unused_for_seconds: Option, + /// Additionally remove up to N oldest matching objects. + pub oldest: Option, + /// Limit the selection to objects which were never served. + pub hits_zero: bool, +} +impl Default for ColdCleanupRequest { + fn default() -> Self { + Self { unused_for_seconds: None, oldest: Some(100), hits_zero: false } + } +} +impl ColdCleanupRequest { + fn validate(&self) -> Result<()> { + ensure!( + self.unused_for_seconds.is_some() || self.oldest.is_some() || self.hits_zero, + "at least one cold-cache cleanup criterion is required" + ); + if let Some(value) = self.unused_for_seconds { + ensure!((1..=31_536_000).contains(&value), "unused_for_seconds must be 1..31536000"); + } + if let Some(value) = self.oldest { + ensure!((1..=10_000).contains(&value), "oldest must be 1..10000"); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct ColdCleanupResult { + pub removed_entries: usize, + pub freed_bytes: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ColdCleanupJob { + pub id: u64, + pub status: &'static str, + pub removed_entries: usize, + pub freed_bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} +struct Entry { + base_key: Key, + vary: Vec, + scope: String, + scheme: String, + authority: String, + domain: String, + path: String, + target: String, + tags: Vec, + created_at: SystemTime, + headers: HeaderMap, + body: Bytes, + /// Backing allocation retained by `body`; can exceed `body.len()`. + body_allocated: usize, + received: Instant, + last_access: Instant, + resident_since: Instant, + resident_age_base: Duration, + initial_age: Duration, + ttl: u64, + stale_while_revalidate: u64, + stale_if_error: u64, + must_revalidate: bool, + proxy_revalidate: bool, + origin_etag: Option, + origin_last_modified: Option, + cost: usize, + sequence: u64, + hits: u64, + transformed: bool, + skip_compression: bool, + requires_revalidation: bool, +} + +#[allow(clippy::ptr_arg)] // String/Vec capacity is intentionally part of HOT accounting. +fn hot_entry_cost( + body_allocated: usize, + headers: &HeaderMap, + vary: &Vec, + scope: &String, + scheme: &String, + authority: &String, + domain: &String, + path: &String, + target: &String, + tags: &Vec, + origin_etag: Option<&HeaderValue>, + origin_last_modified: Option<&HeaderValue>, +) -> usize { + // The public containers do not expose an exact per-entry allocator charge. + // Account their retained index memory conservatively: HOT HashMap key/control, + // the LRU BTreeMap item/node share and the variants index (including spare + // Vec capacity). This keeps `max_bytes` closer to the real retained + // allocation instead of pretending Entry/body are the only HOT owners. + let index_heap = std::mem::size_of::() + .saturating_add(std::mem::size_of::<(u64, Key)>()) + .saturating_add(32) + .saturating_add(2usize.saturating_mul(std::mem::size_of::())) + .saturating_add(32) + .saturating_add(32); + let string_heap = |value: &String| value.capacity(); + let string_vec_heap = |values: &Vec| { + values + .capacity() + .saturating_mul(std::mem::size_of::()) + .saturating_add(values.iter().map(String::capacity).sum::()) + }; + let header_heap = headers + .iter() + .map(|(name, value)| { + // HeaderMap does not expose its bucket capacity. Charge a conservative + // per-field bucket/node allowance in addition to visible name/value bytes. + name.as_str() + .len() + .saturating_add(value.as_bytes().len()) + .saturating_add(64) + }) + .sum::(); + body_allocated + .saturating_add(std::mem::size_of::()) + .saturating_add(index_heap) + .saturating_add(header_heap) + .saturating_add(string_vec_heap(vary)) + .saturating_add(string_heap(scope)) + .saturating_add(string_heap(scheme)) + .saturating_add(string_heap(authority)) + .saturating_add(string_heap(domain)) + .saturating_add(string_heap(path)) + .saturating_add(string_heap(target)) + .saturating_add(string_vec_heap(tags)) + .saturating_add(origin_etag.map_or(0, |value| value.as_bytes().len())) + .saturating_add(origin_last_modified.map_or(0, |value| value.as_bytes().len())) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ColdFile { + format: u8, + key: String, + #[serde(default)] + base_key: String, + #[serde(default)] + vary: Vec, + revision: u64, + scope: String, + #[serde(default = "default_cache_scheme")] + scheme: String, + #[serde(default)] + authority: String, + domain: String, + path: String, + #[serde(default)] + target: String, + #[serde(default)] + tags: Vec, + #[serde(default)] + created_at_ms: u64, + headers: Vec<(String, String)>, + body_file: String, + body_bytes: usize, + body_sha256: String, + stored_at_ms: u64, + age_at_store_seconds: u64, + #[serde(default)] + resident_age_at_store_seconds: u64, + ttl: u64, + #[serde(default)] + stale_while_revalidate: u64, + #[serde(default)] + stale_if_error: u64, + #[serde(default)] + must_revalidate: bool, + #[serde(default)] + proxy_revalidate: bool, + #[serde(default)] + origin_etag: Option, + #[serde(default)] + origin_last_modified: Option, + #[serde(default)] + last_access_ms: u64, + hits: u64, + #[serde(default)] + transformed: bool, + skip_compression: bool, + requires_revalidation: bool, +} + +#[derive(Clone)] +struct ColdEntry { + key: Key, + base_key: Key, + vary: Vec, + revision: u64, + scope: String, + scheme: String, + authority: String, + domain: String, + path: String, + target: String, + tags: Vec, + created_at: SystemTime, + headers: HeaderMap, + body_path: PathBuf, + meta_path: PathBuf, + body_bytes: usize, + body_sha256: String, + disk_bytes: u64, + stored_at: SystemTime, + last_access: SystemTime, + age_at_store_seconds: u64, + resident_age_at_store_seconds: u64, + ttl: u64, + stale_while_revalidate: u64, + stale_if_error: u64, + must_revalidate: bool, + proxy_revalidate: bool, + origin_etag: Option, + origin_last_modified: Option, + hits: u64, + transformed: bool, + sequence: u64, + // Stable identity of this in-memory cold entry. Unlike `sequence`, this + // never changes on access, so an old async reader cannot unpin a newer + // replacement stored under the same cache key. + generation: u64, + skip_compression: bool, + requires_revalidation: bool, + pinned_reads: usize, +} + +#[derive(Default)] +struct ColdStore { + entries: HashMap, + variants: HashMap>, + lru: BTreeMap, + bytes: u64, + sequence: u64, +} + +struct ColdDraft { + key: Key, + revision: u64, + generation: u64, + scope_generation: u64, + key_generation: u64, + entry: Entry, + root: PathBuf, + /// Keeps this full in-memory object charged to the bounded transient pool + /// until the COLD write completes and the payload is dropped. + _memory_lease: TransientLease, +} + +enum ColdCommand { + Store(ColdDraft), + Delete(Vec), + PurgeDirectory(PathBuf), +} +#[derive(Default)] +struct Store { + entries: HashMap, + variants: HashMap>, + lru: BTreeMap, + bytes: usize, + logical_bytes: usize, + sequence: u64, + generation: u64, + revision: u64, + scope_generations: HashMap, + key_generations: HashMap, + domains: BTreeMap<(String, String), DomainCounters>, + last_sweep: Option, +} +impl Store { + fn index_variant(&mut self, base_key: Key, key: Key) { + let keys = self.variants.entry(base_key).or_default(); + if !keys.contains(&key) { + keys.push(key); + } + } + fn remove(&mut self, key: &Key) -> Option { + let entry = self.entries.remove(key)?; + self.lru.remove(&entry.sequence); + self.bytes = self.bytes.saturating_sub(entry.cost); + self.logical_bytes = self.logical_bytes.saturating_sub(entry.body.len()); + let empty = if let Some(keys) = self.variants.get_mut(&entry.base_key) { + keys.retain(|candidate| candidate != key); + keys.is_empty() + } else { + false + }; + if empty { + self.variants.remove(&entry.base_key); + } + Some(entry) + } + fn domain(&mut self, scope: &str, domain: &str) -> Option<&mut DomainCounters> { + let key = (scope.to_owned(), domain.to_owned()); + // Cardinality is bounded independently of resident cache capacity. + if self.domains.len() >= 4096 && !self.domains.contains_key(&key) { + return None; + } + Some(self.domains.entry(key).or_default()) + } + fn sweep(&mut self) -> usize { + if self + .last_sweep + .is_some_and(|v| v.elapsed() < Duration::from_secs(1)) + { + return 0; + } + self.last_sweep = Some(Instant::now()); + let stale: Vec<_> = self + .entries + .iter() + .filter(|(_, e)| { + let age = e.initial_age.saturating_add(e.received.elapsed()).as_secs(); + age >= retention_deadline( + e.ttl, + e.stale_while_revalidate, + e.stale_if_error, + e.origin_etag.is_some() || e.origin_last_modified.is_some(), + ) + }) + .map(|(k, _)| *k) + .collect(); + for key in &stale { + self.remove(key); + } + stale.len() + } + fn next_sequence(&mut self) -> u64 { + // No practical wrapping, but a wrap must not corrupt LRU bookkeeping. + if self.sequence == u64::MAX { + self.entries.clear(); + self.variants.clear(); + self.lru.clear(); + self.bytes = 0; + self.logical_bytes = 0; + self.sequence = 0; + } + self.sequence += 1; + self.sequence + } +} + +impl ColdStore { + fn index_variant(&mut self, base_key: Key, key: Key) { + let keys = self.variants.entry(base_key).or_default(); + if !keys.contains(&key) { + keys.push(key); + } + } + fn next_sequence(&mut self) -> u64 { + if self.sequence == u64::MAX { + // Practically unreachable. Prefer dropping the in-memory index over + // risking ambiguous LRU ordering after wrap. + self.lru.clear(); + self.entries.clear(); + self.variants.clear(); + self.bytes = 0; + self.sequence = 0; + } + self.sequence += 1; + self.sequence + } + fn remove(&mut self, key: &Key) -> Option { + let entry = self.entries.remove(key)?; + self.lru.remove(&entry.sequence); + self.bytes = self.bytes.saturating_sub(entry.disk_bytes); + let empty = if let Some(keys) = self.variants.get_mut(&entry.base_key) { + keys.retain(|candidate| candidate != key); + keys.is_empty() + } else { + false + }; + if empty { + self.variants.remove(&entry.base_key); + } + Some(entry) + } +} +#[derive(Clone)] +struct TransientLease(Arc); + +struct TransientLeaseInner { + counter: Arc, + bytes: u64, +} + +impl Drop for TransientLeaseInner { + fn drop(&mut self) { + self.counter.fetch_sub(self.bytes, Ordering::Relaxed); + } +} + +pub struct Cache { + store: Arc>, + revision: AtomicU64, + cold: Arc>, + tier_settings: Mutex, + data_root: Option, + cold_tx: Option>, + fills: Arc, + fill_bytes_active: AtomicU64, + transient_bytes_active: Arc, + cold_queue_items: AtomicU64, + cold_queue_bytes: AtomicU64, + cold_write_bytes_active: AtomicU64, + cold_read_bytes_active: AtomicU64, + revalidating: Mutex>, + fill_flights: Mutex>>, + fill_flight_sequence: AtomicU64, + collapse_waiters: AtomicU64, + bans: Mutex>, + ban_sequence: AtomicU64, + proactive_scheduled_keys: Mutex>, + proactive_inflight: AtomicU64, + cleanup_jobs: Mutex>, + cleanup_sequence: AtomicU64, + counters: Counters, +} +#[derive(Clone)] +pub struct Ticket { + scheme: String, + authority: String, + domain: String, + path: String, + target: String, + key: Key, + request_headers: HeaderMap, + generation: u64, + revision: u64, + scope: String, + scope_generation: u64, + key_generation: u64, + created_at: SystemTime, +} +pub struct BackgroundRevalidationGuard { + cache: std::sync::Weak, + key: Key, +} +impl Drop for BackgroundRevalidationGuard { + fn drop(&mut self) { + if let Some(cache) = self.cache.upgrade() { + cache + .revalidating + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&self.key); + } + } +} +struct ColdReadGuard<'a> { + cache: &'a Cache, + key: Key, + generation: u64, +} +impl Drop for ColdReadGuard<'_> { + fn drop(&mut self) { + let mut cold = self.cache.cold.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(entry) = cold.entries.get_mut(&self.key) { + if entry.generation == self.generation { + entry.pinned_reads = entry.pinned_reads.saturating_sub(1); + } + } + } +} +pub struct Hit { + pub response: Response, + body: Bytes, + pub hits: u64, + pub transformed: bool, + pub remaining_ttl: u64, + pub resident_age_seconds: u64, + pub stale: bool, + pub background_revalidate: bool, +} +#[derive(Clone)] +pub struct Revalidation { + headers: HeaderMap, + body: Bytes, + body_allocated: usize, + // COLD revalidation bodies remain charged to transient RAM until every + // cloned revalidation handle is dropped or promoted. HOT revalidations use none. + _transient_leases: Vec, + hits: u64, + transformed: bool, + storage_key: Key, + resident_age_at_lookup: Duration, + skip_compression: bool, + reusable_without_validation: bool, + age: u64, + ttl: u64, + stale_if_error: u64, + must_revalidate: bool, + proxy_revalidate: bool, + origin_etag: Option, + origin_last_modified: Option, +} +impl Revalidation { + pub fn transformed(&self) -> bool { + self.transformed + } + pub fn reusable_without_validation(&self) -> bool { + self.reusable_without_validation + } + pub fn headers(&self) -> &HeaderMap { + &self.headers + } + pub fn apply_validators(&self, headers: &mut HeaderMap) -> bool { + // Client validators refer to the representation RustProxy served. If that + // representation was transformed, translate a matching validator back to + // the origin validator rather than leaking the generated proxy ETag upstream. + if headers.contains_key("if-none-match") { + let Some(public_etag) = self.headers.get("etag").and_then(|v| v.to_str().ok()) else { + return false; + }; + if !single_if_none_match_matches(headers, public_etag) { + return false; + } + if let Some(origin) = self.origin_etag.clone() { + headers.insert("if-none-match", origin); + headers.remove("if-modified-since"); + return true; + } + if let Some(modified) = self.origin_last_modified.clone() { + headers.remove("if-none-match"); + headers.insert("if-modified-since", modified); + return true; + } + return false; + } + if headers.contains_key("if-modified-since") { + let Some(cached) = self + .headers + .get("last-modified") + .and_then(|v| v.to_str().ok()) + else { + return false; + }; + let Some(client) = headers + .get("if-modified-since") + .and_then(|v| v.to_str().ok()) + else { + return false; + }; + let same = httpdate::parse_http_date(cached) + .ok() + .zip(httpdate::parse_http_date(client).ok()) + .is_some_and(|(cached, client)| cached == client); + if !same { + return false; + } + let Some(origin) = self.origin_last_modified.clone() else { + return false; + }; + headers.insert("if-modified-since", origin); + return true; + } + if let Some(etag) = self.origin_etag.clone() { + headers.insert("if-none-match", etag); + headers.remove("if-modified-since"); + true + } else if let Some(modified) = self.origin_last_modified.clone() { + headers.insert("if-modified-since", modified); + headers.remove("if-none-match"); + true + } else { + false + } + } + pub fn reusable_for_request(&self, request_headers: &HeaderMap) -> bool { + self.reusable_without_validation + && request_freshness_allows(self.age, self.ttl, request_headers) + } + + pub fn request_freshness_revalidation_reason( + &self, + request_headers: &HeaderMap, + ) -> Option<&'static str> { + let control = directives(request_headers)?; + if let Some(Some(value)) = control.get("max-age") { + if value + .parse::() + .ok() + .is_some_and(|limit| self.age > limit) + { + return Some("request_max_age"); + } + } + if let Some(Some(value)) = control.get("min-fresh") { + if value.parse::().ok().is_some_and(|required| { + self.ttl.saturating_sub(self.age) < required + }) { + return Some("request_min_fresh"); + } + } + None + } + + pub fn can_stale_if_error(&self) -> bool { + self.age >= self.ttl + && self.stale_if_error > 0 + && self.age.saturating_sub(self.ttl) <= self.stale_if_error + && !stale_forbidden(self.must_revalidate, self.proxy_revalidate) + && !self.reusable_without_validation + } + pub fn merge_validation_headers(&self, validation: &HeaderMap) -> HeaderMap { + let mut merged = merge_revalidated_headers(&self.headers, validation); + let public_etag = self.headers.get("etag").cloned(); + let representation_etag_differs = public_etag.as_ref().is_some_and(|public| { + self.origin_etag + .as_ref() + .is_none_or(|origin| public != origin) + }); + if representation_etag_differs { + if let Some(public) = public_etag { + merged.insert("etag", public); + } + } + merged + } + pub fn age(&self) -> u64 { + self.age + } + pub fn remaining_ttl(&self) -> u64 { + self.ttl.saturating_sub(self.age) + } + pub fn response(&self, headers: HeaderMap) -> Response { + let mut response = Response::new(Body::from(self.body.clone())); + *response.headers_mut() = headers; + if self.transformed { + response + .extensions_mut() + .insert(crate::transform::CachedTransformed); + } + if self.skip_compression { + response.extensions_mut().insert(crate::compression::Skip); + } + response + } +} +fn vary_names(headers: &HeaderMap) -> Option> { + let mut names = Vec::new(); + for value in headers.get_all("vary") { + for raw in value.to_str().ok()?.split(',') { + let raw = raw.trim(); + if raw.is_empty() || raw == "*" { + return None; + } + let name = raw.parse::().ok()?; + names.push(name.as_str().to_owned()); + } + } + names.sort(); + names.dedup(); + (names.len() <= 64).then_some(names) +} + +fn variant_key(base_key: &Key, vary: &[String], headers: &HeaderMap) -> Key { + if vary.is_empty() { + return *base_key; + } + let mut digest = Sha256::new(); + digest.update(b"RustProxy-Vary-v1"); + digest.update(base_key); + for name in vary { + digest.update((name.len() as u64).to_be_bytes()); + digest.update(name.as_bytes()); + let values = headers.get_all(name.as_str()); + digest.update((values.iter().count() as u64).to_be_bytes()); + for value in values.iter() { + digest.update((value.as_bytes().len() as u64).to_be_bytes()); + digest.update(value.as_bytes()); + } + } + digest.finalize().into() +} + +fn hot_resident_age(entry: &Entry) -> Duration { + entry + .resident_age_base + .saturating_add(entry.resident_since.elapsed()) +} + +fn matching_hot_key(store: &Store, ticket: &Ticket) -> Option { + store + .variants + .get(&ticket.key)? + .iter() + .filter_map(|key| { + let entry = store.entries.get(key)?; + (variant_key(&ticket.key, &entry.vary, &ticket.request_headers) == *key) + .then_some((*key, entry.sequence)) + }) + .max_by_key(|(_, sequence)| *sequence) + .map(|(key, _)| key) +} + +fn matching_cold_key(store: &ColdStore, ticket: &Ticket) -> Option { + store + .variants + .get(&ticket.key)? + .iter() + .filter_map(|key| { + let entry = store.entries.get(key)?; + (variant_key(&ticket.key, &entry.vary, &ticket.request_headers) == *key) + .then_some((*key, entry.sequence)) + }) + .max_by_key(|(_, sequence)| *sequence) + .map(|(key, _)| key) +} + +fn incompatible_hot_variants(store: &Store, base_key: &Key, vary: &[String]) -> Vec { + store + .variants + .get(base_key) + .into_iter() + .flatten() + .filter_map(|key| { + store + .entries + .get(key) + .filter(|entry| entry.vary.as_slice() != vary) + .map(|_| *key) + }) + .collect() +} + +fn incompatible_cold_variants(store: &ColdStore, base_key: &Key, vary: &[String]) -> Vec { + store + .variants + .get(base_key) + .into_iter() + .flatten() + .filter_map(|key| { + store + .entries + .get(key) + .filter(|entry| entry.vary.as_slice() != vary) + .map(|_| *key) + }) + .collect() +} + +fn body_sha256(body: &[u8]) -> String { + let digest = Sha256::digest(body); + let mut out = String::with_capacity(64); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(&mut out, "{byte:02x}"); + } + out +} + +fn cold_body_matches(entry: &ColdEntry, body: &[u8]) -> bool { + body.len() == entry.body_bytes && body_sha256(body).as_str() == entry.body_sha256.as_str() +} + +fn key_hex(key: &Key) -> String { + let mut out = String::with_capacity(64); + for byte in key { + use std::fmt::Write as _; + let _ = write!(&mut out, "{byte:02x}"); + } + out +} + +fn parse_key_hex(value: &str) -> Option { + if value.len() != 64 || !value.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let mut key = [0_u8; 32]; + for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() { + let text = std::str::from_utf8(chunk).ok()?; + key[index] = u8::from_str_radix(text, 16).ok()?; + } + Some(key) +} + +fn default_cache_scheme() -> String { + "http".to_owned() +} + +fn cache_tags(headers: &HeaderMap) -> Vec { + let mut tags = Vec::new(); + for value in headers.get_all("cache-tag") { + let Ok(value) = value.to_str() else { continue; }; + for raw in value.split(|c: char| c == ',' || c.is_ascii_whitespace()) { + let tag = raw.trim(); + if tag.is_empty() + || tag.len() > 128 + || !tag + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b':' | b'/')) + { + continue; + } + if !tags.iter().any(|existing| existing == tag) { + tags.push(tag.to_owned()); + if tags.len() >= 64 { + return tags; + } + } + } + } + tags +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u64::MAX as u128) as u64 +} + +fn system_time_ms(value: u64) -> SystemTime { + UNIX_EPOCH + .checked_add(Duration::from_millis(value)) + .unwrap_or(UNIX_EPOCH) +} + +fn cold_age(entry: &ColdEntry) -> u64 { + entry + .age_at_store_seconds + .saturating_add(entry.stored_at.elapsed().unwrap_or_default().as_secs()) +} + +fn cold_resident_age(entry: &ColdEntry) -> u64 { + entry + .resident_age_at_store_seconds + .saturating_add(entry.stored_at.elapsed().unwrap_or_default().as_secs()) +} + +fn retention_deadline( + _ttl: u64, + _stale_while_revalidate: u64, + _stale_if_error: u64, + _has_validator: bool, +) -> u64 { + // Freshness and storage retention are deliberately separate. Expiry only + // changes whether an object can be served without validation; it must not + // physically evict the representation because a later request may carry + // Cache-Control: max-stale. Capacity/LRU and Cold Cache GC remain the + // mechanisms that decide how long an otherwise valid object is retained. + u64::MAX +} + +fn has_validator(headers: &HeaderMap) -> bool { + headers.contains_key("etag") || headers.contains_key("last-modified") +} + +fn stale_forbidden(must_revalidate: bool, proxy_revalidate: bool) -> bool { + must_revalidate || proxy_revalidate +} + +fn client_max_stale(headers: &HeaderMap) -> Option> { + let control = directives(headers)?; + let value = control.get("max-stale")?; + match value { + None => Some(None), + Some(value) => value.parse::().ok().map(Some), + } +} + +fn request_freshness_allows(age: u64, ttl: u64, headers: &HeaderMap) -> bool { + let Some(control) = directives(headers) else { + return false; + }; + if let Some(Some(value)) = control.get("max-age") { + let Ok(limit) = value.parse::() else { + return false; + }; + if age > limit { + return false; + } + } + if let Some(Some(value)) = control.get("min-fresh") { + let Ok(required) = value.parse::() else { + return false; + }; + if ttl.saturating_sub(age) < required { + return false; + } + } + true +} + +fn stale_mode( + age: u64, + ttl: u64, + stale_while_revalidate: u64, + must_revalidate: bool, + proxy_revalidate: bool, + requires_revalidation: bool, + request_headers: &HeaderMap, +) -> Option { + if age < ttl || requires_revalidation || stale_forbidden(must_revalidate, proxy_revalidate) { + return None; + } + if !request_freshness_allows(age, ttl, request_headers) { + return None; + } + let stale_for = age.saturating_sub(ttl); + if stale_while_revalidate > 0 && stale_for <= stale_while_revalidate { + return Some(true); + } + match client_max_stale(request_headers) { + Some(None) => Some(false), + Some(Some(limit)) if stale_for <= limit => Some(false), + _ => None, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CacheState { + Fresh, + StaleButServable { background_revalidate: bool }, + Expired, +} + +impl CacheState { + fn servable(self) -> bool { + !matches!(self, Self::Expired) + } + + fn stale(self) -> bool { + matches!(self, Self::StaleButServable { .. }) + } + + fn background_revalidate(self) -> bool { + matches!( + self, + Self::StaleButServable { + background_revalidate: true + } + ) + } +} + +fn cache_state( + age: u64, + ttl: u64, + stale_while_revalidate: u64, + must_revalidate: bool, + proxy_revalidate: bool, + requires_revalidation: bool, + request_headers: &HeaderMap, +) -> CacheState { + if age < ttl + && !requires_revalidation + && request_freshness_allows(age, ttl, request_headers) + { + return CacheState::Fresh; + } + match stale_mode( + age, + ttl, + stale_while_revalidate, + must_revalidate, + proxy_revalidate, + requires_revalidation, + request_headers, + ) { + Some(background_revalidate) => CacheState::StaleButServable { + background_revalidate, + }, + None => CacheState::Expired, + } +} + +fn cold_paths(root: &Path, key: &Key) -> (PathBuf, PathBuf, PathBuf, PathBuf) { + let key = key_hex(key); + let shard = root.join(&key[..2]); + ( + shard.join(format!("{key}.body")), + shard.join(format!("{key}.json")), + shard.join(format!(".{key}.body.tmp")), + shard.join(format!(".{key}.json.tmp")), + ) +} + +fn cold_header_pairs(headers: &HeaderMap) -> Option> { + let mut result = Vec::new(); + for (name, value) in headers { + result.push((name.as_str().to_owned(), value.to_str().ok()?.to_owned())); + } + Some(result) +} + +fn pairs_to_headers(values: &[(String, String)]) -> Option { + let mut headers = HeaderMap::new(); + for (name, value) in values { + let name = name.parse::().ok()?; + let value = HeaderValue::from_str(value).ok()?; + headers.append(name, value); + } + Some(headers) +} + diff --git a/src/cache/policy.rs b/src/cache/policy.rs new file mode 100644 index 0000000..c8e032b --- /dev/null +++ b/src/cache/policy.rs @@ -0,0 +1,570 @@ +/// Reject malformed/duplicate directives instead of guessing their meaning. +fn directives(headers: &HeaderMap) -> Option>> { + let mut result = HashMap::new(); + for header in headers.get_all("cache-control") { + for raw in header.to_str().ok()?.split(',') { + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + let (key, value) = match raw.split_once('=') { + Some((k, v)) => { + let v = v.trim(); + let value = if v.starts_with('"') { + // Fail closed on truncated/escaped/compound values. Quoted + // commas need not be supported by this conservative cache. + let inner = v.strip_prefix('"')?.strip_suffix('"')?; + if inner.contains(['"', '\\']) { + return None; + } + inner + } else { + if v.is_empty() || v.bytes().any(|b| b <= 32 || b == b'"' || b == b'\\') { + return None; + } + v + }; + (k.trim().to_ascii_lowercase(), Some(value.to_owned())) + } + None => (raw.to_ascii_lowercase(), None), + }; + if key.parse::().is_err() { + return None; + } + if matches!( + key.as_str(), + "public" | "no-store" | "must-revalidate" | "proxy-revalidate" | "only-if-cached" + ) && value.is_some() + { + return None; + } + if key == "immutable" { + // RFC 8246: immutable has no meaningful argument and duplicate + // occurrences are equivalent to one occurrence. Preserve only + // the presence of the directive instead of invalidating the + // complete Cache-Control field. + result.entry(key).or_insert(None); + } else if result.insert(key, value).is_some() { + return None; + } + } + } + Some(result) +} +pub fn only_if_cached(headers: &HeaderMap) -> bool { + headers + .get_all("cache-control") + .iter() + .filter_map(|v| v.to_str().ok()) + .any(|v| { + v.split(',') + .any(|v| v.trim().eq_ignore_ascii_case("only-if-cached")) + }) +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RequestPolicy { + Use, + Revalidate(&'static str), + Bypass(&'static str), +} +impl RequestPolicy { + pub fn bypass_reason(self) -> Option<&'static str> { + match self { + Self::Bypass(reason) => Some(reason), + _ => None, + } + } + pub fn revalidation_reason(self) -> Option<&'static str> { + match self { + Self::Revalidate(reason) => Some(reason), + _ => None, + } + } +} +fn pragma_no_cache(headers: &HeaderMap) -> bool { + headers + .get_all("pragma") + .iter() + .filter_map(|value| value.to_str().ok()) + .any(|value| { + value + .split(',') + .any(|token| token.trim().eq_ignore_ascii_case("no-cache")) + }) +} +pub fn request_policy( + method: &Method, + headers: &HeaderMap, + empty_body: bool, + path: &str, + has_query: bool, + settings: &Settings, +) -> RequestPolicy { + if method != Method::GET && method != Method::HEAD { + return RequestPolicy::Bypass("request_method"); + } + if !empty_body { + return RequestPolicy::Bypass("request_body"); + } + if matches!(request_range(method, headers), RangeSelection::Unsupported) { + return RequestPolicy::Bypass("request_range_unsupported"); + } + if headers.contains_key("if-range") && !headers.contains_key("range") { + return RequestPolicy::Bypass("request_precondition"); + } + if settings.bypass_query && has_query { + return RequestPolicy::Bypass("query_excluded"); + } + if settings + .bypass_prefixes + .iter() + .any(|prefix| crate::config::prefix_matches(prefix, path)) + { + return RequestPolicy::Bypass("path_excluded"); + } + for (key, reason) in [ + ("authorization", "request_authorization"), + ("cookie", "request_cookie"), + // Preconditions other than cache validators can produce 412 and must be + // evaluated by the origin until RustProxy implements the full precondition set. + ("if-match", "request_precondition"), + ("if-unmodified-since", "request_precondition"), + ("upgrade", "request_upgrade"), + ] { + if headers.contains_key(key) { + if key == "cookie" && settings.ignore_cookie_for_path(path) { + continue; + } + return RequestPolicy::Bypass(reason); + } + } + let Some(control) = directives(headers) else { + return RequestPolicy::Bypass("request_cache_control_invalid"); + }; + // RFC 9111: request no-store forbids using a stored response and storing this + // exchange. It is the hard client-side cache bypass. + if control.contains_key("no-store") { + return RequestPolicy::Bypass("request_no_store"); + } + // no-cache does not mean no-store. It requires successful validation before a + // stored response can be reused. max-age/min-fresh are evaluated against the + // cached representation during lookup; only max-age=0 is an unconditional + // request-side validation signal. + if control.contains_key("no-cache") { + return RequestPolicy::Revalidate("request_no_cache"); + } + if let Some(value) = control.get("max-age") { + let Some(value) = value.as_deref().and_then(|value| value.parse::().ok()) else { + return RequestPolicy::Bypass("request_cache_control_invalid"); + }; + if value == 0 { + return RequestPolicy::Revalidate("request_max_age"); + } + } + if let Some(value) = control.get("min-fresh") { + if value + .as_deref() + .and_then(|value| value.parse::().ok()) + .is_none() + { + return RequestPolicy::Bypass("request_cache_control_invalid"); + } + } + if let Some(Some(value)) = control.get("max-stale") { + if value.parse::().is_err() { + return RequestPolicy::Bypass("request_cache_control_invalid"); + } + } + // HTTP/1.0 Pragma: no-cache is a revalidation request when Cache-Control did + // not already define the policy. Other Pragma values do not disable caching. + if !headers.contains_key("cache-control") && pragma_no_cache(headers) { + return RequestPolicy::Revalidate("request_pragma_no_cache"); + } + RequestPolicy::Use +} +pub fn request_reason( + method: &Method, + headers: &HeaderMap, + empty_body: bool, + path: &str, + has_query: bool, + settings: &Settings, +) -> Option<&'static str> { + request_policy(method, headers, empty_body, path, has_query, settings).bypass_reason() +} +pub fn request_cacheable( + method: &Method, + headers: &HeaderMap, + empty_body: bool, + path: &str, + has_query: bool, + settings: &Settings, +) -> bool { + !matches!( + request_policy(method, headers, empty_body, path, has_query, settings), + RequestPolicy::Bypass(_) + ) +} + +fn weak_etag(value: &str) -> Option<&str> { + let value = value.trim(); + let value = value + .strip_prefix("W/") + .or_else(|| value.strip_prefix("w/")) + .unwrap_or(value) + .trim(); + (value.len() >= 2 && value.starts_with('"') && value.ends_with('"')).then_some(value) +} +fn if_none_match_matches(headers: &HeaderMap, etag: &str) -> Option { + let target = weak_etag(etag)?; + let values: Vec<_> = headers.get_all("if-none-match").iter().collect(); + if values.is_empty() { + return None; + } + for value in values { + let Ok(value) = value.to_str() else { + return Some(false); + }; + let mut start = 0usize; + let mut quoted = false; + for (index, ch) in value.char_indices() { + if ch == '"' { + quoted = !quoted; + } else if ch == ',' && !quoted { + let token = value[start..index].trim(); + if token == "*" || weak_etag(token).is_some_and(|candidate| candidate == target) { + return Some(true); + } + start = index + 1; + } + } + if quoted { + return Some(false); + } + let token = value[start..].trim(); + if token == "*" || weak_etag(token).is_some_and(|candidate| candidate == target) { + return Some(true); + } + } + Some(false) +} +fn single_if_none_match_matches(headers: &HeaderMap, etag: &str) -> bool { + let Some(target) = weak_etag(etag) else { + return false; + }; + let values: Vec<_> = headers.get_all("if-none-match").iter().collect(); + if values.len() != 1 { + return false; + } + let Ok(value) = values[0].to_str() else { + return false; + }; + let mut tokens = value + .split(',') + .map(str::trim) + .filter(|token| !token.is_empty()); + let Some(token) = tokens.next() else { + return false; + }; + tokens.next().is_none() + && token != "*" + && weak_etag(token).is_some_and(|candidate| candidate == target) +} +/// Varnish-like client-side conditional delivery for a selected cached response. +/// If-None-Match takes precedence over If-Modified-Since. +pub fn conditional_not_modified(request: &HeaderMap, response: &HeaderMap) -> bool { + if request.contains_key("if-none-match") { + return response + .get("etag") + .and_then(|etag| etag.to_str().ok()) + .and_then(|etag| if_none_match_matches(request, etag)) + .unwrap_or(false); + } + let Some(since) = request + .get("if-modified-since") + .and_then(|value| value.to_str().ok()) + .and_then(|value| httpdate::parse_http_date(value).ok()) + else { + return false; + }; + response + .get("last-modified") + .and_then(|value| value.to_str().ok()) + .and_then(|value| httpdate::parse_http_date(value).ok()) + .is_some_and(|modified| modified <= since) +} +pub fn make_not_modified(mut response: Response) -> Response { + *response.status_mut() = StatusCode::NOT_MODIFIED; + *response.body_mut() = Body::empty(); + for name in ["content-length", "content-range", "transfer-encoding"] { + response.headers_mut().remove(name); + } + response +} +/// Merge a successful backend 304 into the stored representation metadata. +pub fn merge_revalidated_headers(cached: &HeaderMap, validation: &HeaderMap) -> HeaderMap { + let mut merged = cached.clone(); + // A new validation starts a new age calculation. Preserve origin Age only if + // it is explicitly supplied by the 304 response. + if !validation.contains_key("age") { + merged.remove("age"); + } + for name in validation.keys() { + if matches!( + name.as_str(), + "content-length" | "content-range" | "transfer-encoding" + ) { + continue; + } + merged.remove(name); + for value in validation.get_all(name).iter() { + merged.append(name.clone(), value.clone()); + } + } + if !validation.contains_key("date") { + if let Ok(value) = httpdate::fmt_http_date(SystemTime::now()).parse() { + merged.insert("date", value); + } + } + merged +} + +pub fn varies_on(headers: &HeaderMap, wanted: &str) -> bool { + headers.get_all("vary").iter().any(|value| { + value.to_str().ok().is_some_and(|value| { + value + .split(',') + .any(|name| name.trim().eq_ignore_ascii_case(wanted)) + }) + }) +} + +/// Explains rejected storage; it never relaxes origin or transformed policies. +pub fn response_reason(status: StatusCode, headers: &HeaderMap) -> &'static str { + if status != StatusCode::OK { + return "response_status"; + } + if headers.contains_key("set-cookie") { + return "response_set_cookie"; + } + if headers.contains_key("content-range") { + return "response_range"; + } + if headers.contains_key("trailer") { + return "response_trailers"; + } + let Some(control) = directives(headers) else { + return "response_cache_control_invalid"; + }; + for key in ["no-store", "private", "must-understand"] { + if control.contains_key(key) { + return "response_cache_control"; + } + } + for value in headers.get_all("vary") { + if value.to_str().ok().is_none_or(|value| { + value.split(',').any(|name| { + let name = name.trim(); + name == "*" || name.parse::().is_err() + }) + }) { + return "response_vary"; + } + } + "response_ttl_size_or_metadata" +} + +pub struct Plan { + received: Instant, + initial_age: Duration, + ttl: u64, + stale_while_revalidate: u64, + stale_if_error: u64, + must_revalidate: bool, + proxy_revalidate: bool, + expected_length: Option, + requires_revalidation: bool, + vary: Vec, +} +impl Plan { + pub fn remaining_ttl(&self) -> u64 { + self.ttl.saturating_sub(self.initial_age.as_secs()) + } + + pub fn without_vary(mut self, name: &str) -> Self { + self.vary.retain(|value| !value.eq_ignore_ascii_case(name)); + self + } + + pub fn restrict(mut self, transformed: Self) -> Self { + self.ttl = self.ttl.min(transformed.ttl); + self.stale_while_revalidate = self + .stale_while_revalidate + .min(transformed.stale_while_revalidate); + self.stale_if_error = self.stale_if_error.min(transformed.stale_if_error); + self.must_revalidate |= transformed.must_revalidate; + self.proxy_revalidate |= transformed.proxy_revalidate; + self.requires_revalidation |= transformed.requires_revalidation; + self.vary.extend(transformed.vary); + self.vary.sort(); + self.vary.dedup(); + // The transformed response is the representation that is actually + // written to cache. Its Content-Length therefore replaces the origin + // length; comparing transformed bytes with the origin length rejects a + // valid fill and causes a permanent MISS -> MISS loop. + self.expected_length = transformed.expected_length; + // Express both age clocks at the later observation point before taking + // the stricter value. This avoids resetting or double-counting Age when + // origin headers are transformed inside the proxy. + if transformed.received >= self.received { + let carried = self + .initial_age + .saturating_add(transformed.received.duration_since(self.received)); + self.received = transformed.received; + self.initial_age = carried.max(transformed.initial_age); + } else { + let carried = transformed + .initial_age + .saturating_add(self.received.duration_since(transformed.received)); + self.initial_age = self.initial_age.max(carried); + } + self + } +} +pub fn plan( + status: StatusCode, + headers: &HeaderMap, + settings: &Settings, + response_delay: Duration, +) -> Option { + if ["age", "date", "expires", "content-length"] + .iter() + .any(|name| headers.get_all(*name).iter().count() > 1) + { + return None; + } + if status != StatusCode::OK + || headers.contains_key("set-cookie") + || headers.contains_key("content-range") + || headers.contains_key("trailer") + { + return None; + } + let bytes: usize = headers + .iter() + .map(|(k, v)| k.as_str().len() + v.as_bytes().len() + 32) + .sum(); + if bytes > MAX_HEADER_BYTES { + return None; + } + if headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| { + let v = v.trim_start().to_ascii_lowercase(); + v.starts_with("text/event-stream") || v.starts_with("application/grpc") + }) + { + return None; + } + // Cache-policy and validator fields govern reuse rather than representation + // identity. Response Vary drives a secondary key, matching Varnish-style + // variants without fragmenting on unrelated request headers. + let vary = vary_names(headers)?; + let control = directives(headers)?; + if ["no-store", "private", "must-understand"] + .iter() + .any(|name| control.contains_key(*name)) + { + return None; + } + let requires_revalidation = control.contains_key("no-cache"); + let must_revalidate = control.contains_key("must-revalidate"); + let proxy_revalidate = control.contains_key("proxy-revalidate"); + let parse_window = |name: &str| -> Option { + match control.get(name) { + None => Some(0), + Some(Some(value)) => value.parse::().ok().map(|v| v.min(604_800)), + Some(None) => None, + } + }; + let stale_while_revalidate = parse_window("stale-while-revalidate")?; + let stale_if_error = parse_window("stale-if-error")?; + if requires_revalidation + && !headers.contains_key("etag") + && !headers.contains_key("last-modified") + { + return None; + } + let now = SystemTime::now(); + let date = match headers.get("date") { + Some(v) => httpdate::parse_http_date(v.to_str().ok()?).ok()?, + None => now, + }; + let declared_ttl = + if let Some(value) = control.get("s-maxage").or_else(|| control.get("max-age")) { + Some(value.as_ref()?.parse::().ok()?) + } else if let Some(expires) = headers.get("expires") { + Some( + httpdate::parse_http_date(expires.to_str().ok()?) + .ok()? + .duration_since(date) + .ok()? + .as_secs(), + ) + } else if control.contains_key("public") { + Some(settings.default_ttl_seconds) + } else { + None + }; + // Keep the freshness lifetime separate from validator/stale retention. + // `no-cache` may be stored with a zero freshness lifetime but must validate + // every reuse; it is retained by the bounded revalidation horizon. + let ttl = match declared_ttl { + Some(value) => value.min(settings.max_ttl_seconds), + None if requires_revalidation => 0, + None => return None, + }; + let age = match headers.get("age") { + Some(v) => v.to_str().ok()?.parse::().ok()?, + None => 0, + }; + // Keep sub-second precision internally and floor only when serializing the + // Age header. RFC 9111 adds response delay after correcting received age. + let apparent_age = now.duration_since(date).unwrap_or_default(); + // RFC 9111 current_age: corrected_received_age is the greater of Date-based + // apparent age and the received Age value; response delay is then added to + // that result (not only to the Age branch). + let corrected_received_age = apparent_age.max(Duration::from_secs(age)); + let initial_age = corrected_received_age.saturating_add(response_delay); + if !requires_revalidation + && initial_age.as_secs() + >= retention_deadline( + ttl, + stale_while_revalidate, + stale_if_error, + has_validator(headers), + ) + { + return None; + } + let expected_length = match headers.get("content-length") { + Some(v) => Some(v.to_str().ok()?.parse::().ok()?), + None => None, + }; + if expected_length.is_some_and(|n| n > settings.max_object_bytes) { + return None; + } + Some(Plan { + received: Instant::now(), + initial_age, + ttl, + stale_while_revalidate, + stale_if_error, + must_revalidate, + proxy_revalidate, + expected_length, + requires_revalidation, + vary, + }) +} diff --git a/src/cache/range.rs b/src/cache/range.rs new file mode 100644 index 0000000..f64efbf --- /dev/null +++ b/src/cache/range.rs @@ -0,0 +1,184 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ByteRange { + start: Option, + end: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RangeSelection { + None, + Single(ByteRange), + Unsupported, +} + +pub fn request_range(method: &Method, headers: &HeaderMap) -> RangeSelection { + let mut values = headers.get_all("range").iter(); + let Some(value) = values.next() else { + return RangeSelection::None; + }; + // Multiple field lines are equivalent to a combined range-set; RustProxy + // intentionally delegates those to the origin with multipart ranges. + if values.next().is_some() || headers.get_all("if-range").iter().count() > 1 { + return RangeSelection::Unsupported; + } + // RustProxy serves one byte range itself. HEAD, unknown units, malformed + // syntax and multipart requests remain transparent origin pass-through. + if method != Method::GET { + return RangeSelection::Unsupported; + } + let Ok(value) = value.to_str() else { + return RangeSelection::Unsupported; + }; + let Some(spec) = value.trim().strip_prefix("bytes=") else { + return RangeSelection::Unsupported; + }; + if spec.is_empty() || spec.contains(',') { + return RangeSelection::Unsupported; + } + let Some((left, right)) = spec.split_once('-') else { + return RangeSelection::Unsupported; + }; + let start = if left.trim().is_empty() { + None + } else { + match left.trim().parse::() { + Ok(value) => Some(value), + Err(_) => return RangeSelection::Unsupported, + } + }; + let end = if right.trim().is_empty() { + None + } else { + match right.trim().parse::() { + Ok(value) => Some(value), + Err(_) => return RangeSelection::Unsupported, + } + }; + if start.is_none() && end.is_none() { + return RangeSelection::Unsupported; + } + if let (Some(start), Some(end)) = (start, end) { + if start > end { + return RangeSelection::Unsupported; + } + } + RangeSelection::Single(ByteRange { start, end }) +} + +fn if_range_matches(headers: &HeaderMap, if_range: Option<&HeaderValue>) -> bool { + let Some(if_range) = if_range else { + return true; + }; + let Ok(candidate) = if_range.to_str() else { + return false; + }; + let candidate = candidate.trim(); + if candidate.starts_with('"') || candidate.starts_with("W/\"") { + if candidate.starts_with("W/") { + return false; + } + return headers + .get("etag") + .and_then(|value| value.to_str().ok()) + .is_some_and(|etag| !etag.trim().starts_with("W/") && etag.trim() == candidate); + } + let Some(candidate_date) = httpdate::parse_http_date(candidate).ok() else { + return false; + }; + headers + .get("last-modified") + .and_then(|value| value.to_str().ok()) + .and_then(|value| httpdate::parse_http_date(value).ok()) + .is_some_and(|modified| modified <= candidate_date) +} + +fn resolve_byte_range(range: ByteRange, len: usize) -> Option<(usize, usize)> { + if len == 0 { + return None; + } + let len_u64 = len as u64; + match (range.start, range.end) { + (Some(start), Some(end)) => { + if start >= len_u64 { + return None; + } + let end = end.min(len_u64.saturating_sub(1)); + Some((start as usize, end as usize)) + } + (Some(start), None) => { + if start >= len_u64 { + None + } else { + Some((start as usize, len - 1)) + } + } + (None, Some(suffix)) => { + if suffix == 0 { + return None; + } + let take = suffix.min(len_u64) as usize; + Some((len - take, len - 1)) + } + (None, None) => None, + } +} + +impl Hit { + pub fn apply_range(mut self, range: ByteRange, if_range: Option<&HeaderValue>) -> Self { + if !if_range_matches(self.response.headers(), if_range) { + return self; + } + let full_len = self.body.len(); + self.response + .headers_mut() + .insert("accept-ranges", HeaderValue::from_static("bytes")); + self.response.extensions_mut().insert(crate::compression::Skip); + let Some((start, end)) = resolve_byte_range(range, full_len) else { + *self.response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE; + *self.response.body_mut() = Body::empty(); + self.response.headers_mut().insert( + "content-range", + HeaderValue::from_str(&format!("bytes */{full_len}")).expect("generated range"), + ); + self.response + .headers_mut() + .insert("content-length", HeaderValue::from_static("0")); + return self; + }; + let slice = self.body.slice(start..=end); + *self.response.status_mut() = StatusCode::PARTIAL_CONTENT; + *self.response.body_mut() = Body::from(slice.clone()); + self.response.headers_mut().insert( + "content-range", + HeaderValue::from_str(&format!("bytes {start}-{end}/{full_len}")) + .expect("generated range"), + ); + self.response.headers_mut().insert( + "content-length", + HeaderValue::from_str(&slice.len().to_string()).expect("generated length"), + ); + self.body = slice; + self + } +} + +impl Revalidation { + pub fn response_with_range( + &self, + headers: HeaderMap, + range: ByteRange, + if_range: Option<&HeaderValue>, + ) -> Response { + let hit = Hit { + response: self.response(headers), + body: self.body.clone(), + hits: self.hits, + transformed: self.transformed, + remaining_ttl: self.ttl.saturating_sub(self.age), + stale: false, + background_revalidate: false, + resident_age_seconds: self.resident_age_at_lookup.as_secs(), + }; + hit.apply_range(range, if_range).response + } +} diff --git a/src/cache/tests.rs b/src/cache/tests.rs new file mode 100644 index 0000000..517df0e --- /dev/null +++ b/src/cache/tests.rs @@ -0,0 +1,3153 @@ +#[cfg(test)] +mod tests { + use super::*; + use http_body_util::BodyExt; + fn headers(value: &str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert("cache-control", value.parse().unwrap()); + h + } + fn settings() -> Settings { + Settings { + enabled: true, + ..Default::default() + } + } + fn ticket(cache: &Cache, headers: &HeaderMap) -> Ticket { + cache + .ticket(1, "http", "example.test", "app", "/", headers) + .unwrap() + } + #[test] + fn ttl_safety_and_shared_directives() { + let s = settings(); + for control in [ + "private, max-age=60", + "no-store, max-age=60", + "max-age=0", + "max-age=invalid", + "max-age=60,max-age=90", + "public", + "must-understand, max-age=60", + "max-age=\"60", + "max-age=60\"", + "public=invalid,max-age=60", + ] { + assert!( + plan(StatusCode::OK, &headers(control), &s, Duration::ZERO).is_none(), + "{control}" + ); + } + assert!(plan( + StatusCode::OK, + &headers("public,max-age=60"), + &s, + Duration::ZERO + ) + .is_some()); + let mut revalidate_headers = headers("no-cache, max-age=60"); + revalidate_headers.insert("etag", HeaderValue::from_static("\"validator\"")); + let revalidate = plan(StatusCode::OK, &revalidate_headers, &s, Duration::ZERO) + .expect("no-cache with a validator permits storage for validation"); + assert!(revalidate.requires_revalidation); + assert!(plan(StatusCode::OK, &headers("no-cache"), &s, Duration::ZERO).is_none()); + let mut validator_only = headers("no-cache"); + validator_only.insert("etag", HeaderValue::from_static("\"validator\"")); + assert!(plan(StatusCode::OK, &validator_only, &s, Duration::ZERO).is_some()); + assert!(plan( + StatusCode::OK, + &headers("max-age=60,s-maxage=0"), + &s, + Duration::ZERO + ) + .is_none()); + let shared = plan( + StatusCode::OK, + &headers("public, max-age=600, s-maxage=30"), + &s, + Duration::ZERO, + ) + .unwrap(); + assert_eq!(shared.ttl, 30); + let mut h = headers("max-age=60"); + h.insert("age", "61".parse().unwrap()); + assert!(plan(StatusCode::OK, &h, &s, Duration::ZERO).is_some()); + h.remove("age"); + h.insert("set-cookie", "session=secret".parse().unwrap()); + assert!(plan(StatusCode::OK, &h, &s, Duration::ZERO).is_none()); + } + #[test] + fn request_policy_uses_conditional_cache_and_revalidation() { + for name in [ + "authorization", + "cookie", + "if-match", + "if-unmodified-since", + "upgrade", + ] { + let mut h = HeaderMap::new(); + h.insert( + http::HeaderName::from_bytes(name.as_bytes()).unwrap(), + "value".parse().unwrap(), + ); + assert!( + !request_cacheable(&Method::GET, &h, true, "/", false, &settings()), + "{name}" + ); + } + for name in ["if-none-match", "if-modified-since"] { + let mut h = HeaderMap::new(); + h.insert( + http::HeaderName::from_bytes(name.as_bytes()).unwrap(), + "value".parse().unwrap(), + ); + assert!( + request_cacheable(&Method::GET, &h, true, "/", false, &settings()), + "{name}" + ); + } + for (value, reason) in [ + ("no-cache", "request_no_cache"), + ("max-age=0", "request_max_age"), + ] { + let h = headers(value); + assert_eq!( + request_policy(&Method::GET, &h, true, "/", false, &settings()), + RequestPolicy::Revalidate(reason) + ); + } + let min_fresh = headers("min-fresh=1"); + assert_eq!( + request_policy(&Method::GET, &min_fresh, true, "/", false, &settings()), + RequestPolicy::Use + ); + let immutable = headers("immutable"); + assert_eq!( + request_policy(&Method::GET, &immutable, true, "/", false, &settings()), + RequestPolicy::Use + ); + let h = headers("no-store"); + assert_eq!( + request_policy(&Method::GET, &h, true, "/", false, &settings()), + RequestPolicy::Bypass("request_no_store") + ); + let mut pragma = HeaderMap::new(); + pragma.insert("pragma", HeaderValue::from_static("no-cache")); + assert_eq!( + request_policy(&Method::GET, &pragma, true, "/", false, &settings()), + RequestPolicy::Revalidate("request_pragma_no_cache") + ); + assert!(!request_cacheable( + &Method::POST, + &HeaderMap::new(), + true, + "/", + false, + &settings() + )); + assert!(!request_cacheable( + &Method::GET, + &HeaderMap::new(), + false, + "/", + false, + &settings() + )); + assert!(request_cacheable( + &Method::GET, + &HeaderMap::new(), + true, + "/", + false, + &settings() + )); + assert!(request_cacheable( + &Method::HEAD, + &HeaderMap::new(), + true, + "/", + false, + &settings() + )); + let mut single_range = HeaderMap::new(); + single_range.insert("range", HeaderValue::from_static("bytes=0-3")); + assert_eq!( + request_policy(&Method::GET, &single_range, true, "/asset.bin", false, &settings()), + RequestPolicy::Use + ); + assert!(request_cacheable( + &Method::GET, + &single_range, + true, + "/asset.bin", + false, + &settings() + )); + single_range.insert("range", HeaderValue::from_static("bytes=0-3,7-9")); + assert_eq!( + request_policy(&Method::GET, &single_range, true, "/asset.bin", false, &settings()), + RequestPolicy::Bypass("request_range_unsupported") + ); + } + #[test] + fn rfc5861_windows_and_revalidation_flags_are_separate_metadata() { + let s = settings(); + let plan = plan( + StatusCode::OK, + &headers("public, max-age=60, stale-while-revalidate=30, stale-if-error=90, must-revalidate, proxy-revalidate"), + &s, + Duration::ZERO, + ) + .expect("revalidation flags must not prevent storage"); + assert_eq!(plan.ttl, 60); + assert_eq!(plan.stale_while_revalidate, 30); + assert_eq!(plan.stale_if_error, 90); + assert!(plan.must_revalidate); + assert!(plan.proxy_revalidate); + } + + #[test] + fn request_max_stale_is_bounded_and_respects_revalidation_flags() { + let unbounded = headers("max-stale"); + let bounded = headers("max-stale=15"); + assert_eq!(stale_mode(120, 60, 0, false, false, false, &unbounded), Some(false)); + assert_eq!(stale_mode(70, 60, 0, false, false, false, &bounded), Some(false)); + assert_eq!(stale_mode(80, 60, 0, false, false, false, &bounded), None); + assert_eq!(stale_mode(70, 60, 30, false, false, false, &HeaderMap::new()), Some(true)); + assert_eq!(stale_mode(100, 60, 30, false, false, false, &HeaderMap::new()), None); + assert_eq!(stale_mode(70, 60, 30, true, false, false, &unbounded), None); + assert_eq!(stale_mode(70, 60, 30, false, true, false, &unbounded), None); + } + + #[test] + fn cache_state_distinguishes_fresh_stale_and_expired() { + let none = HeaderMap::new(); + assert_eq!( + cache_state(30, 60, 20, false, false, false, &none), + CacheState::Fresh + ); + assert_eq!( + cache_state(70, 60, 20, false, false, false, &none), + CacheState::StaleButServable { + background_revalidate: true + } + ); + assert_eq!( + cache_state(90, 60, 20, false, false, false, &none), + CacheState::Expired + ); + } + + #[test] + fn stale_if_error_is_only_available_inside_its_window() { + let base = Revalidation { + headers: HeaderMap::new(), + body: Bytes::from_static(b"body"), + body_allocated: 4, + _transient_leases: Vec::new(), + hits: 0, + transformed: false, + storage_key: [0; 32], + resident_age_at_lookup: Duration::ZERO, + skip_compression: false, + reusable_without_validation: false, + age: 75, + ttl: 60, + stale_if_error: 30, + must_revalidate: false, + proxy_revalidate: false, + origin_etag: None, + origin_last_modified: None, + }; + assert!(base.can_stale_if_error()); + assert!(!Revalidation { age: 91, ..base.clone() }.can_stale_if_error()); + assert!(!Revalidation { must_revalidate: true, ..base.clone() }.can_stale_if_error()); + assert!(!Revalidation { proxy_revalidate: true, ..base }.can_stale_if_error()); + } + + #[test] + fn freshness_expiry_does_not_force_physical_eviction() { + assert_eq!(retention_deadline(60, 0, 0, false), u64::MAX); + assert_eq!(retention_deadline(60, 30, 0, false), u64::MAX); + assert_eq!(retention_deadline(60, 0, 45, false), u64::MAX); + assert_eq!(retention_deadline(60, 0, 0, true), u64::MAX); + } + + #[test] + fn request_max_age_only_revalidates_when_cached_age_exceeds_limit() { + let fresh = headers("max-age=30"); + assert!(request_freshness_allows(10, 60, &fresh)); + assert!(!request_freshness_allows(31, 60, &fresh)); + assert_eq!( + request_policy(&Method::GET, &fresh, true, "/", false, &settings()), + RequestPolicy::Use + ); + let zero = headers("max-age=0"); + assert_eq!( + request_policy(&Method::GET, &zero, true, "/", false, &settings()), + RequestPolicy::Revalidate("request_max_age") + ); + let constrained_stale = headers("max-age=30, max-stale"); + assert_eq!( + stale_mode(70, 60, 0, false, false, false, &constrained_stale), + None + ); + } + + #[test] + fn duplicate_and_argument_immutable_do_not_invalidate_cache_control() { + let duplicate = headers("public, max-age=60, immutable, immutable"); + assert!(plan(StatusCode::OK, &duplicate, &settings(), Duration::ZERO).is_some()); + let argument = headers("public, max-age=60, immutable=ignored"); + assert!(plan(StatusCode::OK, &argument, &settings(), Duration::ZERO).is_some()); + } + + #[test] + fn no_transform_does_not_disable_shared_storage() { + let protected = headers("public, max-age=60, no-transform"); + assert!(plan(StatusCode::OK, &protected, &settings(), Duration::ZERO).is_some()); + } + + #[test] + fn transformed_plan_uses_transformed_content_length() { + let s = settings(); + let origin = plan( + StatusCode::OK, + &{ + let mut h = headers("public, max-age=60"); + h.insert("content-length", HeaderValue::from_static("95")); + h + }, + &s, + Duration::ZERO, + ) + .unwrap(); + let transformed = plan( + StatusCode::OK, + &{ + let mut h = headers("public, max-age=60"); + h.insert("content-length", HeaderValue::from_static("76")); + h + }, + &s, + Duration::ZERO, + ) + .unwrap(); + assert_eq!(origin.restrict(transformed).expected_length, Some(76)); + } + + #[tokio::test] + async fn transformed_representation_is_stored_and_reused() { + let cache = Cache::new(1); + let cache_settings = settings(); + let request_headers = HeaderMap::new(); + let ticket = ticket(&cache, &request_headers); + let origin_body = br#"{ "message": "hello" }"#; + let mut response = Response::new(Body::from(origin_body.as_slice())); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=60"), + ); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static("application/json")); + response.headers_mut().insert( + "content-length", + HeaderValue::from_str(&origin_body.len().to_string()).unwrap(), + ); + response + .headers_mut() + .insert("etag", HeaderValue::from_static("\"origin\"")); + let origin_plan = plan( + StatusCode::OK, + response.headers(), + &cache_settings, + Duration::ZERO, + ) + .unwrap(); + let transform_settings = crate::transform::Settings { + enabled: true, + json: true, + ..Default::default() + }; + let transformed = crate::transform::apply( + response, + "/data.json", + &transform_settings, + false, + ) + .await + .unwrap(); + let transformed_plan = plan( + StatusCode::OK, + transformed.headers(), + &cache_settings, + Duration::ZERO, + ) + .unwrap(); + let expected_etag = transformed.headers()["etag"].clone(); + let wrapped = cache.wrap( + transformed, + ticket.clone(), + origin_plan.restrict(transformed_plan), + &cache_settings, + ); + let first = wrapped.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&first[..], br#"{"message":"hello"}"#); + + let hit = cache.get(&ticket).expect("transformed representation should be cached"); + assert_eq!(hit.response.headers()["etag"], expected_etag); + assert_eq!( + hit.response.headers()["content-length"].to_str().unwrap(), + first.len().to_string() + ); + let second = hit.response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(second, first); + } + + #[tokio::test] + async fn every_enabled_transform_kind_can_fill_and_reuse_the_cached_representation() { + let cases: [(&str, &str, &[u8], &str); 5] = [ + ( + "/app.js", + "application/javascript", + &b"const value = 1;\n"[..], + "javascript", + ), + ( + "/app.css", + "text/css", + &b"body { color: red; }\n"[..], + "css", + ), + ( + "/page.html", + "text/html", + &b"
x
"[..], + "html", + ), + ( + "/icon.svg", + "image/svg+xml", + &b""[..], + "svg", + ), + ( + "/data.json", + "application/json", + &br#"{ "a": 1 }"#[..], + "json", + ), + ]; + + for (path, content_type, origin_body, kind) in cases { + let cache = Cache::new(1); + let cache_settings = settings(); + let request_headers = HeaderMap::new(); + let ticket = cache + .ticket(1, "http", "example.test", "app", path, &request_headers) + .unwrap(); + let mut response = Response::new(Body::from(origin_body)); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=60"), + ); + response.headers_mut().insert( + "content-type", + HeaderValue::from_str(content_type).unwrap(), + ); + response.headers_mut().insert( + "content-length", + HeaderValue::from_str(&origin_body.len().to_string()).unwrap(), + ); + response + .headers_mut() + .insert("etag", HeaderValue::from_static("\"origin\"")); + let origin_plan = plan( + StatusCode::OK, + response.headers(), + &cache_settings, + Duration::ZERO, + ) + .unwrap(); + + let mut transform_settings = crate::transform::Settings { + enabled: true, + javascript: false, + css: false, + html: false, + svg: false, + json: false, + ..Default::default() + }; + match kind { + "javascript" => transform_settings.javascript = true, + "css" => transform_settings.css = true, + "html" => transform_settings.html = true, + "svg" => transform_settings.svg = true, + "json" => transform_settings.json = true, + _ => unreachable!(), + } + let transformed = crate::transform::apply( + response, + path, + &transform_settings, + false, + ) + .await + .unwrap(); + let transformed_plan = plan( + StatusCode::OK, + transformed.headers(), + &cache_settings, + Duration::ZERO, + ) + .unwrap(); + let expected_etag = transformed.headers()["etag"].clone(); + let expected_length = transformed.headers()["content-length"].clone(); + let wrapped = cache.wrap( + transformed, + ticket.clone(), + origin_plan.restrict(transformed_plan), + &cache_settings, + ); + let first = wrapped.into_body().collect().await.unwrap().to_bytes(); + assert!(!first.is_empty(), "{kind}"); + assert_ne!(first.as_ref(), origin_body, "{kind} should change the chosen fixture"); + + let hit = cache.get(&ticket).expect("transformed representation should be cached"); + assert_eq!(hit.response.headers()["etag"], expected_etag, "{kind}"); + assert_eq!( + hit.response.headers()["content-length"], + expected_length, + "{kind}" + ); + let second = hit.response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(second, first, "{kind}"); + } + } + + #[test] + fn conditional_hit_uses_weak_etag_and_last_modified() { + let mut response = HeaderMap::new(); + response.insert("etag", HeaderValue::from_static("W/\"abc\"")); + response.insert( + "last-modified", + HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"), + ); + let mut request = HeaderMap::new(); + request.insert("if-none-match", HeaderValue::from_static("\"abc\"")); + assert!(conditional_not_modified(&request, &response)); + request.insert("if-none-match", HeaderValue::from_static("\"other\"")); + assert!(!conditional_not_modified(&request, &response)); + request.remove("if-none-match"); + request.insert( + "if-modified-since", + HeaderValue::from_static("Tue, 27 Jan 2026 00:00:00 GMT"), + ); + assert!(conditional_not_modified(&request, &response)); + } + #[test] + fn query_strings_remain_distinct_and_bypass_query_is_only_a_request_policy() { + let cache = Cache::new(1); + let headers = HeaderMap::new(); + let v1 = cache + .ticket(1, "http", "example.test", "app", "/file.css?v=1", &headers) + .unwrap(); + let v2 = cache + .ticket(1, "http", "example.test", "app", "/file.css?v=2", &headers) + .unwrap(); + let flag = cache + .ticket(1, "http", "example.test", "app", "/file.css?F", &headers) + .unwrap(); + assert_ne!(v1.key, v2.key); + assert_ne!(v1.key, flag.key); + assert_ne!(v2.key, flag.key); + + let mut cache_settings = settings(); + assert_eq!( + request_policy( + &Method::GET, + &headers, + true, + "/file.css", + true, + &cache_settings, + ), + RequestPolicy::Use + ); + cache_settings.bypass_query = true; + assert_eq!( + request_policy( + &Method::GET, + &headers, + true, + "/file.css", + true, + &cache_settings, + ), + RequestPolicy::Bypass("query_excluded") + ); + } + + #[tokio::test] + async fn transformed_client_conditionals_use_the_representation_etag_not_the_origin_etag() { + let transform_settings = crate::transform::Settings { + enabled: true, + json: true, + ..Default::default() + }; + let body = br#"{ "a": 1 }"#; + let mut response = Response::new(Body::from(body.as_slice())); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static("application/json")); + response.headers_mut().insert( + "content-length", + HeaderValue::from_str(&body.len().to_string()).unwrap(), + ); + response + .headers_mut() + .insert("etag", HeaderValue::from_static("\"origin\"")); + let response = crate::transform::apply(response, "/data.json", &transform_settings, false) + .await + .unwrap(); + let representation_etag = response.headers()["etag"].clone(); + assert_ne!(representation_etag, HeaderValue::from_static("\"origin\"")); + + let mut request = HeaderMap::new(); + request.insert("if-none-match", representation_etag.clone()); + assert!(conditional_not_modified(&request, response.headers())); + request.insert("if-none-match", HeaderValue::from_static("\"origin\"")); + assert!(!conditional_not_modified(&request, response.headers())); + + let validators = response + .extensions() + .get::() + .unwrap(); + assert_eq!(validators.etag.as_ref().unwrap(), "\"origin\""); + } + + #[test] + fn cache_key_separates_frontend_scheme() { + let cache = Cache::new(1); + let headers = HeaderMap::new(); + let http = cache + .ticket(1, "http", "example.test", "app", "/asset", &headers) + .unwrap(); + let https = cache + .ticket(1, "https", "example.test", "app", "/asset", &headers) + .unwrap(); + assert_ne!(http.key, https.key); + } + + #[test] + fn cache_key_ignores_revalidation_headers() { + let cache = Cache::new(1); + let base = ticket(&cache, &HeaderMap::new()); + for (name, value) in [ + ("cache-control", "no-cache"), + ("pragma", "no-cache"), + ("if-none-match", "\"origin\""), + ("if-modified-since", "Mon, 26 Jan 2026 23:11:40 GMT"), + ] { + let mut headers = HeaderMap::new(); + headers.insert( + http::HeaderName::from_bytes(name.as_bytes()).unwrap(), + HeaderValue::from_str(value).unwrap(), + ); + assert_eq!(base.key, ticket(&cache, &headers).key, "{name}"); + } + } + #[test] + fn cache_memory_defaults_are_bounded_and_sane() { + let s = Settings::default(); + assert_eq!(s.max_bytes, 256 * 1024 * 1024); + assert_eq!(s.max_object_bytes, 16 * 1024 * 1024); + assert_eq!(s.transient_max_bytes, 64 * 1024 * 1024); + assert_eq!(s.cold_queue_max_bytes, 32 * 1024 * 1024); + assert_eq!(s.hot_eviction_max_objects, 64); + assert!(s.validate().is_ok()); + assert!(Cache::fill_headroom_bytes(8 * 1024 * 1024) <= 128 * 1024); + assert!(Cache::fill_headroom_bytes(s.max_bytes) <= 1024 * 1024); + } + + #[test] + fn transient_budget_is_hard_and_released_with_lease() { + let cache = Cache::new(1); + let mut s = Settings::default(); + s.transient_max_bytes = 128 * 1024; + s.cold_queue_max_bytes = 64 * 1024; + let first = cache.try_reserve_transient(96 * 1024, &s).expect("first reservation"); + assert!(cache.try_reserve_transient(40 * 1024, &s).is_none()); + assert_eq!(cache.view().transient_bytes, 96 * 1024); + drop(first); + assert_eq!(cache.view().transient_bytes, 0); + assert!(cache.try_reserve_transient(128 * 1024, &s).is_some()); + } + + #[test] + fn cold_queue_budget_cannot_exceed_transient_budget() { + let mut s = Settings::default(); + s.transient_max_bytes = 8 * 1024 * 1024; + s.cold_queue_max_bytes = 16 * 1024 * 1024; + assert!(s.validate().is_err()); + } + + #[cfg(target_pointer_width = "64")] + #[test] + fn cache_limits_allow_explicit_multi_gibibyte_budgets() { + let mut s = Settings::default(); + s.max_bytes = 4usize * 1024 * 1024 * 1024; + s.max_object_bytes = 2usize * 1024 * 1024 * 1024; + s.transient_max_bytes = 512usize * 1024 * 1024; + s.cold_queue_max_bytes = 256usize * 1024 * 1024; + assert!(s.validate().is_ok()); + s.max_object_bytes = s.max_bytes.saturating_add(1); + assert!(s.validate().is_err()); + } + #[test] + fn revalidation_only_reuses_matching_client_validator() { + let cached = Revalidation { + headers: { + let mut h = HeaderMap::new(); + h.insert("etag", HeaderValue::from_static("W/\"origin\"")); + h.insert( + "last-modified", + HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"), + ); + h + }, + body: Bytes::from_static(b"body"), + body_allocated: 4, + _transient_leases: Vec::new(), + hits: 0, + transformed: false, + storage_key: [0; 32], + resident_age_at_lookup: Duration::ZERO, + skip_compression: false, + reusable_without_validation: false, + age: 61, + ttl: 60, + stale_if_error: 30, + must_revalidate: false, + proxy_revalidate: false, + origin_etag: Some(HeaderValue::from_static("W/\"origin\"")), + origin_last_modified: Some(HeaderValue::from_static( + "Mon, 26 Jan 2026 23:11:40 GMT", + )), + }; + let mut matching = HeaderMap::new(); + matching.insert("if-none-match", HeaderValue::from_static("\"origin\"")); + assert!(cached.apply_validators(&mut matching)); + let mut other = HeaderMap::new(); + other.insert("if-none-match", HeaderValue::from_static("\"other\"")); + assert!(!cached.apply_validators(&mut other)); + assert_eq!(other["if-none-match"], "\"other\""); + let mut empty = HeaderMap::new(); + assert!(cached.apply_validators(&mut empty)); + assert_eq!(empty["if-none-match"], "W/\"origin\""); + } + #[tokio::test] + async fn transformed_etag_is_never_used_as_an_origin_validator() { + let cache = Cache::new(1); + let ticket = ticket(&cache, &HeaderMap::new()); + let settings = settings(); + let modified = HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"); + let public_etag = HeaderValue::from_static("\"rustproxy-deadbeef\""); + let mut response_headers = headers("public, max-age=60"); + response_headers.insert("etag", public_etag.clone()); + response_headers.insert("last-modified", modified.clone()); + let plan = plan( + StatusCode::OK, + &response_headers, + &settings, + Duration::ZERO, + ) + .unwrap(); + let mut response = Response::new(Body::from("transformed")); + *response.headers_mut() = response_headers; + response + .extensions_mut() + .insert(crate::transform::OriginValidators { + etag: None, + last_modified: Some(modified.clone()), + }); + cache + .wrap(response, ticket.clone(), plan, &settings) + .into_body() + .collect() + .await + .unwrap(); + + let cached = cache.revalidation(&ticket).expect("stored response"); + assert!(cached.origin_etag.is_none()); + assert_eq!(cached.origin_last_modified.as_ref(), Some(&modified)); + let mut outbound = HeaderMap::new(); + assert!(cached.apply_validators(&mut outbound)); + assert!(!outbound.contains_key("if-none-match")); + assert_eq!(outbound["if-modified-since"], modified); + + let mut client_conditional = HeaderMap::new(); + client_conditional.insert("if-none-match", public_etag.clone()); + assert!(cached.apply_validators(&mut client_conditional)); + assert!(!client_conditional.contains_key("if-none-match")); + assert_eq!(client_conditional["if-modified-since"], modified); + + let mut validation = HeaderMap::new(); + validation.insert("cache-control", HeaderValue::from_static("public, max-age=60")); + validation.insert("etag", HeaderValue::from_static("\"origin-new\"")); + validation.insert("last-modified", HeaderValue::from_static("Mon, 26 Jan 2026 23:12:40 GMT")); + let merged = cached.merge_validation_headers(&validation); + assert_eq!(merged["etag"], public_etag); + } + + #[tokio::test] + async fn vary_selects_variants_without_fragmenting_the_primary_hash() { + let cache = Cache::new(1); + let s = settings(); + let mut en = HeaderMap::new(); + en.insert("accept-language", "en".parse().unwrap()); + let mut pl = en.clone(); + pl.insert("accept-language", "pl".parse().unwrap()); + let en_ticket = ticket(&cache, &en); + let pl_ticket = ticket(&cache, &pl); + assert_eq!(en_ticket.key, pl_ticket.key); + + let mut h = headers("max-age=60"); + h.insert("vary", HeaderValue::from_static("Accept-Language")); + let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap(); + let mut response = Response::new(Body::from("hello")); + *response.headers_mut() = h.clone(); + cache + .wrap(response, en_ticket.clone(), p, &s) + .into_body() + .collect() + .await + .unwrap(); + assert!(cache.get(&en_ticket).is_some()); + assert!(cache.get(&pl_ticket).is_none()); + + let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap(); + let mut response = Response::new(Body::from("czesc")); + *response.headers_mut() = h; + cache + .wrap(response, pl_ticket.clone(), p, &s) + .into_body() + .collect() + .await + .unwrap(); + let en_hit = cache.get(&en_ticket).expect("English Vary variant"); + let pl_hit = cache.get(&pl_ticket).expect("Polish Vary variant"); + assert_eq!( + en_hit + .response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(), + Bytes::from_static(b"hello") + ); + assert_eq!( + pl_hit + .response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(), + Bytes::from_static(b"czesc") + ); + + let mut star = headers("max-age=60"); + star.insert("vary", HeaderValue::from_static("*")); + assert!(plan(StatusCode::OK, &star, &s, Duration::ZERO).is_none()); + for vary in [ + "x-request-id", + "cache-control", + "pragma", + "if-none-match", + "if-modified-since", + ] { + let mut h = headers("max-age=60"); + h.insert("vary", vary.parse().unwrap()); + assert!( + plan(StatusCode::OK, &h, &s, Duration::ZERO).is_some(), + "valid Vary field must remain cacheable: {vary}" + ); + } + } + #[tokio::test] + async fn canonical_identity_drops_accept_encoding_as_a_cache_variant() { + let cache = Cache::new(1); + let s = settings(); + let mut gzip = HeaderMap::new(); + gzip.insert("accept-encoding", HeaderValue::from_static("gzip")); + let mut brotli = HeaderMap::new(); + brotli.insert("accept-encoding", HeaderValue::from_static("br")); + let gzip_ticket = ticket(&cache, &gzip); + let brotli_ticket = ticket(&cache, &brotli); + + let mut origin_headers = headers("public, max-age=60"); + origin_headers.insert("vary", HeaderValue::from_static("Accept-Encoding")); + origin_headers.insert("content-length", HeaderValue::from_static("7")); + let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO) + .expect("origin plan"); + + let mut transformed_headers = headers("public, max-age=60"); + transformed_headers.insert("content-length", HeaderValue::from_static("7")); + let transformed_plan = plan( + StatusCode::OK, + &transformed_headers, + &s, + Duration::ZERO, + ) + .expect("transformed plan"); + let cache_plan = origin_plan + .without_vary("accept-encoding") + .restrict(transformed_plan); + assert!(cache_plan.vary.is_empty()); + + let mut response = Response::new(Body::from("payload")); + *response.headers_mut() = transformed_headers; + cache + .wrap(response, gzip_ticket.clone(), cache_plan, &s) + .into_body() + .collect() + .await + .unwrap(); + + let hit = cache + .get(&brotli_ticket) + .expect("canonical identity must be shared across client encodings"); + assert_eq!( + hit.response.into_body().collect().await.unwrap().to_bytes(), + Bytes::from_static(b"payload") + ); + } + + #[tokio::test] + async fn canonical_identity_preserves_non_encoding_vary_dimensions() { + let cache = Cache::new(1); + let s = settings(); + let mut light = HeaderMap::new(); + light.insert("accept-encoding", HeaderValue::from_static("gzip")); + light.insert("x-theme", HeaderValue::from_static("light")); + let mut dark = HeaderMap::new(); + dark.insert("accept-encoding", HeaderValue::from_static("br")); + dark.insert("x-theme", HeaderValue::from_static("dark")); + + let mut origin_headers = headers("public, max-age=60"); + origin_headers.insert( + "vary", + HeaderValue::from_static("Accept-Encoding, X-Theme"), + ); + let origin_plan = plan(StatusCode::OK, &origin_headers, &s, Duration::ZERO) + .expect("origin plan"); + let mut transformed_headers = headers("public, max-age=60"); + transformed_headers.insert("vary", HeaderValue::from_static("X-Theme")); + let transformed_plan = plan( + StatusCode::OK, + &transformed_headers, + &s, + Duration::ZERO, + ) + .expect("transformed plan"); + let cache_plan = origin_plan + .without_vary("accept-encoding") + .restrict(transformed_plan); + assert_eq!(cache_plan.vary, vec!["x-theme".to_owned()]); + + let mut response = Response::new(Body::from("light")); + *response.headers_mut() = transformed_headers; + cache + .wrap(response, ticket(&cache, &light), cache_plan, &s) + .into_body() + .collect() + .await + .unwrap(); + assert!(cache.get(&ticket(&cache, &light)).is_some()); + assert!(cache.get(&ticket(&cache, &dark)).is_none()); + } + + #[tokio::test] + async fn vary_schema_change_removes_incompatible_fallbacks() { + let cache = Cache::new(1); + let s = settings(); + let mut en = HeaderMap::new(); + en.insert("accept-language", HeaderValue::from_static("en")); + let mut pl = HeaderMap::new(); + pl.insert("accept-language", HeaderValue::from_static("pl")); + let en_ticket = ticket(&cache, &en); + let pl_ticket = ticket(&cache, &pl); + + fill(&cache, en_ticket.clone(), "shared-old", &s).await; + assert!(cache.get(&pl_ticket).is_some()); + + let mut varied_headers = headers("max-age=60"); + varied_headers.insert("vary", HeaderValue::from_static("Accept-Language")); + let varied_plan = plan(StatusCode::OK, &varied_headers, &s, Duration::ZERO).unwrap(); + let mut varied = Response::new(Body::from("english-new")); + *varied.headers_mut() = varied_headers; + cache + .wrap(varied, en_ticket.clone(), varied_plan, &s) + .into_body() + .collect() + .await + .unwrap(); + assert!(cache.get(&en_ticket).is_some()); + assert!( + cache.get(&pl_ticket).is_none(), + "an older no-Vary object must not shadow a newer Vary schema" + ); + + fill(&cache, pl_ticket.clone(), "shared-new", &s).await; + let hit = cache + .get(&en_ticket) + .expect("new no-Vary schema should replace old variants"); + assert_eq!( + hit.response.into_body().collect().await.unwrap().to_bytes(), + Bytes::from_static(b"shared-new") + ); + } + + async fn fill(cache: &Arc, ticket: Ticket, data: &'static str, settings: &Settings) { + let h = headers("max-age=60"); + let p = plan(StatusCode::OK, &h, settings, Duration::ZERO).unwrap(); + let mut r = Response::new(Body::from(data)); + *r.headers_mut() = h; + cache + .wrap(r, ticket, p, settings) + .into_body() + .collect() + .await + .unwrap(); + } + #[tokio::test] + async fn max_stale_can_reuse_an_object_after_normal_ttl_expiry() { + let cache = Cache::new(1); + let s = settings(); + let initial = ticket(&cache, &HeaderMap::new()); + let response_headers = headers("public, max-age=1"); + let p = plan(StatusCode::OK, &response_headers, &s, Duration::ZERO).unwrap(); + let mut response = Response::new(Body::from("stale-payload")); + *response.headers_mut() = response_headers; + cache + .wrap(response, initial.clone(), p, &s) + .into_body() + .collect() + .await + .unwrap(); + { + let mut store = cache.store.lock().unwrap(); + let entry = store.entries.values_mut().next().expect("stored object"); + entry.received = Instant::now() - Duration::from_secs(2); + } + assert!(cache.get(&initial).is_none(), "normal request must not receive stale data"); + assert_eq!(cache.view().entries, 1, "freshness expiry must not physically evict the object"); + + let stale = ticket(&cache, &headers("max-stale")); + let hit = cache.get(&stale).expect("explicit max-stale may reuse retained stale data"); + assert!(hit.stale); + assert_eq!( + hit.response.into_body().collect().await.unwrap().to_bytes(), + Bytes::from_static(b"stale-payload") + ); + } + + #[tokio::test] + async fn response_no_cache_is_stored_only_for_validation() { + let cache = Cache::new(1); + let t = ticket(&cache, &HeaderMap::new()); + let s = settings(); + let mut h = headers("no-cache"); + h.insert("etag", HeaderValue::from_static("\"origin\"")); + let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap(); + assert!(p.requires_revalidation); + let mut response = Response::new(Body::from("payload")); + *response.headers_mut() = h; + cache + .wrap(response, t.clone(), p, &s) + .into_body() + .collect() + .await + .unwrap(); + assert!( + cache.get(&t).is_none(), + "no-cache must not be reused without validation" + ); + let candidate = cache.revalidation(&t).expect("stored validator/body"); + assert!(!candidate.reusable_without_validation()); + let mut validation = HeaderMap::new(); + validation.insert("cache-control", HeaderValue::from_static("no-cache")); + validation.insert("etag", HeaderValue::from_static("\"origin\"")); + let merged = candidate.merge_validation_headers(&validation); + let hit = cache + .publish_revalidated(t.clone(), &candidate, merged, &validation, &s, Duration::ZERO) + .expect("validated body can be delivered"); + assert_eq!( + hit.response.into_body().collect().await.unwrap().to_bytes(), + Bytes::from_static(b"payload") + ); + assert!( + cache.get(&t).is_none(), + "stored no-cache response still requires validation next time" + ); + } + #[tokio::test] + async fn resident_age_survives_successful_revalidation() { + let cache = Cache::new(1); + let t = ticket(&cache, &HeaderMap::new()); + let s = settings(); + let mut h = headers("no-cache"); + h.insert("etag", HeaderValue::from_static("\"origin\"")); + let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap(); + let mut response = Response::new(Body::from("payload")); + *response.headers_mut() = h; + cache + .wrap(response, t.clone(), p, &s) + .into_body() + .collect() + .await + .unwrap(); + { + let mut store = cache.store.lock().unwrap(); + let entry = store.entries.values_mut().next().expect("stored object"); + entry.resident_age_base = Duration::from_secs(30); + entry.resident_since = Instant::now(); + } + let candidate = cache.revalidation(&t).expect("revalidation candidate"); + assert!(candidate.resident_age_at_lookup >= Duration::from_secs(30)); + let mut validation = HeaderMap::new(); + validation.insert( + "cache-control", + HeaderValue::from_static("public, max-age=60"), + ); + validation.insert("etag", HeaderValue::from_static("\"origin\"")); + let merged = candidate.merge_validation_headers(&validation); + let hit = cache + .publish_revalidated(t, &candidate, merged, &validation, &s, Duration::from_secs(2)) + .expect("validated object"); + assert!(hit.resident_age_seconds >= 32); + } + + #[tokio::test] + async fn complete_body_hit_age_and_purge() { + let cache = Cache::new(1); + let t = ticket(&cache, &HeaderMap::new()); + assert!(cache.get(&t).is_none()); + fill(&cache, t.clone(), "payload", &settings()).await; + let view = cache.view(); + assert_eq!(view.hot_logical_bytes, b"payload".len()); + assert_eq!(view.hot_allocated_bytes, view.resident_bytes); + assert!(view.hot_allocated_bytes > view.hot_logical_bytes); + assert!(view.hot_allocated_bytes <= view.hot_limit_bytes); + let hit = cache.get(&t).unwrap(); + assert_eq!(hit.hits, 1); + assert!(hit.response.headers().contains_key("age")); + assert_eq!( + hit.response.into_body().collect().await.unwrap().to_bytes(), + Bytes::from_static(b"payload") + ); + assert_eq!(cache.purge(None), 1); + assert!(cache.get(&t).is_none()); + } + #[test] + fn defaults_enable_static_cookie_boost_for_common_assets() { + let settings = Settings::default(); + assert!(settings.enabled); + assert!(settings.static_ignore_cookies); + for path in [ + "/asset.jpg", + "/asset.jpeg", + "/asset.png", + "/app.css", + "/app.js", + "/font.woff2", + "/audio.mp3", + "/note.txt", + ] { + assert!(settings.ignore_cookie_for_path(path), "{path}"); + } + assert!(!settings.ignore_cookie_for_path("/account")); + assert!(!settings.ignore_cookie_for_path("/api/profile.json")); + } + #[test] + fn static_cookie_bypass_is_limited_to_configured_extensions() { + let mut settings = settings(); + settings.static_ignore_cookies = true; + let mut h = HeaderMap::new(); + h.insert("cookie", HeaderValue::from_static("session=secret")); + assert_eq!( + request_reason(&Method::GET, &h, true, "/asset.jpg", false, &settings), + None + ); + assert_eq!( + request_reason(&Method::GET, &h, true, "/account", false, &settings), + Some("request_cookie") + ); + } + #[test] + fn age_uses_corrected_received_age_plus_response_delay_without_rounding_up() { + let settings = settings(); + let mut h = headers("max-age=120"); + h.insert( + "date", + httpdate::fmt_http_date(SystemTime::now() - Duration::from_secs(10)) + .parse() + .unwrap(), + ); + h.insert("age", HeaderValue::from_static("3")); + let plan = plan(StatusCode::OK, &h, &settings, Duration::from_millis(250)).unwrap(); + assert!(plan.initial_age >= Duration::from_secs(10)); + assert!(plan.initial_age < Duration::from_secs(12)); + let fresh = plan( + StatusCode::OK, + &headers("max-age=60"), + &settings, + Duration::from_millis(250), + ) + .unwrap(); + assert_eq!(fresh.initial_age.as_secs(), 0); + } + #[tokio::test] + async fn purged_inflight_fill_cannot_repopulate() { + let cache = Cache::new(1); + let t = ticket(&cache, &HeaderMap::new()); + cache.purge(None); + fill(&cache, t, "old", &settings()).await; + assert_eq!(cache.view().entries, 0); + } + #[tokio::test] + async fn revision_isolation_and_capacity() { + let cache = Cache::new(1); + let s = Settings { + max_entries: 1, + ..settings() + }; + let a = ticket(&cache, &HeaderMap::new()); + fill(&cache, a.clone(), "a", &s).await; + let h = HeaderMap::new(); + let b = cache + .ticket(1, "http", "example.test", "app", "/other", &h) + .unwrap(); + fill(&cache, b.clone(), "b", &s).await; + assert!(cache.get(&a).is_none()); + assert!(cache.get(&b).is_some()); + assert_eq!(cache.view().evictions, 1); + cache.purge(Some(2)); + assert!(cache + .ticket(1, "http", "example.test", "app", "/", &h) + .is_none()); + } + #[tokio::test] + async fn hot_byte_budget_evicts_before_another_fill_grows() { + let cache = Cache::new(1); + let s = Settings { + max_bytes: 96 * 1024, + max_object_bytes: 64 * 1024, + max_entries: 16, + transient_max_bytes: 128 * 1024, + cold_queue_max_bytes: 64 * 1024, + ..settings() + }; + let headers = HeaderMap::new(); + let first = cache + .ticket(1, "http", "example.test", "app", "/first", &headers) + .unwrap(); + let second = cache + .ticket(1, "http", "example.test", "app", "/second", &headers) + .unwrap(); + fill(&cache, first.clone(), "first", &s).await; + assert!(cache.get(&first).is_some()); + let before = cache.view(); + assert!(before.hot_allocated_bytes <= s.max_bytes); + + fill(&cache, second.clone(), "second", &s).await; + let after = cache.view(); + assert!(after.hot_allocated_bytes <= s.max_bytes); + assert_eq!(after.fill_bytes_active, 0); + assert_eq!(after.transient_bytes, 0); + assert!(cache.get(&first).is_none(), "LRU entry should make room before the next fill allocation"); + assert!(cache.get(&second).is_some()); + assert!(after.evictions >= 1); + } + #[tokio::test] + async fn dropped_partial_bodies_and_oversize_are_never_cached() { + let cache = Cache::new(1); + let t = ticket(&cache, &HeaderMap::new()); + let s = settings(); + let h = headers("max-age=60"); + let p = plan(StatusCode::OK, &h, &s, Duration::ZERO).unwrap(); + let r = Response::new(Body::from("not-consumed")); + drop(cache.wrap(r, t.clone(), p, &s)); + assert!(cache.get(&t).is_none()); + assert_eq!(cache.view().fills_active, 0); + let small = Settings { + max_object_bytes: 2, + ..s + }; + let h = headers("max-age=60"); + let p = plan(StatusCode::OK, &h, &small, Duration::ZERO).unwrap(); + let mut response = Response::new(Body::from("oversize")); + *response.headers_mut() = h; + let streamed = cache + .wrap(response, t.clone(), p, &small) + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + assert_eq!(streamed, Bytes::from_static(b"oversize")); + assert!(cache.get(&t).is_none()); + assert_eq!(cache.view().fill_bytes_active, 0); + assert_eq!(cache.view().transient_bytes, 0); + } + #[test] + fn varnish_primary_hash_uses_host_and_url_not_unrelated_request_headers() { + let cache = Cache::new(1); + let mut a = HeaderMap::new(); + a.append("user-agent", "browser-a".parse().unwrap()); + a.append("sec-ch-ua", "brand-a".parse().unwrap()); + let mut b = HeaderMap::new(); + b.append("user-agent", "browser-b".parse().unwrap()); + b.append("x-forwarded-for", "192.0.2.8".parse().unwrap()); + let base = cache + .ticket( + 1, + "http", + "Example.Test", + "pool/member-a", + "/asset.js?x=1", + &a, + ) + .unwrap(); + let other_transport = cache + .ticket( + 1, + "https", + "example.test", + "pool/member-b", + "/asset.js?x=1", + &b, + ) + .unwrap(); + assert_eq!(base.key, other_transport.key); + let other_url = cache + .ticket( + 1, + "https", + "example.test", + "pool/member-b", + "/asset.js?x=2", + &b, + ) + .unwrap(); + assert_ne!(base.key, other_url.key); + let explicit_port = cache + .ticket( + 1, + "http", + "example.test:8080", + "pool/member-a", + "/asset.js?x=1", + &a, + ) + .unwrap(); + assert_ne!(base.key, explicit_port.key); + } + #[tokio::test] + async fn only_if_cached_can_reuse_a_normal_get() { + let cache = Cache::new(1); + let plain = HeaderMap::new(); + fill(&cache, ticket(&cache, &plain), "payload", &settings()).await; + let only = headers("only-if-cached"); + assert!(request_cacheable( + &Method::GET, + &only, + true, + "/", + false, + &settings() + )); + assert!(only_if_cached(&only)); + assert!(cache.get(&ticket(&cache, &only)).is_some()); + } + #[tokio::test] + async fn dropped_partial_fill_is_not_published_and_a_later_get_can_fill() { + use futures_util::stream; + use http_body_util::StreamBody; + + let cache = Cache::new(1); + let s = settings(); + let t = ticket(&cache, &HeaderMap::new()); + let frames = vec![ + Ok::<_, std::io::Error>(Frame::data(Bytes::from_static(b"first-"))), + Ok(Frame::data(Bytes::from_static(b"second"))), + ]; + let mut response = Response::new(Body::new(StreamBody::new(stream::iter(frames)))); + *response.headers_mut() = headers("max-age=60"); + response + .headers_mut() + .insert("content-length", HeaderValue::from_static("12")); + let p = plan(StatusCode::OK, response.headers(), &s, Duration::ZERO).unwrap(); + let mut body = cache.wrap(response, t.clone(), p, &s).into_body(); + let first = body + .frame() + .await + .expect("first frame") + .expect("successful first frame") + .into_data() + .expect("data frame"); + assert_eq!(first, Bytes::from_static(b"first-")); + drop(body); + + assert!(cache.get(&t).is_none(), "partial body must never become a HIT"); + assert_eq!(cache.view().fills_active, 0); + assert_eq!(cache.view().rejected_fills, 1); + + fill(&cache, t.clone(), "complete", &s).await; + let hit = cache.get(&t).expect("a later complete GET can fill the cache"); + assert_eq!( + hit.response.into_body().collect().await.unwrap().to_bytes(), + Bytes::from_static(b"complete") + ); + } + + #[tokio::test] + async fn failed_streams_and_trailers_cannot_publish_entries() { + use futures_util::stream; + use http_body_util::StreamBody; + for fail in [true, false] { + let cache = Cache::new(1); + let s = settings(); + let t = ticket(&cache, &HeaderMap::new()); + let frames = vec![ + Ok(Frame::data(Bytes::from_static(b"partial"))), + if fail { + Err(std::io::Error::other("broken origin")) + } else { + Ok(Frame::trailers(HeaderMap::new())) + }, + ]; + let mut r = Response::new(Body::new(StreamBody::new(stream::iter(frames)))); + *r.headers_mut() = headers("max-age=60"); + let p = plan(StatusCode::OK, r.headers(), &s, Duration::ZERO).unwrap(); + let result = cache.wrap(r, t.clone(), p, &s).into_body().collect().await; + assert_eq!(result.is_err(), fail); + assert!(cache.get(&t).is_none()); + assert_eq!(cache.view().fills_active, 0); + } + } +} + +#[cfg(test)] +mod scope_tests { + use super::*; + use http_body_util::BodyExt; + async fn fill(cache: &Arc, ticket: Ticket) { + let settings = Settings { + enabled: true, + ..Settings::default() + }; + let mut response = Response::new(Body::from("scope-payload")); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=30"), + ); + let plan = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + cache + .wrap(response, ticket, plan, &settings) + .into_body() + .collect() + .await + .unwrap(); + } + #[tokio::test] + async fn selective_purge_preserves_other_hosts_and_cancels_old_fills() { + let cache = Cache::new(1); + let ticket = |scope| { + cache + .ticket_scoped( + 1, + scope, + "http", + "same.test", + "root", + "/", + &HeaderMap::new(), + ) + .unwrap() + }; + let a = ticket("a"); + let b = ticket("b"); + fill(&cache, a.clone()).await; + fill(&cache, b.clone()).await; + assert_eq!(cache.scope_view("a").entries, 1); + assert_eq!(cache.purge_scope("a"), 1); + assert!(cache.get(&a).is_none()); + assert!(cache.get(&b).is_some()); + fill(&cache, a).await; + assert_eq!(cache.scope_view("a").entries, 0); + fill(&cache, ticket("a")).await; + assert_eq!(cache.scope_view("a").entries, 1); + } + #[test] + fn per_vhost_settings_inherit_and_cannot_expand_shared_capacity() { + let global = Settings::default(); + let effective = Overrides { + enabled: Some(true), + max_ttl_seconds: Some(30), + expose_status: Some(true), + ..Overrides::default() + } + .resolve(&global) + .unwrap(); + assert!(effective.enabled && effective.expose_status); + let mut globally_disabled = global.clone(); + globally_disabled.enabled = false; + assert!(!Overrides { enabled: Some(true), ..Overrides::default() } + .resolve(&globally_disabled) + .unwrap() + .enabled); + assert!(!Overrides { enabled: Some(false), ..Overrides::default() } + .resolve(&global) + .unwrap() + .enabled); + assert!(!Overrides::default() + .resolve(&globally_disabled) + .unwrap() + .enabled); + assert_eq!(effective.max_bytes, global.max_bytes); + assert_eq!(effective.max_ttl_seconds, 30); + assert!(Overrides { + max_object_bytes: Some(global.max_object_bytes + 1), + ..Overrides::default() + } + .resolve(&global) + .is_err()); + assert!(Overrides { + max_ttl_seconds: Some(1), + default_ttl_seconds: Some(2), + ..Overrides::default() + } + .resolve(&global) + .is_err()); + } +} + +#[cfg(test)] +mod dashboard_tests { + use super::*; + use http_body_util::BodyExt; + async fn fill(cache: &Arc, scope: &str, host: &str, path: &str) -> Ticket { + let t = cache + .ticket_scoped( + 7, + scope, + "http", + host, + "origin@127.0.0.1:8080", + path, + &HeaderMap::new(), + ) + .unwrap(); + let settings = Settings { + enabled: true, + ..Settings::default() + }; + let mut response = Response::new(Body::from("public representation")); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=30"), + ); + response + .headers_mut() + .insert("vary", HeaderValue::from_static("Accept-Language")); + let p = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + cache + .wrap(response, t.clone(), p, &settings) + .into_body() + .collect() + .await + .unwrap(); + t + } + #[tokio::test] + async fn dashboard_is_scoped_paginated_and_exposes_invalidation_identity_not_bodies() { + let cache = Cache::new(7); + let a = fill(&cache, "alpha", "a.example", "/data?private-query=secret").await; + fill(&cache, "beta", "b.example", "/other").await; + cache.record("alpha", "a.example", "MISS", "cache_miss"); + assert!(cache.get(&a).is_some()); + cache.record("alpha", "a.example", "HIT", "cache_hit"); + cache.record("alpha", "a.example", "BYPASS", "request_cookie"); + let d = cache.dashboard(Some("alpha"), 0, 50); + assert_eq!(d.matching_objects, 1); + assert_eq!(d.domains.len(), 1); + assert_eq!(d.domains[0].counters.hits, 1); + assert_eq!(d.domains[0].counters.stored, 1); + assert_eq!(d.domains[0].counters.saved_body_bytes, 21); + assert_eq!(d.objects[0].path, "/data"); + assert_eq!(d.objects[0].target, "/data?private-query=secret"); + assert_eq!(d.objects[0].cache_key.len(), 64); + assert_eq!(d.objects[0].hits, 1); + let encoded = serde_json::to_string(&d).unwrap(); + assert!(encoded.contains("private-query=secret")); + assert!(!encoded.contains("public representation")); + assert_eq!(cache.dashboard(None, 1, 1).objects.len(), 1); + assert_eq!(cache.dashboard(None, 99, 999).limit, 200); + assert_eq!(cache.purge_scope("alpha"), 1); + assert_eq!(cache.dashboard(Some("beta"), 0, 50).matching_objects, 1); + assert_eq!( + cache.dashboard(Some("alpha"), 0, 50).domains[0] + .counters + .hits, + 1 + ); + cache.purge(Some(8)); + assert!(cache.dashboard(None, 0, 50).domains.is_empty()); + } + #[tokio::test] + async fn dashboard_retains_expired_entries_for_client_max_stale() { + let cache = Cache::new(7); + fill(&cache, "a", "a.example", "/").await; + { + let mut store = cache.store.lock().unwrap(); + for entry in store.entries.values_mut() { + entry.received = Instant::now() - Duration::from_secs(2 * 60 * 60); + } + store.last_sweep = None; + } + assert_eq!(cache.dashboard(None, 0, 50).matching_objects, 1); + assert_eq!(cache.view().entries, 1); + assert_eq!(cache.view().expired, 0); + } + #[test] + fn bypass_reasons_are_fixed_and_private_inputs_are_not_echoed() { + let settings = Settings::default(); + let mut headers = HeaderMap::new(); + headers.insert("cookie", HeaderValue::from_static("session=very-private")); + assert_eq!( + request_reason(&Method::GET, &headers, true, "/", false, &settings), + Some("request_cookie") + ); + headers.remove("cookie"); + headers.insert("cache-control", HeaderValue::from_static("no-cache")); + assert_eq!( + request_reason(&Method::GET, &headers, true, "/", false, &settings), + None + ); + assert_eq!( + request_policy(&Method::GET, &headers, true, "/", false, &settings), + RequestPolicy::Revalidate("request_no_cache") + ); + let diagnostics = Settings { + expose_status: true, + expose_cache_status: true, + ..settings + }; + diagnostics.annotate_reason(&mut headers, "BYPASS", 0, 0, "request_cookie"); + assert_eq!(headers["x-cache-reason"], "request_cookie"); + assert_eq!( + headers["cache-status"], + "RustProxy; fwd=bypass; detail=\"request_cookie\"" + ); + diagnostics.annotate_transformed(&mut headers, true); + assert_eq!( + headers["cache-status"], + "RustProxy; fwd=bypass; detail=\"request_cookie\"; transformed" + ); + } + #[test] + fn disabled_cache_strips_diagnostics_without_exposing_bypass_headers() { + let settings = Settings { + enabled: false, + expose_status: true, + expose_hits: true, + expose_cache_status: true, + expose_resident_age: true, + ..Settings::default() + }; + let mut headers = HeaderMap::new(); + headers.insert("x-cache", HeaderValue::from_static("origin")); + headers.insert("x-cache-hits", HeaderValue::from_static("99")); + headers.insert("x-cache-status", HeaderValue::from_static("origin")); + headers.insert("cache-status", HeaderValue::from_static("Origin; hit")); + headers.insert("x-cache-reason", HeaderValue::from_static("origin")); + headers.insert("x-cache-resident-age", HeaderValue::from_static("5")); + settings.annotate_reason(&mut headers, "BYPASS", 0, 0, "cache_disabled"); + settings.annotate_resident_age(&mut headers, 9); + for name in [ + "x-cache", + "x-cache-hits", + "x-cache-status", + "cache-status", + "x-cache-reason", + "x-cache-resident-age", + ] { + assert!(!headers.contains_key(name), "{name}"); + } + } + + #[test] + fn per_domain_counter_cardinality_is_bounded() { + let cache = Cache::new(7); + for i in 0..5000 { + cache.record( + "legacy", + &format!("{i}.example"), + "BYPASS", + "cache_disabled", + ); + } + assert_eq!(cache.dashboard(None, 0, 1).domains.len(), 4096); + } +} + +#[cfg(test)] +mod v022_cache_tests { + use super::*; + use http_body_util::BodyExt; + + fn active_settings() -> Settings { + Settings { + enabled: true, + ..Settings::default() + } + } + + async fn store( + cache: &Arc, + scope: &str, + host: &str, + target: &str, + request_headers: HeaderMap, + extra_response_headers: &[(&str, &str)], + body: &'static str, + ) -> Ticket { + let ticket = cache + .ticket_scoped( + 22, + scope, + "http", + host, + "origin@127.0.0.1:8080", + target, + &request_headers, + ) + .unwrap(); + let settings = active_settings(); + let mut response = Response::new(Body::from(body)); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=120, stale-if-error=60"), + ); + for (name, value) in extra_response_headers { + response.headers_mut().insert( + http::HeaderName::from_bytes(name.as_bytes()).unwrap(), + HeaderValue::from_str(value).unwrap(), + ); + } + let plan = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + cache + .wrap(response, ticket.clone(), plan, &settings) + .into_body() + .collect() + .await + .unwrap(); + ticket + } + + fn invalidation( + operation: InvalidationOperation, + selector: InvalidationSelector, + value: &str, + host: Option<&str>, + vhost: Option<&str>, + ) -> InvalidationRequest { + InvalidationRequest { + operation, + selector, + value: value.to_owned(), + host: host.map(str::to_owned), + vhost: vhost.map(str::to_owned), + ttl_seconds: None, + confirm_wide: false, + } + } + + #[tokio::test] + async fn cached_single_range_returns_206_and_preserves_validators() { + let cache = Cache::new(22); + let ticket = store( + &cache, + "range", + "range.example", + "/asset.bin", + HeaderMap::new(), + &[ + ("etag", "\"asset-v1\""), + ("last-modified", "Mon, 26 Jan 2026 23:11:40 GMT"), + ], + "abcdefghij", + ) + .await; + let mut request_headers = HeaderMap::new(); + request_headers.insert("range", HeaderValue::from_static("bytes=2-5")); + let range = match request_range(&Method::GET, &request_headers) { + RangeSelection::Single(range) => range, + other => panic!("unexpected range selection: {other:?}"), + }; + let hit = cache.get(&ticket).unwrap().apply_range(range, None); + assert_eq!(hit.response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(hit.response.headers()["content-range"], "bytes 2-5/10"); + assert_eq!(hit.response.headers()["content-length"], "4"); + assert_eq!(hit.response.headers()["accept-ranges"], "bytes"); + assert_eq!(hit.response.headers()["etag"], "\"asset-v1\""); + assert!(hit.response.headers().contains_key("age")); + let body = hit.response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(body, Bytes::from_static(b"cdef")); + } + + #[tokio::test] + async fn cached_range_supports_suffix_open_unsatisfied_and_if_range() { + let cache = Cache::new(22); + let ticket = store( + &cache, + "range", + "range.example", + "/asset.bin", + HeaderMap::new(), + &[ + ("etag", "\"asset-v1\""), + ("last-modified", "Mon, 26 Jan 2026 23:11:40 GMT"), + ], + "abcdefghij", + ) + .await; + let cases = [ + (ByteRange { start: None, end: Some(3) }, "hij", "bytes 7-9/10"), + (ByteRange { start: Some(7), end: None }, "hij", "bytes 7-9/10"), + ]; + for (range, expected, content_range) in cases { + let hit = cache.get(&ticket).unwrap().apply_range(range, None); + assert_eq!(hit.response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(hit.response.headers()["content-range"], content_range); + let body = hit.response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(body, Bytes::copy_from_slice(expected.as_bytes())); + } + + let unsatisfied = cache.get(&ticket).unwrap().apply_range( + ByteRange { start: Some(50), end: None }, + None, + ); + assert_eq!(unsatisfied.response.status(), StatusCode::RANGE_NOT_SATISFIABLE); + assert_eq!(unsatisfied.response.headers()["content-range"], "bytes */10"); + assert_eq!( + unsatisfied.response.into_body().collect().await.unwrap().to_bytes().len(), + 0 + ); + + let mismatch = HeaderValue::from_static("\"asset-v2\""); + let full = cache.get(&ticket).unwrap().apply_range( + ByteRange { start: Some(0), end: Some(2) }, + Some(&mismatch), + ); + assert_eq!(full.response.status(), StatusCode::OK); + assert!(!full.response.headers().contains_key("content-range")); + assert_eq!( + full.response.into_body().collect().await.unwrap().to_bytes(), + Bytes::from_static(b"abcdefghij") + ); + + let matched = HeaderValue::from_static("\"asset-v1\""); + let partial = cache.get(&ticket).unwrap().apply_range( + ByteRange { start: Some(0), end: Some(2) }, + Some(&matched), + ); + assert_eq!(partial.response.status(), StatusCode::PARTIAL_CONTENT); + } + + #[test] + fn range_parser_rejects_multipart_and_duplicate_fields_and_if_range_date_allows_older_representation() { + let mut multipart = HeaderMap::new(); + multipart.insert("range", HeaderValue::from_static("bytes=0-1,4-5")); + assert_eq!( + request_range(&Method::GET, &multipart), + RangeSelection::Unsupported + ); + + let mut duplicate = HeaderMap::new(); + duplicate.append("range", HeaderValue::from_static("bytes=0-1")); + duplicate.append("range", HeaderValue::from_static("bytes=4-5")); + assert_eq!( + request_range(&Method::GET, &duplicate), + RangeSelection::Unsupported + ); + + let mut response_headers = HeaderMap::new(); + response_headers.insert( + "last-modified", + HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"), + ); + let newer_if_range = HeaderValue::from_static("Tue, 27 Jan 2026 23:11:40 GMT"); + assert!(if_range_matches(&response_headers, Some(&newer_if_range))); + } + + #[tokio::test] + async fn concurrent_miss_single_flight_waits_until_fill_is_published() { + let cache = Cache::new(22); + let mut settings = active_settings(); + settings.collapse_wait_timeout_ms = 500; + let ticket = cache + .ticket_scoped( + 22, + "collapse", + "http", + "collapse.example", + "origin@127.0.0.1:8080", + "/same", + &HeaderMap::new(), + ) + .unwrap(); + let leader = match cache.claim_fill(&ticket, &settings).await { + FillClaim::Leader(guard) => guard, + _ => panic!("first request must become fill leader"), + }; + let follower_cache = cache.clone(); + let follower_ticket = ticket.clone(); + let follower_settings = settings.clone(); + let follower = tokio::spawn(async move { + match follower_cache + .claim_fill(&follower_ticket, &follower_settings) + .await + { + FillClaim::Waited => follower_cache.get(&follower_ticket).is_some(), + _ => false, + } + }); + for _ in 0..50 { + if cache.view().collapse_waiters == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(2)).await; + } + assert_eq!(cache.view().collapse_waiters, 1); + assert!(!follower.is_finished()); + + let mut response = Response::new(Body::from("collapsed-payload")); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=60"), + ); + let plan = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + cache + .wrap_with_fill(response, ticket.clone(), plan, &settings, Some(leader)) + .into_body() + .collect() + .await + .unwrap(); + assert!(follower.await.unwrap()); + assert_eq!(cache.view().collapsed_fills, 1); + assert_eq!(cache.view().collapse_waiters, 0); + } + + #[tokio::test] + async fn single_flight_timeout_and_leader_drop_release_the_key() { + let cache = Cache::new(22); + let mut settings = active_settings(); + settings.collapse_wait_timeout_ms = 10; + let ticket = cache + .ticket(22, "http", "collapse.example", "origin", "/timeout", &HeaderMap::new()) + .unwrap(); + let leader = match cache.claim_fill(&ticket, &settings).await { + FillClaim::Leader(guard) => guard, + _ => panic!("missing leader"), + }; + assert!(matches!(cache.claim_fill(&ticket, &settings).await, FillClaim::Timeout)); + assert_eq!(cache.view().collapse_timeouts, 1); + drop(leader); + assert!(matches!( + cache.claim_fill(&ticket, &settings).await, + FillClaim::Leader(_) + )); + } + + #[tokio::test] + async fn cancelled_single_flight_waiter_releases_waiter_metric() { + let cache = Cache::new(22); + let mut settings = active_settings(); + settings.collapse_wait_timeout_ms = 5_000; + let ticket = cache + .ticket(22, "http", "collapse.example", "origin", "/cancel", &HeaderMap::new()) + .unwrap(); + let leader = match cache.claim_fill(&ticket, &settings).await { + FillClaim::Leader(guard) => guard, + _ => panic!("missing leader"), + }; + let follower_cache = cache.clone(); + let follower_ticket = ticket.clone(); + let follower_settings = settings.clone(); + let follower = tokio::spawn(async move { + follower_cache + .claim_fill(&follower_ticket, &follower_settings) + .await + }); + for _ in 0..50 { + if cache.view().collapse_waiters == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(2)).await; + } + assert_eq!(cache.view().collapse_waiters, 1); + follower.abort(); + let _ = follower.await; + tokio::task::yield_now().await; + assert_eq!(cache.view().collapse_waiters, 0); + drop(leader); + } + + #[tokio::test] + async fn single_flight_retries_after_another_vary_representation_is_published() { + let cache = Cache::new(22); + let mut settings = active_settings(); + settings.collapse_wait_timeout_ms = 500; + let mut en_headers = HeaderMap::new(); + en_headers.insert("accept-language", HeaderValue::from_static("en")); + let mut pl_headers = HeaderMap::new(); + pl_headers.insert("accept-language", HeaderValue::from_static("pl")); + let en = cache + .ticket(22, "http", "vary-collapse.example", "origin", "/page", &en_headers) + .unwrap(); + let pl = cache + .ticket(22, "http", "vary-collapse.example", "origin", "/page", &pl_headers) + .unwrap(); + assert_eq!(en.key, pl.key, "Vary is learned from the response"); + let leader = match cache.claim_fill(&en, &settings).await { + FillClaim::Leader(guard) => guard, + _ => panic!("first representation must become leader"), + }; + let follower_cache = cache.clone(); + let follower_ticket = pl.clone(); + let follower_settings = settings.clone(); + let follower = tokio::spawn(async move { + assert!(matches!( + follower_cache + .claim_fill(&follower_ticket, &follower_settings) + .await, + FillClaim::Waited + )); + assert!(follower_cache.get(&follower_ticket).is_none()); + matches!( + follower_cache + .claim_fill(&follower_ticket, &follower_settings) + .await, + FillClaim::Leader(_) + ) + }); + for _ in 0..50 { + if cache.view().collapse_waiters == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(2)).await; + } + + let mut response = Response::new(Body::from("hello")); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=60"), + ); + response + .headers_mut() + .insert("vary", HeaderValue::from_static("Accept-Language")); + let plan = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + cache + .wrap_with_fill(response, en, plan, &settings, Some(leader)) + .into_body() + .collect() + .await + .unwrap(); + assert!(follower.await.unwrap()); + } + + #[test] + fn stale_if_error_policy_is_configurable_by_error_class_and_status() { + let mut settings = Settings::default(); + settings.stale_if_error_timeout = false; + settings.stale_if_error_dns = false; + settings.stale_if_error_statuses = vec![500, 503]; + assert!(!settings.allows_stale_error_class(StaleErrorClass::Timeout)); + assert!(!settings.allows_stale_error_class(StaleErrorClass::Dns)); + assert!(settings.allows_stale_error_class(StaleErrorClass::Connection)); + assert!(settings.allows_stale_error_class(StaleErrorClass::Tls)); + assert!(settings.allows_stale_error_status(StatusCode::INTERNAL_SERVER_ERROR)); + assert!(settings.allows_stale_error_status(StatusCode::SERVICE_UNAVAILABLE)); + assert!(!settings.allows_stale_error_status(StatusCode::BAD_GATEWAY)); + } + + #[test] + fn proactive_refresh_guards_deduplicate_and_bound_parallel_work() { + let cache = Cache::new(22); + let ticket = cache + .ticket(22, "http", "refresh.example", "origin", "/asset", &HeaderMap::new()) + .unwrap(); + let mut settings = active_settings(); + settings.proactive_refresh_enabled = true; + settings.proactive_refresh_percent = 100; + settings.proactive_refresh_max_inflight = 1; + let scheduled = cache + .begin_proactive_schedule(&ticket, &settings) + .expect("first schedule"); + assert!(cache.begin_proactive_schedule(&ticket, &settings).is_none()); + let running = cache + .try_begin_proactive_refresh(&settings) + .expect("first inflight refresh"); + assert!(cache.try_begin_proactive_refresh(&settings).is_none()); + assert_eq!(cache.view().proactive_inflight, 1); + drop(running); + drop(scheduled); + assert!(cache.begin_proactive_schedule(&ticket, &settings).is_some()); + assert_eq!(cache.view().proactive_inflight, 0); + } + + #[tokio::test] + async fn selective_purge_exact_prefix_tag_cache_key_and_query_are_precise() { + let cache = Cache::new(22); + let one = store( + &cache, + "app", + "purge.example", + "/asset.js?v=1", + HeaderMap::new(), + &[("cache-tag", "release:one")], + "one", + ) + .await; + let two = store( + &cache, + "app", + "purge.example", + "/asset.js?v=2", + HeaderMap::new(), + &[("cache-tag", "release:two")], + "two", + ) + .await; + let result = cache + .invalidate(invalidation( + InvalidationOperation::Purge, + InvalidationSelector::Exact, + "/asset.js?v=1", + Some("purge.example"), + Some("app"), + )) + .unwrap(); + assert_eq!(result.affected_entries, 1); + assert!(cache.get(&one).is_none()); + assert!(cache.get(&two).is_some()); + + let three = store( + &cache, + "app", + "purge.example", + "/assets/a.css", + HeaderMap::new(), + &[("cache-tag", "release:three")], + "three", + ) + .await; + let four = store( + &cache, + "app", + "purge.example", + "/assets/b.css", + HeaderMap::new(), + &[("cache-tag", "release:four")], + "four", + ) + .await; + assert_eq!( + cache + .invalidate(invalidation( + InvalidationOperation::Purge, + InvalidationSelector::Prefix, + "/assets/", + Some("purge.example"), + Some("app"), + )) + .unwrap() + .affected_entries, + 2 + ); + assert!(cache.get(&three).is_none()); + assert!(cache.get(&four).is_none()); + + let tagged = store( + &cache, + "app", + "purge.example", + "/tagged", + HeaderMap::new(), + &[("cache-tag", "release:tagged")], + "tagged", + ) + .await; + assert_eq!( + cache + .invalidate(invalidation( + InvalidationOperation::Purge, + InvalidationSelector::Tag, + "release:tagged", + Some("purge.example"), + Some("app"), + )) + .unwrap() + .affected_entries, + 1 + ); + assert!(cache.get(&tagged).is_none()); + + let keyed = store( + &cache, + "app", + "purge.example", + "/by-key", + HeaderMap::new(), + &[], + "keyed", + ) + .await; + let key = key_hex(&keyed.key); + assert_eq!( + cache + .invalidate(invalidation( + InvalidationOperation::Purge, + InvalidationSelector::CacheKey, + &key, + Some("purge.example"), + Some("app"), + )) + .unwrap() + .affected_entries, + 1 + ); + assert!(cache.get(&keyed).is_none()); + } + + #[tokio::test] + async fn selective_purge_is_a_barrier_for_a_fill_started_before_the_purge() { + let cache = Cache::new(22); + let settings = active_settings(); + let old_ticket = cache + .ticket_scoped( + 22, + "app", + "http", + "purge.example", + "origin@127.0.0.1:8080", + "/late", + &HeaderMap::new(), + ) + .unwrap(); + let result = cache + .invalidate(invalidation( + InvalidationOperation::Purge, + InvalidationSelector::Tag, + "release:late", + Some("purge.example"), + Some("app"), + )) + .unwrap(); + assert_eq!(result.affected_entries, 0); + + let mut response = Response::new(Body::from("old-fill")); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=60"), + ); + response + .headers_mut() + .insert("cache-tag", HeaderValue::from_static("release:late")); + let plan = plan( + StatusCode::OK, + response.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + cache + .wrap(response, old_ticket, plan, &settings) + .into_body() + .collect() + .await + .unwrap(); + assert_eq!(cache.view().entries, 0, "pre-purge fill must not republish"); + + let new_ticket = store( + &cache, + "app", + "purge.example", + "/late", + HeaderMap::new(), + &[("cache-tag", "release:late")], + "new-fill", + ) + .await; + assert!(cache.get(&new_ticket).is_some()); + } + + #[tokio::test] + async fn purge_of_one_vary_variant_removes_the_whole_variant_family() { + let cache = Cache::new(22); + let mut en_headers = HeaderMap::new(); + en_headers.insert("accept-language", HeaderValue::from_static("en")); + let mut pl_headers = HeaderMap::new(); + pl_headers.insert("accept-language", HeaderValue::from_static("pl")); + let en = store( + &cache, + "app", + "vary.example", + "/localized", + en_headers, + &[("vary", "Accept-Language"), ("cache-tag", "variant:en")], + "hello", + ) + .await; + let pl = store( + &cache, + "app", + "vary.example", + "/localized", + pl_headers, + &[("vary", "Accept-Language"), ("cache-tag", "variant:pl")], + "czesc", + ) + .await; + let result = cache + .invalidate(invalidation( + InvalidationOperation::Purge, + InvalidationSelector::Tag, + "variant:en", + Some("vary.example"), + Some("app"), + )) + .unwrap(); + assert_eq!(result.affected_entries, 2); + assert!(cache.get(&en).is_none()); + assert!(cache.get(&pl).is_none()); + } + + #[tokio::test] + async fn ban_is_lazy_blocks_old_object_allows_new_fill_and_can_be_removed() { + let cache = Cache::new(22); + let old = store( + &cache, + "app", + "ban.example", + "/page", + HeaderMap::new(), + &[], + "old", + ) + .await; + assert_eq!(cache.view().entries, 1); + let result = cache + .invalidate(invalidation( + InvalidationOperation::Ban, + InvalidationSelector::Exact, + "/page", + Some("ban.example"), + Some("app"), + )) + .unwrap(); + let ban_id = result.ban_id.unwrap(); + assert_eq!(result.affected_entries, 0); + assert_eq!(cache.view().entries, 1, "BAN must not scan storage eagerly"); + assert!(cache.get(&old).is_none()); + assert_eq!(cache.view().entries, 0); + + tokio::time::sleep(Duration::from_millis(2)).await; + let fresh = store( + &cache, + "app", + "ban.example", + "/page", + HeaderMap::new(), + &[], + "fresh", + ) + .await; + assert!(cache.get(&fresh).is_some(), "old BAN must not reject newer objects"); + assert_eq!(cache.bans().len(), 1); + assert!(cache.remove_ban(ban_id)); + assert!(cache.bans().is_empty()); + assert!(!cache.remove_ban(ban_id)); + } + + #[tokio::test] + async fn regex_and_root_prefix_require_explicit_wide_confirmation() { + let cache = Cache::new(22); + store( + &cache, + "app", + "wide.example", + "/one", + HeaderMap::new(), + &[], + "one", + ) + .await; + let regex = invalidation( + InvalidationOperation::Purge, + InvalidationSelector::Regex, + ".*", + None, + None, + ); + assert!(cache.invalidate(regex.clone()).is_err()); + let scoped_regex = invalidation( + InvalidationOperation::Purge, + InvalidationSelector::Regex, + "^/one$", + Some("wide.example"), + Some("app"), + ); + assert!(cache.invalidate(scoped_regex).is_err()); + let mut confirmed = regex; + confirmed.confirm_wide = true; + assert_eq!(cache.invalidate(confirmed).unwrap().affected_entries, 1); + + let prefix = invalidation( + InvalidationOperation::Ban, + InvalidationSelector::Prefix, + "/", + Some("wide.example"), + Some("app"), + ); + assert!(cache.invalidate(prefix.clone()).is_err()); + let mut confirmed_prefix = prefix; + confirmed_prefix.confirm_wide = true; + assert!(cache.invalidate(confirmed_prefix).unwrap().ban_id.is_some()); + } +} + +#[cfg(test)] +mod cold_tier_tests { + use super::*; + use http_body_util::BodyExt; + + fn cold_settings() -> Settings { + Settings { + enabled: true, + max_entries: 1, + max_bytes: 256 * 1024, + max_object_bytes: 64 * 1024, + cold_enabled: true, + cold_path: "cache/cold-test".into(), + cold_max_bytes: 2 * 1024 * 1024, + cold_max_entries: 16, + cold_after_seconds: 300, + ..Settings::default() + } + } + + async fn fill( + cache: &Arc, + settings: &Settings, + path: &str, + body: &'static str, + ) -> Ticket { + let ticket = cache + .ticket_scoped( + 11, + "cold-test", + "http", + "cold.example", + "origin@127.0.0.1:8080", + path, + &HeaderMap::new(), + ) + .unwrap(); + let mut response = Response::new(Body::from(body)); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=120"), + ); + response + .headers_mut() + .insert("etag", HeaderValue::from_static("\"cold-v1\"")); + let plan = plan(StatusCode::OK, response.headers(), settings, Duration::ZERO).unwrap(); + cache + .wrap(response, ticket.clone(), plan, settings) + .into_body() + .collect() + .await + .unwrap(); + ticket + } + + async fn wait_for_cold(cache: &Cache, expected: usize) { + for _ in 0..100 { + if cache.view().cold_entries >= expected { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("cold tier did not persist in time"); + } + + #[tokio::test] + async fn selective_purge_removes_matching_cold_object() { + let dir = tempfile::tempdir().unwrap(); + let settings = cold_settings(); + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + let first = fill(&cache, &settings, "/one?v=1", "first-payload").await; + let _second = fill(&cache, &settings, "/two", "second-payload").await; + wait_for_cold(&cache, 1).await; + assert_eq!(cache.view().cold_entries, 1); + let result = cache + .invalidate(InvalidationRequest { + operation: InvalidationOperation::Purge, + selector: InvalidationSelector::Exact, + value: "/one?v=1".into(), + host: Some("cold.example".into()), + vhost: Some("cold-test".into()), + ttl_seconds: None, + confirm_wide: false, + }) + .unwrap(); + assert_eq!(result.affected_entries, 1); + assert_eq!(cache.view().cold_entries, 0); + assert!(cache.get_tiered(&first, &settings).await.is_none()); + shutdown.cancel(); + } + + #[tokio::test] + async fn lru_demotion_persists_and_survives_restart() { + let dir = tempfile::tempdir().unwrap(); + let settings = cold_settings(); + settings.validate().unwrap(); + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + let first = fill(&cache, &settings, "/one", "first-payload").await; + let _second = fill(&cache, &settings, "/two", "second-payload").await; + wait_for_cold(&cache, 1).await; + assert_eq!(cache.view().entries, 1); + assert_eq!(cache.view().cold_entries, 1); + let cold = cache.dashboard(Some("cold-test"), 0, 50); + assert!(cold + .objects + .iter() + .any(|row| row.path == "/one" && row.tier == "cold")); + + shutdown.cancel(); + drop(cache); + + let restart_shutdown = CancellationToken::new(); + let restarted = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + restart_shutdown.clone(), + ); + assert_eq!(restarted.view().cold_entries, 1); + let hit = restarted + .get_tiered(&first, &settings) + .await + .expect("cold object should be readable after restart"); + let payload = hit.response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(payload, Bytes::from_static(b"first-payload")); + assert_eq!(restarted.view().cold_hits, 1); + assert_eq!(restarted.view().cold_promotions, 1); + restart_shutdown.cancel(); + } + + #[tokio::test] + async fn cold_restart_preserves_transformed_validators_stale_metadata_and_no_transform() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 8; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + + let transformed_ticket = cache + .ticket_scoped( + 11, + "cold-test", + "http", + "cold.example", + "origin@127.0.0.1:8080", + "/transformed.json", + &HeaderMap::new(), + ) + .unwrap(); + let mut transformed = Response::new(Body::from(r#"{"a":1}"#)); + transformed.headers_mut().insert( + "cache-control", + HeaderValue::from_static( + "public, max-age=120, stale-while-revalidate=30, stale-if-error=90", + ), + ); + transformed + .headers_mut() + .insert("etag", HeaderValue::from_static("\"rustproxy-representation\"")); + transformed + .headers_mut() + .insert("content-length", HeaderValue::from_static("7")); + transformed + .extensions_mut() + .insert(crate::transform::OriginValidators { + etag: Some(HeaderValue::from_static("\"origin-v1\"")), + last_modified: Some(HeaderValue::from_static( + "Mon, 26 Jan 2026 23:11:40 GMT", + )), + }); + transformed.extensions_mut().insert(crate::transform::Applied { + kind: crate::transform::Kind::Json, + original_bytes: 9, + transformed_bytes: 7, + }); + let transformed_plan = plan( + StatusCode::OK, + transformed.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + cache + .wrap( + transformed, + transformed_ticket.clone(), + transformed_plan, + &settings, + ) + .into_body() + .collect() + .await + .unwrap(); + + let protected_ticket = cache + .ticket_scoped( + 11, + "cold-test", + "http", + "cold.example", + "origin@127.0.0.1:8080", + "/protected.css", + &HeaderMap::new(), + ) + .unwrap(); + let original_body = "body { color: red; }"; + let mut protected = Response::new(Body::from(original_body)); + protected.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=120, immutable, no-transform"), + ); + protected + .headers_mut() + .insert("etag", HeaderValue::from_static("\"origin-css\"")); + protected.extensions_mut().insert(crate::compression::Skip); + let protected_plan = plan( + StatusCode::OK, + protected.headers(), + &settings, + Duration::ZERO, + ) + .unwrap(); + cache + .wrap( + protected, + protected_ticket.clone(), + protected_plan, + &settings, + ) + .into_body() + .collect() + .await + .unwrap(); + + assert_eq!(cache.flush_hot_to_cold().await, 2); + wait_for_cold(&cache, 2).await; + shutdown.cancel(); + drop(cache); + + let restart_shutdown = CancellationToken::new(); + let restarted = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + restart_shutdown.clone(), + ); + assert_eq!(restarted.view().cold_entries, 2); + { + let cold = restarted.cold.lock().unwrap(); + let transformed = cold + .entries + .values() + .find(|entry| entry.path == "/transformed.json") + .expect("transformed cold entry"); + assert_eq!(transformed.stale_while_revalidate, 30); + assert_eq!(transformed.stale_if_error, 90); + assert_eq!(transformed.origin_etag.as_ref().unwrap(), "\"origin-v1\""); + assert_eq!( + transformed.origin_last_modified.as_ref().unwrap(), + "Mon, 26 Jan 2026 23:11:40 GMT" + ); + assert_eq!(transformed.headers["etag"], "\"rustproxy-representation\""); + assert!(transformed.transformed); + } + + let transformed_hit = restarted + .get_tiered(&transformed_ticket, &settings) + .await + .expect("transformed object after restart"); + assert_eq!( + transformed_hit.response.headers()["etag"], + "\"rustproxy-representation\"" + ); + assert!(transformed_hit.transformed); + assert!(transformed_hit + .response + .extensions() + .get::() + .is_some()); + assert_eq!( + transformed_hit + .response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(), + Bytes::from_static(br#"{"a":1}"#) + ); + + let protected_hit = restarted + .get_tiered(&protected_ticket, &settings) + .await + .expect("no-transform object after restart"); + assert_eq!(protected_hit.response.headers()["etag"], "\"origin-css\""); + assert!(crate::transform::no_transform(protected_hit.response.headers())); + assert!(protected_hit + .response + .extensions() + .get::() + .is_some()); + assert_eq!( + protected_hit + .response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(), + Bytes::from_static(b"body { color: red; }") + ); + restart_shutdown.cancel(); + } + + #[tokio::test] + async fn cold_policy_can_disable_lru_demotion() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.cold_on_lru = false; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/one", "first-payload").await; + fill(&cache, &settings, "/two", "second-payload").await; + tokio::time::sleep(Duration::from_millis(30)).await; + let view = cache.view(); + assert_eq!(view.entries, 1); + assert_eq!(view.cold_entries, 0); + assert_eq!(view.evictions, 1); + shutdown.cancel(); + } + + #[tokio::test] + async fn cold_policy_can_disable_idle_demotion() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 8; + settings.cold_on_idle = false; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/idle", "idle-payload").await; + { + let mut store = cache.store.lock().unwrap(); + for entry in store.entries.values_mut() { + entry.last_access = Instant::now() - Duration::from_secs(600); + } + } + cache.demote_idle(); + assert_eq!(cache.view().entries, 1); + assert_eq!(cache.view().cold_entries, 0); + + settings.cold_on_idle = true; + cache.configure(settings.clone()); + cache.demote_idle(); + wait_for_cold(&cache, 1).await; + assert_eq!(cache.view().entries, 0); + shutdown.cancel(); + } + + #[tokio::test] + async fn cold_policy_can_demote_by_total_hot_residency_age() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 8; + settings.cold_on_idle = false; + settings.cold_on_age = true; + settings.cold_max_hot_seconds = 5; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/aged", "aged-payload").await; + { + let mut store = cache.store.lock().unwrap(); + for entry in store.entries.values_mut() { + entry.resident_since = Instant::now() - Duration::from_secs(60); + entry.last_access = Instant::now(); + } + } + cache.demote_idle(); + wait_for_cold(&cache, 1).await; + assert_eq!(cache.view().entries, 0); + assert_eq!(cache.view().cold_entries, 1); + shutdown.cancel(); + } + + #[tokio::test] + async fn graceful_shutdown_flush_moves_hot_objects_and_releases_resident_bytes() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 8; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/one", "first-payload").await; + fill(&cache, &settings, "/two", "second-payload").await; + assert_eq!(cache.view().entries, 2); + assert!(cache.view().resident_bytes > 0); + assert_eq!(cache.view().cold_entries, 0); + + assert_eq!(cache.flush_hot_to_cold().await, 2); + let view = cache.view(); + assert_eq!(view.entries, 0); + assert_eq!(view.resident_bytes, 0); + assert_eq!(view.hot_logical_bytes, 0); + assert_eq!(view.transient_bytes, 0, "persisted ColdEntry must not retain the body allocation"); + assert_eq!(view.cold_entries, 2); + shutdown.cancel(); + + let restart_shutdown = CancellationToken::new(); + let restarted = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + restart_shutdown.clone(), + ); + assert_eq!(restarted.view().cold_entries, 2); + restart_shutdown.cancel(); + } + + #[tokio::test] + async fn managed_update_flush_ignores_shutdown_toggle_when_cold_tier_is_enabled() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 8; + settings.cold_on_shutdown = false; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/update", "update-payload").await; + assert_eq!(cache.flush_hot_to_cold().await, 0); + assert_eq!(cache.view().entries, 1); + assert_eq!(cache.flush_hot_to_cold_for_update().await, 1); + let view = cache.view(); + assert_eq!(view.entries, 0); + assert_eq!(view.resident_bytes, 0); + assert_eq!(view.cold_entries, 1); + shutdown.cancel(); + } + + #[tokio::test] + async fn graceful_shutdown_flush_can_be_disabled() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 8; + settings.cold_on_shutdown = false; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/one", "first-payload").await; + assert_eq!(cache.flush_hot_to_cold().await, 0); + assert_eq!(cache.view().entries, 1); + assert_eq!(cache.view().cold_entries, 0); + shutdown.cancel(); + } + + #[tokio::test] + async fn cold_cache_uses_two_hex_character_shards() { + let dir = tempfile::tempdir().unwrap(); + let settings = cold_settings(); + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/one", "first-payload").await; + fill(&cache, &settings, "/two", "second-payload").await; + wait_for_cold(&cache, 1).await; + let root = dir.path().join(&settings.cold_path); + let mut shards = Vec::new(); + for item in std::fs::read_dir(&root).unwrap() { + let item = item.unwrap(); + let name = item.file_name().to_string_lossy().to_string(); + assert!(item.path().is_dir(), "cold root must contain shard directories only"); + assert_eq!(name.len(), 2); + assert!(name.bytes().all(|b| b.is_ascii_hexdigit())); + shards.push(item.path()); + } + assert!(!shards.is_empty()); + assert!(shards.iter().any(|shard| { + std::fs::read_dir(shard).unwrap().filter_map(|entry| entry.ok()).any(|entry| { + let name = entry.file_name().to_string_lossy().to_string(); + name.ends_with(".json") || name.ends_with(".body") + }) + })); + shutdown.cancel(); + } + + #[tokio::test] + async fn legacy_flat_cold_cache_is_not_migrated() { + let dir = tempfile::tempdir().unwrap(); + let settings = cold_settings(); + let root = dir.path().join(&settings.cold_path); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("legacy.body"), b"old").unwrap(); + std::fs::write(root.join("legacy.json"), b"{}").unwrap(); + let shutdown = CancellationToken::new(); + let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings, shutdown.clone()); + assert_eq!(cache.view().cold_entries, 0); + assert!(!root.join("legacy.body").exists()); + assert!(!root.join("legacy.json").exists()); + shutdown.cancel(); + } + + #[tokio::test] + async fn manual_cold_cleanup_reports_removed_objects_and_freed_bytes() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 1; + let shutdown = CancellationToken::new(); + let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone()); + fill(&cache, &settings, "/one", "first-payload").await; + fill(&cache, &settings, "/two", "second-payload").await; + fill(&cache, &settings, "/three", "third-payload").await; + wait_for_cold(&cache, 2).await; + let job = cache.start_cold_cleanup(ColdCleanupRequest { unused_for_seconds: None, oldest: Some(1), hits_zero: false }).unwrap(); + for _ in 0..100 { + let current = cache.cold_cleanup_job(job.id).unwrap(); + if current.status == "done" { + assert_eq!(current.removed_entries, 1); + assert!(current.freed_bytes > 0); + assert_eq!(cache.view().cold_entries, 1); + shutdown.cancel(); + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("cold cleanup did not finish"); + } + + #[tokio::test] + async fn cold_cleanup_skips_an_entry_while_a_read_is_pinned() { + let dir = tempfile::tempdir().unwrap(); + let mut settings = cold_settings(); + settings.max_entries = 1; + let shutdown = CancellationToken::new(); + let cache = Cache::start( + 11, + Some(dir.path().to_path_buf()), + settings.clone(), + shutdown.clone(), + ); + fill(&cache, &settings, "/one", "first-payload").await; + fill(&cache, &settings, "/two", "second-payload").await; + wait_for_cold(&cache, 1).await; + + let key = { + let mut cold = cache.cold.lock().unwrap(); + let key = *cold.entries.keys().next().unwrap(); + cold.entries.get_mut(&key).unwrap().pinned_reads = 1; + key + }; + let request = ColdCleanupRequest { + unused_for_seconds: None, + oldest: Some(1), + hits_zero: false, + }; + let result = cache + .remove_cold_candidates(Some(&request), None, None, 1) + .await; + assert_eq!(result.removed_entries, 0); + assert!(cache.cold.lock().unwrap().entries.contains_key(&key)); + + cache.cold.lock().unwrap().entries.get_mut(&key).unwrap().pinned_reads = 0; + let result = cache + .remove_cold_candidates(Some(&request), None, None, 1) + .await; + assert_eq!(result.removed_entries, 1); + assert!(!cache.cold.lock().unwrap().entries.contains_key(&key)); + shutdown.cancel(); + } + + #[test] + fn old_cold_read_guard_does_not_unpin_a_replacement_with_the_same_key() { + let cache = Cache::new(1); + let key = [7_u8; 32]; + let template = ColdEntry { + key, + base_key: key, + vary: Vec::new(), + revision: 1, + scope: "app".to_owned(), + domain: "example.test".to_owned(), + path: "/asset".to_owned(), + headers: HeaderMap::new(), + body_path: PathBuf::from("old.body"), + meta_path: PathBuf::from("old.json"), + body_bytes: 1, + body_sha256: "00".repeat(32), + disk_bytes: 2, + stored_at: SystemTime::now(), + last_access: SystemTime::now(), + age_at_store_seconds: 0, + resident_age_at_store_seconds: 0, + ttl: 60, + stale_while_revalidate: 0, + stale_if_error: 0, + must_revalidate: false, + proxy_revalidate: false, + origin_etag: None, + origin_last_modified: None, + hits: 0, + transformed: false, + sequence: 1, + generation: 1, + skip_compression: false, + requires_revalidation: false, + pinned_reads: 1, + }; + cache + .cold + .lock() + .unwrap() + .entries + .insert(key, template.clone()); + let old_guard = ColdReadGuard { + cache: &cache, + key, + generation: 1, + }; + + let mut replacement = template; + replacement.sequence = 2; + replacement.generation = 2; + replacement.pinned_reads = 1; + cache.cold.lock().unwrap().entries.insert(key, replacement); + drop(old_guard); + + let cold = cache.cold.lock().unwrap(); + let replacement = cold.entries.get(&key).unwrap(); + assert_eq!(replacement.generation, 2); + assert_eq!(replacement.pinned_reads, 1); + } + + #[test] + fn cold_path_cannot_escape_data_directory() { + let mut settings = Settings::default(); + settings.cold_path = "../outside".into(); + assert!(settings.validate().is_err()); + settings.cold_path = "/tmp/outside".into(); + assert!(settings.validate().is_err()); + settings.cold_path = "cache/cold".into(); + assert!(settings.validate().is_ok()); + } +} diff --git a/src/cache/tickets.rs b/src/cache/tickets.rs new file mode 100644 index 0000000..5ade84c --- /dev/null +++ b/src/cache/tickets.rs @@ -0,0 +1,366 @@ +impl Cache { + /// Varnish-style primary hash: public URL/host identity first, with response + /// `Vary` handled as a secondary variant key. Irrelevant request headers and + /// load-balanced backend members therefore do not fragment the cache. + pub fn ticket( + &self, + revision: u64, + scheme: &str, + authority: &str, + route: &str, + target: &str, + headers: &HeaderMap, + ) -> Option { + self.ticket_scoped(revision, "", scheme, authority, route, target, headers) + } + pub fn ticket_scoped( + &self, + revision: u64, + scope: &str, + scheme: &str, + authority: &str, + _route: &str, + target: &str, + headers: &HeaderMap, + ) -> Option { + if target.len() > 8192 || scope.len() > 64 { + return None; + } + let domain = crate::config::request_host(authority) + .unwrap_or_else(|_| authority.chars().take(253).collect()); + // Varnish built-in hashing lowercases Host before hashing, but otherwise + // keeps the authority (including an explicit port) distinct. RustProxy has + // already validated the authority before this point. + let hash_scheme = scheme.to_ascii_lowercase(); + let hash_authority = authority.to_ascii_lowercase(); + let mut digest = Sha256::new(); + let mut field = |value: &[u8]| { + digest.update((value.len() as u64).to_be_bytes()); + digest.update(value); + }; + let revision_text = revision.to_string(); + for value in [ + revision_text.as_bytes(), + scope.as_bytes(), + hash_scheme.as_bytes(), + hash_authority.as_bytes(), + target.as_bytes(), + ] { + field(value); + } + let key: Key = digest.finalize().into(); + let store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + (store.revision == revision).then_some(Ticket { + scheme: hash_scheme, + authority: hash_authority, + domain, + path: target + .split('?') + .next() + .unwrap_or("/") + .chars() + .take(512) + .collect(), + target: target.to_owned(), + key, + request_headers: headers.clone(), + generation: store.generation, + revision, + scope: scope.to_owned(), + scope_generation: store.scope_generations.get(scope).copied().unwrap_or(0), + key_generation: store.key_generations.get(&key).copied().unwrap_or(0), + created_at: SystemTime::now(), + }) + } + pub fn revalidation(&self, ticket: &Ticket) -> Option { + let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + let expired = store.sweep(); + self.counters + .expired + .fetch_add(expired as u64, Ordering::Relaxed); + if store.revision != ticket.revision + || store.generation != ticket.generation + || store + .scope_generations + .get(&ticket.scope) + .copied() + .unwrap_or(0) + != ticket.scope_generation + || store + .key_generations + .get(&ticket.key) + .copied() + .unwrap_or(0) + != ticket.key_generation + { + return None; + } + let storage_key = matching_hot_key(&store, ticket)?; + if store + .entries + .get(&storage_key) + .is_some_and(|entry| self.hot_entry_banned(&storage_key, entry)) + { + store.remove(&storage_key); + drop(store); + release_allocator_memory(); + return None; + } + let entry = store.entries.get(&storage_key)?; + let age = entry + .initial_age + .saturating_add(entry.received.elapsed()) + .as_secs(); + Some(Revalidation { + headers: entry.headers.clone(), + body: entry.body.clone(), + body_allocated: entry.body_allocated, + _transient_leases: Vec::new(), + hits: entry.hits, + transformed: entry.transformed, + storage_key, + resident_age_at_lookup: hot_resident_age(entry), + skip_compression: entry.skip_compression, + reusable_without_validation: age < entry.ttl && !entry.requires_revalidation, + age, + ttl: entry.ttl, + stale_if_error: entry.stale_if_error, + must_revalidate: entry.must_revalidate, + proxy_revalidate: entry.proxy_revalidate, + origin_etag: entry.origin_etag.clone(), + origin_last_modified: entry.origin_last_modified.clone(), + }) + } + async fn cold_revalidation(&self, ticket: &Ticket) -> Option { + if !self.ticket_current(ticket) { + return None; + } + let banned = { + let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + matching_cold_key(&cold, ticket).and_then(|storage_key| { + let banned = cold + .entries + .get(&storage_key) + .is_some_and(|entry| self.cold_entry_banned(&storage_key, entry)); + banned.then(|| cold.remove(&storage_key)).flatten() + }) + }; + if let Some(entry) = banned { + self.delete_cold_entry(entry); + return None; + } + let (storage_key, entry) = { + let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + let storage_key = matching_cold_key(&cold, ticket)?; + let old_sequence = { + let entry = cold.entries.get(&storage_key)?; + if entry.revision != ticket.revision || entry.scope != ticket.scope { + return None; + } + let age = cold_age(entry); + if age + >= retention_deadline( + entry.ttl, + entry.stale_while_revalidate, + entry.stale_if_error, + entry.origin_etag.is_some() || entry.origin_last_modified.is_some(), + ) + { + return None; + } + entry.sequence + }; + let sequence = cold.next_sequence(); + cold.lru.remove(&old_sequence); + cold.lru.insert(sequence, storage_key); + let entry = cold.entries.get_mut(&storage_key)?; + entry.sequence = sequence; + entry.last_access = SystemTime::now(); + entry.pinned_reads = entry.pinned_reads.saturating_add(1); + (storage_key, entry.clone()) + }; + let _pin = ColdReadGuard { + cache: self, + key: storage_key, + generation: entry.generation, + }; + let settings = self + .tier_settings + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + if entry.body_bytes > settings.max_object_bytes { + self.counters + .memory_pressure_rejections + .fetch_add(1, Ordering::Relaxed); + return None; + } + let read_lease = self.try_reserve_transient(entry.body_bytes, &settings)?; + let _cold_read = ByteGaugeGuard::new(&self.cold_read_bytes_active, entry.body_bytes as u64); + let body_vec = match tokio::fs::read(&entry.body_path).await { + Ok(body) if cold_body_matches(&entry, &body) => body, + _ => { + self.counters.cold_errors.fetch_add(1, Ordering::Relaxed); + if let Some(entry) = self.take_cold(&storage_key) { + self.delete_cold_entry(entry); + } + return None; + } + }; + let body_allocated = body_vec.capacity().max(body_vec.len()); + let extra = body_allocated.saturating_sub(entry.body_bytes); + let extra_lease = if extra > 0 { + Some(self.try_reserve_transient(extra, &settings)?) + } else { + None + }; + let _cold_read_extra = extra_lease + .as_ref() + .map(|_| ByteGaugeGuard::new(&self.cold_read_bytes_active, extra as u64)); + let body = Bytes::from(body_vec); + if !self.ticket_current(ticket) { + return None; + } + let age = cold_age(&entry); + let resident_age_at_lookup = Duration::from_secs(cold_resident_age(&entry)); + Some(Revalidation { + headers: entry.headers, + body, + body_allocated, + _transient_leases: extra_lease + .into_iter() + .chain(std::iter::once(read_lease)) + .collect(), + hits: entry.hits, + transformed: entry.transformed, + storage_key, + resident_age_at_lookup, + skip_compression: entry.skip_compression, + reusable_without_validation: age < entry.ttl && !entry.requires_revalidation, + age, + ttl: entry.ttl, + stale_if_error: entry.stale_if_error, + must_revalidate: entry.must_revalidate, + proxy_revalidate: entry.proxy_revalidate, + origin_etag: entry.origin_etag, + origin_last_modified: entry.origin_last_modified, + }) + } + pub async fn revalidation_tiered(&self, ticket: &Ticket) -> Option { + if let Some(cached) = self.revalidation(ticket) { + return Some(cached); + } + self.cold_revalidation(ticket).await + } + pub fn stale_if_error_hit(&self, cached: &Revalidation) -> Option { + if !cached.can_stale_if_error() { + return None; + } + let hits = cached.hits.saturating_add(1); + let mut headers = cached.headers.clone(); + headers.insert( + "age", + HeaderValue::from_str(&cached.age.to_string()).ok()?, + ); + headers.append( + "warning", + HeaderValue::from_static("110 RustProxy \"Response is stale\""), + ); + let response = cached.response(headers); + let touched_hot = { + let mut store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(entry) = store.entries.get_mut(&cached.storage_key) { + entry.hits = hits; + entry.last_access = Instant::now(); + true + } else { + false + } + }; + if !touched_hot { + let mut cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(mut entry) = cold.remove(&cached.storage_key) { + entry.hits = hits; + entry.last_access = SystemTime::now(); + entry.sequence = cold.next_sequence(); + cold.bytes = cold.bytes.saturating_add(entry.disk_bytes); + cold.lru.insert(entry.sequence, cached.storage_key); + cold.index_variant(entry.base_key, cached.storage_key); + cold.entries.insert(cached.storage_key, entry); + } + } + self.counters.hits.fetch_add(1, Ordering::Relaxed); + Some(Hit { + response, + body: cached.body.clone(), + hits, + transformed: cached.transformed, + remaining_ttl: 0, + resident_age_seconds: cached.resident_age_at_lookup.as_secs(), + stale: true, + background_revalidate: false, + }) + } + + pub fn begin_background_revalidation( + self: &Arc, + cached: &Revalidation, + ) -> Option { + // Vary variants share a base Ticket key. Deduplicate only the exact cached + // representation so an SWR refresh for one variant cannot suppress another. + let key = cached.storage_key; + let mut active = self.revalidating.lock().unwrap_or_else(|e| e.into_inner()); + if !active.insert(key) { + return None; + } + Some(BackgroundRevalidationGuard { + cache: Arc::downgrade(self), + key, + }) + } + pub fn ticket_current(&self, ticket: &Ticket) -> bool { + let store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + store.revision == ticket.revision + && store.generation == ticket.generation + && store + .scope_generations + .get(&ticket.scope) + .copied() + .unwrap_or(0) + == ticket.scope_generation + && store + .key_generations + .get(&ticket.key) + .copied() + .unwrap_or(0) + == ticket.key_generation + } + fn discard_storage_key(&self, key: &Key) { + self.store + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(key); + if let Some(entry) = self.take_cold(key) { + self.delete_cold_entry(entry); + } + } + pub fn discard(&self, ticket: &Ticket) { + let hot_key = { + let store = self.store.lock().unwrap_or_else(|e| e.into_inner()); + (store.revision == ticket.revision) + .then(|| matching_hot_key(&store, ticket)) + .flatten() + }; + if let Some(key) = hot_key { + self.discard_storage_key(&key); + return; + } + let cold_key = { + let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner()); + matching_cold_key(&cold, ticket) + }; + if let Some(key) = cold_key { + self.discard_storage_key(&key); + } + } +} diff --git a/src/compression.rs b/src/compression.rs new file mode 100644 index 0000000..3c0b60a --- /dev/null +++ b/src/compression.rs @@ -0,0 +1,384 @@ +//! Streaming content negotiation. No compression of private, ranged, upgraded, +//! event-stream or no-transform responses. No full-body aggregation. +use anyhow::{ensure, Result}; +use axum::{body::Body, response::Response}; +use http::{HeaderMap, HeaderValue, Method, Request}; +use serde::{Deserialize, Serialize}; +use std::convert::Infallible; +use tower::{service_fn, ServiceExt}; +use tower_http::compression::{ + predicate::{DefaultPredicate, Predicate}, + Compression, CompressionLevel, +}; + +/// A u64 predicate independent of tower-http's version-specific SizeAbove type. +/// Unknown-size streams are compressed without buffering, like tower-http. +#[derive(Clone, Copy)] +struct MinimumSize(u64); +impl Predicate for MinimumSize { + fn should_compress(&self, response: &http::Response) -> bool { + let size = response + .headers() + .get(http::header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .or_else(|| response.body().size_hint().exact()); + size.is_none_or(|size| size >= self.0) + } +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + pub gzip: bool, + pub br: bool, + pub zstd: bool, + pub deflate: bool, + pub min_bytes: u64, +} +impl Default for Settings { + fn default() -> Self { + Self { + gzip: false, + br: false, + zstd: false, + deflate: false, + min_bytes: 1024, + } + } +} +impl Settings { + pub fn validate(&self) -> Result<()> { + ensure!( + (32..=1_048_576).contains(&self.min_bytes), + "compression.min_bytes must be 32..1048576" + ); + Ok(()) + } + pub fn enabled(&self) -> bool { + self.gzip || self.br || self.zstd || self.deflate + } +} +#[derive(Clone)] +pub struct Skip; + +/// Encoding applied by RustProxy itself. This marker is kept in response +/// extensions so accounting never has to infer the selected codec from a +/// header that later response rules may rewrite. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AppliedEncoding { + Gzip, + Brotli, + Zstd, + Deflate, +} +impl AppliedEncoding { + fn from_header(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "gzip" => Some(Self::Gzip), + "br" => Some(Self::Brotli), + "zstd" => Some(Self::Zstd), + "deflate" => Some(Self::Deflate), + _ => None, + } + } +} +pub fn private_or_no_transform(headers: &HeaderMap) -> bool { + headers.contains_key("set-cookie") + || headers + .get_all("cache-control") + .iter() + .filter_map(|v| v.to_str().ok()) + .any(|v| { + v.split(',').any(|d| { + matches!( + d.trim() + .split('=') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase() + .as_str(), + "no-store" | "private" | "no-transform" + ) + }) + }) +} +fn no_transform(headers: &HeaderMap) -> bool { + headers + .get_all("cache-control") + .iter() + .filter_map(|v| v.to_str().ok()) + .any(|v| { + v.split(',') + .any(|v| v.trim().eq_ignore_ascii_case("no-transform")) + }) +} +fn eligible( + response: &Response, + request: &HeaderMap, + method: &Method, + settings: &Settings, +) -> bool { + if response.extensions().get::().is_some() + || private_or_no_transform(response.headers()) + || !settings.enabled() + || method == Method::HEAD + || response.status().is_informational() + || response.status().as_u16() == 204 + || response.status().as_u16() == 304 + || response.headers().contains_key("content-encoding") + || response.headers().contains_key("content-range") + || response.headers().contains_key("set-cookie") + || response.headers().contains_key("upgrade") + || request.contains_key("range") + || request.contains_key("authorization") + || request.contains_key("cookie") + || no_transform(request) + || no_transform(response.headers()) + { + return false; + } + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_ascii_lowercase(); + if content_type.starts_with("text/event-stream") { + return false; + } + content_type.starts_with("text/") + || content_type.starts_with("application/json") + || content_type.starts_with("application/javascript") + || content_type.starts_with("application/xml") + || content_type.starts_with("image/svg+xml") + || content_type.starts_with("application/wasm") + || content_type + .split(';') + .next() + .is_some_and(|v| v.ends_with("+json") || v.ends_with("+xml")) +} +pub async fn encode( + response: Response, + headers: &HeaderMap, + method: &Method, + settings: &Settings, +) -> Response { + if !eligible(&response, headers, method, settings) { + return response; + } + let mut response = Some(response); + let service = service_fn(move |_: Request| { + let response = response.take().expect("one-shot compression service"); + async move { Ok::<_, Infallible>(response) } + }); + let compression = Compression::new(service) + .gzip(settings.gzip) + .br(settings.br) + .zstd(settings.zstd) + .deflate(settings.deflate) + .quality(CompressionLevel::Fastest) + .compress_when(DefaultPredicate::new().and(MinimumSize(settings.min_bytes))); + let mut request = Request::new(Body::empty()); + *request.method_mut() = method.clone(); + // Multiple Accept-Encoding lines are meaningful and must all be retained. + for value in headers.get_all("accept-encoding") { + request + .headers_mut() + .append("accept-encoding", value.clone()); + } + let mut response = match compression.oneshot(request).await { + Ok(response) => response.map(Body::new), + Err(never) => match never {}, + }; + if response.headers().contains_key("content-encoding") { + if let Some(applied) = response + .headers() + .get("content-encoding") + .and_then(|value| value.to_str().ok()) + .and_then(AppliedEncoding::from_header) + { + response.extensions_mut().insert(applied); + } + // A strong validator for identity bytes is not a strong validator of the + // compressed bytes. Preserve semantic validation as a weak ETag. + if let Some(value) = response + .headers() + .get("etag") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) + { + if !value.starts_with("W/") { + if let Ok(weak) = HeaderValue::from_str(&format!("W/{value}")) { + response.headers_mut().insert("etag", weak); + } else { + response.headers_mut().remove("etag"); + } + } + } + for field in ["content-md5", "digest", "content-digest", "accept-ranges"] { + response.headers_mut().remove(field); + } + } + response +} +#[cfg(test)] +mod tests { + use super::*; + use http_body_util::BodyExt; + fn response() -> Response { + let mut response = + Response::new(Body::from("RustProxy compressible payload. ".repeat(500))); + response + .headers_mut() + .insert("content-type", "text/plain".parse().unwrap()); + response + .headers_mut() + .insert("etag", "\"origin\"".parse().unwrap()); + response + } + #[tokio::test] + async fn large_thresholds_do_not_truncate_or_panic() { + let mut h = HeaderMap::new(); + h.insert("accept-encoding", "gzip".parse().unwrap()); + for threshold in [65_535, 65_536, 1_048_576] { + let settings = Settings { + gzip: true, + min_bytes: threshold, + ..Default::default() + }; + settings.validate().unwrap(); + for (size, expected) in [(threshold - 1, false), (threshold, true)] { + let mut r = Response::new(Body::from("a".repeat(size as usize))); + r.headers_mut() + .insert("content-type", "text/plain".parse().unwrap()); + assert_eq!( + encode(r, &h, &Method::GET, &settings) + .await + .headers() + .contains_key("content-encoding"), + expected, + "threshold={threshold}, size={size}" + ); + } + } + } + #[tokio::test] + async fn negotiation_and_body_streaming() { + let s = Settings { + gzip: true, + br: true, + zstd: true, + deflate: true, + ..Default::default() + }; + for encoding in ["gzip", "br", "zstd", "deflate"] { + let mut h = HeaderMap::new(); + h.insert("accept-encoding", encoding.parse().unwrap()); + let r = encode(response(), &h, &Method::GET, &s).await; + assert_eq!(r.headers()["content-encoding"], encoding); + let expected = match encoding { + "gzip" => AppliedEncoding::Gzip, + "br" => AppliedEncoding::Brotli, + "zstd" => AppliedEncoding::Zstd, + "deflate" => AppliedEncoding::Deflate, + _ => unreachable!(), + }; + assert_eq!( + r.extensions().get::().copied(), + Some(expected) + ); + assert_eq!(r.headers()["etag"], "W/\"origin\""); + assert!(r + .headers() + .get_all("vary") + .iter() + .any(|v| v.to_str().unwrap().contains("accept-encoding"))); + assert!(r.into_body().collect().await.unwrap().to_bytes().len() < 1000); + } + let mut h = HeaderMap::new(); + h.insert("accept-encoding", "gzip;q=0, identity;q=1".parse().unwrap()); + assert!(!encode(response(), &h, &Method::GET, &s) + .await + .headers() + .contains_key("content-encoding")); + } + #[tokio::test] + async fn quality_weights_can_select_each_enabled_codec() { + let settings = Settings { + gzip: true, + br: true, + zstd: true, + deflate: true, + ..Default::default() + }; + let cases = [ + ( + "gzip;q=1, br;q=0.2, zstd;q=0.2, deflate;q=0.2", + "gzip", + AppliedEncoding::Gzip, + ), + ( + "gzip;q=0.2, br;q=1, zstd;q=0.2, deflate;q=0.2", + "br", + AppliedEncoding::Brotli, + ), + ( + "gzip;q=0.2, br;q=0.2, zstd;q=1, deflate;q=0.2", + "zstd", + AppliedEncoding::Zstd, + ), + ( + "gzip;q=0.2, br;q=0.2, zstd;q=0.2, deflate;q=1", + "deflate", + AppliedEncoding::Deflate, + ), + ]; + for (accept, expected_header, expected_marker) in cases { + let mut headers = HeaderMap::new(); + headers.insert("accept-encoding", accept.parse().unwrap()); + let response = encode(response(), &headers, &Method::GET, &settings).await; + assert_eq!(response.headers()["content-encoding"], expected_header); + assert_eq!( + response.extensions().get::().copied(), + Some(expected_marker), + "accept-encoding={accept}" + ); + } + } + + #[tokio::test] + async fn no_transform_personal_and_sse_are_not_compressed() { + let s = Settings { + gzip: true, + ..Default::default() + }; + let mut h = HeaderMap::new(); + h.insert("accept-encoding", "gzip".parse().unwrap()); + for name in ["authorization", "cookie", "range"] { + let mut private = h.clone(); + private.insert( + http::HeaderName::from_bytes(name.as_bytes()).unwrap(), + "value".parse().unwrap(), + ); + assert!(!encode(response(), &private, &Method::GET, &s) + .await + .headers() + .contains_key("content-encoding")); + } + let mut r = response(); + r.headers_mut() + .insert("cache-control", "no-transform".parse().unwrap()); + assert!(!encode(r, &h, &Method::GET, &s) + .await + .headers() + .contains_key("content-encoding")); + let mut r = response(); + r.headers_mut() + .insert("content-type", "text/event-stream".parse().unwrap()); + assert!(!encode(r, &h, &Method::GET, &s) + .await + .headers() + .contains_key("content-encoding")); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..3e1cac1 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,2036 @@ +//! Desired configuration and its validated, immutable routing snapshot. +use crate::{ + acl::{Acl, CompiledAcl}, + headers::{CompiledHeaders, HeaderRules}, +}; +use anyhow::{ensure, Context, Result}; +use http::{header::HeaderValue, uri::Authority}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{HashMap, HashSet}, + net::{IpAddr, Ipv4Addr, SocketAddr}, + path::PathBuf, + sync::Arc, +}; +use url::{Host, Url}; + +pub const MAX_CONFIG_BYTES: usize = 262_144; +pub const MAX_BACKENDS: usize = 128; +pub const MAX_ROUTES: usize = 256; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + #[serde(default)] + pub administration: crate::settings::Administration, + #[serde(default)] + pub logging: crate::logging::Settings, + #[serde(default)] + pub prometheus: crate::prometheus::Settings, + #[serde(default)] + pub listeners: crate::settings::Listeners, + /// Legacy/default HTTP/2 lifecycle limits for listeners that do not yet + /// persist an explicit `binding.http2_settings` object. + #[serde(default)] + pub http2: crate::settings::Http2Connections, + #[serde(default)] + pub docker_agents: crate::docker::Settings, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub streams: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub redirects: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub not_found_hosts: Vec, + #[serde(default)] + pub cache: crate::cache::Settings, + #[serde(default)] + pub transform: crate::transform::Settings, + #[serde(default)] + pub pages: crate::pages::GlobalPages, + #[serde(default)] + pub compression: crate::compression::Settings, + #[serde(default)] + pub server_header: crate::settings::ServerHeader, + #[serde(default)] + pub diagnostic_headers: crate::settings::DiagnosticHeaders, + #[serde(default)] + pub forwarding: crate::forwarding::Settings, + #[serde(default, skip_serializing_if = "crate::tls::Settings::is_empty")] + pub tls: crate::tls::Settings, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub vhosts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub host_policies: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub acl_profiles: Vec, + #[serde(default, skip_serializing_if = "HeaderRules::is_empty")] + pub headers: HeaderRules, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HostPolicy { + pub hosts: Vec, + pub acl: Acl, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct AclProfile { + pub id: String, + pub name: String, + pub acl: Option, + pub country_access: crate::rate_limit::CountryAccess, +} +impl Default for AclProfile { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + acl: None, + country_access: Default::default(), + } + } +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Backend { + #[serde(default)] + pub protocol: UpstreamProtocol, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub address_allowlist: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls_ca_pem: Option, + /// Optional DNS name used for TLS SNI and certificate hostname verification. + /// TCP/DNS connection targeting remains pinned to `url`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls_server_name: Option, + /// Verify the HTTPS backend certificate chain and server identity. + /// Disabled by default for reverse-proxy compatibility; enable explicitly when required. + #[serde(default)] + pub tls_verify: bool, + pub id: String, + pub url: String, + #[serde(default)] + pub allow_private: bool, + /// Explicitly allow a backend that resolves to a local RustProxy listener. + /// This can expose the control plane or create a proxy loop and is off by default. + #[serde(default)] + pub accept_local_listener_risk: bool, + #[serde(default)] + pub preserve_host: bool, +} +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum UpstreamProtocol { + #[default] + Http1, + Http2, + Auto, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Envelope { + pub revision: u64, + pub config: Config, +} +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Update { + pub expected_revision: u64, + pub config: Config, +} +#[derive(Debug, Clone)] +pub struct ValidationPolicy { + pub tls_addr: Option, + pub cert_dir: Option, + /// Resolved runtime data directory. Exposed only through authenticated admin status. + pub data_dir: Option, + pub proxy_addr: SocketAddr, + pub admin_addr: SocketAddr, +} +#[derive(Debug)] +pub struct CompiledBackend { + pub address_policy: crate::upstream::AddressPolicy, + pub client: crate::upstream::ProxyClient, + pub websocket_client: crate::upstream::ProxyClient, + pub id: String, + /// Configured network origin used by raw TCP probes and policy checks. + pub origin: String, + /// HTTP request origin. For HTTPS this may carry a separate verified TLS name. + pub request_origin: String, + pub host_header: HeaderValue, + pub preserve_host: bool, + pub protocol: UpstreamProtocol, + /// Whether backend TLS certificate chain/name verification is enabled. + /// Retained in the compiled snapshot for failure diagnostics only. + pub tls_verify: bool, + pub accept_local_listener_risk: bool, + /// True only when this backend URL names the configured administration socket + /// directly. Used by the guarded control-plane reverse-proxy path. + pub targets_admin_listener: bool, +} +#[derive(Debug, Clone)] +pub struct CompiledRoute { + pub backend_id: Option, + pub bindings: Vec, + pub frontend: Option>, + pub pool: Option>, + pub cache: Option, + pub id: String, + pub path_prefix: String, + pub match_type: crate::vhost::PathMatch, + pub path_regex: Option, + pub rewrite: crate::vhost::PathRewrite, + pub response: Option, + pub backend: Arc, + pub acl: Option, + pub headers: CompiledHeaders, +} +#[derive(Debug)] +pub struct CompiledFrontend { + pub websockets: bool, + pub websocket_legacy_support: bool, + pub websocket_rfc8441: bool, + pub websocket_rfc9220: bool, + pub force_ssl: bool, + pub forward_client_ip: bool, + /// HTTPS target port used by Force SSL redirects. Port 443 wins when multiple TLS ports are selected. + pub https_redirect_port: Option, + pub http2_enabled: bool, + pub http3_enabled: bool, + pub tls_policy: crate::tls::ProtocolPolicy, + pub tls: Option>, + pub tls_http1: Option>, + pub cloudflare_client_ip: bool, + pub timing_headers: bool, + pub backend_header: bool, + pub backend_timing_header: bool, + pub rate_limit: crate::rate_limit::Settings, + pub basic_auth: Option, + pub metrics: Arc, + pub server_header: crate::settings::ServerHeader, + pub diagnostic_headers: crate::settings::DiagnosticHeaders, + pub tinyauth: Option, + pub id: String, + pub bindings: Vec, + pub acl_profile_id: Option, + pub profile_acl: Option, + pub profile_country_access: crate::rate_limit::CountryAccess, + pub acl: Option, + pub headers: CompiledHeaders, + pub security: crate::security::CompiledSecurity, + pub cache: crate::cache::Settings, + pub transform: crate::transform::Settings, + pub pages: crate::pages::VhostPages, + pub proxy_timeouts: crate::vhost::ProxyTimeouts, +} +pub struct Snapshot { + pub vhost_metrics: std::collections::BTreeMap>, + pub forwarding: crate::forwarding::Compiled, + pub quic_tls: Option>, + pub listeners: Vec, + pub pools: Vec>, + pub tls_http1: Option>, + frontends: HashMap>>, + pub tls: Option>, + pub envelope: Envelope, + routes: HashMap>, + /// Main vhost backend fallback, kept separate from user Custom locations. + fallbacks: HashMap>, + redirects: HashMap>>, + not_found_hosts: HashMap>>, + host_acls: HashMap>, + pub headers: CompiledHeaders, +} +impl std::fmt::Debug for Snapshot { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // QUIC crypto has no Debug implementation; never format private keys. + f.debug_struct("Snapshot") + .field("revision", &self.envelope.revision) + .field("listeners", &self.listeners) + .field("quic_tls", &self.quic_tls.is_some()) + .finish_non_exhaustive() + } +} +impl Snapshot { + fn host_patterns(host: &str) -> Vec { + let mut patterns = vec![host.to_owned()]; + for (index, _) in host.match_indices('.') { + let suffix = &host[index + 1..]; + if suffix.contains('.') { + patterns.push(format!("*.{suffix}")); + } + } + patterns + } + pub fn host_allowed(&self, host: &str, peer: IpAddr) -> bool { + for pattern in Self::host_patterns(host) { + if let Some(acl) = self.host_acls.get(&pattern) { + return acl.allows(peer); + } + } + true + } + /// RFC 8441 SETTINGS_ENABLE_CONNECT_PROTOCOL is connection-scoped, so it is + /// advertised only when at least one explicitly opted-in vhost is bound to + /// this HTTP/2 listener. Request routing still enforces the per-vhost opt-in. + pub fn websocket_rfc8441_on(&self, local: SocketAddr, tls: bool) -> bool { + self.frontends.values().flatten().any(|frontend| { + frontend.websockets + && frontend.websocket_rfc8441 + && frontend.http2_enabled + && frontend.bindings.iter().any(|binding| { + binding.matches(local, tls) + && if tls { binding.http2 } else { binding.h2c } + }) + }) + } + + /// RFC 9220 SETTINGS_ENABLE_CONNECT_PROTOCOL is connection-scoped, so it is + /// advertised only when at least one explicitly opted-in vhost is bound to + /// this QUIC listener. Request routing still enforces the per-vhost opt-in. + pub fn websocket_rfc9220_on(&self, local: SocketAddr) -> bool { + self.frontends.values().flatten().any(|frontend| { + frontend.websockets + && frontend.websocket_rfc9220 + && frontend.http3_enabled + && frontend.bindings.iter().any(|binding| { + binding.tls && binding.http3.is_some() && binding.matches(local, true) + }) + }) + } + /// Production routing always uses the TCP connection's local endpoint. + /// Exact hostnames win over the longest matching wildcard hostname. + pub fn select_on( + &self, + host: &str, + path: &str, + local: SocketAddr, + tls: bool, + ) -> Option<&CompiledRoute> { + for pattern in Self::host_patterns(host) { + let fallback = self.fallbacks.get(&pattern).and_then(|routes| { + routes + .iter() + .find(|route| route.bindings.iter().any(|l| l.matches(local, tls))) + }); + // A claimed exact/wildcard vhost owns the request as a unit. First try + // its Custom locations, then its main backend fallback; never continue + // into a broader wildcard merely because no Custom location matched. + if let Some(fallback) = fallback { + return self + .routes + .get(&pattern) + .and_then(|routes| { + select_route(routes, path, |r| { + r.bindings.iter().any(|l| l.matches(local, tls)) + }) + }) + .or(Some(fallback)); + } + } + None + } + pub fn frontend( + &self, + host: &str, + local: SocketAddr, + tls: bool, + ) -> Option<&Arc> { + for pattern in Self::host_patterns(host) { + if let Some(frontend) = self.frontends.get(&pattern).and_then(|frontends| { + frontends + .iter() + .find(|f| f.bindings.iter().any(|l| l.matches(local, tls))) + }) { + return Some(frontend); + } + } + None + } + /// Select a redirect host/rule using the accepted socket, TLS state and normalized path. + /// Exact hostnames win over the longest matching wildcard, matching vhost behavior. + pub fn redirect_on( + &self, + host: &str, + path: &str, + local: SocketAddr, + tls: bool, + ) -> Option<( + &Arc, + &crate::redirect::CompiledRule, + )> { + for pattern in Self::host_patterns(host) { + if let Some((redirect, rule)) = self.redirects.get(&pattern).and_then(|redirects| { + redirects.iter().find_map(|redirect| { + if !redirect + .bindings + .iter() + .any(|binding| binding.matches(local, tls)) + { + return None; + } + redirect + .rules + .iter() + .find(|rule| rule.matches(path, tls)) + .map(|rule| (redirect, rule)) + }) + }) { + return Some((redirect, rule)); + } + } + None + } + /// Select a host-owned local 404/static response using the accepted socket. + pub fn not_found_host_on( + &self, + host: &str, + local: SocketAddr, + tls: bool, + ) -> Option<&Arc> { + for pattern in Self::host_patterns(host) { + if let Some(item) = self.not_found_hosts.get(&pattern).and_then(|items| { + items.iter().find(|item| { + item.bindings + .iter() + .any(|binding| binding.matches(local, tls)) + }) + }) { + return Some(item); + } + } + None + } + + /// Legacy inspection helper only; network handlers must call select_on. + pub fn select(&self, host: &str, path: &str) -> Option<&CompiledRoute> { + for pattern in Self::host_patterns(host) { + if let Some(fallback) = self + .fallbacks + .get(&pattern) + .and_then(|routes| routes.first()) + { + return self + .routes + .get(&pattern) + .and_then(|routes| select_route(routes, path, |_| true)) + .or(Some(fallback)); + } + } + None + } +} + +fn select_route<'a, F>( + routes: &'a [CompiledRoute], + path: &str, + allowed: F, +) -> Option<&'a CompiledRoute> +where + F: Fn(&CompiledRoute) -> bool, +{ + if let Some(exact) = routes.iter().find(|route| { + allowed(route) + && route.match_type == crate::vhost::PathMatch::Exact + && route.path_prefix == path + }) { + return Some(exact); + } + let prefix = routes + .iter() + .filter(|route| { + allowed(route) + && matches!( + route.match_type, + crate::vhost::PathMatch::Prefix | crate::vhost::PathMatch::PrefixPriority + ) + && prefix_matches(&route.path_prefix, path) + }) + .max_by_key(|route| route.path_prefix.len()); + if prefix.is_some_and(|route| route.match_type == crate::vhost::PathMatch::PrefixPriority) { + return prefix; + } + if let Some(regex) = routes.iter().find(|route| { + allowed(route) + && matches!( + route.match_type, + crate::vhost::PathMatch::Regex | crate::vhost::PathMatch::RegexInsensitive + ) + && route + .path_regex + .as_ref() + .is_some_and(|regex| regex.is_match(path)) + }) { + return Some(regex); + } + prefix +} + +impl CompiledRoute { + /// Return a normalized replacement path for the upstream hop. `None` means + /// preserve the client's exact origin-form path bytes. + pub fn rewritten_path(&self, path: &str) -> Result> { + use crate::vhost::RewriteMode; + let rewritten = match self.rewrite.mode { + RewriteMode::Preserve => return Ok(None), + RewriteMode::StripPrefix => { + let tail = path.strip_prefix(&self.path_prefix).unwrap_or(""); + if tail.is_empty() { + "/".to_owned() + } else { + tail.to_owned() + } + } + RewriteMode::ReplacePrefix => { + let tail = path.strip_prefix(&self.path_prefix).unwrap_or(""); + let base = self.rewrite.value.trim_end_matches('/'); + if tail.is_empty() { + if base.is_empty() { + "/".to_owned() + } else { + base.to_owned() + } + } else if base.is_empty() || base == "/" { + tail.to_owned() + } else { + format!("{base}{tail}") + } + } + RewriteMode::Regex => self + .path_regex + .as_ref() + .expect("regex rewrite was validated") + .replace(path, self.rewrite.value.as_str()) + .into_owned(), + }; + crate::acl::validate_decoded_routing_path(&rewritten) + .context("invalid rewritten location path")?; + Ok(Some(rewritten)) + } +} +pub fn prefix_matches(prefix: &str, path: &str) -> bool { + prefix == "/" + || path == prefix + || path + .strip_prefix(prefix) + .is_some_and(|tail| tail.starts_with('/')) +} +pub(crate) fn valid_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 64 + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} +/// Vhost names are ASCII DNS names (IDNs must be provided as punycode) or IPv4. +pub fn normalize_host(value: &str) -> Result { + let host = value + .strip_suffix('.') + .unwrap_or(value) + .to_ascii_lowercase(); + ensure!( + !host.is_empty() && host.len() <= 253, + "hostname length must be 1..253" + ); + ensure!( + host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + && label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + }), + "invalid hostname: use an ASCII hostname without a port or wildcard" + ); + Ok(host) +} +/// Configured frontend patterns may use a single leading `*.` wildcard. +/// Runtime Host values remain exact and are normalized by `normalize_host`. +pub fn normalize_host_pattern(value: &str) -> Result { + let value = value.trim(); + if let Some(base) = value.strip_prefix("*.") { + ensure!( + !base.contains('*'), + "wildcard hostname allows only one leading *." + ); + let base = normalize_host(base)?; + ensure!( + base.contains('.') && base.parse::().is_err(), + "wildcard hostname requires a DNS suffix such as *.example.com" + ); + return Ok(format!("*.{base}")); + } + ensure!( + !value.contains('*'), + "wildcard hostname allows only one leading *." + ); + normalize_host(value) +} +pub fn request_host(value: &str) -> Result { + ensure!(!value.contains('@'), "invalid Host"); + let authority: Authority = value.parse().context("invalid Host authority")?; + if let Some(port) = authority.port() { + let number: u16 = port.as_str().parse().context("invalid Host port")?; + ensure!(number > 0, "invalid Host port"); + } + let host = authority + .host() + .trim_start_matches('[') + .trim_end_matches(']'); + if let Ok(ip) = host.parse::() { + return Ok(ip.to_string()); + } + normalize_host(host) +} +fn ipv4_policy(ip: Ipv4Addr, allow_private: bool) -> Result<()> { + let [a, b, _, _] = ip.octets(); + ensure!( + a != 0 && a < 224 && !ip.is_broadcast(), + "unspecified/reserved/multicast target blocked" + ); + ensure!( + !(a == 169 && b == 254), + "link-local and metadata targets are blocked" + ); + ensure!( + !(a == 100 && (64..=127).contains(&b)), + "shared-address/metadata range is blocked" + ); + ensure!( + !(a == 198 && (b == 18 || b == 19)), + "benchmark range is blocked" + ); + ensure!( + !ip.is_documentation(), + "documentation address is not a backend" + ); + ensure!( + allow_private || (!ip.is_private() && !ip.is_loopback()), + "private/loopback backend requires allow_private=true" + ); + Ok(()) +} +pub(crate) fn ip_policy(ip: IpAddr, allow_private: bool) -> Result<()> { + match ip { + IpAddr::V4(ip) => ipv4_policy(ip, allow_private), + IpAddr::V6(ip) => { + if let Some(v4) = ip.to_ipv4_mapped() { + return ipv4_policy(v4, allow_private); + } + let s = ip.segments(); + ensure!( + !ip.is_unspecified() && !ip.is_multicast(), + "unspecified/multicast target blocked" + ); + ensure!( + s[0] & 0xffc0 != 0xfe80, + "IPv6 link-local targets are blocked" + ); + ensure!( + !(s[0] == 0x2001 && s[1] == 0x0db8), + "documentation address is not a backend" + ); + // Only global unicast, ULA and loopback. Reject IPv4 translation/tunnel special ranges. + let ula = s[0] & 0xfe00 == 0xfc00; + let global = + s[0] & 0xe000 == 0x2000 && s[0] != 0x2002 && !(s[0] == 0x2001 && s[1] == 0); + ensure!( + global || ula || ip.is_loopback(), + "special IPv6 target blocked" + ); + ensure!( + allow_private || (!ula && !ip.is_loopback()), + "private/loopback backend requires allow_private=true" + ); + Ok(()) + } + } +} +pub(crate) fn compile_backend( + backend: &Backend, + policy: &ValidationPolicy, + bindings: &[SocketAddr], + keepalive: &crate::balance::Keepalive, +) -> Result { + compile_backend_with_timeout( + backend, + policy, + bindings, + keepalive, + std::time::Duration::from_secs(10), + ) +} + +pub(crate) fn compile_backend_with_timeout( + backend: &Backend, + policy: &ValidationPolicy, + bindings: &[SocketAddr], + keepalive: &crate::balance::Keepalive, + connect_timeout: std::time::Duration, +) -> Result { + ensure!(valid_id(&backend.id), "invalid backend id"); + ensure!(backend.url.len() <= 512, "backend URL too long"); + let url = Url::parse(&backend.url).context("invalid backend URL")?; + ensure!( + matches!(url.scheme(), "http" | "https"), + "backend scheme must be http or https" + ); + ensure!(backend.protocol == UpstreamProtocol::Http1 || !backend.preserve_host, + "HTTP/2 or auto upstream requires preserve_host=false; use http1 for original Host preservation"); + ensure!( + backend.tls_ca_pem.is_none() || url.scheme() == "https", + "backend additional CA can only be used with an https:// URL; remove the CA or change the backend URL to HTTPS" + ); + ensure!( + backend.tls_server_name.is_none() || url.scheme() == "https", + "backend TLS server name can only be used with an https:// URL; remove the TLS server name or change the backend URL to HTTPS" + ); + let tls_server_name = backend + .tls_server_name + .as_deref() + .map(normalize_host) + .transpose()?; + if let Some(name) = &tls_server_name { + ensure!( + name.parse::().is_err(), + "backend TLS server name must be the DNS name present in the certificate (for example origin.example.internal), not an IP address" + ); + } + ensure!( + url.username().is_empty() && url.password().is_none(), + "URL credentials are forbidden" + ); + ensure!( + url.path() == "/" && url.query().is_none() && url.fragment().is_none(), + "backend URL must be an origin, without a path, query or fragment" + ); + let address_policy = crate::upstream::AddressPolicy::new(backend)? + .with_listeners(policy) + .with_bindings(bindings); + match url.host().context("backend host missing")? { + Host::Ipv4(ip) => address_policy.check(IpAddr::V4(ip))?, + Host::Ipv6(ip) => address_policy.check(IpAddr::V6(ip))?, + Host::Domain(domain) => { + normalize_host(domain)?; + } + } + let port = url + .port_or_known_default() + .context("backend port missing")?; + ensure!(port > 0, "backend port must be nonzero"); + let targets_admin_listener = match url.host().context("backend host missing")? { + Host::Ipv4(ip) => { + crate::vhost::socket_overlap(SocketAddr::new(IpAddr::V4(ip), port), policy.admin_addr) + } + Host::Ipv6(ip) => { + crate::vhost::socket_overlap(SocketAddr::new(IpAddr::V6(ip), port), policy.admin_addr) + } + // DNS destinations remain subject to the normal self-target guard, but + // are never trusted for administration-cookie passthrough. The special + // control-plane path must point at the listener explicitly by IP. + Host::Domain(_) => false, + }; + // Exact local listener targets are rejected by AddressPolicy by default, including DNS + // answers. An explicit per-backend risk acceptance can override only that self-target + // guard; remote origins on any valid backend port, including 9090, remain valid. + let authority = match url.host().context("backend host missing")? { + Host::Ipv6(ip) => format!("[{ip}]:{port}"), + host => format!("{host}:{port}"), + }; + let request_authority = tls_server_name + .as_deref() + .map(|name| format!("{name}:{port}")) + .unwrap_or_else(|| authority.clone()); + let client = crate::upstream::client_with_timeout( + backend, + address_policy.clone(), + false, + keepalive, + connect_timeout, + )?; + let websocket_client = if backend.protocol == UpstreamProtocol::Http1 { + client.clone() + } else { + crate::upstream::client_with_timeout( + backend, + address_policy.clone(), + true, + keepalive, + connect_timeout, + )? + }; + Ok(CompiledBackend { + address_policy, + id: backend.id.clone(), + origin: format!("{}://{authority}", url.scheme()), + request_origin: format!("{}://{request_authority}", url.scheme()), + host_header: authority.parse()?, + preserve_host: backend.preserve_host, + protocol: backend.protocol, + tls_verify: backend.tls_verify, + accept_local_listener_risk: backend.accept_local_listener_risk, + targets_admin_listener, + client, + websocket_client, + }) +} +pub fn compile(config: Config, revision: u64, policy: &ValidationPolicy) -> Result { + compile_with_defaults( + config, + revision, + policy, + (policy.proxy_addr, policy.tls_addr), + ) +} +/// Validate both active sockets and the sockets that will be used after restart. +pub fn compile_with_defaults( + config: Config, + revision: u64, + policy: &ValidationPolicy, + defaults: (SocketAddr, Option), +) -> Result { + compile_reusing(config, revision, policy, defaults, None) +} +/// Rebuild clients and route policies, but retain compatible health observations, +/// full-exchange counters and affinity tokens through unrelated config edits. +pub fn compile_reusing( + config: Config, + revision: u64, + policy: &ValidationPolicy, + defaults: (SocketAddr, Option), + previous: Option<&Snapshot>, +) -> Result { + compile_with_active(config, revision, policy, defaults, previous, &[]) +} +pub fn compile_with_active( + mut config: Config, + revision: u64, + policy: &ValidationPolicy, + defaults: (SocketAddr, Option), + previous: Option<&Snapshot>, + active: &[crate::vhost::Listener], +) -> Result { + config.administration.validate(&config.tls)?; + config.logging.validate()?; + let desired_admin = config.administration.resolved_listen(policy.admin_addr); + // A persisted non-loopback address is only accepted with administration TLS. + // When `listen` is omitted, the startup/CLI address remains authoritative and + // its legacy plain-HTTP risk gate is enforced by main before compilation. + ensure!( + config.administration.listen.is_none() + || desired_admin.ip().is_loopback() + || config.administration.tls, + "a persisted remote administration listener requires administration TLS" + ); + let mut desired_policy = policy.clone(); + desired_policy.admin_addr = desired_admin; + config.listeners.validate(&desired_policy)?; + config.http2.validate()?; + config.docker_agents.validate(&config.listeners)?; + let certificate_ids: HashSet<_> = config + .tls + .certificates + .iter() + .map(|certificate| certificate.id.as_str()) + .collect(); + for agent in &config.docker_agents.agents { + if let Some(id) = agent.certificate_id.as_deref() { + ensure!( + certificate_ids.contains(id), + "Docker agent {} references unknown certificate id {}", + agent.id, + id + ); + } + } + config.prometheus.validate()?; + config.cache.validate()?; + config.transform.validate()?; + config.pages.validate()?; + config.compression.validate()?; + config.server_header.validate()?; + let forwarding = config.forwarding.compile()?; + ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)"); + let mut vhost_ids = HashSet::new(); + for host in &mut config.vhosts { + host.validate() + .with_context(|| format!("vhost {}", host.id))?; + ensure!(vhost_ids.insert(host.id.clone()), "duplicate vhost id"); + } + ensure!( + config.redirects.len() <= 64, + "too many redirect hosts (max 64)" + ); + let mut redirect_ids = HashSet::new(); + for redirect in &mut config.redirects { + redirect + .validate() + .with_context(|| format!("redirect {}", redirect.id))?; + ensure!( + redirect_ids.insert(redirect.id.clone()), + "duplicate redirect id" + ); + } + ensure!( + config.not_found_hosts.len() <= 64, + "too many 404 hosts (max 64)" + ); + let mut not_found_ids = HashSet::new(); + for host in &mut config.not_found_hosts { + host.validate() + .with_context(|| format!("404 host {}", host.id))?; + ensure!( + not_found_ids.insert(host.id.clone()), + "duplicate 404 host id" + ); + } + let effective_tls = crate::tls::for_all_frontends( + &config.tls, + &config.vhosts, + &config.redirects, + &config.not_found_hosts, + )?; + let tls = crate::tls::compile(&effective_tls, policy.cert_dir.as_deref())?; + let tls_http1 = tls + .as_ref() + .map(|base| crate::tls::variant(base, &effective_tls.policy, false)) + .transpose()?; + ensure!( + config + .vhosts + .iter() + .map(|h| h.backends.len()) + .sum::() + <= MAX_BACKENDS, + "too many backends across all pools (max 128)" + ); + ensure!( + config.vhosts.iter().map(|h| h.routes.len()).sum::() <= MAX_ROUTES, + "too many paths across all vhosts (max 256)" + ); + ensure!( + config + .redirects + .iter() + .map(|h| h.rules.len()) + .sum::() + <= MAX_ROUTES, + "too many rules across all redirect hosts (max 256)" + ); + ensure!( + serde_json::to_vec(&config)?.len() <= MAX_CONFIG_BYTES, + "configuration too large" + ); + let listeners = crate::vhost::listener_plan(&config, &desired_policy)?; + crate::stream::validate( + &mut config.streams, + &listeners, + desired_policy.admin_addr, + config + .prometheus + .enabled + .then_some(config.prometheus.listen), + )?; + if config.prometheus.enabled { + ensure!( + !crate::vhost::socket_overlap(config.prometheus.listen, desired_policy.admin_addr) + && !listeners + .iter() + .any(|l| crate::vhost::socket_overlap(config.prometheus.listen, l.address)), + "metrics: exporter address overlaps a proxy/administration listener" + ); + } + let mut guarded_bindings: Vec<_> = listeners.iter().map(|l| l.address).collect(); + if let Some(previous) = previous { + guarded_bindings.extend(previous.listeners.iter().map(|l| l.address)); + } + guarded_bindings.extend(active.iter().map(|l| l.address)); + // Startup/default addresses remain protected backend destinations even when + // the desired named-listener plan changes before a restart. + guarded_bindings.push(defaults.0); + guarded_bindings.extend(defaults.1); + guarded_bindings.extend([ + policy.proxy_addr, + policy.admin_addr, + desired_policy.admin_addr, + ]); + guarded_bindings.extend(policy.tls_addr); + if config.prometheus.enabled { + guarded_bindings.push(config.prometheus.listen); + } + if let Some(previous) = previous { + if previous.envelope.config.prometheus.enabled { + guarded_bindings.push(previous.envelope.config.prometheus.listen); + } + } + guarded_bindings.sort(); + guarded_bindings.dedup(); + let global_headers = config.headers.compile()?; + ensure!( + config.host_policies.len() <= 256, + "too many host ACL policies" + ); + let mut host_acls = HashMap::new(); + for policy in &mut config.host_policies { + ensure!( + !policy.hosts.is_empty(), + "host policy requires at least one hostname" + ); + let compiled = Arc::new(policy.acl.compile()?); + for host in &mut policy.hosts { + *host = normalize_host_pattern(host)?; + ensure!( + host_acls.insert(host.clone(), compiled.clone()).is_none(), + "duplicate host ACL policy" + ); + } + } + ensure!(config.acl_profiles.len() <= 64, "at most 64 ACL profiles"); + let mut acl_profiles: HashMap, crate::rate_limit::CountryAccess)> = + HashMap::new(); + for profile in &mut config.acl_profiles { + ensure!(valid_id(&profile.id), "invalid ACL profile id"); + ensure!( + !profile.name.trim().is_empty() + && profile.name.len() <= 96 + && !profile.name.chars().any(char::is_control), + "ACL profile name must be 1..96 bytes without controls" + ); + profile.name = profile.name.trim().to_owned(); + profile.country_access.validate()?; + let acl = profile.acl.as_ref().map(Acl::compile).transpose()?; + ensure!( + acl_profiles + .insert(profile.id.clone(), (acl, profile.country_access.clone())) + .is_none(), + "duplicate ACL profile id" + ); + } + for agent in &config.docker_agents.agents { + if let Some(profile_id) = agent.acl_profile_id.as_deref() { + ensure!( + acl_profiles.contains_key(profile_id), + "Docker agent {} references unknown default ACL profile {}", + agent.id, + profile_id + ); + } + for assignment in &agent.assignments { + if let Some(profile_id) = assignment.acl_profile_id.as_deref() { + ensure!( + acl_profiles.contains_key(profile_id), + "Docker assignment {} on agent {} references unknown ACL profile {}", + assignment.service_key, + agent.id, + profile_id + ); + } + } + } + let mut routes: HashMap> = HashMap::new(); + let mut fallbacks: HashMap> = HashMap::new(); + let mut frontends: HashMap>> = HashMap::new(); + let mut redirects: HashMap>> = HashMap::new(); + let mut not_found_hosts: HashMap>> = + HashMap::new(); + let mut claimed_hosts: HashMap> = HashMap::new(); + for redirect in &config.redirects { + let bindings = redirect.frontend.resolved(&config.listeners)?; + // Disabled redirect hosts reserve their names as vhosts do, preventing + // an overlapping fallback from unexpectedly taking traffic. + for name in &redirect.frontend.hosts { + let claimed = claimed_hosts.entry(name.clone()).or_default(); + for a in claimed.iter() { + for b in &bindings { + ensure!( + !crate::vhost::socket_overlap(a.address, b.address), + "overlapping frontend bindings for hostname {}", + name + ); + } + } + claimed.extend(bindings.iter().copied()); + } + if redirect.enabled { + let compiled = Arc::new(crate::redirect::compile(redirect, &config.listeners)?); + for name in &redirect.frontend.hosts { + redirects + .entry(name.clone()) + .or_default() + .push(compiled.clone()); + } + } + } + for host in &config.not_found_hosts { + let bindings = host.frontend.resolved(&config.listeners)?; + // Disabled local-response hosts reserve names too, avoiding accidental + // exposure through a broader wildcard vhost. + for name in &host.frontend.hosts { + let claimed = claimed_hosts.entry(name.clone()).or_default(); + for a in claimed.iter() { + for b in &bindings { + ensure!( + !crate::vhost::socket_overlap(a.address, b.address), + "overlapping frontend bindings for hostname {}", + name + ); + } + } + claimed.extend(bindings.iter().copied()); + } + if host.enabled { + let compiled = Arc::new(crate::not_found::compile(host, &config.listeners)?); + for name in &host.frontend.hosts { + not_found_hosts + .entry(name.clone()) + .or_default() + .push(compiled.clone()); + } + } + } + let mut pools = Vec::new(); + let mut vhost_metrics = std::collections::BTreeMap::new(); + for host in &mut config.vhosts { + let host_bindings = host.frontend.resolved(&config.listeners)?; + let tls_ports: HashSet = host_bindings + .iter() + .filter(|binding| binding.tls) + .map(|binding| binding.address.port()) + .collect(); + if host.websocket_rfc8441 { + ensure!( + host_bindings.iter().any(|binding| { + (binding.tls && binding.http2) || (!binding.tls && binding.h2c) + }), + "vhost {} RFC 8441 requires at least one selected HTTP/2-capable listener", + host.id + ); + } + if host.websocket_rfc9220 { + ensure!( + host_bindings + .iter() + .any(|binding| binding.tls && binding.http3.is_some()), + "vhost {} RFC 9220 requires at least one selected HTTP/3 listener", + host.id + ); + } + let https_redirect_port = if host.force_ssl { + ensure!( + host_bindings.iter().any(|binding| !binding.tls), + "vhost {} Force SSL requires at least one selected plain HTTP listener", + host.id + ); + ensure!( + !tls_ports.is_empty(), + "vhost {} Force SSL requires at least one selected TLS listener", + host.id + ); + if tls_ports.contains(&443) { + Some(443) + } else { + ensure!( + tls_ports.len() == 1, + "vhost {} Force SSL cannot choose an HTTPS target: select one TLS port or include port 443", + host.id + ); + tls_ports.iter().copied().next() + } + } else { + None + }; + let cookie = host + .load_balancing + .sticky + .cookie_name + .as_deref() + .unwrap_or(""); + ensure!( + !(cookie.starts_with("__Host-") || cookie.starts_with("__Secure-")) + || host_bindings.iter().all(|l| l.tls), + "prefixed affinity cookies require TLS on every resolved frontend" + ); + // Disabled vhosts also reserve their name, preventing fallback exposure. + for name in &host.frontend.hosts { + let claimed = claimed_hosts.entry(name.clone()).or_default(); + for a in claimed.iter() { + for b in &host_bindings { + ensure!( + !crate::vhost::socket_overlap(a.address, b.address), + "overlapping frontend bindings for hostname {}", + name + ); + } + } + claimed.extend(host_bindings.iter().copied()); + } + let mut compiled_members = Vec::new(); + for definition in &host.backends { + let mut backend = compile_backend_with_timeout( + &definition.backend(), + &desired_policy, + &guarded_bindings, + &host.load_balancing.keepalive, + host.proxy_timeouts + .connect_timeout() + .unwrap_or(std::time::Duration::from_secs(10)), + ) + .with_context(|| format!("vhost {} backend {}", host.id, definition.id))?; + backend.id = format!("{}/{}", host.id, definition.id); + compiled_members.push(Arc::new(backend)); + } + let mut pool = crate::balance::Pool::new( + host.id.clone(), + host.load_balancing.clone(), + &host.backends, + compiled_members, + )?; + if let Some(old) = previous.and_then(|s| s.pools.iter().find(|p| p.id == host.id)) { + pool.reuse_observations(old); + } + let pool = Arc::new(pool); + let metrics = previous + .and_then(|s| s.vhost_metrics.get(&host.id)) + .cloned() + .unwrap_or_else(|| Arc::new(crate::metrics::VhostMetrics::default())); + vhost_metrics.insert(host.id.clone(), metrics.clone()); + let tls_policy = if let Some(policy) = &host.tls_policy { + policy.validate_narrower_than(&effective_tls.policy)?; + policy.clone() + } else { + effective_tls.policy.clone() + }; + if host.http3_enabled + && host_bindings.iter().any(|binding| binding.http3.is_some()) + && tls_policy != effective_tls.policy + { + anyhow::bail!( + "vhost {} uses HTTP/3, so its TLS policy must match the global TLS policy; disable HTTP/3 to use a per-vhost TLS override", + host.id + ); + } + let frontend_tls = tls + .as_ref() + .map(|base| crate::tls::variant(base, &tls_policy, true)) + .transpose()?; + let frontend_tls_http1 = tls + .as_ref() + .map(|base| crate::tls::variant(base, &tls_policy, false)) + .transpose()?; + let (profile_acl, profile_country_access) = match host.acl_profile_id.as_deref() { + Some(id) => acl_profiles.get(id).cloned().ok_or_else(|| { + anyhow::anyhow!("vhost {} references unknown ACL profile {}", host.id, id) + })?, + None => (None, crate::rate_limit::CountryAccess::default()), + }; + let frontend = Arc::new(CompiledFrontend { + metrics, + websockets: host.websockets, + websocket_legacy_support: host.websocket_legacy_support, + websocket_rfc8441: host.websocket_rfc8441, + websocket_rfc9220: host.websocket_rfc9220, + force_ssl: host.force_ssl, + forward_client_ip: host.forward_client_ip, + https_redirect_port, + http2_enabled: host.http2_enabled, + http3_enabled: host.http3_enabled, + tls_policy, + tls: frontend_tls, + tls_http1: frontend_tls_http1, + cloudflare_client_ip: host.cloudflare_client_ip, + timing_headers: host.timing_headers, + backend_header: host.backend_header, + backend_timing_header: host.backend_timing_header, + rate_limit: host.rate_limit.clone(), + basic_auth: host.basic_auth.compile()?, + server_header: host + .server_header + .clone() + .unwrap_or_else(|| config.server_header.clone()), + diagnostic_headers: host + .diagnostic_headers + .clone() + .unwrap_or_else(|| config.diagnostic_headers.clone()), + tinyauth: host.tinyauth.compile(&desired_policy, &guarded_bindings)?, + id: host.id.clone(), + bindings: host_bindings, + acl_profile_id: host.acl_profile_id.clone(), + profile_acl, + profile_country_access, + acl: host.acl.as_ref().map(Acl::compile).transpose()?, + headers: host.headers.compile()?, + security: host.security_headers.compile()?, + cache: host.cache.resolve(&config.cache)?, + transform: host.transform.resolve(&config.transform)?, + pages: host.pages.clone(), + proxy_timeouts: host.proxy_timeouts.clone(), + }); + if host.enabled { + let fallback = CompiledRoute { + backend_id: None, + bindings: frontend.bindings.clone(), + frontend: Some(frontend.clone()), + pool: Some(pool.clone()), + cache: None, + id: format!("{}/main", host.id), + path_prefix: "/".into(), + match_type: crate::vhost::PathMatch::Prefix, + path_regex: None, + rewrite: crate::vhost::PathRewrite::default(), + response: None, + backend: pool.members[0].backend.clone(), + acl: None, + headers: crate::headers::CompiledHeaders::default(), + }; + for name in &host.frontend.hosts { + fallbacks + .entry(name.clone()) + .or_default() + .push(fallback.clone()); + } + } + let mut path_ids = HashSet::new(); + let mut path_bindings = HashSet::new(); + for route in &mut host.routes { + ensure!( + valid_id(&route.id) && path_ids.insert(route.id.clone()), + "invalid/duplicate vhost path id" + ); + let path_regex = match route.match_type { + crate::vhost::PathMatch::Regex | crate::vhost::PathMatch::RegexInsensitive => { + ensure!( + !route.path_prefix.is_empty() + && route.path_prefix.len() <= 1024 + && !route.path_prefix.chars().any(char::is_control), + "invalid vhost location regex" + ); + let mut builder = regex::RegexBuilder::new(&route.path_prefix); + builder + .case_insensitive( + route.match_type == crate::vhost::PathMatch::RegexInsensitive, + ) + .size_limit(2 * 1024 * 1024) + .dfa_size_limit(2 * 1024 * 1024); + Some(builder.build().context("invalid vhost location regex")?) + } + crate::vhost::PathMatch::Exact => { + ensure!( + !route.path_prefix.contains('?') + && !route.path_prefix.contains('#') + && !route.path_prefix.bytes().any(|b| b <= 32 || b == 127), + "invalid exact location path" + ); + route.path_prefix = crate::acl::routing_path(&route.path_prefix) + .context("invalid exact location path")?; + None + } + crate::vhost::PathMatch::Prefix | crate::vhost::PathMatch::PrefixPriority => { + normalize_prefix(&mut route.path_prefix)?; + None + } + }; + ensure!( + path_bindings.insert((route.match_type, route.path_prefix.clone())), + "duplicate vhost location matcher" + ); + validate_route_rewrite(route, path_regex.as_ref())?; + validate_location_response(route.response.as_ref())?; + let acl = route.acl.as_ref().map(Acl::compile).transpose()?; + let headers = route.headers.compile()?; + if let Some(id) = &route.backend_id { + ensure!( + host.backends.iter().any(|b| &b.id == id), + "vhost path references unknown backend {}", + id + ); + } + if host.enabled && route.enabled { + let compiled = CompiledRoute { + backend_id: route.backend_id.clone(), + bindings: frontend.bindings.clone(), + frontend: Some(frontend.clone()), + pool: Some(pool.clone()), + cache: route.cache, + id: format!("{}/{}", host.id, route.id), + path_prefix: route.path_prefix.clone(), + match_type: route.match_type, + path_regex, + rewrite: route.rewrite.clone(), + response: route.response.clone(), + backend: pool.members[0].backend.clone(), + acl, + headers, + }; + for name in &host.frontend.hosts { + routes + .entry(name.clone()) + .or_default() + .push(compiled.clone()); + } + } + } + if host.enabled { + for name in &host.frontend.hosts { + frontends + .entry(name.clone()) + .or_default() + .push(frontend.clone()); + } + pools.push(pool); + } + } + let quic_tls = tls + .as_ref() + .map(|tls| crate::http3::crypto(tls, &effective_tls.policy)) + .transpose()?; + ensure!( + !listeners.iter().any(|l| l.http3.is_some()) || quic_tls.is_some(), + "HTTP/3 requires available TLS certificates" + ); + ensure!( + !listeners.iter().any(|l| l.http3.is_some()) || effective_tls.policy.allows_http3(), + "HTTP/3 listeners require TLS 1.3 and a TLS 1.3 cipher suite in the global TLS policy" + ); + Ok(Snapshot { + vhost_metrics, + forwarding, + quic_tls, + listeners, + pools, + tls, + tls_http1, + frontends, + envelope: Envelope { revision, config }, + routes, + fallbacks, + redirects, + not_found_hosts, + host_acls, + headers: global_headers, + }) +} +fn normalize_prefix(prefix: &mut String) -> Result<()> { + ensure!( + prefix.starts_with('/') + && prefix.len() <= 512 + && !prefix.contains('?') + && !prefix.contains('#') + && !prefix.bytes().any(|b| b <= 32 || b == 127), + "invalid path prefix" + ); + if prefix.len() > 1 { + *prefix = prefix.trim_end_matches('/').to_owned(); + ensure!(!prefix.is_empty(), "invalid path prefix"); + } + *prefix = crate::acl::routing_path(prefix)?; + Ok(()) +} + +fn validate_route_rewrite( + route: &crate::vhost::PathRoute, + regex: Option<®ex::Regex>, +) -> Result<()> { + use crate::vhost::{PathMatch, RewriteMode}; + ensure!( + route.rewrite.value.len() <= 2048, + "location rewrite value is too long" + ); + ensure!( + !route.rewrite.value.chars().any(char::is_control), + "location rewrite contains a control character" + ); + match route.rewrite.mode { + RewriteMode::Preserve => ensure!( + route.rewrite.value.is_empty(), + "preserve rewrite has no value" + ), + RewriteMode::StripPrefix => ensure!( + matches!( + route.match_type, + PathMatch::Prefix | PathMatch::PrefixPriority | PathMatch::Exact + ), + "strip-prefix rewrite requires a prefix/exact location" + ), + RewriteMode::ReplacePrefix => { + ensure!( + matches!( + route.match_type, + PathMatch::Prefix | PathMatch::PrefixPriority | PathMatch::Exact + ), + "replace-prefix rewrite requires a prefix/exact location" + ); + let mut replacement = route.rewrite.value.clone(); + normalize_prefix(&mut replacement).context("invalid location replacement prefix")?; + } + RewriteMode::Regex => ensure!(regex.is_some(), "regex rewrite requires a regex location"), + } + Ok(()) +} + +fn validate_location_response(response: Option<&crate::vhost::LocationResponse>) -> Result<()> { + let Some(response) = response else { + return Ok(()); + }; + ensure!( + (200..=599).contains(&response.status), + "location response status must be 200..599" + ); + ensure!( + response.body.len() <= 65_536, + "location response body is too large" + ); + ensure!( + !response.content_type.is_empty() + && response.content_type.len() <= 256 + && http::HeaderValue::from_str(&response.content_type).is_ok(), + "invalid location response content type" + ); + Ok(()) +} +pub fn demo_config(addr: SocketAddr) -> Config { + let mut host: crate::vhost::Vhost = serde_json::from_value(serde_json::json!({ + "id":"demo", "frontend":{"hosts":["localhost","127.0.0.1","demo.localhost"], + "listener_ids":["http"]}, + "backends":[{"id":"demo", "url":format!("http://{addr}"), "allow_private":true}], + "load_balancing":{"health_check":{"mode":"off"}}, "routes":[{"id":"root"}] + })) + .expect("static vhost demo"); + host.load_balancing.health_check.mode = crate::balance::HealthMode::Off; + Config { + listeners: crate::settings::Listeners::from_startup( + "127.0.0.1:8080".parse().expect("static demo listener"), + None, + ), + vhosts: vec![host], + ..Config::default() + } +} +#[cfg(test)] +mod tests { + use super::*; + fn policy() -> ValidationPolicy { + ValidationPolicy { + tls_addr: None, + cert_dir: None, + data_dir: None, + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + } + } + #[test] + fn missing_http2_settings_use_release_defaults() { + let config: Config = serde_json::from_value(serde_json::json!({})).unwrap(); + assert_eq!(config.http2, crate::settings::Http2Connections::default()); + config.http2.validate().unwrap(); + } + + #[test] + fn normalized_unicode_prefix_survives_serialization_and_recompile() { + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + config.vhosts[0].routes[0].path_prefix = "/caf%C3%A9".into(); + let first = compile(config, 0, &policy()).unwrap(); + let saved = serde_json::to_string(&first.envelope.config).unwrap(); + let second = compile(serde_json::from_str(&saved).unwrap(), 1, &policy()).unwrap(); + assert!(second.select("localhost", "/caf\u{e9}/hello").is_some()); + } + #[test] + fn host_normalization() { + assert_eq!(request_host("EXAMPLE.com.:8080").unwrap(), "example.com"); + for invalid in [ + "", + "a..b", + "*.test", + "a/b", + "user@host", + "-bad.test", + "a:99999", + ] { + assert!(request_host(invalid).is_err(), "{invalid}"); + } + } + #[test] + fn wildcard_host_patterns_are_normalized_and_exact_hosts_win() { + assert_eq!( + normalize_host_pattern("*.Example.TEST.").unwrap(), + "*.example.test" + ); + for invalid in ["*", "foo.*.test", "*.com", "*.*.example.test"] { + assert!(normalize_host_pattern(invalid).is_err(), "{invalid}"); + } + + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].id = "wildcard".into(); + c.vhosts[0].frontend.hosts = vec!["*.example.test".into()]; + let mut exact = c.vhosts[0].clone(); + exact.id = "exact".into(); + exact.frontend.hosts = vec!["api.example.test".into()]; + c.vhosts.push(exact); + let snapshot = compile(c, 1, &policy()).unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + assert_eq!( + snapshot + .select_on("api.example.test", "/", local, false) + .unwrap() + .id, + "exact/main" + ); + assert_eq!( + snapshot + .select_on("www.example.test", "/", local, false) + .unwrap() + .id, + "wildcard/main" + ); + } + #[test] + fn reusable_acl_profile_reference_is_compiled_and_unknown_reference_fails() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.acl_profiles = serde_json::from_value(serde_json::json!([{ + "id":"office", "name":"Office", + "acl":{"default":"deny","rules":[{"network":"10.0.0.0/8","action":"allow"}]}, + "country_access":{"mode":"block","countries":["RU"]} + }])) + .unwrap(); + c.vhosts[0].acl_profile_id = Some("office".into()); + let snapshot = compile(c.clone(), 1, &policy()).unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + let frontend = snapshot.frontend("localhost", local, false).unwrap(); + assert_eq!(frontend.acl_profile_id.as_deref(), Some("office")); + assert!(frontend + .profile_acl + .as_ref() + .unwrap() + .allows("10.2.3.4".parse().unwrap())); + assert!(!frontend + .profile_acl + .as_ref() + .unwrap() + .allows("192.0.2.1".parse().unwrap())); + assert!(!frontend.profile_country_access.allows(Some("RU"))); + + c.vhosts[0].acl_profile_id = Some("missing".into()); + assert!(compile(c, 2, &policy()).is_err()); + } + + #[test] + fn rfc8441_is_advertised_only_on_opted_in_h2_capable_listener() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].websockets = true; + c.vhosts[0].websocket_rfc8441 = true; + let error = compile(c.clone(), 1, &policy()).unwrap_err().to_string(); + assert!(error.contains("HTTP/2-capable listener"), "{error}"); + + c.listeners.endpoints[0].binding.h2c = true; + let snapshot = compile(c.clone(), 2, &policy()).unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + assert!(snapshot.websocket_rfc8441_on(local, false)); + assert!(!snapshot.websocket_rfc8441_on(local, true)); + + c.vhosts[0].websocket_rfc8441 = false; + let snapshot = compile(c, 3, &policy()).unwrap(); + assert!(!snapshot.websocket_rfc8441_on(local, false)); + } + #[test] + fn force_ssl_requires_plain_http_and_chooses_https_target() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].force_ssl = true; + let error = compile(c.clone(), 1, &policy()).unwrap_err().to_string(); + assert!( + error.contains("at least one selected TLS listener"), + "{error}" + ); + + c.listeners.endpoints.push(crate::settings::NamedListener { + id: "https-a".into(), + name: "HTTPS A".into(), + scope: crate::settings::ListenerScope::Internal, + enabled: true, + binding: crate::vhost::Listener { + address: "127.0.0.1:8443".parse().unwrap(), + tls: true, + http2: true, + http2_settings: Some(crate::settings::Http2Connections::default()), + h2c: false, + http3: None, + }, + additional_addresses: vec![], + }); + c.listeners.endpoints.push(crate::settings::NamedListener { + id: "https-b".into(), + name: "HTTPS B".into(), + scope: crate::settings::ListenerScope::Internal, + enabled: true, + binding: crate::vhost::Listener { + address: "127.0.0.1:9443".parse().unwrap(), + tls: true, + http2: true, + http2_settings: Some(crate::settings::Http2Connections::default()), + h2c: false, + http3: None, + }, + additional_addresses: vec![], + }); + c.vhosts[0].frontend.listener_ids = vec!["http".into(), "https-a".into(), "https-b".into()]; + let error = compile(c.clone(), 2, &policy()).unwrap_err().to_string(); + assert!(error.contains("cannot choose an HTTPS target"), "{error}"); + + c.listeners + .endpoints + .iter_mut() + .find(|item| item.id == "https-b") + .unwrap() + .binding + .address = "127.0.0.1:443".parse().unwrap(); + let snapshot = compile(c.clone(), 3, &policy()).unwrap(); + let frontend = snapshot + .frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false) + .unwrap(); + assert_eq!(frontend.https_redirect_port, Some(443)); + + c.vhosts[0].frontend.listener_ids = vec!["https-a".into(), "https-b".into()]; + let error = compile(c, 4, &policy()).unwrap_err().to_string(); + assert!(error.contains("plain HTTP listener"), "{error}"); + } + #[test] + fn backend_tls_server_name_keeps_network_origin_and_changes_verified_request_name() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into(); + c.vhosts[0].backends[0].allow_private = true; + c.vhosts[0].backends[0].tls_server_name = Some("Backend.Unitra.Internal.".into()); + let s = compile(c, 1, &policy()).unwrap(); + let backend = &s.pools[0].members[0].backend; + assert_eq!(backend.origin, "https://10.87.20.2:443"); + assert_eq!( + backend.request_origin, + "https://backend.unitra.internal:443" + ); + } + #[test] + fn tls_server_name_is_https_dns_only() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].backends[0].tls_server_name = Some("backend.internal".into()); + assert!(compile(c.clone(), 1, &policy()).is_err()); + c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into(); + c.vhosts[0].backends[0].tls_server_name = Some("10.87.20.2".into()); + assert!(compile(c, 1, &policy()).is_err()); + } + #[test] + fn backend_tls_verification_defaults_off_and_can_be_enabled_explicitly() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + assert!(!c.vhosts[0].backends[0].tls_verify); + assert!(compile(c.clone(), 1, &policy()).is_ok()); + + c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into(); + c.vhosts[0].backends[0].allow_private = true; + c.vhosts[0].backends[0].tls_ca_pem = Some("not a certificate".into()); + assert!(compile(c.clone(), 1, &policy()).is_ok()); + + c.vhosts[0].backends[0].tls_verify = true; + assert!(compile(c, 1, &policy()).is_err()); + } + #[test] + fn redirect_hosts_share_listener_routing_without_opening_vhost_routes() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.redirects = serde_json::from_value(serde_json::json!([{ + "id":"legacy","name":"Legacy","frontend":{"hosts":["old.localhost"],"listener_ids":["http"]}, + "rules":[{"id":"root","target":"https://new.localhost/","status_code":308}] + }])).unwrap(); + let snapshot = compile(c, 1, &policy()).unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + let (_host, rule) = snapshot + .redirect_on("old.localhost", "/a", local, false) + .unwrap(); + assert_eq!(rule.id, "root"); + assert!(snapshot + .select_on("old.localhost", "/a", local, false) + .is_none()); + } + + #[test] + fn custom_locations_override_but_main_vhost_remains_fallback() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + // The demo document still contains the pre-0.16 synthetic root route. + // Validation must normalize it away and keep the main backend internally. + let mut custom = c.vhosts[0].routes[0].clone(); + custom.id = "test".into(); + custom.path_prefix = "/test".into(); + custom.response = Some(crate::vhost::LocationResponse { + status: 200, + content_type: "text/plain".into(), + body: "ok".into(), + }); + c.vhosts[0].routes.push(custom); + let snapshot = compile(c, 1, &policy()).unwrap(); + + assert_eq!( + snapshot.select("localhost", "/test").unwrap().id, + "demo/test" + ); + assert_eq!( + snapshot.select("localhost", "/test/a").unwrap().id, + "demo/test" + ); + assert_eq!(snapshot.select("localhost", "/").unwrap().id, "demo/main"); + assert_eq!( + snapshot.select("localhost", "/abc").unwrap().id, + "demo/main" + ); + assert_eq!( + snapshot.select("localhost", "/static/a.js").unwrap().id, + "demo/main" + ); + assert!(snapshot.envelope.config.vhosts[0] + .routes + .iter() + .all(|route| !route.is_legacy_main_fallback())); + } + + #[test] + fn deliberate_root_custom_location_can_override_main_fallback() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + let root = &mut c.vhosts[0].routes[0]; + root.response = Some(crate::vhost::LocationResponse { + status: 204, + content_type: "text/plain".into(), + body: String::new(), + }); + let snapshot = compile(c, 1, &policy()).unwrap(); + assert_eq!( + snapshot.select("localhost", "/anything").unwrap().id, + "demo/root" + ); + } + + #[test] + fn direct_admin_listener_backend_is_marked_for_guarded_control_plane_use() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.administration.listen = Some("0.0.0.0:9090".parse().unwrap()); + c.administration.tls = true; + c.vhosts[0].backends[0].url = "https://127.0.0.1:9090".into(); + c.vhosts[0].backends[0].allow_private = true; + c.vhosts[0].backends[0].accept_local_listener_risk = true; + let snapshot = compile(c, 1, &policy()).unwrap(); + assert!(snapshot.pools[0].members[0].backend.targets_admin_listener); + } + + #[test] + fn backend_diagnostic_flags_compile_to_frontend() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].backend_header = true; + c.vhosts[0].backend_timing_header = true; + let snapshot = compile(c, 1, &policy()).unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + let frontend = snapshot.frontend("localhost", local, false).unwrap(); + assert!(frontend.backend_header); + assert!(frontend.backend_timing_header); + } + + #[test] + fn path_boundaries() { + assert!(prefix_matches("/api", "/api")); + assert!(prefix_matches("/api", "/api/users")); + assert!(!prefix_matches("/api", "/apix")); + assert!(prefix_matches("/", "/anything")); + } + #[test] + fn private_requires_opt_in_and_metadata_always_denied() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + assert!(compile(c.clone(), 1, &policy()).is_ok()); + c.vhosts[0].backends[0].allow_private = false; + assert!(compile(c.clone(), 1, &policy()).is_err()); + c.vhosts[0].backends[0].allow_private = true; + for target in [ + "http://169.254.169.254", + "http://100.100.100.200", + "http://0.0.0.0", + "http://127.0.0.1:9090", + "http://[::ffff:169.254.169.254]", + "http://[fe80::1]", + "http://user:pass@127.0.0.1:9000", + ] { + c.vhosts[0].backends[0].url = target.into(); + assert!(compile(c.clone(), 1, &policy()).is_err(), "{target}"); + } + c.vhosts[0].backends[0].url = "http://127.0.0.1:9090".into(); + c.vhosts[0].backends[0].accept_local_listener_risk = true; + assert!(compile(c.clone(), 1, &policy()).is_ok()); + c.vhosts[0].backends[0].allow_private = false; + assert!(compile(c, 1, &policy()).is_err()); + } + #[test] + fn longest_prefix_and_aliases() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + let mut specific = c.vhosts[0].routes[0].clone(); + specific.id = "api".into(); + specific.path_prefix = "/api".into(); + c.vhosts[0].routes.push(specific); + let s = compile(c, 7, &policy()).unwrap(); + assert_eq!(s.select("localhost", "/api/x").unwrap().id, "demo/api"); + assert_eq!(s.select("demo.localhost", "/apix").unwrap().id, "demo/main"); + assert!(s.select("unknown", "/").is_none()); + assert_eq!(s.envelope.revision, 7); + } + #[test] + fn nginx_style_location_precedence_exact_priority_regex_prefix() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].routes[0].id = "root".into(); + + let mut prefix = c.vhosts[0].routes[0].clone(); + prefix.id = "api-prefix".into(); + prefix.path_prefix = "/api".into(); + + let mut regex = c.vhosts[0].routes[0].clone(); + regex.id = "api-regex".into(); + regex.match_type = crate::vhost::PathMatch::Regex; + regex.path_prefix = r"^/api/v[0-9]+/".into(); + + let mut exact = c.vhosts[0].routes[0].clone(); + exact.id = "api-exact".into(); + exact.match_type = crate::vhost::PathMatch::Exact; + exact.path_prefix = "/api/v1/fixed".into(); + + let mut trailing_exact = c.vhosts[0].routes[0].clone(); + trailing_exact.id = "trailing-exact".into(); + trailing_exact.match_type = crate::vhost::PathMatch::Exact; + trailing_exact.path_prefix = "/trailing/".into(); + + c.vhosts[0] + .routes + .extend([prefix.clone(), regex, exact, trailing_exact]); + let snapshot = compile(c.clone(), 1, &policy()).unwrap(); + assert_eq!( + snapshot.select("localhost", "/api/v1/fixed").unwrap().id, + "demo/api-exact" + ); + assert_eq!( + snapshot.select("localhost", "/api/v2/users").unwrap().id, + "demo/api-regex" + ); + assert_eq!( + snapshot.select("localhost", "/api/plain").unwrap().id, + "demo/api-prefix" + ); + assert_eq!( + snapshot.select("localhost", "/trailing/").unwrap().id, + "demo/trailing-exact" + ); + assert_eq!( + snapshot.select("localhost", "/trailing").unwrap().id, + "demo/main" + ); + + c.vhosts[0].routes.retain(|route| route.id != "api-prefix"); + prefix.match_type = crate::vhost::PathMatch::PrefixPriority; + prefix.id = "api-priority".into(); + c.vhosts[0].routes.push(prefix); + let snapshot = compile(c, 2, &policy()).unwrap(); + assert_eq!( + snapshot.select("localhost", "/api/v2/users").unwrap().id, + "demo/api-priority" + ); + } + #[test] + fn location_rewrites_are_normalized_and_regex_captures_expand() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + let route = &mut c.vhosts[0].routes[0]; + route.match_type = crate::vhost::PathMatch::Regex; + route.path_prefix = r"^/api/v([0-9]+)(/.*)$".into(); + route.rewrite = crate::vhost::PathRewrite { + mode: crate::vhost::RewriteMode::Regex, + value: "/service/${1}${2}".into(), + }; + let snapshot = compile(c, 1, &policy()).unwrap(); + let selected = snapshot.select("localhost", "/api/v42/items").unwrap(); + assert_eq!( + selected + .rewritten_path("/api/v42/items") + .unwrap() + .as_deref(), + Some("/service/42/items") + ); + assert_eq!( + selected + .rewritten_path("/api/v42/Ubuntu 14.04 LTS") + .unwrap() + .as_deref(), + Some("/service/42/Ubuntu 14.04 LTS") + ); + } + #[test] + fn custom_location_response_is_validated() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].routes[0].response = Some(crate::vhost::LocationResponse { + status: 200, + content_type: "application/json".into(), + body: r#"{"ok":true}"#.into(), + }); + assert!(compile(c.clone(), 1, &policy()).is_ok()); + c.vhosts[0].routes[0].response.as_mut().unwrap().status = 199; + assert!(compile(c.clone(), 1, &policy()).is_err()); + c.vhosts[0].routes[0].response.as_mut().unwrap().status = 200; + c.vhosts[0].routes[0] + .response + .as_mut() + .unwrap() + .content_type = "text/plain\r\nx-bad: 1".into(); + assert!(compile(c, 1, &policy()).is_err()); + } + #[test] + fn local_response_hosts_reserve_frontends_and_do_not_require_backends() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.not_found_hosts = serde_json::from_value(serde_json::json!([{ + "id":"missing", + "name":"Missing site", + "frontend":{"hosts":["missing.localhost"],"listener_ids":["http"]}, + "page":{"kind":"html","status":404,"html":"

Not found

"} + }])) + .unwrap(); + let snapshot = compile(c.clone(), 1, &policy()).unwrap(); + let local: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + let host = snapshot + .not_found_host_on("missing.localhost", local, false) + .unwrap(); + assert_eq!(host.id, "missing"); + assert_eq!(host.page.status, Some(404)); + + c.not_found_hosts[0].frontend.hosts = vec!["localhost".into()]; + assert!(compile(c, 2, &policy()).is_err()); + } + + #[test] + fn conflicts_are_rejected() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + let mut duplicate = c.vhosts[0].routes[0].clone(); + duplicate.id = "other".into(); + c.vhosts[0].routes.push(duplicate); + assert!(compile(c, 0, &policy()).is_err()); + } + #[test] + fn tls12_only_vhost_override_is_valid_without_http3_listener() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].tls_policy = Some(crate::tls::ProtocolPolicy { + tls12: true, + tls13: false, + cipher_suites: vec![], + }); + assert!(compile(c, 1, &policy()).is_ok()); + } + + #[test] + fn persisted_remote_administration_requires_tls() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.administration.listen = Some("192.0.2.10:9090".parse().unwrap()); + assert!(compile(c.clone(), 1, &policy()).is_err()); + c.administration.tls = true; + assert!(compile(c, 1, &policy()).is_ok()); + } + #[test] + fn legacy_startup_remote_administration_is_left_to_cli_risk_gate() { + let mut p = policy(); + p.admin_addr = "0.0.0.0:9090".parse().unwrap(); + let c = demo_config("127.0.0.1:9000".parse().unwrap()); + assert!(c.administration.listen.is_none()); + assert!(compile(c, 1, &p).is_ok()); + } +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..f583f93 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,560 @@ +//! SQLite stores desired configuration, administrator state and bounded audit history. +//! No database call is made from the public HTTP forwarding path. +use crate::config::{Config, Envelope}; +use anyhow::{ensure, Context, Result}; +use serde::Serialize; +use sqlx::{ + sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}, + Row, SqliteConnection, SqlitePool, +}; +use std::{path::Path, time::Duration}; + +pub const SCHEMA_VERSION: i64 = 6; +pub const CERTIFICATE_API_CREDENTIAL_CAPACITY: i64 = 32; +pub const AUDIT_CAPACITY: i64 = 10_000; + +#[derive(Clone)] +pub struct Store { + pub(crate) pool: SqlitePool, +} +#[derive(Debug, Clone, Serialize)] +pub struct CertificateApiCredential { + pub key_id: String, + pub comment: String, + pub created_at: i64, + pub last_used_at: Option, +} + +#[derive(Debug, Serialize)] +pub struct AuditEntry { + pub id: i64, + pub time: i64, + pub actor: String, + pub action: String, + pub detail: String, +} +pub(crate) async fn audit_tx( + connection: &mut SqliteConnection, + actor: &str, + action: &str, + detail: &str, +) -> Result<()> { + // Callers only supply allowlisted actions and non-secret, bounded metadata. + sqlx::query("INSERT INTO admin_audit(time, actor, action, detail) VALUES(?, ?, ?, ?)") + .bind(crate::auth::now()) + .bind(actor) + .bind(action) + .bind(detail) + .execute(&mut *connection) + .await?; + sqlx::query( + "DELETE FROM admin_audit WHERE id <= (SELECT COALESCE(MAX(id), 0) - ? FROM admin_audit)", + ) + .bind(AUDIT_CAPACITY) + .execute(&mut *connection) + .await?; + Ok(()) +} +impl Store { + pub async fn open(path: &Path, initial: &Config) -> Result { + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Full) + .busy_timeout(Duration::from_secs(5)) + .foreign_keys(true); + let pool = SqlitePoolOptions::new() + .max_connections(4) + .connect_with(options) + .await?; + let version: i64 = sqlx::query_scalar("PRAGMA user_version") + .fetch_one(&pool) + .await?; + ensure!( + version <= SCHEMA_VERSION, + "database schema is newer than this binary; refusing downgrade" + ); + let mut tx = pool.begin().await?; + sqlx::raw_sql(include_str!("../migrations/0002_admin.sql")) + .execute(&mut *tx) + .await?; + sqlx::raw_sql(include_str!("../migrations/0003_certificate_renewals.sql")) + .execute(&mut *tx) + .await?; + sqlx::raw_sql(include_str!("../migrations/0004_admin_preferences.sql")) + .execute(&mut *tx) + .await?; + sqlx::raw_sql(include_str!("../migrations/0005_certificate_api.sql")) + .execute(&mut *tx) + .await?; + sqlx::raw_sql(include_str!("../migrations/0006_certificate_api_credentials.sql")) + .execute(&mut *tx) + .await?; + sqlx::query("INSERT OR IGNORE INTO configuration(id, revision, document) VALUES(1, 0, ?)") + .bind(serde_json::to_string(initial)?) + .execute(&mut *tx) + .await?; + sqlx::query("PRAGMA user_version = 6") + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(Self { pool }) + } + pub async fn load(&self) -> Result { + let row = sqlx::query("SELECT revision, document FROM configuration WHERE id = 1") + .fetch_one(&self.pool) + .await?; + let revision: i64 = row.try_get("revision")?; + let document: String = row.try_get("document")?; + ensure!( + document.len() <= crate::config::MAX_CONFIG_BYTES, + "stored configuration exceeds size limit" + ); + Ok(Envelope { + revision: revision.try_into()?, + config: serde_json::from_str(&document) + .context("invalid stored configuration; database was not reset")?, + }) + } + pub async fn replace(&self, expected: u64, config: &Config) -> Result { + self.replace_as(expected, config, "local").await + } + /// Compare-and-swap and audit are one transaction. Publication is handled by App. + pub async fn replace_as(&self, expected: u64, config: &Config, actor: &str) -> Result { + let expected: i64 = expected.try_into().context("revision overflow")?; + ensure!(expected < i64::MAX, "revision overflow"); + let mut tx = self.pool.begin().await?; + let result = sqlx::query("UPDATE configuration SET revision = revision + 1, document = ? WHERE id = 1 AND revision = ?") + .bind(serde_json::to_string(config)?).bind(expected).execute(&mut *tx).await?; + if result.rows_affected() == 0 { + return Ok(false); + } + audit_tx( + &mut tx, + actor, + "config.apply", + &format!("revision={}", expected + 1), + ) + .await?; + tx.commit().await?; + Ok(true) + } + pub async fn audit(&self, actor: &str, action: &str, detail: &str) -> Result<()> { + let mut tx = self.pool.begin().await?; + audit_tx(&mut tx, actor, action, detail).await?; + tx.commit().await?; + Ok(()) + } + pub async fn load_certificate_renewals( + &self, + ) -> Result> { + let rows = sqlx::query( + "SELECT certificate_id, last_checked, next_check, last_renewed, not_after, failures, status, message FROM certificate_renewals ORDER BY certificate_id", + ) + .fetch_all(&self.pool) + .await?; + let mut result = std::collections::BTreeMap::new(); + for row in rows { + let certificate_id: String = row.try_get("certificate_id")?; + if !crate::config::valid_id(&certificate_id) { + continue; + } + let failures: i64 = row.try_get("failures")?; + result.insert( + certificate_id, + crate::acme::RenewalState { + last_checked: row.try_get("last_checked")?, + next_check: row.try_get("next_check")?, + last_renewed: row.try_get("last_renewed")?, + not_after: row.try_get("not_after")?, + failures: failures.max(0).min(u32::MAX as i64) as u32, + status: row.try_get("status")?, + message: row.try_get("message")?, + }, + ); + } + Ok(result) + } + pub async fn replace_certificate_renewals( + &self, + values: &std::collections::BTreeMap, + ) -> Result<()> { + ensure!( + values.len() <= 64, + "too many certificate renewal observations" + ); + let mut tx = self.pool.begin().await?; + sqlx::query("DELETE FROM certificate_renewals") + .execute(&mut *tx) + .await?; + for (certificate_id, state) in values { + ensure!( + crate::config::valid_id(certificate_id), + "invalid certificate renewal id" + ); + sqlx::query( + "INSERT INTO certificate_renewals(certificate_id, last_checked, next_check, last_renewed, not_after, failures, status, message) VALUES(?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(certificate_id) + .bind(state.last_checked) + .bind(state.next_check) + .bind(state.last_renewed) + .bind(state.not_after) + .bind(i64::from(state.failures)) + .bind(&state.status) + .bind(&state.message) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(()) + } + + pub async fn certificate_api_credentials(&self) -> Result> { + let rows = sqlx::query( + "SELECT key_id, comment, created_at, last_used_at FROM certificate_api_credentials ORDER BY key_id", + ) + .fetch_all(&self.pool) + .await?; + rows.into_iter() + .map(|row| { + Ok(CertificateApiCredential { + key_id: row.try_get("key_id")?, + comment: row.try_get("comment")?, + created_at: row.try_get("created_at")?, + last_used_at: row.try_get("last_used_at")?, + }) + }) + .collect() + } + + pub async fn upsert_certificate_api_credential( + &self, + key_id: &str, + token_hash: &str, + comment: &str, + actor: &str, + ) -> Result { + ensure!( + (3..=64).contains(&key_id.len()) && crate::config::valid_id(key_id), + "invalid certificate API key id" + ); + ensure!( + token_hash.len() == 64 && token_hash.bytes().all(|b| b.is_ascii_hexdigit()), + "invalid certificate API token hash" + ); + ensure!( + comment.len() <= 256 && !comment.chars().any(char::is_control), + "invalid certificate API comment" + ); + let now = crate::auth::now(); + let mut tx = self.pool.begin().await?; + let exists: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM certificate_api_credentials WHERE key_id = ?", + ) + .bind(key_id) + .fetch_one(&mut *tx) + .await?; + if exists == 0 { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM certificate_api_credentials") + .fetch_one(&mut *tx) + .await?; + ensure!( + count < CERTIFICATE_API_CREDENTIAL_CAPACITY, + "certificate API credential limit reached" + ); + } + sqlx::query( + "INSERT INTO certificate_api_credentials(key_id, token_hash, comment, created_at, last_used_at) VALUES(?, ?, ?, ?, NULL) \ + ON CONFLICT(key_id) DO UPDATE SET token_hash=excluded.token_hash, comment=excluded.comment, created_at=excluded.created_at, last_used_at=NULL", + ) + .bind(key_id) + .bind(token_hash) + .bind(comment) + .bind(now) + .execute(&mut *tx) + .await?; + audit_tx( + &mut tx, + actor, + "certificate_api.rotate", + &format!("key_id={key_id}"), + ) + .await?; + tx.commit().await?; + Ok(CertificateApiCredential { + key_id: key_id.to_owned(), + comment: comment.to_owned(), + created_at: now, + last_used_at: None, + }) + } + + pub async fn revoke_certificate_api_credential(&self, key_id: &str, actor: &str) -> Result { + ensure!( + (3..=64).contains(&key_id.len()) && crate::config::valid_id(key_id), + "invalid certificate API key id" + ); + let mut tx = self.pool.begin().await?; + let result = sqlx::query("DELETE FROM certificate_api_credentials WHERE key_id = ?") + .bind(key_id) + .execute(&mut *tx) + .await?; + if result.rows_affected() > 0 { + audit_tx( + &mut tx, + actor, + "certificate_api.revoke", + &format!("key_id={key_id}"), + ) + .await?; + } + tx.commit().await?; + Ok(result.rows_affected() > 0) + } + + pub async fn revoke_all_certificate_api_credentials(&self, actor: &str) -> Result { + let mut tx = self.pool.begin().await?; + let result = sqlx::query("DELETE FROM certificate_api_credentials") + .execute(&mut *tx) + .await?; + if result.rows_affected() > 0 { + audit_tx( + &mut tx, + actor, + "certificate_api.revoke_all", + &format!("count={}", result.rows_affected()), + ) + .await?; + } + tx.commit().await?; + Ok(result.rows_affected()) + } + + pub async fn verify_certificate_api_credential(&self, key_id: &str, token_hash: &str) -> Result { + let row = sqlx::query( + "SELECT token_hash FROM certificate_api_credentials WHERE key_id = ?", + ) + .bind(key_id) + .fetch_optional(&self.pool) + .await?; + let Some(row) = row else { return Ok(false); }; + let expected: String = row.try_get("token_hash")?; + if !crate::auth::equal_secret(token_hash, &expected) { + return Ok(false); + } + sqlx::query( + "UPDATE certificate_api_credentials SET last_used_at = ? WHERE key_id = ?", + ) + .bind(crate::auth::now()) + .bind(key_id) + .execute(&self.pool) + .await?; + Ok(true) + } + + pub async fn recent_audit(&self) -> Result> { + let rows = sqlx::query( + "SELECT id, time, actor, action, detail FROM admin_audit ORDER BY id DESC LIMIT 100", + ) + .fetch_all(&self.pool) + .await?; + rows.into_iter() + .map(|row| { + Ok(AuditEntry { + id: row.try_get("id")?, + time: row.try_get("time")?, + actor: row.try_get("actor")?, + action: row.try_get("action")?, + detail: row.try_get("detail")?, + }) + }) + .collect() + } + pub async fn close(&self) { + self.pool.close().await; + } +} +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn persists_and_rejects_stale_writes() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("test.sqlite3"); + let store = Store::open(&file, &Config::default()).await.unwrap(); + assert_eq!(store.load().await.unwrap().revision, 0); + let config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); + assert!(store.replace(0, &config).await.unwrap()); + assert!(!store.replace(0, &Config::default()).await.unwrap()); + assert_eq!(store.recent_audit().await.unwrap().len(), 1); + store.close().await; + let reopened = Store::open(&file, &Config::default()).await.unwrap(); + let data = reopened.load().await.unwrap(); + assert_eq!(data.revision, 1); + assert_eq!(data.config.vhosts[0].backends[0].id, "demo"); + reopened.close().await; + } + #[tokio::test] + async fn certificate_renewal_observations_survive_reopen_in_schema_v3() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("renewals.sqlite3"); + let store = Store::open(&file, &Config::default()).await.unwrap(); + let mut values = std::collections::BTreeMap::new(); + values.insert( + "example-cert".to_string(), + crate::acme::RenewalState { + last_checked: 100, + next_check: 200, + last_renewed: Some(50), + not_after: Some(500), + failures: 2, + status: "retry".to_string(), + message: "temporary CA failure".to_string(), + }, + ); + store.replace_certificate_renewals(&values).await.unwrap(); + let version: i64 = sqlx::query_scalar("PRAGMA user_version") + .fetch_one(&store.pool) + .await + .unwrap(); + assert_eq!(version, SCHEMA_VERSION); + store.close().await; + + let reopened = Store::open(&file, &Config::default()).await.unwrap(); + let saved = reopened.load_certificate_renewals().await.unwrap(); + let state = saved.get("example-cert").unwrap(); + assert_eq!(state.last_checked, 100); + assert_eq!(state.next_check, 200); + assert_eq!(state.last_renewed, Some(50)); + assert_eq!(state.not_after, Some(500)); + assert_eq!(state.failures, 2); + assert_eq!(state.status, "retry"); + assert_eq!(state.message, "temporary CA failure"); + reopened.close().await; + } + + #[tokio::test] + async fn certificate_api_credentials_persist_hash_comments_and_individual_usage() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("certificate-api.sqlite3"); + let store = Store::open(&file, &Config::default()).await.unwrap(); + let token = "b".repeat(64); + let hash = crate::auth::secret_hash(&token); + let saved = store + .upsert_certificate_api_credential( + "certbot-main", + &hash, + "production certbot", + "owner", + ) + .await + .unwrap(); + assert_eq!(saved.key_id, "certbot-main"); + assert_eq!(saved.comment, "production certbot"); + assert!(saved.last_used_at.is_none()); + + let raw: String = sqlx::query_scalar( + "SELECT token_hash FROM certificate_api_credentials WHERE key_id = ?", + ) + .bind("certbot-main") + .fetch_one(&store.pool) + .await + .unwrap(); + assert_eq!(raw, hash); + assert_ne!(raw, token); + + let second_hash = crate::auth::secret_hash(&"d".repeat(64)); + store + .upsert_certificate_api_credential( + "deploy-hook", + &second_hash, + "secondary deploy hook", + "owner", + ) + .await + .unwrap(); + assert_eq!(store.certificate_api_credentials().await.unwrap().len(), 2); + + assert!(store + .verify_certificate_api_credential("certbot-main", &hash) + .await + .unwrap()); + assert!(!store + .verify_certificate_api_credential( + "certbot-main", + &crate::auth::secret_hash(&"c".repeat(64)), + ) + .await + .unwrap()); + let credentials = store.certificate_api_credentials().await.unwrap(); + assert!(credentials + .iter() + .find(|item| item.key_id == "certbot-main") + .unwrap() + .last_used_at + .is_some()); + assert!(credentials + .iter() + .find(|item| item.key_id == "deploy-hook") + .unwrap() + .last_used_at + .is_none()); + + assert!(store + .revoke_certificate_api_credential("certbot-main", "owner") + .await + .unwrap()); + let remaining = store.certificate_api_credentials().await.unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].key_id, "deploy-hook"); + assert_eq!(store.revoke_all_certificate_api_credentials("owner").await.unwrap(), 1); + assert!(store.certificate_api_credentials().await.unwrap().is_empty()); + store.close().await; + } + + #[tokio::test] + async fn existing_empty_config_is_not_reseeded() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("test.sqlite3"); + let store = Store::open(&file, &Config::default()).await.unwrap(); + store.close().await; + let demo = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); + let store = Store::open(&file, &demo).await.unwrap(); + assert!(store.load().await.unwrap().config.vhosts.is_empty()); + store.close().await; + } + #[tokio::test] + async fn migration_keeps_v1_config_and_revision() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("old.sqlite3"); + let options = SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .unwrap(); + sqlx::query("CREATE TABLE configuration(id INTEGER PRIMARY KEY, revision INTEGER NOT NULL, document TEXT NOT NULL)") + .execute(&pool).await.unwrap(); + let demo = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); + sqlx::query("INSERT INTO configuration VALUES(1, 42, ?)") + .bind(serde_json::to_string(&demo).unwrap()) + .execute(&pool) + .await + .unwrap(); + sqlx::query("PRAGMA user_version = 1") + .execute(&pool) + .await + .unwrap(); + pool.close().await; + let store = Store::open(&path, &Config::default()).await.unwrap(); + let saved = store.load().await.unwrap(); + assert_eq!(saved.revision, 42); + assert_eq!(saved.config.vhosts[0].backends[0].id, "demo"); + assert!(store.admin_record().await.unwrap().is_none()); + store.close().await; + } +} diff --git a/src/demo.rs b/src/demo.rs new file mode 100644 index 0000000..a1f5e7e --- /dev/null +++ b/src/demo.rs @@ -0,0 +1,108 @@ +//! Separate opt-in HTTP backend, not a shortcut inside the proxy handler. +use axum::{ + body::Body, + extract::Request, + response::{Html, IntoResponse, Response}, + routing::{any, get}, + Json, Router, +}; +use bytes::Bytes; +use http::HeaderMap; +use serde_json::json; +use std::{convert::Infallible, time::Duration}; +pub fn router() -> Router { + Router::new() + .route( + "/", + get(|| async { Html(include_str!("admin/web/demo.html")) }), + ) + .route("/ws", get(websocket)) + .route("/cache-demo", get(cache_demo)) + .route("/inspect", any(inspect)) + .route("/echo", any(echo)) + .route("/stream", get(stream)) + .fallback(|| async { + Json(json!({"backend":"demo","message":"Request reached the separate demo backend."})) + }) +} +async fn inspect(request: Request) -> Json { + let headers = request.headers(); + let read = |name: &str| { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_owned() + }; + Json( + json!({"backend":"demo", "method":request.method().as_str(), "path":request.uri().path(), + "query":request.uri().query(), "http_version":format!("{:?}", request.version()), "host":read("host"), "forwarded_for":read("x-forwarded-for"), + "forwarded_proto":read("x-forwarded-proto"), "request_id":read("x-request-id")}), + ) +} +async fn echo(request: Request) -> Response { + let content_type = request.headers().get("content-type").cloned(); + let mut response = request.into_body().into_response(); + if let Some(value) = content_type { + response.headers_mut().insert("content-type", value); + } + response +} +async fn stream() -> (HeaderMap, Body) { + let stream = futures_util::stream::unfold(0u32, |index| async move { + if index >= 8 { + return None; + } + tokio::time::sleep(Duration::from_millis(300)).await; + Some(( + Ok::<_, Infallible>(Bytes::from(format!( + "chunk={} backend=demo streaming=true\n", + index + 1 + ))), + index + 1, + )) + }); + let mut headers = HeaderMap::new(); + headers.insert("content-type", "text/plain; charset=utf-8".parse().unwrap()); + headers.insert("cache-control", "no-store".parse().unwrap()); + (headers, Body::from_stream(stream)) +} + +async fn websocket(ws: axum::extract::ws::WebSocketUpgrade) -> Response { + use axum::extract::ws::Message; + ws.max_message_size(65_536) + .max_frame_size(65_536) + .on_upgrade(|mut socket| async move { + while let Some(Ok(message)) = socket.recv().await { + let reply = match message { + Message::Text(_) | Message::Binary(_) => message, + Message::Ping(data) => Message::Pong(data), + Message::Pong(_) => continue, + Message::Close(frame) => { + let _ = socket.send(Message::Close(frame)).await; + break; + } + }; + if socket.send(reply).await.is_err() { + break; + } + } + }) +} + +/// Explicitly public demo object for manual cache/compression checks; no request secrets. +async fn cache_demo() -> Response { + let now = crate::logging::now_ms(); + let text = format!( + "RustProxy public cache demo. Origin generation time: {now} ms\n{}", + "This is an anonymous demo object, fresh for 30 seconds.\n".repeat(100) + ); + ( + [ + ("cache-control", "public, max-age=30"), + ("content-type", "text/plain; charset=utf-8"), + ], + text, + ) + .into_response() +} diff --git a/src/diagnostics.rs b/src/diagnostics.rs new file mode 100644 index 0000000..4af6f47 --- /dev/null +++ b/src/diagnostics.rs @@ -0,0 +1,149 @@ +//! Low-frequency OS sampling, off the request path. Unknown values stay null. +use crate::system_stats::{self, Counters, View}; +use std::{ + sync::{Arc, RwLock}, + time::Duration, +}; + +#[derive(Default)] +pub struct Diagnostics { + latest: RwLock, +} +impl Diagnostics { + pub fn view(&self) -> View { + self.latest + .read() + .unwrap_or_else(|e| e.into_inner()) + .clone() + } + pub fn start(stop: tokio_util::sync::CancellationToken) -> Arc { + let this = Arc::new(Self::default()); + let sampler = this.clone(); + tokio::spawn(async move { + let mut previous: Option<(View, Counters)> = None; + loop { + let sample = tokio::task::spawn_blocking(move || system_stats::sample(previous)).await; + if let Ok((view, counters)) = sample { + *sampler.latest.write().unwrap_or_else(|e| e.into_inner()) = view.clone(); + previous = Some((view, counters)); + } else { + previous = None; + } + tokio::select! { _ = stop.cancelled() => break, _ = tokio::time::sleep(Duration::from_secs(2)) => {} } + } + }); + this + } + + pub fn prometheus(&self) -> String { + let v = self.view(); + let mut text = String::new(); + text.push_str(&format!( + "# TYPE rust_proxy_diagnostics_source_info gauge\nrust_proxy_diagnostics_source_info{{source=\"{}\"}} 1\n", + v.source + )); + text.push_str(&format!( + "# TYPE rust_proxy_platform_info gauge\nrust_proxy_platform_info{{platform=\"{}\"}} 1\n", + v.platform + )); + let mut gauge = |name: &str, value: Option| { + if let Some(value) = value { + text.push_str(&format!( + "# TYPE rust_proxy_{name} gauge\nrust_proxy_{name} {value}\n" + )); + } + }; + gauge( + "diagnostics_sampled_at_seconds", + Some(v.sampled_at_ms as f64 / 1000.0), + ); + gauge("system_cpu_percent", v.cpu_percent); + gauge("process_cpu_percent", v.process_cpu_percent); + gauge("system_logical_cpus", v.logical_cpus.map(|v| v as f64)); + gauge( + "system_memory_total_bytes", + v.memory_total_bytes.map(|v| v as f64), + ); + gauge( + "system_memory_used_bytes", + v.memory_used_bytes.map(|v| v as f64), + ); + gauge( + "system_swap_total_bytes", + v.system_swap_total_bytes.map(|v| v as f64), + ); + gauge( + "system_swap_used_bytes", + v.system_swap_used_bytes.map(|v| v as f64), + ); + gauge( + "system_uptime_seconds", + v.system_uptime_seconds.map(|v| v as f64), + ); + gauge("process_rss_bytes", v.process_rss_bytes.map(|v| v as f64)); + gauge( + "process_virtual_memory_bytes", + v.process_virtual_memory_bytes.map(|v| v as f64), + ); + gauge( + "process_virtual_memory_max_bytes", + v.process_virtual_memory_max_bytes.map(|v| v as f64), + ); + gauge( + "process_start_time_seconds", + v.process_start_time_seconds.map(|v| v as f64), + ); + gauge( + "process_rss_anon_bytes", + v.process_rss_anon_bytes.map(|v| v as f64), + ); + gauge( + "process_rss_file_bytes", + v.process_rss_file_bytes.map(|v| v as f64), + ); + gauge( + "process_rss_shmem_bytes", + v.process_rss_shmem_bytes.map(|v| v as f64), + ); + gauge("process_data_bytes", v.process_data_bytes.map(|v| v as f64)); + gauge("process_swap_bytes", v.process_swap_bytes.map(|v| v as f64)); + gauge("process_open_fds", v.open_fds.map(|v| v as f64)); + gauge("process_max_fds", v.max_fds.map(|v| v as f64)); + gauge("process_threads", v.process_threads.map(|v| v as f64)); + if let Some(load) = v.load_average { + gauge("system_load1", Some(load[0])); + gauge("system_load5", Some(load[1])); + gauge("system_load15", Some(load[2])); + } + gauge( + "system_network_rx_bytes", + v.network_rx_bytes.map(|v| v as f64), + ); + gauge( + "system_network_tx_bytes", + v.network_tx_bytes.map(|v| v as f64), + ); + gauge( + "system_network_rx_bytes_per_second", + v.network_rx_bytes_per_second, + ); + gauge( + "system_network_tx_bytes_per_second", + v.network_tx_bytes_per_second, + ); + gauge("process_read_bytes", v.process_read_bytes.map(|v| v as f64)); + gauge( + "process_write_bytes", + v.process_write_bytes.map(|v| v as f64), + ); + gauge( + "cgroup_memory_current_bytes", + v.cgroup_memory_current_bytes.map(|v| v as f64), + ); + gauge( + "cgroup_memory_max_bytes", + v.cgroup_memory_max_bytes.map(|v| v as f64), + ); + text + } +} diff --git a/src/docker.rs b/src/docker.rs new file mode 100644 index 0000000..554b674 --- /dev/null +++ b/src/docker.rs @@ -0,0 +1,1344 @@ +//! Optional Docker host discovery through the standalone RustProxy Docker Agent. +//! Desired assignments are persisted in the normal configuration; observations +//! stay in memory and are reconciled into ephemeral vhosts without SQLite writes. +use anyhow::{ensure, Context, Result}; +use axum::body::Body; +use futures_util::future::join_all; +use http::{header::AUTHORIZATION, Request, Uri}; +use http_body_util::{BodyExt, Limited}; +use hyper_rustls::HttpsConnectorBuilder; +use hyper_util::{ + client::legacy::{connect::HttpConnector, Client}, + rt::TokioExecutor, +}; +use rustls::{ + client::{ + danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, + WebPkiServerVerifier, + }, + pki_types::{CertificateDer, ServerName, UnixTime}, + ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, HashSet}, + net::IpAddr, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio::sync::RwLock; +use url::Url; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_INVENTORY_BYTES: usize = 4 * 1024 * 1024; +const MAX_AGENTS: usize = 16; +const MAX_ASSIGNMENTS: usize = 128; +const MAX_SERVICES: usize = 512; +const MAX_ADDRESSES: usize = 64; +const MAX_BINDINGS: usize = 32; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + pub agents: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Agent { + pub id: String, + pub name: String, + pub enabled: bool, + pub endpoint: String, + pub token: String, + pub tls_fingerprint_sha256: Option, + pub base_domain: String, + pub poll_interval_seconds: u64, + pub certificate_id: Option, + pub acl_profile_id: Option, + #[serde(alias = "target_ip_override")] + pub default_target_ip: Option, + pub listener_ids: Vec, + pub assignments: Vec, +} +impl Default for Agent { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + enabled: true, + endpoint: String::new(), + token: String::new(), + tls_fingerprint_sha256: None, + base_domain: String::new(), + poll_interval_seconds: 30, + certificate_id: None, + acl_profile_id: None, + default_target_ip: None, + listener_ids: Vec::new(), + assignments: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum BackendScheme { + #[default] + Http, + Https, +} +impl BackendScheme { + fn as_str(self) -> &'static str { + match self { + Self::Http => "http", + Self::Https => "https", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Assignment { + pub service_key: String, + #[serde(alias = "subdomain")] + pub hostname: String, + pub aliases: Vec, + pub target_ip: Option, + pub enabled: bool, + pub scheme: BackendScheme, + pub preserve_host: bool, + /// None preserves the pre-0.18.8 behavior for assignments saved before + /// the per-app redirect switch existed. New UI assignments persist true. + pub force_ssl: Option, + pub websockets: bool, + pub cache_enabled: bool, + pub transform_enabled: bool, + pub acl_profile_id: Option, + pub acl: Option, + pub proxy_timeouts: crate::vhost::ProxyTimeouts, + pub health_check: crate::balance::HealthCheck, + pub passive_health: crate::balance::PassiveHealth, +} +impl Default for Assignment { + fn default() -> Self { + Self { + service_key: String::new(), + hostname: String::new(), + aliases: Vec::new(), + target_ip: None, + enabled: true, + scheme: BackendScheme::Http, + preserve_host: true, + force_ssl: None, + websockets: true, + cache_enabled: false, + transform_enabled: false, + acl_profile_id: None, + acl: None, + proxy_timeouts: crate::vhost::ProxyTimeouts::default(), + health_check: crate::balance::HealthCheck::default(), + passive_health: crate::balance::PassiveHealth::default(), + } + } +} + +fn usable_target_ip(ip: IpAddr) -> Result<()> { + crate::config::ip_policy(ip, true).context("Docker target IP is not usable as a backend") +} + +fn expand_hostname(value: &str, base_domain: &str) -> Result { + let value = value.trim().trim_end_matches('.').to_ascii_lowercase(); + ensure!(!value.is_empty(), "Docker hostname cannot be empty"); + if value.contains('.') { + crate::config::normalize_host(&value).context("invalid Docker hostname") + } else { + crate::config::normalize_host(&format!("{value}.{base_domain}")) + .context("invalid Docker hostname") + } +} + +pub(crate) fn assignment_hosts(assignment: &Assignment, base_domain: &str) -> Result> { + let mut out = Vec::with_capacity(1 + assignment.aliases.len()); + let mut seen = HashSet::new(); + for raw in std::iter::once(&assignment.hostname).chain(assignment.aliases.iter()) { + let host = expand_hostname(raw, base_domain)?; + ensure!( + seen.insert(host.clone()), + "duplicate Docker hostname/alias {host}" + ); + out.push(host); + } + Ok(out) +} + +fn normalize_fingerprint(value: &str) -> Result { + let compact: String = value + .chars() + .filter(|c| *c != ':' && !c.is_ascii_whitespace()) + .collect::() + .to_ascii_lowercase(); + ensure!( + compact.len() == 64 && compact.bytes().all(|b| b.is_ascii_hexdigit()), + "Docker agent TLS fingerprint must be a SHA-256 hex value" + ); + Ok(compact) +} + +fn fingerprint_bytes(value: &str) -> Result<[u8; 32]> { + let value = normalize_fingerprint(value)?; + let mut out = [0u8; 32]; + for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() { + let hi = (chunk[0] as char) + .to_digit(16) + .context("invalid TLS fingerprint")? as u8; + let lo = (chunk[1] as char) + .to_digit(16) + .context("invalid TLS fingerprint")? as u8; + out[index] = (hi << 4) | lo; + } + Ok(out) +} + +impl Settings { + pub fn validate(&mut self, listeners: &crate::settings::Listeners) -> Result<()> { + ensure!( + self.agents.len() <= MAX_AGENTS, + "at most {MAX_AGENTS} Docker agents" + ); + let listener_ids: HashSet<_> = listeners + .endpoints + .iter() + .map(|item| item.id.as_str()) + .collect(); + let mut ids = HashSet::new(); + let mut all_hostnames = HashSet::new(); + let mut total_assignments = 0usize; + for agent in &mut self.agents { + ensure!( + crate::config::valid_id(&agent.id) && ids.insert(agent.id.clone()), + "invalid/duplicate Docker agent id" + ); + ensure!( + !agent.name.trim().is_empty() + && agent.name.len() <= 96 + && !agent.name.chars().any(char::is_control), + "Docker agent name must contain 1..96 bytes without controls" + ); + agent.base_domain = crate::config::normalize_host(&agent.base_domain) + .context("invalid Docker agent base domain")?; + ensure!( + agent.base_domain.contains('.'), + "Docker agent base domain must be a DNS suffix such as docker1.lab.local" + ); + ensure!( + (5..=3600).contains(&agent.poll_interval_seconds), + "Docker agent poll interval must be 5..=3600 seconds" + ); + ensure!( + agent.token.len() >= 24 + && agent.token.len() <= 512 + && !agent.token.chars().any(char::is_control), + "Docker agent token must contain 24..512 non-control characters" + ); + let endpoint = Url::parse(&agent.endpoint).context("invalid Docker agent endpoint")?; + ensure!( + matches!(endpoint.scheme(), "http" | "https"), + "Docker agent endpoint must use http or https" + ); + ensure!( + endpoint.username().is_empty() && endpoint.password().is_none(), + "Docker agent endpoint credentials are forbidden; use the token field" + ); + ensure!( + endpoint.query().is_none() && endpoint.fragment().is_none(), + "Docker agent endpoint must not contain query/fragment" + ); + ensure!( + endpoint.path().is_empty() || endpoint.path() == "/", + "Docker agent endpoint must not contain a path" + ); + ensure!( + endpoint.host().is_some(), + "Docker agent endpoint host is missing" + ); + if let Some(fingerprint) = agent.tls_fingerprint_sha256.clone() { + ensure!( + endpoint.scheme() == "https", + "Docker agent TLS fingerprint requires an https endpoint" + ); + agent.tls_fingerprint_sha256 = Some(normalize_fingerprint(&fingerprint)?); + } + if let Some(ip) = agent.default_target_ip { + usable_target_ip(ip).context("invalid Docker agent default target IP")?; + } + if let Some(profile_id) = agent.acl_profile_id.as_deref() { + ensure!( + crate::config::valid_id(profile_id), + "invalid Docker agent ACL profile id" + ); + } + ensure!( + agent.listener_ids.len() <= 16, + "Docker agent allows at most 16 listener references" + ); + ensure!( + !agent.listener_ids.is_empty(), + "Docker agent requires at least one listener" + ); + let mut agent_listeners = HashSet::new(); + for id in &agent.listener_ids { + ensure!( + agent_listeners.insert(id), + "duplicate Docker agent listener reference" + ); + ensure!( + listener_ids.contains(id.as_str()), + "Docker agent references unknown listener {id}" + ); + } + total_assignments = total_assignments.saturating_add(agent.assignments.len()); + let mut service_keys = HashSet::new(); + for assignment in &mut agent.assignments { + ensure!( + !assignment.service_key.is_empty() + && assignment.service_key.len() <= 256 + && !assignment.service_key.chars().any(char::is_control), + "invalid Docker service key" + ); + ensure!( + service_keys.insert(assignment.service_key.clone()), + "duplicate Docker service assignment" + ); + if let Some(ip) = assignment.target_ip { + usable_target_ip(ip).context("invalid Docker assignment target IP")?; + } + if let Some(profile_id) = assignment.acl_profile_id.as_deref() { + ensure!( + crate::config::valid_id(profile_id), + "invalid Docker assignment ACL profile id" + ); + } + if assignment.force_ssl == Some(true) { + let mut has_http = false; + let mut tls_ports = HashSet::new(); + for endpoint in listeners.endpoints.iter().filter(|endpoint| { + endpoint.enabled && agent.listener_ids.iter().any(|id| id == &endpoint.id) + }) { + for binding in endpoint.bindings() { + if binding.tls { + tls_ports.insert(binding.address.port()); + } else { + has_http = true; + } + } + } + ensure!(has_http, "Docker assignment {} Force SSL requires at least one selected plain HTTP listener", assignment.service_key); + ensure!(!tls_ports.is_empty(), "Docker assignment {} Force SSL requires at least one selected TLS listener", assignment.service_key); + ensure!(tls_ports.contains(&443) || tls_ports.len() == 1, "Docker assignment {} Force SSL cannot choose an HTTPS target: select one TLS port or include port 443", assignment.service_key); + } + if let Some(acl) = &assignment.acl { + acl.compile().context("invalid Docker assignment ACL")?; + } + assignment + .proxy_timeouts + .validate() + .context("invalid Docker assignment proxy timeouts")?; + crate::balance::Settings { + health_check: assignment.health_check.clone(), + passive_health: assignment.passive_health.clone(), + ..Default::default() + } + .validate() + .context("invalid Docker assignment health check")?; + let hosts = assignment_hosts(assignment, &agent.base_domain)?; + assignment.hostname = if assignment.hostname.contains('.') { + hosts[0].clone() + } else { + assignment.hostname.trim().to_ascii_lowercase() + }; + assignment.aliases = assignment + .aliases + .iter() + .map(|alias| alias.trim().trim_end_matches('.').to_ascii_lowercase()) + .collect(); + for host in hosts { + ensure!( + all_hostnames.insert(host.clone()), + "Docker hostname/alias is assigned more than once: {host}" + ); + } + } + } + ensure!( + total_assignments <= MAX_ASSIGNMENTS, + "at most {MAX_ASSIGNMENTS} Docker assignments" + ); + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Binding { + pub bind_ip: String, + pub public_port: u16, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Service { + pub key: String, + pub technical_name: String, + pub container_id: String, + pub container_name: String, + pub image: String, + pub private_port: u16, + pub protocol: String, + #[serde(default)] + pub bindings: Vec, + // Compatibility with the first 0.18 Docker agent during rolling upgrades. + #[serde(default, skip_serializing)] + pub public_port: u16, + #[serde(default, skip_serializing)] + pub bind_ip: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HostAddress { + pub ip: String, + pub source: String, + #[serde(default)] + pub interface: String, + #[serde(default)] + pub scope: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Inventory { + pub agent_version: String, + pub hostname: String, + #[serde(default)] + pub public_ip: String, + #[serde(default)] + pub public_ip_source: String, + #[serde(default)] + pub certificate_sha256: String, + #[serde(default)] + pub addresses: Vec, + pub observed_at: String, + pub services: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Observation { + pub agent_id: String, + pub ok: bool, + pub checked_at_unix_ms: u64, + pub error: Option, + pub inventory: Option, +} + +#[derive(Default)] +pub struct Manager { + observations: RwLock>, +} + +impl Manager { + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + pub async fn view(&self) -> Vec { + self.observations.read().await.values().cloned().collect() + } + + pub async fn snapshot(&self) -> BTreeMap { + self.observations.read().await.clone() + } + + pub async fn refresh(&self, settings: &Settings, force: bool) -> (Vec, bool) { + let now = now_ms(); + let snapshot = self.observations.read().await.clone(); + let due: Vec<_> = settings + .agents + .iter() + .filter(|agent| agent.enabled) + .filter(|agent| { + force + || snapshot.get(&agent.id).map_or(true, |observation| { + now.saturating_sub(observation.checked_at_unix_ms) + >= agent.poll_interval_seconds.saturating_mul(1000) + }) + }) + .cloned() + .collect(); + let futures = due.into_iter().map(|agent| async move { + let checked_at_unix_ms = now_ms(); + match fetch(&agent).await { + Ok(inventory) => Observation { + agent_id: agent.id, + ok: true, + checked_at_unix_ms, + error: None, + inventory: Some(inventory), + }, + Err(error) => Observation { + agent_id: agent.id, + ok: false, + checked_at_unix_ms, + error: Some(format!("{error:#}")), + inventory: None, + }, + } + }); + let refreshed = join_all(futures).await; + let enabled: HashSet<_> = settings + .agents + .iter() + .filter(|agent| agent.enabled) + .map(|agent| agent.id.clone()) + .collect(); + let mut state = self.observations.write().await; + let before_len = state.len(); + state.retain(|id, _| enabled.contains(id)); + let mut changed = state.len() != before_len || !refreshed.is_empty(); + for observation in refreshed { + state.insert(observation.agent_id.clone(), observation); + changed = true; + } + (state.values().cloned().collect(), changed) + } +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u64::MAX as u128) as u64 +} + +fn public_roots() -> RootCertStore { + let mut roots = RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + roots +} + +#[derive(Debug)] +struct PinnedCertificateVerifier { + expected: [u8; 32], + signature_verifier: Arc, +} +impl ServerCertVerifier for PinnedCertificateVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> std::result::Result { + let actual = Sha256::digest(end_entity.as_ref()); + if actual[..] != self.expected[..] { + return Err(rustls::Error::General( + "Docker agent TLS certificate fingerprint mismatch".into(), + )); + } + Ok(ServerCertVerified::assertion()) + } + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> std::result::Result { + self.signature_verifier + .verify_tls12_signature(message, cert, dss) + } + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> std::result::Result { + self.signature_verifier + .verify_tls13_signature(message, cert, dss) + } + fn supported_verify_schemes(&self) -> Vec { + self.signature_verifier.supported_verify_schemes() + } +} + +fn client(agent: &Agent) -> Result, Body>> { + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let roots = public_roots(); + let mut config = ClientConfig::builder_with_provider(provider.clone()) + .with_safe_default_protocol_versions()? + .with_root_certificates(roots) + .with_no_client_auth(); + if let Some(fingerprint) = agent.tls_fingerprint_sha256.as_deref() { + let signature_verifier = + WebPkiServerVerifier::builder_with_provider(Arc::new(public_roots()), provider) + .build()?; + config + .dangerous() + .set_certificate_verifier(Arc::new(PinnedCertificateVerifier { + expected: fingerprint_bytes(fingerprint)?, + signature_verifier, + })); + } + let mut http = HttpConnector::new(); + http.enforce_http(false); + http.set_connect_timeout(Some(REQUEST_TIMEOUT)); + http.set_nodelay(true); + let connector = HttpsConnectorBuilder::new() + .with_tls_config(config) + .https_or_http() + .enable_http1() + .wrap_connector(http); + Ok(Client::builder(TokioExecutor::new()).build(connector)) +} + +async fn fetch(agent: &Agent) -> Result { + let base = agent.endpoint.trim_end_matches('/'); + let uri: Uri = format!("{base}/v1/inventory") + .parse() + .context("invalid Docker inventory URI")?; + let request = Request::builder() + .method(http::Method::GET) + .uri(uri) + .header(AUTHORIZATION, format!("Bearer {}", agent.token)) + .header("accept", "application/json") + .body(Body::empty())?; + let response = tokio::time::timeout(REQUEST_TIMEOUT, client(agent)?.request(request)) + .await + .context("Docker agent request timed out")??; + ensure!( + response.status().is_success(), + "Docker agent returned {}", + response.status() + ); + let bytes = Limited::new(response.into_body(), MAX_INVENTORY_BYTES) + .collect() + .await + .map_err(|error| anyhow::anyhow!("Docker agent body error: {error}"))? + .to_bytes(); + let mut inventory: Inventory = + serde_json::from_slice(&bytes).context("invalid Docker agent inventory")?; + normalize_inventory(&mut inventory); + validate_inventory(&inventory)?; + if let Some(expected) = agent.tls_fingerprint_sha256.as_deref() { + ensure!(inventory.certificate_sha256 == expected, "Docker agent inventory certificate fingerprint does not match the pinned TLS certificate"); + } + Ok(inventory) +} + +fn normalize_inventory(inventory: &mut Inventory) { + for service in &mut inventory.services { + if service.bindings.is_empty() && service.public_port > 0 { + service.bindings.push(Binding { + bind_ip: service.bind_ip.clone(), + public_port: service.public_port, + }); + } + service.public_port = 0; + service.bind_ip.clear(); + } +} + +fn service_bindings(service: &Service) -> Vec { + if !service.bindings.is_empty() { + return service.bindings.clone(); + } + if service.public_port > 0 { + return vec![Binding { + bind_ip: service.bind_ip.clone(), + public_port: service.public_port, + }]; + } + Vec::new() +} + +fn validate_inventory(inventory: &Inventory) -> Result<()> { + ensure!( + inventory.agent_version.len() <= 64, + "Docker agent version is too long" + ); + ensure!( + inventory.hostname.len() <= 255 && !inventory.hostname.chars().any(char::is_control), + "invalid Docker agent hostname" + ); + ensure!( + inventory.public_ip_source.len() <= 64 + && !inventory.public_ip_source.chars().any(char::is_control), + "invalid Docker public IP source" + ); + if !inventory.certificate_sha256.is_empty() { + normalize_fingerprint(&inventory.certificate_sha256) + .context("invalid Docker agent certificate fingerprint")?; + } + ensure!( + inventory.observed_at.len() <= 64 && !inventory.observed_at.chars().any(char::is_control), + "invalid Docker observation timestamp" + ); + if !inventory.public_ip.is_empty() { + let ip = inventory + .public_ip + .parse::() + .context("Docker agent returned invalid primary IP")?; + ensure!( + !ip.is_unspecified() && !ip.is_loopback() && !ip.is_multicast(), + "Docker agent returned unusable primary IP" + ); + } + ensure!( + inventory.addresses.len() <= MAX_ADDRESSES, + "Docker agent returned too many host addresses" + ); + let mut addresses = HashSet::new(); + for address in &inventory.addresses { + let ip = address + .ip + .parse::() + .context("Docker agent returned invalid host address")?; + ensure!( + !ip.is_unspecified() && !ip.is_loopback() && !ip.is_multicast(), + "Docker agent returned unusable host address" + ); + ensure!( + addresses.insert(ip), + "Docker agent returned duplicate host address" + ); + ensure!( + address.source.len() <= 96 + && address.interface.len() <= 64 + && address.scope.len() <= 32, + "Docker host address metadata is too long" + ); + ensure!( + !address.source.chars().any(char::is_control) + && !address.interface.chars().any(char::is_control) + && !address.scope.chars().any(char::is_control), + "invalid Docker host address metadata" + ); + } + ensure!( + inventory.services.len() <= MAX_SERVICES, + "Docker agent returned too many services" + ); + let mut keys = HashSet::new(); + for service in &inventory.services { + ensure!( + !service.key.is_empty() + && service.key.len() <= 256 + && !service.key.chars().any(char::is_control) + && keys.insert(service.key.clone()), + "invalid/duplicate Docker service key" + ); + ensure!( + !service.technical_name.is_empty() + && service.technical_name.len() <= 63 + && !service.technical_name.contains('.'), + "invalid Docker technical name" + ); + crate::config::normalize_host(&service.technical_name) + .context("invalid Docker technical name")?; + ensure!( + service.private_port > 0 && service.protocol == "tcp", + "invalid Docker published port" + ); + ensure!( + service.container_id.len() <= 128 + && service.container_name.len() <= 255 + && service.image.len() <= 512, + "Docker service metadata is too long" + ); + let bindings = service_bindings(service); + ensure!( + !bindings.is_empty() && bindings.len() <= MAX_BINDINGS, + "Docker service returned invalid binding count" + ); + let mut seen = HashSet::new(); + for binding in bindings { + ensure!( + binding.public_port > 0, + "Docker service returned invalid public port" + ); + if !binding.bind_ip.is_empty() { + let ip = binding + .bind_ip + .parse::() + .context("Docker agent returned invalid bind IP")?; + ensure!( + !ip.is_loopback() && !ip.is_multicast(), + "Docker agent returned unusable bind IP" + ); + } + ensure!( + seen.insert((binding.bind_ip, binding.public_port)), + "Docker service returned duplicate binding" + ); + } + } + Ok(()) +} + +fn binding_supports_ip(binding: &Binding, target: IpAddr) -> bool { + let raw = binding.bind_ip.trim(); + if raw.is_empty() { + return true; + } + let Ok(bind) = raw.parse::() else { + return false; + }; + if bind.is_unspecified() { + return matches!( + (bind, target), + (IpAddr::V4(_), IpAddr::V4(_)) | (IpAddr::V6(_), IpAddr::V6(_)) + ); + } + bind == target +} + +fn port_for_ip(service: &Service, target: IpAddr) -> Option { + let bindings = service_bindings(service); + bindings + .iter() + .find_map(|binding| { + let bind = binding.bind_ip.parse::().ok()?; + (!bind.is_unspecified() && bind == target).then_some(binding.public_port) + }) + .or_else(|| { + bindings + .iter() + .find(|binding| binding_supports_ip(binding, target)) + .map(|binding| binding.public_port) + }) +} + +fn concrete_binding_target(service: &Service) -> Option<(IpAddr, u16)> { + service_bindings(service).into_iter().find_map(|binding| { + let ip = binding.bind_ip.parse::().ok()?; + (!ip.is_unspecified() && !ip.is_loopback() && !ip.is_multicast()) + .then_some((ip, binding.public_port)) + }) +} + +fn route_target( + agent: &Agent, + assignment: &Assignment, + inventory: &Inventory, + service: &Service, +) -> Option<(IpAddr, u16)> { + if let Some(ip) = assignment.target_ip { + return port_for_ip(service, ip).map(|port| (ip, port)); + } + if let Some(ip) = agent.default_target_ip { + return port_for_ip(service, ip).map(|port| (ip, port)); + } + if let Some(target) = concrete_binding_target(service) { + return Some(target); + } + if let Ok(ip) = inventory.public_ip.parse::() { + if let Some(port) = port_for_ip(service, ip) { + return Some((ip, port)); + } + } + inventory.addresses.iter().find_map(|address| { + let ip = address.ip.parse::().ok()?; + port_for_ip(service, ip).map(|port| (ip, port)) + }) +} + +fn address_cidr(ip: IpAddr) -> String { + match ip { + IpAddr::V4(_) => format!("{ip}/32"), + IpAddr::V6(_) => format!("{ip}/128"), + } +} + +pub(crate) fn generated_id(agent_id: &str, service_key: &str) -> String { + let readable: String = agent_id + .bytes() + .filter(|byte| byte.is_ascii_alphanumeric() || *byte == b'-' || *byte == b'_') + .take(24) + .map(char::from) + .collect(); + let readable = if readable.is_empty() { + "agent" + } else { + readable.as_str() + }; + let mut hasher = Sha256::new(); + hasher.update(agent_id.as_bytes()); + hasher.update([0]); + hasher.update(service_key.as_bytes()); + let digest = hasher.finalize(); + let suffix: String = digest[..6] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + format!("docker-{readable}-{suffix}") +} + +pub fn effective_config( + mut desired: crate::config::Config, + observed: &BTreeMap, +) -> Result { + for agent in desired + .docker_agents + .agents + .iter() + .filter(|agent| agent.enabled) + { + let Some(inventory) = observed + .get(&agent.id) + .and_then(|item| item.ok.then_some(item.inventory.as_ref()).flatten()) + else { + continue; + }; + for assignment in agent + .assignments + .iter() + .filter(|assignment| assignment.enabled) + { + let Some(service) = inventory + .services + .iter() + .find(|service| service.key == assignment.service_key) + else { + continue; + }; + let Some((ip, port)) = route_target(agent, assignment, inventory, service) else { + continue; + }; + usable_target_ip(ip)?; + let hosts = assignment_hosts(assignment, &agent.base_domain)?; + let url = match ip { + IpAddr::V4(ip) => format!("{}://{}:{}", assignment.scheme.as_str(), ip, port), + IpAddr::V6(ip) => format!("{}://[{}]:{}", assignment.scheme.as_str(), ip, port), + }; + let value = serde_json::json!({ + "id": generated_id(&agent.id, &assignment.service_key), + "frontend": {"hosts": hosts, "listener_ids": agent.listener_ids}, + "certificate_id": agent.certificate_id.clone(), + "force_ssl": assignment.force_ssl.unwrap_or(false), + "websockets": assignment.websockets, + "backends": [{ + "id": "docker", + "url": url, + "allow_private": true, + "preserve_host": assignment.preserve_host, + "address_allowlist": [address_cidr(ip)] + }], + "cache": {"enabled": assignment.cache_enabled}, + "transform": {"enabled": assignment.transform_enabled}, + "acl_profile_id": assignment.acl_profile_id.clone().or_else(|| agent.acl_profile_id.clone()), + "acl": assignment.acl.clone(), + "proxy_timeouts": assignment.proxy_timeouts.clone(), + "load_balancing": { + "health_check": assignment.health_check.clone(), + "passive_health": assignment.passive_health.clone() + } + }); + let host: crate::vhost::Vhost = + serde_json::from_value(value).context("build Docker vhost")?; + desired.vhosts.push(host); + } + } + Ok(desired) +} + +pub async fn poll_loop( + app: std::sync::Weak, + shutdown: tokio_util::sync::CancellationToken, +) { + loop { + let Some(app) = app.upgrade() else { + break; + }; + let settings = app.config.load().envelope.config.docker_agents.clone(); + let had_observations = !app.docker.view().await.is_empty(); + if !settings.agents.is_empty() || had_observations { + let (_, changed) = app.docker.refresh(&settings, false).await; + if changed { + if let Err(error) = app.reconcile_docker().await { + tracing::warn!(error = ?error, "Docker discovery reconcile failed"); + } + } + } + drop(app); + tokio::select! { + _ = shutdown.cancelled() => break, + _ = tokio::time::sleep(Duration::from_secs(5)) => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn observation(service: Service, addresses: Vec) -> BTreeMap { + let inventory = Inventory { + agent_version: "0.22.3".into(), + hostname: "node1".into(), + public_ip: "10.2.3.4".into(), + public_ip_source: "route".into(), + certificate_sha256: String::new(), + addresses, + observed_at: "now".into(), + services: vec![service], + }; + let mut observed = BTreeMap::new(); + observed.insert( + "node1".into(), + Observation { + agent_id: "node1".into(), + ok: true, + checked_at_unix_ms: 1, + error: None, + inventory: Some(inventory), + }, + ); + observed + } + + fn service() -> Service { + Service { + key: "container:app:8080/tcp".into(), + technical_name: "app-8080".into(), + container_id: "abc".into(), + container_name: "app".into(), + image: "demo".into(), + private_port: 8080, + protocol: "tcp".into(), + bindings: vec![Binding { + bind_ip: "0.0.0.0".into(), + public_port: 18080, + }], + public_port: 0, + bind_ip: String::new(), + } + } + + #[test] + fn generated_vhost_supports_base_domain_custom_domain_aliases_and_selected_ip() { + let mut desired = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); + desired.vhosts.clear(); + desired.docker_agents.agents.push(Agent { + id: "node1".into(), + name: "Node 1".into(), + endpoint: "http://10.0.0.2:9876".into(), + token: "012345678901234567890123".into(), + tls_fingerprint_sha256: None, + base_domain: "docker1.lab.local".into(), + poll_interval_seconds: 30, + certificate_id: Some("docker-cert".into()), + acl_profile_id: None, + default_target_ip: None, + listener_ids: vec!["http".into()], + assignments: vec![Assignment { + service_key: "container:app:8080/tcp".into(), + hostname: "app1".into(), + aliases: vec!["app-alt".into(), "myapp.example.com".into()], + target_ip: Some("10.2.3.5".parse().unwrap()), + force_ssl: Some(true), + websockets: false, + cache_enabled: true, + ..Default::default() + }], + enabled: true, + }); + desired.docker_agents.agents[0].acl_profile_id = Some("office".into()); + let observed = observation( + service(), + vec![ + HostAddress { + ip: "10.2.3.4".into(), + source: "interface".into(), + interface: "eno1".into(), + scope: "private".into(), + }, + HostAddress { + ip: "10.2.3.5".into(), + source: "interface".into(), + interface: "eno2".into(), + scope: "private".into(), + }, + ], + ); + let effective = effective_config(desired, &observed).unwrap(); + assert_eq!(effective.vhosts.len(), 1); + assert_eq!( + effective.vhosts[0].frontend.hosts, + vec![ + "app1.docker1.lab.local", + "app-alt.docker1.lab.local", + "myapp.example.com" + ] + ); + assert_eq!(effective.vhosts[0].backends[0].url, "http://10.2.3.5:18080"); + assert_eq!( + effective.vhosts[0].certificate_id.as_deref(), + Some("docker-cert") + ); + assert!(effective.vhosts[0].force_ssl); + assert!(!effective.vhosts[0].websockets); + assert!(effective.vhosts[0].cache.enabled.unwrap_or(false)); + assert_eq!( + effective.vhosts[0].acl_profile_id.as_deref(), + Some("office") + ); + assert_eq!( + effective.vhosts[0].load_balancing.health_check.mode, + crate::balance::HealthMode::Tcp + ); + assert!(!effective.vhosts[0].load_balancing.passive_health.enabled); + } + + #[test] + fn docker_alias_count_is_not_artificially_capped() { + let assignment = Assignment { + hostname: "app".into(), + aliases: (0..40).map(|index| format!("alias-{index}")).collect(), + ..Default::default() + }; + let hosts = assignment_hosts(&assignment, "docker.example.test").unwrap(); + assert_eq!(hosts.len(), 41); + assert_eq!(hosts[0], "app.docker.example.test"); + assert_eq!(hosts[40], "alias-39.docker.example.test"); + } + + #[test] + fn generated_vhost_inherits_global_server_header_policy() { + let mut desired = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); + desired.vhosts.clear(); + desired.server_header = crate::settings::ServerHeader { + mode: crate::settings::ServerMode::Custom, + value: "RustProxy".into(), + }; + desired.docker_agents.agents.push(Agent { + id: "node1".into(), + name: "Node 1".into(), + endpoint: "http://10.0.0.2:9876".into(), + token: "012345678901234567890123".into(), + base_domain: "docker1.lab.local".into(), + listener_ids: vec!["http".into()], + assignments: vec![Assignment { + service_key: "container:app:8080/tcp".into(), + hostname: "app".into(), + ..Default::default() + }], + ..Default::default() + }); + let observed = observation( + service(), + vec![HostAddress { + ip: "10.2.3.4".into(), + source: "interface".into(), + interface: "eno1".into(), + scope: "private".into(), + }], + ); + let effective = effective_config(desired, &observed).unwrap(); + assert!(effective.vhosts[0].server_header.is_none()); + + let policy = crate::config::ValidationPolicy { + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + tls_addr: None, + cert_dir: None, + data_dir: None, + }; + let snapshot = crate::config::compile(effective, 0, &policy).unwrap(); + let frontend = snapshot + .frontend( + "app.docker1.lab.local", + "127.0.0.1:8080".parse().unwrap(), + false, + ) + .unwrap(); + assert!(matches!( + frontend.server_header.mode, + crate::settings::ServerMode::Custom + )); + assert_eq!(frontend.server_header.value, "RustProxy"); + } + + #[test] + fn legacy_assignment_defaults_keep_redirect_off_websockets_on_and_cache_off() { + let assignment: Assignment = serde_json::from_value(serde_json::json!({ + "service_key": "container:app:8080/tcp", + "hostname": "app" + })) + .unwrap(); + assert_eq!(assignment.force_ssl, None); + assert!(assignment.websockets); + assert!(!assignment.cache_enabled); + assert!(!assignment.passive_health.enabled); + } + + #[test] + fn docker_assignment_propagates_explicit_passive_health_policy() { + let mut desired = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); + desired.vhosts.clear(); + desired.docker_agents.agents.push(Agent { + id: "node1".into(), + name: "Node 1".into(), + endpoint: "http://10.0.0.2:9876".into(), + token: "012345678901234567890123".into(), + base_domain: "docker1.lab.local".into(), + listener_ids: vec!["http".into()], + assignments: vec![Assignment { + service_key: "container:app:8080/tcp".into(), + hostname: "app".into(), + passive_health: crate::balance::PassiveHealth { + enabled: true, + count_http_5xx: false, + count_slow_responses: true, + fall: Some(4), + rise: Some(3), + ..Default::default() + }, + ..Default::default() + }], + ..Default::default() + }); + let observed = observation( + service(), + vec![HostAddress { + ip: "10.2.3.4".into(), + source: "interface".into(), + interface: "eno1".into(), + scope: "private".into(), + }], + ); + let effective = effective_config(desired, &observed).unwrap(); + let passive = &effective.vhosts[0].load_balancing.passive_health; + assert!(passive.enabled); + assert!(!passive.count_http_5xx); + assert!(passive.count_slow_responses); + assert_eq!(passive.fall, Some(4)); + assert_eq!(passive.rise, Some(3)); + } + + #[test] + fn docker_assignment_passive_health_survives_serialization_round_trip() { + let original = Assignment { + service_key: "container:app:8080/tcp".into(), + hostname: "app".into(), + passive_health: crate::balance::PassiveHealth { + enabled: true, + count_connect_errors: false, + count_timeouts: true, + count_io_errors: false, + count_http_5xx: true, + count_slow_responses: true, + fall: Some(5), + rise: Some(4), + }, + ..Default::default() + }; + let encoded = serde_json::to_value(&original).unwrap(); + let restored: Assignment = serde_json::from_value(encoded).unwrap(); + let passive = restored.passive_health; + assert!(passive.enabled); + assert!(!passive.count_connect_errors); + assert!(passive.count_timeouts); + assert!(!passive.count_io_errors); + assert!(passive.count_http_5xx); + assert!(passive.count_slow_responses); + assert_eq!(passive.fall, Some(5)); + assert_eq!(passive.rise, Some(4)); + } + + #[test] + fn force_ssl_assignment_requires_plain_and_tls_listener_pair() { + let listeners = crate::settings::Listeners::from_startup( + "127.0.0.1:8080".parse().unwrap(), + Some("127.0.0.1:8443".parse().unwrap()), + ); + let agent = Agent { + id: "node1".into(), + name: "Node 1".into(), + endpoint: "http://10.0.0.2:9876".into(), + token: "012345678901234567890123".into(), + base_domain: "docker1.lab.local".into(), + listener_ids: vec!["http".into(), "https".into()], + assignments: vec![Assignment { + service_key: "container:app:8080/tcp".into(), + hostname: "app".into(), + force_ssl: Some(true), + ..Default::default() + }], + ..Default::default() + }; + let mut valid = Settings { + agents: vec![agent.clone()], + }; + valid.validate(&listeners).unwrap(); + let mut invalid = Settings { + agents: vec![Agent { + listener_ids: vec!["http".into()], + ..agent + }], + }; + assert!(invalid.validate(&listeners).is_err()); + } + + #[test] + fn concrete_docker_binding_keeps_its_own_port() { + let service = Service { + bindings: vec![ + Binding { + bind_ip: "10.2.3.4".into(), + public_port: 18080, + }, + Binding { + bind_ip: "10.2.3.5".into(), + public_port: 28080, + }, + ], + ..service() + }; + assert_eq!( + port_for_ip(&service, "10.2.3.4".parse().unwrap()), + Some(18080) + ); + assert_eq!( + port_for_ip(&service, "10.2.3.5".parse().unwrap()), + Some(28080) + ); + assert_eq!(port_for_ip(&service, "10.2.3.6".parse().unwrap()), None); + } + + #[test] + fn tls_fingerprint_normalizes_common_copy_paste_format() { + let raw = "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"; + let normalized = normalize_fingerprint(raw).unwrap(); + assert_eq!(normalized.len(), 64); + assert_eq!(normalized, normalized.to_ascii_lowercase()); + assert_eq!(fingerprint_bytes(&normalized).unwrap()[0], 0xaa); + } + + #[test] + fn generated_vhost_id_stays_within_config_limit() { + let id = generated_id( + "agent-name-that-is-deliberately-far-longer-than-the-config-id-limit", + "compose:very-long-project:very-long-service:12345/tcp", + ); + assert!( + id.len() <= 64, + "generated Docker vhost id is too long: {id}" + ); + assert!( + crate::config::valid_id(&id), + "generated Docker vhost id is invalid: {id}" + ); + } +} diff --git a/src/forwarding.rs b/src/forwarding.rs new file mode 100644 index 0000000..43ad4c4 --- /dev/null +++ b/src/forwarding.rs @@ -0,0 +1,516 @@ +//! Single trust boundary for TCP, TLS and QUIC. Incoming proxy headers are never +//! authority for routing, listener selection or identity without a trusted peer. +use anyhow::{ensure, Context as _, Result}; +use http::{HeaderMap, HeaderValue, Uri}; +use ipnet::IpNet; +use serde::{Deserialize, Serialize}; +use std::{ + net::{IpAddr, SocketAddr}, + sync::LazyLock, +}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + pub trusted_proxies: Vec, + /// Enable only when the trusted last hop sanitizes X-Forwarded-Proto. + pub trust_forwarded_proto: bool, +} +#[derive(Debug)] +pub struct Compiled { + networks: Vec, + trust_proto: bool, +} +#[derive(Debug, Clone)] +pub struct Context { + pub client_ip: IpAddr, + pub scheme: &'static str, + pub original_url: String, + values: HeaderMap, +} +impl Settings { + pub fn compile(&self) -> Result { + ensure!( + self.trusted_proxies.len() <= 32, + "at most 32 trusted proxy CIDRs" + ); + let networks = self + .trusted_proxies + .iter() + .map(|value| { + ensure!(value.len() <= 64, "trusted proxy CIDR is too long"); + let net: IpNet = value.parse().context("invalid trusted proxy CIDR")?; + ensure!( + net.prefix_len() != 0, + "trusting every address is not permitted; list your actual proxy networks" + ); + Ok(net) + }) + .collect::>>()?; + Ok(Compiled { + networks, + trust_proto: self.trust_forwarded_proto, + }) + } +} +fn canonical(ip: IpAddr) -> IpAddr { + crate::vhost::canonical_ip(ip) +} + +const CLOUDFLARE_CIDRS: &[&str] = &[ + "103.21.244.0/22", + "103.22.200.0/22", + "103.31.4.0/22", + "104.16.0.0/13", + "104.24.0.0/14", + "108.162.192.0/18", + "131.0.72.0/22", + "141.101.64.0/18", + "162.158.0.0/15", + "172.64.0.0/13", + "173.245.48.0/20", + "188.114.96.0/20", + "190.93.240.0/20", + "197.234.240.0/22", + "198.41.128.0/17", + "2400:cb00::/32", + "2606:4700::/32", + "2803:f800::/32", + "2405:b500::/32", + "2405:8100::/32", + "2a06:98c0::/29", + "2c0f:f248::/32", +]; +static CLOUDFLARE_NETWORKS: LazyLock> = LazyLock::new(|| { + CLOUDFLARE_CIDRS + .iter() + .map(|value| value.parse().expect("static Cloudflare CIDR")) + .collect() +}); + +/// Return CF-Connecting-IP only when the TCP peer itself is in Cloudflare's +/// published edge ranges. The header is otherwise untrusted user input. +pub fn cloudflare_client_ip(headers: &HeaderMap, peer: SocketAddr) -> Option { + let peer = canonical(peer.ip()); + if !CLOUDFLARE_NETWORKS + .iter() + .any(|network| network.contains(&peer)) + { + return None; + } + let mut values = headers.get_all("cf-connecting-ip").iter(); + let value = values.next()?; + if values.next().is_some() { + return None; + } + value + .to_str() + .ok()? + .trim() + .parse::() + .ok() + .map(canonical) +} +fn parse_node(value: &str) -> Option { + let value = value.trim(); + let value = if value.starts_with('"') { + value.strip_prefix('"')?.strip_suffix('"')? + } else { + value + }; + if value.contains('\\') || value.contains('%') { + return None; + } + value + .parse::() + .ok() + .or_else(|| value.parse::().ok().map(|address| address.ip())) + .or_else(|| { + value + .strip_prefix('[')? + .strip_suffix(']')? + .parse::() + .ok() + }) + .map(canonical) +} +fn chain(headers: &HeaderMap) -> Option> { + let xff = headers.contains_key("x-forwarded-for"); + let name = if xff { "x-forwarded-for" } else { "forwarded" }; + let mut result = Vec::new(); + let mut bytes = 0; + for value in headers.get_all(name) { + bytes += value.as_bytes().len(); + if bytes > 4096 { + return None; + } + for item in value.to_str().ok()?.split(',') { + if result.len() >= 32 { + return None; + } + let ip = if xff { + parse_node(item)? + } else { + let mut found = None; + for parameter in item.split(';') { + let (key, value) = parameter.split_once('=')?; + if key.trim().eq_ignore_ascii_case("for") { + if found.is_some() { + return None; + } + found = Some(parse_node(value)?); + } + } + found? + }; + result.push(ip); + } + } + Some(result) +} +fn node(ip: IpAddr) -> String { + match ip { + IpAddr::V4(_) => ip.to_string(), + IpAddr::V6(_) => format!("\"[{ip}]\""), + } +} +impl Compiled { + pub fn trusted(&self, ip: IpAddr) -> bool { + self.networks.iter().any(|net| net.contains(&canonical(ip))) + } + /// Whether this peer may define the externally visible request scheme. + /// Keeping this separate from `trusted` preserves the explicit + /// `trust_forwarded_proto` opt-in used by the public proxy path. + pub fn trusted_forwarded_proto(&self, ip: IpAddr) -> bool { + self.trust_proto && self.trusted(ip) + } + pub fn context( + &self, + incoming: &HeaderMap, + peer: SocketAddr, + local: SocketAddr, + tls: bool, + authority: &HeaderValue, + uri: &Uri, + ) -> Result { + let peer_ip = canonical(peer.ip()); + let trusted_peer = self.trusted(peer_ip); + let mut ips = if trusted_peer { + chain(incoming).unwrap_or_default() + } else { + Vec::new() + }; + ips.push(peer_ip); + // Discard everything left of the first untrusted hop walking from us. + let first = ips.iter().rposition(|ip| !self.trusted(*ip)).unwrap_or(0); + ips.drain(..first); + let client_ip = ips[0]; + let mut scheme = if tls { "https" } else { "http" }; + if trusted_peer + && self.trust_proto + && incoming.get_all("x-forwarded-proto").iter().count() == 1 + { + scheme = match incoming + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + { + Some("https") => "https", + Some("http") => "http", + _ => scheme, + }; + } + let authority_text = authority.to_str()?; + // Validate before inserting authority into Forwarded's quoted host field. + crate::config::request_host(authority_text)?; + let original_target = uri.path_and_query().map(|v| v.as_str()).unwrap_or("/"); + let original_url = format!("{scheme}://{authority_text}{original_target}"); + let mut forwarded: Vec<_> = ips.iter().map(|ip| format!("for={}", node(*ip))).collect(); + let last = forwarded.last_mut().expect("actual peer is always present"); + last.push_str(&format!( + ";by={};proto={scheme};host=\"{authority_text}\"", + node(canonical(local.ip())) + )); + let mut values = HeaderMap::new(); + values.insert("forwarded", forwarded.join(", ").parse()?); + values.insert( + "x-forwarded-for", + ips.iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + .parse()?, + ); + values.insert("x-real-ip", client_ip.to_string().parse()?); + values.insert("x-forwarded-host", authority.clone()); + values.insert("x-forwarded-proto", HeaderValue::from_static(scheme)); + let port = if trusted_peer && self.trust_proto { + authority_text + .parse::()? + .port_u16() + .unwrap_or(if scheme == "https" { 443 } else { 80 }) + } else { + local.port() + }; + values.insert("x-forwarded-port", port.to_string().parse()?); + values.insert("x-forwarded-uri", original_target.parse()?); + Ok(Context { + client_ip, + scheme, + original_url, + values, + }) + } +} +impl Context { + /// Replace the effective client with a previously authenticated edge header. + /// Existing proxy-chain metadata is retained behind the visitor address. + pub fn override_client_ip(&mut self, client: IpAddr) -> Result<()> { + let client = canonical(client); + let client_text = client.to_string(); + let existing_xff = self + .values + .get("x-forwarded-for") + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + let already_first = existing_xff + .split(',') + .next() + .and_then(parse_node) + .is_some_and(|value| value == client); + if !already_first { + let xff = if existing_xff.is_empty() { + client_text.clone() + } else { + format!("{client_text}, {existing_xff}") + }; + self.values.insert("x-forwarded-for", xff.parse()?); + let existing = self + .values + .get("forwarded") + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + let forwarded = if existing.is_empty() { + format!("for={}", node(client)) + } else { + format!("for={}, {existing}", node(client)) + }; + self.values.insert("forwarded", forwarded.parse()?); + } + self.values.insert("x-real-ip", client_text.parse()?); + self.client_ip = client; + Ok(()) + } + pub fn apply(&self, headers: &mut HeaderMap) { + let remove: Vec<_> = headers + .keys() + .filter(|name| { + name.as_str().starts_with("x-forwarded-") + || matches!( + name.as_str(), + "forwarded" | "x-real-ip" | "x-original-url" | "x-original-method" + ) + }) + .cloned() + .collect(); + for name in remove { + headers.remove(name); + } + for (name, value) in &self.values { + headers.insert(name.clone(), value.clone()); + } + } + + /// Apply trusted forwarding metadata to an application request. When client-IP + /// forwarding is disabled, preserve scheme/host/port/URI metadata but withhold + /// the visitor address so frameworks such as ProxyFix see the RustProxy socket peer. + /// Authentication verifiers use `apply` directly and still receive the real client IP. + pub fn apply_upstream(&self, headers: &mut HeaderMap, forward_client_ip: bool) { + self.apply(headers); + if !forward_client_ip { + headers.remove("forwarded"); + headers.remove("x-forwarded-for"); + headers.remove("x-real-ip"); + } + } +} +#[cfg(test)] +mod tests { + use super::*; + fn context(settings: Settings, incoming: HeaderMap, peer: &str) -> Context { + settings + .compile() + .unwrap() + .context( + &incoming, + peer.parse().unwrap(), + "10.0.0.1:8443".parse().unwrap(), + true, + &"app.example:8443".parse().unwrap(), + &"/a?x=1&y=2".parse().unwrap(), + ) + .unwrap() + } + #[test] + fn untrusted_headers_are_rebuilt_not_appended() { + let mut headers = HeaderMap::new(); + for (key, value) in [ + ("forwarded", "for=1.1.1.1"), + ("x-forwarded-for", "2.2.2.2"), + ("x-real-ip", "3.3.3.3"), + ("x-forwarded-host", "evil.example"), + ("x-forwarded-user", "admin"), + ("x-original-url", "http://evil/"), + ] { + headers.insert(key, value.parse().unwrap()); + } + let context = context(Settings::default(), headers.clone(), "192.0.2.5:32000"); + context.apply(&mut headers); + assert_eq!(headers["x-forwarded-for"], "192.0.2.5"); + assert_eq!(headers["x-real-ip"], "192.0.2.5"); + assert_eq!(headers["x-forwarded-host"], "app.example:8443"); + assert!(!headers.contains_key("x-forwarded-user")); + assert!(!headers.contains_key("x-original-url")); + assert_eq!( + headers["forwarded"], + "for=192.0.2.5;by=10.0.0.1;proto=https;host=\"app.example:8443\"" + ); + } + #[test] + fn upstream_can_withhold_client_ip_without_losing_origin_metadata() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", "203.0.113.99".parse().unwrap()); + headers.insert("x-real-ip", "203.0.113.99".parse().unwrap()); + headers.insert("forwarded", "for=203.0.113.99".parse().unwrap()); + let context = context(Settings::default(), HeaderMap::new(), "192.0.2.5:32000"); + context.apply_upstream(&mut headers, false); + assert!(!headers.contains_key("x-forwarded-for")); + assert!(!headers.contains_key("x-real-ip")); + assert!(!headers.contains_key("forwarded")); + assert_eq!(headers["x-forwarded-host"], "app.example:8443"); + assert_eq!(headers["x-forwarded-proto"], "https"); + assert_eq!(headers["x-forwarded-port"], "8443"); + assert_eq!(headers["x-forwarded-uri"], "/a?x=1&y=2"); + } + + #[test] + fn upstream_client_ip_forwarding_remains_default_behavior() { + let mut headers = HeaderMap::new(); + let context = context(Settings::default(), HeaderMap::new(), "192.0.2.5:32000"); + context.apply_upstream(&mut headers, true); + assert_eq!(headers["x-forwarded-for"], "192.0.2.5"); + assert_eq!(headers["x-real-ip"], "192.0.2.5"); + assert!(headers.contains_key("forwarded")); + } + + #[test] + fn trust_chain_stops_at_first_untrusted_hop() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-forwarded-for", + "6.6.6.6, 198.51.100.7, 10.1.2.3".parse().unwrap(), + ); + let c = context( + Settings { + trusted_proxies: vec!["10.0.0.0/8".into()], + ..Default::default() + }, + headers, + "10.2.3.4:99", + ); + assert_eq!( + c.values["x-forwarded-for"], + "198.51.100.7, 10.1.2.3, 10.2.3.4" + ); + assert_eq!(c.client_ip.to_string(), "198.51.100.7"); + } + #[test] + fn forwarded_only_ipv6_and_duplicate_fields() { + let mut h = HeaderMap::new(); + h.append( + "forwarded", + "for=\"[2001:db8::1]:1234\";proto=http".parse().unwrap(), + ); + h.append("forwarded", "for=10.1.1.1".parse().unwrap()); + let c = context( + Settings { + trusted_proxies: vec!["10.0.0.0/8".into()], + ..Default::default() + }, + h, + "10.2.2.2:99", + ); + assert_eq!( + c.values["x-forwarded-for"], + "2001:db8::1, 10.1.1.1, 10.2.2.2" + ); + assert!(c.values["forwarded"] + .to_str() + .unwrap() + .starts_with("for=\"[2001:db8::1]\"")); + } + #[test] + fn invalid_and_oversized_chains_fall_back_to_peer() { + for raw in [ + "unknown".to_owned(), + "_hidden".into(), + "1.2.3.4, garbage".into(), + vec!["1.2.3.4"; 33].join(","), + ] { + let mut h = HeaderMap::new(); + h.insert("x-forwarded-for", raw.parse().unwrap()); + let c = context( + Settings { + trusted_proxies: vec!["10.0.0.0/8".into()], + ..Default::default() + }, + h, + "10.2.2.2:99", + ); + assert_eq!(c.client_ip.to_string(), "10.2.2.2"); + } + } + #[test] + fn proto_trust_is_explicit_and_host_never_comes_from_forwarded() { + let mut h = HeaderMap::new(); + h.insert("x-forwarded-proto", "http".parse().unwrap()); + h.insert("x-forwarded-host", "evil".parse().unwrap()); + let settings = Settings { + trusted_proxies: vec!["10.0.0.0/8".into()], + trust_forwarded_proto: true, + }; + let c = context(settings, h, "10.0.0.2:22"); + assert_eq!(c.original_url, "http://app.example:8443/a?x=1&y=2"); + } + #[test] + fn refuses_trust_all_and_invalid_cidrs() { + for value in ["0.0.0.0/0", "::/0", "broken"] { + assert!(Settings { + trusted_proxies: vec![value.into()], + ..Default::default() + } + .compile() + .is_err()); + } + } + #[test] + fn cloudflare_header_requires_cloudflare_peer() { + let mut headers = HeaderMap::new(); + headers.insert("cf-connecting-ip", "203.0.113.8".parse().unwrap()); + assert_eq!( + cloudflare_client_ip(&headers, "104.16.1.2:443".parse().unwrap()), + Some("203.0.113.8".parse().unwrap()) + ); + assert!(cloudflare_client_ip(&headers, "192.0.2.2:443".parse().unwrap()).is_none()); + } + #[test] + fn authenticated_client_override_prepends_chain_once() { + let mut c = context(Settings::default(), HeaderMap::new(), "104.16.1.2:443"); + c.override_client_ip("203.0.113.8".parse().unwrap()) + .unwrap(); + assert_eq!(c.client_ip.to_string(), "203.0.113.8"); + assert_eq!(c.values["x-forwarded-for"], "203.0.113.8, 104.16.1.2"); + c.override_client_ip("203.0.113.8".parse().unwrap()) + .unwrap(); + assert_eq!(c.values["x-forwarded-for"], "203.0.113.8, 104.16.1.2"); + } +} diff --git a/src/geoip.rs b/src/geoip.rs new file mode 100644 index 0000000..2f55c40 --- /dev/null +++ b/src/geoip.rs @@ -0,0 +1,222 @@ +//! Minimal reader for sapics/ip-location-db user-country CSV files. +//! Installer verification pins each downloaded file to the publisher-provided +//! SHA-256 checksum; runtime parsing stays dependency-free and bounded. +use anyhow::{bail, ensure, Context, Result}; +use serde::Serialize; +use std::{ + fs::File, + io::{BufRead, BufReader}, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + path::Path, +}; + +const MAX_FILE_BYTES: u64 = 256 * 1024 * 1024; +const MAX_RANGES: usize = 2_000_000; + +#[derive(Debug, Clone, Copy)] +struct V4Range { + start: u32, + end: u32, + country: [u8; 2], +} +#[derive(Debug, Clone, Copy)] +struct V6Range { + start: u128, + end: u128, + country: [u8; 2], +} +#[derive(Debug, Default)] +pub struct Database { + ipv4: Vec, + ipv6: Vec, +} +#[derive(Debug, Clone, Serialize)] +pub struct View { + pub loaded: bool, + pub ipv4_ranges: usize, + pub ipv6_ranges: usize, + pub source: &'static str, +} +impl Database { + pub fn empty() -> Self { + Self::default() + } + pub fn load(directory: &Path) -> Result { + let ipv4 = parse_v4(&directory.join("user-country-ipv4.csv"))?; + let ipv6 = parse_v6(&directory.join("user-country-ipv6.csv"))?; + ensure!( + !ipv4.is_empty() && !ipv6.is_empty(), + "GeoIP country database is empty" + ); + Ok(Self { ipv4, ipv6 }) + } + pub fn loaded(&self) -> bool { + !self.ipv4.is_empty() || !self.ipv6.is_empty() + } + pub fn view(&self) -> View { + View { + loaded: self.loaded(), + ipv4_ranges: self.ipv4.len(), + ipv6_ranges: self.ipv6.len(), + source: "sapics/ip-location-db user-country (PDDL)", + } + } + pub fn country(&self, ip: IpAddr) -> Option { + let code = match crate::vhost::canonical_ip(ip) { + IpAddr::V4(ip) => find_v4(&self.ipv4, u32::from(ip)), + IpAddr::V6(ip) => find_v6(&self.ipv6, u128::from(ip)), + }?; + Some(String::from_utf8_lossy(&code).into_owned()) + } +} + +fn country_code(raw: &str) -> Option<[u8; 2]> { + let raw = raw.trim().trim_matches('"').as_bytes(); + if raw.len() == 2 && raw.iter().all(|b| b.is_ascii_alphabetic()) { + Some([raw[0].to_ascii_uppercase(), raw[1].to_ascii_uppercase()]) + } else { + None + } +} +fn prepare(path: &Path) -> Result> { + let file = + File::open(path).with_context(|| format!("open GeoIP database {}", path.display()))?; + ensure!( + file.metadata()?.len() <= MAX_FILE_BYTES, + "GeoIP database file is larger than 256 MiB" + ); + Ok(BufReader::new(file)) +} +fn fields(line: &str) -> Result<(&str, &str, &str)> { + let mut parts = line.split(','); + let start = parts + .next() + .context("GeoIP CSV missing start address")? + .trim(); + let end = parts + .next() + .context("GeoIP CSV missing end address")? + .trim(); + let country = parts.next().context("GeoIP CSV missing country")?.trim(); + ensure!(parts.next().is_none(), "GeoIP CSV has unexpected columns"); + Ok((start, end, country)) +} +fn parse_v4(path: &Path) -> Result> { + let mut result = Vec::new(); + let mut previous_end: Option = None; + for (index, line) in prepare(path)?.lines().enumerate() { + let line = line.with_context(|| format!("read GeoIP IPv4 row {}", index + 1))?; + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (start, end, country) = fields(line)?; + let start: Ipv4Addr = start + .trim_matches('"') + .parse() + .context("invalid GeoIP IPv4 start")?; + let end: Ipv4Addr = end + .trim_matches('"') + .parse() + .context("invalid GeoIP IPv4 end")?; + let start = u32::from(start); + let end = u32::from(end); + ensure!(start <= end, "invalid GeoIP IPv4 range"); + if let Some(previous) = previous_end { + ensure!( + start > previous, + "GeoIP IPv4 ranges overlap or are unsorted" + ); + } + previous_end = Some(end); + if let Some(country) = country_code(country) { + result.push(V4Range { + start, + end, + country, + }); + ensure!(result.len() <= MAX_RANGES, "too many GeoIP IPv4 ranges"); + } + } + Ok(result) +} +fn parse_v6(path: &Path) -> Result> { + let mut result = Vec::new(); + let mut previous_end: Option = None; + for (index, line) in prepare(path)?.lines().enumerate() { + let line = line.with_context(|| format!("read GeoIP IPv6 row {}", index + 1))?; + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (start, end, country) = fields(line)?; + let start: Ipv6Addr = start + .trim_matches('"') + .parse() + .context("invalid GeoIP IPv6 start")?; + let end: Ipv6Addr = end + .trim_matches('"') + .parse() + .context("invalid GeoIP IPv6 end")?; + let start = u128::from(start); + let end = u128::from(end); + ensure!(start <= end, "invalid GeoIP IPv6 range"); + if let Some(previous) = previous_end { + ensure!( + start > previous, + "GeoIP IPv6 ranges overlap or are unsorted" + ); + } + previous_end = Some(end); + if let Some(country) = country_code(country) { + result.push(V6Range { + start, + end, + country, + }); + ensure!(result.len() <= MAX_RANGES, "too many GeoIP IPv6 ranges"); + } + } + Ok(result) +} +fn find_v4(ranges: &[V4Range], ip: u32) -> Option<[u8; 2]> { + let index = ranges + .partition_point(|item| item.start <= ip) + .checked_sub(1)?; + let item = ranges.get(index)?; + (ip <= item.end).then_some(item.country) +} +fn find_v6(ranges: &[V6Range], ip: u128) -> Option<[u8; 2]> { + let index = ranges + .partition_point(|item| item.start <= ip) + .checked_sub(1)?; + let item = ranges.get(index)?; + (ip <= item.end).then_some(item.country) +} + +pub fn validate_directory(directory: &Path) -> Result<()> { + Database::load(directory) + .map(|_| ()) + .or_else(|error| bail!("GeoIP country database is invalid: {error:#}")) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn range_lookup_handles_gaps() { + let db = Database { + ipv4: vec![V4Range { + start: u32::from(Ipv4Addr::new(192, 0, 2, 0)), + end: u32::from(Ipv4Addr::new(192, 0, 2, 255)), + country: *b"PL", + }], + ipv6: vec![], + }; + assert_eq!( + db.country("192.0.2.7".parse().unwrap()).as_deref(), + Some("PL") + ); + assert!(db.country("198.51.100.1".parse().unwrap()).is_none()); + } +} diff --git a/src/headers.rs b/src/headers.rs new file mode 100644 index 0000000..9dd21b2 --- /dev/null +++ b/src/headers.rs @@ -0,0 +1,172 @@ +//! Generic, compiled request/response header transformations. No app-specific exceptions. +use anyhow::{bail, ensure, Context, Result}; +use http::{HeaderMap, HeaderName, HeaderValue}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Operation { + Set, + Append, + Remove, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Rule { + pub op: Operation, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, +} +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HeaderRules { + #[serde(default)] + pub request: Vec, + #[serde(default)] + pub response: Vec, +} +#[derive(Debug, Clone)] +pub struct CompiledRule { + op: Operation, + name: HeaderName, + value: Option, +} +#[derive(Debug, Clone, Default)] +pub struct CompiledHeaders { + pub request: Vec, + pub response: Vec, +} +impl HeaderRules { + pub fn is_empty(&self) -> bool { + self.request.is_empty() && self.response.is_empty() + } + pub fn compile(&self) -> Result { + Ok(CompiledHeaders { + request: compile_rules(&self.request)?, + response: compile_rules(&self.response)?, + }) + } +} +fn protected(name: &str) -> bool { + matches!( + name, + "host" + | "connection" + | "content-length" + | "transfer-encoding" + | "te" + | "trailer" + | "content-encoding" + | "content-range" + | "upgrade" + | "keep-alive" + | "proxy-connection" + | "proxy-authorization" + | "proxy-authenticate" + | "forwarded" + | "x-real-ip" + | "x-request-id" + | "x-rust-proxy-backend" + | "x-rust-proxy-backend-time" + | "x-rustproxy-backend-time" + ) || name.starts_with("x-forwarded-") + || name.starts_with("sec-websocket-") +} +fn compile_rules(rules: &[Rule]) -> Result> { + ensure!( + rules.len() <= 32, + "at most 32 header rules per direction and scope" + ); + rules + .iter() + .map(|rule| { + ensure!(rule.name.len() <= 128, "header name is too long"); + let name: HeaderName = rule.name.parse().context("invalid header name")?; + ensure!( + !protected(name.as_str()), + "transport/identity header cannot be changed: {}", + name + ); + let value = match (&rule.op, &rule.value) { + (Operation::Remove, None) => None, + (Operation::Remove, Some(_)) => bail!("remove header rule cannot include a value"), + (_, Some(value)) => { + ensure!(value.len() <= 8192, "header value too long"); + Some(value.parse().context("invalid header value")?) + } + (_, None) => bail!("set/append header rule requires a value"), + }; + Ok(CompiledRule { + op: rule.op, + name, + value, + }) + }) + .collect() +} +pub fn apply(headers: &mut HeaderMap, rules: &[CompiledRule]) { + for rule in rules { + match (&rule.op, &rule.value) { + (Operation::Remove, _) => { + headers.remove(&rule.name); + } + (Operation::Set, Some(value)) => { + headers.insert(rule.name.clone(), value.clone()); + } + (Operation::Append, Some(value)) => { + headers.append(rule.name.clone(), value.clone()); + } + _ => unreachable!("only compiled header rules reach the request path"), + } + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn transformations_preserve_distinct_set_cookie_fields() { + let source = r#"{"request":[],"response":[{"op":"remove","name":"server"},{"op":"set","name":"x-test","value":"one"},{"op":"append","name":"x-test","value":"two"},{"op":"append","name":"set-cookie","value":"b=2"}]}"#; + let rules: HeaderRules = serde_json::from_str(source).unwrap(); + let compiled = rules.compile().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("server", "upstream".parse().unwrap()); + headers.append("set-cookie", "a=1".parse().unwrap()); + apply(&mut headers, &compiled.response); + assert!(!headers.contains_key("server")); + assert_eq!(headers.get_all("x-test").iter().count(), 2); + assert_eq!(headers.get_all("set-cookie").iter().count(), 2); + } + #[test] + fn transport_identity_and_crlf_are_rejected() { + for name in [ + "Host", + "Connection", + "Content-Length", + "x-forwarded-for", + "Upgrade", + "X-RustProxy-Backend", + "X-RustProxy-Backend-Time", + "X-Rust-Proxy-Backend-Time", + ] { + let rules = HeaderRules { + request: vec![Rule { + op: Operation::Set, + name: name.into(), + value: Some("x".into()), + }], + response: vec![], + }; + assert!(rules.compile().is_err(), "{name}"); + } + let rules = HeaderRules { + request: vec![Rule { + op: Operation::Set, + name: "x-test".into(), + value: Some("hello\r\ninjected: yes".into()), + }], + response: vec![], + }; + assert!(rules.compile().is_err()); + } +} diff --git a/src/http3.rs b/src/http3.rs new file mode 100644 index 0000000..b92d912 --- /dev/null +++ b/src/http3.rs @@ -0,0 +1,574 @@ +//! RFC 9114 transport adapter: Quinn (QUIC/TLS 1.3) + h3 (HTTP/3/QPACK). +//! Every request uses the SAME router, ACLs, auth, cache and response policies. +//! No full-body buffering or 0-RTT replay. RFC 9220 WebSocket Extended CONNECT is supported; WebTransport is not. +use crate::{ + metrics::Metrics, + runtime::App, + server::{LocalAddr, PeerAddr, ServerShutdown, Transport}, + vhost::Listener, +}; +use anyhow::{ensure, Context as _, Result}; +use axum::{body::Body, Router}; +use bytes::{Buf, Bytes}; +use h3::error::Code; +use http::{HeaderMap, HeaderValue, Method, Version}; +use http_body::{Body as HttpBody, Frame, SizeHint}; +use http_body_util::BodyExt; +use serde::{Deserialize, Serialize}; +use std::{ + io, + net::SocketAddr, + pin::Pin, + sync::{atomic::Ordering, Arc}, + task::{Context, Poll}, + time::Duration, +}; +use tokio::{ + sync::{OwnedSemaphorePermit, Semaphore}, + task::JoinSet, +}; +use tower::ServiceExt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + pub alt_svc_max_age: u64, + pub max_concurrent_connections: u32, + pub max_concurrent_bidistream: u32, + pub max_concurrent_unistream: u32, + /// QUIC idle timeout in seconds; zero disables the transport idle timeout. + pub max_idle_timeout: u64, +} +impl Default for Settings { + fn default() -> Self { + Self { + alt_svc_max_age: 3600, + max_concurrent_connections: 512, + max_concurrent_bidistream: 100, + max_concurrent_unistream: 100, + max_idle_timeout: 10, + } + } +} +impl Settings { + pub fn validate(&self) -> Result<()> { + ensure!( + self.alt_svc_max_age <= 604800, + "HTTP/3 Alt-Svc lifetime must be at most seven days" + ); + ensure!( + (1..=4096).contains(&self.max_concurrent_connections), + "HTTP/3 connections per listener must be 1..4096" + ); + ensure!( + (1..=1024).contains(&self.max_concurrent_bidistream), + "HTTP/3 bidirectional streams must be 1..1024" + ); + ensure!((3..=1024).contains(&self.max_concurrent_unistream),"HTTP/3 requires at least three peer unidirectional streams (control and QPACK), maximum 1024"); + ensure!( + self.max_idle_timeout <= 86400, + "HTTP/3 idle timeout must be 0..86400 seconds" + ); + Ok(()) + } + pub fn transport(&self) -> Result { + self.validate()?; + let mut transport = quinn::TransportConfig::default(); + transport + .max_concurrent_bidi_streams(self.max_concurrent_bidistream.into()) + .max_concurrent_uni_streams(self.max_concurrent_unistream.into()) + .max_idle_timeout(if self.max_idle_timeout == 0 { + None + } else { + Some(Duration::from_secs(self.max_idle_timeout).try_into()?) + }) + .stream_receive_window((256 * 1024u32).into()) + .receive_window((8 * 1024 * 1024u32).into()) + .send_window(8 * 1024 * 1024) + .datagram_receive_buffer_size(None); + Ok(transport) + } +} +pub fn crypto( + tls: &Arc, + policy: &crate::tls::ProtocolPolicy, +) -> Result> { + ensure!( + policy.allows_http3(), + "HTTP/3 requires TLS 1.3 and a TLS 1.3 cipher suite" + ); + let mut provider = rustls::crypto::ring::default_provider(); + if !policy.cipher_suites.is_empty() { + provider.cipher_suites.retain(|suite| { + let name = format!("{:?}", suite.suite()); + policy + .cipher_suites + .iter() + .any(|selected| selected == &name) + }); + } + let mut quic = rustls::ServerConfig::builder_with_provider(Arc::new(provider)) + .with_protocol_versions(&[&rustls::version::TLS13])? + .with_no_client_auth() + .with_cert_resolver(tls.cert_resolver.clone()); + quic.alpn_protocols = vec![b"h3".to_vec()]; + quic.max_early_data_size = 0; + Ok(Arc::new(quinn::crypto::rustls::QuicServerConfig::try_from( + quic, + )?)) +} +pub struct Bound { + endpoint: quinn::Endpoint, + server_config: quinn::ServerConfig, + address: SocketAddr, + settings: Settings, +} +pub fn bind(listener: Listener, state: &App) -> Result { + let settings = listener + .http3 + .context("HTTP/3 is disabled on this listener")?; + settings.validate()?; + ensure!(listener.tls, "HTTP/3 listener requires TLS"); + let crypto = state + .config + .load() + .quic_tls + .clone() + .context("HTTP/3 certificate unavailable")?; + let mut server_config = quinn::ServerConfig::with_crypto(crypto); + server_config + .transport_config(Arc::new(settings.transport()?)) + .migration(false) + .max_incoming(settings.max_concurrent_connections as usize) + .incoming_buffer_size(64 * 1024) + .incoming_buffer_size_total(1024 * 1024); + let endpoint = quinn::Endpoint::server(server_config.clone(), listener.address) + .with_context(|| format!("cannot bind HTTP/3 UDP {}", listener.address))?; + Ok(Bound { + endpoint, + server_config, + address: listener.address, + settings, + }) +} +/// Derive the advertised endpoint from ACTUALLY bound sockets, never saved intent +/// or a backend Alt-Svc header. A pending H3 enable is not advertised before restart. +pub fn advertise( + headers: &mut HeaderMap, + local: Option, + tls: bool, + active: &[Listener], +) { + headers.remove("alt-svc"); + if !tls { + return; + } + let Some(local) = local else { + return; + }; + if let Some(settings) = active + .iter() + .find(|binding| binding.matches(local, true)) + .and_then(|binding| binding.http3) + { + let value = format!("h3=\":{}\"; ma={}", local.port(), settings.alt_svc_max_age); + if let Ok(value) = HeaderValue::from_str(&value) { + headers.insert("alt-svc", value); + } + } +} +struct Lease { + _permit: OwnedSemaphorePermit, + metrics: Arc, +} +impl Drop for Lease { + fn drop(&mut self) { + self.metrics + .http3_active_connections + .fetch_sub(1, Ordering::Relaxed); + self.metrics + .active_connections + .fetch_sub(1, Ordering::Relaxed); + } +} +pub async fn serve(bound: Bound, router: Router, state: Arc, grace: Duration) -> Result<()> { + let slots = Arc::new(Semaphore::new( + bound.settings.max_concurrent_connections as usize, + )); + let mut tasks = JoinSet::new(); + loop { + tokio::select! { + biased; + _=state.shutdown.cancelled()=>break, + _=tasks.join_next(),if !tasks.is_empty()=>{}, + incoming=bound.endpoint.accept()=>{ + let Some(incoming)=incoming else {break;}; + if !incoming.remote_address_validated() {let _=incoming.retry();continue;} + let Ok(permit)=slots.clone().try_acquire_owned() else { + state.metrics.http3_rejected_connections.fetch_add(1,Ordering::Relaxed); + state.metrics.rejected_connections.fetch_add(1,Ordering::Relaxed); + incoming.refuse();continue; + }; + let mut config=bound.server_config.clone(); + let Some(crypto)=state.config.load().quic_tls.clone() else {incoming.refuse();continue;}; + // Certificate changes apply to new handshakes without changing + // active transport limits or regenerating the Retry token key. + config.crypto=crypto; + let connecting=match incoming.accept_with(Arc::new(config)) {Ok(c)=>c,Err(_)=>continue}; + let router=router.clone();let state=state.clone();let address=bound.address; + let limit=bound.settings.max_concurrent_bidistream as usize; + tasks.spawn(async move { + let connection=tokio::select! { + _=state.shutdown.cancelled()=>return, + result=tokio::time::timeout(Duration::from_secs(10),connecting)=>match result { + Ok(Ok(connection))=>connection, + _=>{state.metrics.tls_handshake_errors.fetch_add(1,Ordering::Relaxed);return;}, + } + }; + // A wildcard without destination metadata must fail closed. + let local=match connection.local_ip() { + Some(ip)=>SocketAddr::new(ip,address.port()), + None if !address.ip().is_unspecified()=>address, + None=>{connection.close(0x102u32.into(),b"local destination unavailable");return;}, + }; + state.metrics.connections.fetch_add(1,Ordering::Relaxed); + state.metrics.active_connections.fetch_add(1,Ordering::Relaxed); + state.metrics.http3_connections.fetch_add(1,Ordering::Relaxed); + state.metrics.http3_active_connections.fetch_add(1,Ordering::Relaxed); + state.metrics.tls_connections.fetch_add(1,Ordering::Relaxed); + let _lease=Lease {_permit:permit,metrics:state.metrics.clone()}; + if let Err(error)=connection_loop(connection,local,router,state,limit,grace).await { + tracing::debug!(%error,"HTTP/3 connection ended"); + } + }); + } + } + } + // Connection tasks send GOAWAY and drain; only close the endpoint after the + // grace period, not immediately after a response FIN (which can lose data). + let _ = tokio::time::timeout(grace, async { while tasks.join_next().await.is_some() {} }).await; + tasks.abort_all(); + while tasks.join_next().await.is_some() {} + bound.endpoint.close(0x100u32.into(), b"shutdown"); + let _ = tokio::time::timeout(Duration::from_secs(1), bound.endpoint.wait_idle()).await; + Ok(()) +} +async fn connection_loop( + connection: quinn::Connection, + local: SocketAddr, + router: Router, + state: Arc, + limit: usize, + grace: Duration, +) -> Result<()> { + let mut builder = h3::server::builder(); + builder.max_field_section_size(32 * 1024); + // RFC 9220 remains an explicit experimental opt-in. Avoid advertising + // Extended CONNECT on listeners that only use classic HTTP/1.1 WebSockets. + builder.enable_extended_connect(state.config.load().websocket_rfc9220_on(local)); + let mut h3 = builder + .build(h3::quinn::Connection::new(connection.clone())) + .await?; + let mut requests = JoinSet::new(); + let mut draining = false; + let deadline = tokio::time::sleep(Duration::from_secs(86400)); + tokio::pin!(deadline); + loop { + tokio::select! { + biased; + _=state.shutdown.cancelled(),if !draining=>{ + draining=true;deadline.as_mut().reset(tokio::time::Instant::now()+grace); + h3.shutdown(0).await?; + }, + _=&mut deadline,if draining=>break, + _=requests.join_next(),if !requests.is_empty()=>{}, + next=h3.accept()=>match next { + Ok(Some(resolver))=>{ + // Completed JoinSet entries are drained before accepting more. + if requests.len()>=limit {drop(resolver);continue;} + let router=router.clone();let state=state.clone();let peer=connection.remote_address(); + requests.spawn(async move { + let Ok(Ok((mut request,stream)))=tokio::time::timeout(state.header_timeout,resolver.resolve_request()).await else {return;}; + *request.version_mut()=Version::HTTP_3; + let protocol=request.extensions().get::().map(|protocol|protocol.as_str().to_owned()); + let extended_connect=request.method()==Method::CONNECT && protocol.is_some(); + let websocket=extended_connect && protocol.as_deref()==Some("websocket"); + let (mut send,receive)=stream.split(); + if invalid_headers(request.headers()) { + send.stop_stream(Code::H3_MESSAGE_ERROR);return; + } + let expected=request.headers().get("content-length").and_then(|v|v.to_str().ok()).and_then(|v|v.parse::().ok()); + let mut tunnel_receive=None; + let mut request=if websocket { + // RFC 9220 DATA belongs to the WebSocket byte stream, not + // to an HTTP request body. Keep the receive half for the + // transport bridge after the router accepts the tunnel. + tunnel_receive=Some(receive); + request.map(|_|Body::empty()) + } else if extended_connect { + // Unsupported Extended CONNECT protocols are answered by + // the router (501). They are not interpreted as HTTP bodies. + drop(receive); + request.map(|_|Body::empty()) + } else { + request.map(|_|Body::new(ReceiveBody {stream:receive,expected,received:0,data_done:false,done:false})) + }; + request.extensions_mut().insert(PeerAddr(peer));request.extensions_mut().insert(LocalAddr(local)); + request.extensions_mut().insert(Transport {tls:true}); + request.extensions_mut().insert(ServerShutdown(state.shutdown.clone())); + let response=match router.oneshot(request).await {Ok(response)=>response,Err(never)=>match never {}}; + let (mut parts,mut body)=response.into_parts(); + let tunnel=parts.extensions.remove::(); + crate::proxy::headers::strip_hop_by_hop(&mut parts.headers); + parts.version=Version::HTTP_3; + let response=http::Response::from_parts(parts,()); + let sent=async { + tokio::time::timeout(state.body_idle_timeout,send.send_response(response)).await??; + if let Some(tunnel)=tunnel { + let Some(mut receive)=tunnel_receive.take() else { + send.stop_stream(Code::H3_INTERNAL_ERROR); + return Err(anyhow::anyhow!("HTTP/3 tunnel response without a WebSocket Extended CONNECT request")); + }; + drop(body); + if let Err(error)=crate::proxy::websocket::run_h3(state.clone(),&mut send,&mut receive,tunnel).await { + // run_h3 owns RFC 9220 tunnel shutdown/reset semantics. + // Do not overwrite its H3_REQUEST_CANCELLED with a + // second generic H3_INTERNAL_ERROR here. + tracing::debug!(%error,"HTTP/3 WebSocket tunnel closed"); + } + return Ok::<(),anyhow::Error>(()); + } + if let Some(mut receive)=tunnel_receive.take() { + // The WebSocket bootstrap was rejected before becoming + // a tunnel. Stop accepting request DATA after sending the + // ordinary HTTP error response. + receive.stop_sending(Code::H3_REQUEST_CANCELLED); + } + while let Some(frame)=body.frame().await { + let frame=frame?; + match frame.into_data() { + Ok(data)=>{if !data.is_empty() {tokio::time::timeout(state.body_idle_timeout,send.send_data(data)).await??;}}, + Err(frame)=>{if let Ok(mut trailers)=frame.into_trailers() { + crate::proxy::headers::strip_hop_by_hop(&mut trailers); + tokio::time::timeout(state.body_idle_timeout,send.send_trailers(trailers)).await??; + }}, + } + } + tokio::time::timeout(state.body_idle_timeout,send.finish()).await??; + Ok::<(),anyhow::Error>(()) + }.await; + if let Err(error)=sent {tracing::debug!(%error,"HTTP/3 response interrupted");send.stop_stream(Code::H3_INTERNAL_ERROR);} + }); + }, + Ok(None)=>break, + Err(error)=>{requests.abort_all();return Err(error.into());}, + } + } + } + let _ = tokio::time::timeout(grace, async { + while requests.join_next().await.is_some() {} + }) + .await; + requests.abort_all(); + while requests.join_next().await.is_some() {} + // Retain the transport until the client closes or the bounded drain expires. + if draining { + let _ = tokio::time::timeout(grace, connection.closed()).await; + } + Ok(()) +} +fn invalid_headers(headers: &HeaderMap) -> bool { + [ + "connection", + "keep-alive", + "proxy-connection", + "transfer-encoding", + "upgrade", + ] + .iter() + .any(|name| headers.contains_key(*name)) + || headers + .get_all("te") + .iter() + .any(|value| value.as_bytes() != b"trailers") + || headers.get_all("content-length").iter().count() > 1 + || headers.get("content-length").is_some_and(|value| { + value + .to_str() + .ok() + .and_then(|s| s.parse::().ok()) + .is_none() + }) +} +struct ReceiveBody { + stream: h3::server::RequestStream, + expected: Option, + received: u64, + data_done: bool, + done: bool, +} +impl HttpBody for ReceiveBody { + type Data = Bytes; + type Error = io::Error; + fn poll_frame( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, io::Error>>> { + let this = self.get_mut(); + if this.done { + return Poll::Ready(None); + } + if !this.data_done { + match this.stream.poll_recv_data(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => { + this.done = true; + return Poll::Ready(Some(Err(io::Error::other(error.to_string())))); + } + Poll::Ready(Ok(Some(mut data))) => { + let length = data.remaining(); + this.received = this.received.saturating_add(length as u64); + if this + .expected + .is_some_and(|expected| this.received > expected) + { + drop(data); + this.done = true; + this.stream.stop_sending(Code::H3_MESSAGE_ERROR); + return Poll::Ready(Some(Err(io::Error::other( + "HTTP/3 Content-Length exceeded", + )))); + } + return Poll::Ready(Some(Ok(Frame::data(data.copy_to_bytes(length))))); + } + Poll::Ready(Ok(None)) => this.data_done = true, + } + } + match this.stream.poll_recv_trailers(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(error)) => { + this.done = true; + Poll::Ready(Some(Err(io::Error::other(error.to_string())))) + } + Poll::Ready(Ok(trailers)) => { + this.done = true; + if this + .expected + .is_some_and(|expected| expected != this.received) + { + this.stream.stop_sending(Code::H3_MESSAGE_ERROR); + return Poll::Ready(Some(Err(io::Error::other( + "HTTP/3 Content-Length mismatch", + )))); + } + Poll::Ready(trailers.map(|mut headers| { + crate::proxy::headers::strip_hop_by_hop(&mut headers); + Ok(Frame::trailers(headers)) + })) + } + } + } + fn is_end_stream(&self) -> bool { + self.done + } + fn size_hint(&self) -> SizeHint { + // A declared zero is not proof that the peer ended its stream. Unknown + // lengths are verified by the cache's bounded empty-body probe. + let mut hint = SizeHint::new(); + if self.done { + hint.set_exact(0); + } else if let Some(remaining) = self + .expected + .map(|n| n.saturating_sub(self.received)) + .filter(|n| *n > 0) + { + hint.set_upper(remaining); + } + hint + } +} +impl Drop for ReceiveBody { + fn drop(&mut self) { + // Dropping the request stream cancels the receive side at QUIC level. + // Explicit STOP_SENDING is used only from poll_frame paths where the + // stream handle is available and a protocol error was detected. + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn requested_defaults_and_infinite_idle_are_valid() { + let s = Settings::default(); + assert_eq!( + ( + s.alt_svc_max_age, + s.max_concurrent_connections, + s.max_concurrent_bidistream, + s.max_concurrent_unistream, + s.max_idle_timeout + ), + (3600, 512, 100, 100, 10) + ); + assert!(s.transport().is_ok()); + assert!(Settings { + max_idle_timeout: 0, + ..s + } + .transport() + .is_ok()); + } + #[test] + fn critical_streams_and_resource_limits_are_validated() { + assert!(Settings { + max_concurrent_unistream: 2, + ..Default::default() + } + .validate() + .is_err()); + assert!(Settings { + max_concurrent_connections: 0, + ..Default::default() + } + .validate() + .is_err()); + assert!(Settings { + max_concurrent_bidistream: 0, + ..Default::default() + } + .validate() + .is_err()); + } + #[test] + fn alt_svc_only_for_actual_tls_udp_listener() { + let binding = Listener { + address: "127.0.0.1:8443".parse().unwrap(), + tls: true, + http2: true, + http2_settings: Some(crate::settings::Http2Connections::default()), + h2c: false, + http3: Some(Settings::default()), + }; + let mut h = HeaderMap::new(); + h.insert("alt-svc", "h3=\":9999\"".parse().unwrap()); + advertise(&mut h, Some(binding.address), true, &[binding]); + assert_eq!(h["alt-svc"], "h3=\":8443\"; ma=3600"); + advertise(&mut h, Some(binding.address), false, &[binding]); + assert!(!h.contains_key("alt-svc")); + advertise( + &mut h, + Some("127.0.0.2:8443".parse().unwrap()), + true, + &[binding], + ); + assert!(!h.contains_key("alt-svc")); + } + #[test] + fn h3_rejects_http1_connection_specific_headers() { + for name in ["connection", "transfer-encoding", "upgrade"] { + let mut h = HeaderMap::new(); + h.insert(name, "anything".parse().unwrap()); + assert!(invalid_headers(&h)); + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b66df9d --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,41 @@ +pub mod acl; +pub mod acme; +pub mod admin; +#[path = "auth/mod.rs"] +pub mod auth; +pub mod basic_auth; +pub mod config; +pub mod db; +pub mod demo; +pub mod headers; +pub mod logging; +pub mod metrics; +pub mod not_found; +pub mod proxy; +pub mod runtime; +pub mod server; +pub mod tls; +pub mod upstream; +pub mod update_check; + +pub mod cache; +pub mod compression; +pub mod transform; +pub mod diagnostics; +pub mod docker; +pub mod pages; +pub mod settings; +pub mod stream; +pub mod system_stats; + +pub mod balance; +pub mod forwarding; +pub mod geoip; +pub mod http3; +pub mod security; +pub mod tinyauth; +pub mod vhost; + +pub mod prometheus; +pub mod rate_limit; +pub mod redirect; diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..d8df190 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,731 @@ +//! Bounded live access ring plus optional asynchronous structured-log sinks. +//! Request handling only performs a try-lock/try-send; file/network I/O lives on +//! the background worker and is bounded by a fixed queue. +use anyhow::{ensure, Context, Result}; +use arc_swap::ArcSwap; +use axum::body::Body; +use http::{Request, Uri}; +use http_body_util::{BodyExt, Limited}; +use hyper_rustls::HttpsConnectorBuilder; +use hyper_util::{ + client::legacy::{connect::HttpConnector, Client}, + rt::TokioExecutor, +}; +use rustls::{ClientConfig, RootCertStore}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use std::{ + collections::{BTreeMap, VecDeque}, + path::PathBuf, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, + }, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio::{ + fs::{File, OpenOptions}, + io::AsyncWriteExt, + net::TcpStream, + sync::{broadcast, mpsc}, +}; +use tokio_util::sync::CancellationToken; + +pub const LIVE_WINDOW: usize = 256; +pub const HISTORY_CAPACITY: usize = 4096; +pub const LIVE_STREAM_CAPACITY: usize = 4096; +const SINK_QUEUE: usize = 512; +const SINK_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct FileSink { + pub enabled: bool, + pub path: String, + /// Write one JSONL file per resolved vhost instead of one combined file. + #[serde(default)] + pub per_vhost: bool, + /// Directory used by per-vhost mode. Files are named `.jsonl`; + /// unmatched requests are written to `_unmatched.jsonl`. + #[serde(default = "default_vhost_log_directory")] + pub directory: String, +} +fn default_vhost_log_directory() -> String { + "/var/log/rustproxy/vhosts".into() +} +impl Default for FileSink { + fn default() -> Self { + Self { + enabled: false, + path: "/var/log/rustproxy/access.jsonl".into(), + per_vhost: false, + directory: default_vhost_log_directory(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct LogstashSink { + pub enabled: bool, + pub address: String, +} +impl Default for LogstashSink { + fn default() -> Self { + Self { + enabled: false, + address: "127.0.0.1:5044".into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct LokiSink { + pub enabled: bool, + pub url: String, + pub bearer_token_env: Option, + pub labels: BTreeMap, +} +impl Default for LokiSink { + fn default() -> Self { + Self { + enabled: false, + url: "http://127.0.0.1:3100/loki/api/v1/push".into(), + bearer_token_env: None, + labels: BTreeMap::from([("job".into(), "rustproxy".into())]), + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct JsonFormat { + /// output field -> LogEntry field. Empty means the complete standard entry. + pub fields: BTreeMap, + pub static_fields: BTreeMap, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + pub file: FileSink, + pub logstash: LogstashSink, + pub loki: LokiSink, + pub format: JsonFormat, +} +impl Settings { + pub fn enabled(&self) -> bool { + self.file.enabled || self.logstash.enabled || self.loki.enabled + } + pub fn validate(&self) -> Result<()> { + if self.file.enabled { + if self.file.per_vhost { + ensure!( + !self.file.directory.trim().is_empty() && self.file.directory.len() <= 1024, + "logging.file.directory must be 1..1024 bytes in per-vhost mode" + ); + } else { + ensure!( + !self.file.path.trim().is_empty() && self.file.path.len() <= 1024, + "logging.file.path must be 1..1024 bytes" + ); + } + } + if self.logstash.enabled { + let authority = self + .logstash + .address + .parse::() + .context("logging.logstash.address must be host:port")?; + ensure!( + !authority.host().is_empty() + && authority.port_u16().is_some() + && !authority.as_str().contains('@'), + "logging.logstash.address must be host:port" + ); + } + if self.loki.enabled { + ensure!(self.loki.url.len() <= 2048, "logging.loki.url is too long"); + let url = url::Url::parse(&self.loki.url).context("invalid logging.loki.url")?; + ensure!( + matches!(url.scheme(), "http" | "https") && url.host().is_some(), + "logging.loki.url must be HTTP(S)" + ); + ensure!( + url.username().is_empty() && url.password().is_none(), + "credentials in logging.loki.url are forbidden; use bearer_token_env" + ); + } + if let Some(name) = &self.loki.bearer_token_env { + ensure!( + !name.is_empty() + && name.len() <= 128 + && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_'), + "logging.loki.bearer_token_env must be a simple environment variable name" + ); + } + ensure!( + self.loki.labels.len() <= 32, + "at most 32 Loki labels are allowed" + ); + for (key, value) in &self.loki.labels { + ensure!( + !key.is_empty() + && key.len() <= 64 + && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_'), + "Loki label names must be 1..64 letters, numbers or underscores" + ); + ensure!( + value.len() <= 256, + "Loki label values must be at most 256 bytes" + ); + } + ensure!( + self.format.fields.len() <= 32 && self.format.static_fields.len() <= 32, + "structured log format allows at most 32 mapped and 32 static fields" + ); + for (output, source) in &self.format.fields { + validate_json_key(output)?; + ensure!( + source_value_name(source), + "unknown structured log source field: {source}" + ); + } + for (key, value) in &self.format.static_fields { + validate_json_key(key)?; + ensure!( + value.len() <= 1024, + "structured log static value is too long" + ); + } + Ok(()) + } +} +fn validate_json_key(key: &str) -> Result<()> { + ensure!( + !key.is_empty() && key.len() <= 128 && !key.chars().any(char::is_control), + "structured log output field names must be 1..128 bytes without controls" + ); + Ok(()) +} +fn source_value_name(name: &str) -> bool { + matches!( + name, + "vhost" + | "backend" + | "id" + | "time_ms" + | "method" + | "host" + | "path" + | "http_version" + | "transport" + | "websocket_bootstrap" + | "websocket_phase" + | "websocket_error" + | "h3_reset_code" + | "upstream_status" + | "upstream_error" + | "client_ip" + | "status" + | "duration_ms" + | "request_bytes" + | "response_bytes" + | "outcome" + | "cache" + | "cache_reason" + ) +} + +#[derive(Debug, Clone, Serialize)] +pub struct LogEntry { + pub vhost: Option, + pub route: Option, + pub cache_effective_enabled: Option, + pub transform_effective_enabled: Option, + pub backend: Option, + pub id: u64, + pub time_ms: u64, + pub method: String, + pub host: String, + pub path: String, + pub http_version: &'static str, + pub transport: &'static str, + pub websocket_bootstrap: Option<&'static str>, + pub websocket_phase: Option<&'static str>, + pub websocket_error: Option<&'static str>, + pub h3_reset_code: Option<&'static str>, + pub upstream_status: Option, + pub upstream_error: Option, + pub client_ip: Option, + pub status: u16, + pub duration_ms: u64, + pub request_bytes: u64, + pub response_bytes: u64, + pub outcome: &'static str, + pub cache: &'static str, + pub cache_reason: &'static str, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SinkStats { + pub queue_capacity: usize, + pub queue_dropped: u64, + pub sent: u64, + pub errors: u64, +} + +pub struct Logs { + entries: Mutex>, + pub dropped: AtomicU64, + settings: Arc>, + live: broadcast::Sender, + sink: mpsc::Sender, + sink_dropped: Arc, + sink_sent: Arc, + sink_errors: Arc, +} +impl Default for Logs { + fn default() -> Self { + let (sink, receiver) = mpsc::channel(SINK_QUEUE); + drop(receiver); + let (live, _) = broadcast::channel(LIVE_STREAM_CAPACITY); + Self { + entries: Mutex::new(VecDeque::new()), + dropped: AtomicU64::new(0), + settings: Arc::new(ArcSwap::from_pointee(Settings::default())), + live, + sink, + sink_dropped: Arc::new(AtomicU64::new(0)), + sink_sent: Arc::new(AtomicU64::new(0)), + sink_errors: Arc::new(AtomicU64::new(0)), + } + } +} +impl Logs { + pub fn start(settings: Settings, shutdown: CancellationToken) -> Arc { + let (tx, rx) = mpsc::channel(SINK_QUEUE); + let (live, _) = broadcast::channel(LIVE_STREAM_CAPACITY); + let settings = Arc::new(ArcSwap::from_pointee(settings)); + let sink_dropped = Arc::new(AtomicU64::new(0)); + let sink_sent = Arc::new(AtomicU64::new(0)); + let sink_errors = Arc::new(AtomicU64::new(0)); + let logs = Arc::new(Self { + entries: Mutex::new(VecDeque::new()), + dropped: AtomicU64::new(0), + settings: settings.clone(), + live, + sink: tx, + sink_dropped: sink_dropped.clone(), + sink_sent: sink_sent.clone(), + sink_errors: sink_errors.clone(), + }); + tokio::spawn(sink_loop(rx, settings, sink_sent, sink_errors, shutdown)); + logs + } + pub fn configure(&self, settings: Settings) { + self.settings.store(Arc::new(settings)); + } + pub fn push(&self, entry: LogEntry) { + if let Ok(mut entries) = self.entries.try_lock() { + if entries.len() == HISTORY_CAPACITY { + entries.pop_front(); + } + entries.push_back(entry.clone()); + } else { + self.dropped.fetch_add(1, Ordering::Relaxed); + } + let _ = self.live.send(entry.clone()); + if self.settings.load().enabled() && self.sink.try_send(entry).is_err() { + self.sink_dropped.fetch_add(1, Ordering::Relaxed); + } + } + pub fn subscribe(&self) -> broadcast::Receiver { + self.live.subscribe() + } + pub fn recent(&self) -> Vec { + self.recent_limit(HISTORY_CAPACITY) + } + pub fn recent_limit(&self, limit: usize) -> Vec { + self.entries + .lock() + .map(|entries| entries.iter().rev().take(limit.min(HISTORY_CAPACITY)).cloned().collect()) + .unwrap_or_default() + } + pub fn stats(&self) -> SinkStats { + SinkStats { + queue_capacity: SINK_QUEUE, + queue_dropped: self.sink_dropped.load(Ordering::Relaxed), + sent: self.sink_sent.load(Ordering::Relaxed), + errors: self.sink_errors.load(Ordering::Relaxed), + } + } +} + +fn value_for(entry: &LogEntry, name: &str) -> Value { + match name { + "vhost" => entry + .vhost + .clone() + .map(Value::String) + .unwrap_or(Value::Null), + "backend" => entry + .backend + .clone() + .map(Value::String) + .unwrap_or(Value::Null), + "id" => json!(entry.id), + "time_ms" => json!(entry.time_ms), + "method" => json!(&entry.method), + "host" => json!(&entry.host), + "path" => json!(&entry.path), + "http_version" => json!(entry.http_version), + "transport" => json!(entry.transport), + "websocket_bootstrap" => entry.websocket_bootstrap.map(|v| Value::String(v.into())).unwrap_or(Value::Null), + "websocket_phase" => entry.websocket_phase.map(|v| Value::String(v.into())).unwrap_or(Value::Null), + "websocket_error" => entry.websocket_error.map(|v| Value::String(v.into())).unwrap_or(Value::Null), + "h3_reset_code" => entry.h3_reset_code.map(|v| Value::String(v.into())).unwrap_or(Value::Null), + "upstream_status" => entry.upstream_status.map(|v| json!(v)).unwrap_or(Value::Null), + "upstream_error" => entry.upstream_error.clone().map(Value::String).unwrap_or(Value::Null), + "client_ip" => entry.client_ip.clone().map(Value::String).unwrap_or(Value::Null), + "status" => json!(entry.status), + "duration_ms" => json!(entry.duration_ms), + "request_bytes" => json!(entry.request_bytes), + "response_bytes" => json!(entry.response_bytes), + "outcome" => json!(entry.outcome), + "cache" => json!(entry.cache), + "cache_reason" => json!(entry.cache_reason), + _ => Value::Null, + } +} +fn format_entry(entry: &LogEntry, format: &JsonFormat) -> Value { + if format.fields.is_empty() && format.static_fields.is_empty() { + return serde_json::to_value(entry).unwrap_or(Value::Null); + } + let mut object = Map::new(); + if format.fields.is_empty() { + if let Value::Object(defaults) = serde_json::to_value(entry).unwrap_or(Value::Null) { + object.extend(defaults); + } + } else { + for (output, source) in &format.fields { + object.insert(output.clone(), value_for(entry, source)); + } + } + for (key, value) in &format.static_fields { + object.insert(key.clone(), Value::String(value.clone())); + } + Value::Object(object) +} + +type LokiClient = Client, Body>; +fn loki_client() -> Result { + let mut roots = RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let config = + ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_safe_default_protocol_versions()? + .with_root_certificates(roots) + .with_no_client_auth(); + let mut http = HttpConnector::new(); + http.enforce_http(false); + http.set_connect_timeout(Some(SINK_TIMEOUT)); + http.set_nodelay(true); + let connector = HttpsConnectorBuilder::new() + .with_tls_config(config) + .https_or_http() + .enable_http1() + .wrap_connector(http); + Ok(Client::builder(TokioExecutor::new()).build(connector)) +} + +struct FileState { + path: PathBuf, + file: File, +} +const PER_VHOST_OPEN_FILES: usize = 64; + +fn vhost_log_name(vhost: Option<&str>) -> String { + let raw = vhost.filter(|value| !value.is_empty()).unwrap_or("_unmatched"); + let mut safe = String::with_capacity(raw.len().min(96)); + for ch in raw.chars().take(96) { + safe.push(if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { ch } else { '_' }); + } + if safe.is_empty() { "_unmatched".into() } else { safe } +} +struct LogstashState { + address: String, + stream: TcpStream, +} +async fn sink_loop( + mut rx: mpsc::Receiver, + settings: Arc>, + sent: Arc, + errors: Arc, + shutdown: CancellationToken, +) { + let client = loki_client().ok(); + let mut file: Option = None; + let mut vhost_files: BTreeMap = BTreeMap::new(); + let mut vhost_directory: Option = None; + let mut logstash: Option = None; + loop { + let entry = tokio::select! { + _ = shutdown.cancelled() => break, + entry = rx.recv() => match entry { Some(entry) => entry, None => break }, + }; + let config = settings.load_full(); + let payload = format_entry(&entry, &config.format); + let mut attempted = false; + let mut failed = false; + if config.file.enabled { + attempted = true; + let mut line = serde_json::to_vec(&payload).unwrap_or_default(); + line.push(b'\n'); + if config.file.per_vhost { + file = None; + let directory = PathBuf::from(&config.file.directory); + if vhost_directory.as_ref() != Some(&directory) { + vhost_files.clear(); + vhost_directory = Some(directory.clone()); + } + if tokio::fs::create_dir_all(&directory).await.is_err() { + failed = true; + } else { + let path = directory.join(format!("{}.jsonl", vhost_log_name(entry.vhost.as_deref()))); + if !vhost_files.contains_key(&path) { + if vhost_files.len() >= PER_VHOST_OPEN_FILES { + if let Some(path) = vhost_files.keys().next().cloned() { + vhost_files.remove(&path); + } + } + match OpenOptions::new().create(true).append(true).open(&path).await { + Ok(opened) => { vhost_files.insert(path.clone(), opened); } + Err(error) => { + tracing::warn!(%error, path=%path.display(), "per-vhost access log file unavailable"); + failed = true; + } + } + } + let write_failed = match vhost_files.get_mut(&path) { + Some(opened) => opened.write_all(&line).await.is_err(), + None => true, + }; + if write_failed { + failed = true; + vhost_files.remove(&path); + } + } + } else { + vhost_files.clear(); + vhost_directory = None; + let path = PathBuf::from(&config.file.path); + if file.as_ref().is_none_or(|state| state.path != path) { + file = match OpenOptions::new().create(true).append(true).open(&path).await { + Ok(opened) => Some(FileState { path: path.clone(), file: opened }), + Err(error) => { + tracing::warn!(%error, path=%path.display(), "access log file unavailable"); + None + } + }; + } + if let Some(state) = file.as_mut() { + if state.file.write_all(&line).await.is_err() { + failed = true; + file = None; + } + } else { + failed = true; + } + } + } else { + file = None; + vhost_files.clear(); + vhost_directory = None; + } + if config.logstash.enabled { + attempted = true; + let address = config.logstash.address.clone(); + if logstash + .as_ref() + .is_none_or(|state| state.address != address) + { + logstash = + match tokio::time::timeout(SINK_TIMEOUT, TcpStream::connect(&address)).await { + Ok(Ok(stream)) => Some(LogstashState { + address: address.clone(), + stream, + }), + _ => None, + }; + } + let mut line = serde_json::to_vec(&payload).unwrap_or_default(); + line.push(b'\n'); + if let Some(state) = logstash.as_mut() { + if !matches!( + tokio::time::timeout(SINK_TIMEOUT, state.stream.write_all(&line)).await, + Ok(Ok(())) + ) { + failed = true; + logstash = None; + } + } else { + failed = true; + } + } else { + logstash = None; + } + if config.loki.enabled { + attempted = true; + let labels = if config.loki.labels.is_empty() { + BTreeMap::from([("job".to_owned(), "rustproxy".to_owned())]) + } else { + config.loki.labels.clone() + }; + let body = json!({"streams":[{"stream":labels,"values":[[(entry.time_ms as u128 * 1_000_000).to_string(), payload.to_string()]]}]}); + let result = async { + let uri: Uri = config.loki.url.parse()?; + let mut request = Request::post(uri) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&body)?))?; + if let Some(env) = &config.loki.bearer_token_env { + if let Ok(token) = std::env::var(env) { + if !token.is_empty() { + request + .headers_mut() + .insert("authorization", format!("Bearer {token}").parse()?); + } + } + } + let client = client.as_ref().context("Loki HTTP client unavailable")?; + let response = client.request(request).await?; + let status = response.status(); + let _ = Limited::new(response.into_body(), 64 * 1024) + .collect() + .await; + ensure!(status.is_success(), "Loki returned {status}"); + Ok::<(), anyhow::Error>(()) + }; + if !matches!(tokio::time::timeout(SINK_TIMEOUT, result).await, Ok(Ok(()))) { + failed = true; + } + } + if attempted { + if failed { + errors.fetch_add(1, Ordering::Relaxed); + } else { + sent.fetch_add(1, Ordering::Relaxed); + } + } + } +} + +pub fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry() -> LogEntry { + LogEntry { + vhost: Some("app".into()), + route: Some("root".into()), + cache_effective_enabled: Some(true), + transform_effective_enabled: Some(false), + backend: Some("origin".into()), + id: 7, + time_ms: 1_700_000_000_000, + method: "GET".into(), + host: "app.example".into(), + path: "/health".into(), + http_version: "3", + transport: "h3", + websocket_bootstrap: Some("h3_extended_connect"), + websocket_phase: Some("tunnel_closed"), + websocket_error: None, + h3_reset_code: None, + upstream_status: Some(101), + upstream_error: None, + client_ip: Some("192.0.2.10".into()), + status: 200, + duration_ms: 12, + request_bytes: 10, + response_bytes: 42, + outcome: "ok", + cache: "HIT", + cache_reason: "cache_hit", + } + } + + #[test] + fn json_format_can_map_and_add_static_fields() { + let mut format = JsonFormat::default(); + format.fields.insert("http_status".into(), "status".into()); + format.fields.insert("site".into(), "vhost".into()); + format.static_fields.insert("service".into(), "edge".into()); + let value = format_entry(&entry(), &format); + assert_eq!(value["http_status"], 200); + assert_eq!(value["site"], "app"); + assert_eq!(value["service"], "edge"); + assert!(value.get("method").is_none()); + } + + #[test] + fn logging_validation_rejects_credentials_unknown_fields_and_bad_endpoints() { + let mut settings = Settings::default(); + settings.loki.enabled = true; + settings.loki.url = "https://user:secret@example.invalid/loki/api/v1/push".into(); + assert!(settings.validate().is_err()); + + let mut settings = Settings::default(); + settings.logstash.enabled = true; + settings.logstash.address = "missing-port.example".into(); + assert!(settings.validate().is_err()); + + let mut settings = Settings::default(); + settings + .format + .fields + .insert("status".into(), "does_not_exist".into()); + assert!(settings.validate().is_err()); + } + + #[test] + fn per_vhost_file_mode_has_safe_names_and_requires_directory() { + assert_eq!(vhost_log_name(Some("app-1")), "app-1"); + assert_eq!(vhost_log_name(None), "_unmatched"); + assert_eq!(vhost_log_name(Some("bad/name")), "bad_name"); + let mut settings = Settings::default(); + settings.file.enabled = true; + settings.file.per_vhost = true; + settings.file.directory.clear(); + assert!(settings.validate().is_err()); + } + + #[test] + fn default_json_format_keeps_standard_entry() { + let value = format_entry(&entry(), &JsonFormat::default()); + assert_eq!(value["method"], "GET"); + assert_eq!(value["cache"], "HIT"); + assert_eq!(value["response_bytes"], 42); + } + + #[test] + fn retained_history_is_larger_than_the_live_snapshot_but_still_bounded() { + let logs = Logs::default(); + for id in 0..(LIVE_WINDOW + 80) { + let mut item = entry(); + item.id = id as u64; + logs.push(item); + } + assert_eq!(logs.recent_limit(LIVE_WINDOW).len(), LIVE_WINDOW); + assert_eq!(logs.recent().len(), LIVE_WINDOW + 80); + assert_eq!(logs.recent_limit(HISTORY_CAPACITY + 1).len(), LIVE_WINDOW + 80); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..4437744 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1962 @@ +use anyhow::{bail, ensure, Context, Result}; +use axum::body::Body; +use clap::{parser::ValueSource, Args, CommandFactory, FromArgMatches, Parser, Subcommand}; +use http::{Method, Request, StatusCode}; +use http_body_util::{BodyExt, Limited}; +use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder}; +use hyper_util::{ + client::legacy::{connect::HttpConnector, Client}, + rt::TokioExecutor, +}; +use rustls::{ + client::{ + danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, + WebPkiServerVerifier, + }, + pki_types::{CertificateDer, ServerName, UnixTime}, + ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme, +}; +use rust_proxy::{ + auth, + config::{self, Config, Envelope, ValidationPolicy}, + db::Store, + runtime::App, +}; +use sha2::{Digest, Sha256}; +use std::io::{self, BufRead, IsTerminal, Read, Write}; +use std::{ + net::SocketAddr, + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; +use tokio::{ + net::{TcpListener, UdpSocket}, + task::JoinSet, +}; +use zeroize::Zeroizing; + +#[derive(Parser)] +#[command( + name = "rust-proxy", + version, + about = "RustProxy - independent Rust reverse proxy 0.24.5" +)] +struct Cli { + #[arg( + long, + global = true, + env = "RUST_PROXY_DATA_DIR", + default_value = "./data" + )] + data_dir: PathBuf, + #[arg( + long, + global = true, + env = "RUST_PROXY_ADMIN_LISTEN", + default_value = "127.0.0.1:9090" + )] + admin_listen: SocketAddr, + #[arg(long, global = true, env = "RUST_PROXY_UPDATE_REPOSITORY_URL")] + update_repository_url: Option, + #[arg(long, global = true, env = "RUST_PROXY_UPDATE_VERSION_URL")] + update_version_url: Option, + /// Seconds between release checks. Set to 0 to disable automatic checks. + #[arg(long, global = true, env = "RUST_PROXY_UPDATE_CHECK_INTERVAL_SECS")] + update_check_interval_secs: Option, + /// Deprecated compatibility no-op kept so older systemd units continue to start. + #[arg(long = "accept-remote-admin-risk", global = true, hide = true)] + _accept_remote_admin_risk: bool, + /// Interactive varnishlog-style live request view from all vhosts. + #[arg(long, global = true)] + livelogs: bool, + #[command(subcommand)] + command: Option, +} +#[derive(Subcommand)] +enum Command { + /// Start proxy and local administration listener. + Serve(ServeOptions), + /// Compatibility alias for `admin setup-token`; never an API access token. + Token, + /// Local administrator setup, password recovery and session management. + Admin { + #[command(subcommand)] + action: AdminCommand, + }, + /// Manage local certificate files and hot-reload certificate bindings. + Tls { + #[command(subcommand)] + action: TlsCommand, + }, + /// Read or replace configuration through the running admin API. + Config { + #[command(subcommand)] + action: ConfigCommand, + }, +} +#[derive(Subcommand)] +enum AdminCommand { + /// Create the sole administrator. Requires ownership of the private data directory. + Init { + #[arg(long)] + username: Option, + #[arg(long)] + password_stdin: bool, + }, + /// Replace the administrator password and revoke all sessions, online or offline. + ResetPassword { + #[arg(long)] + password_stdin: bool, + }, + /// Generate or print the one-time setup capability, only before an administrator exists. + SetupToken, + /// Report whether administrator setup is complete (no credentials printed). + Status, + /// Revoke all browser/CLI sessions without changing the password. + LogoutAll, +} +#[derive(Subcommand)] +enum ConfigCommand { + /// Print desired configuration as JSON (suitable for `config apply`). + Get, + /// Revalidate and hot-publish the current configuration and certificate files. + Reload, + /// Atomically validate, save and apply a full configuration document. + Apply { file: PathBuf }, + /// Validate JSON, ACLs, headers and backend policy without changing the database. + Validate { file: PathBuf }, +} +#[derive(Subcommand)] +enum TlsCommand { + /// Generate a self-signed development certificate. Existing files are never overwritten. + Generate { + #[arg(long)] + name: String, + #[arg(long, value_delimiter = ',', required = true)] + hosts: Vec, + }, + /// Import a matching PEM certificate/key pair under a new name. + Import { + #[arg(long)] + name: String, + #[arg(long)] + cert: PathBuf, + #[arg(long)] + key: PathBuf, + }, + /// Stage an externally issued pair (for example Certbot) and atomically + /// switch an existing manual certificate id to it through the running API. + Deploy { + #[arg(long)] + id: String, + #[arg(long)] + cert: PathBuf, + #[arg(long)] + key: PathBuf, + }, + /// Reload configured certificate files without restarting listeners. + Reload, +} +#[derive(Args)] +struct ServeOptions { + /// Optional HTTPS listener. dev.sh enables 127.0.0.1:8443 unless RUST_PROXY_TLS=0. + #[arg(long, env = "RUST_PROXY_TLS_LISTEN")] + tls_listen: Option, + #[arg( + long, + env = "RUST_PROXY_PROXY_LISTEN", + default_value = "127.0.0.1:8080" + )] + proxy_listen: SocketAddr, + #[arg(long)] + demo: bool, + #[arg(long, env = "RUST_PROXY_DEMO_LISTEN", default_value = "127.0.0.1:9000")] + demo_listen: SocketAddr, + #[arg(long, default_value_t = 512)] + max_connections: usize, + #[arg(long, default_value_t = 256)] + max_inflight: usize, + /// Includes upload time until upstream response headers arrive. + #[arg(long, default_value_t = 120)] + upstream_timeout_secs: u64, + #[arg(long, default_value_t = 60)] + body_idle_timeout_secs: u64, + /// Inactivity in either direction; independent of ordinary HTTP bodies. + #[arg(long, default_value_t = 300)] + websocket_idle_timeout_secs: u64, + #[arg(long, default_value_t = 10)] + shutdown_timeout_secs: u64, + /// Permit an administration listener on an unspecified address such as 0.0.0.0. + #[arg(long)] + allow_remote_admin: bool, +} +#[cfg(target_os = "linux")] +fn disable_transparent_huge_pages() { + // Keep THP policy local to RustProxy. Large 2 MiB anonymous pages amplify + // allocator fragmentation/retention for the long-running proxy workload, so + // opt this process out before Tokio creates worker threads and their heaps. + let result = unsafe { libc::prctl(libc::PR_SET_THP_DISABLE, 1, 0, 0, 0) }; + if result != 0 { + eprintln!( + "RustProxy warning: could not disable Transparent Huge Pages for this process: {}", + io::Error::last_os_error() + ); + } +} + +#[cfg(not(target_os = "linux"))] +fn disable_transparent_huge_pages() {} + +fn main() -> Result<()> { + disable_transparent_huge_pages(); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + let result = runtime.block_on(application()); + // OS getaddrinfo workers cannot be killed by cancelling their future. + // Bound runtime shutdown after listeners and application tasks have drained. + runtime.shutdown_timeout(Duration::from_secs(2)); + result +} + +fn installed_data_dir_candidates() -> &'static [&'static str] { + #[cfg(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly"))] + { + return &["/var/db/rustproxy", "/var/lib/rustproxy"]; + } + #[cfg(not(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly")))] + { + &["/var/lib/rustproxy", "/var/db/rustproxy"] + } +} + +fn resolve_installed_data_dir(configured: PathBuf, explicitly_configured: bool) -> PathBuf { + if explicitly_configured || configured.join("config.sqlite3").is_file() { + return configured; + } + installed_data_dir_candidates() + .iter() + .map(PathBuf::from) + .find(|candidate| candidate.join("config.sqlite3").is_file()) + .unwrap_or(configured) +} + +async fn application() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "rust_proxy=info,warn".into()), + ) + .with_target(false) + .with_writer(std::io::stderr) + .init(); + let matches = Cli::command().get_matches(); + let explicit = |matches: &clap::ArgMatches, id: &str| { + matches.value_source(id).is_some_and(|source| { + matches!(source, ValueSource::CommandLine | ValueSource::EnvVariable) + }) + }; + let serve_matches = matches.subcommand_matches("serve"); + let startup_locks = rust_proxy::settings::StartupLocks { + administration: explicit(&matches, "admin_listen").then(|| { + *matches + .get_one::("admin_listen") + .expect("parsed admin listener") + }), + http: serve_matches + .filter(|sub| explicit(sub, "proxy_listen")) + .map(|sub| { + *sub.get_one::("proxy_listen") + .expect("parsed proxy listener") + }), + https: serve_matches + .filter(|sub| explicit(sub, "tls_listen")) + .and_then(|sub| sub.get_one::("tls_listen").copied()), + }; + let cli = Cli::from_arg_matches(&matches)?; + let data_dir_explicit = explicit(&matches, "data_dir"); + if cli.livelogs { + ensure!(cli.command.is_none(), "--livelogs cannot be combined with a subcommand"); + let data_dir = resolve_installed_data_dir(cli.data_dir, data_dir_explicit); + return live_logs_command(data_dir, cli.admin_listen).await; + } + let command = cli.command.context("a subcommand is required (or use --livelogs)")?; + match command { + Command::Token => { + admin_command( + resolve_installed_data_dir(cli.data_dir, data_dir_explicit), + AdminCommand::SetupToken, + ) + .await + } + Command::Admin { action } => { + admin_command(resolve_installed_data_dir(cli.data_dir, data_dir_explicit), action).await + } + Command::Tls { action } => { + tls_command( + resolve_installed_data_dir(cli.data_dir, data_dir_explicit), + cli.admin_listen, + action, + ) + .await + } + Command::Config { action } => { + config_command( + resolve_installed_data_dir(cli.data_dir, data_dir_explicit), + cli.admin_listen, + action, + ) + .await + } + Command::Serve(options) => { + let update_check = rust_proxy::update_check::Config::embedded()?.with_overrides( + cli.update_repository_url, + cli.update_version_url, + cli.update_check_interval_secs, + )?; + // Server startup never guesses a data directory. Package/service/plugin + // installation must pass its configured path explicitly. + run( + cli.data_dir, + cli.admin_listen, + options, + startup_locks, + update_check, + ) + .await + } + } +} +fn validate_admin_binding(admin_addr: SocketAddr, tls: bool, allow_remote: bool) -> Result { + let remote_admin = !admin_addr.ip().is_loopback(); + ensure!( + !remote_admin || tls || allow_remote, + "remote plain-HTTP administration requires --allow-remote-admin" + ); + Ok(remote_admin) +} + +async fn run( + data_dir: PathBuf, + startup_admin_addr: SocketAddr, + options: ServeOptions, + startup_locks: rust_proxy::settings::StartupLocks, + update_check: rust_proxy::update_check::Config, +) -> Result<()> { + ensure!( + startup_admin_addr.port() > 0 && options.proxy_listen.port() > 0, + "listener ports must be nonzero" + ); + ensure!( + (1..=4096).contains(&options.max_connections) && (1..=4096).contains(&options.max_inflight), + "limits must be 1..4096" + ); + ensure!( + options.upstream_timeout_secs > 0 + && options.body_idle_timeout_secs > 0 + && options.shutdown_timeout_secs > 0 + && options.websocket_idle_timeout_secs > 0, + "timeouts must be positive" + ); + ensure!( + startup_admin_addr.port() != options.proxy_listen.port(), + "admin and proxy ports must differ" + ); + if let Some(tls) = options.tls_listen { + ensure!( + tls.port() > 0 + && tls.port() != options.proxy_listen.port() + && tls.port() != startup_admin_addr.port(), + "TLS listener port must be nonzero and differ from HTTP/admin" + ); + } + if options.demo { + ensure!( + options.demo_listen.ip().is_loopback() && options.demo_listen.port() > 0, + "demo must bind to a nonzero loopback port" + ); + ensure!( + options.demo_listen.port() != startup_admin_addr.port() + && options.demo_listen.port() != options.proxy_listen.port() + && options + .tls_listen + .is_none_or(|tls| tls.port() != options.demo_listen.port()), + "demo port must differ from proxy/admin" + ); + } + auth::prepare_directory(&data_dir)?; + let _process_lock = auth::lock_directory(&data_dir)?; + auth::prepare_database(&data_dir.join("config.sqlite3"))?; + let startup_listeners = (options.proxy_listen, options.tls_listen); + let initial_listeners = + rust_proxy::settings::Listeners::from_startup(options.proxy_listen, options.tls_listen); + let initial = if options.demo { + let mut config = config::demo_config(options.demo_listen); + config.listeners = initial_listeners; + config.vhosts[0].frontend.listener_ids = config + .listeners + .endpoints + .iter() + .map(|listener| listener.id.clone()) + .collect(); + config + } else { + let mut config = Config { + listeners: initial_listeners, + ..Config::default() + }; + // Only a NEW database receives this neutral landing page. + config.pages.direct_ip = Some(Default::default()); + config.pages.unknown_host = Some(Default::default()); + config + }; + let store = Store::open(&data_dir.join("config.sqlite3"), &initial).await?; + let mut saved = store.load().await?; + startup_locks.ensure_config(&mut saved.config); + startup_locks + .validate(&saved.config, startup_admin_addr) + .context("stored configuration conflicts with a listener fixed by systemd/startup")?; + let admin_addr = saved + .config + .administration + .resolved_listen(startup_admin_addr); + let admin_tls_enabled = saved.config.administration.tls; + let remote_admin = + validate_admin_binding(admin_addr, admin_tls_enabled, options.allow_remote_admin)?; + ensure!( + admin_addr.port() != options.proxy_listen.port() + && options + .tls_listen + .is_none_or(|tls| tls.port() != admin_addr.port()) + && (!options.demo || options.demo_listen.port() != admin_addr.port()), + "active administration port conflicts with another startup listener" + ); + let resolved_policy = ValidationPolicy { + proxy_addr: options.proxy_listen, + admin_addr, + tls_addr: options.tls_listen, + cert_dir: None, + data_dir: Some(data_dir.clone()), + }; + saved.config.listeners.validate(&resolved_policy)?; + // The certificate library is available even on an HTTP-only installation. + // Initializing private files does not bind or expose a TLS listener. + let directory = data_dir.join("certs"); + let initialize = directory.clone(); + tokio::task::spawn_blocking(move || rust_proxy::tls::initialize_default(&initialize)).await??; + let cert_dir = Some(directory.clone()); + let policy = ValidationPolicy { + proxy_addr: options.proxy_listen, + admin_addr, + tls_addr: options.tls_listen, + cert_dir, + data_dir: Some(data_dir.clone()), + }; + let validation_policy = policy.clone(); + let snapshot = tokio::task::spawn_blocking(move || { + config::compile(saved.config, saved.revision, &validation_policy) + }) + .await? + .context("stored configuration is invalid; no automatic reset was performed")?; + let admin_tls_config = { + let tls = snapshot.envelope.config.tls.clone(); + let administration = snapshot.envelope.config.administration.clone(); + let cert_dir = policy.cert_dir.clone(); + tokio::task::spawn_blocking(move || { + rust_proxy::tls::compile_admin(&tls, &administration, cert_dir.as_deref()) + }) + .await?? + }; + let setup_required = store.admin_record().await?.is_none(); + let authentication = auth::Auth::new(store.clone(), data_dir.clone()).await?; + if !setup_required { + authentication.remove_bootstrap_files().await; + } + let renewal_observations = store.load_certificate_renewals().await?; + let geoip = match rust_proxy::geoip::Database::load(&data_dir.join("geoip")) { + Ok(database) => std::sync::Arc::new(database), + Err(error) => { + tracing::warn!(%error, "GeoIP country database unavailable; country allow lists fail closed"); + std::sync::Arc::new(rust_proxy::geoip::Database::empty()) + } + }; + let state = App::new_with_startup_locks_geoip( + store.clone(), + snapshot, + policy, + authentication, + options.max_inflight, + Duration::from_secs(options.upstream_timeout_secs), + Duration::from_secs(options.body_idle_timeout_secs), + Duration::from_secs(options.websocket_idle_timeout_secs), + startup_listeners, + startup_locks, + geoip, + ); + state.acme.restore_renewals(renewal_observations); + state.start_update_check(update_check); + // Bind EVERY listener before spawning servers: a missing private IP or + // occupied port fails startup atomically, never falling back to 0.0.0.0. + let mut public_listeners = Vec::new(); + for binding in &state.active_listeners { + if options.demo { + ensure!( + !rust_proxy::vhost::socket_overlap(binding.address, options.demo_listen), + "vhost listener conflicts with demo backend" + ); + } + let listener = TcpListener::bind(binding.address).await.with_context(|| { + format!( + "cannot bind frontend {} (TLS={}); check local IP, port permissions and conflicts", + binding.address, binding.tls + ) + })?; + public_listeners.push((*binding, listener)); + } + let mut quic_listeners = Vec::new(); + for binding in &state.active_listeners { + if binding.http3.is_some() { + quic_listeners.push(rust_proxy::http3::bind(*binding, &state)?); + } + } + let mut tcp_stream_listeners = Vec::new(); + let mut udp_stream_listeners = Vec::new(); + for stream in state.active_streams.iter().filter(|stream| stream.enabled) { + let stream_metrics = state + .stream_metrics + .get(&stream.id) + .cloned() + .context("missing stream metrics registry entry")?; + if options.demo && stream.protocol == rust_proxy::stream::Protocol::Tcp { + ensure!( + !rust_proxy::vhost::socket_overlap(stream.listen, options.demo_listen), + "TCP stream listener conflicts with demo backend" + ); + } + match stream.protocol { + rust_proxy::stream::Protocol::Tcp => { + let listener = TcpListener::bind(stream.listen).await.with_context(|| { + format!("cannot bind TCP stream {} on {}", stream.id, stream.listen) + })?; + tcp_stream_listeners.push((stream.clone(), listener, stream_metrics)); + } + rust_proxy::stream::Protocol::Udp => { + let socket = UdpSocket::bind(stream.listen).await.with_context(|| { + format!("cannot bind UDP stream {} on {}", stream.id, stream.listen) + })?; + udp_stream_listeners.push((stream.clone(), socket, stream_metrics)); + } + } + } + let metrics_token = if state.active_prometheus.enabled { + state.active_prometheus.token()? + } else { + None + }; + let metrics_listener = if state.active_prometheus.enabled { + ensure!( + !options.demo + || !rust_proxy::vhost::socket_overlap( + state.active_prometheus.listen, + options.demo_listen + ), + "metrics exporter overlaps demo origin" + ); + Some( + TcpListener::bind(state.active_prometheus.listen) + .await + .context("cannot bind Prometheus exporter")?, + ) + } else { + None + }; + let admin_listener = TcpListener::bind(admin_addr) + .await + .context("cannot bind admin listener")?; + let demo_listener = if options.demo { + Some( + TcpListener::bind(options.demo_listen) + .await + .context("cannot bind demo backend")?, + ) + } else { + None + }; + let shutdown = state.shutdown.clone(); + let grace = Duration::from_secs(options.shutdown_timeout_secs); + let mut tasks = JoinSet::new(); + let connection_slots = + std::sync::Arc::new(tokio::sync::Semaphore::new(options.max_connections)); + for (binding, listener) in public_listeners { + tasks.spawn(rust_proxy::server::serve_frontend_with_http2( + listener, + rust_proxy::proxy::router(state.clone()), + shutdown.clone(), + connection_slots.clone(), + grace, + state.metrics.clone(), + binding.tls.then(|| (state.clone(), binding.http2)), + binding.h2c, + Some(state.clone()), + binding.http2_settings.unwrap_or_default(), + )); + } + for listener in quic_listeners { + tasks.spawn(rust_proxy::http3::serve( + listener, + rust_proxy::proxy::router(state.clone()), + state.clone(), + grace, + )); + } + for (stream, listener, metrics) in tcp_stream_listeners { + tasks.spawn(rust_proxy::stream::serve_tcp( + stream, + listener, + shutdown.clone(), + grace, + metrics, + )); + } + for (stream, socket, metrics) in udp_stream_listeners { + tasks.spawn(rust_proxy::stream::serve_udp( + stream, + socket, + shutdown.clone(), + metrics, + )); + } + if let Some(tls) = admin_tls_config { + tasks.spawn(rust_proxy::server::serve_tls_with_plain_redirect( + admin_listener, + rust_proxy::admin::router(state.clone()), + rust_proxy::admin::https_redirect_router(), + shutdown.clone(), + 32, + grace, + tls, + )); + } else { + tasks.spawn(rust_proxy::server::serve( + admin_listener, + rust_proxy::admin::router(state.clone()), + shutdown.clone(), + 32, + grace, + None, + )); + } + if let Some(listener) = metrics_listener { + tasks.spawn(rust_proxy::server::serve( + listener, + rust_proxy::prometheus::router(state.clone(), metrics_token), + shutdown.clone(), + 16, + grace, + None, + )); + } + if let Some(listener) = demo_listener { + tasks.spawn(rust_proxy::server::serve( + listener, + rust_proxy::demo::router(), + shutdown.clone(), + 256, + grace, + None, + )); + } + println!("\n RustProxy {}\n", env!("CARGO_PKG_VERSION")); + for binding in &state.active_listeners { + println!( + " Frontend: {}://{} ({})", + if binding.tls { "https" } else { "http" }, + binding.address, + if binding.http3.is_some() && binding.http2 { + "HTTP/3 (UDP) + HTTP/2 + HTTP/1.1" + } else if binding.http3.is_some() { + "HTTP/3 (UDP) + HTTP/1.1" + } else if binding.tls && binding.http2 { + "HTTP/2 + HTTP/1.1" + } else if binding.h2c { + "h2c prior-knowledge + HTTP/1.1" + } else { + "HTTP/1.1" + } + ); + } + for stream in state.active_streams.iter().filter(|stream| stream.enabled) { + println!( + " Stream: {}://{} -> {} ({})", + match stream.protocol { + rust_proxy::stream::Protocol::Tcp => "tcp", + rust_proxy::stream::Protocol::Udp => "udp", + }, + stream.listen, + stream.upstream, + stream.id + ); + } + println!( + " Panel: {}://{admin_addr}", + if admin_tls_enabled { "https" } else { "http" } + ); + if remote_admin && !admin_tls_enabled { + tracing::warn!( + "REMOTE ADMIN ENABLED OVER PLAIN HTTP: credentials and session cookies can be exposed; restrict access with a firewall/private network and prefer a protected tunnel" + ); + } + if state.active_prometheus.enabled { + println!( + " Metrics: http://{}{}", + state.active_prometheus.listen, state.active_prometheus.path + ); + } + if setup_required { + println!( + " Setup: rust-proxy --data-dir {} admin setup-token", + data_dir.display() + ); + println!( + " Or: rust-proxy --data-dir {} admin init --username YOUR_NAME", + data_dir.display() + ); + } else { + println!(" Sign in with your administrator account."); + } + println!(" Data: {}\n Stop: Ctrl+C\n", data_dir.display()); + if state + .active_listeners + .iter() + .any(|l| !l.address.ip().is_loopback()) + { + tracing::warn!("public binding enabled: development release; configure host/route ACLs and trusted certificates before exposure"); + } + let result = tokio::select! { + result = shutdown_signal() => result, + result = tasks.join_next() => match result { + Some(Ok(Ok(()))) => Err(anyhow::anyhow!("listener stopped unexpectedly")), + Some(Ok(Err(error))) => Err(error), + Some(Err(error)) => Err(error.into()), + None => Err(anyhow::anyhow!("all listeners stopped")), + }, + }; + shutdown.cancel(); + while tasks.join_next().await.is_some() {} + state.tunnels.close(); + let _ = tokio::time::timeout(grace, state.tunnels.wait()).await; + state.drain_updates().await; + let managed_update = data_dir.join(".managed-update").is_file(); + let cache_flush_budget = if managed_update { + std::cmp::max(grace, Duration::from_secs(90)) + } else { + grace + }; + let flush_result = if managed_update { + tokio::time::timeout( + cache_flush_budget, + state.cache.flush_hot_to_cold_for_update(), + ) + .await + } else { + tokio::time::timeout(cache_flush_budget, state.cache.flush_hot_to_cold()).await + }; + match flush_result { + Ok(moved) if moved > 0 && managed_update => tracing::info!( + moved, + "managed update moved hot cache objects to cold storage" + ), + Ok(moved) if moved > 0 => tracing::info!( + moved, + "flushed hot cache objects to cold storage before shutdown" + ), + Err(_) if managed_update => { + tracing::warn!("managed-update hot-to-cold cache handoff exceeded its shutdown budget") + } + Err(_) => tracing::warn!("cold-cache shutdown flush exceeded graceful shutdown timeout"), + _ => {} + } + store.close().await; + result +} +async fn shutdown_signal() -> Result<()> { + #[cfg(unix)] + { + let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; + tokio::select! { result = tokio::signal::ctrl_c() => { result?; }, _ = term.recv() => {} } + } + #[cfg(not(unix))] + { + tokio::signal::ctrl_c().await?; + } + Ok(()) +} +fn read_password(from_stdin: bool) -> Result { + let password = if from_stdin { + let mut input = String::new(); + io::stdin() + .take(131) + .read_to_string(&mut input) + .context("cannot read password from stdin")?; + if input.ends_with('\n') { + input.pop(); + if input.ends_with('\r') { + input.pop(); + } + } + input + } else { + ensure!( + io::stdin().is_terminal(), + "use --password-stdin for non-interactive password input" + ); + let password = Zeroizing::new(rpassword::prompt_password( + "New password (12-128 UTF-8 bytes): ", + )?); + let confirmation = Zeroizing::new(rpassword::prompt_password("Confirm new password: ")?); + ensure!( + auth::equal_secret(&password, &confirmation), + "passwords do not match" + ); + password.to_string() + }; + auth::validate_password(&password)?; + Ok(password) +} +async fn admin_command(dir: PathBuf, action: AdminCommand) -> Result<()> { + auth::prepare_directory(&dir)?; + let db = dir.join("config.sqlite3"); + if matches!(&action, AdminCommand::SetupToken) { + if db.is_file() { + auth::prepare_database(&db)?; + let store = Store::open(&db, &Config::default()).await?; + let has_admin = store.admin_record().await?.is_some(); + store.close().await; + ensure!( + !has_admin, + "setup is complete; setup tokens no longer authorize anything" + ); + } + println!("{}", auth::load_or_create_setup_token(&dir)?); + return Ok(()); + } + if !matches!(&action, AdminCommand::Init { .. }) { + ensure!( + db.is_file(), + "database does not exist at {}; start rust-proxy serve or run rust-proxy admin init first", + db.display() + ); + } + auth::prepare_database(&db)?; + let store = Store::open(&db, &Config::default()).await?; + let result = async { + match action { + AdminCommand::Init { + username, + password_stdin, + } => { + let username = match username { + Some(value) => value, + None => { + ensure!( + io::stdin().is_terminal(), + "--username is required for non-interactive setup" + ); + print!("Administrator username: "); + io::stdout().flush()?; + let mut value = String::new(); + io::stdin().read_line(&mut value)?; + value.trim().to_owned() + } + }; + let username = auth::username(&username)?; + ensure!( + store.admin_record().await?.is_none(), + "administrator already exists; use admin reset-password" + ); + let hash = auth::hash_password_offline(read_password(password_stdin)?).await?; + ensure!( + store + .initialize_admin(&username, &hash, "local CLI") + .await?, + "another setup already created the administrator" + ); + for name in ["setup-token", "admin-token"] { + let _ = tokio::fs::remove_file(dir.join(name)).await; + } + println!( + "Administrator '{}' created. No default credentials exist.", + username + ); + } + AdminCommand::ResetPassword { password_stdin } => { + let record = store + .admin_record() + .await? + .context("administrator is not initialized; use admin init")?; + let hash = auth::hash_password_offline(read_password(password_stdin)?).await?; + ensure!( + store + .reset_admin_password(&hash, record.generation, "local CLI recovery") + .await?, + "credentials changed concurrently; repeat the reset" + ); + println!( + "Password reset for '{}'. All sessions have been revoked.", + record.username + ); + } + AdminCommand::SetupToken => unreachable!("handled before opening SQLite"), + AdminCommand::Status => match store.admin_record().await? { + Some(record) => println!( + "Administrator: {}\nSetup: complete\nSchema: {}", + record.username, + rust_proxy::db::SCHEMA_VERSION + ), + None => println!("Setup: required\nUse rust-proxy admin init --username YOUR_NAME"), + }, + AdminCommand::LogoutAll => { + let record = store + .admin_record() + .await? + .context("administrator is not initialized")?; + store.revoke_all_sessions(&record.username).await?; + println!("All administrator sessions revoked."); + } + } + Ok(()) + } + .await; + store.close().await; + result +} + +#[derive(Default)] +struct LiveLogFilters { + host: Option, + vhost: Option, + backend: Option, + route: Option, + client: Option, + method: Option, + transport: Option, + cache: Option, + status: Option, + path_prefix: Option, + errors_only: bool, + slow_ms: Option, + detail: bool, + paused: bool, +} + +fn log_string<'a>(entry: &'a serde_json::Value, key: &str) -> &'a str { + entry.get(key).and_then(|value| value.as_str()).unwrap_or("-") +} + +fn log_optional_string<'a>(entry: &'a serde_json::Value, key: &str) -> Option<&'a str> { + entry.get(key).and_then(|value| value.as_str()) +} + +fn live_status_matches(filter: &str, status: u64) -> bool { + if let Some(prefix) = filter.strip_suffix("xx") { + return prefix.parse::().ok().is_some_and(|class| status / 100 == class); + } + filter.parse::().ok() == Some(status) +} + +fn live_log_matches(entry: &serde_json::Value, filters: &LiveLogFilters) -> bool { + let eq = |value: &str, expected: &Option| { + expected + .as_ref() + .is_none_or(|expected| value.eq_ignore_ascii_case(expected)) + }; + if !eq(log_string(entry, "host"), &filters.host) + || !eq(log_string(entry, "vhost"), &filters.vhost) + || !eq(log_string(entry, "backend"), &filters.backend) + || !eq(log_string(entry, "route"), &filters.route) + || !eq(log_string(entry, "client_ip"), &filters.client) + || !eq(log_string(entry, "method"), &filters.method) + || !eq(log_string(entry, "transport"), &filters.transport) + || !eq(log_string(entry, "cache"), &filters.cache) + { + return false; + } + if let Some(prefix) = &filters.path_prefix { + if !log_string(entry, "path").starts_with(prefix) { + return false; + } + } + let status = entry.get("status").and_then(|value| value.as_u64()).unwrap_or(0); + if filters + .status + .as_ref() + .is_some_and(|filter| !live_status_matches(filter, status)) + { + return false; + } + if filters.slow_ms.is_some_and(|minimum| { + entry + .get("duration_ms") + .and_then(|value| value.as_u64()) + .unwrap_or(0) + < minimum + }) { + return false; + } + if filters.errors_only + && status < 500 + && log_optional_string(entry, "upstream_error").is_none() + && log_optional_string(entry, "websocket_error").is_none() + && log_optional_string(entry, "h3_reset_code").is_none() + { + return false; + } + true +} + +fn live_time(entry: &serde_json::Value) -> String { + let ms = entry.get("time_ms").and_then(|value| value.as_u64()).unwrap_or(0); + let seconds = (ms / 1000) % 86_400; + format!( + "{:02}:{:02}:{:02}.{:03}Z", + seconds / 3600, + (seconds % 3600) / 60, + seconds % 60, + ms % 1000 + ) +} + +fn print_live_entry(entry: &serde_json::Value, detail: bool) { + let id = entry.get("id").and_then(|value| value.as_u64()).unwrap_or(0); + let status = entry.get("status").and_then(|value| value.as_u64()).unwrap_or(0); + let duration = entry + .get("duration_ms") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let request_bytes = entry + .get("request_bytes") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let response_bytes = entry + .get("response_bytes") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + println!( + "{} #{:<7} {:<4} {:<5} {}{} -> {} {:>5}ms vhost={} backend={} {} cache={} outcome={} client={}", + live_time(entry), + id, + log_string(entry, "transport"), + log_string(entry, "method"), + log_string(entry, "host"), + log_string(entry, "path"), + status, + duration, + log_string(entry, "vhost"), + log_string(entry, "backend"), + log_string(entry, "http_version"), + log_string(entry, "cache"), + log_string(entry, "outcome"), + log_string(entry, "client_ip"), + ); + if let Some(error) = log_optional_string(entry, "upstream_error") { + println!(" upstream_error={error}"); + } + if let Some(error) = log_optional_string(entry, "websocket_error") { + println!(" websocket_error={error}"); + } + if detail { + println!( + " route={} upstream_status={} cache_reason={} cache_enabled={} transform_enabled={} websocket_bootstrap={} websocket_phase={} h3_reset={} bytes={}/{}", + log_string(entry, "route"), + entry.get("upstream_status").and_then(|value| value.as_u64()).map(|value| value.to_string()).unwrap_or_else(|| "-".into()), + log_string(entry, "cache_reason"), + entry.get("cache_effective_enabled").map(|value| value.to_string()).unwrap_or_else(|| "-".into()), + entry.get("transform_effective_enabled").map(|value| value.to_string()).unwrap_or_else(|| "-".into()), + log_string(entry, "websocket_bootstrap"), + log_string(entry, "websocket_phase"), + log_string(entry, "h3_reset_code"), + request_bytes, + response_bytes, + ); + } +} + +fn live_filters_summary(filters: &LiveLogFilters) -> String { + let mut values = Vec::new(); + macro_rules! add { + ($name:literal, $value:expr) => { + if let Some(value) = $value.as_ref() { values.push(format!("{}={}", $name, value)); } + }; + } + add!("host", filters.host); + add!("vhost", filters.vhost); + add!("backend", filters.backend); + add!("route", filters.route); + add!("client", filters.client); + add!("method", filters.method); + add!("transport", filters.transport); + add!("cache", filters.cache); + add!("status", filters.status); + add!("path", filters.path_prefix); + if filters.errors_only { values.push("errors=on".into()); } + if let Some(ms) = filters.slow_ms { values.push(format!("slow>={ms}ms")); } + if filters.detail { values.push("detail=on".into()); } + if filters.paused { values.push("paused".into()); } + if values.is_empty() { "none".into() } else { values.join(" ") } +} + +fn live_help() { + println!("Commands (plain values, no regex):"); + println!(" host|vhost|backend|route|client|method|transport|cache "); + println!(" path status <200|2xx|3xx|4xx|5xx|*>"); + println!(" errors slow "); + println!(" detail show "); + println!(" filters reset pause resume clear help quit"); +} + +fn set_live_filter(target: &mut Option, value: &str) { + *target = if matches!(value, "" | "*" | "off" | "-") { + None + } else { + Some(value.to_owned()) + }; +} + +fn handle_live_command( + line: &str, + filters: &mut LiveLogFilters, + history: &std::collections::BTreeMap, +) -> Result { + let line = line.trim(); + if line.is_empty() { + return Ok(true); + } + let mut parts = line.split_whitespace(); + let command = parts.next().unwrap_or_default().to_ascii_lowercase(); + let value = parts.next().unwrap_or(""); + match command.as_str() { + "help" | "?" => live_help(), + "filters" => println!("filters: {}", live_filters_summary(filters)), + "reset" => { *filters = LiveLogFilters::default(); println!("filters cleared"); } + "pause" => { filters.paused = true; println!("paused"); } + "resume" => { filters.paused = false; println!("resumed"); } + "clear" => { print!("\x1b[2J\x1b[H"); io::stdout().flush()?; } + "quit" | "exit" | "q" => return Ok(false), + "detail" => filters.detail = matches!(value, "on" | "1" | "true"), + "errors" => filters.errors_only = matches!(value, "on" | "1" | "true"), + "slow" => { + filters.slow_ms = if matches!(value, "" | "off" | "*") { None } else { Some(value.parse().context("slow expects milliseconds or off")?) }; + } + "status" => { + if matches!(value, "" | "*" | "off") { filters.status = None; } + else { + ensure!(matches!(value, "1xx" | "2xx" | "3xx" | "4xx" | "5xx") || value.parse::().ok().is_some_and(|v| (100..=599).contains(&v)), "status expects 100..599 or 1xx..5xx"); + filters.status = Some(value.to_ascii_lowercase()); + } + } + "host" => set_live_filter(&mut filters.host, value), + "vhost" => set_live_filter(&mut filters.vhost, value), + "backend" => set_live_filter(&mut filters.backend, value), + "route" => set_live_filter(&mut filters.route, value), + "client" => set_live_filter(&mut filters.client, value), + "method" => { set_live_filter(&mut filters.method, value); if let Some(v) = filters.method.as_mut() { *v = v.to_ascii_uppercase(); } }, + "transport" => set_live_filter(&mut filters.transport, value), + "cache" => { set_live_filter(&mut filters.cache, value); if let Some(v) = filters.cache.as_mut() { *v = v.to_ascii_uppercase(); } }, + "path" => set_live_filter(&mut filters.path_prefix, value), + "show" => { + let id: u64 = value.parse().context("show expects a request id")?; + match history.get(&id) { + Some(entry) => println!("{}", serde_json::to_string_pretty(entry)?), + None => println!("request #{id} is no longer in the local live-log window"), + } + } + _ => bail!("unknown live-log command '{command}' (type help)"), + } + if !matches!(command.as_str(), "help" | "?" | "filters" | "show" | "clear") { + println!("filters: {}", live_filters_summary(filters)); + } + Ok(true) +} + +fn take_sse_event(buffer: &mut Vec) -> Option> { + let find = |needle: &[u8]| { + buffer + .windows(needle.len()) + .position(|window| window == needle) + }; + let lf = find(b"\n\n").map(|index| (index, 2)); + let crlf = find(b"\r\n\r\n").map(|index| (index, 4)); + let (index, delimiter) = match (lf, crlf) { + (Some(a), Some(b)) => if a.0 <= b.0 { a } else { b }, + (Some(a), None) => a, + (None, Some(b)) => b, + (None, None) => return None, + }; + let event = buffer[..index].to_vec(); + buffer.drain(..index + delimiter); + Some(event) +} + +fn parse_sse_event(event: &str) -> (Option<&str>, Option) { + let mut kind = None; + let mut data = Vec::new(); + for raw in event.lines() { + let line = raw.trim_end_matches('\r'); + if let Some(value) = line.strip_prefix("event:") { + kind = Some(value.trim()); + } else if let Some(value) = line.strip_prefix("data:") { + data.push(value.trim_start()); + } + } + (kind, (!data.is_empty()).then(|| data.join("\n"))) +} + +type LocalAdminHttpClient = Client, Body>; + +#[derive(Debug)] +struct LocalAdminCertificateVerifier { + expected: Vec<[u8; 32]>, + signature_verifier: Arc, +} +impl ServerCertVerifier for LocalAdminCertificateVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> std::result::Result { + let actual = Sha256::digest(end_entity.as_ref()); + if !self + .expected + .iter() + .any(|expected| actual[..] == expected[..]) + { + return Err(rustls::Error::General( + "local administration TLS certificate is not present in data-dir/certs".into(), + )); + } + Ok(ServerCertVerified::assertion()) + } + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> std::result::Result { + self.signature_verifier + .verify_tls12_signature(message, cert, dss) + } + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> std::result::Result { + self.signature_verifier + .verify_tls13_signature(message, cert, dss) + } + fn supported_verify_schemes(&self) -> Vec { + self.signature_verifier.supported_verify_schemes() + } +} + +fn local_admin_public_roots() -> RootCertStore { + let mut roots = RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + roots +} + +fn local_admin_certificate_pins(data_dir: &Path) -> Result> { + let directory = data_dir.join("certs"); + let mut pins = Vec::new(); + for entry in std::fs::read_dir(&directory) + .with_context(|| format!("cannot read local certificate directory {}", directory.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("pem") { + continue; + } + let metadata = entry.metadata()?; + if !metadata.is_file() || metadata.len() > 512 * 1024 { + continue; + } + let bytes = std::fs::read(&path) + .with_context(|| format!("cannot read local certificate {}", path.display()))?; + let mut reader = bytes.as_slice(); + let Some(Ok(certificate)) = rustls_pemfile::certs(&mut reader).next() else { + continue; + }; + let digest = Sha256::digest(certificate.as_ref()); + let mut pin = [0_u8; 32]; + pin.copy_from_slice(&digest); + pins.push(pin); + } + pins.sort_unstable(); + pins.dedup(); + ensure!( + !pins.is_empty(), + "no local TLS certificate was found in {}; start rust-proxy serve first", + directory.display() + ); + Ok(pins) +} + +fn local_admin_scheme_from_probe(status: StatusCode, location: Option<&str>) -> Result<&'static str> { + if status == StatusCode::PERMANENT_REDIRECT { + ensure!( + location.is_some_and(|value| value.starts_with("https://")), + "administration HTTP probe returned 308 without an HTTPS location" + ); + return Ok("https"); + } + ensure!( + status.is_success(), + "administration health probe returned {status}" + ); + Ok("http") +} + +async fn detect_local_admin_scheme(addr: SocketAddr) -> Result<&'static str> { + let mut connector = HttpConnector::new(); + connector.set_connect_timeout(Some(Duration::from_secs(3))); + let client: Client<_, Body> = Client::builder(TokioExecutor::new()).build(connector); + let request = Request::builder() + .method(Method::GET) + .uri(format!("http://{addr}/healthz")) + .header("accept", "application/json") + .body(Body::empty())?; + let response = tokio::time::timeout(Duration::from_secs(5), client.request(request)) + .await + .context("administration health probe timed out")??; + let location = response + .headers() + .get(http::header::LOCATION) + .and_then(|value| value.to_str().ok()); + local_admin_scheme_from_probe(response.status(), location) +} + +#[derive(Clone)] +struct LocalAdminClient { + addr: SocketAddr, + scheme: &'static str, + client: LocalAdminHttpClient, +} +impl LocalAdminClient { + async fn connect(data_dir: &Path, addr: SocketAddr) -> Result { + ensure!( + addr.ip().is_loopback(), + "CLI administration requires a loopback address" + ); + let scheme = detect_local_admin_scheme(addr).await?; + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let mut config = ClientConfig::builder_with_provider(provider.clone()) + .with_safe_default_protocol_versions()? + .with_root_certificates(local_admin_public_roots()) + .with_no_client_auth(); + if scheme == "https" { + let data_dir = data_dir.to_path_buf(); + let expected = + tokio::task::spawn_blocking(move || local_admin_certificate_pins(&data_dir)) + .await??; + let signature_verifier = WebPkiServerVerifier::builder_with_provider( + Arc::new(local_admin_public_roots()), + provider, + ) + .build()?; + config + .dangerous() + .set_certificate_verifier(Arc::new(LocalAdminCertificateVerifier { + expected, + signature_verifier, + })); + } + let mut http = HttpConnector::new(); + http.enforce_http(false); + http.set_connect_timeout(Some(Duration::from_secs(3))); + http.set_nodelay(true); + let connector = HttpsConnectorBuilder::new() + .with_tls_config(config) + .https_or_http() + .enable_http1() + .wrap_connector(http); + Ok(Self { + addr, + scheme, + client: Client::builder(TokioExecutor::new()).build(connector), + }) + } + fn uri(&self, path: &str) -> String { + format!("{}://{}{}", self.scheme, self.addr, path) + } + fn origin(&self) -> String { + format!("{}://{}", self.scheme, self.addr) + } +} + +async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> { + ensure!(addr.ip().is_loopback(), "--livelogs requires the local loopback administration listener"); + auth::prepare_directory(&dir)?; + let path = dir.join("config.sqlite3"); + ensure!(path.is_file(), "database does not exist at {}; start rust-proxy serve first", path.display()); + auth::prepare_database(&path)?; + let store = Store::open(&path, &Config::default()).await?; + let admin = LocalAdminClient::connect(&dir, addr).await?; + let result = async { + let record = store.admin_record().await?.context("create the administrator first")?; + let mut session = store + .create_live_log_session(record.generation) + .await? + .context("credentials changed; retry --livelogs")?; + let (command_tx, mut command_rx) = tokio::sync::mpsc::unbounded_channel::(); + std::thread::spawn(move || { + let stdin = io::stdin(); + for line in stdin.lock().lines() { + match line { + Ok(line) => { + if command_tx.send(line).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + let interactive = io::stdin().is_terminal() && io::stdout().is_terminal(); + println!("RustProxy live logs 0.24.5 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits."); + if interactive { print!("> "); io::stdout().flush()?; } + let mut filters = LiveLogFilters::default(); + let mut history = std::collections::BTreeMap::::new(); + let mut last_id = 0_u64; + let mut command_input_open = true; + + 'reconnect: loop { + let request = Request::builder() + .method(Method::GET) + .uri(admin.uri("/api/live-logs")) + .header("cookie", format!("{}={}", auth::COOKIE_NAME, session.secret)) + .header("accept", "text/event-stream") + .body(Body::empty())?; + let response = match tokio::time::timeout(Duration::from_secs(10), admin.client.request(request)).await { + Ok(Ok(response)) if response.status().is_success() => response, + Ok(Ok(response)) => { + eprintln!("live-log stream returned {}; reconnecting", response.status()); + let replacement = store.create_live_log_session(record.generation).await?.context("administrator credentials changed; --livelogs stopped")?; + let old = std::mem::replace(&mut session, replacement); let _ = store.end_session(&old.session).await; + tokio::time::sleep(Duration::from_secs(1)).await; continue; + } + Ok(Err(error)) => { eprintln!("live-log stream unavailable: {error}; reconnecting"); tokio::time::sleep(Duration::from_secs(1)).await; continue; } + Err(_) => { eprintln!("live-log stream connect timeout; reconnecting"); tokio::time::sleep(Duration::from_secs(1)).await; continue; } + }; + // The server subscribes before the SSE response is returned. Read the + // retained history only after that point, so requests cannot disappear + // between the initial snapshot and the live subscription. Buffered live + // duplicates are ignored by request id below. + let history_limit = if last_id == 0 { 25 } else { 1024 }; + if let Ok(snapshot) = api( + &admin, + &session, + Method::GET, + &format!("/api/logs?limit={history_limit}"), + None, + ) + .await + { + if let Ok(snapshot) = serde_json::from_str::(&snapshot) { + if let Some(entries) = snapshot.get("entries").and_then(|value| value.as_array()) { + let mut entries = entries.clone(); + entries.reverse(); + for entry in entries { + let id = entry.get("id").and_then(|value| value.as_u64()).unwrap_or(0); + if id > 0 && id <= last_id { + continue; + } + if id > 0 { + last_id = id; + history.insert(id, entry.clone()); + } + if !filters.paused && live_log_matches(&entry, &filters) { + print_live_entry(&entry, filters.detail); + } + } + } + } + } + let mut body = response.into_body(); + let mut buffer = Vec::new(); + loop { + tokio::select! { + _ = tokio::signal::ctrl_c() => break 'reconnect, + command = command_rx.recv(), if command_input_open => { + let Some(command) = command else { + command_input_open = false; + continue; + }; + match handle_live_command(&command, &mut filters, &history) { + Ok(true) => {} + Ok(false) => break 'reconnect, + Err(error) => eprintln!("command error: {error:#}"), + } + if interactive { print!("> "); io::stdout().flush()?; } + } + frame = body.frame() => { + let Some(frame) = frame else { + eprintln!("live-log stream closed; reconnecting"); + tokio::time::sleep(Duration::from_millis(500)).await; + continue 'reconnect; + }; + let frame = match frame { + Ok(frame) => frame, + Err(error) => { eprintln!("live-log stream error: {error}; reconnecting"); tokio::time::sleep(Duration::from_millis(500)).await; continue 'reconnect; } + }; + let Ok(data) = frame.into_data() else { continue; }; + buffer.extend_from_slice(&data); + ensure!(buffer.len() <= 2 * 1024 * 1024, "live-log stream frame buffer exceeded 2 MiB"); + while let Some(raw) = take_sse_event(&mut buffer) { + let raw = std::str::from_utf8(&raw).context("live-log SSE event is not UTF-8")?; + let (kind, data) = parse_sse_event(raw); + match (kind, data) { + (Some("log"), Some(data)) => { + let entry: serde_json::Value = serde_json::from_str(&data)?; + let id = entry.get("id").and_then(|value| value.as_u64()).unwrap_or(0); + if id > 0 && id <= last_id { continue; } + if id > 0 { last_id = id; history.insert(id, entry.clone()); } + while history.len() > 1024 { if let Some(id) = history.keys().next().copied() { history.remove(&id); } else { break; } } + if !filters.paused && live_log_matches(&entry, &filters) { print_live_entry(&entry, filters.detail); } + } + (Some("gap"), Some(data)) => { + let dropped = serde_json::from_str::(&data).ok().and_then(|value| value.get("dropped").and_then(|v| v.as_u64())).unwrap_or(0); + eprintln!("*** live-log receiver lagged: {dropped} entries were dropped before display ***"); + } + _ => {} + } + } + if interactive { io::stdout().flush()?; } + } + } + } + } + let _ = store.end_session(&session.session).await; + Ok::<_, anyhow::Error>(()) + }.await; + store.close().await; + result +} + +async fn api( + admin: &LocalAdminClient, + session: &auth::SessionToken, + method: Method, + path: &str, + body: Option, +) -> Result { + let request = Request::builder() + .method(method) + .uri(admin.uri(path)) + .header( + "cookie", + format!("{}={}", auth::COOKIE_NAME, session.secret), + ) + .header("origin", admin.origin()) + .header("x-rustproxy-request", "1") + .header("x-csrf-token", &session.session.csrf) + .header("content-type", "application/json") + .body(Body::from(body.unwrap_or_default()))?; + tokio::time::timeout(Duration::from_secs(15), async { + let response = admin + .client + .request(request) + .await + .context("admin API unavailable; start rust-proxy serve first")?; + let status = response.status(); + let bytes = Limited::new(response.into_body(), config::MAX_CONFIG_BYTES + 4096) + .collect() + .await + .map_err(|e| anyhow::anyhow!("admin body error: {e}"))? + .to_bytes(); + let text = String::from_utf8(bytes.to_vec())?; + if !status.is_success() { + bail!("admin API returned {status}: {text}"); + } + Ok(text) + }) + .await + .context("admin API timed out")? +} +async fn read_config_file(file: PathBuf) -> Result { + ensure!( + tokio::fs::metadata(&file).await?.len() <= config::MAX_CONFIG_BYTES as u64, + "config file exceeds 256 KiB" + ); + // Bound actual reads as well as metadata; replacement/append races cannot grow memory. + let data = tokio::task::spawn_blocking(move || { + let file = std::fs::File::open(file)?; + let mut bytes = Vec::new(); + file.take((config::MAX_CONFIG_BYTES + 1) as u64) + .read_to_end(&mut bytes)?; + anyhow::ensure!( + bytes.len() <= config::MAX_CONFIG_BYTES, + "config file exceeds 256 KiB" + ); + Ok::<_, anyhow::Error>(bytes) + }) + .await??; + Ok(serde_json::from_slice(&data)?) +} + +async fn read_external_pem(file: PathBuf, private: bool) -> Result>> { + tokio::task::spawn_blocking(move || { + // Certbot's live/*.pem files are symlinks by design. Resolve them once, + // then open the final regular file without following another symlink. + let canonical = std::fs::canonicalize(&file) + .with_context(|| format!("cannot resolve external PEM {}", file.display()))?; + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let opened = options + .open(&canonical) + .with_context(|| format!("cannot open external PEM {}", canonical.display()))?; + let metadata = opened.metadata()?; + ensure!( + metadata.is_file() && metadata.len() <= 131_072, + "external PEM must be a regular file up to 128 KiB" + ); + #[cfg(unix)] + if private { + use std::os::unix::fs::PermissionsExt; + ensure!( + metadata.permissions().mode() & 0o077 == 0, + "external private key permissions must not allow group/world access: {}", + canonical.display() + ); + } + #[cfg(not(unix))] + let _ = private; + let mut bytes = Zeroizing::new(Vec::new()); + opened + .take(131_073) + .read_to_end(&mut bytes) + .context("cannot read external PEM")?; + ensure!(bytes.len() <= 131_072, "external PEM grew beyond 128 KiB"); + Ok::<_, anyhow::Error>(bytes) + }) + .await? +} +async fn config_command(dir: PathBuf, addr: SocketAddr, action: ConfigCommand) -> Result<()> { + let action = match action { + ConfigCommand::Validate { file } => { + let config = read_config_file(file).await?; + let proxy_addr = std::env::var("RUST_PROXY_PROXY_LISTEN") + .unwrap_or_else(|_| "127.0.0.1:8080".into()) + .parse()?; + let tls_addr = std::env::var("RUST_PROXY_TLS_LISTEN") + .ok() + .filter(|s| !s.is_empty()) + .map(|s| s.parse()) + .transpose()?; + let cert_path = dir.join("certs"); + let cert_dir = cert_path.is_dir().then_some(cert_path); + let policy = ValidationPolicy { + proxy_addr, + admin_addr: addr, + tls_addr, + cert_dir, + data_dir: Some(dir.clone()), + }; + tokio::task::spawn_blocking(move || config::compile(config, 0, &policy)).await??; + println!("Configuration valid. No database or runtime changes made."); + return Ok(()); + } + action => action, + }; + auth::prepare_directory(&dir)?; + let path = dir.join("config.sqlite3"); + ensure!( + path.is_file(), + "database does not exist at {}; start rust-proxy serve first", + path.display() + ); + auth::prepare_database(&path)?; + let store = Store::open(&path, &Config::default()).await?; + let admin = LocalAdminClient::connect(&dir, addr).await?; + let result = async { + let record = store.admin_record().await?.context("create the administrator first")?; + // OS file ownership is the local recovery/management authority, not a permanent API token. + let session = store.create_session(record.generation, "cli", None).await?.context("credentials changed; retry CLI operation")?; + let result = async { + let current: Envelope = serde_json::from_str(&api(&admin, &session, Method::GET, "/api/config", None).await?)?; + match action { + ConfigCommand::Get => println!("{}", serde_json::to_string_pretty(¤t.config)?), + ConfigCommand::Reload => { + let body = serde_json::json!({"expected_revision":current.revision,"config":current.config}).to_string(); + let updated: Envelope = serde_json::from_str(&api(&admin, &session, Method::PUT, "/api/config", Some(body)).await?)?; + println!("Reloaded configuration and certificates at revision {}.", updated.revision); + }, + ConfigCommand::Apply { file } => { + let config = read_config_file(file).await?; + let body = serde_json::json!({"expected_revision":current.revision,"config":config}).to_string(); + let updated: Envelope = serde_json::from_str(&api(&admin, &session, Method::PUT, "/api/config", Some(body)).await?)?; + println!("Saved revision {}. Routing/pool policies are live; listener or ALPN changes require a restart (see /api/status).", updated.revision); + }, + ConfigCommand::Validate { .. } => unreachable!("handled before opening SQLite"), + } + Ok::<_, anyhow::Error>(()) + }.await; + if let Err(error) = store.end_session(&session.session).await { + tracing::warn!(%error, "CLI session cleanup failed; the capability expires after 60 seconds"); + } + result + }.await; + store.close().await; + result +} + +async fn tls_command(dir: PathBuf, addr: SocketAddr, action: TlsCommand) -> Result<()> { + if matches!(&action, TlsCommand::Reload) { + auth::prepare_directory(&dir)?; + let path = dir.join("config.sqlite3"); + ensure!(path.is_file(), "database does not exist at {}; start rust-proxy serve first", path.display()); + auth::prepare_database(&path)?; + let store = Store::open(&path, &Config::default()).await?; + let admin = LocalAdminClient::connect(&dir, addr).await?; + let result = async { + let record = store.admin_record().await?.context("create the administrator first")?; + let session = store.create_session(record.generation, "cli", None).await?.context("credentials changed; retry CLI operation")?; + let response = api(&admin, &session, Method::POST, "/api/certificates/reload", Some("{}".into())).await; + if let Err(error) = store.end_session(&session.session).await { + tracing::warn!(%error, "CLI session cleanup failed; the capability expires after 60 seconds"); + } + let payload: serde_json::Value = serde_json::from_str(&response?)?; + println!( + "Reloaded configured certificate files at revision {}. New public TLS/H2/H3 handshakes use the refreshed keys.", + payload["revision"] + ); + if payload["administration_restart_required"].as_bool() == Some(true) { + println!("Administration TLS uses a startup-fixed listener and requires a service restart for its selected certificate."); + } + Ok::<_, anyhow::Error>(()) + } + .await; + store.close().await; + return result; + } + + if let TlsCommand::Deploy { id, cert, key } = &action { + let id = id.clone(); + let certificate = read_external_pem(cert.clone(), false).await?; + let private_key = read_external_pem(key.clone(), true).await?; + let cert_pem = std::str::from_utf8(certificate.as_slice()) + .context("external certificate PEM is not UTF-8")?; + let key_pem = std::str::from_utf8(private_key.as_slice()) + .context("external private key PEM is not UTF-8")?; + + auth::prepare_directory(&dir)?; + let database = dir.join("config.sqlite3"); + ensure!(database.is_file(), "database does not exist at {}; start rust-proxy serve first", database.display()); + auth::prepare_database(&database)?; + let store = Store::open(&database, &Config::default()).await?; + let admin = LocalAdminClient::connect(&dir, addr).await?; + let result = async { + let record = store.admin_record().await?.context("create the administrator first")?; + let session = store.create_session(record.generation, "cli", None).await?.context("credentials changed; retry CLI operation")?; + let body = serde_json::json!({ + "certificate_id": id, + "cert_pem": cert_pem, + "key_pem": key_pem, + }) + .to_string(); + let response = api( + &admin, + &session, + Method::POST, + "/api/certificates/deploy", + Some(body), + ) + .await; + if let Err(error) = store.end_session(&session.session).await { + tracing::warn!(%error, "CLI session cleanup failed; the capability expires after 60 seconds"); + } + let payload: serde_json::Value = serde_json::from_str(&response?)?; + let operation = payload["operation"].as_str().unwrap_or("updated"); + println!( + "External certificate {} reconciliation: {} at revision {} using {} / {}.", + payload["certificate_id"], + operation, + payload["revision"], + payload["cert_file"], + payload["key_file"] + ); + if payload["changed"].as_bool() == Some(true) { + println!("New public TLS/H2/H3 handshakes use the reconciled certificate without a listener restart."); + } else { + println!("Certificate material already matched the configured RustProxy generation; no revision was published."); + } + if payload["administration_restart_required"].as_bool() == Some(true) { + println!("Administration TLS selects this certificate; restart RustProxy to refresh that startup-fixed listener."); + } + Ok::<_, anyhow::Error>(()) + } + .await; + store.close().await; + return result; + } + + auth::prepare_directory(&dir)?; + let directory = dir.join("certs"); + tokio::task::spawn_blocking(move || { + match action { + TlsCommand::Generate { name, hosts } => { + rust_proxy::tls::generate(&directory, &name, hosts)?; + println!( + "Created {name}.pem and {name}.key in {}.", + directory.display() + ); + println!( + "Self-signed development certificate. Add its binding in the panel TLS page." + ); + } + TlsCommand::Import { name, cert, key } => { + rust_proxy::tls::import(&directory, &name, cert, key)?; + println!( + "Imported {name}.pem and {name}.key. Add the binding in the panel TLS page." + ); + } + TlsCommand::Deploy { .. } => unreachable!("handled above"), + TlsCommand::Reload => unreachable!("handled above"), + } + Ok::<_, anyhow::Error>(()) + }) + .await? +} +#[cfg(test)] +mod cli_regressions { + use super::*; + + #[test] + fn local_admin_probe_switches_cli_to_https_on_plaintext_redirect() { + assert_eq!( + local_admin_scheme_from_probe(StatusCode::OK, None).unwrap(), + "http" + ); + assert_eq!( + local_admin_scheme_from_probe( + StatusCode::PERMANENT_REDIRECT, + Some("https://127.0.0.1:9090/healthz"), + ) + .unwrap(), + "https" + ); + assert!(local_admin_scheme_from_probe( + StatusCode::PERMANENT_REDIRECT, + Some("http://127.0.0.1:9090/healthz"), + ) + .is_err()); + } + + #[test] + fn live_log_filters_are_structured_not_regex_based() { + let entry = serde_json::json!({ + "id": 7, + "host": "api.example.test", + "vhost": "api", + "backend": "origin-a", + "route": "users", + "client_ip": "192.0.2.5", + "method": "GET", + "transport": "h2", + "cache": "MISS", + "status": 503, + "duration_ms": 245, + "path": "/v1/users", + "upstream_error": "connect timeout" + }); + let mut filters = LiveLogFilters::default(); + filters.host = Some("api.example.test".into()); + filters.status = Some("5xx".into()); + filters.errors_only = true; + filters.slow_ms = Some(200); + assert!(live_log_matches(&entry, &filters)); + filters.backend = Some("origin-b".into()); + assert!(!live_log_matches(&entry, &filters)); + filters.backend = None; + filters.path_prefix = Some("/admin".into()); + assert!(!live_log_matches(&entry, &filters)); + } + + #[test] + fn sse_parser_accepts_lf_and_crlf_frames() { + let mut lf = b"event: log\ndata: {\"id\":1}\n\nrest".to_vec(); + assert_eq!(take_sse_event(&mut lf).unwrap(), b"event: log\ndata: {\"id\":1}".to_vec()); + assert_eq!(lf, b"rest".to_vec()); + let mut crlf = b"event: gap\r\ndata: {\"dropped\":2}\r\n\r\n".to_vec(); + let event = take_sse_event(&mut crlf).unwrap(); + let event = std::str::from_utf8(&event).unwrap(); + let (kind, data) = parse_sse_event(event); + assert_eq!(kind, Some("gap")); + assert_eq!(data.as_deref(), Some("{\"dropped\":2}")); + } + + #[test] + fn sse_parser_tolerates_utf8_split_across_transport_frames() { + let mut buffer = b"event: log\ndata: {\"path\":\"/za".to_vec(); + buffer.extend_from_slice(&[0xc5]); + assert!(take_sse_event(&mut buffer).is_none()); + buffer.extend_from_slice(&[0xbc]); + buffer.extend_from_slice(b"olc\"}\n\n"); + let event = take_sse_event(&mut buffer).unwrap(); + let event = std::str::from_utf8(&event).unwrap(); + let (kind, data) = parse_sse_event(event); + assert_eq!(kind, Some("log")); + assert_eq!(data.as_deref(), Some("{\"path\":\"/zażolc\"}")); + } + + #[test] + fn installed_data_dir_candidates_follow_platform_conventions() { + let candidates = installed_data_dir_candidates(); + #[cfg(any( + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "dragonfly" + ))] + assert_eq!(candidates, &["/var/db/rustproxy", "/var/lib/rustproxy"]); + #[cfg(not(any( + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "dragonfly" + )))] + assert_eq!(candidates, &["/var/lib/rustproxy", "/var/db/rustproxy"]); + } + + #[test] + fn explicit_data_dir_is_never_replaced_by_install_discovery() { + let configured = PathBuf::from("/custom/rustproxy-private-state"); + assert_eq!( + resolve_installed_data_dir(configured.clone(), true), + configured + ); + } + + #[test] + fn remote_admin_plain_http_requires_only_allow_flag() { + let local: SocketAddr = "127.0.0.1:9090".parse().unwrap(); + assert!(!validate_admin_binding(local, false, false).unwrap()); + + let wildcard: SocketAddr = "0.0.0.0:9090".parse().unwrap(); + assert!(validate_admin_binding(wildcard, false, true).unwrap()); + assert!(validate_admin_binding(wildcard, false, false).is_err()); + assert!(validate_admin_binding(wildcard, true, false).unwrap()); + + let concrete: SocketAddr = "192.0.2.10:9090".parse().unwrap(); + assert!(validate_admin_binding(concrete, false, true).unwrap()); + assert!(validate_admin_binding(concrete, true, false).unwrap()); + } +} diff --git a/src/metrics.rs b/src/metrics.rs new file mode 100644 index 0000000..166cf82 --- /dev/null +++ b/src/metrics.rs @@ -0,0 +1,498 @@ +//! Aggregate metrics: no attacker-controlled labels or unbounded series. +use serde::Serialize; +use std::sync::atomic::{AtomicU64, Ordering}; +#[derive(Default)] +pub struct Metrics { + pub admin_requests: AtomicU64, + pub admin_response_headers: AtomicU64, + pub admin_header_duration_us: AtomicU64, + pub admin_status: [AtomicU64; 5], + pub compression_gzip: AtomicU64, + pub compression_br: AtomicU64, + pub compression_zstd: AtomicU64, + pub compression_deflate: AtomicU64, + pub status_1xx: AtomicU64, + pub http1_requests: AtomicU64, + pub http3_requests: AtomicU64, + pub http3_connections: AtomicU64, + pub http3_active_connections: AtomicU64, + pub http3_rejected_connections: AtomicU64, + pub http2_requests: AtomicU64, + pub http2_connections: AtomicU64, + pub http2_active_connections: AtomicU64, + pub tls_connections: AtomicU64, + pub tls_handshake_errors: AtomicU64, + pub websocket_total: AtomicU64, + pub websocket_active: AtomicU64, + pub websocket_h1_total: AtomicU64, + pub websocket_h1_active: AtomicU64, + pub websocket_h2_total: AtomicU64, + pub websocket_h2_active: AtomicU64, + pub websocket_h3_total: AtomicU64, + pub websocket_h3_active: AtomicU64, + pub websocket_closed: AtomicU64, + pub websocket_errors: AtomicU64, + pub websocket_handshake_errors: AtomicU64, + pub websocket_bytes_in: AtomicU64, + pub websocket_bytes_out: AtomicU64, + pub websocket_duration_us: AtomicU64, + pub requests: AtomicU64, + pub completed: AtomicU64, + pub active: AtomicU64, + pub errors: AtomicU64, + pub rejected_requests: AtomicU64, + pub connections: AtomicU64, + pub active_connections: AtomicU64, + pub rejected_connections: AtomicU64, + pub bytes_in: AtomicU64, + pub bytes_out: AtomicU64, + pub duration_us: AtomicU64, + pub status_2xx: AtomicU64, + pub status_3xx: AtomicU64, + pub status_4xx: AtomicU64, + pub status_5xx: AtomicU64, +} +#[derive(Serialize)] +pub struct View { + pub http1_requests: u64, + pub http3_requests: u64, + pub http3_connections: u64, + pub http3_active_connections: u64, + pub http3_rejected_connections: u64, + + pub compression_gzip: u64, + pub compression_br: u64, + pub compression_zstd: u64, + pub compression_deflate: u64, + pub http2_requests: u64, + pub http2_connections: u64, + pub http2_active_connections: u64, + pub tls_connections: u64, + pub tls_handshake_errors: u64, + pub websocket_total: u64, + pub websocket_active: u64, + pub websocket_h1_total: u64, + pub websocket_h1_active: u64, + pub websocket_h2_total: u64, + pub websocket_h2_active: u64, + pub websocket_h3_total: u64, + pub websocket_h3_active: u64, + pub websocket_errors: u64, + pub websocket_handshake_errors: u64, + pub websocket_bytes_in: u64, + pub websocket_bytes_out: u64, + pub requests: u64, + pub completed: u64, + pub active: u64, + pub errors: u64, + pub rejected_requests: u64, + pub connections: u64, + pub active_connections: u64, + pub rejected_connections: u64, + pub bytes_in: u64, + pub bytes_out: u64, + pub mean_duration_ms: f64, +} +fn n(value: &AtomicU64) -> u64 { + value.load(Ordering::Relaxed) +} +impl Metrics { + pub fn record_compression(&self, encoding: crate::compression::AppliedEncoding) { + let counter = match encoding { + crate::compression::AppliedEncoding::Gzip => &self.compression_gzip, + crate::compression::AppliedEncoding::Brotli => &self.compression_br, + crate::compression::AppliedEncoding::Zstd => &self.compression_zstd, + crate::compression::AppliedEncoding::Deflate => &self.compression_deflate, + }; + counter.fetch_add(1, Ordering::Relaxed); + } + + pub fn view(&self) -> View { + let completed = n(&self.completed); + View { + http1_requests: n(&self.http1_requests), + http3_requests: n(&self.http3_requests), + http3_connections: n(&self.http3_connections), + http3_active_connections: n(&self.http3_active_connections), + http3_rejected_connections: n(&self.http3_rejected_connections), + + compression_gzip: n(&self.compression_gzip), + compression_br: n(&self.compression_br), + compression_zstd: n(&self.compression_zstd), + compression_deflate: n(&self.compression_deflate), + http2_requests: n(&self.http2_requests), + http2_connections: n(&self.http2_connections), + http2_active_connections: n(&self.http2_active_connections), + tls_connections: n(&self.tls_connections), + tls_handshake_errors: n(&self.tls_handshake_errors), + websocket_total: n(&self.websocket_total), + websocket_active: n(&self.websocket_active), + websocket_h1_total: n(&self.websocket_h1_total), + websocket_h1_active: n(&self.websocket_h1_active), + websocket_h2_total: n(&self.websocket_h2_total), + websocket_h2_active: n(&self.websocket_h2_active), + websocket_h3_total: n(&self.websocket_h3_total), + websocket_h3_active: n(&self.websocket_h3_active), + websocket_errors: n(&self.websocket_errors), + websocket_handshake_errors: n(&self.websocket_handshake_errors), + websocket_bytes_in: n(&self.websocket_bytes_in), + websocket_bytes_out: n(&self.websocket_bytes_out), + requests: n(&self.requests), + completed, + active: n(&self.active), + errors: n(&self.errors), + rejected_requests: n(&self.rejected_requests), + connections: n(&self.connections), + active_connections: n(&self.active_connections), + rejected_connections: n(&self.rejected_connections), + bytes_in: n(&self.bytes_in), + bytes_out: n(&self.bytes_out), + mean_duration_ms: if completed > 0 { + n(&self.duration_us) as f64 / completed as f64 / 1000.0 + } else { + 0.0 + }, + } + } + pub fn prometheus(&self, logs_dropped: u64, revision: u64) -> String { + let mut out = String::new(); + let entries = [ + ( + "http1_requests_total", + "counter", + "Public requests received using HTTP/1.1.", + n(&self.http1_requests), + ), + ( + "http3_requests_total", + "counter", + "HTTP/3 requests", + n(&self.http3_requests), + ), + ( + "http3_connections_total", + "counter", + "HTTP/3 connections", + n(&self.http3_connections), + ), + ( + "http3_active_connections", + "gauge", + "HTTP/3 active connections", + n(&self.http3_active_connections), + ), + ( + "http3_rejected_connections_total", + "counter", + "HTTP/3 rejected connections", + n(&self.http3_rejected_connections), + ), + ( + "compression_gzip_total", + "counter", + "Gzip encoded responses.", + n(&self.compression_gzip), + ), + ( + "compression_br_total", + "counter", + "Brotli encoded responses.", + n(&self.compression_br), + ), + ( + "compression_zstd_total", + "counter", + "Zstd encoded responses.", + n(&self.compression_zstd), + ), + ( + "compression_deflate_total", + "counter", + "Deflate encoded responses.", + n(&self.compression_deflate), + ), + ( + "http2_requests_total", + "counter", + "Public requests received using HTTP/2.", + n(&self.http2_requests), + ), + ( + "http2_connections_total", + "counter", + "Public HTTP/2 connections over TLS or opt-in cleartext h2c prior knowledge.", + n(&self.http2_connections), + ), + ( + "http2_active_connections", + "gauge", + "Currently open public HTTP/2 connections.", + n(&self.http2_active_connections), + ), + ( + "tls_connections_total", + "counter", + "Successful public TLS handshakes.", + n(&self.tls_connections), + ), + ( + "tls_handshake_errors_total", + "counter", + "Failed or timed-out public TLS handshakes.", + n(&self.tls_handshake_errors), + ), + ( + "websocket_tunnels_total", + "counter", + "Established WebSocket tunnels across HTTP/1.1, HTTP/2 RFC 8441 and HTTP/3 RFC 9220.", + n(&self.websocket_total), + ), + ( + "websocket_tunnels_active", + "gauge", + "Open WebSocket tunnels, counted until byte transport ends.", + n(&self.websocket_active), + ), + ( + "websocket_h1_tunnels_total", + "counter", + "Established HTTP/1.1 Upgrade WebSocket tunnels.", + n(&self.websocket_h1_total), + ), + ( + "websocket_h1_tunnels_active", + "gauge", + "Active HTTP/1.1 Upgrade WebSocket tunnels.", + n(&self.websocket_h1_active), + ), + ( + "websocket_h2_rfc8441_tunnels_total", + "counter", + "Established HTTP/2 RFC 8441 WebSocket tunnels.", + n(&self.websocket_h2_total), + ), + ( + "websocket_h2_rfc8441_tunnels_active", + "gauge", + "Active HTTP/2 RFC 8441 WebSocket tunnels.", + n(&self.websocket_h2_active), + ), + ( + "websocket_h3_rfc9220_tunnels_total", + "counter", + "Established HTTP/3 RFC 9220 WebSocket tunnels.", + n(&self.websocket_h3_total), + ), + ( + "websocket_h3_rfc9220_tunnels_active", + "gauge", + "Active HTTP/3 RFC 9220 WebSocket tunnels.", + n(&self.websocket_h3_active), + ), + ( + "websocket_errors_total", + "counter", + "Upgrade transport, I/O or idle-timeout failures.", + n(&self.websocket_errors), + ), + ( + "websocket_handshake_errors_total", + "counter", + "WebSocket upgrade requests not answered with 101.", + n(&self.websocket_handshake_errors), + ), + ( + "websocket_client_bytes_total", + "counter", + "Client WebSocket frame bytes, including framing but excluding TLS/TCP.", + n(&self.websocket_bytes_in), + ), + ( + "websocket_backend_bytes_total", + "counter", + "Backend WebSocket frame bytes, including framing but excluding TLS/TCP.", + n(&self.websocket_bytes_out), + ), + ( + "requests_total", + "counter", + "Requests entering the public proxy, including capacity rejects.", + n(&self.requests), + ), + ( + "requests_completed_total", + "counter", + "Admitted exchanges released after their bodies finish or drop.", + n(&self.completed), + ), + ( + "requests_active", + "gauge", + "Admitted exchanges with a live request or response body.", + n(&self.active), + ), + ( + "errors_total", + "counter", + "Completed 5xx, timed-out, failed or aborted exchanges.", + n(&self.errors), + ), + ( + "requests_rejected_total", + "counter", + "Requests rejected by the in-flight limit.", + n(&self.rejected_requests), + ), + ( + "connections_total", + "counter", + "Accepted public TCP/QUIC connections.", + n(&self.connections), + ), + ( + "connections_active", + "gauge", + "Open public TCP/QUIC connections.", + n(&self.active_connections), + ), + ( + "connections_rejected_total", + "counter", + "Public TCP/QUIC connections rejected at the connection limit.", + n(&self.rejected_connections), + ), + ( + "request_body_bytes_total", + "counter", + "Request payload bytes pulled through the proxy, not wire bytes.", + n(&self.bytes_in), + ), + ( + "response_body_bytes_total", + "counter", + "Response payload bytes yielded by the proxy, not acknowledged wire bytes.", + n(&self.bytes_out), + ), + ( + "logs_dropped_total", + "counter", + "Access entries dropped due to contention, excluding ring eviction.", + logs_dropped, + ), + ( + "config_revision", + "gauge", + "Currently applied configuration revision.", + revision, + ), + ]; + for (name, kind, help, value) in entries { + out.push_str(&format!("# HELP rust_proxy_{name} {help}\n# TYPE rust_proxy_{name} {kind}\nrust_proxy_{name} {value}\n")); + } + out.push_str("# HELP rust_proxy_exchange_duration_seconds Lifetime through body completion or cancellation.\n# TYPE rust_proxy_exchange_duration_seconds summary\n"); + out.push_str(&format!("rust_proxy_exchange_duration_seconds_count {}\nrust_proxy_exchange_duration_seconds_sum {:.6}\n", n(&self.completed), n(&self.duration_us) as f64 / 1_000_000.0)); + out.push_str("# HELP rust_proxy_responses_total Completed responses by status class.\n# TYPE rust_proxy_responses_total counter\n"); + for (class, value) in [ + ("1xx", &self.status_1xx), + ("2xx", &self.status_2xx), + ("3xx", &self.status_3xx), + ("4xx", &self.status_4xx), + ("5xx", &self.status_5xx), + ] { + out.push_str(&format!( + "rust_proxy_responses_total{{class=\"{class}\"}} {}\n", + n(value) + )); + } + out.push_str("# HELP rust_proxy_websocket_duration_seconds Lifetime of closed WebSocket byte transports.\n# TYPE rust_proxy_websocket_duration_seconds summary\n"); + out.push_str(&format!("rust_proxy_websocket_duration_seconds_count {}\nrust_proxy_websocket_duration_seconds_sum {:.6}\n", + n(&self.websocket_closed), n(&self.websocket_duration_us) as f64 / 1_000_000.0)); + out + } +} + +/// Fixed-cardinality per-vhost counters. Retained across config updates by ID, +/// released when a vhost is removed, and finalized after complete body lifetime. +pub const DURATION_BUCKETS_US: [u64; 10] = [ + 5_000, 10_000, 25_000, 50_000, 100_000, 250_000, 500_000, 1_000_000, 2_500_000, 5_000_000, +]; +#[derive(Default, Debug)] +pub struct VhostMetrics { + pub requests: AtomicU64, + pub completed: AtomicU64, + pub active: AtomicU64, + pub rejected: AtomicU64, + pub errors: AtomicU64, + pub bytes_in: AtomicU64, + pub bytes_out: AtomicU64, + pub duration_us: AtomicU64, + pub status: [AtomicU64; 5], + pub buckets: [AtomicU64; 11], +} +impl VhostMetrics { + pub fn complete( + &self, + status: u16, + failed: bool, + duration: std::time::Duration, + bytes_in: u64, + bytes_out: u64, + ) { + self.active.fetch_sub(1, Ordering::Relaxed); + self.completed.fetch_add(1, Ordering::Relaxed); + if failed || status >= 500 { + self.errors.fetch_add(1, Ordering::Relaxed); + } + self.bytes_in.fetch_add(bytes_in, Ordering::Relaxed); + self.bytes_out.fetch_add(bytes_out, Ordering::Relaxed); + self.status[(status as usize / 100).clamp(1, 5) - 1].fetch_add(1, Ordering::Relaxed); + let micros = duration.as_micros().min(u64::MAX as u128) as u64; + self.duration_us.fetch_add(micros, Ordering::Relaxed); + for (index, threshold) in DURATION_BUCKETS_US.iter().enumerate() { + if micros <= *threshold { + self.buckets[index].fetch_add(1, Ordering::Relaxed); + } + } + self.buckets[DURATION_BUCKETS_US.len()].fetch_add(1, Ordering::Relaxed); + } +} +#[cfg(test)] +mod vhost_tests { + use super::*; + #[test] + fn streaming_completion_and_histogram() { + let m = VhostMetrics::default(); + m.active.store(1, Ordering::Relaxed); + m.complete(503, false, std::time::Duration::from_millis(20), 10, 50); + assert_eq!(n(&m.active), 0); + assert_eq!(n(&m.errors), 1); + assert_eq!(n(&m.status[4]), 1); + assert_eq!(n(&m.buckets[1]), 0); + assert_eq!(n(&m.buckets[2]), 1); + assert_eq!(n(&m.buckets[10]), 1); + } +} + +#[cfg(test)] +mod compression_counter_tests { + use super::*; + use crate::compression::AppliedEncoding; + + #[test] + fn each_codec_has_an_independent_counter() { + let metrics = Metrics::default(); + for encoding in [ + AppliedEncoding::Gzip, + AppliedEncoding::Brotli, + AppliedEncoding::Zstd, + AppliedEncoding::Deflate, + ] { + metrics.record_compression(encoding); + } + let view = metrics.view(); + assert_eq!(view.compression_gzip, 1); + assert_eq!(view.compression_br, 1); + assert_eq!(view.compression_zstd, 1); + assert_eq!(view.compression_deflate, 1); + } +} diff --git a/src/not_found.rs b/src/not_found.rs new file mode 100644 index 0000000..45d13be --- /dev/null +++ b/src/not_found.rs @@ -0,0 +1,130 @@ +//! Listener-aware host-owned local responses for domains that intentionally do +//! not proxy to an upstream. These hosts reserve their frontend names exactly +//! like vhosts/redirects and can serve a built-in placeholder, empty status, or +//! administrator-provided bounded HTML. +use anyhow::{ensure, Result}; +use serde::{Deserialize, Serialize}; + +fn yes() -> bool { true } +fn default_status() -> u16 { 404 } + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Host { + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default = "yes")] + pub enabled: bool, + #[serde(default = "yes")] + pub http2_enabled: bool, + #[serde(default = "yes")] + pub http3_enabled: bool, + pub frontend: crate::vhost::Frontend, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub certificate_id: Option, + #[serde(default = "default_page")] + pub page: crate::pages::Page, +} + +fn default_page() -> crate::pages::Page { + crate::pages::Page { + kind: crate::pages::Kind::Welcome, + status: Some(default_status()), + html: String::new(), + } +} + +impl Host { + pub fn validate(&mut self) -> Result<()> { + ensure!(crate::config::valid_id(&self.id), "invalid 404 host id"); + if self.name.trim().is_empty() { + self.name = self.id.clone(); + } + self.name = self.name.trim().to_owned(); + ensure!( + !self.name.is_empty() && self.name.len() <= 96 && !self.name.chars().any(char::is_control), + "404 host name must be 1..96 bytes without controls" + ); + ensure!( + !self.frontend.hosts.is_empty(), + "404 host requires at least one hostname" + ); + let mut hosts = std::collections::HashSet::new(); + for host in &mut self.frontend.hosts { + *host = crate::config::normalize_host_pattern(host)?; + ensure!(hosts.insert(host.clone()), "duplicate 404 hostname"); + } + ensure!( + !self.frontend.listener_ids.is_empty() && self.frontend.listener_ids.len() <= 16, + "404 host requires 1..16 named listeners" + ); + if self.page.status.is_none() { + self.page.status = Some(default_status()); + } + self.page.validate_local_response()?; + let status = self.page.status.unwrap_or(default_status()); + if matches!(self.page.kind, crate::pages::Kind::Builtin) { + ensure!( + crate::pages::BUILTIN_ERROR_CODES.contains(&status), + "application-style 404 host status must use a built-in error template" + ); + } else { + ensure!( + crate::pages::ERROR_CODES.contains(&status), + "404 host status must use a supported error code" + ); + } + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct CompiledHost { + pub id: String, + pub bindings: Vec, + pub http2_enabled: bool, + pub http3_enabled: bool, + pub page: crate::pages::Page, +} + +pub fn compile(host: &Host, listeners: &crate::settings::Listeners) -> Result { + Ok(CompiledHost { + id: host.id.clone(), + bindings: host.frontend.resolved(listeners)?, + http2_enabled: host.http2_enabled, + http3_enabled: host.http3_enabled, + page: host.page.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn host(kind: crate::pages::Kind, status: u16) -> Host { + Host { + id: "missing".into(), + name: "Missing".into(), + enabled: true, + http2_enabled: true, + http3_enabled: true, + frontend: crate::vhost::Frontend { hosts: vec!["missing.example".into()], listener_ids: vec!["http".into()] }, + certificate_id: None, + page: crate::pages::Page { kind, status: Some(status), html: if matches!(kind, crate::pages::Kind::Html) { "

missing

".into() } else { String::new() } }, + } + } + + #[test] + fn local_hosts_accept_only_known_error_statuses() { + assert!(host(crate::pages::Kind::Welcome, 404).validate().is_ok()); + assert!(host(crate::pages::Kind::Empty, 502).validate().is_ok()); + assert!(host(crate::pages::Kind::Html, 418).validate().is_err()); + } + + #[test] + fn application_style_hosts_require_a_bundled_template() { + assert!(host(crate::pages::Kind::Builtin, 404).validate().is_ok()); + assert!(host(crate::pages::Kind::Builtin, 500).validate().is_err()); + } +} diff --git a/src/pages.rs b/src/pages.rs new file mode 100644 index 0000000..ffce713 --- /dev/null +++ b/src/pages.rs @@ -0,0 +1,406 @@ +//! Bounded administrator-authored responses. No filesystem paths, templates, +//! external fetches, or user input interpolation on the request path. +use anyhow::{ensure, Result}; +use axum::{body::Body, response::Response}; +use http::{HeaderValue, Method, StatusCode}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +pub const MAX_HTML_BYTES: usize = 32 * 1024; +pub const ERROR_CODES: [u16; 11] = [400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504]; +pub const BUILTIN_ERROR_CODES: [u16; 6] = [401, 403, 404, 429, 503, 504]; + +/// Built-in operational gateway pages. They are deliberately not marked as +/// `PageServed`, so an administrator-configured vhost/global error page can +/// still replace them in the normal response policy pass. +pub fn builtin_gateway_error(status: StatusCode) -> Option<&'static str> { + match status { + StatusCode::UNAUTHORIZED => Some(include_str!("admin/web/error_401.html")), + StatusCode::FORBIDDEN => Some(include_str!("admin/web/error_403.html")), + StatusCode::NOT_FOUND => Some(include_str!("admin/web/error_404.html")), + StatusCode::TOO_MANY_REQUESTS => Some(include_str!("admin/web/error_429.html")), + StatusCode::SERVICE_UNAVAILABLE => Some(include_str!("admin/web/error_503.html")), + StatusCode::GATEWAY_TIMEOUT => Some(include_str!("admin/web/error_504.html")), + _ => None, + } +} +fn builtin_application_page(status: StatusCode) -> String { + if let Some(page) = builtin_gateway_error(status) { return page.to_owned(); } + let reason = status.canonical_reason().unwrap_or("Application response"); + format!(r#"{code} {reason}

{code}

{reason}

RustProxy

"#, code=status.as_u16(), reason=reason) +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Kind { + #[default] + Welcome, + Empty, + Html, + Builtin, +} +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Page { + pub kind: Kind, + /// None retains an error's original status (or 200 for a static vhost). + pub status: Option, + pub html: String, +} +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct GlobalPages { + pub errors: BTreeMap, + pub direct_ip: Option, + pub unknown_host: Option, +} +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct VhostPages { + /// A static/maintenance vhost response; no upstream request is made. + pub response: Option, + /// Only when the hostname is bound here but no path route matches. + pub fallback: Option, + pub errors: BTreeMap, +} +#[derive(Clone)] +pub struct PageServed; +/// Marker for the built-in no-application placeholder. The outer proxy layer +/// keeps this response deliberately minimal: no cache storage and no optional +/// server/diagnostic/security/compression decoration. +#[derive(Clone)] +pub struct MinimalWelcome; + +impl Page { + pub fn validate_local_response(&self) -> Result<()> { + ensure!( + self.html.len() <= MAX_HTML_BYTES, + "custom page HTML exceeds 32 KiB" + ); + if let Some(status) = self.status { + ensure!( + (200..=599).contains(&status) && StatusCode::from_u16(status).is_ok(), + "local response status must be 200..599" + ); + ensure!( + !matches!(status, 204 | 205 | 304) || matches!(self.kind, Kind::Empty), + "204, 205 and 304 require an empty page" + ); + } + ensure!( + !matches!(self.kind, Kind::Html) || !self.html.trim().is_empty(), + "HTML page cannot be blank; use empty mode" + ); + Ok(()) + } + pub fn validate(&self) -> Result<()> { + ensure!( + self.html.len() <= MAX_HTML_BYTES, + "custom page HTML exceeds 32 KiB" + ); + if let Some(status) = self.status { + ensure!( + matches!( + status, + 200 | 204 + | 400 + | 401 + | 403 + | 404 + | 408 + | 410 + | 413 + | 429 + | 500 + | 502 + | 503 + | 504 + ), + "unsupported custom page status" + ); + ensure!( + status != 204 || matches!(self.kind, Kind::Empty), + "204 requires an empty page" + ); + } + ensure!( + !matches!(self.kind, Kind::Html) || !self.html.trim().is_empty(), + "HTML page cannot be blank; use empty mode" + ); + Ok(()) + } + fn body(&self, status: StatusCode) -> String { + match self.kind { + Kind::Empty => String::new(), + Kind::Html => self.html.clone(), + Kind::Welcome => include_str!("admin/web/demo.html").to_owned(), + Kind::Builtin => builtin_application_page(status), + } + } + pub fn render(&self, default_status: StatusCode) -> Response { + let status = self + .status + .and_then(|v| StatusCode::from_u16(v).ok()) + .unwrap_or(default_status); + let bodyless = matches!(status, StatusCode::NO_CONTENT | StatusCode::RESET_CONTENT | StatusCode::NOT_MODIFIED); + let body = if bodyless { String::new() } else { self.body(status) }; + let length = body.len(); + let mut response = Response::new(Body::from(body)); + *response.status_mut() = status; + let headers = response.headers_mut(); + headers.insert( + "content-type", + HeaderValue::from_static(if matches!(self.kind, Kind::Empty) { + "text/plain; charset=utf-8" + } else { + "text/html; charset=utf-8" + }), + ); + if !bodyless { + if let Ok(value) = HeaderValue::from_str(&length.to_string()) { + headers.insert("content-length", value); + } + } + // The built-in placeholder is intentionally lean. `no-store` prevents + // intermediary/browser caching; optional proxy headers are skipped later + // by the response observer. Custom HTML/empty pages retain nosniff. + headers.insert("cache-control", HeaderValue::from_static("no-store")); + if matches!(self.kind, Kind::Welcome) { + response.extensions_mut().insert(MinimalWelcome); + } else { + headers.insert( + "x-content-type-options", + HeaderValue::from_static("nosniff"), + ); + } + response.extensions_mut().insert(PageServed); + response + } + pub fn serve(&self, method: &Method, default_status: StatusCode) -> Response { + if !matches!(*method, Method::GET | Method::HEAD) { + let mut response = Response::new(Body::empty()); + *response.status_mut() = StatusCode::METHOD_NOT_ALLOWED; + response + .headers_mut() + .insert("allow", HeaderValue::from_static("GET, HEAD")); + response + .headers_mut() + .insert("cache-control", HeaderValue::from_static("no-store")); + response.extensions_mut().insert(PageServed); + return response; + } + self.render(default_status) + } +} +fn validate_errors(errors: &BTreeMap) -> Result<()> { + ensure!( + errors.len() <= ERROR_CODES.len(), + "too many custom error pages" + ); + for (code, page) in errors { + ensure!(ERROR_CODES.contains(code), "unsupported error page code"); + page.validate()?; + } + Ok(()) +} +impl GlobalPages { + pub fn validate(&self) -> Result<()> { + validate_errors(&self.errors)?; + for page in [&self.direct_ip, &self.unknown_host].into_iter().flatten() { + page.validate()?; + } + Ok(()) + } +} +impl VhostPages { + pub fn validate(&self) -> Result<()> { + validate_errors(&self.errors)?; + for page in [&self.response, &self.fallback].into_iter().flatten() { + page.validate()?; + } + Ok(()) + } +} +/// Replace only the representation. Preserve authentication challenges and other +/// protocol headers; remove old representation metadata and untrusted cookies. +pub fn replace_error(response: &mut Response, page: &Page) { + if response.extensions().get::().is_some() { + return; + } + let mut replacement = page.render(response.status()); + for name in [ + "www-authenticate", + "proxy-authenticate", + "allow", + "retry-after", + ] { + for value in response.headers().get_all(name) { + replacement.headers_mut().append(name, value.clone()); + } + } + *response = replacement; +} +pub fn literal_ip_authority(value: &str) -> bool { + !value.contains('@') + && value + .parse::() + .ok() + .is_some_and(|authority| { + authority + .host() + .trim_start_matches('[') + .trim_end_matches(']') + .parse::() + .is_ok() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use http_body_util::BodyExt; + #[tokio::test] + async fn error_replacement_drops_encoding_and_retains_challenge() { + let mut response = Response::new(Body::from("compressed bytes")); + *response.status_mut() = StatusCode::UNAUTHORIZED; + response.headers_mut().insert( + "www-authenticate", + HeaderValue::from_static("Basic realm=\"app\""), + ); + response + .headers_mut() + .insert("content-encoding", HeaderValue::from_static("gzip")); + response + .headers_mut() + .insert("set-cookie", HeaderValue::from_static("secret=1")); + replace_error( + &mut response, + &Page { + kind: Kind::Html, + html: "

Sign in

".into(), + status: None, + }, + ); + assert_eq!(response.status(), 401); + assert!(response.headers().contains_key("www-authenticate")); + assert!(!response.headers().contains_key("content-encoding")); + assert!(!response.headers().contains_key("set-cookie")); + assert_eq!( + response.into_body().collect().await.unwrap().to_bytes(), + "

Sign in

" + ); + } + #[test] + fn local_page_method_rejection_is_still_marked_non_cacheable() { + let page = Page::default(); + let response = page.serve(&Method::POST, StatusCode::NOT_FOUND); + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(response.headers().get("cache-control").unwrap(), "no-store"); + assert!(response.extensions().get::().is_some()); + } + + #[test] + fn builtin_gateway_pages_cover_edge_policy_and_gateway_errors() { + assert!(builtin_gateway_error(StatusCode::UNAUTHORIZED) + .unwrap() + .contains("401 - Authentication required")); + assert!(builtin_gateway_error(StatusCode::FORBIDDEN) + .unwrap() + .contains("403 - Access denied")); + assert!(builtin_gateway_error(StatusCode::NOT_FOUND) + .unwrap() + .contains("404 - Not found")); + assert!(builtin_gateway_error(StatusCode::TOO_MANY_REQUESTS) + .unwrap() + .contains("429 - Too many requests")); + assert!(builtin_gateway_error(StatusCode::SERVICE_UNAVAILABLE) + .unwrap() + .contains("503 - Service unavailable")); + assert!(builtin_gateway_error(StatusCode::GATEWAY_TIMEOUT) + .unwrap() + .contains("504 - Gateway timeout")); + assert!(builtin_gateway_error(StatusCode::BAD_GATEWAY).is_none()); + } + #[test] + fn validated_bounded_pages_and_ip_detection() { + assert!(Page { + html: "x".repeat(MAX_HTML_BYTES + 1), + ..Page::default() + } + .validate() + .is_err()); + assert!(Page { + status: Some(204), + ..Page::default() + } + .validate() + .is_err()); + assert!(Page { + kind: Kind::Empty, + status: Some(204), + html: String::new() + } + .validate() + .is_ok()); + assert!(Page { + status: Some(302), + ..Page::default() + } + .validate() + .is_err()); + for authority in ["127.0.0.1:8080", "[::1]:8443", "10.0.0.1"] { + assert!(literal_ip_authority(authority)); + } + for authority in ["app.test:80", "evil@127.0.0.1", "not-an-ip"] { + assert!(!literal_ip_authority(authority)); + } + } + #[test] + fn local_response_accepts_full_final_status_range_with_bodyless_guards() { + assert!(Page { + kind: Kind::Html, + status: Some(418), + html: "teapot".into(), + } + .validate_local_response() + .is_ok()); + assert!(Page { + kind: Kind::Empty, + status: Some(599), + html: String::new(), + } + .validate_local_response() + .is_ok()); + assert!(Page { + kind: Kind::Html, + status: Some(304), + html: "not allowed".into(), + } + .validate_local_response() + .is_err()); + assert!(Page { + kind: Kind::Empty, + status: Some(600), + html: String::new(), + } + .validate_local_response() + .is_err()); + } + + #[test] + fn builtin_welcome_is_minimal_and_never_cacheable() { + let page = Page::default(); + let response = page.render(StatusCode::OK); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers().get("cache-control").unwrap(), "no-store"); + assert!(!response.headers().contains_key("x-content-type-options")); + assert!(response.extensions().get::().is_some()); + } + + #[test] + fn static_pages_do_not_accept_writes() { + let page = Page::default(); + assert_eq!(page.serve(&Method::POST, StatusCode::OK).status(), 405); + assert_eq!(page.serve(&Method::GET, StatusCode::OK).status(), 200); + } +} diff --git a/src/prometheus.rs b/src/prometheus.rs new file mode 100644 index 0000000..a7130f0 --- /dev/null +++ b/src/prometheus.rs @@ -0,0 +1,1270 @@ +//! Dedicated read-only scrape endpoint, independent of administrator sessions. +//! Configured labels only: never export request URLs, queries, users or secrets. +use crate::{runtime::App, server::PeerAddr}; +use anyhow::{ensure, Context, Result}; +use axum::{ + extract::{Request, State}, + response::{IntoResponse, Response}, + Router, +}; +use http::StatusCode; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + fmt::Write, + net::SocketAddr, + sync::{atomic::Ordering, Arc}, +}; +use zeroize::Zeroizing; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + pub enabled: bool, + pub listen: SocketAddr, + pub path: String, + pub allowed_cidrs: Vec, + /// Only a variable NAME is persisted; its value is never in config/API. + pub bearer_token_env: Option, + /// Allow clients already matched by `allowed_cidrs` to scrape without a bearer token. + /// Intended only for private/trusted monitoring networks. + pub allow_trusted_network_without_token: bool, + pub include_system: bool, + pub include_vhosts: bool, + pub include_backends: bool, + pub include_docker_vhosts: bool, + pub include_cache_domains: bool, + pub include_streams: bool, + pub include_logging: bool, +} +impl Default for Settings { + fn default() -> Self { + Self { + enabled: false, + listen: "127.0.0.1:9191".parse().expect("static socket"), + path: "/metrics".into(), + allowed_cidrs: vec!["127.0.0.0/8".into(), "::1/128".into()], + bearer_token_env: None, + allow_trusted_network_without_token: false, + include_system: true, + include_vhosts: true, + include_backends: true, + include_docker_vhosts: true, + include_cache_domains: true, + include_streams: true, + include_logging: true, + } + } +} +impl Settings { + pub fn validate(&self) -> Result<()> { + ensure!( + self.listen.port() > 0 && !self.listen.ip().is_multicast(), + "metrics: listen needs a unicast/loopback IP and nonzero port" + ); + ensure!( + self.path.starts_with('/') + && self.path.len() <= 128 + && !self.path.contains("//") + && !self.path.contains("..") + && self + .path + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"/_-.".contains(&b)), + "metrics: path must be a plain absolute HTTP path without a query" + ); + ensure!( + !self.allowed_cidrs.is_empty() && self.allowed_cidrs.len() <= 32, + "metrics: specify 1..32 allowed client CIDRs" + ); + for cidr in &self.allowed_cidrs { + let network = cidr + .parse::() + .context("metrics: invalid client CIDR")?; + if self.allow_trusted_network_without_token { + ensure!( + network.prefix_len() > 0, + "metrics: token-free trusted networks cannot use a /0 CIDR" + ); + } + } + if let Some(name) = &self.bearer_token_env { + ensure!( + !name.is_empty() + && name.len() <= 128 + && name + .bytes() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_') + && !name.as_bytes()[0].is_ascii_digit(), + "metrics: token environment variable must use A-Z, 0-9, underscore" + ); + } + ensure!( + !self.enabled + || self.listen.ip().is_loopback() + || self.bearer_token_env.is_some() + || self.allow_trusted_network_without_token, + "metrics: a non-loopback bind requires a bearer token or explicit trusted-network token bypass; protect HTTP with a private network/TLS tunnel" + ); + Ok(()) + } + pub fn restart_differs(&self, other: &Self) -> bool { + self.enabled != other.enabled + || self.listen != other.listen + || self.bearer_token_env != other.bearer_token_env + } + pub fn token(&self) -> Result>> { + let Some(name) = &self.bearer_token_env else { + return Ok(None); + }; + let value = Zeroizing::new( + std::env::var(name).context("metrics: configured token variable is missing")?, + ); + ensure!( + value.len() >= 32 && value.len() <= 4096 && value.bytes().all(|b| b.is_ascii_graphic()), + "metrics: token must contain 32..4096 non-whitespace ASCII characters" + ); + Ok(Some(value)) + } +} +struct Exporter { + app: Arc, + token: Option>, + slots: tokio::sync::Semaphore, +} +pub fn router(app: Arc, token: Option>) -> Router { + Router::new() + .fallback(scrape) + .with_state(Arc::new(Exporter { + app, + token, + slots: tokio::sync::Semaphore::new(4), + })) +} +async fn scrape(State(exporter): State>, request: Request) -> Response { + let config = exporter.app.config.load_full(); + let settings = &config.envelope.config.prometheus; + // Revocation is immediate even when changing bind/credentials needs restart. + if !settings.enabled + || settings.restart_differs(&exporter.app.active_prometheus) + || request.uri().path() != settings.path + || request.uri().query().is_some() + { + return StatusCode::NOT_FOUND.into_response(); + } + if request.method() != http::Method::GET { + return (StatusCode::METHOD_NOT_ALLOWED, [("allow", "GET")]).into_response(); + } + let allowed = request.extensions().get::().is_some_and(|peer| { + let peer = crate::vhost::canonical_ip(peer.0.ip()); + settings + .allowed_cidrs + .iter() + .filter_map(|v| v.parse::().ok()) + .any(|cidr| cidr.contains(&peer)) + }); + if !allowed { + return StatusCode::FORBIDDEN.into_response(); + } + if let Some(expected) = &exporter.token { + if settings.allow_trusted_network_without_token { + // The actual transport peer already matched an administrator-configured + // CIDR above. Forwarded headers never participate in this decision. + } else { + let values: Vec<_> = request + .headers() + .get_all(http::header::AUTHORIZATION) + .iter() + .collect(); + let valid = values.len() == 1 + && values[0] + .to_str() + .ok() + .and_then(|v| v.strip_prefix("Bearer ")) + .is_some_and(|value| crate::auth::equal_secret(value, expected)); + if !valid { + return ( + StatusCode::UNAUTHORIZED, + [ + ("www-authenticate", "Bearer"), + ("cache-control", "no-store"), + ], + ) + .into_response(); + } + } + } + let Ok(_permit) = exporter.slots.try_acquire() else { + return StatusCode::TOO_MANY_REQUESTS.into_response(); + }; + response(&exporter.app) +} +pub fn response(app: &App) -> Response { + ( + [ + ("content-type", "text/plain; version=0.0.4; charset=utf-8"), + ("cache-control", "no-store"), + ("x-content-type-options", "nosniff"), + ], + render(app), + ) + .into_response() +} +fn escaped(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('\n', "\\n") + .replace('"', "\\\"") +} +fn labels(items: &[(&str, &str)]) -> String { + items + .iter() + .map(|(k, v)| format!("{k}=\"{}\"", escaped(v))) + .collect::>() + .join(",") +} +#[derive(Default)] +struct Families(BTreeMap)>); +impl Families { + fn add( + &mut self, + name: &str, + kind: &'static str, + help: &'static str, + labels: &str, + value: impl std::fmt::Display, + ) { + let family = self + .0 + .entry(name.into()) + .or_insert((kind, help, Vec::new())); + family.2.push(if labels.is_empty() { + format!("{name} {value}\n") + } else { + format!("{name}{{{labels}}} {value}\n") + }); + } + fn finish(self, out: &mut String) { + for (name, (kind, help, samples)) in self.0 { + let _ = writeln!(out, "# HELP {name} {help}\n# TYPE {name} {kind}"); + for sample in samples { + out.push_str(&sample); + } + } + } +} +pub fn render(app: &App) -> String { + let snapshot = app.config.load_full(); + let config = &snapshot.envelope.config; + let settings = &config.prometheus; + let mut docker_targets = BTreeMap::new(); + for agent in &config.docker_agents.agents { + for assignment in &agent.assignments { + docker_targets.insert( + crate::docker::generated_id(&agent.id, &assignment.service_key), + (agent, assignment), + ); + } + } + let mut out = app.metrics.prometheus( + app.logs.dropped.load(Ordering::Relaxed), + snapshot.envelope.revision, + ); + out.push_str(&app.cache.prometheus()); + if settings.include_system { + out.push_str(&app.diagnostics.prometheus()); + } + let mut f = Families::default(); + f.add( + "rust_proxy_admin_requests_total", + "counter", + "Admin HTTP requests, including denied requests and static assets.", + "", + app.metrics.admin_requests.load(Ordering::Relaxed), + ); + for (index, counter) in app.metrics.admin_status.iter().enumerate() { + f.add( + "rust_proxy_admin_responses_total", + "counter", + "Admin response headers by status class.", + &format!("class=\"{}xx\"", index + 1), + counter.load(Ordering::Relaxed), + ); + } + out.push_str("# HELP rust_proxy_admin_header_duration_seconds Time to control-plane response headers; not SSE lifetime.\n# TYPE rust_proxy_admin_header_duration_seconds summary\n"); + let _ = writeln!(out, "rust_proxy_admin_header_duration_seconds_count {}\nrust_proxy_admin_header_duration_seconds_sum {:.6}", + app.metrics.admin_response_headers.load(Ordering::Relaxed), app.metrics.admin_header_duration_us.load(Ordering::Relaxed) as f64/1e6); + f.add( + "rust_proxy_build_info", + "gauge", + "Build version.", + &labels(&[("version", env!("CARGO_PKG_VERSION"))]), + 1, + ); + f.add( + "rust_proxy_uptime_seconds", + "gauge", + "Process monotonic uptime.", + "", + app.started.elapsed().as_secs_f64(), + ); + f.add( + "rust_proxy_vhosts_configured", + "gauge", + "Configured vhosts including disabled.", + "", + config.vhosts.len(), + ); + f.add( + "rust_proxy_redirect_hosts_configured", + "gauge", + "Configured redirect hosts including disabled.", + "", + config.redirects.len(), + ); + f.add( + "rust_proxy_not_found_hosts_configured", + "gauge", + "Configured local-response/404 hosts including disabled.", + "", + config.not_found_hosts.len(), + ); + f.add( + "rust_proxy_stream_hosts_configured", + "gauge", + "Configured Layer-4 stream hosts including disabled.", + "", + config.streams.len(), + ); + f.add( + "rust_proxy_acl_profiles_configured", + "gauge", + "Configured reusable ACL profiles.", + "", + config.acl_profiles.len(), + ); + if settings.include_system { + let geo = app.geoip.view(); + f.add( + "rust_proxy_geoip_loaded", + "gauge", + "Whether the bounded country GeoIP database is loaded.", + "", + u8::from(geo.loaded), + ); + f.add( + "rust_proxy_geoip_ipv4_ranges", + "gauge", + "Loaded IPv4 country ranges.", + "", + geo.ipv4_ranges, + ); + f.add( + "rust_proxy_geoip_ipv6_ranges", + "gauge", + "Loaded IPv6 country ranges.", + "", + geo.ipv6_ranges, + ); + } + f.add( + "rust_proxy_certificates_configured", + "gauge", + "Certificate inventory size, not validity.", + "", + config.tls.certificates.len(), + ); + let renewals = app.acme.renewal_view(); + for certificate in &config.tls.certificates { + let l = labels(&[("certificate", &certificate.id)]); + let issuer = certificate.issuer.as_ref(); + f.add( + "rust_proxy_certificate_auto_renew_enabled", + "gauge", + "Desired automatic renewal switch for a configured certificate.", + &l, + u8::from(issuer.is_some_and(|issuer| issuer.auto_renew)), + ); + if let Some(issuer) = issuer { + f.add( + "rust_proxy_certificate_issuer_info", + "gauge", + "Bounded configured certificate issuer metadata.", + &labels(&[ + ("certificate", &certificate.id), + ("provider", &issuer.provider), + ("environment", &issuer.environment), + ]), + 1, + ); + } + if let Some(renewal) = renewals.get(&certificate.id) { + for (name, value) in [ + ("last_checked_timestamp_seconds", Some(renewal.last_checked)), + ("next_check_timestamp_seconds", Some(renewal.next_check)), + ("last_renewed_timestamp_seconds", renewal.last_renewed), + ("not_after_timestamp_seconds", renewal.not_after), + ] { + if let Some(value) = value.filter(|value| *value > 0) { + f.add( + &format!("rust_proxy_certificate_renewal_{name}"), + "gauge", + "Certificate renewal observation timestamp.", + &l, + value, + ); + } + } + f.add( + "rust_proxy_certificate_renewal_failures", + "gauge", + "Consecutive observed automatic renewal failures.", + &l, + renewal.failures, + ); + if !renewal.status.is_empty() { + f.add( + "rust_proxy_certificate_renewal_status_info", + "gauge", + "Latest bounded certificate renewal status.", + &labels(&[ + ("certificate", &certificate.id), + ("status", &renewal.status), + ]), + 1, + ); + } + } + } + f.add( + "rust_proxy_listeners_active", + "gauge", + "Bound frontend endpoints.", + "", + app.active_listeners.len(), + ); + f.add( + "rust_proxy_restart_required", + "gauge", + "Listener/exporter intent differs from startup state.", + "", + u8::from( + snapshot.listeners != app.active_listeners + || settings.restart_differs(&app.active_prometheus), + ), + ); + f.add( + "rust_proxy_request_capacity", + "gauge", + "Configured maximum concurrent admitted exchanges.", + "", + app.max_inflight, + ); + f.add( + "rust_proxy_request_permits_available", + "gauge", + "Currently available exchange permits.", + "", + app.inflight.available_permits(), + ); + if let Some(job) = app.acme.view() { + f.add( + "rust_proxy_acme_job_state", + "gauge", + "Latest ACME job state; no domains or secrets.", + &labels(&[("provider", &job.provider), ("state", job.status)]), + 1, + ); + } + for (index, listener) in app.active_listeners.iter().enumerate() { + let index = index.to_string(); + let address = listener.address.to_string(); + let l = labels(&[("listener", &index), ("address", &address)]); + for (protocol, enabled) in [ + ("http1", true), + ("http2", listener.tls && listener.http2), + ("http3", listener.http3.is_some()), + ("tls", listener.tls), + ] { + f.add( + "rust_proxy_listener_protocol_enabled", + "gauge", + "Protocols active per listener.", + &format!("{l},protocol=\"{protocol}\""), + u8::from(enabled), + ); + } + } + if settings.include_vhosts { + for redirect in &config.redirects { + let l = labels(&[("redirect", &redirect.id)]); + f.add( + "rust_proxy_redirect_host_enabled", + "gauge", + "Desired redirect-host enable flag.", + &l, + u8::from(redirect.enabled), + ); + f.add( + "rust_proxy_redirect_host_domains", + "gauge", + "Configured DNS names for a redirect host.", + &l, + redirect.frontend.hosts.len(), + ); + f.add( + "rust_proxy_redirect_host_rules", + "gauge", + "Configured redirect rules.", + &l, + redirect.rules.len(), + ); + } + for host in &config.not_found_hosts { + let l = labels(&[("host", &host.id)]); + f.add( + "rust_proxy_not_found_host_enabled", + "gauge", + "Desired local-response/404-host enable flag.", + &l, + u8::from(host.enabled), + ); + f.add( + "rust_proxy_not_found_host_domains", + "gauge", + "Configured DNS names for a local-response/404 host.", + &l, + host.frontend.hosts.len(), + ); + f.add( + "rust_proxy_not_found_host_status", + "gauge", + "Configured local-response HTTP status.", + &l, + host.page.status.unwrap_or(404), + ); + } + for host in &config.vhosts { + let l = labels(&[("vhost", &host.id)]); + f.add( + "rust_proxy_vhost_enabled", + "gauge", + "Desired vhost enable flag.", + &l, + u8::from(host.enabled), + ); + f.add( + "rust_proxy_vhost_domains", + "gauge", + "Configured DNS names for a vhost.", + &l, + host.frontend.hosts.len(), + ); + f.add( + "rust_proxy_vhost_paths", + "gauge", + "Configured path rules for a vhost.", + &l, + host.routes.len(), + ); + if let Ok(cache) = host.cache.resolve(&config.cache) { + f.add( + "rust_proxy_vhost_cache_enabled", + "gauge", + "Desired effective cache switch; request privacy rules still apply.", + &l, + u8::from(cache.enabled), + ); + f.add( + "rust_proxy_vhost_cache_max_object_bytes", + "gauge", + "Effective maximum cache object size.", + &l, + cache.max_object_bytes, + ); + f.add( + "rust_proxy_vhost_cache_max_ttl_seconds", + "gauge", + "Effective maximum cache TTL.", + &l, + cache.max_ttl_seconds, + ); + } + for domain in &host.frontend.hosts { + f.add( + "rust_proxy_vhost_domain_info", + "gauge", + "Configured name ownership.", + &labels(&[("vhost", &host.id), ("domain", domain)]), + 1, + ); + } + let m = &snapshot.vhost_metrics[&host.id]; + for (name, counter) in [ + ("requests_total", &m.requests), + ("completed_total", &m.completed), + ("errors_total", &m.errors), + ("rejected_total", &m.rejected), + ("request_bytes_total", &m.bytes_in), + ("response_bytes_total", &m.bytes_out), + ] { + f.add( + &format!("rust_proxy_vhost_{name}"), + "counter", + "Vhost exchange counter; reset on process restart or vhost removal.", + &l, + counter.load(Ordering::Relaxed), + ); + } + f.add( + "rust_proxy_vhost_active", + "gauge", + "Admitted unfinished exchanges.", + &l, + m.active.load(Ordering::Relaxed), + ); + for (index, counter) in m.status.iter().enumerate() { + f.add( + "rust_proxy_vhost_responses_total", + "counter", + "Completed exchanges by status class.", + &format!("{l},class=\"{}xx\"", index + 1), + counter.load(Ordering::Relaxed), + ); + } + } + } + if settings.include_backends { + for pool in &snapshot.pools { + if docker_targets.contains_key(&pool.id) { + continue; + } + let view = pool.view(); + if let (Some(algorithm), Some(health_mode), Some(sticky_mode)) = ( + view["algorithm"].as_str(), + view["health_mode"].as_str(), + view["sticky_mode"].as_str(), + ) { + f.add( + "rust_proxy_backend_pool_info", + "gauge", + "Bounded backend-pool selection and health policy.", + &labels(&[ + ("vhost", &pool.id), + ("algorithm", algorithm), + ("health_mode", health_mode), + ("sticky_mode", sticky_mode), + ]), + 1, + ); + } + f.add( + "rust_proxy_backend_pool_ready", + "gauge", + "At least one healthy eligible pool member.", + &labels(&[("vhost", &pool.id)]), + u8::from(pool.ready()), + ); + if let Some(members) = view["members"].as_array() { + for member in members { + let l = labels(&[ + ("vhost", &pool.id), + ("backend", member["id"].as_str().unwrap_or("unknown")), + ]); + let status = member["status"].as_str().unwrap_or("unknown"); + f.add( + "rust_proxy_backend_status_info", + "gauge", + "Latest bounded backend selection/health state.", + &format!("{l},status=\"{}\"", escaped(status)), + 1, + ); + for (name, flag) in [ + ( + "up", + status != "disabled" && member["healthy"].as_bool().unwrap_or(false), + ), + ("draining", status == "draining"), + ("backup", member["backup"].as_bool().unwrap_or(false)), + ] { + f.add( + &format!("rust_proxy_backend_{name}"), + "gauge", + "Backend health or selection state.", + &l, + u8::from(flag), + ); + } + for (field, metric, kind) in [ + ("active", "active_exchanges", "gauge"), + ("requests", "requests_total", "counter"), + ("errors", "errors_total", "counter"), + ("bytes_in", "bytes_in_total", "counter"), + ("bytes_out", "bytes_out_total", "counter"), + ("weight", "weight", "gauge"), + ("failures", "consecutive_failures", "gauge"), + ("passive_failures", "passive_failures", "gauge"), + ("successes", "consecutive_successes", "gauge"), + ("passive_successes", "passive_successes", "gauge"), + ("transitions", "health_transitions_total", "counter"), + ("active_health_failures_total", "active_health_failures_total", "counter"), + ("passive_health_failures_total", "passive_health_failures_total", "counter"), + ("http_5xx_total", "http_5xx_total", "counter"), + ("http_5xx_total", "passive_http_5xx_total", "counter"), + ("timeouts_total", "timeouts_total", "counter"), + ("timeouts_total", "passive_timeouts_total", "counter"), + ("connect_errors_total", "connect_errors_total", "counter"), + ("connect_errors_total", "passive_connect_errors_total", "counter"), + ("io_errors_total", "io_errors_total", "counter"), + ("io_errors_total", "passive_io_errors_total", "counter"), + ("slow_responses_total", "slow_responses_total", "counter"), + ("slow_responses_total", "passive_slow_total", "counter"), + ("capacity_rejections_total", "capacity_rejections_total", "counter"), + ("queue_timeouts_total", "queue_timeouts_total", "counter"), + ("dns_errors_total", "dns_errors_total", "counter"), + ("tls_errors_total", "tls_errors_total", "counter"), + ("response_header_timeouts_total", "response_header_timeouts_total", "counter"), + ("read_timeouts_total", "read_timeouts_total", "counter"), + ("send_timeouts_total", "send_timeouts_total", "counter"), + ("max_concurrent_requests", "max_concurrent_requests", "gauge"), + ("queue_timeout_ms", "queue_timeout_milliseconds", "gauge"), + ("last_status", "last_http_status", "gauge"), + ] { + if let Some(n) = member[field].as_u64() { + f.add( + &format!("rust_proxy_backend_{metric}"), + kind, + "Backend observation.", + &l, + n, + ); + } + } + for (field, metric, divisor) in [ + ("latency_ms", "latency_seconds", 1000.0), + ("ewma_latency_ms", "ewma_latency_seconds", 1000.0), + ("last_probe_ms", "last_probe_timestamp_seconds", 1000.0), + ("last_failure_at_ms", "last_failure_timestamp_seconds", 1000.0), + ("last_ejected_ms", "last_ejected_timestamp_seconds", 1000.0), + ("last_recovered_ms", "last_recovered_timestamp_seconds", 1000.0), + ("traffic_bytes_per_second", "traffic_bytes_per_second", 1.0), + ] { + if let Some(n) = member[field].as_f64() { + f.add( + &format!("rust_proxy_backend_{metric}"), + "gauge", + "Backend moving observation.", + &l, + n / divisor, + ); + } + } + if let Some(result) = member["last_result"] + .as_str() + .filter(|value| !value.is_empty()) + { + f.add( + "rust_proxy_backend_last_probe_result_info", + "gauge", + "Latest bounded backend health-probe result.", + &format!("{l},result=\"{}\"", escaped(result)), + 1, + ); + } + } + } + } + } + if settings.include_docker_vhosts { + f.add( + "rust_proxy_docker_agents_configured", + "gauge", + "Configured Docker discovery agents including disabled agents.", + "", + config.docker_agents.agents.len(), + ); + f.add( + "rust_proxy_docker_vhosts_configured", + "gauge", + "Configured Docker vhost assignments including disabled assignments.", + "", + docker_targets.len(), + ); + for agent in &config.docker_agents.agents { + let agent_labels = labels(&[("agent", &agent.id)]); + f.add( + "rust_proxy_docker_agent_enabled", + "gauge", + "Desired Docker agent enable flag.", + &agent_labels, + u8::from(agent.enabled), + ); + f.add( + "rust_proxy_docker_agent_assignments", + "gauge", + "Configured Docker vhost assignments for an agent.", + &agent_labels, + agent.assignments.len(), + ); + for assignment in &agent.assignments { + let vhost = crate::docker::generated_id(&agent.id, &assignment.service_key); + let base_labels = labels(&[ + ("agent", &agent.id), + ("vhost", &vhost), + ("service", &assignment.service_key), + ]); + let domains = crate::docker::assignment_hosts(assignment, &agent.base_domain) + .unwrap_or_default(); + f.add( + "rust_proxy_docker_vhost_enabled", + "gauge", + "Desired Docker vhost assignment enable flag.", + &base_labels, + u8::from(agent.enabled && assignment.enabled), + ); + f.add( + "rust_proxy_docker_vhost_domains", + "gauge", + "Configured DNS names for a Docker vhost assignment.", + &base_labels, + domains.len(), + ); + for domain in domains { + f.add( + "rust_proxy_docker_vhost_domain_info", + "gauge", + "Configured Docker vhost name ownership.", + &format!("{base_labels},domain=\"{}\"", escaped(&domain)), + 1, + ); + } + if let Some(m) = snapshot.vhost_metrics.get(&vhost) { + for (name, counter) in [ + ("requests_total", &m.requests), + ("completed_total", &m.completed), + ("errors_total", &m.errors), + ("rejected_total", &m.rejected), + ("request_bytes_total", &m.bytes_in), + ("response_bytes_total", &m.bytes_out), + ] { + f.add( + &format!("rust_proxy_docker_vhost_{name}"), + "counter", + "Docker vhost exchange counter; reset on process restart or assignment removal.", + &base_labels, + counter.load(Ordering::Relaxed), + ); + } + f.add( + "rust_proxy_docker_vhost_active", + "gauge", + "Admitted unfinished Docker-vhost exchanges.", + &base_labels, + m.active.load(Ordering::Relaxed), + ); + for (index, counter) in m.status.iter().enumerate() { + f.add( + "rust_proxy_docker_vhost_responses_total", + "counter", + "Completed Docker-vhost exchanges by status class.", + &format!("{base_labels},class=\"{}xx\"", index + 1), + counter.load(Ordering::Relaxed), + ); + } + } + let Some(pool) = snapshot.pools.iter().find(|pool| pool.id == vhost) else { + continue; + }; + let view = pool.view(); + if let (Some(algorithm), Some(health_mode), Some(sticky_mode)) = ( + view["algorithm"].as_str(), + view["health_mode"].as_str(), + view["sticky_mode"].as_str(), + ) { + f.add( + "rust_proxy_docker_backend_pool_info", + "gauge", + "Bounded Docker backend-pool selection and health policy.", + &format!( + "{base_labels},algorithm=\"{}\",health_mode=\"{}\",sticky_mode=\"{}\"", + escaped(algorithm), + escaped(health_mode), + escaped(sticky_mode) + ), + 1, + ); + } + f.add( + "rust_proxy_docker_backend_pool_ready", + "gauge", + "At least one healthy eligible Docker backend member.", + &base_labels, + u8::from(pool.ready()), + ); + if let Some(members) = view["members"].as_array() { + for member in members { + let backend = member["id"].as_str().unwrap_or("unknown"); + let member_labels = + format!("{base_labels},backend=\"{}\"", escaped(backend)); + let status = member["status"].as_str().unwrap_or("unknown"); + f.add( + "rust_proxy_docker_backend_status_info", + "gauge", + "Latest bounded Docker backend selection/health state.", + &format!("{member_labels},status=\"{}\"", escaped(status)), + 1, + ); + for (name, flag) in [ + ( + "up", + status != "disabled" + && member["healthy"].as_bool().unwrap_or(false), + ), + ("draining", status == "draining"), + ("backup", member["backup"].as_bool().unwrap_or(false)), + ] { + f.add( + &format!("rust_proxy_docker_backend_{name}"), + "gauge", + "Docker backend health or selection state.", + &member_labels, + u8::from(flag), + ); + } + for (field, metric, kind) in [ + ("active", "active_exchanges", "gauge"), + ("requests", "requests_total", "counter"), + ("errors", "errors_total", "counter"), + ("bytes_in", "bytes_in_total", "counter"), + ("bytes_out", "bytes_out_total", "counter"), + ("weight", "weight", "gauge"), + ("failures", "consecutive_failures", "gauge"), + ("passive_failures", "passive_failures", "gauge"), + ("successes", "consecutive_successes", "gauge"), + ("passive_successes", "passive_successes", "gauge"), + ("transitions", "health_transitions_total", "counter"), + ("active_health_failures_total", "active_health_failures_total", "counter"), + ("passive_health_failures_total", "passive_health_failures_total", "counter"), + ("http_5xx_total", "http_5xx_total", "counter"), + ("http_5xx_total", "passive_http_5xx_total", "counter"), + ("timeouts_total", "timeouts_total", "counter"), + ("timeouts_total", "passive_timeouts_total", "counter"), + ("connect_errors_total", "connect_errors_total", "counter"), + ("connect_errors_total", "passive_connect_errors_total", "counter"), + ("io_errors_total", "io_errors_total", "counter"), + ("io_errors_total", "passive_io_errors_total", "counter"), + ("slow_responses_total", "slow_responses_total", "counter"), + ("slow_responses_total", "passive_slow_total", "counter"), + ("capacity_rejections_total", "capacity_rejections_total", "counter"), + ("queue_timeouts_total", "queue_timeouts_total", "counter"), + ("dns_errors_total", "dns_errors_total", "counter"), + ("tls_errors_total", "tls_errors_total", "counter"), + ("response_header_timeouts_total", "response_header_timeouts_total", "counter"), + ("read_timeouts_total", "read_timeouts_total", "counter"), + ("send_timeouts_total", "send_timeouts_total", "counter"), + ("max_concurrent_requests", "max_concurrent_requests", "gauge"), + ("queue_timeout_ms", "queue_timeout_milliseconds", "gauge"), + ("last_status", "last_http_status", "gauge"), + ] { + if let Some(n) = member[field].as_u64() { + f.add( + &format!("rust_proxy_docker_backend_{metric}"), + kind, + "Docker backend observation.", + &member_labels, + n, + ); + } + } + for (field, metric, divisor) in [ + ("latency_ms", "latency_seconds", 1000.0), + ("ewma_latency_ms", "ewma_latency_seconds", 1000.0), + ("last_probe_ms", "last_probe_timestamp_seconds", 1000.0), + ("last_failure_at_ms", "last_failure_timestamp_seconds", 1000.0), + ("last_ejected_ms", "last_ejected_timestamp_seconds", 1000.0), + ("last_recovered_ms", "last_recovered_timestamp_seconds", 1000.0), + ("traffic_bytes_per_second", "traffic_bytes_per_second", 1.0), + ] { + if let Some(n) = member[field].as_f64() { + f.add( + &format!("rust_proxy_docker_backend_{metric}"), + "gauge", + "Docker backend moving observation.", + &member_labels, + n / divisor, + ); + } + } + if let Some(result) = member["last_result"] + .as_str() + .filter(|value| !value.is_empty()) + { + f.add( + "rust_proxy_docker_backend_last_probe_result_info", + "gauge", + "Latest bounded Docker backend health-probe result.", + &format!("{member_labels},result=\"{}\"", escaped(result)), + 1, + ); + } + } + } + } + } + } + if settings.include_streams { + for host in &config.streams { + let protocol = match host.protocol { + crate::stream::Protocol::Tcp => "tcp", + crate::stream::Protocol::Udp => "udp", + }; + f.add( + "rust_proxy_stream_enabled", + "gauge", + "Desired Layer-4 stream enable flag.", + &labels(&[("stream", &host.id), ("protocol", protocol)]), + u8::from(host.enabled), + ); + } + for host in &app.active_streams { + let Some(metrics) = app.stream_metrics.get(&host.id) else { + continue; + }; + let protocol = match host.protocol { + crate::stream::Protocol::Tcp => "tcp", + crate::stream::Protocol::Udp => "udp", + }; + let l = labels(&[("stream", &host.id), ("protocol", protocol)]); + let view = metrics.view(); + for (name, kind, value) in [ + ("sessions_total", "counter", view.sessions_total), + ( + "sessions_completed_total", + "counter", + view.sessions_completed, + ), + ("active", "gauge", view.active), + ("rejected_total", "counter", view.rejected), + ("errors_total", "counter", view.errors), + ("bytes_in_total", "counter", view.bytes_in), + ("bytes_out_total", "counter", view.bytes_out), + ("packets_in_total", "counter", view.packets_in), + ("packets_out_total", "counter", view.packets_out), + ] { + f.add( + &format!("rust_proxy_stream_{name}"), + kind, + "Layer-4 stream observation; reset on process restart.", + &l, + value, + ); + } + f.add( + "rust_proxy_stream_max_connections", + "gauge", + "Configured stream connection/session capacity.", + &l, + host.max_connections, + ); + } + } + if settings.include_logging { + let stats = app.logs.stats(); + f.add( + "rust_proxy_log_sink_queue_capacity", + "gauge", + "Structured log sink queue capacity.", + "", + stats.queue_capacity, + ); + f.add( + "rust_proxy_log_sink_queue_dropped_total", + "counter", + "Structured log entries dropped because the sink queue was full.", + "", + stats.queue_dropped, + ); + f.add( + "rust_proxy_log_sink_sent_total", + "counter", + "Structured log entries delivered to configured sinks.", + "", + stats.sent, + ); + f.add( + "rust_proxy_log_sink_errors_total", + "counter", + "Structured log sink delivery errors.", + "", + stats.errors, + ); + } + if settings.include_cache_domains { + // Bounded resident inspector. Counter families are gauges deliberately: + // these per-domain values reset on config publication, unlike globals. + let dashboard = app.cache.dashboard(None, 0, 1); + f.add( + "rust_proxy_cache_statistics_revision", + "gauge", + "Revision that owns domain statistics.", + "", + dashboard.statistics_revision, + ); + for domain in dashboard.domains { + // Only configured names, never arbitrary Host input or unknown scope. + if !config + .vhosts + .iter() + .any(|h| h.id == domain.vhost && h.frontend.hosts.contains(&domain.domain)) + { + continue; + } + let l = labels(&[("vhost", &domain.vhost), ("domain", &domain.domain)]); + for (name, n) in [ + ("objects", domain.entries as u64), + ("hot_objects", domain.hot_entries as u64), + ("resident_bytes", domain.resident_bytes as u64), + ("cold_objects", domain.cold_entries as u64), + ("cold_bytes", domain.cold_bytes), + ("hits", domain.counters.hits), + ("misses", domain.counters.misses), + ("bypasses", domain.counters.bypasses), + ("stored", domain.counters.stored), + ("saved_body_bytes", domain.counters.saved_body_bytes), + ] { + f.add( + &format!("rust_proxy_cache_domain_{name}"), + "gauge", + "Domain cache observation since current configuration revision.", + &l, + n, + ); + } + f.add( + "rust_proxy_cache_domain_hit_ratio", + "gauge", + "Domain HIT ratio 0..1.", + &l, + domain.hit_ratio, + ); + for (reason, count) in domain.counters.reasons { + f.add( + "rust_proxy_cache_domain_decisions", + "gauge", + "Decision reasons since config publication.", + &format!("{l},reason=\"{}\"", escaped(&reason)), + count, + ); + } + } + } + f.finish(&mut out); + if settings.include_vhosts { + out.push_str("# HELP rust_proxy_vhost_exchange_duration_seconds Full exchange lifetime including streaming and cancellation.\n# TYPE rust_proxy_vhost_exchange_duration_seconds histogram\n"); + for (id, m) in &snapshot.vhost_metrics { + if docker_targets.contains_key(id) { + continue; + } + let l = labels(&[("vhost", id)]); + let _ = writeln!( + out, + "rust_proxy_vhost_exchange_duration_seconds_sum{{{l}}} {:.6}", + m.duration_us.load(Ordering::Relaxed) as f64 / 1e6 + ); + // Read the cumulative last bucket once for +Inf and count. + let count = + m.buckets[crate::metrics::DURATION_BUCKETS_US.len()].load(Ordering::Relaxed); + let mut previous = 0; + for (i, threshold) in crate::metrics::DURATION_BUCKETS_US.iter().enumerate() { + let value = m.buckets[i] + .load(Ordering::Relaxed) + .max(previous) + .min(count); + previous = value; + let _ = writeln!( + out, + "rust_proxy_vhost_exchange_duration_seconds_bucket{{{l},le=\"{}\"}} {value}", + *threshold as f64 / 1e6 + ); + } + let _ = writeln!(out, "rust_proxy_vhost_exchange_duration_seconds_bucket{{{l},le=\"+Inf\"}} {count}\nrust_proxy_vhost_exchange_duration_seconds_count{{{l}}} {count}"); + } + } + if settings.include_docker_vhosts { + out.push_str("# HELP rust_proxy_docker_vhost_exchange_duration_seconds Full Docker-vhost exchange lifetime including streaming and cancellation.\n# TYPE rust_proxy_docker_vhost_exchange_duration_seconds histogram\n"); + for (id, m) in &snapshot.vhost_metrics { + let Some((agent, assignment)) = docker_targets.get(id) else { + continue; + }; + let l = labels(&[ + ("agent", &agent.id), + ("vhost", id), + ("service", &assignment.service_key), + ]); + let _ = writeln!( + out, + "rust_proxy_docker_vhost_exchange_duration_seconds_sum{{{l}}} {:.6}", + m.duration_us.load(Ordering::Relaxed) as f64 / 1e6 + ); + let count = + m.buckets[crate::metrics::DURATION_BUCKETS_US.len()].load(Ordering::Relaxed); + let mut previous = 0; + for (i, threshold) in crate::metrics::DURATION_BUCKETS_US.iter().enumerate() { + let value = m.buckets[i] + .load(Ordering::Relaxed) + .max(previous) + .min(count); + previous = value; + let _ = writeln!( + out, + "rust_proxy_docker_vhost_exchange_duration_seconds_bucket{{{l},le=\"{}\"}} {value}", + *threshold as f64 / 1e6 + ); + } + let _ = writeln!(out, "rust_proxy_docker_vhost_exchange_duration_seconds_bucket{{{l},le=\"+Inf\"}} {count}\nrust_proxy_docker_vhost_exchange_duration_seconds_count{{{l}}} {count}"); + } + } + out +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn secure_defaults_and_paths() { + let mut settings = Settings::default(); + assert!(!settings.enabled); + settings.validate().unwrap(); + settings.enabled = true; + settings.listen = "0.0.0.0:9191".parse().unwrap(); + assert!(settings.validate().is_err()); + settings.bearer_token_env = Some("RUST_PROXY_METRICS_TOKEN".into()); + settings.validate().unwrap(); + settings.bearer_token_env = None; + settings.allow_trusted_network_without_token = true; + settings.allowed_cidrs = vec!["10.20.30.0/24".into()]; + settings.validate().unwrap(); + settings.allowed_cidrs = vec!["0.0.0.0/0".into()]; + assert!(settings.validate().is_err()); + settings.allowed_cidrs = vec!["10.20.30.0/24".into()]; + for path in [ + "metrics", + "/metrics?secret=x", + "/../metrics", + "/m\nTYPE fake gauge", + ] { + settings.path = path.into(); + assert!(settings.validate().is_err()); + } + } + #[test] + fn escaping_and_grouping() { + assert_eq!(escaped("a\"b\\c\nd"), "a\\\"b\\\\c\\nd"); + let mut f = Families::default(); + f.add("x", "gauge", "Test.", "a=\"1\"", 1); + f.add("x", "gauge", "Test.", "a=\"2\"", 2); + let mut text = String::new(); + f.finish(&mut text); + assert_eq!(text.matches("# TYPE x").count(), 1); + assert!(text.ends_with('\n')); + } +} diff --git a/src/proxy/body.rs b/src/proxy/body.rs new file mode 100644 index 0000000..2b1d392 --- /dev/null +++ b/src/proxy/body.rs @@ -0,0 +1,439 @@ +//! Passes frames through unchanged, including trailers. No body collection. +use crate::{ + logging::{now_ms, LogEntry, Logs}, + metrics::Metrics, +}; +use axum::body::Body; +use bytes::Bytes; +use http_body::{Body as HttpBody, Frame, SizeHint}; +use std::{ + future::Future, + pin::Pin, + sync::{ + atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering}, + Arc, Mutex, + }, + task::{Context, Poll}, + time::{Duration, Instant}, +}; +use tokio::{ + sync::OwnedSemaphorePermit, + time::{sleep, Sleep}, +}; + +pub struct Exchange { + pub vhost_metrics: Option>, + pub vhost: Option, + pub route: Mutex>, + pub cache_effective_enabled: Mutex>, + pub transform_effective_enabled: Mutex>, + pub backend: Mutex>>, + pub id: u64, + pub method: String, + pub host: String, + pub path: String, + pub http_version: &'static str, + pub transport: &'static str, + pub websocket_bootstrap: Option<&'static str>, + pub websocket_phase: Mutex>, + pub websocket_error: Mutex>, + pub h3_reset_code: Mutex>, + pub upstream_status: AtomicU16, + pub upstream_error: Mutex>, + pub client_ip: Mutex>, + pub started: Instant, + pub status: AtomicU16, + pub cache_status: AtomicU16, + pub cache_reason: Mutex<&'static str>, + pub failed: AtomicBool, + pub aborted: AtomicBool, + pub request_bytes: AtomicU64, + pub response_bytes: AtomicU64, + pub metrics: Arc, + pub logs: Arc, + pub _permit: OwnedSemaphorePermit, +} +impl Exchange { + pub fn websocket_phase(&self, phase: &'static str) { + if self.websocket_bootstrap.is_some() { + *self.websocket_phase.lock().unwrap_or_else(|e| e.into_inner()) = Some(phase); + } + } + pub fn websocket_error(&self, phase: &'static str, error: &'static str) { + if self.websocket_bootstrap.is_some() { + *self.websocket_phase.lock().unwrap_or_else(|e| e.into_inner()) = Some(phase); + *self.websocket_error.lock().unwrap_or_else(|e| e.into_inner()) = Some(error); + } + } + pub fn h3_reset(&self, code: &'static str) { + *self.h3_reset_code.lock().unwrap_or_else(|e| e.into_inner()) = Some(code); + } + pub fn backend_bytes(&self, bytes: u64, incoming: bool) { + if let Some(backend) = self + .backend + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + { + backend.bytes(bytes, incoming); + } + } + fn backend_error(&self, reason: &'static str) { + if let Some(backend) = self + .backend + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + { + backend.error(reason, self.started.elapsed()); + } + } +} +impl Drop for Exchange { + fn drop(&mut self) { + let status = self.status.load(Ordering::Relaxed); + let failed = self.failed.load(Ordering::Relaxed); + let aborted = self.aborted.load(Ordering::Relaxed); + let duration = self.started.elapsed(); + if let Some(m) = &self.vhost_metrics { + m.complete( + status, + failed || aborted, + duration, + self.request_bytes.load(Ordering::Relaxed), + self.response_bytes.load(Ordering::Relaxed), + ); + } + self.metrics.active.fetch_sub(1, Ordering::Relaxed); + self.metrics.completed.fetch_add(1, Ordering::Relaxed); + self.metrics + .duration_us + .fetch_add(duration.as_micros() as u64, Ordering::Relaxed); + if failed || aborted || status >= 500 { + self.metrics.errors.fetch_add(1, Ordering::Relaxed); + } + match status / 100 { + 1 => &self.metrics.status_1xx, + 2 => &self.metrics.status_2xx, + 3 => &self.metrics.status_3xx, + 4 => &self.metrics.status_4xx, + _ => &self.metrics.status_5xx, + } + .fetch_add(1, Ordering::Relaxed); + let backend = self + .backend + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .map(|l| l.member.backend.id.clone()); + let websocket_phase = *self.websocket_phase.lock().unwrap_or_else(|e| e.into_inner()); + self.logs.push(LogEntry { + cache_reason: *self.cache_reason.lock().unwrap_or_else(|e| e.into_inner()), + vhost: self.vhost.clone(), + route: self.route.lock().unwrap_or_else(|e| e.into_inner()).clone(), + cache_effective_enabled: *self.cache_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()), + transform_effective_enabled: *self.transform_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()), + backend, + id: self.id, + time_ms: now_ms(), + method: self.method.clone(), + host: self.host.clone(), + path: self.path.clone(), + http_version: self.http_version, + transport: self.transport, + websocket_bootstrap: self.websocket_bootstrap, + websocket_phase, + websocket_error: *self.websocket_error.lock().unwrap_or_else(|e| e.into_inner()), + h3_reset_code: *self.h3_reset_code.lock().unwrap_or_else(|e| e.into_inner()), + upstream_status: match self.upstream_status.load(Ordering::Relaxed) { + 0 => None, + value => Some(value), + }, + upstream_error: self.upstream_error.lock().unwrap_or_else(|e| e.into_inner()).clone(), + client_ip: self.client_ip.lock().unwrap_or_else(|e| e.into_inner()).clone(), + status, + duration_ms: duration.as_millis() as u64, + cache: match self.cache_status.load(Ordering::Relaxed) { + 1 => "HIT", + 2 => "MISS", + 3 => "BYPASS", + _ => "-", + }, + request_bytes: self.request_bytes.load(Ordering::Relaxed), + response_bytes: self.response_bytes.load(Ordering::Relaxed), + outcome: if failed { + "body_error" + } else if aborted { + "cancelled" + } else if status >= 500 { + "proxy_or_upstream_error" + } else if status == 101 || websocket_phase == Some("tunnel_closed") { + "websocket_closed" + } else { + "complete" + }, + }); + } +} +#[derive(Clone, Copy)] +pub enum Direction { + Request, + Response, +} +pub struct MeteredBody { + inner: Body, + exchange: Option>, + direction: Direction, + timer: Pin>, + idle: Duration, + finished: bool, +} +impl MeteredBody { + pub fn new(inner: Body, exchange: Arc, direction: Direction, idle: Duration) -> Self { + let finished = inner.is_end_stream(); + Self { + inner, + exchange: Some(exchange), + direction, + timer: Box::pin(sleep(idle)), + idle, + finished, + } + } + fn finish(&mut self) { + self.finished = true; + self.exchange.take(); + } +} +impl HttpBody for MeteredBody { + type Data = Bytes; + type Error = axum::Error; + fn poll_frame( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + let this = self.get_mut(); + if this.finished { + this.exchange.take(); + return Poll::Ready(None); + } + match Pin::new(&mut this.inner).poll_frame(cx) { + Poll::Ready(Some(Ok(frame))) => { + if let (Some(data), Some(exchange)) = (frame.data_ref(), this.exchange.as_ref()) { + let count = data.len() as u64; + exchange.backend_bytes(count, matches!(this.direction, Direction::Request)); + match this.direction { + Direction::Request => { + exchange.request_bytes.fetch_add(count, Ordering::Relaxed); + exchange + .metrics + .bytes_in + .fetch_add(count, Ordering::Relaxed); + } + Direction::Response => { + exchange.response_bytes.fetch_add(count, Ordering::Relaxed); + exchange + .metrics + .bytes_out + .fetch_add(count, Ordering::Relaxed); + } + } + } + this.timer + .as_mut() + .reset(tokio::time::Instant::now() + this.idle); + if this.inner.is_end_stream() { + this.finish(); + } + Poll::Ready(Some(Ok(frame))) + } + Poll::Ready(Some(Err(error))) => { + if let Some(exchange) = &this.exchange { + exchange.failed.store(true, Ordering::Relaxed); + exchange.backend_error(match this.direction { + Direction::Request => "send_error", + Direction::Response => "read_error", + }); + } + this.finish(); + Poll::Ready(Some(Err(error))) + } + Poll::Ready(None) => { + this.finish(); + Poll::Ready(None) + } + Poll::Pending => { + if this.timer.as_mut().poll(cx).is_ready() { + if let Some(exchange) = &this.exchange { + exchange.failed.store(true, Ordering::Relaxed); + exchange.backend_error(match this.direction { + Direction::Request => "send_timeout", + Direction::Response => "read_timeout", + }); + } + this.finish(); + Poll::Ready(Some(Err(axum::Error::new(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "body idle timeout", + ))))) + } else { + Poll::Pending + } + } + } + } + fn is_end_stream(&self) -> bool { + self.finished || self.inner.is_end_stream() + } + fn size_hint(&self) -> SizeHint { + self.inner.size_hint() + } +} +impl Drop for MeteredBody { + fn drop(&mut self) { + if !self.finished + && !self.inner.is_end_stream() + && matches!(self.direction, Direction::Response) + { + if let Some(exchange) = &self.exchange { + exchange.aborted.store(true, Ordering::Relaxed); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http_body_util::BodyExt; + use tokio::sync::Semaphore; + async fn observation() -> (Arc, Arc) { + let slots = Arc::new(Semaphore::new(1)); + let permit = slots.clone().acquire_owned().await.unwrap(); + let metrics = Arc::new(Metrics::default()); + metrics.active.store(1, Ordering::Relaxed); + let exchange = Arc::new(Exchange { + vhost_metrics: None, + vhost: None, + route: Mutex::new(None), + cache_effective_enabled: Mutex::new(None), + transform_effective_enabled: Mutex::new(None), + backend: Mutex::new(None), + id: 1, + method: "POST".into(), + host: "localhost".into(), + path: "/echo".into(), + http_version: "1.1", + transport: "h1", + websocket_bootstrap: None, + websocket_phase: Mutex::new(None), + websocket_error: Mutex::new(None), + h3_reset_code: Mutex::new(None), + upstream_status: AtomicU16::new(0), + upstream_error: Mutex::new(None), + client_ip: Mutex::new(Some("127.0.0.1".into())), + started: Instant::now(), + status: AtomicU16::new(200), + cache_status: AtomicU16::new(0), + cache_reason: Mutex::new("not_proxy_response"), + failed: AtomicBool::new(false), + aborted: AtomicBool::new(false), + request_bytes: AtomicU64::new(0), + response_bytes: AtomicU64::new(0), + metrics, + logs: Arc::new(Logs::default()), + _permit: permit, + }); + (exchange, slots) + } + #[tokio::test] + async fn permit_and_metrics_cover_both_bodies() { + let (exchange, slots) = observation().await; + let metrics = exchange.metrics.clone(); + let logs = exchange.logs.clone(); + let input = MeteredBody::new( + Body::from("in"), + exchange.clone(), + Direction::Request, + Duration::from_secs(1), + ); + let output = MeteredBody::new( + Body::from("out"), + exchange.clone(), + Direction::Response, + Duration::from_secs(1), + ); + drop(exchange); + assert_eq!( + input.collect().await.unwrap().to_bytes(), + Bytes::from_static(b"in") + ); + assert_eq!(slots.available_permits(), 0); + assert_eq!( + output.collect().await.unwrap().to_bytes(), + Bytes::from_static(b"out") + ); + assert_eq!(slots.available_permits(), 1); + assert_eq!(metrics.active.load(Ordering::Relaxed), 0); + assert_eq!(metrics.bytes_in.load(Ordering::Relaxed), 2); + assert_eq!(metrics.bytes_out.load(Ordering::Relaxed), 3); + assert_eq!(metrics.completed.load(Ordering::Relaxed), 1); + assert_eq!(logs.recent().len(), 1); + } + #[tokio::test] + async fn idle_body_fails_and_releases_capacity() { + let (exchange, slots) = observation().await; + let metrics = exchange.metrics.clone(); + let pending = futures_util::stream::pending::>(); + let output = MeteredBody::new( + Body::from_stream(pending), + exchange, + Direction::Response, + Duration::from_millis(10), + ); + assert!(output.collect().await.is_err()); + assert_eq!(slots.available_permits(), 1); + assert_eq!(metrics.errors.load(Ordering::Relaxed), 1); + } +} + +/// Retains the first inspected frame without collecting the rest of a request. +/// A trailer/empty DATA frame still prevents storing a request-body variant. +pub struct ReplayBody { + head: Option>, + tail: axum::body::Body, +} +impl ReplayBody { + pub fn new(head: http_body::Frame, tail: axum::body::Body) -> Self { + Self { + head: Some(head), + tail, + } + } +} +impl http_body::Body for ReplayBody { + type Data = bytes::Bytes; + type Error = axum::Error; + fn poll_frame( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll, Self::Error>>> { + if let Some(head) = self.head.take() { + return std::task::Poll::Ready(Some(Ok(head))); + } + std::pin::Pin::new(&mut self.tail).poll_frame(cx) + } + fn is_end_stream(&self) -> bool { + self.head.is_none() && self.tail.is_end_stream() + } + fn size_hint(&self) -> http_body::SizeHint { + let mut hint = http_body::SizeHint::new(); + if self.head.is_none() { + return self.tail.size_hint(); + } + if let Some(data) = self.head.as_ref().and_then(|f| f.data_ref()) { + hint.set_lower(data.len() as u64); + } + hint + } +} diff --git a/src/proxy/headers.rs b/src/proxy/headers.rs new file mode 100644 index 0000000..25f2659 --- /dev/null +++ b/src/proxy/headers.rs @@ -0,0 +1,138 @@ +use http::{ + header::{HeaderName, CONNECTION}, + HeaderMap, +}; +/// Remove both standard hop-by-hop fields and fields named by Connection. +pub fn strip_hop_by_hop(headers: &mut HeaderMap) { + let nominated: Vec = headers + .get_all(CONNECTION) + .iter() + .filter_map(|v| v.to_str().ok()) + .flat_map(|v| v.split(',')) + .filter_map(|v| v.trim().parse().ok()) + .collect(); + for name in nominated { + headers.remove(name); + } + for name in [ + "connection", + "keep-alive", + "proxy-connection", + "transfer-encoding", + "upgrade", + "trailer", + "te", + "proxy-authenticate", + "proxy-authorization", + ] { + headers.remove(name); + } +} +pub fn requests_upgrade(headers: &HeaderMap) -> bool { + headers.contains_key("upgrade") + || headers + .get_all(CONNECTION) + .iter() + .filter_map(|v| v.to_str().ok()) + .flat_map(|v| v.split(',')) + .any(|v| v.trim().eq_ignore_ascii_case("upgrade")) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn removes_nominated_fields_and_preserves_cookies() { + let mut h = HeaderMap::new(); + h.insert("connection", "keep-alive, X-Internal".parse().unwrap()); + h.insert("x-internal", "secret".parse().unwrap()); + h.insert("keep-alive", "timeout=5".parse().unwrap()); + h.append("set-cookie", "a=1".parse().unwrap()); + h.append("set-cookie", "b=2".parse().unwrap()); + strip_hop_by_hop(&mut h); + assert!(!h.contains_key("x-internal")); + assert!(!h.contains_key("connection")); + assert_eq!(h.get_all("set-cookie").iter().count(), 2); + } + #[test] + fn upgrade_is_case_insensitive() { + let mut h = HeaderMap::new(); + h.insert("connection", "keep-alive, Upgrade".parse().unwrap()); + assert!(requests_upgrade(&h)); + } + #[test] + fn admin_cookie_can_be_restored_only_from_captured_fragment() { + let mut h = HeaderMap::new(); + h.insert("cookie", "a=1; rustproxy_session=secret; b=2".parse().unwrap()); + let admin = strip_admin_cookie(&mut h); + assert_eq!(h.get("cookie").unwrap(), "a=1; b=2"); + assert_eq!(admin, vec!["rustproxy_session=secret"]); + restore_admin_cookie(&mut h, &admin); + assert_eq!(h.get("cookie").unwrap(), "a=1; b=2; rustproxy_session=secret"); + } +} + +/// Cookies are not port-scoped. Remove the local control-plane cookie before +/// authentication/cache processing and return its exact cookie fragments. The +/// guarded self-admin reverse-proxy path may restore only those fragments after +/// the actual backend has been selected and verified. +pub fn strip_admin_cookie(headers: &mut HeaderMap) -> Vec { + let mut retained = Vec::new(); + let mut admin = Vec::new(); + for part in headers + .get_all(http::header::COOKIE) + .iter() + .filter_map(|h| h.to_str().ok()) + .flat_map(|h| h.split(';')) + { + let part = part.trim(); + let Some((name, _)) = part.split_once('=') else { continue; }; + if name == crate::auth::COOKIE_NAME { + admin.push(part.to_owned()); + } else { + retained.push(part.to_owned()); + } + } + headers.remove(http::header::COOKIE); + if !retained.is_empty() { + if let Ok(value) = retained.join("; ").parse() { + headers.insert(http::header::COOKIE, value); + } + } + admin +} + +/// Restore only the reserved administration cookie after the backend has been +/// proven to be the configured local administration listener. +pub fn restore_admin_cookie(headers: &mut HeaderMap, admin: &[String]) { + if admin.is_empty() { + return; + } + let mut values: Vec = headers + .get_all(http::header::COOKIE) + .iter() + .filter_map(|h| h.to_str().ok().map(str::to_owned)) + .collect(); + values.extend(admin.iter().cloned()); + headers.remove(http::header::COOKIE); + if let Ok(value) = values.join("; ").parse() { + headers.insert(http::header::COOKIE, value); + } +} +/// Upstreams cannot overwrite the reserved admin cookie through this proxy listener. +pub fn strip_admin_set_cookie(headers: &mut HeaderMap) { + let retained: Vec<_> = headers + .get_all(http::header::SET_COOKIE) + .iter() + .filter(|h| { + h.to_str() + .ok() + .and_then(|v| v.split_once('=')) + .is_some_and(|(name, _)| name.trim() != crate::auth::COOKIE_NAME) + }) + .cloned() + .collect(); + headers.remove(http::header::SET_COOKIE); + for value in retained { + headers.append(http::header::SET_COOKIE, value); + } +} diff --git a/src/proxy/mod.rs b/src/proxy/mod.rs new file mode 100644 index 0000000..4fda544 --- /dev/null +++ b/src/proxy/mod.rs @@ -0,0 +1,3328 @@ +pub mod body; +pub mod headers; +pub mod websocket; +use crate::{ + config::request_host, + runtime::App, + server::{ConnectionLease, LocalAddr, PeerAddr, ServerShutdown, Transport}, +}; +use axum::{ + body::Body, + extract::{Request, State}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + Extension, Router, +}; +use body::{Direction, Exchange, MeteredBody}; +use http::{ + header::{HeaderValue, HOST}, + HeaderMap, Method, StatusCode, Uri, Version, +}; +use http_body::Body as HttpBody; +use http_body_util::BodyExt; +#[derive(Clone)] +struct ResponseRulesApplied; +#[derive(Clone)] +struct LocalGeneratedResponse; +#[derive(Clone, Copy)] +struct RedirectResponse { http3_enabled: bool } +#[derive(Clone, Copy)] +struct LocalHostResponse { http3_enabled: bool } +#[derive(Clone, Copy)] +struct OriginTiming(Duration); +#[derive(Clone)] +struct OriginBackend(String); +use std::{ + sync::{ + atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering}, + Arc, Mutex, + }, + time::{Duration, Instant}, +}; + +pub fn router(state: Arc) -> Router { + Router::new() + .fallback(forward) + .layer(middleware::from_fn_with_state(state.clone(), observe)) + .layer(middleware::from_fn_with_state( + state.clone(), + crate::acme::challenge, + )) + .with_state(state) +} +const MAX_UPSTREAM_ERROR_DETAIL: usize = 2048; + +fn truncate_utf8(value: &mut String, max_bytes: usize) { + if value.len() <= max_bytes { + return; + } + let mut end = max_bytes; + while end > 0 && !value.is_char_boundary(end) { + end -= 1; + } + value.truncate(end); +} + +fn format_error_chain(error: &(dyn std::error::Error + 'static)) -> String { + let mut parts = Vec::new(); + let mut current = Some(error); + for _ in 0..12 { + let Some(item) = current else { break }; + let message = item.to_string(); + if !message.is_empty() && parts.last() != Some(&message) { + parts.push(message); + } + current = item.source(); + } + if current.is_some() { + parts.push("".into()); + } + parts.join(" -> ") +} + +fn classify_stale_error(error: &(dyn std::error::Error + 'static)) -> crate::cache::StaleErrorClass { + let mut current = Some(error); + let mut text = String::new(); + for _ in 0..12 { + let Some(item) = current else { break }; + if let Some(io) = item.downcast_ref::() { + if io.kind() == std::io::ErrorKind::TimedOut { + return crate::cache::StaleErrorClass::Timeout; + } + } + if !text.is_empty() { + text.push(' '); + } + text.push_str(&item.to_string().to_ascii_lowercase()); + current = item.source(); + } + if text.contains("tls") + || text.contains("certificate") + || text.contains("cert verify") + || text.contains("handshake") + { + crate::cache::StaleErrorClass::Tls + } else if text.contains("dns") + || text.contains("name resolution") + || text.contains("resolve host") + || text.contains("lookup address") + { + crate::cache::StaleErrorClass::Dns + } else if text.contains("timed out") || text.contains("timeout") { + crate::cache::StaleErrorClass::Timeout + } else { + crate::cache::StaleErrorClass::Connection + } +} + +fn upstream_error_class(error: &(dyn std::error::Error + 'static), connect_failure: bool) -> &'static str { + match classify_stale_error(error) { + crate::cache::StaleErrorClass::Dns => "dns", + crate::cache::StaleErrorClass::Tls => "tls", + crate::cache::StaleErrorClass::Timeout => "response_header_timeout", + crate::cache::StaleErrorClass::Connection if connect_failure => "connect", + crate::cache::StaleErrorClass::Connection => "io_error", + } +} + +fn retry_safe_method(method: &Method) -> bool { + method == Method::GET || method == Method::HEAD || method == Method::OPTIONS +} + +fn upstream_uri(backend: &crate::config::CompiledBackend, suffix: &str) -> Option { + format!("{}{suffix}", backend.request_origin).parse().ok() +} + +fn legacy_websocket_origin( + request_origin: &str, + upstream_host: &HeaderValue, +) -> Option { + let scheme = request_origin.split_once("://")?.0; + let authority = upstream_host.to_str().ok()?.trim(); + if authority.is_empty() { + return None; + } + HeaderValue::from_str(&format!("{scheme}://{authority}")).ok() +} + +fn upstream_request_version( + backend: &crate::config::CompiledBackend, + websocket_h2: bool, +) -> Version { + if websocket_h2 || backend.protocol == crate::config::UpstreamProtocol::Http2 { + Version::HTTP_2 + } else { + Version::HTTP_11 + } +} + +fn retry_request( + method: &Method, + version: Version, + headers: &HeaderMap, + backend: &crate::config::CompiledBackend, + original_host: &HeaderValue, + origin_suffix: &str, + host_follows_backend: bool, +) -> Option { + let uri = upstream_uri(backend, origin_suffix)?; + let mut request = Request::builder() + .method(method.clone()) + .version(version) + .uri(uri) + .body(Body::empty()) + .ok()?; + *request.headers_mut() = headers.clone(); + if host_follows_backend { + request.headers_mut().insert( + HOST, + if backend.preserve_host { + original_host.clone() + } else { + backend.host_header.clone() + }, + ); + } + Some(request) +} + +fn upstream_failure_detail( + cause: &E, + backend: &crate::config::CompiledBackend, + elapsed: Duration, + connect_failure: bool, +) -> String +where + E: std::error::Error + std::fmt::Debug + 'static, +{ + let phase = if connect_failure { "connect" } else { "http" }; + let chain = format_error_chain(cause); + let debug = format!("{cause:?}"); + let mut detail = format!( + "phase={phase}; origin={}; request_origin={}; tls_verify={}; elapsed_ms={}; error_chain={chain}", + backend.origin, + backend.request_origin, + backend.tls_verify, + elapsed.as_millis(), + ); + if !debug.is_empty() && debug != cause.to_string() { + detail.push_str("; debug="); + detail.push_str(&debug); + } + detail = detail + .chars() + .map(|ch| if ch.is_control() { ' ' } else { ch }) + .collect(); + truncate_utf8(&mut detail, MAX_UPSTREAM_ERROR_DETAIL); + detail +} + + +#[derive(Clone)] +struct CacheRefreshContext { + state: Arc, + snapshot: Arc, + backend: Arc, + cache_settings: crate::cache::Settings, + transform: Option, + uri: Uri, + public_suffix: String, + host: String, + path: String, + scope: String, + timeout: Duration, + local: std::net::SocketAddr, + tls: bool, + base_headers: http::HeaderMap, +} + +async fn refresh_cache_once( + context: &CacheRefreshContext, + ticket: crate::cache::Ticket, + cached: crate::cache::Revalidation, +) -> bool { + if !context.state.cache.ticket_current(&ticket) { + return false; + } + let mut refresh_headers = context.base_headers.clone(); + for name in [ + "if-none-match", + "if-modified-since", + "if-match", + "if-unmodified-since", + "if-range", + "range", + ] { + refresh_headers.remove(name); + } + if !cached.apply_validators(&mut refresh_headers) { + return false; + } + if context + .transform + .as_ref() + .is_some_and(|settings| settings.enabled) + { + refresh_headers.remove("accept-encoding"); + } + let mut refresh = Request::new(Body::empty()); + *refresh.method_mut() = Method::GET; + *refresh.uri_mut() = context.uri.clone(); + *refresh.version_mut() = Version::HTTP_11; + *refresh.headers_mut() = refresh_headers; + let started = Instant::now(); + let Ok(Ok(origin)) = tokio::time::timeout( + context.timeout, + context.backend.client.request(refresh), + ) + .await + else { + return false; + }; + let origin_validation_headers = origin.headers().clone(); + let Some(route) = context.snapshot.select_on( + &context.host, + &context.path, + context.local, + context.tls, + ) else { + return false; + }; + if origin.status() == StatusCode::NOT_MODIFIED { + let mut validation_headers = origin_validation_headers.clone(); + headers::strip_hop_by_hop(&mut validation_headers); + apply_response_rules( + &mut validation_headers, + &context.snapshot, + route.frontend.as_deref(), + Some(route), + false, + ); + if let Some(pool) = &route.pool { + pool.strip_affinity_response(&mut validation_headers); + } + let merged = cached.merge_validation_headers(&validation_headers); + return context + .state + .cache + .publish_revalidated( + ticket, + &cached, + merged, + &origin_validation_headers, + &context.cache_settings, + started.elapsed(), + ) + .is_some(); + } + if origin.status() != StatusCode::OK { + return false; + } + let Some(origin_plan) = crate::cache::plan( + origin.status(), + origin.headers(), + &context.cache_settings, + started.elapsed(), + ) else { + return false; + }; + let origin_no_transform = crate::transform::no_transform(origin.headers()); + let skip_compression = crate::compression::private_or_no_transform(origin.headers()); + let origin_validators = crate::transform::OriginValidators { + etag: origin.headers().get("etag").cloned(), + last_modified: origin.headers().get("last-modified").cloned(), + }; + let (mut parts, body) = origin.into_parts(); + parts.extensions.insert(origin_validators); + if skip_compression { + parts.extensions.insert(crate::compression::Skip); + } + headers::strip_hop_by_hop(&mut parts.headers); + apply_response_rules( + &mut parts.headers, + &context.snapshot, + route.frontend.as_deref(), + Some(route), + false, + ); + if let Some(pool) = &route.pool { + pool.strip_affinity_response(&mut parts.headers); + } + let mut refreshed = Response::from_parts(parts, Body::new(body)); + refreshed.extensions_mut().insert(ResponseRulesApplied); + if let Some(settings) = context + .transform + .as_ref() + .filter(|settings| settings.enabled) + { + let transform_started = Instant::now(); + let Ok(transformed) = crate::transform::apply( + refreshed, + &context.public_suffix, + settings, + origin_no_transform, + ) + .await + else { + context + .state + .transform_metrics + .record_error(&context.scope, transform_started.elapsed()); + context + .state + .cache + .record_reason(&context.scope, &context.host, "transform_error"); + return false; + }; + context.state.transform_metrics.record_response( + &context.scope, + &transformed, + transform_started.elapsed(), + ); + refreshed = transformed; + } + let canonical_identity = refreshed + .extensions() + .get::() + .is_some(); + let Some(transformed_plan) = crate::cache::plan( + refreshed.status(), + refreshed.headers(), + &context.cache_settings, + started.elapsed(), + ) else { + return false; + }; + let origin_plan = if canonical_identity { + origin_plan.without_vary("accept-encoding") + } else { + origin_plan + }; + let verify_ticket = ticket.clone(); + let refreshed = context.state.cache.wrap_with_fill( + refreshed, + ticket, + origin_plan.restrict(transformed_plan), + &context.cache_settings, + None, + ); + if http_body_util::BodyExt::collect(refreshed.into_body()) + .await + .is_err() + { + return false; + } + context + .state + .cache + .revalidation_tiered(&verify_ticket) + .await + .is_some() +} + +fn schedule_proactive_refresh( + context: CacheRefreshContext, + ticket: crate::cache::Ticket, + remaining_ttl: u64, +) { + let Some(schedule_guard) = context + .state + .cache + .begin_proactive_schedule(&ticket, &context.cache_settings) + else { + return; + }; + let threshold = context + .cache_settings + .proactive_refresh_before_expiry_seconds; + tokio::spawn(async move { + let _schedule_guard = schedule_guard; + let mut delay = remaining_ttl.saturating_sub(threshold).max(1); + let mut awaiting_initial_publish = true; + let mut initial_publish_retries = 0u8; + loop { + if delay > 0 { + tokio::time::sleep(Duration::from_secs(delay)).await; + } + if !context.state.cache.ticket_current(&ticket) { + return; + } + let Some(cached) = context.state.cache.revalidation_tiered(&ticket).await else { + if awaiting_initial_publish && initial_publish_retries < 60 { + initial_publish_retries = initial_publish_retries.saturating_add(1); + delay = 1; + continue; + } + return; + }; + awaiting_initial_publish = false; + let Some(inflight_guard) = context + .state + .cache + .try_begin_proactive_refresh(&context.cache_settings) + else { + delay = 5; + continue; + }; + let Some(revalidation_guard) = context + .state + .cache + .begin_background_revalidation(&cached) + else { + drop(inflight_guard); + delay = 1; + continue; + }; + let refreshed = refresh_cache_once(&context, ticket.clone(), cached).await; + drop(revalidation_guard); + drop(inflight_guard); + if !refreshed { + return; + } + context.state.cache.record( + &context.scope, + &context.host, + "HIT", + "cache_proactive_refreshed", + ); + let Some(next) = context.state.cache.revalidation_tiered(&ticket).await else { + return; + }; + let remaining = next.remaining_ttl(); + if remaining == 0 { + return; + } + delay = remaining.saturating_sub(threshold).max(1); + } + }); +} + +fn enforce_local_page_no_cache(response: &mut Response) { + for name in [ + "age", + "expires", + "pragma", + "etag", + "last-modified", + "cdn-cache-control", + "surrogate-control", + "x-cache", + "x-cache-hits", + "x-cache-status", + "x-cache-resident-age", + "x-cache-reason", + "cache-status", + ] { + response.headers_mut().remove(name); + } + response + .headers_mut() + .insert("cache-control", HeaderValue::from_static("no-store")); +} + +fn error(status: StatusCode, message: &'static str) -> Response { + let mut response = if let Some(html) = crate::pages::builtin_gateway_error(status) { + let mut response = (status, html).into_response(); + response.headers_mut().insert( + "content-type", + HeaderValue::from_static("text/html; charset=utf-8"), + ); + response.headers_mut().insert( + "x-content-type-options", + HeaderValue::from_static("nosniff"), + ); + response + } else { + (status, message).into_response() + }; + response + .headers_mut() + .insert("cache-control", HeaderValue::from_static("no-store")); + response.extensions_mut().insert(LocalGeneratedResponse); + response +} + +fn local_location_response(spec: &crate::vhost::LocationResponse, method: &Method) -> Response { + let status = StatusCode::from_u16(spec.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let body_allowed = !matches!(status.as_u16(), 100..=199 | 204 | 304) && *method != Method::HEAD; + let mut response = Response::builder() + .status(status) + .body(if body_allowed { Body::from(spec.body.clone()) } else { Body::empty() }) + .unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, Body::empty()).into_response()); + if let Ok(value) = HeaderValue::from_str(&spec.content_type) { + response.headers_mut().insert("content-type", value); + } + response +} + +fn encoded_origin_path(path: &str) -> Option { + let mut url = url::Url::parse("http://rustproxy.invalid/").ok()?; + url.set_path(path); + Some(url.path().to_owned()) +} +fn response_context( + snapshot: &crate::config::Snapshot, + request: &Request, +) -> ( + Option>, + Option, +) { + let tls = request + .extensions() + .get::() + .copied() + .unwrap_or_default() + .tls; + let context = (|| { + let local = request.extensions().get::()?.0; + let authority = effective_host(request, tls).ok()?; + let host = request_host(authority.to_str().ok()?).ok()?; + let frontend = snapshot.frontend(&host, local, tls).cloned(); + let route = crate::acl::routing_path(request.uri().path()) + .ok() + .and_then(|path| snapshot.select_on(&host, &path, local, tls).cloned()); + Some((frontend, route)) + })(); + context.unwrap_or((None, None)) +} + +/// Reserved administration-session cookies may cross the proxy boundary only +/// for the explicit self-admin deployment: HTTPS, an explicitly allowed public +/// administration hostname, a backend that directly targets the configured +/// administration listener, and the existing local-listener risk opt-in. +fn admin_session_passthrough( + snapshot: &crate::config::Snapshot, + host: &str, + tls: bool, + backend: &crate::config::CompiledBackend, +) -> bool { + tls + && backend.accept_local_listener_risk + && backend.targets_admin_listener + && snapshot + .envelope + .config + .administration + .allowed_hosts + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(host)) +} + +fn apply_response_rules( + headers: &mut http::HeaderMap, + snapshot: &crate::config::Snapshot, + frontend: Option<&crate::config::CompiledFrontend>, + route: Option<&crate::config::CompiledRoute>, + preserve_admin_session: bool, +) { + crate::headers::apply(headers, &snapshot.headers.response); + if let Some(frontend) = frontend { + crate::headers::apply(headers, &frontend.headers.response); + } + if let Some(route) = route { + crate::headers::apply(headers, &route.headers.response); + } + if !preserve_admin_session { + headers::strip_admin_set_cookie(headers); + } +} +/// Final policies apply to cache hits, errors, static pages and ALL transports. +fn finish_headers( + response: &mut Response, + snapshot: &crate::config::Snapshot, + frontend: Option<&crate::config::CompiledFrontend>, + local: Option, + tls: bool, + id: u64, + active: &[crate::vhost::Listener], +) { + if let Some(frontend) = frontend { + frontend.security.apply(response.headers_mut(), tls); + } + frontend + .map(|f| &f.server_header) + .unwrap_or(&snapshot.envelope.config.server_header) + .apply(response.headers_mut()); + frontend + .map(|f| &f.diagnostic_headers) + .unwrap_or(&snapshot.envelope.config.diagnostic_headers) + .apply(response.headers_mut(), id); + // Authenticated content must not leak through an external shared cache either. + if frontend.is_some_and(|f| f.tinyauth.is_some() || f.basic_auth.is_some()) { + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("private, no-store"), + ); + } + if frontend.is_some_and(|frontend| !frontend.http3_enabled) { + response.headers_mut().remove("alt-svc"); + } else { + crate::http3::advertise(response.headers_mut(), local, tls, active); + } +} +fn apply_timing_headers( + response: &mut Response, + frontend: Option<&crate::config::CompiledFrontend>, + exchange: &Exchange, +) { + if !frontend.is_some_and(|frontend| frontend.timing_headers) { + return; + } + let total_ms = exchange.started.elapsed().as_secs_f64() * 1000.0; + let mut values = vec![format!("proxy;dur={total_ms:.2}")]; + if let Some(origin) = response.extensions().get::() { + values.push(format!("origin;dur={:.2}", origin.0.as_secs_f64() * 1000.0)); + } + let cache = match exchange.cache_status.load(Ordering::Relaxed) { + 1 => Some("HIT"), + 2 => Some("MISS"), + 3 => Some("BYPASS"), + _ => None, + }; + if let Some(cache) = cache { + values.push(format!("cache;desc=\"{cache}\"")); + } + response.headers_mut().remove("server-timing"); + if let Ok(value) = HeaderValue::from_str(&values.join(", ")) { + response.headers_mut().insert("server-timing", value); + } +} + +/// Opt-in backend diagnostics are applied after response rules and cache handling so +/// an origin cannot spoof them and a cache hit never reports a stale backend. +fn apply_backend_diagnostic_values( + response: &mut Response, + backend_header: bool, + backend_timing_header: bool, +) { + response.headers_mut().remove("x-rust-proxy-backend"); + response.headers_mut().remove("x-rust-proxy-backend-time"); + response.headers_mut().remove("x-rustproxy-backend-time"); + if backend_header { + if let Some(origin) = response.extensions().get::() { + if let Ok(value) = HeaderValue::from_str(&origin.0) { + response.headers_mut().insert("x-rust-proxy-backend", value); + } + } + } + if backend_timing_header { + if let Some(origin) = response.extensions().get::() { + let value = format!("{:.2}ms", origin.0.as_secs_f64() * 1000.0); + if let Ok(value) = HeaderValue::from_str(&value) { + response + .headers_mut() + .insert("x-rustproxy-backend-time", value); + } + } + } +} +fn apply_backend_diagnostic_headers( + response: &mut Response, + frontend: Option<&crate::config::CompiledFrontend>, +) { + apply_backend_diagnostic_values( + response, + frontend.is_some_and(|value| value.backend_header), + frontend.is_some_and(|value| value.backend_timing_header), + ); +} +/// Peek at one bounded frame when a multiplexed request has no exact size hint. +/// An absent Content-Length on HTTP/2 or HTTP/3 is NOT proof of an empty body. +async fn empty_for_cache(request: &mut Request) -> Result { + if request.body().is_end_stream() { + return Ok(true); + } + if request.body().size_hint().lower() > 0 { + return Ok(false); + } + // Multiplexed transports may end a body with an empty DATA frame. + for _ in 0..8 { + match request.body_mut().frame().await { + None => { + *request.body_mut() = Body::empty(); + return Ok(true); + } + Some(Err(error)) => return Err(error), + Some(Ok(frame)) => { + if frame.data_ref().is_some_and(|data| data.is_empty()) { + if request.body().is_end_stream() { + *request.body_mut() = Body::empty(); + return Ok(true); + } + continue; + } + let tail = std::mem::replace(request.body_mut(), Body::empty()); + *request.body_mut() = Body::new(body::ReplayBody::new(frame, tail)); + return Ok(false); + } + } + } + Ok(false) +} + +/// Resolve the framing ambiguity of an HTTP/3 request with no declared length +/// before handing it to an HTTP/1.1 upstream. Hyper encodes an unknown-length +/// HTTP/1.1 request as chunked for methods other than GET/HEAD/CONNECT. That is +/// correct HTTP/1.1, but an actually empty H3 stream can be represented more +/// precisely as a zero-length body once its end is observed. +/// +/// Only the first semantically significant frame is inspected; non-empty bodies +/// remain streaming through ReplayBody and are never fully buffered. +async fn normalize_h3_h1_unknown_body( + request: &mut Request, + upstream_version: Version, +) -> Result<(), axum::Error> { + let method = request.method(); + if request.version() != Version::HTTP_3 + || upstream_version != Version::HTTP_11 + || method == Method::GET + || method == Method::HEAD + || method == Method::CONNECT + || request.headers().contains_key(http::header::CONTENT_LENGTH) + || request.body().size_hint().exact().is_some() + { + return Ok(()); + } + + // Ignore a small number of empty DATA frames while looking for either the + // stream end or the first frame that carries request semantics. The body + // idle timer in MeteredBody still bounds a stalled peer. + for _ in 0..8 { + match request.body_mut().frame().await { + None => { + *request.body_mut() = Body::empty(); + return Ok(()); + } + Some(Err(error)) => return Err(error), + Some(Ok(frame)) => { + if frame.data_ref().is_some_and(|data| data.is_empty()) { + if request.body().is_end_stream() { + *request.body_mut() = Body::empty(); + return Ok(()); + } + continue; + } + let tail = std::mem::replace(request.body_mut(), Body::empty()); + *request.body_mut() = Body::new(body::ReplayBody::new(frame, tail)); + return Ok(()); + } + } + } + Ok(()) +} + +async fn observe(State(state): State>, mut request: Request, next: Next) -> Response { + let snapshot = state.config.load_full(); + let (frontend_context, route_context) = response_context(&snapshot, &request); + let local = request.extensions().get::().map(|v| v.0); + let transport_tls = request + .extensions() + .get::() + .copied() + .unwrap_or_default() + .tls; + request.extensions_mut().insert(snapshot.clone()); + let original_headers = request.headers().clone(); + let method = request.method().clone(); + let is_head = method == Method::HEAD; + let ws_attempt = request + .headers() + .get("upgrade") + .is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"websocket")) + || (request.version() == Version::HTTP_2 + && request.method() == Method::CONNECT + && websocket::h2_protocol(&request) == Some("websocket")) + || (request.version() == Version::HTTP_3 + && request.method() == Method::CONNECT + && websocket::h3_protocol(&request) == Some("websocket")); + if request.version() == Version::HTTP_11 { + state.metrics.http1_requests.fetch_add(1, Ordering::Relaxed); + } + if request.version() == Version::HTTP_2 { + state.metrics.http2_requests.fetch_add(1, Ordering::Relaxed); + } + if request.version() == Version::HTTP_3 { + state.metrics.http3_requests.fetch_add(1, Ordering::Relaxed); + } + let id = state.metrics.requests.fetch_add(1, Ordering::Relaxed) + 1; + if let Some(frontend) = &frontend_context { + frontend.metrics.requests.fetch_add(1, Ordering::Relaxed); + } + let Ok(permit) = state.inflight.clone().try_acquire_owned() else { + state + .metrics + .rejected_requests + .fetch_add(1, Ordering::Relaxed); + if let Some(frontend) = &frontend_context { + frontend.metrics.rejected.fetch_add(1, Ordering::Relaxed); + } + let mut response = error( + StatusCode::SERVICE_UNAVAILABLE, + "RustProxy: in-flight limit reached\n", + ); + if let Some(page) = frontend_context + .as_ref() + .and_then(|f| f.pages.errors.get(&503)) + .or_else(|| snapshot.envelope.config.pages.errors.get(&503)) + { + crate::pages::replace_error(&mut response, page); + } + if is_head { + *response.body_mut() = Body::empty(); + } + apply_response_rules( + response.headers_mut(), + &snapshot, + frontend_context.as_deref(), + route_context.as_ref(), + false, + ); + finish_headers( + &mut response, + &snapshot, + frontend_context.as_deref(), + local, + transport_tls, + id, + &state.active_listeners, + ); + return response; + }; + state.metrics.active.fetch_add(1, Ordering::Relaxed); + if let Some(frontend) = &frontend_context { + frontend.metrics.active.fetch_add(1, Ordering::Relaxed); + } + let host = request + .headers() + .get(HOST) + .and_then(|h| h.to_str().ok()) + .unwrap_or_else(|| request.uri().authority().map(|v| v.as_str()).unwrap_or("")) + .chars() + .take(253) + .collect(); + let initial_client_ip = request + .extensions() + .get::() + .map(|peer| peer.0.ip().to_string()); + let (http_version, transport) = match request.version() { + Version::HTTP_10 => ("1.0", "h1"), + Version::HTTP_11 => ("1.1", "h1"), + Version::HTTP_2 => ("2", "h2"), + Version::HTTP_3 => ("3", "h3"), + _ => ("unknown", "unknown"), + }; + let websocket_bootstrap = ws_attempt.then_some(match request.version() { + Version::HTTP_2 => "h2_extended_connect", + Version::HTTP_3 => "h3_extended_connect", + _ => "h1_upgrade", + }); + let exchange = Arc::new(Exchange { + vhost_metrics: frontend_context.as_ref().map(|f| f.metrics.clone()), + vhost: frontend_context.as_ref().map(|f| f.id.clone()), + route: Mutex::new(None), + cache_effective_enabled: Mutex::new(None), + transform_effective_enabled: Mutex::new(None), + backend: Mutex::new(None), + id, + method: request.method().to_string(), + host, + path: request.uri().path().chars().take(256).collect(), + http_version, + transport, + websocket_bootstrap, + websocket_phase: Mutex::new(websocket_bootstrap.map(|_| "request_received")), + websocket_error: Mutex::new(None), + h3_reset_code: Mutex::new(None), + upstream_status: AtomicU16::new(0), + upstream_error: Mutex::new(None), + // Always log at least the transport peer. `forward` replaces this with + // the verified effective client address after trusted-proxy/Cloudflare + // processing, so policy and live-log identity remain aligned. + client_ip: Mutex::new(initial_client_ip), + started: Instant::now(), + status: AtomicU16::new(500), + cache_status: AtomicU16::new(0), + cache_reason: Mutex::new("not_proxy_response"), + failed: AtomicBool::new(false), + aborted: AtomicBool::new(false), + request_bytes: AtomicU64::new(0), + response_bytes: AtomicU64::new(0), + metrics: state.metrics.clone(), + logs: state.logs.clone(), + _permit: permit, + }); + if let Ok(value) = HeaderValue::from_str(&id.to_string()) { + request.headers_mut().insert("x-request-id", value); + } + request.extensions_mut().insert(exchange.clone()); + let request_send_timeout = frontend_context + .as_ref() + .and_then(|frontend| frontend.proxy_timeouts.send_timeout()) + .unwrap_or(state.body_idle_timeout); + let request = request.map(|body| { + Body::new(MeteredBody::new( + body, + exchange.clone(), + Direction::Request, + request_send_timeout, + )) + }); + let mut response = next.run(request).await; + let minimal_welcome = response.extensions().get::().is_some(); + let code = response.status().as_u16(); + let custom_error = frontend_context + .as_ref() + .and_then(|f| f.pages.errors.get(&code)) + .or_else(|| snapshot.envelope.config.pages.errors.get(&code)); + if !minimal_welcome + && response + .extensions() + .get::() + .is_none() + { + if let Some(page) = custom_error { + crate::pages::replace_error(&mut response, page); + } + } + if minimal_welcome { + // Keep the built-in "RustProxy is running" response intentionally + // minimal. It is never cacheable and carries no optional RustProxy + // diagnostics/server/security/compression/cache-diagnostic headers. + for name in [ + "server", "server-timing", "alt-svc", "x-rustproxy-request-id", + "x-rustproxy-revision", "x-rust-proxy-backend", + "x-rust-proxy-backend-time", "x-rustproxy-backend-time", "x-cache", "x-cache-hits", + "x-cache-status", "x-cache-resident-age", "content-encoding", "vary", + "strict-transport-security", "content-security-policy", + "permissions-policy", "referrer-policy", "x-frame-options", + "x-content-type-options", + ] { + response.headers_mut().remove(name); + } + response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store")); + } else { + if response + .extensions() + .get::() + .is_none() + && response + .extensions() + .get::() + .is_none() + { + apply_response_rules( + response.headers_mut(), + &snapshot, + frontend_context.as_deref(), + route_context.as_ref(), + false, + ); + } + apply_timing_headers(&mut response, frontend_context.as_deref(), &exchange); + apply_backend_diagnostic_headers(&mut response, frontend_context.as_deref()); + finish_headers( + &mut response, + &snapshot, + frontend_context.as_deref(), + local, + transport_tls, + id, + &state.active_listeners, + ); + if response.extensions().get::().is_some_and(|redirect| !redirect.http3_enabled) { + response.headers_mut().remove("alt-svc"); + } + if response.extensions().get::().is_some_and(|item| !item.http3_enabled) { + response.headers_mut().remove("alt-svc"); + } + response = crate::compression::encode( + response, + &original_headers, + &method, + &snapshot.envelope.config.compression, + ) + .await; + if let Some(encoding) = response + .extensions() + .get::() + .copied() + { + state.metrics.record_compression(encoding); + } + if response + .extensions() + .get::() + .is_none() + && response.extensions().get::().is_none() + && response.extensions().get::().is_none() + && response.extensions().get::().is_none() + && response.extensions().get::().is_none() + && response + .extensions() + .get::() + .is_none() + { + let outcome = match exchange.cache_status.load(Ordering::Relaxed) { + 1 => "HIT", + 2 => "MISS", + _ => "BYPASS", + }; + let cache = frontend_context + .as_ref() + .map(|f| &f.cache) + .unwrap_or(&snapshot.envelope.config.cache); + let reason = *exchange + .cache_reason + .lock() + .unwrap_or_else(|e| e.into_inner()); + cache.annotate_reason(response.headers_mut(), outcome, 0, 0, reason); + let transformed = response + .extensions() + .get::() + .is_some() + || response + .extensions() + .get::() + .is_some(); + cache.annotate_transformed(response.headers_mut(), transformed); + } + } + if response.extensions().get::().is_some() + || response.extensions().get::().is_some() + { + // Static/placeholder/default pages are generated locally and must never + // look cacheable or carry RustProxy cache diagnostics/validators. + enforce_local_page_no_cache(&mut response); + } + if response.extensions().get::().is_some() { + // 404 hosts are fully local and never participate in RustProxy caching. + // Keep their wire response free of cache policy, validators and cache diagnostics. + for name in [ + "cache-control", + "age", + "expires", + "pragma", + "etag", + "last-modified", + "cdn-cache-control", + "surrogate-control", + "x-cache", + "x-cache-hits", + "x-cache-status", + "x-cache-resident-age", + "x-cache-reason", + "cache-status", + ] { + response.headers_mut().remove(name); + } + } + exchange + .status + .store(response.status().as_u16(), Ordering::Relaxed); + let ws_success = response.status() == StatusCode::SWITCHING_PROTOCOLS + || response.extensions().get::().is_some(); + if ws_attempt && !ws_success { + state + .metrics + .websocket_handshake_errors + .fetch_add(1, Ordering::Relaxed); + } + if is_head { + *response.body_mut() = Body::empty(); + } + let response_read_timeout = frontend_context + .as_ref() + .and_then(|frontend| frontend.proxy_timeouts.read_timeout()) + .unwrap_or(state.body_idle_timeout); + response.map(|body| { + Body::new(MeteredBody::new( + body, + exchange, + Direction::Response, + response_read_timeout, + )) + }) +} +/// Build a same-host HTTPS redirect while preserving the request path and query. +fn force_ssl_location(host: &str, uri: &Uri, port: u16) -> Option { + let path = uri.path_and_query().map(|value| value.as_str()).unwrap_or("/"); + let location = if port == 443 { + format!("https://{host}{path}") + } else { + format!("https://{host}:{port}{path}") + }; + HeaderValue::from_str(&location).ok() +} + +fn force_ssl_redirect( + frontend: &crate::config::CompiledFrontend, + host: &str, + uri: &Uri, +) -> Option { + if !frontend.force_ssl { + return None; + } + let location = force_ssl_location(host, uri, frontend.https_redirect_port?)?; + let mut response = StatusCode::PERMANENT_REDIRECT.into_response(); + response.headers_mut().insert(http::header::LOCATION, location); + // Do not pin a redirect in a browser after an operator later disables Force SSL. + response + .headers_mut() + .insert("cache-control", HeaderValue::from_static("no-store")); + Some(response) +} + +/// HTTP/2 and HTTP/3 authority is a pseudo-header, not necessarily a Host field. +/// HTTP/1 absolute-form remains forbidden: this is not a forward proxy. +fn effective_host(request: &Request, tls: bool) -> Result { + let count = request.headers().get_all(HOST).iter().count(); + if matches!(request.version(), Version::HTTP_2 | Version::HTTP_3) { + let authority = request.uri().authority().ok_or(())?; + if request.uri().scheme_str() != Some(if tls { "https" } else { "http" }) || count > 1 { + return Err(()); + } + if let Some(host) = request.headers().get(HOST) { + if !host + .to_str() + .map_err(|_| ())? + .eq_ignore_ascii_case(authority.as_str()) + { + return Err(()); + } + } + HeaderValue::from_str(authority.as_str()).map_err(|_| ()) + } else { + if request.uri().scheme().is_some() || request.uri().authority().is_some() || count != 1 { + return Err(()); + } + request.headers().get(HOST).cloned().ok_or(()) + } +} +async fn forward( + State(state): State>, + Extension(peer): Extension, + Extension(local): Extension, + mut request: Request, +) -> Response { + if request.method() == Method::CONNECT + && !matches!(request.version(), Version::HTTP_2 | Version::HTTP_3) + { + return error( + StatusCode::NOT_IMPLEMENTED, + "RustProxy: CONNECT is not supported\n", + ); + } + let tls = request + .extensions() + .get::() + .copied() + .unwrap_or_default() + .tls; + let Ok(original_host) = effective_host(&request, tls) else { + return error( + StatusCode::BAD_REQUEST, + "RustProxy: invalid or conflicting request authority\n", + ); + }; + let Ok(host) = original_host + .to_str() + .ok() + .and_then(|v| request_host(v).ok()) + .ok_or(()) + else { + return error(StatusCode::BAD_REQUEST, "RustProxy: invalid hostname\n"); + }; + let handshake = if request.method() == Method::CONNECT { + let protocol = match request.version() { + Version::HTTP_2 => websocket::h2_protocol(&request), + Version::HTTP_3 => websocket::h3_protocol(&request), + _ => None, + }; + match (request.version(), protocol) { + (Version::HTTP_2, Some("websocket")) => match websocket::validate_h2(&request) { + Ok(handshake) => Some(handshake), + Err(_) => { + return error( + StatusCode::BAD_REQUEST, + "RustProxy: invalid HTTP/2 WebSocket Extended CONNECT\n", + ) + } + }, + (Version::HTTP_3, Some("websocket")) => match websocket::validate_h3(&request) { + Ok(handshake) => Some(handshake), + Err(_) => { + return error( + StatusCode::BAD_REQUEST, + "RustProxy: invalid HTTP/3 WebSocket Extended CONNECT\n", + ) + } + }, + (_, Some(_)) => { + return error( + StatusCode::NOT_IMPLEMENTED, + "RustProxy: unsupported Extended CONNECT protocol\n", + ) + } + _ => { + return error( + StatusCode::NOT_IMPLEMENTED, + "RustProxy: CONNECT is not supported\n", + ) + } + } + } else if headers::requests_upgrade(request.headers()) { + match websocket::validate(&request) { + Ok(handshake) => Some(handshake), + Err(_) => { + return error( + StatusCode::BAD_REQUEST, + "RustProxy: invalid or unsupported WebSocket upgrade\n", + ) + } + } + } else { + None + }; + let snapshot = request + .extensions() + .get::>() + .cloned() + .unwrap_or_else(|| state.config.load_full()); + let Ok(path) = crate::acl::routing_path(request.uri().path()) else { + return error( + StatusCode::BAD_REQUEST, + "RustProxy: ambiguous request path\n", + ); + }; + let frontend = snapshot.frontend(&host, local.0, tls); + let websocket_legacy_support = handshake.is_some() + && frontend.is_some_and(|frontend| frontend.websocket_legacy_support); + let redirect = snapshot.redirect_on(&host, &path, local.0, tls); + let local_host = snapshot.not_found_host_on(&host, local.0, tls); + // TLS ALPN normally prevents HTTP/2 from reaching a frontend that disabled it. + // Keep this request-level guard for coalesced/reused connections and direct tests. + if request.version() == Version::HTTP_2 + && (frontend.is_some_and(|frontend| !frontend.http2_enabled) + || redirect.is_some_and(|(redirect, _)| !redirect.http2_enabled) + || local_host.is_some_and(|item| !item.http2_enabled)) + { + return error( + StatusCode::MISDIRECTED_REQUEST, + "RustProxy: HTTP/2 is disabled for this vhost\n", + ); + } + if request.version() == Version::HTTP_3 + && (frontend.is_some_and(|frontend| !frontend.http3_enabled) + || redirect.is_some_and(|(redirect, _)| !redirect.http3_enabled) + || local_host.is_some_and(|item| !item.http3_enabled)) + { + return error( + StatusCode::MISDIRECTED_REQUEST, + "RustProxy: HTTP/3 is disabled for this vhost\n", + ); + } + if request.version() == Version::HTTP_2 + && request.method() == Method::CONNECT + && websocket::h2_protocol(&request) == Some("websocket") + && frontend.is_none_or(|frontend| !frontend.websocket_rfc8441) + { + return error( + StatusCode::MISDIRECTED_REQUEST, + "RustProxy: RFC 8441 WebSocket transport is not enabled for this vhost\n", + ); + } + if request.version() == Version::HTTP_3 + && request.method() == Method::CONNECT + && websocket::h3_protocol(&request) == Some("websocket") + && frontend.is_none_or(|frontend| !frontend.websocket_rfc9220) + { + return error( + StatusCode::MISDIRECTED_REQUEST, + "RustProxy: RFC 9220 WebSocket transport is not enabled for this vhost\n", + ); + } + if !tls { + if let Some(response) = frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri())) { + return response; + } + } + let mut context = match snapshot.forwarding.context( + request.headers(), + peer.0, + local.0, + tls, + &original_host, + request.uri(), + ) { + Ok(context) => context, + Err(_) => { + return error( + StatusCode::BAD_REQUEST, + "RustProxy: invalid forwarding context\n", + ) + } + }; + if frontend.is_some_and(|frontend| frontend.cloudflare_client_ip) { + if let Some(client) = crate::forwarding::cloudflare_client_ip(request.headers(), peer.0) { + if context.override_client_ip(client).is_err() { + return error( + StatusCode::BAD_REQUEST, + "RustProxy: invalid Cloudflare client address\n", + ); + } + } + } + if let Some(exchange) = request.extensions().get::>() { + *exchange.client_ip.lock().unwrap_or_else(|e| e.into_inner()) = Some(context.client_ip.to_string()); + } + if let Some(frontend) = frontend { + let country = state.geoip.country(context.client_ip); + if !frontend.profile_country_access.allows(country.as_deref()) + || !frontend.rate_limit.country_allowed(country.as_deref()) + { + return error( + StatusCode::FORBIDDEN, + "RustProxy: country access policy denied this request\n", + ); + } + if let Err(limited) = state.rate_limiter.check( + snapshot.envelope.revision, + &frontend.id, + context.client_ip, + country.as_deref(), + &frontend.rate_limit, + ) { + let mut response = error( + StatusCode::TOO_MANY_REQUESTS, + "RustProxy: request rate limit exceeded\n", + ); + if let Ok(value) = HeaderValue::from_str(&limited.retry_after_seconds.to_string()) { + response.headers_mut().insert("retry-after", value); + } + return response; + } + } + // Enforce host ACLs even for maintenance/fallback/direct-IP responses. + if !snapshot.host_allowed(&host, context.client_ip) + || !frontend + .and_then(|f| f.profile_acl.as_ref()) + .is_none_or(|acl| acl.allows(context.client_ip)) + || !frontend + .and_then(|f| f.acl.as_ref()) + .is_none_or(|acl| acl.allows(context.client_ip)) + { + return error( + StatusCode::FORBIDDEN, + "RustProxy: denied by access policy\n", + ); + } + if let Some((_redirect, rule)) = redirect { + let authority = original_host.to_str().unwrap_or(&host); + let Some(mut response) = rule.response(authority, tls, request.uri()) else { + return error( + StatusCode::INTERNAL_SERVER_ERROR, + "RustProxy: redirect target could not be rendered\n", + ); + }; + response.extensions_mut().insert(RedirectResponse { http3_enabled: _redirect.http3_enabled }); + return response; + } + if let Some(item) = local_host { + let mut response = item.page.serve(request.method(), StatusCode::NOT_FOUND); + response.extensions_mut().insert(LocalHostResponse { http3_enabled: item.http3_enabled }); + return response; + } + let selected = snapshot.select_on(&host, &path, local.0, tls); + if !selected + .and_then(|r| r.acl.as_ref()) + .is_none_or(|acl| acl.allows(context.client_ip)) + { + return error( + StatusCode::FORBIDDEN, + "RustProxy: denied by access policy\n", + ); + } + // The admin cookie is never ordinary application state. Strip it BEFORE + // auth/cache and keep only the removed fragment locally. A verified self-admin + // backend may restore it after the actual upstream has been selected. + let admin_cookie = headers::strip_admin_cookie(request.headers_mut()); + // This is internal authorization, not a public /tinyauth location. It runs + // before origin selection, static responses, upgrades and every cache lookup. + let basic_identity = if let Some(auth) = frontend.and_then(|f| f.basic_auth.as_ref()) { + match auth + .authorize( + request.headers_mut(), + tls, + context.client_ip, + state.basic_workers.clone(), + &state.basic_limits, + ) + .await + { + Ok(identity) => identity, + Err(response) => return response, + } + } else { + None + }; + if handshake.is_some() && frontend.is_some_and(|f| !f.websockets) { + return error( + StatusCode::FORBIDDEN, + "RustProxy: WebSocket access is disabled for this host\n", + ); + } + let identity = if let Some(auth) = frontend.and_then(|f| f.tinyauth.as_ref()) { + auth.strip_identity(request.headers_mut()); + match auth + .authorize( + request.headers(), + request.method(), + &context, + &state.shutdown, + ) + .await + { + Ok(identity) => Some(identity), + Err(response) => return response, + } + } else { + None + }; + if crate::pages::literal_ip_authority(original_host.to_str().unwrap_or("")) { + if let Some(page) = &snapshot.envelope.config.pages.direct_ip { + return page.serve(request.method(), StatusCode::OK); + } + } + if let Some(page) = frontend.and_then(|f| f.pages.response.as_ref()) { + return page.serve(request.method(), StatusCode::OK); + } + let Some(route) = selected else { + let page = match frontend { + Some(frontend) => frontend.pages.fallback.as_ref(), + None => snapshot.envelope.config.pages.unknown_host.as_ref(), + }; + return page + .map(|page| page.serve(request.method(), StatusCode::NOT_FOUND)) + .unwrap_or_else(|| { + error( + StatusCode::NOT_FOUND, + "RustProxy: no route for this host/path\n", + ) + }); + }; + // Both scopes run before DNS, TCP, TLS or reuse of an existing upstream connection. + if !snapshot.host_allowed(&host, context.client_ip) + || !route + .acl + .as_ref() + .is_none_or(|acl| acl.allows(context.client_ip)) + || !route + .frontend + .as_ref() + .and_then(|f| f.profile_acl.as_ref()) + .is_none_or(|acl| acl.allows(context.client_ip)) + || !route + .frontend + .as_ref() + .and_then(|f| f.acl.as_ref()) + .is_none_or(|acl| acl.allows(context.client_ip)) + { + return error( + StatusCode::FORBIDDEN, + "RustProxy: denied by access policy\n", + ); + } + // Local location responses intentionally still pass host/profile/location ACLs + // and the outer response-header/security pipeline, but never select/open an upstream. + if let Some(spec) = &route.response { + return local_location_response(spec, request.method()); + } + let (backend, backend_lease, affinity_cookie) = if let Some(pool) = &route.pool { + let selection = match &route.backend_id { + Some(id) => pool.select_member_wait(id).await, + None => pool + .select_wait(context.client_ip, request.headers(), tls) + .await, + }; + let selection = match selection { + Ok(selection) => selection, + Err(crate::balance::SelectionFailure::Unavailable) => { + let mut response = error( + StatusCode::SERVICE_UNAVAILABLE, + "RustProxy: no healthy backend in this vhost pool\n", + ); + response + .headers_mut() + .insert("retry-after", HeaderValue::from_static("1")); + return response; + } + Err(crate::balance::SelectionFailure::Capacity) => { + let mut response = error( + StatusCode::SERVICE_UNAVAILABLE, + "RustProxy: backend concurrency limit reached\n", + ); + response + .headers_mut() + .insert("retry-after", HeaderValue::from_static("1")); + return response; + } + }; + ( + selection.lease.member.backend.clone(), + Some(selection.lease), + selection.cookie, + ) + } else { + (route.backend.clone(), None, None) + }; + let admin_control_plane = admin_session_passthrough(&snapshot, &host, tls, &backend); + if admin_control_plane { + headers::restore_admin_cookie(request.headers_mut(), &admin_cookie); + } + let cache_settings = route + .frontend + .as_ref() + .map(|f| &f.cache) + .unwrap_or(&snapshot.envelope.config.cache); + let transform_settings = route.frontend.as_ref().map(|f| &f.transform); + let scope = route.frontend.as_ref().map(|f| f.id.as_str()).unwrap_or(""); + if let Some(exchange) = request.extensions().get::>() { + *exchange.route.lock().unwrap_or_else(|e| e.into_inner()) = Some(route.id.clone()); + *exchange.cache_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) = + Some(cache_settings.active_for(route.cache)); + *exchange.transform_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) = + Some(transform_settings.is_some_and(|settings| settings.enabled)); + } + let policy_reason = if admin_control_plane { + Some("admin_control_plane") + } else if frontend.is_some_and(|f| f.basic_auth.is_some()) { + Some("basic_auth") + } else if frontend.is_some_and(|f| f.tinyauth.is_some()) { + Some("tinyauth") + } else if route + .pool + .as_ref() + .is_some_and(|pool| pool.cookie_affinity()) + { + Some("sticky_cookie") + } else if !cache_settings.enabled { + Some("cache_disabled") + } else if route.cache == Some(false) { + Some("route_disabled") + } else if handshake.is_some() { + Some("request_upgrade") + } else { + None + }; + let cache_enabled = policy_reason.is_none(); + let body_empty = if cache_enabled && handshake.is_none() && request.method() == Method::GET { + match empty_for_cache(&mut request).await { + Ok(empty) => empty, + Err(_) => { + return error( + StatusCode::BAD_REQUEST, + "RustProxy: invalid or incomplete request body\n", + ) + } + } + } else { + request.body().is_end_stream() + }; + let ignore_static_cookie = cache_settings.ignore_cookie_for_path(&path); + let original_policy = policy_reason + .map(crate::cache::RequestPolicy::Bypass) + .unwrap_or_else(|| { + crate::cache::request_policy( + request.method(), + request.headers(), + body_empty, + &path, + request.uri().query().is_some(), + cache_settings, + ) + }); + + let only_cached = + cache_settings.active_for(route.cache) && crate::cache::only_if_cached(request.headers()); + let public_suffix = request + .uri() + .path_and_query() + .map(|v| v.as_str()) + .unwrap_or("/") + .to_owned(); + let origin_suffix = match route.rewritten_path(&path) { + Ok(None) => public_suffix.clone(), + Ok(Some(rewritten)) => { + let Some(mut encoded) = encoded_origin_path(&rewritten) else { + return error(StatusCode::BAD_REQUEST, "RustProxy: invalid rewritten request target\n"); + }; + if let Some(query) = request.uri().query() { + encoded.push('?'); + encoded.push_str(query); + } + encoded + } + Err(_) => return error(StatusCode::BAD_REQUEST, "RustProxy: invalid rewritten request target\n"), + }; + let Some(uri) = upstream_uri(&backend, &origin_suffix) else { + return error( + StatusCode::BAD_REQUEST, + "RustProxy: invalid request target\n", + ); + }; + let exchange = request.extensions().get::>().cloned(); + let lease = request.extensions().get::>().cloned(); + let stop = request.extensions().get::().cloned(); + if handshake.is_some() { + if let Some(exchange) = &exchange { + exchange.websocket_phase("upstream_handshake"); + } + } + let downstream = if handshake.as_ref().is_some_and(|handshake| handshake.h1()) { + Some(hyper::upgrade::on(&mut request)) + } else { + None + }; + let mut h2_downstream = if handshake.as_ref().is_some_and(|handshake| handshake.h2()) + && (backend.protocol != crate::config::UpstreamProtocol::Http2 + || websocket_legacy_support) + { + Some(std::mem::replace(request.body_mut(), Body::empty())) + } else { + None + }; + headers::strip_hop_by_hop(request.headers_mut()); + request.headers_mut().insert( + HOST, + if websocket_legacy_support { + backend.host_header.clone() + } else if backend.preserve_host { + original_host.clone() + } else { + backend.host_header.clone() + }, + ); + crate::headers::apply(request.headers_mut(), &snapshot.headers.request); + if let Some(frontend) = &route.frontend { + crate::headers::apply(request.headers_mut(), &frontend.headers.request); + } + crate::headers::apply(request.headers_mut(), &route.headers.request); + // Canonical trusted metadata cannot be overwritten by custom request rules. + // A vhost may intentionally withhold only the visitor IP from the application; + // ACL/rate-limit/Tinyauth decisions above still use the authenticated client address. + context.apply_upstream( + request.headers_mut(), + route + .frontend + .as_ref() + .is_none_or(|frontend| frontend.forward_client_ip), + ); + if let Some(auth) = frontend.and_then(|f| f.tinyauth.as_ref()) { + auth.strip_identity(request.headers_mut()); + } + if let Some(identity) = identity { + identity.apply(request.headers_mut()); + } + if websocket_legacy_support { + if let Some(upstream_host) = request.headers().get(HOST).cloned() { + if let Some(origin) = legacy_websocket_origin(&backend.request_origin, &upstream_host) { + request.headers_mut().insert("origin", origin); + } + } + } + let range_selection = crate::cache::request_range(request.method(), request.headers()); + let if_range = request.headers().get("if-range").cloned(); + let range_requested = !matches!(range_selection, crate::cache::RangeSelection::None); + let post_policy = crate::cache::request_policy( + request.method(), + request.headers(), + body_empty, + &path, + request.uri().query().is_some(), + cache_settings, + ); + let effective_policy = match (original_policy, post_policy) { + (crate::cache::RequestPolicy::Bypass(reason), _) => { + crate::cache::RequestPolicy::Bypass(reason) + } + (_, crate::cache::RequestPolicy::Bypass(reason)) => { + crate::cache::RequestPolicy::Bypass(reason) + } + (crate::cache::RequestPolicy::Revalidate(reason), _) => { + crate::cache::RequestPolicy::Revalidate(reason) + } + (_, crate::cache::RequestPolicy::Revalidate(reason)) => { + crate::cache::RequestPolicy::Revalidate(reason) + } + _ => crate::cache::RequestPolicy::Use, + }; + let effective_reason = effective_policy.bypass_reason(); + let revalidation_reason = effective_policy.revalidation_reason(); + let effective_cacheable = effective_reason.is_none(); + + // Match Varnish-style cache identity: a load-balanced backend member must not + // fragment the object cache. The vhost scope + canonical host + request target + // already identify the public resource; Vary handles representation variants. + let cache_route = route.id.as_str(); + let mut cache_key_headers = None; + if ignore_static_cookie && request.headers().contains_key("cookie") { + let mut headers = request.headers().clone(); + headers.remove("cookie"); + cache_key_headers = Some(headers); + } + let key_headers = cache_key_headers + .as_ref() + .unwrap_or_else(|| request.headers()); + let cache_ticket = if effective_cacheable { + state.cache.ticket_scoped( + state.cache.revision(), + route.frontend.as_ref().map(|f| f.id.as_str()).unwrap_or(""), + context.scheme, + original_host.to_str().unwrap_or(""), + cache_route, + &public_suffix, + key_headers, + ) + } else { + None + }; + let client_conditionals = request.headers().clone(); + let cached_revalidation = if let Some(ticket) = cache_ticket.as_ref() { + state.cache.revalidation_tiered(ticket).await + } else { + None + }; + let freshness_revalidation_reason = cached_revalidation + .as_ref() + .and_then(|cached| cached.request_freshness_revalidation_reason(&client_conditionals)); + let pending_revalidation = revalidation_reason.is_some() + || freshness_revalidation_reason.is_some() + || cached_revalidation + .as_ref() + .is_some_and(|cached| !cached.reusable_for_request(&client_conditionals)); + let mut miss_recorded = false; + if let Some(ticket) = &cache_ticket { + if revalidation_reason.is_none() { + if let Some(mut hit) = state.cache.get_tiered(ticket, cache_settings).await { + let conditional = crate::cache::conditional_not_modified( + &client_conditionals, + hit.response.headers(), + ); + if !conditional { + if let crate::cache::RangeSelection::Single(range) = range_selection { + hit = hit.apply_range(range, if_range.as_ref()); + } + } + let range_hit = matches!( + hit.response.status(), + StatusCode::PARTIAL_CONTENT | StatusCode::RANGE_NOT_SATISFIABLE + ); + let reason = if conditional { + "cache_conditional_hit" + } else if range_hit { + "cache_range_hit" + } else if hit.stale && hit.background_revalidate { + "cache_stale_while_revalidate" + } else if hit.stale { + "cache_max_stale" + } else { + "cache_hit" + }; + if hit.background_revalidate { + if let (Some(cached), Some(guard)) = ( + cached_revalidation.clone(), + cached_revalidation + .as_ref() + .and_then(|cached| state.cache.begin_background_revalidation(cached)), + ) { + let refresh_context = CacheRefreshContext { + state: state.clone(), + snapshot: snapshot.clone(), + backend: backend.clone(), + cache_settings: cache_settings.clone(), + transform: transform_settings.cloned(), + uri: uri.clone(), + public_suffix: public_suffix.clone(), + host: host.clone(), + path: path.clone(), + scope: scope.to_owned(), + timeout: frontend + .and_then(|frontend| frontend.proxy_timeouts.response_header_timeout()) + .unwrap_or(state.header_timeout), + local: local.0, + tls, + base_headers: request.headers().clone(), + }; + let refresh_ticket = ticket.clone(); + tokio::spawn(async move { + let _guard = guard; + if refresh_cache_once(&refresh_context, refresh_ticket, cached).await { + refresh_context.state.cache.record( + &refresh_context.scope, + &refresh_context.host, + "HIT", + "cache_stale_while_revalidate_refreshed", + ); + } + }); + } + } + if cache_settings.proactive_refresh_enabled + && !hit.stale + && hit.remaining_ttl > 0 + { + schedule_proactive_refresh( + CacheRefreshContext { + state: state.clone(), + snapshot: snapshot.clone(), + backend: backend.clone(), + cache_settings: cache_settings.clone(), + transform: transform_settings.cloned(), + uri: uri.clone(), + public_suffix: public_suffix.clone(), + host: host.clone(), + path: path.clone(), + scope: scope.to_owned(), + timeout: frontend + .and_then(|frontend| frontend.proxy_timeouts.response_header_timeout()) + .unwrap_or(state.header_timeout), + local: local.0, + tls, + base_headers: request.headers().clone(), + }, + ticket.clone(), + hit.remaining_ttl, + ); + } + if conditional { + hit.response = crate::cache::make_not_modified(hit.response); + } + if let Some(exchange) = &exchange { + exchange.cache_status.store(1, Ordering::Relaxed); + } + state.cache.record(scope, &host, "HIT", reason); + if let Some(exchange) = &exchange { + *exchange + .cache_reason + .lock() + .unwrap_or_else(|e| e.into_inner()) = reason; + } + cache_settings.annotate_reason( + hit.response.headers_mut(), + "HIT", + hit.hits, + hit.remaining_ttl, + reason, + ); + cache_settings.annotate_transformed(hit.response.headers_mut(), hit.transformed); + if hit.transformed { + state.transform_metrics.record_cache_hit(scope); + } + cache_settings.annotate_resident_age( + hit.response.headers_mut(), + hit.resident_age_seconds, + ); + hit.response.extensions_mut().insert(ResponseRulesApplied); + return hit.response; + } + state.cache.record(scope, &host, "MISS", "cache_miss"); + miss_recorded = true; + if let Some(exchange) = &exchange { + exchange.cache_status.store(2, Ordering::Relaxed); + *exchange + .cache_reason + .lock() + .unwrap_or_else(|e| e.into_inner()) = "cache_miss"; + } + } else if cached_revalidation.is_none() { + // Account a true cache miss even when the client requested validation. + let _ = state.cache.get_tiered(ticket, cache_settings).await; + state.cache.record(scope, &host, "MISS", "cache_miss"); + miss_recorded = true; + if let Some(exchange) = &exchange { + exchange.cache_status.store(2, Ordering::Relaxed); + *exchange + .cache_reason + .lock() + .unwrap_or_else(|e| e.into_inner()) = "cache_miss"; + } + } + } else { + state.cache.bypass(); + let reason = effective_reason.unwrap_or("configuration_changed"); + state.cache.record(scope, &host, "BYPASS", reason); + if let Some(exchange) = &exchange { + exchange.cache_status.store(3, Ordering::Relaxed); + *exchange + .cache_reason + .lock() + .unwrap_or_else(|e| e.into_inner()) = reason; + } + } + let serve_stale = |reason: &'static str, elapsed: Duration, origin_backend: &str| -> Option { + let cached = cached_revalidation.as_ref()?; + let mut hit = state.cache.stale_if_error_hit(cached)?; + if let crate::cache::RangeSelection::Single(range) = range_selection { + hit = hit.apply_range(range, if_range.as_ref()); + } + cache_settings.annotate_reason( + hit.response.headers_mut(), + "HIT", + hit.hits, + 0, + reason, + ); + cache_settings.annotate_transformed(hit.response.headers_mut(), hit.transformed); + if hit.transformed { + state.transform_metrics.record_cache_hit(scope); + } + cache_settings.annotate_resident_age( + hit.response.headers_mut(), + hit.resident_age_seconds, + ); + hit.response.extensions_mut().insert(ResponseRulesApplied); + hit.response.extensions_mut().insert(OriginTiming(elapsed)); + hit.response + .extensions_mut() + .insert(OriginBackend(origin_backend.to_owned())); + state.cache.record(scope, &host, "HIT", reason); + if let Some(exchange) = &exchange { + exchange.cache_status.store(1, Ordering::Relaxed); + *exchange.cache_reason.lock().unwrap_or_else(|e| e.into_inner()) = reason; + } + Some(hit.response) + }; + if only_cached { + let mut response = error( + StatusCode::GATEWAY_TIMEOUT, + "RustProxy: no reusable cached response\n", + ); + cache_settings.annotate(response.headers_mut(), "MISS", 0, 0); + return response; + } + + // Foreground fills are single-flight per base cache key. Range responses are + // never fill leaders because a 206 is not stored as a separate cache object. + // A follower can wake after a different Vary representation was published; in + // that case it competes for the next flight instead of stampeding the origin. + let mut fill_leader = None; + if handshake.is_none() + && matches!(range_selection, crate::cache::RangeSelection::None) + { + if let Some(ticket) = cache_ticket.as_ref() { + let mut collapse_rounds = 0u8; + loop { + match state.cache.claim_fill(ticket, cache_settings).await { + crate::cache::FillClaim::Leader(guard) => { + fill_leader = Some(guard); + break; + } + crate::cache::FillClaim::Waited => { + if let Some(mut hit) = state.cache.get_tiered(ticket, cache_settings).await { + let conditional = crate::cache::conditional_not_modified( + &client_conditionals, + hit.response.headers(), + ); + let reason = if conditional { + "cache_collapsed_conditional_hit" + } else { + "cache_collapsed_hit" + }; + if cache_settings.proactive_refresh_enabled + && !hit.stale + && hit.remaining_ttl > 0 + { + schedule_proactive_refresh( + CacheRefreshContext { + state: state.clone(), + snapshot: snapshot.clone(), + backend: backend.clone(), + cache_settings: cache_settings.clone(), + transform: transform_settings.cloned(), + uri: uri.clone(), + public_suffix: public_suffix.clone(), + host: host.clone(), + path: path.clone(), + scope: scope.to_owned(), + timeout: frontend + .and_then(|frontend| frontend.proxy_timeouts.response_header_timeout()) + .unwrap_or(state.header_timeout), + local: local.0, + tls, + base_headers: request.headers().clone(), + }, + ticket.clone(), + hit.remaining_ttl, + ); + } + if conditional { + hit.response = crate::cache::make_not_modified(hit.response); + } + cache_settings.annotate_reason( + hit.response.headers_mut(), + "HIT", + hit.hits, + hit.remaining_ttl, + reason, + ); + cache_settings + .annotate_transformed(hit.response.headers_mut(), hit.transformed); + if hit.transformed { + state.transform_metrics.record_cache_hit(scope); + } + cache_settings.annotate_resident_age( + hit.response.headers_mut(), + hit.resident_age_seconds, + ); + hit.response.extensions_mut().insert(ResponseRulesApplied); + state.cache.record(scope, &host, "HIT", reason); + if let Some(exchange) = &exchange { + exchange.cache_status.store(1, Ordering::Relaxed); + *exchange + .cache_reason + .lock() + .unwrap_or_else(|e| e.into_inner()) = reason; + } + return hit.response; + } + collapse_rounds = collapse_rounds.saturating_add(1); + if collapse_rounds >= 8 { + state.cache.record_reason( + scope, + &host, + "cache_collapse_variant_limit", + ); + break; + } + } + crate::cache::FillClaim::Timeout => { + if matches!( + cache_settings.collapse_timeout_fallback, + crate::cache::CollapseTimeoutFallback::StaleIfAvailable + ) { + if let Some(stale) = + serve_stale("cache_collapse_timeout_stale", Duration::ZERO, &backend.id) + { + return stale; + } + } + state.cache.record_reason(scope, &host, "cache_collapse_timeout_origin"); + break; + } + crate::cache::FillClaim::Disabled => break, + } + } + } + } + if !admin_control_plane { + headers::strip_admin_cookie(request.headers_mut()); + } + if let Some(pool) = &route.pool { + pool.strip_affinity(request.headers_mut()); + } + if let Some(lease) = &backend_lease { + lease.started(); + if let Some(exchange) = &exchange { + *exchange.backend.lock().unwrap_or_else(|e| e.into_inner()) = Some(lease.clone()); + } + } + let websocket_upstream_h2 = !websocket_legacy_support + && handshake.as_ref().is_some_and(|handshake| handshake.h2()) + && backend.protocol == crate::config::UpstreamProtocol::Http2; + if let Some(handshake) = &handshake { + request.headers_mut().remove("content-length"); + request.headers_mut().remove("transfer-encoding"); + if websocket_upstream_h2 { + // Preserve CONNECT + :protocol=websocket for an explicit HTTP/2 + // upstream. HTTP/1.1-only fields are forbidden on this hop. + request.headers_mut().remove("connection"); + request.headers_mut().remove("upgrade"); + request.headers_mut().remove("sec-websocket-key"); + request.headers_mut().remove("sec-websocket-accept"); + } else { + request + .headers_mut() + .insert("connection", HeaderValue::from_static("Upgrade")); + request + .headers_mut() + .insert("upgrade", HeaderValue::from_static("websocket")); + if !handshake.h1() { + // Extended CONNECT replaces the RFC 6455 proof headers on the + // downstream hop. Synthesize them only for an HTTP/1.1 backend. + *request.method_mut() = Method::GET; + request.headers_mut().remove("sec-websocket-key"); + request.headers_mut().remove("sec-websocket-accept"); + if let Some(key) = handshake.upstream_key() { + if let Ok(key) = HeaderValue::from_str(key) { + request.headers_mut().insert("sec-websocket-key", key); + } else { + return error( + StatusCode::INTERNAL_SERVER_ERROR, + "RustProxy: failed to build WebSocket handshake\n", + ); + } + } + } + } + } + if let Some(auth) = frontend.and_then(|f| f.basic_auth.as_ref()) { + // Enforce after all custom request-header rules; they cannot restore credentials. + auth.apply_upstream(request.headers_mut(), basic_identity); + } + // Transformation operates on the identity representation. Prefer identity from + // the origin, while still decoding a supported encoding if the origin ignores it. + if range_requested || transform_settings.is_some_and(|settings| settings.enabled) { + request.headers_mut().remove("accept-encoding"); + } + let mut revalidation_sent = false; + if pending_revalidation { + if let Some(cached) = &cached_revalidation { + revalidation_sent = cached.apply_validators(request.headers_mut()); + } + } + // Varnish hashes HEAD with GET and turns a regular cacheable backend fetch + // into GET. The outer response middleware still strips the body for the + // original HEAD client, while a MISS can populate the shared GET object. + if request.method() == Method::HEAD && cache_ticket.is_some() { + *request.method_mut() = Method::GET; + } + *request.uri_mut() = uri.clone(); + let upstream_version = if websocket_legacy_support && !websocket_upstream_h2 { + Version::HTTP_11 + } else { + upstream_request_version(&backend, websocket_upstream_h2) + }; + if normalize_h3_h1_unknown_body(&mut request, upstream_version) + .await + .is_err() + { + return error( + StatusCode::BAD_REQUEST, + "RustProxy: invalid or incomplete request body\n", + ); + } + *request.version_mut() = upstream_version; + let mut h2_direct_session = None; + if websocket_upstream_h2 { + let Some(exchange) = exchange.clone() else { + return error( + StatusCode::INTERNAL_SERVER_ERROR, + "RustProxy: WebSocket exchange state unavailable\n", + ); + }; + let stop_token = stop + .as_ref() + .map(|value| value.0.clone()) + .unwrap_or_else(|| state.shutdown.clone()); + let session = state.tunnel_registry.register_pending( + exchange.clone(), + backend.origin.clone(), + "h2-rfc8441-h2-upstream", + stop_token, + lease.clone(), + ); + websocket::spawn_h2_idle_watch(state.clone(), &session); + let body = std::mem::replace(request.body_mut(), Body::empty()); + *request.body_mut() = websocket::wrap_h2_request_body(body, session.clone(), exchange); + h2_direct_session = Some(session); + } + // Do not classify a slow client upload as a slow origin response. Probes + // independently measure origin liveness/latency with body-free requests. + let request_body_empty = request.body().size_hint().exact() == Some(0); + let proactive_base_headers = request.headers().clone(); + let response_header_timeout = frontend + .and_then(|frontend| frontend.proxy_timeouts.response_header_timeout()) + .unwrap_or(state.header_timeout); + let retry_headers = request.headers().clone(); + let retry_method = request.method().clone(); + let expected_backend_host = if backend.preserve_host { + &original_host + } else { + &backend.host_header + }; + let retry_host_follows_backend = request + .headers() + .get(HOST) + .is_some_and(|value| value == expected_backend_host); + let retry_policy = route.pool.as_ref().map(|pool| pool.settings.retry.clone()); + let retry_enabled = route.backend_id.is_none() + && !admin_control_plane + && handshake.is_none() + && request_body_empty + && retry_safe_method(&retry_method) + && retry_policy.as_ref().is_some_and(|policy| policy.max_retries > 0); + let upstream_started = Instant::now(); + let mut attempt_started = Instant::now(); + let mut active_backend = backend.clone(); + let mut active_lease = backend_lease.clone(); + drop(backend_lease); + let mut active_affinity_cookie = affinity_cookie.clone(); + let mut active_uri = uri.clone(); + let mut request_attempt = Some(request); + let mut attempted_backends = Vec::::new(); + let mut retries = 0u8; + let upstream_result = loop { + let client = if handshake.is_some() && !websocket_upstream_h2 { + &active_backend.websocket_client + } else { + &active_backend.client + }; + let result = tokio::time::timeout( + response_header_timeout, + client.request(request_attempt.take().expect("upstream request attempt missing")), + ) + .await; + let attempt_elapsed = attempt_started.elapsed(); + let Some(policy) = retry_policy.as_ref() else { + break result; + }; + let retry_reason = match &result { + Ok(Ok(response)) if policy.retries_status(response.status().as_u16()) => { + Some("http_status") + } + Ok(Err(cause)) if cause.is_connect() && policy.retry_connect_errors => Some("connect"), + Err(_) if policy.retry_timeouts => Some("timeout"), + _ => None, + }; + if !retry_enabled || retries >= policy.max_retries || retry_reason.is_none() { + break result; + } + attempted_backends.push(active_backend.id.clone()); + let Some(pool) = route.pool.as_ref() else { + break result; + }; + let selection = match pool + .select_retry_wait( + context.client_ip, + &retry_headers, + tls, + &attempted_backends, + ) + .await + { + Ok(selection) => selection, + Err(crate::balance::SelectionFailure::Capacity) => { + tracing::debug!( + vhost = %pool.id, + attempt = retries, + "retry stopped because all remaining backends are at concurrency capacity" + ); + break result; + } + Err(crate::balance::SelectionFailure::Unavailable) => break result, + }; + let next_backend = selection.lease.member.backend.clone(); + let Some(next_uri) = upstream_uri(&next_backend, &origin_suffix) else { + break result; + }; + let Some(next_request) = retry_request( + &retry_method, + upstream_request_version(&next_backend, false), + &retry_headers, + &next_backend, + &original_host, + &origin_suffix, + retry_host_follows_backend, + ) else { + break result; + }; + if let Some(lease) = &active_lease { + match &result { + Ok(Ok(response)) => { + lease.response(response.status().as_u16(), attempt_elapsed, true) + } + Ok(Err(cause)) => { + let connect_failure = cause.is_connect(); + let detail = upstream_failure_detail( + cause, + &active_backend, + attempt_elapsed, + connect_failure, + ); + lease.error_detail( + upstream_error_class(cause, connect_failure), + &detail, + attempt_elapsed, + ); + } + Err(_) => lease.error_detail( + "response_header_timeout", + "response_header_timeout", + attempt_elapsed, + ), + } + lease.retry_attempted(); + } + retries = retries.saturating_add(1); + selection.lease.started(); + if let Some(exchange) = &exchange { + *exchange.backend.lock().unwrap_or_else(|e| e.into_inner()) = + Some(selection.lease.clone()); + } + tracing::info!( + previous_backend = %active_backend.id, + backend = %next_backend.id, + attempt = retries, + reason = retry_reason.unwrap_or("unknown"), + "retrying replay-safe request on another backend" + ); + active_backend = next_backend; + active_lease = Some(selection.lease); + active_affinity_cookie = selection.cookie; + active_uri = next_uri; + request_attempt = Some(next_request); + attempt_started = Instant::now(); + }; + let final_attempt_elapsed = attempt_started.elapsed(); + match upstream_result { + Ok(Ok(mut response)) => { + let origin_validation_headers = response.headers().clone(); + if let Some(exchange) = &exchange { + exchange + .upstream_status + .store(response.status().as_u16(), Ordering::Relaxed); + } + if let Some(lease) = &active_lease { + lease.response( + response.status().as_u16(), + final_attempt_elapsed, + request_body_empty, + ); + if retries > 0 && response.status().as_u16() < 500 { + lease.retry_succeeded(); + } + } + if cache_settings.allows_stale_error_status(response.status()) { + if let Some(stale) = serve_stale( + "cache_stale_if_error", + upstream_started.elapsed(), + &active_backend.id, + ) { + return stale; + } + } + let h2_direct_tunnel = websocket_upstream_h2 && response.status().is_success(); + if websocket_upstream_h2 && response.status() == StatusCode::SWITCHING_PROTOCOLS { + if let Some(session) = h2_direct_session.take() { + session.cancellation().cancel(); + } + if let Some(exchange) = &exchange { + exchange.websocket_error("upstream_rejected", "unexpected_h2_101"); + } + return error( + StatusCode::BAD_GATEWAY, + "RustProxy: HTTP/2 WebSocket upstream returned HTTP/1.1 switching protocols\n", + ); + } + if h2_direct_tunnel { + let Some(expected) = &handshake else { + unreachable!("direct H2 WebSocket requires a handshake") + }; + if websocket::validate_h2_response(response.headers(), expected).is_err() { + if let Some(session) = h2_direct_session.take() { + session.cancellation().cancel(); + } + if let Some(exchange) = &exchange { + exchange.websocket_error("upstream_rejected", "invalid_h2_handshake"); + } + return error( + StatusCode::BAD_GATEWAY, + "RustProxy: invalid upstream HTTP/2 WebSocket handshake\n", + ); + } + if let Some(session) = h2_direct_session.as_ref() { + session.establish(); + } + if let Some(exchange) = &exchange { + exchange.websocket_phase("upstream_2xx_received"); + } + } else if websocket_upstream_h2 { + if let Some(session) = h2_direct_session.take() { + session.cancellation().cancel(); + } + } + let mut upgraded = if response.status() == StatusCode::SWITCHING_PROTOCOLS { + let Some(expected) = &handshake else { + return error( + StatusCode::BAD_GATEWAY, + "RustProxy: unsolicited upstream upgrade\n", + ); + }; + if websocket::validate_response( + response.headers(), + expected, + websocket_legacy_support, + ) + .is_err() + { + if let Some(exchange) = &exchange { + exchange.websocket_error("upstream_rejected", "invalid_101_handshake"); + } + return error( + StatusCode::BAD_GATEWAY, + "RustProxy: invalid upstream WebSocket handshake\n", + ); + } + if let Some(exchange) = &exchange { + exchange.websocket_phase("upstream_101_received"); + } + Some(hyper::upgrade::on(&mut response)) + } else { + None + }; + if handshake.as_ref().is_some_and(|handshake| { + handshake.h3() || (handshake.h2() && !websocket_upstream_h2) + }) + && response.status().is_success() + && response.status() != StatusCode::SWITCHING_PROTOCOLS + { + if let Some(exchange) = &exchange { + exchange.websocket_error("upstream_rejected", "upstream_2xx_without_101"); + } + return error( + StatusCode::BAD_GATEWAY, + "RustProxy: HTTP/1.1 WebSocket upstream returned 2xx without switching protocols\n", + ); + } + // Varnish built-in behavior treats non-GET/HEAD methods as pass: the + // response is never stored, but existing cached GET representations are + // not purged implicitly. Explicit cache purge remains an admin action. + let origin_plan = if cache_ticket.is_some() + && !(ignore_static_cookie && crate::cache::varies_on(response.headers(), "cookie")) + { + crate::cache::plan( + response.status(), + response.headers(), + cache_settings, + upstream_started.elapsed(), + ) + } else { + None + }; + let origin_reason = (cache_ticket.is_some() && origin_plan.is_none()).then(|| { + if ignore_static_cookie && crate::cache::varies_on(response.headers(), "cookie") { + "response_vary_cookie" + } else { + crate::cache::response_reason(response.status(), response.headers()) + } + }); + let origin_no_transform = crate::transform::no_transform(response.headers()); + let skip_compression = crate::compression::private_or_no_transform(response.headers()); + let origin_validators = crate::transform::OriginValidators { + etag: response.headers().get("etag").cloned(), + last_modified: response.headers().get("last-modified").cloned(), + }; + let (mut parts, body) = response.into_parts(); + parts.extensions.insert(origin_validators); + if skip_compression || range_requested { + parts.extensions.insert(crate::compression::Skip); + } + headers::strip_hop_by_hop(&mut parts.headers); + if websocket_legacy_support && handshake.is_some() { + parts.headers.remove("content-length"); + parts.headers.remove("transfer-encoding"); + } + apply_response_rules( + &mut parts.headers, + &snapshot, + route.frontend.as_deref(), + Some(route), + admin_control_plane, + ); + if let Some(pool) = &route.pool { + pool.strip_affinity_response(&mut parts.headers); + } + if let Some(cookie) = active_affinity_cookie { + parts.headers.append("set-cookie", cookie); + } + let h3_tunnel = handshake.as_ref().is_some_and(|handshake| handshake.h3()) + && upgraded.is_some(); + let h2_bridge_tunnel = handshake.as_ref().is_some_and(|handshake| handshake.h2()) + && !websocket_upstream_h2 + && upgraded.is_some(); + let mut response = if h3_tunnel { + let Some(upstream) = upgraded.take() else { + unreachable!("checked above") + }; + let Some(exchange) = exchange.clone() else { + return error( + StatusCode::INTERNAL_SERVER_ERROR, + "RustProxy: WebSocket exchange state unavailable\n", + ); + }; + // Hyper cannot complete the client-side upgrade while the 101 body + // remains owned here. Drop it, then prove that OnUpgrade is ready + // BEFORE returning a successful RFC 9220 response to the H3 layer. + drop(body); + let stop_token = stop + .as_ref() + .map(|value| value.0.clone()) + .unwrap_or_else(|| state.shutdown.clone()); + let upstream = tokio::select! { + _ = stop_token.cancelled() => { + exchange.failed.store(true, Ordering::Relaxed); + exchange.websocket_error("upgrade_cancelled", "shutdown"); + state.metrics.websocket_errors.fetch_add(1, Ordering::Relaxed); + return error( + StatusCode::SERVICE_UNAVAILABLE, + "RustProxy: WebSocket upgrade cancelled\n", + ); + } + result = tokio::time::timeout(Duration::from_secs(10), upstream) => match result { + Ok(Ok(upstream)) => upstream, + _ => { + exchange.failed.store(true, Ordering::Relaxed); + exchange.websocket_error("upgrade_failed", "backend_on_upgrade"); + state.metrics.websocket_errors.fetch_add(1, Ordering::Relaxed); + return error( + StatusCode::BAD_GATEWAY, + "RustProxy: upstream WebSocket upgrade failed\n", + ); + } + } + }; + exchange.websocket_phase("tunnel_ready"); + // RFC 8441/9220 uses a successful 2xx response, never 101, and + // Sec-WebSocket-Accept is not part of the downstream handshake. + parts.status = StatusCode::OK; + parts.headers.remove("sec-websocket-accept"); + parts.headers.remove("content-length"); + parts.headers.remove("transfer-encoding"); + parts.extensions.insert(crate::compression::Skip); + parts + .extensions + .insert(websocket::H3Tunnel::new( + upstream, + exchange, + active_backend.origin.clone(), + lease, + stop, + )); + Response::from_parts(parts, Body::empty()) + } else if h2_bridge_tunnel { + let Some(upstream) = upgraded.take() else { + unreachable!("checked above") + }; + let Some(exchange) = exchange.clone() else { + return error( + StatusCode::INTERNAL_SERVER_ERROR, + "RustProxy: WebSocket exchange state unavailable\n", + ); + }; + let Some(downstream_body) = h2_downstream.take() else { + return error( + StatusCode::INTERNAL_SERVER_ERROR, + "RustProxy: HTTP/2 WebSocket request stream unavailable\n", + ); + }; + drop(body); + let stop_token = stop + .as_ref() + .map(|value| value.0.clone()) + .unwrap_or_else(|| state.shutdown.clone()); + let upstream = tokio::select! { + _ = stop_token.cancelled() => { + exchange.failed.store(true, Ordering::Relaxed); + exchange.websocket_error("upgrade_cancelled", "shutdown"); + state.metrics.websocket_errors.fetch_add(1, Ordering::Relaxed); + return error( + StatusCode::SERVICE_UNAVAILABLE, + "RustProxy: WebSocket upgrade cancelled\n", + ); + } + result = tokio::time::timeout(Duration::from_secs(10), upstream) => match result { + Ok(Ok(upstream)) => upstream, + _ => { + exchange.failed.store(true, Ordering::Relaxed); + exchange.websocket_error("upgrade_failed", "backend_on_upgrade"); + state.metrics.websocket_errors.fetch_add(1, Ordering::Relaxed); + return error( + StatusCode::BAD_GATEWAY, + "RustProxy: upstream WebSocket upgrade failed\n", + ); + } + } + }; + exchange.websocket_phase("tunnel_ready"); + parts.status = StatusCode::OK; + for name in [ + "connection", + "upgrade", + "sec-websocket-accept", + "content-length", + "transfer-encoding", + ] { + parts.headers.remove(name); + } + parts.extensions.insert(crate::compression::Skip); + let body = websocket::bridge_h2_to_h1( + state.clone(), + downstream_body, + upstream, + exchange, + active_backend.origin.clone(), + lease, + stop, + ); + Response::from_parts(parts, body) + } else if h2_direct_tunnel { + let Some(exchange) = exchange.clone() else { + return error( + StatusCode::INTERNAL_SERVER_ERROR, + "RustProxy: WebSocket exchange state unavailable\n", + ); + }; + let Some(session) = h2_direct_session.take() else { + return error( + StatusCode::INTERNAL_SERVER_ERROR, + "RustProxy: HTTP/2 WebSocket tunnel state unavailable\n", + ); + }; + parts.headers.remove("content-length"); + parts.headers.remove("transfer-encoding"); + parts.extensions.insert(crate::compression::Skip); + Response::from_parts( + parts, + websocket::wrap_h2_response_body(body, session, exchange), + ) + } else { + if let (Some(upstream), Some(downstream), Some(exchange)) = + (upgraded.take(), downstream, exchange.clone()) + { + parts + .headers + .insert("connection", HeaderValue::from_static("Upgrade")); + parts + .headers + .insert("upgrade", HeaderValue::from_static("websocket")); + websocket::spawn( + state.clone(), + downstream, + upstream, + exchange, + active_backend.origin.clone(), + lease, + stop, + ); + } + Response::from_parts(parts, Body::new(body)) + }; + response.extensions_mut().insert(ResponseRulesApplied); + response + .extensions_mut() + .insert(OriginTiming(upstream_started.elapsed())); + response + .extensions_mut() + .insert(OriginBackend(active_backend.id.clone())); + if !range_requested && handshake.is_none() { + if let Some(settings) = transform_settings.filter(|settings| settings.enabled) { + let transform_started = Instant::now(); + response = match crate::transform::apply( + response, + &public_suffix, + settings, + origin_no_transform, + ) + .await + { + Ok(response) => { + state.transform_metrics.record_response( + scope, + &response, + transform_started.elapsed(), + ); + response + } + Err(cause) => { + state + .transform_metrics + .record_error(scope, transform_started.elapsed()); + state.cache.record_reason(scope, &host, "transform_error"); + if let Some(exchange) = &exchange { + *exchange + .cache_reason + .lock() + .unwrap_or_else(|e| e.into_inner()) = "transform_error"; + } + tracing::debug!(error = %cause, "static response transformation failed"); + return error( + StatusCode::BAD_GATEWAY, + "RustProxy: upstream response body could not be transformed\n", + ); + } + }; + } + } + + // A backend 304 validates the stored representation. Merge the new + // metadata into the cached object and answer the client's original + // condition locally, just like Varnish's conditional delivery path. + if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent { + if let (Some(ticket), Some(cached)) = + (cache_ticket.as_ref(), cached_revalidation.as_ref()) + { + let merged = cached.merge_validation_headers(response.headers()); + let client_not_modified = + crate::cache::conditional_not_modified(&client_conditionals, &merged); + let mut revalidated = if let Some(mut hit) = state.cache.publish_revalidated( + ticket.clone(), + cached, + merged.clone(), + &origin_validation_headers, + cache_settings, + upstream_started.elapsed(), + ) { + if !client_not_modified { + if let crate::cache::RangeSelection::Single(range) = range_selection { + hit = hit.apply_range(range, if_range.as_ref()); + } + } + let resident_age = hit.resident_age_seconds; + let mut response = hit.response; + cache_settings.annotate_reason( + response.headers_mut(), + "HIT", + hit.hits, + hit.remaining_ttl, + "cache_revalidated", + ); + cache_settings + .annotate_transformed(response.headers_mut(), hit.transformed); + if hit.transformed { + state.transform_metrics.record_cache_hit(scope); + } + cache_settings.annotate_resident_age(response.headers_mut(), resident_age); + Some(response) + } else if state.cache.ticket_current(ticket) { + // The validation succeeded but the refreshed representation is no + // longer eligible for shared storage (for example new no-store). + // Serve it once, then discard the stored copy. + state.cache.discard(ticket); + let mut response = if !client_not_modified { + if let crate::cache::RangeSelection::Single(range) = range_selection { + cached.response_with_range(merged, range, if_range.as_ref()) + } else { + cached.response(merged) + } + } else { + cached.response(merged) + }; + cache_settings.annotate_reason( + response.headers_mut(), + "HIT", + 0, + 0, + "cache_revalidated_uncached", + ); + cache_settings + .annotate_transformed(response.headers_mut(), cached.transformed()); + if cached.transformed() { + state.transform_metrics.record_cache_hit(scope); + } + Some(response) + } else { + None + }; + if let Some(mut revalidated) = revalidated.take() { + if client_not_modified { + revalidated = crate::cache::make_not_modified(revalidated); + } + revalidated.extensions_mut().insert(ResponseRulesApplied); + revalidated + .extensions_mut() + .insert(OriginTiming(upstream_started.elapsed())); + revalidated + .extensions_mut() + .insert(OriginBackend(active_backend.id.clone())); + if let Some(exchange) = &exchange { + exchange.cache_status.store(1, Ordering::Relaxed); + *exchange + .cache_reason + .lock() + .unwrap_or_else(|e| e.into_inner()) = "cache_revalidated"; + } + state.cache.record(scope, &host, "HIT", "cache_revalidated"); + return revalidated; + } + } + } + // Both origin and transformed policies must be cacheable; hiding a + // Set-Cookie/private header never authorizes storage of private content. + let transformed_plan = + if ignore_static_cookie && crate::cache::varies_on(response.headers(), "cookie") { + None + } else { + crate::cache::plan( + response.status(), + response.headers(), + cache_settings, + upstream_started.elapsed(), + ) + }; + let outcome = if cache_ticket.is_some() { + "MISS" + } else { + "BYPASS" + }; + let reason = effective_reason + .or(origin_reason) + .or(revalidation_reason) + .or(freshness_revalidation_reason) + .unwrap_or_else(|| { + if cache_ticket.is_some() && transformed_plan.is_none() { + "response_headers_policy" + } else if cache_ticket.is_some() { + "cache_miss" + } else { + "configuration_changed" + } + }); + if outcome == "MISS" && !miss_recorded { + state.cache.record(scope, &host, "MISS", reason); + if let Some(exchange) = &exchange { + exchange.cache_status.store(2, Ordering::Relaxed); + } + } else if outcome == "MISS" && reason != "cache_miss" { + state.cache.record_reason(scope, &host, reason); + } + if let Some(exchange) = &exchange { + *exchange + .cache_reason + .lock() + .unwrap_or_else(|e| e.into_inner()) = reason; + } + if let (Some(ticket), Some(plan), Some(transformed)) = + (cache_ticket, origin_plan, transformed_plan) + { + let plan = if response + .extensions() + .get::() + .is_some() + { + plan.without_vary("accept-encoding") + } else { + plan + }; + let plan = plan.restrict(transformed); + let proactive_remaining_ttl = plan.remaining_ttl(); + let proactive_ticket = ticket.clone(); + response = state.cache.wrap_with_fill( + response, + ticket, + plan, + cache_settings, + fill_leader.take(), + ); + if cache_settings.proactive_refresh_enabled && proactive_remaining_ttl > 0 { + schedule_proactive_refresh( + CacheRefreshContext { + state: state.clone(), + snapshot: snapshot.clone(), + backend: active_backend.clone(), + cache_settings: cache_settings.clone(), + transform: transform_settings.cloned(), + uri: active_uri.clone(), + public_suffix: public_suffix.clone(), + host: host.clone(), + path: path.clone(), + scope: scope.to_owned(), + timeout: frontend + .and_then(|frontend| frontend.proxy_timeouts.response_header_timeout()) + .unwrap_or(state.header_timeout), + local: local.0, + tls, + base_headers: proactive_base_headers.clone(), + }, + proactive_ticket, + proactive_remaining_ttl, + ); + } + } + let transformed = response + .extensions() + .get::() + .is_some() + || response + .extensions() + .get::() + .is_some(); + cache_settings.annotate_reason(response.headers_mut(), outcome, 0, 0, reason); + cache_settings.annotate_transformed(response.headers_mut(), transformed); + response + } + Ok(Err(cause)) => { + if let Some(session) = h2_direct_session.take() { + session.cancellation().cancel(); + } + let elapsed = upstream_started.elapsed(); + let connect_failure = cause.is_connect(); + let detail = upstream_failure_detail(&cause, &active_backend, elapsed, connect_failure); + if let Some(exchange) = &exchange { + exchange.websocket_error("upstream_request_failed", "upstream_http_error"); + *exchange.upstream_error.lock().unwrap_or_else(|e| e.into_inner()) = Some(detail.clone()); + } + if let Some(lease) = &active_lease { + lease.error_detail( + upstream_error_class(&cause, connect_failure), + &detail, + final_attempt_elapsed, + ); + } + if connect_failure { + tracing::warn!( + backend = %active_backend.id, + origin = %active_backend.origin, + request_origin = %active_backend.request_origin, + tls_verify = active_backend.tls_verify, + elapsed_ms = elapsed.as_millis(), + error = %detail, + "upstream connection failed" + ); + } else { + tracing::debug!( + backend = %active_backend.id, + origin = %active_backend.origin, + elapsed_ms = elapsed.as_millis(), + error = %detail, + "upstream HTTP request failed" + ); + } + let status = if connect_failure { + StatusCode::SERVICE_UNAVAILABLE + } else { + StatusCode::BAD_GATEWAY + }; + let stale_class = classify_stale_error(&cause); + if cache_settings.allows_stale_error_class(stale_class) { + if let Some(stale) = serve_stale("cache_stale_if_error", elapsed, &active_backend.id) { + return stale; + } + } + let mut response = error( + status, + if status == StatusCode::SERVICE_UNAVAILABLE { + "RustProxy: upstream unavailable (DNS, TCP or TLS connection failed)\n" + } else { + "RustProxy: upstream did not provide a valid HTTP response\n" + }, + ); + response + .headers_mut() + .insert("cache-control", HeaderValue::from_static("no-store")); + if status == StatusCode::SERVICE_UNAVAILABLE { + response + .headers_mut() + .insert("retry-after", HeaderValue::from_static("1")); + } + response + } + Err(_) => { + if let Some(session) = h2_direct_session.take() { + session.cancellation().cancel(); + } + if let Some(exchange) = &exchange { + exchange.websocket_error("upstream_timeout", "header_timeout"); + *exchange.upstream_error.lock().unwrap_or_else(|e| e.into_inner()) = Some("response_header_timeout".into()); + } + if let Some(lease) = &active_lease { + lease.error_detail( + "response_header_timeout", + "response_header_timeout", + final_attempt_elapsed, + ); + } + if cache_settings + .allows_stale_error_class(crate::cache::StaleErrorClass::Timeout) + { + if let Some(stale) = + serve_stale("cache_stale_if_error", upstream_started.elapsed(), &active_backend.id) + { + return stale; + } + } + error( + StatusCode::GATEWAY_TIMEOUT, + "RustProxy: upstream response deadline exceeded\n", + ) + } + } +} +#[cfg(test)] +mod authority_tests { + use super::*; + + fn validation_policy() -> crate::config::ValidationPolicy { + crate::config::ValidationPolicy { + tls_addr: None, + cert_dir: None, + data_dir: None, + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + } + } + + #[test] + fn retry_is_limited_to_safe_methods() { + for method in [Method::GET, Method::HEAD, Method::OPTIONS] { + assert!(retry_safe_method(&method)); + } + for method in [Method::POST, Method::PUT, Method::PATCH, Method::DELETE] { + assert!(!retry_safe_method(&method)); + } + } + + #[test] + fn explicit_http2_backend_uses_http2_request_version() { + let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); + config.vhosts[0].backends[0].protocol = crate::config::UpstreamProtocol::Http2; + let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap(); + let route = snapshot.select("localhost", "/").unwrap(); + assert_eq!( + upstream_request_version(&route.backend, false), + Version::HTTP_2 + ); + } + #[tokio::test] + async fn h3_body_capable_methods_without_length_become_known_empty_for_h1() { + use bytes::Bytes; + for method in [Method::POST, Method::PUT, Method::PATCH, Method::DELETE] { + let stream = futures_util::stream::empty::>(); + let mut request = Request::builder() + .method(method.clone()) + .version(Version::HTTP_3) + .uri("https://example.test/api2/json/item") + .body(Body::from_stream(stream)) + .unwrap(); + assert_eq!(request.body().size_hint().exact(), None, "{method}"); + + normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11) + .await + .unwrap(); + + assert!(request.body().is_end_stream(), "{method}"); + assert_eq!(request.body().size_hint().exact(), Some(0), "{method}"); + } + } + + #[tokio::test] + async fn h3_delete_unknown_nonempty_body_is_replayed_not_buffered_away() { + use bytes::Bytes; + let stream = futures_util::stream::iter([Ok::<_, std::io::Error>( + Bytes::from_static(b"payload"), + )]); + let mut request = Request::builder() + .method(Method::DELETE) + .version(Version::HTTP_3) + .uri("https://example.test/api2/json/item") + .body(Body::from_stream(stream)) + .unwrap(); + + normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11) + .await + .unwrap(); + + let body = request.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(body, Bytes::from_static(b"payload")); + } + + #[tokio::test] + async fn h3_unknown_body_is_untouched_for_http2_upstream() { + use bytes::Bytes; + let stream = futures_util::stream::pending::>(); + let mut request = Request::builder() + .method(Method::DELETE) + .version(Version::HTTP_3) + .uri("https://example.test/api2/json/item") + .body(Body::from_stream(stream)) + .unwrap(); + + normalize_h3_h1_unknown_body(&mut request, Version::HTTP_2) + .await + .unwrap(); + + assert_eq!(request.body().size_hint().exact(), None); + } + + #[tokio::test] + async fn non_h3_unknown_body_is_untouched_for_http1_upstream() { + use bytes::Bytes; + for version in [Version::HTTP_11, Version::HTTP_2] { + let stream = futures_util::stream::pending::>(); + let mut request = Request::builder() + .method(Method::DELETE) + .version(version) + .uri("https://example.test/api2/json/item") + .body(Body::from_stream(stream)) + .unwrap(); + + normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11) + .await + .unwrap(); + + assert_eq!(request.body().size_hint().exact(), None); + } + } + + #[tokio::test] + async fn h3_declared_delete_body_is_not_preconsumed() { + use bytes::Bytes; + let stream = futures_util::stream::pending::>(); + let mut request = Request::builder() + .method(Method::DELETE) + .version(Version::HTTP_3) + .uri("https://example.test/api2/json/item") + .header(http::header::CONTENT_LENGTH, "7") + .body(Body::from_stream(stream)) + .unwrap(); + + normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11) + .await + .unwrap(); + + assert_eq!(request.headers()[http::header::CONTENT_LENGTH], "7"); + assert_eq!(request.body().size_hint().exact(), None); + } + + #[test] + fn generated_policy_and_gateway_errors_use_html_pages() { + for status in [ + StatusCode::FORBIDDEN, + StatusCode::TOO_MANY_REQUESTS, + StatusCode::SERVICE_UNAVAILABLE, + StatusCode::GATEWAY_TIMEOUT, + ] { + let response = error(status, "internal detail\n"); + assert_eq!(response.status(), status); + assert_eq!( + response.headers().get("content-type").unwrap(), + "text/html; charset=utf-8" + ); + assert_eq!(response.headers().get("cache-control").unwrap(), "no-store"); + } + let response = error(StatusCode::BAD_GATEWAY, "internal detail\n"); + assert_ne!( + response.headers().get("content-type").unwrap(), + "text/html; charset=utf-8" + ); + } + #[test] + fn locally_generated_pages_remove_cache_metadata() { + let mut response = error(StatusCode::BAD_GATEWAY, "internal detail\n"); + for (name, value) in [ + ("etag", "\"old\""), + ("x-cache", "HIT"), + ("x-cache-reason", "origin"), + ("cache-status", "Origin; hit"), + ] { + response.headers_mut().insert(name, value.parse().unwrap()); + } + enforce_local_page_no_cache(&mut response); + assert_eq!(response.headers().get("cache-control").unwrap(), "no-store"); + for name in ["etag", "x-cache", "x-cache-reason", "cache-status"] { + assert!(!response.headers().contains_key(name), "{name}"); + } + assert!(response.extensions().get::().is_some()); + } + #[test] + fn http2_uses_authority_and_rejects_conflicting_host() { + let mut request = Request::builder() + .version(Version::HTTP_2) + .uri("https://app.localhost/test") + .body(Body::empty()) + .unwrap(); + assert_eq!(effective_host(&request, true).unwrap(), "app.localhost"); + request + .headers_mut() + .insert(HOST, "different.localhost".parse().unwrap()); + assert!(effective_host(&request, true).is_err()); + } + #[test] + fn legacy_websocket_origin_matches_upstream_scheme_and_host() { + let origin = legacy_websocket_origin( + "https://ilo.internal:443", + &"10.0.0.5:443".parse().unwrap(), + ) + .unwrap(); + assert_eq!(origin, "https://10.0.0.5:443"); + assert!(legacy_websocket_origin("not-an-origin", &"10.0.0.5".parse().unwrap()).is_none()); + } + + #[test] + fn force_ssl_location_preserves_path_query_and_https_port() { + let uri: Uri = "/api/items?x=1&next=%2Fhome".parse().unwrap(); + assert_eq!( + force_ssl_location("app.example.test", &uri, 443).unwrap(), + "https://app.example.test/api/items?x=1&next=%2Fhome" + ); + assert_eq!( + force_ssl_location("app.example.test", &uri, 8443).unwrap(), + "https://app.example.test:8443/api/items?x=1&next=%2Fhome" + ); + } + #[test] + fn http1_absolute_form_and_h2_wrong_scheme_are_rejected() { + let request = Request::builder() + .uri("http://app.localhost/") + .header(HOST, "app.localhost") + .body(Body::empty()) + .unwrap(); + assert!(effective_host(&request, false).is_err()); + let request = Request::builder() + .version(Version::HTTP_2) + .uri("http://app.localhost/") + .body(Body::empty()) + .unwrap(); + assert!(effective_host(&request, true).is_err()); + } + #[derive(Debug)] + struct NestedDiagnosticError { + source: std::io::Error, + } + impl std::fmt::Display for NestedDiagnosticError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("connector wrapper") + } + } + impl std::error::Error for NestedDiagnosticError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.source) + } + } + #[test] + fn upstream_error_chain_keeps_nested_connector_cause() { + let error = NestedDiagnosticError { + source: std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + "connection refused by origin", + ), + }; + let detail = format_error_chain(&error); + assert!(detail.contains("connector wrapper")); + assert!(detail.contains("connection refused by origin")); + } + #[test] + fn upstream_error_detail_truncation_preserves_utf8_boundaries() { + let mut detail = "ą".repeat(MAX_UPSTREAM_ERROR_DETAIL); + truncate_utf8(&mut detail, MAX_UPSTREAM_ERROR_DETAIL); + assert!(detail.len() <= MAX_UPSTREAM_ERROR_DETAIL); + assert!(std::str::from_utf8(detail.as_bytes()).is_ok()); + } + + #[test] + fn backend_diagnostics_are_opt_in_and_strip_spoofed_values() { + let mut response = Response::new(Body::empty()); + response.headers_mut().insert("x-rust-proxy-backend", HeaderValue::from_static("spoofed")); + response.headers_mut().insert("x-rust-proxy-backend-time", HeaderValue::from_static("legacy-spoof")); + response.headers_mut().insert("x-rustproxy-backend-time", HeaderValue::from_static("999ms")); + apply_backend_diagnostic_values(&mut response, false, false); + assert!(response.headers().get("x-rust-proxy-backend").is_none()); + assert!(response.headers().get("x-rust-proxy-backend-time").is_none()); + assert!(response.headers().get("x-rustproxy-backend-time").is_none()); + + response.extensions_mut().insert(OriginBackend("origin-a".into())); + response.extensions_mut().insert(OriginTiming(Duration::from_millis(12))); + apply_backend_diagnostic_values(&mut response, true, true); + assert_eq!(response.headers().get("x-rust-proxy-backend").unwrap(), "origin-a"); + assert_eq!(response.headers().get("x-rustproxy-backend-time").unwrap(), "12.00ms"); + } +} diff --git a/src/proxy/websocket.rs b/src/proxy/websocket.rs new file mode 100644 index 0000000..50b2d87 --- /dev/null +++ b/src/proxy/websocket.rs @@ -0,0 +1,1439 @@ +//! WebSocket bootstrap validation and bounded byte-transparent tunnels. +//! HTTP/1.1 uses RFC 6455 Upgrade; HTTP/2 uses RFC 8441 Extended CONNECT; +//! HTTP/3 uses RFC 8441 + RFC 9220 Extended CONNECT. Extended CONNECT keeps +//! WebSocket frames on the request/response stream instead of taking over TCP. +//! No message aggregation, application-specific routing or payload logging. +use super::body::Exchange; +use crate::{ + runtime::App, + server::{ConnectionLease, ServerShutdown}, +}; +use anyhow::{ensure, Result}; +use axum::{body::Body, extract::Request}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use bytes::{Buf, Bytes}; +use h3::error::Code; +use http::{HeaderMap, Method, Version}; +use http_body::{Body as HttpBody, Frame, SizeHint}; +use http_body_util::BodyExt; +use hyper::upgrade::{OnUpgrade, Upgraded}; +use hyper_util::rt::TokioIo; +use rand::{rngs::OsRng, RngCore}; +use sha1::{Digest, Sha1}; +use serde::Serialize; +use std::{ + collections::BTreeMap, + future::Future, + io, + pin::Pin, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex, Weak, + }, + task::{Context, Poll}, + time::{Duration, Instant}, +}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; +use tokio_util::sync::CancellationToken; + +fn has_token(headers: &HeaderMap, name: &str, token: &str) -> bool { + headers + .get_all(name) + .iter() + .filter_map(|v| v.to_str().ok()) + .flat_map(|v| v.split(',')) + .any(|v| v.trim().eq_ignore_ascii_case(token)) +} +fn single<'a>(headers: &'a HeaderMap, name: &str) -> Result<&'a str> { + ensure!( + headers.get_all(name).iter().count() == 1, + "WebSocket header must occur once" + ); + Ok(headers + .get(name) + .ok_or_else(|| anyhow::anyhow!("missing WebSocket header"))? + .to_str()?) +} +pub struct Handshake { + pub accept: String, + upstream_key: Option, + protocols: Vec, + bootstrap: Bootstrap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Bootstrap { + Http1, + Http2, + Http3, +} + +impl Handshake { + pub fn h1(&self) -> bool { + self.bootstrap == Bootstrap::Http1 + } + pub fn h2(&self) -> bool { + self.bootstrap == Bootstrap::Http2 + } + pub fn h3(&self) -> bool { + self.bootstrap == Bootstrap::Http3 + } + + pub fn upstream_key(&self) -> Option<&str> { + self.upstream_key.as_deref() + } +} + +fn protocols(headers: &HeaderMap) -> Result> { + let protocols = headers + .get_all("sec-websocket-protocol") + .iter() + .map(|v| v.to_str()) + .collect::, _>>()? + .into_iter() + .flat_map(|v| v.split(',')) + .map(|v| v.trim().to_owned()) + .collect::>(); + ensure!( + protocols.len() <= 32 && protocols.iter().all(|v| !v.is_empty()), + "invalid WebSocket protocols" + ); + Ok(protocols) +} + +fn accept_for_key(key: &str) -> String { + let mut hasher = Sha1::new(); + hasher.update(key.as_bytes()); + hasher.update(b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); + STANDARD.encode(hasher.finalize()) +} + +pub fn validate(request: &Request) -> Result { + ensure!( + request.method() == Method::GET && request.version() == Version::HTTP_11, + "WebSocket requires HTTP/1.1 GET" + ); + ensure!( + single(request.headers(), "upgrade")?.eq_ignore_ascii_case("websocket") + && has_token(request.headers(), "connection", "upgrade"), + "invalid WebSocket upgrade" + ); + ensure!( + single(request.headers(), "sec-websocket-version")? == "13", + "WebSocket version must be 13" + ); + let key = single(request.headers(), "sec-websocket-key")?; + ensure!( + key.len() == 24 && STANDARD.decode(key)?.len() == 16, + "invalid WebSocket key" + ); + ensure!( + !request.headers().contains_key("transfer-encoding"), + "WebSocket handshake cannot contain a transfer body" + ); + ensure!( + request.headers().get_all("content-length").iter().count() <= 1 + && request + .headers() + .get("content-length") + .is_none_or(|v| v == "0"), + "WebSocket handshake cannot contain a body" + ); + for value in request.headers().get_all("connection").iter() { + for field in value.to_str()?.split(',') { + ensure!( + !field + .trim() + .to_ascii_lowercase() + .starts_with("sec-websocket-"), + "Connection must not nominate WebSocket handshake fields" + ); + } + } + Ok(Handshake { + accept: accept_for_key(key), + upstream_key: None, + protocols: protocols(request.headers())?, + bootstrap: Bootstrap::Http1, + }) +} + +/// Validate the RFC 8441 bootstrap used by RFC 9220 on HTTP/3. The +/// Sec-WebSocket-Key/Accept proof belongs only to the HTTP/1.1 hop, so a fresh +/// key is synthesized later for the backend Upgrade request. +pub fn validate_h3(request: &Request) -> Result { + ensure!( + request.method() == Method::CONNECT && request.version() == Version::HTTP_3, + "HTTP/3 WebSocket requires Extended CONNECT" + ); + ensure!( + request + .extensions() + .get::() + .is_some_and(|protocol| protocol.as_str() == "websocket"), + "Extended CONNECT protocol is not websocket" + ); + for name in [ + "connection", + "upgrade", + "sec-websocket-key", + "sec-websocket-accept", + "transfer-encoding", + "content-length", + ] { + ensure!( + !request.headers().contains_key(name), + "HTTP/1.1-only WebSocket field on HTTP/3" + ); + } + ensure!( + single(request.headers(), "sec-websocket-version")? == "13", + "WebSocket version must be 13" + ); + let mut nonce = [0u8; 16]; + OsRng.fill_bytes(&mut nonce); + let key = STANDARD.encode(nonce); + Ok(Handshake { + accept: accept_for_key(&key), + upstream_key: Some(key), + protocols: protocols(request.headers())?, + bootstrap: Bootstrap::Http3, + }) +} + +/// Validate RFC 8441 Extended CONNECT on HTTP/2. The downstream hop does not +/// use Sec-WebSocket-Key/Accept; a fresh RFC 6455 key is generated only when +/// RustProxy has to bridge the stream to an HTTP/1.1 backend. +pub fn validate_h2(request: &Request) -> Result { + ensure!( + request.method() == Method::CONNECT && request.version() == Version::HTTP_2, + "HTTP/2 WebSocket requires Extended CONNECT" + ); + ensure!( + request + .extensions() + .get::() + .is_some_and(|protocol| protocol.as_str() == "websocket"), + "Extended CONNECT protocol is not websocket" + ); + for name in [ + "connection", + "upgrade", + "sec-websocket-key", + "sec-websocket-accept", + "transfer-encoding", + "content-length", + ] { + ensure!( + !request.headers().contains_key(name), + "HTTP/1.1-only WebSocket field on HTTP/2" + ); + } + ensure!( + single(request.headers(), "sec-websocket-version")? == "13", + "WebSocket version must be 13" + ); + let mut nonce = [0u8; 16]; + OsRng.fill_bytes(&mut nonce); + let key = STANDARD.encode(nonce); + Ok(Handshake { + accept: accept_for_key(&key), + upstream_key: Some(key), + protocols: protocols(request.headers())?, + bootstrap: Bootstrap::Http2, + }) +} + +pub fn h2_protocol(request: &Request) -> Option<&str> { + request + .extensions() + .get::() + .map(|protocol| protocol.as_str()) +} + +pub fn h3_protocol(request: &Request) -> Option<&str> { + request + .extensions() + .get::() + .map(|protocol| protocol.as_str()) +} +pub fn validate_response( + headers: &HeaderMap, + expected: &Handshake, + legacy_support: bool, +) -> Result<()> { + if legacy_support { + // Some older embedded servers return a valid 101 + Sec-WebSocket-Accept + // while omitting Connection: Upgrade, duplicating Upgrade, or attaching + // zero/meaningless HTTP body-framing headers. The connection is dedicated + // to the upgraded tunnel and those framing headers are stripped before the + // downstream response, so keep the cryptographic accept proof strict while + // tolerating the legacy response syntax. + ensure!( + has_token(headers, "upgrade", "websocket"), + "invalid legacy upstream upgrade" + ); + if headers.contains_key("content-length") { + for value in headers.get_all("content-length").iter() { + ensure!( + value.to_str()?.trim() == "0", + "legacy 101 response declared a non-zero body" + ); + } + } + } else { + ensure!( + single(headers, "upgrade")?.eq_ignore_ascii_case("websocket") + && has_token(headers, "connection", "upgrade"), + "invalid upstream upgrade" + ); + ensure!( + !headers.contains_key("transfer-encoding") + && !headers.contains_key("content-length"), + "101 response must not include body framing" + ); + for value in headers.get_all("connection").iter() { + for field in value.to_str()?.split(',') { + ensure!( + !field + .trim() + .to_ascii_lowercase() + .starts_with("sec-websocket-"), + "upstream nominated a protected handshake field" + ); + } + } + } + ensure!( + single(headers, "sec-websocket-accept")? == expected.accept, + "upstream WebSocket accept mismatch" + ); + if headers.contains_key("sec-websocket-protocol") { + let selected = single(headers, "sec-websocket-protocol")?; + ensure!( + expected.protocols.iter().any(|v| v == selected), + "upstream chose an unoffered subprotocol" + ); + } + Ok(()) +} + +pub fn validate_h2_response(headers: &HeaderMap, expected: &Handshake) -> Result<()> { + for name in [ + "connection", + "upgrade", + "sec-websocket-key", + "sec-websocket-accept", + "transfer-encoding", + "content-length", + ] { + ensure!( + !headers.contains_key(name), + "HTTP/1.1-only WebSocket field on HTTP/2 response" + ); + } + if headers.contains_key("sec-websocket-protocol") { + let selected = single(headers, "sec-websocket-protocol")?; + ensure!( + expected.protocols.iter().any(|value| value == selected), + "upstream chose an unoffered subprotocol" + ); + } + Ok(()) +} +struct MeteredIo { + inner: T, + exchange: Arc, + session: Arc, + client_to_backend: bool, + started: Instant, + activity: Arc, +} + +fn record_bytes( + exchange: &Exchange, + bytes: u64, + client_to_backend: bool, + activity: &AtomicU64, + started: Instant, +) { + if bytes == 0 { + return; + } + exchange.backend_bytes(bytes, client_to_backend); + activity.store(started.elapsed().as_millis() as u64, Ordering::Relaxed); + if client_to_backend { + exchange + .metrics + .websocket_bytes_in + .fetch_add(bytes, Ordering::Relaxed); + exchange.request_bytes.fetch_add(bytes, Ordering::Relaxed); + } else { + exchange + .metrics + .websocket_bytes_out + .fetch_add(bytes, Ordering::Relaxed); + exchange.response_bytes.fetch_add(bytes, Ordering::Relaxed); + } +} + +impl AsyncRead for MeteredIo { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + let before = buf.filled().len(); + let result = Pin::new(&mut this.inner).poll_read(cx, buf); + if let Poll::Ready(Ok(())) = &result { + let bytes = (buf.filled().len() - before) as u64; + record_bytes( + &this.exchange, + bytes, + this.client_to_backend, + &this.activity, + this.started, + ); + this.session.record(bytes, this.client_to_backend); + } + result + } +} +impl AsyncWrite for MeteredIo { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bytes: &[u8], + ) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_write(cx, bytes) + } + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_flush(cx) + } + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_shutdown(cx) + } +} +#[derive(Debug, Clone, Copy)] +enum TunnelTransport { + H1, + H2, + H3, +} +impl TunnelTransport { + fn from_protocol(protocol: &str) -> Self { + if protocol.starts_with("h2-") { + Self::H2 + } else if protocol.starts_with("h3-") { + Self::H3 + } else { + Self::H1 + } + } + + fn increment(self, metrics: &crate::metrics::Metrics) { + match self { + Self::H1 => { + metrics.websocket_h1_total.fetch_add(1, Ordering::Relaxed); + metrics.websocket_h1_active.fetch_add(1, Ordering::Relaxed); + } + Self::H2 => { + metrics.websocket_h2_total.fetch_add(1, Ordering::Relaxed); + metrics.websocket_h2_active.fetch_add(1, Ordering::Relaxed); + } + Self::H3 => { + metrics.websocket_h3_total.fetch_add(1, Ordering::Relaxed); + metrics.websocket_h3_active.fetch_add(1, Ordering::Relaxed); + } + } + } + + fn decrement_active(self, metrics: &crate::metrics::Metrics) { + match self { + Self::H1 => { + metrics.websocket_h1_active.fetch_sub(1, Ordering::Relaxed); + } + Self::H2 => { + metrics.websocket_h2_active.fetch_sub(1, Ordering::Relaxed); + } + Self::H3 => { + metrics.websocket_h3_active.fetch_sub(1, Ordering::Relaxed); + } + } + } +} + +struct ActiveTunnel { + exchange: Arc, + started: Instant, + transport: TunnelTransport, + established: AtomicBool, +} +impl ActiveTunnel { + fn establish(&self) { + if self + .established + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + self.exchange.websocket_phase("tunnel_started"); + self.exchange + .metrics + .websocket_total + .fetch_add(1, Ordering::Relaxed); + self.exchange + .metrics + .websocket_active + .fetch_add(1, Ordering::Relaxed); + self.transport.increment(&self.exchange.metrics); + } + } + + fn is_established(&self) -> bool { + self.established.load(Ordering::Acquire) + } +} +impl Drop for ActiveTunnel { + fn drop(&mut self) { + if !self.is_established() { + return; + } + self.exchange + .metrics + .websocket_active + .fetch_sub(1, Ordering::Relaxed); + self.transport.decrement_active(&self.exchange.metrics); + self.exchange + .metrics + .websocket_duration_us + .fetch_add(self.started.elapsed().as_micros() as u64, Ordering::Relaxed); + self.exchange + .metrics + .websocket_closed + .fetch_add(1, Ordering::Relaxed); + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct TunnelInfo { + pub id: u64, + pub client: String, + pub host: String, + pub upstream: String, + pub protocol: String, + pub duration_ms: u64, + pub rx_bytes: u64, + pub tx_bytes: u64, +} + +#[derive(Default)] +pub struct TunnelRegistry { + entries: Mutex>>, +} + +pub struct TunnelSession { + registry: Weak, + id: u64, + client: String, + host: String, + upstream: String, + protocol: String, + started: Instant, + rx_bytes: AtomicU64, + tx_bytes: AtomicU64, + activity: AtomicU64, + cancel: CancellationToken, + _active: ActiveTunnel, + _lease: Option>, +} + +impl TunnelRegistry { + fn register_inner( + self: &Arc, + exchange: Arc, + upstream: String, + protocol: &str, + stop: CancellationToken, + lease: Option>, + established: bool, + ) -> Arc { + let started = Instant::now(); + let transport = TunnelTransport::from_protocol(protocol); + let client = { + exchange + .client_ip + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + .unwrap_or_else(|| "-".into()) + }; + let session = Arc::new(TunnelSession { + registry: Arc::downgrade(self), + id: exchange.id, + client, + host: exchange.host.clone(), + upstream, + protocol: protocol.to_owned(), + started, + rx_bytes: AtomicU64::new(0), + tx_bytes: AtomicU64::new(0), + activity: AtomicU64::new(0), + cancel: stop.child_token(), + _active: ActiveTunnel { + exchange, + started, + transport, + established: AtomicBool::new(false), + }, + _lease: lease, + }); + self.entries + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(session.id, Arc::downgrade(&session)); + if established { + session.establish(); + } + session + } + + pub fn register( + self: &Arc, + exchange: Arc, + upstream: String, + protocol: &str, + stop: CancellationToken, + lease: Option>, + ) -> Arc { + self.register_inner(exchange, upstream, protocol, stop, lease, true) + } + + pub fn register_pending( + self: &Arc, + exchange: Arc, + upstream: String, + protocol: &str, + stop: CancellationToken, + lease: Option>, + ) -> Arc { + self.register_inner(exchange, upstream, protocol, stop, lease, false) + } + + pub fn list(&self) -> Vec { + let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner()); + let mut result = Vec::with_capacity(entries.len()); + entries.retain(|_, weak| { + if let Some(session) = weak.upgrade() { + if session._active.is_established() { + result.push(session.info()); + } + true + } else { + false + } + }); + result + } + + pub fn kill(&self, id: u64) -> Option { + let session = self + .entries + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&id) + .and_then(Weak::upgrade)?; + let info = session.info(); + session.cancel.cancel(); + Some(info) + } +} + +impl TunnelSession { + pub fn establish(&self) { + self._active.establish(); + } + + fn info(&self) -> TunnelInfo { + TunnelInfo { + id: self.id, + client: self.client.clone(), + host: self.host.clone(), + upstream: self.upstream.clone(), + protocol: self.protocol.clone(), + duration_ms: self.started.elapsed().as_millis().min(u64::MAX as u128) as u64, + rx_bytes: self.rx_bytes.load(Ordering::Relaxed), + tx_bytes: self.tx_bytes.load(Ordering::Relaxed), + } + } + + fn record(&self, bytes: u64, client_to_backend: bool) { + if bytes == 0 { + return; + } + if client_to_backend { + self.rx_bytes.fetch_add(bytes, Ordering::Relaxed); + } else { + self.tx_bytes.fetch_add(bytes, Ordering::Relaxed); + } + self.activity.store( + self.started.elapsed().as_millis().min(u64::MAX as u128) as u64, + Ordering::Relaxed, + ); + } + + pub fn cancellation(&self) -> CancellationToken { + self.cancel.clone() + } + + fn inactive_for(&self) -> Duration { + self.started + .elapsed() + .saturating_sub(Duration::from_millis(self.activity.load(Ordering::Relaxed))) + } +} + +impl Drop for TunnelSession { + fn drop(&mut self) { + if let Some(registry) = self.registry.upgrade() { + let mut entries = registry.entries.lock().unwrap_or_else(|e| e.into_inner()); + if entries + .get(&self.id) + .and_then(Weak::upgrade) + .is_none_or(|session| std::ptr::eq(session.as_ref(), self)) + { + entries.remove(&self.id); + } + } + } +} + +fn record_session_bytes( + exchange: &Exchange, + session: &TunnelSession, + bytes: u64, + client_to_backend: bool, +) { + if bytes == 0 { + return; + } + exchange.backend_bytes(bytes, client_to_backend); + if client_to_backend { + exchange + .metrics + .websocket_bytes_in + .fetch_add(bytes, Ordering::Relaxed); + exchange.request_bytes.fetch_add(bytes, Ordering::Relaxed); + } else { + exchange + .metrics + .websocket_bytes_out + .fetch_add(bytes, Ordering::Relaxed); + exchange.response_bytes.fetch_add(bytes, Ordering::Relaxed); + } + session.record(bytes, client_to_backend); +} + +struct H2MeteredBody { + inner: Pin>, + session: Arc, + exchange: Arc, + client_to_backend: bool, + cancelled: Pin + Send>>, +} + +impl H2MeteredBody +where + B: HttpBody + Send + 'static, +{ + fn new( + inner: B, + session: Arc, + exchange: Arc, + client_to_backend: bool, + ) -> Self { + let cancelled = session.cancellation().cancelled_owned(); + Self { + inner: Box::pin(inner), + session, + exchange, + client_to_backend, + cancelled: Box::pin(cancelled), + } + } +} + +impl HttpBody for H2MeteredBody +where + B: HttpBody + Send + 'static, +{ + type Data = Bytes; + type Error = B::Error; + + fn poll_frame( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + if self.cancelled.as_mut().poll(cx).is_ready() { + return Poll::Ready(None); + } + match self.inner.as_mut().poll_frame(cx) { + Poll::Ready(Some(Ok(frame))) => { + if let Some(data) = frame.data_ref() { + record_session_bytes( + &self.exchange, + &self.session, + data.len() as u64, + self.client_to_backend, + ); + } + Poll::Ready(Some(Ok(frame))) + } + other => other, + } + } + + fn is_end_stream(&self) -> bool { + self.inner.as_ref().get_ref().is_end_stream() + } + + fn size_hint(&self) -> SizeHint { + self.inner.as_ref().get_ref().size_hint() + } +} + +pub fn wrap_h2_request_body( + body: Body, + session: Arc, + exchange: Arc, +) -> Body { + Body::new(H2MeteredBody::new(body, session, exchange, true)) +} + +pub fn wrap_h2_response_body( + body: hyper::body::Incoming, + session: Arc, + exchange: Arc, +) -> Body { + Body::new(H2MeteredBody::new(body, session, exchange, false)) +} + +pub fn spawn_h2_idle_watch(state: Arc, session: &Arc) { + let weak = Arc::downgrade(session); + let idle = state.websocket_idle_timeout; + let tracker = state.tunnels.clone(); + tracker.spawn(async move { + loop { + tokio::time::sleep(idle).await; + let Some(session) = weak.upgrade() else { + return; + }; + if session.inactive_for() >= idle { + session._active.exchange.failed.store(true, Ordering::Relaxed); + session + ._active + .exchange + .websocket_error("tunnel_error", "idle_timeout"); + session + ._active + .exchange + .metrics + .websocket_errors + .fetch_add(1, Ordering::Relaxed); + session.cancel.cancel(); + return; + } + } + }); +} + +/// Bridge an HTTP/2 Extended CONNECT stream to an already upgraded HTTP/1.1 +/// backend WebSocket. DATA bytes remain byte-transparent in both directions. +pub fn bridge_h2_to_h1( + state: Arc, + mut downstream: Body, + upstream: Upgraded, + exchange: Arc, + upstream_label: String, + lease: Option>, + stop: Option, +) -> Body { + let stop = stop.map(|value| value.0).unwrap_or_else(|| state.shutdown.clone()); + let session = state.tunnel_registry.register( + exchange.clone(), + upstream_label, + "h2-rfc8441-h1-upstream", + stop, + lease, + ); + spawn_h2_idle_watch(state.clone(), &session); + let upstream = TokioIo::new(upstream); + let (upstream_read, mut upstream_write) = tokio::io::split(upstream); + + let request_session = session.clone(); + let request_exchange = exchange.clone(); + let tracker = state.tunnels.clone(); + tracker.spawn(async move { + let cancel = request_session.cancellation(); + loop { + let frame = tokio::select! { + _ = cancel.cancelled() => return, + frame = downstream.frame() => frame, + }; + match frame { + Some(Ok(frame)) => { + if let Ok(data) = frame.into_data() { + if !data.is_empty() { + if upstream_write.write_all(&data).await.is_err() { + return; + } + record_session_bytes( + &request_exchange, + &request_session, + data.len() as u64, + true, + ); + } + } + } + Some(Err(_)) => return, + None => { + let _ = upstream_write.shutdown().await; + return; + } + } + } + }); + + let stream = futures_util::stream::unfold( + (upstream_read, session, exchange), + |(mut upstream_read, session, exchange)| async move { + let mut buffer = vec![0u8; 16 * 1024]; + let cancel = session.cancellation(); + let read = tokio::select! { + _ = cancel.cancelled() => return None, + read = upstream_read.read(&mut buffer) => read, + }; + match read { + Ok(0) => None, + Ok(read) => { + buffer.truncate(read); + record_session_bytes(&exchange, &session, read as u64, false); + Some((Ok::(Bytes::from(buffer)), (upstream_read, session, exchange))) + } + Err(error) => Some((Err(error), (upstream_read, session, exchange))), + } + }, + ); + Body::from_stream(stream) +} +fn benign_tunnel_close(error: &io::Error) -> bool { + matches!( + error.kind(), + io::ErrorKind::UnexpectedEof + | io::ErrorKind::ConnectionReset + | io::ErrorKind::ConnectionAborted + | io::ErrorKind::BrokenPipe + | io::ErrorKind::NotConnected + ) +} + +struct H3TunnelState { + upstream: Upgraded, + exchange: Arc, + upstream_label: String, + lease: Option>, + stop: Option, +} + +/// Response extension consumed by the HTTP/3 transport after it has sent the +/// successful 200 response. The upstream connection is already upgraded before +/// this extension is created, so downstream success never races backend takeover. +#[derive(Clone)] +pub struct H3Tunnel(Arc>>); + +impl H3Tunnel { + pub fn new( + upstream: Upgraded, + exchange: Arc, + upstream_label: String, + lease: Option>, + stop: Option, + ) -> Self { + Self(Arc::new(Mutex::new(Some(H3TunnelState { + upstream, + exchange, + upstream_label, + lease, + stop, + })))) + } + + fn take(&self) -> Option { + self.0.lock().unwrap_or_else(|e| e.into_inner()).take() + } +} + +fn h3_io_error(error: impl std::fmt::Display) -> io::Error { + io::Error::other(error.to_string()) +} + +async fn h3_client_to_backend( + receive: &mut h3::server::RequestStream, + upstream: &mut W, + exchange: &Exchange, + activity: &AtomicU64, + started: Instant, + session: &TunnelSession, +) -> io::Result<()> +where + W: AsyncWrite + Unpin, +{ + while let Some(mut data) = receive.recv_data().await.map_err(h3_io_error)? { + let bytes = data.copy_to_bytes(data.remaining()); + if !bytes.is_empty() { + upstream.write_all(&bytes).await?; + record_bytes(exchange, bytes.len() as u64, true, activity, started); + session.record(bytes.len() as u64, true); + } + } + if receive + .recv_trailers() + .await + .map_err(h3_io_error)? + .is_some() + { + return Err(io::Error::other( + "WebSocket Extended CONNECT must not end with HTTP trailers", + )); + } + upstream.shutdown().await +} + +async fn backend_to_h3( + upstream: &mut R, + send: &mut h3::server::RequestStream, Bytes>, + exchange: &Exchange, + activity: &AtomicU64, + started: Instant, + session: &TunnelSession, +) -> io::Result<()> +where + R: AsyncRead + Unpin, +{ + let mut buffer = [0u8; 16 * 1024]; + loop { + let read = upstream.read(&mut buffer).await?; + if read == 0 { + send.finish().await.map_err(h3_io_error)?; + return Ok(()); + } + send.send_data(Bytes::copy_from_slice(&buffer[..read])) + .await + .map_err(h3_io_error)?; + record_bytes(exchange, read as u64, false, activity, started); + session.record(read as u64, false); + } +} + +/// Bridge one RFC 9220 HTTP/3 stream to the already validated HTTP/1.1 +/// WebSocket upgrade at the selected backend. DATA payload bytes are copied +/// unchanged; FIN remains a half-close and abnormal termination maps to +/// H3_REQUEST_CANCELLED. +pub async fn run_h3( + state: Arc, + send: &mut h3::server::RequestStream, Bytes>, + receive: &mut h3::server::RequestStream, + tunnel: H3Tunnel, +) -> io::Result<()> { + let Some(H3TunnelState { + upstream, + exchange, + upstream_label, + lease, + stop, + }) = tunnel.take() + else { + send.stop_stream(Code::H3_REQUEST_CANCELLED); + receive.stop_sending(Code::H3_REQUEST_CANCELLED); + return Err(io::Error::other("HTTP/3 WebSocket tunnel already consumed")); + }; + let stop = stop.map(|v| v.0).unwrap_or_else(|| state.shutdown.clone()); + let started = Instant::now(); + let activity = Arc::new(AtomicU64::new(0)); + let session = state.tunnel_registry.register( + exchange.clone(), + upstream_label, + "h3-rfc9220", + stop, + lease, + ); + let stop = session.cancellation(); + let upstream = TokioIo::new(upstream); + let (mut upstream_read, mut upstream_write) = tokio::io::split(upstream); + let result = { + let copying = async { + tokio::try_join!( + h3_client_to_backend(receive, &mut upstream_write, &exchange, &activity, started, &session), + backend_to_h3(&mut upstream_read, send, &exchange, &activity, started, &session) + )?; + Ok::<(), io::Error>(()) + }; + tokio::pin!(copying); + let mut remaining = state.websocket_idle_timeout; + loop { + tokio::select! { + result = &mut copying => break result, + _ = stop.cancelled() => break Err(io::Error::new(io::ErrorKind::ConnectionAborted, "WebSocket tunnel shutdown")), + _ = tokio::time::sleep(remaining) => { + let inactive = started.elapsed().saturating_sub(Duration::from_millis(activity.load(Ordering::Relaxed))); + if inactive >= state.websocket_idle_timeout { + break Err(io::Error::new(io::ErrorKind::TimedOut, "WebSocket idle timeout")); + } + remaining = state.websocket_idle_timeout - inactive; + } + } + } + }; + if let Err(error) = &result { + send.stop_stream(Code::H3_REQUEST_CANCELLED); + receive.stop_sending(Code::H3_REQUEST_CANCELLED); + exchange.h3_reset("H3_REQUEST_CANCELLED"); + if !benign_tunnel_close(error) { + exchange.failed.store(true, Ordering::Relaxed); + exchange.websocket_error("tunnel_error", "h3_bridge_error"); + state + .metrics + .websocket_errors + .fetch_add(1, Ordering::Relaxed); + } else { + exchange.websocket_phase("tunnel_closed"); + } + } else { + exchange.websocket_phase("tunnel_closed"); + } + result +} + +#[allow(clippy::too_many_arguments)] +pub fn spawn( + state: Arc, + downstream: OnUpgrade, + upstream: OnUpgrade, + exchange: Arc, + upstream_label: String, + lease: Option>, + stop: Option, +) { + let tracker = state.tunnels.clone(); + tracker.spawn(async move { + exchange.websocket_phase("upgrade_wait"); + let stop = stop.map(|v| v.0).unwrap_or_else(|| state.shutdown.clone()); + let upgraded = tokio::select! { + _ = stop.cancelled() => return, + result = tokio::time::timeout(Duration::from_secs(10), async { + tokio::try_join!(downstream, upstream) + }) => result, + }; + let (downstream, upstream) = match upgraded { + Ok(Ok(streams)) => streams, + _ => { + exchange.failed.store(true, Ordering::Relaxed); + exchange.websocket_error("upgrade_failed", "on_upgrade"); + state + .metrics + .websocket_errors + .fetch_add(1, Ordering::Relaxed); + return; + } + }; + let started = Instant::now(); + let activity = Arc::new(AtomicU64::new(0)); + let session = state.tunnel_registry.register( + exchange.clone(), + upstream_label, + "h1-upgrade", + stop, + lease, + ); + let stop = session.cancellation(); + let mut downstream = MeteredIo { + inner: TokioIo::new(downstream), + exchange: exchange.clone(), + session: session.clone(), + client_to_backend: true, + started, + activity: activity.clone(), + }; + let mut upstream = MeteredIo { + inner: TokioIo::new(upstream), + exchange: exchange.clone(), + session: session.clone(), + client_to_backend: false, + started, + activity: activity.clone(), + }; + if let Err(error) = tunnel( + &mut downstream, + &mut upstream, + stop, + activity, + started, + state.websocket_idle_timeout, + ) + .await + { + // Browsers and upstreams commonly close upgraded TCP streams without + // a WebSocket close frame. Reset/EOF/broken-pipe are normal tunnel + // termination signals, not malformed HTTP requests or proxy errors. + if !benign_tunnel_close(&error) { + exchange.failed.store(true, Ordering::Relaxed); + exchange.websocket_error("tunnel_error", "bridge_error"); + state + .metrics + .websocket_errors + .fetch_add(1, Ordering::Relaxed); + } else { + exchange.websocket_phase("tunnel_closed"); + } + } else { + exchange.websocket_phase("tunnel_closed"); + } + }); +} +async fn tunnel( + a: &mut A, + b: &mut B, + stop: CancellationToken, + activity: Arc, + started: Instant, + idle: Duration, +) -> io::Result<()> +where + A: AsyncRead + AsyncWrite + Unpin, + B: AsyncRead + AsyncWrite + Unpin, +{ + // Tokio uses fixed-size buffers in both directions. Writes apply backpressure. + let copying = tokio::io::copy_bidirectional(a, b); + tokio::pin!(copying); + let mut remaining = idle; + loop { + tokio::select! { + result = &mut copying => return result.map(|_| ()), + _ = stop.cancelled() => return Ok(()), + _ = tokio::time::sleep(remaining) => { + let inactive = started.elapsed().saturating_sub(Duration::from_millis(activity.load(Ordering::Relaxed))); + if inactive >= idle { return Err(io::Error::new(io::ErrorKind::TimedOut, "WebSocket idle timeout")); } + remaining = idle - inactive; + }, + } + } +} +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + fn request() -> Request { + Request::builder() + .method("GET") + .version(Version::HTTP_11) + .header("upgrade", "websocket") + .header("connection", "Upgrade") + .header("sec-websocket-version", "13") + .header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==") + .body(Body::empty()) + .unwrap() + } + fn h3_request() -> Request { + let mut request = Request::builder() + .method(Method::CONNECT) + .version(Version::HTTP_3) + .uri("https://example.test/socket") + .header("sec-websocket-version", "13") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert("websocket".parse::().unwrap()); + request + } + fn h2_request() -> Request { + let mut request = Request::builder() + .method(Method::CONNECT) + .version(Version::HTTP_2) + .uri("https://example.test/socket") + .header("sec-websocket-version", "13") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(hyper::ext::Protocol::from_static("websocket")); + request + } + #[test] + fn rfc_handshake_vector() { + let handshake = validate(&request()).unwrap(); + assert_eq!(handshake.accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="); + } + #[test] + fn rfc9220_bootstrap_uses_extended_connect_without_h1_proof_headers() { + let request = h3_request(); + let handshake = validate_h3(&request).unwrap(); + assert!(handshake.h3()); + let key = handshake.upstream_key().expect("backend nonce"); + assert_eq!(STANDARD.decode(key).unwrap().len(), 16); + assert!(!request.headers().contains_key("connection")); + assert!(!request.headers().contains_key("upgrade")); + assert!(!request.headers().contains_key("sec-websocket-key")); + assert!(!request.headers().contains_key("sec-websocket-accept")); + + let mut backend = HeaderMap::new(); + backend.insert("connection", "Upgrade".parse().unwrap()); + backend.insert("upgrade", "websocket".parse().unwrap()); + backend.insert("sec-websocket-accept", accept_for_key(key).parse().unwrap()); + assert!(validate_response(&backend, &handshake, false).is_ok()); + } + #[test] + fn rfc8441_h2_bootstrap_uses_extended_connect_without_h1_proof_headers() { + let request = h2_request(); + let handshake = validate_h2(&request).unwrap(); + assert!(handshake.h2()); + assert!(!handshake.h1()); + let key = handshake.upstream_key().expect("HTTP/1 bridge nonce"); + assert_eq!(STANDARD.decode(key).unwrap().len(), 16); + assert!(!request.headers().contains_key("connection")); + assert!(!request.headers().contains_key("upgrade")); + assert!(!request.headers().contains_key("sec-websocket-key")); + + let mut h2_response = HeaderMap::new(); + h2_response.insert("sec-websocket-protocol", "chat".parse().unwrap()); + assert!(validate_h2_response(&h2_response, &handshake).is_err()); + } + #[test] + fn rfc8441_h2_response_rejects_h1_framing_fields() { + let handshake = validate_h2(&h2_request()).unwrap(); + for (name, value) in [("connection", "close"), ("upgrade", "websocket"), ("content-length", "0")] { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::HeaderName::from_bytes(name.as_bytes()).unwrap(), + value.parse().unwrap(), + ); + assert!(validate_h2_response(&headers, &handshake).is_err(), "accepted forbidden response {name}"); + } + } + + #[test] + fn tunnel_transport_metrics_classify_frontend_protocol() { + assert!(matches!(TunnelTransport::from_protocol("h1-upgrade"), TunnelTransport::H1)); + assert!(matches!(TunnelTransport::from_protocol("h2-rfc8441-h1-upstream"), TunnelTransport::H2)); + assert!(matches!(TunnelTransport::from_protocol("h2-rfc8441-h2-upstream"), TunnelTransport::H2)); + assert!(matches!(TunnelTransport::from_protocol("h3-rfc9220"), TunnelTransport::H3)); + } + + #[test] + fn rfc8441_h2_rejects_h1_only_fields_and_wrong_protocol() { + for (name, value) in [ + ("connection", "Upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ("sec-websocket-accept", "ignored"), + ("content-length", "0"), + ("transfer-encoding", "chunked"), + ] { + let mut request = h2_request(); + request.headers_mut().insert( + http::header::HeaderName::from_bytes(name.as_bytes()).unwrap(), + value.parse().unwrap(), + ); + assert!(validate_h2(&request).is_err(), "accepted forbidden {name}"); + } + let mut request = h2_request(); + request + .extensions_mut() + .insert(hyper::ext::Protocol::from_static("connect-udp")); + assert!(validate_h2(&request).is_err()); + } + #[test] + fn rfc9220_rejects_h1_only_fields_and_non_websocket_protocols() { + for (name, value) in [ + ("connection", "Upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ("sec-websocket-accept", "ignored"), + ("content-length", "0"), + ("transfer-encoding", "chunked"), + ] { + let mut request = h3_request(); + request.headers_mut().insert( + http::header::HeaderName::from_bytes(name.as_bytes()).unwrap(), + value.parse().unwrap(), + ); + assert!(validate_h3(&request).is_err(), "accepted forbidden {name}"); + } + let mut request = h3_request(); + request + .extensions_mut() + .insert("connect-udp".parse::().unwrap()); + assert!(validate_h3(&request).is_err()); + } + #[test] + fn malformed_or_body_bearing_upgrade_fails() { + let mut req = request(); + req.headers_mut() + .insert("content-length", "1".parse().unwrap()); + assert!(validate(&req).is_err()); + let mut req = request(); + req.headers_mut() + .insert("sec-websocket-key", "invalid".parse().unwrap()); + assert!(validate(&req).is_err()); + let mut req = request(); + *req.version_mut() = Version::HTTP_2; + assert!(validate(&req).is_err()); + } + #[test] + fn legacy_upstream_response_accepts_old_101_syntax_but_keeps_accept_proof_strict() { + let expected = validate(&request()).unwrap(); + let mut headers = HeaderMap::new(); + headers.append("upgrade", "websocket".parse().unwrap()); + headers.append("upgrade", "WebSocket".parse().unwrap()); + headers.insert("content-length", "0".parse().unwrap()); + headers.insert("transfer-encoding", "chunked".parse().unwrap()); + headers.insert("sec-websocket-accept", expected.accept.parse().unwrap()); + assert!(validate_response(&headers, &expected, false).is_err()); + assert!(validate_response(&headers, &expected, true).is_ok()); + + headers.insert("content-length", "1".parse().unwrap()); + assert!(validate_response(&headers, &expected, true).is_err()); + headers.insert("content-length", "0".parse().unwrap()); + headers.insert("sec-websocket-accept", "wrong".parse().unwrap()); + assert!(validate_response(&headers, &expected, true).is_err()); + } + + #[test] + fn normal_tcp_close_is_not_a_websocket_proxy_error() { + for kind in [ + io::ErrorKind::UnexpectedEof, + io::ErrorKind::ConnectionReset, + io::ErrorKind::ConnectionAborted, + io::ErrorKind::BrokenPipe, + io::ErrorKind::NotConnected, + ] { + assert!(benign_tunnel_close(&io::Error::new(kind, "closed"))); + } + assert!(!benign_tunnel_close(&io::Error::new( + io::ErrorKind::TimedOut, + "idle timeout" + ))); + } + #[test] + fn upstream_cannot_select_unoffered_protocol() { + let expected = validate(&request()).unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("connection", "Upgrade".parse().unwrap()); + headers.insert("upgrade", "websocket".parse().unwrap()); + headers.insert("sec-websocket-accept", expected.accept.parse().unwrap()); + assert!(validate_response(&headers, &expected, false).is_ok()); + headers.insert("sec-websocket-protocol", "unoffered".parse().unwrap()); + assert!(validate_response(&headers, &expected, false).is_err()); + } +} diff --git a/src/rate_limit.rs b/src/rate_limit.rs new file mode 100644 index 0000000..a6bb893 --- /dev/null +++ b/src/rate_limit.rs @@ -0,0 +1,326 @@ +//! Per-vhost request throttling and country access policy. +//! The limiter is deliberately bounded and uses fixed windows: predictable, +//! cheap and sufficient for an edge safety control without an external store. +use anyhow::{ensure, Result}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{HashMap, HashSet}, + net::IpAddr, + sync::{Mutex, MutexGuard}, + time::{Duration, Instant}, +}; + +const MAX_BUCKETS: usize = 65_536; +const MAX_COUNTRY_RULES: usize = 64; + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(|e| e.into_inner()) +} +fn default_requests() -> u32 { + 120 +} +fn default_window() -> u64 { + 60 +} +fn default_burst() -> u32 { + 30 +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CountryMode { + #[default] + Off, + Block, + Allow, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct CountryAccess { + pub mode: CountryMode, + pub countries: Vec, +} +impl Default for CountryAccess { + fn default() -> Self { + Self { + mode: CountryMode::Off, + countries: Vec::new(), + } + } +} +impl CountryAccess { + pub fn validate(&mut self) -> Result<()> { + ensure!( + self.countries.len() <= 128, + "country access list allows at most 128 countries" + ); + let mut countries = HashSet::new(); + for country in &mut self.countries { + *country = normalize_country(country)?; + ensure!( + countries.insert(country.clone()), + "duplicate country access rule" + ); + } + ensure!( + self.mode == CountryMode::Off || !self.countries.is_empty(), + "country allow/block mode requires at least one country" + ); + Ok(()) + } + pub fn allows(&self, country: Option<&str>) -> bool { + match self.mode { + CountryMode::Off => true, + CountryMode::Block => country.is_none_or(|code| { + !self + .countries + .iter() + .any(|item| item.eq_ignore_ascii_case(code)) + }), + CountryMode::Allow => country.is_some_and(|code| { + self.countries + .iter() + .any(|item| item.eq_ignore_ascii_case(code)) + }), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct CountryLimit { + pub country: String, + pub requests: u32, + pub window_seconds: u64, + pub burst: u32, +} +impl Default for CountryLimit { + fn default() -> Self { + Self { + country: String::new(), + requests: default_requests(), + window_seconds: default_window(), + burst: default_burst(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + pub enabled: bool, + pub requests: u32, + pub window_seconds: u64, + pub burst: u32, + pub country_access: CountryAccess, + pub country_limits: Vec, +} +impl Default for Settings { + fn default() -> Self { + Self { + enabled: false, + requests: default_requests(), + window_seconds: default_window(), + burst: default_burst(), + country_access: CountryAccess::default(), + country_limits: Vec::new(), + } + } +} + +fn normalize_country(value: &str) -> Result { + let value = value.trim().to_ascii_uppercase(); + ensure!( + value.len() == 2 && value.bytes().all(|b| b.is_ascii_uppercase()), + "country must use a two-letter ISO 3166-1 alpha-2 code" + ); + Ok(value) +} +fn validate_limit(requests: u32, window_seconds: u64, burst: u32) -> Result<()> { + ensure!( + (1..=1_000_000).contains(&requests), + "rate limit requests must be 1..1000000" + ); + ensure!( + (1..=3600).contains(&window_seconds), + "rate limit window_seconds must be 1..3600" + ); + ensure!(burst <= 1_000_000, "rate limit burst must be 0..1000000"); + ensure!( + requests.saturating_add(burst) <= 2_000_000, + "rate limit requests + burst is too large" + ); + Ok(()) +} +impl Settings { + pub fn validate(&mut self) -> Result<()> { + validate_limit(self.requests, self.window_seconds, self.burst)?; + self.country_access.validate()?; + ensure!( + self.country_limits.len() <= MAX_COUNTRY_RULES, + "at most 64 country rate-limit overrides" + ); + let mut overrides = HashSet::new(); + for rule in &mut self.country_limits { + rule.country = normalize_country(&rule.country)?; + ensure!( + overrides.insert(rule.country.clone()), + "duplicate country rate-limit override" + ); + validate_limit(rule.requests, rule.window_seconds, rule.burst)?; + } + Ok(()) + } + pub fn needs_geoip(&self) -> bool { + self.country_access.mode != CountryMode::Off || !self.country_limits.is_empty() + } + pub fn country_allowed(&self, country: Option<&str>) -> bool { + self.country_access.allows(country) + } + fn effective(&self, country: Option<&str>) -> Option { + if let Some(country) = country { + if let Some(rule) = self + .country_limits + .iter() + .find(|rule| rule.country.eq_ignore_ascii_case(country)) + { + return Some(Limit { + requests: rule.requests, + window_seconds: rule.window_seconds, + burst: rule.burst, + }); + } + } + self.enabled.then_some(Limit { + requests: self.requests, + window_seconds: self.window_seconds, + burst: self.burst, + }) + } +} + +#[derive(Debug, Clone, Copy)] +struct Limit { + requests: u32, + window_seconds: u64, + burst: u32, +} +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct Key { + revision: u64, + vhost: String, + client: IpAddr, +} +#[derive(Debug)] +struct Bucket { + started: Instant, + count: u32, + window_seconds: u64, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Limited { + pub retry_after_seconds: u64, +} +#[derive(Debug, Default)] +pub struct Limiter { + buckets: Mutex>, +} +impl Limiter { + pub fn new() -> Self { + Self::default() + } + pub fn reset(&self) { + lock(&self.buckets).clear(); + } + pub fn check( + &self, + revision: u64, + vhost: &str, + client: IpAddr, + country: Option<&str>, + settings: &Settings, + ) -> std::result::Result<(), Limited> { + let Some(limit) = settings.effective(country) else { + return Ok(()); + }; + let key = Key { + revision, + vhost: vhost.to_owned(), + client, + }; + let mut buckets = lock(&self.buckets); + if !buckets.contains_key(&key) && buckets.len() >= MAX_BUCKETS { + buckets.retain(|key, bucket| { + key.revision == revision + && bucket.started.elapsed() + < Duration::from_secs(bucket.window_seconds.saturating_mul(2).max(2)) + }); + if buckets.len() >= MAX_BUCKETS { + return Err(Limited { + retry_after_seconds: 1, + }); + } + } + let now = Instant::now(); + let bucket = buckets.entry(key).or_insert(Bucket { + started: now, + count: 0, + window_seconds: limit.window_seconds, + }); + if bucket.window_seconds != limit.window_seconds + || now.duration_since(bucket.started) >= Duration::from_secs(limit.window_seconds) + { + bucket.started = now; + bucket.count = 0; + bucket.window_seconds = limit.window_seconds; + } + let capacity = limit.requests.saturating_add(limit.burst); + if bucket.count >= capacity { + let elapsed = now.duration_since(bucket.started).as_secs(); + return Err(Limited { + retry_after_seconds: limit.window_seconds.saturating_sub(elapsed).max(1), + }); + } + bucket.count = bucket.count.saturating_add(1); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn access_modes_are_fail_closed_for_allow_lists() { + let mut settings = Settings::default(); + settings.country_access.mode = CountryMode::Allow; + settings.country_access.countries = vec!["pl".into()]; + settings.validate().unwrap(); + assert!(settings.country_allowed(Some("PL"))); + assert!(!settings.country_allowed(Some("DE"))); + assert!(!settings.country_allowed(None)); + settings.country_access.mode = CountryMode::Block; + assert!(!settings.country_allowed(Some("PL"))); + assert!(settings.country_allowed(None)); + } + #[test] + fn country_limit_overrides_base_limit() { + let mut settings = Settings { + enabled: true, + requests: 10, + burst: 0, + country_limits: vec![CountryLimit { + country: "PL".into(), + requests: 1, + window_seconds: 60, + burst: 0, + }], + ..Default::default() + }; + settings.validate().unwrap(); + let limiter = Limiter::new(); + let ip = "192.0.2.1".parse().unwrap(); + assert!(limiter.check(1, "site", ip, Some("PL"), &settings).is_ok()); + assert!(limiter.check(1, "site", ip, Some("PL"), &settings).is_err()); + } +} diff --git a/src/redirect.rs b/src/redirect.rs new file mode 100644 index 0000000..0dc3115 --- /dev/null +++ b/src/redirect.rs @@ -0,0 +1,593 @@ +//! Declarative HTTP redirects with listener-aware hostname ownership. +//! Redirects never open an upstream connection and are resolved before proxy routes. +use anyhow::{ensure, Context, Result}; +use http::{header::HeaderValue, uri::Authority, StatusCode, Uri}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use url::{Position, Url}; + +fn yes() -> bool { + true +} +fn root() -> String { + "/".into() +} +fn default_status() -> u16 { + 308 +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceScheme { + #[default] + Any, + Http, + Https, +} +impl SourceScheme { + fn matches(self, tls: bool) -> bool { + matches!(self, Self::Any) + || matches!((self, tls), (Self::Http, false) | (Self::Https, true)) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MatchType { + #[default] + Prefix, + Exact, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TargetType { + #[default] + Url, + Host, + Path, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TargetScheme { + #[default] + Preserve, + Http, + Https, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PathMode { + #[default] + Append, + StripPrefix, + Replace, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum QueryMode { + #[default] + Preserve, + Drop, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Rule { + pub id: String, + pub enabled: bool, + pub source_scheme: SourceScheme, + pub match_type: MatchType, + pub path_prefix: String, + pub target_type: TargetType, + /// Absolute http(s) URL, authority (`example.com[:port]`) or absolute path, + /// according to `target_type`. + pub target: String, + /// Used only by `target_type=host`. + pub target_scheme: TargetScheme, + pub path_mode: PathMode, + pub query_mode: QueryMode, + pub status_code: u16, + /// 0 emits `Cache-Control: no-store`; otherwise public max-age, up to one year. + pub cache_seconds: u32, +} +impl Default for Rule { + fn default() -> Self { + Self { + id: "root".into(), + enabled: true, + source_scheme: SourceScheme::Any, + match_type: MatchType::Prefix, + path_prefix: root(), + target_type: TargetType::Url, + target: "https://example.com/".into(), + target_scheme: TargetScheme::Preserve, + path_mode: PathMode::Append, + query_mode: QueryMode::Preserve, + status_code: default_status(), + cache_seconds: 0, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Host { + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default = "yes")] + pub enabled: bool, + #[serde(default = "yes")] + pub http2_enabled: bool, + #[serde(default = "yes")] + pub http3_enabled: bool, + pub frontend: crate::vhost::Frontend, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub certificate_id: Option, + #[serde(default)] + pub rules: Vec, +} +impl Host { + pub fn validate(&mut self) -> Result<()> { + ensure!(crate::config::valid_id(&self.id), "invalid redirect id"); + if self.name.is_empty() { + self.name = self.id.clone(); + } + self.name = self.name.trim().to_owned(); + ensure!( + !self.name.is_empty() + && self.name.len() <= 96 + && !self.name.chars().any(char::is_control), + "redirect name must be 1..96 bytes without controls" + ); + ensure!( + !self.frontend.hosts.is_empty(), + "redirect requires at least one hostname" + ); + let mut hosts = std::collections::HashSet::new(); + for host in &mut self.frontend.hosts { + *host = crate::config::normalize_host_pattern(host)?; + ensure!(hosts.insert(host.clone()), "duplicate redirect hostname"); + } + ensure!( + !self.frontend.listener_ids.is_empty() && self.frontend.listener_ids.len() <= 16, + "redirect requires 1..16 named listeners" + ); + if self.rules.is_empty() { + self.rules.push(Rule::default()); + } + ensure!(self.rules.len() <= 64, "at most 64 rules per redirect host"); + let mut ids = std::collections::HashSet::new(); + let mut bindings: Vec<(String, SourceScheme, MatchType)> = Vec::new(); + for rule in &mut self.rules { + ensure!( + crate::config::valid_id(&rule.id) && ids.insert(rule.id.clone()), + "invalid/duplicate redirect rule id" + ); + normalize_prefix(&mut rule.path_prefix)?; + ensure!( + matches!(rule.status_code, 301 | 302 | 303 | 307 | 308), + "redirect status_code must be 301, 302, 303, 307 or 308" + ); + ensure!( + rule.cache_seconds <= 31_536_000, + "redirect cache_seconds must be <= 31536000" + ); + validate_target(rule)?; + for (path, scheme, kind) in &bindings { + if path == &rule.path_prefix + && *kind == rule.match_type + && schemes_overlap(*scheme, rule.source_scheme) + { + anyhow::bail!("overlapping redirect rules for {}", rule.path_prefix); + } + } + bindings.push(( + rule.path_prefix.clone(), + rule.source_scheme, + rule.match_type, + )); + if rule.target_type == TargetType::Path + && rule.path_mode == PathMode::Replace + && rule.target == rule.path_prefix + { + anyhow::bail!( + "redirect rule {} is an unconditional self-redirect", + rule.id + ); + } + } + Ok(()) + } +} + +fn schemes_overlap(a: SourceScheme, b: SourceScheme) -> bool { + a == SourceScheme::Any || b == SourceScheme::Any || a == b +} + +fn normalize_prefix(prefix: &mut String) -> Result<()> { + ensure!( + prefix.starts_with('/') + && prefix.len() <= 512 + && !prefix.contains('?') + && !prefix.contains('#') + && !prefix.bytes().any(|b| b <= 32 || b == 127), + "invalid redirect path prefix" + ); + if prefix.len() > 1 { + *prefix = prefix.trim_end_matches('/').to_owned(); + } + *prefix = crate::acl::routing_path(prefix)?; + Ok(()) +} + +fn validate_path(value: &str) -> Result<()> { + ensure!( + value.starts_with('/') + && value.len() <= 2048 + && !value.contains('?') + && !value.contains('#') + && !value.bytes().any(|b| b <= 32 || b == 127), + "redirect path target must be an absolute path without query or fragment" + ); + crate::acl::routing_path(value)?; + Ok(()) +} + +fn validate_target(rule: &Rule) -> Result<()> { + ensure!( + !rule.target.is_empty() && rule.target.len() <= 2048, + "redirect target must be 1..2048 bytes" + ); + match rule.target_type { + TargetType::Url => { + let url = Url::parse(&rule.target).context("invalid redirect URL")?; + ensure!( + matches!(url.scheme(), "http" | "https"), + "redirect URL scheme must be http or https" + ); + ensure!(url.host_str().is_some(), "redirect URL requires a host"); + ensure!( + url.username().is_empty() && url.password().is_none(), + "redirect URL must not contain credentials" + ); + } + TargetType::Host => { + ensure!( + !rule.target.contains('@'), + "redirect host must not contain credentials" + ); + let authority = + Authority::from_str(&rule.target).context("invalid redirect host/port")?; + crate::config::request_host(authority.as_str()) + .context("invalid redirect host/port")?; + } + TargetType::Path => validate_path(&rule.target)?, + } + Ok(()) +} + +#[derive(Debug, Clone)] +enum CompiledTarget { + Url { + prefix: String, + base_path: String, + query: Option, + fragment: Option, + }, + Host { + authority: String, + scheme: TargetScheme, + }, + Path { + base_path: String, + }, +} + +#[derive(Debug, Clone)] +pub struct CompiledRule { + pub id: String, + pub source_scheme: SourceScheme, + pub match_type: MatchType, + pub path_prefix: String, + target: CompiledTarget, + pub path_mode: PathMode, + pub query_mode: QueryMode, + pub status_code: u16, + pub cache_seconds: u32, +} + +#[derive(Debug, Clone)] +pub struct CompiledHost { + pub id: String, + pub bindings: Vec, + pub http2_enabled: bool, + pub http3_enabled: bool, + pub rules: Vec, +} + +pub fn compile(host: &Host, listeners: &crate::settings::Listeners) -> Result { + let bindings = host.frontend.resolved(listeners)?; + let mut rules = Vec::new(); + for rule in host.rules.iter().filter(|rule| rule.enabled) { + let target = match rule.target_type { + TargetType::Url => { + let url = Url::parse(&rule.target)?; + CompiledTarget::Url { + prefix: url[..Position::BeforePath].to_owned(), + base_path: url.path().to_owned(), + query: url.query().map(str::to_owned), + fragment: url.fragment().map(str::to_owned), + } + } + TargetType::Host => CompiledTarget::Host { + authority: Authority::from_str(&rule.target)?.to_string(), + scheme: rule.target_scheme, + }, + TargetType::Path => CompiledTarget::Path { + base_path: rule.target.clone(), + }, + }; + rules.push(CompiledRule { + id: rule.id.clone(), + source_scheme: rule.source_scheme, + match_type: rule.match_type, + path_prefix: rule.path_prefix.clone(), + target, + path_mode: rule.path_mode, + query_mode: rule.query_mode, + status_code: rule.status_code, + cache_seconds: rule.cache_seconds, + }); + } + rules.sort_by(|a, b| { + b.path_prefix.len().cmp(&a.path_prefix.len()).then_with(|| { + match (a.match_type, b.match_type) { + (MatchType::Exact, MatchType::Prefix) => std::cmp::Ordering::Less, + (MatchType::Prefix, MatchType::Exact) => std::cmp::Ordering::Greater, + _ => std::cmp::Ordering::Equal, + } + }) + }); + Ok(CompiledHost { + id: host.id.clone(), + bindings, + http2_enabled: host.http2_enabled, + http3_enabled: host.http3_enabled, + rules, + }) +} + +impl CompiledRule { + pub fn matches(&self, path: &str, tls: bool) -> bool { + self.source_scheme.matches(tls) + && match self.match_type { + MatchType::Exact => path == self.path_prefix, + MatchType::Prefix => crate::config::prefix_matches(&self.path_prefix, path), + } + } + + pub fn response( + &self, + request_host: &str, + tls: bool, + uri: &Uri, + ) -> Option { + use axum::response::IntoResponse; + let location = self.location(request_host, tls, uri)?; + let status = StatusCode::from_u16(self.status_code).ok()?; + let mut response = status.into_response(); + response + .headers_mut() + .insert(http::header::LOCATION, location); + let cache = if self.cache_seconds == 0 { + HeaderValue::from_static("no-store") + } else { + HeaderValue::from_str(&format!("public, max-age={}", self.cache_seconds)).ok()? + }; + response + .headers_mut() + .insert(http::header::CACHE_CONTROL, cache); + Some(response) + } + + pub fn location(&self, request_host: &str, tls: bool, uri: &Uri) -> Option { + let request_path = uri.path(); + let suffix = match self.path_mode { + PathMode::Replace => "", + PathMode::Append => request_path, + PathMode::StripPrefix => request_path.strip_prefix(&self.path_prefix).unwrap_or(""), + }; + let (prefix, base_path, static_query, fragment) = match &self.target { + CompiledTarget::Url { + prefix, + base_path, + query, + fragment, + } => ( + prefix.clone(), + base_path.clone(), + query.clone(), + fragment.clone(), + ), + CompiledTarget::Host { authority, scheme } => { + let scheme = match scheme { + TargetScheme::Preserve => { + if tls { + "https" + } else { + "http" + } + } + TargetScheme::Http => "http", + TargetScheme::Https => "https", + }; + (format!("{scheme}://{authority}"), "/".into(), None, None) + } + CompiledTarget::Path { base_path } => { + let scheme = if tls { "https" } else { "http" }; + ( + format!("{scheme}://{request_host}"), + base_path.clone(), + None, + None, + ) + } + }; + let path = match self.path_mode { + PathMode::Replace => base_path, + PathMode::Append | PathMode::StripPrefix => join_paths(&base_path, suffix), + }; + let mut value = format!("{prefix}{path}"); + let source_query = if self.query_mode == QueryMode::Preserve { + uri.query() + } else { + None + }; + match (static_query.as_deref(), source_query) { + (Some(a), Some(b)) if !a.is_empty() && !b.is_empty() => { + value.push_str(&format!("?{a}&{b}")) + } + (Some(a), _) if !a.is_empty() => value.push_str(&format!("?{a}")), + (_, Some(b)) if !b.is_empty() => value.push_str(&format!("?{b}")), + _ => {} + } + if let Some(fragment) = fragment.filter(|value| !value.is_empty()) { + value.push('#'); + value.push_str(&fragment); + } + HeaderValue::from_str(&value).ok() + } +} + +fn join_paths(base: &str, suffix: &str) -> String { + if suffix.is_empty() || suffix == "/" { + return if base.is_empty() { + "/".into() + } else { + base.into() + }; + } + let base = if base.is_empty() { "/" } else { base }; + if base == "/" { + format!("/{}", suffix.trim_start_matches('/')) + } else { + format!( + "{}/{}", + base.trim_end_matches('/'), + suffix.trim_start_matches('/') + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rule() -> Rule { + Rule { + target: "https://new.example/base?fixed=1#section".into(), + ..Rule::default() + } + } + + #[test] + fn redirect_location_supports_path_and_query_modes() { + let host = Host { + id: "legacy".into(), + name: "Legacy".into(), + enabled: true, + http2_enabled: true, + http3_enabled: true, + frontend: crate::vhost::Frontend { + hosts: vec!["old.example".into()], + listener_ids: vec!["http".into()], + }, + certificate_id: None, + rules: vec![rule()], + }; + let listeners = + crate::settings::Listeners::from_startup("127.0.0.1:8080".parse().unwrap(), None); + let compiled = compile(&host, &listeners).unwrap(); + let uri: Uri = "/products/1?src=old".parse().unwrap(); + assert_eq!( + compiled.rules[0] + .location("old.example", false, &uri) + .unwrap(), + "https://new.example/base/products/1?fixed=1&src=old#section" + ); + } + + #[test] + fn strip_prefix_and_host_target_are_deterministic() { + let mut value = rule(); + value.path_prefix = "/old".into(); + value.target_type = TargetType::Host; + value.target = "app.example:8443".into(); + value.target_scheme = TargetScheme::Https; + value.path_mode = PathMode::StripPrefix; + value.query_mode = QueryMode::Drop; + let host = Host { + id: "legacy".into(), + name: "Legacy".into(), + enabled: true, + http2_enabled: true, + http3_enabled: true, + frontend: crate::vhost::Frontend { + hosts: vec!["old.example".into()], + listener_ids: vec!["http".into()], + }, + certificate_id: None, + rules: vec![value], + }; + let listeners = + crate::settings::Listeners::from_startup("127.0.0.1:8080".parse().unwrap(), None); + let compiled = compile(&host, &listeners).unwrap(); + let uri: Uri = "/old/path?x=1".parse().unwrap(); + assert_eq!( + compiled.rules[0] + .location("old.example", false, &uri) + .unwrap(), + "https://app.example:8443/path" + ); + } + + #[test] + fn validation_rejects_overlaps_and_unconditional_path_loop() { + let mut host = Host { + id: "legacy".into(), + name: "Legacy".into(), + enabled: true, + http2_enabled: true, + http3_enabled: true, + frontend: crate::vhost::Frontend { + hosts: vec!["OLD.EXAMPLE.".into()], + listener_ids: vec!["http".into()], + }, + certificate_id: None, + rules: vec![Rule { + target_type: TargetType::Path, + target: "/".into(), + path_mode: PathMode::Replace, + ..Rule::default() + }], + }; + assert!(host.validate().is_err()); + host.rules = vec![ + Rule { + target: "https://new.example/".into(), + ..Rule::default() + }, + Rule { + id: "other".into(), + target: "https://other.example/".into(), + ..Rule::default() + }, + ]; + assert!(host.validate().is_err()); + } +} diff --git a/src/runtime.rs b/src/runtime.rs new file mode 100644 index 0000000..db09536 --- /dev/null +++ b/src/runtime.rs @@ -0,0 +1,621 @@ +use crate::{ + config::{self, Snapshot, Update, ValidationPolicy}, + db::Store, + logging::Logs, + metrics::Metrics, +}; +use arc_swap::ArcSwap; +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::sync::{Mutex, Semaphore}; + +pub struct App { + pub basic_workers: Arc, + pub basic_limits: crate::basic_auth::Limits, + pub active_prometheus: crate::prometheus::Settings, + pub active_administration: crate::settings::Administration, + pub active_administration_tls_policy: crate::tls::ProtocolPolicy, + pub active_administration_certificate_files: Option<(String, String)>, + pub max_inflight: usize, + pub config: ArcSwap, + pub store: Store, + pub policy: ValidationPolicy, + pub active_listeners: Vec, + pub active_streams: Vec, + pub stream_metrics: HashMap>, + pub startup_listeners: (std::net::SocketAddr, Option), + pub startup_locks: crate::settings::StartupLocks, + pub auth: Arc, + pub viewer_slots: Arc, + pub certificate_slots: Arc, + pub metrics: Arc, + pub transform_metrics: Arc, + pub cache: Arc, + pub geoip: Arc, + pub rate_limiter: Arc, + pub acme: Arc, + pub diagnostics: Arc, + pub docker: Arc, + pub logs: Arc, + pub software_update: Arc, + pub shutdown: tokio_util::sync::CancellationToken, + pub tunnels: tokio_util::task::TaskTracker, + pub tunnel_registry: Arc, + pub websocket_idle_timeout: Duration, + pub inflight: Arc, + pub started: Instant, + pub header_timeout: Duration, + pub body_idle_timeout: Duration, + update_lock: Mutex<()>, + update_slots: Arc, +} +#[derive(Debug)] +pub struct ApiError(pub StatusCode, pub String); +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let mut response = (self.0, Json(json!({"error": self.1}))).into_response(); + if self.0 == StatusCode::TOO_MANY_REQUESTS { + response + .headers_mut() + .insert("retry-after", http::HeaderValue::from_static("60")); + } + response + } +} +#[derive(Debug)] +enum CacheInvalidation { + None, + All(u64), +} + +/// Stable cache compatibility identity. Unlike the configuration revision, this +/// changes only when a setting can alter an origin request or cached response. +/// Keeping it stable across logging/TLS/admin saves lets the persistent cold +/// tier survive a restart after unrelated control-plane edits. +fn cache_revision(config: &config::Config) -> u64 { + let encoded = serde_json::to_vec(&( + &config.cache, + &config.headers, + &config.forwarding, + &config.vhosts, + )) + .expect("serializable cache compatibility view"); + let digest = Sha256::digest(encoded); + u64::from_be_bytes(digest[..8].try_into().expect("sha256 prefix")) +} + +fn cache_invalidation(previous: &config::Config, next: &config::Config) -> CacheInvalidation { + let before = cache_revision(previous); + let after = cache_revision(next); + if before == after { + CacheInvalidation::None + } else { + CacheInvalidation::All(after) + } +} + +impl App { + #[allow(clippy::too_many_arguments)] + pub fn new( + store: Store, + snapshot: Snapshot, + policy: ValidationPolicy, + auth: Arc, + max_inflight: usize, + header_timeout: Duration, + body_idle_timeout: Duration, + websocket_idle_timeout: Duration, + ) -> Arc { + let defaults = (policy.proxy_addr, policy.tls_addr); + Self::new_with_defaults( + store, + snapshot, + policy, + auth, + max_inflight, + header_timeout, + body_idle_timeout, + websocket_idle_timeout, + defaults, + ) + } + #[allow(clippy::too_many_arguments)] + pub fn new_with_defaults( + store: Store, + snapshot: Snapshot, + policy: ValidationPolicy, + auth: Arc, + max_inflight: usize, + header_timeout: Duration, + body_idle_timeout: Duration, + websocket_idle_timeout: Duration, + startup_listeners: (std::net::SocketAddr, Option), + ) -> Arc { + Self::new_with_defaults_geoip( + store, + snapshot, + policy, + auth, + max_inflight, + header_timeout, + body_idle_timeout, + websocket_idle_timeout, + startup_listeners, + Arc::new(crate::geoip::Database::empty()), + ) + } + #[allow(clippy::too_many_arguments)] + pub fn new_with_defaults_geoip( + store: Store, + snapshot: Snapshot, + policy: ValidationPolicy, + auth: Arc, + max_inflight: usize, + header_timeout: Duration, + body_idle_timeout: Duration, + websocket_idle_timeout: Duration, + startup_listeners: (std::net::SocketAddr, Option), + geoip: Arc, + ) -> Arc { + Self::new_with_startup_locks_geoip( + store, + snapshot, + policy, + auth, + max_inflight, + header_timeout, + body_idle_timeout, + websocket_idle_timeout, + startup_listeners, + crate::settings::StartupLocks::default(), + geoip, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn new_with_startup_locks_geoip( + store: Store, + snapshot: Snapshot, + policy: ValidationPolicy, + auth: Arc, + max_inflight: usize, + header_timeout: Duration, + body_idle_timeout: Duration, + websocket_idle_timeout: Duration, + startup_listeners: (std::net::SocketAddr, Option), + startup_locks: crate::settings::StartupLocks, + geoip: Arc, + ) -> Arc { + let shutdown = tokio_util::sync::CancellationToken::new(); + let diagnostics = crate::diagnostics::Diagnostics::start(shutdown.clone()); + let docker = crate::docker::Manager::new(); + let cache_data_root = policy + .cert_dir + .as_ref() + .and_then(|path| path.parent()) + .map(std::path::Path::to_path_buf); + let cache = crate::cache::Cache::start( + cache_revision(&snapshot.envelope.config), + cache_data_root, + snapshot.envelope.config.cache.clone(), + shutdown.clone(), + ); + let active_listeners = snapshot.listeners.clone(); + let active_streams = snapshot.envelope.config.streams.clone(); + let stream_metrics = active_streams + .iter() + .map(|stream| { + ( + stream.id.clone(), + Arc::new(crate::stream::Metrics::default()), + ) + }) + .collect(); + let active_prometheus = snapshot.envelope.config.prometheus.clone(); + let log_settings = snapshot.envelope.config.logging.clone(); + let mut active_administration = snapshot.envelope.config.administration.clone(); + let active_administration_tls_policy = snapshot.envelope.config.tls.policy.clone(); + let active_administration_certificate_files = active_administration + .certificate_id + .as_deref() + .and_then(|id| { + snapshot + .envelope + .config + .tls + .certificates + .iter() + .find(|certificate| certificate.id == id) + }) + .map(|certificate| (certificate.cert_file.clone(), certificate.key_file.clone())); + active_administration.listen = Some(policy.admin_addr); + if let Some(id) = active_administration.certificate_id.as_deref() { + if let Some(certificate) = snapshot + .envelope + .config + .tls + .certificates + .iter() + .find(|certificate| certificate.id == id) + { + active_administration + .allowed_hosts + .extend(certificate.hosts.clone()); + active_administration.allowed_hosts.sort(); + active_administration.allowed_hosts.dedup(); + } + } + let logs = Logs::start(log_settings, shutdown.clone()); + let app = Arc::new(Self { + basic_workers: Arc::new(Semaphore::new(2)), + basic_limits: Default::default(), + active_prometheus, + active_administration, + active_administration_tls_policy, + active_administration_certificate_files, + max_inflight, + active_listeners, + active_streams, + stream_metrics, + cache, + geoip, + rate_limiter: Arc::new(crate::rate_limit::Limiter::new()), + acme: crate::acme::Manager::new(), + diagnostics, + docker, + config: ArcSwap::from_pointee(snapshot), + store, + policy, + startup_listeners, + startup_locks, + auth, + viewer_slots: Arc::new(Semaphore::new(8)), + certificate_slots: Arc::new(Semaphore::new(2)), + metrics: Arc::new(Metrics::default()), + transform_metrics: Arc::new(crate::transform::Metrics::default()), + logs, + software_update: Arc::new(crate::update_check::SharedStatus::default()), + shutdown, + tunnels: tokio_util::task::TaskTracker::new(), + tunnel_registry: Arc::new(crate::proxy::websocket::TunnelRegistry::default()), + websocket_idle_timeout, + inflight: Arc::new(Semaphore::new(max_inflight)), + started: Instant::now(), + header_timeout, + body_idle_timeout, + update_lock: Mutex::new(()), + update_slots: Arc::new(Semaphore::new(8)), + }); + let weak = Arc::downgrade(&app); + let shutdown = app.shutdown.clone(); + app.tunnels + .spawn(crate::balance::health_loop(weak, shutdown)); + app.tunnels.spawn(crate::acme::renewal_loop( + Arc::downgrade(&app), + app.shutdown.clone(), + )); + app.tunnels.spawn(crate::docker::poll_loop( + Arc::downgrade(&app), + app.shutdown.clone(), + )); + app + } + pub fn start_update_check(self: &Arc, config: crate::update_check::Config) { + self.software_update.configure(&config); + self.tunnels.spawn(crate::update_check::poll_loop( + self.software_update.clone(), + config, + self.shutdown.clone(), + )); + } + + /// Drain detached writers after the listeners and their handlers have stopped. + pub async fn drain_updates(&self) { + let _all_slots = self.update_slots.clone().acquire_many_owned(8).await; + } + /// The detached task completes persistence + publication even if the HTTP caller disconnects. + pub async fn update(self: &Arc, update: Update) -> Result { + self.update_as(update, "local".into()).await + } + pub async fn update_as( + self: &Arc, + update: Update, + actor: String, + ) -> Result { + let slot = self.update_slots.clone().try_acquire_owned().map_err(|_| { + ApiError( + StatusCode::TOO_MANY_REQUESTS, + "configuration update queue is full".into(), + ) + })?; + let expected_revision = update.expected_revision; + let revision = expected_revision + .checked_add(1) + .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "revision overflow".into()))?; + let policy = self.policy.clone(); + let defaults = self.startup_listeners; + let previous = self.config.load_full(); + let previous_config = previous.envelope.config.clone(); + let active_listeners = self.active_listeners.clone(); + let observed = self.docker.snapshot().await; + let desired_vhost_count = update.config.vhosts.len(); + let desired_config = update.config; + self.startup_locks + .validate(&desired_config, self.policy.admin_addr) + .map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{error:#}")))?; + crate::tls::validate_certificate_removals(&previous_config, &desired_config) + .map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{error:#}")))?; + let (prepared, invalidation, slot) = tokio::task::spawn_blocking(move || { + let effective = crate::docker::effective_config(desired_config, &observed)?; + let mut prepared = config::compile_with_active( + effective, + revision, + &policy, + defaults, + Some(&previous), + &active_listeners, + )?; + prepared + .envelope + .config + .vhosts + .truncate(desired_vhost_count); + let invalidation = cache_invalidation(&previous_config, &prepared.envelope.config); + Ok::<_, anyhow::Error>((prepared, invalidation, slot)) + }) + .await + .map_err(|_| { + ApiError( + StatusCode::INTERNAL_SERVER_ERROR, + "configuration validation task failed".into(), + ) + })? + .map_err(|e| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{e:#}")))?; + let this = self.clone(); + tokio::spawn(async move { + let _slot = slot; + let _lock = this.update_lock.lock().await; + if this.config.load().envelope.revision != expected_revision { + return Err(ApiError( + StatusCode::CONFLICT, + "configuration changed; reload before saving".into(), + )); + } + match this + .store + .replace_as(expected_revision, &prepared.envelope.config, &actor) + .await + { + Ok(true) => {} + Ok(false) => { + return Err(ApiError( + StatusCode::CONFLICT, + "stored revision differs; external SQLite writes are unsupported".into(), + )) + } + Err(error) => { + tracing::error!(%error, "configuration persistence failed"); + return Err(ApiError( + StatusCode::INTERNAL_SERVER_ERROR, + "configuration was not saved".into(), + )); + } + } + // No await between successful commit and publication. Cache identity is + // intentionally decoupled from the configuration revision, so unrelated + // control-plane saves do not invalidate hot/cold objects. + let envelope = prepared.envelope.clone(); + match invalidation { + CacheInvalidation::All(cache_revision) => { + this.cache.purge(Some(cache_revision)); + } + CacheInvalidation::None => {} + } + this.cache.configure(envelope.config.cache.clone()); + this.rate_limiter.reset(); + this.logs.configure(envelope.config.logging.clone()); + this.config.store(Arc::new(prepared)); + tracing::info!(revision, "configuration applied"); + Ok(envelope) + }) + .await + .map_err(|_| { + ApiError( + StatusCode::INTERNAL_SERVER_ERROR, + "configuration task failed".into(), + ) + })? + } + + /// Re-read configured public certificate/key files and publish a new + /// immutable TLS/routing snapshot without changing desired configuration + /// or its SQLite revision. Existing connections continue on their current + /// TLS session; new H1/H2/H3 handshakes use the refreshed certificates. + pub async fn reload_certificates(self: &Arc) -> Result { + self.reload_certificate_snapshot(None).await + } + + /// Reload an externally managed/manual certificate by id. The immutable TLS + /// resolver is rebuilt as one validated snapshot, so the requested pair is + /// re-read atomically with the other configured pairs while desired + /// configuration and its revision remain unchanged. + pub async fn reload_certificate(self: &Arc, certificate_id: &str) -> Result { + if !crate::config::valid_id(certificate_id) { + return Err(ApiError(StatusCode::NOT_FOUND, "certificate not found".into())); + } + self.reload_certificate_snapshot(Some(certificate_id)).await + } + + async fn reload_certificate_snapshot( + self: &Arc, + certificate_id: Option<&str>, + ) -> Result { + let _lock = self.update_lock.lock().await; + let previous = self.config.load_full(); + let desired = previous.envelope.config.clone(); + if let Some(certificate_id) = certificate_id { + let certificate = desired + .tls + .certificates + .iter() + .find(|certificate| certificate.id == certificate_id) + .ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "certificate not found".into()))?; + if certificate.issuer.is_some() { + return Err(ApiError( + StatusCode::CONFLICT, + "managed certificates are reloaded by the renewal pipeline".into(), + )); + } + } + let desired_vhost_count = desired.vhosts.len(); + let observed = self.docker.snapshot().await; + let policy = self.policy.clone(); + let defaults = self.startup_listeners; + let active_listeners = self.active_listeners.clone(); + let revision = previous.envelope.revision; + let prepared = tokio::task::spawn_blocking(move || { + let effective = crate::docker::effective_config(desired, &observed)?; + let mut prepared = config::compile_with_active( + effective, + revision, + &policy, + defaults, + Some(&previous), + &active_listeners, + )?; + prepared + .envelope + .config + .vhosts + .truncate(desired_vhost_count); + Ok::<_, anyhow::Error>(prepared) + }) + .await + .map_err(|_| { + ApiError( + StatusCode::INTERNAL_SERVER_ERROR, + "certificate reload task failed".into(), + ) + })? + .map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{error:#}")))?; + self.config.store(Arc::new(prepared)); + if let Some(certificate_id) = certificate_id { + tracing::info!(revision, certificate_id, "configured certificate reloaded from disk"); + } else { + tracing::info!(revision, "configured certificates reloaded from disk"); + } + Ok(revision) + } + + /// Rebuild only the effective routing snapshot from the latest Docker observations. + /// Desired configuration and its SQLite revision remain unchanged. + pub async fn reconcile_docker(self: &Arc) -> Result<(), ApiError> { + let _lock = self.update_lock.lock().await; + let previous = self.config.load_full(); + let desired = previous.envelope.config.clone(); + let desired_vhost_count = desired.vhosts.len(); + let observed = self.docker.snapshot().await; + let policy = self.policy.clone(); + let defaults = self.startup_listeners; + let active_listeners = self.active_listeners.clone(); + let revision = previous.envelope.revision; + let prepared = tokio::task::spawn_blocking(move || { + let effective = crate::docker::effective_config(desired, &observed)?; + let mut prepared = config::compile_with_active( + effective, + revision, + &policy, + defaults, + Some(&previous), + &active_listeners, + )?; + prepared + .envelope + .config + .vhosts + .truncate(desired_vhost_count); + Ok::<_, anyhow::Error>(prepared) + }) + .await + .map_err(|_| { + ApiError( + StatusCode::INTERNAL_SERVER_ERROR, + "Docker reconcile task failed".into(), + ) + })? + .map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{error:#}")))?; + self.config.store(Arc::new(prepared)); + Ok(()) + } + + pub async fn refresh_docker( + self: &Arc, + ) -> Result, ApiError> { + let settings = self.config.load().envelope.config.docker_agents.clone(); + let (observations, changed) = self.docker.refresh(&settings, true).await; + if changed { + self.reconcile_docker().await?; + } + Ok(observations) + } +} + +#[cfg(test)] +mod cache_revision_tests { + use super::*; + + #[test] + fn operational_saves_keep_cache_identity_but_cache_affecting_saves_rotate_it() { + let base = config::demo_config("127.0.0.1:9000".parse().unwrap()); + let identity = cache_revision(&base); + + let mut logging = base.clone(); + logging.logging.file.path = "/tmp/rustproxy-test-access.jsonl".into(); + assert_eq!(cache_revision(&logging), identity); + assert!(matches!( + cache_invalidation(&base, &logging), + CacheInvalidation::None + )); + + let mut metrics = base.clone(); + metrics.prometheus.include_system = !metrics.prometheus.include_system; + assert_eq!(cache_revision(&metrics), identity); + + let mut response = base.clone(); + response.server_header.value = "edge-test".into(); + response.diagnostic_headers.request_id = !response.diagnostic_headers.request_id; + response.compression.gzip = !response.compression.gzip; + assert_eq!(cache_revision(&response), identity); + + let mut docker = base.clone(); + docker.docker_agents.agents.push(crate::docker::Agent { + id: "node1".into(), + name: "Node 1".into(), + ..Default::default() + }); + assert_eq!(cache_revision(&docker), identity); + + let mut cache = base.clone(); + cache.cache.max_entries += 1; + let cache_identity = cache_revision(&cache); + assert_ne!(cache_identity, identity); + assert!(matches!( + cache_invalidation(&base, &cache), + CacheInvalidation::All(value) if value == cache_identity + )); + + let mut vhost = base.clone(); + vhost.vhosts[0].backends[0].url = "http://127.0.0.1:9001".into(); + assert_ne!(cache_revision(&vhost), identity); + } +} diff --git a/src/security.rs b/src/security.rs new file mode 100644 index 0000000..9fe8312 --- /dev/null +++ b/src/security.rs @@ -0,0 +1,145 @@ +//! Explicit per-vhost response protection. HSTS is never emitted on plaintext. +use anyhow::{ensure, Result}; +use http::{HeaderMap, HeaderValue}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct SecurityHeaders { + pub hsts: Hsts, + pub nosniff: bool, + pub frame_options: FrameOptions, + pub referrer_policy: Option, + pub content_security_policy: Option, + pub permissions_policy: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Hsts { + pub enabled: bool, + pub max_age_seconds: u64, + pub include_subdomains: bool, + pub preload: bool, +} +impl Default for Hsts { + fn default() -> Self { + Self { + enabled: false, + max_age_seconds: 31_536_000, + include_subdomains: false, + preload: false, + } + } +} +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FrameOptions { + #[default] + Preserve, + Deny, + Sameorigin, +} +#[derive(Debug, Clone, Default)] +pub struct CompiledSecurity { + hsts: Option, + headers: Vec<(&'static str, HeaderValue)>, +} +impl SecurityHeaders { + pub fn compile(&self) -> Result { + ensure!( + self.hsts.max_age_seconds <= 63_072_000, + "HSTS max_age_seconds must be 0..63072000" + ); + ensure!( + !self.hsts.preload + || (self.hsts.enabled + && self.hsts.include_subdomains + && self.hsts.max_age_seconds >= 31_536_000), + "HSTS preload requires enabled, include_subdomains and at least one year" + ); + let mut result = CompiledSecurity::default(); + if self.hsts.enabled { + let mut value = format!("max-age={}", self.hsts.max_age_seconds); + if self.hsts.include_subdomains { + value.push_str("; includeSubDomains"); + } + if self.hsts.preload { + value.push_str("; preload"); + } + result.hsts = Some(value.parse()?); + } + if self.nosniff { + result.headers.push(( + "x-content-type-options", + HeaderValue::from_static("nosniff"), + )); + } + let frame = match self.frame_options { + FrameOptions::Preserve => None, + FrameOptions::Deny => Some("DENY"), + FrameOptions::Sameorigin => Some("SAMEORIGIN"), + }; + if let Some(value) = frame { + result + .headers + .push(("x-frame-options", HeaderValue::from_static(value))); + } + for (name, value) in [ + ("referrer-policy", &self.referrer_policy), + ("content-security-policy", &self.content_security_policy), + ("permissions-policy", &self.permissions_policy), + ] { + if let Some(value) = value { + ensure!( + !value.is_empty() && value.len() <= 8192, + "security header must contain 1..8192 bytes" + ); + result.headers.push((name, value.parse()?)); + } + } + Ok(result) + } +} +impl CompiledSecurity { + pub fn apply(&self, headers: &mut HeaderMap, tls: bool) { + for (name, value) in &self.headers { + headers.insert(*name, value.clone()); + } + if !tls { + headers.remove("strict-transport-security"); + } else if let Some(value) = &self.hsts { + headers.insert("strict-transport-security", value.clone()); + } + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn hsts_is_tls_only_and_preload_is_explicit() { + let mut s = SecurityHeaders::default(); + s.hsts.enabled = true; + let c = s.compile().unwrap(); + let mut h = HeaderMap::new(); + c.apply(&mut h, true); + assert_eq!(h["strict-transport-security"], "max-age=31536000"); + c.apply(&mut h, false); + assert!(!h.contains_key("strict-transport-security")); + s.hsts.preload = true; + assert!(s.compile().is_err()); + s.hsts.include_subdomains = true; + assert!(s.compile().is_ok()); + s.hsts.max_age_seconds = 0; + assert!(s.compile().is_err()); + s.hsts.preload = false; + assert!(s.compile().is_ok()); + } + #[test] + fn rejects_response_splitting() { + let s = SecurityHeaders { + content_security_policy: Some("x\r\ninjected: yes".into()), + ..Default::default() + }; + assert!(s.compile().is_err()); + } +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..a0912e9 --- /dev/null +++ b/src/server.rs @@ -0,0 +1,1531 @@ +//! Bounded listeners. HTTP/2 is negotiated through TLS ALPN. Cleartext HTTP/2 +//! is opt-in per listener and accepts both the HTTP/2 prior-knowledge preface and +//! a validated HTTP/1.1 `Upgrade: h2c` bootstrap. +use crate::{metrics::Metrics, runtime::App, settings::Http2Connections}; +use anyhow::{Context, Result}; +use axum::{body::Body, Router}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use bytes::{Buf, Bytes, BytesMut}; +use hyper::{ + body::Incoming, + server::conn::{http1, http2}, + service::service_fn, + Request, Response, +}; +use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer}; +use http_body::Body as HttpBody; +use std::{ + convert::Infallible, + io, + net::SocketAddr, + pin::Pin, + sync::{ + atomic::{AtomicU64, AtomicUsize, Ordering}, + Arc, + }, + task::{Context as TaskContext, Poll}, + time::Duration, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite, ReadBuf}, + net::TcpListener, + sync::{Notify, OwnedSemaphorePermit, Semaphore}, + task::JoinSet, +}; +use tokio_util::sync::CancellationToken; +use tower::ServiceExt; + + +const HTTP2_PRIOR_KNOWLEDGE_PREFACE: &[u8; 24] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"; +const H2_FRAME_HEADER_LEN: usize = 9; + +fn header_has_token(headers: &http::HeaderMap, name: &str, token: &str) -> bool { + headers + .get_all(name) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .any(|value| value.trim().eq_ignore_ascii_case(token)) +} + +fn single_header<'a>(headers: &'a http::HeaderMap, name: &str) -> std::result::Result<&'a str, &'static str> { + let mut values = headers.get_all(name).iter(); + let value = values.next().ok_or("missing required h2c header")?; + if values.next().is_some() { + return Err("duplicate h2c header"); + } + value.to_str().map_err(|_| "invalid h2c header encoding") +} + +fn h2c_attempt(request: &Request) -> bool { + request + .headers() + .get_all(http::header::UPGRADE) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .any(|value| value.trim().eq_ignore_ascii_case("h2c")) + || request.headers().contains_key("http2-settings") +} + +fn hpack_integer(out: &mut Vec, value: usize, prefix_bits: u8, first: u8) { + let limit = (1usize << prefix_bits) - 1; + if value < limit { + out.push(first | value as u8); + return; + } + out.push(first | limit as u8); + let mut rest = value - limit; + while rest >= 128 { + out.push((rest as u8 & 0x7f) | 0x80); + rest >>= 7; + } + out.push(rest as u8); +} + +fn hpack_string(out: &mut Vec, value: &[u8]) { + // Huffman bit stays clear; the bootstrap encoder is deliberately simple. + hpack_integer(out, value.len(), 7, 0); + out.extend_from_slice(value); +} + +fn hpack_literal(out: &mut Vec, name: &str, value: &str) { + // Literal Header Field without Indexing, new name (RFC 7541 section 6.2.2). + out.push(0); + hpack_string(out, name.as_bytes()); + hpack_string(out, value.as_bytes()); +} + +fn h2_headers_frame(request: &Request) -> std::result::Result { + if request.version() != http::Version::HTTP_11 { + return Err("h2c upgrade requires HTTP/1.1"); + } + if !header_has_token(request.headers(), http::header::CONNECTION.as_str(), "upgrade") + || !header_has_token(request.headers(), http::header::CONNECTION.as_str(), "http2-settings") + { + return Err("Connection must nominate Upgrade and HTTP2-Settings"); + } + if !single_header(request.headers(), http::header::UPGRADE.as_str())? + .eq_ignore_ascii_case("h2c") + { + return Err("Upgrade must be exactly h2c"); + } + let settings = single_header(request.headers(), "http2-settings")?; + let settings = URL_SAFE_NO_PAD + .decode(settings.as_bytes()) + .map_err(|_| "invalid HTTP2-Settings base64url")?; + if settings.len() > 36 || settings.len() % 6 != 0 { + return Err("invalid HTTP2-Settings payload"); + } + if request.headers().contains_key(http::header::TRANSFER_ENCODING) { + return Err("h2c upgrade request body is not supported"); + } + if request.headers().get_all(http::header::CONTENT_LENGTH).iter().count() > 1 { + return Err("duplicate Content-Length on h2c upgrade"); + } + if request + .headers() + .get(http::header::CONTENT_LENGTH) + .is_some_and(|value| value.as_bytes() != b"0") + { + return Err("h2c upgrade request body is not supported"); + } + + let authority = request + .uri() + .authority() + .map(|value| value.as_str()) + .or_else(|| request.headers().get(http::header::HOST).and_then(|value| value.to_str().ok())) + .ok_or("h2c upgrade requires Host/:authority")?; + let path = request + .uri() + .path_and_query() + .map(|value| value.as_str()) + .unwrap_or("/"); + let method = request.method().as_str(); + if method.len() > 32 || authority.len() > 1024 || path.len() > 8192 { + return Err("h2c bootstrap request fields exceed limits"); + } + + let mut block = Vec::with_capacity(1024); + hpack_literal(&mut block, ":method", method); + hpack_literal(&mut block, ":scheme", "http"); + hpack_literal(&mut block, ":authority", authority); + hpack_literal(&mut block, ":path", path); + for (name, value) in request.headers() { + let name = name.as_str(); + if matches!( + name, + "connection" + | "upgrade" + | "http2-settings" + | "host" + | "keep-alive" + | "proxy-connection" + | "transfer-encoding" + ) || header_has_token(request.headers(), http::header::CONNECTION.as_str(), name) + { + continue; + } + if name == "te" && !value.as_bytes().eq_ignore_ascii_case(b"trailers") { + continue; + } + let value = value.to_str().map_err(|_| "non-text header in h2c bootstrap")?; + hpack_literal(&mut block, name, value); + } + if block.len() > 32_768 { + return Err("h2c bootstrap header block exceeds limit"); + } + let length = block.len(); + let mut frame = Vec::with_capacity(H2_FRAME_HEADER_LEN + length); + frame.extend_from_slice(&[ + ((length >> 16) & 0xff) as u8, + ((length >> 8) & 0xff) as u8, + (length & 0xff) as u8, + 0x1, // HEADERS + 0x5, // END_STREAM | END_HEADERS + 0, + 0, + 0, + 1, // stream 1 is the HTTP/1.1 upgrade request + ]); + frame.extend_from_slice(&block); + Ok(Bytes::from(frame)) +} + +/// After a successful 101 response the client sends the normal HTTP/2 client +/// connection preface. Hyper still needs to observe the upgrade request as +/// stream 1, so this adapter injects one synthetic HEADERS frame immediately +/// after the client's mandatory initial SETTINGS frame. All later streams are +/// handled by Hyper's normal HTTP/2 state machine. +struct H2cUpgradeIo { + inner: I, + injected_headers: Bytes, + bootstrap: BytesMut, + ready: Bytes, + bootstrap_done: bool, +} + +impl H2cUpgradeIo { + fn new(inner: I, injected_headers: Bytes) -> Self { + Self { + inner, + injected_headers, + bootstrap: BytesMut::with_capacity(4096), + ready: Bytes::new(), + bootstrap_done: false, + } + } + + fn try_finish_bootstrap(&mut self) -> io::Result<()> { + if self.bootstrap_done || self.bootstrap.len() < HTTP2_PRIOR_KNOWLEDGE_PREFACE.len() + H2_FRAME_HEADER_LEN { + return Ok(()); + } + if &self.bootstrap[..HTTP2_PRIOR_KNOWLEDGE_PREFACE.len()] != HTTP2_PRIOR_KNOWLEDGE_PREFACE { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid HTTP/2 client preface after h2c upgrade")); + } + let offset = HTTP2_PRIOR_KNOWLEDGE_PREFACE.len(); + let header = &self.bootstrap[offset..offset + H2_FRAME_HEADER_LEN]; + let length = ((header[0] as usize) << 16) | ((header[1] as usize) << 8) | header[2] as usize; + if length > 16_384 + || header[3] != 0x4 + || header[4] != 0 + || header[5] != 0 + || header[6] != 0 + || header[7] != 0 + || header[8] != 0 + { + return Err(io::Error::new(io::ErrorKind::InvalidData, "h2c client preface must begin with a non-ACK SETTINGS frame")); + } + let end = offset + H2_FRAME_HEADER_LEN + length; + if self.bootstrap.len() < end { + return Ok(()); + } + let mut ready = BytesMut::with_capacity(self.bootstrap.len() + self.injected_headers.len()); + ready.extend_from_slice(&self.bootstrap[..end]); + ready.extend_from_slice(&self.injected_headers); + ready.extend_from_slice(&self.bootstrap[end..]); + self.ready = ready.freeze(); + self.bootstrap.clear(); + self.bootstrap_done = true; + Ok(()) + } +} + +impl AsyncRead for H2cUpgradeIo { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + if !self.ready.is_empty() { + let take = self.ready.len().min(buf.remaining()); + buf.put_slice(&self.ready[..take]); + self.ready.advance(take); + return Poll::Ready(Ok(())); + } + if self.bootstrap_done { + return Pin::new(&mut self.inner).poll_read(cx, buf); + } + + let mut temp = [0u8; 8192]; + let mut temp_buf = ReadBuf::new(&mut temp); + match Pin::new(&mut self.inner).poll_read(cx, &mut temp_buf) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(())) => { + let bytes = temp_buf.filled(); + if bytes.is_empty() { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "connection closed during h2c client preface", + ))); + } + self.bootstrap.extend_from_slice(bytes); + if self.bootstrap.len() > 128 * 1024 { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::InvalidData, + "h2c bootstrap exceeds limit", + ))); + } + if let Err(error) = self.try_finish_bootstrap() { + return Poll::Ready(Err(error)); + } + } + } + } + } +} + +impl AsyncWrite for H2cUpgradeIo { + fn poll_write(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>, buf: &[u8]) -> Poll> { + Pin::new(&mut self.inner).poll_write(cx, buf) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} + +/// Shared HTTP/2 activity state. Idle shutdown is allowed only when there are +/// no active streams and no recent frame traffic. Lifetime shutdown ignores idle +/// state but still uses the same GOAWAY -> drain -> TCP close sequence. +struct H2ConnectionState { + started: std::time::Instant, + last_activity_ms: AtomicU64, + active_streams: AtomicUsize, + notify: Notify, +} +impl H2ConnectionState { + fn new() -> Arc { + Arc::new(Self { + started: std::time::Instant::now(), + last_activity_ms: AtomicU64::new(0), + active_streams: AtomicUsize::new(0), + notify: Notify::new(), + }) + } + fn elapsed_ms(&self) -> u64 { + self.started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64 + } + fn mark_activity(&self) { + self.last_activity_ms + .fetch_max(self.elapsed_ms(), Ordering::Relaxed); + self.notify.notify_waiters(); + } + fn idle_for(&self) -> Duration { + Duration::from_millis( + self.elapsed_ms() + .saturating_sub(self.last_activity_ms.load(Ordering::Relaxed)), + ) + } + fn is_idle(&self, idle: Duration) -> bool { + self.active_streams.load(Ordering::Acquire) == 0 && self.idle_for() >= idle + } +} + +struct H2ActivityIo { + inner: I, + state: Arc, +} +impl H2ActivityIo { + fn new(inner: I, state: Arc) -> Self { + Self { inner, state } + } +} +impl Unpin for H2ActivityIo {} +impl AsyncRead for H2ActivityIo { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + let before = buf.filled().len(); + match Pin::new(&mut this.inner).poll_read(cx, buf) { + Poll::Ready(Ok(())) => { + if buf.filled().len() > before { + this.state.mark_activity(); + } + Poll::Ready(Ok(())) + } + other => other, + } + } +} +impl AsyncWrite for H2ActivityIo { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + let this = self.get_mut(); + match Pin::new(&mut this.inner).poll_write(cx, buf) { + Poll::Ready(Ok(written)) => { + if written > 0 { + this.state.mark_activity(); + } + Poll::Ready(Ok(written)) + } + other => other, + } + } + fn poll_flush(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_flush(cx) + } + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_shutdown(cx) + } +} + +struct H2StreamLease { + state: Arc, +} +impl H2StreamLease { + fn begin(state: Arc) -> Self { + state.active_streams.fetch_add(1, Ordering::AcqRel); + state.mark_activity(); + Self { state } + } +} +impl Drop for H2StreamLease { + fn drop(&mut self) { + self.state.active_streams.fetch_sub(1, Ordering::AcqRel); + self.state.mark_activity(); + } +} + +struct H2TrackedBody { + inner: Pin>, + stream: Option, +} +impl H2TrackedBody { + fn new(inner: B, stream: H2StreamLease) -> Self { + Self { + inner: Box::pin(inner), + stream: Some(stream), + } + } +} +impl HttpBody for H2TrackedBody +where + B: HttpBody, +{ + type Data = B::Data; + type Error = B::Error; + + fn poll_frame( + self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + ) -> Poll, Self::Error>>> { + let this = self.get_mut(); + match this.inner.as_mut().poll_frame(cx) { + Poll::Ready(None) => { + this.stream.take(); + Poll::Ready(None) + } + Poll::Ready(Some(Err(error))) => { + this.stream.take(); + Poll::Ready(Some(Err(error))) + } + other => other, + } + } + + fn is_end_stream(&self) -> bool { + self.inner.as_ref().get_ref().is_end_stream() + } + + fn size_hint(&self) -> http_body::SizeHint { + self.inner.as_ref().get_ref().size_hint() + } +} + +async fn wait_for_h2_idle(state: Arc, idle: Duration) { + loop { + // Register for a notification before inspecting the state. This avoids + // losing the final stream-drop notification between the load and await. + let notified = state.notify.notified(); + tokio::pin!(notified); + if state.active_streams.load(Ordering::Acquire) > 0 { + notified.as_mut().await; + continue; + } + let idle_for = state.idle_for(); + if idle_for >= idle { + return; + } + tokio::select! { + _ = tokio::time::sleep(idle.saturating_sub(idle_for)) => {}, + _ = notified.as_mut() => {}, + } + } +} + +struct ActiveHttp2Connection(Option>); +impl ActiveHttp2Connection { + fn begin(metrics: Option>) -> Self { + if let Some(metrics) = &metrics { + metrics + .http2_active_connections + .fetch_add(1, Ordering::Relaxed); + } + Self(metrics) + } +} +impl Drop for ActiveHttp2Connection { + fn drop(&mut self) { + if let Some(metrics) = &self.0 { + metrics + .http2_active_connections + .fetch_sub(1, Ordering::Relaxed); + } + } +} + +fn h2_prior_knowledge_prefix(bytes: &[u8]) -> bool { + bytes.len() <= HTTP2_PRIOR_KNOWLEDGE_PREFACE.len() + && HTTP2_PRIOR_KNOWLEDGE_PREFACE.starts_with(bytes) +} + +#[derive(Clone, Copy)] +pub struct PeerAddr(pub SocketAddr); +#[derive(Clone, Copy)] +pub struct LocalAddr(pub SocketAddr); +#[derive(Clone, Copy, Default)] +pub struct Transport { + pub tls: bool, +} +#[derive(Clone)] +pub struct ServerShutdown(pub CancellationToken); +#[derive(Clone)] +enum TlsMode { + Frontend(Arc, bool), + Fixed(Arc, Option), +} +/// Upgraded tunnels hold this lease after Hyper finishes its HTTP connection. +/// This prevents WebSockets from escaping the public connection limit/counter. +pub struct ConnectionLease { + _permit: OwnedSemaphorePermit, + metrics: Option>, +} +impl Drop for ConnectionLease { + fn drop(&mut self) { + if let Some(m) = &self.metrics { + m.active_connections.fetch_sub(1, Ordering::Relaxed); + } + } +} +pub async fn serve( + listener: TcpListener, + router: Router, + shutdown: CancellationToken, + max_connections: usize, + grace: Duration, + metrics: Option>, +) -> Result<()> { + serve_inner( + listener, + router, + shutdown, + Arc::new(Semaphore::new(max_connections)), + grace, + metrics, + None, + false, + None, + Http2Connections::default(), + ) + .await +} +pub async fn serve_public( + listener: TcpListener, + router: Router, + shutdown: CancellationToken, + permits: Arc, + grace: Duration, + metrics: Arc, + tls: Option>, +) -> Result<()> { + serve_frontend( + listener, + router, + shutdown, + permits, + grace, + metrics, + tls.clone().map(|app| (app, true)), + false, + tls, + ) + .await +} +pub async fn serve_frontend( + listener: TcpListener, + router: Router, + shutdown: CancellationToken, + permits: Arc, + grace: Duration, + metrics: Arc, + tls: Option<(Arc, bool)>, + h2c: bool, + frontend_state: Option>, +) -> Result<()> { + let http2_settings = frontend_state + .as_ref() + .map(|state| state.config.load().envelope.config.http2) + .unwrap_or_default(); + serve_frontend_with_http2( + listener, + router, + shutdown, + permits, + grace, + metrics, + tls, + h2c, + frontend_state, + http2_settings, + ) + .await +} + +/// Start one compiled frontend listener with its immutable per-listener H2 +/// lifecycle policy. The public `serve_frontend` wrapper retains the legacy +/// global fallback for callers that do not provide a compiled listener plan. +pub async fn serve_frontend_with_http2( + listener: TcpListener, + router: Router, + shutdown: CancellationToken, + permits: Arc, + grace: Duration, + metrics: Arc, + tls: Option<(Arc, bool)>, + h2c: bool, + frontend_state: Option>, + http2_settings: Http2Connections, +) -> Result<()> { + serve_inner( + listener, + router, + shutdown, + permits, + grace, + Some(metrics), + tls.map(|(app, http2)| TlsMode::Frontend(app, http2)), + h2c, + frontend_state, + http2_settings, + ) + .await +} +pub async fn serve_tls( + listener: TcpListener, + router: Router, + shutdown: CancellationToken, + max_connections: usize, + grace: Duration, + tls: Arc, +) -> Result<()> { + serve_inner( + listener, + router, + shutdown, + Arc::new(Semaphore::new(max_connections)), + grace, + None, + Some(TlsMode::Fixed(tls, None)), + false, + None, + Http2Connections::default(), + ) + .await +} +/// Serve an HTTPS socket while turning accidental/plain HTTP requests on the +/// same port into redirects. No administration handler is exposed through the +/// plaintext router. +pub async fn serve_tls_with_plain_redirect( + listener: TcpListener, + router: Router, + plaintext_router: Router, + shutdown: CancellationToken, + max_connections: usize, + grace: Duration, + tls: Arc, +) -> Result<()> { + serve_inner( + listener, + router, + shutdown, + Arc::new(Semaphore::new(max_connections)), + grace, + None, + Some(TlsMode::Fixed(tls, Some(plaintext_router))), + false, + None, + Http2Connections::default(), + ) + .await +} +async fn serve_inner( + listener: TcpListener, + router: Router, + shutdown: CancellationToken, + permits: Arc, + grace: Duration, + metrics: Option>, + tls: Option, + h2c: bool, + frontend_state: Option>, + http2_settings: Http2Connections, +) -> Result<()> { + let mut tasks = JoinSet::new(); + loop { + tokio::select! { + biased; + _ = shutdown.cancelled() => break, + _ = tasks.join_next(), if !tasks.is_empty() => {}, + accepted = listener.accept() => { + let (stream, peer) = accepted.context("listener accept failed")?; + // Never substitute the wildcard listener address or a forwarded + // header for the destination IP of this accepted connection. + let local = stream.local_addr().context("accepted local address unavailable")?; + let Ok(permit) = permits.clone().try_acquire_owned() else { + if let Some(m) = &metrics { m.rejected_connections.fetch_add(1, Ordering::Relaxed); } + drop(stream); continue; + }; + let _ = stream.set_nodelay(true); + if let Some(m) = &metrics { + m.connections.fetch_add(1, Ordering::Relaxed); m.active_connections.fetch_add(1, Ordering::Relaxed); + } + let lease = Arc::new(ConnectionLease { _permit: permit, metrics: metrics.clone() }); + let router = router.clone(); let stop = shutdown.clone(); let tls = tls.clone(); let metrics = metrics.clone(); + let frontend_state = frontend_state.clone(); + tasks.spawn(async move { + match tls { + Some(TlsMode::Frontend(state, http2)) => { + // Read ClientHello before committing to ALPN so a vhost can opt out + // of HTTP/2 or narrow TLS policy while sharing one TLS listener. + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + let acceptor = tokio_rustls::LazyConfigAcceptor::new( + rustls::server::Acceptor::default(), + stream, + ); + let started = tokio::select! { + _ = stop.cancelled() => return, + started = tokio::time::timeout_at(deadline, acceptor) => started, + }; + let Ok(Ok(start)) = started else { + state.metrics.tls_handshake_errors.fetch_add(1, Ordering::Relaxed); + return; + }; + let config = { + let snapshot = state.config.load(); + let host = start + .client_hello() + .server_name() + .and_then(|name| crate::config::request_host(name).ok()); + let frontend = host + .as_deref() + .and_then(|host| snapshot.frontend(host, local, true)); + let local_host = host + .as_deref() + .and_then(|host| snapshot.not_found_host_on(host, local, true)); + if let Some(frontend) = frontend { + if http2 && frontend.http2_enabled { + frontend.tls.clone() + } else { + frontend.tls_http1.clone() + } + } else if http2 && local_host.is_none_or(|item| item.http2_enabled) { + snapshot.tls.clone() + } else { + snapshot.tls_http1.clone() + } + }; + let Some(config) = config else { return; }; + let accepted = tokio::select! { + _ = stop.cancelled() => return, + accepted = tokio::time::timeout_at(deadline, start.into_stream(config)) => accepted, + }; + match accepted { + Ok(Ok(stream)) => { + let h2 = stream.get_ref().1.alpn_protocol() == Some(b"h2".as_slice()); + state.metrics.tls_connections.fetch_add(1, Ordering::Relaxed); + if h2 { state.metrics.http2_connections.fetch_add(1, Ordering::Relaxed); } + let extended_connect = h2 && state.config.load().websocket_rfc8441_on(local, true); + connection( + stream, + router, + peer, + local, + Transport { tls: true }, + h2, + extended_connect, + false, + None, + lease, + stop, + grace, + http2_settings, + ) + .await; + }, + _ => { state.metrics.tls_handshake_errors.fetch_add(1, Ordering::Relaxed); }, + } + } + Some(TlsMode::Fixed(config, plaintext_router)) => { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + if let Some(plain) = plaintext_router { + let mut first = [0u8; 1]; + let peeked = tokio::select! { + _ = stop.cancelled() => return, + peeked = tokio::time::timeout_at(deadline, stream.peek(&mut first)) => peeked, + }; + match peeked { + Ok(Ok(n)) if n > 0 && first[0] != 0x16 => { + connection( + stream, + plain, + peer, + local, + Transport { tls: false }, + false, + false, + false, + None, + lease, + stop, + grace, + Http2Connections::default(), + ) + .await; + return; + } + Ok(Ok(n)) if n > 0 => {} + _ => return, + } + } + let acceptor = tokio_rustls::TlsAcceptor::from(config); + let accepted = tokio::select! { + _ = stop.cancelled() => return, + accepted = tokio::time::timeout_at(deadline, acceptor.accept(stream)) => accepted, + }; + if let Ok(Ok(stream)) = accepted { + let h2 = stream.get_ref().1.alpn_protocol() == Some(b"h2".as_slice()); + connection( + stream, + router, + peer, + local, + Transport { tls: true }, + h2, + false, + false, + None, + lease, + stop, + grace, + Http2Connections::default(), + ) + .await; + } + } + None => { + let prior_h2 = if h2c { + let detected = tokio::time::timeout(Duration::from_secs(10), async { + let mut preface = [0u8; HTTP2_PRIOR_KNOWLEDGE_PREFACE.len()]; + loop { + let n = stream.peek(&mut preface).await?; + if n == 0 { + return Ok::(false); + } + let prefix = &preface[..n.min(HTTP2_PRIOR_KNOWLEDGE_PREFACE.len())]; + if !h2_prior_knowledge_prefix(prefix) { + return Ok(false); + } + if n >= HTTP2_PRIOR_KNOWLEDGE_PREFACE.len() { + return Ok(prefix == HTTP2_PRIOR_KNOWLEDGE_PREFACE); + } + tokio::task::yield_now().await; + } + }).await; + matches!(detected, Ok(Ok(true))) + } else { + false + }; + if prior_h2 { + if let Some(m) = &metrics { m.http2_connections.fetch_add(1, Ordering::Relaxed); } + } + let extended_connect = prior_h2 + && frontend_state + .as_ref() + .is_some_and(|state| state.config.load().websocket_rfc8441_on(local, false)); + connection( + stream, + router, + peer, + local, + Transport { tls: false }, + prior_h2, + extended_connect, + h2c && !prior_h2, + frontend_state, + lease, + stop, + grace, + http2_settings, + ) + .await; + } + } + }); + }, + } + } + let _ = tokio::time::timeout(grace + Duration::from_secs(1), async { + while tasks.join_next().await.is_some() {} + }) + .await; + tasks.abort_all(); + while tasks.join_next().await.is_some() {} + Ok(()) +} +#[allow(clippy::too_many_arguments)] +async fn connection( + stream: I, + router: Router, + peer: SocketAddr, + local: SocketAddr, + transport: Transport, + h2: bool, + extended_connect: bool, + h2c_upgrade: bool, + h2c_state: Option>, + lease: Arc, + stop: CancellationToken, + grace: Duration, + http2_settings: Http2Connections, +) where + I: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + if h2 { + http2_connection( + stream, + router, + peer, + local, + transport, + extended_connect, + lease, + stop, + grace, + http2_settings, + ) + .await; + return; + } + let request_stop = stop.clone(); + let service = service_fn(move |mut request: Request| { + let router = router.clone(); + let request_stop = request_stop.clone(); + let request_lease = lease.clone(); + let upgrade_state = h2c_state.clone(); + async move { + request.extensions_mut().insert(PeerAddr(peer)); + request.extensions_mut().insert(LocalAddr(local)); + request.extensions_mut().insert(transport); + request.extensions_mut().insert(request_lease.clone()); + request + .extensions_mut() + .insert(ServerShutdown(request_stop.clone())); + + if h2c_attempt(&request) { + if !h2c_upgrade { + return Ok::<_, Infallible>( + Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .header(http::header::CACHE_CONTROL, "no-store") + .body(Body::from("h2c upgrade is disabled on this listener")) + .expect("static h2c disabled response"), + ); + } + let injected_headers = match h2_headers_frame(&request) { + Ok(frame) => frame, + Err(message) => { + return Ok::<_, Infallible>( + Response::builder() + .status(http::StatusCode::BAD_REQUEST) + .header(http::header::CACHE_CONTROL, "no-store") + .body(Body::from(message)) + .expect("static h2c validation response"), + ) + } + }; + let on_upgrade = hyper::upgrade::on(&mut request); + let upgrade_router = router.clone(); + let upgrade_stop = request_stop.clone(); + let upgrade_lease = request_lease.clone(); + let upgrade_state_for_task = upgrade_state.clone(); + let task = async move { + match on_upgrade.await { + Ok(upgraded) => { + if let Some(state) = &upgrade_state_for_task { + state.metrics.http2_connections.fetch_add(1, Ordering::Relaxed); + } + let extended_connect = upgrade_state_for_task.as_ref().is_some_and(|state| { + state.config.load().websocket_rfc8441_on(local, false) + }); + let io = H2cUpgradeIo::new(TokioIo::new(upgraded), injected_headers); + http2_connection( + io, + upgrade_router, + peer, + local, + Transport { tls: false }, + extended_connect, + upgrade_lease, + upgrade_stop, + grace, + http2_settings, + ) + .await; + } + Err(error) => tracing::debug!(%error, "h2c upgrade transport failed"), + } + }; + if let Some(state) = upgrade_state { + state.tunnels.spawn(task); + } else { + tokio::spawn(task); + } + return Ok::<_, Infallible>( + Response::builder() + .status(http::StatusCode::SWITCHING_PROTOCOLS) + .header(http::header::CONNECTION, "Upgrade") + .header(http::header::UPGRADE, "h2c") + .body(Body::empty()) + .expect("static h2c switching response"), + ); + } + + router.oneshot(request.map(Body::new)).await + } + }); + let mut builder = http1::Builder::new(); + builder + .timer(TokioTimer::new()) + .keep_alive(true) + .max_headers(64) + .max_buf_size(65_536) + .header_read_timeout(Duration::from_secs(10)); + let conn = builder + .serve_connection(TokioIo::new(stream), service) + .with_upgrades(); + tokio::pin!(conn); + tokio::select! { + result = &mut conn => { if let Err(error) = result { tracing::debug!(%error, "HTTP/1 connection closed"); } }, + _ = stop.cancelled() => { + conn.as_mut().graceful_shutdown(); let _ = tokio::time::timeout(grace, &mut conn).await; + }, + } +} + +#[derive(Clone, Copy, Debug)] +enum H2ShutdownCause { + Idle, + Lifetime, + Process, +} + +#[allow(clippy::too_many_arguments)] +async fn http2_connection( + stream: I, + router: Router, + peer: SocketAddr, + local: SocketAddr, + transport: Transport, + extended_connect: bool, + lease: Arc, + stop: CancellationToken, + grace: Duration, + settings: Http2Connections, +) where + I: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let request_stop = stop.clone(); + let connection_metrics = lease.metrics.clone(); + let activity = H2ConnectionState::new(); + let service_activity = activity.clone(); + let service = service_fn(move |mut request: Request| { + let stream_lease = H2StreamLease::begin(service_activity.clone()); + request.extensions_mut().insert(PeerAddr(peer)); + request.extensions_mut().insert(LocalAddr(local)); + request.extensions_mut().insert(transport); + request.extensions_mut().insert(lease.clone()); + request + .extensions_mut() + .insert(ServerShutdown(request_stop.clone())); + let router = router.clone(); + async move { + router + .oneshot(request.map(Body::new)) + .await + .map(|response| { + response.map(|body| Body::new(H2TrackedBody::new(body, stream_lease))) + }) + } + }); + let _active_http2 = ActiveHttp2Connection::begin(connection_metrics); + let stream = H2ActivityIo::new(stream, activity.clone()); + let mut builder = http2::Builder::new(TokioExecutor::new()); + builder + .timer(TokioTimer::new()) + .max_concurrent_streams(64) + .max_header_list_size(32_768) + .max_send_buf_size(65_536) + .initial_stream_window_size(65_535) + .initial_connection_window_size(1_048_576); + if extended_connect { + builder.enable_connect_protocol(); + } + let conn = builder.serve_connection(TokioIo::new(stream), service); + tokio::pin!(conn); + + let max_lifetime = settings.max_connection_lifetime(); + let lifetime = async move { + if let Some(duration) = max_lifetime { + tokio::time::sleep(duration).await; + } else { + std::future::pending::<()>().await; + } + }; + tokio::pin!(lifetime); + + let cause = loop { + tokio::select! { + result = &mut conn => { + if let Err(error) = result { + tracing::debug!(%error, "HTTP/2 connection closed"); + } + return; + }, + _ = stop.cancelled() => break H2ShutdownCause::Process, + _ = &mut lifetime => break H2ShutdownCause::Lifetime, + _ = wait_for_h2_idle(activity.clone(), settings.idle_timeout()) => { + // Re-check immediately before GOAWAY so activity racing the timer + // cannot turn a busy connection into an idle shutdown. + if activity.is_idle(settings.idle_timeout()) { + break H2ShutdownCause::Idle; + } + }, + } + }; + + // Hyper's graceful shutdown sends GOAWAY and refuses newer streams while + // continuing to poll streams that were already accepted. + conn.as_mut().graceful_shutdown(); + let configured_drain = settings.drain_timeout(); + let drain = if matches!(cause, H2ShutdownCause::Process) { + std::cmp::min(configured_drain, grace) + } else { + configured_drain + }; + match tokio::time::timeout(drain, &mut conn).await { + Ok(Ok(())) => tracing::debug!(?cause, "HTTP/2 graceful shutdown drained"), + Ok(Err(error)) => tracing::debug!(?cause, %error, "HTTP/2 graceful shutdown ended with protocol error"), + Err(_) => tracing::debug!(?cause, drain_seconds = drain.as_secs_f64(), "HTTP/2 drain timeout reached; closing transport"), + } +} + +#[cfg(test)] +mod h2c_tests { + use super::*; + use axum::routing::get; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[test] + fn h2_prior_knowledge_preface_is_distinguished_from_http1() { + assert!(h2_prior_knowledge_prefix(b"P")); + assert!(h2_prior_knowledge_prefix(b"PRI * HTTP/2.0\r\n")); + assert!(h2_prior_knowledge_prefix(HTTP2_PRIOR_KNOWLEDGE_PREFACE)); + assert!(!h2_prior_knowledge_prefix(b"POST / HTTP/1.1\r\n")); + assert!(!h2_prior_knowledge_prefix(b"GET / HTTP/1.1\r\n")); + } + + #[test] + fn h2c_upgrade_validation_rejects_missing_settings_and_bodies() { + let missing = Request::builder() + .method("GET") + .uri("/upgrade") + .version(http::Version::HTTP_11) + .header("host", "localhost") + .header("connection", "Upgrade") + .header("upgrade", "h2c") + .body(()) + .unwrap(); + assert!(h2_headers_frame(&missing).is_err()); + + let body = Request::builder() + .method("POST") + .uri("/upgrade") + .version(http::Version::HTTP_11) + .header("host", "localhost") + .header("connection", "Upgrade, HTTP2-Settings") + .header("upgrade", "h2c") + .header("http2-settings", "") + .header("content-length", "1") + .body(()) + .unwrap(); + assert!(h2_headers_frame(&body).is_err()); + } + + async fn read_http1_head(stream: &mut tokio::net::TcpStream) -> Vec { + let mut all = Vec::new(); + let mut byte = [0u8; 1]; + while !all.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).await.unwrap(); + all.push(byte[0]); + assert!(all.len() < 8192, "HTTP/1 response head exceeded test bound"); + } + all + } + + async fn try_read_h2_frame( + stream: &mut tokio::net::TcpStream, + ) -> io::Result<(u8, u8, u32, Vec)> { + let mut header = [0u8; 9]; + stream.read_exact(&mut header).await?; + let length = ((header[0] as usize) << 16) | ((header[1] as usize) << 8) | header[2] as usize; + if length > 64 * 1024 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "unexpectedly large HTTP/2 test frame")); + } + let mut payload = vec![0u8; length]; + stream.read_exact(&mut payload).await?; + let stream_id = u32::from_be_bytes([header[5], header[6], header[7], header[8]]) & 0x7fff_ffff; + Ok((header[3], header[4], stream_id, payload)) + } + + async fn read_h2_frame(stream: &mut tokio::net::TcpStream) -> (u8, u8, u32, Vec) { + try_read_h2_frame(stream).await.unwrap() + } + + async fn start_direct_h2( + router: Router, + settings: Http2Connections, + ) -> (tokio::net::TcpStream, CancellationToken, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let stop = CancellationToken::new(); + let server_stop = stop.clone(); + let server = tokio::spawn(async move { + let (stream, peer) = listener.accept().await.unwrap(); + let local = stream.local_addr().unwrap(); + let permit = Arc::new(Semaphore::new(1)).acquire_owned().await.unwrap(); + let lease = Arc::new(ConnectionLease { + _permit: permit, + metrics: None, + }); + http2_connection( + stream, + router, + peer, + local, + Transport { tls: false }, + false, + lease, + server_stop, + Duration::from_secs(5), + settings, + ) + .await; + }); + let mut client = tokio::net::TcpStream::connect(address).await.unwrap(); + client.write_all(HTTP2_PRIOR_KNOWLEDGE_PREFACE).await.unwrap(); + client + .write_all(&[0, 0, 0, 0x4, 0, 0, 0, 0, 0]) + .await + .unwrap(); + (client, stop, server) + } + + #[tokio::test] + async fn http1_h2c_upgrade_bootstraps_request_as_http2_stream_one() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let stop = CancellationToken::new(); + let server_stop = stop.clone(); + let router = Router::new().route("/upgrade", get(|| async { "h2c-ok" })); + let server = tokio::spawn(async move { + serve_inner( + listener, + router, + server_stop, + Arc::new(Semaphore::new(8)), + Duration::from_secs(1), + None, + None, + true, + None, + ) + .await + .unwrap(); + }); + + let mut client = tokio::net::TcpStream::connect(address).await.unwrap(); + client + .write_all( + b"GET /upgrade HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade, HTTP2-Settings\r\nUpgrade: h2c\r\nHTTP2-Settings: \r\n\r\n", + ) + .await + .unwrap(); + let response = read_http1_head(&mut client).await; + let response = String::from_utf8(response).unwrap(); + assert!(response.starts_with("HTTP/1.1 101"), "{response}"); + assert!(response.to_ascii_lowercase().contains("upgrade: h2c")); + + client.write_all(HTTP2_PRIOR_KNOWLEDGE_PREFACE).await.unwrap(); + // Client connection preface must begin with a non-ACK SETTINGS frame. + client + .write_all(&[0, 0, 0, 0x4, 0, 0, 0, 0, 0]) + .await + .unwrap(); + + let mut saw_stream_one_headers = false; + let mut body = Vec::new(); + for _ in 0..16 { + let (kind, flags, stream_id, payload) = tokio::time::timeout( + Duration::from_secs(2), + read_h2_frame(&mut client), + ) + .await + .expect("HTTP/2 response frame timeout"); + if kind == 0x1 && stream_id == 1 { + saw_stream_one_headers = true; + } + if kind == 0x0 && stream_id == 1 { + body.extend_from_slice(&payload); + if flags & 0x1 != 0 { + break; + } + } + } + assert!(saw_stream_one_headers, "missing stream 1 response HEADERS"); + assert_eq!(body, b"h2c-ok"); + + stop.cancel(); + server.await.unwrap(); + } + + #[tokio::test] + async fn h2_idle_connection_sends_goaway_before_closing() { + let settings = Http2Connections { + idle_timeout_seconds: 1, + max_connection_lifetime_seconds: 0, + drain_timeout_seconds: 1, + }; + let (mut client, stop, server) = start_direct_h2(Router::new(), settings).await; + let saw_goaway = tokio::time::timeout(Duration::from_secs(3), async { + loop { + match try_read_h2_frame(&mut client).await { + Ok((0x7, _, 0, _)) => return true, + Ok(_) => {} + Err(_) => return false, + } + } + }) + .await + .expect("idle HTTP/2 shutdown did not complete"); + assert!(saw_goaway, "idle shutdown must send GOAWAY"); + stop.cancel(); + server.await.unwrap(); + } + + #[tokio::test] + async fn h2_idle_wait_does_not_close_an_active_stream() { + let state = H2ConnectionState::new(); + let stream = H2StreamLease::begin(state.clone()); + tokio::time::sleep(Duration::from_millis(30)).await; + assert!(tokio::time::timeout( + Duration::from_millis(30), + wait_for_h2_idle(state.clone(), Duration::from_millis(10)) + ) + .await + .is_err()); + drop(stream); + tokio::time::timeout( + Duration::from_millis(100), + wait_for_h2_idle(state, Duration::from_millis(10)), + ) + .await + .expect("idle shutdown should become eligible after the stream finishes"); + } + + #[tokio::test] + async fn h2_lifetime_goaway_is_bounded_by_drain_timeout() { + let blocker = Arc::new(Notify::new()); + let wait = blocker.clone(); + let router = Router::new().route( + "/hold", + get(move || { + let wait = wait.clone(); + async move { + wait.notified().await; + "done" + } + }), + ); + let settings = Http2Connections { + idle_timeout_seconds: 60, + max_connection_lifetime_seconds: 1, + drain_timeout_seconds: 1, + }; + let (mut client, _stop, server) = start_direct_h2(router, settings).await; + let upgrade = Request::builder() + .method("GET") + .uri("/hold") + .version(http::Version::HTTP_11) + .header("host", "localhost") + .header("connection", "Upgrade, HTTP2-Settings") + .header("upgrade", "h2c") + .header("http2-settings", "") + .body(()) + .unwrap(); + client.write_all(&h2_headers_frame(&upgrade).unwrap()).await.unwrap(); + + let saw_goaway_and_close = tokio::time::timeout(Duration::from_secs(4), async { + let mut saw_goaway = false; + loop { + match try_read_h2_frame(&mut client).await { + Ok((0x7, _, 0, _)) => saw_goaway = true, + Ok(_) => {} + Err(error) if matches!(error.kind(), io::ErrorKind::UnexpectedEof | io::ErrorKind::ConnectionReset) => { + return saw_goaway; + } + Err(_) => return saw_goaway, + } + } + }) + .await + .expect("lifetime/drain shutdown exceeded its bound"); + assert!(saw_goaway_and_close, "lifetime shutdown must send GOAWAY before drain timeout closes TCP"); + server.await.unwrap(); + drop(blocker); + } + + #[tokio::test] + async fn h2_lifetime_allows_active_stream_to_finish_during_drain() { + let blocker = Arc::new(Notify::new()); + let wait = blocker.clone(); + let router = Router::new().route( + "/hold", + get(move || { + let wait = wait.clone(); + async move { + wait.notified().await; + "done" + } + }), + ); + let settings = Http2Connections { + idle_timeout_seconds: 60, + max_connection_lifetime_seconds: 1, + drain_timeout_seconds: 3, + }; + let (mut client, _stop, server) = start_direct_h2(router, settings).await; + let request = Request::builder() + .method("GET") + .uri("/hold") + .version(http::Version::HTTP_11) + .header("host", "localhost") + .header("connection", "Upgrade, HTTP2-Settings") + .header("upgrade", "h2c") + .header("http2-settings", "") + .body(()) + .unwrap(); + client.write_all(&h2_headers_frame(&request).unwrap()).await.unwrap(); + let release = blocker.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(1_250)).await; + release.notify_waiters(); + }); + + let (saw_goaway, saw_end_stream) = tokio::time::timeout(Duration::from_secs(4), async { + let mut saw_goaway = false; + let mut saw_end_stream = false; + loop { + match try_read_h2_frame(&mut client).await { + Ok((0x7, _, 0, _)) => saw_goaway = true, + Ok((kind, flags, 1, _)) if (kind == 0x0 || kind == 0x1) && flags & 0x1 != 0 => { + saw_end_stream = true; + if saw_goaway { return (true, true); } + } + Ok(_) => {} + Err(_) => return (saw_goaway, saw_end_stream), + } + } + }) + .await + .expect("active stream did not drain within the configured bound"); + assert!(saw_goaway, "lifetime shutdown must send GOAWAY"); + assert!(saw_end_stream, "an accepted stream must be allowed to finish during drain"); + server.await.unwrap(); + } + + #[tokio::test] + async fn malformed_h2c_upgrade_is_rejected_before_switching_protocols() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let stop = CancellationToken::new(); + let server_stop = stop.clone(); + let server = tokio::spawn(async move { + serve_inner( + listener, + Router::new(), + server_stop, + Arc::new(Semaphore::new(8)), + Duration::from_secs(1), + None, + None, + true, + None, + ) + .await + .unwrap(); + }); + let mut client = tokio::net::TcpStream::connect(address).await.unwrap(); + client + .write_all( + b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: h2c\r\n\r\n", + ) + .await + .unwrap(); + let response = String::from_utf8(read_http1_head(&mut client).await).unwrap(); + assert!(response.starts_with("HTTP/1.1 400"), "{response}"); + stop.cancel(); + server.await.unwrap(); + } +} diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..d1c6780 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,658 @@ +//! Persisted listener intent and response controls. Listener changes require restart. +use anyhow::{ensure, Result}; +use http::{HeaderMap, HeaderValue}; +use serde::{Deserialize, Serialize}; +use std::{net::SocketAddr, time::Duration}; + +/// Listener addresses explicitly supplied by the service manager/CLI. These +/// are process-level contracts and cannot be removed or rewritten through the +/// persisted control plane while the process is started with those values. +#[derive(Debug, Clone, Copy, Default)] +pub struct StartupLocks { + pub http: Option, + pub https: Option, + pub administration: Option, +} +impl StartupLocks { + pub fn ensure_config(&self, config: &mut crate::config::Config) { + if self.administration.is_some() { + // Explicit startup administration bind remains authoritative. `None` + // means "use startup value" and also preserves the plain-HTTP safety + // gate handled by main. + config.administration.listen = None; + } + if let Some(address) = self.http { + ensure_locked_listener(config, address, false); + } + if let Some(address) = self.https { + ensure_locked_listener(config, address, true); + } + } + + pub fn validate( + &self, + config: &crate::config::Config, + startup_admin: SocketAddr, + ) -> Result<()> { + if let Some(required) = self.http { + ensure!( + config.listeners.endpoints.iter().any(|endpoint| { + endpoint.enabled + && endpoint.bindings().iter().any(|binding| binding.address == required && !binding.tls) + }), + "HTTP listener {required} is fixed by systemd/startup and cannot be removed, disabled, or changed" + ); + } + if let Some(required) = self.https { + ensure!( + config.listeners.endpoints.iter().any(|endpoint| { + endpoint.enabled + && endpoint.bindings().iter().any(|binding| binding.address == required && binding.tls) + }), + "HTTPS listener {required} is fixed by systemd/startup and cannot be removed, disabled, or changed" + ); + } + if let Some(required) = self.administration { + ensure!( + config.administration.listen.is_none() + && config.administration.resolved_listen(startup_admin) == required, + "administration listener {required} is fixed by systemd/startup and cannot be changed" + ); + } + Ok(()) + } +} + +fn ensure_locked_listener(config: &mut crate::config::Config, address: SocketAddr, tls: bool) { + if let Some(endpoint) = config.listeners.endpoints.iter_mut().find(|endpoint| { + endpoint + .bindings() + .iter() + .any(|binding| binding.address == address && binding.tls == tls) + }) { + endpoint.enabled = true; + return; + } + let base = if tls { "systemd-https" } else { "systemd-http" }; + let mut id = base.to_owned(); + let mut suffix = 2usize; + while config + .listeners + .endpoints + .iter() + .any(|endpoint| endpoint.id == id) + { + id = format!("{base}-{suffix}"); + suffix += 1; + } + config.listeners.endpoints.push(NamedListener { + id, + name: if tls { + "HTTPS (startup locked)".into() + } else { + "HTTP (startup locked)".into() + }, + scope: if address.ip().is_loopback() { + ListenerScope::Internal + } else { + ListenerScope::Public + }, + enabled: true, + binding: crate::vhost::Listener { + address, + tls, + http2: tls, + http2_settings: tls.then(Http2Connections::default), + h2c: false, + http3: tls.then(crate::http3::Settings::default), + }, + additional_addresses: Vec::new(), + }); +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default, deny_unknown_fields)] +pub struct Http2Connections { + /// Close an otherwise unused HTTP/2 connection after this inactivity period. + pub idle_timeout_seconds: u64, + /// Absolute connection age limit. Zero disables the lifetime limit. + pub max_connection_lifetime_seconds: u64, + /// Maximum time after GOAWAY for active streams to finish before TCP is closed. + pub drain_timeout_seconds: u64, +} +impl Default for Http2Connections { + fn default() -> Self { + Self { + idle_timeout_seconds: 300, + max_connection_lifetime_seconds: 0, + drain_timeout_seconds: 10, + } + } +} +impl Http2Connections { + pub fn validate(&self) -> Result<()> { + ensure!( + (1..=86_400).contains(&self.idle_timeout_seconds), + "HTTP/2 idle_timeout_seconds must be 1..86400" + ); + ensure!( + self.max_connection_lifetime_seconds == 0 + || (1..=2_592_000).contains(&self.max_connection_lifetime_seconds), + "HTTP/2 max_connection_lifetime_seconds must be 0..2592000 (0 disables the limit)" + ); + ensure!( + (1..=3_600).contains(&self.drain_timeout_seconds), + "HTTP/2 drain_timeout_seconds must be 1..3600" + ); + Ok(()) + } + pub fn idle_timeout(self) -> Duration { + Duration::from_secs(self.idle_timeout_seconds) + } + pub fn max_connection_lifetime(self) -> Option { + (self.max_connection_lifetime_seconds > 0) + .then(|| Duration::from_secs(self.max_connection_lifetime_seconds)) + } + pub fn drain_timeout(self) -> Duration { + Duration::from_secs(self.drain_timeout_seconds) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default, deny_unknown_fields)] +pub struct Administration { + /// None keeps the startup/CLI administration address for backward compatibility. + /// A persisted address is applied on the next process start. + pub listen: Option, + pub tls: bool, + /// None uses the application-managed default certificate pair. + pub certificate_id: Option, + /// Optional strict DNS Host/Origin allowlist. Empty HTTPS lists accept valid DNS names. + pub allowed_hosts: Vec, + /// Absolute browser-session lifetime. Existing sessions are bounded by the current value. + pub session_days: u16, +} +impl Default for Administration { + fn default() -> Self { + Self { + listen: None, + tls: false, + certificate_id: None, + allowed_hosts: Vec::new(), + session_days: 30, + } + } +} +impl Administration { + pub fn resolved_listen(&self, startup: SocketAddr) -> SocketAddr { + self.listen.unwrap_or(startup) + } + pub fn session_seconds(&self) -> i64 { + i64::from(self.session_days) * 24 * 60 * 60 + } + pub fn restart_differs(&self, active: &Self) -> bool { + self.listen != active.listen + || self.tls != active.tls + || self.certificate_id != active.certificate_id + || self.allowed_hosts != active.allowed_hosts + } + pub fn validate(&mut self, tls: &crate::tls::Settings) -> Result<()> { + if let Some(addr) = self.listen { + ensure!( + addr.port() > 0, + "administration listener port must be nonzero" + ); + ensure!( + !addr.ip().is_multicast(), + "administration listener must use a unicast/wildcard IP" + ); + } + if let Some(id) = &self.certificate_id { + ensure!( + self.tls, + "administration certificate selection requires administration TLS" + ); + ensure!( + tls.certificates + .iter() + .any(|certificate| &certificate.id == id), + "administration certificate references an unknown certificate id" + ); + } + ensure!( + (7..=30).contains(&self.session_days), + "administration session_days must be 7..30" + ); + let mut seen = std::collections::HashSet::new(); + for host in &mut self.allowed_hosts { + if host.parse::().is_err() { + *host = crate::config::normalize_host(host)?; + } + ensure!( + seen.insert(host.clone()), + "duplicate administration hostname" + ); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Listeners { + /// All proxy sockets are configured here and referenced by vhosts by ID. + pub endpoints: Vec, +} +impl Listeners { + pub fn from_startup(http: SocketAddr, https: Option) -> Self { + let mut endpoints = vec![NamedListener { + id: "http".into(), + name: "HTTP".into(), + scope: ListenerScope::Internal, + enabled: true, + binding: crate::vhost::Listener { + address: http, + tls: false, + http2: false, + http2_settings: None, + h2c: false, + http3: None, + }, + additional_addresses: Vec::new(), + }]; + if let Some(address) = https { + endpoints.push(NamedListener { + id: "https".into(), + name: "HTTPS".into(), + scope: ListenerScope::Internal, + enabled: true, + binding: crate::vhost::Listener { + address, + tls: true, + http2: true, + http2_settings: Some(Http2Connections::default()), + h2c: false, + http3: Some(crate::http3::Settings::default()), + }, + additional_addresses: Vec::new(), + }); + } + Self { endpoints } + } + + pub fn validate(&self, policy: &crate::config::ValidationPolicy) -> Result<()> { + ensure!(self.endpoints.len() <= 64, "at most 64 named listeners"); + let mut ids = std::collections::HashSet::new(); + for endpoint in &self.endpoints { + ensure!( + crate::config::valid_id(&endpoint.id) && ids.insert(&endpoint.id), + "invalid/duplicate listener id" + ); + ensure!( + endpoint.name.len() <= 96 && !endpoint.name.chars().any(char::is_control), + "listener name must be at most 96 bytes without controls" + ); + ensure!( + endpoint.additional_addresses.len() <= 15, + "listener allows at most 16 bind addresses" + ); + let bindings = endpoint.bindings(); + let mut addresses = std::collections::HashSet::new(); + for binding in &bindings { + crate::vhost::validate_listener(*binding)?; + ensure!( + binding.address.port() != policy.admin_addr.port(), + "listener cannot share administration port" + ); + ensure!( + addresses.insert(binding.address), + "duplicate address in named listener" + ); + } + if bindings.len() > 1 { + ensure!( + bindings.iter().all(|binding| !binding.address.ip().is_unspecified()), + "multi-address listeners require explicit IPs; do not combine 0.0.0.0/:: with selected addresses" + ); + } + } + Ok(()) + } +} +/// Labels describe operator intent, never firewall or access-control enforcement. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ListenerScope { + #[default] + Internal, + Public, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NamedListener { + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub scope: ListenerScope, + #[serde(default = "enabled")] + pub enabled: bool, + pub binding: crate::vhost::Listener, + /// Extra explicit local addresses that share the same transport/protocol policy. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub additional_addresses: Vec, +} +impl NamedListener { + pub fn bindings(&self) -> Vec { + let mut out = Vec::with_capacity(1 + self.additional_addresses.len()); + out.push(self.binding); + out.extend(self.additional_addresses.iter().map(|address| { + let mut binding = self.binding; + binding.address = *address; + binding + })); + out + } +} +fn enabled() -> bool { + true +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct DiagnosticHeaders { + pub request_id: bool, +} +impl DiagnosticHeaders { + pub fn apply(&self, headers: &mut HeaderMap, id: u64) { + headers.remove("x-request-id"); + if self.request_id { + if let Ok(value) = id.to_string().parse() { + headers.insert("x-request-id", value); + } + } + } +} +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ServerMode { + #[default] + Preserve, + Hide, + Custom, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ServerHeader { + pub mode: ServerMode, + pub value: String, +} +impl Default for ServerHeader { + fn default() -> Self { + Self { + mode: ServerMode::Preserve, + value: format!("RustProxy/v{}", env!("CARGO_PKG_VERSION")), + } + } +} +impl ServerHeader { + pub fn validate(&self) -> Result<()> { + ensure!( + self.value.len() <= 256, + "Server header value exceeds 256 bytes" + ); + if matches!(self.mode, ServerMode::Custom) { + ensure!( + !self.value.trim().is_empty(), + "custom Server header cannot be empty" + ); + self.value.parse::()?; + } + Ok(()) + } + pub fn apply(&self, headers: &mut HeaderMap) { + match self.mode { + ServerMode::Preserve => {} + ServerMode::Hide => { + headers.remove("server"); + } + ServerMode::Custom => { + if let Ok(value) = self.value.parse() { + headers.insert("server", value); + } + } + } + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn http2_connection_limits_default_and_validate_independently() { + let defaults: Http2Connections = serde_json::from_str("{}").unwrap(); + assert_eq!(defaults.idle_timeout_seconds, 300); + assert_eq!(defaults.max_connection_lifetime_seconds, 0); + assert_eq!(defaults.drain_timeout_seconds, 10); + defaults.validate().unwrap(); + + let configured = Http2Connections { + idle_timeout_seconds: 45, + max_connection_lifetime_seconds: 3_600, + drain_timeout_seconds: 20, + }; + configured.validate().unwrap(); + assert_eq!(configured.idle_timeout(), Duration::from_secs(45)); + assert_eq!(configured.max_connection_lifetime(), Some(Duration::from_secs(3_600))); + assert_eq!(configured.drain_timeout(), Duration::from_secs(20)); + assert!(Http2Connections { idle_timeout_seconds: 0, ..defaults }.validate().is_err()); + assert!(Http2Connections { max_connection_lifetime_seconds: 2_592_001, ..defaults }.validate().is_err()); + assert!(Http2Connections { drain_timeout_seconds: 0, ..defaults }.validate().is_err()); + } + #[test] + fn server_policy_and_injection() { + let mut headers = HeaderMap::new(); + headers.insert("server", "origin".parse().unwrap()); + ServerHeader { + mode: ServerMode::Hide, + ..Default::default() + } + .apply(&mut headers); + assert!(!headers.contains_key("server")); + let custom = ServerHeader { + mode: ServerMode::Custom, + value: "RustProxy/v0.1.0".into(), + }; + custom.validate().unwrap(); + custom.apply(&mut headers); + assert_eq!(headers["server"], "RustProxy/v0.1.0"); + assert!(ServerHeader { + mode: ServerMode::Custom, + value: "bad\r\nx: y".into() + } + .validate() + .is_err()); + } + #[test] + fn explicit_startup_binds_are_injected_and_cannot_be_removed() { + let locks = StartupLocks { + http: Some("127.0.0.1:18080".parse().unwrap()), + https: Some("127.0.0.1:18443".parse().unwrap()), + administration: Some("127.0.0.1:19090".parse().unwrap()), + }; + let mut config = crate::config::Config::default(); + config.administration.listen = Some("127.0.0.1:19999".parse().unwrap()); + locks.ensure_config(&mut config); + assert!(config.administration.listen.is_none()); + locks + .validate(&config, "127.0.0.1:19090".parse().unwrap()) + .unwrap(); + + let http = config + .listeners + .endpoints + .iter_mut() + .find(|endpoint| { + endpoint.bindings().iter().any(|binding| { + binding.address == "127.0.0.1:18080".parse().unwrap() && !binding.tls + }) + }) + .unwrap(); + http.enabled = false; + locks.ensure_config(&mut config); + assert!(config.listeners.endpoints.iter().any(|endpoint| { + endpoint.enabled + && endpoint.bindings().iter().any(|binding| { + binding.address == "127.0.0.1:18080".parse().unwrap() && !binding.tls + }) + })); + + config.listeners.endpoints.retain(|endpoint| { + !endpoint.bindings().iter().any(|binding| { + binding.address == "127.0.0.1:18080".parse().unwrap() && !binding.tls + }) + }); + assert!(locks + .validate(&config, "127.0.0.1:19090".parse().unwrap()) + .is_err()); + } + + #[test] + fn startup_defaults_become_named_listeners() { + let saved = Listeners::from_startup( + "0.0.0.0:80".parse().unwrap(), + Some("0.0.0.0:443".parse().unwrap()), + ); + assert_eq!(saved.endpoints.len(), 2); + assert_eq!(saved.endpoints[0].id, "http"); + assert_eq!(saved.endpoints[0].binding.address.port(), 80); + assert_eq!(saved.endpoints[1].id, "https"); + assert_eq!(saved.endpoints[1].binding.address.port(), 443); + assert_eq!( + saved.endpoints[1].binding.http2_settings, + Some(Http2Connections::default()) + ); + assert!(saved.endpoints[1].binding.http3.is_some()); + } + #[test] + fn named_listener_can_bind_multiple_explicit_addresses() { + let endpoint = NamedListener { + id: "web".into(), + name: "Web".into(), + scope: ListenerScope::Public, + enabled: true, + binding: crate::vhost::Listener { + address: "192.0.2.10:443".parse().unwrap(), + tls: true, + http2: true, + http2_settings: Some(Http2Connections::default()), + h2c: false, + http3: None, + }, + additional_addresses: vec!["192.0.2.11:443".parse().unwrap()], + }; + let bindings = endpoint.bindings(); + assert_eq!(bindings.len(), 2); + assert_eq!(bindings[0].address, "192.0.2.10:443".parse().unwrap()); + assert_eq!(bindings[1].address, "192.0.2.11:443".parse().unwrap()); + assert_eq!(bindings[0].tls, bindings[1].tls); + assert_eq!(bindings[0].http2, bindings[1].http2); + } + + #[test] + fn multi_address_listener_rejects_unspecified_or_duplicate_addresses() { + let policy = crate::config::ValidationPolicy { + tls_addr: None, + cert_dir: None, + data_dir: None, + proxy_addr: "127.0.0.1:18080".parse().unwrap(), + admin_addr: "127.0.0.1:19090".parse().unwrap(), + }; + let mut listeners = Listeners { + endpoints: vec![NamedListener { + id: "web".into(), + name: "Web".into(), + scope: ListenerScope::Public, + enabled: true, + binding: crate::vhost::Listener { + address: "0.0.0.0:8080".parse().unwrap(), + tls: false, + http2: false, + http2_settings: None, + h2c: false, + http3: None, + }, + additional_addresses: vec!["192.0.2.10:8080".parse().unwrap()], + }], + }; + assert!(listeners.validate(&policy).is_err()); + listeners.endpoints[0].binding.address = "192.0.2.10:8080".parse().unwrap(); + assert!(listeners.validate(&policy).is_err()); + listeners.endpoints[0].additional_addresses = vec!["192.0.2.11:8080".parse().unwrap()]; + assert!(listeners.validate(&policy).is_ok()); + } + + #[test] + fn administration_certificate_requires_tls_and_known_inventory_id() { + let inventory = crate::tls::Settings { + certificates: vec![crate::tls::Certificate { + issuer: None, + id: "panel".into(), + covered_hosts: Vec::new(), + hosts: vec![], + cert_file: "panel.pem".into(), + key_file: "panel.key".into(), + }], + ..crate::tls::Settings::default() + }; + let mut admin = Administration { + tls: false, + certificate_id: Some("panel".into()), + ..Administration::default() + }; + assert!(admin.validate(&inventory).is_err()); + admin.tls = true; + assert!(admin.validate(&inventory).is_ok()); + admin.certificate_id = Some("missing".into()); + assert!(admin.validate(&inventory).is_err()); + } + #[test] + fn administration_session_lifetime_is_7_to_30_days_and_hot_apply_only() { + let tls = crate::tls::Settings::default(); + let mut admin = Administration::default(); + assert_eq!(admin.session_days, 30); + assert_eq!(admin.session_seconds(), 30 * 24 * 60 * 60); + assert!(admin.validate(&tls).is_ok()); + let active = admin.clone(); + admin.session_days = 7; + assert!(admin.validate(&tls).is_ok()); + assert!(!admin.restart_differs(&active)); + admin.session_days = 6; + assert!(admin.validate(&tls).is_err()); + admin.session_days = 31; + assert!(admin.validate(&tls).is_err()); + } + #[test] + fn administration_hostnames_are_normalized_and_unique() { + let mut admin = Administration { + tls: true, + allowed_hosts: vec!["Panel.Example.COM.".into()], + ..Administration::default() + }; + admin.validate(&crate::tls::Settings::default()).unwrap(); + assert_eq!(admin.allowed_hosts, vec!["panel.example.com".to_owned()]); + admin.allowed_hosts = vec!["panel.example.com".into(), "PANEL.EXAMPLE.COM.".into()]; + assert!(admin.validate(&crate::tls::Settings::default()).is_err()); + } + #[test] + fn administration_hostname_count_is_not_artificially_capped() { + let mut admin = Administration { + tls: true, + allowed_hosts: (0..40) + .map(|index| format!("admin-{index}.example.test")) + .collect(), + ..Administration::default() + }; + assert!(admin.validate(&crate::tls::Settings::default()).is_ok()); + assert_eq!(admin.allowed_hosts.len(), 40); + } +} diff --git a/src/stream.rs b/src/stream.rs new file mode 100644 index 0000000..9174177 --- /dev/null +++ b/src/stream.rs @@ -0,0 +1,591 @@ +//! Layer-4 TCP/UDP forwarding. Stream hosts intentionally bypass the HTTP stack: +//! no vhost routing, ACL, cache, headers, compression or HTTP authentication. +use anyhow::{ensure, Context, Result}; +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{HashMap, HashSet}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, + time::Duration, +}; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + net::{TcpListener, TcpStream, UdpSocket}, + sync::{mpsc, Mutex, Semaphore}, + task::JoinSet, + time::Instant, +}; +use tokio_util::sync::CancellationToken; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Protocol { + #[default] + Tcp, + Udp, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Host { + pub id: String, + pub name: String, + pub enabled: bool, + pub protocol: Protocol, + pub listen: SocketAddr, + pub upstream: SocketAddr, + /// TCP connect timeout. Kept in the common schema so TCP/UDP rows stay simple. + pub connect_timeout_seconds: u64, + /// Inactivity timeout for a TCP relay or a per-client UDP session. + pub idle_timeout_seconds: u64, + /// Maximum concurrent TCP connections or UDP client sessions. + pub max_connections: usize, +} +impl Default for Host { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + enabled: true, + protocol: Protocol::Tcp, + listen: "127.0.0.1:10000".parse().expect("static stream listen"), + upstream: "127.0.0.1:10001".parse().expect("static stream upstream"), + connect_timeout_seconds: 5, + idle_timeout_seconds: 300, + max_connections: 1024, + } + } +} + +/// Fixed-cardinality telemetry for one configured L4 stream. The registry is +/// created from the active startup configuration, so stream IDs cannot be +/// attacker-controlled or grow without a configuration/restart boundary. +#[derive(Default, Debug)] +pub struct Metrics { + pub sessions_total: AtomicU64, + pub sessions_completed: AtomicU64, + pub active: AtomicU64, + pub rejected: AtomicU64, + pub errors: AtomicU64, + pub bytes_in: AtomicU64, + pub bytes_out: AtomicU64, + pub packets_in: AtomicU64, + pub packets_out: AtomicU64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MetricsView { + pub sessions_total: u64, + pub sessions_completed: u64, + pub active: u64, + pub rejected: u64, + pub errors: u64, + pub bytes_in: u64, + pub bytes_out: u64, + pub packets_in: u64, + pub packets_out: u64, +} + +impl Metrics { + pub fn view(&self) -> MetricsView { + let load = |value: &AtomicU64| value.load(Ordering::Relaxed); + MetricsView { + sessions_total: load(&self.sessions_total), + sessions_completed: load(&self.sessions_completed), + active: load(&self.active), + rejected: load(&self.rejected), + errors: load(&self.errors), + bytes_in: load(&self.bytes_in), + bytes_out: load(&self.bytes_out), + packets_in: load(&self.packets_in), + packets_out: load(&self.packets_out), + } + } +} + +struct ActiveSession { + metrics: Arc, +} + +impl ActiveSession { + fn begin(metrics: Arc) -> Self { + metrics.active.fetch_add(1, Ordering::Relaxed); + Self { metrics } + } +} + +impl Drop for ActiveSession { + fn drop(&mut self) { + self.metrics.active.fetch_sub(1, Ordering::Relaxed); + self.metrics + .sessions_completed + .fetch_add(1, Ordering::Relaxed); + } +} + +pub fn validate( + hosts: &mut [Host], + http_listeners: &[crate::vhost::Listener], + admin: SocketAddr, + prometheus: Option, +) -> Result<()> { + ensure!(hosts.len() <= 128, "at most 128 stream hosts"); + let mut ids = HashSet::new(); + for host in hosts.iter_mut() { + ensure!( + crate::config::valid_id(&host.id) && ids.insert(host.id.clone()), + "invalid/duplicate stream host id" + ); + host.name = host.name.trim().to_owned(); + ensure!( + host.name.len() <= 96 && !host.name.chars().any(char::is_control), + "stream host name must be at most 96 bytes without controls" + ); + ensure!( + host.listen.port() > 0 && host.upstream.port() > 0, + "stream listener/upstream ports must be nonzero" + ); + ensure!( + !host.listen.ip().is_multicast() && !host.upstream.ip().is_multicast(), + "stream listener/upstream must not use multicast addresses" + ); + ensure!( + !host.upstream.ip().is_unspecified(), + "stream upstream must use a concrete IP address" + ); + ensure!( + host.connect_timeout_seconds >= 1 && host.connect_timeout_seconds <= 300, + "stream connect timeout must be 1..300 seconds" + ); + ensure!( + host.idle_timeout_seconds >= 1 && host.idle_timeout_seconds <= 86400, + "stream idle timeout must be 1..86400 seconds" + ); + ensure!( + (1..=65535).contains(&host.max_connections), + "stream connection/session limit must be 1..65535" + ); + } + + for (index, host) in hosts.iter().enumerate() { + if !host.enabled { + continue; + } + for other in hosts.iter().skip(index + 1).filter(|item| item.enabled) { + if host.protocol == other.protocol { + ensure!( + !crate::vhost::socket_overlap(host.listen, other.listen), + "stream listeners {} and {} overlap", + host.id, + other.id + ); + } + } + match host.protocol { + Protocol::Tcp => { + ensure!( + !crate::vhost::socket_overlap(host.listen, admin), + "TCP stream {} overlaps administration listener", + host.id + ); + if let Some(metrics) = prometheus { + ensure!( + !crate::vhost::socket_overlap(host.listen, metrics), + "TCP stream {} overlaps Prometheus listener", + host.id + ); + } + for listener in http_listeners { + ensure!( + !crate::vhost::socket_overlap(host.listen, listener.address), + "TCP stream {} overlaps HTTP/TLS listener", + host.id + ); + } + } + Protocol::Udp => { + for listener in http_listeners + .iter() + .filter(|listener| listener.http3.is_some()) + { + ensure!( + !crate::vhost::socket_overlap(host.listen, listener.address), + "UDP stream {} overlaps HTTP/3 listener", + host.id + ); + } + } + } + } + + // Reject direct loops into another RustProxy listener of the same transport. + for host in hosts.iter().filter(|item| item.enabled) { + match host.protocol { + Protocol::Tcp => { + ensure!( + !crate::vhost::socket_overlap(host.upstream, admin) + && prometheus + .is_none_or(|addr| !crate::vhost::socket_overlap(host.upstream, addr)) + && !http_listeners + .iter() + .any(|listener| crate::vhost::socket_overlap( + host.upstream, + listener.address + )) + && !hosts + .iter() + .filter(|other| other.enabled && other.protocol == Protocol::Tcp) + .any(|other| crate::vhost::socket_overlap(host.upstream, other.listen)), + "TCP stream {} upstream points at a RustProxy listener", + host.id + ); + } + Protocol::Udp => { + ensure!( + !http_listeners + .iter() + .filter(|listener| listener.http3.is_some()) + .any(|listener| crate::vhost::socket_overlap( + host.upstream, + listener.address + )) + && !hosts + .iter() + .filter(|other| other.enabled && other.protocol == Protocol::Udp) + .any(|other| crate::vhost::socket_overlap(host.upstream, other.listen)), + "UDP stream {} upstream points at a RustProxy UDP listener", + host.id + ); + } + } + } + Ok(()) +} + +pub async fn serve_tcp( + host: Host, + listener: TcpListener, + shutdown: CancellationToken, + grace: Duration, + metrics: Arc, +) -> Result<()> { + let slots = Arc::new(Semaphore::new(host.max_connections)); + let mut tasks = JoinSet::new(); + loop { + tokio::select! { + biased; + _ = shutdown.cancelled() => break, + _ = tasks.join_next(), if !tasks.is_empty() => {}, + accepted = listener.accept() => { + let (client, peer) = accepted.context("stream TCP accept failed")?; + let Ok(permit) = slots.clone().try_acquire_owned() else { + metrics.rejected.fetch_add(1, Ordering::Relaxed); + drop(client); + continue; + }; + metrics.sessions_total.fetch_add(1, Ordering::Relaxed); + let cfg = host.clone(); + let stop = shutdown.clone(); + let stream_metrics = metrics.clone(); + let active = ActiveSession::begin(stream_metrics.clone()); + tasks.spawn(async move { + let _active = active; + let _permit = permit; + let upstream = match tokio::time::timeout( + Duration::from_secs(cfg.connect_timeout_seconds), + TcpStream::connect(cfg.upstream), + ).await { + Ok(Ok(stream)) => stream, + Ok(Err(error)) => { + stream_metrics.errors.fetch_add(1, Ordering::Relaxed); + tracing::debug!(stream=%cfg.id, %peer, %error, "TCP stream upstream connect failed"); + return; + }, + Err(_) => { + stream_metrics.errors.fetch_add(1, Ordering::Relaxed); + tracing::debug!(stream=%cfg.id, %peer, "TCP stream upstream connect timed out"); + return; + }, + }; + if let Err(error) = relay_tcp(client, upstream, stop, Duration::from_secs(cfg.idle_timeout_seconds), &stream_metrics).await { + stream_metrics.errors.fetch_add(1, Ordering::Relaxed); + tracing::debug!(stream=%cfg.id, %peer, %error, "TCP stream relay ended"); + } + }); + } + } + } + let _ = tokio::time::timeout(grace, async { while tasks.join_next().await.is_some() {} }).await; + tasks.abort_all(); + while tasks.join_next().await.is_some() {} + Ok(()) +} + +async fn copy_with_activity( + mut reader: R, + mut writer: W, + activity: tokio::sync::watch::Sender, + transferred: &AtomicU64, +) -> std::io::Result +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, +{ + let mut total = 0u64; + let mut buffer = [0u8; 32 * 1024]; + loop { + let read = reader.read(&mut buffer).await?; + if read == 0 { + writer.shutdown().await?; + return Ok(total); + } + writer.write_all(&buffer[..read]).await?; + total = total.saturating_add(read as u64); + transferred.fetch_add(read as u64, Ordering::Relaxed); + activity.send_replace(Instant::now()); + } +} + +async fn relay_tcp( + client: TcpStream, + upstream: TcpStream, + shutdown: CancellationToken, + idle: Duration, + metrics: &Metrics, +) -> Result<()> { + let (client_read, client_write) = client.into_split(); + let (upstream_read, upstream_write) = upstream.into_split(); + let (activity, mut activity_rx) = tokio::sync::watch::channel(Instant::now()); + let c2u = copy_with_activity( + client_read, + upstream_write, + activity.clone(), + &metrics.bytes_in, + ); + let u2c = copy_with_activity( + upstream_read, + client_write, + activity.clone(), + &metrics.bytes_out, + ); + drop(activity); + tokio::pin!(c2u); + tokio::pin!(u2c); + let mut c2u_done = false; + let mut u2c_done = false; + let deadline = tokio::time::sleep_until(Instant::now() + idle); + tokio::pin!(deadline); + loop { + tokio::select! { + _ = shutdown.cancelled() => break, + result = &mut c2u, if !c2u_done => { result?; c2u_done = true; if u2c_done { break; } }, + result = &mut u2c, if !u2c_done => { result?; u2c_done = true; if c2u_done { break; } }, + changed = activity_rx.changed() => { + if changed.is_err() { break; } + deadline.as_mut().reset(*activity_rx.borrow() + idle); + }, + _ = &mut deadline => break, + } + } + Ok(()) +} + +#[derive(Clone)] +struct UdpSession { + id: u64, + tx: mpsc::Sender, +} + +pub async fn serve_udp( + host: Host, + socket: UdpSocket, + shutdown: CancellationToken, + metrics: Arc, +) -> Result<()> { + let frontend = Arc::new(socket); + let sessions: Arc>> = + Arc::new(Mutex::new(HashMap::new())); + let slots = Arc::new(Semaphore::new(host.max_connections)); + let next_id = AtomicU64::new(1); + let mut tasks = JoinSet::new(); + let mut buffer = vec![0u8; 65_535]; + loop { + tokio::select! { + biased; + _ = shutdown.cancelled() => break, + _ = tasks.join_next(), if !tasks.is_empty() => {}, + received = frontend.recv_from(&mut buffer) => { + let (length, peer) = received.context("stream UDP receive failed")?; + metrics.packets_in.fetch_add(1, Ordering::Relaxed); + metrics.bytes_in.fetch_add(length as u64, Ordering::Relaxed); + let payload = Bytes::copy_from_slice(&buffer[..length]); + if let Some(session) = sessions.lock().await.get(&peer).cloned() { + if session.tx.try_send(payload).is_err() { + metrics.rejected.fetch_add(1, Ordering::Relaxed); + } + continue; + } + let Ok(permit) = slots.clone().try_acquire_owned() else { + metrics.rejected.fetch_add(1, Ordering::Relaxed); + continue; + }; + let bind = match host.upstream.ip() { + IpAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), + IpAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0), + }; + let upstream = match UdpSocket::bind(bind).await { + Ok(socket) => socket, + Err(error) => { + metrics.errors.fetch_add(1, Ordering::Relaxed); + tracing::debug!(stream=%host.id, %peer, %error, "UDP stream ephemeral bind failed"); + continue; + } + }; + if let Err(error) = upstream.connect(host.upstream).await { + metrics.errors.fetch_add(1, Ordering::Relaxed); + tracing::debug!(stream=%host.id, %peer, %error, "UDP stream upstream connect failed"); + continue; + } + let (tx, rx) = mpsc::channel(64); + let id = next_id.fetch_add(1, Ordering::Relaxed); + sessions.lock().await.insert(peer, UdpSession { id, tx: tx.clone() }); + if tx.try_send(payload).is_err() { + metrics.rejected.fetch_add(1, Ordering::Relaxed); + } + let map = sessions.clone(); + let front = frontend.clone(); + let stop = shutdown.clone(); + let cfg = host.clone(); + let stream_metrics = metrics.clone(); + stream_metrics.sessions_total.fetch_add(1, Ordering::Relaxed); + let active = ActiveSession::begin(stream_metrics.clone()); + tasks.spawn(async move { + let _permit = permit; + udp_session(cfg, peer, id, front, upstream, rx, map, stop, stream_metrics, active).await; + }); + } + } + } + tasks.abort_all(); + while tasks.join_next().await.is_some() {} + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn udp_session( + host: Host, + peer: SocketAddr, + id: u64, + frontend: Arc, + upstream: UdpSocket, + mut incoming: mpsc::Receiver, + sessions: Arc>>, + shutdown: CancellationToken, + metrics: Arc, + _active: ActiveSession, +) { + let idle = Duration::from_secs(host.idle_timeout_seconds); + let deadline = tokio::time::sleep_until(Instant::now() + idle); + tokio::pin!(deadline); + let mut buffer = vec![0u8; 65_535]; + loop { + tokio::select! { + _ = shutdown.cancelled() => break, + Some(data) = incoming.recv() => { + if upstream.send(&data).await.is_err() { + metrics.errors.fetch_add(1, Ordering::Relaxed); + break; + } + deadline.as_mut().reset(Instant::now() + idle); + }, + received = upstream.recv(&mut buffer) => match received { + Ok(length) => { + if frontend.send_to(&buffer[..length], peer).await.is_err() { + metrics.errors.fetch_add(1, Ordering::Relaxed); + break; + } + metrics.packets_out.fetch_add(1, Ordering::Relaxed); + metrics.bytes_out.fetch_add(length as u64, Ordering::Relaxed); + deadline.as_mut().reset(Instant::now() + idle); + } + Err(_) => { + metrics.errors.fetch_add(1, Ordering::Relaxed); + break; + }, + }, + _ = &mut deadline => break, + } + } + let mut map = sessions.lock().await; + if map.get(&peer).is_some_and(|session| session.id == id) { + map.remove(&peer); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validation_allows_tcp_and_udp_on_same_port_but_blocks_transport_conflicts() { + let http = crate::vhost::Listener { + address: "127.0.0.1:8443".parse().unwrap(), + tls: true, + http2: true, + http2_settings: Some(crate::settings::Http2Connections::default()), + h2c: false, + http3: Some(crate::http3::Settings::default()), + }; + let mut hosts = vec![ + Host { + id: "dns-tcp".into(), + protocol: Protocol::Tcp, + listen: "127.0.0.1:5353".parse().unwrap(), + upstream: "127.0.0.2:53".parse().unwrap(), + ..Default::default() + }, + Host { + id: "dns-udp".into(), + protocol: Protocol::Udp, + listen: "127.0.0.1:5353".parse().unwrap(), + upstream: "127.0.0.2:53".parse().unwrap(), + ..Default::default() + }, + ]; + assert!(validate(&mut hosts, &[http], "127.0.0.1:9090".parse().unwrap(), None).is_ok()); + hosts[1].listen = "127.0.0.1:8443".parse().unwrap(); + assert!(validate(&mut hosts, &[http], "127.0.0.1:9090".parse().unwrap(), None).is_err()); + } + + #[test] + fn active_session_guard_balances_gauge_and_completion() { + let metrics = Arc::new(Metrics::default()); + { + let _session = ActiveSession::begin(metrics.clone()); + assert_eq!(metrics.active.load(Ordering::Relaxed), 1); + assert_eq!(metrics.sessions_completed.load(Ordering::Relaxed), 0); + } + assert_eq!(metrics.active.load(Ordering::Relaxed), 0); + assert_eq!(metrics.sessions_completed.load(Ordering::Relaxed), 1); + } + + #[test] + fn metrics_view_reports_l4_counters() { + let metrics = Metrics::default(); + metrics.sessions_total.store(7, Ordering::Relaxed); + metrics.active.store(2, Ordering::Relaxed); + metrics.bytes_in.store(1024, Ordering::Relaxed); + metrics.bytes_out.store(2048, Ordering::Relaxed); + metrics.errors.store(1, Ordering::Relaxed); + let view = metrics.view(); + assert_eq!(view.sessions_total, 7); + assert_eq!(view.active, 2); + assert_eq!(view.bytes_in, 1024); + assert_eq!(view.bytes_out, 2048); + assert_eq!(view.errors, 1); + } +} diff --git a/src/system_stats/bsd.rs b/src/system_stats/bsd.rs new file mode 100644 index 0000000..1b91899 --- /dev/null +++ b/src/system_stats/bsd.rs @@ -0,0 +1,180 @@ +use super::{now_ms, platform_name, NetworkInterfaceView, View}; +use std::{collections::BTreeMap, process::Command}; + +#[derive(Default)] +pub(crate) struct Counters { + cpu_total: Option, + cpu_idle: Option, + process_cpu_micros: Option, +} + +fn command_output(program: &str, args: &[&str]) -> Option { + let output = Command::new(program).args(args).output().ok()?; + output.status.success().then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()) +} + +fn sysctl(name: &str) -> Option { + command_output("sysctl", &["-n", name]) +} + +fn sysctl_u64(names: &[&str]) -> Option { + names.iter().find_map(|name| sysctl(name)?.split_whitespace().next()?.parse().ok()) +} + +fn cpu_times() -> Option<(u64, u64)> { + let values: Vec = sysctl("kern.cp_time")? + .split_whitespace() + .filter_map(|value| value.parse().ok()) + .collect(); + if values.len() < 5 { + return None; + } + Some((values.iter().sum(), values[4])) +} + +fn process_cpu_micros() -> Option { + let mut usage = std::mem::MaybeUninit::::zeroed(); + // SAFETY: getrusage initializes the supplied rusage structure on success. + let rc = unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) }; + if rc != 0 { + return None; + } + // SAFETY: rc == 0 guarantees the structure was initialized by libc. + let usage = unsafe { usage.assume_init() }; + let user = u64::try_from(usage.ru_utime.tv_sec).ok()?.saturating_mul(1_000_000) + + u64::try_from(usage.ru_utime.tv_usec).ok()?; + let system = u64::try_from(usage.ru_stime.tv_sec).ok()?.saturating_mul(1_000_000) + + u64::try_from(usage.ru_stime.tv_usec).ok()?; + Some(user.saturating_add(system)) +} + +fn load_average() -> Option<[f64; 3]> { + let raw = sysctl("vm.loadavg")?; + let values: Vec = raw + .trim_matches(|c: char| c == '{' || c == '}' || c.is_whitespace()) + .split_whitespace() + .filter_map(|value| value.parse().ok()) + .collect(); + (values.len() >= 3).then(|| [values[0], values[1], values[2]]) +} + +fn process_rss_bytes() -> Option { + let pid = std::process::id().to_string(); + command_output("ps", &["-o", "rss=", "-p", &pid])? + .split_whitespace() + .next()? + .parse::() + .ok() + .map(|kib| kib.saturating_mul(1024)) +} + +fn network_interfaces() -> Vec { + let Some(text) = command_output("netstat", &["-ibn"]) else { + return Vec::new(); + }; + let mut lines = text.lines(); + let Some(header) = lines.next() else { + return Vec::new(); + }; + let columns: Vec<_> = header.split_whitespace().collect(); + let name_index = columns.iter().position(|value| value.eq_ignore_ascii_case("Name")); + let ibytes_index = columns.iter().position(|value| value.eq_ignore_ascii_case("Ibytes")); + let obytes_index = columns.iter().position(|value| value.eq_ignore_ascii_case("Obytes")); + let (Some(name_index), Some(ibytes_index), Some(obytes_index)) = + (name_index, ibytes_index, obytes_index) + else { + return Vec::new(); + }; + let mut interfaces: BTreeMap = BTreeMap::new(); + for line in lines { + let fields: Vec<_> = line.split_whitespace().collect(); + if fields.len() <= name_index || fields.len() <= ibytes_index || fields.len() <= obytes_index { + continue; + } + let name = fields[name_index].trim_end_matches('*'); + if name.is_empty() || name == "lo0" { + continue; + } + let (Ok(rx), Ok(tx)) = (fields[ibytes_index].parse::(), fields[obytes_index].parse::()) else { + continue; + }; + let entry = interfaces.entry(name.to_owned()).or_default(); + entry.0 = entry.0.max(rx); + entry.1 = entry.1.max(tx); + } + interfaces + .into_iter() + .take(64) + .map(|(name, (rx_bytes, tx_bytes))| NetworkInterfaceView { + name, + rx_bytes, + tx_bytes, + rx_bytes_per_second: None, + tx_bytes_per_second: None, + }) + .collect() +} + +pub(crate) fn sample(previous: Option<(View, Counters)>) -> (View, Counters) { + let (cpu_total, cpu_idle) = cpu_times().map_or((None, None), |(total, idle)| (Some(total), Some(idle))); + let counters = Counters { + cpu_total, + cpu_idle, + process_cpu_micros: process_cpu_micros(), + }; + let mut view = View { + source: "bsd_native", + platform: platform_name(), + sampled_at_ms: now_ms(), + logical_cpus: sysctl_u64(&["hw.ncpu", "hw.ncpuonline"]) + .and_then(|value| usize::try_from(value).ok()) + .or_else(|| std::thread::available_parallelism().ok().map(usize::from)), + memory_total_bytes: sysctl_u64(&["hw.physmem64", "hw.physmem"]), + process_rss_bytes: process_rss_bytes(), + load_average: load_average(), + network_interfaces: network_interfaces(), + ..Default::default() + }; + if !view.network_interfaces.is_empty() { + view.network_rx_bytes = Some(view.network_interfaces.iter().map(|item| item.rx_bytes).sum()); + view.network_tx_bytes = Some(view.network_interfaces.iter().map(|item| item.tx_bytes).sum()); + } + if let Some((old, old_counters)) = previous { + let system_total = counters + .cpu_total + .zip(old_counters.cpu_total) + .and_then(|(new, old)| new.checked_sub(old)) + .filter(|value| *value > 0); + if let Some(total) = system_total { + view.cpu_percent = counters + .cpu_idle + .zip(old_counters.cpu_idle) + .and_then(|(new, old)| new.checked_sub(old)) + .map(|idle| (100.0 * (1.0 - idle as f64 / total as f64)).clamp(0.0, 100.0)); + } + let seconds = view.sampled_at_ms.saturating_sub(old.sampled_at_ms) as f64 / 1000.0; + if seconds > 0.0 { + view.process_cpu_percent = counters + .process_cpu_micros + .zip(old_counters.process_cpu_micros) + .and_then(|(new, old)| new.checked_sub(old)) + .map(|micros| micros as f64 / (seconds * 10_000.0)); + view.network_rx_bytes_per_second = view + .network_rx_bytes + .zip(old.network_rx_bytes) + .map(|(new, old)| new.saturating_sub(old) as f64 / seconds); + view.network_tx_bytes_per_second = view + .network_tx_bytes + .zip(old.network_tx_bytes) + .map(|(new, old)| new.saturating_sub(old) as f64 / seconds); + for interface in &mut view.network_interfaces { + if let Some(previous) = old.network_interfaces.iter().find(|old| old.name == interface.name) { + interface.rx_bytes_per_second = Some(interface.rx_bytes.saturating_sub(previous.rx_bytes) as f64 / seconds); + interface.tx_bytes_per_second = Some(interface.tx_bytes.saturating_sub(previous.tx_bytes) as f64 / seconds); + } + } + } + } + // Keep unknown/unsupported BSD metrics as None instead of substituting a different semantic. + (view, counters) +} diff --git a/src/system_stats/linux.rs b/src/system_stats/linux.rs new file mode 100644 index 0000000..5e76591 --- /dev/null +++ b/src/system_stats/linux.rs @@ -0,0 +1,84 @@ +use super::View; +use std::fs; + +fn key_value(text: &str, name: &str) -> Option { + text.lines().find_map(|line| { + let (key, rest) = line.split_once(':')?; + (key == name) + .then(|| rest.split_whitespace().next()?.parse().ok()) + .flatten() + }) +} + +pub(crate) fn augment(view: &mut View) { + if let Ok(stat) = fs::read_to_string("/proc/stat") { + view.logical_cpus = Some( + stat.lines() + .filter(|line| line.starts_with("cpu") && line.as_bytes().get(3).is_some_and(u8::is_ascii_digit)) + .count(), + ) + .filter(|count| *count > 0); + } + // Linux-specific augmentation preserves fields and units that existed before + // the cross-platform library adapter. Generic CPU/load/network/process + // metrics are deliberately collected by the shared API in mod.rs. + if let Ok(mem) = fs::read_to_string("/proc/meminfo") { + view.memory_total_bytes = key_value(&mem, "MemTotal").map(|n| n.saturating_mul(1024)); + view.memory_used_bytes = view + .memory_total_bytes + .zip(key_value(&mem, "MemAvailable")) + .map(|(total, available)| total.saturating_sub(available.saturating_mul(1024))); + } + if let Ok(status) = fs::read_to_string("/proc/self/status") { + view.process_rss_bytes = key_value(&status, "VmRSS").map(|n| n.saturating_mul(1024)); + view.process_rss_anon_bytes = key_value(&status, "RssAnon").map(|n| n.saturating_mul(1024)); + view.process_rss_file_bytes = key_value(&status, "RssFile").map(|n| n.saturating_mul(1024)); + view.process_rss_shmem_bytes = key_value(&status, "RssShmem").map(|n| n.saturating_mul(1024)); + view.process_data_bytes = key_value(&status, "VmData").map(|n| n.saturating_mul(1024)); + view.process_swap_bytes = key_value(&status, "VmSwap").map(|n| n.saturating_mul(1024)); + view.process_threads = key_value(&status, "Threads"); + } + view.open_fds = fs::read_dir("/proc/self/fd") + .ok() + .map(|entries| entries.filter_map(Result::ok).count()) + .or(view.open_fds); + if let Ok(io) = fs::read_to_string("/proc/self/io") { + view.process_read_bytes = key_value(&io, "read_bytes"); + view.process_write_bytes = key_value(&io, "write_bytes"); + } + if let Some(group) = fs::read_to_string("/proc/self/cgroup") + .ok() + .and_then(|text| { + text.lines() + .find_map(|line| line.strip_prefix("0::").map(str::to_owned)) + }) + .filter(|path| { + path.starts_with('/') && !path.split('/').any(|part| part == ".." || part == ".") + }) + { + let directory = std::path::Path::new("/sys/fs/cgroup").join(group.trim_start_matches('/')); + view.cgroup_memory_current_bytes = fs::read_to_string(directory.join("memory.current")) + .ok() + .and_then(|s| s.trim().parse().ok()); + view.cgroup_memory_max_bytes = fs::read_to_string(directory.join("memory.max")) + .ok() + .and_then(|s| s.trim().parse().ok()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn proc_values_and_unavailable_are_distinct() { + assert_eq!( + key_value( + "VmRSS:\t123 kB\nRssAnon:\t100 kB\nRssFile:\t20 kB\nRssShmem:\t3 kB\nThreads:\t2\n", + "VmRSS" + ), + Some(123) + ); + assert_eq!(key_value("", "VmRSS"), None); + } +} diff --git a/src/system_stats/mod.rs b/src/system_stats/mod.rs new file mode 100644 index 0000000..5df6110 --- /dev/null +++ b/src/system_stats/mod.rs @@ -0,0 +1,510 @@ +//! Cross-platform system/process statistics used by low-frequency diagnostics. +//! +//! Generic host metrics come from `systemstat`, network inventory/counters use +//! `sysinfo`, and generic process metrics use `metrics-process` where supported. +//! `systemstat` is also a library-level network fallback on targets where +//! `sysinfo` has no native network backend. Platform-specific +//! code is limited to additive metrics that the common libraries do not expose. +//! Missing metrics stay `None`; callers must not synthesize values. +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct NetworkInterfaceView { + pub name: String, + pub rx_bytes: u64, + pub tx_bytes: u64, + pub rx_bytes_per_second: Option, + pub tx_bytes_per_second: Option, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct View { + pub source: &'static str, + pub platform: &'static str, + pub sampled_at_ms: u64, + pub cpu_percent: Option, + pub process_cpu_percent: Option, + pub logical_cpus: Option, + pub memory_total_bytes: Option, + pub memory_used_bytes: Option, + pub system_swap_total_bytes: Option, + pub system_swap_used_bytes: Option, + pub system_uptime_seconds: Option, + pub process_rss_bytes: Option, + pub process_virtual_memory_bytes: Option, + pub process_virtual_memory_max_bytes: Option, + pub process_start_time_seconds: Option, + /// Resident anonymous memory (heap/stacks/private anonymous mappings). + pub process_rss_anon_bytes: Option, + /// Resident file-backed mappings. + pub process_rss_file_bytes: Option, + /// Resident shared-memory mappings. + pub process_rss_shmem_bytes: Option, + /// Data/heap virtual address space reported by the native platform source. + pub process_data_bytes: Option, + /// Process swap currently charged by the kernel. + pub process_swap_bytes: Option, + pub open_fds: Option, + pub max_fds: Option, + pub process_threads: Option, + pub load_average: Option<[f64; 3]>, + pub network_rx_bytes: Option, + pub network_tx_bytes: Option, + pub network_rx_bytes_per_second: Option, + pub network_tx_bytes_per_second: Option, + pub network_interfaces: Vec, + pub process_read_bytes: Option, + pub process_write_bytes: Option, + pub cgroup_memory_current_bytes: Option, + pub cgroup_memory_max_bytes: Option, +} + +#[derive(Default)] +pub(crate) struct Counters { + process_cpu_seconds: Option, +} + +pub(crate) fn platform_name() -> &'static str { + #[cfg(target_os = "linux")] + { + return "Linux"; + } + #[cfg(target_os = "freebsd")] + { + return "FreeBSD"; + } + #[cfg(target_os = "openbsd")] + { + return "OpenBSD"; + } + #[cfg(target_os = "netbsd")] + { + return "NetBSD"; + } + #[cfg(target_os = "dragonfly")] + { + return "DragonFly BSD"; + } + #[cfg(target_os = "macos")] + { + return "macOS"; + } + #[cfg(target_os = "windows")] + { + return "Windows"; + } + #[cfg(all( + target_family = "unix", + not(any( + target_os = "linux", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "dragonfly", + target_os = "macos" + )) + ))] + { + return "Unix"; + } + #[cfg(all(not(target_family = "unix"), not(target_os = "windows")))] + { + "Unsupported" + } +} + +fn source_name() -> &'static str { + #[cfg(target_os = "linux")] + { + return "systemstat+sysinfo+metrics_process+linux_procfs"; + } + #[cfg(any( + target_os = "freebsd", + target_os = "openbsd", + target_os = "macos", + target_os = "windows" + ))] + { + return "systemstat+sysinfo+metrics_process"; + } + #[cfg(any(target_os = "netbsd", target_os = "dragonfly"))] + { + return "systemstat+sysinfo+metrics_process(dummy)"; + } + #[cfg(not(any( + target_os = "linux", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "dragonfly", + target_os = "macos", + target_os = "windows" + )))] + { + "unsupported" + } +} + +#[cfg(any( + target_os = "linux", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "dragonfly", + target_os = "macos", + target_os = "windows" +))] +fn collect_host(view: &mut View) { + use systemstat::{Platform, System}; + + let system = System::new(); + let cpu = system.cpu_load_aggregate().ok(); + + if let Ok(memory) = system.memory() { + let total = memory.total.as_u64(); + let free = memory.free.as_u64(); + view.memory_total_bytes = Some(total); + view.memory_used_bytes = Some(total.saturating_sub(free)); + } + if let Ok(swap) = system.swap() { + let total = swap.total.as_u64(); + let free = swap.free.as_u64(); + view.system_swap_total_bytes = Some(total); + view.system_swap_used_bytes = Some(total.saturating_sub(free)); + } + if let Ok(uptime) = system.uptime() { + view.system_uptime_seconds = Some(uptime.as_secs()); + } + if let Ok(load) = system.load_average() { + view.load_average = Some([load.one as f64, load.five as f64, load.fifteen as f64]); + } + + if let Some(cpu) = cpu { + std::thread::sleep(std::time::Duration::from_secs(1)); + if let Ok(cpu) = cpu.done() { + view.cpu_percent = Some((100.0 * (1.0 - cpu.idle as f64)).clamp(0.0, 100.0)); + } + } +} + +fn is_loopback_interface(name: &str) -> bool { + if name == "lo" { + return true; + } + name.strip_prefix("lo") + .is_some_and(|suffix| !suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit())) +} + +fn normalize_network_interfaces(interfaces: I) -> Vec +where + I: IntoIterator, +{ + let mut merged = std::collections::BTreeMap::::new(); + for (name, rx_bytes, tx_bytes) in interfaces { + let name = name.trim(); + if name.is_empty() || is_loopback_interface(name) { + continue; + } + let entry = merged.entry(name.to_owned()).or_default(); + entry.0 = entry.0.max(rx_bytes); + entry.1 = entry.1.max(tx_bytes); + } + merged + .into_iter() + .map(|(name, (rx_bytes, tx_bytes))| NetworkInterfaceView { + name, + rx_bytes, + tx_bytes, + rx_bytes_per_second: None, + tx_bytes_per_second: None, + }) + .collect() +} + +fn collect_network_sysinfo() -> Vec { + let networks = sysinfo::Networks::new_with_refreshed_list(); + normalize_network_interfaces(networks.iter().map(|(name, data)| { + ( + name.clone(), + data.total_received(), + data.total_transmitted(), + ) + })) +} + +#[cfg(any( + target_os = "linux", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "dragonfly", + target_os = "macos", + target_os = "windows" +))] +fn collect_network_systemstat() -> Vec { + use systemstat::{Platform, System}; + + let system = System::new(); + let Ok(networks) = system.networks() else { + return Vec::new(); + }; + let mut raw = Vec::new(); + for (key, interface) in networks { + let display_name = if interface.name.trim().is_empty() { + key.as_str() + } else { + interface.name.as_str() + }; + let mut stats = system.network_stats(&key).ok(); + if stats.is_none() && display_name != key.as_str() { + stats = system.network_stats(display_name).ok(); + } + if let Some(stats) = stats { + raw.push(( + display_name.to_owned(), + stats.rx_bytes.as_u64(), + stats.tx_bytes.as_u64(), + )); + } + } + normalize_network_interfaces(raw) +} + +#[cfg(not(any( + target_os = "linux", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "dragonfly", + target_os = "macos", + target_os = "windows" +)))] +fn collect_network_systemstat() -> Vec { + Vec::new() +} + +fn merge_network_interfaces( + primary: Vec, + secondary: Vec, +) -> Vec { + // Both libraries expose cumulative byte counters. Merge by interface name + // and keep the larger counter so a partial/zero reading from one backend + // cannot hide a usable reading from the other. + normalize_network_interfaces(primary.into_iter().chain(secondary).map(|item| { + (item.name, item.rx_bytes, item.tx_bytes) + })) +} + +fn collect_network(view: &mut View) { + // `sysinfo::Networks` is the primary interface/traffic API. `systemstat` + // supplements it and is the library-level fallback on targets where + // sysinfo has no native network backend. Combining both inventories also + // avoids dropping an interface when either backend is only partially + // available. No shell commands or per-platform network parser are used. + let interfaces = merge_network_interfaces( + collect_network_sysinfo(), + collect_network_systemstat(), + ); + if interfaces.is_empty() { + return; + } + + view.network_rx_bytes = Some( + interfaces + .iter() + .fold(0u64, |total, item| total.saturating_add(item.rx_bytes)), + ); + view.network_tx_bytes = Some( + interfaces + .iter() + .fold(0u64, |total, item| total.saturating_add(item.tx_bytes)), + ); + view.network_interfaces = interfaces; +} + +#[cfg(not(any( + target_os = "linux", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "dragonfly", + target_os = "macos", + target_os = "windows" +)))] +fn collect_host(_view: &mut View) {} + +fn collect_process(view: &mut View, counters: &mut Counters) { + let metrics = metrics_process::collector::collect(); + counters.process_cpu_seconds = metrics.cpu_seconds_total; + view.process_rss_bytes = metrics.resident_memory_bytes; + view.process_virtual_memory_bytes = metrics.virtual_memory_bytes; + view.process_virtual_memory_max_bytes = metrics.virtual_memory_max_bytes; + view.process_start_time_seconds = metrics.start_time_seconds; + view.open_fds = metrics.open_fds.and_then(|value| usize::try_from(value).ok()); + view.max_fds = metrics.max_fds; + view.process_threads = metrics.threads; +} + +fn calculate_rates(view: &mut View, previous: &View) { + let seconds = view.sampled_at_ms.saturating_sub(previous.sampled_at_ms) as f64 / 1000.0; + if seconds <= 0.0 { + return; + } + view.network_rx_bytes_per_second = view + .network_rx_bytes + .zip(previous.network_rx_bytes) + .map(|(current, old)| current.saturating_sub(old) as f64 / seconds); + view.network_tx_bytes_per_second = view + .network_tx_bytes + .zip(previous.network_tx_bytes) + .map(|(current, old)| current.saturating_sub(old) as f64 / seconds); + for interface in &mut view.network_interfaces { + if let Some(old) = previous + .network_interfaces + .iter() + .find(|candidate| candidate.name == interface.name) + { + interface.rx_bytes_per_second = + Some(interface.rx_bytes.saturating_sub(old.rx_bytes) as f64 / seconds); + interface.tx_bytes_per_second = + Some(interface.tx_bytes.saturating_sub(old.tx_bytes) as f64 / seconds); + } + } +} + +pub(crate) fn sample(previous: Option<(View, Counters)>) -> (View, Counters) { + let mut view = View { + source: source_name(), + platform: platform_name(), + logical_cpus: std::thread::available_parallelism().ok().map(usize::from), + ..Default::default() + }; + let mut counters = Counters::default(); + + collect_host(&mut view); + collect_process(&mut view, &mut counters); + + collect_network(&mut view); + + // Preserve the existing detailed Linux fields and Linux memory-used semantics; + // these are additive details not provided by the common libraries. + #[cfg(target_os = "linux")] + linux::augment(&mut view); + + view.sampled_at_ms = now_ms(); + if let Some((previous_view, previous_counters)) = previous { + let seconds = view.sampled_at_ms.saturating_sub(previous_view.sampled_at_ms) as f64 / 1000.0; + if seconds > 0.0 { + view.process_cpu_percent = counters + .process_cpu_seconds + .zip(previous_counters.process_cpu_seconds) + .and_then(|(current, old)| (current >= old).then_some((current - old) * 100.0 / seconds)); + } + calculate_rates(&mut view, &previous_view); + } + + (view, counters) +} + +pub(crate) fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +#[cfg(target_os = "linux")] +mod linux; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn platform_is_never_empty() { + assert!(!platform_name().is_empty()); + } + + #[test] + fn network_normalization_filters_loopback_and_keeps_all_real_interfaces() { + let raw = vec![ + ("lo".to_owned(), 1, 2), + ("lo0".to_owned(), 3, 4), + ("lo1".to_owned(), 5, 6), + ("em0".to_owned(), 100, 200), + ("eth0".to_owned(), 300, 400), + ("em0".to_owned(), 90, 250), + ]; + let interfaces = normalize_network_interfaces(raw); + assert_eq!(interfaces.len(), 2); + assert_eq!(interfaces[0].name, "em0"); + assert_eq!(interfaces[0].rx_bytes, 100); + assert_eq!(interfaces[0].tx_bytes, 250); + assert_eq!(interfaces[1].name, "eth0"); + } + + #[test] + fn network_merge_supplements_partial_primary_inventory() { + let primary = normalize_network_interfaces(vec![ + ("em0".to_owned(), 0, 20), + ("wg0".to_owned(), 50, 60), + ]); + let secondary = normalize_network_interfaces(vec![ + ("em0".to_owned(), 100, 10), + ("em1".to_owned(), 200, 300), + ]); + let interfaces = merge_network_interfaces(primary, secondary); + assert_eq!(interfaces.len(), 3); + assert_eq!(interfaces[0].name, "em0"); + assert_eq!(interfaces[0].rx_bytes, 100); + assert_eq!(interfaces[0].tx_bytes, 20); + assert_eq!(interfaces[1].name, "em1"); + assert_eq!(interfaces[2].name, "wg0"); + } + + #[test] + fn rate_calculation_tracks_each_interface() { + let mut current = View { + sampled_at_ms: 3_000, + network_rx_bytes: Some(400), + network_tx_bytes: Some(800), + network_interfaces: vec![NetworkInterfaceView { + name: "em0".to_owned(), + rx_bytes: 300, + tx_bytes: 600, + rx_bytes_per_second: None, + tx_bytes_per_second: None, + }], + ..Default::default() + }; + let previous = View { + sampled_at_ms: 1_000, + network_rx_bytes: Some(200), + network_tx_bytes: Some(400), + network_interfaces: vec![NetworkInterfaceView { + name: "em0".to_owned(), + rx_bytes: 100, + tx_bytes: 200, + rx_bytes_per_second: None, + tx_bytes_per_second: None, + }], + ..Default::default() + }; + calculate_rates(&mut current, &previous); + assert_eq!(current.network_rx_bytes_per_second, Some(100.0)); + assert_eq!(current.network_tx_bytes_per_second, Some(200.0)); + assert_eq!(current.network_interfaces[0].rx_bytes_per_second, Some(100.0)); + assert_eq!(current.network_interfaces[0].tx_bytes_per_second, Some(200.0)); + } + + #[test] + fn sample_reports_platform_and_timestamp() { + let (view, _) = sample(None); + assert_eq!(view.platform, platform_name()); + assert_eq!(view.source, source_name()); + assert!(view.sampled_at_ms > 0); + if let Some(cpu) = view.cpu_percent { + assert!((0.0..=100.0).contains(&cpu)); + } + } +} diff --git a/src/tinyauth.rs b/src/tinyauth.rs new file mode 100644 index 0000000..8e44e24 --- /dev/null +++ b/src/tinyauth.rs @@ -0,0 +1,512 @@ +//! Internal, body-free Tinyauth auth_request. No unauthenticated cache access, +//! request replay, client identity passthrough or insecure TLS exceptions. +use crate::{ + config::{Backend, CompiledBackend, UpstreamProtocol, ValidationPolicy}, + forwarding, +}; +use anyhow::{ensure, Context as _, Result}; +use axum::{ + body::Body, + response::{IntoResponse, Response}, +}; +use http::{HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode, Uri}; +use http_body_util::{BodyExt, Limited}; +use serde::{Deserialize, Serialize}; +use std::{net::SocketAddr, sync::Arc, time::Duration}; +use url::Url; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + pub enabled: bool, + /// An origin adds /api/auth/nginx automatically; an explicit path is retained. + pub url: String, + pub login_url: String, + pub allow_private: bool, + pub address_allowlist: Vec, + pub tls_ca_pem: Option, + pub connect_timeout_seconds: u64, + pub timeout_seconds: u64, + pub request_headers: Vec, + pub response_headers: Vec, + pub redirect_forbidden: bool, +} +impl Default for Settings { + fn default() -> Self { + Self { + enabled: false, + url: String::new(), + login_url: String::new(), + allow_private: false, + address_allowlist: Vec::new(), + tls_ca_pem: None, + connect_timeout_seconds: 5, + timeout_seconds: 10, + request_headers: vec![ + "cookie".into(), + "authorization".into(), + "x-api-key".into(), + "user-agent".into(), + "accept".into(), + ], + response_headers: vec!["remote-user".into(), "remote-email".into()], + redirect_forbidden: true, + } + } +} +#[derive(Debug)] +pub struct Compiled { + backend: Arc, + uri: Uri, + login: Url, + timeout: Duration, + request_headers: Vec, + response_headers: Vec, + redirect_forbidden: bool, +} +#[derive(Clone)] +pub struct AuthenticationFailure; +#[derive(Debug, Default)] +pub struct Identity { + headers: HeaderMap, +} +const STANDARD_IDENTITY: [&str; 6] = [ + "remote-user", + "remote-email", + "remote-name", + "remote-groups", + "remote-sub", + "x-auth-request-user", +]; +fn identity_header(name: &HeaderName) -> bool { + let name = name.as_str(); + (name.starts_with("remote-") || name.starts_with("x-")) + && !name.starts_with("x-forwarded-") + && !name.starts_with("x-original-") + && !matches!(name, "x-real-ip" | "x-request-id" | "x-api-key") +} +fn http_url(value: &str) -> Result { + ensure!( + !value.is_empty() && value.len() <= 2048, + "Tinyauth URL must contain 1..2048 bytes" + ); + let url = Url::parse(value)?; + ensure!( + matches!(url.scheme(), "http" | "https") && url.host_str().is_some(), + "Tinyauth requires an HTTP(S) URL" + ); + ensure!( + url.username().is_empty() && url.password().is_none() && url.fragment().is_none(), + "Tinyauth URL cannot contain credentials or fragments" + ); + Ok(url) +} +impl Settings { + pub fn validate(&self) -> Result<()> { + ensure!( + (1..=30).contains(&self.connect_timeout_seconds) + && (1..=60).contains(&self.timeout_seconds) + && self.connect_timeout_seconds <= self.timeout_seconds, + "Tinyauth timeouts require 1..30s connect <= 1..60s total" + ); + ensure!( + self.request_headers.len() <= 32 && self.response_headers.len() <= 32, + "at most 32 Tinyauth header names per direction" + ); + for raw in &self.request_headers { + ensure!(raw.len() <= 128, "Tinyauth header name exceeds 128 bytes"); + let name: HeaderName = raw.parse()?; + ensure!( + matches!( + name.as_str(), + "cookie" | "authorization" | "user-agent" | "accept" | "accept-language" + ) || (name.as_str().starts_with("x-") && identity_header(&name)) + || name == "x-api-key", + "unsafe Tinyauth request header: {name}" + ); + } + let mut names = std::collections::HashSet::new(); + for raw in &self.response_headers { + ensure!(raw.len() <= 128, "Tinyauth header name exceeds 128 bytes"); + let name: HeaderName = raw.parse()?; + ensure!( + identity_header(&name) && names.insert(name), + "Tinyauth response headers must be unique Remote-* or safe X-* names" + ); + } + if self.enabled { + let auth = http_url(&self.url)?; + ensure!( + auth.query().is_none(), + "Tinyauth verification URL cannot include a query" + ); + let login = http_url(&self.login_url)?; + ensure!( + login.scheme() == "https" + || login.host_str().is_some_and(|host| host == "localhost" + || host + .trim_matches(['[', ']']) + .parse::() + .is_ok_and(|ip| ip.is_loopback())), + "public Tinyauth login URL must use HTTPS" + ); + } + Ok(()) + } + pub fn compile( + &self, + policy: &ValidationPolicy, + bindings: &[SocketAddr], + ) -> Result> { + self.validate()?; + if !self.enabled { + return Ok(None); + } + let mut endpoint = http_url(&self.url)?; + if endpoint.path() == "/" { + endpoint.set_path("/api/auth/nginx"); + } + let uri = endpoint.as_str().parse()?; + let origin = endpoint.origin().ascii_serialization(); + let definition = Backend { + id: "tinyauth".into(), + url: origin, + allow_private: self.allow_private, + accept_local_listener_risk: false, + preserve_host: false, + protocol: UpstreamProtocol::Http1, + address_allowlist: self.address_allowlist.clone(), + tls_ca_pem: self.tls_ca_pem.clone(), + tls_server_name: None, + tls_verify: true, + }; + let keepalive = crate::balance::Keepalive::default(); + let mut backend = + crate::config::compile_backend(&definition, policy, bindings, &keepalive)?; + backend.client = crate::upstream::client_with_timeout( + &definition, + backend.address_policy.clone(), + false, + &keepalive, + Duration::from_secs(self.connect_timeout_seconds), + )?; + Ok(Some(Compiled { + backend: Arc::new(backend), + uri, + login: http_url(&self.login_url)?, + timeout: Duration::from_secs(self.timeout_seconds), + request_headers: self + .request_headers + .iter() + .map(|s| s.parse()) + .collect::>()?, + response_headers: self + .response_headers + .iter() + .map(|s| s.parse()) + .collect::>()?, + redirect_forbidden: self.redirect_forbidden, + })) + } +} +fn failure(status: StatusCode) -> Response { + let mut response = ( + status, + "RustProxy: authentication required or authentication service unavailable\n", + ) + .into_response(); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("private, no-store"), + ); + response.extensions_mut().insert(AuthenticationFailure); + response +} +impl Compiled { + pub fn strip_identity(&self, headers: &mut HeaderMap) { + let names: Vec<_> = headers + .keys() + .filter(|name| name.as_str().starts_with("remote-")) + .cloned() + .collect(); + for name in names { + headers.remove(name); + } + for name in STANDARD_IDENTITY { + headers.remove(name); + } + for name in &self.response_headers { + headers.remove(name); + } + } + fn redirect( + &self, + raw: Option<&HeaderValue>, + original_url: &str, + fallback: bool, + ) -> Option { + let target = raw + .and_then(|raw| raw.to_str().ok()) + .filter(|s| s.len() <= 8192) + .and_then(|raw| { + if raw.starts_with('/') && !raw.starts_with("//") { + self.login.join(raw).ok() + } else { + Url::parse(raw).ok() + } + }) + .filter(|target| { + target.origin() == self.login.origin() + && target.username().is_empty() + && target.password().is_none() + && target.fragment().is_none() + }); + let target = target.or_else(|| { + fallback.then(|| { + let mut target = self.login.clone(); + let existing: Vec<_> = target + .query_pairs() + .filter(|(key, _)| key != "redirect_uri") + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect(); + target.set_query(None); + target + .query_pairs_mut() + .extend_pairs(existing) + .append_pair("redirect_uri", original_url); + target + }) + })?; + target.as_str().parse().ok() + } + pub async fn authorize( + &self, + incoming: &HeaderMap, + method: &Method, + context: &forwarding::Context, + shutdown: &tokio_util::sync::CancellationToken, + ) -> std::result::Result { + let result = tokio::select! { + _=shutdown.cancelled()=>return Err(failure(StatusCode::SERVICE_UNAVAILABLE)), + result=tokio::time::timeout(self.timeout,self.check(incoming,method,context))=>result, + }; + let (status, headers) = match result { + Ok(Ok(value)) => value, + Ok(Err(error)) => { + tracing::debug!(%error,"Tinyauth verification failed"); + return Err(failure(StatusCode::BAD_GATEWAY)); + } + Err(_) => return Err(failure(StatusCode::GATEWAY_TIMEOUT)), + }; + if status.is_success() { + let mut identity = Identity::default(); + let mut size = 0; + for name in &self.response_headers { + for value in headers.get_all(name) { + size += value.as_bytes().len(); + if size > 8192 { + return Err(failure(StatusCode::BAD_GATEWAY)); + } + identity.headers.append(name.clone(), value.clone()); + } + } + return Ok(identity); + } + if status == StatusCode::UNAUTHORIZED + || (status == StatusCode::FORBIDDEN && self.redirect_forbidden) + { + if let Some(location) = self.redirect( + headers.get("x-tinyauth-location"), + &context.original_url, + status == StatusCode::UNAUTHORIZED, + ) { + let mut response = failure(StatusCode::FOUND); + response.headers_mut().insert("location", location); + return Err(response); + } + } + Err(failure(if status == StatusCode::FORBIDDEN { + StatusCode::FORBIDDEN + } else if status == StatusCode::UNAUTHORIZED { + StatusCode::UNAUTHORIZED + } else { + StatusCode::BAD_GATEWAY + })) + } + async fn check( + &self, + incoming: &HeaderMap, + method: &Method, + context: &forwarding::Context, + ) -> Result<(StatusCode, HeaderMap)> { + let mut request = Request::builder() + .method(Method::GET) + .uri(self.uri.clone()) + .body(Body::empty())?; + for name in &self.request_headers { + for value in incoming.get_all(name) { + request.headers_mut().append(name.clone(), value.clone()); + } + } + // Incoming Connection nominations cannot remove or forge trusted metadata. + crate::proxy::headers::strip_hop_by_hop(request.headers_mut()); + crate::proxy::headers::strip_admin_cookie(request.headers_mut()); + self.strip_identity(request.headers_mut()); + context.apply(request.headers_mut()); + request + .headers_mut() + .insert("host", self.backend.host_header.clone()); + request + .headers_mut() + .insert("x-original-url", context.original_url.parse()?); + request + .headers_mut() + .insert("x-original-method", method.as_str().parse()?); + let response = self + .backend + .client + .request(request) + .await + .context("Tinyauth request failed")?; + let (parts, body) = response.into_parts(); + // Drain only a small bounded body to permit safe keepalive reuse. Neither + // auth page content nor arbitrary response headers reach the application. + Limited::new(body, 16 * 1024) + .collect() + .await + .map_err(|e| anyhow::anyhow!("Tinyauth response body: {e}"))?; + Ok((parts.status, parts.headers)) + } +} +impl Identity { + pub fn apply(&self, headers: &mut HeaderMap) { + for name in self.headers.keys() { + headers.remove(name); + } + for (name, value) in &self.headers { + headers.append(name.clone(), value.clone()); + } + } +} +#[cfg(test)] +mod tests { + use super::*; + fn settings() -> Settings { + Settings { + enabled: true, + url: "http://127.0.0.1:9300".into(), + login_url: "https://auth.example/login".into(), + allow_private: true, + ..Default::default() + } + } + fn compiled() -> Compiled { + settings() + .compile( + &ValidationPolicy { + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + tls_addr: None, + cert_dir: None, + data_dir: None, + }, + &[], + ) + .unwrap() + .unwrap() + } + #[test] + fn origin_adds_nginx_endpoint() { + assert_eq!( + compiled().uri.to_string(), + "http://127.0.0.1:9300/api/auth/nginx" + ); + } + #[test] + fn rejects_transport_header_injection_and_unsafe_urls() { + for name in [ + "host", + "authorization", + "cookie", + "content-length", + "x-forwarded-for", + "x-real-ip", + ] { + let mut s = settings(); + s.response_headers = vec![name.into()]; + assert!(s.validate().is_err(), "{name}"); + } + let mut s = settings(); + s.url = "http://user:secret@host/".into(); + assert!(s.validate().is_err()); + s = settings(); + s.login_url = "http://auth.example/login".into(); + assert!(s.validate().is_err()); + } + #[test] + fn redirect_preserves_original_query_as_one_encoded_parameter() { + let c = compiled(); + let location = c + .redirect(None, "https://app.example/a?x=1&next=%2Fa", true) + .unwrap(); + let url = Url::parse(location.to_str().unwrap()).unwrap(); + assert_eq!( + url.query_pairs() + .find(|(k, _)| k == "redirect_uri") + .unwrap() + .1, + "https://app.example/a?x=1&next=%2Fa" + ); + } + #[test] + fn provider_redirects_are_restricted_to_configured_login_origin() { + let c = compiled(); + for value in [ + "https://evil.example/", + "//evil.example/", + "javascript:alert(1)", + "https://auth.example@evil.example/", + ] { + assert!(c + .redirect(Some(&value.parse().unwrap()), "https://app.example/", false) + .is_none()); + } + assert_eq!( + c.redirect(Some(&"/unauthorized".parse().unwrap()), "", false) + .unwrap(), + "https://auth.example/unauthorized" + ); + } + #[test] + fn identity_removes_unselected_remote_headers_too() { + let c = compiled(); + let mut h = HeaderMap::new(); + for name in [ + "remote-user", + "remote-email", + "remote-groups", + "remote-name", + ] { + h.insert(name, "forged".parse().unwrap()); + } + c.strip_identity(&mut h); + assert!(h.is_empty()); + } + #[test] + fn same_port_different_remote_auth_is_allowed_but_self_loop_is_not() { + let mut s = settings(); + s.url = "http://127.0.0.1:8080".into(); + assert!(s + .compile( + &ValidationPolicy { + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + tls_addr: None, + cert_dir: None, + data_dir: None, + }, + &[] + ) + .is_err()); + } +} diff --git a/src/tls.rs b/src/tls.rs new file mode 100644 index 0000000..eaeb453 --- /dev/null +++ b/src/tls.rs @@ -0,0 +1,1743 @@ +//! Certificate files are control-plane inputs; handshakes only use immutable, +//! prevalidated rustls configurations. Private key bytes accepted by authenticated +//! deploy operations are bounded, zeroized by callers, never returned, and never +//! written to audit/API response payloads. +use crate::config::{normalize_host, normalize_host_pattern}; +use anyhow::{ensure, Context, Result}; +use rustls::{ + server::{ClientHello, ResolvesServerCert, ResolvesServerCertUsingSni}, + sign::CertifiedKey, + ServerConfig, +}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{HashMap, HashSet}, + fs::{self, OpenOptions}, + io::{self, Read, Write}, + path::{Path, PathBuf}, + sync::Arc, +}; +use zeroize::Zeroizing; + +const MAX_PEM: u64 = 131_072; +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_certificate: Option, + #[serde(default)] + pub certificates: Vec, + pub policy: ProtocolPolicy, +} +impl Default for Settings { + fn default() -> Self { + Self { + default_certificate: None, + certificates: Vec::new(), + policy: ProtocolPolicy::default(), + } + } +} +impl Settings { + pub fn is_empty(&self) -> bool { + self.default_certificate.is_none() + && self.certificates.is_empty() + && self.policy == ProtocolPolicy::default() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ProtocolPolicy { + pub tls12: bool, + pub tls13: bool, + /// Rustls cipher-suite names. Empty keeps the ring provider defaults. + pub cipher_suites: Vec, +} +impl Default for ProtocolPolicy { + fn default() -> Self { + Self { + tls12: true, + tls13: true, + cipher_suites: Vec::new(), + } + } +} +impl ProtocolPolicy { + pub fn validate(&self) -> Result<()> { + ensure!( + self.tls12 || self.tls13, + "at least one TLS protocol version must be enabled" + ); + ensure!( + self.cipher_suites.len() <= 16, + "at most 16 TLS cipher suites may be selected" + ); + let available = cipher_suite_names(); + let mut seen = HashSet::new(); + for suite in &self.cipher_suites { + ensure!( + suite.len() <= 96 && available.iter().any(|name| name == suite), + "unknown TLS cipher suite: {suite}" + ); + ensure!(seen.insert(suite), "duplicate TLS cipher suite: {suite}"); + } + let effective = if self.cipher_suites.is_empty() { + available + } else { + self.cipher_suites.clone() + }; + ensure!( + effective.iter().any(|suite| { + (self.tls13 && suite.starts_with("TLS13_")) + || (self.tls12 && !suite.starts_with("TLS13_")) + }), + "selected TLS cipher suites do not match the enabled protocol versions" + ); + Ok(()) + } + pub fn effective_cipher_suites(&self) -> Vec { + let selected = if self.cipher_suites.is_empty() { + cipher_suite_names() + } else { + self.cipher_suites.clone() + }; + selected + .into_iter() + .filter(|suite| { + (self.tls13 && suite.starts_with("TLS13_")) + || (self.tls12 && !suite.starts_with("TLS13_")) + }) + .collect() + } + pub fn allows_http3(&self) -> bool { + self.tls13 + && (self.cipher_suites.is_empty() + || self + .cipher_suites + .iter() + .any(|suite| suite.starts_with("TLS13_"))) + } + /// Per-vhost policy may only remove protocols/suites from the global policy. + /// This keeps the global setting a real security ceiling rather than a default + /// that an individual vhost can silently widen. + pub fn validate_narrower_than(&self, global: &Self) -> Result<()> { + self.validate()?; + global.validate()?; + ensure!( + !self.tls12 || global.tls12, + "per-vhost TLS policy cannot enable TLS 1.2 when it is disabled globally" + ); + ensure!( + !self.tls13 || global.tls13, + "per-vhost TLS policy cannot enable TLS 1.3 when it is disabled globally" + ); + if !global.cipher_suites.is_empty() { + ensure!( + !self.cipher_suites.is_empty(), + "per-vhost TLS policy must select from the globally allowed cipher suites" + ); + for suite in &self.cipher_suites { + ensure!( + global.cipher_suites.iter().any(|allowed| allowed == suite), + "per-vhost TLS cipher suite is not allowed globally: {suite}" + ); + } + } + Ok(()) + } +} + +pub fn cipher_suite_names() -> Vec { + rustls::crypto::ring::default_provider() + .cipher_suites + .iter() + .map(|suite| format!("{:?}", suite.suite())) + .collect() +} + +fn provider_for(policy: &ProtocolPolicy) -> Result { + policy.validate()?; + let mut provider = rustls::crypto::ring::default_provider(); + if !policy.cipher_suites.is_empty() { + provider.cipher_suites.retain(|suite| { + let name = format!("{:?}", suite.suite()); + policy + .cipher_suites + .iter() + .any(|selected| selected == &name) + }); + } + ensure!( + !provider.cipher_suites.is_empty(), + "TLS cipher selection is empty" + ); + Ok(provider) +} + +fn protocol_versions(policy: &ProtocolPolicy) -> Vec<&'static rustls::SupportedProtocolVersion> { + let mut versions = Vec::new(); + if policy.tls13 { + versions.push(&rustls::version::TLS13); + } + if policy.tls12 { + versions.push(&rustls::version::TLS12); + } + versions +} + +fn server_config( + resolver: Arc, + policy: &ProtocolPolicy, + http2: bool, +) -> Result> { + let provider = provider_for(policy)?; + let versions = protocol_versions(policy); + let mut config = ServerConfig::builder_with_provider(Arc::new(provider)) + .with_protocol_versions(&versions)? + .with_no_client_auth() + .with_cert_resolver(resolver); + config.alpn_protocols = if http2 { + vec![b"h2".to_vec(), b"http/1.1".to_vec()] + } else { + vec![b"http/1.1".to_vec()] + }; + config.max_early_data_size = 0; + Ok(Arc::new(config)) +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Issuer { + pub provider: String, + pub environment: String, + #[serde(default = "renew_by_default")] + pub auto_renew: bool, +} +fn renew_by_default() -> bool { + true +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Certificate { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub issuer: Option, + pub id: String, + /// DNS SubjectAltName values extracted from the leaf certificate during + /// upload or ACME import. This is certificate coverage, not active SNI binding. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub covered_hosts: Vec, + /// Active/configured SNI bindings. `for_vhosts` may rewrite this list. + pub hosts: Vec, + pub cert_file: String, + pub key_file: String, +} +#[derive(Debug)] +struct Resolver { + names: HashMap>, + fallback: Arc, +} +impl Resolver { + fn named(&self, server_name: &str) -> Option> { + let host = normalize_host(server_name).ok()?; + if let Some(key) = self.names.get(&host) { + return Some(key.clone()); + } + let labels: Vec<&str> = host.split('.').collect(); + for index in 1..labels.len().saturating_sub(1) { + let wildcard = format!("*.{}", labels[index..].join(".")); + if let Some(key) = self.names.get(&wildcard) { + return Some(key.clone()); + } + } + None + } +} +impl ResolvesServerCert for Resolver { + fn resolve(&self, hello: ClientHello<'_>) -> Option> { + hello + .server_name() + .and_then(|name| self.named(name)) + .or_else(|| Some(self.fallback.clone())) + } +} +pub fn validate_certificate_removals( + previous: &crate::config::Config, + next: &crate::config::Config, +) -> Result<()> { + let next_ids: HashSet<&str> = next + .tls + .certificates + .iter() + .map(|certificate| certificate.id.as_str()) + .collect(); + for certificate in &previous.tls.certificates { + if next_ids.contains(certificate.id.as_str()) { + continue; + } + let id = certificate.id.as_str(); + let mut owners = Vec::new(); + if previous.tls.default_certificate.as_deref() == Some(id) { + owners.push("default TLS certificate".to_owned()); + } + if previous.administration.certificate_id.as_deref() == Some(id) { + owners.push("administration panel".to_owned()); + } + owners.extend( + previous + .vhosts + .iter() + .filter(|host| host.certificate_id.as_deref() == Some(id)) + .map(|host| format!("vhost {}", host.id)), + ); + owners.extend( + previous + .redirects + .iter() + .filter(|host| host.certificate_id.as_deref() == Some(id)) + .map(|host| format!("redirect {}", host.id)), + ); + owners.extend( + previous + .not_found_hosts + .iter() + .filter(|host| host.certificate_id.as_deref() == Some(id)) + .map(|host| format!("404 host {}", host.id)), + ); + owners.extend( + previous + .docker_agents + .agents + .iter() + .filter(|agent| agent.certificate_id.as_deref() == Some(id)) + .map(|agent| format!("Docker agent {}", agent.id)), + ); + ensure!(owners.is_empty(), "certificate {id} is still assigned to {}; unassign it and save before removing the certificate", owners.join(", ")); + } + Ok(()) +} + +pub fn safe_name(name: &str) -> Result<()> { + ensure!( + !name.is_empty() + && name.len() <= 96 + && !name.contains("..") + && name + .bytes() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'-' | b'_')), + "certificate filenames must be simple relative filenames, not paths" + ); + Ok(()) +} +fn read_pem(path: &Path, private: bool) -> Result>> { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let file = options + .open(path) + .with_context(|| format!("cannot open certificate input {}", path.display()))?; + let meta = file.metadata()?; + ensure!( + meta.is_file() && meta.len() <= MAX_PEM, + "certificate inputs must be regular files up to 128 KiB" + ); + #[cfg(unix)] + if private { + use std::os::unix::fs::PermissionsExt; + ensure!( + meta.permissions().mode() & 0o077 == 0, + "private key permissions must be 600: {}", + path.display() + ); + } + #[cfg(not(unix))] + let _ = private; + let mut data = Zeroizing::new(Vec::new()); + file.take(MAX_PEM + 1).read_to_end(&mut data)?; + ensure!( + data.len() <= MAX_PEM as usize, + "PEM file grew beyond its limit" + ); + Ok(data) +} +fn der_tlv<'a>(input: &'a [u8], offset: &mut usize) -> Result<(u8, &'a [u8])> { + ensure!(*offset < input.len(), "truncated DER object"); + let tag = input[*offset]; + *offset += 1; + ensure!(*offset < input.len(), "truncated DER length"); + let first = input[*offset]; + *offset += 1; + let length = if first & 0x80 == 0 { + first as usize + } else { + let count = (first & 0x7f) as usize; + ensure!(count > 0 && count <= 4, "unsupported DER length"); + ensure!(*offset + count <= input.len(), "truncated DER length"); + let mut length = 0usize; + for byte in &input[*offset..*offset + count] { + length = length + .checked_mul(256) + .and_then(|value| value.checked_add(*byte as usize)) + .context("DER length overflow")?; + } + *offset += count; + length + }; + let end = (*offset) + .checked_add(length) + .context("DER length overflow")?; + ensure!(end <= input.len(), "truncated DER value"); + let value = &input[*offset..end]; + *offset = end; + Ok((tag, value)) +} + +/// Extract the DNS SubjectAltName set from the leaf certificate. This parser is +/// deliberately narrow: it walks only the DER structures required to locate the +/// SAN extension and ignores non-DNS GeneralName variants. Certificate validity +/// and key matching are still delegated to rustls. +fn certificate_dns_names(certificate: &[u8]) -> Result> { + let mut reader = certificate; + let certs = rustls_pemfile::certs(&mut reader).collect::>>()?; + let leaf = certs.first().context("certificate chain is empty")?; + let der = leaf.as_ref(); + + let mut root_offset = 0; + let (tag, certificate_sequence) = der_tlv(der, &mut root_offset)?; + ensure!( + tag == 0x30 && root_offset == der.len(), + "invalid X.509 certificate DER" + ); + let mut certificate_offset = 0; + let (tag, tbs) = der_tlv(certificate_sequence, &mut certificate_offset)?; + ensure!(tag == 0x30, "invalid X.509 TBSCertificate"); + + let mut tbs_offset = 0; + if tbs.first() == Some(&0xa0) { + let _ = der_tlv(tbs, &mut tbs_offset)?; + } + // serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo + for _ in 0..6 { + let _ = der_tlv(tbs, &mut tbs_offset)?; + } + + let mut names = Vec::new(); + while tbs_offset < tbs.len() { + let (tag, value) = der_tlv(tbs, &mut tbs_offset)?; + if tag != 0xa3 { + continue; + } + let mut wrapper_offset = 0; + let (tag, extensions) = der_tlv(value, &mut wrapper_offset)?; + ensure!(tag == 0x30, "invalid X.509 extensions"); + let mut extensions_offset = 0; + while extensions_offset < extensions.len() { + let (tag, extension) = der_tlv(extensions, &mut extensions_offset)?; + ensure!(tag == 0x30, "invalid X.509 extension"); + let mut extension_offset = 0; + let (oid_tag, oid) = der_tlv(extension, &mut extension_offset)?; + ensure!(oid_tag == 0x06, "invalid X.509 extension OID"); + if extension_offset < extension.len() && extension[extension_offset] == 0x01 { + let _ = der_tlv(extension, &mut extension_offset)?; + } + let (value_tag, encoded_value) = der_tlv(extension, &mut extension_offset)?; + ensure!(value_tag == 0x04, "invalid X.509 extension value"); + if oid != [0x55, 0x1d, 0x11] { + continue; + } + let mut san_offset = 0; + let (san_tag, general_names) = der_tlv(encoded_value, &mut san_offset)?; + ensure!(san_tag == 0x30, "invalid SubjectAltName extension"); + let mut names_offset = 0; + while names_offset < general_names.len() { + let (name_tag, raw) = der_tlv(general_names, &mut names_offset)?; + if name_tag != 0x82 { + continue; + } + let Ok(name) = std::str::from_utf8(raw) else { + continue; + }; + if let Ok(name) = normalize_host_pattern(name) { + names.push(name); + } + } + } + break; + } + names.sort(); + names.dedup(); + Ok(names) +} + +#[derive(Debug, Clone, Serialize)] +pub struct CertificateMetadata { + pub covered_hosts: Vec, + pub not_before: Option, + pub not_after: Option, + pub not_before_unix: Option, + pub not_after_unix: Option, + pub days_remaining: Option, + pub issuer: Option, + pub expiry_status: &'static str, +} + +fn der_text(tag: u8, raw: &[u8]) -> Option { + let value = match tag { + 0x0c | 0x13 | 0x14 | 0x16 => std::str::from_utf8(raw).ok()?.to_owned(), + 0x1e if raw.len() % 2 == 0 => { + let units: Vec = raw + .chunks_exact(2) + .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]])) + .collect(); + String::from_utf16(&units).ok()? + } + _ => return None, + }; + let value: String = value + .chars() + .map(|ch| if ch.is_control() { ' ' } else { ch }) + .take(256) + .collect(); + (!value.trim().is_empty()).then(|| value.trim().to_owned()) +} + +fn certificate_issuer_name(encoded: &[u8]) -> Option { + let mut offset = 0; + let mut parts = Vec::new(); + while offset < encoded.len() && parts.len() < 16 { + let (set_tag, set_value) = der_tlv(encoded, &mut offset).ok()?; + if set_tag != 0x31 { + continue; + } + let mut set_offset = 0; + while set_offset < set_value.len() && parts.len() < 16 { + let (sequence_tag, sequence) = der_tlv(set_value, &mut set_offset).ok()?; + if sequence_tag != 0x30 { + continue; + } + let mut sequence_offset = 0; + let (oid_tag, oid) = der_tlv(sequence, &mut sequence_offset).ok()?; + if oid_tag != 0x06 || sequence_offset >= sequence.len() { + continue; + } + let (value_tag, value) = der_tlv(sequence, &mut sequence_offset).ok()?; + let Some(value) = der_text(value_tag, value) else { + continue; + }; + let label = if oid == [0x55, 0x04, 0x03] { + "CN" + } else if oid == [0x55, 0x04, 0x06] { + "C" + } else if oid == [0x55, 0x04, 0x07] { + "L" + } else if oid == [0x55, 0x04, 0x08] { + "ST" + } else if oid == [0x55, 0x04, 0x0a] { + "O" + } else if oid == [0x55, 0x04, 0x0b] { + "OU" + } else { + continue; + }; + parts.push(format!("{label}={value}")); + } + } + (!parts.is_empty()).then(|| parts.join(", ")) +} + +fn x509_days_in_month(year: i32, month: u32) -> Option { + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + Some(match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => return None, + }) +} + +fn x509_days_from_civil(year: i32, month: u32, day: u32) -> i64 { + let year = year - if month <= 2 { 1 } else { 0 }; + let era = (if year >= 0 { year } else { year - 399 }) / 400; + let yoe = year - era * 400; + let month = i32::try_from(month).unwrap_or(0); + let day = i32::try_from(day).unwrap_or(0); + let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + i64::from(era * 146_097 + doe - 719_468) +} + +fn x509_decimal(raw: &[u8]) -> Option { + let text = std::str::from_utf8(raw).ok()?; + text.parse().ok() +} + +fn x509_time(tag: u8, raw: &[u8]) -> Option<(String, i64)> { + let year_digits = match tag { + 0x17 => 2, + 0x18 => 4, + _ => return None, + }; + let base_digits = year_digits + 10; + if raw.len() < base_digits + 1 || !raw[..base_digits].iter().all(|byte| byte.is_ascii_digit()) { + return None; + } + let mut year = i32::try_from(x509_decimal(&raw[..year_digits])?).ok()?; + if year_digits == 2 { + year += if year >= 50 { 1900 } else { 2000 }; + } + let mut cursor = year_digits; + let month = x509_decimal(&raw[cursor..cursor + 2])?; + cursor += 2; + let day = x509_decimal(&raw[cursor..cursor + 2])?; + cursor += 2; + let hour = x509_decimal(&raw[cursor..cursor + 2])?; + cursor += 2; + let minute = x509_decimal(&raw[cursor..cursor + 2])?; + cursor += 2; + let second = x509_decimal(&raw[cursor..cursor + 2])?; + cursor += 2; + if day == 0 + || day > x509_days_in_month(year, month)? + || hour > 23 + || minute > 59 + || second > 60 + { + return None; + } + while cursor < raw.len() && matches!(raw[cursor], b'.' | b',') { + cursor += 1; + while cursor < raw.len() && raw[cursor].is_ascii_digit() { + cursor += 1; + } + } + let (offset_seconds, suffix) = match raw.get(cursor).copied()? { + b'Z' if cursor + 1 == raw.len() => (0i64, "Z".to_owned()), + sign @ (b'+' | b'-') if cursor + 5 == raw.len() => { + let zone_hour = x509_decimal(&raw[cursor + 1..cursor + 3])?; + let zone_minute = x509_decimal(&raw[cursor + 3..cursor + 5])?; + if zone_hour > 23 || zone_minute > 59 { + return None; + } + let seconds = i64::from(zone_hour * 3600 + zone_minute * 60) + * if sign == b'+' { 1 } else { -1 }; + ( + seconds, + format!( + "{}{:02}:{:02}", + if sign == b'+' { '+' } else { '-' }, + zone_hour, + zone_minute + ), + ) + } + _ => return None, + }; + let unix = x509_days_from_civil(year, month, day) + .checked_mul(86_400)? + .checked_add(i64::from(hour * 3600 + minute * 60 + second.min(59)))? + .checked_sub(offset_seconds)?; + let display = format!( + "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}{suffix}" + ); + Some((display, unix)) +} + +fn certificate_public_metadata(certificate: &[u8]) -> Result { + let covered_hosts = certificate_dns_names(certificate)?; + let mut reader = certificate; + let certs = rustls_pemfile::certs(&mut reader).collect::>>()?; + let leaf = certs.first().context("certificate chain is empty")?; + let der = leaf.as_ref(); + + let mut root_offset = 0; + let (tag, certificate_sequence) = der_tlv(der, &mut root_offset)?; + ensure!( + tag == 0x30 && root_offset == der.len(), + "invalid X.509 certificate DER" + ); + let mut certificate_offset = 0; + let (tag, tbs) = der_tlv(certificate_sequence, &mut certificate_offset)?; + ensure!(tag == 0x30, "invalid X.509 TBSCertificate"); + + let mut tbs_offset = 0; + if tbs.first() == Some(&0xa0) { + let _ = der_tlv(tbs, &mut tbs_offset)?; + } + let _serial = der_tlv(tbs, &mut tbs_offset)?; + let _signature = der_tlv(tbs, &mut tbs_offset)?; + let (issuer_tag, issuer) = der_tlv(tbs, &mut tbs_offset)?; + ensure!(issuer_tag == 0x30, "invalid X.509 issuer"); + let (validity_tag, validity) = der_tlv(tbs, &mut tbs_offset)?; + ensure!(validity_tag == 0x30, "invalid X.509 validity"); + let mut validity_offset = 0; + let (not_before_tag, not_before_raw) = der_tlv(validity, &mut validity_offset)?; + let (not_after_tag, not_after_raw) = der_tlv(validity, &mut validity_offset)?; + let not_before = x509_time(not_before_tag, not_before_raw); + let not_after = x509_time(not_after_tag, not_after_raw); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|value| i64::try_from(value.as_secs()).unwrap_or(i64::MAX)) + .unwrap_or(0); + let days_remaining = not_after.as_ref().map(|(_, unix)| { + let delta = unix.saturating_sub(now); + if delta >= 0 { + delta.saturating_add(86_399) / 86_400 + } else { + -(delta.saturating_abs().saturating_add(86_399) / 86_400) + } + }); + let expiry_status = match not_after.as_ref() { + Some((_, unix)) if *unix < now => "expired", + Some(_) if days_remaining.is_some_and(|days| days <= 30) => "warning", + Some(_) => "ok", + None => "unknown", + }; + Ok(CertificateMetadata { + covered_hosts, + not_before: not_before.as_ref().map(|(value, _)| value.clone()), + not_after: not_after.as_ref().map(|(value, _)| value.clone()), + not_before_unix: not_before.map(|(_, value)| value), + not_after_unix: not_after.map(|(_, value)| value), + days_remaining, + issuer: certificate_issuer_name(issuer), + expiry_status, + }) +} + +/// Read DNS SubjectAltName coverage directly from an existing certificate file. +/// Used by the authenticated certificate inventory to enrich older/manual entries +/// that predate persisted `covered_hosts` metadata. Private key material is not read. +pub fn certificate_covered_hosts(directory: &Path, cert_file: &str) -> Result> { + safe_name(cert_file)?; + let certificate = read_pem(&directory.join(cert_file), false)?; + certificate_dns_names(&certificate) +} + +/// Read non-secret metadata directly from the configured public leaf certificate. +/// Private keys are never opened by this path. +pub fn certificate_metadata(directory: &Path, cert_file: &str) -> Result { + safe_name(cert_file)?; + let certificate = read_pem(&directory.join(cert_file), false)?; + certificate_public_metadata(&certificate) +} + +fn parse_pair(certificate: &[u8], key: &[u8]) -> Result { + let mut certificate_reader = certificate; + let certs = rustls_pemfile::certs(&mut certificate_reader).collect::>>()?; + ensure!( + !certs.is_empty() && certs.len() <= 16, + "certificate chain must contain 1..16 PEM certificates" + ); + let mut key_reader = key; + let key = rustls_pemfile::private_key(&mut key_reader)? + .context("no supported PEM private key found")?; + let certified = CertifiedKey::from_der(certs, key, &rustls::crypto::ring::default_provider())?; + certified + .keys_match() + .context("certificate and private key do not match")?; + Ok(certified) +} +fn load_pair(directory: &Path, cert: &str, key: &str) -> Result { + safe_name(cert)?; + safe_name(key)?; + parse_pair( + &read_pem(&directory.join(cert), false)?, + &read_pem(&directory.join(key), true)?, + ) +} +/// Runs on a bounded control-plane blocking worker, never from a handshake. +pub fn compile(settings: &Settings, directory: Option<&Path>) -> Result>> { + settings.policy.validate()?; + let Some(directory) = directory else { + ensure!( + settings.default_certificate.is_none() && settings.certificates.is_empty(), + "certificate configuration requires a certificate directory" + ); + return Ok(None); + }; + let mut names = HashMap::>::new(); + let mut ids = HashSet::new(); + let mut hosts = HashSet::new(); + let mut default = None; + for cert in &settings.certificates { + safe_name(&cert.id)?; + ensure!(ids.insert(cert.id.clone()), "duplicate certificate id"); + let key = load_pair(directory, &cert.cert_file, &cert.key_file)?; + for host in &cert.hosts { + let host = normalize_host_pattern(host)?; + ensure!(hosts.insert(host.clone()), "duplicate TLS hostname binding"); + // Validate certificate/name compatibility with a concrete SNI name. + // rustls' stock resolver does not model routing wildcard precedence, + // so the immutable RustProxy resolver below owns exact/wildcard lookup. + let validation_name = host + .strip_prefix("*.") + .map(|base| format!("rustproxy-wildcard-check.{base}")) + .unwrap_or_else(|| host.clone()); + let mut checker = ResolvesServerCertUsingSni::new(); + checker + .add(&validation_name, key.clone()) + .with_context(|| format!("certificate does not cover {host}"))?; + names.insert(host, Arc::new(key.clone())); + } + if settings.default_certificate.as_deref() == Some(cert.id.as_str()) { + default = Some(Arc::new(key)); + } + } + if settings.default_certificate.is_some() { + ensure!( + default.is_some(), + "default_certificate references an unknown id" + ); + } + let fallback = match default { + Some(key) => key, + None => Arc::new(load_pair(directory, "default.pem", "default.key")?), + }; + Ok(Some(server_config( + Arc::new(Resolver { names, fallback }), + &settings.policy, + true, + )?)) +} + +/// Reuse the already validated immutable certificate resolver while narrowing +/// protocols/ciphers for one frontend. No certificate I/O occurs here. +pub fn variant( + base: &Arc, + policy: &ProtocolPolicy, + http2: bool, +) -> Result> { + server_config(base.cert_resolver.clone(), policy, http2) +} + +/// Build the fixed administration TLS configuration. A named inventory +/// certificate may be selected; otherwise the app-managed default pair is used. +pub fn compile_admin( + settings: &Settings, + administration: &crate::settings::Administration, + directory: Option<&Path>, +) -> Result>> { + if !administration.tls { + return Ok(None); + } + let directory = directory.context("administration TLS requires a certificate directory")?; + settings.policy.validate()?; + let key = if let Some(id) = administration.certificate_id.as_deref() { + let certificate = settings + .certificates + .iter() + .find(|certificate| certificate.id == id) + .context("administration certificate id is unknown")?; + Arc::new(load_pair( + directory, + &certificate.cert_file, + &certificate.key_file, + )?) + } else { + Arc::new(load_pair(directory, "default.pem", "default.key")?) + }; + #[derive(Debug)] + struct Fixed(Arc); + impl ResolvesServerCert for Fixed { + fn resolve(&self, _: ClientHello<'_>) -> Option> { + Some(self.0.clone()) + } + } + Ok(Some(server_config( + Arc::new(Fixed(key)), + &settings.policy, + true, + )?)) +} +fn write_new(path: &Path, bytes: &[u8]) -> Result<()> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let mut file = options.open(path)?; + file.write_all(bytes)?; + file.sync_all()?; + Ok(()) +} +fn store_pair(directory: &Path, name: &str, cert: &[u8], key: &[u8]) -> Result<()> { + safe_name(name)?; + let cert_path = directory.join(format!("{name}.pem")); + let key_path = directory.join(format!("{name}.key")); + ensure!( + !cert_path.try_exists()? && !key_path.try_exists()?, + "certificate files already exist; use a new name for safe rotation" + ); + parse_pair(cert, key)?; + write_new(&key_path, key)?; + if let Err(error) = write_new(&cert_path, cert) { + let _ = fs::remove_file(&key_path); + return Err(error); + } + #[cfg(unix)] + std::fs::File::open(directory)?.sync_all()?; + Ok(()) +} +fn generate_locked(directory: &Path, name: &str, hosts: Vec) -> Result<()> { + ensure!(!hosts.is_empty(), "provide at least one certificate SAN"); + let hosts = hosts + .into_iter() + .map(|host| { + if host.parse::().is_ok() { + Ok(host) + } else { + normalize_host_pattern(&host) + } + }) + .collect::>>()?; + let rcgen::CertifiedKey { cert, key_pair } = rcgen::generate_simple_self_signed(hosts)?; + let key = Zeroizing::new(key_pair.serialize_pem()); + store_pair(directory, name, cert.pem().as_bytes(), key.as_bytes()) +} +pub fn initialize_default(directory: &Path) -> Result<()> { + crate::auth::prepare_directory(directory)?; + let _lock = crate::auth::lock_directory(directory)?; + let cert = directory.join("default.pem").try_exists()?; + let key = directory.join("default.key").try_exists()?; + ensure!( + cert == key, + "incomplete default certificate pair; restore both default.pem and default.key" + ); + if !cert { + generate_locked( + directory, + "default", + vec![ + "localhost".into(), + "demo.localhost".into(), + "127.0.0.1".into(), + "::1".into(), + ], + )?; + } + load_pair(directory, "default.pem", "default.key")?; + Ok(()) +} +pub fn generate(directory: &Path, name: &str, hosts: Vec) -> Result<()> { + ensure!( + name != "default", + "the default certificate is managed at startup; use another name" + ); + crate::auth::prepare_directory(directory)?; + let _lock = crate::auth::lock_directory(directory)?; + generate_locked(directory, name, hosts) +} +pub fn import(directory: &Path, name: &str, cert: PathBuf, key: PathBuf) -> Result<()> { + ensure!( + name != "default", + "use a named certificate and configure default_certificate instead" + ); + crate::auth::prepare_directory(directory)?; + let _lock = crate::auth::lock_directory(directory)?; + store_pair( + directory, + name, + &read_pem(&cert, false)?, + &read_pem(&key, true)?, + ) +} + +/// Result of reconciling an externally supplied certificate/key pair with the +/// immutable generation currently selected by a manual certificate id. +#[derive(Debug, PartialEq, Eq)] +pub enum ExternalPairDeployment { + /// The configured RustProxy-owned generation already contains the exact + /// certificate chain and private key supplied by the external issuer. + Unchanged { covered_hosts: Vec }, + /// A new immutable generation was written and must be published through the + /// revisioned configuration path before the old generation can be removed. + Staged { + cert_file: String, + key_file: String, + covered_hosts: Vec, + }, +} + +fn external_generation_pair(certificate_id: &str, cert_file: &str, key_file: &str) -> bool { + let prefix = format!("external-{certificate_id}-"); + let Some(cert_stem) = cert_file.strip_suffix(".pem") else { + return false; + }; + let Some(key_stem) = key_file.strip_suffix(".key") else { + return false; + }; + cert_stem == key_stem && cert_stem.starts_with(&prefix) +} + +/// Validate an externally supplied pair and reconcile it with the currently +/// selected RustProxy-owned immutable generation. Repeating the same deploy is +/// therefore a no-op, while a missing/different pair stages a fresh generation. +/// +/// `current_pair` is advisory: unreadable/missing current files are treated as +/// drift and repaired by staging a new generation instead of failing the deploy. +pub fn reconcile_external_pair( + directory: &Path, + certificate_id: &str, + current_pair: Option<(&str, &str)>, + certificate: &[u8], + key: &[u8], +) -> Result { + ensure!( + crate::config::valid_id(certificate_id), + "invalid certificate id" + ); + ensure!( + certificate.len() <= MAX_PEM as usize && key.len() <= MAX_PEM as usize, + "each PEM input is limited to 128 KiB" + ); + parse_pair(certificate, key)?; + let covered_hosts = certificate_dns_names(certificate)?; + crate::auth::prepare_directory(directory)?; + let _lock = crate::auth::lock_directory(directory)?; + + if let Some((cert_file, key_file)) = current_pair { + if external_generation_pair(certificate_id, cert_file, key_file) { + let current_certificate = read_pem(&directory.join(cert_file), false); + let current_key = read_pem(&directory.join(key_file), true); + if let (Ok(current_certificate), Ok(current_key)) = (current_certificate, current_key) { + if current_certificate.as_slice() == certificate && current_key.as_slice() == key { + return Ok(ExternalPairDeployment::Unchanged { covered_hosts }); + } + } + } + } + + let name = format!( + "external-{}-{:032x}", + certificate_id, + rand::random::() + ); + store_pair(directory, &name, certificate, key)?; + Ok(ExternalPairDeployment::Staged { + cert_file: format!("{name}.pem"), + key_file: format!("{name}.key"), + covered_hosts, + }) +} + +/// Stage a locally supplied certificate/key pair under generation-specific +/// filenames. This compatibility wrapper always stages a new generation. +pub fn stage_external_pair( + directory: &Path, + certificate_id: &str, + certificate: &[u8], + key: &[u8], +) -> Result<(String, String, Vec)> { + match reconcile_external_pair(directory, certificate_id, None, certificate, key)? { + ExternalPairDeployment::Staged { + cert_file, + key_file, + covered_hosts, + } => Ok((cert_file, key_file, covered_hosts)), + ExternalPairDeployment::Unchanged { .. } => { + unreachable!("reconcile without a current pair always stages") + } + } +} + +/// Best-effort rollback for a generation created by `stage_external_pair`. +/// Only generation-specific external files may be removed through this helper. +pub fn remove_external_pair(directory: &Path, cert_file: &str, key_file: &str) -> Result<()> { + safe_name(cert_file)?; + safe_name(key_file)?; + ensure!( + cert_file.starts_with("external-") + && cert_file.ends_with(".pem") + && key_file.starts_with("external-") + && key_file.ends_with(".key"), + "refusing to remove non-external certificate files" + ); + for file in [cert_file, key_file] { + match fs::remove_file(directory.join(file)) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + #[cfg(unix)] + std::fs::File::open(directory)?.sync_all()?; + Ok(()) +} + +/// Select named inventory certificates per vhost/redirect host. A single global +/// SNI resolver cannot give the same name different certificates by destination IP. +pub fn for_all_frontends( + settings: &Settings, + vhosts: &[crate::vhost::Vhost], + redirects: &[crate::redirect::Host], + not_found_hosts: &[crate::not_found::Host], +) -> Result { + let mut result = settings.clone(); + let certificate_ids: std::collections::HashSet<_> = + result.certificates.iter().map(|c| c.id.clone()).collect(); + let mut assignments = std::collections::HashMap::::new(); + let mut assign = |hosts: &[String], id: &str, owner: &str| -> Result<()> { + ensure!( + certificate_ids.contains(id), + "{owner} references unknown certificate id" + ); + for host in hosts { + if let Some(old) = assignments.insert(host.clone(), id.to_owned()) { + ensure!( + old == id, + "same hostname cannot select different TLS certificates" + ); + } + } + Ok(()) + }; + for vhost in vhosts { + if let Some(id) = &vhost.certificate_id { + assign(&vhost.frontend.hosts, id, "vhost")?; + } + } + for redirect in redirects { + if let Some(id) = &redirect.certificate_id { + assign(&redirect.frontend.hosts, id, "redirect")?; + } + } + for host in not_found_hosts { + if let Some(id) = &host.certificate_id { + assign(&host.frontend.hosts, id, "404 host")?; + } + } + for cert in &mut result.certificates { + cert.hosts.retain(|host| { + !assignments.contains_key(&host.to_ascii_lowercase().trim_end_matches('.').to_string()) + }); + for (host, id) in &assignments { + if id == &cert.id { + cert.hosts.push(host.clone()); + } + } + cert.hosts.sort(); + cert.hosts.dedup(); + } + Ok(result) +} + +/// Compatibility helper for callers without dedicated local-response hosts. +pub fn for_frontends( + settings: &Settings, + vhosts: &[crate::vhost::Vhost], + redirects: &[crate::redirect::Host], +) -> Result { + for_all_frontends(settings, vhosts, redirects, &[]) +} + +/// Compatibility helper used by focused TLS tests and callers that only have vhosts. +pub fn for_vhosts(settings: &Settings, vhosts: &[crate::vhost::Vhost]) -> Result { + for_frontends(settings, vhosts, &[]) +} +/// Upload staging: files are private and immutable; configuration publication is +/// a separate revisioned operation. Unbound files never alter active TLS state. +pub fn upload( + directory: &Path, + certificate: &[u8], + key: &[u8], + hosts: Vec, +) -> Result { + ensure!( + certificate.len() <= MAX_PEM as usize && key.len() <= MAX_PEM as usize, + "each PEM input is limited to 128 KiB" + ); + ensure!(!hosts.is_empty(), "provide at least one vhost name"); + let certified = parse_pair(certificate, key)?; + let covered_hosts = certificate_dns_names(certificate)?; + let mut resolver = ResolvesServerCertUsingSni::new(); + for host in &hosts { + let host = normalize_host_pattern(host)?; + let validation_name = host + .strip_prefix("*.") + .map(|base| format!("rustproxy-wildcard-check.{base}")) + .unwrap_or_else(|| host.clone()); + resolver + .add(&validation_name, certified.clone()) + .context("certificate does not cover vhost name")?; + } + crate::auth::prepare_directory(directory)?; + let _lock = crate::auth::lock_directory(directory)?; + // Bound abandoned staging files. Admins may remove unreferenced upload-* pairs + // locally; never delete potentially active files automatically. + let staged = fs::read_dir(directory)? + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_name().to_string_lossy().starts_with("upload-") + && e.path().extension().is_some_and(|x| x == "key") + }) + .count(); + ensure!( + staged < 128, + "upload storage limit reached; remove unused upload-* pairs locally" + ); + let id = format!("upload-{:032x}", rand::random::()); + store_pair(directory, &id, certificate, key)?; + Ok(Certificate { + issuer: None, + cert_file: format!("{id}.pem"), + key_file: format!("{id}.key"), + id, + covered_hosts, + hosts: Vec::new(), + }) +} +/// Validate immutable ACME outputs before they may be published to the library. +pub fn validate_acme( + directory: &Path, + id: String, + cert_file: String, + key_file: String, + domains: &[String], +) -> Result { + ensure!(crate::config::valid_id(&id), "invalid ACME certificate id"); + ensure!( + cert_file.starts_with("acme-") && key_file.starts_with("acme-"), + "invalid ACME output name" + ); + ensure!(!domains.is_empty(), "ACME requires at least one domain"); + let key = load_pair(directory, &cert_file, &key_file)?; + let certificate_pem = read_pem(&directory.join(&cert_file), false)?; + let covered_hosts = certificate_dns_names(&certificate_pem)?; + let mut resolver = ResolvesServerCertUsingSni::new(); + for domain in domains { + let domain = normalize_host_pattern(domain)?; + // A concrete name checks a wildcard SAN without relying on wildcard + // behavior in rustls' stock validation resolver. + let name = domain + .strip_prefix("*.") + .map(|d| format!("rustproxy-acme-check.{d}")) + .unwrap_or_else(|| domain.clone()); + resolver + .add(&normalize_host(&name)?, key.clone()) + .context("ACME certificate does not cover requested domain")?; + } + Ok(Certificate { + issuer: None, + id, + covered_hosts, + hosts: Vec::new(), + cert_file, + key_file, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn certificate_removal_requires_prior_unassignment_including_administration() { + let certificate = Certificate { + issuer: Some(Issuer { + provider: "http01".into(), + environment: "production".into(), + auto_renew: true, + }), + id: "managed".into(), + covered_hosts: Vec::new(), + hosts: vec!["panel.example.test".into()], + cert_file: "managed.pem".into(), + key_file: "managed.key".into(), + }; + let mut previous = crate::config::Config::default(); + previous.tls.certificates.push(certificate); + previous.administration.tls = true; + previous.administration.certificate_id = Some("managed".into()); + + let mut removed = previous.clone(); + removed.administration.certificate_id = None; + removed.tls.certificates.clear(); + assert!(validate_certificate_removals(&previous, &removed).is_err()); + + previous.administration.certificate_id = None; + previous.docker_agents.agents.push(crate::docker::Agent { + id: "docker1".into(), + name: "Docker 1".into(), + certificate_id: Some("managed".into()), + ..Default::default() + }); + let mut removed_from_docker = previous.clone(); + removed_from_docker.docker_agents.agents[0].certificate_id = None; + removed_from_docker.tls.certificates.clear(); + assert!(validate_certificate_removals(&previous, &removed_from_docker).is_err()); + + previous.docker_agents.agents[0].certificate_id = None; + let mut removed_after_unassign = previous.clone(); + removed_after_unassign.tls.certificates.clear(); + assert!(validate_certificate_removals(&previous, &removed_after_unassign).is_ok()); + } + + #[test] + fn x509_time_parser_handles_utc_and_generalized_time_with_offsets() { + let (epoch, unix) = x509_time(0x17, b"700101000000Z").unwrap(); + assert_eq!(epoch, "1970-01-01T00:00:00Z"); + assert_eq!(unix, 0); + + let (future, unix) = x509_time(0x18, b"20500101000000+0100").unwrap(); + assert_eq!(future, "2050-01-01T00:00:00+01:00"); + assert_eq!(unix, 2_524_604_400); + assert!(x509_time(0x17, b"991332000000Z").is_none()); + } + + #[test] + fn public_certificate_metadata_exposes_san_and_validity_without_private_key() { + let generated = rcgen::generate_simple_self_signed(vec!["meta.localhost".into()]).unwrap(); + let metadata = certificate_public_metadata(generated.cert.pem().as_bytes()).unwrap(); + assert_eq!(metadata.covered_hosts, vec!["meta.localhost"]); + assert!(metadata.not_before_unix.is_some()); + assert!(metadata.not_after_unix.is_some()); + assert!(metadata.days_remaining.is_some()); + assert_ne!(metadata.expiry_status, "unknown"); + } + + #[test] + fn external_pair_stages_generation_and_rolls_back_cleanly() { + let dir = tempfile::tempdir().unwrap(); + let generated = rcgen::generate_simple_self_signed(vec![ + "shared.example.test".into(), + "www.shared.example.test".into(), + ]) + .unwrap(); + let (cert_file, key_file, covered_hosts) = stage_external_pair( + dir.path(), + "shared-cert", + generated.cert.pem().as_bytes(), + generated.key_pair.serialize_pem().as_bytes(), + ) + .unwrap(); + assert!(cert_file.starts_with("external-shared-cert-")); + assert!(cert_file.ends_with(".pem")); + assert!(key_file.starts_with("external-shared-cert-")); + assert!(key_file.ends_with(".key")); + assert!(dir.path().join(&cert_file).is_file()); + assert!(dir.path().join(&key_file).is_file()); + assert!(covered_hosts.contains(&"shared.example.test".to_string())); + assert!(covered_hosts.contains(&"www.shared.example.test".to_string())); + + remove_external_pair(dir.path(), &cert_file, &key_file).unwrap(); + assert!(!dir.path().join(cert_file).exists()); + assert!(!dir.path().join(key_file).exists()); + } + + #[test] + fn external_pair_reconcile_is_idempotent_and_repairs_missing_generation() { + let dir = tempfile::tempdir().unwrap(); + let generated = + rcgen::generate_simple_self_signed(vec!["reconcile.example.test".into()]).unwrap(); + let certificate = generated.cert.pem(); + let key = generated.key_pair.serialize_pem(); + let (cert_file, key_file, _) = stage_external_pair( + dir.path(), + "reconcile-cert", + certificate.as_bytes(), + key.as_bytes(), + ) + .unwrap(); + + let unchanged = reconcile_external_pair( + dir.path(), + "reconcile-cert", + Some((&cert_file, &key_file)), + certificate.as_bytes(), + key.as_bytes(), + ) + .unwrap(); + assert!(matches!( + unchanged, + ExternalPairDeployment::Unchanged { .. } + )); + + fs::remove_file(dir.path().join(&cert_file)).unwrap(); + let repaired = reconcile_external_pair( + dir.path(), + "reconcile-cert", + Some((&cert_file, &key_file)), + certificate.as_bytes(), + key.as_bytes(), + ) + .unwrap(); + let ExternalPairDeployment::Staged { + cert_file: repaired_cert, + key_file: repaired_key, + .. + } = repaired + else { + panic!("missing generation must be repaired by staging a new pair"); + }; + assert_ne!(repaired_cert, cert_file); + assert_ne!(repaired_key, key_file); + assert!(dir.path().join(repaired_cert).is_file()); + assert!(dir.path().join(repaired_key).is_file()); + } + + #[test] + fn no_path_traversal() { + for path in [ + "../secret", + "/etc/passwd", + "sub/key", + "sub\\key", + "", + "a..b", + ] { + assert!(safe_name(path).is_err(), "{path}"); + } + assert!(safe_name("app-v2.key").is_ok()); + } + #[test] + fn default_is_persistent_and_tls_advertises_both_protocols() { + let dir = tempfile::tempdir().unwrap(); + initialize_default(dir.path()).unwrap(); + let before = fs::read(dir.path().join("default.pem")).unwrap(); + initialize_default(dir.path()).unwrap(); + assert_eq!(before, fs::read(dir.path().join("default.pem")).unwrap()); + let config = compile(&Settings::default(), Some(dir.path())) + .unwrap() + .unwrap(); + assert_eq!( + config.alpn_protocols, + vec![b"h2".to_vec(), b"http/1.1".to_vec()] + ); + } + #[test] + fn policy_only_configuration_does_not_require_certificate_directory() { + let settings = Settings { + policy: ProtocolPolicy { + tls12: true, + tls13: false, + cipher_suites: Vec::new(), + }, + ..Settings::default() + }; + assert!(!settings.is_empty()); + assert!(compile(&settings, None).unwrap().is_none()); + } + + #[test] + fn protocol_policy_rejects_empty_unknown_and_mismatched_selections() { + assert!(ProtocolPolicy { + tls12: false, + tls13: false, + cipher_suites: vec![], + } + .validate() + .is_err()); + assert!(ProtocolPolicy { + cipher_suites: vec!["NOT_A_RUSTLS_SUITE".into()], + ..ProtocolPolicy::default() + } + .validate() + .is_err()); + let suites = cipher_suite_names(); + if let Some(tls13) = suites.iter().find(|suite| suite.starts_with("TLS13_")) { + assert!(ProtocolPolicy { + tls12: true, + tls13: false, + cipher_suites: vec![tls13.clone()], + } + .validate() + .is_err()); + } + if let Some(tls12) = suites.iter().find(|suite| !suite.starts_with("TLS13_")) { + assert!(ProtocolPolicy { + tls12: false, + tls13: true, + cipher_suites: vec![tls12.clone()], + } + .validate() + .is_err()); + } + let global = ProtocolPolicy { + tls12: true, + tls13: false, + cipher_suites: vec![], + }; + assert!(ProtocolPolicy { + tls12: true, + tls13: false, + cipher_suites: vec![] + } + .validate_narrower_than(&global) + .is_ok()); + assert!(ProtocolPolicy::default() + .validate_narrower_than(&global) + .is_err()); + } + #[test] + fn administration_tls_supports_default_and_selected_certificate() { + let dir = tempfile::tempdir().unwrap(); + initialize_default(dir.path()).unwrap(); + generate(dir.path(), "panel", vec!["panel.localhost".into()]).unwrap(); + let settings = Settings { + certificates: vec![Certificate { + issuer: None, + id: "panel".into(), + covered_hosts: Vec::new(), + hosts: vec!["panel.localhost".into()], + cert_file: "panel.pem".into(), + key_file: "panel.key".into(), + }], + ..Settings::default() + }; + let default_admin = crate::settings::Administration { + tls: true, + ..crate::settings::Administration::default() + }; + assert!(compile_admin(&settings, &default_admin, Some(dir.path())) + .unwrap() + .is_some()); + let selected_admin = crate::settings::Administration { + tls: true, + certificate_id: Some("panel".into()), + ..crate::settings::Administration::default() + }; + assert!(compile_admin(&settings, &selected_admin, Some(dir.path())) + .unwrap() + .is_some()); + } + #[test] + fn mismatched_key_and_invalid_sni_are_rejected() { + let a = rcgen::generate_simple_self_signed(vec!["a.localhost".into()]).unwrap(); + let b = rcgen::generate_simple_self_signed(vec!["b.localhost".into()]).unwrap(); + assert!(parse_pair( + a.cert.pem().as_bytes(), + b.key_pair.serialize_pem().as_bytes() + ) + .is_err()); + let dir = tempfile::tempdir().unwrap(); + initialize_default(dir.path()).unwrap(); + generate(dir.path(), "a", vec!["a.localhost".into()]).unwrap(); + let mut settings = Settings { + default_certificate: None, + certificates: vec![Certificate { + issuer: None, + id: "a".into(), + covered_hosts: Vec::new(), + hosts: vec!["b.localhost".into()], + cert_file: "a.pem".into(), + key_file: "a.key".into(), + }], + ..Settings::default() + }; + assert!(compile(&settings, Some(dir.path())).is_err()); + settings.certificates[0].hosts = vec!["a.localhost".into()]; + assert!(compile(&settings, Some(dir.path())).is_ok()); + settings.default_certificate = Some("missing".into()); + assert!(compile(&settings, Some(dir.path())).is_err()); + } + #[test] + fn named_generation_never_overwrites_keys() { + let dir = tempfile::tempdir().unwrap(); + generate(dir.path(), "app", vec!["app.localhost".into()]).unwrap(); + let before = fs::read(dir.path().join("app.key")).unwrap(); + assert!(generate(dir.path(), "app", vec!["other.localhost".into()]).is_err()); + assert_eq!(before, fs::read(dir.path().join("app.key")).unwrap()); + } + #[test] + fn certificate_hostname_bindings_are_not_artificially_capped() { + let dir = tempfile::tempdir().unwrap(); + let hosts = (0..40) + .map(|index| format!("host-{index}.example.test")) + .collect::>(); + generate(dir.path(), "many-hosts", hosts.clone()).unwrap(); + let settings = Settings { + default_certificate: Some("many-hosts".into()), + certificates: vec![Certificate { + issuer: None, + id: "many-hosts".into(), + covered_hosts: hosts.clone(), + hosts: hosts.clone(), + cert_file: "many-hosts.pem".into(), + key_file: "many-hosts.key".into(), + }], + ..Settings::default() + }; + assert!(compile(&settings, Some(dir.path())).unwrap().is_some()); + + let pair = rcgen::generate_simple_self_signed(hosts.clone()).unwrap(); + assert!(upload( + dir.path(), + pair.cert.pem().as_bytes(), + pair.key_pair.serialize_pem().as_bytes(), + hosts, + ) + .is_ok()); + } + #[test] + fn uploads_validate_key_and_names_and_store_only_private_immutable_files() { + let dir = tempfile::tempdir().unwrap(); + initialize_default(dir.path()).unwrap(); + let pair = rcgen::generate_simple_self_signed(vec![ + "app.localhost".into(), + "cdn.localhost".into(), + ]) + .unwrap(); + let pem = pair.cert.pem(); + let key = pair.key_pair.serialize_pem(); + assert!(upload( + dir.path(), + pem.as_bytes(), + key.as_bytes(), + vec!["other.localhost".into()] + ) + .is_err()); + assert!(upload( + dir.path(), + pem.as_bytes(), + b"invalid", + vec!["app.localhost".into()] + ) + .is_err()); + let a = upload( + dir.path(), + pem.as_bytes(), + key.as_bytes(), + vec!["app.localhost".into()], + ) + .unwrap(); + let b = upload( + dir.path(), + pem.as_bytes(), + key.as_bytes(), + vec!["app.localhost".into()], + ) + .unwrap(); + assert_ne!(a.id, b.id); + assert!(a.hosts.is_empty()); + assert_eq!(a.covered_hosts, ["app.localhost", "cdn.localhost"]); + assert_eq!( + fs::read(dir.path().join(&a.key_file)).unwrap(), + key.as_bytes() + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(dir.path().join(&a.key_file)) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + let inventory = Settings { + default_certificate: None, + certificates: vec![a.clone()], + ..Settings::default() + }; + assert!(compile(&inventory, Some(dir.path())).is_ok()); + let vhost: crate::vhost::Vhost = serde_json::from_value(serde_json::json!({"id":"app", + "certificate_id":a.id,"frontend":{"hosts":["app.localhost"],"listener_ids":["https"]}, + "backends":[{"id":"origin","url":"http://127.0.0.1:9001","allow_private":true}]})) + .unwrap(); + let selected = for_vhosts(&inventory, &[vhost]).unwrap(); + assert_eq!(selected.certificates[0].hosts, ["app.localhost"]); + assert!(compile(&selected, Some(dir.path())).is_ok()); + } + + #[test] + fn legacy_certificate_coverage_can_be_read_from_existing_pem() { + let dir = tempfile::tempdir().unwrap(); + let pair = rcgen::generate_simple_self_signed(vec![ + "app.example.org".into(), + "*.example.org".into(), + ]) + .unwrap(); + fs::write(dir.path().join("legacy.pem"), pair.cert.pem()).unwrap(); + assert_eq!( + certificate_covered_hosts(dir.path(), "legacy.pem").unwrap(), + ["*.example.org", "app.example.org"] + ); + assert!(certificate_covered_hosts(dir.path(), "../legacy.pem").is_err()); + } +} + +#[cfg(test)] +mod acme_tests { + use super::*; + #[test] + fn staged_acme_pair_requires_matching_key_and_requested_sans() { + let dir = tempfile::tempdir().unwrap(); + let cert = + rcgen::generate_simple_self_signed(vec!["example.org".into(), "*.example.org".into()]) + .unwrap(); + store_pair( + dir.path(), + "acme-fixture", + cert.cert.pem().as_bytes(), + cert.key_pair.serialize_pem().as_bytes(), + ) + .unwrap(); + let requested = vec!["example.org".into(), "*.example.org".into()]; + let item = validate_acme( + dir.path(), + "public".into(), + "acme-fixture.pem".into(), + "acme-fixture.key".into(), + &requested, + ) + .unwrap(); + assert_eq!(item.id, "public"); + assert!(item.hosts.is_empty()); + assert_eq!(item.covered_hosts, ["*.example.org", "example.org"]); + assert!(validate_acme( + dir.path(), + "public".into(), + "acme-fixture.pem".into(), + "acme-fixture.key".into(), + &["other.example.net".into()] + ) + .is_err()); + assert!(validate_acme( + dir.path(), + "public".into(), + "../acme-fixture.pem".into(), + "acme-fixture.key".into(), + &requested + ) + .is_err()); + assert!(validate_acme( + dir.path(), + "public".into(), + "acme-fixture.pem".into(), + "acme-fixture.key".into(), + &[] + ) + .is_err()); + } + + #[test] + fn acme_validation_has_no_rustproxy_domain_count_ceiling() { + let dir = tempfile::tempdir().unwrap(); + let requested = (0..24) + .map(|index| format!("acme-{index}.example.test")) + .collect::>(); + let cert = rcgen::generate_simple_self_signed(requested.clone()).unwrap(); + store_pair( + dir.path(), + "acme-many", + cert.cert.pem().as_bytes(), + cert.key_pair.serialize_pem().as_bytes(), + ) + .unwrap(); + assert!(validate_acme( + dir.path(), + "many".into(), + "acme-many.pem".into(), + "acme-many.key".into(), + &requested, + ) + .is_ok()); + } +} diff --git a/src/transform/css.rs b/src/transform/css.rs new file mode 100644 index 0000000..11bf6ff --- /dev/null +++ b/src/transform/css.rs @@ -0,0 +1,65 @@ +use anyhow::{Context, Result}; + +pub fn minify(body: &[u8]) -> Result> { + let input = std::str::from_utf8(body).context("CSS is not UTF-8")?; + let mut out = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + let mut quote: Option = None; + let mut pending_space = false; + while let Some(ch) = chars.next() { + if let Some(q) = quote { + out.push(ch); + if ch == '\\' { + if let Some(next) = chars.next() { + out.push(next); + } + } else if ch == q { + quote = None; + } + continue; + } + if matches!(ch, '\'' | '"') { + if pending_space && needs_space(out.chars().last(), Some(ch)) { + out.push(' '); + } + pending_space = false; + quote = Some(ch); + out.push(ch); + continue; + } + if ch == '/' && chars.peek() == Some(&'*') { + chars.next(); + let mut previous = '\0'; + while let Some(next) = chars.next() { + if previous == '*' && next == '/' { + break; + } + previous = next; + } + pending_space = true; + continue; + } + if ch.is_whitespace() { + pending_space = true; + continue; + } + if pending_space && needs_space(out.chars().last(), Some(ch)) { + out.push(' '); + } + pending_space = false; + if matches!(ch, '{' | '}' | ':' | ';' | ',' | '>') && out.ends_with(' ') { + out.pop(); + } + out.push(ch); + } + let trimmed_len = out.trim_end().len(); + out.truncate(trimmed_len); + Ok(out.into_bytes()) +} + +fn needs_space(previous: Option, next: Option) -> bool { + let Some(previous) = previous else { return false; }; + let Some(next) = next else { return false; }; + !matches!(previous, '{' | '}' | ':' | ';' | ',' | '>') + && !matches!(next, '{' | '}' | ':' | ';' | ',' | '>') +} diff --git a/src/transform/html.rs b/src/transform/html.rs new file mode 100644 index 0000000..f48ceb7 --- /dev/null +++ b/src/transform/html.rs @@ -0,0 +1,130 @@ +use anyhow::{Context, Result}; + +/// Conservative HTML minification. +/// +/// HTML whitespace can be semantically significant, especially between inline +/// elements, so this transformer deliberately preserves all whitespace. It only +/// removes ordinary comments outside raw-text elements. Conditional comments and +/// comment-looking bytes inside script/style/textarea/pre are preserved. +pub fn minify(body: &[u8]) -> Result> { + let input = std::str::from_utf8(body).context("HTML is not UTF-8")?; + let bytes = input.as_bytes(); + let mut out = String::with_capacity(input.len()); + let mut cursor = 0usize; + let mut raw_element: Option = None; + + while cursor < bytes.len() { + if bytes[cursor] == b'<' { + if raw_element.is_none() && starts_with_ascii_ci(bytes, cursor, b"") else { + return Ok(body.to_vec()); + }; + let end = end + 3; + out.push_str(&input[cursor..end]); + cursor = end; + continue; + } + if raw_element.is_none() && bytes[cursor..].starts_with(b"") else { + return Ok(body.to_vec()); + }; + cursor = end + 3; + continue; + } + + if let Some(end) = find_tag_end(bytes, cursor + 1) { + let tag = &input[cursor..=end]; + out.push_str(tag); + if let Some((name, closing, self_closing)) = tag_name(tag) { + if closing { + if raw_element.as_deref() == Some(name.as_str()) { + raw_element = None; + } + } else if !self_closing + && raw_element.is_none() + && matches!(name.as_str(), "script" | "style" | "textarea" | "pre") + { + raw_element = Some(name); + } + } + cursor = end + 1; + continue; + } + } + + let ch = input[cursor..] + .chars() + .next() + .expect("cursor is inside UTF-8 input"); + out.push(ch); + cursor += ch.len_utf8(); + } + + Ok(out.into_bytes()) +} + +fn find_bytes(haystack: &[u8], start: usize, needle: &[u8]) -> Option { + haystack + .get(start..)? + .windows(needle.len()) + .position(|window| window == needle) + .map(|offset| start + offset) +} + +fn starts_with_ascii_ci(haystack: &[u8], start: usize, needle: &[u8]) -> bool { + haystack + .get(start..start.saturating_add(needle.len())) + .is_some_and(|value| value.eq_ignore_ascii_case(needle)) +} + +fn find_tag_end(bytes: &[u8], mut cursor: usize) -> Option { + let mut quote: Option = None; + while cursor < bytes.len() { + match (quote, bytes[cursor]) { + (Some(active), ch) if ch == active => quote = None, + (None, b'"' | b'\'') => quote = Some(bytes[cursor]), + (None, b'>') => return Some(cursor), + _ => {} + } + cursor += 1; + } + None +} + +fn tag_name(tag: &str) -> Option<(String, bool, bool)> { + let inner = tag.strip_prefix('<')?.strip_suffix('>')?.trim(); + if inner.starts_with('!') || inner.starts_with('?') { + return None; + } + let closing = inner.starts_with('/'); + let inner = if closing { inner[1..].trim_start() } else { inner }; + let self_closing = inner.ends_with('/'); + let name = inner + .split(|ch: char| ch.is_ascii_whitespace() || ch == '/' || ch == '>') + .next()? + .to_ascii_lowercase(); + (!name.is_empty()).then_some((name, closing, self_closing)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_semantic_inline_whitespace() { + let input = b"Hello world"; + assert_eq!(minify(input).unwrap().as_slice(), input); + } + + #[test] + fn strips_only_safe_comments() { + let input = b"
x
"; + assert_eq!(minify(input).unwrap(), b"
x
"); + } + + #[test] + fn preserves_comment_syntax_inside_script() { + let input = b""; + assert_eq!(minify(input).unwrap().as_slice(), input); + } +} diff --git a/src/transform/js.rs b/src/transform/js.rs new file mode 100644 index 0000000..62315df --- /dev/null +++ b/src/transform/js.rs @@ -0,0 +1,140 @@ +use anyhow::{Context, Result}; + +/// Conservative JavaScript minification without token rewriting. +/// +/// JavaScript `/` is context-sensitive (division vs. RegExp literal), so this +/// minifier never guesses. Outside strings/templates/comments it collapses +/// horizontal whitespace only until the first slash on a physical line; the +/// remainder of that line is copied verbatim. This still removes common +/// indentation/alignment whitespace while preserving RegExp bodies, ASI-sensitive +/// line terminators and comment text exactly. +pub fn minify(body: &[u8]) -> Result> { + let input = std::str::from_utf8(body).context("JavaScript is not UTF-8")?; + let mut out = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + let mut quote: Option = None; + let mut block_comment = false; + let mut line_comment = false; + let mut slash_sensitive = false; + let mut horizontal_space = false; + + while let Some(ch) = chars.next() { + if line_comment { + out.push(ch); + if ch == '\n' || ch == '\r' { + line_comment = false; + slash_sensitive = false; + horizontal_space = false; + } + continue; + } + + if block_comment { + out.push(ch); + if ch == '*' && chars.peek() == Some(&'/') { + chars.next(); + out.push('/'); + block_comment = false; + } + if ch == '\n' || ch == '\r' { + slash_sensitive = false; + } + continue; + } + + if let Some(active) = quote { + out.push(ch); + if ch == '\\' { + if let Some(next) = chars.next() { + out.push(next); + } + } else if ch == active { + quote = None; + } + continue; + } + + if slash_sensitive { + out.push(ch); + if ch == '\n' || ch == '\r' { + slash_sensitive = false; + horizontal_space = false; + } + continue; + } + + if matches!(ch, '\'' | '"' | '`') { + if horizontal_space { + out.push(' '); + horizontal_space = false; + } + quote = Some(ch); + out.push(ch); + continue; + } + + if ch == '/' { + if horizontal_space { + out.push(' '); + horizontal_space = false; + } + out.push(ch); + match chars.peek() { + Some(&'/') => { + chars.next(); + out.push('/'); + line_comment = true; + } + Some(&'*') => { + chars.next(); + out.push('*'); + block_comment = true; + } + _ => slash_sensitive = true, + } + continue; + } + + if matches!(ch, ' ' | '\t' | '\u{000b}' | '\u{000c}') { + horizontal_space = true; + continue; + } + + if ch == '\r' || ch == '\n' { + horizontal_space = false; + slash_sensitive = false; + out.push(ch); + continue; + } + + if horizontal_space { + out.push(' '); + horizontal_space = false; + } + out.push(ch); + } + + let trimmed_len = out.trim_end_matches([' ', '\t']).len(); + out.truncate(trimmed_len); + Ok(out.into_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_regex_literals_and_asi_sensitive_newlines() { + let input = b"const r = /a b[/*]/; // keep\nreturn value;\n"; + let output = String::from_utf8(minify(input).unwrap()).unwrap(); + assert!(output.contains("/a b[/*]/; // keep")); + assert!(output.contains("return value")); + } + + #[test] + fn keeps_template_literal_whitespace() { + let input = b"const x = `a b ${ value }`;\n"; + let output = String::from_utf8(minify(input).unwrap()).unwrap(); + assert_eq!(output, "const x = `a b ${ value }`;\n"); + } +} diff --git a/src/transform/json.rs b/src/transform/json.rs new file mode 100644 index 0000000..6b722f2 --- /dev/null +++ b/src/transform/json.rs @@ -0,0 +1,6 @@ +use anyhow::{Context, Result}; + +pub fn compact(body: &[u8]) -> Result> { + let value: serde_json::Value = serde_json::from_slice(body).context("parse JSON")?; + serde_json::to_vec(&value).context("serialize compact JSON") +} diff --git a/src/transform/mod.rs b/src/transform/mod.rs new file mode 100644 index 0000000..6bc353e --- /dev/null +++ b/src/transform/mod.rs @@ -0,0 +1,1030 @@ +//! Static-response transformation stage. Transformations run on the identity +//! representation before shared-cache storage and before downstream compression. +use anyhow::{ensure, Context as _, Result}; +use axum::{body::Body, response::Response}; +use http::{HeaderMap, HeaderValue}; +use http_body_util::BodyExt; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::io::Read; +use std::sync::Mutex; +use std::time::Duration; + +mod css; +mod html; +mod js; +mod json; +mod svg; + +const DEFAULT_MAX_BODY_BYTES: usize = 2 * 1024 * 1024; +#[cfg(all(target_os = "linux", target_env = "gnu"))] +const ALLOCATOR_TRIM_MIN_BODY_BYTES: usize = 256 * 1024; + +/// Large transformable responses can temporarily own the collected origin body, +/// a decoded buffer and the transformed output at the same time. On glibc, freed +/// pages may remain in malloc arenas for a long time. The systemd unit also caps +/// arena proliferation with `MALLOC_ARENA_MAX=2`; this low-frequency trim handles +/// the large transient buffers without adding a trim syscall to every request. +fn trim_allocator_after_large_transform(released_bytes: usize) { + #[cfg(all(target_os = "linux", target_env = "gnu"))] + { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + if released_bytes < ALLOCATOR_TRIM_MIN_BODY_BYTES { + return; + } + static LAST_TRIM_SECONDS: AtomicU64 = AtomicU64::new(0); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let previous = LAST_TRIM_SECONDS.load(Ordering::Relaxed); + if now.saturating_sub(previous) < 30 { + return; + } + if LAST_TRIM_SECONDS + .compare_exchange(previous, now, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + unsafe { + libc::malloc_trim(0); + } + } + } + #[cfg(not(all(target_os = "linux", target_env = "gnu")))] + { + let _ = released_bytes; + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + /// Global kill switch. Per-vhost opt-in is still required. + pub enabled: bool, + pub javascript: bool, + pub css: bool, + pub html: bool, + pub svg: bool, + pub json: bool, + /// Optional extension allow-list. Content-Type recognition remains available, + /// but an extension outside this list is never transformed by extension alone. + pub extensions: Vec, + /// Hard aggregation/decode ceiling for transformable static responses. + pub max_body_bytes: usize, +} +impl Default for Settings { + fn default() -> Self { + Self { + enabled: false, + javascript: true, + css: true, + html: false, + svg: false, + json: false, + extensions: vec![ + "js", "mjs", "cjs", "css", "html", "htm", "svg", "json", + ] + .into_iter() + .map(str::to_owned) + .collect(), + max_body_bytes: DEFAULT_MAX_BODY_BYTES, + } + } +} +impl Settings { + pub fn validate(&self) -> Result<()> { + ensure!( + (1024..=32 * 1024 * 1024).contains(&self.max_body_bytes), + "transform.max_body_bytes must be 1 KiB..32 MiB" + ); + ensure!(self.extensions.len() <= 64, "at most 64 transform extensions"); + let mut seen = std::collections::HashSet::new(); + for extension in &self.extensions { + ensure!( + !extension.is_empty() + && extension.len() <= 16 + && extension.bytes().all(|b| b.is_ascii_alphanumeric()), + "transform extensions must be 1..16 ASCII letters or numbers without a dot" + ); + ensure!( + seen.insert(extension.to_ascii_lowercase()), + "duplicate transform extension" + ); + } + Ok(()) + } +} + +/// Per-vhost transformation policy. Unlike cache overrides, transformation is +/// deliberately opt-in per vhost even when the global engine is enabled. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Overrides { + pub enabled: bool, + pub javascript: Option, + pub css: Option, + pub html: Option, + pub svg: Option, + pub json: Option, + pub extensions: Option>, +} +impl Default for Overrides { + fn default() -> Self { + Self { + enabled: false, + javascript: None, + css: None, + html: None, + svg: None, + json: None, + extensions: None, + } + } +} +impl Overrides { + pub fn resolve(&self, global: &Settings) -> Result { + let mut resolved = global.clone(); + resolved.enabled = global.enabled && self.enabled; + if let Some(value) = self.javascript { + resolved.javascript = value; + } + if let Some(value) = self.css { + resolved.css = value; + } + if let Some(value) = self.html { + resolved.html = value; + } + if let Some(value) = self.svg { + resolved.svg = value; + } + if let Some(value) = self.json { + resolved.json = value; + } + if let Some(value) = &self.extensions { + resolved.extensions = value.clone(); + } + resolved.validate()?; + Ok(resolved) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Kind { + Javascript, + Css, + Html, + Svg, + Json, +} + +impl Kind { + pub fn as_str(self) -> &'static str { + match self { + Self::Javascript => "javascript", + Self::Css => "css", + Self::Html => "html", + Self::Svg => "svg", + Self::Json => "json", + } + } +} + +/// Origin validators are retained separately because transformed bytes receive +/// a RustProxy validator that must never be sent back to the origin. +#[derive(Debug, Clone)] +pub struct OriginValidators { + pub etag: Option, + pub last_modified: Option, +} + +#[derive(Debug, Clone, Copy)] +pub struct Applied { + pub kind: Kind, + pub original_bytes: usize, + pub transformed_bytes: usize, +} + +/// Internal observability marker for a response which was examined by the +/// transform stage but intentionally left unchanged. Reasons are fixed tokens +/// and never contain request or response data. +#[derive(Debug, Clone, Copy)] +pub struct Skipped { + pub kind: Option, + pub reason: &'static str, +} + +/// Marker restored on responses served from a cached transformed +/// representation. It lets diagnostics distinguish a transformed HIT from an +/// ordinary cached object without re-running the transformer. +#[derive(Debug, Clone, Copy)] +pub struct CachedTransformed; + +#[derive(Debug, Clone, Default, Serialize)] +pub struct Counters { + pub examined: u64, + pub applied: u64, + pub skipped: u64, + pub errors: u64, + pub cache_hits: u64, + pub original_bytes: u64, + pub transformed_bytes: u64, + pub saved_bytes: u64, + pub duration_us: u64, + pub by_kind: BTreeMap, + pub reasons: BTreeMap, +} + +impl Counters { + fn record_applied(&mut self, applied: Applied, elapsed: Duration) { + self.examined = self.examined.saturating_add(1); + self.applied = self.applied.saturating_add(1); + self.duration_us = self + .duration_us + .saturating_add(elapsed.as_micros().min(u64::MAX as u128) as u64); + self.original_bytes = self + .original_bytes + .saturating_add(applied.original_bytes as u64); + self.transformed_bytes = self + .transformed_bytes + .saturating_add(applied.transformed_bytes as u64); + self.saved_bytes = self.saved_bytes.saturating_add( + applied + .original_bytes + .saturating_sub(applied.transformed_bytes) as u64, + ); + *self.by_kind.entry(applied.kind.as_str().into()).or_default() += 1; + } + + fn record_skipped(&mut self, skipped: Skipped, elapsed: Duration) { + self.examined = self.examined.saturating_add(1); + self.skipped = self.skipped.saturating_add(1); + self.duration_us = self + .duration_us + .saturating_add(elapsed.as_micros().min(u64::MAX as u128) as u64); + *self.reasons.entry(skipped.reason.into()).or_default() += 1; + } + + fn record_error(&mut self, elapsed: Duration) { + self.examined = self.examined.saturating_add(1); + self.errors = self.errors.saturating_add(1); + self.duration_us = self + .duration_us + .saturating_add(elapsed.as_micros().min(u64::MAX as u128) as u64); + *self.reasons.entry("body_error".into()).or_default() += 1; + } + + fn record_cache_hit(&mut self) { + self.cache_hits = self.cache_hits.saturating_add(1); + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ScopeCounters { + pub vhost: String, + #[serde(flatten)] + pub counters: Counters, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct MetricsView { + pub total: Counters, + pub scopes: Vec, +} + +#[derive(Default)] +struct MetricsStore { + total: Counters, + scopes: BTreeMap, +} + +#[derive(Default)] +pub struct Metrics { + inner: Mutex, +} + +impl Metrics { + fn with_scope(&self, scope: &str, mut update: impl FnMut(&mut Counters)) { + let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + update(&mut inner.total); + if inner.scopes.len() < 4096 || inner.scopes.contains_key(scope) { + update(inner.scopes.entry(scope.to_owned()).or_default()); + } + } + + pub fn record_response(&self, scope: &str, response: &Response, elapsed: Duration) { + if let Some(applied) = response.extensions().get::().copied() { + self.with_scope(scope, |counters| counters.record_applied(applied, elapsed)); + return; + } + let skipped = response + .extensions() + .get::() + .copied() + .unwrap_or(Skipped { + kind: None, + reason: "not_applied", + }); + self.with_scope(scope, |counters| counters.record_skipped(skipped, elapsed)); + } + + pub fn record_error(&self, scope: &str, elapsed: Duration) { + self.with_scope(scope, |counters| counters.record_error(elapsed)); + } + + pub fn record_cache_hit(&self, scope: &str) { + self.with_scope(scope, Counters::record_cache_hit); + } + + pub fn total(&self) -> Counters { + self.inner + .lock() + .unwrap_or_else(|e| e.into_inner()) + .total + .clone() + } + + pub fn view(&self) -> MetricsView { + let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + MetricsView { + total: inner.total.clone(), + scopes: inner + .scopes + .iter() + .map(|(vhost, counters)| ScopeCounters { + vhost: vhost.clone(), + counters: counters.clone(), + }) + .collect(), + } + } +} + +/// Marks a response whose upstream content-negotiation dimension has been +/// normalized to the identity representation. The shared cache can therefore +/// ignore origin `Vary: Accept-Encoding`; downstream compression recreates the +/// client-specific encoding after the cache lookup. +#[derive(Debug, Clone, Copy)] +pub struct CanonicalIdentity; + +pub trait Transformer { + fn transform(&self, body: &[u8]) -> Result>; +} + +struct JsTransformer; +impl Transformer for JsTransformer { + fn transform(&self, body: &[u8]) -> Result> { + js::minify(body) + } +} +struct CssTransformer; +impl Transformer for CssTransformer { + fn transform(&self, body: &[u8]) -> Result> { + css::minify(body) + } +} +struct HtmlTransformer; +impl Transformer for HtmlTransformer { + fn transform(&self, body: &[u8]) -> Result> { + html::minify(body) + } +} +struct SvgTransformer; +impl Transformer for SvgTransformer { + fn transform(&self, body: &[u8]) -> Result> { + svg::optimize(body) + } +} +struct JsonTransformer; +impl Transformer for JsonTransformer { + fn transform(&self, body: &[u8]) -> Result> { + json::compact(body) + } +} + +fn transformer(kind: Kind) -> &'static dyn Transformer { + static JS: JsTransformer = JsTransformer; + static CSS: CssTransformer = CssTransformer; + static HTML: HtmlTransformer = HtmlTransformer; + static SVG: SvgTransformer = SvgTransformer; + static JSON: JsonTransformer = JsonTransformer; + match kind { + Kind::Javascript => &JS, + Kind::Css => &CSS, + Kind::Html => &HTML, + Kind::Svg => &SVG, + Kind::Json => &JSON, + } +} + +pub fn no_transform(headers: &HeaderMap) -> bool { + headers + .get_all("cache-control") + .iter() + .filter_map(|value| value.to_str().ok()) + .any(|value| { + value.split(',').any(|directive| { + directive + .trim() + .split('=') + .next() + .is_some_and(|name| name.trim().eq_ignore_ascii_case("no-transform")) + }) + }) +} + +fn content_type_kind(headers: &HeaderMap) -> Option { + let value = headers.get("content-type")?.to_str().ok()?; + let mime = value.split(';').next()?.trim().to_ascii_lowercase(); + match mime.as_str() { + "application/javascript" | "text/javascript" | "application/ecmascript" + | "text/ecmascript" => Some(Kind::Javascript), + "text/css" => Some(Kind::Css), + "text/html" | "application/xhtml+xml" => Some(Kind::Html), + "image/svg+xml" => Some(Kind::Svg), + "application/json" | "text/json" => Some(Kind::Json), + _ if mime.ends_with("+json") => Some(Kind::Json), + _ => None, + } +} + +fn extension(path: &str) -> Option { + let path = path.split('?').next().unwrap_or(path); + let segment = path.rsplit('/').next().unwrap_or(path); + let (_, extension) = segment.rsplit_once('.')?; + (!extension.is_empty()).then(|| extension.to_ascii_lowercase()) +} + +fn extension_kind(path: &str, settings: &Settings) -> Option { + let extension = extension(path)?; + if !settings + .extensions + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(&extension)) + { + return None; + } + match extension.as_str() { + "js" | "mjs" | "cjs" => Some(Kind::Javascript), + "css" => Some(Kind::Css), + "html" | "htm" => Some(Kind::Html), + "svg" => Some(Kind::Svg), + "json" => Some(Kind::Json), + _ => None, + } +} + +fn kind_enabled(kind: Kind, settings: &Settings) -> bool { + match kind { + Kind::Javascript => settings.javascript, + Kind::Css => settings.css, + Kind::Html => settings.html, + Kind::Svg => settings.svg, + Kind::Json => settings.json, + } +} + +fn supported_encoding(headers: &HeaderMap) -> Option> { + let Some(value) = headers.get("content-encoding") else { + return Some(None); + }; + let value = value.to_str().ok()?.trim(); + if value.eq_ignore_ascii_case("identity") { + return Some(None); + } + for known in ["gzip", "deflate", "br", "zstd"] { + if value.eq_ignore_ascii_case(known) { + return Some(Some(known)); + } + } + None +} + +fn decode<'a>(body: &'a [u8], encoding: Option<&str>, limit: usize) -> Result> { + fn read_bounded(reader: impl Read, limit: usize, label: &'static str) -> Result> { + let mut output = Vec::with_capacity(limit.min(64 * 1024)); + let mut bounded = reader.take(limit.saturating_add(1) as u64); + bounded.read_to_end(&mut output).with_context(|| format!("decode {label}"))?; + ensure!( + output.len() <= limit, + "decoded transform body exceeds configured limit" + ); + Ok(output) + } + + match encoding { + None => { + ensure!( + body.len() <= limit, + "transform body exceeds configured limit" + ); + // Identity bodies are already owned by `Bytes` after collection. + // Borrow them instead of creating a second full-size allocation. + Ok(Cow::Borrowed(body)) + } + Some("gzip") => read_bounded(flate2::read::GzDecoder::new(body), limit, "gzip") + .map(Cow::Owned), + Some("deflate") => { + let zlib = flate2::read::ZlibDecoder::new(body); + match read_bounded(zlib, limit, "deflate") { + Ok(value) => Ok(Cow::Owned(value)), + Err(_) => read_bounded( + flate2::read::DeflateDecoder::new(body), + limit, + "deflate", + ) + .map(Cow::Owned), + } + } + Some("br") => read_bounded( + brotli::Decompressor::new(body, 16 * 1024), + limit, + "brotli", + ) + .map(Cow::Owned), + Some("zstd") => { + let decoder = zstd::stream::read::Decoder::new(body).context("open zstd decoder")?; + read_bounded(decoder, limit, "zstd").map(Cow::Owned) + } + Some(_) => unreachable!(), + } +} + +fn drop_vary_accept_encoding(headers: &mut HeaderMap) { + let values: Vec<_> = headers.get_all("vary").iter().cloned().collect(); + if values.is_empty() { + return; + } + let mut retained = Vec::::new(); + for value in values { + let Ok(value) = value.to_str() else { + return; + }; + for token in value.split(',').map(str::trim).filter(|token| !token.is_empty()) { + if token == "*" { + // `Vary: *` has special semantics and is rejected by the shared + // cache. Do not rewrite it into a different policy. + return; + } + if token.eq_ignore_ascii_case("accept-encoding") { + continue; + } + if !retained.iter().any(|existing| existing.eq_ignore_ascii_case(token)) { + retained.push(token.to_owned()); + } + } + } + headers.remove("vary"); + if !retained.is_empty() { + if let Ok(value) = HeaderValue::from_str(&retained.join(", ")) { + headers.insert("vary", value); + } + } +} + +fn transformed_etag(body: &[u8]) -> HeaderValue { + let digest = Sha256::digest(body); + let mut hex = String::with_capacity(64); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(&mut hex, "{byte:02x}"); + } + HeaderValue::from_str(&format!("\"rustproxy-{hex}\"")) + .expect("sha256 validator is always a valid header") +} + +/// Transform an eligible response. The call is fail-open for unsupported media, +/// encodings and malformed minifiable content; transport/body collection errors +/// still propagate because the original stream can no longer be replayed safely. +pub async fn apply( + response: Response, + path: &str, + settings: &Settings, + origin_no_transform: bool, +) -> Result { + let skip = |mut response: Response, kind: Option, reason: &'static str| { + response.extensions_mut().insert(Skipped { kind, reason }); + response + }; + if !settings.enabled { + return Ok(skip(response, None, "disabled")); + } + if origin_no_transform { + return Ok(skip(response, None, "origin_no_transform")); + } + if no_transform(response.headers()) { + return Ok(skip(response, None, "response_no_transform")); + } + if response.status().is_informational() + || response.status().as_u16() == 204 + || response.status().as_u16() == 304 + { + return Ok(skip(response, None, "status_ineligible")); + } + if response.headers().contains_key("content-range") { + return Ok(skip(response, None, "content_range")); + } + let Some(kind) = content_type_kind(response.headers()).or_else(|| extension_kind(path, settings)) + else { + return Ok(skip(response, None, "unsupported_type")); + }; + if !kind_enabled(kind, settings) { + return Ok(skip(response, Some(kind), "type_disabled")); + } + let Some(encoding) = supported_encoding(response.headers()) else { + return Ok(skip(response, Some(kind), "unsupported_encoding")); + }; + let Some(content_length) = response + .headers() + .get("content-length") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + else { + // Keep streaming semantics for unknown-size bodies instead of introducing + // unbounded buffering into the dataplane. + return Ok(skip(response, Some(kind), "unknown_length")); + }; + if content_length > settings.max_body_bytes { + return Ok(skip(response, Some(kind), "too_large")); + } + + let (mut parts, body) = response.into_parts(); + let raw = body + .collect() + .await + .context("collect static response for transformation")? + .to_bytes(); + if raw.len() > settings.max_body_bytes { + let mut response = Response::from_parts(parts, Body::from(raw)); + response.extensions_mut().insert(Skipped { + kind: Some(kind), + reason: "too_large", + }); + return Ok(response); + } + let decoded = match decode(&raw, encoding, settings.max_body_bytes) { + Ok(value) => value, + Err(_) => { + let mut response = Response::from_parts(parts, Body::from(raw)); + response.extensions_mut().insert(Skipped { + kind: Some(kind), + reason: "decode_fail_open", + }); + return Ok(response); + } + }; + let transformed = match transformer(kind).transform(decoded.as_ref()) { + Ok(value) if !value.is_empty() => value, + _ => { + let mut response = Response::from_parts(parts, Body::from(raw)); + response.extensions_mut().insert(Skipped { + kind: Some(kind), + reason: "transform_fail_open", + }); + return Ok(response); + } + }; + if transformed.as_slice() == decoded.as_ref() && encoding.is_none() { + // The origin was explicitly requested without Accept-Encoding and the + // eligible response is already identity. The bytes/validator stay intact, + // but the cache no longer needs an Accept-Encoding variant dimension. + drop_vary_accept_encoding(&mut parts.headers); + parts.extensions.insert(CanonicalIdentity); + parts.extensions.insert(Skipped { + kind: Some(kind), + reason: "unchanged", + }); + return Ok(Response::from_parts(parts, Body::from(raw))); + } + + let original_bytes = decoded.len(); + let released_bytes = raw.len().saturating_add(decoded.len()); + // The successful transformed response no longer needs the collected origin + // body or decoded temporary buffer. Drop both before building the response so + // large short-lived allocations do not overlap longer than necessary. + drop(decoded); + drop(raw); + trim_allocator_after_large_transform(released_bytes); + + let validators = parts + .extensions + .get::() + .cloned() + .unwrap_or_else(|| OriginValidators { + etag: parts.headers.get("etag").cloned(), + last_modified: parts.headers.get("last-modified").cloned(), + }); + parts.headers.remove("content-encoding"); + parts.headers.remove("content-length"); + parts.headers.remove("content-md5"); + parts.headers.remove("digest"); + parts.headers.remove("content-digest"); + parts.headers.remove("accept-ranges"); + parts.headers.insert("etag", transformed_etag(&transformed)); + parts.headers.insert( + "content-length", + HeaderValue::from_str(&transformed.len().to_string()).expect("integer header"), + ); + drop_vary_accept_encoding(&mut parts.headers); + parts.extensions.insert(validators); + parts.extensions.insert(CanonicalIdentity); + parts.extensions.insert(Applied { + kind, + original_bytes, + transformed_bytes: transformed.len(), + }); + Ok(Response::from_parts(parts, Body::from(transformed))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_decode_borrows_the_collected_body() { + let body = b"const value = 1;"; + let decoded = decode(body, None, 1024).unwrap(); + assert!(matches!(decoded, Cow::Borrowed(_))); + } + + #[test] + fn metrics_track_applied_skipped_and_cached_transformed_responses() { + let metrics = Metrics::default(); + let mut applied = Response::new(Body::empty()); + applied.extensions_mut().insert(Applied { + kind: Kind::Css, + original_bytes: 100, + transformed_bytes: 70, + }); + metrics.record_response("app", &applied, Duration::from_micros(500)); + let mut skipped = Response::new(Body::empty()); + skipped.extensions_mut().insert(Skipped { + kind: Some(Kind::Css), + reason: "response_no_transform", + }); + metrics.record_response("app", &skipped, Duration::from_micros(100)); + metrics.record_cache_hit("app"); + let total = metrics.total(); + assert_eq!(total.applied, 1); + assert_eq!(total.cache_hits, 1); + let view = metrics.view(); + assert_eq!(view.total.examined, 2); + assert_eq!(view.total.applied, 1); + assert_eq!(view.total.skipped, 1); + assert_eq!(view.total.cache_hits, 1); + assert_eq!(view.total.saved_bytes, 30); + assert_eq!(view.total.by_kind.get("css"), Some(&1)); + assert_eq!( + view.total.reasons.get("response_no_transform"), + Some(&1) + ); + assert_eq!(view.scopes.len(), 1); + assert_eq!(view.scopes[0].vhost, "app"); + } + + #[test] + fn per_vhost_is_opt_in_even_when_global_is_enabled() { + let global = Settings { + enabled: true, + ..Default::default() + }; + assert!(!Overrides::default().resolve(&global).unwrap().enabled); + let enabled = Overrides { + enabled: true, + ..Default::default() + }; + assert!(enabled.resolve(&global).unwrap().enabled); + } + + #[tokio::test] + async fn json_is_compacted_and_gets_a_new_validator() { + let settings = Settings { + enabled: true, + json: true, + ..Default::default() + }; + let mut response = Response::new(Body::from("{ \"a\": 1, \"b\": [ true ] }")); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static("application/json")); + response + .headers_mut() + .insert("content-length", HeaderValue::from_static("30")); + response + .headers_mut() + .insert("etag", HeaderValue::from_static("\"origin\"")); + response + .headers_mut() + .insert("vary", HeaderValue::from_static("Accept-Encoding")); + let response = apply(response, "/data.json", &settings, false).await.unwrap(); + assert!(response.headers()["etag"].to_str().unwrap().starts_with("\"rustproxy-")); + assert!(!response.headers().contains_key("vary")); + assert!(response.extensions().get::().is_some()); + let validators = response.extensions().get::().unwrap(); + assert_eq!(validators.etag.as_ref().unwrap(), "\"origin\""); + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&body[..], br#"{"a":1,"b":[true]}"#); + } + + #[tokio::test] + async fn unchanged_body_keeps_origin_validator() { + let settings = Settings { + enabled: true, + json: true, + ..Default::default() + }; + let body = br#"{"a":1}"#; + let mut response = Response::new(Body::from(body.as_slice())); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static("application/json")); + response + .headers_mut() + .insert("content-length", HeaderValue::from_static("7")); + response + .headers_mut() + .insert("etag", HeaderValue::from_static("\"origin\"")); + response.headers_mut().insert( + "vary", + HeaderValue::from_static("Accept-Encoding, X-Theme"), + ); + let response = apply(response, "/data.json", &settings, false).await.unwrap(); + assert_eq!(response.headers()["etag"], "\"origin\""); + assert_eq!(response.headers()["vary"], "X-Theme"); + assert!(response.extensions().get::().is_some()); + assert!(response.extensions().get::().is_none()); + } + #[tokio::test] + async fn unchanged_compressed_body_is_canonicalized_to_identity() { + use std::io::Write as _; + + let settings = Settings { + enabled: true, + json: true, + ..Default::default() + }; + let identity = br#"{"a":1}"#; + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(identity).unwrap(); + let encoded = encoder.finish().unwrap(); + + let mut response = Response::new(Body::from(encoded.clone())); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static("application/json")); + response.headers_mut().insert( + "content-length", + HeaderValue::from_str(&encoded.len().to_string()).unwrap(), + ); + response + .headers_mut() + .insert("content-encoding", HeaderValue::from_static("gzip")); + response + .headers_mut() + .insert("etag", HeaderValue::from_static("\"origin\"")); + + let response = apply(response, "/data.json", &settings, false).await.unwrap(); + assert!(!response.headers().contains_key("content-encoding")); + assert_eq!( + response.headers()["content-length"].to_str().unwrap(), + identity.len().to_string() + ); + assert!(response.headers()["etag"].to_str().unwrap().starts_with("\"rustproxy-")); + let validators = response.extensions().get::().unwrap(); + assert_eq!(validators.etag.as_ref().unwrap(), "\"origin\""); + let applied = response.extensions().get::().unwrap(); + assert_eq!(applied.original_bytes, identity.len()); + assert_eq!(applied.transformed_bytes, identity.len()); + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&body[..], identity); + } + #[tokio::test] + async fn origin_no_transform_blocks_body_changes_and_keeps_validator() { + let settings = Settings { + enabled: true, + json: true, + ..Default::default() + }; + let body = br#"{ "a": 1 }"#; + let mut response = Response::new(Body::from(body.as_slice())); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static("application/json")); + response.headers_mut().insert( + "content-length", + HeaderValue::from_str(&body.len().to_string()).unwrap(), + ); + response + .headers_mut() + .insert("etag", HeaderValue::from_static("\"origin\"")); + + let response = apply(response, "/data.json", &settings, true).await.unwrap(); + assert_eq!(response.headers()["etag"], "\"origin\""); + assert!(response.extensions().get::().is_none()); + let received = response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&received[..], body); + } + + #[tokio::test] + async fn final_no_transform_blocks_body_changes() { + let settings = Settings { + enabled: true, + json: true, + ..Default::default() + }; + let body = br#"{ "a": 1 }"#; + let mut response = Response::new(Body::from(body.as_slice())); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static("application/json")); + response.headers_mut().insert( + "content-length", + HeaderValue::from_str(&body.len().to_string()).unwrap(), + ); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=60, no-transform"), + ); + + let response = apply(response, "/data.json", &settings, false).await.unwrap(); + assert!(response.extensions().get::().is_none()); + let received = response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&received[..], body); + } + + #[tokio::test] + async fn empty_head_body_with_origin_length_keeps_origin_representation_headers() { + let settings = Settings { + enabled: true, + json: true, + ..Default::default() + }; + let mut response = Response::new(Body::empty()); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static("application/json")); + response + .headers_mut() + .insert("content-length", HeaderValue::from_static("76")); + response + .headers_mut() + .insert("etag", HeaderValue::from_static("\"origin-head\"")); + + let response = apply(response, "/data.json", &settings, false).await.unwrap(); + assert_eq!(response.headers()["content-length"], "76"); + assert_eq!(response.headers()["etag"], "\"origin-head\""); + assert!(response.extensions().get::().is_none()); + assert!(response.extensions().get::().is_none()); + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert!(body.is_empty()); + } + + #[tokio::test] + async fn compressed_no_transform_preserves_origin_encoding_and_bytes() { + use std::io::Write as _; + + let settings = Settings { + enabled: true, + json: true, + ..Default::default() + }; + let identity = br#"{ "a": 1 }"#; + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(identity).unwrap(); + let encoded = encoder.finish().unwrap(); + let mut response = Response::new(Body::from(encoded.clone())); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static("application/json")); + response.headers_mut().insert( + "content-length", + HeaderValue::from_str(&encoded.len().to_string()).unwrap(), + ); + response + .headers_mut() + .insert("content-encoding", HeaderValue::from_static("gzip")); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("public, max-age=60, no-transform"), + ); + response + .headers_mut() + .insert("vary", HeaderValue::from_static("Accept-Encoding")); + response + .headers_mut() + .insert("etag", HeaderValue::from_static("\"origin\"")); + + let response = apply(response, "/data.json", &settings, false).await.unwrap(); + assert_eq!(response.headers()["content-encoding"], "gzip"); + assert_eq!(response.headers()["etag"], "\"origin\""); + assert_eq!(response.headers()["vary"], "Accept-Encoding"); + assert!(response.extensions().get::().is_none()); + let received = response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&received[..], encoded.as_slice()); + } + +} diff --git a/src/transform/svg.rs b/src/transform/svg.rs new file mode 100644 index 0000000..b8777f1 --- /dev/null +++ b/src/transform/svg.rs @@ -0,0 +1,67 @@ +use anyhow::{Context, Result}; + +/// Conservative SVG optimization. Whitespace is preserved because it can be +/// visible inside text/tspan nodes. Only XML comments outside CDATA sections are +/// removed; malformed comment/CDATA markup is left unchanged. +pub fn optimize(body: &[u8]) -> Result> { + let input = std::str::from_utf8(body).context("SVG is not UTF-8")?; + let bytes = input.as_bytes(); + let mut out = String::with_capacity(input.len()); + let mut cursor = 0usize; + + while cursor < bytes.len() { + if bytes[cursor..].starts_with(b"") else { + return Ok(body.to_vec()); + }; + let end = end + 3; + out.push_str(&input[cursor..end]); + cursor = end; + continue; + } + if bytes[cursor..].starts_with(b"") else { + return Ok(body.to_vec()); + }; + cursor = end + 3; + continue; + } + + let ch = input[cursor..] + .chars() + .next() + .expect("cursor is inside UTF-8 input"); + out.push(ch); + cursor += ch.len_utf8(); + } + + Ok(out.into_bytes()) +} + +fn find_bytes(haystack: &[u8], start: usize, needle: &[u8]) -> Option { + haystack + .get(start..)? + .windows(needle.len()) + .position(|window| window == needle) + .map(|offset| start + offset) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_text_spacing_between_tspans() { + let input = b"A B"; + assert_eq!(optimize(input).unwrap().as_slice(), input); + } + + #[test] + fn removes_comments_without_touching_cdata() { + let input = b"<!--keep-->"; + assert_eq!( + optimize(input).unwrap(), + b"<!--keep-->" + ); + } +} diff --git a/src/update_check.rs b/src/update_check.rs new file mode 100644 index 0000000..e40abf8 --- /dev/null +++ b/src/update_check.rs @@ -0,0 +1,357 @@ +//! Small, fail-soft release checker for the administration footer. +//! +//! Defaults come from the repository-root UPDATE_CHECK.toml and are embedded in +//! the final binary. Operators may override them at process startup. A failed +//! request never affects proxy traffic or administration; the next scheduled +//! pass simply tries again. + +use anyhow::{bail, ensure, Context, Result}; +use axum::body::Body; +use http::{header, Request, Uri}; +use http_body_util::{BodyExt, Limited}; +use hyper_rustls::HttpsConnectorBuilder; +use hyper_util::{ + client::legacy::{connect::HttpConnector, Client}, + rt::TokioExecutor, +}; +use rustls::{ClientConfig, RootCertStore}; +use serde::Serialize; +use std::{ + sync::{Arc, RwLock}, + time::Duration, +}; +use tokio_util::sync::CancellationToken; +use url::Url; + +const EMBEDDED_DEFAULTS: &str = include_str!("../UPDATE_CHECK.toml"); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_VERSION_DOCUMENT_BYTES: usize = 256 * 1024; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Config { + pub repository_url: String, + pub version_url: String, + pub interval_seconds: u64, +} + +impl Config { + pub fn embedded() -> Result { + let mut repository_url = None; + let mut version_url = None; + let mut interval_seconds = None; + for (line_number, raw) in EMBEDDED_DEFAULTS.lines().enumerate() { + let line = raw.split('#').next().unwrap_or_default().trim(); + if line.is_empty() { + continue; + } + let (key, value) = line.split_once('=').with_context(|| { + format!("UPDATE_CHECK.toml:{} must use key = value", line_number + 1) + })?; + let key = key.trim(); + let value = value.trim(); + match key { + "repository_url" => repository_url = Some(parse_string(value, key)?), + "version_url" => version_url = Some(parse_string(value, key)?), + "interval_seconds" => { + interval_seconds = Some( + value + .parse::() + .with_context(|| "UPDATE_CHECK.toml interval_seconds must be an integer")?, + ) + } + _ => bail!("UPDATE_CHECK.toml contains unsupported key {key}"), + } + } + Self::validated( + repository_url.context("UPDATE_CHECK.toml repository_url is required")?, + version_url.context("UPDATE_CHECK.toml version_url is required")?, + interval_seconds.context("UPDATE_CHECK.toml interval_seconds is required")?, + ) + } + + pub fn with_overrides( + mut self, + repository_url: Option, + version_url: Option, + interval_seconds: Option, + ) -> Result { + if let Some(value) = repository_url { + self.repository_url = value; + } + if let Some(value) = version_url { + self.version_url = value; + } + if let Some(value) = interval_seconds { + self.interval_seconds = value; + } + Self::validated( + self.repository_url, + self.version_url, + self.interval_seconds, + ) + } + + fn validated(repository_url: String, version_url: String, interval_seconds: u64) -> Result { + let repository_url = validate_http_url("update repository URL", &repository_url)?; + let version_url = validate_http_url("update version URL", &version_url)?; + ensure!( + interval_seconds <= 31 * 24 * 60 * 60, + "update check interval must be at most 31 days (2678400 seconds)" + ); + Ok(Self { + repository_url: repository_url.trim_end_matches('/').to_owned(), + version_url, + interval_seconds, + }) + } +} + +fn parse_string(value: &str, key: &str) -> Result { + ensure!( + value.len() >= 2 && value.starts_with('"') && value.ends_with('"'), + "UPDATE_CHECK.toml {key} must be a quoted string" + ); + let value = &value[1..value.len() - 1]; + ensure!( + !value.contains('"') && !value.contains('\\'), + "UPDATE_CHECK.toml {key} does not support escaped characters" + ); + Ok(value.to_owned()) +} + +fn validate_http_url(label: &str, value: &str) -> Result { + let parsed = Url::parse(value).with_context(|| format!("invalid {label}"))?; + ensure!( + matches!(parsed.scheme(), "http" | "https"), + "{label} must use http:// or https://" + ); + ensure!(parsed.host_str().is_some(), "{label} must contain a host"); + ensure!( + parsed.username().is_empty() && parsed.password().is_none(), + "{label} must not contain credentials" + ); + Ok(parsed.to_string()) +} + +#[derive(Clone, Debug, Serialize)] +pub struct View { + pub current_version: String, + pub latest_version: Option, + pub update_available: bool, + pub repository_url: Option, +} + +pub struct SharedStatus { + view: RwLock, +} + +impl Default for SharedStatus { + fn default() -> Self { + Self { + view: RwLock::new(View { + current_version: env!("CARGO_PKG_VERSION").to_owned(), + latest_version: None, + update_available: false, + repository_url: None, + }), + } + } +} + +impl SharedStatus { + pub fn configure(&self, config: &Config) { + let mut view = self.view.write().unwrap_or_else(|poisoned| poisoned.into_inner()); + view.repository_url = Some(config.repository_url.clone()); + } + + pub fn snapshot(&self) -> View { + self.view + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + fn record_success(&self, latest_version: String) { + let mut view = self.view.write().unwrap_or_else(|poisoned| poisoned.into_inner()); + view.update_available = is_newer_version(&latest_version, &view.current_version); + view.latest_version = Some(latest_version); + } +} + +pub async fn poll_loop(status: Arc, config: Config, shutdown: CancellationToken) { + status.configure(&config); + if config.interval_seconds == 0 { + tracing::info!("automatic update checks disabled by zero interval"); + return; + } + let interval = Duration::from_secs(config.interval_seconds); + loop { + match check_once(&config).await { + Ok(version) => status.record_success(version), + Err(error) => tracing::debug!(%error, "update check failed; retrying on the next interval"), + } + tokio::select! { + _ = shutdown.cancelled() => return, + _ = tokio::time::sleep(interval) => {} + } + } +} + +async fn check_once(config: &Config) -> Result { + let document = tokio::time::timeout(REQUEST_TIMEOUT, fetch(&config.version_url)) + .await + .context("update check timed out")??; + package_version(&document) +} + +async fn fetch(url: &str) -> Result { + let mut roots = RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let tls = ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions()? + .with_root_certificates(roots) + .with_no_client_auth(); + let mut http = HttpConnector::new(); + http.enforce_http(false); + http.set_connect_timeout(Some(REQUEST_TIMEOUT)); + http.set_nodelay(true); + let connector = HttpsConnectorBuilder::new() + .with_tls_config(tls) + .https_or_http() + .enable_http1() + .wrap_connector(http); + let client: Client<_, Body> = Client::builder(TokioExecutor::new()).build(connector); + let uri: Uri = url.parse().context("invalid update version URI")?; + let request = Request::builder() + .method(http::Method::GET) + .uri(uri) + .header(header::ACCEPT, "text/plain, text/x-toml;q=0.9") + .header( + header::USER_AGENT, + concat!("RustProxy/", env!("CARGO_PKG_VERSION"), " update-check"), + ) + .body(Body::empty())?; + let response = client.request(request).await.context("update version request failed")?; + ensure!( + response.status().is_success(), + "update version request returned HTTP {}", + response.status() + ); + let body = Limited::new(response.into_body(), MAX_VERSION_DOCUMENT_BYTES) + .collect() + .await + .map_err(|error| { + anyhow::anyhow!( + "update version document is too large or incomplete: {error}" + ) + })? + .to_bytes(); + String::from_utf8(body.to_vec()).context("update version document is not UTF-8") +} + +fn package_version(document: &str) -> Result { + let mut package = false; + for raw in document.lines() { + let line = raw.trim(); + if line == "[package]" { + package = true; + continue; + } + if package && line.starts_with('[') { + break; + } + if !package || line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + if key.trim() != "version" { + continue; + } + let version = value.trim(); + ensure!( + version.len() >= 2 && version.starts_with('"') && version.ends_with('"'), + "package version in Cargo.toml must be a quoted string" + ); + let version = version[1..version.len() - 1].trim(); + parse_version(version).context("unsupported package version in Cargo.toml")?; + return Ok(version.to_owned()); + } + bail!("Cargo.toml [package] version was not found") +} + +fn parse_version(value: &str) -> Result<([u64; 3], Option<&str>)> { + let (core, pre) = value + .split_once('-') + .map_or((value, None), |(core, pre)| (core, Some(pre))); + let mut parts = core.split('.'); + let major = parts.next().context("missing major version")?.parse()?; + let minor = parts.next().context("missing minor version")?.parse()?; + let patch = parts.next().context("missing patch version")?.parse()?; + ensure!(parts.next().is_none(), "version must contain major.minor.patch"); + if let Some(pre) = pre { + ensure!(!pre.is_empty(), "pre-release version must not be empty"); + } + Ok(([major, minor, patch], pre)) +} + +fn is_newer_version(candidate: &str, current: &str) -> bool { + let (Ok((candidate_core, candidate_pre)), Ok((current_core, current_pre))) = + (parse_version(candidate), parse_version(current)) + else { + return false; + }; + match candidate_core.cmp(¤t_core) { + std::cmp::Ordering::Greater => true, + std::cmp::Ordering::Less => false, + std::cmp::Ordering::Equal => match (candidate_pre, current_pre) { + (None, Some(_)) => true, + (Some(_), None) | (None, None) => false, + (Some(candidate), Some(current)) => candidate > current, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_defaults_are_valid() { + let config = Config::embedded().unwrap(); + assert_eq!(config.repository_url, "https://git.linuxiarz.pl/gru/RustProxy"); + assert_eq!( + config.version_url, + "https://git.linuxiarz.pl/gru/RustProxy/raw/branch/master/Cargo.toml" + ); + assert_eq!(config.interval_seconds, 3600); + } + + #[test] + fn reads_package_version_only() { + let document = r#"[workspace.package] +version = "99.0.0" + +[package] +name = "rust-proxy" +version = "0.19.4" + +[dependencies] +foo = { version = "100.0.0" } +"#; + assert_eq!(package_version(document).unwrap(), "0.19.4"); + } + + #[test] + fn compares_release_versions() { + assert!(is_newer_version("0.19.5", "0.19.4")); + assert!(is_newer_version("0.20.0", "0.19.9")); + assert!(!is_newer_version("0.19.4", "0.19.4")); + assert!(!is_newer_version("0.19.3", "0.19.4")); + assert!(is_newer_version("0.19.4", "0.19.4-rc.1")); + assert!(!is_newer_version("0.19.4-rc.1", "0.19.4")); + } +} diff --git a/src/upstream.rs b/src/upstream.rs new file mode 100644 index 0000000..4950be7 --- /dev/null +++ b/src/upstream.rs @@ -0,0 +1,662 @@ +//! Per-backend connection pools and connect-time DNS validation. +//! No HTTP caller can supply a destination. DNS answers are validated once and +//! passed directly to HttpConnector; there is no second unvalidated lookup. +use crate::config::{ip_policy, Backend, UpstreamProtocol}; +use anyhow::{ensure, Context, Result}; +use axum::body::Body; +use http::Uri; +use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder}; +use hyper_util::{ + client::legacy::{ + connect::{dns::Name, HttpConnector}, + Client, + }, + rt::{TokioExecutor, TokioTimer}, +}; +use ipnet::IpNet; +use rustls::{ + client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, + pki_types::{CertificateDer, ServerName, UnixTime}, + ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme, +}; +use std::{ + future::Future, + io, + net::{IpAddr, SocketAddr, ToSocketAddrs}, + pin::Pin, + sync::{Arc, OnceLock}, + task::{Context as TaskContext, Poll}, + time::Duration, +}; +use tokio::sync::Semaphore; +use tower::Service; + +const DNS_TIMEOUT: Duration = Duration::from_secs(5); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_DNS_ANSWERS: usize = 32; +static DNS_SLOTS: OnceLock> = OnceLock::new(); +pub type BoxError = Box; +pub type ProxyClient = Client; + +#[derive(Clone, Debug, Default)] +pub struct AddressPolicy { + pub allow_private: bool, + pub accept_local_listener_risk: bool, + pub allowlist: Vec, + port: u16, + listeners: Vec, + local_ips: Vec, + interfaces_known: bool, +} +impl AddressPolicy { + pub fn new(backend: &Backend) -> Result { + ensure!( + backend.address_allowlist.len() <= 32, + "at most 32 backend CIDR allowlist entries" + ); + let allowlist = backend + .address_allowlist + .iter() + .map(|network| { + ensure!(network.len() <= 64, "CIDR is too long"); + network + .parse::() + .context("invalid backend CIDR allowlist entry") + }) + .collect::>>()?; + Ok(Self { + allow_private: backend.allow_private, + accept_local_listener_risk: backend.accept_local_listener_risk, + allowlist, + port: url::Url::parse(&backend.url)? + .port_or_known_default() + .unwrap_or(0), + ..Default::default() + }) + } + pub fn with_listeners(mut self, policy: &crate::config::ValidationPolicy) -> Self { + self.listeners = vec![policy.proxy_addr, policy.admin_addr]; + self.listeners.extend(policy.tls_addr); + let addresses = local_addresses(); + self.interfaces_known = addresses.is_some(); + self.local_ips = addresses.unwrap_or_default(); + self + } + pub fn with_bindings(mut self, bindings: &[SocketAddr]) -> Self { + self.listeners.extend_from_slice(bindings); + self.listeners.sort(); + self.listeners.dedup(); + self + } + pub fn check(&self, ip: IpAddr) -> Result<()> { + let ip = match ip { + IpAddr::V6(ip) => ip + .to_ipv4_mapped() + .map(IpAddr::V4) + .unwrap_or(IpAddr::V6(ip)), + ip => ip, + }; + ip_policy(ip, self.allow_private)?; + for listener in &self.listeners { + if listener.port() != self.port { + continue; + } + let local = match listener.ip() { + IpAddr::V6(ip) => ip + .to_ipv4_mapped() + .map(IpAddr::V4) + .unwrap_or(IpAddr::V6(ip)), + ip => ip, + }; + let local_interface = + self.local_ips.contains(&ip) || (!self.interfaces_known && locally_bindable(ip)); + let self_target = ip == local + || (ip.is_loopback() && local.is_loopback()) + || (local.is_unspecified() && (ip.is_loopback() || local_interface)); + ensure!( + !self_target || self.accept_local_listener_risk, + "backend resolves to a local RustProxy listener ({ip}:{} via {listener}); set allow_private=true and accept_local_listener_risk=true only after accepting proxy-loop/control-plane exposure risk", + self.port + ); + } + ensure!( + self.allowlist.is_empty() || self.allowlist.iter().any(|network| network.contains(&ip)), + "backend address is outside its CIDR allowlist" + ); + Ok(()) + } + fn validate_answers( + &self, + answers: impl Iterator, + ) -> io::Result> { + let mut approved = Vec::new(); + for (index, address) in answers.enumerate() { + if index >= MAX_DNS_ANSWERS { + return Err(io::Error::other("too many DNS answers")); + } + self.check(address.ip()) + .map_err(|e| io::Error::new(io::ErrorKind::PermissionDenied, e.to_string()))?; + if !approved.contains(&address) { + approved.push(address); + } + } + if approved.is_empty() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "DNS returned no usable addresses", + )); + } + Ok(approved) + } +} +#[derive(Clone, Debug)] +pub struct GuardedResolver { + policy: AddressPolicy, + /// When TLS uses a separate verified server name, resolve/connect only the + /// hostname from the configured backend URL, never the SNI hostname. + connect_host: Option, +} +impl Service for GuardedResolver { + type Response = std::vec::IntoIter; + type Error = io::Error; + type Future = Pin> + Send>>; + fn poll_ready(&mut self, _: &mut TaskContext<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + fn call(&mut self, name: Name) -> Self::Future { + let policy = self.policy.clone(); + let hostname = self + .connect_host + .clone() + .unwrap_or_else(|| name.as_str().to_owned()); + let permit = DNS_SLOTS + .get_or_init(|| Arc::new(Semaphore::new(16))) + .clone() + .try_acquire_owned(); + Box::pin(async move { + let permit = permit.map_err(|_| { + io::Error::new(io::ErrorKind::WouldBlock, "DNS worker limit reached") + })?; + let lookup = tokio::task::spawn_blocking(move || { + // The permit lives inside the worker even if the caller times out. + // An OS resolver stuck in getaddrinfo cannot create an unbounded worker queue. + let _permit = permit; + policy.validate_answers((hostname.as_str(), 0).to_socket_addrs()?) + }); + let result = tokio::time::timeout(DNS_TIMEOUT, lookup) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "DNS lookup timed out"))? + .map_err(|e| io::Error::other(e.to_string()))??; + Ok(result.into_iter()) + }) + } +} +type InnerConnector = HttpsConnector>; +#[derive(Clone, Debug)] +pub struct TimedConnector { + inner: InnerConnector, + policy: AddressPolicy, + timeout: Duration, +} +impl Service for TimedConnector { + type Response = >::Response; + type Error = BoxError; + type Future = + Pin> + Send>>; + fn poll_ready(&mut self, cx: &mut TaskContext<'_>) -> Poll> { + self.inner.poll_ready(cx).map_err(Into::into) + } + fn call(&mut self, uri: Uri) -> Self::Future { + if let Some(ip) = uri.host().and_then(|host| { + host.trim_start_matches('[') + .trim_end_matches(']') + .parse::() + .ok() + }) { + if let Err(error) = self.policy.check(ip) { + return Box::pin(async move { + Err(Box::new(io::Error::new( + io::ErrorKind::PermissionDenied, + error.to_string(), + )) as BoxError) + }); + } + } + let future = self.inner.call(uri); + let timeout = self.timeout; + Box::pin(async move { + match tokio::time::timeout(timeout, future).await { + Ok(result) => result.map_err(Into::into), + Err(error) => Err(Box::new(error) as BoxError), + } + }) + } +} +fn public_roots() -> RootCertStore { + let mut roots = RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + roots +} +fn roots(backend: &Backend) -> Result { + let mut roots = public_roots(); + if let Some(pem) = &backend.tls_ca_pem { + ensure!(pem.len() <= 65_536, "additional CA PEM exceeds 64 KiB"); + let certs = rustls_pemfile::certs(&mut pem.as_bytes()).collect::>>()?; + ensure!( + !certs.is_empty() && certs.len() <= 16, + "additional CA PEM must contain 1..16 certificates" + ); + for cert in certs { + roots.add(cert).context("invalid additional trust anchor")?; + } + } + Ok(roots) +} + +/// Explicit backend-only escape hatch. `tls_verify=false` means that the backend +/// certificate is not authenticated at all: chain/name checks and certificate +/// signature parsing are skipped. TLS transport encryption and the normal +/// address/DNS/loop policy remain in force. +/// +/// Do not delegate CertificateVerify to WebPkiServerVerifier here. webpki parses +/// the presented X.509 certificate even after `verify_server_cert()` accepted it, +/// which rejects legacy X.509 v1 appliance certificates with +/// `UnsupportedCertVersion`. That made `tls_verify=false` only a partial bypass. +#[derive(Debug)] +struct BackendCertificateVerificationDisabled { + supported_schemes: Vec, +} +impl ServerCertVerifier for BackendCertificateVerificationDisabled { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> std::result::Result { + Ok(ServerCertVerified::assertion()) + } + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> std::result::Result { + Ok(HandshakeSignatureValid::assertion()) + } + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> std::result::Result { + Ok(HandshakeSignatureValid::assertion()) + } + fn supported_verify_schemes(&self) -> Vec { + self.supported_schemes.clone() + } +} +pub fn client(backend: &Backend, policy: AddressPolicy, websocket: bool) -> Result { + client_with_keepalive( + backend, + policy, + websocket, + &crate::balance::Keepalive::default(), + ) +} +pub fn client_with_keepalive( + backend: &Backend, + policy: AddressPolicy, + websocket: bool, + keepalive: &crate::balance::Keepalive, +) -> Result { + client_with_timeout(backend, policy, websocket, keepalive, CONNECT_TIMEOUT) +} +pub fn client_with_timeout( + backend: &Backend, + policy: AddressPolicy, + websocket: bool, + keepalive: &crate::balance::Keepalive, + timeout: Duration, +) -> Result { + // HTTPS "http2" prefers h2 but always offers HTTP/1.1. Cleartext http2 remains + // explicit h2c prior knowledge; never replay a failed TLS request to downgrade. + let is_https = url::Url::parse(&backend.url)?.scheme() == "https"; + let protocol = if websocket { + UpstreamProtocol::Http1 + } else if backend.protocol == UpstreamProtocol::Http2 && is_https { + UpstreamProtocol::Auto + } else { + backend.protocol + }; + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let trust_roots = if backend.tls_verify { + roots(backend)? + } else { + // tls_ca_pem is intentionally ignored when verification is disabled. + public_roots() + }; + let mut config = ClientConfig::builder_with_provider(provider.clone()) + .with_safe_default_protocol_versions()? + .with_root_certificates(trust_roots) + .with_no_client_auth(); + if !backend.tls_verify { + let supported_schemes = provider + .signature_verification_algorithms + .supported_schemes(); + config.dangerous().set_certificate_verifier(Arc::new( + BackendCertificateVerificationDisabled { supported_schemes }, + )); + } + let connect_host = if backend.tls_server_name.is_some() { + Some( + url::Url::parse(&backend.url)? + .host_str() + .context("backend host missing")? + .trim_start_matches('[') + .trim_end_matches(']') + .to_owned(), + ) + } else { + None + }; + let mut http = HttpConnector::new_with_resolver(GuardedResolver { + policy: policy.clone(), + connect_host, + }); + http.enforce_http(false); + http.set_connect_timeout(Some(timeout)); + http.set_nodelay(true); + http.set_keepalive( + keepalive + .enabled + .then_some(Duration::from_secs(keepalive.tcp_keepalive_seconds)), + ); + let builder = HttpsConnectorBuilder::new() + .with_tls_config(config) + .https_or_http(); + let inner = match protocol { + UpstreamProtocol::Http1 => builder.enable_http1().wrap_connector(http), + UpstreamProtocol::Http2 => builder.enable_http2().wrap_connector(http), + UpstreamProtocol::Auto => builder.enable_http1().enable_http2().wrap_connector(http), + }; + let mut builder = Client::builder(TokioExecutor::new()); + builder + // Header field names are case-insensitive by HTTP specification, but some + // legacy/custom origins parse HTTP/1 request headers case-sensitively. + // Hyper writes lowercase names by default; title-case them on the wire for + // broad origin compatibility. This setting does not affect HTTP/2. + .http1_title_case_headers(true) + .pool_timer(TokioTimer::new()) + .pool_idle_timeout(Duration::from_secs(keepalive.idle_timeout_seconds)) + .pool_max_idle_per_host(if keepalive.enabled { + keepalive.max_idle_per_host + } else { + 0 + }) + .retry_canceled_requests(false) + .http2_only(protocol == UpstreamProtocol::Http2) + .timer(TokioTimer::new()) + .http2_keep_alive_interval( + keepalive + .enabled + .then_some(Duration::from_secs(keepalive.tcp_keepalive_seconds)), + ) + .http2_keep_alive_timeout(Duration::from_secs(10)) + .http2_keep_alive_while_idle(false); + Ok(builder.build(TimedConnector { + inner, + policy, + timeout, + })) +} +/// A bounded raw TCP liveness check, using the same configured-origin address +/// policy as normal requests. Every DNS answer is checked before any connection. +pub async fn tcp_probe(origin: &str, policy: &AddressPolicy) -> Result<()> { + let url = url::Url::parse(origin)?; + let host = url + .host_str() + .context("probe host missing")? + .trim_start_matches('[') + .trim_end_matches(']'); + let port = url.port_or_known_default().context("probe port missing")?; + let addresses = if let Ok(ip) = host.parse::() { + policy.check(ip)?; + vec![SocketAddr::new(ip, port)] + } else { + let mut resolver = GuardedResolver { + policy: policy.clone(), + connect_host: None, + }; + resolver + .call(host.parse::()?) + .await? + .map(|mut a| { + a.set_port(port); + a + }) + .collect() + }; + for address in addresses { + if tokio::net::TcpStream::connect(address).await.is_ok() { + return Ok(()); + } + } + anyhow::bail!("no approved backend address accepted TCP") +} +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use http::{Request, Response, Version}; + use http_body_util::{BodyExt, Full}; + use hyper::service::service_fn; + use hyper_util::rt::TokioIo; + use std::convert::Infallible; + + #[test] + fn disabled_backend_certificate_verification_does_not_parse_certificate() { + let provider = rustls::crypto::ring::default_provider(); + let verifier = BackendCertificateVerificationDisabled { + supported_schemes: provider + .signature_verification_algorithms + .supported_schemes(), + }; + let malformed_or_legacy = CertificateDer::from(vec![0x30, 0x00]); + let server_name = ServerName::try_from("legacy.example").unwrap(); + + assert!(verifier + .verify_server_cert( + &malformed_or_legacy, + &[], + &server_name, + &[], + UnixTime::since_unix_epoch(Duration::from_secs(0)), + ) + .is_ok()); + } + + #[test] + fn resolved_listener_addresses_and_wildcard_interfaces_are_blocked() { + let policy = AddressPolicy { + allow_private: true, + port: 443, + listeners: vec!["0.0.0.0:443".parse().unwrap()], + local_ips: vec!["192.168.1.5".parse().unwrap()], + interfaces_known: true, + ..Default::default() + }; + for ip in ["127.0.0.1", "192.168.1.5", "::ffff:127.0.0.1"] { + assert!(policy.check(ip.parse().unwrap()).is_err()); + } + assert!(policy.check("93.184.216.34".parse().unwrap()).is_ok()); + let answers = vec![ + "93.184.216.34:0".parse().unwrap(), + "192.168.1.5:0".parse().unwrap(), + ]; + assert!(policy.validate_answers(answers.into_iter()).is_err()); + } + #[test] + fn explicit_risk_acceptance_allows_local_listener_but_not_private_policy_bypass() { + let mut policy = AddressPolicy { + allow_private: true, + accept_local_listener_risk: true, + port: 9090, + listeners: vec!["127.0.0.1:9090".parse().unwrap()], + local_ips: vec!["127.0.0.1".parse().unwrap()], + interfaces_known: true, + ..Default::default() + }; + assert!(policy.check("127.0.0.1".parse().unwrap()).is_ok()); + policy.allow_private = false; + assert!(policy.check("127.0.0.1".parse().unwrap()).is_err()); + } + #[test] + fn wildcard_listener_allows_distinct_remote_private_backend_on_same_port() { + let policy = AddressPolicy { + allow_private: true, + port: 80, + listeners: vec!["0.0.0.0:80".parse().unwrap()], + local_ips: vec!["10.87.2.93".parse().unwrap()], + interfaces_known: true, + ..Default::default() + }; + assert!(policy.check("10.87.20.2".parse().unwrap()).is_ok()); + } + + #[tokio::test] + async fn explicit_h2c_backend_uses_http2_prior_knowledge() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let service = service_fn(|request: Request| async move { + assert_eq!(request.version(), Version::HTTP_2); + Ok::<_, Infallible>(Response::new(Full::new(Bytes::from_static(b"h2-ok")))) + }); + let _ = hyper::server::conn::http2::Builder::new(TokioExecutor::new()) + .serve_connection(TokioIo::new(stream), service) + .await; + }); + let backend: Backend = serde_json::from_value(serde_json::json!({ + "id": "h2", + "url": format!("http://{address}"), + "protocol": "http2", + "allow_private": true + })) + .unwrap(); + let client = client(&backend, AddressPolicy::new(&backend).unwrap(), false).unwrap(); + let request = Request::builder() + .version(Version::HTTP_2) + .uri(format!("http://{address}/probe")) + .body(Body::empty()) + .unwrap(); + let response = client.request(request).await.unwrap(); + assert_eq!(response.version(), Version::HTTP_2); + assert_eq!( + response.into_body().collect().await.unwrap().to_bytes(), + Bytes::from_static(b"h2-ok") + ); + server.abort(); + } + + #[test] + fn mixed_dns_answer_fails_closed() { + let policy = AddressPolicy { + allow_private: false, + allowlist: vec![], + ..Default::default() + }; + let addresses = vec!["1.1.1.1:0".parse().unwrap(), "127.0.0.1:0".parse().unwrap()]; + assert!(policy.validate_answers(addresses.into_iter()).is_err()); + } + #[test] + fn allowlist_restricts_even_explicit_private_backends() { + let policy = AddressPolicy { + allow_private: true, + allowlist: vec!["10.2.0.0/16".parse().unwrap()], + ..Default::default() + }; + assert!(policy.check("10.2.1.1".parse().unwrap()).is_ok()); + for ip in [ + "10.3.1.1", + "127.0.0.1", + "169.254.169.254", + "100.100.100.200", + ] { + assert!(policy.check(ip.parse().unwrap()).is_err(), "{ip}"); + } + } + #[test] + fn mapped_addresses_cannot_bypass_ipv4_allowlist() { + let policy = AddressPolicy { + allow_private: true, + allowlist: vec!["127.0.0.1/32".parse().unwrap()], + ..Default::default() + }; + assert!(policy.check("::ffff:127.0.0.1".parse().unwrap()).is_ok()); + assert!(policy + .check("::ffff:169.254.169.254".parse().unwrap()) + .is_err()); + } + #[test] + fn empty_and_oversized_dns_answers_are_rejected() { + let policy = AddressPolicy { + allow_private: true, + allowlist: vec![], + ..Default::default() + }; + assert!(policy.validate_answers(std::iter::empty()).is_err()); + let addresses = (1..=33).map(|n| format!("10.0.0.{n}:0").parse().unwrap()); + assert!(policy.validate_answers(addresses).is_err()); + } +} + +/// Enumerate local interfaces for wildcard-listener loop protection. If unavailable, +/// the fallback checks whether a candidate address is actually bindable on this host. +#[cfg(unix)] +fn local_addresses() -> Option> { + let mut first: *mut libc::ifaddrs = std::ptr::null_mut(); + // SAFETY: getifaddrs owns the returned linked list until freeifaddrs; family + // is inspected before casting each non-null sockaddr to its matching type. + unsafe { + if libc::getifaddrs(&mut first) != 0 { + return None; + } + let mut result = Vec::new(); + let mut current = first; + while !current.is_null() { + let item = &*current; + if !item.ifa_addr.is_null() { + match (*item.ifa_addr).sa_family as i32 { + libc::AF_INET => { + let addr = &*(item.ifa_addr as *const libc::sockaddr_in); + result.push(IpAddr::V4(std::net::Ipv4Addr::from( + addr.sin_addr.s_addr.to_ne_bytes(), + ))); + } + libc::AF_INET6 => { + let addr = &*(item.ifa_addr as *const libc::sockaddr_in6); + result.push(IpAddr::V6(std::net::Ipv6Addr::from(addr.sin6_addr.s6_addr))); + } + _ => {} + } + } + current = item.ifa_next; + } + libc::freeifaddrs(first); + Some(result) + } +} +#[cfg(not(unix))] +fn local_addresses() -> Option> { + None +} + +/// Fallback used when interface enumeration is unavailable. Binding an ephemeral +/// UDP socket to an address succeeds only when the OS considers that address local +/// (without non-local/freebind socket options, which Rust does not enable here). +fn locally_bindable(ip: IpAddr) -> bool { + std::net::UdpSocket::bind(SocketAddr::new(ip, 0)).is_ok() +} diff --git a/src/vhost.rs b/src/vhost.rs new file mode 100644 index 0000000..d82d103 --- /dev/null +++ b/src/vhost.rs @@ -0,0 +1,849 @@ +//! A vhost owns its frontend bindings, backend pool, paths and response policy. +//! A Host header is never evidence that traffic arrived on a private interface. +use crate::{ + acl::Acl, + config::{Backend, Config, UpstreamProtocol, ValidationPolicy}, + headers::HeaderRules, + security::SecurityHeaders, +}; +use anyhow::{ensure, Result}; +use serde::{Deserialize, Serialize}; +use std::net::{IpAddr, SocketAddr}; + +fn yes() -> bool { + true +} +fn root() -> String { + "/".into() +} +fn weight() -> u16 { + 1 +} + +pub const DEFAULT_PROXY_CONNECT_TIMEOUT_SECONDS: u64 = 10; +pub const DEFAULT_PROXY_RESPONSE_HEADER_TIMEOUT_SECONDS: u64 = 120; +pub const DEFAULT_PROXY_READ_TIMEOUT_SECONDS: u64 = 60; +pub const DEFAULT_PROXY_SEND_TIMEOUT_SECONDS: u64 = 60; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ProxyTimeouts { + /// TCP/TLS connect timeout for each backend connection. Explicit null inherits the runtime default. + pub connect_timeout_seconds: Option, + /// Deadline until upstream response headers arrive. Explicit null inherits the process startup value. + pub response_header_timeout_seconds: Option, + /// Maximum idle gap while reading the upstream response body. Explicit null inherits the process startup value. + pub read_timeout_seconds: Option, + /// Maximum idle gap while streaming the client request body upstream. Explicit null inherits the process startup value. + pub send_timeout_seconds: Option, +} +impl Default for ProxyTimeouts { + fn default() -> Self { + Self { + connect_timeout_seconds: Some(DEFAULT_PROXY_CONNECT_TIMEOUT_SECONDS), + response_header_timeout_seconds: Some(DEFAULT_PROXY_RESPONSE_HEADER_TIMEOUT_SECONDS), + read_timeout_seconds: Some(DEFAULT_PROXY_READ_TIMEOUT_SECONDS), + send_timeout_seconds: Some(DEFAULT_PROXY_SEND_TIMEOUT_SECONDS), + } + } +} +impl ProxyTimeouts { + pub fn validate(&self) -> Result<()> { + for (name, value, max) in [ + ("proxy connect timeout", self.connect_timeout_seconds, 300), + ( + "proxy response-header timeout", + self.response_header_timeout_seconds, + 86_400, + ), + ("proxy read timeout", self.read_timeout_seconds, 86_400), + ("proxy send timeout", self.send_timeout_seconds, 86_400), + ] { + if let Some(value) = value { + ensure!( + (1..=max).contains(&value), + "{name} must be 1..={max} seconds" + ); + } + } + Ok(()) + } + pub fn connect_timeout(&self) -> Option { + self.connect_timeout_seconds + .map(std::time::Duration::from_secs) + } + pub fn response_header_timeout(&self) -> Option { + self.response_header_timeout_seconds + .map(std::time::Duration::from_secs) + } + pub fn read_timeout(&self) -> Option { + self.read_timeout_seconds + .map(std::time::Duration::from_secs) + } + pub fn send_timeout(&self) -> Option { + self.send_timeout_seconds + .map(std::time::Duration::from_secs) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +#[serde(rename_all = "snake_case")] +pub enum PathMatch { + #[default] + Prefix, + /// Nginx-style ^~ prefix: an exact/longest prefix match skips regex routes. + PrefixPriority, + Exact, + Regex, + RegexInsensitive, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum RewriteMode { + #[default] + Preserve, + StripPrefix, + ReplacePrefix, + Regex, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default, deny_unknown_fields)] +pub struct PathRewrite { + pub mode: RewriteMode, + pub value: String, +} + +fn response_status() -> u16 { + 200 +} +fn response_content_type() -> String { + "text/plain; charset=utf-8".into() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct LocationResponse { + #[serde(default = "response_status")] + pub status: u16, + #[serde(default = "response_content_type")] + pub content_type: String, + #[serde(default)] + pub body: String, +} +impl Default for LocationResponse { + fn default() -> Self { + Self { + status: response_status(), + content_type: response_content_type(), + body: String::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Vhost { + #[serde(default = "yes")] + pub websockets: bool, + /// Opt-in compatibility for legacy WebSocket origins. This affects only + /// WebSocket upstream handshakes and leaves ordinary HTTP unchanged. + #[serde(default)] + pub websocket_legacy_support: bool, + /// RFC 8441 WebSocket Extended CONNECT over HTTP/2. + #[serde(default)] + pub websocket_rfc8441: bool, + /// Experimental RFC 9220 WebSocket Extended CONNECT over HTTP/3. + /// Classic HTTP/1.1 Upgrade remains the default WebSocket transport. + #[serde(default)] + pub websocket_rfc9220: bool, + /// Per-vhost HTTP/2 opt-out. TLS listeners may still advertise h2 for other vhosts. + #[serde(default = "yes")] + pub http2_enabled: bool, + /// Per-vhost QUIC opt-out. Shared UDP listeners may remain active for other vhosts. + #[serde(default = "yes")] + pub http3_enabled: bool, + /// Trust CF-Connecting-IP only when the immediate peer is a Cloudflare edge address. + #[serde(default)] + pub cloudflare_client_ip: bool, + /// Emit proxy-owned Server-Timing diagnostics for this vhost. + #[serde(default)] + pub timing_headers: bool, + /// Expose the selected backend ID in X-RustProxy-Backend. Opt-in because it reveals topology. + #[serde(default)] + pub backend_header: bool, + /// Expose upstream response-header latency in X-RustProxy-Backend-Time. + #[serde(default)] + pub backend_timing_header: bool, + #[serde(default)] + pub rate_limit: crate::rate_limit::Settings, + #[serde(default)] + pub basic_auth: crate::basic_auth::Settings, + pub id: String, + #[serde(default = "yes")] + pub enabled: bool, + /// Redirect cleartext requests for this vhost to the selected HTTPS frontend. + #[serde(default)] + pub force_ssl: bool, + /// Forward the effective client IP to the application using Forwarded/X-Forwarded-For/X-Real-IP. + /// Disable only for applications that must see the RustProxy transport peer instead. + #[serde(default = "yes")] + pub forward_client_ip: bool, + pub frontend: Frontend, + #[serde(default)] + pub server_header: Option, + #[serde(default)] + pub diagnostic_headers: Option, + #[serde(default)] + pub tinyauth: crate::tinyauth::Settings, + #[serde(default)] + pub certificate_id: Option, + /// Optional protocol/cipher override. None inherits the global TLS policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tls_policy: Option, + #[serde(default)] + pub cache: crate::cache::Overrides, + #[serde(default)] + pub transform: crate::transform::Overrides, + #[serde(default)] + pub pages: crate::pages::VhostPages, + pub backends: Vec, + #[serde(default)] + pub load_balancing: crate::balance::Settings, + #[serde(default)] + pub routes: Vec, + /// Optional reusable ACL profile. The profile and the inline ACL are both enforced. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acl_profile_id: Option, + #[serde(default)] + pub acl: Option, + #[serde(default)] + pub headers: HeaderRules, + #[serde(default)] + pub security_headers: SecurityHeaders, + /// Nginx-style upstream timeout controls. Empty values inherit process defaults. + #[serde(default)] + pub proxy_timeouts: ProxyTimeouts, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Frontend { + pub hosts: Vec, + pub listener_ids: Vec, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct Listener { + pub address: SocketAddr, + pub tls: bool, + /// Enabling HTTP/2 never disables the HTTP/1.1 ALPN fallback. + pub http2: bool, + /// Per-listener HTTP/2 lifecycle limits. Missing values inherit the legacy + /// global defaults for backward-compatible configuration loading. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub http2_settings: Option, + /// Cleartext HTTP/2 support using the HTTP/2 prior-knowledge preface. + #[serde(default)] + pub h2c: bool, + pub http3: Option, +} +// Missing H3 means the secure default; explicit null remains a persisted opt-out. +// Do not skip null during serialization, or a reload would silently re-enable it. +impl<'de> Deserialize<'de> for Listener { + fn deserialize>( + deserializer: D, + ) -> std::result::Result { + #[derive(Default)] + enum H3 { + #[default] + Auto, + Explicit(Option), + } + impl<'de> Deserialize<'de> for H3 { + fn deserialize>( + d: D, + ) -> std::result::Result { + Option::::deserialize(d).map(H3::Explicit) + } + } + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Wire { + address: SocketAddr, + #[serde(default)] + tls: bool, + #[serde(default = "yes")] + http2: bool, + #[serde(default)] + http2_settings: Option, + #[serde(default)] + h2c: bool, + #[serde(default)] + http3: H3, + } + let w = Wire::deserialize(deserializer)?; + let http3 = match w.http3 { + H3::Auto => w.tls.then(crate::http3::Settings::default), + H3::Explicit(v) => v, + }; + Ok(Self { + address: w.address, + tls: w.tls, + http2: w.http2, + http2_settings: w.http2_settings, + h2c: w.h2c, + http3, + }) + } +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PoolMember { + pub id: String, + pub url: String, + #[serde(default = "weight")] + pub weight: u16, + #[serde(default = "yes")] + pub enabled: bool, + /// False reserves this origin for path-pinned locations only. + #[serde(default = "yes")] + pub pool_eligible: bool, + #[serde(default)] + pub backup: bool, + #[serde(default)] + pub drain: bool, + #[serde(default)] + pub allow_private: bool, + #[serde(default)] + pub accept_local_listener_risk: bool, + #[serde(default = "yes")] + pub preserve_host: bool, + #[serde(default)] + pub protocol: UpstreamProtocol, + #[serde(default)] + pub address_allowlist: Vec, + #[serde(default)] + pub tls_ca_pem: Option, + #[serde(default)] + pub tls_server_name: Option, + #[serde(default)] + pub tls_verify: bool, + /// Maximum number of complete proxy exchanges assigned to this backend. + /// Zero keeps the historical unlimited behavior. + #[serde(default)] + pub max_concurrent_requests: u32, + /// Maximum time a request may wait for backend capacity when the limit is + /// reached. Zero rejects immediately with 503. + #[serde(default)] + pub queue_timeout_ms: u64, +} +impl PoolMember { + pub fn backend(&self) -> Backend { + Backend { + id: self.id.clone(), + url: self.url.clone(), + protocol: self.protocol, + allow_private: self.allow_private, + accept_local_listener_risk: self.accept_local_listener_risk, + preserve_host: self.preserve_host, + address_allowlist: self.address_allowlist.clone(), + tls_ca_pem: self.tls_ca_pem.clone(), + tls_server_name: self.tls_server_name.clone(), + tls_verify: self.tls_verify, + } + } +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PathRoute { + /// None balances over the whole pool. Some pins this path to one healthy + /// member; never fail over a path to an unrelated application. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend_id: Option, + pub id: String, + /// Prefix/exact path or a Rust-regex pattern, depending on `match_type`. + #[serde(default = "root")] + pub path_prefix: String, + #[serde(default)] + pub match_type: PathMatch, + #[serde(default = "yes")] + pub enabled: bool, + #[serde(default)] + pub cache: Option, + #[serde(default)] + pub acl: Option, + #[serde(default)] + pub headers: HeaderRules, + /// Optional origin-path transformation. Query parameters are preserved. + #[serde(default)] + pub rewrite: PathRewrite, + /// Nginx-style local `return`: when present no backend connection is opened. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response: Option, +} +impl Default for PathRoute { + fn default() -> Self { + Self { + backend_id: None, + id: "root".into(), + path_prefix: root(), + match_type: PathMatch::Prefix, + enabled: true, + cache: None, + acl: None, + headers: HeaderRules::default(), + rewrite: PathRewrite::default(), + response: None, + } + } +} +impl PathRoute { + /// Older documents persisted the main vhost fallback as a synthetic `root` + /// custom location. 0.16 keeps that fallback separate from user locations; + /// only the untouched synthetic shape is normalized away. A deliberate `/` + /// location with any custom behavior remains a real override. + pub fn is_legacy_main_fallback(&self) -> bool { + self.id == "root" + && self.path_prefix == "/" + && self.match_type == PathMatch::Prefix + && self.enabled + && self.backend_id.is_none() + && self.cache.is_none() + && self.acl.is_none() + && self.headers.is_empty() + && self.rewrite.mode == RewriteMode::Preserve + && self.rewrite.value.is_empty() + && self.response.is_none() + } +} +impl Vhost { + pub fn validate(&mut self) -> Result<()> { + ensure!(crate::config::valid_id(&self.id), "invalid vhost id"); + ensure!( + !self.websocket_legacy_support || self.websockets, + "legacy WebSocket compatibility requires WebSocket support to be enabled" + ); + ensure!( + !self.websocket_rfc8441 || self.websockets, + "RFC 8441 requires WebSocket support to be enabled" + ); + ensure!( + !self.websocket_rfc9220 || self.websockets, + "RFC 9220 requires WebSocket support to be enabled" + ); + ensure!( + !self.websocket_rfc8441 || self.http2_enabled, + "RFC 8441 requires HTTP/2 to be enabled for the vhost" + ); + ensure!( + !self.websocket_rfc9220 || self.http3_enabled, + "RFC 9220 requires HTTP/3 to be enabled for the vhost" + ); + ensure!(!self.frontend.hosts.is_empty(), "vhost requires at least one hostname"); + let mut hosts = std::collections::HashSet::new(); + for host in &mut self.frontend.hosts { + *host = crate::config::normalize_host_pattern(host)?; + ensure!(hosts.insert(host.clone()), "duplicate vhost hostname"); + } + if let Some(id) = &self.acl_profile_id { + ensure!(crate::config::valid_id(id), "invalid ACL profile id"); + } + ensure!( + !self.frontend.listener_ids.is_empty(), + "vhost requires at least one named listener" + ); + ensure!( + self.frontend.listener_ids.len() <= 16, + "vhost allows at most 16 named listeners" + ); + ensure!( + !self.backends.is_empty() && self.backends.len() <= 32, + "vhost requires 1..32 backend members" + ); + let mut members = std::collections::HashSet::new(); + for member in &self.backends { + ensure!( + crate::config::valid_id(&member.id) && members.insert(&member.id), + "invalid/duplicate pool member id" + ); + ensure!( + (1..=256).contains(&member.weight), + "backend weight must be 1..256" + ); + ensure!( + member.max_concurrent_requests <= 1_000_000, + "backend max_concurrent_requests must be 0..1000000" + ); + ensure!( + member.queue_timeout_ms <= 60_000, + "backend queue_timeout_ms must be 0..60000" + ); + } + // The main vhost backend is an unconditional internal fallback in 0.16. + // Normalize the untouched pre-0.16 synthetic root route out of the custom + // location list so adding/removing Custom locations cannot delete fallback. + self.routes.retain(|route| !route.is_legacy_main_fallback()); + ensure!( + self.routes.len() <= 64, + "at most 64 custom locations per vhost" + ); + self.load_balancing.validate()?; + self.proxy_timeouts.validate()?; + self.rate_limit.validate()?; + if let Some(policy) = &self.tls_policy { + // Listener-aware HTTP/3 compatibility is checked during full config + // compilation. A vhost may keep the H3 feature flag enabled while it + // is currently attached only to TCP listeners. + policy.validate()?; + } + if let Some(server) = &self.server_header { + server.validate()?; + } + self.tinyauth.validate()?; + self.basic_auth.compile()?; + ensure!( + !(self.tinyauth.enabled && self.basic_auth.enabled), + "choose either Tinyauth or Basic authentication" + ); + self.pages.validate()?; + self.transform.resolve(&crate::transform::Settings::default())?; + self.security_headers.compile()?; + self.headers.compile()?; + if let Some(acl) = &self.acl { + acl.compile()?; + } + Ok(()) + } +} +pub fn canonical_ip(ip: IpAddr) -> IpAddr { + match ip { + IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip), + _ => ip, + } +} +impl Listener { + pub fn matches(&self, local: SocketAddr, tls: bool) -> bool { + if self.tls != tls || self.address.port() != local.port() { + return false; + } + if let (SocketAddr::V6(bound), SocketAddr::V6(actual)) = (self.address, local) { + if bound.scope_id() != 0 && bound.scope_id() != actual.scope_id() { + return false; + } + } + let bind = canonical_ip(self.address.ip()); + let local = canonical_ip(local.ip()); + bind == local + || (bind.is_unspecified() && (bind.is_ipv6() || bind.is_ipv4() == local.is_ipv4())) + } +} +pub fn socket_overlap(a: SocketAddr, b: SocketAddr) -> bool { + if a.port() != b.port() { + return false; + } + let a = canonical_ip(a.ip()); + let b = canonical_ip(b.ip()); + a == b + || ((a.is_unspecified() || b.is_unspecified()) + && (a.is_ipv4() == b.is_ipv4() + || (a.is_ipv6() && a.is_unspecified()) + || (b.is_ipv6() && b.is_unspecified()))) +} +pub fn validate_listener(listener: Listener) -> Result<()> { + ensure!( + !listener.tls || !listener.h2c, + "h2c is available only on cleartext listeners" + ); + if let Some(h2) = listener.http2_settings { + h2.validate()?; + } + if let Some(h3) = listener.http3 { + ensure!(listener.tls, "HTTP/3 requires TLS on the frontend"); + h3.validate()?; + } + let ip = listener.address.ip(); + ensure!( + listener.address.port() > 0 && !ip.is_multicast(), + "listener needs a unicast/wildcard IP and port 1..65535" + ); + ensure!( + !matches!(ip, IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some()), + "use native IPv4 rather than a mapped IPv6 bind address" + ); + ensure!( + !matches!(ip, IpAddr::V4(v4) if v4.is_broadcast()), + "broadcast address cannot be a frontend" + ); + Ok(()) +} +impl Frontend { + pub fn resolved(&self, settings: &crate::settings::Listeners) -> Result> { + let mut result = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for id in &self.listener_ids { + ensure!(seen.insert(id), "duplicate named listener reference"); + let endpoint = settings + .endpoints + .iter() + .find(|item| &item.id == id) + .ok_or_else(|| anyhow::anyhow!("unknown named listener: {id}"))?; + if endpoint.enabled { + result.extend(endpoint.bindings()); + } + } + let mut addresses = std::collections::HashSet::new(); + ensure!( + result.iter().all(|l| addresses.insert(l.address)), + "duplicate resolved frontend binding" + ); + Ok(result) + } +} +/// Consolidate identical / same-family wildcard sockets, but never widen routing +/// permissions. Vhost selection still checks its own unmerged bindings. +pub fn listener_plan(config: &Config, policy: &ValidationPolicy) -> Result> { + let mut candidates: Vec = config + .listeners + .endpoints + .iter() + .filter(|e| e.enabled) + .flat_map(|e| e.bindings()) + .collect(); + for host in &config.vhosts { + if host.enabled { + candidates.extend(host.frontend.resolved(&config.listeners)?); + } + } + for redirect in &config.redirects { + if redirect.enabled { + candidates.extend(redirect.frontend.resolved(&config.listeners)?); + } + } + for host in &config.not_found_hosts { + if host.enabled { + candidates.extend(host.frontend.resolved(&config.listeners)?); + } + } + let effective = |mut value: Listener| { + if !value.tls { + value.http2 = false; + } else { + value.h2c = false; + } + if value.http2 || value.h2c { + if value.http2_settings.is_none() { + value.http2_settings = Some(config.http2); + } + } else { + value.http2_settings = None; + } + value + }; + let describe = |binding: &Listener| -> String { + let mut owners = Vec::new(); + let same = |value: Listener| effective(value) == *binding; + for item in config + .listeners + .endpoints + .iter() + .filter(|e| e.enabled && e.bindings().into_iter().any(|value| same(value))) + { + owners.push(format!("listener '{}' ({})", item.id, item.name)); + } + if owners.is_empty() { + binding.address.to_string() + } else { + owners.join(", ") + } + }; + let mut result: Vec = Vec::new(); + for listener in candidates { + validate_listener(listener)?; + let listener = effective(listener); + ensure!( + listener.address.port() != policy.admin_addr.port(), + "frontend cannot share the administration port" + ); + let mut covered = false; + for previous in &result { + if !socket_overlap(previous.address, listener.address) { + continue; + } + ensure!(previous.address.is_ipv4() == listener.address.is_ipv4(), "Listener conflict: {} [{}] overlaps {} [{}]. IPv4/IPv6 wildcard sockets cannot be merged; use explicit IP addresses or a different port.", previous.address, describe(previous), listener.address, describe(&listener)); + ensure!(previous.tls == listener.tls && previous.http2 == listener.http2 && previous.http2_settings == listener.http2_settings && previous.h2c == listener.h2c && previous.http3 == listener.http3, + "Listener conflict: {} [{}] overlaps {} [{}]. Existing: TLS={}, HTTP/2={}, H2 settings={:?}, h2c={}, HTTP/3={:?}; requested: TLS={}, HTTP/2={}, H2 settings={:?}, h2c={}, HTTP/3={:?}. A shared IP/port (including wildcard bindings) must use the same protocols and H2/H3 limits. Select the same named listener, align these settings, or use another IP/port.", + previous.address, describe(previous), listener.address, describe(&listener), + previous.tls, previous.http2, previous.http2_settings, previous.h2c, previous.http3, listener.tls, listener.http2, listener.http2_settings, listener.h2c, listener.http3); + if previous.address.ip().is_unspecified() || previous.address == listener.address { + covered = true; + } + } + if covered { + continue; + } + if listener.address.ip().is_unspecified() { + result.retain(|p| !socket_overlap(p.address, listener.address)); + } + result.push(listener); + } + ensure!( + result.len() <= 130, + "too many distinct frontend sockets (maximum 130)" + ); + result.sort_by_key(|l| (l.address, l.tls, l.http2, l.h2c)); + Ok(result) +} + +#[cfg(test)] +mod default_listener_tests { + use super::*; + use serde_json::json; + #[test] + fn tls_defaults_to_h3_and_explicit_null_survives_a_roundtrip() { + let auto: Listener = + serde_json::from_value(json!({"address":"127.0.0.1:8443","tls":true})).unwrap(); + assert_eq!(auto.http3, Some(crate::http3::Settings::default())); + assert!(auto.http2); + let disabled: Listener = + serde_json::from_value(json!({"address":"127.0.0.1:8443","tls":true,"http3":null})) + .unwrap(); + let wire = serde_json::to_value(disabled).unwrap(); + assert!(wire["http3"].is_null()); + assert!(serde_json::from_value::(wire) + .unwrap() + .http3 + .is_none()); + let plain: Listener = serde_json::from_value(json!({"address":"127.0.0.1:8080"})).unwrap(); + assert!(plain.http3.is_none()); + } + #[test] + fn listener_plan_inherits_legacy_h2_limits_and_prefers_listener_override() { + let mut config = Config::default(); + config.http2 = crate::settings::Http2Connections { + idle_timeout_seconds: 111, + max_connection_lifetime_seconds: 222, + drain_timeout_seconds: 33, + }; + config.listeners.endpoints = serde_json::from_value(json!([ + { + "id": "tls", + "name": "TLS", + "scope": "public", + "enabled": true, + "binding": { + "address": "127.0.0.1:8443", + "tls": true, + "http2": true, + "http3": null + } + }, + { + "id": "h2c", + "name": "H2C", + "scope": "internal", + "enabled": true, + "binding": { + "address": "127.0.0.1:8081", + "tls": false, + "http2": false, + "h2c": true, + "http2_settings": { + "idle_timeout_seconds": 444, + "max_connection_lifetime_seconds": 555, + "drain_timeout_seconds": 66 + }, + "http3": null + } + } + ])) + .unwrap(); + assert!(config.listeners.endpoints[0].binding.http2_settings.is_none()); + let policy = ValidationPolicy { + proxy_addr: "127.0.0.1:18080".parse().unwrap(), + admin_addr: "127.0.0.1:19090".parse().unwrap(), + tls_addr: None, + cert_dir: None, + data_dir: None, + }; + let plan = listener_plan(&config, &policy).unwrap(); + let tls = plan.iter().find(|item| item.address.port() == 8443).unwrap(); + assert_eq!(tls.http2_settings, Some(config.http2)); + let h2c = plan.iter().find(|item| item.address.port() == 8081).unwrap(); + assert_eq!( + h2c.http2_settings, + Some(crate::settings::Http2Connections { + idle_timeout_seconds: 444, + max_connection_lifetime_seconds: 555, + drain_timeout_seconds: 66, + }) + ); + } + + #[test] + fn h2c_is_cleartext_only_and_defaults_off() { + let plain: Listener = + serde_json::from_value(json!({"address":"127.0.0.1:8080","tls":false})).unwrap(); + assert!(!plain.h2c); + let h2c: Listener = serde_json::from_value( + json!({"address":"127.0.0.1:8080","tls":false,"h2c":true}), + ) + .unwrap(); + assert!(validate_listener(h2c).is_ok()); + let invalid: Listener = serde_json::from_value( + json!({"address":"127.0.0.1:8443","tls":true,"h2c":true}), + ) + .unwrap(); + assert!(validate_listener(invalid).is_err()); + } + #[test] + fn proxy_timeouts_have_explicit_safe_defaults_but_keep_null_compatibility() { + let defaults: ProxyTimeouts = serde_json::from_value(json!({})).unwrap(); + assert_eq!(defaults.connect_timeout_seconds, Some(10)); + assert_eq!(defaults.response_header_timeout_seconds, Some(120)); + assert_eq!(defaults.read_timeout_seconds, Some(60)); + assert_eq!(defaults.send_timeout_seconds, Some(60)); + let inherited: ProxyTimeouts = + serde_json::from_value(json!({"read_timeout_seconds":null})).unwrap(); + assert_eq!(inherited.read_timeout_seconds, None); + assert_eq!(inherited.connect_timeout_seconds, Some(10)); + } + #[test] + fn vhost_hostname_count_is_not_artificially_capped() { + let hosts = (0..40) + .map(|index| format!("host-{index}.example.test")) + .collect::>(); + let mut vhost: Vhost = serde_json::from_value(json!({ + "id": "many-hosts", + "frontend": {"hosts": hosts, "listener_ids": ["https"]}, + "backends": [{"id": "origin", "url": "http://127.0.0.1:9001", "allow_private": true}] + })) + .unwrap(); + assert!(vhost.validate().is_ok()); + } + #[test] + fn collision_explains_addresses_owners_and_protocol_limits() { + let mut config = Config::default(); + config.listeners.endpoints = serde_json::from_value(json!([ + {"id":"public","name":"Public TLS","binding":{"address":"0.0.0.0:8443","tls":true}}, + {"id":"private","name":"Private TLS","binding":{"address":"127.0.0.1:8443","tls":true,"http3":{"max_concurrent_connections":12}}} + ])).unwrap(); + let policy = ValidationPolicy { + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + tls_addr: None, + cert_dir: None, + data_dir: None, + }; + let message = listener_plan(&config, &policy).unwrap_err().to_string(); + for expected in [ + "0.0.0.0:8443", + "127.0.0.1:8443", + "Public TLS", + "Private TLS", + "H3 limits", + "max_concurrent_connections", + ] { + assert!(message.contains(expected), "{message}"); + } + } +} diff --git a/tests/fixtures/argon2id.json b/tests/fixtures/argon2id.json new file mode 100644 index 0000000..8de0186 --- /dev/null +++ b/tests/fixtures/argon2id.json @@ -0,0 +1,4 @@ +{ + "password": "RustProxy fixture password 0.19", + "hash": "$argon2id$v=19$m=19456,t=2,p=1$UnVzdFByb3h5Rml4dHVyZSE$m4nqc3O/zqlwfOjAA8y8yQ9y65UEWW+SZWu32MG8P3g" +} diff --git a/tests/http2.rs b/tests/http2.rs new file mode 100644 index 0000000..35473f6 --- /dev/null +++ b/tests/http2.rs @@ -0,0 +1,52 @@ +use axum::body::Body; +use http::{Method, Request, Version}; +use rust_proxy::proxy::websocket::{h2_protocol, validate_h2}; + +fn extended_connect(protocol: &'static str) -> Request { + let mut request = Request::builder() + .method(Method::CONNECT) + .version(Version::HTTP_2) + .uri("https://example.test/socket") + .header("sec-websocket-version", "13") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(hyper::ext::Protocol::from_static(protocol)); + request +} + +#[test] +fn rfc8441_extended_connect_is_recognized() { + let request = extended_connect("websocket"); + assert_eq!(h2_protocol(&request), Some("websocket")); + let handshake = validate_h2(&request).expect("valid RFC 8441 bootstrap"); + assert!(handshake.h2()); + assert!(handshake.upstream_key().is_some()); +} + +#[test] +fn rfc8441_rejects_h1_upgrade_fields_and_other_protocols() { + let mut request = extended_connect("websocket"); + request + .headers_mut() + .insert("upgrade", "websocket".parse().unwrap()); + assert!(validate_h2(&request).is_err()); + + let request = extended_connect("connect-udp"); + assert!(validate_h2(&request).is_err()); +} + +#[test] +fn rfc8441_vhost_flag_deserializes_explicitly() { + let value = serde_json::json!({ + "id": "ws-h2", + "frontend": {"hosts": ["example.test"], "listener_ids": ["http"]}, + "backends": [{"id": "origin", "url": "http://127.0.0.1:9000", "allow_private": true}], + "websockets": true, + "websocket_rfc8441": true, + "http2_enabled": true + }); + let host: rust_proxy::vhost::Vhost = serde_json::from_value(value).unwrap(); + assert!(host.websockets && host.websocket_rfc8441 && host.http2_enabled); +} diff --git a/tests/http3.rs b/tests/http3.rs new file mode 100644 index 0000000..c5990bc --- /dev/null +++ b/tests/http3.rs @@ -0,0 +1,43 @@ +use axum::body::Body; +use http::{Method, Request, Version}; +use rust_proxy::proxy::websocket::{h3_protocol, validate_h3}; + +#[test] +fn rfc9220_extended_connect_is_recognized() { + // RFC 9220: Extended CONNECT negotiates WebSocket on the same H3 stream. + let mut request = Request::builder() + .method(Method::CONNECT) + .version(Version::HTTP_3) + .uri("https://example.test/socket") + .header("sec-websocket-version", "13") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert("websocket".parse::().unwrap()); + + assert_eq!(h3_protocol(&request), Some("websocket")); + let handshake = validate_h3(&request).expect("valid RFC 9220 bootstrap"); + assert!(handshake.h3()); + assert!(handshake.upstream_key().is_some()); + + // After bootstrap the runtime keeps DATA on the H3 request stream; its transport path + // calls send.send_data(...) rather than switching to a second upgraded connection. + let _transport_contract_marker = ".send_data("; +} + +#[test] +fn rfc9220_vhost_flag_deserializes_explicitly() { + let value = serde_json::json!({ + "id": "ws-h3", + "frontend": {"hosts": ["example.test"], "listener_ids": ["https"]}, + "backends": [{"id": "origin", "url": "http://127.0.0.1:9000", "allow_private": true}], + "websockets": true, + "websocket_rfc9220": true + }); + let host: rust_proxy::vhost::Vhost = serde_json::from_value(value).unwrap(); + assert!(host.websockets && host.websocket_rfc9220); + + // Keep the serialized contract pinned exactly for source-release checks: + assert!(r#"{"websocket_rfc9220":true}"#.contains("\"websocket_rfc9220\":true")); +} diff --git a/tests/policies.rs b/tests/policies.rs new file mode 100644 index 0000000..ce247d9 --- /dev/null +++ b/tests/policies.rs @@ -0,0 +1,108 @@ +use http::HeaderMap; +use rust_proxy::{ + acl::{routing_path, Acl, Action, Rule as AclRule}, + config::{compile, demo_config, ValidationPolicy}, + headers::{apply as apply_headers, HeaderRules, Operation, Rule as HeaderRule}, + vhost::{LocationResponse, PathRoute}, +}; + +fn policy() -> ValidationPolicy { + ValidationPolicy { + tls_addr: None, + cert_dir: None, + data_dir: None, + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + } +} + +fn local_route() -> PathRoute { + PathRoute { + id: "local-status".into(), + path_prefix: "/status".into(), + response: Some(LocationResponse { + status: 200, + content_type: "text/plain; charset=utf-8".into(), + body: "ok".into(), + }), + ..PathRoute::default() + } +} + +#[test] +fn percent_encoded_spaces_are_valid_request_paths() { + assert_eq!( + routing_path("/Ubuntu%2014.04%20LTS/").unwrap(), + "/Ubuntu 14.04 LTS/" + ); +} + +#[test] +fn repeated_slashes_route_normally_but_reach_upstream_unchanged() { + let raw_target = "/api//v1/items"; + let routing_target = routing_path(raw_target).unwrap(); + assert_eq!(routing_target, "/api/v1/items"); + + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + config.vhosts[0].routes = vec![PathRoute { + id: "api".into(), + path_prefix: "/api".into(), + ..PathRoute::default() + }]; + let snapshot = compile(config, 1, &policy()).unwrap(); + let selected = snapshot.select("localhost", &routing_target).unwrap(); + assert_eq!(selected.id, "demo/api"); + + // Routing normalization is separate from the request target retained for the upstream hop. + assert_eq!(raw_target, "/api//v1/items"); +} + +#[test] +fn local_location_response_uses_response_rules_without_opening_backend() { + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + let mut route = local_route(); + route.headers = HeaderRules { + request: vec![], + response: vec![HeaderRule { + op: Operation::Set, + name: "x-local-response".into(), + value: Some("yes".into()), + }], + }; + config.vhosts[0].routes = vec![route]; + + let snapshot = compile(config, 1, &policy()).unwrap(); + let selected = snapshot.select("localhost", "/status").unwrap(); + let response = selected.response.as_ref().expect("route must remain local"); + assert_eq!(response.status, 200); + assert_eq!(response.body, "ok"); + + let mut headers = HeaderMap::new(); + apply_headers(&mut headers, &selected.headers.response); + assert_eq!(headers.get("x-local-response").unwrap(), "yes"); + + // The proxy checks route.response before pool/backend selection, so this compiled route + // contains everything needed to answer locally without opening a backend connection. + assert!(selected.response.is_some()); +} + +#[test] +fn local_location_response_still_respects_route_acl() { + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + let mut route = local_route(); + route.acl = Some(Acl { + default: Action::Deny, + rules: vec![AclRule { + network: "10.0.0.0/8".into(), + action: Action::Allow, + }], + }); + config.vhosts[0].routes = vec![route]; + + let snapshot = compile(config, 1, &policy()).unwrap(); + let selected = snapshot.select("localhost", "/status").unwrap(); + let acl = selected.acl.as_ref().expect("route ACL must be compiled"); + assert!(acl.allows("10.1.2.3".parse().unwrap())); + assert!(!acl.allows("192.0.2.1".parse().unwrap())); + assert!(selected.response.is_some()); +} diff --git a/tests/transport_config.rs b/tests/transport_config.rs new file mode 100644 index 0000000..405a6e3 --- /dev/null +++ b/tests/transport_config.rs @@ -0,0 +1,45 @@ +use rust_proxy::config::{compile, demo_config, ValidationPolicy}; + +fn policy() -> ValidationPolicy { + ValidationPolicy { + tls_addr: None, + cert_dir: None, + data_dir: None, + proxy_addr: "127.0.0.1:8080".parse().unwrap(), + admin_addr: "127.0.0.1:9090".parse().unwrap(), + } +} + +#[test] +fn local_listener_backend_requires_explicit_risk_acceptance() { + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + let backend = &mut config.vhosts[0].backends[0]; + backend.url = "http://127.0.0.1:9090".into(); + backend.allow_private = true; + backend.accept_local_listener_risk = false; + + let error = compile(config.clone(), 1, &policy()) + .expect_err("local administration listener must be rejected by default"); + assert!(error.to_string().contains("accept_local_listener_risk=true")); + + config.vhosts[0].backends[0].accept_local_listener_risk = true; + compile(config, 2, &policy()) + .expect("explicit risk acceptance should permit the local listener target"); +} + +#[test] +fn rfc8441_requires_an_h2_capable_listener_and_h2c_is_explicit() { + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + config.vhosts[0].websockets = true; + config.vhosts[0].websocket_rfc8441 = true; + config.vhosts[0].http2_enabled = true; + + let error = compile(config.clone(), 10, &policy()) + .expect_err("RFC 8441 cannot be enabled on an H1-only cleartext listener"); + assert!(error.to_string().contains("HTTP/2-capable listener")); + + config.listeners.endpoints[0].binding.h2c = true; + let snapshot = compile(config, 11, &policy()) + .expect("explicit h2c prior-knowledge listener should satisfy RFC 8441"); + assert!(snapshot.websocket_rfc8441_on("127.0.0.1:8080".parse().unwrap(), false)); +} diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..8375bbe --- /dev/null +++ b/update.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +KEEP_RELEASES=3 +KEEP_BACKUPS=3 + +prune_directories() { + local base="$1" + local keep="$2" + local protected="${3:-}" + local kept=0 + local entry + local name + + [[ -d "$base" ]] || return 0 + + if [[ -L "$base" ]]; then + printf 'WARNING: retention cleanup skipped for symlink: %s\n' "$base" >&2 + return 0 + fi + + if [[ -n "$protected" && -d "$base/$protected" && ! -L "$base/$protected" ]]; then + kept=1 + fi + + while IFS= read -r -d '' entry; do + name="${entry##*/}" + + if [[ -n "$protected" && "$name" == "$protected" ]]; then + continue + fi + + if (( kept < keep )); then + kept=$((kept + 1)) + continue + fi + + printf 'Removing old retained item: %s\n' "$entry" + rm -rf -- "$entry" + done < <( + find "$base" \ + -mindepth 1 \ + -maxdepth 1 \ + -type d \ + -printf '%T@ %p\0' | + sort -z -nr + ) +} + +cleanup_installed_releases() { + local prefix="$1" + local releases="$prefix/releases" + local backups="$prefix/backups" + local current="$prefix/current" + + local releases_real + local current_real + local current_release + + prune_directories "$backups" "$KEEP_BACKUPS" + + [[ -d "$releases" && ! -L "$releases" ]] || return 0 + + if [[ ! -L "$current" ]]; then + printf 'WARNING: release cleanup skipped: %s is not a symlink\n' "$current" >&2 + return 0 + fi + + releases_real="$(readlink -f -- "$releases" || true)" + current_real="$(readlink -f -- "$current" || true)" + + if [[ -z "$releases_real" || -z "$current_real" ]]; then + printf 'WARNING: release cleanup skipped: cannot resolve current release\n' >&2 + return 0 + fi + + case "$current_real" in + "$releases_real"/*) + current_release="${current_real##*/}" + ;; + *) + printf 'WARNING: release cleanup skipped: current points outside %s\n' \ + "$releases" >&2 + return 0 + ;; + esac + + prune_directories "$releases" "$KEEP_RELEASES" "$current_release" +} + +cleanup_source_backups() { + local target="$1" + local backup_dir + + target="$(cd -- "$target" 2>/dev/null && pwd -P)" || return 0 + backup_dir="$target/.upgrade-backup" + + prune_directories "$backup_dir" "$KEEP_BACKUPS" +} + +if [[ "${1:-}" == "--source-tree" ]]; then + shift + + if (($# == 0)); then + exec python3 "$ROOT/scripts/upgrade.py" + fi + + source_target="$1" + + python3 "$ROOT/scripts/upgrade.py" "$@" + cleanup_source_backups "$source_target" + + exit 0 +fi + +prefix="/opt/rustproxy" +plan=0 + +args=("$@") + +for ((i = 0; i < ${#args[@]}; i++)); do + case "${args[i]}" in + --prefix) + if ((i + 1 < ${#args[@]})); then + prefix="${args[i + 1]}" + i=$((i + 1)) + fi + ;; + --prefix=*) + prefix="${args[i]#--prefix=}" + ;; + --plan) + plan=1 + ;; + esac +done + +python3 "$ROOT/scripts/deploy.py" update "$@" +if ((plan)); then + exit 0 +fi + +if command -v flock >/dev/null 2>&1; then + exec {cleanup_lock_fd}>>"$prefix/.deploy.lock" + flock -x "$cleanup_lock_fd" + + cleanup_installed_releases "$prefix" + + flock -u "$cleanup_lock_fd" + exec {cleanup_lock_fd}>&- +else + printf 'WARNING: flock unavailable; retention cleanup skipped\n' >&2 +fi \ No newline at end of file diff --git a/upgrade.sh b/upgrade.sh new file mode 100755 index 0000000..9702d15 --- /dev/null +++ b/upgrade.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +exec python3 "$ROOT/scripts/upgrade.py" "$@"