diff --git a/AGENTS.md b/AGENTS.md index e046fda..2d351e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ 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.25.0. +compatibility. Current source version: 0.25.1. ## Delivery diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a2a210..044dbb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ > test-only changes and packaging implementation notes were moved to > [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md). +## 0.25.1 + +- Reorganize **Traffic** into compact Overview, Clients, Requests and Delivery sub-tabs and visualize all bounded analytics dimensions with donut or horizontal bar charts instead of requiring a long page scroll. + ## 0.25.0 - Add reusable Web Services with document root, ordered index files and optional directory listing in the new **Web Server** dashboard section. diff --git a/Cargo.lock b/Cargo.lock index d0e46b3..817e0d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1688,7 +1688,7 @@ dependencies = [ [[package]] name = "rust-proxy" -version = "0.25.0" +version = "0.25.1" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 52f8df1..f67daa0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-proxy" -version = "0.25.0" +version = "0.25.1" edition = "2021" rust-version = "1.88" description = "RustProxy - independent reverse proxy with local administration" diff --git a/README.md b/README.md index b18bc14..d3eb6e5 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# RustProxy 0.25.0 +# RustProxy 0.25.1 Independent Rust reverse proxy and static Web Server with a dashboard, SQLite configuration, streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhosts and backend pools. -**0.25.0 release source.** +**0.25.1 release source.** ## Layer-4 streams @@ -11,27 +11,13 @@ Raw TCP/UDP forwarding has its own **Streams** page in the primary navigation, a Named HTTP/HTTPS listener bind addresses use the same chip-style editor as vhost hostnames, including multi-address listeners. -## What changed in 0.25.0 +## What changed in 0.25.1 -- Add reusable **Web Services** configured independently from vhosts. Each service owns its document root, optional ordered index-file list and directory-listing policy. -- Let the main vhost source and every custom location choose **Reverse Proxy** or **Web Server**, with Web Server locations referencing an existing Web Service instead of duplicating filesystem settings. A single vhost can mix static files and reverse-proxied applications. -- Add static `GET`/`HEAD`, MIME types, `Content-Length`, streaming file bodies, index lookup, directory slash redirects and optional escaped directory listings. -- Add HTTP validators (`ETag`, `Last-Modified`, `If-None-Match`, `If-Modified-Since`) plus one-range `Range`/`If-Range` handling with `206` and `416`. -- Confine local files to the configured document root with decoded-path validation and race-resistant Unix `openat`; symlinks are denied by default, with an explicit Linux-only static mode that follows only targets verified inside the root. -- Keep ACL/authentication, headers, security headers, custom error pages, compression and H1/H2/H3 transport in the existing vhost pipeline. The Web Server adds no second URL router, error-page subsystem or object cache. -- Add the **Web Server** dashboard workspace, API/OpenAPI schema, Polish/English UI strings, regression tests and dedicated operator documentation. -- Add Web Service MIME overrides, a fallback MIME type and optional `Cache-Control`; keep static data in filesystem/OS page cache instead of duplicating it into the reverse-proxy object cache. -- Expand Web Services with ordered `try_files`, internal/redirect rewrite rules, per-extension/MIME cache policy, fixed source headers, precompressed `.br`/`.gz` sidecars, forced-download extensions, Linux inside-root symlink policy and bounded FastCGI/PHP-FPM execution. PHP-FPM configuration supports Unix/loopback-TCP endpoint syntax, Authorization forwarding control, streaming body/header/stderr limits, trusted custom FastCGI parameters and validated `PHP_VALUE` overrides. Organize these settings into General, Routing, .htaccess, MIME, Cache & headers, Delivery, PHP-FPM and Security tabs using the same compact sticky modal-tab pattern as the Vhost editor. -- Fix repeated directory-listing refreshes, keep index-before-listing behavior per directory, and expose vhost-wide Web Server request/active/error/EWMA/traffic statistics. -- Split **Settings -> Logging** into Outputs, JSON format and Error log subsections in the same in-card sub-tab layout as Cache and accept route/cache/transform effective fields in custom JSON mappings. -- Add bounded per-vhost **Traffic** analytics with top countries, User-Agents, effective client IPs, paths, routes, methods, status classes and cache outcomes plus donut charts for the main dimensions. User-Agent capture is enabled by default; country capture/GeoIP lookup is explicit and disabled by default unless a country ACL/rate-limit already requires GeoIP. -- Keep Web Service lists stable across fast dashboard refreshes and return the normal customizable `403` page when a directory has neither an index nor directory listing. -- Add a bounded migration-oriented `.htaccess` compatibility layer with common rewrite/front-controller, index/listing, MIME, header, Expires and PHP `AddHandler` support; compile files on Web Service Apply, fail closed on unsupported access/handler directives, and never expose `.ht*` control files. -- Add one-shot Web Service presets for Static/SPA/PHP and common CMS/framework deployments (WordPress, Drupal, Laravel, Symfony, Joomla, PrestaShop, Downloads) plus optional hardening for high-confidence attack probes/sensitive files and response fingerprint headers. -- Add automatic RSS relief without vhost reloads: periodically ask glibc to return already-free pages and trim only idle HOT cache objects above a configurable waterline, while keeping active objects and bounded upstream keep-alive pools intact. -- Add an authenticated **Docs Reader** at `/docs` for bundled project Markdown, with the OpenAPI/Swagger interface kept in a separate **API** tab. +- Reorganize **Traffic** into Overview, Clients, Requests and Delivery sub-tabs so per-vhost analytics stay compact without a long page scroll. Render all bounded traffic dimensions as charts: donuts for low-cardinality distributions and horizontal bars for User-Agent, client IP, path, route and byte-volume rankings. +- Keep the Rust source-tree 500-line ceiling as a release contract, isolate unit tests from implementation files and strengthen split-module regression checks for dangling attributes/imports. Split the Docker Agent and oversized dashboard source bundles into bounded source modules while preserving the existing browser asset URLs. +- Fix the first Web Server module split regressions around `htaccess`, `server_protocol`, `anyhow::Context` and dangling derive boundaries; compiler-enabled `cargo check` remains a release-host gate when the packaging workspace lacks Rust. -See [Web Server](docs/WEB_SERVER.md) for configuration and security details, [documentation index](docs/README.md) for the current documentation set, and [0.25 specification audit](docs/V0.25.0_AUDIT.md) for point-by-point release status. Open follow-up work is tracked separately in [TODO](docs/TODO.md). +See [Web Server](docs/WEB_SERVER.md) for configuration and security details, [documentation index](docs/README.md) for the current documentation set, and [0.25.1 release audit](docs/V0.25.1_AUDIT.md) for point-by-point release status. Open follow-up work is tracked separately in [TODO](docs/TODO.md). ## Documentation @@ -39,7 +25,8 @@ See [Web Server](docs/WEB_SERVER.md) for configuration and security details, [do - [docs/README.md](docs/README.md) - canonical documentation index. - [docs/WEB_SERVER.md](docs/WEB_SERVER.md) - Web Server configuration, HTTP behavior and filesystem security. - [docs/PHP_FPM.md](docs/PHP_FPM.md) - implemented FastCGI/PHP-FPM integration, safety model and runtime requirements. -- [docs/V0.25.0_AUDIT.md](docs/V0.25.0_AUDIT.md) - mapping of the 0.25 specification to implementation and release gates. +- [docs/V0.25.1_AUDIT.md](docs/V0.25.1_AUDIT.md) - 0.25.1 Traffic/modularization release audit and compiler gate. +- [docs/V0.25.0_AUDIT.md](docs/V0.25.0_AUDIT.md) - Web Server foundation specification audit. - [docs/TEST_REPORT.md](docs/TEST_REPORT.md) - verification results and environment limitations. - [docs/TODO.md](docs/TODO.md) - unresolved follow-up only. @@ -104,7 +91,7 @@ CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_LTO=false \ CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8 cargo build --release --locked ``` -The 0.25.0 source archive preserves the resolved `Cargo.lock` supplied with the source tree and +The 0.25.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 @@ -321,10 +308,10 @@ 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.25.0 keeps the existing SQLite schema version 7. Web Services and vhost/location +RustProxy 0.25.1 keeps the existing SQLite schema version 7. Web Services and vhost/location source selection are stored in the normal revisioned configuration document, so no separate database migration is required; omitted source fields still default to Reverse Proxy. Once a -0.25.0 configuration containing the new fields is saved, an older binary may reject unknown +0.25.1 configuration containing the new fields is saved, an older binary may reject unknown configuration fields. Roll back using the pre-upgrade data backup as well as the old source, not just the binary. @@ -342,7 +329,7 @@ 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.25.0.zip +python3 make_zip.py /tmp/RustProxy-0.25.1.zip ``` ## New management and test-origin guide diff --git a/ROADMAP.md b/ROADMAP.md index 9d42d42..3d896be 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,16 @@ # RustProxy roadmap +## 0.25.1 - Traffic UX and source modularization + +Current direction: 0.25.0 -> 0.25.1 + +- [x] Split Traffic into Overview, Clients, Requests and Delivery sub-tabs. +- [x] Chart every bounded Traffic dimension with donut or horizontal bar visualizations suited to the data shape. +- [x] Keep every Rust source file at or below 500 lines and keep unit tests outside implementation files. +- [x] Split oversized dashboard source bundles and the Docker Agent into bounded source modules while preserving behavior and URLs. +- [x] Guard the Web Server split against dangling attributes and missing `htaccess`, `server_protocol` and `anyhow::Context` imports. +- [ ] Run compiler-enabled `cargo fmt --all`, `cargo check --locked --all-targets` and `cargo test --locked --all-targets` on the release host. + ## 0.25.0 - Web Server foundation Current direction: 0.24.10 -> 0.25.0 diff --git a/SHA256SUMS b/SHA256SUMS index bfaae2c..e7fe3c8 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,19 +1,19 @@ 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore -e6b44a4b8cc06e58b89e2cc7bcdbd80e95fdd2ae6119984447cc60e17047c20b AGENTS.md -69b9f617b84157d3001e96e3d5e5d975bc22335acd85a3d93d6ee388d318fae4 CHANGELOG.md -bf92c8ccba0a855496a0f22fcf74281099984ec1af87c01ecf22604d7df9ed9e Cargo.lock -a3f97f0300f001696f0ee4ec1aecc5a22d069a4fd2034b1dd2da1fb036512e90 Cargo.toml +b170ebacc706ae4fd4ccfd1e0e39ec09b32339a1d4c0c09e8c4a256a30b6088a AGENTS.md +c7267b5be9b10b758c56a7bb6201378bb07e92d945c3068ac7057011414eb61b CHANGELOG.md +2de8e335cb69d6e2375d9c40e5dfa92b9259d475bc91db9da1e8aea5178d1c0e Cargo.lock +37ebd727f68dc6552d83927466b41d020677acf8b61cb5c6a8606e6dc0d349a6 Cargo.toml d6d22ba63f4bc3d16a13012ecaabff8a156be57c824d2f20f66976502fb27d1f Dockerfile -23577110cc37c6a95190dae9dcc205dfdfabe8994bd76ebcad00b3c893131372 README.md -1f63ec071934e049ad899b148c2e44cc33cb6ddce6a467a7e2bbdc91c700ac67 ROADMAP.md +2e96d60cb489209c0c24344738ee7b2e2228b0c56e5e179b02040df5cf52a0ce README.md +183292392aab167a5fc0172c0059530e8f098d39958f07bcd7b843c0d625b85f ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml 928ba6a504bc431ae100c6e587861e5088b28822d2cb19da5d437b39d26c864a build.rs -11e775a3e1079580848119a75920bb87db052974f6a98f3b7e91492eb8579fb8 debian/README.md -5d8f0b963f652ffad9e174e2bc5e7e4085cb27d0d6aa1e8503c88dd6a8a3a07d debian/build-package.sh +66f7c9aa12782d6e57b6a512c9cb10f623abd8cbb9ddd4582f19013e837964b5 debian/README.md +58e94c1c9fab848aca8ae2b79ab4ed071bcbc7841130e91c3a8e08114e3bf918 debian/build-package.sh 0763a4bd8b429f7922b23a7276ff694f3a751b83fb127f6ad7ec654e5e507ecd debian/build-repository.sh -4e86c147a487a2b7b5e3acafbf9e8d18474a6ccef9413f8ad5d6ed5929a00f21 debian/changelog +941c7b6c16e59f037c60b26278584819cbf4a898ce11f164ca94d0cac4644df4 debian/changelog c90457b19b1b2f7998d3db59a656d215eb0fab23769197405960457b66d95cf7 debian/control dff2ecb9c14623416981f444bc6df1c4e2bf55d330260f36a312d8d42245400f debian/rules c537d54fd92a9b97c5f6e6ec63bc1276cda324513e2946b341b2193f63b62e6a debian/rustproxy.environment @@ -24,39 +24,44 @@ cb0db03a2c1b06fa04a11f7690755a0ca90b17f19ed8fdb228810f505c48c78a debian/rustpro e8e18df40bcd69d6aa404282679186d9b8256ac63a5b4e80d7f081e8a0095a2e debian/source/format 385ac7970fa94f043d0bcf04a91184e6e2c807587b85e55517ed064bb4bddba6 deploy/README.md 7b7fdf383bbaea4f37b5c120e98a24e9a075fba56ae7838a31a5152c7edcde02 deploy/rustproxy.service -c9e4f0199c691f0d34af937d393a7c37383e41a09fab6e7e2531b256d40c2a4e dev.sh +565c83dde85cfb32af90950bd57ff0480c467bc97f65145806bb92ed8b1f4ced dev.sh 5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md +7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go 5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example +bf687a851374d8f1fa59d050f3f76532f7c12f16a6daa7d3c36102c2d9a8ec9c docker-agent/docker.go 9e9f3f17f38f9b0730f598f65988ce8ef562d2182ef6778fc7292b990e9cf22f docker-agent/go.mod 9d77c90767fa929ee8338734d72ce83284d3d965673270f62cb2b1613d587a33 docker-agent/install.sh -c42caaf53dfc21bc5cedac3a2643b8bf32f44b6420fd6944a140d9df4015fb4b docker-agent/main.go +b68234472bec5cedd67c7cfe1de7d8806a965dfd4101f944aa6320132f18c14e docker-agent/main.go 3d4051a1f805d145df3877476e4b87773e74be8e123045065955a5133afdc68c docker-agent/main_test.go +fb8c89f015e3a05edb16b84e0a76771a9ed9cb347d339ea41fefd788812f71a9 docker-agent/model.go +e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/network.go 634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service +5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go 802a30b793a6ae6a5046489612a23659f33d25e41d0794551381db202daac904 docs/ACME_PROVIDERS.md ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md 6fb6dd879b83380187fc720e2668e412c3eacb66350e466b4aa1c3e6dba76edd docs/API.md -52124e3c4728e6328ec2346da5c47a3e0e5befc259473ab7a65a2b4f67fae89b docs/API_ACCESS.md -aa606e2429829d6b4e83bac25bc8a6ee38b21bf15d0a0a71755b186dc698b9a4 docs/ARCHITECTURE.md +7598602a113e951bc1b0af5f1aedd3dae7c4486270599cc014f17ab552085f5a docs/API_ACCESS.md +0f7f159ad5b4b5277f80c19f3c47f4e7823ea439ef02e5913e1391f1b34d129f docs/ARCHITECTURE.md cd2597465af031cdb47db802f8ad6d3af34ef9e79f679df906a7b84220ea3260 docs/CACHE.md c25f3f6763c35dacf718b4b87c5665c2030b1fb3584e8c20940fc3ca7a2d26a3 docs/CACHE_DIAGNOSTICS.md -b931bbbbb5b20b7edb6c4efa250de0781296ba670b4462366cdbf44199dd5abc docs/CHANGELOG_TECHNICAL_ARCHIVE.md +f2f850b9500e7aa297337b54253f76fc3ceddad940a66c40332029adf32d124c docs/CHANGELOG_TECHNICAL_ARCHIVE.md db5534427ff0f004940fd6960906d3eaa75347a110e6322827c51c5d63f20adb docs/CONTAINER.md c51ae0b6cacdd9b34f7b80872192d4a1e1c4e2ad697006d24855864c9e136a1b docs/CUSTOM_LOCATIONS.md 7db65a96f696aff1d7f74e2041cfc6ecdf437b1412ece90cee26da1ceff4cf7e docs/DOCKER_DISCOVERY.md 9a882f07945b207d032936feb843f29467e4654f8255fa0104d988a54987f982 docs/ERROR_LOGGING.md df6ecd4a50796640243676ad4b3a87789f6e5d20cbebea6255e92fbfff1c7131 docs/HTTP3.md -38b8133710e326401ba409e94dc018620a928e47adaef3cbd384f8c63c18e081 docs/INSTALL.md +3912d71181c50d4c96f53f592429e188f34bfee757e214c90c59b49acd181e34 docs/INSTALL.md 3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md b30e6307d00580471a8ea44cdc88cbe2139676357f736660cb53f7120e8fae24 docs/LIVE_LOGS.md 7c35c4cdbe7bbb81d94f3bf6aeb22b8a866af3e7f0d62bbedc299e4d6f264e0d docs/MANAGEMENT.md -681c928bf5548b7bb92ce3322ed82a797b36f63c8ed2f7ab39fdab3e1da2f95a docs/MIGRATION.md +17571cbe8aa7de141bb6225d07231da6b92893c9bf3b654c07474caac8e7c3db docs/MIGRATION.md 5f090a190000c6badab2a74e5d4968e70c34de43b59c1452f5b05503b5c5c18a docs/MOCK_AUTH.md 1f85fac0e06e432ef6f744f0b5f9c1b7a27a1e9e1ad30dc1a218eab96b1caffe docs/MOCK_SERVER.md e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND_HOSTS.md 3da047768f32064c5ef0ad7a4c73c4b14930fe7317c54f5650f426af9f484593 docs/NPM_MIGRATION.md 712cb1cbf84515384b081fd2929e86e40af7c10f65bc69919b78b7b3e2fd12b5 docs/PHP_FPM.md 44e8b1940af190417574866911800ee5385ddce59c1f13c76dff35f02c134af8 docs/PROMETHEUS.md -9d4d76ff4fee1c1dcd95ace0e9c67e141845202ba3bb948ab17b57459876db45 docs/README.md +51dc5a75acb7b58e894dd23427e32f75b062c770b9cf7772aebff90c07800edc docs/README.md 6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md 0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md ce5afb4f531bb5fb4c453deb49f643f2a563f32f7908c995e5bc8e0d7de995b7 docs/RENEWAL.md @@ -66,7 +71,7 @@ a699fd663bfbf001525f22f0fbacdf03a58c5e825232d8d144e51cbb8b1008b2 docs/SECURITY. 620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md 559a0653ec7d4bc05d677408f91931881be03bc49b87ead629d2cafab9af2916 docs/SETTINGS.md 3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md -b6df2c2d941bd98f86ae3296fbe96ad27b3cde5e23ccf6c9891d1165ed393aa4 docs/TEST_REPORT.md +bf9fda355405b92309300cb2639a453b780ec2ebbefc8a9374174af41d8804fa docs/TEST_REPORT.md cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md 8ee6187a5460ca45643b7fd789e27bffd264449e3a92ff70e9359edee011bbbb docs/TRANSFORM.md b02957b708174c9def830ced3c70c943a47af7a33467ef858255d04ab92c4af0 docs/TRANSPORT.md @@ -89,9 +94,10 @@ b891db1453cd7cf6b10f79879f4edaf7c83f14e91e4e95826919d40cb5dca2d4 docs/V0.24.4_A d0e609febbcd1cfdb8ea0b00b4fa6f081eac01e2d43a81e38a53ae25783152cc docs/V0.24.8_AUDIT.md 6131ef0ac65af2c9544d9d44ecfb680587cb358b37efd9b2d1cf69f26e935861 docs/V0.25.0_AUDIT.md 1a8a07eb150100e6d5ab8443dac664038e1d838033609df168aaf60a15aeb84f docs/V0.25.0_SECURITY_PREPROD_AUDIT.md +32c4053d48e8f24eb6c3395a4bbbc18f1c9790601166c73fb83173a14142789e docs/V0.25.1_AUDIT.md d64070b2186f9971862e9c929373d90a38022a3a9f3030e2014e280cd7b392f8 docs/VHOSTS.md e32472cea5ef3cd25c058e55df8adfcc1d7bf03e0d12dba69d2f2b9b94fe04b7 docs/VHOST_ACCESS.md -019d50274d4c213be7287a3b4276deff623cf6b72c90f5d735a124c97af7dae0 docs/WEB_SERVER.md +f0e76167944861517d1d42b9186ff0b2f184be0371b9e8597827e60895de4a76 docs/WEB_SERVER.md c336f998027330c58d5afbbbb3a3a682fd44ae3997643882fe342633822f8bb3 docs/screenshots/README.md 5502e8ce44276213803b66c58e3a4d804a1354f5ed5d83f47ee805e8c44ab088 examples/acl-headers.json 2431dd88a2d9ad2cf3cd4b8fcdfa602563ab1013c612d8917fddbe7febfb2dc3 examples/cache-dashboard.json @@ -104,13 +110,13 @@ b26833cb4251f9390a44192ecc827ff7f052fd19016a5ccf7498c94503ab4ce3 examples/mock- 17e7971fa9d9b141019fba3135d87cb1aa0cbc55d56b0718f879f880b615da26 examples/vhosts-balanced.json e06905b6363103b1bcee1a09b9e02292033f114d88f3edbea22ddab53e9e5071 examples/vhosts-private.json 529b47c6d4ea44d2a25d1cf9561cb5ee1e05f50a568c6dba5889741bcf936434 grafana/README.md -68cc91d67d950076762a202e57a3dbd6c3a18ae321d62151f37769e6e6c85dba grafana/rustproxy-overview.json +a64b996085d39688a3c5c5dee986a3ecdf576a7ec4b7430091765678b1b44d7b grafana/rustproxy-overview.json 30763fc61c38992a44bc24ad6932b88fd3b322724fce799db1f17f9a747eb59a install-debian13.sh 858b39fa6886b71d20690bf369e76580922364b3cc7f0cf6ba9b040df9cd76e3 install.sh 29cada0936762f26495064d1524a668262ed729b21f20aebddc8b6e5cfea363b langs/README.md -07c1387009e6d8105267b8dd8ec7ce0e5df7db293dca4457cae4aeadfefff290 langs/en_US.json -7c875c96745ef5e93d8ca573d6ac64249b009eb24e7447845868b2d3e47a22fc langs/pl_pl.json -241a052261ccd9af3fbc177de63e268e603e86148512de868a4736b61474a03b make_zip.py +402b463e17362363f3199d743286d26fc69b2f4342c23d4b6747974d21451a28 langs/en_US.json +931dbbcb75711d5a52bce45a3e1b5fdd0eec2ae13eb7854ce322b2e3cebd8a5b langs/pl_pl.json +de2a01f795e2ca6dd52ca8de46d9f41b810a2e798678c3f8651a4cbf0df35814 make_zip.py e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql 5c62da30fa9db594a4d656c77ae676d3c388072b6a85f91170ec7d911d7cde43 migrations/0004_admin_preferences.sql @@ -128,39 +134,70 @@ edb6ee1445b01cd6f3a74a3a607f39fb107d411b49df028d2e0f4679e093de31 scripts/acme_p e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/acme_rfc2136.py 7e524285bfa2e3dbde0391257660635b039f23b0b3807e9510df663f757a5ad8 scripts/admin_audit.py 20e83f8b6347b819d1d1ea3de5c691c36f0816954a4210b0d3f39557fefe1931 scripts/backup.py -1714b62b9eb794aca08f9d65f4e5472a0999c41a3bf1cd89b869fd7822b0bbcd scripts/check_source.py -069f83d487ca4e6b0b04dc47e6bc67103407724750a97c0435fb9b9d40267079 scripts/deploy.py -939225c393bc0010a53709f10fda33e7e83f5e0645cd8119abbe2af325baae16 scripts/docs_check.py +23fd428ef2285f473b09cf2a945c562a9f0738913614b837190423f4654569fb scripts/check_source.py +187ee92c8029cdd59e89b4447cf4d170e856017a2091ceb85ae7c22dd1a79c72 scripts/deploy.py +d1bba7e7e810b4bbd4fd6b0292c00099e93f34dae734b236328230112fac4292 scripts/deploy_core.py +f014472588e889488976970dbaa95a8745b6907516aa81a7cca21d0f81eaaad3 scripts/deploy_runtime.py +741f49cafc470baab8a00779cb954cb78530ac51cefa344580d463a88dd3226b scripts/docs_check.py 6facb2d65bfab234f09855097d12da8f72669a38ebceb4b1cbd03ab42b8d2469 scripts/langs_check.py -963198e1e582a084fff116825b90b6d237bbf4c485e99d555356c7f752f8e65e scripts/memory_contracts.py -60504dd426195fca88d77e075d607c04540dbe8aae2cd806077b3818e0e0cf08 scripts/migrate_npm.py +2f846042b98a9148be944fb74a2b023b871ff884693a36847837228613a17679 scripts/memory_contracts.py +2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migrate_npm.py +a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migrate_npm_api.py +31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migrate_npm_core.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 -a6df3371e61cb0de6b439c92cd59f9a44dbc97520544846ec7ca253b3c072a41 scripts/preflight.py -e37ffa78df75bc5ca8bbf80b0ca99612abb3aa664dde0a9b4cce441bb7f0d2ef scripts/release_smoke.py +693907aa06a608600761b666fb0ee7ed081898c2b4664d2d134b216debdd9005 scripts/preflight.py +29d71ff431637282f540ed36214606443c17bc7e7c0dec0611cb4636e4c561bf scripts/release_smoke.py 45be151cd85fc2ef2f1fdc3003ace63381af262e73af8a685ab48cb697cc0de1 scripts/routeros-oci-build/.env.routeros.example 3b48cff8506e5a6ae7d3876f47721f5de7d63e2e02264a78cf5b6ddd52d9fbc6 scripts/routeros-oci-build/Dockerfile.routeros 7c7a3e51f43b43e1c962a4b0bb591f99c1f06977bf77f23719f7f867dfe7eb8e scripts/routeros-oci-build/Dockerfile.routeros.dockerignore 68c076ef4537a047a7c390c534dbadd90e00cf495931c6d739f1c28b6b4dc7df scripts/routeros-oci-build/README.md d53b2ae7315ac143bd17d6327611a39e7c23666408bb95e39da2ca8517322ccb scripts/routeros-oci-build/build-routeros-oci.sh 3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a scripts/routeros-oci-build/docker-entrypoint.sh -ae440f5d8ddb83d79cc2ad084b7917f14dd5b8e0bf677698d832b768a5c67204 scripts/security_contracts.py +4aca6fa11cd0b88b3b0d1527d2cf6132c94ce1405e69ff057ca1c9e48a5479ac scripts/rust_module_contracts.py +ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed scripts/rust_source.py +1c83054336b3508d7ad72cd259f039394682642439877fe69639c0b76be09154 scripts/security_contracts.py 295b6f2625b1875950ea3eecccd142f49be327afdd00c92641286e6de530ec51 scripts/security_dependency_contracts.py -2526f49adba4587cb407c09371e009874b1c1641e7e9b8dcb9ec854f1bd270b2 scripts/swagger_assets.py +2470b4b4625070fd896ae8438f7cb672ce3bfa8ffab328e2d83f2fd3b5f0af31 scripts/source_module_contracts.py +732645adaefae38cca28901ce3786d5ac5756030bc5aa879400a9d90801d5225 scripts/swagger_assets.py 102b23de19a88a8e60de392fb83aefd3dfb33e90cc97ed1690d9f3d4a33d8866 scripts/test_compression.py -42579a21b28a17f75f0733363a12a684f742bae8ef5cb9e2a580d290ff59fee7 scripts/upgrade.py -14f00f5a5acd9c87ce5e52f693bd1b6c9cd85469917087892a6f9eea023b33f2 scripts/web_server_contracts.py +1f4d60d918939e7e7cbd9e13b0ed40668f0c40b16317cfc4ab3d3d0733d83670 scripts/upgrade.py +ea20bd340ecf3687c2721c522b4da09e88498de683bc4288bb85202969fd3d75 scripts/web_assets.py +b630b5221730a9e7939c8751acbea8c046ebe2832ae265f3f825785041e9a0f1 scripts/web_server_contracts.py b8ce3e8fd08865015ce312fae9c0457612af18d6ef4513a6281b9e3212f8d458 scripts/web_service_ui_test.mjs -e0e3eb166d0d89851e6bccae2cec9d93a0c4e387485909d0bafe96eab0ad09f8 src/acl.rs -4213236dc8317667313fb028fa0536ce957ccc6210bb364913f82730517a15cd src/acme.rs -46f26b862d23b952130a9fbfe7061f3b15482ff94e66a5a7d323745d61e9bf29 src/admin/documentation.rs -d925f1f0a596f6a99de1c4aedc120c1d107869e0a6c6dcc301edcbd7b4fe7264 src/admin/mod.rs -a2a917291af57ec155b4ad0b39096e0aef72b2590b35da373d9c7d87e4e15473 src/admin/openapi.json +d8e2b51c39252dbb4305512fa9b1ef508ec610968aed316b10514fd160298762 src/acl.rs +e8ffed9a9f50a467062d0758aec6aefdc8a39615282b2eef73603d08bc33c00a src/acl/tests.rs +992126cb77249a6a173a71b2e5387f218b987545a04b26bc0340f922b4600ec1 src/acme.rs +6ac56bf1895c26bb5a514ab2f60099add161a7a4bb42d6411264054f824841a5 src/acme/jobs.rs +5ae0514e93148b43a5b587cd870dcc5563f59a65a680ea0080aea6c09bcea96e src/acme/model.rs +c5334de6c732d76b2bea7bfc17b3ac305a250bbaad31a5420d6a7d7abb200449 src/acme/providers.rs +b9e57db4bd80ef37564dfc533a96897d8a494671d53acea9fb4bf28d23ba370d src/acme/providers/tests.rs +7acbaf34e7b953b753ab074893e47b5024f0cd756dc4c7a1074067dd936c2e8e src/acme/tests.rs +8a07a8c9d845e55ffa47332d4379e8ffdc2cee09b22e6c6396edb8e768b24540 src/admin/access.rs +21e4ae7e72b0c86ba674bc820aabdd201cd62126fdd09ba22cc4b420e38ad300 src/admin/certificates.rs +e414672b1a7f8c083fe7020405ca05df3cbce193139b7247ed74cbaea8fc8954 src/admin/documentation.rs +67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs +cc5c84d8476e2ba11c3a4b1996ca616eb402bb0aea734434ac6e185e1041736e src/admin/logging_api.rs +468b6062cb806ef489bc107f69d4725dbf9c08d7e18d15b4482ae0f309c15d2f src/admin/mod.rs +27543e3a5034cae663827a3ec2bd93c3a0e70c105abbfe45be16c2f8418de1b6 src/admin/openapi.json +04ac147a823a1b5510977185fc10b4f5e3fddaa76c00ee73ab5209782dd4d859 src/admin/operations.rs b4fa393b780b2676b5d72e0f90cd7290292f424b8062d4b5983e722d6ca72c32 src/admin/public.rs +b7a1b9535d01db8b9cb0f97048203dffd55404b331ec8e0386c63e8c417bf19a src/admin/router.rs +68685c80f57d8e2b0e1d835368893ddc71a8bb3519933fde99e74f6e64a0a6da src/admin/session.rs +6720220b8ca9b7a414540755b6515706c5031b5f563a7daae94c6c8c5f2da810 src/admin/status.rs +cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs +9bed1f93a8ef7bea4478b26bc825aacc8825ca70cf26c993fe78ac784aab2526 src/admin/tests.rs +5df98246c46135a176b4b8b705c92fd328719e82eb7342f088256abb976d5ae7 src/admin/tests/part_01.rs +b8866db46f8be4f3f5bcceb5d84d32fe482f65d534b65cc75b4b3e33dba576c0 src/admin/tests/part_02.rs +b31d594499d7f8164c14b66dd77971b9985cefd99bd4d3686f9ea26e11e5a59c src/admin/tests/part_03.rs +7c1c18d2d07311f7fcffd2872cca6d5e56212242e4ab7a8912395b4df6d3dcd2 src/admin/tests/part_04.rs +829d0ef87b6d0c4d2a8a9e1e299c27d2d84f83a73ea222df45b0384abc2e2da8 src/admin/tests/part_05.rs 23c55f23deeec844a9abc2289440c9e0583aaeb72416fc5f3632a43a864c0efd src/admin/web/acme.js -07757a8c6e38f750d9cdbc54857f3ab31cd99083807d9aa5d1ada14074511ded src/admin/web/app.js +c4f879c172e30e569ef2bd4003abdd73b3b3d775943a69c38157f011ae0ef9df src/admin/web/app/core.js +d164400471f97860792fd7c0726df9ca807cff9076b122e80337f7511a31ba80 src/admin/web/app/runtime.js +ad8fc39dc4b3227730d8707b1091f80f366827c17f47dc54567acea6c23c1fdd src/admin/web/app/settings.js d0be71555de13f732fd6ee44438a0110eb08c91e78bbc1aa36a1a84182780210 src/admin/web/cache-panel.js 50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html 3b84a7a08f9f68e2c78fed4523b594c31a251addc5480a69c5f6949908230539 src/admin/web/docker.js @@ -175,7 +212,7 @@ f7d0955ab18c15e04369204e789f341d2e832f927c1ff20a3b2b9e35e7898f00 src/admin/web/ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/error_504.html 0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg dee3b113907f77138cdf919efdfb097e45b889cff3893bfc6f02ed998643bbae src/admin/web/host404.js -eaa9757a7580e951e3920dd05aad29b97cb6a0839ae474dcdac992706c506d41 src/admin/web/index.html +6370e91e80e5ddb049a332e9269afcc60ee9e8160408a82b5778d02c1f989692 src/admin/web/index.html 24db88842707f764064657712d3660c6ac129fbfb47d188c0903925cf8244ce5 src/admin/web/integrations.js cdab0c11a28deebdf401c0453fbc76b675e9e643722106fb20e812bb9648ccde src/admin/web/login-keys.json a253d202dc9a147f2add34c64e0f35c38fdc7f667f9908933ce5f959b1a92a2e src/admin/web/login.html @@ -184,78 +221,242 @@ e41619e5cb0ba01a3d287748f28eb38bbe7641f9bedade252d286c18e619c400 src/admin/web/ 22318f39f7bb1ae98e65737d3c2ad9e63065b8e94ad59bed560c0e80d3a46949 src/admin/web/redirects.js 811a6b6d7f7c0d0a268113849be021176098097cd33279acb9a9ffc40d2b934f src/admin/web/settings.js 1a30affd6a717095a1763998a5850a79ae0f86b7b694806f2aba2b880735dd1a src/admin/web/streams.js -35f56feb875dc1d3e9b2b9ca4438602958e6fdafac41f3638ebbba829d379db9 src/admin/web/style.css -8e3c6957373552efe72a0e9b4f5c816c4c46b77fcd7cf4d45535b332ba0e552d src/admin/web/traffic-panel.js +f04e772b89e9aa265a55e2601675ccd0217df012e6f426d3be3461d5daece59e src/admin/web/style.css +47e98065281c04b4c185d53df4ed0cb12fabc716e52143e6ae3c3c25db62126a src/admin/web/traffic-panel.js 049be02e0f68c3f6702f3bf29be760c4e5bc1199393ed198107c0a264711300d src/admin/web/transform-panel.js -39bfeaf00644f9570444cff170fffe77d8bc92e07ce987a6a5dd66ffe646adf1 src/admin/web/vhosts.js +f42f079f648ce80ec6c55d281d73c9dcc69a120e6cdb7e3eb32edda6bf99aecc src/admin/web/vhosts/core.js +3c483d7b1eb69455d7bceb83f44f2b8fd60e02b2ec0dd03f4b69308525fa54a4 src/admin/web/vhosts/editor.js +769383dfc5afa28e01a1e99c9eebcce39fc9457fba0602992f750acab9911236 src/admin/web/vhosts/render.js 0de35a0671f3ac8fed62260d63ae43d04efce4947428ebdb9254cff0295295f9 src/admin/web/web-server.js -1e5c6c6157f8b66d90751a0480cb90c6fdfa229330fc4c3f3504bc0c689c3d60 src/auth/files.rs -606f5491f94da007881a679051677a4609881d3151ac753de02556e0e76e1ce8 src/auth/mod.rs +42c99af2cd73ab9d257e076d38c1b1a81e743ed04e96c440ad9617a394fbbf6a src/auth/files.rs +b2f948bcc3dbd24952c7b1b02d41d06757f49f0c38a78caf51f8f8a687d8480d src/auth/files/tests.rs +d19f47e061d5965eb3f4100063c396f8438f14e14c0fe5e9d4d32f82f72dddb7 src/auth/mod.rs 1ceb57d97cbff655f185e126d98b8dc7884c7aac865e1f70e3c6d199685c13a7 src/auth/store.rs -e85c6d9a9e80c59d0d8ed1ec8a1be5ebae18a1e9dba1dedcb0f5872c903bf569 src/backup.rs -168f55f75ad6ebd80f98c3376f5885cdc8d313f8b03f4e93066a28cf7b8c8b81 src/balance.rs -e2bf9d4f8a8c8c7f9a8df96872a657b9706d05f3f3a83721fe912ca0af37b29a src/basic_auth.rs -9a2880276819a62fef40c4c926be688e38b7c0eb551995d5653c119ffced0db3 src/cache/cold.rs +745226cabb007819370fc6aa680995a03a8a13fa057dacfab0fcc21d0c43bbe4 src/auth/tests.rs +4f123aa55c69cd3b13db31ae5d9b6984911eecd922b5e8fc60437fd54265d1f1 src/backup.rs +968bf5c4ddb67fd9b53aed051234fb42df35b5045f26add611c144ba399814dc src/backup/archive.rs +b1740707011300581d1e5d414b81ecfb774083567aebfea807b4b53ed425e52a src/backup/tests.rs +4b143f32b96a0640345353f71d69e345b415efbbe89d7b24b00641aae4e5a7d4 src/backup/validation.rs +426a946b2c9e244f09f6cbc5f9664b8ad0737529a9bb5a3d549d27196a1f8043 src/balance.rs +6521d493edffd36a68a5753d8c808dd66394243f97fd96984b7cbe60588666f1 src/balance/health.rs +35d42b26de1757a490e936b907bae6803c51582aaaa6a9209eb61a701dd586d9 src/balance/lease_runtime.rs +6b6993caed810bc3a943c9613b1080abd955bf7543dd672ed4b48c4b4a5095bc src/balance/model.rs +1a5ac7acd28a3876d13745be4d28758b0616d155494083ee66e96fde07d279e7 src/balance/pool_core.rs +477132dd381e23f1be197c32ff58a0925f41d3e6aa63fe77c10973cefb2b76f6 src/balance/pool_selection.rs +f8667285f4dee1d6b6d6ace09e0b57225ec38048fcffe81d72b105b87aa4762e src/balance/pool_view.rs +7d94d1bd4eb698e0588b822d93e56e3dc5594c46edac4aa9713f148893ebe6d5 src/balance/tests.rs +745952fdae2a6d0efe988fd8cbf44d7330772c28a972dfce3f78e570edd27ee2 src/balance/tests/part_01.rs +96eeb18c350763af7eb03e2fc843cbb4362e07fbecffea34c6815f7d3731a64c src/balance/tests/part_02.rs +ccb3e1988d6ae5825450e894eea896d53ae9fac150d0175ea5e742413e55105a src/basic_auth.rs +9a8858b7ff461a4d961ba701ca3ed6eaec15c3f7f05eef8e0e30edc63617605a src/basic_auth/tests.rs +7def03ace07dc38a4806277d75b6ce673d33ddaaf1c4855fa0a3372be1cf9489 src/cache/cold.rs +006cd727ef4e2515576be61af99c72f989ce29002945ac64a0776ebf8dc447ae src/cache/cold/cleanup.rs +e091ffb895a6e12017c36dee180341ef9446a12e7447a749ca919a1f638cefd3 src/cache/cold/demotion.rs +4680051619a2804f327b98435687edd27533cea96f68ab4a8d2668e86437f77d src/cache/cold/startup.rs +5d20f7342df20a95d1cd0a4283287f5e557212998adc5458a642707f453132e5 src/cache/cold/worker.rs d51fa4f320be56c572f767617428c3f3838543cb0eeb7d1f8ae791e1a1ce9a71 src/cache/collapse.rs 27a35689fef639f1aea00d9f5a86adc981c0c304dc3e28e6da40494dffbb6bfa src/cache/dashboard.rs bbd6de8568fea79cc4a11d33b905af75713e609c8f37ec943c82f0d180e66dcc src/cache/fill.rs -9110e0662022da6a0518bfbf93872143e8ecafb0f541c0d30a0288ad09fc5883 src/cache/invalidation.rs +675d18afb53f25c56cba49bbb431fa39ecd9d7bdd192c7b39b0698225de7a2a2 src/cache/invalidation.rs +c32bd2350c6561418b28e4f03bbe9f0e3517523eb5eb24f70a130718775bdf2a src/cache/invalidation/ban.rs +b963d75d027f297644d8a928f64b44177e874379af607d1b37aa869b2f21eb80 src/cache/invalidation/invalidate.rs b74945c7460b76ed7afcb02a45ede09d76f08e0703c47920b9b9d794d4b74980 src/cache/lifecycle.rs -b458d1cc79a8998cf8f62b66fc998054114b9fa34618a631ce5d55e7ceed57e1 src/cache/lookup.rs +bd831807ca4720b3a41a6ea1d9a6c0ed2d935225e2dacbd9313be064f2e4a20e src/cache/lookup.rs +5a28cf01e860fa586f7b227877889bd529aab3215095016607b5d6524114b1b3 src/cache/lookup/hot.rs +b865fe86aeb9ee798da1b69d3b1c74d9c0c7a29e6793eb6cec85886ceaeca734 src/cache/lookup/insert.rs +8a974f04b36bc507615539d44c4290cff8d690478ca0c8691c72225e8c7929a7 src/cache/lookup/tiered.rs 58c03fde09e5eab40968679793b364f4f1cf5fd832bd79ebcdc92bb23871ad82 src/cache/mod.rs -aeb6c15c5c76a5d32aefdc9e543444e46d7991eea315c6d985b2551f32dbdf9a src/cache/model.rs -cae9d35be0e31b5f942e3d3b20e7496581ec6491d7974509ece4cf540271b1f5 src/cache/policy.rs +f2ce12806e9572f0ac03277d38d9feafa7ed6a045ef010434e33069271ddd6a1 src/cache/model.rs +c179e5bd35873985d7060094e0ad61b46f4e9cbafdbacfee3b3e24910e13b974 src/cache/model/cache.rs +d203d38f8792963994b041fac07c780eeb33ede27228289165f5374af11e359c src/cache/model/entry.rs +0c20bdc8429ca192988df047ab01ff3001f834ed728273be124dfc6359d43cc8 src/cache/model/maintenance.rs +36d471e4b417f1eaffe1fd39bdaa64b6a6aa68faf58b26fe42d6b977f08769d9 src/cache/model/settings.rs +29e0cd1c1b05ea836cbe716da1940a0590e120ef63eaaae99ec88eb9d797a9b1 src/cache/model/views.rs +e26dce02df9c043329ca164fed215d271781bce8fb03240f528331bf3df6b9d7 src/cache/policy.rs +ee06af7a6d14bc12e73be5c1bd494426c1735d0bba2a8870a2aea4764ba4270c src/cache/policy/request.rs +c46f335cf75b91b728125596242a9682a7ef3dbe86242dde7fc038790984a6e4 src/cache/policy/response.rs 7e2bcfbb2b57732cdc704d9df7a9298fb80e735e37984431bc936630e41e7f8a src/cache/range.rs -b20e2643084ac7f72f80c7f8fb8cf7ac4e16ee3d83d2f73767fcbb3e0ffe3cf8 src/cache/tests.rs +efde2bd046ed073900cd3e67051f0542c802a06e8f5e38c2c20ee92604080c36 src/cache/tests.rs +13dab7b7a587cfc146826a92ab94ec9e04f7e500b7a84e678d77226829844923 src/cache/tests/cold_tier_tests.rs +332297f1bc2164795f501f75a145f56c30fe97c88b37c71ca34f3a4aba503b9c src/cache/tests/cold_tier_tests/part_01.rs +5e4dfdf1878bfe7a859460020f4981f2d4ab995c42b6dd1129ae5791481c64bd src/cache/tests/cold_tier_tests/part_02.rs +8bcbd6e8158620c4e0c056b2872e0b5f786d5f2bc2e2203351b8416a94641f09 src/cache/tests/dashboard_tests.rs +6c69ef40567d34671d26231358e2db74cc26ec0113843303caa98d4dc0fbf1f9 src/cache/tests/scope_tests.rs +578b18cac918ba51fad775eca188f645b228500e59eb1790f783a0b8d0245b7b src/cache/tests/tests.rs +c33013089bf9e75a07a5b43f1030acaba8aba4098796e393ed4bad784f4fff96 src/cache/tests/tests/part_01.rs +2f579a67e73817907525651f32c65db9f4175d55c9ea34773e988a7764a7fc8b src/cache/tests/tests/part_02.rs +02d1b147db43a5f8750ba63ceeb71d127ff55da045079b2b0c1fb4f8aef3b867 src/cache/tests/tests/part_03.rs +cdc41a7241739b89297494323f29e0230fa06abbd74e219fedfda11df1db4334 src/cache/tests/tests/part_04.rs +541a62b749440aa1099cf0fc8a2bd31eac0c082ccef35df22c1e049e7928e216 src/cache/tests/v022_cache_tests.rs +15e85357c3ddf4fc2283d621f5f4cd8ca309275e06677020299d4105f0475949 src/cache/tests/v022_cache_tests/part_01.rs +7ee652a781081c0b53d39dff213c2bda38e405a18a3d0b908f97cd3ac51d556f src/cache/tests/v022_cache_tests/part_02.rs bc7f52d55c707fbb409306eadd9aa31115e823590f81b24f68a45264a6f6c86e src/cache/tickets.rs -5d1788e6da0669546729e7bea56c64f3b989d399793e67d34fdbf427a6e0c65b src/compression.rs -405d8a67dca3efb48485adc905ae14fd00a6e31f18a14cf24938337b1e082274 src/config.rs -ed08b39fad6e7f6a536f8fa7d09221209f99a92a6735d6d4ee9d3ddfa6503091 src/db.rs +992e62f8240aa462b9c670eea6e46e86391d4eb82402f3278c23ad73acc89b1f src/compression.rs +ad47db6519e471ec53a408129bb10a794033430e28fcd3d9a4cf273669900829 src/compression/tests.rs +7921ae451ccd65d647165b02919bd97399399da471454a6a1a1f63e682b76fc8 src/config.rs +fcb45e7b5cf59f1fd18975db501eafcfb8d70c6139589d851f46a3fc5eac043e src/config/backend.rs +0ce2c6b96fd96518258f08c8ed73db333948a623796a81f8af0976c3bea1e188 src/config/compile.rs +2abf288c0541474e52e45b80f2fff41c572612662be3f9dea4342a1d41851440 src/config/model.rs +fcd733906d4477911a20ba9315635c88c5fa3f8d515420390024e06a750571a0 src/config/routing.rs +51aa3205dbe5c9a789369736285703338b1524b8fe7cd366eb935abf3dd6bfb9 src/config/snapshot.rs +7d94d1bd4eb698e0588b822d93e56e3dc5594c46edac4aa9713f148893ebe6d5 src/config/tests.rs +be6852c11041bcea2c387b76ba3d66a8ac06f8425bd1c976075efe5cc802902d src/config/tests/part_01.rs +467b89435d0d115b227c8c8093d06c3443b5c4c063bc18e322e7d2edf635a02e src/config/tests/part_02.rs +236f27a916b278411d0f45408b1489339043c35e8fa9beea1e131821c2588c1a src/db.rs +cef6f5d63aa33433df5acc40c22e55a55c8ea275424b200084eceea3e3432cbd src/db/tests.rs 19ab6c3ae37009c544291cb0b65c1bb9c721e168f95c1ed3fa88155aca7463ad src/demo.rs e69031875ffec32c9f548c43bed5f1a5c4c626e9568c9173e3b093f9a8b73036 src/diagnostics.rs -d7c1093b62dd37479dbefd1bc9f18d2fbc0ee5d4ebe155f7a7bbaccd058b1fd3 src/docker.rs -43f09add98bfd3b2333486979e163bd31f3716c7517dc99c47dc2f4e72c8951a src/forwarding.rs -eff4484fd9630009471f824c57c5c74f3d3c5378f9e46a3bdda03ede37daec82 src/geoip.rs -185a7874d2ba45e6705fb69703fb99c96eef2a8134f43343e2b20f105f1a1be3 src/headers.rs -2f6dd8c74fe1d22a8fa4ec5a21d0d334a667ede1b11629541f4802252765b21b src/http3.rs +b45d6040810c93ae2acb04ef6b0a59c189751edf9008cd81a4521c9e04e7f07d src/docker.rs +27ba9cfd11b13cbce3725e971e1f54844d219bfb62e19bf72a6c499bcbca8203 src/docker/client.rs +a1eebaf42bd039fd72015adc062a04554f18a26c5aa652008f9d4e17d5558674 src/docker/model.rs +845192f983bbd1fa82500543d2dcb0fdb32b755bbca4c409b05300c6b329c07b src/docker/reconcile.rs +fe571c85a924c827097f4be01b3a827a16bc5dd17a55fbf991ee3ad016641305 src/docker/tests.rs +d90db562b53af317e36d4a02e87fa77b1ef65fd9098f52114f242ea4969876f8 src/forwarding.rs +94bf4a4c5d222fe1d7316f76df2ecc2909e2e0248b642d85a00eb49cc72608c8 src/forwarding/tests.rs +72215ef6911893f55cc82278169268f03d869b94728f118bf007fc555d609fde src/geoip.rs +e40171e72327bc4350ba95cedacde2f3c9148c26a5a232d9f790f4449f20a685 src/geoip/tests.rs +650818b60f76e1ae1a26c6fbf71d7ba63bc85dcb88754a592e8da87b5938e5af src/headers.rs +01cb98570ec388170420883e4ba18b2bf85ad1af97854dbc3a12b135db511c5c src/headers/tests.rs +63c421fe3ae93aea00b63c599eb5eb74539a1dcaed42afa9d6275f568e23a377 src/http3.rs +68bc11160bc5275a904e48af8698b37433096fb6d592364d8281d59bb9e2b3f4 src/http3/tests.rs 3dab8fac75263120e48ee869ad55a3e2ccc2a19fc9f6d6a6fe055657ec55b1e3 src/lib.rs -b255ec47d156baae79bace1abbaaa6e9ffa4e5798996307d5a39afc40f3bf0dc src/logging.rs -b9ca7d7304205feead5a5e2f9a0f4f68a3a48af42c7768e984602af64c4166b2 src/main.rs -a4878f40efeb164687726517f0efb8703f99637f9dc8645838fa4cb7c94b13b5 src/metrics.rs -64494e5746912cfcb10612ea4e0b336004b1f738bd7999be7e58fc96abadb6e1 src/not_found.rs -e4b1f61c12437cc840c36eb7ab7b69b03f310bca3f89288ade9992d9dd1b7e2c src/pages.rs -f355d84c16cef89c43785fdc02181d6ad234fbc9c834367843822ff970088d00 src/prometheus.rs -f2dff3a70141ae744d33c5fa22234a4fa727cc93841caa2afb86dbce66a52b12 src/proxy/body.rs -69edf65d8dea5b6e942e682c96a67f072bd2196c9c4901a68f8ab8d8d4464598 src/proxy/headers.rs -aad218caa7496cdcbb98a07bed1daf9c4859919c295e1fc29fbdd43f186adea7 src/proxy/mod.rs -64665d778a90a22e93af7a17be037255d59fff3220d8924737da9be965acae69 src/proxy/websocket.rs -8ffe552be6d736cdb28ef6db025b6d2e1020faefaf6f5a85eb919bd0fb1f55b0 src/rate_limit.rs -acdca1bf861f95b85f08dbbcf69e8a2090335cab4d542f2509383bcc67a0e699 src/redirect.rs -6580f6806ae84ea2f921c001a792977b3df2f8a261b205d1474d0c6d7214d9fc src/runtime.rs -2f439d96a43536ac23ed7727bf96a616b26c2ff30d3813c44398755d8525fd97 src/secret_store.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 -69f0ed0dd1168b443e6c69830c242ec167ab3c51bdcff120f21dd5ca18cd3478 src/traffic.rs +6946917d57fb589bdba639fef2cec1d256fac0c8bfde92adffef2b31c3b3e08c src/logging.rs +452d30961b7640979c61146aef0fd1f218ad080fceb2dfcd83f31267c33603a8 src/logging/format.rs +b1f2fa0df9ffb7e621d0009c30a188402f0741701f68b77977ef1f40421ab659 src/logging/model.rs +13194c28f0e38e00727c70552403e3f45d4aa819ed0aba168c60d098fc03f232 src/logging/runtime.rs +d55dddba3f35a4f51a9b224af3b776ce7d25c8e57ae061e130e451aa5769edea src/logging/tests.rs +3836ed14b6710e183f048ee4d7807a35366c27a27338057fe4a2811129b63302 src/main.rs +79e1d39d7be966c22faad99146e384407427f2b1cec917b73786c5808ecc12f1 src/main/admin_runtime.rs +91cc63d6a3404361b2aadaff2899bfe7dba6871bfac28599ab3d5c5266a2718a src/main/bootstrap.rs +192623ac6d9ef376648d3dc372e7dedc0a37afbaa3dacf737e2e372228de09e5 src/main/proxy_runtime.rs +f58e3b75ed39e85fb4f943b970ea5bb271e709bce0979fd96a25e416387a81d9 src/main/shutdown.rs +a8783a34c01a288822c71b2a6807cc8d7645e22af5766053fed5c429d4536407 src/main/shutdown/tests.rs +6392ac75b2c4e3adef89cd071f5de24be7ea6f69e9209c49e714cce530ec97d2 src/main/tasks.rs +49d7e104585c8791ac9a203dddd95802784b68c0ba65d6f90f3d57154b4c7871 src/metrics.rs +201ab702722d6d21585fe8bb49939150d61bfac7ed8dc198b98c2cc6098ffb5f src/metrics/global.rs +b1ed4042f2d6c1029523c3977b85e2ab6020e2da7d5079f98051cef5e96ca390 src/metrics/vhost.rs +a54b5b34ea65f29723b89b81bf7968a238e8247b9c9760f3430d3504cb1017c9 src/metrics/vhost/tests.rs +a4702c95c7e5077c2ccff86e2e3216e1da6ff9ee4383ae199a0aff2f78db325f src/not_found.rs +5b3c5f28245df529f3099f16fb4fd93c00e760b1202d4b692c1d1d79601f6144 src/not_found/tests.rs +6aab4f97d72ec6352c940bc89e911b8e50c580821da8202744e9832621458784 src/pages.rs +73a0770726eb90f89fe1a65e0e146c39209120e70d5817fe2e8e63f3c3521c40 src/pages/tests.rs +4c78f7b66e2305bf9ac379270b65ed412fee40da2c30e62097bca21708567d58 src/prometheus.rs +c055093b450c8f4ab81db2338d87a88024f151fdb79b1caa2c00045f460fd279 src/prometheus/core.rs +b40d1404dae711f9d79a777ce3e4459ecebc3601d16067bdb2a017412e703715 src/prometheus/docker_misc.rs +33e409fae3f8e9574ba2776b0d2eb6c75b26f45f48806e307d78b6fa355d8c86 src/prometheus/model.rs +53bb68f43cf66916dcb4f5ffb47c89a63275c589b6a23691bd9ccaf4fdc56332 src/prometheus/tests.rs +b3ea198d87f95942a2991c122b4c90e1f0244dd1f592fa276f0df8a43e43ca43 src/prometheus/vhosts_backends.rs +4467766076a23612938cb23cc1bb0cca08aaf4df35898156273499801135049a src/proxy/body.rs +0b043c50d9da00a0cfae795ecdbab030425c2d9ed776488305db9daf73c11dec src/proxy/body/tests.rs +e84b3b51e99a4281d70f426f6d8345ec3a244e6e1758a868a23cfbb08acaf62d src/proxy/cache_helpers.rs +cfb0910af8cd868bde3f6c9992319e46d5328d6d8f69c50701df0ea12bef2108 src/proxy/forward/cache_lookup.rs +212de22f407dd96800afcf65fd1fc7c40fede1b88f80d04850eb8d1227b08a5b src/proxy/forward/response.rs +581792030807d1db28091b232eabc3b86b9f19b890afa7d6295c051ef89f14a7 src/proxy/forward/route_cache.rs +51c620b77004cca26edf387f9650f07d27801e093d32cc0162eaee1d0702c126 src/proxy/forward/success_finish.rs +5b639e63bc0110c93ab166bd22ac5bea5d8fa260e62c41d4bdb25d5ffff129d4 src/proxy/forward/success_prepare.rs +23b6bd26f6175b52c13d2571318fddd12550ba34ee77b87b5ac0e4811f8e2bee src/proxy/forward/upstream.rs +abdc5681e2f1b9cd59fae410aaf858903a723dbad081b27159313ec2d62ddbcf src/proxy/forward_entry.rs +10d740d2e41c2b1349553f8f6e8d624fda8085f665fe6b4a84d913cd465c5825 src/proxy/headers.rs +5cb7522ceb9a9f083a15596ac4898559295e38d1138c80099577211de8a868ec src/proxy/headers/tests.rs +f6aef54204b5bb9305dd1367818c7ffd3cb273008404c9fab0317af1d13ba7a6 src/proxy/mod.rs +ac678d8a2c565fc5662d97acf60e3c59850446861103d2fe04719f96295933a8 src/proxy/tests/authority_tests.rs +b8214b1c847d80c4d42390e913832e2a393dd35e0c8f0657d6eb8aea3254c155 src/proxy/types.rs +1dc295db8e0c395bc8483ec5b3679b585bb59cb82942bf52b647b9853a7c5e4c src/proxy/upstream_helpers.rs +69f775f6746b4c6d729dc10961dbef54168846f4ce9cf7fe453ab5a2b879c9b8 src/proxy/websocket.rs +5980ffd2f48248abab72ec96ce22da80a023ccd5e14f6bc21e3c85125876f8b0 src/proxy/websocket/handshake.rs +11b1d7291c75c5b41aeb551ae87fb4878f93ba68c87178a9aeff88fd84fe2b6a src/proxy/websocket/tests.rs +4aa3d2aae84e7fe6985a5a3adde43ef3a7135e0662c88b11545d4ffb962b738d src/proxy/websocket/tunnel.rs +6ae4152d5918b629cfd647ac16f6d33f976d2915e09364c91f66cda746eedcde src/proxy/websocket/validation.rs +26f57e7e21be5697b57dbeceae8f148c50ac461973adcd8c07a0fd8a8c0d8c33 src/rate_limit.rs +3ac0b5c6407b2cf13e28b570ab94aab3ade62faf93e40a06269a058019fde165 src/rate_limit/tests.rs +d40ad63939097d2b91339127d5835a74b618d83b7ba31db0c512f05a82107d6e src/redirect.rs +811c83ae9fd0f5a13d35b5443cd37843f82b9d600bee4bb136ba9b87732a5347 src/redirect/tests.rs +d0939e1b0829c25356a0b67ecb3a178c74936dd07baa15fee24eb32544db6cad src/runtime.rs +5e7098a94e18b428e3514acee60d6d5c47b0d7bf01a3a5765184cbe4cdd7fbc1 src/runtime/certificates.rs +117b68f9ae6322eb2037651a2b47de50c5172dbdd12507a07cf7ee4699697b44 src/runtime/constructors.rs +31dc5cdea00efd63594dc6becd5a9103594696145cbc8e7b7594fb407214ad9d src/runtime/docker.rs +c052b35673d905219a71130b44a5dc0027bf9b853bff0b6bdbed4be4064550ea src/runtime/tests.rs +03144b1b00fb45c5616ca9c9b0e57013e86c1f07f129830aaec98a73b274199f src/runtime/types.rs +bed516efee36731a2232614b50cb0b87decdd0eb5fa9ba4f68d14c8570c89470 src/runtime/updates.rs +b4c17b304166592e63c81884076589fbaf725c3f57043f8924c46167b061ac5b src/secret_store.rs +4a5ab7602324408a436ababc32011c9c8d958987505a05040d9633243ed88ae1 src/secret_store/tests.rs +6fa1d8c352bce7a16ff0208bc4cdbe80404715df0aff4ed26910a5df57cbbcd4 src/security.rs +0e5fc2aff856bf6fd959cba12b5ddd1e3b3cbeb0fbaadde906c811cdfafdaae7 src/security/tests.rs +53a4bed4d1233aa434712b946857ea2050acaaf5de011c6dadca5b5c22155a83 src/server.rs +2a48d22cdba034f1ba015b657ba7966458b89e411796f286997b728c7101a2d6 src/server/http.rs +cdb65a73886c360f453ca8f2ba473baa76e9bad92a36b4b3d350cd5db053577d src/server/listeners.rs +8838a30cc1b070c7d7deec408b5457e7d0c561f2c583be20bc49d41e7735b316 src/server/serve.rs +a51651d9c97965f9c34d127cdd81c14a42532dc26e794165456e80190e29c17b src/server/serve/tests.rs +0ac35c8432c9e3a6d31374e7cd96731b685d9a5e01beb751bdbfff2eb144288b src/server/tls_runtime.rs +8ec31f92cf11a3b676bcf2b6cc251a5b509ff34088ae6efef513fffdc84a3be0 src/settings.rs +b8b3150abe1c6f61d3f593e169795aae397b578607710ea6fd887b90f37b872e src/settings/tests.rs +566daf9c0e1a30da2dc18e51090541a85ced3320a0fc00ed1d1de3e2b4da5072 src/stream.rs +46d9f63ce13a634e00f38d36ca3bdb217f2e37f04d75a4ef118e7dac9896a489 src/stream/model.rs +9c59bdb856106c0a74d1f8c156b0dd7289a170322ea556bad19bb8de3bf13d69 src/stream/runtime.rs +71c8850c9f80ec2c2eaa1ab3559670ba6b10be4fa5ad088e94047238d2d0c67b src/stream/tests.rs +4767200ef61fd6826471ca7a19b84c43fb1d5dc992dcebeb08f49cdbabaf61f9 src/system_stats/linux.rs +9c29121f17057cbcb3f3ea0a24fe90ad5e6cb80242830290ea55dc66265d8796 src/system_stats/linux/tests.rs +182bb00acba5535521d2e056a9bb946e76f672154b92b3649f9fc530c43c363a src/system_stats/mod.rs +f5f4d755453ab32196083ab968a42c1210fa9543bc63b29ce7c4653506ecd51a src/system_stats/tests.rs +7467d83066bb6986d8c9103597f7a7617ac677579ed46d573c573ff6ca57bbd9 src/tinyauth.rs +0e1d87554db55e724f30c41ec4a5c80bdb8fc9c8cba8700ac509864d4abf8871 src/tinyauth/tests.rs +7d94e9ea622abb2ffee9f2bba43b3d41e603d3e907a42f0fe2a83b0c78faa7b1 src/tls.rs +e62b55a057ab39daf540e225342e98a57a6266f1603d685c48468f2f7c37c4b8 src/tls/certificates.rs +f5af75a2f6b360576393f2fd628144e54c28dbd28cbbc0f9ba59643d12c21c1f src/tls/server_config.rs +4b546ac47c142286e27c729f24df23810e38fc33d02c3b90837663041e55630c src/tls/tests.rs +e0ab65406911d4b95cb26594df65779a88d9011d5b11adb22c1a28831cda3826 src/tls/tests/acme.rs +9196ec11be2f0107bc86c71e302a10868801709c979d07aa9a1119296803c932 src/tls/types.rs +c1645d0bfc620dd4aa20047fb59ac998357f36741619f1c4e1aa37d3c8be7318 src/traffic.rs +f9ffe22f38c31e4ae4516eb517a0a608a49c0fa21637fac173761e19a9403df8 src/traffic/tests.rs +725eefaab8bd8dcc5a8c92248c72b20860ac49ec3890b618c7cb19c3e4da55e5 src/transform/apply.rs +58178c9fcbe2d6f829c1cbba53a72a7afc0920b4b563b6e0c052f0caa7d27784 src/transform/compile.rs 7005259b77047a8afd8e32f7c1eccac5683cfab9beb2cbf8ae6a4923fc321b6c src/transform/css.rs -1b82a3ebf89e4ba198ed202bc026862568a41579de44aa8444990594fe3b98c0 src/transform/html.rs -388f602176f152ef29a7c3ecd3ab328d6534b6d1eaae6e7c35a959b432232413 src/transform/js.rs -eca00dadae42aecfdcd918ec62b8603deb27caa20e1778befe211b57afa35001 src/transform/json.rs -bef367ca40eb3c25e39b6f51f82745a50e8112496ae7303939722e30698908db src/transform/mod.rs -c0c150a8799942c53500e48b6c9d3068210d6e6ef5693514ee9602127f6e5b0c src/transform/svg.rs -7c32623d984adcf7e88ed29dd7d31ffea7f40aeac716eadd2d9da1d45456c119 src/update_check.rs -77cabbcd9730e01d19e1147ede94272f6100d97281f7892d2b1bc0279c780f63 src/upstream.rs -c9ad1e399f339f7abf4d35affe79591f077c14443e3af8366e03acf0cb377b1f src/vhost.rs -d212ac0119a1989b40a549348b834d226884795b93ec4ea92c833331e009650b src/web_server.rs -6935d7f678ccfc6f1c92a05b25c3a38d2be13d4958d94d44aa72068bf87d9dd0 src/web_server/htaccess.rs +f427ac35285fc486d1c7bef75d7b40c293a969039172961009ceecf540d9f96f src/transform/html.rs +aa99b0d3029ef92c78371cb90e11f2026cce890d1be8e10ebd7fa9bc5cb13287 src/transform/html/tests.rs +a1d2fec07617c8a8dc425bfaeaf77c844152a0a174568a88a5baf4c317cc6c5c src/transform/js.rs +cf5195ab6bacccae0082da7b3f1bd0382b73fdeb3607f4ae569ca6daab9d10d0 src/transform/js/tests.rs +226ae561dc7b0243c04e55e28cdfd2b76acd48cf614d1919b81c196660b03cfb src/transform/json.rs +216e24e29ea87be4a124f95f898e155f127fd959d7d5d9b30f8cd0b2b5018f30 src/transform/json/tests.rs +6a8c28be93fb590942493c075b29ace1527981542bd183f232aa82b62e951eca src/transform/mod.rs +d5cf1c81b9d248376124a09b80e9b67163a6373a1eb6c4142a6a144f4f86eef7 src/transform/model.rs +a4e1d2aa2913587c250f251356431097b76995aee6e8bf9e35c4978a95491de1 src/transform/svg.rs +0f53c64f442390ee20a59087a10f8f9ffb58053263e9aa848ddabfae1a6f3f51 src/transform/svg/tests.rs +155d2f2292816061a74eb303469b2f820b3b5f56cc648010e269f9fbe64671dc src/transform/tests.rs +639ed68b2c62a9c8ef23beac480ed4ecb0a7d31672177c9e0bde6c223137fac1 src/update_check.rs +b5e505a2c8e38ca5915dd3ea4f0b33a5bf9cc4ef3e0ec20054748bb1820502c3 src/update_check/tests.rs +e4b85f407103f941cc535116b61527be20e5489af0dfce5371fdfc17bb627844 src/upstream.rs +84bb415eac5d6d7965763681339f2608d2c72aa12c7b7f3ae530ff94740a9223 src/upstream/tests.rs +e4c1ff8f53112e7cfad101a266355f7e47b82895480db431662f4cc59a1d9702 src/vhost.rs +bb64d0335559968b00a28b9b0088fc4738dfd0c30cc7a6a5314613a06735c23a src/vhost/model.rs +fae5a47efda9a4ae4dc94711ecc5857e10b3c20aa2bf2c742c59651a85b9d978 src/vhost/tests.rs +744f329e868a31a00c0e03c51a58397bf9e57aaeb5b7dac75c32e7eaccc9d7f0 src/vhost/validation.rs +8d1eff4b24f01d137414ef5419f75dd281879228adb70409645b11b3c78c20e9 src/web_server/config.rs +34417031a2544bcc88ddee81a949da757787f0f1151a85ac9739da93fb9d95e5 src/web_server/config/compile.rs +9c1c8d6eedecb95eadbfb23c3caea562f6f7a995606ef988f25b58c2d03bcdd5 src/web_server/config/model.rs +6191f4d541476c4a3ae296e7dd28bdb9f8a123fc509b47619f6f44519c44d278 src/web_server/config/validation.rs +86c744216d7132d8294a6e90ba9be5fd871646006313d1c62506d40c81db9b12 src/web_server/fastcgi.rs +d06e753a2ac7c3867e4a9311716d5ee071bcc7ad05ba81d3613dbb7e2f4f2c92 src/web_server/fastcgi/protocol.rs +134c4998f6a0aa27e121af1ae3355b7e335488b2ae867ac0a934c58b4eb77191 src/web_server/fastcgi/response.rs +8186363315bd998919c6c783e6f7501528f257a5c0da77c5d60f423e8670db5e src/web_server/filesystem.rs +0aafd7f37c4fe784b047646e4ce5ddcb5fe821231a41ed0a9f519529385288a8 src/web_server/htaccess.rs +013830a79a78ce803ce776a9b333de406367751e0cea7e33c3f6a98e6600c243 src/web_server/htaccess/model.rs +76de1d11df75f4a64556b2821fd0a1774d26c9f0ca0e5f2b5fcfad0722d43d47 src/web_server/htaccess/parse.rs +66260d159986d41eab0dfde2cb9f0713fc1cc209c2fb47e9023bd24fd37919e8 src/web_server/htaccess/rewrite.rs +80f946570c14c8915fd21bfdb1a84dfb6a14c633ab6060acb828fab4c7c21daa src/web_server/mod.rs +67ab8bef567452f123c4b9b46a942b39c93f1f765c128edca7ee808ff1c5e304 src/web_server/request.rs +c5cb561b0f276e8082f904b9446fa5885f6b3c0608f25ea2ef425061e9f7f6b4 src/web_server/response.rs +ce97afa23fcb6ca1fff7327dc8286e293bdecfbf704a98057a7bef8b1a189f0a src/web_server/response/file.rs +9aa68251a5f549f4ce3b1bda88d4abb9efc629caf444989381b4117c9488e37b src/web_server/response/headers.rs +2268a6aeed2f565f9791af33e1cd21c56c228c8873b031725b39a6c57184f2d1 src/web_server/routing.rs +400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs +3e0b4e92600a7d5abcec92efed532b3a01e0781cb4f2681daac6d6ae13b0a6e9 src/web_server/serve.rs +cf86c4692080723bbdf8f4105e1abfae33d7fcf020d2ad9e13ebae3943bd0cbe src/web_server/tests.rs +c62752fe2fa6b74ddc9d94d36f8d191376b1575b15d21fc434c5a5da4c932692 src/web_server/tests/part_01.rs +c5ad6d26743fbbd934fe6fa146e92b699a945fb57e28a8666eb017f2c319f247 src/web_server/tests/part_02.rs +e54f44f6ca1ed2c41e620de9195d32c4f8bb6936c3881745316a07160f87b4fa src/web_server/tests/part_03.rs f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json 1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs -fd8655c72f35f83586af20a9f04468528cf2cfea96a6329cb582972ed8a6548f tests/http3.rs +347a4ff9e028a1e999ad129590c355b5734cf928dd81e5099aea0c8aca8d5908 tests/http3.rs eecf680f001f2110dde4eceaccf2f922859a16c946a644f8854e8de4d3771f7c tests/policies.rs 728999110a6d8cacb62bc0399bc333fa23a66526ee23f563a3c93bf00e5fad19 tests/transport_config.rs 77ee069a5c0f69c49d2688eff3c9aa0f3e39fa02042cfe348e410a23b8f3e116 update.sh diff --git a/debian/README.md b/debian/README.md index 64523bb..dc7c1f0 100644 --- a/debian/README.md +++ b/debian/README.md @@ -1,6 +1,6 @@ # Debian packaging -This directory builds the `rustproxy` binary package for RustProxy 0.25.0. +This directory builds the `rustproxy` binary package for RustProxy 0.25.1. The package installs: - `/usr/bin/rust-proxy` diff --git a/debian/build-package.sh b/debian/build-package.sh index c1d4eed..1db3f63 100755 --- a/debian/build-package.sh +++ b/debian/build-package.sh @@ -21,6 +21,6 @@ cargo fetch --locked dpkg-buildpackage -us -uc -b mkdir -p dist -find .. -maxdepth 1 -type f \( -name 'rustproxy_0.25.0_*.deb' -o -name 'rustproxy_0.25.0_*.changes' -o -name 'rustproxy_0.25.0_*.buildinfo' \) \ +find .. -maxdepth 1 -type f \( -name 'rustproxy_0.25.1_*.deb' -o -name 'rustproxy_0.25.1_*.changes' -o -name 'rustproxy_0.25.1_*.buildinfo' \) \ -exec cp -f {} dist/ \; echo "Packages copied to $ROOT/dist" diff --git a/debian/changelog b/debian/changelog index b1cf74d..9e18dba 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,8 +1,7 @@ -rustproxy (0.25.0) stable; urgency=medium +rustproxy (0.25.1) stable; urgency=medium - * Add reusable Web Services and vhost/location Web Server source selection. - * Add secure streaming static files, validators, ranges, indexes and optional directory listings. - * Reuse the existing vhost access, response-policy and transport pipeline for local files. + * Reorganize Traffic into compact Overview, Clients, Requests and Delivery tabs with donut and bar charts. + * Harden source/release modularity checks while preserving existing runtime configuration compatibility. -- RustProxy Release Sat, 03 Oct 2026 22:43:00 +0200 diff --git a/dev.sh b/dev.sh index 1b697e8..d919fef 100755 --- a/dev.sh +++ b/dev.sh @@ -18,7 +18,7 @@ if [[ $# -gt 0 ]]; then shift; fi case "$mode" in help|-h|--help) cat <<'HELP' -RustProxy 0.25.0 +RustProxy 0.25.1 ./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 @@ -88,6 +88,8 @@ 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/rust_module_contracts.py + python3 scripts/source_module_contracts.py python3 scripts/check_source.py python3 scripts/release_smoke.py python3 scripts/memory_contracts.py @@ -133,7 +135,11 @@ case "$mode" in check) exec cargo check --locked --all-targets "$@" ;; release) exec cargo build --locked --release "$@" ;; esac -printf 'Building RustProxy 0.25.0...\n' >&2 +if [[ "$mode" == "test" ]]; then + printf 'Checking all Rust targets before tests...\n' >&2 + cargo check --locked --all-targets +fi +printf 'Building RustProxy 0.25.1...\n' >&2 cargo build --locked BIN="$CARGO_TARGET_DIR/debug/rust-proxy" case "$mode" in diff --git a/docker-agent/certificate.go b/docker-agent/certificate.go new file mode 100644 index 0000000..f919127 --- /dev/null +++ b/docker-agent/certificate.go @@ -0,0 +1,136 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "time" +) + +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 +} diff --git a/docker-agent/docker.go b/docker-agent/docker.go new file mode 100644 index 0000000..66b965b --- /dev/null +++ b/docker-agent/docker.go @@ -0,0 +1,167 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "sort" + "strconv" + "strings" + "time" +) + +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 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 +} diff --git a/docker-agent/main.go b/docker-agent/main.go index 9dbfe78..975f958 100644 --- a/docker-agent/main.go +++ b/docker-agent/main.go @@ -2,602 +2,20 @@ 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.25.0" - -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 diff --git a/docker-agent/model.go b/docker-agent/model.go new file mode 100644 index 0000000..dc74a61 --- /dev/null +++ b/docker-agent/model.go @@ -0,0 +1,65 @@ +package main + +const version = "0.25.1" + +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"` +} diff --git a/docker-agent/network.go b/docker-agent/network.go new file mode 100644 index 0000000..6b51927 --- /dev/null +++ b/docker-agent/network.go @@ -0,0 +1,192 @@ +package main + +import ( + "context" + "io" + "net" + "net/http" + "sort" + "strings" + "sync" + "time" +) + +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 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" + } +} diff --git a/docker-agent/server.go b/docker-agent/server.go new file mode 100644 index 0000000..c8ebcd1 --- /dev/null +++ b/docker-agent/server.go @@ -0,0 +1,63 @@ +package main + +import ( + "crypto/subtle" + "encoding/json" + "io" + "net/http" + "strings" + "time" +) + +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 +} diff --git a/docs/API_ACCESS.md b/docs/API_ACCESS.md index 200d0ac..ca67e05 100644 --- a/docs/API_ACCESS.md +++ b/docs/API_ACCESS.md @@ -1,4 +1,4 @@ -# Administration API access - 0.25.0 +# Administration API access - 0.25.1 ## Sign-in boundary diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e041521..f8de221 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -39,6 +39,22 @@ committed document at startup; this is not one cross-memory/SQLite transaction. | `runtime` | Shared process state, snapshot updates, admission limits and tunnel tracking | | `demo` | A separate opt-in demonstration backend with its own listener | +### Rust source organization + +Rust modules are kept as small responsibility-oriented files. Every checked-in `.rs` file +is limited to **500 lines**; `scripts/rust_module_contracts.py` enforces the limit across +the repository. Large logical modules use a thin facade (`config.rs`, `docker.rs`, +`server.rs`, `runtime.rs`, `balance.rs`, `prometheus.rs`, and similar) plus files in a +matching directory. `web_server` uses the standard `web_server/mod.rs` facade with +submodules for configuration, filesystem confinement, responses, routing, FastCGI and +`.htaccess`. + +Unit-test implementations are not embedded in production source files. They live in +`tests.rs` or `tests/` files and are included or declared only behind `#[cfg(test)]`. +Source-level CI also checks `include!` targets, balanced split fragments and dangling +attributes/doc comments. This prevents a file boundary from separating `#[derive]`, +`#[test]` or documentation from the Rust item it belongs to. + 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 diff --git a/docs/CHANGELOG_TECHNICAL_ARCHIVE.md b/docs/CHANGELOG_TECHNICAL_ARCHIVE.md index e53cb45..c44ec7d 100644 --- a/docs/CHANGELOG_TECHNICAL_ARCHIVE.md +++ b/docs/CHANGELOG_TECHNICAL_ARCHIVE.md @@ -7,6 +7,23 @@ # Changelog +## 0.25.1 + +### 0.25.1 source-split compiler follow-up + +- Corrected Rust `include!` split regressions reported by a real `cargo` build: dangling method documentation in runtime fragments, an invalid inner module doc in `balance/model.rs`, and the missing sibling `htaccess` import in Web Server response headers. +- Removed the warning set reported by that build (stale imports, unnecessary `mut` bindings, an unnecessary returned timeout value, and unused Prometheus parameters). +- Strengthened `rust_module_contracts.py` so doc comments/attributes immediately before a closing brace and inner `//!` comments in included fragments fail preflight instead of producing a false PASS. + +### Source modularization and release guards + +- Enforce a 500-line ceiling for Rust implementation/test source files and bounded dashboard JavaScript, Docker Agent Go and operational deployment/migration modules. +- Keep Rust tests in dedicated `tests.rs` / `tests/` sources and Docker Agent tests in `_test.go`; split `config`, `docker`, Web Server, dashboard bundles, Docker Agent, deployment and NPM migration source by responsibility. +- Preserve public browser asset URLs by assembling the bounded app/vhost source fragments once inside the admin router. +- Add compiler-independent regressions for dangling Rust attributes, broken `include!` targets, missing Web Server `htaccess`/`server_protocol`/`anyhow::Context` wiring and browser split-boundary syntax. +- The package workspace still has no Rust toolchain, so `cargo fmt/check/test --locked --all-targets` remains a release-host gate. + + ## 0.25.0 - Add reusable Web Services with document root, index-file list and optional directory listing, managed from the new **Web Server** dashboard section. @@ -16,6 +33,8 @@ - Reuse the existing vhost ACL/authentication/header/error/TLS/HTTP pipeline for local files without adding a separate router, error-page system or object cache. - Add an authenticated **Docs Reader** for bundled project documentation alongside the existing API/OpenAPI view, and separate the current documentation index, release audit and open TODO material. - Fix the 0.25 Docs Reader source embedding so compilation does not require a generated `OUT_DIR/docs-content.json`; Markdown sources are now direct compiler inputs and the JSON bundle is serialized from embedded strings. +- Split the 0.25 Web Server implementation from the former monolithic `src/web_server.rs` into the responsibility-based `src/web_server/` module (`config`, filesystem, security, response, routing, FastCGI, dispatcher, `.htaccess` and tests), keeping `mod.rs` as the stable public facade and updating source/package gates for the new layout. +- Extend the source-layout cleanup across the Rust tree: every checked-in `.rs` file is capped at 500 lines, large `config`/`docker`/`server`/`runtime`/`balance`/`prometheus`/`proxy`/cache/admin/etc. modules are split by responsibility, unit-test bodies live in separate test files, and compiler-independent contracts reject dangling attributes/doc comments, broken `include!` targets and unbalanced split fragments. ## 0.24.10 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 85124a7..f30d9d8 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -1,4 +1,4 @@ -# Installing and updating RustProxy 0.25.0 +# Installing and updating RustProxy 0.25.1 The scripts prepare a Linux/systemd installation. This source archive is **not a verified production build**: Rust/Cargo, real CA operations, live systemd startup diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index ee612e2..89dbf51 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -1,5 +1,11 @@ # Deployment and configuration migration history +## 0.25.0 -> 0.25.1 + +There is no SQLite schema or saved-configuration migration. 0.25.1 reorganizes the Traffic dashboard and source tree only; existing Web Services, vhosts, traffic settings, cache state and administrator data remain compatible. Browser asset URLs remain `/assets/app.js` and `/assets/vhosts.js` even though their source is now split into bounded modules. + +When updating a source checkout by extracting the archive over an older tree, remove the obsolete source monoliths `src/admin/web/app.js` and `src/admin/web/vhosts.js` if they remain. Checkouts older than the Web Server module split may also retain obsolete `src/web_server.rs`; remove it as well. Do **not** remove `src/config.rs`, `src/docker.rs`, `src/runtime.rs`, `src/balance.rs`, `scripts/deploy.py`, `scripts/migrate_npm.py` or `docker-agent/main.go`: these are intentional module/CLI entry points in 0.25.1. Build/cache artifacts such as `target/` and `__pycache__/` may be deleted safely, but `.env`, `data/`, databases and local certificates are runtime/operator state and must not be deleted as part of a source refresh. + ## 0.24.10 -> 0.25.0 There is no SQLite schema migration for the first Web Server stage. `web_services` is an diff --git a/docs/README.md b/docs/README.md index 6f1fba1..a8b5334 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,8 @@ This is the canonical index for the current project documentation. The authentic - [ROADMAP](../ROADMAP.md) - planned and completed release scope. - [CHANGELOG](../CHANGELOG.md) - user/operator-visible changes by release. - [TODO](TODO.md) - open work only; completed work is not retained as TODO. -- [0.25.0 specification audit](V0.25.0_AUDIT.md) - point-by-point implementation status and remaining release-host gates. +- [0.25.1 release audit](V0.25.1_AUDIT.md) - Traffic/module-split release scope, compiler-regression guards and remaining release-host gate. +- [0.25.0 specification audit](V0.25.0_AUDIT.md) - Web Server foundation point-by-point implementation status. - [0.25.0 pre-production security audit](V0.25.0_SECURITY_PREPROD_AUDIT.md) - threat-oriented source/configuration review, fixes made in-session and mandatory runtime security gates. - [TEST_REPORT](TEST_REPORT.md) - verification commands, results and environment limitations. @@ -55,7 +56,7 @@ This is the canonical index for the current project documentation. The authentic ## Historical or version-labelled context -Version-labelled `V*.md` files are retained as historical implementation/audit records. Their version number describes the audited milestone; they are not current-release documentation. The current audit is [V0.25.0_AUDIT.md](V0.25.0_AUDIT.md). +Version-labelled `V*.md` files are retained as historical implementation/audit records. Their version number describes the audited milestone; they are not current-release documentation. The current audit is [V0.25.1_AUDIT.md](V0.25.1_AUDIT.md); [V0.25.0_AUDIT.md](V0.25.0_AUDIT.md) remains the Web Server foundation audit. Older context documents such as [INTEGRATIONS_040](INTEGRATIONS_040.md), [MANAGEMENT](MANAGEMENT.md), [SECURITY_AUDIT](SECURITY_AUDIT.md), [ACME_RFC2136](ACME_RFC2136.md) and older version audits remain available for traceability but should not override the current functional/technical documents above. diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index ecf0f52..bf16bd6 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -1,8 +1,8 @@ -# Verification report - RustProxy 0.25.0 +# Verification report - RustProxy 0.25.1 ## Scope -RustProxy 0.25.0 adds a reusable Web Server source while preserving the existing reverse-proxy pipeline. Web Services are independent configuration resources; vhosts and custom locations only choose a source type and reference a Web Service ID. The current development tree provides static GET/HEAD streaming, directory/index behavior, validators, single ranges, document-root confinement, ordered `try_files`, Web Service rewrites, a bounded migration-oriented `.htaccess` compatibility layer, expanded MIME policy, per-extension/MIME Cache-Control rules, fixed source headers, precompressed sidecars, forced-download extensions and optional bounded FastCGI/PHP-FPM. The Web Service editor is organized into General, Routing, .htaccess, MIME, Cache & headers, Delivery, PHP-FPM and Security tabs, with one-shot presets for Static/SPA/PHP/WordPress/Drupal/Laravel/Symfony/Joomla/PrestaShop/Downloads. Existing 0.25 work also includes repeatable directory listing, Web Server vhost-wide metrics, Logging sub-tabs, corrected structured-log source validation, bounded per-vhost Traffic analytics with effective-client-IP rankings/donut charts, User-Agent/optional-country logging, stable Web Service rendering after fast refreshes, generic/customizable 403 handling and automatic RSS relief for reclaimable allocator/HOT-cache memory. The `/docs` surface separates the Markdown Reader from API/OpenAPI and documents the implemented PHP-FPM runtime. +RustProxy 0.25.1 is a focused Traffic-UX and source-modularization release on top of the reusable Web Server foundation from 0.25.0. Web Services are independent configuration resources; vhosts and custom locations only choose a source type and reference a Web Service ID. The current development tree provides static GET/HEAD streaming, directory/index behavior, validators, single ranges, document-root confinement, ordered `try_files`, Web Service rewrites, a bounded migration-oriented `.htaccess` compatibility layer, expanded MIME policy, per-extension/MIME Cache-Control rules, fixed source headers, precompressed sidecars, forced-download extensions and optional bounded FastCGI/PHP-FPM. The Web Service editor is organized into General, Routing, .htaccess, MIME, Cache & headers, Delivery, PHP-FPM and Security tabs, with one-shot presets for Static/SPA/PHP/WordPress/Drupal/Laravel/Symfony/Joomla/PrestaShop/Downloads. Existing 0.25 work also includes repeatable directory listing, Web Server vhost-wide metrics, Logging sub-tabs, corrected structured-log source validation, bounded per-vhost Traffic analytics with effective-client-IP rankings/donut charts, User-Agent/optional-country logging, stable Web Service rendering after fast refreshes, generic/customizable 403 handling and automatic RSS relief for reclaimable allocator/HOT-cache memory. The `/docs` surface separates the Markdown Reader from API/OpenAPI and documents the implemented PHP-FPM runtime. ## Source contracts reviewed @@ -31,7 +31,7 @@ RustProxy 0.25.0 adds a reusable Web Server source while preserving the existing - Status payloads expose fixed-cardinality per-vhost metrics independently of backend pools. The Vhosts dashboard uses those counters for Web Server-only request/active/error values, EWMA latency and byte-rate traffic while retaining backend health details for reverse-proxy sources. - Logging settings are split into Outputs, JSON format and Error log sub-tabs inside one card, matching the Cache subsection layout. `route`, `cache_effective_enabled` and `transform_effective_enabled` are accepted by the same backend validator that saves custom JSON mappings, and the regression test validates then renders those fields. - Structured logging now exposes `user_agent` and `country`. User-Agent capture defaults on; country capture defaults off and GeoIP resolution is skipped unless capture or an existing country ACL/rate-limit needs it. -- The authenticated Traffic workspace uses a bounded 2048-sample non-blocking queue, at most 64 vhost buckets and 32-entry approximate top tables for countries, User-Agents, effective client IPs, paths, routes, methods, status classes and cache outcomes. It never writes per-request analytics to SQLite. Countries, User-Agents, client IPs, methods, status classes and cache outcomes also render bounded donut summaries from the same in-memory TOP state. +- The authenticated Traffic workspace uses a bounded 2048-sample non-blocking queue, at most 64 vhost buckets and 32-entry approximate top tables for countries, User-Agents, effective client IPs, paths, routes, methods, status classes and cache outcomes. It never writes per-request analytics to SQLite. The Traffic UI is split into Overview, Clients, Requests and Delivery sub-tabs. Countries/methods/status/cache use donut summaries; User-Agent, effective client IP, path, route and request/response byte volume use bounded horizontal bar charts from the same in-memory TOP state. - Sustained-traffic memory maintenance calls `malloc_trim(0)` every 60 seconds on Linux/glibc to release already-free allocator pages and trims HOT objects only when above the configured waterline and idle past the configured age. It does not periodically reload vhosts, purge active objects or reset bounded upstream keep-alive pools. - Cache compatibility currently includes vhost configuration, so a cache-affecting vhost save can purge cache during snapshot publication; replacing the compiled snapshot also retires its upstream clients after outstanding references drain. These mechanisms explain why a manual vhost save can visibly reduce RSS without implying that reloads are required for steady-state operation. - The Web Server list now uses the same vhost card/detail-cell visual structure as the Vhosts list. Reverse Proxy vhost creation reports a dedicated “at least one backend” validation error instead of the generic paths/ACL/headers JSON error. @@ -44,10 +44,20 @@ RustProxy 0.25.0 adds a reusable Web Server source while preserving the existing ## Implemented automated coverage -Rust unit tests in `src/web_server.rs` cover GET/HEAD/index/slash redirect, MIME/length, validators, valid/invalid range handling, If-Range, escaped/repeated directory listings, per-directory index-before-listing fallback, generic 403/HEAD behavior, MIME overrides/default MIME, cache rules, source headers, `try_files`, rewrite redirect/internal behavior, `.htaccess` WordPress-style front-controller routing, nested DirectoryIndex/MIME/Header/Expires behavior, strict-mode rejection and PHP-source protection, hardening probe/method blocking and fingerprint-header stripping, precompressed representations/ranges, download headers, PHP FastCGI fixture execution/failure-source-disclosure behavior, request-body 413 enforcement, custom FastCGI/PHP_VALUE validation and reserved-parameter rejection, direct traversal rejection and Unix/Linux symlink policy. `src/traffic.rs` includes bounded-table and effective-client-IP view coverage. Configuration tests cover pure static vhosts without a pool, mixed Web Server/Reverse Proxy routing, reuse of one Web Service by multiple vhosts, unknown service references and backward-compatible default source behavior. ACL tests include encoded traversal/separator/malformed-encoding cases. +Rust unit tests in `src/web_server/tests.rs` cover GET/HEAD/index/slash redirect, MIME/length, validators, valid/invalid range handling, If-Range, escaped/repeated directory listings, per-directory index-before-listing fallback, generic 403/HEAD behavior, MIME overrides/default MIME, cache rules, source headers, `try_files`, rewrite redirect/internal behavior, `.htaccess` WordPress-style front-controller routing, nested DirectoryIndex/MIME/Header/Expires behavior, strict-mode rejection and PHP-source protection, hardening probe/method blocking and fingerprint-header stripping, precompressed representations/ranges, download headers, PHP FastCGI fixture execution/failure-source-disclosure behavior, request-body 413 enforcement, custom FastCGI/PHP_VALUE validation and reserved-parameter rejection, direct traversal rejection and Unix/Linux symlink policy. `src/traffic/tests.rs` includes bounded-table and effective-client-IP view coverage. Configuration tests cover pure static vhosts without a pool, mixed Web Server/Reverse Proxy routing, reuse of one Web Service by multiple vhosts, unknown service references and backward-compatible default source behavior. ACL tests include encoded traversal/separator/malformed-encoding cases. Rust-independent release checks additionally validate UI/JSON/language/source contracts and reproducible packaging. +### Modular source-tree verification + +- `python3 scripts/rust_module_contracts.py`: PASS. Every repository `.rs` file is at most 500 lines. +- `python3 scripts/source_module_contracts.py`: PASS. Dashboard JavaScript, Docker Agent Go implementation and operational deploy/NPM-migration Python modules are at most 500 lines; Docker tests remain in `_test.go`, and browser split boundaries/public bundles parse with Node when available. +- Oversized `app.js`/`vhosts.js` source is split while the public `/assets/app.js` and `/assets/vhosts.js` URLs remain stable; the Docker Agent is split into model/Docker/network/certificate/server/entry-point files. +- Rust unit-test bodies are isolated under `tests.rs` / `tests/`; implementation files contain only guarded test includes/declarations. +- All `include!("*.rs")` targets resolve, split fragments have balanced delimiters, and no fragment ends in a dangling Rust attribute or doc comment. +- Regression guards cover the Web Server split errors previously observed around `#[derive]`, `htaccess`, `server_protocol` and `anyhow::Context`. +- This is a compiler-independent structural check; Cargo parsing/type checking remains a release-host requirement when the Rust toolchain is unavailable in the packaging environment. + ## Verification status in this workspace | Check | Status | @@ -56,7 +66,7 @@ Rust-independent release checks additionally validate UI/JSON/language/source co | `python3 scripts/docs_check.py` | PASS | | `python3 scripts/memory_contracts.py` | PASS | | `python3 scripts/web_server_contracts.py` | PASS | -| Browser JavaScript `node --check` | PASS for app, Settings, Docs Reader, Web Server, Traffic and vhost administration bundles | +| Browser JavaScript `node --check` | PASS for split app source, vhost split boundaries/public bundle, Settings, Docs Reader, Web Server and Traffic | | OpenAPI/language JSON parse | PASS | | Shell `bash -n` | PASS in source/release checks | | `go test ./...` in `docker-agent` | PASS | diff --git a/docs/V0.25.1_AUDIT.md b/docs/V0.25.1_AUDIT.md new file mode 100644 index 0000000..1a3592d --- /dev/null +++ b/docs/V0.25.1_AUDIT.md @@ -0,0 +1,56 @@ +# RustProxy 0.25.1 release audit + +RustProxy 0.25.1 is a focused Traffic-UX and source-modularization release on top of the 0.25.0 Web Server foundation. No configuration-schema migration is introduced. + +## Implemented scope + +- Traffic is split into **Overview**, **Clients**, **Requests** and **Delivery** sub-tabs so the page no longer requires a long vertical scroll. +- All bounded Traffic dimensions are visualized: donuts for methods/status/cache/countries and horizontal bars for User-Agent, effective client IP, path, route and byte volume. +- Every Rust source file is release-guarded at **500 lines maximum** and unit tests must live outside implementation files. +- Oversized dashboard JavaScript is split into bounded modules while `/assets/app.js` and `/assets/vhosts.js` remain stable browser URLs assembled by the Rust admin router. +- Docker Agent implementation is split into model, Docker, network, certificate, server and entry-point modules; tests remain in `main_test.go`. +- Deployment and NPM migration utilities are split into bounded implementation modules while keeping their existing command-line entry points and helper API used by release tests. +- The Web Server split regressions reported after 0.25.0 are guarded explicitly: dangling attributes, unresolved `htaccess`, inaccessible `server_protocol`, missing `anyhow::Context`, broken `include!` targets and test code embedded in implementation modules. + +## Compiler-error regression status + +The current source tree contains the intended corrections: + +- `src/web_server/response.rs` ends with valid `include!` items and cannot end with a dangling `#[derive(...)]` under the structural contract. +- the FastCGI logical module imports `htaccess` and `anyhow::Context`; +- `server_protocol` is exposed inside the Web Server module through `request::server_protocol` and imported by FastCGI; +- `serve.rs` does not retain the previously unused `str::FromStr` import. +- split runtime documentation comments are attached to their actual methods instead of ending an `include!` fragment immediately before `}`; +- included Rust fragments cannot start with inner `//!` module documentation; +- the split Web Server response-header fragment imports sibling `htaccess` explicitly and no longer retains the stale `ACCEPT_ENCODING`/`FromStr` imports; +- split proxy/config/Prometheus fragments no longer export unnecessary `mut` bindings or unused values from their `include!` tuples. + +`python3 scripts/rust_module_contracts.py` checks these conditions without a compiler. A real `cargo check --locked --all-targets` remains mandatory on the release host. + +## Verification gates + +Rust-independent acceptance for this package includes: + +```text +python3 scripts/rust_module_contracts.py +python3 scripts/source_module_contracts.py +python3 scripts/check_source.py +python3 scripts/release_smoke.py +python3 scripts/memory_contracts.py +python3 scripts/web_server_contracts.py +python3 scripts/security_contracts.py +python3 scripts/security_dependency_contracts.py +python3 scripts/docs_check.py +python3 scripts/langs_check.py +go test ./... # from docker-agent/ +``` + +The packaging workspace has no Rust/Cargo toolchain. Before production/release acceptance, run: + +```bash +cargo fmt --all -- --check +cargo check --locked --all-targets +cargo test --locked --all-targets +``` + +No claim of compiler success is made until those commands pass on a Rust-enabled host. diff --git a/docs/WEB_SERVER.md b/docs/WEB_SERVER.md index d406541..6e8211f 100644 --- a/docs/WEB_SERVER.md +++ b/docs/WEB_SERVER.md @@ -16,6 +16,10 @@ listener -> vhost -> location -> shared ACL/auth/policy A Web Service can be reused by several vhosts and one vhost can mix Web Server and Reverse Proxy locations. Web Services live in the normal revisioned RustProxy configuration (`web_services`); there is no separate Web Server configuration database. +### Source-module layout + +The Web Server implementation is split under `src/web_server/` by responsibility. `mod.rs` is only the public facade; `config.rs` owns persisted/compiled service configuration, `filesystem.rs` owns document-root confinement and directory reads, `security.rs` owns Web Service hardening, `response.rs` owns static HTTP response/validator/range/listing behavior, `routing.rs` owns rewrites/`try_files`/precompressed selection, `fastcgi.rs` owns PHP-FPM/FastCGI, `serve.rs` owns request dispatch, and `htaccess.rs` owns the compatibility layer. Regression tests live in `tests.rs`. This keeps filesystem/security and FastCGI changes reviewable without growing another single Web Server source file. + ## Dashboard configuration The Web Service editor is split into stable configuration tabs so later features do not create one long form: diff --git a/grafana/rustproxy-overview.json b/grafana/rustproxy-overview.json index f61b65d..6cd555d 100644 --- a/grafana/rustproxy-overview.json +++ b/grafana/rustproxy-overview.json @@ -3672,7 +3672,7 @@ }, "timepicker": {}, "timezone": "browser", - "title": "RustProxy 0.25.0 - Operations", + "title": "RustProxy 0.25.1 - Operations", "uid": "rustproxy-0170", "version": 2, "weekStart": "" diff --git a/langs/en_US.json b/langs/en_US.json index 3e24c6a..b6de173 100644 --- a/langs/en_US.json +++ b/langs/en_US.json @@ -1908,6 +1908,11 @@ "webServiceHardeningTitle": "Hardening", "webServiceHardeningAttacks": "Block common attack probes", "webServiceHardeningHeaders": "Strip unnecessary fingerprint headers", - "webServiceHardeningSecurityHelp": "Hardening switches are shown beside the preset selector on General. They remain independent settings after a preset is applied." + "webServiceHardeningSecurityHelp": "Hardening switches are shown beside the preset selector on General. They remain independent settings after a preset is applied.", + "trafficTabOverview": "Overview", + "trafficTabClients": "Clients", + "trafficTabRequests": "Requests", + "trafficTabDelivery": "Delivery", + "trafficVolume": "Traffic volume" } } diff --git a/langs/pl_pl.json b/langs/pl_pl.json index c3b9cde..ee1ecd0 100644 --- a/langs/pl_pl.json +++ b/langs/pl_pl.json @@ -1908,6 +1908,11 @@ "webServiceHardeningTitle": "Utwardzenie", "webServiceHardeningAttacks": "Blokuj typowe próby ataków", "webServiceHardeningHeaders": "Usuwaj zbędne nagłówki ujawniające technologię", - "webServiceHardeningSecurityHelp": "Przełączniki utwardzenia są widoczne obok wyboru presetu w zakładce Ogólne. Po zastosowaniu presetu pozostają niezależnymi ustawieniami." + "webServiceHardeningSecurityHelp": "Przełączniki utwardzenia są widoczne obok wyboru presetu w zakładce Ogólne. Po zastosowaniu presetu pozostają niezależnymi ustawieniami.", + "trafficTabOverview": "Przegląd", + "trafficTabClients": "Klienci", + "trafficTabRequests": "Żądania", + "trafficTabDelivery": "Obsługa odpowiedzi", + "trafficVolume": "Wolumen ruchu" } } diff --git a/make_zip.py b/make_zip.py index 4f4e3b5..72025eb 100755 --- a/make_zip.py +++ b/make_zip.py @@ -88,7 +88,7 @@ def add_file(archive: zipfile.ZipFile, rel: Path, data: bytes, timestamp: tuple[ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('output', nargs='?', type=Path, default=ROOT.parent / 'RustProxy-0.25.0.zip') + parser.add_argument('output', nargs='?', type=Path, default=ROOT.parent / 'RustProxy-0.25.1.zip') output = parser.parse_args().output.resolve() paths = files() checksum = ROOT / 'SHA256SUMS' diff --git a/scripts/check_source.py b/scripts/check_source.py index 08177d7..9bf0dc5 100755 --- a/scripts/check_source.py +++ b/scripts/check_source.py @@ -8,15 +8,23 @@ import sqlite3 import subprocess import tomllib from preflight import check as preflight +from rust_source import rust_source +from web_assets import read_app_js, read_vhosts_js 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"))) + return rust_source(root, "src/cache/mod.rs") + +def read_deploy_source() -> str: + return "\n".join((root / name).read_text(encoding="utf-8") for name in ("scripts/deploy_core.py", "scripts/deploy_runtime.py", "scripts/deploy.py")) + +def read_docker_agent_source() -> str: + return "\n".join(path.read_text(encoding="utf-8") for path in sorted((root / "docker-agent").glob("*.go")) if not path.name.endswith("_test.go")) preflight(root) manifest = tomllib.loads((root / "Cargo.toml").read_text()) -assert manifest["package"]["version"] == "0.25.0" +assert manifest["package"]["version"] == "0.25.1" 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"] @@ -24,14 +32,14 @@ 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.25.0"' in lock + assert 'name = "rust-proxy"\nversion = "0.25.1"' 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.25.0", - "CHANGELOG.md": "## 0.25.0", - "ROADMAP.md": "## 0.25.0", - "AGENTS.md": "Current source version: 0.25.0.", - "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.25.0", + "README.md": "# RustProxy 0.25.1", + "CHANGELOG.md": "## 0.25.1", + "ROADMAP.md": "## 0.25.1", + "AGENTS.md": "Current source version: 0.25.1.", + "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.25.1", }.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" @@ -41,7 +49,7 @@ assert update_defaults == { "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() +update_check_source = rust_source(root, "src/update_check.rs") for marker in [ 'include_str!("../UPDATE_CHECK.toml")', 'pub async fn poll_loop', @@ -82,7 +90,7 @@ for path in (root / "src").rglob("*.rs"): 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")) +js = "\n".join(p.read_text() for p in (root / "src/admin/web").rglob("*.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)): @@ -112,10 +120,12 @@ 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 "Environment=MALLOC_ARENA_MAX=2" in read_deploy_source() 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) subprocess.run(["python3", str(root / "scripts/web_server_contracts.py")], check=True) +subprocess.run(["python3", str(root / "scripts/rust_module_contracts.py")], check=True) +subprocess.run(["python3", str(root / "scripts/source_module_contracts.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 @@ -131,8 +141,9 @@ assert 'data-panel="docker-vhosts"' in html and 'id="panel-docker-vhosts"' in ht 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}' +for path in ['src/settings.rs', 'src/admin/mod.rs', 'src/cache/mod.rs', 'src/headers.rs', 'src/tinyauth.rs', 'src/admin/web/vhosts/core.js']: + text = rust_source(root, path) if path.endswith('.rs') else (root / path).read_text() + assert 'x-rust-proxy-revision' not in text.lower(), f'legacy revision-header logic remains in {path}' assert './dev.sh admin reset-password' not in html and re.search(r'rust-proxy\s+admin reset-password locally', 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 @@ -140,7 +151,7 @@ assert 'id="settings-tab-manual-config"' in html and 'id="settings-page-manual-c 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() +balance_source = rust_source(root, "src/balance.rs") assert 'mode: HealthMode::Tcp' in balance_source for marker in [ 'pub struct PassiveHealth', 'enabled: false', 'pub passive_health: PassiveHealth', @@ -160,7 +171,7 @@ assert html.index('data-panel="vhosts"') < html.index('data-panel="redirects"') 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() +vhosts_js = read_vhosts_js(root) 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 @@ -182,10 +193,10 @@ 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() +docker_source = rust_source(root, "src/docker.rs") 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() +agent_source = read_docker_agent_source() 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 @@ -213,7 +224,7 @@ assert 'Opt-in health decisions based on ordinary user traffic. Defaults to disa 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.25.0"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/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']: +for marker in ['"version": "0.25.1"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/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 @@ -233,17 +244,17 @@ 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() +cache_mod = rust_source(root, "src/cache/mod.rs") 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 rust_source(root, f"src/cache/{fragment}").lstrip().startswith("impl Cache {"), fragment assert 'disabled_cache_strips_diagnostics_without_exposing_bypass_headers' in cache_source for marker in ['pub enum MemoryPreset', 'pub enum ColdPreset', 'BACKGROUND_REVALIDATION_LIMIT', 'cleanup_slots']: assert marker in cache_source, marker for marker in ['selectedCacheMemoryPreset', 'selectedCacheColdPreset', 'applyRecommendedColdCachePreset', 'data-cache-cold-preset="recommended"']: assert marker in js or marker in html, marker assert 'if !self.enabled' in cache_source -proxy_source = (root / "src/proxy/mod.rs").read_text() +proxy_source = rust_source(root, "src/proxy/mod.rs") 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 [ @@ -290,7 +301,7 @@ for marker in [ 'selective_purge_removes_matching_cold_object', ]: assert marker in cache_source, marker -transform_source = (root / "src/transform/mod.rs").read_text() +transform_source = rust_source(root, "src/transform/mod.rs") for marker in [ 'pub struct Settings', 'pub struct Overrides', @@ -312,9 +323,9 @@ for marker in [ assert marker in transform_source, marker assert 'collect bounded static response for transformation: {error}' in transform_source assert '.context("collect bounded static response for transformation")' not in transform_source -lookup_source = (root / 'src/cache/lookup.rs').read_text() -model_source = (root / 'src/cache/model.rs').read_text() -css_minifier_source = (root / 'src/transform/css.rs').read_text() +lookup_source = rust_source(root, 'src/cache/lookup.rs') +model_source = rust_source(root, 'src/cache/model.rs') +css_minifier_source = rust_source(root, 'src/transform/css.rs') assert 'let Some(_read_lease) = self.try_reserve_transient' in lookup_source assert 'struct TransientLease {\n _inner: Arc,' in model_source assert 'struct TransientLease(Arc);' not in model_source @@ -326,7 +337,7 @@ 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() +assert 'pub mod transform;' in rust_source(root, 'src/lib.rs') for marker in ['cache-cold-gc-interval', 'cache-cold-gc-max-delete', 'settings-tab-transform', 'transform-form', 'transform-memory-budget', 'transform-max-inflight', 'data-transform-preset="uber"', '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']: @@ -355,7 +366,7 @@ for marker in ['liveChartSamples = { hits: [], misses: [], bypasses: [] }', 'LIV 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() +admin_cache_source = rust_source(root, 'src/admin/mod.rs') 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 [ @@ -378,10 +389,10 @@ for marker in [ ]: 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() +redirect_source = rust_source(root, "src/redirect.rs") 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() +proxy_source = rust_source(root, "src/proxy/mod.rs") 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 @@ -389,7 +400,7 @@ assert 'claim_fill(ticket, cache_settings)' in proxy_source and 'cache_collapse_ 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() +config_source = rust_source(root, "src/config.rs") 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 @@ -407,7 +418,7 @@ for marker in [ 'backend_timing_header: host.backend_timing_header', ]: assert marker in config_source, marker -vhost_source = (root / "src/vhost.rs").read_text() +vhost_source = rust_source(root, "src/vhost.rs") assert 'is_legacy_main_fallback' in vhost_source assert 'values.length ? values : [{ id: "root"' not in vhosts_js for marker in [ @@ -423,29 +434,29 @@ for marker in [ 'websocket_phase("tunnel_ready")', ]: assert marker in proxy_source, marker -generic_headers_source = (root / "src/headers.rs").read_text() +generic_headers_source = rust_source(root, "src/headers.rs") 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() +headers_source = rust_source(root, "src/proxy/headers.rs") 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() +settings_source = rust_source(root, "src/settings.rs") 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() +auth_source = rust_source(root, "src/auth/mod.rs") +auth_store = rust_source(root, "src/auth/store.rs") 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() +upstream_source = rust_source(root, "src/upstream.rs") 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() +runtime_source = rust_source(root, "src/runtime.rs") 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() +admin = rust_source(root, "src/admin/mod.rs") for marker in [ "fn admin_request_authority", "Version::HTTP_2 | Version::HTTP_3", @@ -458,7 +469,7 @@ for marker in ['"/api/config/{section}"', 'async fn update_section', 'section_up 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() +app_js = read_app_js(root) 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']: @@ -495,13 +506,13 @@ for marker in ['function setOptionalMetric', 'setOptionalMetric("process-rss-ano 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() -secret_store_source = (root / "src/secret_store.rs").read_text() -backup_source = (root / "src/backup.rs").read_text() -config_source = (root / "src/config.rs").read_text() +db_source = rust_source(root, "src/db.rs") +acme_source = rust_source(root, "src/acme.rs") +main_source = rust_source(root, "src/main.rs") +logging_source = rust_source(root, "src/logging.rs") +secret_store_source = rust_source(root, "src/secret_store.rs") +backup_source = rust_source(root, "src/backup.rs") +config_source = rust_source(root, "src/config.rs") acme_worker_source = (root / "scripts/acme_provider.py").read_text() for feature_marker in [ "AES_256_GCM", "master.key", "O_NOFOLLOW", "migrate_acme_credentials", @@ -545,7 +556,7 @@ for marker in [ '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 marker in main_source or marker in rust_source(root, 'src/auth/store.rs'), 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' @@ -567,29 +578,29 @@ for marker in [ 'state.start_update_check(update_check);', ]: assert marker in main_source, marker -runtime_source = (root / "src/runtime.rs").read_text() +runtime_source = rust_source(root, "src/runtime.rs") 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() +settings_rust = rust_source(root, 'src/settings.rs') assert 'pub struct StartupLocks' in settings_rust and 'fixed by systemd/startup' in settings_rust -logging_rust = (root / 'src/logging.rs').read_text() +logging_rust = rust_source(root, 'src/logging.rs') 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() +pages_rust = rust_source(root, 'src/pages.rs') 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() +tls_source = rust_source(root, "src/tls.rs") 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() +vhost_source = rust_source(root, 'src/vhost.rs') 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() +docker_source = rust_source(root, 'src/docker.rs') 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() @@ -599,33 +610,33 @@ 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() +acl_source = rust_source(root, 'src/acl.rs') 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() +http3_source = rust_source(root, "src/http3.rs") 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 marker in http3_source or marker in rust_source(root, 'src/proxy/websocket.rs'), 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() +admin_source = rust_source(root, 'src/admin/mod.rs') 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() +ws_source = rust_source(root, 'src/proxy/websocket.rs') 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() + assert marker in rust_source(root, 'src/logging.rs'), marker +diagnostics_source = rust_source(root, 'src/diagnostics.rs') + rust_source(root, 'src/system_stats/mod.rs') + rust_source(root, 'src/system_stats/linux.rs') 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() +prometheus_source = rust_source(root, 'src/prometheus.rs') 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 @@ -638,7 +649,7 @@ for marker in ['cache-memory-breakdown', 'cache-transient-breakdown', 'cache-col 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 + assert marker in rust_source(root, 'src/vhost.rs'), 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()']: @@ -650,11 +661,11 @@ for marker in ['locationMatchRegex', 'headersVisualBuilder', 'headersAdvancedJso assert marker in vhosts_js, marker for marker in ['lastApiLatencyMs', 'cacheMemorySamples', 'cacheTransientSamples', 'cacheColdStorageSamples', 'cacheHitSamples', 'processCpuSamples', 'processRssSamples', 'networkRxSamples', 'responseLatencySamples', 'client_ip']: assert marker in app_js, marker -logging_source = (root / 'src/logging.rs').read_text() +logging_source = rust_source(root, 'src/logging.rs') 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() +stream_source = rust_source(root, "src/stream.rs") 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() @@ -663,10 +674,10 @@ 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() +not_found_source = rust_source(root, 'src/not_found.rs') 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() +pages_source = rust_source(root, 'src/pages.rs') 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 @@ -694,15 +705,15 @@ expressions = '\n'.join(target.get('expr','') for panel in grafana['panels'] for 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() +compression_source = rust_source(root, "src/compression.rs") +metrics_source = rust_source(root, "src/metrics.rs") +admin_source = rust_source(root, "src/admin/mod.rs") 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() +diagnostics_source = rust_source(root, "src/diagnostics.rs") + rust_source(root, "src/system_stats/mod.rs") + rust_source(root, "src/system_stats/linux.rs") 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" @@ -717,7 +728,7 @@ 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() +docker_source = rust_source(root, "src/docker.rs") 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 @@ -727,7 +738,7 @@ 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() +vhost_source = rust_source(root, "src/vhost.rs") 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 @@ -764,7 +775,7 @@ assert 'pub transformed: bool' in cache_source and 'annotate_transformed' in cac 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() +assert 'x-rustproxy-backend-time' in rust_source(root, "src/headers.rs") # Expanded upstream connection diagnostics must retain nested Hyper/rustls/io causes. for marker in [ "fn format_error_chain(", @@ -802,7 +813,7 @@ assert '#[serde(default = "default_admin_theme")]' in admin_source and 'matches! 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() +logging_source = rust_source(root, "src/logging.rs") 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 @@ -819,18 +830,18 @@ traffic_panel_js = (root / "src/admin/web/traffic-panel.js").read_text() assert 'id="panel-traffic"' in html and 'data-panel="traffic"' in html for marker in ['/api/traffic', 'trafficTopCountries', 'trafficTopUserAgents', 'trafficTopClientIps', 'traffic-donut', 'rustProxyTrafficPanel']: assert marker in traffic_panel_js or marker in html, marker -traffic_source = (root / "src/traffic.rs").read_text() +traffic_source = rust_source(root, "src/traffic.rs") for marker in ['QUEUE_CAPACITY: usize = 2048', 'TOP_CAPACITY: usize = 32', 'MAX_VHOSTS: usize = 64', 'self.tx.try_send(sample)', 'client_ips: TopTable']: assert marker in traffic_source, marker assert 'if (envelope) render();' in (root / "src/admin/web/web-server.js").read_text() assert 'pub capture_user_agent: bool' in logging_source and 'pub capture_country: bool' in logging_source -assert 'spawn_blocking(crate::diagnostics::trim_allocator)' in (root / 'src/runtime.rs').read_text() +assert 'spawn_blocking(crate::diagnostics::trim_allocator)' in rust_source(root, 'src/runtime.rs') assert 'fn trim_hot_idle(&self)' in cache_source and 'hot_auto_trim_target_percent' in cache_source 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() +balance_source = rust_source(root, 'src/balance.rs') 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'(?.form-actions {' in style_source and 'justify-content: flex-start' in style_source -deploy_source = (root / "scripts/deploy.py").read_text() +deploy_source = read_deploy_source() 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 @@ -874,7 +885,7 @@ assert '' not in html, "duplicate top-right version label 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") +upstream_source = rust_source(root, "src/upstream.rs") 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 @@ -882,9 +893,9 @@ 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() +server_source = rust_source(root, "src/server.rs") +websocket_source = rust_source(root, "src/proxy/websocket.rs") +vhost_source = rust_source(root, "src/vhost.rs") 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() @@ -940,7 +951,7 @@ for memory_marker_0230 in [ '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() +cache_fill_0230 = rust_source(root, 'src/cache/fill.rs') 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 @@ -1008,7 +1019,7 @@ assert 'prior knowledge' in transport_doc and 'Upgrade: h2c' in transport_doc an 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() +balance_0222 = rust_source(root, "src/balance.rs") 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", @@ -1022,9 +1033,9 @@ for marker_0222 in [ "explicit_http2_backend_uses_http2_request_version", ]: assert marker_0222 in proxy_source, marker_0222 -upstream_0222 = (root / "src/upstream.rs").read_text() +upstream_0222 = rust_source(root, "src/upstream.rs") assert "explicit_h2c_backend_uses_http2_prior_knowledge" in upstream_0222 -vhosts_0222 = (root / "src/admin/web/vhosts.js").read_text() +vhosts_0222 = read_vhosts_js(root) for marker_0222 in ["retry-max", "retry-connect", "retry-timeouts", "retry-statuses"]: assert marker_0222 in vhosts_0222, marker_0222 for marker_0222 in [ @@ -1042,7 +1053,7 @@ 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 + assert marker_0222 in rust_source(root, "src/tls.rs"), marker_0222 for marker_0222 in [ '"max_concurrent_requests"', '"queue_timeout_ms"', '"certificate_issuer"', '"days_remaining"', '"expiry_status"', '"last_renewal_error"' @@ -1072,13 +1083,13 @@ for marker_0223 in [ '"operation": "unchanged"', 'config.tls.certificates.push(crate::tls::Certificate', ]: assert marker_0223 in admin, marker_0223 -runtime_0223 = (root / 'src/runtime.rs').read_text() +runtime_0223 = rust_source(root, 'src/runtime.rs') 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() +main_0223 = rust_source(root, 'src/main.rs') 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() +tls_0223 = rust_source(root, 'src/tls.rs') 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"']: @@ -1087,9 +1098,9 @@ 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() +server_0240 = rust_source(root, "src/server.rs") +settings_0240 = rust_source(root, "src/settings.rs") +config_0240 = rust_source(root, "src/config.rs") for marker_0240 in [ "H2ConnectionState", "H2StreamLease", "H2TrackedBody", "wait_for_h2_idle", "graceful_shutdown()", "H2ShutdownCause::Lifetime", @@ -1101,16 +1112,16 @@ for marker_0240 in [ 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 "pub http2_settings: Option" in rust_source(root, "src/vhost.rs") 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() +system_stats_0241 = rust_source(root, "src/system_stats/mod.rs") +linux_stats_0241 = rust_source(root, "src/system_stats/linux.rs") +main_0241 = rust_source(root, "src/main.rs") +admin_0241 = rust_source(root, "src/admin/mod.rs") 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" @@ -1145,14 +1156,14 @@ assert '"error_log" => {' not in admin_0241 and 'request("/api/config/error_log" assert '"error_log"' in openapi and 'logging, error_log, forwarding' not in openapi assert 'logging_section_persists_error_log_settings_atomically' in admin_0241 and 'error_log_is_not_a_standalone_configuration_section' in admin_0241 assert 'location.pathname === "/logging"' in settings_js_0241 -assert '"RuntimeInformation"' in openapi and '"version": "0.25.0"' in openapi +assert '"RuntimeInformation"' in openapi and '"version": "0.25.1"' 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() +tls_0241 = rust_source(root, "src/tls.rs") +vhost_0241 = rust_source(root, "src/vhost.rs") +redirect_0241 = rust_source(root, "src/redirect.rs") +not_found_0241 = rust_source(root, "src/not_found.rs") +docker_0241 = rust_source(root, "src/docker.rs") 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 @@ -1190,5 +1201,5 @@ 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.25.0 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-v7 migrations.") +print("PASS: 0.25.1 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-v7 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 index e1f5d42..85630cb 100755 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -1,705 +1,9 @@ #!/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. -""" +"""Install/update a local release binary on Linux/systemd. No builds.""" 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.25.0'}) - 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 -PrivateDevices=true -ProtectSystem=strict -LogsDirectory=rustproxy -LogsDirectoryMode=0770 -ReadWritePaths={data} /var/log/rustproxy -ProtectHome=true -ProtectKernelTunables=true -ProtectKernelModules=true -ProtectKernelLogs=true -ProtectControlGroups=true -RestrictSUIDSGID=true -RestrictNamespaces=true -RestrictRealtime=true -RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 -SystemCallArchitectures=native -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 +from deploy_core import * # stable public helper surface used by release checks +from deploy_runtime import * # stable install/update helper surface def main() -> None: parser = argparse.ArgumentParser(description=__doc__) @@ -771,5 +75,6 @@ def main() -> None: parser.exit(1, f'Deployment failed: {error}\n') + if __name__ == '__main__': main() diff --git a/scripts/deploy_core.py b/scripts/deploy_core.py new file mode 100755 index 0000000..1df03d1 --- /dev/null +++ b/scripts/deploy_core.py @@ -0,0 +1,352 @@ +#!/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.25.1'}) + 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 +PrivateDevices=true +ProtectSystem=strict +LogsDirectory=rustproxy +LogsDirectoryMode=0770 +ReadWritePaths={data} /var/log/rustproxy +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +RestrictSUIDSGID=true +RestrictNamespaces=true +RestrictRealtime=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallArchitectures=native +LockPersonality=true + +[Install] +WantedBy=multi-user.target +''' + + diff --git a/scripts/deploy_runtime.py b/scripts/deploy_runtime.py new file mode 100755 index 0000000..5422ad5 --- /dev/null +++ b/scripts/deploy_runtime.py @@ -0,0 +1,356 @@ +"""Runtime install/update operations for deploy.py.""" +from __future__ import annotations + +from deploy_core import * # re-exported internal deployment primitives + +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 + + diff --git a/scripts/docs_check.py b/scripts/docs_check.py index 81021c2..f57032e 100755 --- a/scripts/docs_check.py +++ b/scripts/docs_check.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 -"""Rust-independent documentation/Reader consistency checks for the 0.25 release.""" +"""Rust-independent documentation/Reader consistency checks for the 0.25.1 release.""" from pathlib import Path import re +from rust_source import rust_source ROOT = Path(__file__).resolve().parents[1] DOCS = ROOT / "docs" @@ -13,6 +14,7 @@ required = [ DOCS / "README.md", DOCS / "WEB_SERVER.md", DOCS / "TODO.md", + DOCS / "V0.25.1_AUDIT.md", DOCS / "V0.25.0_AUDIT.md", DOCS / "TEST_REPORT.md", DOCS / "CHANGELOG_TECHNICAL_ARCHIVE.md", @@ -22,7 +24,7 @@ for path in required: assert path.is_file(), f"missing required documentation: {path.relative_to(ROOT)}" index = (DOCS / "README.md").read_text(encoding="utf-8") -for marker in ["Functional documentation", "Development and testing", "Technical documentation", "Historical or version-labelled context", "TODO.md", "V0.25.0_AUDIT.md", "CHANGELOG_TECHNICAL_ARCHIVE.md", "ROADMAP_HISTORY.md", "/docs"]: +for marker in ["Functional documentation", "Development and testing", "Technical documentation", "Historical or version-labelled context", "TODO.md", "V0.25.1_AUDIT.md", "V0.25.0_AUDIT.md", "CHANGELOG_TECHNICAL_ARCHIVE.md", "ROADMAP_HISTORY.md", "/docs"]: assert marker in index, marker for document in sorted(DOCS.glob("*.md")): if document.name == "README.md" or (document.name.startswith("V") and document.name.endswith("_AUDIT.md")): @@ -32,7 +34,7 @@ for document in sorted(DOCS.glob("*.md")): reader_html = (ROOT / "src/admin/web/docs.html").read_text(encoding="utf-8") reader_js = (ROOT / "src/admin/web/docs.js").read_text(encoding="utf-8") build = (ROOT / "build.rs").read_text(encoding="utf-8") -admin = (ROOT / "src/admin/mod.rs").read_text(encoding="utf-8") +admin = rust_source(ROOT, "src/admin/mod.rs") for marker in ["docs-tab-reader", "docs-reader-list", "docs-reader-content", "docs-tab-api"]: assert marker in reader_html, marker for marker in ["renderMarkdown", "selectDocument", 'get("/docs/content.json")', "docsByPath", "docsCategoryHistorical"]: @@ -49,13 +51,13 @@ assert '"/docs/content.json"' in admin readme = (ROOT / "README.md").read_text(encoding="utf-8") roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8") changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") -assert readme.startswith("# RustProxy 0.25.0") +assert readme.startswith("# RustProxy 0.25.1") assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme assert readme.count("## What changed in ") == 1, "README duplicates historical release notes; use CHANGELOG/archive" assert "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme -assert roadmap.startswith("# RustProxy roadmap") and "## 0.25.0" in roadmap +assert roadmap.startswith("# RustProxy roadmap") and "## 0.25.1" in roadmap and "## 0.25.0" in roadmap assert "## 0.24." not in roadmap and "## Web Server follow-up" in roadmap and "docs/ROADMAP_HISTORY.md" in roadmap -assert changelog.startswith("# Changelog\n") and "## 0.25.0" in changelog[:800] +assert changelog.startswith("# Changelog\n") and "## 0.25.1" in changelog[:800] assert "CHANGELOG_TECHNICAL_ARCHIVE.md" in changelog for developer_only in ["Clean the remaining compiler warnings", "Update package/source/UI/OpenAPI/release identity", "source regressions and update release identity"]: assert developer_only not in changelog, f"developer-only CHANGELOG detail: {developer_only}" diff --git a/scripts/memory_contracts.py b/scripts/memory_contracts.py index 4b29afc..1af4aa8 100755 --- a/scripts/memory_contracts.py +++ b/scripts/memory_contracts.py @@ -8,12 +8,14 @@ silently disappearing before compiler-enabled acceptance runs. from __future__ import annotations from pathlib import Path +from rust_source import rust_source ROOT = Path(__file__).resolve().parents[1] def read(path: str) -> str: - return (ROOT / path).read_text(encoding="utf-8") + target = ROOT / path + return rust_source(ROOT, path) if target.suffix == ".rs" else target.read_text(encoding="utf-8") def require(path: str, *markers: str) -> None: diff --git a/scripts/migrate_npm.py b/scripts/migrate_npm.py index 81d1f40..2ed5263 100755 --- a/scripts/migrate_npm.py +++ b/scripts/migrate_npm.py @@ -1,524 +1,9 @@ #!/usr/bin/env python3 -"""Migrate Nginx Proxy Manager proxy hosts from SQLite into RustProxy. - -The migrator intentionally focuses on proxy-host identity and upstream routing. It -never imports certificate/key material from NPM. NPM hosts that used TLS are attached -to RustProxy's TLS listener and use the RustProxy default certificate unless a named -certificate is supplied explicitly. - -Run without --apply first. The script logs in to the RustProxy administration API, -reads the current revision, builds a merged configuration and prints a review report. -Only --apply performs the revisioned PUT /api/config. -""" +"""Migrate Nginx Proxy Manager proxy hosts from SQLite into RustProxy.""" from __future__ import annotations -import argparse -from dataclasses import dataclass, asdict -import getpass -import http.cookiejar -import ipaddress -import json -import os -from pathlib import Path -import re -import sqlite3 -import ssl -import sys -from typing import Any -import urllib.error -import urllib.parse -import urllib.request - -MAX_CONFIG_BYTES = 262_144 - - -class MigrationError(RuntimeError): - pass - - -@dataclass(frozen=True) -class NpmProxyHost: - id: int - domain_names: list[str] - forward_scheme: str - forward_host: str - forward_port: int - allow_websocket_upgrade: bool - http2_support: bool - enabled: bool - certificate_id: int - ssl_forced: bool - access_list_id: int - advanced_config: str - locations: list[Any] - caching_enabled: bool - block_exploits: bool - meta: dict[str, Any] - - -@dataclass -class Decision: - npm_id: int - action: str - hosts: list[str] - backend: str | None = None - rustproxy_ids: list[str] | None = None - warnings: list[str] | None = None - reason: str | None = None - - -def json_value(raw: Any, fallback: Any) -> 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) - +from migrate_npm_core import * # stable migration/model API +from migrate_npm_api import * # stable administration API helpers def main() -> None: parser = argparse.ArgumentParser(description=__doc__) @@ -585,5 +70,6 @@ def main() -> None: parser.exit(1, f"Migration failed: {error}\n") + if __name__ == "__main__": main() diff --git a/scripts/migrate_npm_api.py b/scripts/migrate_npm_api.py new file mode 100755 index 0000000..6490ffc --- /dev/null +++ b/scripts/migrate_npm_api.py @@ -0,0 +1,124 @@ +"""RustProxy administration API helpers for NPM migration.""" +from __future__ import annotations + +from migrate_npm_core import * + +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) + + diff --git a/scripts/migrate_npm_core.py b/scripts/migrate_npm_core.py new file mode 100755 index 0000000..67a2a9f --- /dev/null +++ b/scripts/migrate_npm_core.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +"""Migrate Nginx Proxy Manager proxy hosts from SQLite into RustProxy. + +The migrator intentionally focuses on proxy-host identity and upstream routing. It +never imports certificate/key material from NPM. NPM hosts that used TLS are attached +to RustProxy's TLS listener and use the RustProxy default certificate unless a named +certificate is supplied explicitly. + +Run without --apply first. The script logs in to the RustProxy administration API, +reads the current revision, builds a merged configuration and prints a review report. +Only --apply performs the revisioned PUT /api/config. +""" +from __future__ import annotations + +import argparse +from dataclasses import dataclass, asdict +import getpass +import http.cookiejar +import ipaddress +import json +import os +from pathlib import Path +import re +import sqlite3 +import ssl +import sys +from typing import Any +import urllib.error +import urllib.parse +import urllib.request + +MAX_CONFIG_BYTES = 262_144 + + +class MigrationError(RuntimeError): + pass + + +@dataclass(frozen=True) +class NpmProxyHost: + id: int + domain_names: list[str] + forward_scheme: str + forward_host: str + forward_port: int + allow_websocket_upgrade: bool + http2_support: bool + enabled: bool + certificate_id: int + ssl_forced: bool + access_list_id: int + advanced_config: str + locations: list[Any] + caching_enabled: bool + block_exploits: bool + meta: dict[str, Any] + + +@dataclass +class Decision: + npm_id: int + action: str + hosts: list[str] + backend: str | None = None + rustproxy_ids: list[str] | None = None + warnings: list[str] | None = None + reason: str | None = None + + +def json_value(raw: Any, fallback: Any) -> 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 + + diff --git a/scripts/preflight.py b/scripts/preflight.py index 5e701d8..bb32589 100755 --- a/scripts/preflight.py +++ b/scripts/preflight.py @@ -11,6 +11,48 @@ LEGACY_AUTH = "4df9d473c9214365d91c83072f97aafab6ede4edcf4e1369b5c3679ded5cff85" def check(root: Path, repair_known: bool = False) -> list[str]: messages: list[str] = [] + + # 0.25.1 split-source migration: these monoliths are obsolete when the new + # bounded module tree is present. Preserve any local edits instead of silently + # deleting them when a source archive is extracted over an older checkout. + obsolete_split_files = ( + "src/admin/web/app.js", + "src/admin/web/vhosts.js", + "src/web_server.rs", + ) + split_markers = { + "src/admin/web/app.js": "src/admin/web/app/core.js", + "src/admin/web/vhosts.js": "src/admin/web/vhosts/core.js", + "src/web_server.rs": "src/web_server/mod.rs", + } + for relative in obsolete_split_files: + obsolete = root / relative + marker = root / split_markers[relative] + if not obsolete.exists() or not marker.is_file(): + continue + if not repair_known: + raise ValueError( + f"Obsolete 0.25.0 source monolith {relative} remains beside the 0.25.1 split tree. " + "Run ./dev.sh repair (or any normal ./dev.sh command) to preserve it under .upgrade-backup/." + ) + backup_root = root / ".upgrade-backup" / "0.25.1-split" + target = backup_root / relative + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists(): + # Never overwrite a prior backup; if the bytes match, the current stale + # copy can simply be removed. Otherwise force the operator to reconcile it. + if target.read_bytes() != obsolete.read_bytes(): + raise ValueError( + f"Backup target {target.relative_to(root)} already contains different data; " + f"move {relative} outside the source tree manually before rebuilding." + ) + obsolete.unlink() + else: + obsolete.rename(target) + messages.append( + f"Preserved obsolete {relative} as {target.relative_to(root)}" + ) + 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: @@ -31,12 +73,12 @@ def check(root: Path, repair_known: bool = False) -> list[str]: 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/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/web_server.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts.js", "src/admin/web/web-server.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/admin/web/traffic-panel.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", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "docs/TODO.md", "docs/V0.25.0_AUDIT.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "scripts/docs_check.py", "scripts/web_server_contracts.py", "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"]: + 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/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.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/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "docs/TODO.md", "docs/V0.25.1_AUDIT.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "scripts/docs_check.py", "scripts/web_server_contracts.py", "scripts/rust_source.py", "scripts/rust_module_contracts.py", "scripts/source_module_contracts.py", "scripts/acme_provider.py", "src/basic_auth.rs", "scripts/deploy.py", "scripts/deploy_core.py", "scripts/deploy_runtime.py", "scripts/migrate_npm.py", "scripts/migrate_npm_core.py", "scripts/migrate_npm_api.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.25.0 source tree: missing {path}") + raise ValueError(f"Incomplete RustProxy 0.25.1 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.25.0 entry point; extract the complete package, not individual files.") + raise ValueError("src/lib.rs is not the 0.25.1 entry point; extract the complete package, not individual files.") return messages diff --git a/scripts/release_smoke.py b/scripts/release_smoke.py index 3e818d5..d163361 100755 --- a/scripts/release_smoke.py +++ b/scripts/release_smoke.py @@ -21,11 +21,19 @@ import backup as backup_module import deploy import migrate_npm import upgrade as upgrade_module +from rust_source import rust_source +from web_assets import read_app_js, read_vhosts_js 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"))) + return rust_source(ROOT, "src/cache/mod.rs") + +def read_deploy_source() -> str: + return "\n".join((ROOT / name).read_text(encoding="utf-8") for name in ("scripts/deploy_core.py", "scripts/deploy_runtime.py", "scripts/deploy.py")) + +def read_docker_agent_source() -> str: + return "\n".join(path.read_text(encoding="utf-8") for path in sorted((ROOT / "docker-agent").glob("*.go")) if not path.name.endswith("_test.go")) def sql_and_backup_smoke(tmp: Path) -> None: @@ -114,13 +122,18 @@ def upgrade_smoke(tmp: Path) -> None: assert backup.is_dir() assert (backup / "src/obsolete.rs").is_file() assert not (target / "src/obsolete.rs").exists() - assert 'version = "0.25.0"' in (target / "Cargo.toml").read_text(encoding="utf-8") + assert 'version = "0.25.1"' 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() + assert (target / "docker-agent/model.go").is_file() + assert (target / "docker-agent/docker.go").is_file() + assert (target / "docker-agent/network.go").is_file() + assert (target / "docker-agent/certificate.go").is_file() + assert (target / "docker-agent/server.go").is_file() manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8")) - assert manifest["to_version"] == "0.25.0" + assert manifest["to_version"] == "0.25.1" assert manifest["removed_obsolete_runtime_state"] is True assert not legacy.exists() assert not (backup / "legacy-data").exists() @@ -162,7 +175,7 @@ def deployment_gate_smoke(tmp: Path) -> None: assert "TimeoutStopSec=120" in remote_unit for marker in ["PrivateDevices=true", "ProtectKernelLogs=true", "RestrictNamespaces=true", "RestrictRealtime=true", "SystemCallArchitectures=native"]: assert marker in remote_unit, marker - deploy_source = (ROOT / "scripts/deploy.py").read_text(encoding="utf-8") + deploy_source = read_deploy_source() 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 @@ -234,9 +247,9 @@ def npm_migration_smoke(tmp: Path) -> None: 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") + app = read_app_js(ROOT) settings = (ROOT / "src/admin/web/settings.js").read_text(encoding="utf-8") - vhosts = (ROOT / "src/admin/web/vhosts.js").read_text(encoding="utf-8") + vhosts = read_vhosts_js(ROOT) 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") @@ -251,7 +264,15 @@ def ui_contract_smoke() -> None: 'id="traffic-scope"', 'id="traffic-chart-countries"', 'id="traffic-chart-client-ips"', - 'id="traffic-client-ips"', + 'id="traffic-tabs"', + 'data-traffic-tab="overview"', + 'data-traffic-tab="clients"', + 'data-traffic-tab="requests"', + 'data-traffic-tab="delivery"', + 'id="traffic-chart-user-agents"', + 'id="traffic-chart-paths"', + 'id="traffic-chart-routes"', + 'id="traffic-chart-volume"', 'id="log-capture-user-agent"', 'id="log-capture-country"', 'id="cache-auto-trim-enabled"', @@ -316,6 +337,8 @@ def ui_contract_smoke() -> None: for marker in required_html: assert marker in html, marker assert '/api/traffic' in traffic and 'rustProxyTrafficPanel' in traffic + for marker in ['function selectTab(', 'function renderDonut(', 'function renderBars(', 'renderBars("client-ips"', 'renderBars("user-agents"', 'renderBars("paths"', 'renderBars("routes"', 'renderBars("volume"']: + assert marker in traffic, marker assert 'traffic: "/traffic"' in app and 'window.rustProxyTrafficPanel?.refresh()' in app 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 @@ -439,13 +462,13 @@ def ui_contract_smoke() -> None: 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") - secret_store = (ROOT / "src/secret_store.rs").read_text(encoding="utf-8") - backup = (ROOT / "src/backup.rs").read_text(encoding="utf-8") + config = rust_source(ROOT, "src/config.rs") + tls = rust_source(ROOT, "src/tls.rs") + main = rust_source(ROOT, "src/main.rs") + server = rust_source(ROOT, "src/server.rs") + logging = rust_source(ROOT, "src/logging.rs") + secret_store = rust_source(ROOT, "src/secret_store.rs") + backup = rust_source(ROOT, "src/backup.rs") acme_worker = (ROOT / "scripts/acme_provider.py").read_text(encoding="utf-8") for feature_marker in ["AES_256_GCM", "master.key", "O_NOFOLLOW", "migrate_acme_credentials"]: assert feature_marker in secret_store, feature_marker @@ -455,36 +478,36 @@ def source_contract_smoke() -> None: assert feature_marker in logging, feature_marker assert "credential_ini_b64" in acme_worker and "credential_file.unlink" in acme_worker 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") + settings = rust_source(ROOT, "src/settings.rs") + websocket = rust_source(ROOT, "src/proxy/websocket.rs") + proxy = rust_source(ROOT, "src/proxy/mod.rs") 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") + pages = rust_source(ROOT, "src/pages.rs") + admin = rust_source(ROOT, "src/admin/mod.rs") + stream = rust_source(ROOT, "src/stream.rs") + runtime = rust_source(ROOT, "src/runtime.rs") + acl = rust_source(ROOT, "src/acl.rs") 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") + http3 = rust_source(ROOT, "src/http3.rs") + redirect = rust_source(ROOT, "src/redirect.rs") + not_found = rust_source(ROOT, "src/not_found.rs") + prometheus = rust_source(ROOT, "src/prometheus.rs") + auth = rust_source(ROOT, "src/auth/mod.rs") + auth_store = rust_source(ROOT, "src/auth/store.rs") + upstream = rust_source(ROOT, "src/upstream.rs") + vhosts_js = read_vhosts_js(ROOT) + docker_source = rust_source(ROOT, "src/docker.rs") 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") + docker_agent = read_docker_agent_source() 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") + app_js = read_app_js(ROOT) # 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") + system_stats = rust_source(ROOT, "src/system_stats/mod.rs") + linux_stats = rust_source(ROOT, "src/system_stats/linux.rs") 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() @@ -521,7 +544,7 @@ def source_contract_smoke() -> None: 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.25.0"' in openapi_0241 + assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.25.1"' in openapi_0241 assert 'id="panel-logging"' not in html for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']: assert marker in html, marker @@ -567,7 +590,7 @@ def source_contract_smoke() -> None: 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_check = rust_source(ROOT, "src/update_check.rs") 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") @@ -659,11 +682,11 @@ def source_contract_smoke() -> None: 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') + app_js = read_app_js(ROOT) + vhosts_js = read_vhosts_js(ROOT) 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') + admin_rs = rust_source(ROOT, 'src/admin/mod.rs') + logging_rs = rust_source(ROOT, 'src/logging.rs') 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 @@ -673,10 +696,10 @@ def source_contract_smoke() -> None: 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') + cache_mod = rust_source(ROOT, 'src/cache/mod.rs') 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 rust_source(ROOT, f'src/cache/{fragment}').lstrip().startswith('impl Cache {') assert 'payload["transform_metrics"] = json!(state.transform_metrics.total())' in admin_rs for marker in ['payload["transform_resources"]', 'payload["memory_accounting"]', 'non_cache_rss_includes_transform', 'cold_limit_bytes', 'cold_limit_entries']: assert marker in admin_rs or marker in cache, marker @@ -695,12 +718,12 @@ def source_contract_smoke() -> None: 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') + balance_rs = rust_source(ROOT, 'src/balance.rs') 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 @@ -710,7 +733,7 @@ def source_contract_smoke() -> None: 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 'generated_vhost_inherits_global_server_header_policy' in rust_source(ROOT, 'src/docker.rs') 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 @@ -724,7 +747,7 @@ def source_contract_smoke() -> None: 'pub targets_admin_listener: bool', ]: assert marker in config, marker - vhost_source = (ROOT / "src/vhost.rs").read_text(encoding="utf-8") + vhost_source = rust_source(ROOT, "src/vhost.rs") 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 @@ -744,7 +767,7 @@ def source_contract_smoke() -> None: 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") + forwarding = rust_source(ROOT, "src/forwarding.rs") 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"]: @@ -784,7 +807,7 @@ def source_contract_smoke() -> None: "matching_cold_key", ]: assert marker in cache, marker - transform = (ROOT / "src/transform/mod.rs").read_text(encoding="utf-8") + transform = rust_source(ROOT, "src/transform/mod.rs") for marker in [ "pub struct Settings", "pub struct Overrides", "resolved.enabled = global.enabled && self.enabled", @@ -805,20 +828,20 @@ def source_contract_smoke() -> None: assert marker in transform, marker assert 'collect bounded static response for transformation: {error}' in transform assert '.context("collect bounded static response for transformation")' not in transform - lookup = (ROOT / 'src/cache/lookup.rs').read_text(encoding='utf-8') - model = (ROOT / 'src/cache/model.rs').read_text(encoding='utf-8') - css_minifier = (ROOT / 'src/transform/css.rs').read_text(encoding='utf-8') + lookup = rust_source(ROOT, 'src/cache/lookup.rs') + model = rust_source(ROOT, 'src/cache/model.rs') + css_minifier = rust_source(ROOT, 'src/transform/css.rs') assert 'let Some(_read_lease) = self.try_reserve_transient' in lookup assert 'struct TransientLease {\n _inner: Arc,' in model assert 'struct TransientLease(Arc);' not in model assert 'last_char = out.chars().last();' not in css_minifier assert css_minifier.count("last_char = Some(' ');") == 1 - assert "pub mod transform;" in (ROOT / "src/lib.rs").read_text(encoding="utf-8") + assert "pub mod transform;" in rust_source(ROOT, "src/lib.rs") 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") + balance = rust_source(ROOT, "src/balance.rs") for marker in [ "pub struct PassiveHealth", "enabled: false", "pub passive_health: PassiveHealth", "passive_health_is_opt_in_and_uses_separate_thresholds", @@ -839,12 +862,12 @@ def source_contract_smoke() -> None: ]: 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") + diagnostics = rust_source(ROOT, "src/diagnostics.rs") + rust_source(ROOT, "src/system_stats/mod.rs") + rust_source(ROOT, "src/system_stats/linux.rs") 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") + headers_source = rust_source(ROOT, "src/proxy/headers.rs") 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 @@ -860,14 +883,14 @@ def source_contract_smoke() -> None: 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") + upstream = rust_source(ROOT, "src/upstream.rs") 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 + assert marker in rust_source(ROOT, "src/vhost.rs"), 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()"]: @@ -885,17 +908,17 @@ def source_contract_smoke() -> None: 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") + balance = rust_source(ROOT, "src/balance.rs") 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") + tls_source = rust_source(ROOT, "src/tls.rs") 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") + db_source = rust_source(ROOT, "src/db.rs") + acme_source = rust_source(ROOT, "src/acme.rs") assert "SCHEMA_VERSION: i64 = 7" 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 and "0007_secret_store.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"']: @@ -903,28 +926,28 @@ def source_contract_smoke() -> None: 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') + settings_rust = rust_source(ROOT, 'src/settings.rs') 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') + logging_rust = rust_source(ROOT, 'src/logging.rs') 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') + pages_rust = rust_source(ROOT, 'src/pages.rs') 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') + docker_rust = rust_source(ROOT, 'src/docker.rs') 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') + compression_source = rust_source(ROOT, 'src/compression.rs') + metrics_source = rust_source(ROOT, 'src/metrics.rs') 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') + app_source = read_app_js(ROOT) + vhosts_ui = read_vhosts_js(ROOT) 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') + diagnostics_source = rust_source(ROOT, 'src/diagnostics.rs') + rust_source(ROOT, 'src/system_stats/mod.rs') + rust_source(ROOT, 'src/system_stats/linux.rs') 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' @@ -942,14 +965,14 @@ def source_contract_smoke() -> None: 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') + docker_source = rust_source(ROOT, 'src/docker.rs') 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') + vhost_source = rust_source(ROOT, 'src/vhost.rs') 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 @@ -958,22 +981,22 @@ def source_contract_smoke() -> None: 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') + tls_rust = rust_source(ROOT, 'src/tls.rs') 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.25.0"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"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"']: + for marker in ['"version": "0.25.1"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"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') + websocket_source = rust_source(ROOT, 'src/proxy/websocket.rs') + proxy_source = rust_source(ROOT, 'src/proxy/mod.rs') 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']: @@ -982,7 +1005,7 @@ def source_contract_smoke() -> None: 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') + logging_source = rust_source(ROOT, 'src/logging.rs') 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")']: @@ -1005,14 +1028,14 @@ def source_contract_smoke() -> None: 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') + websocket_source = rust_source(ROOT, 'src/proxy/websocket.rs') 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') + logging_source = rust_source(ROOT, 'src/logging.rs') 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') @@ -1082,13 +1105,32 @@ def reproducible_archive_smoke(tmp: Path) -> None: with zipfile.ZipFile(first) as archive: names = archive.namelist() assert "RustProxy/SHA256SUMS" in names + assert "RustProxy/scripts/rust_source.py" in names + assert "RustProxy/scripts/rust_module_contracts.py" in names + assert "RustProxy/scripts/source_module_contracts.py" in names + for name in names: + if name.startswith("RustProxy/") and name.endswith(".rs"): + assert len(archive.read(name).decode("utf-8").splitlines()) <= 500, name + if name.startswith("RustProxy/src/admin/web/") and name.endswith(".js"): + assert len(archive.read(name).decode("utf-8").splitlines()) <= 500, name + if name.startswith("RustProxy/docker-agent/") and name.endswith(".go"): + assert len(archive.read(name).decode("utf-8").splitlines()) <= 500, name 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/docs/WEB_SERVER.md" in names assert "RustProxy/docs/PHP_FPM.md" in names - assert "RustProxy/src/web_server.rs" in names + for module in ["mod.rs", "config.rs", "filesystem.rs", "security.rs", "response.rs", "request.rs", "routing.rs", "fastcgi.rs", "serve.rs", "htaccess.rs", "tests.rs"]: + assert f"RustProxy/src/web_server/{module}" in names + assert "RustProxy/src/web_server.rs" not in names assert "RustProxy/src/admin/web/web-server.js" in names + for module in ["app/core.js", "app/runtime.js", "app/settings.js", "vhosts/core.js", "vhosts/editor.js", "vhosts/render.js"]: + assert f"RustProxy/src/admin/web/{module}" in names + assert "RustProxy/src/admin/web/app.js" not in names and "RustProxy/src/admin/web/vhosts.js" not in names + for module in ["model.go", "docker.go", "network.go", "certificate.go", "server.go", "main.go", "main_test.go"]: + assert f"RustProxy/docker-agent/{module}" in names + for module in ["deploy.py", "deploy_core.py", "deploy_runtime.py", "migrate_npm.py", "migrate_npm_core.py", "migrate_npm_api.py"]: + assert f"RustProxy/scripts/{module}" 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 @@ -1108,6 +1150,8 @@ def reproducible_archive_smoke(tmp: Path) -> None: def main() -> None: + subprocess.run(["python3", str(ROOT / "scripts/rust_module_contracts.py")], check=True) + subprocess.run(["python3", str(ROOT / "scripts/source_module_contracts.py")], check=True) with tempfile.TemporaryDirectory(prefix="rustproxy-release-smoke-") as directory: tmp = Path(directory) sql_and_backup_smoke(tmp / "sql") @@ -1119,7 +1163,7 @@ def main() -> None: source_contract_smoke() container_contract_smoke() debian_grafana_contract_smoke() - print("PASS: 0.25.0 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.") + print("PASS: 0.25.1 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__": diff --git a/scripts/rust_module_contracts.py b/scripts/rust_module_contracts.py new file mode 100755 index 0000000..024c062 --- /dev/null +++ b/scripts/rust_module_contracts.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Static structure contracts for the split Rust source tree. + +These checks are intentionally compiler-independent. They prevent the module split +from regressing into large monoliths, keep unit tests out of implementation files, +and catch the class of dangling-attribute/broken-include mistakes that can otherwise +surface only at cargo check time. +""" +from __future__ import annotations + +from pathlib import Path +import re + +from rust_source import rust_source + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +MAX_LINES = 500 + + +def is_test_source(path: Path) -> bool: + relative = path.relative_to(SRC) + return path.name == "tests.rs" or "tests" in relative.parts + + +include_re = re.compile(r'include!\(\s*"([^"]+\.rs)"\s*\)') +test_attr_re = re.compile(r'(?m)^\s*#\[(?:tokio::)?test(?:\([^\]]*\))?\]') +dangling_doc_before_close_re = re.compile(r'(?ms)(?:^\s*///[^\n]*\n){1,8}\s*}') +dangling_attr_before_close_re = re.compile(r'(?ms)^\s*#\[[^\n]+\]\s*\n\s*}') + +def delimiters_balanced(text: str) -> bool: + stack: list[str] = [] + pairs = {"}": "{", ")": "(", "]": "["} + i = 0 + state = "code" + block_depth = 0 + raw_hashes = 0 + while i < len(text): + char = text[i] + nxt = text[i + 1] if i + 1 < len(text) else "" + if state == "code": + if char == "/" and nxt == "/": + state = "line"; i += 2; continue + if char == "/" and nxt == "*": + state = "block"; block_depth = 1; i += 2; continue + if char == '"': + state = "string"; i += 1; continue + if char == "r": + raw = re.match(r'r(#{0,16})"', text[i:]) + if raw: + raw_hashes = len(raw.group(1)); state = "raw"; i += len(raw.group(0)); continue + if char == "'": + # Lifetimes are identifiers; only enter char mode when a nearby closing quote exists. + cursor = i + 1 + closing = False + while cursor < min(len(text), i + 8): + if text[cursor] == "\\": cursor += 2; continue + if text[cursor] == "'": closing = True; break + if text[cursor] in "\r\n": break + cursor += 1 + if closing: + state = "char"; i += 1; continue + if char in "({[": stack.append(char) + elif char in "})]": + if not stack or stack[-1] != pairs[char]: return False + stack.pop() + i += 1 + elif state == "line": + if char == "\n": state = "code" + i += 1 + elif state == "block": + if char == "/" and nxt == "*": block_depth += 1; i += 2 + elif char == "*" and nxt == "/": + block_depth -= 1; i += 2 + if block_depth == 0: state = "code" + else: i += 1 + elif state == "string": + if char == "\\": i += 2 + elif char == '"': state = "code"; i += 1 + else: i += 1 + elif state == "char": + if char == "\\": i += 2 + elif char == "'": state = "code"; i += 1 + else: i += 1 + elif state == "raw": + terminator = '"' + "#" * raw_hashes + if text.startswith(terminator, i): i += len(terminator); state = "code" + else: i += 1 + return not stack and state in {"code", "line"} + + +# The 500-line rule applies to every Rust source in the repository, including integration tests. +for path in sorted(ROOT.rglob("*.rs")): + if any(part in {"target", ".git"} for part in path.parts): + continue + lines = path.read_text(encoding="utf-8").splitlines() + assert len(lines) <= MAX_LINES, f"{path.relative_to(ROOT)} has {len(lines)} lines (max {MAX_LINES})" + +for path in sorted(SRC.rglob("*.rs")): + text = path.read_text(encoding="utf-8") + lines = text.splitlines() + assert delimiters_balanced(text), f"unbalanced delimiters in split Rust source: {path.relative_to(ROOT)}" + + nonblank = [line.strip() for line in lines if line.strip()] + if nonblank: + assert not nonblank[-1].startswith(("#[", "///", "//!")), f"dangling Rust attribute/doc comment at EOF: {path.relative_to(ROOT)}" + assert not dangling_doc_before_close_re.search(text), f"doc comment before closing brace does not document an item: {path.relative_to(ROOT)}" + assert not dangling_attr_before_close_re.search(text), f"attribute before closing brace does not decorate an item: {path.relative_to(ROOT)}" + + for target in include_re.findall(text): + included = path.parent / target + assert included.is_file(), f"broken include! in {path.relative_to(ROOT)}: {target}" + included_text = included.read_text(encoding="utf-8") + # include! expands into the caller's existing module/item scope. Inner module docs + # (`//!`) in a split fragment are therefore invalid once another item precedes them. + assert not re.search(r'(?m)^\s*//!', included_text), ( + f"inner module doc comment in include! fragment: {included.relative_to(ROOT)}" + ) + + if not is_test_source(path): + assert not test_attr_re.search(text), f"unit test remains in implementation source: {path.relative_to(ROOT)}" + # Test-only implementation must live in a separate file and be pulled in explicitly. + for match in re.finditer(r'(?m)^\s*#\[cfg\(test\)\]\s*$', text): + tail = text[match.end():].lstrip() + assert tail.startswith("include!(") or tail.startswith("mod tests;"), ( + f"test cfg in implementation must target a separate test file: {path.relative_to(ROOT)}" + ) + +# Explicit regressions from the first Web Server split. +response = (SRC / "web_server/response.rs").read_text(encoding="utf-8") +assert not response.rstrip().endswith("#[derive(Debug, Clone)]"), "dangling derive returned in Web Server response module" +fastcgi = rust_source(ROOT, "src/web_server/fastcgi.rs") +request = (SRC / "web_server/request.rs").read_text(encoding="utf-8") +assert "use anyhow::{Context" in fastcgi or "Context," in fastcgi or ", Context" in fastcgi +assert "htaccess," in fastcgi +assert "request::server_protocol" in fastcgi +assert "pub(super) fn server_protocol" in request +headers = (SRC / "web_server/response/headers.rs").read_text(encoding="utf-8") +assert "htaccess," in headers, "Web Server response headers must import sibling htaccess after module split" +assert "ACCEPT_ENCODING," not in headers, "stale ACCEPT_ENCODING import reintroduced in response headers" + +# Regressions exposed by a real `cargo check --locked --all-targets` after the 0.25.1 split. +assert "pub(super) trait FastCgiIo" in fastcgi, "FastCgiIo visibility must match sibling-visible FastCGI helpers" +for relative in ("src/proxy/websocket/tests.rs", "tests/http3.rs"): + text = (ROOT / relative).read_text(encoding="utf-8") + assert 'parse::().unwrap()' not in text, ( + f"{relative} uses Result::unwrap() for h3::ext::Protocol even though InvalidProtocol has no Debug" + ) +server_tests = (SRC / "server/serve/tests.rs").read_text(encoding="utf-8") +assert server_tests.count("serve_inner(") == server_tests.count("Http2Connections::default()"), ( + "direct serve_inner test calls must pass the Http2Connections argument" +) +cache_policy_tests = (SRC / "cache/tests/tests/part_03.rs").read_text(encoding="utf-8") +assert "let plan = plan(" not in cache_policy_tests, "cache policy test shadows the plan() function" +cold_tests = (SRC / "cache/tests/cold_tier_tests/part_02.rs").read_text(encoding="utf-8") +for field in ("scheme:", "authority:", "target:", "tags:", "created_at:"): + assert field in cold_tests, f"ColdEntry test fixture is missing current field {field}" +admin_tests = (SRC / "admin/tests/part_02.rs").read_text(encoding="utf-8") +assert "let configuration = router\n .clone()\n .oneshot(" in admin_tests, ( + "admin persistence test must retain Router before Tower::oneshot consumes a clone" +) +web_tests_1 = (SRC / "web_server/tests/part_01.rs").read_text(encoding="utf-8") +assert "ETAG, EXPIRES," not in web_tests_1, "unused EXPIRES import returned in Web Server tests" + +print(f"PASS: Rust modules are <= {MAX_LINES} lines, test code is isolated, include! targets resolve, and split-module/all-target regressions are guarded.") diff --git a/scripts/rust_source.py b/scripts/rust_source.py new file mode 100755 index 0000000..d5ea8b3 --- /dev/null +++ b/scripts/rust_source.py @@ -0,0 +1,43 @@ +"""Helpers for source-level contracts over split Rust modules. + +The project intentionally keeps Rust source files small and composes several modules +with include!("..."). Static contracts must inspect the logical source, not only the +thin facade file. +""" +from __future__ import annotations + +from pathlib import Path +import re + +_INCLUDE = re.compile(r'include!\(\s*"([^"]+\.rs)"\s*\)\s*;?') + + +def expanded_source(path: Path, _stack: tuple[Path, ...] = ()) -> str: + path = path.resolve() + if path in _stack: + chain = " -> ".join(str(item) for item in (*_stack, path)) + raise RuntimeError(f"cyclic Rust include!: {chain}") + text = path.read_text(encoding="utf-8") + + def replace(match: re.Match[str]) -> str: + child = (path.parent / match.group(1)).resolve() + if not child.is_file(): + raise FileNotFoundError(f"{path}: include target does not exist: {match.group(1)}") + return expanded_source(child, (*_stack, path)) + + return _INCLUDE.sub(replace, text) + + +def rust_source(root: Path, relative: str) -> str: + """Read one logical Rust source file, recursively expanding include! fragments.""" + return expanded_source(root / relative) + + +def rust_tree(root: Path, relative_dir: str) -> str: + """Read every Rust file beneath a module directory (including mod-declared children).""" + directory = root / relative_dir + return "\n".join( + expanded_source(path) + for path in sorted(directory.rglob("*.rs")) + if path.is_file() + ) diff --git a/scripts/security_contracts.py b/scripts/security_contracts.py index dc46754..6a25434 100755 --- a/scripts/security_contracts.py +++ b/scripts/security_contracts.py @@ -2,18 +2,20 @@ """Source-level security invariants that must hold even before Rust runtime tests.""" from __future__ import annotations from pathlib import Path +from rust_source import rust_source, rust_tree ROOT = Path(__file__).resolve().parents[1] def read(path: str) -> str: - return (ROOT / path).read_text(encoding="utf-8") + target = ROOT / path + return rust_source(ROOT, path) if target.suffix == ".rs" else target.read_text(encoding="utf-8") def need(text: str, *needles: str) -> None: for needle in needles: if needle not in text: raise AssertionError(f"missing security invariant: {needle}") -web = read("src/web_server.rs") +web = rust_tree(ROOT, "src/web_server") htaccess = read("src/web_server/htaccess.rs") acl = read("src/acl.rs") admin = read("src/admin/mod.rs") @@ -94,7 +96,7 @@ need(auth, # Web serving/admin code must not execute shell commands as part of request/config handling. for path, text in { - "src/web_server.rs": web, + "src/web_server/*.rs": web, "src/web_server/htaccess.rs": htaccess, "src/admin/mod.rs": admin, }.items(): diff --git a/scripts/source_module_contracts.py b/scripts/source_module_contracts.py new file mode 100755 index 0000000..d4866e1 --- /dev/null +++ b/scripts/source_module_contracts.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Compiler-independent source-size and test-isolation contracts for non-Rust modules.""" +from __future__ import annotations + +from pathlib import Path +import os +import re +import shutil +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +MAX_LINES = 500 + + +def lines(path: Path) -> int: + return len(path.read_text(encoding="utf-8").splitlines()) + +# Browser implementation modules are bounded. Test harnesses under scripts/ are checked separately. +for path in sorted((ROOT / "src/admin/web").rglob("*.js")): + assert lines(path) <= MAX_LINES, f"{path.relative_to(ROOT)} has {lines(path)} lines (max {MAX_LINES})" + +# Docker Agent implementation and Go tests are separate files and all remain bounded. +for path in sorted((ROOT / "docker-agent").glob("*.go")): + assert lines(path) <= MAX_LINES, f"{path.relative_to(ROOT)} has {lines(path)} lines (max {MAX_LINES})" + if not path.name.endswith("_test.go"): + text = path.read_text(encoding="utf-8") + assert not re.search(r"(?m)^func\s+Test[A-Z]", text), f"Go test remains in implementation file: {path.relative_to(ROOT)}" + +# Operational Python entry points are split as product modules; large QA harnesses are intentionally exempt. +for pattern in ("deploy*.py", "migrate_npm*.py"): + for path in sorted((ROOT / "scripts").glob(pattern)): + assert lines(path) <= MAX_LINES, f"{path.relative_to(ROOT)} has {lines(path)} lines (max {MAX_LINES})" + +for old in (ROOT / "src/admin/web/app.js", ROOT / "src/admin/web/vhosts.js"): + assert not old.exists(), f"oversized browser monolith returned: {old.relative_to(ROOT)}" +for required in ( + "src/admin/web/app/core.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", + "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", + "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", + "docker-agent/certificate.go", "docker-agent/server.go", + "scripts/deploy_core.py", "scripts/deploy_runtime.py", + "scripts/migrate_npm_core.py", "scripts/migrate_npm_api.py", +): + assert (ROOT / required).is_file(), f"missing split source module: {required}" + + +# When Node is available, parse every standalone browser module and both assembled public bundles. +node = shutil.which("node") +if node: + env = dict(os.environ, TERM=os.environ.get("TERM", "xterm")) + standalone = list((ROOT / "src/admin/web").rglob("*.js")) + for path in sorted(standalone): + subprocess.run([node, "--check", str(path)], check=True, env=env, stdout=subprocess.DEVNULL) + from web_assets import read_app_js, read_vhosts_js + with tempfile.TemporaryDirectory(prefix="rustproxy-js-contract-") as directory: + temporary = Path(directory) + for name, content in (("app.js", read_app_js(ROOT)), ("vhosts.js", read_vhosts_js(ROOT))): + target = temporary / name + target.write_text(content, encoding="utf-8") + subprocess.run([node, "--check", str(target)], check=True, env=env, stdout=subprocess.DEVNULL) + # The public vhosts asset wraps three individually valid source modules in one private IIFE. + # Every source part is parsed above; this additional check keeps the assembled browser asset valid. + +print(f"PASS: browser, Docker Agent and operational Python implementation modules are <= {MAX_LINES} lines, tests are isolated, and browser source boundaries parse cleanly.") diff --git a/scripts/swagger_assets.py b/scripts/swagger_assets.py index bd5634b..f557a85 100755 --- a/scripts/swagger_assets.py +++ b/scripts/swagger_assets.py @@ -85,7 +85,7 @@ 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.25.0"}) + request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build/0.25.1"}) 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: diff --git a/scripts/upgrade.py b/scripts/upgrade.py index c8facea..34f1020 100755 --- a/scripts/upgrade.py +++ b/scripts/upgrade.py @@ -46,7 +46,7 @@ def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None 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.25.0-" + uuid.uuid4().hex[:12]) + backup = target / ".upgrade-backup" / ("before-0.25.1-" + uuid.uuid4().hex[:12]) backup.mkdir(parents=True, mode=0o700) installed: list[str] = [] moved: list[str] = [] @@ -84,7 +84,7 @@ def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None 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.25.0", "managed_paths":MANAGED, "removed_obsolete_runtime_state": remove_legacy}, indent=2)+"\n", encoding="utf-8") + (backup / "upgrade.json").write_text(json.dumps({"from_directory":str(target), "to_version":"0.25.1", "managed_paths":MANAGED, "removed_obsolete_runtime_state": remove_legacy}, indent=2)+"\n", encoding="utf-8") if remove_legacy: legacy.unlink() except BaseException: @@ -115,7 +115,7 @@ def main() -> None: 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.25.0. Previous managed source files: {backup}") + print(f"Installed RustProxy 0.25.1. 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") diff --git a/scripts/web_assets.py b/scripts/web_assets.py new file mode 100755 index 0000000..de1248b --- /dev/null +++ b/scripts/web_assets.py @@ -0,0 +1,14 @@ +from pathlib import Path + +APP_PARTS = ("core.js", "runtime.js", "settings.js") +VHOST_PARTS = ("core.js", "editor.js", "render.js") + +def _read(root: Path, folder: str, parts: tuple[str, ...]) -> str: + return "\n".join((root / "src/admin/web" / folder / part).read_text(encoding="utf-8") for part in parts) + +def read_app_js(root: Path) -> str: + return _read(root, "app", APP_PARTS) + +def read_vhosts_js(root: Path) -> str: + body = _read(root, "vhosts", VHOST_PARTS) + return "(() => {\n" + body + "\n})();\n" diff --git a/scripts/web_server_contracts.py b/scripts/web_server_contracts.py index 78b1c4f..5cdf5ff 100755 --- a/scripts/web_server_contracts.py +++ b/scripts/web_server_contracts.py @@ -2,6 +2,8 @@ """Rust-independent 0.25 Web Server architecture/security contract checks.""" from pathlib import Path import json +from rust_source import rust_source, rust_tree +from web_assets import read_app_js, read_vhosts_js ROOT = Path(__file__).resolve().parents[1] @@ -9,19 +11,23 @@ def need(text: str, *markers: str) -> None: for marker in markers: assert marker in text, marker -config = (ROOT / "src/config.rs").read_text() -vhost = (ROOT / "src/vhost.rs").read_text() -web = (ROOT / "src/web_server.rs").read_text() -proxy = (ROOT / "src/proxy/mod.rs").read_text() -admin = (ROOT / "src/admin/mod.rs").read_text() -ui = (ROOT / "src/admin/web/vhosts.js").read_text() +config = rust_source(ROOT, "src/config.rs") +vhost = rust_source(ROOT, "src/vhost.rs") +web_dir = ROOT / "src/web_server" +assert not (ROOT / "src/web_server.rs").exists(), "Web Server monolith must not coexist with src/web_server/mod.rs" +for module in ["mod.rs", "config.rs", "filesystem.rs", "security.rs", "response.rs", "request.rs", "routing.rs", "fastcgi.rs", "serve.rs", "htaccess.rs", "tests.rs"]: + assert (web_dir / module).is_file(), module +web = rust_tree(ROOT, "src/web_server") +proxy = rust_source(ROOT, "src/proxy/mod.rs") +admin = rust_source(ROOT, "src/admin/mod.rs") +ui = read_vhosts_js(ROOT) web_ui = (ROOT / "src/admin/web/web-server.js").read_text() -app_ui = (ROOT / "src/admin/web/app.js").read_text() +app_ui = read_app_js(ROOT) html = (ROOT / "src/admin/web/index.html").read_text() settings_ui = (ROOT / "src/admin/web/settings.js").read_text() -logging = (ROOT / "src/logging.rs").read_text() -metrics = (ROOT / "src/metrics.rs").read_text() -acl = (ROOT / "src/acl.rs").read_text() +logging = rust_source(ROOT, "src/logging.rs") +metrics = rust_source(ROOT, "src/metrics.rs") +acl = rust_source(ROOT, "src/acl.rs") openapi = json.loads((ROOT / "src/admin/openapi.json").read_text()) need(config, @@ -90,7 +96,7 @@ need(web, 'HeaderValue::from_static("no-store")', "builtin_error_response(StatusCode::FORBIDDEN, &method)", "empty_index_can_go_directly_to_directory_listing", - "RustProxy 0.25.0", + "RustProxy 0.25.1", "traversal_and_symlink_escape_are_not_served", "try_files_rewrites_cache_rules_headers_and_downloads_work", "precompressed_sidecars_keep_mime_validators_and_range_semantics", @@ -166,12 +172,13 @@ assert web_service_schema["properties"]["htaccess"]["properties"]["enabled"]["de assert web_service_schema["properties"]["htaccess"]["properties"]["filename"]["default"] == ".htaccess" assert web_service_schema["properties"]["hardening"]["properties"]["block_common_attacks"]["default"] is False assert web_service_schema["properties"]["hardening"]["properties"]["strip_unnecessary_headers"]["default"] is False -htaccess_source = (ROOT / "src/web_server/htaccess.rs").read_text() +htaccess_source = rust_source(ROOT, "src/web_server/htaccess.rs") need(htaccess_source, "MAX_HTACCESS_FILES", "MAX_HTACCESS_TOTAL_BYTES", "RewriteEngine", "RewriteCond", "RewriteRule", "DirectoryIndex", "AddType", "AddHandler", "ExpiresByType", "Require all granted/denied", "general Files/FilesMatch semantics are not supported", "security/handler directive", "apply_response", "apply_rewrites") assert "Source" in openapi.get("components", {}).get("schemas", {}) assert "client_ips" in openapi["components"]["schemas"]["TrafficCounters"]["properties"] assert (ROOT / "docs/WEB_SERVER.md").is_file() assert (ROOT / "docs/PHP_FPM.md").is_file() +assert (ROOT / "docs/V0.25.1_AUDIT.md").is_file() assert (ROOT / "docs/V0.25.0_AUDIT.md").is_file() assert (ROOT / "docs/TODO.md").is_file() docs_html = (ROOT / "src/admin/web/docs.html").read_text() diff --git a/src/acl.rs b/src/acl.rs index 62f6232..0b5c060 100644 --- a/src/acl.rs +++ b/src/acl.rs @@ -147,104 +147,4 @@ pub fn routing_path(raw: &str) -> Result { 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" - ); - for path in [ - "/%2e%2e/secret", - "/a/%2E%2E/b", - "/a%2fb", - "/a%5cb", - "/bad%", - "/bad%2", - ] { - assert!(routing_path(path).is_err(), "{path}"); - } - 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}"); - } - } -} +include!("acl/tests.rs"); diff --git a/src/acl/tests.rs b/src/acl/tests.rs new file mode 100644 index 0000000..b5d752e --- /dev/null +++ b/src/acl/tests.rs @@ -0,0 +1,94 @@ +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" + ); + for path in ["/%2e%2e/secret", "/a/%2E%2E/b", "/a%2fb", "/a%5cb", "/bad%", "/bad%2"] { + assert!(routing_path(path).is_err(), "{path}"); + } + 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 index 9c1a5d7..8ac6858 100644 --- a/src/acme.rs +++ b/src/acme.rs @@ -1,1303 +1,9 @@ //! Bounded control-plane adapter for Certbot HTTP-01/DNS-01 providers. Never on traffic paths. //! Provider credentials enter via stdin, are encrypted in Secret Store, and never //! travel through argv, logs or API responses. -use crate::{ - runtime::{ApiError, App}, - tls::Certificate, -}; -use base64::Engine as _; -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() -} -fn provider_credential_bytes(request: &Value, provider: &str) -> Result>, ApiError> { - fn credential<'a>(request: &'a Value, name: &str) -> Result<&'a str, ApiError> { - let invalid = || ApiError(StatusCode::BAD_REQUEST, "acme.invalid_api_credential".into()); - let value = request.get(name).and_then(Value::as_str).ok_or_else(invalid)?; - let valid = (8..=4096).contains(&value.len()) - && value.bytes().all(|byte| { - byte.is_ascii_alphanumeric() - || matches!(byte, b'_' | b'.' | b'/' | b'+' | b'-' | b'=') - }); - if valid { Ok(value) } else { Err(invalid()) } - } - let bytes = match provider { - "cloudflare" => format!("dns_cloudflare_api_token = {}\n", credential(request, "api_token")?).into_bytes(), - "ovh" => { - if request.get("endpoint").is_some_and(|value| !value.is_string()) { - return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_ovh_endpoint".into())); - } - let endpoint = request.get("endpoint").and_then(Value::as_str).unwrap_or("ovh-eu"); - if !matches!(endpoint, "ovh-eu" | "ovh-ca") { - return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_ovh_endpoint".into())); - } - format!( - "dns_ovh_endpoint = {}\ndns_ovh_application_key = {}\ndns_ovh_application_secret = {}\ndns_ovh_consumer_key = {}\n", - endpoint, - credential(request, "application_key")?, - credential(request, "application_secret")?, - credential(request, "consumer_key")? - ) - .into_bytes() - } - "rfc2136" => { - let dns_server = request - .get("dns_server") - .and_then(Value::as_str) - .and_then(|value| value.parse::().ok()) - .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_dns_server".into()))?; - if request.get("dns_port").is_some_and(|value| value.as_u64().is_none()) { - return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_limits".into())); - } - let dns_port = request.get("dns_port").and_then(Value::as_u64).unwrap_or(53); - if !(1..=65535).contains(&dns_port) { - return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_limits".into())); - } - let raw_name = request.get("key_name").and_then(Value::as_str).unwrap_or(""); - let key_name = raw_name.to_ascii_lowercase(); - let key_name = key_name.trim_end_matches('.'); - let valid_name = !key_name.is_empty() - && key_name.len() <= 253 - && key_name.split('.').all(|label| { - !label.is_empty() - && label.len() <= 63 - && label.bytes().enumerate().all(|(index, byte)| { - (byte.is_ascii_lowercase() || byte.is_ascii_digit()) - || (byte == b'-' && index > 0 && index + 1 < label.len()) - }) - }); - if !valid_name { - return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_domain".into())); - } - if request.get("key_algorithm").is_some_and(|value| !value.is_string()) { - return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_algorithm".into())); - } - let key_algorithm = request - .get("key_algorithm") - .and_then(Value::as_str) - .unwrap_or("HMAC-SHA256"); - if !matches!(key_algorithm, "HMAC-SHA256" | "HMAC-SHA384" | "HMAC-SHA512") { - return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_algorithm".into())); - } - let key_secret = request.get("key_secret").and_then(Value::as_str).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_api_credential".into()))?; - let decoded = Zeroizing::new( - base64::engine::general_purpose::STANDARD - .decode(key_secret) - .map_err(|_| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_secret".into()))?, - ); - if key_secret.len() > 1024 || !(16..=512).contains(&decoded.len()) { - return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_secret".into())); - } - if request.get("sign_query").is_some_and(|value| !value.is_boolean()) { - return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_sign_query".into())); - } - let sign_query = request.get("sign_query").and_then(Value::as_bool).unwrap_or(false); - format!( - "dns_rfc2136_server = {dns_server}\ndns_rfc2136_port = {dns_port}\ndns_rfc2136_name = {key_name}.\ndns_rfc2136_secret = {key_secret}\ndns_rfc2136_algorithm = {key_algorithm}\ndns_rfc2136_sign_query = {sign_query}\n" - ) - .into_bytes() - } - "http01" => Vec::new(), - _ => return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_provider".into())), - }; - Ok(Zeroizing::new(bytes)) -} - - -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()))?; - if provider != "http01" { - let data_dir = state.policy.data_dir.as_ref().ok_or_else(|| { - ApiError(StatusCode::CONFLICT, "acme.secret_store_unavailable".into()) - })?; - let credential = provider_credential_bytes(&request, &provider)?; - crate::secret_store::put( - &state.store, - data_dir, - &crate::secret_store::acme_secret_id(&id, &provider), - &provider, - credential.as_ref(), - serde_json::json!({"certificate_id": id.clone(), "purpose": "acme_dns"}), - ) - .await - .map_err(crate::auth::internal)?; - } - 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 -} +include!("acme/model.rs"); +include!("acme/providers.rs"); +include!("acme/jobs.rs"); #[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 mut 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}); - if issuer.provider != "http01" { - let data_dir = state.policy.data_dir.as_ref().ok_or_else(|| { - ApiError(StatusCode::CONFLICT, "acme.secret_store_unavailable".into()) - })?; - let credential = crate::secret_store::get( - &state.store, - data_dir, - &crate::secret_store::acme_secret_id(&cert.id, &issuer.provider), - Some(&issuer.provider), - ) - .await - .map_err(|_| ApiError(StatusCode::CONFLICT, "acme.secret_missing".into()))?; - request["credential_ini_b64"] = Value::String( - base64::engine::general_purpose::STANDARD.encode(credential.as_slice()) - ); - } - let raw = Zeroizing::new(request.to_string()); - if let Some(Value::String(secret)) = request.get_mut("credential_ini_b64") { - secret.zeroize(); - } - 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; - } -} +include!("acme/tests.rs"); diff --git a/src/acme/jobs.rs b/src/acme/jobs.rs new file mode 100644 index 0000000..cbd8ea0 --- /dev/null +++ b/src/acme/jobs.rs @@ -0,0 +1,346 @@ +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 mut 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}); + if issuer.provider != "http01" { + let data_dir = state.policy.data_dir.as_ref().ok_or_else(|| { + ApiError(StatusCode::CONFLICT, "acme.secret_store_unavailable".into()) + })?; + let credential = crate::secret_store::get( + &state.store, + data_dir, + &crate::secret_store::acme_secret_id(&cert.id, &issuer.provider), + Some(&issuer.provider), + ) + .await + .map_err(|_| ApiError(StatusCode::CONFLICT, "acme.secret_missing".into()))?; + request["credential_ini_b64"] = Value::String( + base64::engine::general_purpose::STANDARD.encode(credential.as_slice()) + ); + } + let raw = Zeroizing::new(request.to_string()); + if let Some(Value::String(secret)) = request.get_mut("credential_ini_b64") { + secret.zeroize(); + } + 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)) => {} } + } +} diff --git a/src/acme/model.rs b/src/acme/model.rs new file mode 100644 index 0000000..2991978 --- /dev/null +++ b/src/acme/model.rs @@ -0,0 +1,403 @@ +use crate::{ + runtime::{ApiError, App}, + tls::Certificate, +}; +use base64::Engine as _; +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() +} +fn provider_credential_bytes(request: &Value, provider: &str) -> Result>, ApiError> { + fn credential<'a>(request: &'a Value, name: &str) -> Result<&'a str, ApiError> { + let invalid = || ApiError(StatusCode::BAD_REQUEST, "acme.invalid_api_credential".into()); + let value = request.get(name).and_then(Value::as_str).ok_or_else(invalid)?; + let valid = (8..=4096).contains(&value.len()) + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!(byte, b'_' | b'.' | b'/' | b'+' | b'-' | b'=') + }); + if valid { Ok(value) } else { Err(invalid()) } + } + let bytes = match provider { + "cloudflare" => format!("dns_cloudflare_api_token = {}\n", credential(request, "api_token")?).into_bytes(), + "ovh" => { + if request.get("endpoint").is_some_and(|value| !value.is_string()) { + return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_ovh_endpoint".into())); + } + let endpoint = request.get("endpoint").and_then(Value::as_str).unwrap_or("ovh-eu"); + if !matches!(endpoint, "ovh-eu" | "ovh-ca") { + return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_ovh_endpoint".into())); + } + format!( + "dns_ovh_endpoint = {}\ndns_ovh_application_key = {}\ndns_ovh_application_secret = {}\ndns_ovh_consumer_key = {}\n", + endpoint, + credential(request, "application_key")?, + credential(request, "application_secret")?, + credential(request, "consumer_key")? + ) + .into_bytes() + } + "rfc2136" => { + let dns_server = request + .get("dns_server") + .and_then(Value::as_str) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_dns_server".into()))?; + if request.get("dns_port").is_some_and(|value| value.as_u64().is_none()) { + return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_limits".into())); + } + let dns_port = request.get("dns_port").and_then(Value::as_u64).unwrap_or(53); + if !(1..=65535).contains(&dns_port) { + return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_limits".into())); + } + let raw_name = request.get("key_name").and_then(Value::as_str).unwrap_or(""); + let key_name = raw_name.to_ascii_lowercase(); + let key_name = key_name.trim_end_matches('.'); + let valid_name = !key_name.is_empty() + && key_name.len() <= 253 + && key_name.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label.bytes().enumerate().all(|(index, byte)| { + (byte.is_ascii_lowercase() || byte.is_ascii_digit()) + || (byte == b'-' && index > 0 && index + 1 < label.len()) + }) + }); + if !valid_name { + return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_domain".into())); + } + if request.get("key_algorithm").is_some_and(|value| !value.is_string()) { + return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_algorithm".into())); + } + let key_algorithm = request + .get("key_algorithm") + .and_then(Value::as_str) + .unwrap_or("HMAC-SHA256"); + if !matches!(key_algorithm, "HMAC-SHA256" | "HMAC-SHA384" | "HMAC-SHA512") { + return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_algorithm".into())); + } + let key_secret = request.get("key_secret").and_then(Value::as_str).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_api_credential".into()))?; + let decoded = Zeroizing::new( + base64::engine::general_purpose::STANDARD + .decode(key_secret) + .map_err(|_| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_secret".into()))?, + ); + if key_secret.len() > 1024 || !(16..=512).contains(&decoded.len()) { + return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_secret".into())); + } + if request.get("sign_query").is_some_and(|value| !value.is_boolean()) { + return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_sign_query".into())); + } + let sign_query = request.get("sign_query").and_then(Value::as_bool).unwrap_or(false); + format!( + "dns_rfc2136_server = {dns_server}\ndns_rfc2136_port = {dns_port}\ndns_rfc2136_name = {key_name}.\ndns_rfc2136_secret = {key_secret}\ndns_rfc2136_algorithm = {key_algorithm}\ndns_rfc2136_sign_query = {sign_query}\n" + ) + .into_bytes() + } + "http01" => Vec::new(), + _ => return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_provider".into())), + }; + Ok(Zeroizing::new(bytes)) +} + + +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()))?; + if provider != "http01" { + let data_dir = state.policy.data_dir.as_ref().ok_or_else(|| { + ApiError(StatusCode::CONFLICT, "acme.secret_store_unavailable".into()) + })?; + let credential = provider_credential_bytes(&request, &provider)?; + crate::secret_store::put( + &state.store, + data_dir, + &crate::secret_store::acme_secret_id(&id, &provider), + &provider, + credential.as_ref(), + serde_json::json!({"certificate_id": id.clone(), "purpose": "acme_dns"}), + ) + .await + .map_err(crate::auth::internal)?; + } + 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) +} diff --git a/src/acme/providers.rs b/src/acme/providers.rs new file mode 100644 index 0000000..23fcb60 --- /dev/null +++ b/src/acme/providers.rs @@ -0,0 +1,228 @@ +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)] +include!("providers/tests.rs"); + +/// 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) +} diff --git a/src/acme/providers/tests.rs b/src/acme/providers/tests.rs new file mode 100644 index 0000000..eaaa244 --- /dev/null +++ b/src/acme/providers/tests.rs @@ -0,0 +1,174 @@ +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; + } +} diff --git a/src/acme/tests.rs b/src/acme/tests.rs new file mode 100644 index 0000000..6f7cebc --- /dev/null +++ b/src/acme/tests.rs @@ -0,0 +1,148 @@ +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/access.rs b/src/admin/access.rs new file mode 100644 index 0000000..f81208c --- /dev/null +++ b/src/admin/access.rs @@ -0,0 +1,285 @@ +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"), + } + }) +} diff --git a/src/admin/certificates.rs b/src/admin/certificates.rs new file mode 100644 index 0000000..99a84a8 --- /dev/null +++ b/src/admin/certificates.rs @@ -0,0 +1,427 @@ +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, +} diff --git a/src/admin/documentation.rs b/src/admin/documentation.rs index 5a3394d..bd8bf0d 100644 --- a/src/admin/documentation.rs +++ b/src/admin/documentation.rs @@ -71,6 +71,7 @@ static READER_DOCUMENTS: &[(&str, &str, &str)] = &[ ("docs/V0.24.7_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.7_AUDIT.md"))), ("docs/V0.24.8_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.8_AUDIT.md"))), ("docs/V0.25.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.0_AUDIT.md"))), + ("docs/V0.25.1_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.1_AUDIT.md"))), ("docs/V0.25.0_SECURITY_PREPROD_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.0_SECURITY_PREPROD_AUDIT.md"))), ("docs/VHOSTS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/VHOSTS.md"))), ("docs/VHOST_ACCESS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/VHOST_ACCESS.md"))), @@ -166,47 +167,4 @@ pub(super) fn asset(name: &'static str) -> Response { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn reader_bundle_contains_embedded_project_and_web_server_docs() { - let value: Value = serde_json::from_slice(reader_bundle()).unwrap(); - let documents = value["documents"].as_array().unwrap(); - assert!(documents.iter().any(|doc| doc["path"] == "README.md")); - assert!(documents.iter().any(|doc| doc["path"] == "docs/WEB_SERVER.md")); - assert!(documents.iter().any(|doc| doc["path"] == "docs/PHP_FPM.md")); - assert!(documents.iter().all(|doc| { - doc["path"].as_str().is_some_and(|value| !value.is_empty()) - && doc["title"].as_str().is_some_and(|value| !value.is_empty()) - && doc["category"].as_str().is_some_and(|value| !value.is_empty()) - && doc["content"].as_str().is_some_and(|value| !value.is_empty()) - })); - } - - #[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); - } - } -} +include!("documentation/tests.rs"); diff --git a/src/admin/documentation/tests.rs b/src/admin/documentation/tests.rs new file mode 100644 index 0000000..56e4312 --- /dev/null +++ b/src/admin/documentation/tests.rs @@ -0,0 +1,44 @@ +mod tests { + use super::*; + + #[test] + fn reader_bundle_contains_embedded_project_and_web_server_docs() { + let value: Value = serde_json::from_slice(reader_bundle()).unwrap(); + let documents = value["documents"].as_array().unwrap(); + assert!(documents.iter().any(|doc| doc["path"] == "README.md")); + assert!(documents.iter().any(|doc| doc["path"] == "docs/WEB_SERVER.md")); + assert!(documents.iter().any(|doc| doc["path"] == "docs/PHP_FPM.md")); + assert!(documents.iter().all(|doc| { + doc["path"].as_str().is_some_and(|value| !value.is_empty()) + && doc["title"].as_str().is_some_and(|value| !value.is_empty()) + && doc["category"].as_str().is_some_and(|value| !value.is_empty()) + && doc["content"].as_str().is_some_and(|value| !value.is_empty()) + })); + } + + #[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/logging_api.rs b/src/admin/logging_api.rs new file mode 100644 index 0000000..28a57d5 --- /dev/null +++ b/src/admin/logging_api.rs @@ -0,0 +1,409 @@ +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" => { + if value.get("logging").is_some() || value.get("error_log").is_some() { + let part: LoggingSection = + serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?; + config.logging = part.logging; + config.error_log = part.error_log; + } else { + // Backward compatibility for clients that still send only the + // historical access-logging object to /api/config/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))? + } + "web-services" => { + config.web_services = + 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()) +} + +#[derive(Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct TrafficQuery { + vhost: Option, +} +async fn traffic_status( + State(state): State>, + Query(query): Query, +) -> Result, ApiError> { + let snapshot = state.config.load_full(); + if query.vhost.as_ref().is_some_and(|id| { + id != "*" && !snapshot.envelope.config.vhosts.iter().any(|vhost| &vhost.id == id) + }) { + return Err(ApiError( + StatusCode::NOT_FOUND, + "unknown vhost traffic scope".into(), + )); + } + let scope = query.vhost.as_deref().unwrap_or("*"); + let vhosts = snapshot + .envelope + .config + .vhosts + .iter() + .map(|vhost| json!({"id": vhost.id, "hosts": vhost.frontend.hosts})) + .collect::>(); + Ok(Json(json!({ + "scope": scope, + "since": "process_start", + "settings": { + "capture_user_agent": snapshot.envelope.config.logging.capture_user_agent, + "capture_country": snapshot.envelope.config.logging.capture_country + }, + "geoip": state.geoip.view(), + "analytics": state.traffic.view(Some(scope)), + "vhosts": vhosts + }))) +} + +#[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()}), + )) +} diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 44f60ba..cf6884e 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -1,4503 +1,13 @@ //! 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("/web-server", 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("/traffic", 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/content.json", get(documentation::content)) - .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/traffic-panel.js", - get(|| async { - ( - [("content-type", "text/javascript; charset=utf-8")], - include_str!("web/traffic-panel.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/web-server.js", - get(|| async { - ( - [("content-type", "text/javascript; charset=utf-8")], - include_str!("web/web-server.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/traffic", get(traffic_status)) - .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" - | "/web-server" - | "/docker-vhosts" - | "/redirects" - | "/404-hosts" - | "/streams" - | "/acl-profiles" - | "/configuration" - | "/tls" - | "/traffic" - | "/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") - } -} +include!("router.rs"); +include!("access.rs"); +include!("session.rs"); +include!("status.rs"); +include!("logging_api.rs"); +include!("operations.rs"); +include!("certificates.rs"); +include!("tail.rs"); -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); - let cache_view = state.cache.view(); - let transform_resources = crate::transform::resource_usage(&snapshot.envelope.config.transform); - payload["cache"] = json!(&cache_view); - payload["transform_resources"] = json!(&transform_resources); - payload["geoip"] = json!(state.geoip.view()); - let system = state.diagnostics.view(); - let cache_ram_bytes = (cache_view.hot_allocated_bytes as u64) - .saturating_add(cache_view.transient_bytes); - let non_cache_rss_bytes = system - .process_rss_bytes - .map(|rss| rss.saturating_sub(cache_ram_bytes)); - payload["memory_accounting"] = json!({ - "process_rss_bytes": system.process_rss_bytes, - "cache_hot_bytes": cache_view.hot_allocated_bytes, - "cache_hot_limit_bytes": cache_view.hot_limit_bytes, - "cache_transient_bytes": cache_view.transient_bytes, - "cache_transient_limit_bytes": cache_view.transient_limit_bytes, - "cache_total_bytes": cache_ram_bytes, - "transform_reserved_bytes": transform_resources.memory_reserved_bytes, - "transform_budget_bytes": transform_resources.memory_budget_bytes, - "transform_inflight": transform_resources.inflight, - "transform_max_inflight": transform_resources.max_inflight, - "non_cache_rss_bytes": non_cache_rss_bytes, - "non_cache_rss_includes_transform": true, - "method": "rss_minus_accounted_cache; transform reservation is an overlapping estimate" - }); - 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::>()); - payload["vhost_metrics"] = json!(snapshot - .vhost_metrics - .iter() - .map(|(id, metrics)| (id.clone(), metrics.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(), - "error_file": crate::logging::error_log_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, -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct LoggingSection { - logging: crate::logging::Settings, - error_log: crate::logging::ErrorLogSettings, -} - -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" => { - if value.get("logging").is_some() || value.get("error_log").is_some() { - let part: LoggingSection = - serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?; - config.logging = part.logging; - config.error_log = part.error_log; - } else { - // Backward compatibility for clients that still send only the - // historical access-logging object to /api/config/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))? - } - "web-services" => { - config.web_services = - 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 status_exposes_runtime_memory_accounting_and_cold_limits() { - let (router, store, _dir, secret, _) = fixture().await; - let response = router - .oneshot( - request("/api/status") - .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = json_body(response).await; - assert_eq!(body["transform_resources"]["memory_budget_bytes"], 128 * 1024 * 1024); - assert_eq!(body["transform_resources"]["max_inflight"], 32); - assert_eq!(body["memory_accounting"]["transform_budget_bytes"], 128 * 1024 * 1024); - assert_eq!(body["memory_accounting"]["cache_hot_limit_bytes"], 256 * 1024 * 1024); - assert_eq!(body["cache"]["cold_limit_bytes"], 4_u64 * 1024 * 1024 * 1024); - assert_eq!(body["cache"]["cold_limit_entries"], 50_000); - assert_eq!(body["memory_accounting"]["non_cache_rss_includes_transform"], true); - store.close().await; - } - - #[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/traffic").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 logging_section_persists_error_log_settings_atomically() { - let (router, store, dir, secret, csrf) = fixture().await; - let cookie = format!("{}={secret}", auth::COOKIE_NAME); - let logging = Config::default().logging; - let mut error_log = Config::default().error_log; - error_log.enabled = true; - error_log.path = "/tmp/rustproxy-error.log".into(); - - 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( - serde_json::to_vec(&json!({ - "expected_revision": 0, - "value": { - "logging": logging, - "error_log": error_log, - }, - })) - .unwrap(), - )) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let updated = json_body(response).await; - assert_eq!(updated["revision"], 1); - assert_eq!(updated["config"]["error_log"]["enabled"], true); - assert_eq!(updated["config"]["error_log"]["path"], "/tmp/rustproxy-error.log"); - - let configuration = router - .oneshot( - request("/api/config") - .header("cookie", &cookie) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let persisted = json_body(configuration).await; - assert_eq!(persisted["config"]["error_log"]["enabled"], true); - assert_eq!(persisted["config"]["error_log"]["path"], "/tmp/rustproxy-error.log"); - drop(router); - store.close().await; - let reopened = Store::open(&dir.path().join("db"), &Config::default()) - .await - .unwrap(); - let persisted = reopened.load().await.unwrap(); - assert_eq!(persisted.revision, 1); - assert!(persisted.config.error_log.enabled); - assert_eq!(persisted.config.error_log.path, "/tmp/rustproxy-error.log"); - reopened.close().await; - } - - #[tokio::test] - async fn error_log_is_not_a_standalone_configuration_section() { - let (router, store, _dir, secret, csrf) = fixture().await; - let cookie = format!("{}={secret}", auth::COOKIE_NAME); - let response = router - .oneshot( - request("/api/config/error_log") - .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":0,"value":{}}"#)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.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", - "/web-server", - "/docker-vhosts", - "/redirects", - "/404-hosts", - "/streams", - "/acl-profiles", - "/configuration", - "/tls", - "/traffic", - "/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/traffic", 200), - ("/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/traffic", - "/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/content.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/traffic-panel.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"), - ("/docs/content.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", - "/docs/content.json", - "/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()); - let response = router - .clone() - .oneshot( - request("/docs/content.json") - .header("cookie", &cookie) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let docs = json_body(response).await; - let documents = docs["documents"].as_array().expect("documentation bundle"); - assert!(documents.iter().any(|document| document["path"] == "docs/README.md")); - assert!(documents.iter().any(|document| document["path"] == "docs/WEB_SERVER.md")); - assert!(documents.iter().any(|document| document["path"] == "docs/PHP_FPM.md")); - assert!(documents.iter().any(|document| document["path"] == "docs/V0.25.0_AUDIT.md")); - assert!(documents.iter().any(|document| document["path"] == "docs/V0.25.0_SECURITY_PREPROD_AUDIT.md")); - 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 TrafficQuery { - vhost: Option, -} -async fn traffic_status( - State(state): State>, - Query(query): Query, -) -> Result, ApiError> { - let snapshot = state.config.load_full(); - if query.vhost.as_ref().is_some_and(|id| { - id != "*" && !snapshot.envelope.config.vhosts.iter().any(|vhost| &vhost.id == id) - }) { - return Err(ApiError( - StatusCode::NOT_FOUND, - "unknown vhost traffic scope".into(), - )); - } - let scope = query.vhost.as_deref().unwrap_or("*"); - let vhosts = snapshot - .envelope - .config - .vhosts - .iter() - .map(|vhost| json!({"id": vhost.id, "hosts": vhost.frontend.hosts})) - .collect::>(); - Ok(Json(json!({ - "scope": scope, - "since": "process_start", - "settings": { - "capture_user_agent": snapshot.envelope.config.logging.capture_user_agent, - "capture_country": snapshot.envelope.config.logging.capture_country - }, - "geoip": state.geoip.view(), - "analytics": state.traffic.view(Some(scope)), - "vhosts": vhosts - }))) -} - -#[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, - "resources": crate::transform::resource_usage(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()) -} +include!("tests.rs"); diff --git a/src/admin/openapi.json b/src/admin/openapi.json index 4252156..2a51d0c 100644 --- a/src/admin/openapi.json +++ b/src/admin/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "RustProxy Administration API", - "version": "0.25.0", + "version": "0.25.1", "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": [ diff --git a/src/admin/operations.rs b/src/admin/operations.rs new file mode 100644 index 0000000..b338322 --- /dev/null +++ b/src/admin/operations.rs @@ -0,0 +1,405 @@ +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, + "resources": crate::transform::resource_usage(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, +} diff --git a/src/admin/router.rs b/src/admin/router.rs new file mode 100644 index 0000000..1e02435 --- /dev/null +++ b/src/admin/router.rs @@ -0,0 +1,451 @@ +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, +}; + +fn admin_app_javascript() -> &'static str { + static ASSET: std::sync::LazyLock = std::sync::LazyLock::new(|| { + [ + include_str!("web/app/core.js"), + include_str!("web/app/runtime.js"), + include_str!("web/app/settings.js"), + ].join("\n") + }); + ASSET.as_str() +} + +fn admin_vhosts_javascript() -> &'static str { + static ASSET: std::sync::LazyLock = std::sync::LazyLock::new(|| { + [ + "(() => {", + include_str!("web/vhosts/core.js"), + include_str!("web/vhosts/editor.js"), + include_str!("web/vhosts/render.js"), + "})();", + ].join("\n") + }); + ASSET.as_str() +} + +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("/web-server", 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("/traffic", 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/content.json", get(documentation::content)) + .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")], + admin_app_javascript(), + ) + }), + ) + .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/traffic-panel.js", + get(|| async { + ( + [("content-type", "text/javascript; charset=utf-8")], + include_str!("web/traffic-panel.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")], + admin_vhosts_javascript(), + ) + }), + ) + .route( + "/assets/web-server.js", + get(|| async { + ( + [("content-type", "text/javascript; charset=utf-8")], + include_str!("web/web-server.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/traffic", get(traffic_status)) + .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" + | "/web-server" + | "/docker-vhosts" + | "/redirects" + | "/404-hosts" + | "/streams" + | "/acl-profiles" + | "/configuration" + | "/tls" + | "/traffic" + | "/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) +} diff --git a/src/admin/session.rs b/src/admin/session.rs new file mode 100644 index 0000000..17e04d0 --- /dev/null +++ b/src/admin/session.rs @@ -0,0 +1,406 @@ +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)) +} diff --git a/src/admin/status.rs b/src/admin/status.rs new file mode 100644 index 0000000..8bafbd9 --- /dev/null +++ b/src/admin/status.rs @@ -0,0 +1,370 @@ +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); + let cache_view = state.cache.view(); + let transform_resources = crate::transform::resource_usage(&snapshot.envelope.config.transform); + payload["cache"] = json!(&cache_view); + payload["transform_resources"] = json!(&transform_resources); + payload["geoip"] = json!(state.geoip.view()); + let system = state.diagnostics.view(); + let cache_ram_bytes = (cache_view.hot_allocated_bytes as u64) + .saturating_add(cache_view.transient_bytes); + let non_cache_rss_bytes = system + .process_rss_bytes + .map(|rss| rss.saturating_sub(cache_ram_bytes)); + payload["memory_accounting"] = json!({ + "process_rss_bytes": system.process_rss_bytes, + "cache_hot_bytes": cache_view.hot_allocated_bytes, + "cache_hot_limit_bytes": cache_view.hot_limit_bytes, + "cache_transient_bytes": cache_view.transient_bytes, + "cache_transient_limit_bytes": cache_view.transient_limit_bytes, + "cache_total_bytes": cache_ram_bytes, + "transform_reserved_bytes": transform_resources.memory_reserved_bytes, + "transform_budget_bytes": transform_resources.memory_budget_bytes, + "transform_inflight": transform_resources.inflight, + "transform_max_inflight": transform_resources.max_inflight, + "non_cache_rss_bytes": non_cache_rss_bytes, + "non_cache_rss_includes_transform": true, + "method": "rss_minus_accounted_cache; transform reservation is an overlapping estimate" + }); + 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::>()); + payload["vhost_metrics"] = json!(snapshot + .vhost_metrics + .iter() + .map(|(id, metrics)| (id.clone(), metrics.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(), + "error_file": crate::logging::error_log_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, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LoggingSection { + logging: crate::logging::Settings, + error_log: crate::logging::ErrorLogSettings, +} + +fn invalid_section(section: &str, error: serde_json::Error) -> ApiError { + ApiError( + StatusCode::BAD_REQUEST, + format!("invalid {section} configuration: {error}"), + ) +} diff --git a/src/admin/tail.rs b/src/admin/tail.rs new file mode 100644 index 0000000..07badf8 --- /dev/null +++ b/src/admin/tail.rs @@ -0,0 +1,15 @@ +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/tests.rs b/src/admin/tests.rs new file mode 100644 index 0000000..af9783b --- /dev/null +++ b/src/admin/tests.rs @@ -0,0 +1,7 @@ +mod tests { + include!("tests/part_01.rs"); + include!("tests/part_02.rs"); + include!("tests/part_03.rs"); + include!("tests/part_04.rs"); + include!("tests/part_05.rs"); +} diff --git a/src/admin/tests/part_01.rs b/src/admin/tests/part_01.rs new file mode 100644 index 0000000..2fec490 --- /dev/null +++ b/src/admin/tests/part_01.rs @@ -0,0 +1,366 @@ + 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 status_exposes_runtime_memory_accounting_and_cold_limits() { + let (router, store, _dir, secret, _) = fixture().await; + let response = router + .oneshot( + request("/api/status") + .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = json_body(response).await; + assert_eq!(body["transform_resources"]["memory_budget_bytes"], 128 * 1024 * 1024); + assert_eq!(body["transform_resources"]["max_inflight"], 32); + assert_eq!(body["memory_accounting"]["transform_budget_bytes"], 128 * 1024 * 1024); + assert_eq!(body["memory_accounting"]["cache_hot_limit_bytes"], 256 * 1024 * 1024); + assert_eq!(body["cache"]["cold_limit_bytes"], 4_u64 * 1024 * 1024 * 1024); + assert_eq!(body["cache"]["cold_limit_entries"], 50_000); + assert_eq!(body["memory_accounting"]["non_cache_rss_includes_transform"], true); + store.close().await; + } + + #[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; + } diff --git a/src/admin/tests/part_02.rs b/src/admin/tests/part_02.rs new file mode 100644 index 0000000..f666f5a --- /dev/null +++ b/src/admin/tests/part_02.rs @@ -0,0 +1,420 @@ + #[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/traffic").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 logging_section_persists_error_log_settings_atomically() { + let (router, store, dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let logging = Config::default().logging; + let mut error_log = Config::default().error_log; + error_log.enabled = true; + error_log.path = "/tmp/rustproxy-error.log".into(); + + 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( + serde_json::to_vec(&json!({ + "expected_revision": 0, + "value": { + "logging": logging, + "error_log": error_log, + }, + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let updated = json_body(response).await; + assert_eq!(updated["revision"], 1); + assert_eq!(updated["config"]["error_log"]["enabled"], true); + assert_eq!(updated["config"]["error_log"]["path"], "/tmp/rustproxy-error.log"); + + let configuration = router + .clone() + .oneshot( + request("/api/config") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let persisted = json_body(configuration).await; + assert_eq!(persisted["config"]["error_log"]["enabled"], true); + assert_eq!(persisted["config"]["error_log"]["path"], "/tmp/rustproxy-error.log"); + drop(router); + store.close().await; + let reopened = Store::open(&dir.path().join("db"), &Config::default()) + .await + .unwrap(); + let persisted = reopened.load().await.unwrap(); + assert_eq!(persisted.revision, 1); + assert!(persisted.config.error_log.enabled); + assert_eq!(persisted.config.error_log.path, "/tmp/rustproxy-error.log"); + reopened.close().await; + } + + #[tokio::test] + async fn error_log_is_not_a_standalone_configuration_section() { + let (router, store, _dir, secret, csrf) = fixture().await; + let cookie = format!("{}={secret}", auth::COOKIE_NAME); + let response = router + .oneshot( + request("/api/config/error_log") + .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":0,"value":{}}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.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", + "/web-server", + "/docker-vhosts", + "/redirects", + "/404-hosts", + "/streams", + "/acl-profiles", + "/configuration", + "/tls", + "/traffic", + "/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; + } diff --git a/src/admin/tests/part_03.rs b/src/admin/tests/part_03.rs new file mode 100644 index 0000000..509da30 --- /dev/null +++ b/src/admin/tests/part_03.rs @@ -0,0 +1,372 @@ + #[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/traffic", 200), + ("/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; + } diff --git a/src/admin/tests/part_04.rs b/src/admin/tests/part_04.rs new file mode 100644 index 0000000..07a2444 --- /dev/null +++ b/src/admin/tests/part_04.rs @@ -0,0 +1,420 @@ + #[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/traffic", + "/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/content.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/traffic-panel.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"), + ("/docs/content.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; + } diff --git a/src/admin/tests/part_05.rs b/src/admin/tests/part_05.rs new file mode 100644 index 0000000..14bd1ff --- /dev/null +++ b/src/admin/tests/part_05.rs @@ -0,0 +1,172 @@ + #[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", + "/docs/content.json", + "/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()); + let response = router + .clone() + .oneshot( + request("/docs/content.json") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let docs = json_body(response).await; + let documents = docs["documents"].as_array().expect("documentation bundle"); + assert!(documents.iter().any(|document| document["path"] == "docs/README.md")); + assert!(documents.iter().any(|document| document["path"] == "docs/WEB_SERVER.md")); + assert!(documents.iter().any(|document| document["path"] == "docs/PHP_FPM.md")); + assert!(documents.iter().any(|document| document["path"] == "docs/V0.25.0_AUDIT.md")); + assert!(documents.iter().any(|document| document["path"] == "docs/V0.25.0_SECURITY_PREPROD_AUDIT.md")); + 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; + } diff --git a/src/admin/web/app.js b/src/admin/web/app.js deleted file mode 100644 index f2f3465..0000000 --- a/src/admin/web/app.js +++ /dev/null @@ -1,1202 +0,0 @@ -"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", "web-server": "/web-server", "docker-vhosts": "/docker-vhosts", redirects: "/redirects", notfound: "/404-hosts", streams: "/streams", "acl-profiles": "/acl-profiles", tls: "/tls", traffic: "/traffic", 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(); -let selectedCacheMemoryPreset = null, selectedCacheColdPreset = null, selectedTransformPreset = null; -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/traffic")) return "traffic"; - 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 === "traffic") void window.rustProxyTrafficPanel?.refresh(); 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.rustProxyWebServer?.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.rustProxyTrafficPanel?.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.rustProxyWebServer?.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); -function showStartupFailure(error) { - // index.html is served only after the server has accepted the browser session. - // A module/configuration error must therefore never masquerade as a logout. - $("login").hidden = true; - $("workspace").hidden = false; - $("logout").hidden = !authenticated; - document.documentElement.classList.remove("app-loading"); - toast(error?.message || String(error), true); -} -(async () => { - try { - // Revalidate the session first. The anonymous document has its own login.js; - // app.js belongs to the authenticated shell and must not fall back to its - // legacy embedded sign-in form when another startup step fails. - const info = await api("/api/auth/session"); - await loadLanguages(); - setupRequired = false; - translate(); - syncAuthForm(); - await activate(info); - } catch (e) { - if (e.status === 401) { - location.replace(`${location.pathname}${location.search}`); - return; - } - showStartupFailure(e); - } -})(); - -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 = [], cacheTransientSamples = [], cacheFillSamples = [], cacheColdQueueSamples = [], cacheColdIoSamples = [], cacheColdStorageSamples = [], 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 occupancyText(used, limit) { - if (used == null) return "-"; - const usedNumber = Number(used), limitNumber = Number(limit); - if (!Number.isFinite(limitNumber) || limitNumber <= 0) return fmtBytes(usedNumber); - return `${fmtBytes(usedNumber)} / ${fmtBytes(limitNumber)} · ${fmt(usedNumber / limitNumber * 100, 1)}%`; -} -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))}`; - const coldStorage = knownPair(c.cold_bytes, c.cold_limit_bytes, value => fmtBytes(value)); - const coldObjects = knownPair(c.cold_entries, c.cold_limit_entries, value => fmt(value)); - $("cache-cold-storage").textContent = envelope?.config.cache?.cold_enabled === true - ? `${coldStorage ?? "-"}${coldObjects == null ? "" : ` · ${coldObjects} ${t("cacheColdStorageObjects")}`}` - : t("notEnabled"); - $("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); } - if (c.transient_bytes != null) { cacheTransientSamples.push(Number(c.transient_bytes)); cacheTransientSamples = cacheTransientSamples.slice(-60); } - if (c.fill_bytes_active != null) { cacheFillSamples.push(Number(c.fill_bytes_active)); cacheFillSamples = cacheFillSamples.slice(-60); } - if (c.cold_queue_bytes != null) { cacheColdQueueSamples.push(Number(c.cold_queue_bytes)); cacheColdQueueSamples = cacheColdQueueSamples.slice(-60); } - const coldIoBytes = (Number(c.cold_write_bytes_active) || 0) + (Number(c.cold_read_bytes_active) || 0); - if (c.cold_write_bytes_active != null || c.cold_read_bytes_active != null) { cacheColdIoSamples.push(coldIoBytes); cacheColdIoSamples = cacheColdIoSamples.slice(-60); } - if (c.cold_bytes != null) { cacheColdStorageSamples.push(Number(c.cold_bytes)); cacheColdStorageSamples = cacheColdStorageSamples.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(" / ") : "-"; - const hotLimit = Number(c.hot_limit_bytes), hotPeak = Number.isFinite(hotLimit) && hotLimit > 0 ? Math.max(hotLimit, ...cacheMemorySamples) : null; - $("cache-memory-sparkline").setAttribute("points", sparklinePoints(cacheMemorySamples, 300, 60, 5, hotPeak)); - $("cache-chart-value").textContent = occupancyText(c.resident_bytes, c.hot_limit_bytes); - const transientLimit = Number(c.transient_limit_bytes); - const transientPeak = Number.isFinite(transientLimit) && transientLimit > 0 - ? Math.max(transientLimit, ...cacheTransientSamples, ...cacheFillSamples, ...cacheColdQueueSamples, ...cacheColdIoSamples) - : Math.max(1, ...cacheTransientSamples, ...cacheFillSamples, ...cacheColdQueueSamples, ...cacheColdIoSamples); - $("cache-transient-sparkline").setAttribute("points", sparklinePoints(cacheTransientSamples, 300, 60, 5, transientPeak)); - $("cache-fill-sparkline").setAttribute("points", sparklinePoints(cacheFillSamples, 300, 60, 5, transientPeak)); - $("cache-queue-sparkline").setAttribute("points", sparklinePoints(cacheColdQueueSamples, 300, 60, 5, transientPeak)); - $("cache-io-sparkline").setAttribute("points", sparklinePoints(cacheColdIoSamples, 300, 60, 5, transientPeak)); - $("cache-transient-chart-value").textContent = occupancyText(c.transient_bytes, c.transient_limit_bytes); - const coldEnabled = envelope?.config.cache?.cold_enabled === true; - const coldLimit = Number(c.cold_limit_bytes), coldPeak = Number.isFinite(coldLimit) && coldLimit > 0 ? Math.max(coldLimit, ...cacheColdStorageSamples) : null; - $("cache-cold-sparkline").setAttribute("points", sparklinePoints(cacheColdStorageSamples, 300, 60, 5, coldPeak)); - $("cache-cold-chart-value").textContent = coldEnabled ? occupancyText(c.cold_bytes, c.cold_limit_bytes) : t("notEnabled"); - 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)); - const memory = status.memory_accounting || {}; - const hotMemory = knownPair(memory.cache_hot_bytes, memory.cache_hot_limit_bytes, value => fmtBytes(value)); - const transientMemory = knownPair(memory.cache_transient_bytes, memory.cache_transient_limit_bytes, value => fmtBytes(value)); - const transformMemory = knownPair(memory.transform_reserved_bytes, memory.transform_budget_bytes, value => fmtBytes(value)); - const transformInflight = knownPair(memory.transform_inflight, memory.transform_max_inflight, value => fmt(value)); - setOptionalMetric("memory-cache-hot", hotMemory != null, hotMemory); - setOptionalMetric("memory-cache-transient", transientMemory != null, transientMemory); - setOptionalMetric("memory-transform", transformMemory != null, transformMemory); - setOptionalMetric("memory-transform-inflight", transformInflight != null, transformInflight); - setOptionalMetric("memory-non-cache-rss", memory.non_cache_rss_bytes != null, fmtBytes(memory.non_cache_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.25.0"; - 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-auto-trim-enabled").checked = c.hot_auto_trim_enabled !== false; - $("cache-auto-trim-after").value = c.hot_auto_trim_after_seconds ?? 300; - $("cache-auto-trim-target").value = c.hot_auto_trim_target_percent ?? 80; - $("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; - setCacheMemoryPresetSelection(["conservative", "recommended", "performance"].includes(c.memory_preset) ? c.memory_preset : null); - setCacheColdPresetSelection(c.cold_preset === "recommended" ? "recommended" : null); - syncColdCachePolicy(); - syncAdvancedCachePolicy(); - updateCacheMemorySizing(); - } - if (!settingsDirty.has("transform-form")) { - const tr = config.transform || {}; - setTransformPresetSelection(["conservative", "recommended", "performance", "uber"].includes(tr.preset) ? tr.preset : null); - $("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; - $("transform-memory-budget").value = (tr.memory_budget_bytes ?? 134217728) / 1048576; - $("transform-max-inflight").value = tr.max_inflight ?? 32; - updateTransformResourceSizing(); - } - if (!settingsDirty.has("logging-form")) { - const l = config.logging || {}, file = l.file || {}, logstash = l.logstash || {}, loki = l.loki || {}, format = l.format || {}; - $("log-capture-user-agent").checked = l.capture_user_agent !== false; - $("log-capture-country").checked = l.capture_country === true; - $("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 || {}); - } - if (!settingsDirty.has("error-log-form")) { - const errorLog = config.error_log || {}; - $("error-log-enabled").checked = !!errorLog.enabled; - $("error-log-path").value = errorLog.path || "/var/log/rustproxy/error.log"; - $("error-log-format").value = ["JSON", "RAW"].includes(errorLog.format) ? errorLog.format : "JSON"; - syncErrorLogMode(); - } - const sink = latestStatus?.logging?.sinks, errorSink = latestStatus?.logging?.error_file; - $("logging-status").textContent = sink ? `${t("loggingQueue")}: ${fmt(sink.queue_dropped)} ${t("loggingDropped")} / ${fmt(sink.sent)} ${t("loggingSent")} / ${fmt(sink.errors)} ${t("errors")} · ${t("loggingCapacity")}: ${fmt(sink.queue_capacity)}` : ""; - $("error-log-status").textContent = errorSink ? `${t("loggingQueue")}: ${fmt(errorSink.queue_dropped)} ${t("loggingDropped")} / ${fmt(errorSink.written)} ${t("loggingSent")} / ${fmt(errorSink.errors)} ${t("errors")} · ${t("loggingCapacity")}: ${fmt(errorSink.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 cacheMemoryPresetName(kind) { - return kind ? t({ conservative: "cachePresetConservative", recommended: "cachePresetRecommended", performance: "cachePresetPerformance" }[kind]) : t("cachePresetCustom"); -} -function setCacheMemoryPresetSelection(kind) { - selectedCacheMemoryPreset = ["conservative", "recommended", "performance"].includes(kind) ? kind : null; - document.querySelectorAll("[data-cache-memory-preset]").forEach(button => { - const active = button.dataset.cacheMemoryPreset === selectedCacheMemoryPreset; - button.classList.toggle("primary", active); button.classList.toggle("outline", !active); - button.setAttribute("aria-pressed", String(active)); - }); - const badge = $("cache-memory-preset-selected"); - if (badge) badge.textContent = `${t("cachePresetSelected")}: ${cacheMemoryPresetName(selectedCacheMemoryPreset)}`; -} -function setCacheColdPresetSelection(kind) { - selectedCacheColdPreset = kind === "recommended" ? "recommended" : null; - document.querySelectorAll("[data-cache-cold-preset]").forEach(button => { - const active = button.dataset.cacheColdPreset === selectedCacheColdPreset; - button.classList.toggle("primary", active); button.classList.toggle("outline", !active); - button.setAttribute("aria-pressed", String(active)); - }); - const badge = $("cache-cold-preset-selected"); - if (badge) badge.textContent = `${t("cachePresetSelected")}: ${selectedCacheColdPreset ? t("cachePresetRecommended") : t("cachePresetCustom")}`; -} -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; - setCacheMemoryPresetSelection(kind); - setSettingsDirty("cache-form"); updateCacheMemorySizing(); -} -function applyRecommendedColdCachePreset() { - const transient = Number($("cache-transient-memory").value); - const queue = Number.isFinite(transient) && transient > 0 ? Math.max(0.0625, Math.floor((transient * .5) / 8) * 8 || Math.min(transient, 8)) : 32; - $("cache-cold-enabled").checked = true; - if (!$("cache-cold-path").value.trim()) $("cache-cold-path").value = "cache/cold"; - $("cache-cold-on-idle").checked = true; $("cache-cold-on-age").checked = false; - $("cache-cold-on-lru").checked = true; $("cache-cold-on-shutdown").checked = true; - $("cache-cold-after").value = 300; $("cache-cold-max-hot").value = 3600; - $("cache-cold-gc-interval").value = 60; $("cache-cold-gc-max-delete").value = 256; - $("cache-cold-queue-bytes").value = Math.min(queue, transient || queue); - setCacheColdPresetSelection("recommended"); - syncColdCachePolicy(); setSettingsDirty("cache-form"); updateCacheMemorySizing(); -} -function transformPresetValues(kind) { - return { - conservative: { memoryMiB: 32, bodyKiB: 1024, inflight: 8 }, - recommended: { memoryMiB: 128, bodyKiB: 2048, inflight: 32 }, - performance: { memoryMiB: 512, bodyKiB: 8192, inflight: 64 }, - uber: { memoryMiB: 1024, bodyKiB: 32768, inflight: 128 } - }[kind] || { memoryMiB: 128, bodyKiB: 2048, inflight: 32 }; -} -function transformPresetName(kind) { - return kind ? t({ conservative: "transformPresetConservative", recommended: "transformPresetRecommended", performance: "transformPresetPerformance", uber: "transformPresetUber" }[kind]) : t("cachePresetCustom"); -} -function setTransformPresetSelection(kind) { - selectedTransformPreset = ["conservative", "recommended", "performance", "uber"].includes(kind) ? kind : null; - document.querySelectorAll("[data-transform-preset]").forEach(button => { - const active = button.dataset.transformPreset === selectedTransformPreset; - button.classList.toggle("primary", active); button.classList.toggle("outline", !active); - button.setAttribute("aria-pressed", String(active)); - }); - const badge = $("transform-preset-selected"); - if (badge) badge.textContent = `${t("cachePresetSelected")}: ${transformPresetName(selectedTransformPreset)}`; -} -function updateTransformResourceSizing() { - const warning = $("transform-memory-warning"); if (!warning) return; - const memoryMiB = Number($("transform-memory-budget")?.value), bodyKiB = Number($("transform-max-body")?.value), inflight = Number($("transform-max-inflight")?.value); - const detected = effectiveCacheMemory(); - const budgetBytes = memoryMiB * 1048576, bodyBytes = bodyKiB * 1024; - const worstEncodedEstimate = bodyBytes * 3; - const budgetCannotFitMax = Number.isFinite(budgetBytes) && Number.isFinite(worstEncodedEstimate) && budgetBytes < worstEncodedEstimate; - const highHostShare = detected && Number.isFinite(budgetBytes) && budgetBytes > detected.bytes * .5; - warning.hidden = !(budgetCannotFitMax || highHostShare); - if (budgetCannotFitMax) warning.textContent = t("transformBudgetTooSmallWarning"); - else if (highHostShare) warning.textContent = `${t("transformBudgetHighWarning")} ${fmtBytes(budgetBytes)} / ${fmtBytes(detected.bytes)}.`; - const summary = $("transform-preset-summary"); - if (summary) summary.textContent = `${t("transformResourceCurrent")}: ${Number.isFinite(memoryMiB) ? memoryMiB : "-"} MiB · ${Number.isFinite(bodyKiB) ? bodyKiB : "-"} KiB · ${t("transformMaxInflightShort")} ${Number.isFinite(inflight) ? inflight : "-"}`; -} -function applyTransformPreset(kind) { - const preset = transformPresetValues(kind); - $("transform-memory-budget").value = preset.memoryMiB; - $("transform-max-body").value = preset.bodyKiB; - $("transform-max-inflight").value = preset.inflight; - setTransformPresetSelection(kind); - setSettingsDirty("transform-form"); updateTransformResourceSizing(); -} -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 syncErrorLogMode() { - const disabled = !$("error-log-enabled").checked; - $("error-log-path").disabled = disabled; - $("error-log-format").disabled = disabled; -} - -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, memory_preset: selectedCacheMemoryPreset, 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), hot_auto_trim_enabled: $("cache-auto-trim-enabled").checked, hot_auto_trim_after_seconds: Number($("cache-auto-trim-after").value), hot_auto_trim_target_percent: Number($("cache-auto-trim-target").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, 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, - preset: selectedTransformPreset, - 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), - memory_budget_bytes: Math.round(Number($("transform-memory-budget").value) * 1048576), - max_inflight: Number($("transform-max-inflight").value) - }; -}, "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", "user_agent", "country", "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", http_user_agent: "user_agent", country: "country", 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", "user_agent.original": "user_agent", "client.geo.country_iso_code": "country", "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", user_agent: "Mozilla/5.0 RustProxy-Test", country: "PL", 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"); -} -const selectLoggingSection = next => ({ logging: next.logging, error_log: next.error_log }); -bindSettingsForm("logging-form", next => { - const parseObject = id => parseJsonObjectValue($(id).value, $(id)); - next.logging = { - capture_user_agent: $("log-capture-user-agent").checked, - capture_country: $("log-capture-country").checked, - 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", selectLoggingSection); -bindSettingsForm("error-log-form", next => { - next.error_log = { - enabled: $("error-log-enabled").checked, - path: $("error-log-path").value.trim(), - format: $("error-log-format").value - }; -}, "logging", selectLoggingSection); -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)); -$("error-log-enabled").addEventListener("change", syncErrorLogMode); -$("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))); -document.querySelectorAll("[data-cache-cold-preset]").forEach(button => button.addEventListener("click", applyRecommendedColdCachePreset)); -document.querySelectorAll("[data-transform-preset]").forEach(button => button.addEventListener("click", () => applyTransformPreset(button.dataset.transformPreset))); -for (const id of ["cache-memory", "cache-transient-memory", "cache-object", "cache-entries", "cache-eviction-limit"]) $(id).addEventListener("input", () => { setCacheMemoryPresetSelection(null); updateCacheMemorySizing(); }); -$("cache-cold-queue-bytes").addEventListener("input", () => { setCacheMemoryPresetSelection(null); setCacheColdPresetSelection(null); updateCacheMemorySizing(); }); -for (const id of ["cache-cold-enabled", "cache-cold-on-idle", "cache-cold-on-age", "cache-cold-on-lru", "cache-cold-on-shutdown", "cache-cold-after", "cache-cold-max-hot", "cache-cold-gc-interval", "cache-cold-gc-max-delete"]) { - for (const event of ["input", "change"]) $(id).addEventListener(event, () => setCacheColdPresetSelection(null)); -} -for (const id of ["transform-memory-budget", "transform-max-body", "transform-max-inflight"]) $(id).addEventListener("input", () => { setTransformPresetSelection(null); updateTransformResourceSizing(); }); -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/app/core.js b/src/admin/web/app/core.js new file mode 100644 index 0000000..a66d5f9 --- /dev/null +++ b/src/admin/web/app/core.js @@ -0,0 +1,419 @@ +"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", "web-server": "/web-server", "docker-vhosts": "/docker-vhosts", redirects: "/redirects", notfound: "/404-hosts", streams: "/streams", "acl-profiles": "/acl-profiles", tls: "/tls", traffic: "/traffic", 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(); +let selectedCacheMemoryPreset = null, selectedCacheColdPreset = null, selectedTransformPreset = null; +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/traffic")) return "traffic"; + 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 === "traffic") void window.rustProxyTrafficPanel?.refresh(); 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.rustProxyWebServer?.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.rustProxyTrafficPanel?.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.rustProxyWebServer?.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); +function showStartupFailure(error) { + // index.html is served only after the server has accepted the browser session. + // A module/configuration error must therefore never masquerade as a logout. + $("login").hidden = true; + $("workspace").hidden = false; + $("logout").hidden = !authenticated; + document.documentElement.classList.remove("app-loading"); + toast(error?.message || String(error), true); +} diff --git a/src/admin/web/app/runtime.js b/src/admin/web/app/runtime.js new file mode 100644 index 0000000..8133956 --- /dev/null +++ b/src/admin/web/app/runtime.js @@ -0,0 +1,381 @@ +(async () => { + try { + // Revalidate the session first. The anonymous document has its own login.js; + // app.js belongs to the authenticated shell and must not fall back to its + // legacy embedded sign-in form when another startup step fails. + const info = await api("/api/auth/session"); + await loadLanguages(); + setupRequired = false; + translate(); + syncAuthForm(); + await activate(info); + } catch (e) { + if (e.status === 401) { + location.replace(`${location.pathname}${location.search}`); + return; + } + showStartupFailure(e); + } +})(); + +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 = [], cacheTransientSamples = [], cacheFillSamples = [], cacheColdQueueSamples = [], cacheColdIoSamples = [], cacheColdStorageSamples = [], 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 occupancyText(used, limit) { + if (used == null) return "-"; + const usedNumber = Number(used), limitNumber = Number(limit); + if (!Number.isFinite(limitNumber) || limitNumber <= 0) return fmtBytes(usedNumber); + return `${fmtBytes(usedNumber)} / ${fmtBytes(limitNumber)} · ${fmt(usedNumber / limitNumber * 100, 1)}%`; +} +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))}`; + const coldStorage = knownPair(c.cold_bytes, c.cold_limit_bytes, value => fmtBytes(value)); + const coldObjects = knownPair(c.cold_entries, c.cold_limit_entries, value => fmt(value)); + $("cache-cold-storage").textContent = envelope?.config.cache?.cold_enabled === true + ? `${coldStorage ?? "-"}${coldObjects == null ? "" : ` · ${coldObjects} ${t("cacheColdStorageObjects")}`}` + : t("notEnabled"); + $("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); } + if (c.transient_bytes != null) { cacheTransientSamples.push(Number(c.transient_bytes)); cacheTransientSamples = cacheTransientSamples.slice(-60); } + if (c.fill_bytes_active != null) { cacheFillSamples.push(Number(c.fill_bytes_active)); cacheFillSamples = cacheFillSamples.slice(-60); } + if (c.cold_queue_bytes != null) { cacheColdQueueSamples.push(Number(c.cold_queue_bytes)); cacheColdQueueSamples = cacheColdQueueSamples.slice(-60); } + const coldIoBytes = (Number(c.cold_write_bytes_active) || 0) + (Number(c.cold_read_bytes_active) || 0); + if (c.cold_write_bytes_active != null || c.cold_read_bytes_active != null) { cacheColdIoSamples.push(coldIoBytes); cacheColdIoSamples = cacheColdIoSamples.slice(-60); } + if (c.cold_bytes != null) { cacheColdStorageSamples.push(Number(c.cold_bytes)); cacheColdStorageSamples = cacheColdStorageSamples.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(" / ") : "-"; + const hotLimit = Number(c.hot_limit_bytes), hotPeak = Number.isFinite(hotLimit) && hotLimit > 0 ? Math.max(hotLimit, ...cacheMemorySamples) : null; + $("cache-memory-sparkline").setAttribute("points", sparklinePoints(cacheMemorySamples, 300, 60, 5, hotPeak)); + $("cache-chart-value").textContent = occupancyText(c.resident_bytes, c.hot_limit_bytes); + const transientLimit = Number(c.transient_limit_bytes); + const transientPeak = Number.isFinite(transientLimit) && transientLimit > 0 + ? Math.max(transientLimit, ...cacheTransientSamples, ...cacheFillSamples, ...cacheColdQueueSamples, ...cacheColdIoSamples) + : Math.max(1, ...cacheTransientSamples, ...cacheFillSamples, ...cacheColdQueueSamples, ...cacheColdIoSamples); + $("cache-transient-sparkline").setAttribute("points", sparklinePoints(cacheTransientSamples, 300, 60, 5, transientPeak)); + $("cache-fill-sparkline").setAttribute("points", sparklinePoints(cacheFillSamples, 300, 60, 5, transientPeak)); + $("cache-queue-sparkline").setAttribute("points", sparklinePoints(cacheColdQueueSamples, 300, 60, 5, transientPeak)); + $("cache-io-sparkline").setAttribute("points", sparklinePoints(cacheColdIoSamples, 300, 60, 5, transientPeak)); + $("cache-transient-chart-value").textContent = occupancyText(c.transient_bytes, c.transient_limit_bytes); + const coldEnabled = envelope?.config.cache?.cold_enabled === true; + const coldLimit = Number(c.cold_limit_bytes), coldPeak = Number.isFinite(coldLimit) && coldLimit > 0 ? Math.max(coldLimit, ...cacheColdStorageSamples) : null; + $("cache-cold-sparkline").setAttribute("points", sparklinePoints(cacheColdStorageSamples, 300, 60, 5, coldPeak)); + $("cache-cold-chart-value").textContent = coldEnabled ? occupancyText(c.cold_bytes, c.cold_limit_bytes) : t("notEnabled"); + 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)); + const memory = status.memory_accounting || {}; + const hotMemory = knownPair(memory.cache_hot_bytes, memory.cache_hot_limit_bytes, value => fmtBytes(value)); + const transientMemory = knownPair(memory.cache_transient_bytes, memory.cache_transient_limit_bytes, value => fmtBytes(value)); + const transformMemory = knownPair(memory.transform_reserved_bytes, memory.transform_budget_bytes, value => fmtBytes(value)); + const transformInflight = knownPair(memory.transform_inflight, memory.transform_max_inflight, value => fmt(value)); + setOptionalMetric("memory-cache-hot", hotMemory != null, hotMemory); + setOptionalMetric("memory-cache-transient", transientMemory != null, transientMemory); + setOptionalMetric("memory-transform", transformMemory != null, transformMemory); + setOptionalMetric("memory-transform-inflight", transformInflight != null, transformInflight); + setOptionalMetric("memory-non-cache-rss", memory.non_cache_rss_bytes != null, fmtBytes(memory.non_cache_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 }; + }); +} diff --git a/src/admin/web/app/settings.js b/src/admin/web/app/settings.js new file mode 100644 index 0000000..a1b5596 --- /dev/null +++ b/src/admin/web/app/settings.js @@ -0,0 +1,402 @@ +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.25.1"; + 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-auto-trim-enabled").checked = c.hot_auto_trim_enabled !== false; + $("cache-auto-trim-after").value = c.hot_auto_trim_after_seconds ?? 300; + $("cache-auto-trim-target").value = c.hot_auto_trim_target_percent ?? 80; + $("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; + setCacheMemoryPresetSelection(["conservative", "recommended", "performance"].includes(c.memory_preset) ? c.memory_preset : null); + setCacheColdPresetSelection(c.cold_preset === "recommended" ? "recommended" : null); + syncColdCachePolicy(); + syncAdvancedCachePolicy(); + updateCacheMemorySizing(); + } + if (!settingsDirty.has("transform-form")) { + const tr = config.transform || {}; + setTransformPresetSelection(["conservative", "recommended", "performance", "uber"].includes(tr.preset) ? tr.preset : null); + $("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; + $("transform-memory-budget").value = (tr.memory_budget_bytes ?? 134217728) / 1048576; + $("transform-max-inflight").value = tr.max_inflight ?? 32; + updateTransformResourceSizing(); + } + if (!settingsDirty.has("logging-form")) { + const l = config.logging || {}, file = l.file || {}, logstash = l.logstash || {}, loki = l.loki || {}, format = l.format || {}; + $("log-capture-user-agent").checked = l.capture_user_agent !== false; + $("log-capture-country").checked = l.capture_country === true; + $("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 || {}); + } + if (!settingsDirty.has("error-log-form")) { + const errorLog = config.error_log || {}; + $("error-log-enabled").checked = !!errorLog.enabled; + $("error-log-path").value = errorLog.path || "/var/log/rustproxy/error.log"; + $("error-log-format").value = ["JSON", "RAW"].includes(errorLog.format) ? errorLog.format : "JSON"; + syncErrorLogMode(); + } + const sink = latestStatus?.logging?.sinks, errorSink = latestStatus?.logging?.error_file; + $("logging-status").textContent = sink ? `${t("loggingQueue")}: ${fmt(sink.queue_dropped)} ${t("loggingDropped")} / ${fmt(sink.sent)} ${t("loggingSent")} / ${fmt(sink.errors)} ${t("errors")} · ${t("loggingCapacity")}: ${fmt(sink.queue_capacity)}` : ""; + $("error-log-status").textContent = errorSink ? `${t("loggingQueue")}: ${fmt(errorSink.queue_dropped)} ${t("loggingDropped")} / ${fmt(errorSink.written)} ${t("loggingSent")} / ${fmt(errorSink.errors)} ${t("errors")} · ${t("loggingCapacity")}: ${fmt(errorSink.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 cacheMemoryPresetName(kind) { + return kind ? t({ conservative: "cachePresetConservative", recommended: "cachePresetRecommended", performance: "cachePresetPerformance" }[kind]) : t("cachePresetCustom"); +} +function setCacheMemoryPresetSelection(kind) { + selectedCacheMemoryPreset = ["conservative", "recommended", "performance"].includes(kind) ? kind : null; + document.querySelectorAll("[data-cache-memory-preset]").forEach(button => { + const active = button.dataset.cacheMemoryPreset === selectedCacheMemoryPreset; + button.classList.toggle("primary", active); button.classList.toggle("outline", !active); + button.setAttribute("aria-pressed", String(active)); + }); + const badge = $("cache-memory-preset-selected"); + if (badge) badge.textContent = `${t("cachePresetSelected")}: ${cacheMemoryPresetName(selectedCacheMemoryPreset)}`; +} +function setCacheColdPresetSelection(kind) { + selectedCacheColdPreset = kind === "recommended" ? "recommended" : null; + document.querySelectorAll("[data-cache-cold-preset]").forEach(button => { + const active = button.dataset.cacheColdPreset === selectedCacheColdPreset; + button.classList.toggle("primary", active); button.classList.toggle("outline", !active); + button.setAttribute("aria-pressed", String(active)); + }); + const badge = $("cache-cold-preset-selected"); + if (badge) badge.textContent = `${t("cachePresetSelected")}: ${selectedCacheColdPreset ? t("cachePresetRecommended") : t("cachePresetCustom")}`; +} +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; + setCacheMemoryPresetSelection(kind); + setSettingsDirty("cache-form"); updateCacheMemorySizing(); +} +function applyRecommendedColdCachePreset() { + const transient = Number($("cache-transient-memory").value); + const queue = Number.isFinite(transient) && transient > 0 ? Math.max(0.0625, Math.floor((transient * .5) / 8) * 8 || Math.min(transient, 8)) : 32; + $("cache-cold-enabled").checked = true; + if (!$("cache-cold-path").value.trim()) $("cache-cold-path").value = "cache/cold"; + $("cache-cold-on-idle").checked = true; $("cache-cold-on-age").checked = false; + $("cache-cold-on-lru").checked = true; $("cache-cold-on-shutdown").checked = true; + $("cache-cold-after").value = 300; $("cache-cold-max-hot").value = 3600; + $("cache-cold-gc-interval").value = 60; $("cache-cold-gc-max-delete").value = 256; + $("cache-cold-queue-bytes").value = Math.min(queue, transient || queue); + setCacheColdPresetSelection("recommended"); + syncColdCachePolicy(); setSettingsDirty("cache-form"); updateCacheMemorySizing(); +} +function transformPresetValues(kind) { + return { + conservative: { memoryMiB: 32, bodyKiB: 1024, inflight: 8 }, + recommended: { memoryMiB: 128, bodyKiB: 2048, inflight: 32 }, + performance: { memoryMiB: 512, bodyKiB: 8192, inflight: 64 }, + uber: { memoryMiB: 1024, bodyKiB: 32768, inflight: 128 } + }[kind] || { memoryMiB: 128, bodyKiB: 2048, inflight: 32 }; +} +function transformPresetName(kind) { + return kind ? t({ conservative: "transformPresetConservative", recommended: "transformPresetRecommended", performance: "transformPresetPerformance", uber: "transformPresetUber" }[kind]) : t("cachePresetCustom"); +} +function setTransformPresetSelection(kind) { + selectedTransformPreset = ["conservative", "recommended", "performance", "uber"].includes(kind) ? kind : null; + document.querySelectorAll("[data-transform-preset]").forEach(button => { + const active = button.dataset.transformPreset === selectedTransformPreset; + button.classList.toggle("primary", active); button.classList.toggle("outline", !active); + button.setAttribute("aria-pressed", String(active)); + }); + const badge = $("transform-preset-selected"); + if (badge) badge.textContent = `${t("cachePresetSelected")}: ${transformPresetName(selectedTransformPreset)}`; +} +function updateTransformResourceSizing() { + const warning = $("transform-memory-warning"); if (!warning) return; + const memoryMiB = Number($("transform-memory-budget")?.value), bodyKiB = Number($("transform-max-body")?.value), inflight = Number($("transform-max-inflight")?.value); + const detected = effectiveCacheMemory(); + const budgetBytes = memoryMiB * 1048576, bodyBytes = bodyKiB * 1024; + const worstEncodedEstimate = bodyBytes * 3; + const budgetCannotFitMax = Number.isFinite(budgetBytes) && Number.isFinite(worstEncodedEstimate) && budgetBytes < worstEncodedEstimate; + const highHostShare = detected && Number.isFinite(budgetBytes) && budgetBytes > detected.bytes * .5; + warning.hidden = !(budgetCannotFitMax || highHostShare); + if (budgetCannotFitMax) warning.textContent = t("transformBudgetTooSmallWarning"); + else if (highHostShare) warning.textContent = `${t("transformBudgetHighWarning")} ${fmtBytes(budgetBytes)} / ${fmtBytes(detected.bytes)}.`; + const summary = $("transform-preset-summary"); + if (summary) summary.textContent = `${t("transformResourceCurrent")}: ${Number.isFinite(memoryMiB) ? memoryMiB : "-"} MiB · ${Number.isFinite(bodyKiB) ? bodyKiB : "-"} KiB · ${t("transformMaxInflightShort")} ${Number.isFinite(inflight) ? inflight : "-"}`; +} +function applyTransformPreset(kind) { + const preset = transformPresetValues(kind); + $("transform-memory-budget").value = preset.memoryMiB; + $("transform-max-body").value = preset.bodyKiB; + $("transform-max-inflight").value = preset.inflight; + setTransformPresetSelection(kind); + setSettingsDirty("transform-form"); updateTransformResourceSizing(); +} +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 syncErrorLogMode() { + const disabled = !$("error-log-enabled").checked; + $("error-log-path").disabled = disabled; + $("error-log-format").disabled = disabled; +} + +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, memory_preset: selectedCacheMemoryPreset, 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), hot_auto_trim_enabled: $("cache-auto-trim-enabled").checked, hot_auto_trim_after_seconds: Number($("cache-auto-trim-after").value), hot_auto_trim_target_percent: Number($("cache-auto-trim-target").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, 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, + preset: selectedTransformPreset, + 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), + memory_budget_bytes: Math.round(Number($("transform-memory-budget").value) * 1048576), + max_inflight: Number($("transform-max-inflight").value) + }; +}, "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", "user_agent", "country", "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", http_user_agent: "user_agent", country: "country", 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", "user_agent.original": "user_agent", "client.geo.country_iso_code": "country", "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", user_agent: "Mozilla/5.0 RustProxy-Test", country: "PL", 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"); +} +const selectLoggingSection = next => ({ logging: next.logging, error_log: next.error_log }); +bindSettingsForm("logging-form", next => { + const parseObject = id => parseJsonObjectValue($(id).value, $(id)); + next.logging = { + capture_user_agent: $("log-capture-user-agent").checked, + capture_country: $("log-capture-country").checked, + 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", selectLoggingSection); +bindSettingsForm("error-log-form", next => { + next.error_log = { + enabled: $("error-log-enabled").checked, + path: $("error-log-path").value.trim(), + format: $("error-log-format").value + }; +}, "logging", selectLoggingSection); +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)); +$("error-log-enabled").addEventListener("change", syncErrorLogMode); +$("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))); +document.querySelectorAll("[data-cache-cold-preset]").forEach(button => button.addEventListener("click", applyRecommendedColdCachePreset)); +document.querySelectorAll("[data-transform-preset]").forEach(button => button.addEventListener("click", () => applyTransformPreset(button.dataset.transformPreset))); +for (const id of ["cache-memory", "cache-transient-memory", "cache-object", "cache-entries", "cache-eviction-limit"]) $(id).addEventListener("input", () => { setCacheMemoryPresetSelection(null); updateCacheMemorySizing(); }); +$("cache-cold-queue-bytes").addEventListener("input", () => { setCacheMemoryPresetSelection(null); setCacheColdPresetSelection(null); updateCacheMemorySizing(); }); +for (const id of ["cache-cold-enabled", "cache-cold-on-idle", "cache-cold-on-age", "cache-cold-on-lru", "cache-cold-on-shutdown", "cache-cold-after", "cache-cold-max-hot", "cache-cold-gc-interval", "cache-cold-gc-max-delete"]) { + for (const event of ["input", "change"]) $(id).addEventListener(event, () => setCacheColdPresetSelection(null)); +} +for (const id of ["transform-memory-budget", "transform-max-body", "transform-max-inflight"]) $(id).addEventListener("input", () => { setTransformPresetSelection(null); updateTransformResourceSizing(); }); +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/index.html b/src/admin/web/index.html index c8176bc..b7efdc7 100644 --- a/src/admin/web/index.html +++ b/src/admin/web/index.html @@ -967,34 +967,50 @@
-
-
Requests-Since process start
-
Errors--
-
Response bytes--
-
Mean latency--
+ +
+
+
Requests-Since process start
+
Errors--
+
Response bytes--
+
Mean latency--
+
+
+
BOUNDED / ASYNC

Traffic dimensions

+

Top lists are approximate and bounded so high-cardinality traffic cannot grow proxy memory without limit.

+

+
+
+

Methods

    +

    Status classes

      +

      Cache outcomes

        +
        -
        -
        BOUNDED / ASYNC

        Traffic dimensions

        -

        Top lists are approximate and bounded so high-cardinality traffic cannot grow proxy memory without limit.

        -

        -
        -
        -

        Top countries

          -

          Top User-Agents

            -

            Top client IPs

              -

              Methods

                -

                Status classes

                  -

                  Cache outcomes

                    + -
                    -

                    Top countries

                    ValueRequests

                    Country collection is disabled or no GeoIP data is available.

                    -

                    Top User-Agents

                    User-AgentRequests

                    No User-Agent data collected.

                    -

                    Top client IPs

                    Client IPRequests

                    No traffic data yet.

                    -

                    Top paths

                    ValueRequests

                    No traffic data yet.

                    -

                    Top routes

                    ValueRequests

                    No traffic data yet.

                    -

                    Methods

                    ValueRequests

                    No traffic data yet.

                    -

                    Status classes

                    ValueRequests

                    No traffic data yet.

                    -

                    Cache outcomes

                    ValueRequests

                    No traffic data yet.

                    + +
                    -
                    RustProxy 0.25.0API - / +
                    RustProxy 0.25.1API - / dropped: 0
                    diff --git a/src/admin/web/style.css b/src/admin/web/style.css index c00416f..b97c026 100644 --- a/src/admin/web/style.css +++ b/src/admin/web/style.css @@ -4637,6 +4637,68 @@ dialog .window-heading { flex: 0 0 auto } +#panel-traffic .traffic-tabs { + margin: 12px 0 14px +} + +#panel-traffic [data-traffic-page] { + min-width: 0 +} + +.traffic-wide-chart { + grid-column: span 2 +} + +.traffic-bar-chart { + display: grid; + gap: 8px; + min-width: 0 +} + +.traffic-bar-row { + display: grid; + grid-template-columns: minmax(120px, 1.5fr) minmax(120px, 3fr) minmax(72px, auto); + gap: 10px; + align-items: center; + min-width: 0 +} + +.traffic-bar-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: 11px var(--mono) +} + +.traffic-bar-track { + height: 10px; + border-radius: 999px; + background: var(--panel2); + box-shadow: inset 0 0 0 1px var(--line); + overflow: hidden +} + +.traffic-bar-fill { + display: block; + height: 100%; + min-width: 2px; + border-radius: inherit; + background: var(--accent) +} + +.traffic-bar-value { + text-align: right; + font: 700 10px var(--mono) +} + +@media(max-width:1100px) { + .traffic-wide-chart { grid-column: span 1 } +} + +@media(max-width:650px) { + .traffic-bar-row { grid-template-columns: minmax(88px, 1.2fr) minmax(90px, 2fr) minmax(60px, auto); gap: 7px } +} + .logging-grid label { min-width: 0 } diff --git a/src/admin/web/traffic-panel.js b/src/admin/web/traffic-panel.js index 13fcb50..4d66f31 100644 --- a/src/admin/web/traffic-panel.js +++ b/src/admin/web/traffic-panel.js @@ -1,135 +1,127 @@ "use strict"; (() => { - let loading = false, timer = null, lastData = null; + let loading = false, timer = null, lastData = null, activeTab = "overview"; const chartPalette = ["var(--accent)", "var(--green)", "var(--blue)", "var(--transform-chart)", "var(--red)", "var(--notice-text)", "var(--border-hover)", "var(--separator)"]; const scope = () => $("traffic-scope")?.value || "*"; const number = value => fmt(Number(value) || 0); + + function selectTab(name) { + activeTab = name; + document.querySelectorAll("[data-traffic-tab]").forEach(button => { + const active = button.dataset.trafficTab === name; + button.classList.toggle("active", active); + button.setAttribute("aria-selected", String(active)); + button.tabIndex = active ? 0 : -1; + }); + document.querySelectorAll("[data-traffic-page]").forEach(page => { page.hidden = page.dataset.trafficPage !== name; }); + } + function configure(data) { const selected = scope(), options = [el("option", t("logAllVhosts"))]; options[0].value = "*"; for (const vhost of data.vhosts || []) { const option = el("option", `${vhost.id} / ${(vhost.hosts || []).join(", ")}`); - option.value = vhost.id; - options.push(option); + option.value = vhost.id; options.push(option); } $("traffic-scope").replaceChildren(...options); $("traffic-scope").value = options.some(option => option.value === selected) ? selected : "*"; } - function renderTop(id, entries, enabled = true) { - const body = $(`traffic-${id}`), empty = $(`traffic-${id}-empty`); - const values = enabled ? (entries || []) : []; - body.replaceChildren(...values.map(item => { - const row = el("tr"); - const value = el("td", item.value || "-"); value.title = item.value || "-"; - row.append(value, el("td", number(item.count))); - return row; - })); - empty.hidden = values.length > 0; + + function chartEntries(entries, enabled = true) { + return (enabled ? (entries || []) : []).filter(item => Number(item.count) > 0); } + function renderDonut(id, entries, enabled = true) { const chart = $(`traffic-chart-${id}`), legend = $(`traffic-chart-${id}-legend`), totalNode = $(`traffic-chart-${id}-total`); if (!chart || !legend || !totalNode) return; - const values = (enabled ? (entries || []) : []).filter(item => Number(item.count) > 0); + const empty = $(`traffic-chart-${id}-empty`), values = chartEntries(entries, enabled); const total = values.reduce((sum, item) => sum + Number(item.count || 0), 0); - totalNode.textContent = number(total); + totalNode.textContent = number(total); if (empty) empty.hidden = total > 0; if (!total) { - chart.style.background = "var(--panel2)"; - chart.setAttribute("aria-label", t("trafficNoData")); - legend.replaceChildren(el("li", t("trafficNoData"), "fine")); - return; + chart.style.background = "var(--panel2)"; chart.setAttribute("aria-label", t("trafficNoData")); + legend.replaceChildren(el("li", t("trafficNoData"), "fine")); return; } const shown = values.slice(0, 7).map(item => ({ value: item.value || "-", count: Number(item.count) || 0 })); - if (values.length > shown.length) { - shown.push({ value: t("trafficOther"), count: values.slice(shown.length).reduce((sum, item) => sum + Number(item.count || 0), 0) }); - } + if (values.length > shown.length) shown.push({ value: t("trafficOther"), count: values.slice(shown.length).reduce((sum, item) => sum + Number(item.count || 0), 0) }); let cursor = 0; - const segments = shown.map((item, index) => { - const start = cursor / total * 100; - cursor += item.count; - const end = cursor / total * 100; + chart.style.background = `conic-gradient(${shown.map((item, index) => { + const start = cursor / total * 100; cursor += item.count; const end = cursor / total * 100; return `${chartPalette[index % chartPalette.length]} ${start.toFixed(4)}% ${end.toFixed(4)}%`; - }); - chart.style.background = `conic-gradient(${segments.join(", ")})`; + }).join(", ")})`; const heading = chart.closest(".traffic-chart-card")?.querySelector("h3")?.textContent || t("trafficDimensionsTitle"); chart.setAttribute("aria-label", `${heading}: ${shown.map(item => `${item.value} ${Math.round(item.count / total * 100)}%`).join(", ")}`); legend.replaceChildren(...shown.map((item, index) => { const row = el("li"), label = el("span"), swatch = el("i", undefined, "traffic-chart-swatch"); swatch.style.background = chartPalette[index % chartPalette.length]; const text = el("span", item.value || "-", "traffic-chart-label"); text.title = item.value || "-"; - label.append(swatch, text); - row.append(label, el("b", `${(item.count / total * 100).toFixed(1)}%`)); - return row; + label.append(swatch, text); row.append(label, el("b", `${(item.count / total * 100).toFixed(1)}%`)); return row; })); } + + function renderBars(id, entries, enabled = true, formatter = number) { + const chart = $(`traffic-chart-${id}`); if (!chart) return; + const empty = $(`traffic-chart-${id}-empty`), values = chartEntries(entries, enabled).slice(0, 12); + const total = values.reduce((sum, item) => sum + Number(item.count || 0), 0), peak = Math.max(1, ...values.map(item => Number(item.count) || 0)); + if (empty) empty.hidden = values.length > 0; + chart.replaceChildren(...values.map(item => { + const count = Number(item.count) || 0, row = el("div", undefined, "traffic-bar-row"); + const label = el("span", item.value || "-", "traffic-bar-label"); label.title = item.value || "-"; + const track = el("span", undefined, "traffic-bar-track"), fill = el("i", undefined, "traffic-bar-fill"); fill.style.width = `${Math.max(1.5, count / peak * 100)}%`; track.append(fill); + const value = el("span", formatter(count), "traffic-bar-value"); value.title = total ? `${(count / total * 100).toFixed(1)}%` : ""; + row.append(label, track, value); return row; + })); + chart.setAttribute("aria-label", values.length ? values.map(item => `${item.value}: ${formatter(Number(item.count) || 0)}`).join(", ") : t("trafficNoData")); + } + function render(data) { - lastData = data; - configure(data); + lastData = data; configure(data); const analytics = data.analytics || {}, stats = analytics.data || {}, settings = data.settings || {}; - $("traffic-requests").textContent = number(stats.requests); - $("traffic-errors").textContent = number(stats.errors); + $("traffic-requests").textContent = number(stats.requests); $("traffic-errors").textContent = number(stats.errors); const requests = Number(stats.requests) || 0, errors = Number(stats.errors) || 0; $("traffic-error-ratio").textContent = `${requests ? (errors / requests * 100).toFixed(2) : "0.00"}%`; $("traffic-response-bytes").textContent = fmtBytes(Number(stats.response_bytes) || 0); $("traffic-request-bytes").textContent = `${t("trafficRequestBytes")}: ${fmtBytes(Number(stats.request_bytes) || 0)}`; $("traffic-latency").textContent = stats.mean_duration_ms == null ? "-" : `${fmt(stats.mean_duration_ms, 2)} ms`; $("traffic-dropped").textContent = `${t("trafficDroppedSamples")}: ${number(analytics.queue_dropped)}`; - const capture = []; - capture.push(settings.capture_user_agent ? t("trafficUserAgentEnabled") : t("trafficUserAgentDisabled")); + const capture = [settings.capture_user_agent ? t("trafficUserAgentEnabled") : t("trafficUserAgentDisabled")]; capture.push(settings.capture_country ? (data.geoip?.loaded ? t("trafficCountryEnabled") : t("trafficCountryNoDb")) : t("trafficCountryDisabled")); $("traffic-capture-status").textContent = capture.join(" · "); - renderTop("countries", stats.countries, !!settings.capture_country); - renderTop("user-agents", stats.user_agents, !!settings.capture_user_agent); - renderTop("client-ips", stats.client_ips); - renderTop("paths", stats.paths); - renderTop("routes", stats.routes); - renderTop("methods", stats.methods); - renderTop("statuses", stats.statuses); - renderTop("cache", stats.cache); + renderDonut("countries", stats.countries, !!settings.capture_country); - renderDonut("user-agents", stats.user_agents, !!settings.capture_user_agent); - renderDonut("client-ips", stats.client_ips); - renderDonut("methods", stats.methods); - renderDonut("statuses", stats.statuses); - renderDonut("cache", stats.cache); + renderBars("user-agents", stats.user_agents, !!settings.capture_user_agent); + renderBars("client-ips", stats.client_ips); + renderBars("paths", stats.paths); renderBars("routes", stats.routes); + renderDonut("methods", stats.methods); renderDonut("methods-detail", stats.methods); + renderDonut("statuses", stats.statuses); renderDonut("statuses-detail", stats.statuses); + renderDonut("cache", stats.cache); renderDonut("cache-detail", stats.cache); + renderBars("volume", [ + { value: t("trafficRequestBytes"), count: Number(stats.request_bytes) || 0 }, + { value: t("trafficResponseBytes"), count: Number(stats.response_bytes) || 0 }, + ], true, fmtBytes); $("traffic-panel-state").textContent = `${t("trafficUpdated")}: ${new Date().toLocaleTimeString(locale())}`; } + async function refresh() { - if (!authenticated || loading) return; - loading = true; - try { - const selected = scope(); - const data = await api(`/api/traffic?vhost=${encodeURIComponent(selected)}`); - render(data); - } catch (error) { - $("traffic-panel-state").textContent = error.message; - } finally { loading = false; } + if (!authenticated || loading) return; loading = true; + try { render(await api(`/api/traffic?vhost=${encodeURIComponent(scope())}`)); } + catch (error) { $("traffic-panel-state").textContent = error.message; } + finally { loading = false; } } + function reset() { - lastData = null; - if ($("traffic-panel-state")) $("traffic-panel-state").textContent = ""; - for (const id of ["countries", "user-agents", "client-ips", "paths", "routes", "methods", "statuses", "cache"]) { - $(`traffic-${id}`)?.replaceChildren(); - } - for (const id of ["countries", "user-agents", "client-ips", "methods", "statuses", "cache"]) { + lastData = null; if ($("traffic-panel-state")) $("traffic-panel-state").textContent = ""; + document.querySelectorAll(".traffic-bar-chart").forEach(chart => chart.replaceChildren()); + for (const id of ["countries", "methods", "methods-detail", "statuses", "statuses-detail", "cache", "cache-detail"]) { const chart = $(`traffic-chart-${id}`), legend = $(`traffic-chart-${id}-legend`), total = $(`traffic-chart-${id}-total`); - if (chart) chart.style.background = "var(--panel2)"; - legend?.replaceChildren(); - if (total) total.textContent = "-"; + if (chart) chart.style.background = "var(--panel2)"; legend?.replaceChildren(); if (total) total.textContent = "-"; } + selectTab("overview"); } - function startTimer() { - if (timer) return; - timer = setInterval(() => { - if (!authenticated || !$("traffic-live")?.checked || $("panel-traffic")?.hidden) return; - void refresh(); - }, 3000); - } - $("traffic-refresh")?.addEventListener("click", () => void refresh()); - $("traffic-scope")?.addEventListener("change", () => void refresh()); - $("traffic-settings")?.addEventListener("click", () => { - setPanel("settings"); - window.rustProxySettings?.select("logging"); - }); - startTimer(); - window.rustProxyTrafficPanel = { refresh, reset, rerender: () => lastData && render(lastData) }; + + document.querySelectorAll("[data-traffic-tab]").forEach(button => button.addEventListener("click", () => selectTab(button.dataset.trafficTab))); + $("traffic-refresh")?.addEventListener("click", () => void refresh()); $("traffic-scope")?.addEventListener("change", () => void refresh()); + $("traffic-settings")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("logging"); }); + timer = setInterval(() => { if (authenticated && $("traffic-live")?.checked && !$("panel-traffic")?.hidden) void refresh(); }, 3000); + selectTab(activeTab); + window.rustProxyTrafficPanel = { refresh, reset, rerender: () => lastData && render(lastData), select: selectTab }; })(); diff --git a/src/admin/web/vhosts.js b/src/admin/web/vhosts.js deleted file mode 100644 index 52341b7..0000000 --- a/src/admin/web/vhosts.js +++ /dev/null @@ -1,981 +0,0 @@ -"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 vhostTrafficHistory = 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 fillWebServices = (select, chosen = "") => { - const first = el("option", t("webServiceSelect")); first.value = ""; - select.replaceChildren(first, ...(envelope?.config.web_services || []).map(service => { const option = el("option", service.name || service.id); option.value = service.id; return option; })); - if (chosen && !Array.from(select.options).some(option => option.value === chosen)) { const missing = el("option", `${chosen} / ${t("locationMissing")}`); missing.value = chosen; select.append(missing); } - select.value = chosen; - }; - 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: [] }, - source: { type: "reverse_proxy" }, - 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", { pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 }); - const url = field(main, "member-url", "vhUrl", value.url, "url", { 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); - const handled = action !== "return"; - return { - id: str("location-id", r), path_prefix: str("location-path", r), match_type: str("location-match", r), - source: action === "web_server" ? { type: "web_server", web_service_id: str("location-web-service", r) } : { type: "reverse_proxy" }, - 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: handled ? rewriteMode : "preserve", value: handled && 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 initialAction = value.response ? "return" : value.source?.type === "web_server" ? "web_server" : "proxy"; - const action = field(body, "location-action", "locationAction", initialAction, "select", { values: [["proxy", "locationProxyAction"], ["web_server", "locationWebServerAction"], ["return", "locationReturnAction"]] }); - const backend = field(body, "location-backend", "backend", value.backend_id || "", "select"); memberOptions(backend, value.backend_id || ""); - const webService = field(body, "location-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(webService, value.source?.web_service_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", isWeb = action.value === "web_server", isProxy = action.value === "proxy", isRegex = match.value === "regex" || match.value === "regex_insensitive"; - backend.disabled = !isProxy; cache.disabled = !isProxy; dedicated.disabled = !isProxy; - webService.disabled = !isWeb; webService.required = isWeb; - 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" : isWeb ? "locationWebServerShort" : "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 { - if (isWeb) { - const selected = webService.selectedOptions?.[0]?.textContent || t("webServiceSelect"); - previewValues.handling.textContent = `${t("locationWebServerAction")}: ${selected}`; - previewValues.cache.textContent = t("notEnabled"); - } else { - const selected = backend.selectedOptions?.[0]?.textContent || t("locationPool"); - previewValues.handling.textContent = `${t("locationProxyAction")}: ${selected}`; - previewValues.cache.textContent = t(cache.value === "on" ? "enabled" : cache.value === "off" ? "notEnabled" : "inherit"); - } - 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.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 mainSource = field(g, "source-type", "sourceType", value.source?.type || "reverse_proxy", "select", { values: [["reverse_proxy", "sourceReverseProxy"], ["web_server", "sourceWebServer"]] }); - const mainWebService = field(g, "source-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(mainWebService, value.source?.web_service_id || ""); - const syncMainSource = () => { const web = mainSource.value === "web_server"; mainWebService.disabled = !web; mainWebService.required = web; }; mainSource.addEventListener("change", syncMainSource); syncMainSource(); - note(g, "sourceHelp"); - 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.25.0", "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) })).filter(member => member.url); - const bindingSelection = readBindings(); - const mainSourceType = str("source-type"); - const needsProxy = mainSourceType === "reverse_proxy" || paths.some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"); - if (!bindingSelection.length) throw new Error(t("vhListenerRequired")); - if (needsProxy && !members.length) throw new Error(t("vhBackendRequired")); - if (mainSourceType === "web_server" && !str("source-web-service")) throw new Error(t("webServiceRequired")); - if (paths.some(route => !route.response && route.source?.type === "web_server" && !route.source.web_service_id)) throw new Error(t("webServiceRequired")); - 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"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, 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 configuredHasProxySource = (config.vhosts || []).some(vhost => - vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy") - ); - const diagnosticsCard = $("backend-diagnostics-card"); - if (diagnosticsCard) diagnosticsCard.hidden = !configuredHasProxySource; - 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]), - v.source?.web_service_id, - ...(v.routes || []).map(route => route.source?.web_service_id), - ].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); - if (v.source?.type === "web_server") title.append(el("span", `${t("webServer")}: ${v.source.web_service_id}`, "pill")); - 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.source?.type === "web_server" - ? ((v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy") && (v.backends || []).length - ? `${t("webServer")} + ${(v.backends || []).length}` - : t("webServer")) - : `${(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 hasProxySource = v.source?.type !== "web_server" || (v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"); - if (hasProxySource) { - 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 vhostTrafficRate(id, metrics, snapshot, now, seen) { - seen.add(id); - const total = Math.max(0, Number(metrics?.bytes_in) || 0) + Math.max(0, Number(metrics?.bytes_out) || 0); - const previous = vhostTrafficHistory.get(id); - if (previous?.snapshot === snapshot) return previous.rate; - const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0; - const rate = previous && total >= previous.total ? (total - previous.total) / elapsed : 0; - vhostTrafficHistory.set(id, { 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(), seenVhostTraffic = 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); - for (const [id, vhost] of configuredVhosts) { - const existing = aggregate.get(id) || {}; - const metrics = status.vhost_metrics?.[id]; - const hasProxySource = vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"); - const disabled = vhost.enabled === false; - aggregate.set(id, { - ...existing, - disabled, - staticOnly: !hasProxySource, - healthMode: existing.healthMode, - up: existing.up ?? 0, - total: existing.total ?? 0, - active: metrics ? Number(metrics.active) || 0 : existing.active ?? 0, - requests: metrics ? Number(metrics.requests) || 0 : existing.requests ?? 0, - errors: metrics ? Number(metrics.errors) || 0 : existing.errors ?? 0, - traffic: metrics ? vhostTrafficRate(id, metrics, trafficSnapshot, trafficNow, seenVhostTraffic) : existing.traffic ?? 0, - latency: metrics ? Number(metrics.ewma_latency_ms) || 0 : existing.latency ?? 0, - }); - } - for (const key of vhostTrafficHistory.keys()) if (!seenVhostTraffic.has(key)) vhostTrafficHistory.delete(key); - const hasConfiguredProxySource = [...configuredVhosts.values()].some(vhost => - vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy") - ); - $("pool-rows").replaceChildren(...rows); - $("pool-empty").hidden = rows.length > 0 || !hasConfiguredProxySource; - const backendDiagnosticsCard = $("backend-diagnostics-card"); - if (backendDiagnosticsCard) backendDiagnosticsCard.hidden = !hasConfiguredProxySource; - 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.staticOnly ? t("vhUp") : 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)}`; - } - const editingVhost = editing ? configuredVhosts.get(editing) : null; - const editingHasProxy = editingVhost && (editingVhost.source?.type !== "web_server" || (editingVhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")); - $("vhost-health-live").hidden = !!editingVhost && !editingHasProxy; - $("vhost-health-live").textContent = editing ? (editingHasProxy ? (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(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); } }; - if (envelope) render(); if (latestStatus) renderStatus(latestStatus); -})(); diff --git a/src/admin/web/vhosts/core.js b/src/admin/web/vhosts/core.js new file mode 100644 index 0000000..e6ff89e --- /dev/null +++ b/src/admin/web/vhosts/core.js @@ -0,0 +1,402 @@ +"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 vhostTrafficHistory = 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 fillWebServices = (select, chosen = "") => { + const first = el("option", t("webServiceSelect")); first.value = ""; + select.replaceChildren(first, ...(envelope?.config.web_services || []).map(service => { const option = el("option", service.name || service.id); option.value = service.id; return option; })); + if (chosen && !Array.from(select.options).some(option => option.value === chosen)) { const missing = el("option", `${chosen} / ${t("locationMissing")}`); missing.value = chosen; select.append(missing); } + select.value = chosen; + }; + 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: [] }, + source: { type: "reverse_proxy" }, + 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", { pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 }); + const url = field(main, "member-url", "vhUrl", value.url, "url", { 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); + const handled = action !== "return"; + return { + id: str("location-id", r), path_prefix: str("location-path", r), match_type: str("location-match", r), + source: action === "web_server" ? { type: "web_server", web_service_id: str("location-web-service", r) } : { type: "reverse_proxy" }, + 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: handled ? rewriteMode : "preserve", value: handled && 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 initialAction = value.response ? "return" : value.source?.type === "web_server" ? "web_server" : "proxy"; + const action = field(body, "location-action", "locationAction", initialAction, "select", { values: [["proxy", "locationProxyAction"], ["web_server", "locationWebServerAction"], ["return", "locationReturnAction"]] }); + const backend = field(body, "location-backend", "backend", value.backend_id || "", "select"); memberOptions(backend, value.backend_id || ""); + const webService = field(body, "location-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(webService, value.source?.web_service_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", isWeb = action.value === "web_server", isProxy = action.value === "proxy", isRegex = match.value === "regex" || match.value === "regex_insensitive"; + backend.disabled = !isProxy; cache.disabled = !isProxy; dedicated.disabled = !isProxy; + webService.disabled = !isWeb; webService.required = isWeb; + 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" : isWeb ? "locationWebServerShort" : "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 { + if (isWeb) { + const selected = webService.selectedOptions?.[0]?.textContent || t("webServiceSelect"); + previewValues.handling.textContent = `${t("locationWebServerAction")}: ${selected}`; + previewValues.cache.textContent = t("notEnabled"); + } else { + const selected = backend.selectedOptions?.[0]?.textContent || t("locationPool"); + previewValues.handling.textContent = `${t("locationProxyAction")}: ${selected}`; + previewValues.cache.textContent = t(cache.value === "on" ? "enabled" : cache.value === "off" ? "notEnabled" : "inherit"); + } + 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.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; + } diff --git a/src/admin/web/vhosts/editor.js b/src/admin/web/vhosts/editor.js new file mode 100644 index 0000000..898b291 --- /dev/null +++ b/src/admin/web/vhosts/editor.js @@ -0,0 +1,333 @@ + 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 mainSource = field(g, "source-type", "sourceType", value.source?.type || "reverse_proxy", "select", { values: [["reverse_proxy", "sourceReverseProxy"], ["web_server", "sourceWebServer"]] }); + const mainWebService = field(g, "source-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(mainWebService, value.source?.web_service_id || ""); + const syncMainSource = () => { const web = mainSource.value === "web_server"; mainWebService.disabled = !web; mainWebService.required = web; }; mainSource.addEventListener("change", syncMainSource); syncMainSource(); + note(g, "sourceHelp"); + 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.25.0", "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) })).filter(member => member.url); + const bindingSelection = readBindings(); + const mainSourceType = str("source-type"); + const needsProxy = mainSourceType === "reverse_proxy" || paths.some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"); + if (!bindingSelection.length) throw new Error(t("vhListenerRequired")); + if (needsProxy && !members.length) throw new Error(t("vhBackendRequired")); + if (mainSourceType === "web_server" && !str("source-web-service")) throw new Error(t("webServiceRequired")); + if (paths.some(route => !route.response && route.source?.type === "web_server" && !route.source.web_service_id)) throw new Error(t("webServiceRequired")); + 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"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, 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") } + }; + } diff --git a/src/admin/web/vhosts/render.js b/src/admin/web/vhosts/render.js new file mode 100644 index 0000000..e662a71 --- /dev/null +++ b/src/admin/web/vhosts/render.js @@ -0,0 +1,244 @@ + function render() { + if (!envelope) return; + if (!initialized) load(defaults(), [], false); + const config = envelope.config; + const configuredHasProxySource = (config.vhosts || []).some(vhost => + vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy") + ); + const diagnosticsCard = $("backend-diagnostics-card"); + if (diagnosticsCard) diagnosticsCard.hidden = !configuredHasProxySource; + 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]), + v.source?.web_service_id, + ...(v.routes || []).map(route => route.source?.web_service_id), + ].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); + if (v.source?.type === "web_server") title.append(el("span", `${t("webServer")}: ${v.source.web_service_id}`, "pill")); + 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.source?.type === "web_server" + ? ((v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy") && (v.backends || []).length + ? `${t("webServer")} + ${(v.backends || []).length}` + : t("webServer")) + : `${(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 hasProxySource = v.source?.type !== "web_server" || (v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"); + if (hasProxySource) { + 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 vhostTrafficRate(id, metrics, snapshot, now, seen) { + seen.add(id); + const total = Math.max(0, Number(metrics?.bytes_in) || 0) + Math.max(0, Number(metrics?.bytes_out) || 0); + const previous = vhostTrafficHistory.get(id); + if (previous?.snapshot === snapshot) return previous.rate; + const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0; + const rate = previous && total >= previous.total ? (total - previous.total) / elapsed : 0; + vhostTrafficHistory.set(id, { 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(), seenVhostTraffic = 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); + for (const [id, vhost] of configuredVhosts) { + const existing = aggregate.get(id) || {}; + const metrics = status.vhost_metrics?.[id]; + const hasProxySource = vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"); + const disabled = vhost.enabled === false; + aggregate.set(id, { + ...existing, + disabled, + staticOnly: !hasProxySource, + healthMode: existing.healthMode, + up: existing.up ?? 0, + total: existing.total ?? 0, + active: metrics ? Number(metrics.active) || 0 : existing.active ?? 0, + requests: metrics ? Number(metrics.requests) || 0 : existing.requests ?? 0, + errors: metrics ? Number(metrics.errors) || 0 : existing.errors ?? 0, + traffic: metrics ? vhostTrafficRate(id, metrics, trafficSnapshot, trafficNow, seenVhostTraffic) : existing.traffic ?? 0, + latency: metrics ? Number(metrics.ewma_latency_ms) || 0 : existing.latency ?? 0, + }); + } + for (const key of vhostTrafficHistory.keys()) if (!seenVhostTraffic.has(key)) vhostTrafficHistory.delete(key); + const hasConfiguredProxySource = [...configuredVhosts.values()].some(vhost => + vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy") + ); + $("pool-rows").replaceChildren(...rows); + $("pool-empty").hidden = rows.length > 0 || !hasConfiguredProxySource; + const backendDiagnosticsCard = $("backend-diagnostics-card"); + if (backendDiagnosticsCard) backendDiagnosticsCard.hidden = !hasConfiguredProxySource; + 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.staticOnly ? t("vhUp") : 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)}`; + } + const editingVhost = editing ? configuredVhosts.get(editing) : null; + const editingHasProxy = editingVhost && (editingVhost.source?.type !== "web_server" || (editingVhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")); + $("vhost-health-live").hidden = !!editingVhost && !editingHasProxy; + $("vhost-health-live").textContent = editing ? (editingHasProxy ? (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(); backendTrafficHistory.clear(); vhostTrafficHistory.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 index 06b13ab..dc2edf0 100644 --- a/src/auth/files.rs +++ b/src/auth/files.rs @@ -127,17 +127,4 @@ pub fn prepare_database(path: &Path) -> Result<()> { 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); - } -} +include!("files/tests.rs"); diff --git a/src/auth/files/tests.rs b/src/auth/files/tests.rs new file mode 100644 index 0000000..c56a19f --- /dev/null +++ b/src/auth/files/tests.rs @@ -0,0 +1,14 @@ +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 index de71556..1fee221 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -321,235 +321,4 @@ impl Auth { } } #[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; - } -} +include!("tests.rs"); diff --git a/src/auth/tests.rs b/src/auth/tests.rs new file mode 100644 index 0000000..bd564b0 --- /dev/null +++ b/src/auth/tests.rs @@ -0,0 +1,232 @@ +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/backup.rs b/src/backup.rs index 5edf16e..963ecbe 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -1,748 +1,9 @@ //! Password-encrypted full backup/restore for RustProxy durable data. //! The format is streaming, authenticated and deliberately excludes caches, //! logs, sockets and temporary runtime state. -use anyhow::{bail, ensure, Context, Result}; -use argon2::{Algorithm, Argon2, Params, Version}; -use base64::Engine as _; -use rand::{rngs::OsRng, RngCore}; -use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::{ - fs::{self, File, OpenOptions}, - io::{Read, Write}, - path::{Component, Path, PathBuf}, -}; -use zeroize::Zeroizing; -const BACKUP_MAGIC: &[u8; 8] = b"RPROXYB1"; -const ARCHIVE_MAGIC: &[u8; 8] = b"RPROXYA1"; -const KEY_MAGIC: &[u8; 4] = b"RPK1"; -const FORMAT_VERSION: u32 = 1; -const ARCHIVE_VERSION: u32 = 1; -const CHUNK_SIZE: usize = 1024 * 1024; -const ARGON_MEMORY_KIB: u32 = 64 * 1024; -const ARGON_ITERATIONS: u32 = 3; -const ARGON_PARALLELISM: u32 = 1; -const MAX_HEADER_BYTES: usize = 16 * 1024; -const MAX_MANIFEST_BYTES: usize = 1024 * 1024; -const KEY_AAD: &[u8] = b"rustproxy-secret-store-key-v1"; - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct CryptoHeader { - format_version: u32, - kdf: String, - salt_b64: String, - memory_kib: u32, - iterations: u32, - parallelism: u32, - aead: String, - nonce_prefix_b64: String, - chunk_size: usize, -} - -struct CryptoContext { - header_bytes: Vec, - key: Zeroizing<[u8; 32]>, - nonce_prefix: [u8; 4], -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct Manifest { - archive_version: u32, - rustproxy_version: String, - schema_version: i64, - configuration_revision: u64, - created_at_unix: i64, - secret_store_encryption_version: u32, - backup_encryption: String, - entries: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct ManifestEntry { - path: String, - size: u64, - sha256: String, -} - -enum EntrySource { - File(PathBuf), - Bytes(Vec), -} -struct ArchiveEntry { - manifest: ManifestEntry, - source: EntrySource, -} - -#[derive(Debug, Clone)] -pub struct BackupSummary { - pub path: PathBuf, - pub files: usize, - pub configuration_revision: u64, -} - -struct TempRoot(PathBuf); -impl Drop for TempRoot { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } -} - -struct PartialFile(PathBuf, bool); -impl Drop for PartialFile { - fn drop(&mut self) { - if !self.1 { - let _ = fs::remove_file(&self.0); - } - } -} - -fn parent_or_dot(path: &Path) -> &Path { - path.parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")) -} - -fn validate_backup_version(version: &str) -> Result<()> { - fn parse(value: &str) -> Result<(u64, u64, u64)> { - let value = value.split_once('-').map_or(value, |(base, _)| base); - let mut parts = value.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(), "invalid RustProxy version"); - Ok((major, minor, patch)) - } - let backup = parse(version).context("invalid RustProxy version in backup manifest")?; - let current = parse(env!("CARGO_PKG_VERSION")).context("invalid running RustProxy version")?; - ensure!( - backup <= current, - "backup was created by newer RustProxy version {version}; current version is {}", - env!("CARGO_PKG_VERSION") - ); - Ok(()) -} - -fn harden_tree(path: &Path) -> Result<()> { - let metadata = fs::symlink_metadata(path)?; - ensure!(!metadata.file_type().is_symlink(), "restore staging contains a symlink"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = if metadata.is_dir() { 0o700 } else { 0o600 }; - fs::set_permissions(path, fs::Permissions::from_mode(mode))?; - } - if metadata.is_dir() { - for entry in fs::read_dir(path)? { - harden_tree(&entry?.path())?; - } - } - Ok(()) -} - -fn private_dir(path: &Path) -> Result<()> { - fs::create_dir_all(path)?; - let meta = fs::symlink_metadata(path)?; - ensure!(meta.is_dir() && !meta.file_type().is_symlink(), "backup directory must not be a symlink"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; - } - Ok(()) -} - -fn private_file_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 -} - -fn create_temp_root(parent: &Path, prefix: &str) -> Result { - ensure!(parent.is_dir(), "backup parent directory does not exist"); - for _ in 0..32 { - let name = format!(".{prefix}-{:016x}", rand::random::()); - let path = parent.join(name); - match fs::create_dir(&path) { - Ok(()) => { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?; - } - return Ok(TempRoot(path)); - } - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, - Err(error) => return Err(error.into()), - } - } - bail!("could not allocate private backup staging directory") -} - -fn derive_key(password: &str, salt: &[u8]) -> Result> { - ensure!(!password.is_empty(), "backup password cannot be empty"); - let params = Params::new( - ARGON_MEMORY_KIB, - ARGON_ITERATIONS, - ARGON_PARALLELISM, - Some(32), - ) - .map_err(|_| anyhow::anyhow!("invalid backup KDF parameters"))?; - let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); - let mut key = Zeroizing::new([0u8; 32]); - argon - .hash_password_into(password.as_bytes(), salt, key.as_mut()) - .map_err(|_| anyhow::anyhow!("backup key derivation failed"))?; - Ok(key) -} - -fn new_crypto(password: &str) -> Result { - let mut salt = [0u8; 16]; - let mut nonce_prefix = [0u8; 4]; - OsRng.fill_bytes(&mut salt); - OsRng.fill_bytes(&mut nonce_prefix); - let header = CryptoHeader { - format_version: FORMAT_VERSION, - kdf: "Argon2id".into(), - salt_b64: base64::engine::general_purpose::STANDARD.encode(salt), - memory_kib: ARGON_MEMORY_KIB, - iterations: ARGON_ITERATIONS, - parallelism: ARGON_PARALLELISM, - aead: "AES-256-GCM".into(), - nonce_prefix_b64: base64::engine::general_purpose::STANDARD.encode(nonce_prefix), - chunk_size: CHUNK_SIZE, - }; - let header_bytes = serde_json::to_vec(&header)?; - let key = derive_key(password, &salt)?; - Ok(CryptoContext { - header_bytes, - key, - nonce_prefix, - }) -} - -fn parse_crypto_header(bytes: Vec, password: &str) -> Result { - let header: CryptoHeader = serde_json::from_slice(&bytes).context("invalid backup header")?; - ensure!( - header.format_version == FORMAT_VERSION - && header.kdf == "Argon2id" - && header.memory_kib == ARGON_MEMORY_KIB - && header.iterations == ARGON_ITERATIONS - && header.parallelism == ARGON_PARALLELISM - && header.aead == "AES-256-GCM" - && header.chunk_size == CHUNK_SIZE, - "unsupported backup cryptographic format" - ); - let salt = base64::engine::general_purpose::STANDARD - .decode(&header.salt_b64) - .context("invalid backup salt")?; - ensure!(salt.len() == 16, "invalid backup salt"); - let prefix = base64::engine::general_purpose::STANDARD - .decode(&header.nonce_prefix_b64) - .context("invalid backup nonce prefix")?; - ensure!(prefix.len() == 4, "invalid backup nonce prefix"); - let mut nonce_prefix = [0u8; 4]; - nonce_prefix.copy_from_slice(&prefix); - let key = derive_key(password, &salt)?; - Ok(CryptoContext { - header_bytes: bytes, - key, - nonce_prefix, - }) -} - -fn cipher(key: &[u8]) -> Result { - Ok(LessSafeKey::new( - UnboundKey::new(&AES_256_GCM, key) - .map_err(|_| anyhow::anyhow!("cannot initialize backup cipher"))?, - )) -} - -fn nonce(prefix: [u8; 4], counter: u64) -> Nonce { - let mut bytes = [0u8; 12]; - bytes[..4].copy_from_slice(&prefix); - bytes[4..].copy_from_slice(&counter.to_be_bytes()); - Nonce::assume_unique_for_key(bytes) -} - -fn chunk_aad(header: &[u8], counter: u64) -> Vec { - let mut aad = Vec::with_capacity(BACKUP_MAGIC.len() + header.len() + 8); - aad.extend_from_slice(BACKUP_MAGIC); - aad.extend_from_slice(header); - aad.extend_from_slice(&counter.to_be_bytes()); - aad -} - -fn encrypt_archive(input: &Path, output: &Path, crypto: &CryptoContext) -> Result<()> { - ensure!(!output.exists(), "backup output already exists"); - let mut source = File::open(input)?; - let mut target = private_file_options().create_new(true).open(output)?; - let mut guard = PartialFile(output.to_path_buf(), false); - target.write_all(BACKUP_MAGIC)?; - let header_len: u32 = crypto.header_bytes.len().try_into()?; - target.write_all(&header_len.to_be_bytes())?; - target.write_all(&crypto.header_bytes)?; - let cipher = cipher(crypto.key.as_ref())?; - let mut counter = 0u64; - let mut buffer = vec![0u8; CHUNK_SIZE]; - loop { - let read = source.read(&mut buffer)?; - if read == 0 { - break; - } - let mut chunk = buffer[..read].to_vec(); - cipher - .seal_in_place_append_tag( - nonce(crypto.nonce_prefix, counter), - Aad::from(chunk_aad(&crypto.header_bytes, counter)), - &mut chunk, - ) - .map_err(|_| anyhow::anyhow!("backup encryption failed"))?; - let length: u32 = chunk.len().try_into()?; - target.write_all(&length.to_be_bytes())?; - target.write_all(&chunk)?; - counter = counter.checked_add(1).context("backup is too large")?; - } - target.write_all(&0u32.to_be_bytes())?; - target.sync_all()?; - guard.1 = true; - Ok(()) -} - -fn decrypt_archive(input: &Path, output: &Path, password: &str) -> Result> { - let metadata = fs::symlink_metadata(input)?; - ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "backup must be a regular file"); - let mut source = File::open(input)?; - let mut magic = [0u8; 8]; - source.read_exact(&mut magic)?; - ensure!(&magic == BACKUP_MAGIC, "not a RustProxy backup"); - let header_len = read_u32(&mut source)? as usize; - ensure!(header_len > 0 && header_len <= MAX_HEADER_BYTES, "invalid backup header length"); - let mut header_bytes = vec![0u8; header_len]; - source.read_exact(&mut header_bytes)?; - let crypto = parse_crypto_header(header_bytes, password)?; - let cipher = cipher(crypto.key.as_ref())?; - let mut target = private_file_options().create_new(true).open(output)?; - let mut counter = 0u64; - loop { - let length = read_u32(&mut source)? as usize; - if length == 0 { - break; - } - ensure!( - length >= AES_256_GCM.tag_len() && length <= CHUNK_SIZE + AES_256_GCM.tag_len(), - "invalid encrypted backup chunk" - ); - let mut chunk = vec![0u8; length]; - source.read_exact(&mut chunk)?; - let plaintext = cipher - .open_in_place( - nonce(crypto.nonce_prefix, counter), - Aad::from(chunk_aad(&crypto.header_bytes, counter)), - &mut chunk, - ) - .map_err(|_| anyhow::anyhow!("incorrect backup password or corrupted archive"))?; - target.write_all(plaintext)?; - counter = counter.checked_add(1).context("backup is too large")?; - } - let mut extra = [0u8; 1]; - ensure!(source.read(&mut extra)? == 0, "trailing data after encrypted backup"); - target.sync_all()?; - Ok(crypto.key) -} - -fn seal_master_key(master: &[u8], backup_key: &[u8]) -> Result> { - ensure!(master.len() == 32, "invalid secret store master key"); - let mut nonce_bytes = [0u8; 12]; - OsRng.fill_bytes(&mut nonce_bytes); - let cipher = cipher(backup_key)?; - let mut encrypted = Zeroizing::new(master.to_vec()); - cipher - .seal_in_place_append_tag( - Nonce::assume_unique_for_key(nonce_bytes), - Aad::from(KEY_AAD), - &mut *encrypted, - ) - .map_err(|_| anyhow::anyhow!("could not wrap secret store key"))?; - let mut output = Vec::with_capacity(4 + 12 + encrypted.len()); - output.extend_from_slice(KEY_MAGIC); - output.extend_from_slice(&nonce_bytes); - output.extend_from_slice(encrypted.as_ref()); - Ok(output) -} - -fn open_master_key(blob: &[u8], backup_key: &[u8]) -> Result>> { - ensure!(blob.len() == 4 + 12 + 32 + AES_256_GCM.tag_len(), "invalid wrapped secret store key"); - ensure!(&blob[..4] == KEY_MAGIC, "invalid wrapped secret store key"); - let mut nonce_bytes = [0u8; 12]; - nonce_bytes.copy_from_slice(&blob[4..16]); - let mut encrypted = Zeroizing::new(blob[16..].to_vec()); - let cipher = cipher(backup_key)?; - let plaintext = cipher - .open_in_place( - Nonce::assume_unique_for_key(nonce_bytes), - Aad::from(KEY_AAD), - encrypted.as_mut(), - ) - .map_err(|_| anyhow::anyhow!("wrapped secret store key authentication failed"))?; - Ok(Zeroizing::new(plaintext.to_vec())) -} - -fn hash_file(path: &Path) -> Result<(u64, String)> { - let metadata = fs::symlink_metadata(path)?; - ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "backup input must be a regular file"); - let mut file = File::open(path)?; - let mut hasher = Sha256::new(); - let mut size = 0u64; - let mut buffer = [0u8; 1024 * 1024]; - loop { - let read = file.read(&mut buffer)?; - if read == 0 { - break; - } - size = size.checked_add(read as u64).context("backup input too large")?; - hasher.update(&buffer[..read]); - } - Ok((size, format!("{:x}", hasher.finalize()))) -} - -fn hash_bytes(bytes: &[u8]) -> String { - format!("{:x}", Sha256::digest(bytes)) -} - -fn excluded_directory(name: &str) -> bool { - matches!(name, "cache" | "logs" | "tmp" | "temp" | "work" | "acme-http01") -} -fn excluded_file(name: &str) -> bool { - name == "process.lock" - || name == "config.sqlite3" - || name == "config.sqlite3-wal" - || name == "config.sqlite3-shm" - || name == "master.key" - || name == ".lock" - || name.ends_with(".sock") - || name.ends_with(".pid") - || name.ends_with(".log") - || name.ends_with(".jsonl") -} - -fn collect_data_files(root: &Path, current: &Path, entries: &mut Vec) -> Result<()> { - for item in fs::read_dir(current)? { - let item = item?; - let path = item.path(); - let meta = fs::symlink_metadata(&path)?; - ensure!(!meta.file_type().is_symlink(), "backup refuses symlinks in the data directory: {}", path.display()); - let name = item.file_name(); - let name_text = name.to_string_lossy(); - if meta.is_dir() { - if excluded_directory(&name_text) || (path == root.join("secrets")) { - continue; - } - collect_data_files(root, &path, entries)?; - } else if meta.is_file() { - if excluded_file(&name_text) { - continue; - } - let relative = path.strip_prefix(root)?; - let archive_path = format!("data/{}", relative.to_string_lossy().replace('\\', "/")); - let (size, sha256) = hash_file(&path)?; - entries.push(ArchiveEntry { - manifest: ManifestEntry { path: archive_path, size, sha256 }, - source: EntrySource::File(path), - }); - } - } - Ok(()) -} - -fn write_inner_archive(path: &Path, manifest: &Manifest, entries: &[ArchiveEntry]) -> Result<()> { - let mut file = private_file_options().create_new(true).open(path)?; - file.write_all(ARCHIVE_MAGIC)?; - let manifest_bytes = serde_json::to_vec(manifest)?; - ensure!(manifest_bytes.len() <= MAX_MANIFEST_BYTES, "backup manifest is too large"); - file.write_all(&(manifest_bytes.len() as u32).to_be_bytes())?; - file.write_all(&manifest_bytes)?; - let mut buffer = [0u8; 1024 * 1024]; - for entry in entries { - let path_bytes = entry.manifest.path.as_bytes(); - ensure!(!path_bytes.is_empty() && path_bytes.len() <= u16::MAX as usize, "backup path is too long"); - file.write_all(&(path_bytes.len() as u16).to_be_bytes())?; - file.write_all(path_bytes)?; - file.write_all(&entry.manifest.size.to_be_bytes())?; - match &entry.source { - EntrySource::File(source) => { - let mut input = File::open(source)?; - let mut remaining = entry.manifest.size; - while remaining > 0 { - let take = usize::try_from(remaining.min(buffer.len() as u64))?; - let read = input.read(&mut buffer[..take])?; - ensure!(read > 0, "backup input changed while being read"); - file.write_all(&buffer[..read])?; - remaining -= read as u64; - } - ensure!(input.read(&mut buffer[..1])? == 0, "backup input changed while being read"); - } - EntrySource::Bytes(bytes) => file.write_all(bytes)?, - } - } - file.sync_all()?; - Ok(()) -} - -fn read_u16(reader: &mut R) -> Result { - let mut bytes = [0u8; 2]; - reader.read_exact(&mut bytes)?; - Ok(u16::from_be_bytes(bytes)) -} -fn read_u32(reader: &mut R) -> Result { - let mut bytes = [0u8; 4]; - reader.read_exact(&mut bytes)?; - Ok(u32::from_be_bytes(bytes)) -} -fn read_u64(reader: &mut R) -> Result { - let mut bytes = [0u8; 8]; - reader.read_exact(&mut bytes)?; - Ok(u64::from_be_bytes(bytes)) -} - -fn safe_relative(path: &str) -> Result { - ensure!(!path.is_empty() && path.len() <= 4096 && !path.contains('\\') && !path.contains(':'), "unsafe backup path"); - let value = Path::new(path); - ensure!(!value.is_absolute(), "unsafe absolute backup path"); - for component in value.components() { - ensure!(matches!(component, Component::Normal(_)), "unsafe backup path component"); - } - Ok(value.to_path_buf()) -} - -fn extract_inner_archive(path: &Path, stage: &Path) -> Result<(Manifest, Vec)> { - let mut file = File::open(path)?; - let mut magic = [0u8; 8]; - file.read_exact(&mut magic)?; - ensure!(&magic == ARCHIVE_MAGIC, "invalid decrypted RustProxy backup payload"); - let manifest_len = read_u32(&mut file)? as usize; - ensure!(manifest_len > 0 && manifest_len <= MAX_MANIFEST_BYTES, "invalid backup manifest length"); - let mut manifest_bytes = vec![0u8; manifest_len]; - file.read_exact(&mut manifest_bytes)?; - let manifest: Manifest = serde_json::from_slice(&manifest_bytes).context("invalid backup manifest")?; - ensure!(manifest.archive_version == ARCHIVE_VERSION, "unsupported backup archive version"); - validate_backup_version(&manifest.rustproxy_version)?; - ensure!(manifest.schema_version <= crate::db::SCHEMA_VERSION, "backup database schema is newer than this RustProxy binary"); - ensure!(manifest.entries.len() <= 100_000, "backup contains too many files"); - let mut wrapped_key = None; - let mut buffer = vec![0u8; 1024 * 1024]; - for expected in &manifest.entries { - let path_len = read_u16(&mut file)? as usize; - ensure!(path_len > 0 && path_len <= 4096, "invalid backup path length"); - let mut path_bytes = vec![0u8; path_len]; - file.read_exact(&mut path_bytes)?; - let archive_path = String::from_utf8(path_bytes).context("backup path is not UTF-8")?; - ensure!(archive_path == expected.path, "backup manifest entry order/path mismatch"); - let size = read_u64(&mut file)?; - ensure!(size == expected.size, "backup manifest size mismatch"); - let mut hasher = Sha256::new(); - if archive_path == "keys/secret-store-key.enc" { - ensure!(size <= 256, "wrapped secret store key is too large"); - let mut bytes = vec![0u8; size as usize]; - file.read_exact(&mut bytes)?; - hasher.update(&bytes); - wrapped_key = Some(bytes); - } else { - let relative = archive_path - .strip_prefix("data/") - .ok_or_else(|| anyhow::anyhow!("unsupported backup entry"))?; - let relative = safe_relative(relative)?; - let target = stage.join(relative); - if let Some(parent) = target.parent() { - private_dir(parent)?; - } - let mut output = private_file_options().create_new(true).open(&target)?; - let mut remaining = size; - while remaining > 0 { - let take = usize::try_from(remaining.min(buffer.len() as u64))?; - file.read_exact(&mut buffer[..take])?; - output.write_all(&buffer[..take])?; - hasher.update(&buffer[..take]); - remaining -= take as u64; - } - output.sync_all()?; - } - ensure!(format!("{:x}", hasher.finalize()) == expected.sha256, "backup entry checksum mismatch"); - } - let mut extra = [0u8; 1]; - ensure!(file.read(&mut extra)? == 0, "trailing data in backup payload"); - let key = wrapped_key.context("backup is missing Secret Store key material")?; - ensure!(stage.join("config.sqlite3").is_file(), "backup is missing database"); - Ok((manifest, key)) -} - -pub async fn create(data_dir: &Path, output: &Path, password: &str) -> Result { - crate::auth::prepare_directory(data_dir)?; - let _lock = crate::auth::lock_directory(data_dir) - .context("stop RustProxy before creating a full backup")?; - let database = data_dir.join("config.sqlite3"); - ensure!(database.is_file(), "database does not exist at {}", database.display()); - crate::auth::prepare_database(&database)?; - let store = crate::db::Store::open(&database, &crate::config::Config::default()).await?; - crate::secret_store::initialize(data_dir)?; - let cert_dir = data_dir.join("certs"); - if cert_dir.is_dir() { - crate::secret_store::migrate_acme_credentials(&store, data_dir, &cert_dir).await?; - } - let parent = parent_or_dot(output); - let data_root = fs::canonicalize(data_dir).context("cannot resolve data directory")?; - let output_parent = fs::canonicalize(parent).context("cannot resolve backup output directory")?; - ensure!( - !output_parent.starts_with(&data_root), - "backup output must be outside the RustProxy data directory" - ); - let temp = create_temp_root(parent, "rustproxy-backup")?; - let snapshot = temp.0.join("config.sqlite3"); - store.backup_to(&snapshot).await?; - let envelope = store.load().await?; - let crypto = new_crypto(password)?; - let master = crate::secret_store::read_master_key_for_backup(data_dir)?; - let key_blob = seal_master_key(master.as_ref(), crypto.key.as_ref())?; - - let (db_size, db_hash) = hash_file(&snapshot)?; - let mut entries = vec![ArchiveEntry { - manifest: ManifestEntry { - path: "data/config.sqlite3".into(), - size: db_size, - sha256: db_hash, - }, - source: EntrySource::File(snapshot), - }]; - collect_data_files(data_dir, data_dir, &mut entries)?; - entries.push(ArchiveEntry { - manifest: ManifestEntry { - path: "keys/secret-store-key.enc".into(), - size: key_blob.len() as u64, - sha256: hash_bytes(&key_blob), - }, - source: EntrySource::Bytes(key_blob), - }); - entries.sort_by(|a, b| a.manifest.path.cmp(&b.manifest.path)); - let manifest = Manifest { - archive_version: ARCHIVE_VERSION, - rustproxy_version: env!("CARGO_PKG_VERSION").into(), - schema_version: crate::db::SCHEMA_VERSION, - configuration_revision: envelope.revision, - created_at_unix: crate::auth::now(), - secret_store_encryption_version: 1, - backup_encryption: "Argon2id + AES-256-GCM chunked AEAD".into(), - entries: entries.iter().map(|entry| entry.manifest.clone()).collect(), - }; - let plaintext = temp.0.join("archive.rpa"); - write_inner_archive(&plaintext, &manifest, &entries)?; - encrypt_archive(&plaintext, output, &crypto)?; - store.close().await; - Ok(BackupSummary { - path: output.to_path_buf(), - files: manifest.entries.len(), - configuration_revision: manifest.configuration_revision, - }) -} - -pub async fn restore(archive: &Path, data_dir: &Path, password: &str) -> Result { - let archive_meta = fs::symlink_metadata(archive).context("backup archive does not exist")?; - ensure!(archive_meta.is_file() && !archive_meta.file_type().is_symlink(), "backup archive must be a regular file"); - let parent = parent_or_dot(data_dir); - ensure!(parent.is_dir(), "restore parent directory does not exist"); - let old_lock = if data_dir.exists() { - crate::auth::prepare_directory(data_dir)?; - Some(crate::auth::lock_directory(data_dir).context("stop RustProxy before restore")?) - } else { - None - }; - let temp = create_temp_root(parent, "rustproxy-restore")?; - let plaintext = temp.0.join("archive.rpa"); - let backup_key = decrypt_archive(archive, &plaintext, password)?; - let stage = temp.0.join("stage"); - private_dir(&stage)?; - let (manifest, wrapped_key) = extract_inner_archive(&plaintext, &stage)?; - let master = open_master_key(&wrapped_key, backup_key.as_ref())?; - crate::secret_store::write_restored_master_key(&stage, master.as_ref())?; - harden_tree(&stage)?; - - crate::auth::prepare_directory(&stage)?; - let stage_db = stage.join("config.sqlite3"); - crate::auth::prepare_database(&stage_db)?; - let store = crate::db::Store::open(&stage_db, &crate::config::Config::default()).await?; - let envelope = store.load().await.context("restored configuration is unreadable")?; - crate::secret_store::validate_all(&store, &stage) - .await - .context("restored Secret Store cannot be decrypted")?; - store.close().await; - for suffix in ["-wal", "-shm"] { - let mut name = stage_db.as_os_str().to_os_string(); - name.push(suffix); - let _ = fs::remove_file(PathBuf::from(name)); - } - let stage_lock = crate::auth::lock_directory(&stage)?; - let previous = temp.0.join("previous-data"); - if data_dir.exists() { - fs::rename(data_dir, &previous).context("could not move current data directory aside")?; - } - if let Err(error) = fs::rename(&stage, data_dir) { - if previous.exists() { - let _ = fs::rename(&previous, data_dir); - } - return Err(error).context("could not atomically activate restored data directory"); - } - drop(old_lock); - if previous.exists() { - fs::remove_dir_all(&previous).context("restore succeeded but old data directory could not be removed")?; - } - drop(stage_lock); - Ok(BackupSummary { - path: archive.to_path_buf(), - files: manifest.entries.len(), - configuration_revision: envelope.revision, - }) -} - -pub fn generated_password() -> String { - let mut bytes = [0u8; 32]; - OsRng.fill_bytes(&mut bytes); - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) -} +include!("backup/archive.rs"); +include!("backup/validation.rs"); #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn password_encryption_rejects_wrong_password_and_tampering() { - let dir = tempfile::tempdir().unwrap(); - let input = dir.path().join("input"); - let encrypted = dir.path().join("backup.rpb"); - let output = dir.path().join("output"); - fs::write(&input, b"private backup payload").unwrap(); - let crypto = new_crypto("correct horse battery staple").unwrap(); - encrypt_archive(&input, &encrypted, &crypto).unwrap(); - assert!(decrypt_archive(&encrypted, &output, "wrong password").is_err()); - decrypt_archive(&encrypted, &output, "correct horse battery staple").unwrap(); - assert_eq!(fs::read(&output).unwrap(), b"private backup payload"); - } - - #[test] - fn restored_paths_cannot_escape_staging() { - assert!(safe_relative("certs/example.pem").is_ok()); - assert!(safe_relative("../outside").is_err()); - assert!(safe_relative("/absolute").is_err()); - assert!(safe_relative("a\\b").is_err()); - } - - #[test] - fn manifest_rejects_newer_rustproxy_version() { - assert!(validate_backup_version(env!("CARGO_PKG_VERSION")).is_ok()); - assert!(validate_backup_version("999.0.0").is_err()); - } -} +include!("backup/tests.rs"); diff --git a/src/backup/archive.rs b/src/backup/archive.rs new file mode 100644 index 0000000..ff8066e --- /dev/null +++ b/src/backup/archive.rs @@ -0,0 +1,418 @@ +use anyhow::{bail, ensure, Context, Result}; +use argon2::{Algorithm, Argon2, Params, Version}; +use base64::Engine as _; +use rand::{rngs::OsRng, RngCore}; +use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs::{self, File, OpenOptions}, + io::{Read, Write}, + path::{Component, Path, PathBuf}, +}; +use zeroize::Zeroizing; + +const BACKUP_MAGIC: &[u8; 8] = b"RPROXYB1"; +const ARCHIVE_MAGIC: &[u8; 8] = b"RPROXYA1"; +const KEY_MAGIC: &[u8; 4] = b"RPK1"; +const FORMAT_VERSION: u32 = 1; +const ARCHIVE_VERSION: u32 = 1; +const CHUNK_SIZE: usize = 1024 * 1024; +const ARGON_MEMORY_KIB: u32 = 64 * 1024; +const ARGON_ITERATIONS: u32 = 3; +const ARGON_PARALLELISM: u32 = 1; +const MAX_HEADER_BYTES: usize = 16 * 1024; +const MAX_MANIFEST_BYTES: usize = 1024 * 1024; +const KEY_AAD: &[u8] = b"rustproxy-secret-store-key-v1"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CryptoHeader { + format_version: u32, + kdf: String, + salt_b64: String, + memory_kib: u32, + iterations: u32, + parallelism: u32, + aead: String, + nonce_prefix_b64: String, + chunk_size: usize, +} + +struct CryptoContext { + header_bytes: Vec, + key: Zeroizing<[u8; 32]>, + nonce_prefix: [u8; 4], +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Manifest { + archive_version: u32, + rustproxy_version: String, + schema_version: i64, + configuration_revision: u64, + created_at_unix: i64, + secret_store_encryption_version: u32, + backup_encryption: String, + entries: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ManifestEntry { + path: String, + size: u64, + sha256: String, +} + +enum EntrySource { + File(PathBuf), + Bytes(Vec), +} +struct ArchiveEntry { + manifest: ManifestEntry, + source: EntrySource, +} + +#[derive(Debug, Clone)] +pub struct BackupSummary { + pub path: PathBuf, + pub files: usize, + pub configuration_revision: u64, +} + +struct TempRoot(PathBuf); +impl Drop for TempRoot { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +struct PartialFile(PathBuf, bool); +impl Drop for PartialFile { + fn drop(&mut self) { + if !self.1 { + let _ = fs::remove_file(&self.0); + } + } +} + +fn parent_or_dot(path: &Path) -> &Path { + path.parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) +} + +fn validate_backup_version(version: &str) -> Result<()> { + fn parse(value: &str) -> Result<(u64, u64, u64)> { + let value = value.split_once('-').map_or(value, |(base, _)| base); + let mut parts = value.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(), "invalid RustProxy version"); + Ok((major, minor, patch)) + } + let backup = parse(version).context("invalid RustProxy version in backup manifest")?; + let current = parse(env!("CARGO_PKG_VERSION")).context("invalid running RustProxy version")?; + ensure!( + backup <= current, + "backup was created by newer RustProxy version {version}; current version is {}", + env!("CARGO_PKG_VERSION") + ); + Ok(()) +} + +fn harden_tree(path: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(path)?; + ensure!(!metadata.file_type().is_symlink(), "restore staging contains a symlink"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = if metadata.is_dir() { 0o700 } else { 0o600 }; + fs::set_permissions(path, fs::Permissions::from_mode(mode))?; + } + if metadata.is_dir() { + for entry in fs::read_dir(path)? { + harden_tree(&entry?.path())?; + } + } + Ok(()) +} + +fn private_dir(path: &Path) -> Result<()> { + fs::create_dir_all(path)?; + let meta = fs::symlink_metadata(path)?; + ensure!(meta.is_dir() && !meta.file_type().is_symlink(), "backup directory must not be a symlink"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + } + Ok(()) +} + +fn private_file_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 +} + +fn create_temp_root(parent: &Path, prefix: &str) -> Result { + ensure!(parent.is_dir(), "backup parent directory does not exist"); + for _ in 0..32 { + let name = format!(".{prefix}-{:016x}", rand::random::()); + let path = parent.join(name); + match fs::create_dir(&path) { + Ok(()) => { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?; + } + return Ok(TempRoot(path)); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error.into()), + } + } + bail!("could not allocate private backup staging directory") +} + +fn derive_key(password: &str, salt: &[u8]) -> Result> { + ensure!(!password.is_empty(), "backup password cannot be empty"); + let params = Params::new( + ARGON_MEMORY_KIB, + ARGON_ITERATIONS, + ARGON_PARALLELISM, + Some(32), + ) + .map_err(|_| anyhow::anyhow!("invalid backup KDF parameters"))?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut key = Zeroizing::new([0u8; 32]); + argon + .hash_password_into(password.as_bytes(), salt, key.as_mut()) + .map_err(|_| anyhow::anyhow!("backup key derivation failed"))?; + Ok(key) +} + +fn new_crypto(password: &str) -> Result { + let mut salt = [0u8; 16]; + let mut nonce_prefix = [0u8; 4]; + OsRng.fill_bytes(&mut salt); + OsRng.fill_bytes(&mut nonce_prefix); + let header = CryptoHeader { + format_version: FORMAT_VERSION, + kdf: "Argon2id".into(), + salt_b64: base64::engine::general_purpose::STANDARD.encode(salt), + memory_kib: ARGON_MEMORY_KIB, + iterations: ARGON_ITERATIONS, + parallelism: ARGON_PARALLELISM, + aead: "AES-256-GCM".into(), + nonce_prefix_b64: base64::engine::general_purpose::STANDARD.encode(nonce_prefix), + chunk_size: CHUNK_SIZE, + }; + let header_bytes = serde_json::to_vec(&header)?; + let key = derive_key(password, &salt)?; + Ok(CryptoContext { + header_bytes, + key, + nonce_prefix, + }) +} + +fn parse_crypto_header(bytes: Vec, password: &str) -> Result { + let header: CryptoHeader = serde_json::from_slice(&bytes).context("invalid backup header")?; + ensure!( + header.format_version == FORMAT_VERSION + && header.kdf == "Argon2id" + && header.memory_kib == ARGON_MEMORY_KIB + && header.iterations == ARGON_ITERATIONS + && header.parallelism == ARGON_PARALLELISM + && header.aead == "AES-256-GCM" + && header.chunk_size == CHUNK_SIZE, + "unsupported backup cryptographic format" + ); + let salt = base64::engine::general_purpose::STANDARD + .decode(&header.salt_b64) + .context("invalid backup salt")?; + ensure!(salt.len() == 16, "invalid backup salt"); + let prefix = base64::engine::general_purpose::STANDARD + .decode(&header.nonce_prefix_b64) + .context("invalid backup nonce prefix")?; + ensure!(prefix.len() == 4, "invalid backup nonce prefix"); + let mut nonce_prefix = [0u8; 4]; + nonce_prefix.copy_from_slice(&prefix); + let key = derive_key(password, &salt)?; + Ok(CryptoContext { + header_bytes: bytes, + key, + nonce_prefix, + }) +} + +fn cipher(key: &[u8]) -> Result { + Ok(LessSafeKey::new( + UnboundKey::new(&AES_256_GCM, key) + .map_err(|_| anyhow::anyhow!("cannot initialize backup cipher"))?, + )) +} + +fn nonce(prefix: [u8; 4], counter: u64) -> Nonce { + let mut bytes = [0u8; 12]; + bytes[..4].copy_from_slice(&prefix); + bytes[4..].copy_from_slice(&counter.to_be_bytes()); + Nonce::assume_unique_for_key(bytes) +} + +fn chunk_aad(header: &[u8], counter: u64) -> Vec { + let mut aad = Vec::with_capacity(BACKUP_MAGIC.len() + header.len() + 8); + aad.extend_from_slice(BACKUP_MAGIC); + aad.extend_from_slice(header); + aad.extend_from_slice(&counter.to_be_bytes()); + aad +} + +fn encrypt_archive(input: &Path, output: &Path, crypto: &CryptoContext) -> Result<()> { + ensure!(!output.exists(), "backup output already exists"); + let mut source = File::open(input)?; + let mut target = private_file_options().create_new(true).open(output)?; + let mut guard = PartialFile(output.to_path_buf(), false); + target.write_all(BACKUP_MAGIC)?; + let header_len: u32 = crypto.header_bytes.len().try_into()?; + target.write_all(&header_len.to_be_bytes())?; + target.write_all(&crypto.header_bytes)?; + let cipher = cipher(crypto.key.as_ref())?; + let mut counter = 0u64; + let mut buffer = vec![0u8; CHUNK_SIZE]; + loop { + let read = source.read(&mut buffer)?; + if read == 0 { + break; + } + let mut chunk = buffer[..read].to_vec(); + cipher + .seal_in_place_append_tag( + nonce(crypto.nonce_prefix, counter), + Aad::from(chunk_aad(&crypto.header_bytes, counter)), + &mut chunk, + ) + .map_err(|_| anyhow::anyhow!("backup encryption failed"))?; + let length: u32 = chunk.len().try_into()?; + target.write_all(&length.to_be_bytes())?; + target.write_all(&chunk)?; + counter = counter.checked_add(1).context("backup is too large")?; + } + target.write_all(&0u32.to_be_bytes())?; + target.sync_all()?; + guard.1 = true; + Ok(()) +} + +fn decrypt_archive(input: &Path, output: &Path, password: &str) -> Result> { + let metadata = fs::symlink_metadata(input)?; + ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "backup must be a regular file"); + let mut source = File::open(input)?; + let mut magic = [0u8; 8]; + source.read_exact(&mut magic)?; + ensure!(&magic == BACKUP_MAGIC, "not a RustProxy backup"); + let header_len = read_u32(&mut source)? as usize; + ensure!(header_len > 0 && header_len <= MAX_HEADER_BYTES, "invalid backup header length"); + let mut header_bytes = vec![0u8; header_len]; + source.read_exact(&mut header_bytes)?; + let crypto = parse_crypto_header(header_bytes, password)?; + let cipher = cipher(crypto.key.as_ref())?; + let mut target = private_file_options().create_new(true).open(output)?; + let mut counter = 0u64; + loop { + let length = read_u32(&mut source)? as usize; + if length == 0 { + break; + } + ensure!( + length >= AES_256_GCM.tag_len() && length <= CHUNK_SIZE + AES_256_GCM.tag_len(), + "invalid encrypted backup chunk" + ); + let mut chunk = vec![0u8; length]; + source.read_exact(&mut chunk)?; + let plaintext = cipher + .open_in_place( + nonce(crypto.nonce_prefix, counter), + Aad::from(chunk_aad(&crypto.header_bytes, counter)), + &mut chunk, + ) + .map_err(|_| anyhow::anyhow!("incorrect backup password or corrupted archive"))?; + target.write_all(plaintext)?; + counter = counter.checked_add(1).context("backup is too large")?; + } + let mut extra = [0u8; 1]; + ensure!(source.read(&mut extra)? == 0, "trailing data after encrypted backup"); + target.sync_all()?; + Ok(crypto.key) +} + +fn seal_master_key(master: &[u8], backup_key: &[u8]) -> Result> { + ensure!(master.len() == 32, "invalid secret store master key"); + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let cipher = cipher(backup_key)?; + let mut encrypted = Zeroizing::new(master.to_vec()); + cipher + .seal_in_place_append_tag( + Nonce::assume_unique_for_key(nonce_bytes), + Aad::from(KEY_AAD), + &mut *encrypted, + ) + .map_err(|_| anyhow::anyhow!("could not wrap secret store key"))?; + let mut output = Vec::with_capacity(4 + 12 + encrypted.len()); + output.extend_from_slice(KEY_MAGIC); + output.extend_from_slice(&nonce_bytes); + output.extend_from_slice(encrypted.as_ref()); + Ok(output) +} + +fn open_master_key(blob: &[u8], backup_key: &[u8]) -> Result>> { + ensure!(blob.len() == 4 + 12 + 32 + AES_256_GCM.tag_len(), "invalid wrapped secret store key"); + ensure!(&blob[..4] == KEY_MAGIC, "invalid wrapped secret store key"); + let mut nonce_bytes = [0u8; 12]; + nonce_bytes.copy_from_slice(&blob[4..16]); + let mut encrypted = Zeroizing::new(blob[16..].to_vec()); + let cipher = cipher(backup_key)?; + let plaintext = cipher + .open_in_place( + Nonce::assume_unique_for_key(nonce_bytes), + Aad::from(KEY_AAD), + encrypted.as_mut(), + ) + .map_err(|_| anyhow::anyhow!("wrapped secret store key authentication failed"))?; + Ok(Zeroizing::new(plaintext.to_vec())) +} + +fn hash_file(path: &Path) -> Result<(u64, String)> { + let metadata = fs::symlink_metadata(path)?; + ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "backup input must be a regular file"); + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut size = 0u64; + let mut buffer = [0u8; 1024 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + size = size.checked_add(read as u64).context("backup input too large")?; + hasher.update(&buffer[..read]); + } + Ok((size, format!("{:x}", hasher.finalize()))) +} + +fn hash_bytes(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn excluded_directory(name: &str) -> bool { + matches!(name, "cache" | "logs" | "tmp" | "temp" | "work" | "acme-http01") +} diff --git a/src/backup/tests.rs b/src/backup/tests.rs new file mode 100644 index 0000000..f306b20 --- /dev/null +++ b/src/backup/tests.rs @@ -0,0 +1,31 @@ +mod tests { + use super::*; + + #[test] + fn password_encryption_rejects_wrong_password_and_tampering() { + let dir = tempfile::tempdir().unwrap(); + let input = dir.path().join("input"); + let encrypted = dir.path().join("backup.rpb"); + let output = dir.path().join("output"); + fs::write(&input, b"private backup payload").unwrap(); + let crypto = new_crypto("correct horse battery staple").unwrap(); + encrypt_archive(&input, &encrypted, &crypto).unwrap(); + assert!(decrypt_archive(&encrypted, &output, "wrong password").is_err()); + decrypt_archive(&encrypted, &output, "correct horse battery staple").unwrap(); + assert_eq!(fs::read(&output).unwrap(), b"private backup payload"); + } + + #[test] + fn restored_paths_cannot_escape_staging() { + assert!(safe_relative("certs/example.pem").is_ok()); + assert!(safe_relative("../outside").is_err()); + assert!(safe_relative("/absolute").is_err()); + assert!(safe_relative("a\\b").is_err()); + } + + #[test] + fn manifest_rejects_newer_rustproxy_version() { + assert!(validate_backup_version(env!("CARGO_PKG_VERSION")).is_ok()); + assert!(validate_backup_version("999.0.0").is_err()); + } +} diff --git a/src/backup/validation.rs b/src/backup/validation.rs new file mode 100644 index 0000000..a31eb41 --- /dev/null +++ b/src/backup/validation.rs @@ -0,0 +1,294 @@ +fn excluded_file(name: &str) -> bool { + name == "process.lock" + || name == "config.sqlite3" + || name == "config.sqlite3-wal" + || name == "config.sqlite3-shm" + || name == "master.key" + || name == ".lock" + || name.ends_with(".sock") + || name.ends_with(".pid") + || name.ends_with(".log") + || name.ends_with(".jsonl") +} + +fn collect_data_files(root: &Path, current: &Path, entries: &mut Vec) -> Result<()> { + for item in fs::read_dir(current)? { + let item = item?; + let path = item.path(); + let meta = fs::symlink_metadata(&path)?; + ensure!(!meta.file_type().is_symlink(), "backup refuses symlinks in the data directory: {}", path.display()); + let name = item.file_name(); + let name_text = name.to_string_lossy(); + if meta.is_dir() { + if excluded_directory(&name_text) || (path == root.join("secrets")) { + continue; + } + collect_data_files(root, &path, entries)?; + } else if meta.is_file() { + if excluded_file(&name_text) { + continue; + } + let relative = path.strip_prefix(root)?; + let archive_path = format!("data/{}", relative.to_string_lossy().replace('\\', "/")); + let (size, sha256) = hash_file(&path)?; + entries.push(ArchiveEntry { + manifest: ManifestEntry { path: archive_path, size, sha256 }, + source: EntrySource::File(path), + }); + } + } + Ok(()) +} + +fn write_inner_archive(path: &Path, manifest: &Manifest, entries: &[ArchiveEntry]) -> Result<()> { + let mut file = private_file_options().create_new(true).open(path)?; + file.write_all(ARCHIVE_MAGIC)?; + let manifest_bytes = serde_json::to_vec(manifest)?; + ensure!(manifest_bytes.len() <= MAX_MANIFEST_BYTES, "backup manifest is too large"); + file.write_all(&(manifest_bytes.len() as u32).to_be_bytes())?; + file.write_all(&manifest_bytes)?; + let mut buffer = [0u8; 1024 * 1024]; + for entry in entries { + let path_bytes = entry.manifest.path.as_bytes(); + ensure!(!path_bytes.is_empty() && path_bytes.len() <= u16::MAX as usize, "backup path is too long"); + file.write_all(&(path_bytes.len() as u16).to_be_bytes())?; + file.write_all(path_bytes)?; + file.write_all(&entry.manifest.size.to_be_bytes())?; + match &entry.source { + EntrySource::File(source) => { + let mut input = File::open(source)?; + let mut remaining = entry.manifest.size; + while remaining > 0 { + let take = usize::try_from(remaining.min(buffer.len() as u64))?; + let read = input.read(&mut buffer[..take])?; + ensure!(read > 0, "backup input changed while being read"); + file.write_all(&buffer[..read])?; + remaining -= read as u64; + } + ensure!(input.read(&mut buffer[..1])? == 0, "backup input changed while being read"); + } + EntrySource::Bytes(bytes) => file.write_all(bytes)?, + } + } + file.sync_all()?; + Ok(()) +} + +fn read_u16(reader: &mut R) -> Result { + let mut bytes = [0u8; 2]; + reader.read_exact(&mut bytes)?; + Ok(u16::from_be_bytes(bytes)) +} +fn read_u32(reader: &mut R) -> Result { + let mut bytes = [0u8; 4]; + reader.read_exact(&mut bytes)?; + Ok(u32::from_be_bytes(bytes)) +} +fn read_u64(reader: &mut R) -> Result { + let mut bytes = [0u8; 8]; + reader.read_exact(&mut bytes)?; + Ok(u64::from_be_bytes(bytes)) +} + +fn safe_relative(path: &str) -> Result { + ensure!(!path.is_empty() && path.len() <= 4096 && !path.contains('\\') && !path.contains(':'), "unsafe backup path"); + let value = Path::new(path); + ensure!(!value.is_absolute(), "unsafe absolute backup path"); + for component in value.components() { + ensure!(matches!(component, Component::Normal(_)), "unsafe backup path component"); + } + Ok(value.to_path_buf()) +} + +fn extract_inner_archive(path: &Path, stage: &Path) -> Result<(Manifest, Vec)> { + let mut file = File::open(path)?; + let mut magic = [0u8; 8]; + file.read_exact(&mut magic)?; + ensure!(&magic == ARCHIVE_MAGIC, "invalid decrypted RustProxy backup payload"); + let manifest_len = read_u32(&mut file)? as usize; + ensure!(manifest_len > 0 && manifest_len <= MAX_MANIFEST_BYTES, "invalid backup manifest length"); + let mut manifest_bytes = vec![0u8; manifest_len]; + file.read_exact(&mut manifest_bytes)?; + let manifest: Manifest = serde_json::from_slice(&manifest_bytes).context("invalid backup manifest")?; + ensure!(manifest.archive_version == ARCHIVE_VERSION, "unsupported backup archive version"); + validate_backup_version(&manifest.rustproxy_version)?; + ensure!(manifest.schema_version <= crate::db::SCHEMA_VERSION, "backup database schema is newer than this RustProxy binary"); + ensure!(manifest.entries.len() <= 100_000, "backup contains too many files"); + let mut wrapped_key = None; + let mut buffer = vec![0u8; 1024 * 1024]; + for expected in &manifest.entries { + let path_len = read_u16(&mut file)? as usize; + ensure!(path_len > 0 && path_len <= 4096, "invalid backup path length"); + let mut path_bytes = vec![0u8; path_len]; + file.read_exact(&mut path_bytes)?; + let archive_path = String::from_utf8(path_bytes).context("backup path is not UTF-8")?; + ensure!(archive_path == expected.path, "backup manifest entry order/path mismatch"); + let size = read_u64(&mut file)?; + ensure!(size == expected.size, "backup manifest size mismatch"); + let mut hasher = Sha256::new(); + if archive_path == "keys/secret-store-key.enc" { + ensure!(size <= 256, "wrapped secret store key is too large"); + let mut bytes = vec![0u8; size as usize]; + file.read_exact(&mut bytes)?; + hasher.update(&bytes); + wrapped_key = Some(bytes); + } else { + let relative = archive_path + .strip_prefix("data/") + .ok_or_else(|| anyhow::anyhow!("unsupported backup entry"))?; + let relative = safe_relative(relative)?; + let target = stage.join(relative); + if let Some(parent) = target.parent() { + private_dir(parent)?; + } + let mut output = private_file_options().create_new(true).open(&target)?; + let mut remaining = size; + while remaining > 0 { + let take = usize::try_from(remaining.min(buffer.len() as u64))?; + file.read_exact(&mut buffer[..take])?; + output.write_all(&buffer[..take])?; + hasher.update(&buffer[..take]); + remaining -= take as u64; + } + output.sync_all()?; + } + ensure!(format!("{:x}", hasher.finalize()) == expected.sha256, "backup entry checksum mismatch"); + } + let mut extra = [0u8; 1]; + ensure!(file.read(&mut extra)? == 0, "trailing data in backup payload"); + let key = wrapped_key.context("backup is missing Secret Store key material")?; + ensure!(stage.join("config.sqlite3").is_file(), "backup is missing database"); + Ok((manifest, key)) +} + +pub async fn create(data_dir: &Path, output: &Path, password: &str) -> Result { + crate::auth::prepare_directory(data_dir)?; + let _lock = crate::auth::lock_directory(data_dir) + .context("stop RustProxy before creating a full backup")?; + let database = data_dir.join("config.sqlite3"); + ensure!(database.is_file(), "database does not exist at {}", database.display()); + crate::auth::prepare_database(&database)?; + let store = crate::db::Store::open(&database, &crate::config::Config::default()).await?; + crate::secret_store::initialize(data_dir)?; + let cert_dir = data_dir.join("certs"); + if cert_dir.is_dir() { + crate::secret_store::migrate_acme_credentials(&store, data_dir, &cert_dir).await?; + } + let parent = parent_or_dot(output); + let data_root = fs::canonicalize(data_dir).context("cannot resolve data directory")?; + let output_parent = fs::canonicalize(parent).context("cannot resolve backup output directory")?; + ensure!( + !output_parent.starts_with(&data_root), + "backup output must be outside the RustProxy data directory" + ); + let temp = create_temp_root(parent, "rustproxy-backup")?; + let snapshot = temp.0.join("config.sqlite3"); + store.backup_to(&snapshot).await?; + let envelope = store.load().await?; + let crypto = new_crypto(password)?; + let master = crate::secret_store::read_master_key_for_backup(data_dir)?; + let key_blob = seal_master_key(master.as_ref(), crypto.key.as_ref())?; + + let (db_size, db_hash) = hash_file(&snapshot)?; + let mut entries = vec![ArchiveEntry { + manifest: ManifestEntry { + path: "data/config.sqlite3".into(), + size: db_size, + sha256: db_hash, + }, + source: EntrySource::File(snapshot), + }]; + collect_data_files(data_dir, data_dir, &mut entries)?; + entries.push(ArchiveEntry { + manifest: ManifestEntry { + path: "keys/secret-store-key.enc".into(), + size: key_blob.len() as u64, + sha256: hash_bytes(&key_blob), + }, + source: EntrySource::Bytes(key_blob), + }); + entries.sort_by(|a, b| a.manifest.path.cmp(&b.manifest.path)); + let manifest = Manifest { + archive_version: ARCHIVE_VERSION, + rustproxy_version: env!("CARGO_PKG_VERSION").into(), + schema_version: crate::db::SCHEMA_VERSION, + configuration_revision: envelope.revision, + created_at_unix: crate::auth::now(), + secret_store_encryption_version: 1, + backup_encryption: "Argon2id + AES-256-GCM chunked AEAD".into(), + entries: entries.iter().map(|entry| entry.manifest.clone()).collect(), + }; + let plaintext = temp.0.join("archive.rpa"); + write_inner_archive(&plaintext, &manifest, &entries)?; + encrypt_archive(&plaintext, output, &crypto)?; + store.close().await; + Ok(BackupSummary { + path: output.to_path_buf(), + files: manifest.entries.len(), + configuration_revision: manifest.configuration_revision, + }) +} + +pub async fn restore(archive: &Path, data_dir: &Path, password: &str) -> Result { + let archive_meta = fs::symlink_metadata(archive).context("backup archive does not exist")?; + ensure!(archive_meta.is_file() && !archive_meta.file_type().is_symlink(), "backup archive must be a regular file"); + let parent = parent_or_dot(data_dir); + ensure!(parent.is_dir(), "restore parent directory does not exist"); + let old_lock = if data_dir.exists() { + crate::auth::prepare_directory(data_dir)?; + Some(crate::auth::lock_directory(data_dir).context("stop RustProxy before restore")?) + } else { + None + }; + let temp = create_temp_root(parent, "rustproxy-restore")?; + let plaintext = temp.0.join("archive.rpa"); + let backup_key = decrypt_archive(archive, &plaintext, password)?; + let stage = temp.0.join("stage"); + private_dir(&stage)?; + let (manifest, wrapped_key) = extract_inner_archive(&plaintext, &stage)?; + let master = open_master_key(&wrapped_key, backup_key.as_ref())?; + crate::secret_store::write_restored_master_key(&stage, master.as_ref())?; + harden_tree(&stage)?; + + crate::auth::prepare_directory(&stage)?; + let stage_db = stage.join("config.sqlite3"); + crate::auth::prepare_database(&stage_db)?; + let store = crate::db::Store::open(&stage_db, &crate::config::Config::default()).await?; + let envelope = store.load().await.context("restored configuration is unreadable")?; + crate::secret_store::validate_all(&store, &stage) + .await + .context("restored Secret Store cannot be decrypted")?; + store.close().await; + for suffix in ["-wal", "-shm"] { + let mut name = stage_db.as_os_str().to_os_string(); + name.push(suffix); + let _ = fs::remove_file(PathBuf::from(name)); + } + let stage_lock = crate::auth::lock_directory(&stage)?; + let previous = temp.0.join("previous-data"); + if data_dir.exists() { + fs::rename(data_dir, &previous).context("could not move current data directory aside")?; + } + if let Err(error) = fs::rename(&stage, data_dir) { + if previous.exists() { + let _ = fs::rename(&previous, data_dir); + } + return Err(error).context("could not atomically activate restored data directory"); + } + drop(old_lock); + if previous.exists() { + fs::remove_dir_all(&previous).context("restore succeeded but old data directory could not be removed")?; + } + drop(stage_lock); + Ok(BackupSummary { + path: archive.to_path_buf(), + files: manifest.entries.len(), + configuration_revision: envelope.revision, + }) +} + +pub fn generated_password() -> String { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} diff --git a/src/balance.rs b/src/balance.rs index 1d7d5d6..b32558f 100644 --- a/src/balance.rs +++ b/src/balance.rs @@ -1,2195 +1,12 @@ //! 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() {} -} +include!("balance/model.rs"); +include!("balance/health.rs"); +include!("balance/lease_runtime.rs"); +include!("balance/pool_core.rs"); +include!("balance/pool_selection.rs"); +include!("balance/pool_view.rs"); #[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" - ); - } -} +include!("balance/tests.rs"); diff --git a/src/balance/health.rs b/src/balance/health.rs new file mode 100644 index 0000000..8290c48 --- /dev/null +++ b/src/balance/health.rs @@ -0,0 +1,353 @@ +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, +} diff --git a/src/balance/lease_runtime.rs b/src/balance/lease_runtime.rs new file mode 100644 index 0000000..418bda2 --- /dev/null +++ b/src/balance/lease_runtime.rs @@ -0,0 +1,294 @@ +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 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() {} +} diff --git a/src/balance/model.rs b/src/balance/model.rs new file mode 100644 index 0000000..1bdd9e0 --- /dev/null +++ b/src/balance/model.rs @@ -0,0 +1,360 @@ +// Core pool model and policies. +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, +} diff --git a/src/balance/pool_core.rs b/src/balance/pool_core.rs new file mode 100644 index 0000000..0e766b5 --- /dev/null +++ b/src/balance/pool_core.rs @@ -0,0 +1,334 @@ +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, + }) + } +} diff --git a/src/balance/pool_selection.rs b/src/balance/pool_selection.rs new file mode 100644 index 0000000..a7284f3 --- /dev/null +++ b/src/balance/pool_selection.rs @@ -0,0 +1,132 @@ +impl Pool { + 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; + } + } +} diff --git a/src/balance/pool_view.rs b/src/balance/pool_view.rs new file mode 100644 index 0000000..49882de --- /dev/null +++ b/src/balance/pool_view.rs @@ -0,0 +1,159 @@ +impl Pool { + 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, + }) + } +} diff --git a/src/balance/tests.rs b/src/balance/tests.rs new file mode 100644 index 0000000..da75279 --- /dev/null +++ b/src/balance/tests.rs @@ -0,0 +1,4 @@ +mod tests { + include!("tests/part_01.rs"); + include!("tests/part_02.rs"); +} diff --git a/src/balance/tests/part_01.rs b/src/balance/tests/part_01.rs new file mode 100644 index 0000000..16afe96 --- /dev/null +++ b/src/balance/tests/part_01.rs @@ -0,0 +1,393 @@ + 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 + ); + } + } + } diff --git a/src/balance/tests/part_02.rs b/src/balance/tests/part_02.rs new file mode 100644 index 0000000..64163db --- /dev/null +++ b/src/balance/tests/part_02.rs @@ -0,0 +1,170 @@ + #[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 index 662aa56..bf62c3d 100644 --- a/src/basic_auth.rs +++ b/src/basic_auth.rs @@ -248,82 +248,4 @@ impl Compiled { } #[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())); - } -} +include!("basic_auth/tests.rs"); diff --git a/src/basic_auth/tests.rs b/src/basic_auth/tests.rs new file mode 100644 index 0000000..062f5c5 --- /dev/null +++ b/src/basic_auth/tests.rs @@ -0,0 +1,79 @@ +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 index 5f3701e..8179c8c 100644 --- a/src/cache/cold.rs +++ b/src/cache/cold.rs @@ -1,1062 +1,4 @@ -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 { - _inner: 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, mut paths: Vec) { - if paths.is_empty() { - return; - } - if let Some(tx) = &self.cold_tx { - match tx.try_send(ColdCommand::Delete(paths)) { - Ok(()) => return, - Err(error) => { - paths = match error.into_inner() { - ColdCommand::Delete(paths) => paths, - _ => unreachable!("enqueue_cold_delete only sends delete commands"), - }; - } - } - } - // Do not create an unbounded number of detached delete tasks when the - // writer queue is saturated. A tiny fallback pool absorbs short bursts; - // sustained saturation falls back to synchronous removal so stale files - // cannot be resurrected on restart at the cost of bounded backpressure. - if let Ok(permit) = self.cold_delete_fallbacks.clone().try_acquire_owned() { - tokio::spawn(async move { - let _permit = permit; - for path in paths { - let _ = tokio::fs::remove_file(path).await; - } - }); - } else { - for path in paths { - let _ = std::fs::remove_file(path); - } - } - } - - 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 requested_limit = request - .and_then(|value| value.oldest) - .unwrap_or(max_delete) - .min(max_delete); - let manual = request.is_some(); - let now = SystemTime::now(); - let mut candidates = BinaryHeap::with_capacity(requested_limit.saturating_add(1)); - 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 { - let priority = if manual { - CleanupPriority::Manual(stored_at, last_access, hits) - } else { - CleanupPriority::Automatic(hits, last_access, stored_at) - }; - let candidate = CleanupCandidate { - priority, - sequence, - key, - }; - if candidates.len() < requested_limit { - candidates.push(candidate); - } else if candidates - .peek() - .is_some_and(|worst| candidate.cmp(worst).is_lt()) - { - candidates.pop(); - candidates.push(candidate); - } - } - } - tokio::task::yield_now().await; - } - - let mut candidates = candidates.into_vec(); - candidates.sort_unstable(); - 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 candidate 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(&candidate.key).is_some_and(|entry| { - entry.sequence == candidate.sequence && entry.pinned_reads == 0 - }) { - continue; - } - if let Some(entry) = cold.remove(&candidate.key) { - result.removed_entries += 1; - result.freed_bytes = result.freed_bytes.saturating_add(entry.disk_bytes); - delete.extend([entry.meta_path, entry.body_path]); - } - } - } - // Keep pending deletion metadata bounded to one selection batch; a - // 10k-entry manual cleanup must not build one large Vec. - self.enqueue_cold_delete(std::mem::take(&mut delete)); - tokio::task::yield_now().await; - } - - if result.removed_entries > 0 { - self.counters - .cold_evictions - .fetch_add(result.removed_entries as u64, Ordering::Relaxed); - } - result - } - - async fn cold_gc_pass(&self) -> ColdCleanupResult { - let Ok(_cleanup_slot) = self.cleanup_slots.clone().try_acquire_owned() else { - return ColdCleanupResult::default(); - }; - 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 cleanup_slot = self - .cleanup_slots - .clone() - .try_acquire_owned() - .map_err(|_| anyhow::anyhow!("cold cache cleanup is already running"))?; - 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 _cleanup_slot = cleanup_slot; - { - 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])); - } - } -} +include!("cold/startup.rs"); +include!("cold/worker.rs"); +include!("cold/demotion.rs"); +include!("cold/cleanup.rs"); diff --git a/src/cache/cold/cleanup.rs b/src/cache/cold/cleanup.rs new file mode 100644 index 0000000..733548e --- /dev/null +++ b/src/cache/cold/cleanup.rs @@ -0,0 +1,289 @@ +impl Cache { + fn enqueue_cold_delete(&self, mut paths: Vec) { + if paths.is_empty() { + return; + } + if let Some(tx) = &self.cold_tx { + match tx.try_send(ColdCommand::Delete(paths)) { + Ok(()) => return, + Err(error) => { + paths = match error.into_inner() { + ColdCommand::Delete(paths) => paths, + _ => unreachable!("enqueue_cold_delete only sends delete commands"), + }; + } + } + } + // Do not create an unbounded number of detached delete tasks when the + // writer queue is saturated. A tiny fallback pool absorbs short bursts; + // sustained saturation falls back to synchronous removal so stale files + // cannot be resurrected on restart at the cost of bounded backpressure. + if let Ok(permit) = self.cold_delete_fallbacks.clone().try_acquire_owned() { + tokio::spawn(async move { + let _permit = permit; + for path in paths { + let _ = tokio::fs::remove_file(path).await; + } + }); + } else { + for path in paths { + let _ = std::fs::remove_file(path); + } + } + } + + 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 requested_limit = request + .and_then(|value| value.oldest) + .unwrap_or(max_delete) + .min(max_delete); + let manual = request.is_some(); + let now = SystemTime::now(); + let mut candidates = BinaryHeap::with_capacity(requested_limit.saturating_add(1)); + 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 { + let priority = if manual { + CleanupPriority::Manual(stored_at, last_access, hits) + } else { + CleanupPriority::Automatic(hits, last_access, stored_at) + }; + let candidate = CleanupCandidate { + priority, + sequence, + key, + }; + if candidates.len() < requested_limit { + candidates.push(candidate); + } else if candidates + .peek() + .is_some_and(|worst| candidate.cmp(worst).is_lt()) + { + candidates.pop(); + candidates.push(candidate); + } + } + } + tokio::task::yield_now().await; + } + + let mut candidates = candidates.into_vec(); + candidates.sort_unstable(); + 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 candidate 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(&candidate.key) + .is_some_and(|entry| entry.sequence == candidate.sequence && entry.pinned_reads == 0) + { + continue; + } + if let Some(entry) = cold.remove(&candidate.key) { + result.removed_entries += 1; + result.freed_bytes = result.freed_bytes.saturating_add(entry.disk_bytes); + delete.extend([entry.meta_path, entry.body_path]); + } + } + } + // Keep pending deletion metadata bounded to one selection batch; a + // 10k-entry manual cleanup must not build one large Vec. + self.enqueue_cold_delete(std::mem::take(&mut delete)); + tokio::task::yield_now().await; + } + + if result.removed_entries > 0 { + self.counters + .cold_evictions + .fetch_add(result.removed_entries as u64, Ordering::Relaxed); + } + result + } + + async fn cold_gc_pass(&self) -> ColdCleanupResult { + let Ok(_cleanup_slot) = self.cleanup_slots.clone().try_acquire_owned() else { + return ColdCleanupResult::default(); + }; + 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 cleanup_slot = self + .cleanup_slots + .clone() + .try_acquire_owned() + .map_err(|_| anyhow::anyhow!("cold cache cleanup is already running"))?; + 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 _cleanup_slot = cleanup_slot; + { + 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/cold/demotion.rs b/src/cache/cold/demotion.rs new file mode 100644 index 0000000..de43111 --- /dev/null +++ b/src/cache/cold/demotion.rs @@ -0,0 +1,247 @@ +impl Cache { + 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 { + _inner: 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; + } + } +} diff --git a/src/cache/cold/startup.rs b/src/cache/cold/startup.rs new file mode 100644 index 0000000..200f30c --- /dev/null +++ b/src/cache/cold/startup.rs @@ -0,0 +1,211 @@ +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); + } + } +} diff --git a/src/cache/cold/worker.rs b/src/cache/cold/worker.rs new file mode 100644 index 0000000..171a123 --- /dev/null +++ b/src/cache/cold/worker.rs @@ -0,0 +1,270 @@ +impl Cache { + 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; + } + } +} diff --git a/src/cache/collapse.rs b/src/cache/collapse.rs index f231f5f..5c73b92 100644 --- a/src/cache/collapse.rs +++ b/src/cache/collapse.rs @@ -36,7 +36,10 @@ impl Drop for CollapseWaiterGuard { 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 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); @@ -50,12 +53,19 @@ impl Drop for FillLeaderGuard { } impl Cache { - pub async fn claim_fill(self: &Arc, ticket: &Ticket, settings: &Settings) -> FillClaim { + 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()); + 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 { @@ -121,11 +131,11 @@ impl Cache { 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::>() + 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); @@ -134,7 +144,10 @@ impl Cache { 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 mut flights = self + .fill_flights + .lock() + .unwrap_or_else(|e| e.into_inner()); let keys = flights .iter() .filter(|(_, flight)| flight.scope == scope) @@ -237,7 +250,12 @@ impl Cache { } if self .proactive_inflight - .compare_exchange_weak(current, current + 1, Ordering::AcqRel, Ordering::Relaxed) + .compare_exchange_weak( + current, + current + 1, + Ordering::AcqRel, + Ordering::Relaxed, + ) .is_ok() { self.counters diff --git a/src/cache/dashboard.rs b/src/cache/dashboard.rs index c757bdd..4ab83b9 100644 --- a/src/cache/dashboard.rs +++ b/src/cache/dashboard.rs @@ -102,22 +102,14 @@ impl Cache { ("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_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_allocated_bytes", "gauge", view.hot_allocated_bytes as u64), ("hot_limit_bytes", "gauge", view.hot_limit_bytes as u64), ( "transient_limit_bytes", @@ -132,11 +124,7 @@ impl Cache { ("cold_entries", "gauge", view.cold_entries as u64), ("cold_bytes", "gauge", view.cold_bytes), ("cold_limit_bytes", "gauge", view.cold_limit_bytes), - ( - "cold_limit_entries", - "gauge", - view.cold_limit_entries as u64, - ), + ("cold_limit_entries", "gauge", view.cold_limit_entries as u64), ("cold_hits_total", "counter", view.cold_hits), ("cold_promotions_total", "counter", view.cold_promotions), ("hot_demotions_total", "counter", view.hot_demotions), @@ -213,9 +201,7 @@ impl Cache { 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); + self.counters.purge_entries.fetch_add(count as u64, Ordering::Relaxed); count } /// Purge one vhost without evicting other hosts. Generation bumps also stop @@ -256,9 +242,7 @@ impl Cache { 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); + self.counters.purge_entries.fetch_add(count as u64, Ordering::Relaxed); count } pub fn scope_view(&self, scope: &str) -> ScopeView { diff --git a/src/cache/fill.rs b/src/cache/fill.rs index acb198e..febd05e 100644 --- a/src/cache/fill.rs +++ b/src/cache/fill.rs @@ -233,8 +233,7 @@ impl HttpBody for FillBody { this.candidate = None; } else { let after = candidate.data.capacity(); - let unbudgeted = - after.saturating_sub(before.saturating_add(required_growth)); + let unbudgeted = after.saturating_sub(before.saturating_add(required_growth)); if unbudgeted > 0 && !candidate.fill_gauge.grow(unbudgeted, &candidate.settings) { @@ -341,3 +340,4 @@ impl Drop for FillBody { } } } + diff --git a/src/cache/invalidation.rs b/src/cache/invalidation.rs index 1c92d66..0e54738 100644 --- a/src/cache/invalidation.rs +++ b/src/cache/invalidation.rs @@ -1,584 +1,2 @@ -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 - } -} +include!("invalidation/ban.rs"); +include!("invalidation/invalidate.rs"); diff --git a/src/cache/invalidation/ban.rs b/src/cache/invalidation/ban.rs new file mode 100644 index 0000000..fa56147 --- /dev/null +++ b/src/cache/invalidation/ban.rs @@ -0,0 +1,277 @@ +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, + } + } +} diff --git a/src/cache/invalidation/invalidate.rs b/src/cache/invalidation/invalidate.rs new file mode 100644 index 0000000..2f8a9e7 --- /dev/null +++ b/src/cache/invalidation/invalidate.rs @@ -0,0 +1,306 @@ +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 index 479fb7f..cbc9835 100644 --- a/src/cache/lifecycle.rs +++ b/src/cache/lifecycle.rs @@ -95,9 +95,7 @@ impl Cache { let weak = Arc::downgrade(&cache); tokio::spawn(async move { loop { - let Some(cache) = weak.upgrade() else { - break; - }; + let Some(cache) = weak.upgrade() else { break; }; let interval = cache .tier_settings .lock() @@ -159,8 +157,8 @@ impl Cache { } 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 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)); @@ -168,14 +166,20 @@ impl Cache { let generation = store.generation; let mut removed = 0usize; let mut released_hot = false; - while store.bytes.saturating_add(fill_now).saturating_add(bytes) > allowed { + 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 { + let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) + else { break; }; let Some(old) = store.remove(&key) else { @@ -207,7 +211,11 @@ impl Cache { self.counters.evictions.fetch_add(1, Ordering::Relaxed); } } - let fits = store.bytes.saturating_add(fill_now).saturating_add(bytes) <= allowed; + 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); @@ -227,6 +235,7 @@ impl Cache { } } + /// Bound long-lived HOT RSS without flushing the working set. Only entries /// idle for the configured age are considered, and only while HOT usage is /// above the configured target waterline. @@ -263,29 +272,14 @@ impl Cache { // LRU order is also last-access order, so newer entries cannot qualify. break; } - let Some(entry) = store.remove(&key) else { - continue; - }; + let Some(entry) = store.remove(&key) else { continue; }; removed += 1; - 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 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 !(settings.cold_enabled && settings.cold_on_lru && self.enqueue_demotion( - key, - entry, - revision, - generation, - scope_generation, - key_generation, + key, entry, revision, generation, scope_generation, key_generation, )) { self.counters.evictions.fetch_add(1, Ordering::Relaxed); @@ -307,7 +301,8 @@ impl Cache { 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 { + let Some((_, key)) = store.lru.first_key_value().map(|(seq, key)| (*seq, *key)) + else { break; }; let Some(old) = store.remove(&key) else { diff --git a/src/cache/lookup.rs b/src/cache/lookup.rs index a87143d..eca72f0 100644 --- a/src/cache/lookup.rs +++ b/src/cache/lookup.rs @@ -1,651 +1,3 @@ -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 - } -} +include!("lookup/hot.rs"); +include!("lookup/tiered.rs"); +include!("lookup/insert.rs"); diff --git a/src/cache/lookup/hot.rs b/src/cache/lookup/hot.rs new file mode 100644 index 0000000..6b566c8 --- /dev/null +++ b/src/cache/lookup/hot.rs @@ -0,0 +1,296 @@ +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(), + }) + } +} diff --git a/src/cache/lookup/insert.rs b/src/cache/lookup/insert.rs new file mode 100644 index 0000000..4977a85 --- /dev/null +++ b/src/cache/lookup/insert.rs @@ -0,0 +1,140 @@ +impl Cache { + 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/lookup/tiered.rs b/src/cache/lookup/tiered.rs new file mode 100644 index 0000000..6365d18 --- /dev/null +++ b/src/cache/lookup/tiered.rs @@ -0,0 +1,212 @@ +impl Cache { + 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, + }), + ) + } +} diff --git a/src/cache/model.rs b/src/cache/model.rs index f08b1eb..9b54f7a 100644 --- a/src/cache/model.rs +++ b/src/cache/model.rs @@ -1,1739 +1,5 @@ -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, BinaryHeap, 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; -const COLD_DELETE_FALLBACKS: usize = 2; -const COLD_CLEANUP_SLOTS: usize = 1; -const BACKGROUND_REVALIDATION_LIMIT: 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]; - -#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)] -enum CleanupPriority { - Automatic(u64, SystemTime, SystemTime), - Manual(SystemTime, SystemTime, u64), -} - -#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)] -struct CleanupCandidate { - priority: CleanupPriority, - sequence: u64, - key: Key, -} - -/// `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, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum MemoryPreset { - Conservative, - Recommended, - Performance, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ColdPreset { - Recommended, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct Settings { - pub enabled: bool, - /// UI sizing profile last explicitly selected by the administrator. The - /// actual numeric limits remain authoritative; this only preserves intent - /// across reloads so the dashboard can show which preset was applied. - pub memory_preset: Option, - /// 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, - /// Automatically release idle HOT objects once the tier is above the target waterline. - pub hot_auto_trim_enabled: bool, - /// Minimum idle age before automatic HOT trimming can evict an object. - pub hot_auto_trim_after_seconds: u64, - /// Target HOT usage percentage reached by each automatic trim pass. - pub hot_auto_trim_target_percent: u8, - /// Persistent cold tier. Disabled by default to avoid implicit disk writes. - pub cold_enabled: bool, - /// Last explicitly selected COLD policy profile, persisted with the cache - /// configuration for the same reason as `memory_preset`. - pub cold_preset: Option, - /// 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, - memory_preset: None, - 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, - hot_auto_trim_enabled: true, - hot_auto_trim_after_seconds: 300, - hot_auto_trim_target_percent: 80, - cold_enabled: false, - cold_preset: None, - 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!( - (5..=86_400).contains(&self.hot_auto_trim_after_seconds), - "cache.hot_auto_trim_after_seconds must be 5..86400" - ); - ensure!( - (10..=95).contains(&self.hot_auto_trim_target_percent), - "cache.hot_auto_trim_target_percent must be 10..95" - ); - 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_limit_bytes: u64, - pub cold_limit_entries: usize, - 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 { - _inner: 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>, - cold_delete_fallbacks: Arc, - cleanup_slots: Arc, - 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) -} +include!("model/settings.rs"); +include!("model/entry.rs"); +include!("model/cache.rs"); +include!("model/maintenance.rs"); +include!("model/views.rs"); diff --git a/src/cache/model/cache.rs b/src/cache/model/cache.rs new file mode 100644 index 0000000..896ac58 --- /dev/null +++ b/src/cache/model/cache.rs @@ -0,0 +1,396 @@ +#[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 + } +} diff --git a/src/cache/model/entry.rs b/src/cache/model/entry.rs new file mode 100644 index 0000000..02b0155 --- /dev/null +++ b/src/cache/model/entry.rs @@ -0,0 +1,428 @@ +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!( + (5..=86_400).contains(&self.hot_auto_trim_after_seconds), + "cache.hot_auto_trim_after_seconds must be 5..86400" + ); + ensure!( + (10..=95).contains(&self.hot_auto_trim_target_percent), + "cache.hot_auto_trim_target_percent must be 10..95" + ); + 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_limit_bytes: u64, + pub cold_limit_entries: usize, + 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, +} diff --git a/src/cache/model/maintenance.rs b/src/cache/model/maintenance.rs new file mode 100644 index 0000000..d3888a7 --- /dev/null +++ b/src/cache/model/maintenance.rs @@ -0,0 +1,426 @@ +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 { + _inner: 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>, + cold_delete_fallbacks: Arc, + cleanup_slots: Arc, + 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 +} diff --git a/src/cache/model/settings.rs b/src/cache/model/settings.rs new file mode 100644 index 0000000..fa264e8 --- /dev/null +++ b/src/cache/model/settings.rs @@ -0,0 +1,245 @@ +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, BinaryHeap, 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; +const COLD_DELETE_FALLBACKS: usize = 2; +const COLD_CLEANUP_SLOTS: usize = 1; +const BACKGROUND_REVALIDATION_LIMIT: 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]; + +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)] +enum CleanupPriority { + Automatic(u64, SystemTime, SystemTime), + Manual(SystemTime, SystemTime, u64), +} + +#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)] +struct CleanupCandidate { + priority: CleanupPriority, + sequence: u64, + key: Key, +} + +/// `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, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MemoryPreset { + Conservative, + Recommended, + Performance, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ColdPreset { + Recommended, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + pub enabled: bool, + /// UI sizing profile last explicitly selected by the administrator. The + /// actual numeric limits remain authoritative; this only preserves intent + /// across reloads so the dashboard can show which preset was applied. + pub memory_preset: Option, + /// 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, + /// Automatically release idle HOT objects once the tier is above the target waterline. + pub hot_auto_trim_enabled: bool, + /// Minimum idle age before automatic HOT trimming can evict an object. + pub hot_auto_trim_after_seconds: u64, + /// Target HOT usage percentage reached by each automatic trim pass. + pub hot_auto_trim_target_percent: u8, + /// Persistent cold tier. Disabled by default to avoid implicit disk writes. + pub cold_enabled: bool, + /// Last explicitly selected COLD policy profile, persisted with the cache + /// configuration for the same reason as `memory_preset`. + pub cold_preset: Option, + /// 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, + memory_preset: None, + 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, + hot_auto_trim_enabled: true, + hot_auto_trim_after_seconds: 300, + hot_auto_trim_target_percent: 80, + cold_enabled: false, + cold_preset: None, + 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, + } + } +} diff --git a/src/cache/model/views.rs b/src/cache/model/views.rs new file mode 100644 index 0000000..effa2d9 --- /dev/null +++ b/src/cache/model/views.rs @@ -0,0 +1,232 @@ +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 index c8e032b..85c5933 100644 --- a/src/cache/policy.rs +++ b/src/cache/policy.rs @@ -1,570 +1,2 @@ -/// 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, - }) -} +include!("policy/request.rs"); +include!("policy/response.rs"); diff --git a/src/cache/policy/request.rs b/src/cache/policy/request.rs new file mode 100644 index 0000000..9f0a692 --- /dev/null +++ b/src/cache/policy/request.rs @@ -0,0 +1,388 @@ +/// 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, +} diff --git a/src/cache/policy/response.rs b/src/cache/policy/response.rs new file mode 100644 index 0000000..46e5536 --- /dev/null +++ b/src/cache/policy/response.rs @@ -0,0 +1,182 @@ +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/tests.rs b/src/cache/tests.rs index 47f4fe5..3632d91 100644 --- a/src/cache/tests.rs +++ b/src/cache/tests.rs @@ -1,3453 +1,18 @@ #[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.memory_preset, None); - assert_eq!(s.cold_preset, None); - 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 cache_preset_intent_round_trips_with_configuration() { - let mut settings = Settings::default(); - settings.memory_preset = Some(MemoryPreset::Recommended); - settings.cold_preset = Some(ColdPreset::Recommended); - let encoded = serde_json::to_value(&settings).unwrap(); - assert_eq!(encoded["memory_preset"], "recommended"); - assert_eq!(encoded["cold_preset"], "recommended"); - let decoded: Settings = serde_json::from_value(encoded).unwrap(); - assert_eq!(decoded.memory_preset, Some(MemoryPreset::Recommended)); - assert_eq!(decoded.cold_preset, Some(ColdPreset::Recommended)); - } - - #[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); - } - } -} +include!("tests/tests.rs"); #[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()); - } -} +include!("tests/scope_tests.rs"); + #[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}"); - } - } +include!("tests/dashboard_tests.rs"); - #[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; +include!("tests/v022_cache_tests.rs"); - 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); - } - - #[test] - fn proactive_refresh_pending_tasks_are_bounded() { - let cache = Cache::new(22); - let mut settings = active_settings(); - settings.proactive_refresh_enabled = true; - settings.proactive_refresh_percent = 100; - settings.proactive_refresh_max_inflight = 1; - let mut guards = Vec::new(); - for index in 0..64 { - let ticket = cache - .ticket( - 22, - "http", - "refresh.example", - "origin", - &format!("/asset-{index}"), - &HeaderMap::new(), - ) - .unwrap(); - guards.push( - cache - .begin_proactive_schedule(&ticket, &settings) - .expect("schedule below pending limit"), - ); - } - let overflow = cache - .ticket( - 22, - "http", - "refresh.example", - "origin", - "/overflow", - &HeaderMap::new(), - ) - .unwrap(); - assert!(cache - .begin_proactive_schedule(&overflow, &settings) - .is_none()); - assert!(cache.view().proactive_skipped > 0); - drop(guards); - } - - #[test] - fn background_revalidation_set_has_a_hard_limit() { - let cache = Cache::new(22); - let base = Revalidation { - headers: HeaderMap::new(), - body: Bytes::new(), - body_allocated: 0, - _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: 0, - ttl: 0, - stale_if_error: 0, - must_revalidate: false, - proxy_revalidate: false, - origin_etag: None, - origin_last_modified: None, - }; - let mut guards = Vec::new(); - for index in 0..BACKGROUND_REVALIDATION_LIMIT { - let mut cached = base.clone(); - cached.storage_key[0..8].copy_from_slice(&(index as u64).to_be_bytes()); - guards.push( - cache - .begin_background_revalidation(&cached) - .expect("revalidation below hard limit"), - ); - } - let mut overflow = base; - overflow.storage_key = [0xff; 32]; - assert!(cache.begin_background_revalidation(&overflow).is_none()); - drop(guards); - } - - #[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; +include!("tests/cold_tier_tests.rs"); - 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 manual_cold_cleanup_is_single_flight() { - 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, - shutdown.clone(), - ); - let _held = cache.cleanup_slots.clone().try_acquire_owned().unwrap(); - let result = cache.start_cold_cleanup(ColdCleanupRequest { - unused_for_seconds: None, - oldest: Some(1), - hits_zero: false, - }); - assert!(result.is_err()); - shutdown.cancel(); - } - - #[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/tests/cold_tier_tests.rs b/src/cache/tests/cold_tier_tests.rs new file mode 100644 index 0000000..e170338 --- /dev/null +++ b/src/cache/tests/cold_tier_tests.rs @@ -0,0 +1,4 @@ +mod cold_tier_tests { + include!("cold_tier_tests/part_01.rs"); + include!("cold_tier_tests/part_02.rs"); +} diff --git a/src/cache/tests/cold_tier_tests/part_01.rs b/src/cache/tests/cold_tier_tests/part_01.rs new file mode 100644 index 0000000..a2d70d7 --- /dev/null +++ b/src/cache/tests/cold_tier_tests/part_01.rs @@ -0,0 +1,412 @@ + 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(); + } diff --git a/src/cache/tests/cold_tier_tests/part_02.rs b/src/cache/tests/cold_tier_tests/part_02.rs new file mode 100644 index 0000000..fb2a34a --- /dev/null +++ b/src/cache/tests/cold_tier_tests/part_02.rs @@ -0,0 +1,293 @@ + #[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 manual_cold_cleanup_is_single_flight() { + 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, shutdown.clone()); + let _held = cache.cleanup_slots.clone().try_acquire_owned().unwrap(); + let result = cache.start_cold_cleanup(ColdCleanupRequest { + unused_for_seconds: None, + oldest: Some(1), + hits_zero: false, + }); + assert!(result.is_err()); + shutdown.cancel(); + } + + #[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(), + scheme: "https".to_owned(), + authority: "example.test".to_owned(), + domain: "example.test".to_owned(), + path: "/asset".to_owned(), + target: "https://example.test/asset".to_owned(), + tags: Vec::new(), + created_at: SystemTime::now(), + 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/tests/dashboard_tests.rs b/src/cache/tests/dashboard_tests.rs new file mode 100644 index 0000000..849c448 --- /dev/null +++ b/src/cache/tests/dashboard_tests.rs @@ -0,0 +1,173 @@ +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); + } +} diff --git a/src/cache/tests/scope_tests.rs b/src/cache/tests/scope_tests.rs new file mode 100644 index 0000000..8b509cd --- /dev/null +++ b/src/cache/tests/scope_tests.rs @@ -0,0 +1,99 @@ +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()); + } +} diff --git a/src/cache/tests/tests.rs b/src/cache/tests/tests.rs new file mode 100644 index 0000000..fe46cbd --- /dev/null +++ b/src/cache/tests/tests.rs @@ -0,0 +1,6 @@ +mod tests { + include!("tests/part_01.rs"); + include!("tests/part_02.rs"); + include!("tests/part_03.rs"); + include!("tests/part_04.rs"); +} diff --git a/src/cache/tests/tests/part_01.rs b/src/cache/tests/tests/part_01.rs new file mode 100644 index 0000000..c39a8ef --- /dev/null +++ b/src/cache/tests/tests/part_01.rs @@ -0,0 +1,404 @@ + 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); + } diff --git a/src/cache/tests/tests/part_02.rs b/src/cache/tests/tests/part_02.rs new file mode 100644 index 0000000..550a9a5 --- /dev/null +++ b/src/cache/tests/tests/part_02.rs @@ -0,0 +1,411 @@ + #[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.memory_preset, None); + assert_eq!(s.cold_preset, None); + 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 cache_preset_intent_round_trips_with_configuration() { + let mut settings = Settings::default(); + settings.memory_preset = Some(MemoryPreset::Recommended); + settings.cold_preset = Some(ColdPreset::Recommended); + let encoded = serde_json::to_value(&settings).unwrap(); + assert_eq!(encoded["memory_preset"], "recommended"); + assert_eq!(encoded["cold_preset"], "recommended"); + let decoded: Settings = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded.memory_preset, Some(MemoryPreset::Recommended)); + assert_eq!(decoded.cold_preset, Some(ColdPreset::Recommended)); + } + + #[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); + } diff --git a/src/cache/tests/tests/part_03.rs b/src/cache/tests/tests/part_03.rs new file mode 100644 index 0000000..3207fbe --- /dev/null +++ b/src/cache/tests/tests/part_03.rs @@ -0,0 +1,417 @@ + #[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 age_plan = plan(StatusCode::OK, &h, &settings, Duration::from_millis(250)).unwrap(); + assert!(age_plan.initial_age >= Duration::from_secs(10)); + assert!(age_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); + } diff --git a/src/cache/tests/tests/part_04.rs b/src/cache/tests/tests/part_04.rs new file mode 100644 index 0000000..bf01009 --- /dev/null +++ b/src/cache/tests/tests/part_04.rs @@ -0,0 +1,221 @@ + #[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); + } + } diff --git a/src/cache/tests/v022_cache_tests.rs b/src/cache/tests/v022_cache_tests.rs new file mode 100644 index 0000000..fdfcb32 --- /dev/null +++ b/src/cache/tests/v022_cache_tests.rs @@ -0,0 +1,4 @@ +mod v022_cache_tests { + include!("v022_cache_tests/part_01.rs"); + include!("v022_cache_tests/part_02.rs"); +} diff --git a/src/cache/tests/v022_cache_tests/part_01.rs b/src/cache/tests/v022_cache_tests/part_01.rs new file mode 100644 index 0000000..af1ea4e --- /dev/null +++ b/src/cache/tests/v022_cache_tests/part_01.rs @@ -0,0 +1,421 @@ + 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); + } diff --git a/src/cache/tests/v022_cache_tests/part_02.rs b/src/cache/tests/v022_cache_tests/part_02.rs new file mode 100644 index 0000000..61f1344 --- /dev/null +++ b/src/cache/tests/v022_cache_tests/part_02.rs @@ -0,0 +1,397 @@ + #[test] + fn proactive_refresh_pending_tasks_are_bounded() { + let cache = Cache::new(22); + let mut settings = active_settings(); + settings.proactive_refresh_enabled = true; + settings.proactive_refresh_percent = 100; + settings.proactive_refresh_max_inflight = 1; + let mut guards = Vec::new(); + for index in 0..64 { + let ticket = cache + .ticket( + 22, + "http", + "refresh.example", + "origin", + &format!("/asset-{index}"), + &HeaderMap::new(), + ) + .unwrap(); + guards.push( + cache + .begin_proactive_schedule(&ticket, &settings) + .expect("schedule below pending limit"), + ); + } + let overflow = cache + .ticket( + 22, + "http", + "refresh.example", + "origin", + "/overflow", + &HeaderMap::new(), + ) + .unwrap(); + assert!(cache.begin_proactive_schedule(&overflow, &settings).is_none()); + assert!(cache.view().proactive_skipped > 0); + drop(guards); + } + + #[test] + fn background_revalidation_set_has_a_hard_limit() { + let cache = Cache::new(22); + let base = Revalidation { + headers: HeaderMap::new(), + body: Bytes::new(), + body_allocated: 0, + _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: 0, + ttl: 0, + stale_if_error: 0, + must_revalidate: false, + proxy_revalidate: false, + origin_etag: None, + origin_last_modified: None, + }; + let mut guards = Vec::new(); + for index in 0..BACKGROUND_REVALIDATION_LIMIT { + let mut cached = base.clone(); + cached.storage_key[0..8].copy_from_slice(&(index as u64).to_be_bytes()); + guards.push( + cache + .begin_background_revalidation(&cached) + .expect("revalidation below hard limit"), + ); + } + let mut overflow = base; + overflow.storage_key = [0xff; 32]; + assert!(cache.begin_background_revalidation(&overflow).is_none()); + drop(guards); + } + + #[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()); + } diff --git a/src/compression.rs b/src/compression.rs index 3c0b60a..f84d97c 100644 --- a/src/compression.rs +++ b/src/compression.rs @@ -223,162 +223,4 @@ pub async fn encode( 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")); - } -} +include!("compression/tests.rs"); diff --git a/src/compression/tests.rs b/src/compression/tests.rs new file mode 100644 index 0000000..565761b --- /dev/null +++ b/src/compression/tests.rs @@ -0,0 +1,159 @@ +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 index 8072bcb..c67c24b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,2262 +1,9 @@ //! 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}; +include!("config/model.rs"); +include!("config/snapshot.rs"); +include!("config/backend.rs"); +include!("config/routing.rs"); +include!("config/compile.rs"); -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 error_log: crate::logging::ErrorLogSettings, - #[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 web_services: Vec, - #[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 enum CompiledSource { - ReverseProxy, - WebServer(Arc), -} - -#[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 source: CompiledSource, - pub backend: Option>, - 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()?; - config.error_log.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.web_services.len() <= 128, - "too many Web Services (max 128)" - ); - let mut web_services = HashMap::new(); - for service in &mut config.web_services { - service - .validate() - .with_context(|| format!("Web Service {}", service.id))?; - let compiled = Arc::new( - service - .compile() - .with_context(|| format!("Web Service {}", service.id))?, - ); - ensure!( - web_services.insert(service.id.clone(), compiled).is_none(), - "duplicate Web Service id" - ); - } - 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))?; - let validate_source = |source: &crate::vhost::Source| -> Result<()> { - if let crate::vhost::Source::WebServer { web_service_id } = source { - ensure!( - web_services.contains_key(web_service_id), - "vhost {} references unknown Web Service {}", - host.id, - web_service_id - ); - } - Ok(()) - }; - validate_source(&host.source)?; - for route in &host.routes { - if route.response.is_none() { - validate_source(&route.source)?; - } - } - 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 pool = if compiled_members.is_empty() { - None - } else { - 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); - } - Some(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_source, fallback_pool, fallback_backend) = match &host.source { - crate::vhost::Source::ReverseProxy => { - let pool = pool.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "vhost {} Reverse Proxy source has no backend pool", - host.id - ) - })?; - ( - CompiledSource::ReverseProxy, - Some(pool.clone()), - Some(pool.members[0].backend.clone()), - ) - } - crate::vhost::Source::WebServer { web_service_id } => ( - CompiledSource::WebServer(web_services[web_service_id].clone()), - None, - None, - ), - }; - let fallback = CompiledRoute { - backend_id: None, - bindings: frontend.bindings.clone(), - frontend: Some(frontend.clone()), - pool: fallback_pool, - 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, - source: fallback_source, - backend: fallback_backend, - 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 route.source.is_reverse_proxy() { - 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 (source, route_pool, backend) = if route.response.is_some() { - // Local return is handled before source dispatch and needs no origin. - (CompiledSource::ReverseProxy, None, None) - } else { - match &route.source { - crate::vhost::Source::ReverseProxy => { - let pool = pool.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "vhost {} location {} Reverse Proxy source has no backend pool", - host.id, - route.id - ) - })?; - let backend = if let Some(id) = route.backend_id.as_deref() { - pool.members - .iter() - .find(|member| member.definition.id == id) - .map(|member| member.backend.clone()) - .ok_or_else(|| anyhow::anyhow!("unknown backend {id}"))? - } else { - pool.members[0].backend.clone() - }; - ( - CompiledSource::ReverseProxy, - Some(pool.clone()), - Some(backend), - ) - } - crate::vhost::Source::WebServer { web_service_id } => ( - CompiledSource::WebServer(web_services[web_service_id].clone()), - None, - None, - ), - } - }; - let compiled = CompiledRoute { - backend_id: route.backend_id.clone(), - bindings: frontend.bindings.clone(), - frontend: Some(frontend.clone()), - pool: route_pool, - 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(), - source, - backend, - 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()); - } - if let Some(pool) = pool { - 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 web_server_main_source_needs_no_backend_pool() { - let tmp = tempfile::TempDir::new().unwrap(); - std::fs::write(tmp.path().join("index.html"), b"hello").unwrap(); - let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); - config.web_services.push(crate::web_server::Service { - id: "site".into(), - name: "Site".into(), - document_root: tmp.path().to_path_buf(), - index: vec!["index.html".into()], - directory_listing: false, - ..Default::default() - }); - config.vhosts[0].source = crate::vhost::Source::WebServer { - web_service_id: "site".into(), - }; - config.vhosts[0].backends.clear(); - config.vhosts[0].routes.clear(); - let snapshot = compile(config, 1, &policy()).unwrap(); - let route = snapshot.select("localhost", "/").unwrap(); - assert!(matches!(&route.source, CompiledSource::WebServer(_))); - assert!(route.pool.is_none()); - assert!(route.backend.is_none()); - assert!(snapshot.pools.is_empty()); - } - - #[test] - fn web_server_and_reverse_proxy_can_share_one_vhost() { - let tmp = tempfile::TempDir::new().unwrap(); - std::fs::write(tmp.path().join("index.html"), b"hello").unwrap(); - let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); - config.web_services.push(crate::web_server::Service { - id: "site".into(), - name: "Site".into(), - document_root: tmp.path().to_path_buf(), - index: vec!["index.html".into()], - directory_listing: false, - ..Default::default() - }); - let host = &mut config.vhosts[0]; - host.source = crate::vhost::Source::WebServer { - web_service_id: "site".into(), - }; - host.routes = vec![crate::vhost::PathRoute { - id: "api".into(), - path_prefix: "/api".into(), - source: crate::vhost::Source::ReverseProxy, - ..Default::default() - }]; - let snapshot = compile(config, 1, &policy()).unwrap(); - let root = snapshot.select("localhost", "/").unwrap(); - let api = snapshot.select("localhost", "/api/test").unwrap(); - assert!(matches!(&root.source, CompiledSource::WebServer(_))); - assert!(matches!(&api.source, CompiledSource::ReverseProxy)); - assert!(api.pool.is_some()); - assert!(api.backend.is_some()); - } - - #[test] - fn one_web_service_can_be_reused_by_multiple_vhosts() { - let tmp = tempfile::TempDir::new().unwrap(); - std::fs::write(tmp.path().join("index.html"), b"shared").unwrap(); - let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); - config.web_services.push(crate::web_server::Service { - id: "shared-site".into(), - name: "Shared site".into(), - document_root: tmp.path().to_path_buf(), - index: vec!["index.html".into()], - directory_listing: false, - ..Default::default() - }); - let first = &mut config.vhosts[0]; - first.source = crate::vhost::Source::WebServer { - web_service_id: "shared-site".into(), - }; - first.backends.clear(); - first.routes.clear(); - - let mut second = first.clone(); - second.id = "shared-second".into(); - second.frontend.hosts = vec!["second.localhost".into()]; - config.vhosts.push(second); - - let snapshot = compile(config, 1, &policy()).unwrap(); - let first_route = snapshot.select("localhost", "/").unwrap(); - let second_route = snapshot.select("second.localhost", "/").unwrap(); - assert!( - matches!(&first_route.source, CompiledSource::WebServer(service) if service.id == "shared-site") - ); - assert!( - matches!(&second_route.source, CompiledSource::WebServer(service) if service.id == "shared-site") - ); - } - - #[test] - fn unknown_web_service_reference_is_rejected() { - let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); - config.vhosts[0].source = crate::vhost::Source::WebServer { - web_service_id: "missing".into(), - }; - let error = compile(config, 1, &policy()).unwrap_err().to_string(); - assert!(error.contains("unknown Web Service"), "{error}"); - } - - #[test] - fn missing_source_fields_remain_reverse_proxy_for_old_configs() { - let source: crate::vhost::Source = - serde_json::from_value(serde_json::json!({"type":"reverse_proxy"})).unwrap(); - assert_eq!(source, crate::vhost::Source::ReverseProxy); - let route: crate::vhost::PathRoute = - serde_json::from_value(serde_json::json!({"id":"legacy"})).unwrap(); - assert_eq!(route.source, crate::vhost::Source::ReverseProxy); - } - - #[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()); - } -} +include!("config/tests.rs"); diff --git a/src/config/backend.rs b/src/config/backend.rs new file mode 100644 index 0000000..c994bfd --- /dev/null +++ b/src/config/backend.rs @@ -0,0 +1,220 @@ +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, &[]) +} + + +type LocalHostBuild = ( + HashMap>>, + HashMap>>, + HashMap>, +); + +fn compile_local_hosts(config: &Config) -> Result { +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()); + } + } +} + Ok((redirects, not_found_hosts, claimed_hosts)) +} + +struct RoutingBuild { + routes: HashMap>, + fallbacks: HashMap>, + frontends: HashMap>>, + redirects: HashMap>>, + not_found_hosts: HashMap>>, + pools: Vec>, + vhost_metrics: std::collections::BTreeMap>, +} diff --git a/src/config/compile.rs b/src/config/compile.rs new file mode 100644 index 0000000..5eaf3bc --- /dev/null +++ b/src/config/compile.rs @@ -0,0 +1,403 @@ +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()?; + config.error_log.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.web_services.len() <= 128, "too many Web Services (max 128)"); + let mut web_services = HashMap::new(); + for service in &mut config.web_services { + service + .validate() + .with_context(|| format!("Web Service {}", service.id))?; + let compiled = Arc::new( + service + .compile() + .with_context(|| format!("Web Service {}", service.id))?, + ); + ensure!( + web_services.insert(service.id.clone(), compiled).is_none(), + "duplicate Web Service id" + ); + } + 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))?; + let validate_source = |source: &crate::vhost::Source| -> Result<()> { + if let crate::vhost::Source::WebServer { web_service_id } = source { + ensure!( + web_services.contains_key(web_service_id), + "vhost {} references unknown Web Service {}", + host.id, + web_service_id + ); + } + Ok(()) + }; + validate_source(&host.source)?; + for route in &host.routes { + if route.response.is_none() { + validate_source(&route.source)?; + } + } + 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 RoutingBuild { + routes, + fallbacks, + frontends, + redirects, + not_found_hosts, + pools, + vhost_metrics, + } = compile_routing( + &mut config, + &desired_policy, + &guarded_bindings, + &acl_profiles, + &web_services, + previous, + &effective_tls, + &tls, + )?; + 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() + } +} diff --git a/src/config/model.rs b/src/config/model.rs new file mode 100644 index 0000000..2d513a7 --- /dev/null +++ b/src/config/model.rs @@ -0,0 +1,262 @@ +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 error_log: crate::logging::ErrorLogSettings, + #[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 web_services: Vec, + #[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 enum CompiledSource { + ReverseProxy, + WebServer(Arc), +} + +#[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 source: CompiledSource, + pub backend: Option>, + 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() + } +} diff --git a/src/config/routing.rs b/src/config/routing.rs new file mode 100644 index 0000000..ed3b5d1 --- /dev/null +++ b/src/config/routing.rs @@ -0,0 +1,374 @@ +#[allow(clippy::too_many_arguments)] +fn compile_routing( + config: &mut Config, + desired_policy: &ValidationPolicy, + guarded_bindings: &[SocketAddr], + acl_profiles: &HashMap, crate::rate_limit::CountryAccess)>, + web_services: &HashMap>, + previous: Option<&Snapshot>, + effective_tls: &crate::tls::Settings, + tls: &Option>, +) -> Result { +let mut routes: HashMap> = HashMap::new(); +let mut fallbacks: HashMap> = HashMap::new(); +let mut frontends: HashMap>> = HashMap::new(); +let (redirects, not_found_hosts, mut claimed_hosts) = compile_local_hosts(config)?; +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 pool = if compiled_members.is_empty() { + None + } else { + 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); + } + Some(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_source, fallback_pool, fallback_backend) = match &host.source { + crate::vhost::Source::ReverseProxy => { + let pool = pool.as_ref().ok_or_else(|| { + anyhow::anyhow!("vhost {} Reverse Proxy source has no backend pool", host.id) + })?; + ( + CompiledSource::ReverseProxy, + Some(pool.clone()), + Some(pool.members[0].backend.clone()), + ) + } + crate::vhost::Source::WebServer { web_service_id } => ( + CompiledSource::WebServer(web_services[web_service_id].clone()), + None, + None, + ), + }; + let fallback = CompiledRoute { + backend_id: None, + bindings: frontend.bindings.clone(), + frontend: Some(frontend.clone()), + pool: fallback_pool, + 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, + source: fallback_source, + backend: fallback_backend, + 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 route.source.is_reverse_proxy() { + 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 (source, route_pool, backend) = if route.response.is_some() { + // Local return is handled before source dispatch and needs no origin. + (CompiledSource::ReverseProxy, None, None) + } else { + match &route.source { + crate::vhost::Source::ReverseProxy => { + let pool = pool.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "vhost {} location {} Reverse Proxy source has no backend pool", + host.id, + route.id + ) + })?; + let backend = if let Some(id) = route.backend_id.as_deref() { + pool.members + .iter() + .find(|member| member.definition.id == id) + .map(|member| member.backend.clone()) + .ok_or_else(|| anyhow::anyhow!("unknown backend {id}"))? + } else { + pool.members[0].backend.clone() + }; + (CompiledSource::ReverseProxy, Some(pool.clone()), Some(backend)) + } + crate::vhost::Source::WebServer { web_service_id } => ( + CompiledSource::WebServer(web_services[web_service_id].clone()), + None, + None, + ), + } + }; + let compiled = CompiledRoute { + backend_id: route.backend_id.clone(), + bindings: frontend.bindings.clone(), + frontend: Some(frontend.clone()), + pool: route_pool, + 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(), + source, + backend, + 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()); + } + if let Some(pool) = pool { + pools.push(pool); + } + } +} + Ok(RoutingBuild { + routes, + fallbacks, + frontends, + redirects, + not_found_hosts, + pools, + vhost_metrics, + }) +} diff --git a/src/config/snapshot.rs b/src/config/snapshot.rs new file mode 100644 index 0000000..32cfcbd --- /dev/null +++ b/src/config/snapshot.rs @@ -0,0 +1,411 @@ +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), + ) +} diff --git a/src/config/tests.rs b/src/config/tests.rs new file mode 100644 index 0000000..da75279 --- /dev/null +++ b/src/config/tests.rs @@ -0,0 +1,4 @@ +mod tests { + include!("tests/part_01.rs"); + include!("tests/part_02.rs"); +} diff --git a/src/config/tests/part_01.rs b/src/config/tests/part_01.rs new file mode 100644 index 0000000..af3a0cc --- /dev/null +++ b/src/config/tests/part_01.rs @@ -0,0 +1,423 @@ + 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 web_server_main_source_needs_no_backend_pool() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.html"), b"hello").unwrap(); + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + config.web_services.push(crate::web_server::Service { + id: "site".into(), + name: "Site".into(), + document_root: tmp.path().to_path_buf(), + index: vec!["index.html".into()], + directory_listing: false, + ..Default::default() + }); + config.vhosts[0].source = crate::vhost::Source::WebServer { + web_service_id: "site".into(), + }; + config.vhosts[0].backends.clear(); + config.vhosts[0].routes.clear(); + let snapshot = compile(config, 1, &policy()).unwrap(); + let route = snapshot.select("localhost", "/").unwrap(); + assert!(matches!(&route.source, CompiledSource::WebServer(_))); + assert!(route.pool.is_none()); + assert!(route.backend.is_none()); + assert!(snapshot.pools.is_empty()); + } + + #[test] + fn web_server_and_reverse_proxy_can_share_one_vhost() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.html"), b"hello").unwrap(); + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + config.web_services.push(crate::web_server::Service { + id: "site".into(), + name: "Site".into(), + document_root: tmp.path().to_path_buf(), + index: vec!["index.html".into()], + directory_listing: false, + ..Default::default() + }); + let host = &mut config.vhosts[0]; + host.source = crate::vhost::Source::WebServer { web_service_id: "site".into() }; + host.routes = vec![crate::vhost::PathRoute { + id: "api".into(), + path_prefix: "/api".into(), + source: crate::vhost::Source::ReverseProxy, + ..Default::default() + }]; + let snapshot = compile(config, 1, &policy()).unwrap(); + let root = snapshot.select("localhost", "/").unwrap(); + let api = snapshot.select("localhost", "/api/test").unwrap(); + assert!(matches!(&root.source, CompiledSource::WebServer(_))); + assert!(matches!(&api.source, CompiledSource::ReverseProxy)); + assert!(api.pool.is_some()); + assert!(api.backend.is_some()); + } + + #[test] + fn one_web_service_can_be_reused_by_multiple_vhosts() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.html"), b"shared").unwrap(); + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + config.web_services.push(crate::web_server::Service { + id: "shared-site".into(), + name: "Shared site".into(), + document_root: tmp.path().to_path_buf(), + index: vec!["index.html".into()], + directory_listing: false, + ..Default::default() + }); + let first = &mut config.vhosts[0]; + first.source = crate::vhost::Source::WebServer { + web_service_id: "shared-site".into(), + }; + first.backends.clear(); + first.routes.clear(); + + let mut second = first.clone(); + second.id = "shared-second".into(); + second.frontend.hosts = vec!["second.localhost".into()]; + config.vhosts.push(second); + + let snapshot = compile(config, 1, &policy()).unwrap(); + let first_route = snapshot.select("localhost", "/").unwrap(); + let second_route = snapshot.select("second.localhost", "/").unwrap(); + assert!(matches!(&first_route.source, CompiledSource::WebServer(service) if service.id == "shared-site")); + assert!(matches!(&second_route.source, CompiledSource::WebServer(service) if service.id == "shared-site")); + } + + #[test] + fn unknown_web_service_reference_is_rejected() { + let mut config = demo_config("127.0.0.1:9000".parse().unwrap()); + config.vhosts[0].source = crate::vhost::Source::WebServer { + web_service_id: "missing".into(), + }; + let error = compile(config, 1, &policy()).unwrap_err().to_string(); + assert!(error.contains("unknown Web Service"), "{error}"); + } + + #[test] + fn missing_source_fields_remain_reverse_proxy_for_old_configs() { + let source: crate::vhost::Source = serde_json::from_value(serde_json::json!({"type":"reverse_proxy"})).unwrap(); + assert_eq!(source, crate::vhost::Source::ReverseProxy); + let route: crate::vhost::PathRoute = serde_json::from_value(serde_json::json!({"id":"legacy"})).unwrap(); + assert_eq!(route.source, crate::vhost::Source::ReverseProxy); + } + + #[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); + } diff --git a/src/config/tests/part_02.rs b/src/config/tests/part_02.rs new file mode 100644 index 0000000..b5d4f67 --- /dev/null +++ b/src/config/tests/part_02.rs @@ -0,0 +1,207 @@ + #[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 index 4996aad..f17f86c 100644 --- a/src/db.rs +++ b/src/db.rs @@ -411,203 +411,4 @@ impl Store { } } #[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 mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); - config.cache.memory_preset = Some(crate::cache::MemoryPreset::Recommended); - config.cache.cold_preset = Some(crate::cache::ColdPreset::Recommended); - config.transform.preset = Some(crate::transform::Preset::Performance); - config.transform.memory_budget_bytes = 512 * 1024 * 1024; - config.transform.max_inflight = 64; - 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"); - assert_eq!( - data.config.cache.memory_preset, - Some(crate::cache::MemoryPreset::Recommended) - ); - assert_eq!( - data.config.cache.cold_preset, - Some(crate::cache::ColdPreset::Recommended) - ); - assert_eq!( - data.config.transform.preset, - Some(crate::transform::Preset::Performance) - ); - assert_eq!(data.config.transform.memory_budget_bytes, 512 * 1024 * 1024); - assert_eq!(data.config.transform.max_inflight, 64); - 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; - } -} +include!("db/tests.rs"); diff --git a/src/db/tests.rs b/src/db/tests.rs new file mode 100644 index 0000000..7180baa --- /dev/null +++ b/src/db/tests.rs @@ -0,0 +1,200 @@ +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 mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); + config.cache.memory_preset = Some(crate::cache::MemoryPreset::Recommended); + config.cache.cold_preset = Some(crate::cache::ColdPreset::Recommended); + config.transform.preset = Some(crate::transform::Preset::Performance); + config.transform.memory_budget_bytes = 512 * 1024 * 1024; + config.transform.max_inflight = 64; + 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"); + assert_eq!( + data.config.cache.memory_preset, + Some(crate::cache::MemoryPreset::Recommended) + ); + assert_eq!( + data.config.cache.cold_preset, + Some(crate::cache::ColdPreset::Recommended) + ); + assert_eq!( + data.config.transform.preset, + Some(crate::transform::Preset::Performance) + ); + assert_eq!(data.config.transform.memory_budget_bytes, 512 * 1024 * 1024); + assert_eq!(data.config.transform.max_inflight, 64); + 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/diagnostics.rs b/src/diagnostics.rs index df728e6..17e0846 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -22,8 +22,7 @@ impl Diagnostics { tokio::spawn(async move { let mut previous: Option<(View, Counters)> = None; loop { - let sample = - tokio::task::spawn_blocking(move || system_stats::sample(previous)).await; + 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)); diff --git a/src/docker.rs b/src/docker.rs index 554b674..2d472bb 100644 --- a/src/docker.rs +++ b/src/docker.rs @@ -1,1344 +1,10 @@ //! 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)) => {} - } - } -} +include!("docker/model.rs"); +include!("docker/client.rs"); +include!("docker/reconcile.rs"); #[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}" - ); - } -} +include!("docker/tests.rs"); diff --git a/src/docker/client.rs b/src/docker/client.rs new file mode 100644 index 0000000..0d28c60 --- /dev/null +++ b/src/docker/client.rs @@ -0,0 +1,411 @@ +#[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)) + }) +} diff --git a/src/docker/model.rs b/src/docker/model.rs new file mode 100644 index 0000000..59ac279 --- /dev/null +++ b/src/docker/model.rs @@ -0,0 +1,414 @@ +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, +} diff --git a/src/docker/reconcile.rs b/src/docker/reconcile.rs new file mode 100644 index 0000000..459d249 --- /dev/null +++ b/src/docker/reconcile.rs @@ -0,0 +1,150 @@ +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)) => {} + } + } +} diff --git a/src/docker/tests.rs b/src/docker/tests.rs new file mode 100644 index 0000000..8610c41 --- /dev/null +++ b/src/docker/tests.rs @@ -0,0 +1,362 @@ +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 index 43ad4c4..dc20dfa 100644 --- a/src/forwarding.rs +++ b/src/forwarding.rs @@ -334,183 +334,4 @@ impl Context { } } #[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"); - } -} +include!("forwarding/tests.rs"); diff --git a/src/forwarding/tests.rs b/src/forwarding/tests.rs new file mode 100644 index 0000000..f01573f --- /dev/null +++ b/src/forwarding/tests.rs @@ -0,0 +1,180 @@ +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 index 2f55c40..402a4d0 100644 --- a/src/geoip.rs +++ b/src/geoip.rs @@ -201,22 +201,4 @@ pub fn validate_directory(directory: &Path) -> Result<()> { } #[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()); - } -} +include!("geoip/tests.rs"); diff --git a/src/geoip/tests.rs b/src/geoip/tests.rs new file mode 100644 index 0000000..87a4fe4 --- /dev/null +++ b/src/geoip/tests.rs @@ -0,0 +1,19 @@ +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 index 9dd21b2..afa7b51 100644 --- a/src/headers.rs +++ b/src/headers.rs @@ -122,51 +122,4 @@ pub fn apply(headers: &mut HeaderMap, rules: &[CompiledRule]) { } } #[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()); - } -} +include!("headers/tests.rs"); diff --git a/src/headers/tests.rs b/src/headers/tests.rs new file mode 100644 index 0000000..80bd422 --- /dev/null +++ b/src/headers/tests.rs @@ -0,0 +1,48 @@ +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 index b92d912..e6815b9 100644 --- a/src/http3.rs +++ b/src/http3.rs @@ -495,80 +495,4 @@ impl Drop for ReceiveBody { } } #[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)); - } - } -} +include!("http3/tests.rs"); diff --git a/src/http3/tests.rs b/src/http3/tests.rs new file mode 100644 index 0000000..dfed2ae --- /dev/null +++ b/src/http3/tests.rs @@ -0,0 +1,77 @@ +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 index 26d8f9e..7c4e876 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,8 +3,8 @@ pub mod acme; pub mod admin; #[path = "auth/mod.rs"] pub mod auth; -pub mod backup; pub mod basic_auth; +pub mod backup; pub mod config; pub mod db; pub mod demo; @@ -17,25 +17,25 @@ pub mod runtime; pub mod server; pub mod tls; pub mod traffic; -pub mod update_check; 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 transform; pub mod balance; pub mod forwarding; pub mod geoip; pub mod http3; -pub mod secret_store; pub mod security; +pub mod secret_store; pub mod tinyauth; pub mod vhost; pub mod web_server; diff --git a/src/logging.rs b/src/logging.rs index d0fd1ac..1a3a137 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -1,1392 +1,10 @@ //! 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}, - io::{self, Write as IoWrite}, - path::PathBuf, - sync::{ - atomic::{AtomicU64, Ordering}, - Arc, Mutex, OnceLock, - }, - time::{Duration, Instant, SystemTime, UNIX_EPOCH}, -}; -use tokio::{ - fs::{File, OpenOptions}, - io::AsyncWriteExt, - net::TcpStream, - sync::{broadcast, mpsc, watch}, -}; -use tokio_util::sync::CancellationToken; -use tracing::{Event, Level, Subscriber}; -use tracing_subscriber::{layer::Context as LayerContext, Layer}; -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, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct Settings { - /// Capture a bounded User-Agent value in access logs and traffic analytics. - pub capture_user_agent: bool, - /// Resolve the effective client IP through the loaded GeoIP database. - /// Disabled by default because this adds per-request lookup work. - pub capture_country: bool, - pub file: FileSink, - pub logstash: LogstashSink, - pub loki: LokiSink, - pub format: JsonFormat, -} -impl Default for Settings { - fn default() -> Self { - Self { - capture_user_agent: true, - capture_country: false, - file: FileSink::default(), - logstash: LogstashSink::default(), - loki: LokiSink::default(), - format: JsonFormat::default(), - } - } -} -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" - | "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" - | "user_agent" - | "country" - | "status" - | "duration_ms" - | "request_bytes" - | "response_bytes" - | "outcome" - | "cache" - | "cache_reason" - ) -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -pub enum ErrorLogFormat { - #[serde(rename = "JSON")] - Json, - #[serde(rename = "RAW")] - Raw, -} -impl Default for ErrorLogFormat { - fn default() -> Self { - Self::Json - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct ErrorLogSettings { - pub enabled: bool, - pub path: String, - pub format: ErrorLogFormat, -} -impl Default for ErrorLogSettings { - fn default() -> Self { - Self { - enabled: false, - path: "/var/log/rustproxy/error.log".into(), - format: ErrorLogFormat::Json, - } - } -} -impl ErrorLogSettings { - pub fn validate(&self) -> Result<()> { - if self.enabled { - ensure!( - !self.path.trim().is_empty() - && self.path.len() <= 1024 - && !self.path.chars().any(char::is_control), - "error_log.path must be 1..1024 bytes without controls" - ); - } - Ok(()) - } -} - -const ERROR_LOG_QUEUE: usize = 512; -const ERROR_LOG_RETRY: Duration = Duration::from_secs(30); - -#[derive(Debug, Clone, Serialize)] -pub struct ErrorLogStats { - pub queue_capacity: usize, - pub queue_dropped: u64, - pub written: u64, - pub errors: u64, -} - -#[derive(Debug)] -struct ErrorEvent { - timestamp: String, - target: String, - message: String, - fields: BTreeMap, -} - -pub struct ErrorLogController { - settings: Arc>, - settings_changed: watch::Sender<()>, - sender: mpsc::Sender, - dropped: Arc, - written: Arc, - errors: Arc, -} -impl ErrorLogController { - pub fn start() -> Arc { - let (sender, receiver) = mpsc::channel(ERROR_LOG_QUEUE); - let (settings_changed, settings_watch) = watch::channel(()); - let settings = Arc::new(ArcSwap::from_pointee(ErrorLogSettings::default())); - let dropped = Arc::new(AtomicU64::new(0)); - let written = Arc::new(AtomicU64::new(0)); - let errors = Arc::new(AtomicU64::new(0)); - tokio::spawn(error_log_loop( - receiver, - settings.clone(), - settings_watch, - written.clone(), - errors.clone(), - )); - Arc::new(Self { - settings, - settings_changed, - sender, - dropped, - written, - errors, - }) - } - pub fn configure(&self, settings: ErrorLogSettings) { - self.settings.store(Arc::new(settings)); - self.settings_changed.send_replace(()); - } - fn push(&self, event: ErrorEvent) { - if self.settings.load().enabled && self.sender.try_send(event).is_err() { - self.dropped.fetch_add(1, Ordering::Relaxed); - } - } - pub fn stats(&self) -> ErrorLogStats { - ErrorLogStats { - queue_capacity: ERROR_LOG_QUEUE, - queue_dropped: self.dropped.load(Ordering::Relaxed), - written: self.written.load(Ordering::Relaxed), - errors: self.errors.load(Ordering::Relaxed), - } - } -} - -static ERROR_LOG: OnceLock> = OnceLock::new(); - -pub fn install_error_log_controller(controller: Arc) -> Result<()> { - ERROR_LOG - .set(controller) - .map_err(|_| anyhow::anyhow!("error logger already initialized")) -} -pub fn configure_error_log(settings: ErrorLogSettings) { - if let Some(controller) = ERROR_LOG.get() { - controller.configure(settings); - } -} -pub fn error_log_stats() -> Option { - ERROR_LOG.get().map(|controller| controller.stats()) -} - -#[derive(Clone)] -pub struct ErrorLogLayer { - controller: Arc, -} -impl ErrorLogLayer { - pub fn new(controller: Arc) -> Self { - Self { controller } - } -} - -#[derive(Default)] -struct EventVisitor { - message: Option, - fields: BTreeMap, -} -impl tracing::field::Visit for EventVisitor { - fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { - let value = format!("{value:?}"); - if field.name() == "message" { - self.message = Some(value); - } else { - self.fields.insert(field.name().to_owned(), value); - } - } - fn record_str(&mut self, field: &tracing::field::Field, value: &str) { - if field.name() == "message" { - self.message = Some(value.to_owned()); - } else { - self.fields - .insert(field.name().to_owned(), value.to_owned()); - } - } - fn record_i64(&mut self, field: &tracing::field::Field, value: i64) { - self.fields - .insert(field.name().to_owned(), value.to_string()); - } - fn record_u64(&mut self, field: &tracing::field::Field, value: u64) { - self.fields - .insert(field.name().to_owned(), value.to_string()); - } - fn record_bool(&mut self, field: &tracing::field::Field, value: bool) { - self.fields - .insert(field.name().to_owned(), value.to_string()); - } - fn record_f64(&mut self, field: &tracing::field::Field, value: f64) { - self.fields - .insert(field.name().to_owned(), value.to_string()); - } -} -impl Layer for ErrorLogLayer { - fn on_event(&self, event: &Event<'_>, _ctx: LayerContext<'_, S>) { - if *event.metadata().level() != Level::ERROR { - return; - } - let mut visitor = EventVisitor::default(); - event.record(&mut visitor); - let mut fields = visitor.fields; - for (key, value) in &mut fields { - *value = if is_secret_field(key) { - "[REDACTED]".into() - } else { - redact_text(value) - }; - } - self.controller.push(ErrorEvent { - timestamp: rfc3339_now(), - target: event.metadata().target().to_owned(), - message: redact_text(visitor.message.as_deref().unwrap_or("error")), - fields, - }); - } -} - -#[derive(Clone, Copy, Default)] -pub struct RedactingStderr; -pub struct RedactingWriter { - buffer: Vec, -} -impl IoWrite for RedactingWriter { - fn write(&mut self, bytes: &[u8]) -> io::Result { - self.buffer.extend_from_slice(bytes); - Ok(bytes.len()) - } - fn flush(&mut self) -> io::Result<()> { - if self.buffer.is_empty() { - return Ok(()); - } - let text = String::from_utf8_lossy(&self.buffer); - let redacted = redact_text(&text); - let mut stderr = io::stderr().lock(); - stderr.write_all(redacted.as_bytes())?; - stderr.flush()?; - self.buffer.clear(); - Ok(()) - } -} -impl Drop for RedactingWriter { - fn drop(&mut self) { - let _ = self.flush(); - } -} -impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RedactingStderr { - type Writer = RedactingWriter; - fn make_writer(&'a self) -> Self::Writer { - RedactingWriter { - buffer: Vec::with_capacity(512), - } - } -} - -fn redact_regex(pattern: &'static str) -> &'static regex::Regex { - static AUTH: OnceLock = OnceLock::new(); - static QUERY: OnceLock = OnceLock::new(); - static JSON: OnceLock = OnceLock::new(); - static KV: OnceLock = OnceLock::new(); - static PEM: OnceLock = OnceLock::new(); - match pattern { - "auth" => AUTH.get_or_init(|| regex::Regex::new(r"(?i)(authorization\s*[:=]\s*)(?:bearer\s+)?[^\s,;]+" ).expect("redaction regex")), - "query" => QUERY.get_or_init(|| regex::Regex::new(r"(?i)([?&](?:token|access_token|api_token|password|backup_password|secret|credential|credentials|key_secret|application_secret|consumer_key)=)[^&\s]+" ).expect("redaction regex")), - "json" => JSON.get_or_init(|| regex::Regex::new(r#"(?i)(\"(?:authorization|proxy_authorization|api_token|access_token|application_secret|consumer_key|key_secret|backup_password|password|credential|credentials|secret|token|private_key|master_key)\"\s*:\s*\")[^\"]*(\")"#).expect("redaction regex")), - "kv" => KV.get_or_init(|| regex::Regex::new(r#"(?i)((?:authorization|proxy[_-]?authorization|api[_-]?token|access[_-]?token|application[_-]?secret|consumer[_-]?key|key[_-]?secret|backup[_-]?password|password|credential|credentials|secret|token|private[_-]?key|master[_-]?key)\s*[=:]\s*)[^\s,;"']+"#).expect("redaction regex")), - "pem" => PEM.get_or_init(|| regex::Regex::new(r"(?s)-----BEGIN [^-\r\n]*PRIVATE KEY-----.*?-----END [^-\r\n]*PRIVATE KEY-----" ).expect("redaction regex")), - _ => unreachable!(), - } -} - -fn is_secret_field(name: &str) -> bool { - matches!( - name.to_ascii_lowercase().as_str(), - "authorization" - | "proxy_authorization" - | "api_token" - | "access_token" - | "application_secret" - | "consumer_key" - | "key_secret" - | "backup_password" - | "password" - | "credential" - | "credentials" - | "secret" - | "token" - | "private_key" - | "master_key" - ) -} - -pub fn redact_text(input: &str) -> String { - let mut value = redact_regex("pem") - .replace_all(input, "[REDACTED PRIVATE KEY]") - .into_owned(); - value = redact_regex("auth") - .replace_all(&value, "$1[REDACTED]") - .into_owned(); - value = redact_regex("query") - .replace_all(&value, "$1[REDACTED]") - .into_owned(); - value = redact_regex("json") - .replace_all(&value, "$1[REDACTED]$2") - .into_owned(); - redact_regex("kv") - .replace_all(&value, "$1[REDACTED]") - .into_owned() -} - -fn redact_value(value: &mut Value) { - match value { - Value::String(text) => *text = redact_text(text), - Value::Array(items) => { - for item in items { - redact_value(item); - } - } - Value::Object(items) => { - for (key, item) in items { - if is_secret_field(key) { - *item = Value::String("[REDACTED]".into()); - } else { - redact_value(item); - } - } - } - _ => {} - } -} - -fn rfc3339_now() -> String { - let ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64; - let seconds = ms / 1000; - let millis = ms % 1000; - let days = seconds.div_euclid(86_400); - let day_seconds = seconds.rem_euclid(86_400); - let z = days + 719_468; - let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; - let mut year = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let day = doy - (153 * mp + 2) / 5 + 1; - let month = mp + if mp < 10 { 3 } else { -9 }; - if month <= 2 { - year += 1; - } - let hour = day_seconds / 3600; - let minute = (day_seconds % 3600) / 60; - let second = day_seconds % 60; - format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z") -} - -async fn open_error_file(path: PathBuf) -> Result { - let file = tokio::task::spawn_blocking(move || -> Result { - let mut options = std::fs::OpenOptions::new(); - options.create(true).append(true).write(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600).custom_flags(libc::O_NOFOLLOW); - } - let file = options.open(&path)?; - let metadata = file.metadata()?; - ensure!(metadata.is_file(), "error log path is not a regular file"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - file.set_permissions(std::fs::Permissions::from_mode(0o600))?; - } - Ok(file) - }) - .await - .map_err(|_| anyhow::anyhow!("error log open worker failed"))??; - Ok(File::from_std(file)) -} - -fn format_error_event(event: &ErrorEvent, format: ErrorLogFormat) -> Vec { - match format { - ErrorLogFormat::Json => { - let mut object = Map::new(); - object.insert("timestamp".into(), Value::String(event.timestamp.clone())); - object.insert("level".into(), Value::String("ERROR".into())); - object.insert("target".into(), Value::String(event.target.clone())); - object.insert("message".into(), Value::String(event.message.clone())); - for (key, value) in &event.fields { - if !matches!(key.as_str(), "timestamp" | "level" | "target" | "message") { - object.insert(key.clone(), Value::String(value.clone())); - } - } - let mut line = serde_json::to_vec(&Value::Object(object)).unwrap_or_default(); - line.push(b'\n'); - line - } - ErrorLogFormat::Raw => { - let one_line = |value: &str| value.replace('\r', "\\r").replace('\n', "\\n"); - let mut line = format!( - "{} ERROR {} {}", - event.timestamp, - event.target, - one_line(&event.message) - ); - for (key, value) in &event.fields { - line.push(' '); - line.push_str(key); - line.push('='); - line.push_str(&one_line(value)); - } - line.push('\n'); - line.into_bytes() - } - } -} - -async fn error_log_loop( - mut receiver: mpsc::Receiver, - settings: Arc>, - mut settings_changed: watch::Receiver<()>, - written: Arc, - errors: Arc, -) { - let mut open_path: Option = None; - let mut file: Option = None; - let mut retry_after: Option = None; - let mut last_warning: Option = None; - loop { - let event = tokio::select! { - event = receiver.recv() => match event { - Some(event) => event, - None => break, - }, - changed = settings_changed.changed() => { - if changed.is_err() && receiver.is_closed() { - break; - } - let config = settings.load_full(); - let path = PathBuf::from(&config.path); - if !config.enabled || open_path.as_ref() != Some(&path) { - // Dropping Tokio's File closes the descriptor without blocking - // the request path. A changed path is reopened on the next event. - file = None; - open_path = None; - retry_after = None; - } - continue; - } - }; - - let config = settings.load_full(); - if !config.enabled { - file = None; - open_path = None; - retry_after = None; - continue; - } - let path = PathBuf::from(&config.path); - if open_path.as_ref() != Some(&path) { - file = None; - open_path = Some(path.clone()); - retry_after = None; - } - if file.is_none() && retry_after.is_none_or(|until| Instant::now() >= until) { - match open_error_file(path.clone()).await { - Ok(opened) => { - file = Some(opened); - retry_after = None; - } - Err(error) => { - errors.fetch_add(1, Ordering::Relaxed); - retry_after = Some(Instant::now() + ERROR_LOG_RETRY); - if last_warning.is_none_or(|time| time.elapsed() >= ERROR_LOG_RETRY) { - tracing::warn!(path=%path.display(), %error, "optional error log file is unavailable"); - last_warning = Some(Instant::now()); - } - } - } - } - let Some(opened) = file.as_mut() else { - continue; - }; - let line = format_error_event(&event, config.format); - if let Err(error) = opened.write_all(&line).await { - errors.fetch_add(1, Ordering::Relaxed); - file = None; - retry_after = Some(Instant::now() + ERROR_LOG_RETRY); - if last_warning.is_none_or(|time| time.elapsed() >= ERROR_LOG_RETRY) { - tracing::warn!(path=%path.display(), %error, "optional error log write failed"); - last_warning = Some(Instant::now()); - } - } else { - written.fetch_add(1, Ordering::Relaxed); - } - } -} - -#[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 user_agent: Option, - pub country: 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 capture_user_agent(&self) -> bool { - self.settings.load().capture_user_agent - } - pub fn capture_country(&self) -> bool { - self.settings.load().capture_country - } - 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), - "route" => entry - .route - .clone() - .map(Value::String) - .unwrap_or(Value::Null), - "cache_effective_enabled" => entry - .cache_effective_enabled - .map(Value::Bool) - .unwrap_or(Value::Null), - "transform_effective_enabled" => entry - .transform_effective_enabled - .map(Value::Bool) - .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), - "user_agent" => entry - .user_agent - .clone() - .map(Value::String) - .unwrap_or(Value::Null), - "country" => entry - .country - .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 mut payload = format_entry(&entry, &config.format); - redact_value(&mut payload); - 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 -} +include!("logging/model.rs"); +include!("logging/format.rs"); +include!("logging/runtime.rs"); #[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()), - user_agent: Some("RustProxy-Test/1.0".into()), - country: Some("PL".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.fields.insert("route".into(), "route".into()); - format.fields.insert("ua".into(), "user_agent".into()); - format.fields.insert("geo_country".into(), "country".into()); - format - .fields - .insert("cache_enabled".into(), "cache_effective_enabled".into()); - format.fields.insert( - "transform_enabled".into(), - "transform_effective_enabled".into(), - ); - format.static_fields.insert("service".into(), "edge".into()); - let mut settings = Settings::default(); - settings.format = format.clone(); - settings.validate().unwrap(); - let value = format_entry(&entry(), &format); - assert_eq!(value["http_status"], 200); - assert_eq!(value["site"], "app"); - assert_eq!(value["route"], "root"); - assert_eq!(value["ua"], "RustProxy-Test/1.0"); - assert_eq!(value["geo_country"], "PL"); - assert_eq!(value["cache_enabled"], true); - assert_eq!(value["transform_enabled"], false); - 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 capture_defaults_keep_geoip_off_request_path() { - let settings = Settings::default(); - assert!(settings.capture_user_agent); - assert!(!settings.capture_country); - } - - #[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 error_log_defaults_off_and_redacts_common_secret_shapes() { - let settings = ErrorLogSettings::default(); - assert!(!settings.enabled); - assert_eq!(settings.format, ErrorLogFormat::Json); - let text = redact_text( - r#"Authorization: Bearer topsecret url=https://x.invalid/?api_token=abc password=hunter2 {\"private_key\":\"pem-data\"}"#, - ); - assert!(!text.contains("topsecret")); - assert!(!text.contains("api_token=abc")); - assert!(!text.contains("hunter2")); - assert!(!text.contains("pem-data")); - } - - #[test] - fn error_json_is_one_record_and_reserved_fields_cannot_be_overwritten() { - let event = ErrorEvent { - timestamp: "2026-10-03T12:34:56.123Z".into(), - target: "rust_proxy::http".into(), - message: "upstream failed\nsecond line".into(), - fields: BTreeMap::from([ - ("message".into(), "attacker override".into()), - ("request_id".into(), "abc".into()), - ]), - }; - let line = format_error_event(&event, ErrorLogFormat::Json); - assert_eq!(line.iter().filter(|byte| **byte == b'\n').count(), 1); - let value: Value = serde_json::from_slice(&line).unwrap(); - assert_eq!(value["message"], "upstream failed\nsecond line"); - assert_eq!(value["request_id"], "abc"); - } - - #[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 - ); - } -} +include!("logging/tests.rs"); diff --git a/src/logging/format.rs b/src/logging/format.rs new file mode 100644 index 0000000..8e12da2 --- /dev/null +++ b/src/logging/format.rs @@ -0,0 +1,425 @@ +impl IoWrite for RedactingWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { self.buffer.extend_from_slice(bytes); Ok(bytes.len()) } + fn flush(&mut self) -> io::Result<()> { + if self.buffer.is_empty() { return Ok(()); } + let text = String::from_utf8_lossy(&self.buffer); + let redacted = redact_text(&text); + let mut stderr = io::stderr().lock(); + stderr.write_all(redacted.as_bytes())?; + stderr.flush()?; + self.buffer.clear(); + Ok(()) + } +} +impl Drop for RedactingWriter { fn drop(&mut self) { let _ = self.flush(); } } +impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RedactingStderr { + type Writer = RedactingWriter; + fn make_writer(&'a self) -> Self::Writer { RedactingWriter { buffer: Vec::with_capacity(512) } } +} + +fn redact_regex(pattern: &'static str) -> &'static regex::Regex { + static AUTH: OnceLock = OnceLock::new(); + static QUERY: OnceLock = OnceLock::new(); + static JSON: OnceLock = OnceLock::new(); + static KV: OnceLock = OnceLock::new(); + static PEM: OnceLock = OnceLock::new(); + match pattern { + "auth" => AUTH.get_or_init(|| regex::Regex::new(r"(?i)(authorization\s*[:=]\s*)(?:bearer\s+)?[^\s,;]+" ).expect("redaction regex")), + "query" => QUERY.get_or_init(|| regex::Regex::new(r"(?i)([?&](?:token|access_token|api_token|password|backup_password|secret|credential|credentials|key_secret|application_secret|consumer_key)=)[^&\s]+" ).expect("redaction regex")), + "json" => JSON.get_or_init(|| regex::Regex::new(r#"(?i)(\"(?:authorization|proxy_authorization|api_token|access_token|application_secret|consumer_key|key_secret|backup_password|password|credential|credentials|secret|token|private_key|master_key)\"\s*:\s*\")[^\"]*(\")"#).expect("redaction regex")), + "kv" => KV.get_or_init(|| regex::Regex::new(r#"(?i)((?:authorization|proxy[_-]?authorization|api[_-]?token|access[_-]?token|application[_-]?secret|consumer[_-]?key|key[_-]?secret|backup[_-]?password|password|credential|credentials|secret|token|private[_-]?key|master[_-]?key)\s*[=:]\s*)[^\s,;"']+"#).expect("redaction regex")), + "pem" => PEM.get_or_init(|| regex::Regex::new(r"(?s)-----BEGIN [^-\r\n]*PRIVATE KEY-----.*?-----END [^-\r\n]*PRIVATE KEY-----" ).expect("redaction regex")), + _ => unreachable!(), + } +} + +fn is_secret_field(name: &str) -> bool { + matches!( + name.to_ascii_lowercase().as_str(), + "authorization" + | "proxy_authorization" + | "api_token" + | "access_token" + | "application_secret" + | "consumer_key" + | "key_secret" + | "backup_password" + | "password" + | "credential" + | "credentials" + | "secret" + | "token" + | "private_key" + | "master_key" + ) +} + +pub fn redact_text(input: &str) -> String { + let mut value = redact_regex("pem").replace_all(input, "[REDACTED PRIVATE KEY]").into_owned(); + value = redact_regex("auth").replace_all(&value, "$1[REDACTED]").into_owned(); + value = redact_regex("query").replace_all(&value, "$1[REDACTED]").into_owned(); + value = redact_regex("json").replace_all(&value, "$1[REDACTED]$2").into_owned(); + redact_regex("kv").replace_all(&value, "$1[REDACTED]").into_owned() +} + +fn redact_value(value: &mut Value) { + match value { + Value::String(text) => *text = redact_text(text), + Value::Array(items) => for item in items { redact_value(item); }, + Value::Object(items) => { + for (key, item) in items { + if is_secret_field(key) { + *item = Value::String("[REDACTED]".into()); + } else { + redact_value(item); + } + } + } + _ => {} + } +} + +fn rfc3339_now() -> String { + let ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as i64; + let seconds = ms / 1000; + let millis = ms % 1000; + let days = seconds.div_euclid(86_400); + let day_seconds = seconds.rem_euclid(86_400); + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let mut year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = mp + if mp < 10 { 3 } else { -9 }; + if month <= 2 { year += 1; } + let hour = day_seconds / 3600; + let minute = (day_seconds % 3600) / 60; + let second = day_seconds % 60; + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z") +} + +async fn open_error_file(path: PathBuf) -> Result { + let file = tokio::task::spawn_blocking(move || -> Result { + let mut options = std::fs::OpenOptions::new(); + options.create(true).append(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let file = options.open(&path)?; + let metadata = file.metadata()?; + ensure!(metadata.is_file(), "error log path is not a regular file"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + Ok(file) + }).await.map_err(|_| anyhow::anyhow!("error log open worker failed"))??; + Ok(File::from_std(file)) +} + +fn format_error_event(event: &ErrorEvent, format: ErrorLogFormat) -> Vec { + match format { + ErrorLogFormat::Json => { + let mut object = Map::new(); + object.insert("timestamp".into(), Value::String(event.timestamp.clone())); + object.insert("level".into(), Value::String("ERROR".into())); + object.insert("target".into(), Value::String(event.target.clone())); + object.insert("message".into(), Value::String(event.message.clone())); + for (key, value) in &event.fields { + if !matches!(key.as_str(), "timestamp" | "level" | "target" | "message") { + object.insert(key.clone(), Value::String(value.clone())); + } + } + let mut line = serde_json::to_vec(&Value::Object(object)).unwrap_or_default(); + line.push(b'\n'); + line + } + ErrorLogFormat::Raw => { + let one_line = |value: &str| value.replace('\r', "\\r").replace('\n', "\\n"); + let mut line = format!( + "{} ERROR {} {}", + event.timestamp, + event.target, + one_line(&event.message) + ); + for (key, value) in &event.fields { + line.push(' '); + line.push_str(key); + line.push('='); + line.push_str(&one_line(value)); + } + line.push('\n'); + line.into_bytes() + } + } +} + +async fn error_log_loop( + mut receiver: mpsc::Receiver, + settings: Arc>, + mut settings_changed: watch::Receiver<()>, + written: Arc, + errors: Arc, +) { + let mut open_path: Option = None; + let mut file: Option = None; + let mut retry_after: Option = None; + let mut last_warning: Option = None; + loop { + let event = tokio::select! { + event = receiver.recv() => match event { + Some(event) => event, + None => break, + }, + changed = settings_changed.changed() => { + if changed.is_err() && receiver.is_closed() { + break; + } + let config = settings.load_full(); + let path = PathBuf::from(&config.path); + if !config.enabled || open_path.as_ref() != Some(&path) { + // Dropping Tokio's File closes the descriptor without blocking + // the request path. A changed path is reopened on the next event. + file = None; + open_path = None; + retry_after = None; + } + continue; + } + }; + + let config = settings.load_full(); + if !config.enabled { + file = None; + open_path = None; + retry_after = None; + continue; + } + let path = PathBuf::from(&config.path); + if open_path.as_ref() != Some(&path) { + file = None; + open_path = Some(path.clone()); + retry_after = None; + } + if file.is_none() && retry_after.is_none_or(|until| Instant::now() >= until) { + match open_error_file(path.clone()).await { + Ok(opened) => { + file = Some(opened); + retry_after = None; + } + Err(error) => { + errors.fetch_add(1, Ordering::Relaxed); + retry_after = Some(Instant::now() + ERROR_LOG_RETRY); + if last_warning.is_none_or(|time| time.elapsed() >= ERROR_LOG_RETRY) { + tracing::warn!(path=%path.display(), %error, "optional error log file is unavailable"); + last_warning = Some(Instant::now()); + } + } + } + } + let Some(opened) = file.as_mut() else { continue; }; + let line = format_error_event(&event, config.format); + if let Err(error) = opened.write_all(&line).await { + errors.fetch_add(1, Ordering::Relaxed); + file = None; + retry_after = Some(Instant::now() + ERROR_LOG_RETRY); + if last_warning.is_none_or(|time| time.elapsed() >= ERROR_LOG_RETRY) { + tracing::warn!(path=%path.display(), %error, "optional error log write failed"); + last_warning = Some(Instant::now()); + } + } else { + written.fetch_add(1, Ordering::Relaxed); + } + } +} + +#[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 user_agent: Option, + pub country: 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 capture_user_agent(&self) -> bool { + self.settings.load().capture_user_agent + } + pub fn capture_country(&self) -> bool { + self.settings.load().capture_country + } + 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), + "route" => entry + .route + .clone() + .map(Value::String) + .unwrap_or(Value::Null), + "cache_effective_enabled" => entry + .cache_effective_enabled + .map(Value::Bool) + .unwrap_or(Value::Null), + "transform_effective_enabled" => entry + .transform_effective_enabled + .map(Value::Bool) + .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), + "user_agent" => entry.user_agent.clone().map(Value::String).unwrap_or(Value::Null), + "country" => entry.country.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, + } +} diff --git a/src/logging/model.rs b/src/logging/model.rs new file mode 100644 index 0000000..03c3748 --- /dev/null +++ b/src/logging/model.rs @@ -0,0 +1,426 @@ +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}, + io::{self, Write as IoWrite}, + path::PathBuf, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, OnceLock, + }, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use tokio::{ + fs::{File, OpenOptions}, + io::AsyncWriteExt, + net::TcpStream, + sync::{broadcast, mpsc, watch}, +}; +use tokio_util::sync::CancellationToken; +use tracing::{Event, Level, Subscriber}; +use tracing_subscriber::{layer::Context as LayerContext, Layer}; + +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, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Settings { + /// Capture a bounded User-Agent value in access logs and traffic analytics. + pub capture_user_agent: bool, + /// Resolve the effective client IP through the loaded GeoIP database. + /// Disabled by default because this adds per-request lookup work. + pub capture_country: bool, + pub file: FileSink, + pub logstash: LogstashSink, + pub loki: LokiSink, + pub format: JsonFormat, +} +impl Default for Settings { + fn default() -> Self { + Self { + capture_user_agent: true, + capture_country: false, + file: FileSink::default(), + logstash: LogstashSink::default(), + loki: LokiSink::default(), + format: JsonFormat::default(), + } + } +} +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" + | "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" + | "user_agent" + | "country" + | "status" + | "duration_ms" + | "request_bytes" + | "response_bytes" + | "outcome" + | "cache" + | "cache_reason" + ) +} + + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum ErrorLogFormat { + #[serde(rename = "JSON")] + Json, + #[serde(rename = "RAW")] + Raw, +} +impl Default for ErrorLogFormat { + fn default() -> Self { Self::Json } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ErrorLogSettings { + pub enabled: bool, + pub path: String, + pub format: ErrorLogFormat, +} +impl Default for ErrorLogSettings { + fn default() -> Self { + Self { enabled: false, path: "/var/log/rustproxy/error.log".into(), format: ErrorLogFormat::Json } + } +} +impl ErrorLogSettings { + pub fn validate(&self) -> Result<()> { + if self.enabled { + ensure!( + !self.path.trim().is_empty() && self.path.len() <= 1024 && !self.path.chars().any(char::is_control), + "error_log.path must be 1..1024 bytes without controls" + ); + } + Ok(()) + } +} + +const ERROR_LOG_QUEUE: usize = 512; +const ERROR_LOG_RETRY: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, Serialize)] +pub struct ErrorLogStats { + pub queue_capacity: usize, + pub queue_dropped: u64, + pub written: u64, + pub errors: u64, +} + +#[derive(Debug)] +struct ErrorEvent { + timestamp: String, + target: String, + message: String, + fields: BTreeMap, +} + +pub struct ErrorLogController { + settings: Arc>, + settings_changed: watch::Sender<()>, + sender: mpsc::Sender, + dropped: Arc, + written: Arc, + errors: Arc, +} +impl ErrorLogController { + pub fn start() -> Arc { + let (sender, receiver) = mpsc::channel(ERROR_LOG_QUEUE); + let (settings_changed, settings_watch) = watch::channel(()); + let settings = Arc::new(ArcSwap::from_pointee(ErrorLogSettings::default())); + let dropped = Arc::new(AtomicU64::new(0)); + let written = Arc::new(AtomicU64::new(0)); + let errors = Arc::new(AtomicU64::new(0)); + tokio::spawn(error_log_loop( + receiver, + settings.clone(), + settings_watch, + written.clone(), + errors.clone(), + )); + Arc::new(Self { settings, settings_changed, sender, dropped, written, errors }) + } + pub fn configure(&self, settings: ErrorLogSettings) { + self.settings.store(Arc::new(settings)); + self.settings_changed.send_replace(()); + } + fn push(&self, event: ErrorEvent) { + if self.settings.load().enabled && self.sender.try_send(event).is_err() { + self.dropped.fetch_add(1, Ordering::Relaxed); + } + } + pub fn stats(&self) -> ErrorLogStats { + ErrorLogStats { + queue_capacity: ERROR_LOG_QUEUE, + queue_dropped: self.dropped.load(Ordering::Relaxed), + written: self.written.load(Ordering::Relaxed), + errors: self.errors.load(Ordering::Relaxed), + } + } +} + +static ERROR_LOG: OnceLock> = OnceLock::new(); + +pub fn install_error_log_controller(controller: Arc) -> Result<()> { + ERROR_LOG.set(controller).map_err(|_| anyhow::anyhow!("error logger already initialized")) +} +pub fn configure_error_log(settings: ErrorLogSettings) { + if let Some(controller) = ERROR_LOG.get() { controller.configure(settings); } +} +pub fn error_log_stats() -> Option { + ERROR_LOG.get().map(|controller| controller.stats()) +} + +#[derive(Clone)] +pub struct ErrorLogLayer { + controller: Arc, +} +impl ErrorLogLayer { + pub fn new(controller: Arc) -> Self { Self { controller } } +} + +#[derive(Default)] +struct EventVisitor { + message: Option, + fields: BTreeMap, +} +impl tracing::field::Visit for EventVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + let value = format!("{value:?}"); + if field.name() == "message" { self.message = Some(value); } + else { self.fields.insert(field.name().to_owned(), value); } + } + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + if field.name() == "message" { self.message = Some(value.to_owned()); } + else { self.fields.insert(field.name().to_owned(), value.to_owned()); } + } + fn record_i64(&mut self, field: &tracing::field::Field, value: i64) { self.fields.insert(field.name().to_owned(), value.to_string()); } + fn record_u64(&mut self, field: &tracing::field::Field, value: u64) { self.fields.insert(field.name().to_owned(), value.to_string()); } + fn record_bool(&mut self, field: &tracing::field::Field, value: bool) { self.fields.insert(field.name().to_owned(), value.to_string()); } + fn record_f64(&mut self, field: &tracing::field::Field, value: f64) { self.fields.insert(field.name().to_owned(), value.to_string()); } +} +impl Layer for ErrorLogLayer { + fn on_event(&self, event: &Event<'_>, _ctx: LayerContext<'_, S>) { + if *event.metadata().level() != Level::ERROR { return; } + let mut visitor = EventVisitor::default(); + event.record(&mut visitor); + let mut fields = visitor.fields; + for (key, value) in &mut fields { + *value = if is_secret_field(key) { + "[REDACTED]".into() + } else { + redact_text(value) + }; + } + self.controller.push(ErrorEvent { + timestamp: rfc3339_now(), + target: event.metadata().target().to_owned(), + message: redact_text(visitor.message.as_deref().unwrap_or("error")), + fields, + }); + } +} + +#[derive(Clone, Copy, Default)] +pub struct RedactingStderr; +pub struct RedactingWriter { buffer: Vec } diff --git a/src/logging/runtime.rs b/src/logging/runtime.rs new file mode 100644 index 0000000..1ee4773 --- /dev/null +++ b/src/logging/runtime.rs @@ -0,0 +1,229 @@ +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 mut payload = format_entry(&entry, &config.format); + redact_value(&mut payload); + 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 +} diff --git a/src/logging/tests.rs b/src/logging/tests.rs new file mode 100644 index 0000000..625c179 --- /dev/null +++ b/src/logging/tests.rs @@ -0,0 +1,159 @@ +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()), + user_agent: Some("RustProxy-Test/1.0".into()), + country: Some("PL".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.fields.insert("route".into(), "route".into()); + format.fields.insert("ua".into(), "user_agent".into()); + format.fields.insert("geo_country".into(), "country".into()); + format + .fields + .insert("cache_enabled".into(), "cache_effective_enabled".into()); + format.fields.insert( + "transform_enabled".into(), + "transform_effective_enabled".into(), + ); + format.static_fields.insert("service".into(), "edge".into()); + let mut settings = Settings::default(); + settings.format = format.clone(); + settings.validate().unwrap(); + let value = format_entry(&entry(), &format); + assert_eq!(value["http_status"], 200); + assert_eq!(value["site"], "app"); + assert_eq!(value["route"], "root"); + assert_eq!(value["ua"], "RustProxy-Test/1.0"); + assert_eq!(value["geo_country"], "PL"); + assert_eq!(value["cache_enabled"], true); + assert_eq!(value["transform_enabled"], false); + 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 capture_defaults_keep_geoip_off_request_path() { + let settings = Settings::default(); + assert!(settings.capture_user_agent); + assert!(!settings.capture_country); + } + + #[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 error_log_defaults_off_and_redacts_common_secret_shapes() { + let settings = ErrorLogSettings::default(); + assert!(!settings.enabled); + assert_eq!(settings.format, ErrorLogFormat::Json); + let text = redact_text( + r#"Authorization: Bearer topsecret url=https://x.invalid/?api_token=abc password=hunter2 {\"private_key\":\"pem-data\"}"#, + ); + assert!(!text.contains("topsecret")); + assert!(!text.contains("api_token=abc")); + assert!(!text.contains("hunter2")); + assert!(!text.contains("pem-data")); + } + + #[test] + fn error_json_is_one_record_and_reserved_fields_cannot_be_overwritten() { + let event = ErrorEvent { + timestamp: "2026-10-03T12:34:56.123Z".into(), + target: "rust_proxy::http".into(), + message: "upstream failed\nsecond line".into(), + fields: BTreeMap::from([ + ("message".into(), "attacker override".into()), + ("request_id".into(), "abc".into()), + ]), + }; + let line = format_error_event(&event, ErrorLogFormat::Json); + assert_eq!(line.iter().filter(|byte| **byte == b'\n').count(), 1); + let value: Value = serde_json::from_slice(&line).unwrap(); + assert_eq!(value["message"], "upstream failed\nsecond line"); + assert_eq!(value["request_id"], "abc"); + } + + #[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 index 9bc8fac..5447746 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,2183 +1,5 @@ -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 rust_proxy::{ - auth, - config::{self, Config, Envelope, ValidationPolicy}, - db::Store, - runtime::App, -}; -use rustls::{ - client::{ - danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, - WebPkiServerVerifier, - }, - pki_types::{CertificateDer, ServerName, UnixTime}, - ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme, -}; -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 tracing_subscriber::prelude::*; -use zeroize::Zeroizing; - -#[derive(Parser)] -#[command( - name = "rust-proxy", - version, - about = "RustProxy - independent Rust reverse proxy and Web Server 0.25.0" -)] -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, - }, - /// Create or restore a password-encrypted full application backup. - Backup { - #[command(subcommand)] - action: BackupCommand, - }, -} -#[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 BackupCommand { - /// Create an encrypted full backup. Stop the RustProxy service first. - Create { - /// Destination .rpb file. Existing files are never overwritten. - #[arg(long)] - output: Option, - }, - /// Restore an encrypted full backup atomically. Stop the RustProxy service first. - Restore { archive: 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<()> { - let error_log = rust_proxy::logging::ErrorLogController::start(); - rust_proxy::logging::install_error_log_controller(error_log.clone())?; - let filter = tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "rust_proxy=info,warn".into()); - tracing_subscriber::registry() - .with(filter) - .with( - tracing_subscriber::fmt::layer() - .with_target(false) - .with_writer(rust_proxy::logging::RedactingStderr), - ) - .with(rust_proxy::logging::ErrorLogLayer::new(error_log)) - .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::Backup { action } => { - backup_command( - resolve_installed_data_dir(cli.data_dir, data_dir_explicit), - 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 - } - } -} -async fn backup_command(data_dir: PathBuf, action: BackupCommand) -> Result<()> { - match action { - BackupCommand::Create { output } => { - let output = output.unwrap_or_else(|| { - PathBuf::from(format!("rustproxy-backup-{}.rpb", rust_proxy::auth::now())) - }); - let mut generated = false; - let password = if io::stdin().is_terminal() { - let value = Zeroizing::new(rpassword::prompt_password( - "Backup password (leave empty to generate): ", - )?); - if value.is_empty() { - generated = true; - Zeroizing::new(rust_proxy::backup::generated_password()) - } else { - let confirmation = - Zeroizing::new(rpassword::prompt_password("Confirm backup password: ")?); - ensure!( - value.as_str() == confirmation.as_str(), - "backup passwords do not match" - ); - value - } - } else { - generated = true; - Zeroizing::new(rust_proxy::backup::generated_password()) - }; - let summary = rust_proxy::backup::create(&data_dir, &output, password.as_str()).await?; - println!( - "Encrypted backup created: {} ({} files, configuration revision {})", - summary.path.display(), - summary.files, - summary.configuration_revision - ); - if generated { - println!( - "Generated backup password (shown once): {}", - password.as_str() - ); - println!("Store this password securely; restore is impossible without it."); - } - Ok(()) - } - BackupCommand::Restore { archive } => { - ensure!( - io::stdin().is_terminal(), - "backup restore requires an interactive TTY for the password" - ); - let password = Zeroizing::new(rpassword::prompt_password("Backup password: ")?); - ensure!(!password.is_empty(), "backup password cannot be empty"); - let summary = - rust_proxy::backup::restore(&archive, &data_dir, password.as_str()).await?; - println!( - "Backup restored to {} ({} files, configuration revision {})", - data_dir.display(), - summary.files, - summary.configuration_revision - ); - Ok(()) - } - } -} - -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??; - rust_proxy::secret_store::initialize(&data_dir)?; - let migrated = - rust_proxy::secret_store::migrate_acme_credentials(&store, &data_dir, &directory).await?; - if migrated > 0 { - tracing::info!( - migrated, - "legacy ACME credentials migrated to encrypted Secret Store" - ); - } - 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")?; - rust_proxy::logging::configure_error_log(snapshot.envelope.config.error_log.clone()); - 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.25.0 - 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()); - } -} +include!("main/bootstrap.rs"); +include!("main/admin_runtime.rs"); +include!("main/proxy_runtime.rs"); +include!("main/tasks.rs"); +include!("main/shutdown.rs"); diff --git a/src/main/admin_runtime.rs b/src/main/admin_runtime.rs new file mode 100644 index 0000000..a74d9b5 --- /dev/null +++ b/src/main/admin_runtime.rs @@ -0,0 +1,449 @@ +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??; + rust_proxy::secret_store::initialize(&data_dir)?; + let migrated = rust_proxy::secret_store::migrate_acme_credentials(&store, &data_dir, &directory).await?; + if migrated > 0 { + tracing::info!(migrated, "legacy ACME credentials migrated to encrypted Secret Store"); + } + 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")?; + rust_proxy::logging::configure_error_log(snapshot.envelope.config.error_log.clone()); + 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 +} diff --git a/src/main/bootstrap.rs b/src/main/bootstrap.rs new file mode 100644 index 0000000..4784808 --- /dev/null +++ b/src/main/bootstrap.rs @@ -0,0 +1,421 @@ +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; +use tracing_subscriber::prelude::*; + +#[derive(Parser)] +#[command( + name = "rust-proxy", + version, + about = "RustProxy - independent Rust reverse proxy and static Web Server 0.25.1" +)] +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, + }, + /// Create or restore a password-encrypted full application backup. + Backup { + #[command(subcommand)] + action: BackupCommand, + }, +} +#[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 BackupCommand { + /// Create an encrypted full backup. Stop the RustProxy service first. + Create { + /// Destination .rpb file. Existing files are never overwritten. + #[arg(long)] + output: Option, + }, + /// Restore an encrypted full backup atomically. Stop the RustProxy service first. + Restore { + archive: 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<()> { + let error_log = rust_proxy::logging::ErrorLogController::start(); + rust_proxy::logging::install_error_log_controller(error_log.clone())?; + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "rust_proxy=info,warn".into()); + tracing_subscriber::registry() + .with(filter) + .with( + tracing_subscriber::fmt::layer() + .with_target(false) + .with_writer(rust_proxy::logging::RedactingStderr), + ) + .with(rust_proxy::logging::ErrorLogLayer::new(error_log)) + .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::Backup { action } => { + backup_command( + resolve_installed_data_dir(cli.data_dir, data_dir_explicit), + 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 + } + } +} +async fn backup_command(data_dir: PathBuf, action: BackupCommand) -> Result<()> { + match action { + BackupCommand::Create { output } => { + let output = output.unwrap_or_else(|| { + PathBuf::from(format!("rustproxy-backup-{}.rpb", rust_proxy::auth::now())) + }); + let mut generated = false; + let password = if io::stdin().is_terminal() { + let value = Zeroizing::new( + rpassword::prompt_password("Backup password (leave empty to generate): ")? + ); + if value.is_empty() { + generated = true; + Zeroizing::new(rust_proxy::backup::generated_password()) + } else { + let confirmation = Zeroizing::new(rpassword::prompt_password("Confirm backup password: ")?); + ensure!(value.as_str() == confirmation.as_str(), "backup passwords do not match"); + value + } + } else { + generated = true; + Zeroizing::new(rust_proxy::backup::generated_password()) + }; + let summary = rust_proxy::backup::create(&data_dir, &output, password.as_str()).await?; + println!( + "Encrypted backup created: {} ({} files, configuration revision {})", + summary.path.display(), summary.files, summary.configuration_revision + ); + if generated { + println!("Generated backup password (shown once): {}", password.as_str()); + println!("Store this password securely; restore is impossible without it."); + } + Ok(()) + } + BackupCommand::Restore { archive } => { + ensure!( + io::stdin().is_terminal(), + "backup restore requires an interactive TTY for the password" + ); + let password = Zeroizing::new(rpassword::prompt_password("Backup password: ")?); + ensure!(!password.is_empty(), "backup password cannot be empty"); + let summary = rust_proxy::backup::restore(&archive, &data_dir, password.as_str()).await?; + println!( + "Backup restored to {} ({} files, configuration revision {})", + data_dir.display(), summary.files, summary.configuration_revision + ); + Ok(()) + } + } +} + +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) +} diff --git a/src/main/proxy_runtime.rs b/src/main/proxy_runtime.rs new file mode 100644 index 0000000..a55022a --- /dev/null +++ b/src/main/proxy_runtime.rs @@ -0,0 +1,417 @@ +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) +} diff --git a/src/main/shutdown.rs b/src/main/shutdown.rs new file mode 100644 index 0000000..f676731 --- /dev/null +++ b/src/main/shutdown.rs @@ -0,0 +1,230 @@ +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)] +include!("shutdown/tests.rs"); diff --git a/src/main/shutdown/tests.rs b/src/main/shutdown/tests.rs new file mode 100644 index 0000000..508afda --- /dev/null +++ b/src/main/shutdown/tests.rs @@ -0,0 +1,124 @@ +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/main/tasks.rs b/src/main/tasks.rs new file mode 100644 index 0000000..ce63596 --- /dev/null +++ b/src/main/tasks.rs @@ -0,0 +1,408 @@ +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.25.1 - 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)?) +} diff --git a/src/metrics.rs b/src/metrics.rs index 5beed86..3d8aa4c 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -1,555 +1,4 @@ //! 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 ewma_duration_us: AtomicU64, - pub status: [AtomicU64; 5], - pub buckets: [AtomicU64; 11], -} - -#[derive(Debug, Clone, Serialize)] -pub struct VhostMetricsView { - pub requests: u64, - pub completed: u64, - pub active: u64, - pub rejected: u64, - pub errors: u64, - pub bytes_in: u64, - pub bytes_out: u64, - pub average_latency_ms: f64, - pub ewma_latency_ms: f64, -} - -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); - let mut current = self.ewma_duration_us.load(Ordering::Relaxed); - loop { - let next = if current == 0 { - micros - } else { - current.saturating_mul(7).saturating_add(micros) / 8 - }; - match self.ewma_duration_us.compare_exchange_weak( - current, - next, - Ordering::Relaxed, - Ordering::Relaxed, - ) { - Ok(_) => break, - Err(observed) => current = observed, - } - } - 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); - } - - pub fn view(&self) -> VhostMetricsView { - let completed = n(&self.completed); - let duration_us = n(&self.duration_us); - VhostMetricsView { - requests: n(&self.requests), - completed, - active: n(&self.active), - rejected: n(&self.rejected), - errors: n(&self.errors), - bytes_in: n(&self.bytes_in), - bytes_out: n(&self.bytes_out), - average_latency_ms: if completed == 0 { - 0.0 - } else { - duration_us as f64 / completed as f64 / 1000.0 - }, - ewma_latency_ms: n(&self.ewma_duration_us) as f64 / 1000.0, - } - } -} -#[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); - let view = m.view(); - assert_eq!(view.requests, 0); - assert_eq!(view.completed, 1); - assert_eq!(view.errors, 1); - assert!(view.ewma_latency_ms >= 20.0); - } -} - -#[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); - } -} +include!("metrics/global.rs"); +include!("metrics/vhost.rs"); diff --git a/src/metrics/global.rs b/src/metrics/global.rs new file mode 100644 index 0000000..b855e32 --- /dev/null +++ b/src/metrics/global.rs @@ -0,0 +1,417 @@ +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, +]; diff --git a/src/metrics/vhost.rs b/src/metrics/vhost.rs new file mode 100644 index 0000000..0dd27b2 --- /dev/null +++ b/src/metrics/vhost.rs @@ -0,0 +1,96 @@ +#[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 ewma_duration_us: AtomicU64, + pub status: [AtomicU64; 5], + pub buckets: [AtomicU64; 11], +} + +#[derive(Debug, Clone, Serialize)] +pub struct VhostMetricsView { + pub requests: u64, + pub completed: u64, + pub active: u64, + pub rejected: u64, + pub errors: u64, + pub bytes_in: u64, + pub bytes_out: u64, + pub average_latency_ms: f64, + pub ewma_latency_ms: f64, +} + +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); + let mut current = self.ewma_duration_us.load(Ordering::Relaxed); + loop { + let next = if current == 0 { + micros + } else { + current.saturating_mul(7).saturating_add(micros) / 8 + }; + match self.ewma_duration_us.compare_exchange_weak( + current, + next, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(observed) => current = observed, + } + } + 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); + } + + pub fn view(&self) -> VhostMetricsView { + let completed = n(&self.completed); + let duration_us = n(&self.duration_us); + VhostMetricsView { + requests: n(&self.requests), + completed, + active: n(&self.active), + rejected: n(&self.rejected), + errors: n(&self.errors), + bytes_in: n(&self.bytes_in), + bytes_out: n(&self.bytes_out), + average_latency_ms: if completed == 0 { + 0.0 + } else { + duration_us as f64 / completed as f64 / 1000.0 + }, + ewma_latency_ms: n(&self.ewma_duration_us) as f64 / 1000.0, + } + } +} +#[cfg(test)] +include!("vhost/tests.rs"); + + diff --git a/src/metrics/vhost/tests.rs b/src/metrics/vhost/tests.rs new file mode 100644 index 0000000..bc2d9fb --- /dev/null +++ b/src/metrics/vhost/tests.rs @@ -0,0 +1,43 @@ +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); + let view = m.view(); + assert_eq!(view.requests, 0); + assert_eq!(view.completed, 1); + assert_eq!(view.errors, 1); + assert!(view.ewma_latency_ms >= 20.0); + } +} + +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 index 45d13be..f5a7e6f 100644 --- a/src/not_found.rs +++ b/src/not_found.rs @@ -99,32 +99,4 @@ pub fn compile(host: &Host, listeners: &crate::settings::Listeners) -> Result 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()); - } -} +include!("not_found/tests.rs"); diff --git a/src/not_found/tests.rs b/src/not_found/tests.rs new file mode 100644 index 0000000..fc13fa8 --- /dev/null +++ b/src/not_found/tests.rs @@ -0,0 +1,29 @@ +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 index ffce713..e97fcb4 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -256,151 +256,4 @@ pub fn literal_ip_authority(value: &str) -> bool { } #[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); - } -} +include!("pages/tests.rs"); diff --git a/src/pages/tests.rs b/src/pages/tests.rs new file mode 100644 index 0000000..e57442b --- /dev/null +++ b/src/pages/tests.rs @@ -0,0 +1,148 @@ +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 index a7130f0..1921f1b 100644 --- a/src/prometheus.rs +++ b/src/prometheus.rs @@ -1,1270 +1,9 @@ //! 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; +include!("prometheus/model.rs"); +include!("prometheus/core.rs"); +include!("prometheus/vhosts_backends.rs"); +include!("prometheus/docker_misc.rs"); -#[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')); - } -} +include!("prometheus/tests.rs"); diff --git a/src/prometheus/core.rs b/src/prometheus/core.rs new file mode 100644 index 0000000..7cbec76 --- /dev/null +++ b/src/prometheus/core.rs @@ -0,0 +1,370 @@ +fn render_core(app: &App, snapshot: &crate::config::Snapshot, out: &mut String, f: &mut Families) { + let config = &snapshot.envelope.config; + let settings = &config.prometheus; +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), + ); + } +} + +} + +fn render_vhosts(snapshot: &crate::config::Snapshot, f: &mut Families) { + let config = &snapshot.envelope.config; + let settings = &config.prometheus; +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), + ); + } + } +} + +} diff --git a/src/prometheus/docker_misc.rs b/src/prometheus/docker_misc.rs new file mode 100644 index 0000000..28e28de --- /dev/null +++ b/src/prometheus/docker_misc.rs @@ -0,0 +1,245 @@ +fn render_misc(app: &App, snapshot: &crate::config::Snapshot, f: &mut Families) { + let config = &snapshot.envelope.config; + let settings = &config.prometheus; +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, + ); + } + } +} + +} + +fn render_histograms(snapshot: &crate::config::Snapshot, docker_targets: &DockerTargets<'_>, out: &mut String) { + let config = &snapshot.envelope.config; + let settings = &config.prometheus; +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}"); + } +} + +} + +pub fn render(app: &App) -> String { + let snapshot = app.config.load_full(); + let config = &snapshot.envelope.config; + let docker_targets = docker_targets(config); + let mut out = app.metrics.prometheus( + app.logs.dropped.load(Ordering::Relaxed), + snapshot.envelope.revision, + ); + out.push_str(&app.cache.prometheus()); + if config.prometheus.include_system { + out.push_str(&app.diagnostics.prometheus()); + } + let mut families = Families::default(); + render_core(app, &snapshot, &mut out, &mut families); + render_vhosts(&snapshot, &mut families); + render_backends(&snapshot, &docker_targets, &mut families); + render_docker(&snapshot, &docker_targets, &mut families); + render_misc(app, &snapshot, &mut families); + families.finish(&mut out); + render_histograms(&snapshot, &docker_targets, &mut out); + out +} diff --git a/src/prometheus/model.rs b/src/prometheus/model.rs new file mode 100644 index 0000000..9afda64 --- /dev/null +++ b/src/prometheus/model.rs @@ -0,0 +1,268 @@ +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); + } + } + } +} + +type DockerTargets<'a> = BTreeMap; + +fn docker_targets(config: &crate::config::Config) -> DockerTargets<'_> { + let mut targets = BTreeMap::new(); + for agent in &config.docker_agents.agents { + for assignment in &agent.assignments { + targets.insert( + crate::docker::generated_id(&agent.id, &assignment.service_key), + (agent, assignment), + ); + } + } + targets +} diff --git a/src/prometheus/tests.rs b/src/prometheus/tests.rs new file mode 100644 index 0000000..844c262 --- /dev/null +++ b/src/prometheus/tests.rs @@ -0,0 +1,41 @@ +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/prometheus/vhosts_backends.rs b/src/prometheus/vhosts_backends.rs new file mode 100644 index 0000000..60716df --- /dev/null +++ b/src/prometheus/vhosts_backends.rs @@ -0,0 +1,389 @@ +fn render_backends(snapshot: &crate::config::Snapshot, docker_targets: &DockerTargets<'_>, f: &mut Families) { + let config = &snapshot.envelope.config; + let settings = &config.prometheus; +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, + ); + } + } + } + } +} + +} + +fn render_docker(snapshot: &crate::config::Snapshot, docker_targets: &DockerTargets<'_>, f: &mut Families) { + let config = &snapshot.envelope.config; + let settings = &config.prometheus; +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, + ); + } + } + } + } + } +} + +} diff --git a/src/proxy/body.rs b/src/proxy/body.rs index 8f2f9ab..a49e36d 100644 --- a/src/proxy/body.rs +++ b/src/proxy/body.rs @@ -59,22 +59,13 @@ pub struct Exchange { 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); + *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); + *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) { @@ -138,29 +129,12 @@ impl Drop for Exchange { .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()); + let websocket_phase = *self.websocket_phase.lock().unwrap_or_else(|e| e.into_inner()); let route = self.route.lock().unwrap_or_else(|e| e.into_inner()).clone(); - let cache_effective_enabled = *self - .cache_effective_enabled - .lock() - .unwrap_or_else(|e| e.into_inner()); - let transform_effective_enabled = *self - .transform_effective_enabled - .lock() - .unwrap_or_else(|e| e.into_inner()); - let client_ip = self - .client_ip - .lock() - .unwrap_or_else(|e| e.into_inner()) - .clone(); - let country = self - .country - .lock() - .unwrap_or_else(|e| e.into_inner()) - .clone(); + let cache_effective_enabled = *self.cache_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()); + let transform_effective_enabled = *self.transform_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()); + let client_ip = self.client_ip.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let country = self.country.lock().unwrap_or_else(|e| e.into_inner()).clone(); let request_bytes = self.request_bytes.load(Ordering::Relaxed); let response_bytes = self.response_bytes.load(Ordering::Relaxed); let duration_ms = duration.as_millis() as u64; @@ -213,20 +187,13 @@ impl Drop for Exchange { transport: self.transport, websocket_bootstrap: self.websocket_bootstrap, websocket_phase, - websocket_error: *self - .websocket_error - .lock() - .unwrap_or_else(|e| e.into_inner()), + 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(), + upstream_error: self.upstream_error.lock().unwrap_or_else(|e| e.into_inner()).clone(), client_ip, user_agent: self.user_agent.clone(), country, @@ -367,102 +334,7 @@ impl Drop for MeteredBody { } #[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())), - user_agent: Some("RustProxy-Test/1.0".into()), - country: Mutex::new(Some("PL".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()), - traffic: crate::traffic::Analytics::start(tokio_util::sync::CancellationToken::new()), - _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); - } -} +include!("body/tests.rs"); /// Retains the first inspected frame without collecting the rest of a request. /// A trailer/empty DATA frame still prevents storing a request-body variant. diff --git a/src/proxy/body/tests.rs b/src/proxy/body/tests.rs new file mode 100644 index 0000000..8a03db1 --- /dev/null +++ b/src/proxy/body/tests.rs @@ -0,0 +1,96 @@ +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())), + user_agent: Some("RustProxy-Test/1.0".into()), + country: Mutex::new(Some("PL".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()), + traffic: crate::traffic::Analytics::start(tokio_util::sync::CancellationToken::new()), + _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); + } +} diff --git a/src/proxy/cache_helpers.rs b/src/proxy/cache_helpers.rs new file mode 100644 index 0000000..2eb82ec --- /dev/null +++ b/src/proxy/cache_helpers.rs @@ -0,0 +1,390 @@ +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(()) +} diff --git a/src/proxy/forward/cache_lookup.rs b/src/proxy/forward/cache_lookup.rs new file mode 100644 index 0000000..c211cb7 --- /dev/null +++ b/src/proxy/forward/cache_lookup.rs @@ -0,0 +1,328 @@ +{ +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()); + } +} + (serve_stale, fill_leader) +} diff --git a/src/proxy/forward/response.rs b/src/proxy/forward/response.rs new file mode 100644 index 0000000..ae0b315 --- /dev/null +++ b/src/proxy/forward/response.rs @@ -0,0 +1,104 @@ +{ + match upstream_result { + Ok(Ok(mut response)) => { + let (origin_validation_headers, origin_plan, origin_reason, mut response) = + include!("success_prepare.rs"); + include!("success_finish.rs") + } + 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", + ) + } + } +} diff --git a/src/proxy/forward/route_cache.rs b/src/proxy/forward/route_cache.rs new file mode 100644 index 0000000..30bc92e --- /dev/null +++ b/src/proxy/forward/route_cache.rs @@ -0,0 +1,357 @@ +{ +// 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()); +} +if let crate::config::CompiledSource::WebServer(service) = &route.source { + if handshake.is_some() { + return error( + StatusCode::BAD_REQUEST, + "RustProxy: Web Server source does not support WebSocket upgrades\n", + ); + } + let service_path = match route.rewritten_path(&path) { + Ok(Some(path)) => path, + Ok(None) => path.clone(), + Err(_) => { + return error( + StatusCode::BAD_REQUEST, + "RustProxy: invalid Web Server rewrite path\n", + ) + } + }; + 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(false); + *exchange.transform_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) = Some(false); + } + // Do not hold a shared reference to Request across await. Axum handlers + // must return Send futures, while Body is not Sync. Clone only static-request + // metadata needed by the filesystem source before entering the async server. + let static_method = request.method().clone(); + let static_headers = request.headers().clone(); + let static_uri = request.uri().clone(); + let static_version = request.version(); + let static_body = std::mem::replace(request.body_mut(), Body::empty()); + let mut response = crate::web_server::serve( + service, + static_method, + static_headers, + static_uri, + &service_path, + static_body, + crate::web_server::RequestContext { + client_ip: context.client_ip, + client_port: Some(peer.0.port()), + server_name: host.clone(), + server_port: local.0.port(), + tls, + version: static_version, + }, + ) + .await; + response.extensions_mut().insert(StaticSourceResponse); + return response; +} +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 { + let Some(backend) = route.backend.clone() else { + return error( + StatusCode::INTERNAL_SERVER_ERROR, + "RustProxy: Reverse Proxy route has no backend\n", + ); + }; + (backend, 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 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 miss_recorded = false; + ( + backend, backend_lease, affinity_cookie, admin_control_plane, cache_settings, + transform_settings, scope, ignore_static_cookie, only_cached, public_suffix, + origin_suffix, uri, exchange, lease, stop, downstream, h2_downstream, + range_selection, if_range, range_requested, effective_reason, revalidation_reason, + cache_ticket, client_conditionals, cached_revalidation, freshness_revalidation_reason, + pending_revalidation, miss_recorded, + ) +} diff --git a/src/proxy/forward/success_finish.rs b/src/proxy/forward/success_finish.rs new file mode 100644 index 0000000..1aaeedd --- /dev/null +++ b/src/proxy/forward/success_finish.rs @@ -0,0 +1,197 @@ +{ +// 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 +} diff --git a/src/proxy/forward/success_prepare.rs b/src/proxy/forward/success_prepare.rs new file mode 100644 index 0000000..4fc48f2 --- /dev/null +++ b/src/proxy/forward/success_prepare.rs @@ -0,0 +1,380 @@ +{ +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", + ); + } + }; + } +} + (origin_validation_headers, origin_plan, origin_reason, response) +} diff --git a/src/proxy/forward/upstream.rs b/src/proxy/forward/upstream.rs new file mode 100644 index 0000000..a37713b --- /dev/null +++ b/src/proxy/forward/upstream.rs @@ -0,0 +1,256 @@ +{ +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(); + ( + websocket_upstream_h2, revalidation_sent, h2_direct_session, request_body_empty, + proactive_base_headers, upstream_started, active_backend, + active_lease, active_affinity_cookie, active_uri, retries, upstream_result, + final_attempt_elapsed, + ) +} diff --git a/src/proxy/forward_entry.rs b/src/proxy/forward_entry.rs new file mode 100644 index 0000000..5c0d713 --- /dev/null +++ b/src/proxy/forward_entry.rs @@ -0,0 +1,345 @@ +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 { + // GeoIP is deliberately off the default request path. Resolve only when + // a country policy/rate-limit requires it or the operator explicitly + // enables country capture for access logs + traffic analytics. + let capture_country = state.logs.capture_country(); + let country = (capture_country + || frontend.profile_country_access.needs_geoip() + || frontend.rate_limit.needs_geoip()) + .then(|| state.geoip.country(context.client_ip)) + .flatten(); + if capture_country { + if let Some(exchange) = request.extensions().get::>() { + *exchange.country.lock().unwrap_or_else(|e| e.into_inner()) = country.clone(); + } + } + 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", + ) + }); + }; + let ( + backend, backend_lease, affinity_cookie, admin_control_plane, cache_settings, + transform_settings, scope, ignore_static_cookie, only_cached, public_suffix, + origin_suffix, uri, exchange, lease, stop, downstream, mut h2_downstream, + range_selection, if_range, range_requested, effective_reason, revalidation_reason, + cache_ticket, client_conditionals, cached_revalidation, freshness_revalidation_reason, + pending_revalidation, mut miss_recorded, + ) = include!("forward/route_cache.rs"); + + let (serve_stale, mut fill_leader) = include!("forward/cache_lookup.rs"); + + let ( + websocket_upstream_h2, revalidation_sent, mut h2_direct_session, request_body_empty, + proactive_base_headers, upstream_started, active_backend, + active_lease, active_affinity_cookie, active_uri, retries, upstream_result, + final_attempt_elapsed, + ) = include!("forward/upstream.rs"); + + include!("forward/response.rs") +} diff --git a/src/proxy/headers.rs b/src/proxy/headers.rs index 5819d80..129a185 100644 --- a/src/proxy/headers.rs +++ b/src/proxy/headers.rs @@ -38,44 +38,7 @@ pub fn requests_upgrade(headers: &HeaderMap) -> bool { .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" - ); - } -} +include!("headers/tests.rs"); /// Cookies are not port-scoped. Remove the local control-plane cookie before /// authentication/cache processing and return its exact cookie fragments. The @@ -91,9 +54,7 @@ pub fn strip_admin_cookie(headers: &mut HeaderMap) -> Vec { .flat_map(|h| h.split(';')) { let part = part.trim(); - let Some((name, _)) = part.split_once('=') else { - continue; - }; + let Some((name, _)) = part.split_once('=') else { continue; }; if name == crate::auth::COOKIE_NAME { admin.push(part.to_owned()); } else { diff --git a/src/proxy/headers/tests.rs b/src/proxy/headers/tests.rs new file mode 100644 index 0000000..5792b01 --- /dev/null +++ b/src/proxy/headers/tests.rs @@ -0,0 +1,32 @@ +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"); + } +} diff --git a/src/proxy/mod.rs b/src/proxy/mod.rs index c74ad6f..70e2639 100644 --- a/src/proxy/mod.rs +++ b/src/proxy/mod.rs @@ -1,3518 +1,7 @@ -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, USER_AGENT}, - 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)] -struct StaticSourceResponse; -#[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}, -}; +include!("types.rs"); +include!("cache_helpers.rs"); +include!("upstream_helpers.rs"); +include!("forward_entry.rs"); -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 user_agent = state - .logs - .capture_user_agent() - .then(|| { - request - .headers() - .get(USER_AGENT) - .and_then(|value| value.to_str().ok()) - .map(|value| value.chars().take(512).collect::()) - }) - .flatten(); - 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), - user_agent, - country: Mutex::new(None), - 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(), - traffic: state.traffic.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() - && 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 { - // GeoIP is deliberately off the default request path. Resolve only when - // a country policy/rate-limit requires it or the operator explicitly - // enables country capture for access logs + traffic analytics. - let capture_country = state.logs.capture_country(); - let country = (capture_country - || frontend.profile_country_access.needs_geoip() - || frontend.rate_limit.needs_geoip()) - .then(|| state.geoip.country(context.client_ip)) - .flatten(); - if capture_country { - if let Some(exchange) = request.extensions().get::>() { - *exchange.country.lock().unwrap_or_else(|e| e.into_inner()) = country.clone(); - } - } - 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()); - } - if let crate::config::CompiledSource::WebServer(service) = &route.source { - if handshake.is_some() { - return error( - StatusCode::BAD_REQUEST, - "RustProxy: Web Server source does not support WebSocket upgrades\n", - ); - } - let service_path = match route.rewritten_path(&path) { - Ok(Some(path)) => path, - Ok(None) => path.clone(), - Err(_) => { - return error( - StatusCode::BAD_REQUEST, - "RustProxy: invalid Web Server rewrite path\n", - ) - } - }; - 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(false); - *exchange - .transform_effective_enabled - .lock() - .unwrap_or_else(|e| e.into_inner()) = Some(false); - } - // Do not hold a shared reference to Request across await. Axum handlers - // must return Send futures, while Body is not Sync. Clone only static-request - // metadata needed by the filesystem source before entering the async server. - let static_method = request.method().clone(); - let static_headers = request.headers().clone(); - let static_uri = request.uri().clone(); - let static_version = request.version(); - let static_body = std::mem::replace(request.body_mut(), Body::empty()); - let mut response = crate::web_server::serve( - service, - static_method, - static_headers, - static_uri, - &service_path, - static_body, - crate::web_server::RequestContext { - client_ip: context.client_ip, - client_port: Some(peer.0.port()), - server_name: host.clone(), - server_port: local.0.port(), - tls, - version: static_version, - }, - ) - .await; - response.extensions_mut().insert(StaticSourceResponse); - return response; - } - 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 { - let Some(backend) = route.backend.clone() else { - return error( - StatusCode::INTERNAL_SERVER_ERROR, - "RustProxy: Reverse Proxy route has no backend\n", - ); - }; - (backend, 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.as_ref().unwrap(), 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" - ); - } -} +include!("tests/authority_tests.rs"); diff --git a/src/proxy/tests/authority_tests.rs b/src/proxy/tests/authority_tests.rs new file mode 100644 index 0000000..dc4d3af --- /dev/null +++ b/src/proxy/tests/authority_tests.rs @@ -0,0 +1,278 @@ +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.as_ref().unwrap(), 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/types.rs b/src/proxy/types.rs new file mode 100644 index 0000000..e2a6c1c --- /dev/null +++ b/src/proxy/types.rs @@ -0,0 +1,423 @@ +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, USER_AGENT}, + 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)] +struct StaticSourceResponse; +#[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() +} diff --git a/src/proxy/upstream_helpers.rs b/src/proxy/upstream_helpers.rs new file mode 100644 index 0000000..d3d5a25 --- /dev/null +++ b/src/proxy/upstream_helpers.rs @@ -0,0 +1,398 @@ +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 user_agent = state.logs.capture_user_agent().then(|| { + request + .headers() + .get(USER_AGENT) + .and_then(|value| value.to_str().ok()) + .map(|value| value.chars().take(512).collect::()) + }).flatten(); + 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), + user_agent, + country: Mutex::new(None), + 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(), + traffic: state.traffic.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() + && 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(()) + } +} diff --git a/src/proxy/websocket.rs b/src/proxy/websocket.rs index e694726..e4f5e4a 100644 --- a/src/proxy/websocket.rs +++ b/src/proxy/websocket.rs @@ -3,1475 +3,10 @@ //! 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 serde::Serialize; -use sha1::{Digest, Sha1}; -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, -} +include!("websocket/handshake.rs"); +include!("websocket/tunnel.rs"); +include!("websocket/validation.rs"); -#[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()); - } -} +include!("websocket/tests.rs"); diff --git a/src/proxy/websocket/handshake.rs b/src/proxy/websocket/handshake.rs new file mode 100644 index 0000000..404abe1 --- /dev/null +++ b/src/proxy/websocket/handshake.rs @@ -0,0 +1,418 @@ +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, +} diff --git a/src/proxy/websocket/tests.rs b/src/proxy/websocket/tests.rs new file mode 100644 index 0000000..0977c58 --- /dev/null +++ b/src/proxy/websocket/tests.rs @@ -0,0 +1,207 @@ +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_or_else(|_| panic!("static websocket H3 protocol token must parse"))); + 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_or_else(|_| panic!("static connect-udp H3 protocol token must parse"))); + 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/proxy/websocket/tunnel.rs b/src/proxy/websocket/tunnel.rs new file mode 100644 index 0000000..31ae619 --- /dev/null +++ b/src/proxy/websocket/tunnel.rs @@ -0,0 +1,425 @@ +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; + } + } + }); +} diff --git a/src/proxy/websocket/validation.rs b/src/proxy/websocket/validation.rs new file mode 100644 index 0000000..c89de6a --- /dev/null +++ b/src/proxy/websocket/validation.rs @@ -0,0 +1,382 @@ +/// 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; + }, + } + } +} diff --git a/src/rate_limit.rs b/src/rate_limit.rs index b5349fd..ffb5da8 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -291,39 +291,4 @@ impl Limiter { } #[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()); - } -} +include!("rate_limit/tests.rs"); diff --git a/src/rate_limit/tests.rs b/src/rate_limit/tests.rs new file mode 100644 index 0000000..b0c4571 --- /dev/null +++ b/src/rate_limit/tests.rs @@ -0,0 +1,36 @@ +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 index 0dc3115..d82aff5 100644 --- a/src/redirect.rs +++ b/src/redirect.rs @@ -485,109 +485,4 @@ fn join_paths(base: &str, suffix: &str) -> String { } #[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()); - } -} +include!("redirect/tests.rs"); diff --git a/src/redirect/tests.rs b/src/redirect/tests.rs new file mode 100644 index 0000000..48fb587 --- /dev/null +++ b/src/redirect/tests.rs @@ -0,0 +1,106 @@ +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 index 3ee8173..f8d428a 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1,687 +1,8 @@ -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 traffic: 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 { - // Preset fields are UI intent metadata. Persist them, but do not rotate the - // cache compatibility identity (and purge HOT/COLD) when only the label - // changes while the effective cache limits stay identical. - let mut cache = config.cache.clone(); - cache.memory_preset = None; - cache.cold_preset = None; - // Resource-only transform controls decide how much optimization work may - // happen concurrently, but do not change the transformed representation. - // Keep those out of the cache identity while including representation and - // eligibility controls such as enabled kinds, extensions and max body. - let mut transform = config.transform.clone(); - transform.preset = None; - transform.memory_budget_bytes = 0; - transform.max_inflight = 0; - let encoded = serde_json::to_vec(&( - &cache, - &transform, - &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 traffic = crate::traffic::Analytics::start(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()), - traffic, - 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(), - )); - // High request rates create short-lived allocations in Hyper/Tokio and - // cache fill paths. glibc may retain already-free arenas as RSS. Trim - // them periodically without evicting live cache objects or connections. - let trim_shutdown = app.shutdown.clone(); - app.tunnels.spawn(async move { - let mut tick = tokio::time::interval(Duration::from_secs(60)); - tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - tick.tick().await; - loop { - tokio::select! { - _ = trim_shutdown.cancelled() => break, - _ = tick.tick() => { - let _ = tokio::task::spawn_blocking(crate::diagnostics::trim_allocator).await; - } - } - } - }); - 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()); - crate::logging::configure_error_log(envelope.config.error_log.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) - } -} +include!("runtime/types.rs"); +include!("runtime/constructors.rs"); +include!("runtime/updates.rs"); +include!("runtime/certificates.rs"); +include!("runtime/docker.rs"); #[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 preset = base.clone(); - preset.cache.memory_preset = Some(crate::cache::MemoryPreset::Recommended); - preset.cache.cold_preset = Some(crate::cache::ColdPreset::Recommended); - preset.transform.preset = Some(crate::transform::Preset::Uber); - preset.transform.memory_budget_bytes = 1024 * 1024 * 1024; - preset.transform.max_inflight = 128; - assert_eq!(cache_revision(&preset), identity); - assert!(matches!( - cache_invalidation(&base, &preset), - CacheInvalidation::None - )); - - let mut transform_policy = base.clone(); - transform_policy.transform.json = !transform_policy.transform.json; - assert_ne!(cache_revision(&transform_policy), identity); - - let mut transform_body_limit = base.clone(); - transform_body_limit.transform.max_body_bytes += 1024; - assert_ne!(cache_revision(&transform_body_limit), 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); - } -} +include!("runtime/tests.rs"); diff --git a/src/runtime/certificates.rs b/src/runtime/certificates.rs new file mode 100644 index 0000000..f7ec946 --- /dev/null +++ b/src/runtime/certificates.rs @@ -0,0 +1,82 @@ +impl App { + /// 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) + } + +} diff --git a/src/runtime/constructors.rs b/src/runtime/constructors.rs new file mode 100644 index 0000000..af1300d --- /dev/null +++ b/src/runtime/constructors.rs @@ -0,0 +1,226 @@ +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 traffic = crate::traffic::Analytics::start(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()), + traffic, + 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(), + )); + // High request rates create short-lived allocations in Hyper/Tokio and + // cache fill paths. glibc may retain already-free arenas as RSS. Trim + // them periodically without evicting live cache objects or connections. + let trim_shutdown = app.shutdown.clone(); + app.tunnels.spawn(async move { + let mut tick = tokio::time::interval(Duration::from_secs(60)); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + tick.tick().await; + loop { + tokio::select! { + _ = trim_shutdown.cancelled() => break, + _ = tick.tick() => { + let _ = tokio::task::spawn_blocking(crate::diagnostics::trim_allocator).await; + } + } + } + }); + app + } +} diff --git a/src/runtime/docker.rs b/src/runtime/docker.rs new file mode 100644 index 0000000..c5240a3 --- /dev/null +++ b/src/runtime/docker.rs @@ -0,0 +1,53 @@ +impl App { + /// 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) + } +} diff --git a/src/runtime/tests.rs b/src/runtime/tests.rs new file mode 100644 index 0000000..682c8c4 --- /dev/null +++ b/src/runtime/tests.rs @@ -0,0 +1,69 @@ +#[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 preset = base.clone(); + preset.cache.memory_preset = Some(crate::cache::MemoryPreset::Recommended); + preset.cache.cold_preset = Some(crate::cache::ColdPreset::Recommended); + preset.transform.preset = Some(crate::transform::Preset::Uber); + preset.transform.memory_budget_bytes = 1024 * 1024 * 1024; + preset.transform.max_inflight = 128; + assert_eq!(cache_revision(&preset), identity); + assert!(matches!( + cache_invalidation(&base, &preset), + CacheInvalidation::None + )); + + let mut transform_policy = base.clone(); + transform_policy.transform.json = !transform_policy.transform.json; + assert_ne!(cache_revision(&transform_policy), identity); + + let mut transform_body_limit = base.clone(); + transform_body_limit.transform.max_body_bytes += 1024; + assert_ne!(cache_revision(&transform_body_limit), 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/runtime/types.rs b/src/runtime/types.rs new file mode 100644 index 0000000..a02eef3 --- /dev/null +++ b/src/runtime/types.rs @@ -0,0 +1,121 @@ +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 traffic: 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 { + // Preset fields are UI intent metadata. Persist them, but do not rotate the + // cache compatibility identity (and purge HOT/COLD) when only the label + // changes while the effective cache limits stay identical. + let mut cache = config.cache.clone(); + cache.memory_preset = None; + cache.cold_preset = None; + // Resource-only transform controls decide how much optimization work may + // happen concurrently, but do not change the transformed representation. + // Keep those out of the cache identity while including representation and + // eligibility controls such as enabled kinds, extensions and max body. + let mut transform = config.transform.clone(); + transform.preset = None; + transform.memory_budget_bytes = 0; + transform.max_inflight = 0; + let encoded = serde_json::to_vec(&( + &cache, + &transform, + &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) + } +} diff --git a/src/runtime/updates.rs b/src/runtime/updates.rs new file mode 100644 index 0000000..2108cd4 --- /dev/null +++ b/src/runtime/updates.rs @@ -0,0 +1,130 @@ +impl 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()); + crate::logging::configure_error_log(envelope.config.error_log.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(), + ) + })? + } + +} diff --git a/src/secret_store.rs b/src/secret_store.rs index a438974..e9f556b 100644 --- a/src/secret_store.rs +++ b/src/secret_store.rs @@ -59,10 +59,7 @@ fn read_key(path: &Path) -> Result> { metadata.is_file() && !metadata.file_type().is_symlink(), "secret store master key must be a regular file" ); - ensure!( - metadata.len() == KEY_BYTES as u64, - "invalid secret store master key size" - ); + ensure!(metadata.len() == KEY_BYTES as u64, "invalid secret store master key size"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -82,10 +79,7 @@ fn read_key(path: &Path) -> Result> { let mut file = options.open(path)?; file.read_exact(bytes.as_mut())?; let mut extra = [0u8; 1]; - ensure!( - file.read(&mut extra)? == 0, - "invalid secret store master key size" - ); + ensure!(file.read(&mut extra)? == 0, "invalid secret store master key size"); Ok(bytes) } @@ -164,10 +158,7 @@ pub async fn put( metadata: serde_json::Value, ) -> Result<()> { validate_identity(secret_id, provider)?; - ensure!( - !plaintext.is_empty() && plaintext.len() <= MAX_SECRET_BYTES, - "invalid secret size" - ); + ensure!(!plaintext.is_empty() && plaintext.len() <= MAX_SECRET_BYTES, "invalid secret size"); let metadata = serde_json::to_string(&metadata)?; ensure!(metadata.len() <= 4096, "secret metadata is too large"); initialize(data_dir)?; @@ -221,16 +212,12 @@ pub async fn get( } validate_identity(secret_id, &provider)?; let version: i64 = row.try_get("encryption_version")?; - ensure!( - version == ENCRYPTION_VERSION, - "unsupported secret encryption version" - ); + ensure!(version == ENCRYPTION_VERSION, "unsupported secret encryption version"); let nonce: Vec = row.try_get("nonce")?; ensure!(nonce.len() == NONCE_BYTES, "invalid secret nonce"); let mut ciphertext = Zeroizing::new(row.try_get::, _>("ciphertext")?); ensure!( - ciphertext.len() >= AES_256_GCM.tag_len() - && ciphertext.len() <= MAX_SECRET_BYTES + AES_256_GCM.tag_len(), + ciphertext.len() >= AES_256_GCM.tag_len() && ciphertext.len() <= MAX_SECRET_BYTES + AES_256_GCM.tag_len(), "invalid encrypted secret size" ); let mut nonce_bytes = [0u8; NONCE_BYTES]; @@ -300,10 +287,7 @@ pub async fn migrate_acme_credentials( return Ok(0); } let root_meta = fs::symlink_metadata(&root)?; - ensure!( - root_meta.is_dir() && !root_meta.file_type().is_symlink(), - "invalid ACME directory" - ); + ensure!(root_meta.is_dir() && !root_meta.file_type().is_symlink(), "invalid ACME directory"); let mut migrated = 0usize; for entry in fs::read_dir(&root)? { let entry = entry?; @@ -317,10 +301,7 @@ pub async fn migrate_acme_credentials( continue; } let profile: serde_json::Value = serde_json::from_slice(&fs::read(&profile_path)?)?; - let provider = profile - .get("provider") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); + let provider = profile.get("provider").and_then(serde_json::Value::as_str).unwrap_or(""); let certificate_id = profile .get("certificate_id") .and_then(serde_json::Value::as_str) @@ -360,8 +341,7 @@ pub async fn migrate_acme_credentials( ) .await?; plaintext.zeroize(); - fs::remove_file(&credential_path) - .context("legacy ACME credential was encrypted but could not be removed")?; + fs::remove_file(&credential_path).context("legacy ACME credential was encrypted but could not be removed")?; migrated += 1; } Ok(migrated) @@ -392,83 +372,4 @@ pub fn write_restored_master_key(data_dir: &Path, key: &[u8]) -> Result<()> { } #[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn secret_round_trip_is_encrypted_in_sqlite_and_survives_key_reload() { - let dir = tempfile::tempdir().unwrap(); - crate::auth::prepare_directory(dir.path()).unwrap(); - let database = dir.path().join("config.sqlite3"); - crate::auth::prepare_database(&database).unwrap(); - let store = crate::db::Store::open(&database, &crate::config::Config::default()) - .await - .unwrap(); - initialize(dir.path()).unwrap(); - let secret = b"cloudflare-example-token-0123456789"; - put( - &store, - dir.path(), - "provider/cloudflare/demo/api_token", - "cloudflare", - secret, - serde_json::json!({"id":"demo"}), - ) - .await - .unwrap(); - let row: Vec = sqlx::query_scalar( - "SELECT ciphertext FROM secret_store WHERE secret_id='provider/cloudflare/demo/api_token'", - ) - .fetch_one(&store.pool) - .await - .unwrap(); - assert!(!row.windows(secret.len()).any(|window| window == secret)); - let value = get( - &store, - dir.path(), - "provider/cloudflare/demo/api_token", - Some("cloudflare"), - ) - .await - .unwrap(); - assert_eq!(value.as_slice(), secret); - drop(value); - assert_eq!( - read_key(&master_key_path(dir.path())).unwrap().len(), - KEY_BYTES - ); - } - - #[tokio::test] - async fn tampered_ciphertext_is_rejected() { - let dir = tempfile::tempdir().unwrap(); - crate::auth::prepare_directory(dir.path()).unwrap(); - let database = dir.path().join("config.sqlite3"); - crate::auth::prepare_database(&database).unwrap(); - let store = crate::db::Store::open(&database, &crate::config::Config::default()) - .await - .unwrap(); - put( - &store, - dir.path(), - "acme/site/cloudflare/credential", - "cloudflare", - b"secret-value", - serde_json::json!({}), - ) - .await - .unwrap(); - sqlx::query("UPDATE secret_store SET ciphertext = zeroblob(length(ciphertext))") - .execute(&store.pool) - .await - .unwrap(); - assert!(get( - &store, - dir.path(), - "acme/site/cloudflare/credential", - Some("cloudflare") - ) - .await - .is_err()); - } -} +include!("secret_store/tests.rs"); diff --git a/src/secret_store/tests.rs b/src/secret_store/tests.rs new file mode 100644 index 0000000..b6335af --- /dev/null +++ b/src/secret_store/tests.rs @@ -0,0 +1,61 @@ +mod tests { + use super::*; + + #[tokio::test] + async fn secret_round_trip_is_encrypted_in_sqlite_and_survives_key_reload() { + let dir = tempfile::tempdir().unwrap(); + crate::auth::prepare_directory(dir.path()).unwrap(); + let database = dir.path().join("config.sqlite3"); + crate::auth::prepare_database(&database).unwrap(); + let store = crate::db::Store::open(&database, &crate::config::Config::default()) + .await + .unwrap(); + initialize(dir.path()).unwrap(); + let secret = b"cloudflare-example-token-0123456789"; + put( + &store, + dir.path(), + "provider/cloudflare/demo/api_token", + "cloudflare", + secret, + serde_json::json!({"id":"demo"}), + ) + .await + .unwrap(); + let row: Vec = sqlx::query_scalar( + "SELECT ciphertext FROM secret_store WHERE secret_id='provider/cloudflare/demo/api_token'", + ) + .fetch_one(&store.pool) + .await + .unwrap(); + assert!(!row.windows(secret.len()).any(|window| window == secret)); + let value = get( + &store, + dir.path(), + "provider/cloudflare/demo/api_token", + Some("cloudflare"), + ) + .await + .unwrap(); + assert_eq!(value.as_slice(), secret); + drop(value); + assert_eq!(read_key(&master_key_path(dir.path())).unwrap().len(), KEY_BYTES); + } + + #[tokio::test] + async fn tampered_ciphertext_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + crate::auth::prepare_directory(dir.path()).unwrap(); + let database = dir.path().join("config.sqlite3"); + crate::auth::prepare_database(&database).unwrap(); + let store = crate::db::Store::open(&database, &crate::config::Config::default()) + .await + .unwrap(); + put(&store, dir.path(), "acme/site/cloudflare/credential", "cloudflare", b"secret-value", serde_json::json!({})).await.unwrap(); + sqlx::query("UPDATE secret_store SET ciphertext = zeroblob(length(ciphertext))") + .execute(&store.pool) + .await + .unwrap(); + assert!(get(&store, dir.path(), "acme/site/cloudflare/credential", Some("cloudflare")).await.is_err()); + } +} diff --git a/src/security.rs b/src/security.rs index 9fe8312..7c2652c 100644 --- a/src/security.rs +++ b/src/security.rs @@ -113,33 +113,4 @@ impl CompiledSecurity { } } #[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()); - } -} +include!("security/tests.rs"); diff --git a/src/security/tests.rs b/src/security/tests.rs new file mode 100644 index 0000000..d81c94a --- /dev/null +++ b/src/security/tests.rs @@ -0,0 +1,30 @@ +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 index 12cf2cc..fb4f099 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,1610 +1,8 @@ //! 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 http_body::Body as HttpBody; -use hyper::{ - body::Incoming, - server::conn::{http1, http2}, - service::service_fn, - Request, Response, -}; -use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer}; -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(); - } -} +include!("server/listeners.rs"); +include!("server/http.rs"); +include!("server/tls_runtime.rs"); +include!("server/serve.rs"); diff --git a/src/server/http.rs b/src/server/http.rs new file mode 100644 index 0000000..a70c5ac --- /dev/null +++ b/src/server/http.rs @@ -0,0 +1,260 @@ +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 +} diff --git a/src/server/listeners.rs b/src/server/listeners.rs new file mode 100644 index 0000000..355d290 --- /dev/null +++ b/src/server/listeners.rs @@ -0,0 +1,429 @@ +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), + } + } +} diff --git a/src/server/serve.rs b/src/server/serve.rs new file mode 100644 index 0000000..fc3808e --- /dev/null +++ b/src/server/serve.rs @@ -0,0 +1,102 @@ +#[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)] +include!("serve/tests.rs"); diff --git a/src/server/serve/tests.rs b/src/server/serve/tests.rs new file mode 100644 index 0000000..cdea997 --- /dev/null +++ b/src/server/serve/tests.rs @@ -0,0 +1,369 @@ +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, + Http2Connections::default(), + ) + .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, + Http2Connections::default(), + ) + .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/server/tls_runtime.rs b/src/server/tls_runtime.rs new file mode 100644 index 0000000..06a51c5 --- /dev/null +++ b/src/server/tls_runtime.rs @@ -0,0 +1,370 @@ +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, +} diff --git a/src/settings.rs b/src/settings.rs index 0150336..840d240 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -425,252 +425,4 @@ impl ServerHeader { } } #[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); - } -} +include!("settings/tests.rs"); diff --git a/src/settings/tests.rs b/src/settings/tests.rs new file mode 100644 index 0000000..e35e9ee --- /dev/null +++ b/src/settings/tests.rs @@ -0,0 +1,231 @@ +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 index 9174177..cadbc1d 100644 --- a/src/stream.rs +++ b/src/stream.rs @@ -1,591 +1,8 @@ //! 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); - } -} +include!("stream/model.rs"); +include!("stream/runtime.rs"); #[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); - } -} +include!("stream/tests.rs"); diff --git a/src/stream/model.rs b/src/stream/model.rs new file mode 100644 index 0000000..3d95a09 --- /dev/null +++ b/src/stream/model.rs @@ -0,0 +1,399 @@ +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, +} diff --git a/src/stream/runtime.rs b/src/stream/runtime.rs new file mode 100644 index 0000000..225ee9f --- /dev/null +++ b/src/stream/runtime.rs @@ -0,0 +1,125 @@ +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); + } +} diff --git a/src/stream/tests.rs b/src/stream/tests.rs new file mode 100644 index 0000000..7d58732 --- /dev/null +++ b/src/stream/tests.rs @@ -0,0 +1,62 @@ +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/linux.rs b/src/system_stats/linux.rs index 5e76591..ef86a29 100644 --- a/src/system_stats/linux.rs +++ b/src/system_stats/linux.rs @@ -67,18 +67,4 @@ pub(crate) fn augment(view: &mut View) { } #[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); - } -} +include!("linux/tests.rs"); diff --git a/src/system_stats/linux/tests.rs b/src/system_stats/linux/tests.rs new file mode 100644 index 0000000..a5233a8 --- /dev/null +++ b/src/system_stats/linux/tests.rs @@ -0,0 +1,15 @@ +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 index 5df6110..920be6d 100644 --- a/src/system_stats/mod.rs +++ b/src/system_stats/mod.rs @@ -417,94 +417,4 @@ pub(crate) fn now_ms() -> u64 { 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)); - } - } -} +include!("tests.rs"); diff --git a/src/system_stats/tests.rs b/src/system_stats/tests.rs new file mode 100644 index 0000000..7552369 --- /dev/null +++ b/src/system_stats/tests.rs @@ -0,0 +1,91 @@ +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 index 8e44e24..de8770a 100644 --- a/src/tinyauth.rs +++ b/src/tinyauth.rs @@ -389,124 +389,4 @@ impl Identity { } } #[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()); - } -} +include!("tinyauth/tests.rs"); diff --git a/src/tinyauth/tests.rs b/src/tinyauth/tests.rs new file mode 100644 index 0000000..ae347d3 --- /dev/null +++ b/src/tinyauth/tests.rs @@ -0,0 +1,121 @@ +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 index e2c54b3..96127f4 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -2,1737 +2,12 @@ //! 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, - }) -} +include!("tls/types.rs"); +include!("tls/server_config.rs"); +include!("tls/certificates.rs"); #[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()); - } -} - +include!("tls/tests.rs"); #[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()); - } -} +include!("tls/tests/acme.rs"); diff --git a/src/tls/certificates.rs b/src/tls/certificates.rs new file mode 100644 index 0000000..f8b9ff8 --- /dev/null +++ b/src/tls/certificates.rs @@ -0,0 +1,417 @@ +/// 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, + }) +} diff --git a/src/tls/server_config.rs b/src/tls/server_config.rs new file mode 100644 index 0000000..6ddf304 --- /dev/null +++ b/src/tls/server_config.rs @@ -0,0 +1,418 @@ +/// 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) +} diff --git a/src/tls/tests.rs b/src/tls/tests.rs new file mode 100644 index 0000000..3dbb577 --- /dev/null +++ b/src/tls/tests.rs @@ -0,0 +1,429 @@ +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()); + } +} diff --git a/src/tls/tests/acme.rs b/src/tls/tests/acme.rs new file mode 100644 index 0000000..873909b --- /dev/null +++ b/src/tls/tests/acme.rs @@ -0,0 +1,77 @@ +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/tls/types.rs b/src/tls/types.rs new file mode 100644 index 0000000..daa5866 --- /dev/null +++ b/src/tls/types.rs @@ -0,0 +1,392 @@ +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)) +} diff --git a/src/traffic.rs b/src/traffic.rs index 0bc60b2..d4c46fc 100644 --- a/src/traffic.rs +++ b/src/traffic.rs @@ -114,8 +114,7 @@ impl Counters { self.response_bytes = self.response_bytes.saturating_add(sample.response_bytes); self.methods.add(&sample.method, 1); self.statuses.add(&format!("{}xx", sample.status / 100), 1); - self.routes - .add(sample.route.as_deref().unwrap_or("(main)"), 1); + self.routes.add(sample.route.as_deref().unwrap_or("(main)"), 1); self.paths.add(&sample.path, 1); self.cache.add(sample.cache, 1); if let Some(country) = sample.country.as_deref() { @@ -150,8 +149,7 @@ impl Counters { duration_ms: self.duration_ms, request_bytes: self.request_bytes, response_bytes: self.response_bytes, - mean_duration_ms: (self.requests > 0) - .then(|| self.duration_ms as f64 / self.requests as f64), + mean_duration_ms: (self.requests > 0).then(|| self.duration_ms as f64 / self.requests as f64), countries: self.countries.view(), user_agents: self.user_agents.view(), client_ips: self.client_ips.view(), @@ -241,12 +239,7 @@ impl Analytics { } total.view() } else { - state - .vhosts - .get(selected) - .cloned() - .unwrap_or_default() - .view() + state.vhosts.get(selected).cloned().unwrap_or_default().view() }; View { scope: selected.to_owned(), @@ -271,12 +264,8 @@ impl Analytics { let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); match scope.filter(|value| !value.is_empty() && *value != "*") { Some(scope) => { - if state.vhosts.remove(scope).is_some() { - 1 - } else { - 0 - } - } + if state.vhosts.remove(scope).is_some() { 1 } else { 0 } + }, None => { let count = state.vhosts.len(); state.vhosts.clear(); @@ -298,46 +287,4 @@ fn truncate(value: &str, max_bytes: usize) -> String { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn top_table_is_bounded() { - let mut table = TopTable::default(); - for i in 0..1000 { - table.add(&format!("value-{i}"), 1); - } - assert_eq!(table.values.len(), TOP_CAPACITY); - } - - #[test] - fn labels_are_truncated_at_utf8_boundaries() { - let value = "ą".repeat(200); - let trimmed = truncate(&value, 255); - assert!(trimmed.len() <= 255); - assert!(trimmed.is_char_boundary(trimmed.len())); - } - - #[test] - fn client_ip_is_exposed_in_the_bounded_top_view() { - let mut counters = Counters::default(); - counters.record(Sample { - vhost: "site".into(), - route: None, - method: "GET".into(), - path: "/".into(), - client_ip: Some("203.0.113.7".into()), - status: 200, - duration_ms: 2, - request_bytes: 10, - response_bytes: 20, - cache: "MISS", - user_agent: Some("test-agent".into()), - country: Some("PL".into()), - }); - let view = counters.view(); - assert_eq!(view.client_ips.len(), 1); - assert_eq!(view.client_ips[0].value, "203.0.113.7"); - assert_eq!(view.client_ips[0].count, 1); - } -} +include!("traffic/tests.rs"); diff --git a/src/traffic/tests.rs b/src/traffic/tests.rs new file mode 100644 index 0000000..50c4f35 --- /dev/null +++ b/src/traffic/tests.rs @@ -0,0 +1,43 @@ +mod tests { + use super::*; + + #[test] + fn top_table_is_bounded() { + let mut table = TopTable::default(); + for i in 0..1000 { + table.add(&format!("value-{i}"), 1); + } + assert_eq!(table.values.len(), TOP_CAPACITY); + } + + #[test] + fn labels_are_truncated_at_utf8_boundaries() { + let value = "ą".repeat(200); + let trimmed = truncate(&value, 255); + assert!(trimmed.len() <= 255); + assert!(trimmed.is_char_boundary(trimmed.len())); + } + + #[test] + fn client_ip_is_exposed_in_the_bounded_top_view() { + let mut counters = Counters::default(); + counters.record(Sample { + vhost: "site".into(), + route: None, + method: "GET".into(), + path: "/".into(), + client_ip: Some("203.0.113.7".into()), + status: 200, + duration_ms: 2, + request_bytes: 10, + response_bytes: 20, + cache: "MISS", + user_agent: Some("test-agent".into()), + country: Some("PL".into()), + }); + let view = counters.view(); + assert_eq!(view.client_ips.len(), 1); + assert_eq!(view.client_ips[0].value, "203.0.113.7"); + assert_eq!(view.client_ips[0].count, 1); + } +} diff --git a/src/transform/apply.rs b/src/transform/apply.rs new file mode 100644 index 0000000..433e946 --- /dev/null +++ b/src/transform/apply.rs @@ -0,0 +1,167 @@ +/// 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 Some(required_memory) = + transform_memory_required_bytes(content_length, encoding, settings.max_body_bytes) + else { + return Ok(skip(response, Some(kind), "memory_budget")); + }; + let Some(_inflight_slot) = try_reserve_transform_slot(settings.max_inflight) else { + return Ok(skip(response, Some(kind), "concurrency_pressure")); + }; + let Some(_memory_reservation) = + try_reserve_transform_memory(required_memory, settings.memory_budget_bytes) + else { + // Transformations are an optimization. Under concurrent allocation + // pressure, preserve streaming/origin bytes instead of queueing more + // full-response buffers behind the memory budget. + return Ok(skip(response, Some(kind), "memory_pressure")); + }; + + let (mut parts, body) = response.into_parts(); + // Do not trust Content-Length as a memory bound. A broken or malicious + // upstream can send more bytes than declared; Limited stops aggregation at + // the configured ceiling instead of letting BodyExt::collect grow without + // bound. Once aggregation starts an over-limit stream cannot be replayed + // safely, so it is treated like the other body/transport collection errors. + let raw = Limited::new(body, settings.max_body_bytes) + .collect() + .await + .map_err(|error| { + anyhow::anyhow!( + "collect bounded static response for transformation: {error}" + ) + })? + .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))) +} diff --git a/src/transform/compile.rs b/src/transform/compile.rs new file mode 100644 index 0000000..fd11ac1 --- /dev/null +++ b/src/transform/compile.rs @@ -0,0 +1,346 @@ +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(mut reader: impl Read, limit: usize, label: &'static str) -> Result> { + let mut output = Vec::with_capacity(limit.min(64 * 1024)); + let mut chunk = [0u8; 16 * 1024]; + loop { + let read = reader + .read(&mut chunk) + .with_context(|| format!("decode {label}"))?; + if read == 0 { + break; + } + ensure!( + output.len().saturating_add(read) <= limit, + "decoded transform body exceeds configured limit" + ); + if output.capacity().saturating_sub(output.len()) < read { + output + .try_reserve_exact(read) + .map_err(|_| anyhow::anyhow!("decoded transform allocation failed"))?; + } + output.extend_from_slice(&chunk[..read]); + } + 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 value = String::with_capacity(76); + value.push_str("\"rustproxy-"); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(&mut value, "{byte:02x}"); + } + value.push('"'); + HeaderValue::from_str(&value) + .expect("sha256 validator is always a valid header") +} + +fn transform_memory_required_bytes( + content_length: usize, + encoding: Option<&str>, + max_body_bytes: usize, +) -> Option { + let estimated = if encoding.is_none() { + content_length.checked_mul(2)? + } else { + content_length.checked_add(max_body_bytes.checked_mul(2)?)? + }; + let units = estimated.checked_add(TRANSFORM_MEMORY_UNIT_BYTES - 1)? + / TRANSFORM_MEMORY_UNIT_BYTES; + units.max(1).checked_mul(TRANSFORM_MEMORY_UNIT_BYTES) +} + +struct TransformMemoryReservation<'a> { + counter: &'a AtomicUsize, + bytes: usize, +} + +impl Drop for TransformMemoryReservation<'_> { + fn drop(&mut self) { + self.counter.fetch_sub(self.bytes, Ordering::AcqRel); + } +} + +fn try_reserve_memory_from( + counter: &AtomicUsize, + required_bytes: usize, + budget_bytes: usize, +) -> Option> { + if required_bytes > budget_bytes { + return None; + } + let mut current = counter.load(Ordering::Acquire); + loop { + let next = current.checked_add(required_bytes)?; + if next > budget_bytes { + return None; + } + match counter.compare_exchange_weak( + current, + next, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => { + return Some(TransformMemoryReservation { + counter, + bytes: required_bytes, + }) + } + Err(actual) => current = actual, + } + } +} + +fn try_reserve_transform_memory( + required_bytes: usize, + budget_bytes: usize, +) -> Option> { + try_reserve_memory_from( + &TRANSFORM_MEMORY_IN_USE_BYTES, + required_bytes, + budget_bytes, + ) +} + +struct TransformInflightReservation<'a> { + counter: &'a AtomicUsize, +} + +impl Drop for TransformInflightReservation<'_> { + fn drop(&mut self) { + self.counter.fetch_sub(1, Ordering::AcqRel); + } +} + +fn try_reserve_slot_from( + counter: &AtomicUsize, + max_inflight: usize, +) -> Option> { + let mut current = counter.load(Ordering::Acquire); + loop { + if current >= max_inflight { + return None; + } + let next = current.checked_add(1)?; + match counter.compare_exchange_weak( + current, + next, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return Some(TransformInflightReservation { counter }), + Err(actual) => current = actual, + } + } +} + +fn try_reserve_transform_slot( + max_inflight: usize, +) -> Option> { + try_reserve_slot_from(&TRANSFORM_INFLIGHT, max_inflight) +} diff --git a/src/transform/html.rs b/src/transform/html.rs index 8fe3f57..f2eabe1 100644 --- a/src/transform/html.rs +++ b/src/transform/html.rs @@ -126,24 +126,4 @@ fn raw_tag(tag: &str) -> Option<(RawElement, bool, bool)> { } #[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); - } -} +include!("html/tests.rs"); diff --git a/src/transform/html/tests.rs b/src/transform/html/tests.rs new file mode 100644 index 0000000..aa76b7a --- /dev/null +++ b/src/transform/html/tests.rs @@ -0,0 +1,21 @@ +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 index 62315df..0b55561 100644 --- a/src/transform/js.rs +++ b/src/transform/js.rs @@ -120,21 +120,4 @@ pub fn minify(body: &[u8]) -> Result> { } #[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"); - } -} +include!("js/tests.rs"); diff --git a/src/transform/js/tests.rs b/src/transform/js/tests.rs new file mode 100644 index 0000000..9c0af32 --- /dev/null +++ b/src/transform/js/tests.rs @@ -0,0 +1,18 @@ +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 index 9d5ff95..7fd4ae8 100644 --- a/src/transform/json.rs +++ b/src/transform/json.rs @@ -35,20 +35,4 @@ pub fn compact(body: &[u8]) -> Result> { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn compacts_without_rewriting_values_or_duplicate_keys() { - let input = br#"{ "n": 1.2300e+02, "s": "a b\\n c", "k": 1, "k": 2 }"#; - assert_eq!( - compact(input).unwrap(), - br#"{"n":1.2300e+02,"s":"a b\\n c","k":1,"k":2}"# - ); - } - - #[test] - fn rejects_invalid_json_instead_of_stripping_whitespace_blindly() { - assert!(compact(br#"{"a": 1,}"#).is_err()); - } -} +include!("json/tests.rs"); diff --git a/src/transform/json/tests.rs b/src/transform/json/tests.rs new file mode 100644 index 0000000..605e63b --- /dev/null +++ b/src/transform/json/tests.rs @@ -0,0 +1,17 @@ +mod tests { + use super::*; + + #[test] + fn compacts_without_rewriting_values_or_duplicate_keys() { + let input = br#"{ "n": 1.2300e+02, "s": "a b\\n c", "k": 1, "k": 2 }"#; + assert_eq!( + compact(input).unwrap(), + br#"{"n":1.2300e+02,"s":"a b\\n c","k":1,"k":2}"# + ); + } + + #[test] + fn rejects_invalid_json_instead_of_stripping_whitespace_blindly() { + assert!(compact(br#"{"a": 1,}"#).is_err()); + } +} diff --git a/src/transform/mod.rs b/src/transform/mod.rs index 5331085..1cdf433 100644 --- a/src/transform/mod.rs +++ b/src/transform/mod.rs @@ -1,1340 +1,9 @@ //! 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, Limited}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::borrow::Cow; -use std::collections::BTreeMap; -use std::io::Read; -use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Mutex, -}; -use std::time::Duration; -mod css; -mod html; -mod js; -mod json; -mod svg; - -const DEFAULT_MAX_BODY_BYTES: usize = 2 * 1024 * 1024; -const DEFAULT_MEMORY_BUDGET_BYTES: usize = 128 * 1024 * 1024; -const DEFAULT_MAX_INFLIGHT: usize = 32; -const TRANSFORM_MEMORY_UNIT_BYTES: usize = 64 * 1024; -static TRANSFORM_MEMORY_IN_USE_BYTES: AtomicUsize = AtomicUsize::new(0); -static TRANSFORM_INFLIGHT: AtomicUsize = AtomicUsize::new(0); -#[cfg(all(target_os = "linux", target_env = "gnu"))] -const ALLOCATOR_TRIM_MIN_BODY_BYTES: usize = 256 * 1024; - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum Preset { - Conservative, - Recommended, - Performance, - Uber, -} - -/// 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, - /// Last resource profile explicitly selected in the administrator UI. The - /// numeric limits below are authoritative; this field only preserves UI - /// intent across reloads. - pub preset: Option, - 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, - /// Process-wide ceiling for estimated temporary response buffers owned by - /// active transformations. This is a limit, not a pre-allocation. - pub memory_budget_bytes: usize, - /// Process-wide number of transformation attempts allowed to own an active - /// transform slot at the same time. Excess work fails open unchanged. - pub max_inflight: usize, -} -impl Default for Settings { - fn default() -> Self { - Self { - enabled: false, - preset: None, - 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, - memory_budget_bytes: DEFAULT_MEMORY_BUDGET_BYTES, - max_inflight: DEFAULT_MAX_INFLIGHT, - } - } -} -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!( - (1024 * 1024..=2 * 1024 * 1024 * 1024).contains(&self.memory_budget_bytes), - "transform.memory_budget_bytes must be 1 MiB..2 GiB" - ); - ensure!( - (1..=1024).contains(&self.max_inflight), - "transform.max_inflight must be 1..1024" - ); - 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(Debug, Clone, Copy, Serialize)] -pub struct ResourceUsage { - /// Estimated temporary transform bytes currently reserved by active work. - pub memory_reserved_bytes: usize, - pub memory_budget_bytes: usize, - pub inflight: usize, - pub max_inflight: usize, -} - -pub fn resource_usage(settings: &Settings) -> ResourceUsage { - ResourceUsage { - memory_reserved_bytes: TRANSFORM_MEMORY_IN_USE_BYTES.load(Ordering::Acquire), - memory_budget_bytes: settings.memory_budget_bytes, - inflight: TRANSFORM_INFLIGHT.load(Ordering::Acquire), - max_inflight: settings.max_inflight, - } -} - -#[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(mut reader: impl Read, limit: usize, label: &'static str) -> Result> { - let mut output = Vec::with_capacity(limit.min(64 * 1024)); - let mut chunk = [0u8; 16 * 1024]; - loop { - let read = reader - .read(&mut chunk) - .with_context(|| format!("decode {label}"))?; - if read == 0 { - break; - } - ensure!( - output.len().saturating_add(read) <= limit, - "decoded transform body exceeds configured limit" - ); - if output.capacity().saturating_sub(output.len()) < read { - output - .try_reserve_exact(read) - .map_err(|_| anyhow::anyhow!("decoded transform allocation failed"))?; - } - output.extend_from_slice(&chunk[..read]); - } - 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 value = String::with_capacity(76); - value.push_str("\"rustproxy-"); - for byte in digest { - use std::fmt::Write as _; - let _ = write!(&mut value, "{byte:02x}"); - } - value.push('"'); - HeaderValue::from_str(&value) - .expect("sha256 validator is always a valid header") -} - -fn transform_memory_required_bytes( - content_length: usize, - encoding: Option<&str>, - max_body_bytes: usize, -) -> Option { - let estimated = if encoding.is_none() { - content_length.checked_mul(2)? - } else { - content_length.checked_add(max_body_bytes.checked_mul(2)?)? - }; - let units = estimated.checked_add(TRANSFORM_MEMORY_UNIT_BYTES - 1)? - / TRANSFORM_MEMORY_UNIT_BYTES; - units.max(1).checked_mul(TRANSFORM_MEMORY_UNIT_BYTES) -} - -struct TransformMemoryReservation<'a> { - counter: &'a AtomicUsize, - bytes: usize, -} - -impl Drop for TransformMemoryReservation<'_> { - fn drop(&mut self) { - self.counter.fetch_sub(self.bytes, Ordering::AcqRel); - } -} - -fn try_reserve_memory_from( - counter: &AtomicUsize, - required_bytes: usize, - budget_bytes: usize, -) -> Option> { - if required_bytes > budget_bytes { - return None; - } - let mut current = counter.load(Ordering::Acquire); - loop { - let next = current.checked_add(required_bytes)?; - if next > budget_bytes { - return None; - } - match counter.compare_exchange_weak( - current, - next, - Ordering::AcqRel, - Ordering::Acquire, - ) { - Ok(_) => { - return Some(TransformMemoryReservation { - counter, - bytes: required_bytes, - }) - } - Err(actual) => current = actual, - } - } -} - -fn try_reserve_transform_memory( - required_bytes: usize, - budget_bytes: usize, -) -> Option> { - try_reserve_memory_from( - &TRANSFORM_MEMORY_IN_USE_BYTES, - required_bytes, - budget_bytes, - ) -} - -struct TransformInflightReservation<'a> { - counter: &'a AtomicUsize, -} - -impl Drop for TransformInflightReservation<'_> { - fn drop(&mut self) { - self.counter.fetch_sub(1, Ordering::AcqRel); - } -} - -fn try_reserve_slot_from( - counter: &AtomicUsize, - max_inflight: usize, -) -> Option> { - let mut current = counter.load(Ordering::Acquire); - loop { - if current >= max_inflight { - return None; - } - let next = current.checked_add(1)?; - match counter.compare_exchange_weak( - current, - next, - Ordering::AcqRel, - Ordering::Acquire, - ) { - Ok(_) => return Some(TransformInflightReservation { counter }), - Err(actual) => current = actual, - } - } -} - -fn try_reserve_transform_slot( - max_inflight: usize, -) -> Option> { - try_reserve_slot_from(&TRANSFORM_INFLIGHT, max_inflight) -} - -/// 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 Some(required_memory) = - transform_memory_required_bytes(content_length, encoding, settings.max_body_bytes) - else { - return Ok(skip(response, Some(kind), "memory_budget")); - }; - let Some(_inflight_slot) = try_reserve_transform_slot(settings.max_inflight) else { - return Ok(skip(response, Some(kind), "concurrency_pressure")); - }; - let Some(_memory_reservation) = - try_reserve_transform_memory(required_memory, settings.memory_budget_bytes) - else { - // Transformations are an optimization. Under concurrent allocation - // pressure, preserve streaming/origin bytes instead of queueing more - // full-response buffers behind the memory budget. - return Ok(skip(response, Some(kind), "memory_pressure")); - }; - - let (mut parts, body) = response.into_parts(); - // Do not trust Content-Length as a memory bound. A broken or malicious - // upstream can send more bytes than declared; Limited stops aggregation at - // the configured ceiling instead of letting BodyExt::collect grow without - // bound. Once aggregation starts an over-limit stream cannot be replayed - // safely, so it is treated like the other body/transport collection errors. - let raw = Limited::new(body, settings.max_body_bytes) - .collect() - .await - .map_err(|error| { - anyhow::anyhow!( - "collect bounded static response for transformation: {error}" - ) - })? - .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))) -} +include!("model.rs"); +include!("compile.rs"); +include!("apply.rs"); #[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 transform_memory_budget_accounts_for_identity_and_decoded_buffers() { - assert_eq!( - transform_memory_required_bytes(2 * 1024 * 1024, None, 2 * 1024 * 1024), - Some(4 * 1024 * 1024) - ); - assert_eq!( - transform_memory_required_bytes( - 2 * 1024 * 1024, - Some("gzip"), - 2 * 1024 * 1024 - ), - Some(6 * 1024 * 1024) - ); - assert_eq!( - transform_memory_required_bytes( - DEFAULT_MEMORY_BUDGET_BYTES, - Some("gzip"), - DEFAULT_MEMORY_BUDGET_BYTES - ), - Some(3 * DEFAULT_MEMORY_BUDGET_BYTES) - ); - } - - #[test] - fn transform_memory_reservation_obeys_runtime_budget() { - let counter = AtomicUsize::new(0); - let required = 4 * 1024 * 1024; - let reservation = try_reserve_memory_from(&counter, required, required).unwrap(); - assert!(try_reserve_memory_from(&counter, required, required).is_none()); - drop(reservation); - assert!(try_reserve_memory_from(&counter, required, required).is_some()); - } - - #[test] - fn transform_inflight_reservation_obeys_runtime_limit() { - let counter = AtomicUsize::new(0); - let slot = try_reserve_slot_from(&counter, 1).unwrap(); - assert!(try_reserve_slot_from(&counter, 1).is_none()); - drop(slot); - assert!(try_reserve_slot_from(&counter, 1).is_some()); - } - - #[test] - fn resource_usage_exposes_configured_limits() { - let settings = Settings { - memory_budget_bytes: 512 * 1024 * 1024, - max_inflight: 64, - ..Default::default() - }; - let usage = resource_usage(&settings); - assert_eq!(usage.memory_budget_bytes, 512 * 1024 * 1024); - assert_eq!(usage.max_inflight, 64); - } - - #[test] - fn resource_preset_metadata_round_trips() { - let mut settings = Settings::default(); - settings.preset = Some(Preset::Uber); - settings.memory_budget_bytes = 1024 * 1024 * 1024; - settings.max_body_bytes = 32 * 1024 * 1024; - settings.max_inflight = 128; - let encoded = serde_json::to_value(&settings).unwrap(); - assert_eq!(encoded["preset"], "uber"); - let decoded: Settings = serde_json::from_value(encoded).unwrap(); - assert_eq!(decoded.preset, Some(Preset::Uber)); - assert_eq!(decoded.memory_budget_bytes, 1024 * 1024 * 1024); - assert_eq!(decoded.max_inflight, 128); - } - - #[test] - fn legacy_transform_settings_receive_resource_defaults() { - let decoded: Settings = serde_json::from_str(r#"{"enabled":true}"#).unwrap(); - assert!(decoded.enabled); - assert_eq!(decoded.preset, None); - assert_eq!(decoded.memory_budget_bytes, DEFAULT_MEMORY_BUDGET_BYTES); - assert_eq!(decoded.max_body_bytes, DEFAULT_MAX_BODY_BYTES); - assert_eq!(decoded.max_inflight, DEFAULT_MAX_INFLIGHT); - } - - #[test] - fn bounded_decoder_rejects_expansion_past_limit() { - use std::io::Write as _; - let identity = vec![b'x'; 128 * 1024]; - let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); - encoder.write_all(&identity).unwrap(); - let encoded = encoder.finish().unwrap(); - assert!(decode(&encoded, Some("gzip"), 64 * 1024).is_err()); - } - - #[tokio::test] - async fn aggregation_is_bounded_even_when_content_length_is_false() { - let settings = Settings { - enabled: true, - json: true, - max_body_bytes: 1024, - ..Default::default() - }; - let mut response = Response::new(Body::from(vec![b' '; 2048])); - response - .headers_mut() - .insert("content-type", HeaderValue::from_static("application/json")); - response - .headers_mut() - .insert("content-length", HeaderValue::from_static("128")); - assert!(apply(response, "/data.json", &settings, false).await.is_err()); - } - - #[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()); - } - -} +include!("tests.rs"); diff --git a/src/transform/model.rs b/src/transform/model.rs new file mode 100644 index 0000000..566138e --- /dev/null +++ b/src/transform/model.rs @@ -0,0 +1,426 @@ +use anyhow::{ensure, Context as _, Result}; +use axum::{body::Body, response::Response}; +use http::{HeaderMap, HeaderValue}; +use http_body_util::{BodyExt, Limited}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::io::Read; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Mutex, +}; +use std::time::Duration; + +mod css; +mod html; +mod js; +mod json; +mod svg; + +const DEFAULT_MAX_BODY_BYTES: usize = 2 * 1024 * 1024; +const DEFAULT_MEMORY_BUDGET_BYTES: usize = 128 * 1024 * 1024; +const DEFAULT_MAX_INFLIGHT: usize = 32; +const TRANSFORM_MEMORY_UNIT_BYTES: usize = 64 * 1024; +static TRANSFORM_MEMORY_IN_USE_BYTES: AtomicUsize = AtomicUsize::new(0); +static TRANSFORM_INFLIGHT: AtomicUsize = AtomicUsize::new(0); +#[cfg(all(target_os = "linux", target_env = "gnu"))] +const ALLOCATOR_TRIM_MIN_BODY_BYTES: usize = 256 * 1024; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Preset { + Conservative, + Recommended, + Performance, + Uber, +} + +/// 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, + /// Last resource profile explicitly selected in the administrator UI. The + /// numeric limits below are authoritative; this field only preserves UI + /// intent across reloads. + pub preset: Option, + 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, + /// Process-wide ceiling for estimated temporary response buffers owned by + /// active transformations. This is a limit, not a pre-allocation. + pub memory_budget_bytes: usize, + /// Process-wide number of transformation attempts allowed to own an active + /// transform slot at the same time. Excess work fails open unchanged. + pub max_inflight: usize, +} +impl Default for Settings { + fn default() -> Self { + Self { + enabled: false, + preset: None, + 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, + memory_budget_bytes: DEFAULT_MEMORY_BUDGET_BYTES, + max_inflight: DEFAULT_MAX_INFLIGHT, + } + } +} +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!( + (1024 * 1024..=2 * 1024 * 1024 * 1024).contains(&self.memory_budget_bytes), + "transform.memory_budget_bytes must be 1 MiB..2 GiB" + ); + ensure!( + (1..=1024).contains(&self.max_inflight), + "transform.max_inflight must be 1..1024" + ); + 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(Debug, Clone, Copy, Serialize)] +pub struct ResourceUsage { + /// Estimated temporary transform bytes currently reserved by active work. + pub memory_reserved_bytes: usize, + pub memory_budget_bytes: usize, + pub inflight: usize, + pub max_inflight: usize, +} + +pub fn resource_usage(settings: &Settings) -> ResourceUsage { + ResourceUsage { + memory_reserved_bytes: TRANSFORM_MEMORY_IN_USE_BYTES.load(Ordering::Acquire), + memory_budget_bytes: settings.memory_budget_bytes, + inflight: TRANSFORM_INFLIGHT.load(Ordering::Acquire), + max_inflight: settings.max_inflight, + } +} + +#[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; diff --git a/src/transform/svg.rs b/src/transform/svg.rs index 840dceb..8926def 100644 --- a/src/transform/svg.rs +++ b/src/transform/svg.rs @@ -51,21 +51,4 @@ fn find_bytes(haystack: &[u8], start: usize, needle: &[u8]) -> Option { } #[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-->" - ); - } -} +include!("svg/tests.rs"); diff --git a/src/transform/svg/tests.rs b/src/transform/svg/tests.rs new file mode 100644 index 0000000..a83d662 --- /dev/null +++ b/src/transform/svg/tests.rs @@ -0,0 +1,18 @@ +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/transform/tests.rs b/src/transform/tests.rs new file mode 100644 index 0000000..a179f70 --- /dev/null +++ b/src/transform/tests.rs @@ -0,0 +1,395 @@ +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 transform_memory_budget_accounts_for_identity_and_decoded_buffers() { + assert_eq!( + transform_memory_required_bytes(2 * 1024 * 1024, None, 2 * 1024 * 1024), + Some(4 * 1024 * 1024) + ); + assert_eq!( + transform_memory_required_bytes( + 2 * 1024 * 1024, + Some("gzip"), + 2 * 1024 * 1024 + ), + Some(6 * 1024 * 1024) + ); + assert_eq!( + transform_memory_required_bytes( + DEFAULT_MEMORY_BUDGET_BYTES, + Some("gzip"), + DEFAULT_MEMORY_BUDGET_BYTES + ), + Some(3 * DEFAULT_MEMORY_BUDGET_BYTES) + ); + } + + #[test] + fn transform_memory_reservation_obeys_runtime_budget() { + let counter = AtomicUsize::new(0); + let required = 4 * 1024 * 1024; + let reservation = try_reserve_memory_from(&counter, required, required).unwrap(); + assert!(try_reserve_memory_from(&counter, required, required).is_none()); + drop(reservation); + assert!(try_reserve_memory_from(&counter, required, required).is_some()); + } + + #[test] + fn transform_inflight_reservation_obeys_runtime_limit() { + let counter = AtomicUsize::new(0); + let slot = try_reserve_slot_from(&counter, 1).unwrap(); + assert!(try_reserve_slot_from(&counter, 1).is_none()); + drop(slot); + assert!(try_reserve_slot_from(&counter, 1).is_some()); + } + + #[test] + fn resource_usage_exposes_configured_limits() { + let settings = Settings { + memory_budget_bytes: 512 * 1024 * 1024, + max_inflight: 64, + ..Default::default() + }; + let usage = resource_usage(&settings); + assert_eq!(usage.memory_budget_bytes, 512 * 1024 * 1024); + assert_eq!(usage.max_inflight, 64); + } + + #[test] + fn resource_preset_metadata_round_trips() { + let mut settings = Settings::default(); + settings.preset = Some(Preset::Uber); + settings.memory_budget_bytes = 1024 * 1024 * 1024; + settings.max_body_bytes = 32 * 1024 * 1024; + settings.max_inflight = 128; + let encoded = serde_json::to_value(&settings).unwrap(); + assert_eq!(encoded["preset"], "uber"); + let decoded: Settings = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded.preset, Some(Preset::Uber)); + assert_eq!(decoded.memory_budget_bytes, 1024 * 1024 * 1024); + assert_eq!(decoded.max_inflight, 128); + } + + #[test] + fn legacy_transform_settings_receive_resource_defaults() { + let decoded: Settings = serde_json::from_str(r#"{"enabled":true}"#).unwrap(); + assert!(decoded.enabled); + assert_eq!(decoded.preset, None); + assert_eq!(decoded.memory_budget_bytes, DEFAULT_MEMORY_BUDGET_BYTES); + assert_eq!(decoded.max_body_bytes, DEFAULT_MAX_BODY_BYTES); + assert_eq!(decoded.max_inflight, DEFAULT_MAX_INFLIGHT); + } + + #[test] + fn bounded_decoder_rejects_expansion_past_limit() { + use std::io::Write as _; + let identity = vec![b'x'; 128 * 1024]; + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&identity).unwrap(); + let encoded = encoder.finish().unwrap(); + assert!(decode(&encoded, Some("gzip"), 64 * 1024).is_err()); + } + + #[tokio::test] + async fn aggregation_is_bounded_even_when_content_length_is_false() { + let settings = Settings { + enabled: true, + json: true, + max_body_bytes: 1024, + ..Default::default() + }; + let mut response = Response::new(Body::from(vec![b' '; 2048])); + response + .headers_mut() + .insert("content-type", HeaderValue::from_static("application/json")); + response + .headers_mut() + .insert("content-length", HeaderValue::from_static("128")); + assert!(apply(response, "/data.json", &settings, false).await.is_err()); + } + + #[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/update_check.rs b/src/update_check.rs index be694a2..ffec527 100644 --- a/src/update_check.rs +++ b/src/update_check.rs @@ -69,10 +69,11 @@ impl Config { "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" - })?) + interval_seconds = Some( + value + .parse::() + .with_context(|| "UPDATE_CHECK.toml interval_seconds must be an integer")?, + ) } _ => bail!("UPDATE_CHECK.toml contains unsupported key {key}"), } @@ -99,14 +100,14 @@ impl Config { if let Some(value) = interval_seconds { self.interval_seconds = value; } - Self::validated(self.repository_url, self.version_url, self.interval_seconds) + Self::validated( + self.repository_url, + self.version_url, + self.interval_seconds, + ) } - fn validated( - repository_url: String, - version_url: String, - interval_seconds: u64, - ) -> Result { + 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!( @@ -175,10 +176,7 @@ impl Default for SharedStatus { impl SharedStatus { pub fn configure(&self, config: &Config) { - let mut view = self - .view - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut view = self.view.write().unwrap_or_else(|poisoned| poisoned.into_inner()); view.repository_url = Some(config.repository_url.clone()); } @@ -190,10 +188,7 @@ impl SharedStatus { } fn record_success(&self, latest_version: String) { - let mut view = self - .view - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()); + 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); } @@ -231,11 +226,12 @@ async fn check_once(config: &Config) -> Result { async fn fetch(url: &str) -> Result { let roots = load_root_store(); - 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 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)); @@ -291,10 +287,13 @@ async fn fetch(url: &str) -> Result { .collect() .await .map_err(|error| { - anyhow::anyhow!("update version document is too large or incomplete: {error}") + anyhow::anyhow!( + "update version document is too large or incomplete: {error}" + ) })? .to_bytes(); - return String::from_utf8(body.to_vec()).context("update version document is not UTF-8"); + return String::from_utf8(body.to_vec()) + .context("update version document is not UTF-8"); } unreachable!("redirect loop always returns or fails at the configured limit") @@ -396,10 +395,7 @@ fn parse_version(value: &str) -> Result<([u64; 3], Option<&str>)> { 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" - ); + 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"); } @@ -424,64 +420,4 @@ fn is_newer_version(candidate: &str, current: &str) -> bool { } #[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 update_redirects_accept_relative_https_and_reject_unsafe_targets() { - let base = - Url::parse("https://updates.example.invalid/releases/current/Cargo.toml").unwrap(); - assert_eq!( - redirect_target(&base, "../next/Cargo.toml") - .unwrap() - .as_str(), - "https://updates.example.invalid/releases/next/Cargo.toml" - ); - assert!(redirect_target(&base, "ftp://updates.example.invalid/Cargo.toml").is_err()); - assert!(redirect_target(&base, "http://updates.example.invalid/Cargo.toml").is_err()); - assert!(redirect_target( - &base, - "https://user:secret@updates.example.invalid/Cargo.toml" - ) - .is_err()); - } - - #[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")); - } -} +include!("update_check/tests.rs"); diff --git a/src/update_check/tests.rs b/src/update_check/tests.rs new file mode 100644 index 0000000..b8e3a1d --- /dev/null +++ b/src/update_check/tests.rs @@ -0,0 +1,56 @@ +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 update_redirects_accept_relative_https_and_reject_unsafe_targets() { + let base = + Url::parse("https://updates.example.invalid/releases/current/Cargo.toml").unwrap(); + assert_eq!( + redirect_target(&base, "../next/Cargo.toml").unwrap().as_str(), + "https://updates.example.invalid/releases/next/Cargo.toml" + ); + assert!(redirect_target(&base, "ftp://updates.example.invalid/Cargo.toml").is_err()); + assert!(redirect_target(&base, "http://updates.example.invalid/Cargo.toml").is_err()); + assert!(redirect_target( + &base, + "https://user:secret@updates.example.invalid/Cargo.toml" + ) + .is_err()); + } + + #[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 index 4950be7..8cd9585 100644 --- a/src/upstream.rs +++ b/src/upstream.rs @@ -446,172 +446,7 @@ pub async fn tcp_probe(origin: &str, policy: &AddressPolicy) -> Result<()> { 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()); - } -} +include!("upstream/tests.rs"); /// Enumerate local interfaces for wildcard-listener loop protection. If unavailable, /// the fallback checks whether a candidate address is actually bindable on this host. diff --git a/src/upstream/tests.rs b/src/upstream/tests.rs new file mode 100644 index 0000000..6f68191 --- /dev/null +++ b/src/upstream/tests.rs @@ -0,0 +1,166 @@ +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()); + } +} diff --git a/src/vhost.rs b/src/vhost.rs index 46245c0..0f5546c 100644 --- a/src/vhost.rs +++ b/src/vhost.rs @@ -1,912 +1,7 @@ //! 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, PartialEq, Eq)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum Source { - ReverseProxy, - WebServer { web_service_id: String }, -} -impl Default for Source { - fn default() -> Self { - Self::ReverseProxy - } -} -impl Source { - pub fn is_reverse_proxy(&self) -> bool { - matches!(self, Self::ReverseProxy) - } - - pub fn validate(&self) -> Result<()> { - if let Self::WebServer { web_service_id } = self { - ensure!( - crate::config::valid_id(web_service_id), - "invalid Web Service reference" - ); - } - Ok(()) - } -} - -#[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, - /// Main source for the vhost fallback. Missing in older configs means Reverse Proxy. - #[serde(default)] - pub source: Source, - #[serde(default)] - 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 { - /// Source selected for this location. A local return ignores this field. - #[serde(default)] - pub source: Source, - /// 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 { - source: Source::default(), - 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.source == Source::ReverseProxy - && 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" - ); - self.source.validate()?; - let needs_proxy = self.source.is_reverse_proxy() - || self.routes.iter().any(|route| { - route.enabled && route.response.is_none() && route.source.is_reverse_proxy() - }); - ensure!( - self.backends.len() <= 32, - "vhost allows at most 32 backend members" - ); - ensure!( - !needs_proxy || !self.backends.is_empty(), - "vhost requires at least one backend member for Reverse Proxy sources" - ); - 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()); - for route in &self.routes { - route.source.validate()?; - ensure!( - route.source.is_reverse_proxy() || route.backend_id.is_none(), - "Web Server location cannot pin a reverse-proxy backend" - ); - } - 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) -} +include!("vhost/model.rs"); +include!("vhost/validation.rs"); #[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}"); - } - } -} +include!("vhost/tests.rs"); diff --git a/src/vhost/model.rs b/src/vhost/model.rs new file mode 100644 index 0000000..028edad --- /dev/null +++ b/src/vhost/model.rs @@ -0,0 +1,416 @@ +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, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Source { + ReverseProxy, + WebServer { web_service_id: String }, +} +impl Default for Source { + fn default() -> Self { + Self::ReverseProxy + } +} +impl Source { + pub fn is_reverse_proxy(&self) -> bool { + matches!(self, Self::ReverseProxy) + } + + pub fn validate(&self) -> Result<()> { + if let Self::WebServer { web_service_id } = self { + ensure!( + crate::config::valid_id(web_service_id), + "invalid Web Service reference" + ); + } + Ok(()) + } +} + +#[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, + /// Main source for the vhost fallback. Missing in older configs means Reverse Proxy. + #[serde(default)] + pub source: Source, + #[serde(default)] + 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 { + /// Source selected for this location. A local return ignores this field. + #[serde(default)] + pub source: Source, + /// 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, +} diff --git a/src/vhost/tests.rs b/src/vhost/tests.rs new file mode 100644 index 0000000..f89857d --- /dev/null +++ b/src/vhost/tests.rs @@ -0,0 +1,152 @@ +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/src/vhost/validation.rs b/src/vhost/validation.rs new file mode 100644 index 0000000..5ccf254 --- /dev/null +++ b/src/vhost/validation.rs @@ -0,0 +1,330 @@ +impl Default for PathRoute { + fn default() -> Self { + Self { + source: Source::default(), + 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.source == Source::ReverseProxy + && 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" + ); + self.source.validate()?; + let needs_proxy = self.source.is_reverse_proxy() + || self.routes.iter().any(|route| { + route.enabled && route.response.is_none() && route.source.is_reverse_proxy() + }); + ensure!( + self.backends.len() <= 32, + "vhost allows at most 32 backend members" + ); + ensure!( + !needs_proxy || !self.backends.is_empty(), + "vhost requires at least one backend member for Reverse Proxy sources" + ); + 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()); + for route in &self.routes { + route.source.validate()?; + ensure!( + route.source.is_reverse_proxy() || route.backend_id.is_none(), + "Web Server location cannot pin a reverse-proxy backend" + ); + } + 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) +} diff --git a/src/web_server.rs b/src/web_server.rs deleted file mode 100644 index ef4d5fd..0000000 --- a/src/web_server.rs +++ /dev/null @@ -1,4031 +0,0 @@ -//! Static Web Server source integrated with the normal RustProxy vhost/location pipeline. -//! Web services are reusable configuration resources. Vhosts and locations only reference them. -mod htaccess; -use anyhow::{ensure, Context, Result}; -use axum::{body::Body, response::Response}; -use bytes::Bytes; -use futures_util::stream; -pub use htaccess::Config as HtaccessConfig; -use http::{ - header::{ - ACCEPT_RANGES, ALLOW, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LENGTH, - CONTENT_RANGE, CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, IF_RANGE, - LAST_MODIFIED, LOCATION, RANGE, VARY, - }, - HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri, Version, -}; -use http_body_util::BodyExt; -use regex::Regex; -use serde::{Deserialize, Serialize}; -#[cfg(unix)] -use std::ffi::CString; -use std::{ - collections::{BTreeMap, HashSet}, - fs::File, - io, - net::IpAddr, - path::{Path, PathBuf}, - str::FromStr, - sync::Arc, - time::{Duration, SystemTime, UNIX_EPOCH}, -}; -use tokio::{ - io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWrite, AsyncWriteExt}, - sync::{mpsc, OwnedSemaphorePermit, Semaphore}, - time::Instant, -}; -use tokio_util::io::ReaderStream; - -const MAX_INDEX_FILES: usize = 16; -const MAX_INDEX_NAME_BYTES: usize = 128; -const MAX_SERVICE_NAME_BYTES: usize = 96; -const MAX_MIME_TYPES: usize = 64; -const MAX_MIME_EXTENSION_BYTES: usize = 32; -const MAX_MIME_VALUE_BYTES: usize = 256; -const MAX_CACHE_CONTROL_BYTES: usize = 1024; -const MAX_TRY_FILES: usize = 16; -const MAX_TRY_FILE_BYTES: usize = 512; -const MAX_REWRITE_RULES: usize = 32; -const MAX_REWRITE_PATTERN_BYTES: usize = 512; -const MAX_REWRITE_REPLACEMENT_BYTES: usize = 1024; -const MAX_CACHE_RULES: usize = 64; -const MAX_RESPONSE_HEADERS: usize = 32; -const MAX_HEADER_NAME_BYTES: usize = 128; -const MAX_HEADER_VALUE_BYTES: usize = 8192; -const MAX_DOWNLOAD_EXTENSIONS: usize = 64; -const MAX_PHP_EXTENSIONS: usize = 16; -const MAX_PHP_CUSTOM_PARAMS: usize = 64; -const MAX_PHP_VALUES: usize = 64; -const MAX_FASTCGI_PARAM_NAME_BYTES: usize = 64; -const MAX_FASTCGI_PARAM_VALUE_BYTES: usize = 8192; -const DEFAULT_FASTCGI_HEADER_BYTES: usize = 64 * 1024; -const DEFAULT_FASTCGI_STDERR_BYTES: usize = 4096; - -fn default_index() -> Vec { - vec!["index.html".into(), "index.htm".into()] -} - -fn default_true() -> bool { - true -} - -fn default_php_extensions() -> Vec { - vec!["php".into()] -} - -fn default_php_connect_timeout_ms() -> u64 { - 1_000 -} - -fn default_php_request_timeout_ms() -> u64 { - 30_000 -} - -fn default_php_max_concurrent_requests() -> usize { - 64 -} - -fn default_php_pass_authorization() -> bool { - true -} - -fn default_php_max_response_header_bytes() -> usize { - DEFAULT_FASTCGI_HEADER_BYTES -} - -fn default_php_stderr_log_bytes() -> usize { - DEFAULT_FASTCGI_STDERR_BYTES -} - -fn valid_extension(value: &str) -> bool { - !value.is_empty() - && value.len() <= MAX_MIME_EXTENSION_BYTES - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) -} - -fn normalize_extensions(values: Vec, label: &str) -> Result> { - let mut seen = HashSet::new(); - let mut normalized = Vec::with_capacity(values.len()); - for value in values { - let value = value.trim().trim_start_matches('.').to_ascii_lowercase(); - ensure!(valid_extension(&value), "{label} is invalid"); - ensure!(seen.insert(value.clone()), "duplicate {label}"); - normalized.push(value); - } - Ok(normalized) -} - -fn reserved_fastcgi_param(name: &str) -> bool { - name.starts_with("HTTP_") - || matches!( - name, - "REQUEST_METHOD" - | "SCRIPT_FILENAME" - | "SCRIPT_NAME" - | "QUERY_STRING" - | "REQUEST_URI" - | "DOCUMENT_ROOT" - | "SERVER_PROTOCOL" - | "SERVER_NAME" - | "SERVER_PORT" - | "REMOTE_ADDR" - | "REMOTE_PORT" - | "HTTPS" - | "GATEWAY_INTERFACE" - | "SERVER_SOFTWARE" - | "REDIRECT_STATUS" - | "CONTENT_TYPE" - | "CONTENT_LENGTH" - | "PATH_INFO" - | "PATH_TRANSLATED" - | "PHP_VALUE" - | "PHP_ADMIN_VALUE" - ) -} - -fn normalize_fastcgi_params(values: BTreeMap) -> Result> { - ensure!( - values.len() <= MAX_PHP_CUSTOM_PARAMS, - "Web Service PHP allows at most 64 custom FastCGI parameters" - ); - let mut normalized = BTreeMap::new(); - for (name, value) in values { - let name = name.trim().to_ascii_uppercase(); - let value = value.trim().to_owned(); - ensure!( - !name.is_empty() - && name.len() <= MAX_FASTCGI_PARAM_NAME_BYTES - && name.bytes().enumerate().all(|(index, byte)| { - byte.is_ascii_uppercase() - || byte.is_ascii_digit() && index > 0 - || byte == b'_' && index > 0 - }), - "Web Service PHP custom FastCGI parameter name is invalid" - ); - ensure!( - !reserved_fastcgi_param(&name), - "Web Service PHP custom FastCGI parameter uses a reserved name" - ); - ensure!( - !value.is_empty() - && value.len() <= MAX_FASTCGI_PARAM_VALUE_BYTES - && !value.chars().any(char::is_control), - "Web Service PHP custom FastCGI parameter value is invalid" - ); - ensure!( - normalized.insert(name, value).is_none(), - "duplicate Web Service PHP custom FastCGI parameter" - ); - } - Ok(normalized) -} - -fn normalize_php_values(values: BTreeMap) -> Result> { - ensure!( - values.len() <= MAX_PHP_VALUES, - "Web Service PHP allows at most 64 PHP INI overrides" - ); - let mut normalized = BTreeMap::new(); - for (name, value) in values { - let name = name.trim().to_ascii_lowercase(); - let value = value.trim().to_owned(); - ensure!( - !name.is_empty() - && name.len() <= MAX_FASTCGI_PARAM_NAME_BYTES - && name.bytes().all(|byte| { - byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-') - }), - "Web Service PHP INI override name is invalid" - ); - ensure!( - !value.is_empty() - && value.len() <= MAX_FASTCGI_PARAM_VALUE_BYTES - && !value.chars().any(char::is_control), - "Web Service PHP INI override value is invalid" - ); - ensure!( - normalized.insert(name, value).is_none(), - "duplicate Web Service PHP INI override" - ); - } - Ok(normalized) -} - -fn protected_service_header(name: &HeaderName) -> bool { - matches!( - name.as_str(), - "connection" - | "content-length" - | "content-encoding" - | "content-range" - | "transfer-encoding" - | "upgrade" - | "keep-alive" - | "proxy-connection" - | "vary" - ) -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] -#[serde(rename_all = "snake_case")] -pub enum SymlinkPolicy { - #[default] - Deny, - InsideRoot, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(default, deny_unknown_fields)] -pub struct RewriteRule { - pub pattern: String, - pub replacement: String, - /// `None` means an internal rewrite. Redirects are limited to 301/302/307/308. - pub status: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(default, deny_unknown_fields)] -pub struct CacheRule { - /// `ext:` or `mime:`. - pub matcher: String, - pub cache_control: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct Precompressed { - pub enabled: bool, - #[serde(default = "default_true")] - pub brotli: bool, - #[serde(default = "default_true")] - pub gzip: bool, -} - -impl Default for Precompressed { - fn default() -> Self { - Self { - enabled: false, - brotli: true, - gzip: true, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct Php { - pub enabled: bool, - #[serde(default = "default_php_extensions")] - pub extensions: Vec, - /// `unix:/run/php/php-fpm.sock` or `tcp:127.0.0.1:9000`. - pub endpoint: String, - #[serde(default = "default_php_connect_timeout_ms")] - pub connect_timeout_ms: u64, - #[serde(default = "default_php_request_timeout_ms")] - pub request_timeout_ms: u64, - #[serde(default = "default_php_max_concurrent_requests")] - pub max_concurrent_requests: usize, - /// Forward the inbound Authorization header as HTTP_AUTHORIZATION. - #[serde(default = "default_php_pass_authorization")] - pub pass_authorization: bool, - /// Optional RustProxy-side request-body limit for PHP. 0 keeps the body unlimited here. - pub max_request_body_bytes: u64, - /// Maximum bytes accepted before the CGI response-header terminator. - #[serde(default = "default_php_max_response_header_bytes")] - pub max_response_header_bytes: usize, - /// Maximum PHP-FPM stderr bytes retained for one request diagnostic log entry. - #[serde(default = "default_php_stderr_log_bytes")] - pub stderr_log_bytes: usize, - /// Trusted operator-defined static FastCGI parameters. Core CGI/HTTP/PHP_* names are reserved. - pub custom_params: BTreeMap, - /// Trusted operator-defined PHP INI overrides encoded through a single PHP_VALUE parameter. - pub php_values: BTreeMap, -} - -impl Default for Php { - fn default() -> Self { - Self { - enabled: false, - extensions: default_php_extensions(), - endpoint: "unix:/run/php/php-fpm.sock".into(), - connect_timeout_ms: default_php_connect_timeout_ms(), - request_timeout_ms: default_php_request_timeout_ms(), - max_concurrent_requests: default_php_max_concurrent_requests(), - pass_authorization: default_php_pass_authorization(), - max_request_body_bytes: 0, - max_response_header_bytes: default_php_max_response_header_bytes(), - stderr_log_bytes: default_php_stderr_log_bytes(), - custom_params: BTreeMap::new(), - php_values: BTreeMap::new(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(default, deny_unknown_fields)] -pub struct Hardening { - /// Reject high-confidence exploit probes and sensitive control/backup paths. - pub block_common_attacks: bool, - /// Remove implementation/fingerprint response headers from Web Server/PHP responses. - pub strip_unnecessary_headers: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct Service { - /// Stable configuration identifier referenced by vhosts/locations. - pub id: String, - /// Human-readable service name shown in the administration UI. - pub name: String, - pub document_root: PathBuf, - pub index: Vec, - pub directory_listing: bool, - /// Optional per-extension MIME overrides. Keys are extensions without a leading dot. - pub mime_types: BTreeMap, - /// Fallback MIME type used when neither an override nor a built-in mapping exists. - pub default_mime_type: String, - /// Browser/proxy cache policy emitted for static file responses. Location/vhost - /// response-header rules run later and can intentionally override this value. - pub cache_control: Option, - /// More specific cache policy rules evaluated before `cache_control`. - pub cache_rules: Vec, - /// Fixed response headers emitted by this Web Service. Vhost/location response - /// headers still run later and remain authoritative. - pub response_headers: BTreeMap, - /// Nginx-style file candidates, e.g. `$uri`, `$uri/`, `/index.html`. - pub try_files: Vec, - /// Regex rewrite/redirect rules evaluated before `try_files`. - pub rewrites: Vec, - /// Serve pre-generated `.br`/`.gz` sidecars when accepted by the client. - pub precompressed: Precompressed, - /// Extensions that should be downloaded as attachments. - pub download_extensions: Vec, - /// Symlink handling. The conservative default is `deny`. - pub symlink_policy: SymlinkPolicy, - /// Optional FastCGI/PHP-FPM execution for configured script extensions. - pub php: Php, - /// Optional bounded Apache .htaccess compatibility layer compiled at Web Service apply time. - pub htaccess: HtaccessConfig, - /// Optional Web Service hardening. This is intentionally narrower than a generic WAF. - pub hardening: Hardening, -} - -impl Default for Service { - fn default() -> Self { - Self { - id: String::new(), - name: String::new(), - document_root: PathBuf::new(), - index: default_index(), - directory_listing: false, - mime_types: BTreeMap::new(), - default_mime_type: "application/octet-stream".into(), - cache_control: None, - cache_rules: Vec::new(), - response_headers: BTreeMap::new(), - try_files: Vec::new(), - rewrites: Vec::new(), - precompressed: Precompressed::default(), - download_extensions: Vec::new(), - symlink_policy: SymlinkPolicy::Deny, - php: Php::default(), - htaccess: HtaccessConfig::default(), - hardening: Hardening::default(), - } - } -} - -impl Service { - pub fn validate(&mut self) -> Result<()> { - ensure!(crate::config::valid_id(&self.id), "invalid Web Service id"); - self.name = self.name.trim().to_owned(); - ensure!( - !self.name.is_empty() - && self.name.len() <= MAX_SERVICE_NAME_BYTES - && !self.name.chars().any(char::is_control), - "Web Service name must be 1..96 bytes without controls" - ); - ensure!( - self.document_root.is_absolute(), - "Web Service document root must be an absolute path" - ); - ensure!( - self.index.len() <= MAX_INDEX_FILES, - "Web Service index list allows at most 16 file names" - ); - let mut seen = std::collections::HashSet::new(); - for item in &mut self.index { - *item = item.trim().to_owned(); - ensure!( - !item.is_empty() - && item.len() <= MAX_INDEX_NAME_BYTES - && !item.chars().any(char::is_control) - && !item.contains('/') - && !item.contains('\\') - && item != "." - && item != "..", - "Web Service index entries must be plain file names" - ); - ensure!( - seen.insert(item.clone()), - "duplicate Web Service index file" - ); - } - ensure!( - self.mime_types.len() <= MAX_MIME_TYPES, - "Web Service MIME map allows at most 64 extensions" - ); - let mut normalized_mime_types = BTreeMap::new(); - for (extension, value) in std::mem::take(&mut self.mime_types) { - let extension = extension - .trim() - .trim_start_matches('.') - .to_ascii_lowercase(); - ensure!( - !extension.is_empty() - && extension.len() <= MAX_MIME_EXTENSION_BYTES - && extension - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')), - "Web Service MIME extensions must be 1..32 letters, numbers, '-' or '_'" - ); - let value = value.trim().to_owned(); - ensure!( - !value.is_empty() && value.len() <= MAX_MIME_VALUE_BYTES, - "Web Service MIME value must be 1..256 bytes" - ); - HeaderValue::from_str(&value).context("invalid Web Service MIME header value")?; - ensure!( - normalized_mime_types.insert(extension, value).is_none(), - "duplicate Web Service MIME extension" - ); - } - self.mime_types = normalized_mime_types; - self.default_mime_type = self.default_mime_type.trim().to_owned(); - ensure!( - !self.default_mime_type.is_empty() - && self.default_mime_type.len() <= MAX_MIME_VALUE_BYTES, - "Web Service default MIME type must be 1..256 bytes" - ); - HeaderValue::from_str(&self.default_mime_type) - .context("invalid Web Service default MIME header value")?; - self.cache_control = self - .cache_control - .take() - .map(|value| value.trim().to_owned()) - .filter(|value| !value.is_empty()); - if let Some(cache_control) = &self.cache_control { - ensure!( - cache_control.len() <= MAX_CACHE_CONTROL_BYTES, - "Web Service Cache-Control must be at most 1024 bytes" - ); - HeaderValue::from_str(cache_control) - .context("invalid Web Service Cache-Control header value")?; - } - - ensure!( - self.cache_rules.len() <= MAX_CACHE_RULES, - "Web Service cache policy allows at most 64 rules" - ); - for rule in &mut self.cache_rules { - rule.matcher = rule.matcher.trim().to_ascii_lowercase(); - rule.cache_control = rule.cache_control.trim().to_owned(); - let valid_matcher = rule - .matcher - .strip_prefix("ext:") - .is_some_and(valid_extension) - || rule.matcher.strip_prefix("mime:").is_some_and(|value| { - !value.is_empty() - && value.len() <= MAX_MIME_VALUE_BYTES - && !value.chars().any(char::is_control) - }); - ensure!( - valid_matcher, - "Web Service cache rule matcher must be ext: or mime:" - ); - ensure!( - !rule.cache_control.is_empty() - && rule.cache_control.len() <= MAX_CACHE_CONTROL_BYTES, - "Web Service cache rule value must be 1..1024 bytes" - ); - HeaderValue::from_str(&rule.cache_control) - .context("invalid Web Service cache rule header value")?; - } - - ensure!( - self.response_headers.len() <= MAX_RESPONSE_HEADERS, - "Web Service allows at most 32 response headers" - ); - let mut normalized_headers = BTreeMap::new(); - for (name, value) in std::mem::take(&mut self.response_headers) { - let name = name.trim().to_ascii_lowercase(); - ensure!( - !name.is_empty() && name.len() <= MAX_HEADER_NAME_BYTES, - "Web Service response header name must be 1..128 bytes" - ); - let header_name = - HeaderName::from_str(&name).context("invalid Web Service response header name")?; - ensure!( - !protected_service_header(&header_name), - "Web Service response header cannot override framing/transport headers" - ); - let value = value.trim().to_owned(); - ensure!( - value.len() <= MAX_HEADER_VALUE_BYTES, - "Web Service response header value must be at most 8192 bytes" - ); - HeaderValue::from_str(&value).context("invalid Web Service response header value")?; - ensure!( - normalized_headers.insert(name, value).is_none(), - "duplicate Web Service response header" - ); - } - self.response_headers = normalized_headers; - - ensure!( - self.try_files.len() <= MAX_TRY_FILES, - "Web Service try_files allows at most 16 candidates" - ); - for candidate in &mut self.try_files { - *candidate = candidate.trim().to_owned(); - ensure!( - !candidate.is_empty() - && candidate.len() <= MAX_TRY_FILE_BYTES - && !candidate.chars().any(char::is_control), - "Web Service try_files entries must be 1..512 bytes without controls" - ); - if let Some(status) = candidate.strip_prefix('=') { - let status: u16 = status.parse().context("invalid try_files status")?; - ensure!( - (400..=599).contains(&status) && StatusCode::from_u16(status).is_ok(), - "try_files status must be an HTTP 4xx/5xx code" - ); - } else { - ensure!( - candidate.starts_with('/') || candidate.starts_with("$uri"), - "Web Service try_files entries must start with / or $uri" - ); - } - } - - ensure!( - self.rewrites.len() <= MAX_REWRITE_RULES, - "Web Service allows at most 32 rewrite rules" - ); - for rule in &mut self.rewrites { - rule.pattern = rule.pattern.trim().to_owned(); - rule.replacement = rule.replacement.trim().to_owned(); - ensure!( - !rule.pattern.is_empty() - && rule.pattern.len() <= MAX_REWRITE_PATTERN_BYTES - && rule.replacement.len() <= MAX_REWRITE_REPLACEMENT_BYTES - && !rule.pattern.chars().any(char::is_control) - && !rule.replacement.chars().any(char::is_control), - "Web Service rewrite pattern/replacement is invalid" - ); - Regex::new(&rule.pattern).context("invalid Web Service rewrite regex")?; - ensure!( - rule.replacement.starts_with('/'), - "Web Service rewrite replacement must start with /" - ); - if let Some(status) = rule.status { - ensure!( - matches!(status, 301 | 302 | 307 | 308), - "Web Service rewrite redirect status must be 301, 302, 307 or 308" - ); - } - } - - ensure!( - self.download_extensions.len() <= MAX_DOWNLOAD_EXTENSIONS, - "Web Service allows at most 64 download extensions" - ); - self.download_extensions = normalize_extensions( - std::mem::take(&mut self.download_extensions), - "Web Service download extension", - )?; - - ensure!( - self.php.extensions.len() <= MAX_PHP_EXTENSIONS, - "Web Service PHP allows at most 16 extensions" - ); - self.php.extensions = normalize_extensions( - std::mem::take(&mut self.php.extensions), - "Web Service PHP extension", - )?; - self.php.endpoint = self.php.endpoint.trim().to_owned(); - ensure!( - self.php.connect_timeout_ms >= 50 && self.php.connect_timeout_ms <= 60_000, - "Web Service PHP connect timeout must be 50..60000 ms" - ); - ensure!( - self.php.request_timeout_ms >= 100 && self.php.request_timeout_ms <= 3_600_000, - "Web Service PHP request timeout must be 100..3600000 ms" - ); - ensure!( - (1..=4096).contains(&self.php.max_concurrent_requests), - "Web Service PHP max concurrent requests must be 1..4096" - ); - ensure!( - self.php.max_request_body_bytes <= 8_u64 * 1024 * 1024 * 1024, - "Web Service PHP request body limit must be 0..8589934592 bytes" - ); - ensure!( - self.php.max_response_header_bytes >= 4096 - && self.php.max_response_header_bytes <= 1024 * 1024, - "Web Service PHP response header limit must be 4096..1048576 bytes" - ); - ensure!( - self.php.stderr_log_bytes <= 64 * 1024, - "Web Service PHP stderr log limit must be 0..65536 bytes" - ); - self.php.custom_params = - normalize_fastcgi_params(std::mem::take(&mut self.php.custom_params))?; - self.php.php_values = normalize_php_values(std::mem::take(&mut self.php.php_values))?; - let php_custom_bytes = self - .php - .custom_params - .iter() - .chain(self.php.php_values.iter()) - .map(|(name, value)| name.len() + value.len() + 2) - .sum::(); - ensure!( - php_custom_bytes <= 128 * 1024, - "Web Service PHP custom FastCGI/PHP_VALUE data must be at most 128 KiB" - ); - htaccess::validate(&mut self.htaccess)?; - - if self.php.enabled { - ensure!( - !self.php.extensions.is_empty(), - "Web Service PHP needs at least one executable extension" - ); - FastCgiEndpoint::parse(&self.php.endpoint)?; - ensure!( - self.symlink_policy == SymlinkPolicy::Deny, - "PHP-FPM currently requires symlink_policy=deny to avoid script-path races" - ); - } - - #[cfg(not(target_os = "linux"))] - ensure!( - self.symlink_policy == SymlinkPolicy::Deny, - "Web Service symlink_policy=inside_root currently requires Linux" - ); - Ok(()) - } - - pub fn compile(&self) -> Result { - let root = std::fs::canonicalize(&self.document_root).with_context(|| { - format!( - "Web Service {} document root {} is not accessible", - self.id, - self.document_root.display() - ) - })?; - ensure!( - std::fs::metadata(&root)?.is_dir(), - "Web Service {} document root is not a directory", - self.id - ); - #[cfg(not(unix))] - anyhow::bail!( - "Web Service secure filesystem confinement currently requires a Unix platform" - ); - #[cfg(unix)] - let root_handle = Arc::new(open_root_directory(&root).with_context(|| { - format!( - "Web Service {} document root could not be opened safely", - self.id - ) - })?); - let htaccess = htaccess::compile(&root, &self.htaccess)?; - if let Some(compiled) = &htaccess { - ensure!( - self.php.enabled || !htaccess::adds_php_extensions(Some(compiled)), - ".htaccess AddHandler for PHP requires PHP-FPM to be enabled on the Web Service" - ); - tracing::debug!(service = %self.id, files = compiled.files_loaded, ".htaccess compatibility compiled"); - for warning in &compiled.warnings { - tracing::warn!(service = %self.id, warning = %warning, ".htaccess compatibility warning"); - } - } - let response_headers = self - .response_headers - .iter() - .map(|(name, value)| { - Ok::<_, anyhow::Error>((HeaderName::from_str(name)?, HeaderValue::from_str(value)?)) - }) - .collect::, anyhow::Error>>()?; - let cache_rules = self - .cache_rules - .iter() - .map(|rule| { - let matcher = if let Some(extension) = rule.matcher.strip_prefix("ext:") { - CompiledCacheMatcher::Extension(extension.to_owned()) - } else if let Some(prefix) = rule.matcher.strip_prefix("mime:") { - CompiledCacheMatcher::MimePrefix(prefix.to_owned()) - } else { - anyhow::bail!("invalid compiled Web Service cache matcher") - }; - Ok(CompiledCacheRule { - matcher, - cache_control: HeaderValue::from_str(&rule.cache_control)?, - }) - }) - .collect::>>()?; - let rewrites = self - .rewrites - .iter() - .map(|rule| { - Ok(CompiledRewriteRule { - pattern: Regex::new(&rule.pattern)?, - replacement: rule.replacement.clone(), - status: rule - .status - .and_then(|value| StatusCode::from_u16(value).ok()), - }) - }) - .collect::>>()?; - let mut script_extensions: HashSet = self.php.extensions.iter().cloned().collect(); - script_extensions.extend( - [ - "php", "phtml", "phar", "php3", "php4", "php5", "php7", "php8", - ] - .into_iter() - .map(str::to_owned), - ); - let php = if self.php.enabled { - Some(CompiledPhp { - extensions: self.php.extensions.iter().cloned().collect(), - endpoint: FastCgiEndpoint::parse(&self.php.endpoint)?, - connect_timeout: Duration::from_millis(self.php.connect_timeout_ms), - request_timeout: Duration::from_millis(self.php.request_timeout_ms), - permits: Arc::new(Semaphore::new(self.php.max_concurrent_requests)), - pass_authorization: self.php.pass_authorization, - max_request_body_bytes: self.php.max_request_body_bytes, - max_response_header_bytes: self.php.max_response_header_bytes, - stderr_log_bytes: self.php.stderr_log_bytes, - custom_params: self.php.custom_params.clone(), - php_values: self.php.php_values.clone(), - }) - } else { - None - }; - Ok(CompiledService { - id: self.id.clone(), - name: self.name.clone(), - root, - index: self.index.clone(), - directory_listing: self.directory_listing, - mime_types: self - .mime_types - .iter() - .map(|(extension, value)| Ok((extension.clone(), HeaderValue::from_str(value)?))) - .collect::, http::header::InvalidHeaderValue>>( - )?, - default_mime_type: HeaderValue::from_str(&self.default_mime_type)?, - cache_control: self - .cache_control - .as_deref() - .map(HeaderValue::from_str) - .transpose()?, - cache_rules, - response_headers, - try_files: self.try_files.clone(), - rewrites, - precompressed: self.precompressed.clone(), - download_extensions: self.download_extensions.iter().cloned().collect(), - symlink_policy: self.symlink_policy, - script_extensions, - php, - htaccess, - hardening: self.hardening.clone(), - #[cfg(unix)] - root_handle, - }) - } -} - -#[derive(Debug, Clone)] -enum CompiledCacheMatcher { - Extension(String), - MimePrefix(String), -} - -#[derive(Debug, Clone)] -struct CompiledCacheRule { - matcher: CompiledCacheMatcher, - cache_control: HeaderValue, -} - -#[derive(Debug, Clone)] -struct CompiledRewriteRule { - pattern: Regex, - replacement: String, - status: Option, -} - -#[derive(Debug, Clone)] -enum FastCgiEndpoint { - #[cfg(unix)] - Unix(PathBuf), - Tcp(std::net::SocketAddr), -} - -impl FastCgiEndpoint { - fn parse(value: &str) -> Result { - if let Some(path) = value.strip_prefix("unix:") { - ensure!(!path.trim().is_empty(), "PHP-FPM unix socket path is empty"); - #[cfg(unix)] - { - return Ok(Self::Unix(PathBuf::from(path))); - } - #[cfg(not(unix))] - anyhow::bail!("PHP-FPM unix sockets require a Unix platform"); - } - if let Some(address) = value.strip_prefix("tcp:") { - let address = address - .parse::() - .context("PHP-FPM TCP endpoint must be an IP socket address")?; - ensure!( - address.ip().is_loopback(), - "PHP-FPM TCP endpoint must use a loopback address" - ); - return Ok(Self::Tcp(address)); - } - anyhow::bail!("PHP-FPM endpoint must start with unix: or tcp:") - } -} - -#[derive(Debug, Clone)] -struct CompiledPhp { - extensions: HashSet, - endpoint: FastCgiEndpoint, - connect_timeout: Duration, - request_timeout: Duration, - permits: Arc, - pass_authorization: bool, - max_request_body_bytes: u64, - max_response_header_bytes: usize, - stderr_log_bytes: usize, - custom_params: BTreeMap, - php_values: BTreeMap, -} - -#[derive(Debug, Clone)] -pub struct CompiledService { - pub id: String, - pub name: String, - root: PathBuf, - index: Vec, - directory_listing: bool, - mime_types: BTreeMap, - default_mime_type: HeaderValue, - cache_control: Option, - cache_rules: Vec, - response_headers: Vec<(HeaderName, HeaderValue)>, - try_files: Vec, - rewrites: Vec, - precompressed: Precompressed, - download_extensions: HashSet, - symlink_policy: SymlinkPolicy, - script_extensions: HashSet, - php: Option, - htaccess: Option, - hardening: Hardening, - #[cfg(unix)] - root_handle: Arc, -} - -#[derive(Debug)] -struct Opened { - file: File, - metadata: std::fs::Metadata, -} - -#[derive(Debug, Clone)] -struct DirectoryEntry { - name: String, - directory: bool, -} - -#[cfg(unix)] -fn cstring_component(value: &str) -> io::Result { - CString::new(value.as_bytes()) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL")) -} - -#[cfg(unix)] -fn open_root_directory(path: &Path) -> io::Result { - use std::os::unix::{ffi::OsStrExt, io::FromRawFd}; - let path = CString::new(path.as_os_str().as_bytes()) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?; - let fd = unsafe { - libc::open( - path.as_ptr(), - libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, - ) - }; - if fd < 0 { - Err(io::Error::last_os_error()) - } else { - Ok(unsafe { File::from_raw_fd(fd) }) - } -} - -fn path_components(path: &str) -> io::Result> { - if !path.starts_with('/') { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "path is not absolute", - )); - } - let mut result = Vec::new(); - for part in path.split('/').filter(|part| !part.is_empty()) { - if part == "." || part == ".." || part.contains('\\') || part.contains('\0') { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "ambiguous path", - )); - } - result.push(part.to_owned()); - } - Ok(result) -} - -#[cfg(unix)] -fn secure_open_sync(service: &CompiledService, components: &[String]) -> io::Result { - use std::os::unix::io::{AsRawFd, FromRawFd}; - - if components.is_empty() { - // `File::try_clone()` uses dup(2), which shares the directory offset with - // the persistent root descriptor. Passing such a clone to readdir(3) - // would leave the next root listing positioned at EOF. openat(".") - // creates a fresh open file description and therefore a fresh offset for - // every request while retaining the same O_NOFOLLOW confinement. - let dot = cstring_component(".")?; - let fd = unsafe { - libc::openat( - service.root_handle.as_raw_fd(), - dot.as_ptr(), - libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, - ) - }; - if fd < 0 { - return Err(io::Error::last_os_error()); - } - let current = unsafe { File::from_raw_fd(fd) }; - let metadata = current.metadata()?; - return Ok(Opened { - file: current, - metadata, - }); - } - let mut current = service.root_handle.try_clone()?; - for (index, component) in components.iter().enumerate() { - let name = cstring_component(component)?; - let intermediate = index + 1 != components.len(); - // The default remains O_NOFOLLOW on every component. `inside_root` is an - // explicit Linux-only opt-in: the opened descriptor is checked through - // /proc/self/fd after each resolution step before it can be used further. - let mut flags = libc::O_RDONLY | libc::O_CLOEXEC; - if service.symlink_policy == SymlinkPolicy::Deny { - flags |= libc::O_NOFOLLOW; - } - if intermediate { - flags |= libc::O_DIRECTORY; - } else { - // Avoid blocking on special files. Only regular files/directories are served. - flags |= libc::O_NONBLOCK; - } - let fd = unsafe { libc::openat(current.as_raw_fd(), name.as_ptr(), flags) }; - if fd < 0 { - return Err(io::Error::last_os_error()); - } - if service.symlink_policy == SymlinkPolicy::InsideRoot { - #[cfg(target_os = "linux")] - verify_linux_fd_beneath_root(service, fd)?; - #[cfg(not(target_os = "linux"))] - { - unsafe { libc::close(fd) }; - return Err(io::Error::new( - io::ErrorKind::Unsupported, - "inside_root symlink policy requires Linux", - )); - } - } - current = unsafe { File::from_raw_fd(fd) }; - } - let metadata = current.metadata()?; - if !metadata.is_file() && !metadata.is_dir() { - return Err(io::Error::new( - io::ErrorKind::PermissionDenied, - "Web Server serves only regular files and directories", - )); - } - Ok(Opened { - file: current, - metadata, - }) -} - -#[cfg(target_os = "linux")] -fn verify_linux_fd_beneath_root(service: &CompiledService, fd: libc::c_int) -> io::Result<()> { - let target = match std::fs::read_link(format!("/proc/self/fd/{fd}")) { - Ok(target) => target, - Err(error) => { - unsafe { libc::close(fd) }; - return Err(error); - } - }; - if target.starts_with(&service.root) { - Ok(()) - } else { - unsafe { libc::close(fd) }; - Err(io::Error::new( - io::ErrorKind::PermissionDenied, - "symlink target escaped document root", - )) - } -} - -#[cfg(not(unix))] -fn secure_open_sync(service: &CompiledService, components: &[String]) -> io::Result { - let mut candidate = service.root.clone(); - for component in components { - candidate.push(component); - } - let canonical = std::fs::canonicalize(&candidate)?; - if !canonical.starts_with(&service.root) { - return Err(io::Error::new( - io::ErrorKind::PermissionDenied, - "path escaped document root", - )); - } - let file = File::open(&canonical)?; - let metadata = file.metadata()?; - if !metadata.is_file() && !metadata.is_dir() { - return Err(io::Error::new( - io::ErrorKind::PermissionDenied, - "Web Server serves only regular files and directories", - )); - } - Ok(Opened { file, metadata }) -} - -#[cfg(unix)] -fn list_directory_sync( - service: &CompiledService, - components: &[String], -) -> io::Result> { - use std::ffi::CStr; - use std::os::unix::io::IntoRawFd; - - let opened = secure_open_sync(service, components)?; - if !opened.metadata.is_dir() { - return Err(io::Error::new( - io::ErrorKind::NotADirectory, - "not a directory", - )); - } - let raw = opened.file.try_clone()?.into_raw_fd(); - let dir = unsafe { libc::fdopendir(raw) }; - if dir.is_null() { - let error = io::Error::last_os_error(); - unsafe { libc::close(raw) }; - return Err(error); - } - let mut entries = Vec::new(); - loop { - let entry = unsafe { libc::readdir(dir) }; - if entry.is_null() { - break; - } - let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes(); - if name == b"." || name == b".." { - continue; - } - let Ok(name) = std::str::from_utf8(name) else { - // Routing intentionally accepts only valid UTF-8, so do not emit an - // unusable or ambiguously decoded link for a non-UTF-8 file name. - continue; - }; - let mut child = components.to_vec(); - child.push(name.to_owned()); - // Re-open through the root fd using the configured symlink policy. The - // default omits every symlink; Linux inside_root may include a target only - // after the opened descriptor has been proven to remain below the root. - let Ok(child_opened) = secure_open_sync(service, &child) else { - continue; - }; - entries.push(DirectoryEntry { - name: name.to_owned(), - directory: child_opened.metadata.is_dir(), - }); - } - unsafe { libc::closedir(dir) }; - entries.sort_by(|a, b| { - b.directory - .cmp(&a.directory) - .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) - .then_with(|| a.name.cmp(&b.name)) - }); - Ok(entries) -} - -#[cfg(not(unix))] -fn list_directory_sync( - service: &CompiledService, - components: &[String], -) -> io::Result> { - let opened = secure_open_sync(service, components)?; - if !opened.metadata.is_dir() { - return Err(io::Error::new( - io::ErrorKind::NotADirectory, - "not a directory", - )); - } - let mut path = service.root.clone(); - for component in components { - path.push(component); - } - let canonical = std::fs::canonicalize(path)?; - if !canonical.starts_with(&service.root) { - return Err(io::Error::new( - io::ErrorKind::PermissionDenied, - "path escaped document root", - )); - } - let mut entries = Vec::new(); - for entry in std::fs::read_dir(canonical)? { - let entry = entry?; - let Some(name) = entry.file_name().to_str().map(str::to_owned) else { - continue; - }; - let mut child = components.to_vec(); - child.push(name.clone()); - let Ok(child_opened) = secure_open_sync(service, &child) else { - continue; - }; - entries.push(DirectoryEntry { - name, - directory: child_opened.metadata.is_dir(), - }); - } - entries.sort_by(|a, b| { - b.directory - .cmp(&a.directory) - .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) - .then_with(|| a.name.cmp(&b.name)) - }); - Ok(entries) -} - -fn hardening_denied_path(service: &CompiledService, path: &str) -> bool { - if !service.hardening.block_common_attacks { - return false; - } - let lower = path.to_ascii_lowercase(); - if [ - "/vendor/phpunit/", - "/_ignition/execute-solution", - "/server-status", - "/server-info", - "/cgi-bin/", - ] - .iter() - .any(|needle| lower.contains(*needle)) - { - return true; - } - let sensitive_names = [ - "wp-config.php", - "wp-config-sample.php", - "configuration.php", - "settings.php", - "services.yml", - "php.ini", - "web.config", - "composer.json", - "composer.lock", - "auth.json", - "id_rsa", - "id_dsa", - "id_ed25519", - ]; - let backup_suffixes = [ - ".bak", ".backup", ".old", ".orig", ".save", ".swp", ".swo", ".sql", ".sqlite", ".sqlite3", - ".db", - ]; - path_components(path).ok().is_some_and(|components| { - components.iter().any(|component| { - let component = component.to_ascii_lowercase(); - (component.starts_with('.') && component != ".well-known") - || component == ".env" - || component.starts_with(".env.") - || sensitive_names.contains(&component.as_str()) - || component.ends_with('~') - || backup_suffixes - .iter() - .any(|suffix| component.ends_with(*suffix)) - }) - }) -} - -fn hardening_denied_method(service: &CompiledService, method: &Method) -> bool { - service.hardening.block_common_attacks - && (method == Method::CONNECT - || method == Method::TRACE - || method.as_str().eq_ignore_ascii_case("TRACK")) -} - -fn strip_unnecessary_headers(response: &mut Response, service: &CompiledService) { - if !service.hardening.strip_unnecessary_headers { - return; - } - for name in [ - "server", - "x-powered-by", - "x-aspnet-version", - "x-aspnetmvc-version", - "x-runtime", - "x-generator", - "x-drupal-cache", - "x-drupal-dynamic-cache", - ] { - response.headers_mut().remove(name); - } -} - -fn status_response(status: StatusCode, message: &'static str) -> Response { - let mut response = Response::new(Body::from(message)); - *response.status_mut() = status; - response.headers_mut().insert( - CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - response -} - -fn builtin_error_response(status: StatusCode, method: &Method) -> Response { - let html = crate::pages::builtin_gateway_error(status) - .unwrap_or("

                    Error

                    "); - let mut response = Response::new(if *method == Method::HEAD { - Body::empty() - } else { - Body::from(html) - }); - *response.status_mut() = status; - response.headers_mut().insert( - CONTENT_TYPE, - HeaderValue::from_static("text/html; charset=utf-8"), - ); - if let Ok(length) = HeaderValue::from_str(&html.len().to_string()) { - response.headers_mut().insert(CONTENT_LENGTH, length); - } - response - .headers_mut() - .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); - response.headers_mut().insert( - "x-content-type-options", - HeaderValue::from_static("nosniff"), - ); - response -} - -fn io_response(error: &io::Error) -> Response { - #[cfg(unix)] - if matches!(error.raw_os_error(), Some(libc::ELOOP)) { - return status_response( - StatusCode::FORBIDDEN, - "RustProxy: Web Server path is not allowed\n", - ); - } - match error.kind() { - io::ErrorKind::NotFound | io::ErrorKind::NotADirectory => status_response( - StatusCode::NOT_FOUND, - "RustProxy: Web Server resource not found\n", - ), - io::ErrorKind::PermissionDenied | io::ErrorKind::InvalidInput => status_response( - StatusCode::FORBIDDEN, - "RustProxy: Web Server path is not allowed\n", - ), - _ => status_response( - StatusCode::INTERNAL_SERVER_ERROR, - "RustProxy: Web Server filesystem error\n", - ), - } -} - -fn builtin_content_type(path: &Path) -> Option<&'static str> { - let ext = path - .extension() - .and_then(|value| value.to_str()) - .unwrap_or("") - .to_ascii_lowercase(); - Some(match ext.as_str() { - "html" | "htm" => "text/html; charset=utf-8", - "css" => "text/css; charset=utf-8", - "js" | "mjs" | "cjs" => "text/javascript; charset=utf-8", - "txt" | "log" | "md" => "text/plain; charset=utf-8", - "csv" => "text/csv; charset=utf-8", - "tsv" => "text/tab-separated-values; charset=utf-8", - "json" | "map" => "application/json; charset=utf-8", - "yaml" | "yml" => "application/yaml; charset=utf-8", - "xml" => "application/xml; charset=utf-8", - "rss" => "application/rss+xml; charset=utf-8", - "atom" => "application/atom+xml; charset=utf-8", - "ics" => "text/calendar; charset=utf-8", - "vcf" => "text/vcard; charset=utf-8", - "pdf" => "application/pdf", - "zip" => "application/zip", - "7z" => "application/x-7z-compressed", - "rar" => "application/vnd.rar", - "gz" => "application/gzip", - "bz2" => "application/x-bzip2", - "xz" => "application/x-xz", - "tar" => "application/x-tar", - "wasm" => "application/wasm", - "webmanifest" => "application/manifest+json; charset=utf-8", - "svg" => "image/svg+xml", - "png" => "image/png", - "apng" => "image/apng", - "jpg" | "jpeg" => "image/jpeg", - "gif" => "image/gif", - "webp" => "image/webp", - "avif" => "image/avif", - "heic" => "image/heic", - "heif" => "image/heif", - "jxl" => "image/jxl", - "ico" => "image/x-icon", - "bmp" => "image/bmp", - "mp3" => "audio/mpeg", - "m4a" => "audio/mp4", - "ogg" | "oga" => "audio/ogg", - "wav" => "audio/wav", - "flac" => "audio/flac", - "mp4" | "m4v" => "video/mp4", - "mov" => "video/quicktime", - "mpeg" | "mpg" => "video/mpeg", - "avi" => "video/x-msvideo", - "webm" => "video/webm", - "ogv" => "video/ogg", - "woff" => "font/woff", - "woff2" => "font/woff2", - "ttf" => "font/ttf", - "otf" => "font/otf", - "eot" => "application/vnd.ms-fontobject", - "doc" => "application/msword", - "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "xls" => "application/vnd.ms-excel", - "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "ppt" => "application/vnd.ms-powerpoint", - "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", - _ => return None, - }) -} - -fn content_type(service: &CompiledService, path: &Path) -> HeaderValue { - if let Some(value) = htaccess::mime_type(service.htaccess.as_ref(), path) { - return value; - } - let extension = path - .extension() - .and_then(|value| value.to_str()) - .unwrap_or("") - .to_ascii_lowercase(); - if let Some(value) = service.mime_types.get(&extension) { - return value.clone(); - } - if let Some(value) = builtin_content_type(path) { - return HeaderValue::from_static(value); - } - service.default_mime_type.clone() -} - -fn cache_control_for( - service: &CompiledService, - path: &Path, - content_type: &HeaderValue, -) -> Option { - let extension = path - .extension() - .and_then(|value| value.to_str()) - .unwrap_or("") - .to_ascii_lowercase(); - let mime = content_type.to_str().unwrap_or("").to_ascii_lowercase(); - for rule in &service.cache_rules { - let matches = match &rule.matcher { - CompiledCacheMatcher::Extension(value) => extension == *value, - CompiledCacheMatcher::MimePrefix(value) => mime.starts_with(value), - }; - if matches { - return Some(rule.cache_control.clone()); - } - } - service.cache_control.clone() -} - -fn add_cache_control( - response: &mut Response, - service: &CompiledService, - path: &Path, - content_type: &HeaderValue, -) { - if let Some(value) = cache_control_for(service, path, content_type) { - response.headers_mut().insert(CACHE_CONTROL, value); - } -} - -fn add_service_headers(response: &mut Response, service: &CompiledService) { - for (name, value) in &service.response_headers { - response.headers_mut().insert(name.clone(), value.clone()); - } -} - -fn download_extension(service: &CompiledService, path: &Path) -> bool { - path.extension() - .and_then(|value| value.to_str()) - .map(|value| value.to_ascii_lowercase()) - .is_some_and(|value| service.download_extensions.contains(&value)) -} - -fn add_download_header(response: &mut Response, service: &CompiledService, path: &Path) { - if !download_extension(service, path) { - return; - } - let filename = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("download"); - let value = format!( - "attachment; filename*=UTF-8''{}", - percent_encode_segment(filename) - ); - if let Ok(value) = HeaderValue::from_str(&value) { - response.headers_mut().insert(CONTENT_DISPOSITION, value); - } -} - -fn accepts_content_encoding(headers: &HeaderMap, wanted: &str) -> bool { - let Some(value) = headers - .get(http::header::ACCEPT_ENCODING) - .and_then(|value| value.to_str().ok()) - else { - return false; - }; - let mut wildcard = None; - for item in value.split(',') { - let mut parts = item.trim().split(';'); - let encoding = parts.next().unwrap_or("").trim(); - let mut q = 1.0f32; - for parameter in parts { - if let Some(value) = parameter.trim().strip_prefix("q=") { - q = value.parse::().unwrap_or(0.0); - } - } - if encoding.eq_ignore_ascii_case(wanted) { - return q > 0.0; - } - if encoding == "*" { - wildcard = Some(q > 0.0); - } - } - wildcard.unwrap_or(false) -} - -fn metadata_validators(metadata: &std::fs::Metadata) -> (Option, Option) { - let modified = metadata.modified().ok(); - let etag = modified.and_then(|modified| { - modified.duration_since(UNIX_EPOCH).ok().map(|stamp| { - format!( - "\"{:x}-{:x}-{:x}\"", - metadata.len(), - stamp.as_secs(), - stamp.subsec_nanos() - ) - }) - }); - (etag, modified) -} - -fn weak_etag_value(value: &str) -> &str { - value.trim().strip_prefix("W/").unwrap_or(value.trim()) -} - -fn if_none_match_matches(value: &HeaderValue, etag: &str) -> bool { - value.to_str().ok().is_some_and(|value| { - value.split(',').any(|candidate| { - let candidate = candidate.trim(); - candidate == "*" || weak_etag_value(candidate) == weak_etag_value(etag) - }) - }) -} - -fn second_precision(value: SystemTime) -> Option { - value - .duration_since(UNIX_EPOCH) - .ok() - .map(|duration| UNIX_EPOCH + Duration::from_secs(duration.as_secs())) -} - -fn not_modified(headers: &HeaderMap, etag: Option<&str>, modified: Option) -> bool { - if let (Some(etag), Some(value)) = (etag, headers.get(IF_NONE_MATCH)) { - return if_none_match_matches(value, etag); - } - if headers.contains_key(IF_NONE_MATCH) { - return false; - } - let Some(modified) = modified.and_then(second_precision) else { - return false; - }; - headers - .get(IF_MODIFIED_SINCE) - .and_then(|value| value.to_str().ok()) - .and_then(|value| httpdate::parse_http_date(value).ok()) - .is_some_and(|since| modified <= since) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct ByteRange { - start: u64, - end: u64, -} - -fn parse_range(value: &str, length: u64) -> Option { - let value = value.strip_prefix("bytes=")?.trim(); - if value.contains(',') || value.is_empty() || length == 0 { - return None; - } - let (start, end) = value.split_once('-')?; - if start.is_empty() { - let suffix: u64 = end.parse().ok()?; - if suffix == 0 { - return None; - } - let size = suffix.min(length); - return Some(ByteRange { - start: length - size, - end: length - 1, - }); - } - let start: u64 = start.parse().ok()?; - if start >= length { - return None; - } - let end = if end.is_empty() { - length - 1 - } else { - end.parse::().ok()?.min(length - 1) - }; - (end >= start).then_some(ByteRange { start, end }) -} - -fn if_range_allows(headers: &HeaderMap, etag: Option<&str>, modified: Option) -> bool { - let Some(value) = headers.get(IF_RANGE).and_then(|value| value.to_str().ok()) else { - return true; - }; - let value = value.trim(); - if value.starts_with('"') || value.starts_with("W/") { - return etag.is_some_and(|etag| !value.starts_with("W/") && value == etag); - } - let Some(modified) = modified.and_then(second_precision) else { - return false; - }; - httpdate::parse_http_date(value) - .ok() - .is_some_and(|date| modified <= date) -} - -fn add_validators(response: &mut Response, etag: Option<&str>, modified: Option) { - if let Some(etag) = etag.and_then(|value| HeaderValue::from_str(value).ok()) { - response.headers_mut().insert(ETAG, etag); - } - if let Some(modified) = modified { - if let Ok(value) = HeaderValue::from_str(&httpdate::fmt_http_date(modified)) { - response.headers_mut().insert(LAST_MODIFIED, value); - } - } -} - -async fn file_response( - service: &CompiledService, - opened: Opened, - path: &Path, - method: &Method, - headers: &HeaderMap, - content_encoding: Option<&'static str>, -) -> Response { - let length = opened.metadata.len(); - let (etag, modified) = metadata_validators(&opened.metadata); - let mime = content_type(service, path); - if not_modified(headers, etag.as_deref(), modified) { - let mut response = Response::new(Body::empty()); - *response.status_mut() = StatusCode::NOT_MODIFIED; - add_validators(&mut response, etag.as_deref(), modified); - add_cache_control(&mut response, service, path, &mime); - add_download_header(&mut response, service, path); - if let Some(encoding) = content_encoding { - response - .headers_mut() - .insert(CONTENT_ENCODING, HeaderValue::from_static(encoding)); - response - .headers_mut() - .insert(VARY, HeaderValue::from_static("Accept-Encoding")); - } - response - .headers_mut() - .insert(ACCEPT_RANGES, HeaderValue::from_static("bytes")); - return response; - } - - let range_header = headers.get(RANGE).and_then(|value| value.to_str().ok()); - let range = if range_header.is_some() && if_range_allows(headers, etag.as_deref(), modified) { - match range_header.and_then(|value| parse_range(value, length)) { - Some(range) => Some(range), - None => { - let mut response = Response::new(Body::empty()); - *response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE; - response.headers_mut().insert( - CONTENT_RANGE, - HeaderValue::from_str(&format!("bytes */{length}")).unwrap(), - ); - response - .headers_mut() - .insert(ACCEPT_RANGES, HeaderValue::from_static("bytes")); - response - .headers_mut() - .insert(CONTENT_LENGTH, HeaderValue::from_static("0")); - add_validators(&mut response, etag.as_deref(), modified); - add_cache_control(&mut response, service, path, &mime); - add_download_header(&mut response, service, path); - if let Some(encoding) = content_encoding { - response - .headers_mut() - .insert(CONTENT_ENCODING, HeaderValue::from_static(encoding)); - response - .headers_mut() - .insert(VARY, HeaderValue::from_static("Accept-Encoding")); - } - return response; - } - } - } else { - None - }; - - let (status, start, body_length) = match range { - Some(range) => ( - StatusCode::PARTIAL_CONTENT, - range.start, - range.end - range.start + 1, - ), - None => (StatusCode::OK, 0, length), - }; - let mut response = Response::new(Body::empty()); - *response.status_mut() = status; - response.headers_mut().insert(CONTENT_TYPE, mime.clone()); - response.headers_mut().insert( - CONTENT_LENGTH, - HeaderValue::from_str(&body_length.to_string()).unwrap(), - ); - response - .headers_mut() - .insert(ACCEPT_RANGES, HeaderValue::from_static("bytes")); - if let Some(range) = range { - response.headers_mut().insert( - CONTENT_RANGE, - HeaderValue::from_str(&format!("bytes {}-{}/{length}", range.start, range.end)) - .unwrap(), - ); - } - add_validators(&mut response, etag.as_deref(), modified); - add_cache_control(&mut response, service, path, &mime); - add_download_header(&mut response, service, path); - if let Some(encoding) = content_encoding { - response - .headers_mut() - .insert(CONTENT_ENCODING, HeaderValue::from_static(encoding)); - response - .headers_mut() - .insert(VARY, HeaderValue::from_static("Accept-Encoding")); - } - if method == Method::HEAD || body_length == 0 { - return response; - } - - let mut file = tokio::fs::File::from_std(opened.file); - if start != 0 { - if file.seek(std::io::SeekFrom::Start(start)).await.is_err() { - return status_response( - StatusCode::INTERNAL_SERVER_ERROR, - "RustProxy: Web Server seek failed\n", - ); - } - } - let stream = ReaderStream::new(file.take(body_length)); - *response.body_mut() = Body::from_stream(stream); - response -} - -fn percent_encode_segment(value: &str) -> String { - let mut out = String::with_capacity(value.len()); - for byte in value.as_bytes() { - if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { - out.push(*byte as char); - } else { - use std::fmt::Write; - let _ = write!(&mut out, "%{byte:02X}"); - } - } - out -} - -fn html_escape(value: &str) -> String { - let mut out = String::with_capacity(value.len()); - for ch in value.chars() { - match ch { - '&' => out.push_str("&"), - '<' => out.push_str("<"), - '>' => out.push_str(">"), - '"' => out.push_str("""), - '\'' => out.push_str("'"), - _ => out.push(ch), - } - } - out -} - -fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec { - let title = html_escape(path); - let mut html = String::with_capacity(4096 + entries.len() * 180); - html.push_str(r#"Index of "#); - html.push_str(&title); - html.push_str(r#" · RustProxy
                    RustProxyWEB SERVER
                    "#); - html.push_str(&title); - html.push_str(r#"
                    DIRECTORY LISTING

                    Index of "#); - html.push_str(&title); - html.push_str("

                    "); - html.push_str(&format!("{} entries", entries.len())); - html.push_str("
                    "); - if path != "/" { - html.push_str(r#"UP../Parent"#); - } - for entry in entries { - let label = html_escape(&entry.name); - let mut href = percent_encode_segment(&entry.name); - if entry.directory { - href.push('/'); - } - let href = html_escape(&href); - let slash = if entry.directory { "/" } else { "" }; - let class = if entry.directory { - "entry directory" - } else { - "entry file" - }; - let kind = if entry.directory { "Directory" } else { "File" }; - let type_label = if entry.directory { "DIR" } else { "FILE" }; - html.push_str(&format!( - "{type_label}{label}{slash}{kind}" - )); - } - html.push_str(r#"
                    RustProxy 0.25.0Web Server · Directory listing
                    "#); - html.into_bytes() -} - -fn directory_redirect(uri: &Uri, method: &Method) -> Response { - let mut target = uri.path().to_owned(); - if !target.ends_with('/') { - target.push('/'); - } - if let Some(query) = uri.query() { - target.push('?'); - target.push_str(query); - } - let mut response = Response::new(Body::empty()); - *response.status_mut() = if *method == Method::GET || *method == Method::HEAD { - StatusCode::MOVED_PERMANENTLY - } else { - StatusCode::PERMANENT_REDIRECT - }; - if let Ok(value) = HeaderValue::from_str(&target) { - response.headers_mut().insert(LOCATION, value); - } - response -} - -#[derive(Debug, Clone)] -pub struct RequestContext { - pub client_ip: IpAddr, - pub client_port: Option, - pub server_name: String, - pub server_port: u16, - pub tls: bool, - pub version: Version, -} - -async fn open_components(service: &CompiledService, components: Vec) -> io::Result { - let service = service.clone(); - tokio::task::spawn_blocking(move || secure_open_sync(&service, &components)) - .await - .map_err(|_| io::Error::other("Web Server filesystem task failed"))? -} - -fn target_path_and_query( - raw: &str, - uri_path: &str, - query: Option<&str>, -) -> Result<(String, Option)> { - let query_string = query.unwrap_or(""); - let expanded = raw - .replace("$uri", uri_path) - .replace("$query_string", query_string); - let (path, query) = match expanded.split_once('?') { - Some((path, query)) => (path.to_owned(), Some(query.to_owned())), - None => (expanded, query.map(str::to_owned)), - }; - ensure!( - path.starts_with('/'), - "Web Server internal target must start with /" - ); - crate::acl::validate_decoded_routing_path(&path) - .map_err(|_| anyhow::anyhow!("invalid Web Server internal target"))?; - Ok((path, query)) -} - -enum RewriteOutcome { - Internal { path: String, query: Option }, - Redirect(Response), -} - -fn apply_rewrites( - service: &CompiledService, - path: &str, - query: Option<&str>, -) -> Result { - let mut path = path.to_owned(); - let mut query = query.map(str::to_owned); - for rule in &service.rewrites { - if !rule.pattern.is_match(&path) { - continue; - } - let replacement = rule - .pattern - .replace(&path, rule.replacement.as_str()) - .to_string(); - let (next_path, next_query) = target_path_and_query(&replacement, &path, query.as_deref())?; - if let Some(status) = rule.status { - let mut target = next_path; - if let Some(query) = next_query.filter(|value| !value.is_empty()) { - target.push('?'); - target.push_str(&query); - } - let mut response = Response::new(Body::empty()); - *response.status_mut() = status; - response - .headers_mut() - .insert(LOCATION, HeaderValue::from_str(&target)?); - return Ok(RewriteOutcome::Redirect(response)); - } - path = next_path; - query = next_query; - } - Ok(RewriteOutcome::Internal { path, query }) -} - -enum TryFilesOutcome { - Path { path: String, query: Option }, - Status(StatusCode), -} - -async fn resolve_try_files( - service: &CompiledService, - path: &str, - query: Option<&str>, -) -> Result { - if service.try_files.is_empty() { - return Ok(TryFilesOutcome::Path { - path: path.to_owned(), - query: query.map(str::to_owned), - }); - } - for (index, raw) in service.try_files.iter().enumerate() { - if let Some(status) = raw.strip_prefix('=') { - let status = StatusCode::from_u16(status.parse::()?)?; - return Ok(TryFilesOutcome::Status(status)); - } - let (candidate, candidate_query) = target_path_and_query(raw, path, query)?; - let last = index + 1 == service.try_files.len(); - if last { - return Ok(TryFilesOutcome::Path { - path: candidate, - query: candidate_query, - }); - } - let components = path_components(&candidate)?; - if open_components(service, components).await.is_ok() { - return Ok(TryFilesOutcome::Path { - path: candidate, - query: candidate_query, - }); - } - } - Ok(TryFilesOutcome::Status(StatusCode::NOT_FOUND)) -} - -async fn select_precompressed( - service: &CompiledService, - components: &[String], - headers: &HeaderMap, -) -> Option<(Opened, &'static str)> { - if !service.precompressed.enabled || components.is_empty() { - return None; - } - let candidates = [ - (service.precompressed.brotli, "br", ".br"), - (service.precompressed.gzip, "gzip", ".gz"), - ]; - for (enabled, encoding, suffix) in candidates { - if !enabled || !accepts_content_encoding(headers, encoding) { - continue; - } - let mut sidecar = components.to_vec(); - let Some(last) = sidecar.last_mut() else { - continue; - }; - last.push_str(suffix); - if let Ok(opened) = open_components(service, sidecar).await { - if opened.metadata.is_file() { - return Some((opened, encoding)); - } - } - } - None -} - -const FCGI_VERSION_1: u8 = 1; -const FCGI_BEGIN_REQUEST: u8 = 1; -const FCGI_END_REQUEST: u8 = 3; -const FCGI_PARAMS: u8 = 4; -const FCGI_STDIN: u8 = 5; -const FCGI_STDOUT: u8 = 6; -const FCGI_STDERR: u8 = 7; -const FCGI_RESPONDER: u16 = 1; -const FCGI_REQUEST_ID: u16 = 1; - -trait FastCgiIo: AsyncRead + AsyncWrite + Unpin + Send {} -impl FastCgiIo for T where T: AsyncRead + AsyncWrite + Unpin + Send {} - -async fn connect_fastcgi(endpoint: &FastCgiEndpoint) -> io::Result> { - match endpoint { - #[cfg(unix)] - FastCgiEndpoint::Unix(path) => Ok(Box::new(tokio::net::UnixStream::connect(path).await?)), - FastCgiEndpoint::Tcp(address) => { - Ok(Box::new(tokio::net::TcpStream::connect(address).await?)) - } - } -} - -async fn write_fcgi_record( - io: &mut dyn FastCgiIo, - record_type: u8, - content: &[u8], -) -> io::Result<()> { - if content.len() > u16::MAX as usize { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "FastCGI record is too large", - )); - } - let padding = (8 - (content.len() % 8)) % 8; - let mut header = [0u8; 8]; - header[0] = FCGI_VERSION_1; - header[1] = record_type; - header[2..4].copy_from_slice(&FCGI_REQUEST_ID.to_be_bytes()); - header[4..6].copy_from_slice(&(content.len() as u16).to_be_bytes()); - header[6] = padding as u8; - io.write_all(&header).await?; - io.write_all(content).await?; - if padding != 0 { - io.write_all(&[0u8; 8][..padding]).await?; - } - Ok(()) -} - -fn encode_fcgi_length(target: &mut Vec, length: usize) -> io::Result<()> { - if length < 128 { - target.push(length as u8); - } else if length <= 0x7fff_ffff { - target.extend_from_slice(&((length as u32) | 0x8000_0000).to_be_bytes()); - } else { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "FastCGI parameter is too large", - )); - } - Ok(()) -} - -fn encode_fcgi_param(target: &mut Vec, name: &str, value: &str) -> io::Result<()> { - encode_fcgi_length(target, name.len())?; - encode_fcgi_length(target, value.len())?; - target.extend_from_slice(name.as_bytes()); - target.extend_from_slice(value.as_bytes()); - Ok(()) -} - -fn server_protocol(version: Version) -> &'static str { - match version { - Version::HTTP_09 => "HTTP/0.9", - Version::HTTP_10 => "HTTP/1.0", - Version::HTTP_11 => "HTTP/1.1", - Version::HTTP_2 => "HTTP/2.0", - Version::HTTP_3 => "HTTP/3.0", - _ => "HTTP/1.1", - } -} - -fn fastcgi_header_param(name: &HeaderName) -> Option { - let lower = name.as_str(); - if matches!( - lower, - "content-type" - | "content-length" - | "connection" - | "transfer-encoding" - | "upgrade" - | "keep-alive" - | "proxy-connection" - | "vary" - | "te" - | "trailer" - ) { - return None; - } - Some(format!( - "HTTP_{}", - lower.replace('-', "_").to_ascii_uppercase() - )) -} - -fn fastcgi_params( - service: &CompiledService, - php: &CompiledPhp, - method: &Method, - headers: &HeaderMap, - original_uri: &Uri, - script_path: &str, - query: Option<&str>, - context: &RequestContext, -) -> Result> { - let components = path_components(script_path)?; - let mut script_filename = service.root.clone(); - for component in &components { - script_filename.push(component); - } - let document_root = service.root.to_string_lossy().to_string(); - let script_filename = script_filename.to_string_lossy().to_string(); - let request_uri = original_uri - .path_and_query() - .map(|value| value.as_str()) - .unwrap_or(original_uri.path()); - let mut params = BTreeMap::::new(); - params.insert("REQUEST_METHOD".into(), method.as_str().into()); - params.insert("SCRIPT_FILENAME".into(), script_filename); - params.insert("SCRIPT_NAME".into(), script_path.into()); - params.insert("QUERY_STRING".into(), query.unwrap_or("").into()); - params.insert("REQUEST_URI".into(), request_uri.into()); - params.insert("DOCUMENT_ROOT".into(), document_root); - params.insert( - "SERVER_PROTOCOL".into(), - server_protocol(context.version).into(), - ); - params.insert("SERVER_NAME".into(), context.server_name.clone()); - params.insert("SERVER_PORT".into(), context.server_port.to_string()); - params.insert("REMOTE_ADDR".into(), context.client_ip.to_string()); - if let Some(port) = context.client_port { - params.insert("REMOTE_PORT".into(), port.to_string()); - } - params.insert( - "HTTPS".into(), - if context.tls { "on" } else { "off" }.into(), - ); - params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into()); - params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.25.0".into()); - params.insert("REDIRECT_STATUS".into(), "200".into()); - if let Some(value) = headers - .get(CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - { - params.insert("CONTENT_TYPE".into(), value.into()); - } - if let Some(value) = headers - .get(CONTENT_LENGTH) - .and_then(|value| value.to_str().ok()) - { - params.insert("CONTENT_LENGTH".into(), value.into()); - } - for (name, value) in headers { - // HTTP_PROXY is a well-known CGI/FastCGI injection vector (httpoxy). - // This must never depend on the optional Web Service hardening switch. - if name.as_str() == "proxy" || (!php.pass_authorization && name.as_str() == "authorization") - { - continue; - } - let Some(param) = fastcgi_header_param(name) else { - continue; - }; - let Ok(value) = value.to_str() else { continue }; - if value.len() > MAX_HEADER_VALUE_BYTES { - continue; - } - params - .entry(param) - .and_modify(|existing| { - if existing.len() + value.len() + 2 <= MAX_HEADER_VALUE_BYTES { - existing.push_str(", "); - existing.push_str(value); - } - }) - .or_insert_with(|| value.to_owned()); - } - // Trusted operator-defined parameters cannot override request-derived/core CGI names. - for (name, value) in &php.custom_params { - params.insert(name.clone(), value.clone()); - } - if !php.php_values.is_empty() { - let mut value = String::new(); - for (name, item) in &php.php_values { - if !value.is_empty() { - value.push('\n'); - } - value.push_str(name); - value.push('='); - value.push_str(item); - } - params.insert("PHP_VALUE".into(), value); - } - // Request headers cannot synthesize PHP_VALUE/PHP_ADMIN_VALUE because only HTTP_* - // names are derived above. PHP_VALUE comes exclusively from validated operator config. - let mut encoded = Vec::with_capacity(4096); - for (name, value) in params { - encode_fcgi_param(&mut encoded, &name, &value)?; - ensure!( - encoded.len() <= 512 * 1024, - "FastCGI parameter block exceeds 512 KiB" - ); - } - Ok(encoded) -} - -async fn read_fcgi_record(io: &mut dyn FastCgiIo, deadline: Instant) -> io::Result<(u8, Vec)> { - let mut header = [0u8; 8]; - tokio::time::timeout_at(deadline, io.read_exact(&mut header)) - .await - .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "FastCGI response timeout"))??; - if header[0] != FCGI_VERSION_1 || u16::from_be_bytes([header[2], header[3]]) != FCGI_REQUEST_ID - { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "invalid FastCGI record header", - )); - } - let length = u16::from_be_bytes([header[4], header[5]]) as usize; - let padding = header[6] as usize; - let mut content = vec![0u8; length]; - if length != 0 { - tokio::time::timeout_at(deadline, io.read_exact(&mut content)) - .await - .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "FastCGI response timeout"))??; - } - if padding != 0 { - let mut discard = [0u8; 255]; - tokio::time::timeout_at(deadline, io.read_exact(&mut discard[..padding])) - .await - .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "FastCGI response timeout"))??; - } - Ok((header[1], content)) -} - -fn find_cgi_header_end(value: &[u8]) -> Option<(usize, usize)> { - value - .windows(4) - .position(|window| window == b"\r\n\r\n") - .map(|position| (position, 4)) - .or_else(|| { - value - .windows(2) - .position(|window| window == b"\n\n") - .map(|position| (position, 2)) - }) -} - -fn parse_cgi_headers(value: &[u8]) -> Result<(StatusCode, HeaderMap)> { - let text = std::str::from_utf8(value).context("PHP-FPM returned non-UTF-8 CGI headers")?; - let mut status = StatusCode::OK; - let mut headers = HeaderMap::new(); - for (index, line) in text.lines().enumerate() { - ensure!(index < 128, "PHP-FPM returned too many CGI headers"); - let line = line.trim_end_matches('\r'); - if line.is_empty() { - continue; - } - let (name, value) = line - .split_once(':') - .context("PHP-FPM returned a malformed CGI header")?; - if name.eq_ignore_ascii_case("status") { - let code = value - .trim() - .split_whitespace() - .next() - .context("PHP-FPM returned an empty Status header")? - .parse::()?; - ensure!( - (200..=599).contains(&code), - "PHP-FPM returned an unsupported informational/invalid status" - ); - status = StatusCode::from_u16(code)?; - continue; - } - let name = HeaderName::from_str(name.trim())?; - if matches!( - name.as_str(), - "connection" - | "content-length" - | "transfer-encoding" - | "upgrade" - | "keep-alive" - | "proxy-connection" - | "te" - | "trailer" - | "proxy-authenticate" - | "proxy-authorization" - ) { - continue; - } - let value = HeaderValue::from_str(value.trim())?; - headers.append(name, value); - } - Ok((status, headers)) -} - -async fn drain_fastcgi_body( - mut io: Box, - deadline: Instant, - tx: mpsc::Sender>, - initial: Vec, - send_body: bool, - stderr_log_bytes: usize, - _permit: OwnedSemaphorePermit, -) { - if send_body && !initial.is_empty() && tx.send(Ok(Bytes::from(initial))).await.is_err() { - return; - } - let mut stderr = Vec::new(); - loop { - match read_fcgi_record(io.as_mut(), deadline).await { - Ok((FCGI_STDOUT, content)) => { - if send_body - && !content.is_empty() - && tx.send(Ok(Bytes::from(content))).await.is_err() - { - return; - } - } - Ok((FCGI_STDERR, content)) => { - let room = stderr_log_bytes.saturating_sub(stderr.len()); - stderr.extend_from_slice(&content[..content.len().min(room)]); - } - Ok((FCGI_END_REQUEST, _)) => break, - Ok(_) => {} - Err(error) => { - if send_body { - let _ = tx.send(Err(error)).await; - } - return; - } - } - } - if !stderr.is_empty() { - tracing::warn!( - stderr = %String::from_utf8_lossy(&stderr), - "PHP-FPM FastCGI stderr" - ); - } -} - -#[derive(Debug)] -enum FastCgiRequestWriteError { - Io(io::Error), - BodyTooLarge, -} - -impl From for FastCgiRequestWriteError { - fn from(value: io::Error) -> Self { - Self::Io(value) - } -} - -async fn php_response( - service: &CompiledService, - php: &CompiledPhp, - method: Method, - headers: HeaderMap, - original_uri: Uri, - script_path: String, - query: Option, - body: Body, - context: RequestContext, -) -> Response { - if php.max_request_body_bytes > 0 { - if let Some(length) = headers - .get(CONTENT_LENGTH) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()) - { - if length > php.max_request_body_bytes { - return builtin_error_response(StatusCode::PAYLOAD_TOO_LARGE, &method); - } - } - } - let permit = match php.permits.clone().try_acquire_owned() { - Ok(permit) => permit, - Err(_) => { - let mut response = builtin_error_response(StatusCode::SERVICE_UNAVAILABLE, &method); - response - .headers_mut() - .insert("retry-after", HeaderValue::from_static("1")); - return response; - } - }; - let mut io = - match tokio::time::timeout(php.connect_timeout, connect_fastcgi(&php.endpoint)).await { - Ok(Ok(io)) => io, - Ok(Err(error)) => { - tracing::warn!(%error, service = %service.id, "PHP-FPM connection failed"); - return builtin_error_response(StatusCode::BAD_GATEWAY, &method); - } - Err(_) => return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method), - }; - let params = match fastcgi_params( - service, - php, - &method, - &headers, - &original_uri, - &script_path, - query.as_deref(), - &context, - ) { - Ok(params) => params, - Err(error) => { - tracing::warn!(%error, service = %service.id, "PHP-FPM parameter preparation failed"); - return builtin_error_response(StatusCode::BAD_GATEWAY, &method); - } - }; - let deadline = Instant::now() + php.request_timeout; - let exchange = async { - let mut begin = [0u8; 8]; - begin[..2].copy_from_slice(&FCGI_RESPONDER.to_be_bytes()); - write_fcgi_record(io.as_mut(), FCGI_BEGIN_REQUEST, &begin).await?; - for chunk in params.chunks(u16::MAX as usize) { - write_fcgi_record(io.as_mut(), FCGI_PARAMS, chunk).await?; - } - write_fcgi_record(io.as_mut(), FCGI_PARAMS, &[]).await?; - - let mut body = body; - let mut body_bytes = 0u64; - while let Some(frame) = body.frame().await { - let frame = frame.map_err(|error| { - FastCgiRequestWriteError::Io(io::Error::other(error.to_string())) - })?; - if let Ok(data) = frame.into_data() { - body_bytes = body_bytes.saturating_add(data.len() as u64); - if php.max_request_body_bytes > 0 && body_bytes > php.max_request_body_bytes { - return Err(FastCgiRequestWriteError::BodyTooLarge); - } - for chunk in data.chunks(u16::MAX as usize) { - write_fcgi_record(io.as_mut(), FCGI_STDIN, chunk).await?; - } - } - } - write_fcgi_record(io.as_mut(), FCGI_STDIN, &[]).await?; - io.flush().await?; - Ok::<(), FastCgiRequestWriteError>(()) - }; - match tokio::time::timeout_at(deadline, exchange).await { - Ok(Ok(())) => {} - Ok(Err(FastCgiRequestWriteError::BodyTooLarge)) => { - return builtin_error_response(StatusCode::PAYLOAD_TOO_LARGE, &method); - } - Ok(Err(FastCgiRequestWriteError::Io(error))) => { - tracing::warn!(%error, service = %service.id, "PHP-FPM request write failed"); - return builtin_error_response(StatusCode::BAD_GATEWAY, &method); - } - Err(_) => return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method), - } - - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - let (status, response_headers, initial_body) = loop { - let record = read_fcgi_record(io.as_mut(), deadline).await; - let (record_type, content) = match record { - Ok(value) => value, - Err(error) if error.kind() == io::ErrorKind::TimedOut => { - return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method) - } - Err(error) => { - tracing::warn!(%error, service = %service.id, "PHP-FPM response read failed"); - return builtin_error_response(StatusCode::BAD_GATEWAY, &method); - } - }; - match record_type { - FCGI_STDOUT => { - stdout.extend_from_slice(&content); - if stdout.len() > php.max_response_header_bytes { - return builtin_error_response(StatusCode::BAD_GATEWAY, &method); - } - if let Some((position, separator_len)) = find_cgi_header_end(&stdout) { - let body = stdout[position + separator_len..].to_vec(); - let parsed = parse_cgi_headers(&stdout[..position]); - match parsed { - Ok((status, headers)) => break (status, headers, body), - Err(error) => { - tracing::warn!(%error, service = %service.id, "PHP-FPM CGI headers rejected"); - return builtin_error_response(StatusCode::BAD_GATEWAY, &method); - } - } - } - } - FCGI_STDERR => { - let room = php.stderr_log_bytes.saturating_sub(stderr.len()); - stderr.extend_from_slice(&content[..content.len().min(room)]); - } - FCGI_END_REQUEST => { - if !stderr.is_empty() { - tracing::warn!(stderr = %String::from_utf8_lossy(&stderr), service = %service.id, "PHP-FPM FastCGI stderr"); - } - return builtin_error_response(StatusCode::BAD_GATEWAY, &method); - } - _ => {} - } - }; - if !stderr.is_empty() { - tracing::warn!(stderr = %String::from_utf8_lossy(&stderr), service = %service.id, "PHP-FPM FastCGI stderr"); - } - let send_body = method != Method::HEAD - && status != StatusCode::NO_CONTENT - && status != StatusCode::NOT_MODIFIED - && !status.is_informational(); - let (tx, rx) = mpsc::channel::>(8); - tokio::spawn(drain_fastcgi_body( - io, - deadline, - tx, - initial_body, - send_body, - php.stderr_log_bytes, - permit, - )); - let mut response = Response::new(if send_body { - Body::from_stream(stream::unfold(rx, |mut rx| async move { - rx.recv().await.map(|item| (item, rx)) - })) - } else { - Body::empty() - }); - *response.status_mut() = status; - *response.headers_mut() = response_headers; - response -} - -fn method_not_allowed_response() -> Response { - let mut response = status_response( - StatusCode::METHOD_NOT_ALLOWED, - "RustProxy: Web Server method is not allowed for a static resource\n", - ); - response - .headers_mut() - .insert(ALLOW, HeaderValue::from_static("GET, HEAD")); - response -} - -fn php_for_path<'a>(service: &'a CompiledService, path: &Path) -> Option<&'a CompiledPhp> { - let php = service.php.as_ref()?; - let extension = path - .extension() - .and_then(|value| value.to_str())? - .to_ascii_lowercase(); - (php.extensions.contains(&extension) - || htaccess::php_extension(service.htaccess.as_ref(), path)) - .then_some(php) -} - -fn protected_script_without_handler(service: &CompiledService, path: &Path) -> bool { - if service.php.is_some() { - return false; - } - path.extension() - .and_then(|value| value.to_str()) - .map(|value| value.to_ascii_lowercase()) - .is_some_and(|extension| service.script_extensions.contains(&extension)) -} - -async fn serve_inner( - service: &CompiledService, - method: Method, - headers: HeaderMap, - uri: Uri, - path: &str, - body: Body, - context: RequestContext, -) -> Response { - if crate::acl::validate_decoded_routing_path(path).is_err() { - return status_response( - StatusCode::BAD_REQUEST, - "RustProxy: invalid Web Server path\n", - ); - } - if hardening_denied_method(service, &method) || hardening_denied_path(service, path) { - return builtin_error_response(StatusCode::FORBIDDEN, &method); - } - if method != Method::GET && method != Method::HEAD && service.php.is_none() { - return method_not_allowed_response(); - } - - if path_components(path).ok().is_some_and(|components| { - components - .iter() - .any(|component| htaccess::protected_component(service.htaccess.as_ref(), component)) - }) || htaccess::denied(service.htaccess.as_ref(), path) - { - return builtin_error_response(StatusCode::FORBIDDEN, &method); - } - - let (rewritten_path, rewritten_query) = match apply_rewrites(service, path, uri.query()) { - Ok(RewriteOutcome::Redirect(response)) => return response, - Ok(RewriteOutcome::Internal { path, query }) => (path, query), - Err(_) => { - return status_response( - StatusCode::BAD_REQUEST, - "RustProxy: invalid Web Server rewrite target\n", - ) - } - }; - let (rewritten_path, rewritten_query) = match htaccess::apply_rewrites( - service.htaccess.as_ref(), - service, - &method, - &headers, - &uri, - &context, - &rewritten_path, - rewritten_query.as_deref(), - ) - .await - { - Ok(htaccess::RewriteOutcome::Redirect(response)) => return response, - Ok(htaccess::RewriteOutcome::Forbidden) => { - return builtin_error_response(StatusCode::FORBIDDEN, &method) - } - Ok(htaccess::RewriteOutcome::Internal { path, query }) => (path, query), - Err(_) => { - return status_response( - StatusCode::BAD_REQUEST, - "RustProxy: invalid .htaccess rewrite target\n", - ) - } - }; - if crate::acl::validate_decoded_routing_path(&rewritten_path).is_err() - || hardening_denied_path(service, &rewritten_path) - || path_components(&rewritten_path) - .ok() - .is_some_and(|components| { - components.iter().any(|component| { - htaccess::protected_component(service.htaccess.as_ref(), component) - }) - }) - || htaccess::denied(service.htaccess.as_ref(), &rewritten_path) - { - return builtin_error_response(StatusCode::FORBIDDEN, &method); - } - let (effective_path, effective_query) = - match resolve_try_files(service, &rewritten_path, rewritten_query.as_deref()).await { - Ok(TryFilesOutcome::Path { path, query }) => (path, query), - Ok(TryFilesOutcome::Status(status)) => return builtin_error_response(status, &method), - Err(_) => { - return status_response( - StatusCode::BAD_REQUEST, - "RustProxy: invalid Web Server try_files target\n", - ) - } - }; - - let components = match path_components(&effective_path) { - Ok(components) => components, - Err(error) => return io_response(&error), - }; - if hardening_denied_path(service, &effective_path) - || components - .iter() - .any(|component| htaccess::protected_component(service.htaccess.as_ref(), component)) - || htaccess::denied(service.htaccess.as_ref(), &effective_path) - { - return builtin_error_response(StatusCode::FORBIDDEN, &method); - } - let opened = match open_components(service, components.clone()).await { - Ok(opened) => opened, - Err(error) => return io_response(&error), - }; - let mut request_body = Some(body); - - if opened.metadata.is_file() { - let public_path = PathBuf::from(&effective_path); - if let Some(php) = php_for_path(service, &public_path) { - return php_response( - service, - php, - method, - headers, - uri, - effective_path, - effective_query, - request_body.take().unwrap_or_else(Body::empty), - context, - ) - .await; - } - if protected_script_without_handler(service, &public_path) { - return builtin_error_response(StatusCode::FORBIDDEN, &method); - } - if method != Method::GET && method != Method::HEAD { - return method_not_allowed_response(); - } - if let Some((compressed, encoding)) = - select_precompressed(service, &components, &headers).await - { - return file_response( - service, - compressed, - &public_path, - &method, - &headers, - Some(encoding), - ) - .await; - } - return file_response(service, opened, &public_path, &method, &headers, None).await; - } - - if !uri.path().ends_with('/') { - return directory_redirect(&uri, &method); - } - - for index in htaccess::index_files(service.htaccess.as_ref(), &components, &service.index) { - let mut candidate = components.clone(); - candidate.push(index.clone()); - match open_components(service, candidate.clone()).await { - Ok(index_opened) if index_opened.metadata.is_file() => { - let mut script_path = effective_path.trim_end_matches('/').to_owned(); - script_path.push('/'); - script_path.push_str(index); - if hardening_denied_path(service, &script_path) { - return builtin_error_response(StatusCode::FORBIDDEN, &method); - } - let public_path = PathBuf::from(&script_path); - if let Some(php) = php_for_path(service, &public_path) { - return php_response( - service, - php, - method, - headers, - uri, - script_path, - effective_query, - request_body.take().unwrap_or_else(Body::empty), - context, - ) - .await; - } - if protected_script_without_handler(service, &public_path) { - return builtin_error_response(StatusCode::FORBIDDEN, &method); - } - if method != Method::GET && method != Method::HEAD { - return method_not_allowed_response(); - } - if let Some((compressed, encoding)) = - select_precompressed(service, &candidate, &headers).await - { - return file_response( - service, - compressed, - &public_path, - &method, - &headers, - Some(encoding), - ) - .await; - } - return file_response(service, index_opened, &public_path, &method, &headers, None) - .await; - } - Ok(_) => continue, - Err(error) - if matches!( - error.kind(), - io::ErrorKind::NotFound | io::ErrorKind::NotADirectory - ) => - { - continue - } - Err(error) => return io_response(&error), - } - } - - if method != Method::GET && method != Method::HEAD { - return method_not_allowed_response(); - } - if !htaccess::directory_listing( - service.htaccess.as_ref(), - &components, - service.directory_listing, - ) { - return builtin_error_response(StatusCode::FORBIDDEN, &method); - } - let service_for_list = service.clone(); - let components_for_list = components.clone(); - let entries = match tokio::task::spawn_blocking(move || { - list_directory_sync(&service_for_list, &components_for_list) - }) - .await - { - Ok(Ok(entries)) => entries, - Ok(Err(error)) => return io_response(&error), - Err(_) => { - return status_response( - StatusCode::INTERNAL_SERVER_ERROR, - "RustProxy: Web Server directory task failed\n", - ) - } - }; - let entries: Vec<_> = entries - .into_iter() - .filter(|entry| { - if htaccess::protected_component(service.htaccess.as_ref(), &entry.name) { - return false; - } - let child_path = if effective_path.ends_with('/') { - format!("{effective_path}{}", entry.name) - } else { - format!("{effective_path}/{}", entry.name) - }; - !hardening_denied_path(service, &child_path) - }) - .collect(); - let listing = directory_listing(&effective_path, &entries); - let mut response = Response::new(if method == Method::HEAD { - Body::empty() - } else { - Body::from(listing.clone()) - }); - response.headers_mut().insert( - CONTENT_TYPE, - HeaderValue::from_static("text/html; charset=utf-8"), - ); - response.headers_mut().insert( - CONTENT_LENGTH, - HeaderValue::from_str(&listing.len().to_string()).unwrap(), - ); - response - .headers_mut() - .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); - response -} - -/// Serve a decoded, validated routing path from a compiled Web Service. -/// The caller is responsible for normal vhost/location ACL/auth/routing selection; -/// this function performs filesystem handling plus optional Web Service routing, -/// precompressed delivery and bounded FastCGI/PHP-FPM execution. -pub async fn serve( - service: &CompiledService, - method: Method, - headers: HeaderMap, - uri: Uri, - path: &str, - body: Body, - context: RequestContext, -) -> Response { - let mut response = serve_inner(service, method, headers, uri, path, body, context).await; - add_service_headers(&mut response, service); - htaccess::apply_response(service.htaccess.as_ref(), &mut response, path); - strip_unnecessary_headers(&mut response, service); - response -} - -#[cfg(test)] -mod tests { - use super::*; - use http_body_util::BodyExt; - use tempfile::TempDir; - - fn service(root: &Path, listing: bool) -> CompiledService { - Service { - id: "site".into(), - name: "Site".into(), - document_root: root.to_path_buf(), - index: vec!["index.html".into(), "index.htm".into()], - directory_listing: listing, - ..Default::default() - } - .compile() - .unwrap() - } - - fn request(method: Method, uri: &str) -> http::Request<()> { - http::Request::builder() - .method(method) - .uri(uri) - .body(()) - .unwrap() - } - - async fn serve_request( - service: &CompiledService, - request: &http::Request<()>, - path: &str, - ) -> Response { - serve_body_request(service, request, path, Body::empty()).await - } - - async fn serve_body_request( - service: &CompiledService, - request: &http::Request<()>, - path: &str, - body: Body, - ) -> Response { - serve( - service, - request.method().clone(), - request.headers().clone(), - request.uri().clone(), - path, - body, - RequestContext { - client_ip: "127.0.0.1".parse().unwrap(), - client_port: Some(12345), - server_name: "example.test".into(), - server_port: 80, - tls: false, - version: Version::HTTP_11, - }, - ) - .await - } - - #[tokio::test] - async fn get_head_index_and_directory_redirect_work() { - let tmp = TempDir::new().unwrap(); - std::fs::create_dir(tmp.path().join("dir")).unwrap(); - std::fs::write(tmp.path().join("index.html"), b"root").unwrap(); - std::fs::write(tmp.path().join("dir/index.html"), b"nested").unwrap(); - let service = service(tmp.path(), false); - - let response = serve_request(&service, &request(Method::GET, "/"), "/").await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); - assert_eq!( - response.into_body().collect().await.unwrap().to_bytes(), - "root" - ); - - let response = serve_request(&service, &request(Method::HEAD, "/"), "/").await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers()[CONTENT_LENGTH], "4"); - assert!(response - .into_body() - .collect() - .await - .unwrap() - .to_bytes() - .is_empty()); - - let response = serve_request(&service, &request(Method::GET, "/dir?x=1"), "/dir").await; - assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY); - assert_eq!(response.headers()[LOCATION], "/dir/?x=1"); - } - - #[tokio::test] - async fn missing_files_and_disabled_directory_listing_return_normal_errors() { - let tmp = TempDir::new().unwrap(); - std::fs::create_dir(tmp.path().join("empty")).unwrap(); - let service = service(tmp.path(), false); - - let missing = serve_request( - &service, - &request(Method::GET, "/missing.txt"), - "/missing.txt", - ) - .await; - assert_eq!(missing.status(), StatusCode::NOT_FOUND); - - let directory = serve_request(&service, &request(Method::GET, "/empty/"), "/empty/").await; - assert_eq!(directory.status(), StatusCode::FORBIDDEN); - assert_eq!( - directory.headers()[CONTENT_TYPE], - "text/html; charset=utf-8" - ); - let directory_body = directory.into_body().collect().await.unwrap().to_bytes(); - let directory_body = std::str::from_utf8(&directory_body).unwrap(); - assert!(directory_body.contains("403")); - assert!(!directory_body.contains("directory listing is disabled")); - - let directory_head = - serve_request(&service, &request(Method::HEAD, "/empty/"), "/empty/").await; - assert_eq!(directory_head.status(), StatusCode::FORBIDDEN); - assert!(directory_head - .into_body() - .collect() - .await - .unwrap() - .to_bytes() - .is_empty()); - - let method = serve_request( - &service, - &request(Method::POST, "/missing.txt"), - "/missing.txt", - ) - .await; - assert_eq!(method.status(), StatusCode::METHOD_NOT_ALLOWED); - assert_eq!(method.headers()[ALLOW], "GET, HEAD"); - } - - #[tokio::test] - async fn last_modified_condition_returns_304() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("file.txt"), b"validator").unwrap(); - let service = service(tmp.path(), false); - let first = serve_request(&service, &request(Method::GET, "/file.txt"), "/file.txt").await; - let last_modified = first.headers()[LAST_MODIFIED].clone(); - assert_eq!(first.headers()[CONTENT_TYPE], "text/plain; charset=utf-8"); - assert_eq!(first.headers()[CONTENT_LENGTH], "9"); - - let mut conditional = request(Method::GET, "/file.txt"); - conditional - .headers_mut() - .insert(IF_MODIFIED_SINCE, last_modified); - let response = serve_request(&service, &conditional, "/file.txt").await; - assert_eq!(response.status(), StatusCode::NOT_MODIFIED); - assert!(response.headers().contains_key(LAST_MODIFIED)); - } - - #[tokio::test] - async fn validators_and_single_ranges_work() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap(); - let service = service(tmp.path(), false); - let first = serve_request(&service, &request(Method::GET, "/file.txt"), "/file.txt").await; - let etag = first.headers()[ETAG].clone(); - assert_eq!(first.headers()[ACCEPT_RANGES], "bytes"); - - let mut conditional = request(Method::GET, "/file.txt"); - conditional.headers_mut().insert(IF_NONE_MATCH, etag); - assert_eq!( - serve_request(&service, &conditional, "/file.txt") - .await - .status(), - StatusCode::NOT_MODIFIED - ); - - let mut ranged = request(Method::GET, "/file.txt"); - ranged - .headers_mut() - .insert(RANGE, "bytes=2-5".parse().unwrap()); - let response = serve_request(&service, &ranged, "/file.txt").await; - assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!(response.headers()[CONTENT_RANGE], "bytes 2-5/10"); - assert_eq!(response.headers()[CONTENT_LENGTH], "4"); - assert_eq!( - response.into_body().collect().await.unwrap().to_bytes(), - "2345" - ); - - let mut invalid = request(Method::GET, "/file.txt"); - invalid - .headers_mut() - .insert(RANGE, "bytes=99-100".parse().unwrap()); - let response = serve_request(&service, &invalid, "/file.txt").await; - assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); - assert_eq!(response.headers()[CONTENT_RANGE], "bytes */10"); - - let mut if_range_match = request(Method::GET, "/file.txt"); - if_range_match - .headers_mut() - .insert(RANGE, "bytes=0-1".parse().unwrap()); - if_range_match - .headers_mut() - .insert(IF_RANGE, first.headers()[ETAG].clone()); - assert_eq!( - serve_request(&service, &if_range_match, "/file.txt") - .await - .status(), - StatusCode::PARTIAL_CONTENT - ); - - let mut if_range_miss = request(Method::GET, "/file.txt"); - if_range_miss - .headers_mut() - .insert(RANGE, "bytes=0-1".parse().unwrap()); - if_range_miss - .headers_mut() - .insert(IF_RANGE, "\"different\"".parse().unwrap()); - let response = serve_request(&service, &if_range_miss, "/file.txt").await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers()[CONTENT_LENGTH], "10"); - } - - #[tokio::test] - async fn listing_escapes_html_and_encodes_urls() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("a & b.txt"), b"x").unwrap(); - let service = service(tmp.path(), true); - let response = serve_request(&service, &request(Method::GET, "/"), "/").await; - let body = response.into_body().collect().await.unwrap().to_bytes(); - let body = std::str::from_utf8(&body).unwrap(); - assert!(body.contains("a & b.txt")); - assert!(body.contains("a%20%26%20b.txt")); - assert!(!body.contains(tmp.path().to_string_lossy().as_ref())); - } - - #[tokio::test] - async fn root_directory_listing_is_repeatable_across_refreshes() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("first.txt"), b"one").unwrap(); - std::fs::write(tmp.path().join("second.txt"), b"two").unwrap(); - let mut configured = Service { - id: "repeat-listing".into(), - name: "Repeat listing".into(), - document_root: tmp.path().to_path_buf(), - index: Vec::new(), - directory_listing: true, - ..Default::default() - }; - configured.validate().unwrap(); - let service = configured.compile().unwrap(); - - for _ in 0..2 { - let response = serve_request(&service, &request(Method::GET, "/"), "/").await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers()[CACHE_CONTROL], "no-store"); - let body = response.into_body().collect().await.unwrap().to_bytes(); - let body = std::str::from_utf8(&body).unwrap(); - assert!(body.contains("first.txt")); - assert!(body.contains("second.txt")); - } - } - - #[tokio::test] - async fn index_precedes_listing_and_missing_index_falls_back_to_listing_per_directory() { - let tmp = TempDir::new().unwrap(); - std::fs::create_dir(tmp.path().join("with-index")).unwrap(); - std::fs::create_dir(tmp.path().join("without-index")).unwrap(); - std::fs::write(tmp.path().join("with-index/index.html"), b"INDEX-WINS").unwrap(); - std::fs::write(tmp.path().join("with-index/other.txt"), b"hidden by index").unwrap(); - std::fs::write(tmp.path().join("without-index/listed.txt"), b"listed").unwrap(); - let service = service(tmp.path(), true); - - let indexed = serve_request( - &service, - &request(Method::GET, "/with-index/"), - "/with-index/", - ) - .await; - assert_eq!(indexed.status(), StatusCode::OK); - assert_eq!( - indexed.into_body().collect().await.unwrap().to_bytes(), - "INDEX-WINS" - ); - - let listed = serve_request( - &service, - &request(Method::GET, "/without-index/"), - "/without-index/", - ) - .await; - assert_eq!(listed.status(), StatusCode::OK); - let body = listed.into_body().collect().await.unwrap().to_bytes(); - let body = std::str::from_utf8(&body).unwrap(); - assert!(body.contains("listed.txt")); - assert!(body.contains("DIRECTORY LISTING")); - } - - #[tokio::test] - async fn mime_overrides_default_mime_and_cache_control_are_applied_to_files() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("asset.foo"), b"foo").unwrap(); - std::fs::write(tmp.path().join("asset.unknown"), b"unknown").unwrap(); - let mut configured = Service { - id: "mime".into(), - name: "MIME".into(), - document_root: tmp.path().to_path_buf(), - mime_types: BTreeMap::from([(".FOO".into(), "application/x-foo".into())]), - default_mime_type: "application/x-fallback".into(), - cache_control: Some("public, max-age=3600".into()), - ..Default::default() - }; - configured.validate().unwrap(); - assert!(configured.mime_types.contains_key("foo")); - let service = configured.compile().unwrap(); - - let custom = - serve_request(&service, &request(Method::GET, "/asset.foo"), "/asset.foo").await; - assert_eq!(custom.headers()[CONTENT_TYPE], "application/x-foo"); - assert_eq!(custom.headers()[CACHE_CONTROL], "public, max-age=3600"); - - let fallback = serve_request( - &service, - &request(Method::GET, "/asset.unknown"), - "/asset.unknown", - ) - .await; - assert_eq!(fallback.headers()[CONTENT_TYPE], "application/x-fallback"); - assert_eq!(fallback.headers()[CACHE_CONTROL], "public, max-age=3600"); - } - - #[tokio::test] - async fn try_files_rewrites_cache_rules_headers_and_downloads_work() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("index.html"), b"SPA").unwrap(); - std::fs::write(tmp.path().join("style.css"), b"css").unwrap(); - std::fs::write(tmp.path().join("archive.zip"), b"zip").unwrap(); - let mut configured = Service { - id: "routing".into(), - name: "Routing".into(), - document_root: tmp.path().to_path_buf(), - try_files: vec!["$uri".into(), "/index.html?$query_string".into()], - rewrites: vec![ - RewriteRule { - pattern: "^/internal$".into(), - replacement: "/style.css".into(), - status: None, - }, - RewriteRule { - pattern: "^/old/(.*)$".into(), - replacement: "/new/$1".into(), - status: Some(301), - }, - ], - cache_control: Some("no-cache".into()), - cache_rules: vec![CacheRule { - matcher: "ext:css".into(), - cache_control: "public, max-age=3600".into(), - }], - response_headers: BTreeMap::from([("x-static-service".into(), "yes".into())]), - download_extensions: vec!["zip".into()], - ..Default::default() - }; - configured.validate().unwrap(); - let service = configured.compile().unwrap(); - - let spa = serve_request(&service, &request(Method::GET, "/missing?x=1"), "/missing").await; - assert_eq!(spa.status(), StatusCode::OK); - assert_eq!(spa.headers()[CACHE_CONTROL], "no-cache"); - assert_eq!(spa.headers()["x-static-service"], "yes"); - assert_eq!(spa.into_body().collect().await.unwrap().to_bytes(), "SPA"); - - let internal = - serve_request(&service, &request(Method::GET, "/internal"), "/internal").await; - assert_eq!(internal.status(), StatusCode::OK); - assert_eq!(internal.headers()[CONTENT_TYPE], "text/css; charset=utf-8"); - assert_eq!(internal.headers()[CACHE_CONTROL], "public, max-age=3600"); - - let redirect = - serve_request(&service, &request(Method::GET, "/old/path"), "/old/path").await; - assert_eq!(redirect.status(), StatusCode::MOVED_PERMANENTLY); - assert_eq!(redirect.headers()[LOCATION], "/new/path"); - - let download = serve_request( - &service, - &request(Method::GET, "/archive.zip"), - "/archive.zip", - ) - .await; - assert!(download.headers()[CONTENT_DISPOSITION] - .to_str() - .unwrap() - .starts_with("attachment;")); - } - - #[tokio::test] - async fn precompressed_sidecars_keep_mime_validators_and_range_semantics() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("app.js"), b"ORIGINAL").unwrap(); - std::fs::write(tmp.path().join("app.js.br"), b"BROTLI").unwrap(); - let mut configured = Service { - id: "compressed".into(), - name: "Compressed".into(), - document_root: tmp.path().to_path_buf(), - precompressed: Precompressed { - enabled: true, - brotli: true, - gzip: false, - }, - ..Default::default() - }; - configured.validate().unwrap(); - let service = configured.compile().unwrap(); - - let mut accepted = request(Method::GET, "/app.js"); - accepted - .headers_mut() - .insert(http::header::ACCEPT_ENCODING, "br".parse().unwrap()); - let response = serve_request(&service, &accepted, "/app.js").await; - assert_eq!(response.headers()[CONTENT_ENCODING], "br"); - assert_eq!( - response.headers()[CONTENT_TYPE], - "text/javascript; charset=utf-8" - ); - assert_eq!(response.headers()[VARY], "Accept-Encoding"); - assert_eq!( - response.into_body().collect().await.unwrap().to_bytes(), - "BROTLI" - ); - - let mut ranged = request(Method::GET, "/app.js"); - ranged - .headers_mut() - .insert(http::header::ACCEPT_ENCODING, "br".parse().unwrap()); - ranged - .headers_mut() - .insert(RANGE, "bytes=0-2".parse().unwrap()); - let response = serve_request(&service, &ranged, "/app.js").await; - assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); - assert_eq!(response.headers()[CONTENT_RANGE], "bytes 0-2/6"); - assert_eq!( - response.into_body().collect().await.unwrap().to_bytes(), - "BRO" - ); - - let plain = serve_request(&service, &request(Method::GET, "/app.js"), "/app.js").await; - assert!(!plain.headers().contains_key(CONTENT_ENCODING)); - assert_eq!( - plain.into_body().collect().await.unwrap().to_bytes(), - "ORIGINAL" - ); - } - - async fn fastcgi_fixture(response_body: &'static [u8]) -> std::net::SocketAddr { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let address = listener.local_addr().unwrap(); - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - loop { - let (kind, content) = read_fcgi_record(&mut stream, deadline).await.unwrap(); - if kind == FCGI_STDIN && content.is_empty() { - break; - } - } - let mut stdout = b"Status: 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nX-PHP-FPM: fixture\r\n\r\n".to_vec(); - stdout.extend_from_slice(response_body); - write_fcgi_record(&mut stream, FCGI_STDOUT, &stdout) - .await - .unwrap(); - write_fcgi_record(&mut stream, FCGI_STDOUT, &[]) - .await - .unwrap(); - write_fcgi_record(&mut stream, FCGI_END_REQUEST, &[0u8; 8]) - .await - .unwrap(); - stream.flush().await.unwrap(); - }); - address - } - - #[tokio::test] - async fn php_fpm_executes_index_and_try_files_front_controller_without_source_disclosure() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("index.php"), b"0.25.0
                    ")); - } - - #[tokio::test] - async fn traversal_and_symlink_escape_are_not_served() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("ok.txt"), b"ok").unwrap(); - let service = service(tmp.path(), false); - assert_eq!( - serve_request(&service, &request(Method::GET, "/../secret"), "/../secret") - .await - .status(), - StatusCode::BAD_REQUEST - ); - - #[cfg(unix)] - { - use std::os::unix::fs::symlink; - let outside = TempDir::new().unwrap(); - std::fs::write(outside.path().join("secret.txt"), b"secret").unwrap(); - symlink( - outside.path().join("secret.txt"), - tmp.path().join("escape.txt"), - ) - .unwrap(); - let response = serve_request( - &service, - &request(Method::GET, "/escape.txt"), - "/escape.txt", - ) - .await; - assert_ne!(response.status(), StatusCode::OK); - } - } - #[tokio::test] - async fn htaccess_wordpress_style_front_controller_and_protected_files_work() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("index.html"), b"front-controller").unwrap(); - std::fs::write(tmp.path().join("existing.txt"), b"existing").unwrap(); - std::fs::write( - tmp.path().join(".htaccess"), - br#" -RewriteEngine On -RewriteCond %{REQUEST_FILENAME} !-f -RewriteCond %{REQUEST_FILENAME} !-d -RewriteRule . /index.html [L] - -"#, - ) - .unwrap(); - let mut configured = Service { - id: "htaccess-rewrite".into(), - name: "htaccess rewrite".into(), - document_root: tmp.path().to_path_buf(), - htaccess: htaccess::Config { - enabled: true, - ..Default::default() - }, - ..Default::default() - }; - configured.validate().unwrap(); - let service = configured.compile().unwrap(); - - let rewritten = - serve_request(&service, &request(Method::GET, "/missing"), "/missing").await; - assert_eq!(rewritten.status(), StatusCode::OK); - assert_eq!( - rewritten.into_body().collect().await.unwrap().to_bytes(), - "front-controller" - ); - - let existing = serve_request( - &service, - &request(Method::GET, "/existing.txt"), - "/existing.txt", - ) - .await; - assert_eq!(existing.status(), StatusCode::OK); - assert_eq!( - existing.into_body().collect().await.unwrap().to_bytes(), - "existing" - ); - - let protected = - serve_request(&service, &request(Method::GET, "/.htaccess"), "/.htaccess").await; - assert_eq!(protected.status(), StatusCode::FORBIDDEN); - } - - #[tokio::test] - async fn htaccess_directory_index_mime_headers_expires_and_nested_scope_work() { - let tmp = TempDir::new().unwrap(); - std::fs::create_dir(tmp.path().join("docs")).unwrap(); - std::fs::write(tmp.path().join("docs/home.foo"), b"nested").unwrap(); - std::fs::write( - tmp.path().join("docs/.htaccess"), - br#"DirectoryIndex home.foo -Options -Indexes -AddType text/x-foo .foo -Header always set X-Htaccess yes -ExpiresActive On -ExpiresByType text/x-foo "access plus 1 hour" -"#, - ) - .unwrap(); - let mut configured = Service { - id: "htaccess-nested".into(), - name: "htaccess nested".into(), - document_root: tmp.path().to_path_buf(), - index: vec!["index.html".into()], - directory_listing: true, - htaccess: htaccess::Config { - enabled: true, - ..Default::default() - }, - ..Default::default() - }; - configured.validate().unwrap(); - let service = configured.compile().unwrap(); - let response = serve_request(&service, &request(Method::GET, "/docs/"), "/docs/").await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers()[CONTENT_TYPE], "text/x-foo"); - assert_eq!(response.headers()["x-htaccess"], "yes"); - assert_eq!(response.headers()[CACHE_CONTROL], "public, max-age=3600"); - assert!(response.headers().contains_key(http::header::EXPIRES)); - assert_eq!( - response.into_body().collect().await.unwrap().to_bytes(), - "nested" - ); - } - - #[tokio::test] - async fn php_source_extensions_are_not_served_without_php_fpm() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("index.php"), b" -SetHandler application/x-httpd-php - -"#, - ) - .unwrap(); - let mut configured = Service { - id: "htaccess-filesmatch".into(), - name: "htaccess filesmatch".into(), - document_root: tmp.path().to_path_buf(), - htaccess: htaccess::Config { - enabled: true, - strict: false, - ..Default::default() - }, - ..Default::default() - }; - configured.validate().unwrap(); - let error = configured.compile().unwrap_err().to_string(); - assert!(error.contains("Files/FilesMatch")); - } - - #[test] - fn htaccess_strict_mode_rejects_unsupported_directives() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("index.html"), b"ok").unwrap(); - std::fs::write( - tmp.path().join(".htaccess"), - b"ErrorDocument 404 /404.html\n", - ) - .unwrap(); - let mut configured = Service { - id: "htaccess-strict".into(), - name: "htaccess strict".into(), - document_root: tmp.path().to_path_buf(), - htaccess: htaccess::Config { - enabled: true, - strict: true, - ..Default::default() - }, - ..Default::default() - }; - configured.validate().unwrap(); - assert!(configured - .compile() - .unwrap_err() - .to_string() - .contains("ErrorDocument")); - } - - #[cfg(unix)] - #[test] - fn htaccess_configuration_file_cannot_be_a_symlink() { - use std::os::unix::fs::symlink; - let tmp = TempDir::new().unwrap(); - let outside = TempDir::new().unwrap(); - std::fs::write(outside.path().join("outside.conf"), b"RewriteEngine On\n").unwrap(); - symlink( - outside.path().join("outside.conf"), - tmp.path().join(".htaccess"), - ) - .unwrap(); - let mut configured = Service { - id: "htaccess-symlink".into(), - name: "htaccess symlink".into(), - document_root: tmp.path().to_path_buf(), - htaccess: htaccess::Config { - enabled: true, - ..Default::default() - }, - ..Default::default() - }; - configured.validate().unwrap(); - let error = configured.compile().unwrap_err().to_string(); - assert!(error.contains("must not be a symlink")); - } - - #[test] - fn fastcgi_never_forwards_proxy_header_as_http_proxy() { - let tmp = TempDir::new().unwrap(); - let mut configured = Service { - id: "fastcgi-params".into(), - name: "FastCGI params".into(), - document_root: tmp.path().to_path_buf(), - php: Php { - enabled: true, - endpoint: "tcp:127.0.0.1:9000".into(), - ..Default::default() - }, - ..Default::default() - }; - configured.validate().unwrap(); - let service = configured.compile().unwrap(); - let php = service.php.as_ref().unwrap(); - let mut headers = HeaderMap::new(); - headers.insert( - "proxy", - HeaderValue::from_static("http://attacker.invalid:8080"), - ); - headers.insert("user-agent", HeaderValue::from_static("security-test")); - let uri: Uri = "/index.php?q=1".parse().unwrap(); - let encoded = fastcgi_params( - &service, - php, - &Method::GET, - &headers, - &uri, - "/index.php", - Some("q=1"), - &RequestContext { - client_ip: "127.0.0.1".parse().unwrap(), - client_port: Some(12345), - server_name: "example.test".into(), - server_port: 80, - tls: false, - version: Version::HTTP_11, - }, - ) - .unwrap(); - assert!(!encoded - .windows(b"HTTP_PROXY".len()) - .any(|window| window == b"HTTP_PROXY")); - assert!(encoded - .windows(b"HTTP_USER_AGENT".len()) - .any(|window| window == b"HTTP_USER_AGENT")); - } - - #[test] - fn php_custom_fastcgi_params_and_php_values_are_validated() { - let tmp = TempDir::new().unwrap(); - let mut configured = Service { - id: "php-custom".into(), - name: "PHP custom".into(), - document_root: tmp.path().to_path_buf(), - php: Php { - enabled: true, - endpoint: "tcp:127.0.0.1:9000".into(), - pass_authorization: false, - custom_params: BTreeMap::from([("app_env".into(), "production".into())]), - php_values: BTreeMap::from([("memory_limit".into(), "256M".into())]), - ..Default::default() - }, - ..Default::default() - }; - configured.validate().unwrap(); - assert!(configured.php.custom_params.contains_key("APP_ENV")); - let service = configured.compile().unwrap(); - let php = service.php.as_ref().unwrap(); - let mut headers = HeaderMap::new(); - headers.insert("authorization", HeaderValue::from_static("Bearer secret")); - let uri: Uri = "/index.php".parse().unwrap(); - let encoded = fastcgi_params( - &service, - php, - &Method::GET, - &headers, - &uri, - "/index.php", - None, - &RequestContext { - client_ip: "127.0.0.1".parse().unwrap(), - client_port: None, - server_name: "example.test".into(), - server_port: 443, - tls: true, - version: Version::HTTP_2, - }, - ) - .unwrap(); - assert!(encoded - .windows(b"APP_ENV".len()) - .any(|window| window == b"APP_ENV")); - assert!(encoded - .windows(b"production".len()) - .any(|window| window == b"production")); - assert!(encoded - .windows(b"PHP_VALUE".len()) - .any(|window| window == b"PHP_VALUE")); - assert!(encoded - .windows(b"memory_limit=256M".len()) - .any(|window| window == b"memory_limit=256M")); - assert!(!encoded - .windows(b"HTTP_AUTHORIZATION".len()) - .any(|window| window == b"HTTP_AUTHORIZATION")); - } - - #[test] - fn php_custom_fastcgi_params_cannot_override_core_or_php_admin_values() { - let tmp = TempDir::new().unwrap(); - for name in [ - "SCRIPT_FILENAME", - "HTTP_HOST", - "PHP_VALUE", - "PHP_ADMIN_VALUE", - ] { - let mut configured = Service { - id: "php-reserved".into(), - name: "PHP reserved".into(), - document_root: tmp.path().to_path_buf(), - php: Php { - custom_params: BTreeMap::from([(name.into(), "bad".into())]), - ..Default::default() - }, - ..Default::default() - }; - assert!(configured - .validate() - .unwrap_err() - .to_string() - .contains("reserved name")); - } - } - - #[test] - fn php_custom_fastcgi_data_has_a_combined_bound() { - let tmp = TempDir::new().unwrap(); - let custom_params = (0..17) - .map(|index| (format!("APP_{index}"), "x".repeat(8192))) - .collect(); - let mut configured = Service { - id: "php-custom-bound".into(), - name: "PHP custom bound".into(), - document_root: tmp.path().to_path_buf(), - php: Php { - custom_params, - ..Default::default() - }, - ..Default::default() - }; - assert!(configured - .validate() - .unwrap_err() - .to_string() - .contains("at most 128 KiB")); - } - - #[test] - fn fastcgi_response_cannot_control_downstream_framing_or_hop_headers() { - let (_, headers) = parse_cgi_headers( - b"Content-Type: text/plain\r\nContent-Length: 999999\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\nTE: trailers\r\nTrailer: X-Test\r\nProxy-Authenticate: Basic realm=x\r\nProxy-Authorization: secret\r\nX-App: ok\r\n", - ) - .unwrap(); - assert_eq!(headers.get("x-app").unwrap(), "ok"); - for name in [ - "content-length", - "transfer-encoding", - "connection", - "te", - "trailer", - "proxy-authenticate", - "proxy-authorization", - ] { - assert!( - !headers.contains_key(name), - "unexpected FastCGI framing header: {name}" - ); - } - } - - #[test] - fn fastcgi_response_rejects_informational_status_codes() { - let error = - parse_cgi_headers(b"Status: 101 Switching Protocols\r\nContent-Type: text/plain\r\n") - .unwrap_err() - .to_string(); - assert!(error.contains("unsupported informational/invalid status")); - } - - #[tokio::test] - async fn hardening_blocks_common_probe_paths_and_trace() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("index.html"), b"ok").unwrap(); - std::fs::write(tmp.path().join(".env"), b"secret").unwrap(); - std::fs::write(tmp.path().join("backup.sql"), b"secret").unwrap(); - std::fs::write(tmp.path().join("public.txt"), b"public").unwrap(); - let mut configured = Service { - id: "hardened".into(), - name: "hardened".into(), - document_root: tmp.path().to_path_buf(), - index: vec![], - directory_listing: true, - hardening: Hardening { - block_common_attacks: true, - strip_unnecessary_headers: false, - }, - ..Default::default() - }; - configured.validate().unwrap(); - let service = configured.compile().unwrap(); - for path in [ - "/.env", - "/wp-config.php", - "/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php", - "/backup.sql", - ] { - let response = serve_request(&service, &request(Method::GET, path), path).await; - assert_eq!(response.status(), StatusCode::FORBIDDEN, "{path}"); - } - let trace = serve_request(&service, &request(Method::TRACE, "/"), "/").await; - assert_eq!(trace.status(), StatusCode::FORBIDDEN); - let listing = serve_request(&service, &request(Method::GET, "/"), "/").await; - let listing = - String::from_utf8_lossy(&listing.into_body().collect().await.unwrap().to_bytes()) - .to_string(); - assert!(listing.contains("public.txt")); - assert!(!listing.contains(".env")); - assert!(!listing.contains("backup.sql")); - let well_known = serve_request( - &service, - &request(Method::GET, "/.well-known/missing"), - "/.well-known/missing", - ) - .await; - assert_eq!(well_known.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn hardening_strips_fingerprint_headers_after_source_rules() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("index.html"), b"ok").unwrap(); - let mut configured = Service { - id: "headers".into(), - name: "headers".into(), - document_root: tmp.path().to_path_buf(), - response_headers: BTreeMap::from([ - ("server".into(), "Example/1.0".into()), - ("x-powered-by".into(), "PHP/8.4".into()), - ("x-custom".into(), "kept".into()), - ]), - hardening: Hardening { - block_common_attacks: false, - strip_unnecessary_headers: true, - }, - ..Default::default() - }; - configured.validate().unwrap(); - let service = configured.compile().unwrap(); - let response = serve_request( - &service, - &request(Method::GET, "/index.html"), - "/index.html", - ) - .await; - assert_eq!(response.status(), StatusCode::OK); - assert!(!response.headers().contains_key("server")); - assert!(!response.headers().contains_key("x-powered-by")); - assert_eq!(response.headers()["x-custom"], "kept"); - } -} diff --git a/src/web_server/config.rs b/src/web_server/config.rs new file mode 100644 index 0000000..b0f64df --- /dev/null +++ b/src/web_server/config.rs @@ -0,0 +1,3 @@ +include!("config/model.rs"); +include!("config/compile.rs"); +include!("config/validation.rs"); diff --git a/src/web_server/config/compile.rs b/src/web_server/config/compile.rs new file mode 100644 index 0000000..439fe90 --- /dev/null +++ b/src/web_server/config/compile.rs @@ -0,0 +1,427 @@ +impl Service { + pub fn validate(&mut self) -> Result<()> { + ensure!(crate::config::valid_id(&self.id), "invalid Web Service id"); + self.name = self.name.trim().to_owned(); + ensure!( + !self.name.is_empty() + && self.name.len() <= MAX_SERVICE_NAME_BYTES + && !self.name.chars().any(char::is_control), + "Web Service name must be 1..96 bytes without controls" + ); + ensure!( + self.document_root.is_absolute(), + "Web Service document root must be an absolute path" + ); + ensure!( + self.index.len() <= MAX_INDEX_FILES, + "Web Service index list allows at most 16 file names" + ); + let mut seen = std::collections::HashSet::new(); + for item in &mut self.index { + *item = item.trim().to_owned(); + ensure!( + !item.is_empty() + && item.len() <= MAX_INDEX_NAME_BYTES + && !item.chars().any(char::is_control) + && !item.contains('/') + && !item.contains('\\') + && item != "." + && item != "..", + "Web Service index entries must be plain file names" + ); + ensure!(seen.insert(item.clone()), "duplicate Web Service index file"); + } + ensure!( + self.mime_types.len() <= MAX_MIME_TYPES, + "Web Service MIME map allows at most 64 extensions" + ); + let mut normalized_mime_types = BTreeMap::new(); + for (extension, value) in std::mem::take(&mut self.mime_types) { + let extension = extension.trim().trim_start_matches('.').to_ascii_lowercase(); + ensure!( + !extension.is_empty() + && extension.len() <= MAX_MIME_EXTENSION_BYTES + && extension + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')), + "Web Service MIME extensions must be 1..32 letters, numbers, '-' or '_'" + ); + let value = value.trim().to_owned(); + ensure!( + !value.is_empty() && value.len() <= MAX_MIME_VALUE_BYTES, + "Web Service MIME value must be 1..256 bytes" + ); + HeaderValue::from_str(&value).context("invalid Web Service MIME header value")?; + ensure!( + normalized_mime_types.insert(extension, value).is_none(), + "duplicate Web Service MIME extension" + ); + } + self.mime_types = normalized_mime_types; + self.default_mime_type = self.default_mime_type.trim().to_owned(); + ensure!( + !self.default_mime_type.is_empty() + && self.default_mime_type.len() <= MAX_MIME_VALUE_BYTES, + "Web Service default MIME type must be 1..256 bytes" + ); + HeaderValue::from_str(&self.default_mime_type) + .context("invalid Web Service default MIME header value")?; + self.cache_control = self + .cache_control + .take() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()); + if let Some(cache_control) = &self.cache_control { + ensure!( + cache_control.len() <= MAX_CACHE_CONTROL_BYTES, + "Web Service Cache-Control must be at most 1024 bytes" + ); + HeaderValue::from_str(cache_control) + .context("invalid Web Service Cache-Control header value")?; + } + + ensure!( + self.cache_rules.len() <= MAX_CACHE_RULES, + "Web Service cache policy allows at most 64 rules" + ); + for rule in &mut self.cache_rules { + rule.matcher = rule.matcher.trim().to_ascii_lowercase(); + rule.cache_control = rule.cache_control.trim().to_owned(); + let valid_matcher = rule + .matcher + .strip_prefix("ext:") + .is_some_and(valid_extension) + || rule + .matcher + .strip_prefix("mime:") + .is_some_and(|value| { + !value.is_empty() + && value.len() <= MAX_MIME_VALUE_BYTES + && !value.chars().any(char::is_control) + }); + ensure!( + valid_matcher, + "Web Service cache rule matcher must be ext: or mime:" + ); + ensure!( + !rule.cache_control.is_empty() + && rule.cache_control.len() <= MAX_CACHE_CONTROL_BYTES, + "Web Service cache rule value must be 1..1024 bytes" + ); + HeaderValue::from_str(&rule.cache_control) + .context("invalid Web Service cache rule header value")?; + } + + ensure!( + self.response_headers.len() <= MAX_RESPONSE_HEADERS, + "Web Service allows at most 32 response headers" + ); + let mut normalized_headers = BTreeMap::new(); + for (name, value) in std::mem::take(&mut self.response_headers) { + let name = name.trim().to_ascii_lowercase(); + ensure!( + !name.is_empty() && name.len() <= MAX_HEADER_NAME_BYTES, + "Web Service response header name must be 1..128 bytes" + ); + let header_name = HeaderName::from_str(&name) + .context("invalid Web Service response header name")?; + ensure!( + !protected_service_header(&header_name), + "Web Service response header cannot override framing/transport headers" + ); + let value = value.trim().to_owned(); + ensure!( + value.len() <= MAX_HEADER_VALUE_BYTES, + "Web Service response header value must be at most 8192 bytes" + ); + HeaderValue::from_str(&value) + .context("invalid Web Service response header value")?; + ensure!( + normalized_headers.insert(name, value).is_none(), + "duplicate Web Service response header" + ); + } + self.response_headers = normalized_headers; + + ensure!( + self.try_files.len() <= MAX_TRY_FILES, + "Web Service try_files allows at most 16 candidates" + ); + for candidate in &mut self.try_files { + *candidate = candidate.trim().to_owned(); + ensure!( + !candidate.is_empty() + && candidate.len() <= MAX_TRY_FILE_BYTES + && !candidate.chars().any(char::is_control), + "Web Service try_files entries must be 1..512 bytes without controls" + ); + if let Some(status) = candidate.strip_prefix('=') { + let status: u16 = status.parse().context("invalid try_files status")?; + ensure!( + (400..=599).contains(&status) && StatusCode::from_u16(status).is_ok(), + "try_files status must be an HTTP 4xx/5xx code" + ); + } else { + ensure!( + candidate.starts_with('/') || candidate.starts_with("$uri"), + "Web Service try_files entries must start with / or $uri" + ); + } + } + + ensure!( + self.rewrites.len() <= MAX_REWRITE_RULES, + "Web Service allows at most 32 rewrite rules" + ); + for rule in &mut self.rewrites { + rule.pattern = rule.pattern.trim().to_owned(); + rule.replacement = rule.replacement.trim().to_owned(); + ensure!( + !rule.pattern.is_empty() + && rule.pattern.len() <= MAX_REWRITE_PATTERN_BYTES + && rule.replacement.len() <= MAX_REWRITE_REPLACEMENT_BYTES + && !rule.pattern.chars().any(char::is_control) + && !rule.replacement.chars().any(char::is_control), + "Web Service rewrite pattern/replacement is invalid" + ); + Regex::new(&rule.pattern).context("invalid Web Service rewrite regex")?; + ensure!( + rule.replacement.starts_with('/'), + "Web Service rewrite replacement must start with /" + ); + if let Some(status) = rule.status { + ensure!( + matches!(status, 301 | 302 | 307 | 308), + "Web Service rewrite redirect status must be 301, 302, 307 or 308" + ); + } + } + + ensure!( + self.download_extensions.len() <= MAX_DOWNLOAD_EXTENSIONS, + "Web Service allows at most 64 download extensions" + ); + self.download_extensions = normalize_extensions( + std::mem::take(&mut self.download_extensions), + "Web Service download extension", + )?; + + ensure!( + self.php.extensions.len() <= MAX_PHP_EXTENSIONS, + "Web Service PHP allows at most 16 extensions" + ); + self.php.extensions = normalize_extensions( + std::mem::take(&mut self.php.extensions), + "Web Service PHP extension", + )?; + self.php.endpoint = self.php.endpoint.trim().to_owned(); + ensure!( + self.php.connect_timeout_ms >= 50 && self.php.connect_timeout_ms <= 60_000, + "Web Service PHP connect timeout must be 50..60000 ms" + ); + ensure!( + self.php.request_timeout_ms >= 100 && self.php.request_timeout_ms <= 3_600_000, + "Web Service PHP request timeout must be 100..3600000 ms" + ); + ensure!( + (1..=4096).contains(&self.php.max_concurrent_requests), + "Web Service PHP max concurrent requests must be 1..4096" + ); + ensure!( + self.php.max_request_body_bytes <= 8_u64 * 1024 * 1024 * 1024, + "Web Service PHP request body limit must be 0..8589934592 bytes" + ); + ensure!( + self.php.max_response_header_bytes >= 4096 + && self.php.max_response_header_bytes <= 1024 * 1024, + "Web Service PHP response header limit must be 4096..1048576 bytes" + ); + ensure!( + self.php.stderr_log_bytes <= 64 * 1024, + "Web Service PHP stderr log limit must be 0..65536 bytes" + ); + self.php.custom_params = normalize_fastcgi_params(std::mem::take(&mut self.php.custom_params))?; + self.php.php_values = normalize_php_values(std::mem::take(&mut self.php.php_values))?; + let php_custom_bytes = self + .php + .custom_params + .iter() + .chain(self.php.php_values.iter()) + .map(|(name, value)| name.len() + value.len() + 2) + .sum::(); + ensure!( + php_custom_bytes <= 128 * 1024, + "Web Service PHP custom FastCGI/PHP_VALUE data must be at most 128 KiB" + ); + htaccess::validate(&mut self.htaccess)?; + + if self.php.enabled { + ensure!( + !self.php.extensions.is_empty(), + "Web Service PHP needs at least one executable extension" + ); + FastCgiEndpoint::parse(&self.php.endpoint)?; + ensure!( + self.symlink_policy == SymlinkPolicy::Deny, + "PHP-FPM currently requires symlink_policy=deny to avoid script-path races" + ); + } + + #[cfg(not(target_os = "linux"))] + ensure!( + self.symlink_policy == SymlinkPolicy::Deny, + "Web Service symlink_policy=inside_root currently requires Linux" + ); + Ok(()) + } + + pub fn compile(&self) -> Result { + let root = std::fs::canonicalize(&self.document_root).with_context(|| { + format!( + "Web Service {} document root {} is not accessible", + self.id, + self.document_root.display() + ) + })?; + ensure!( + std::fs::metadata(&root)?.is_dir(), + "Web Service {} document root is not a directory", + self.id + ); + #[cfg(not(unix))] + anyhow::bail!( + "Web Service secure filesystem confinement currently requires a Unix platform" + ); + #[cfg(unix)] + let root_handle = Arc::new(open_root_directory(&root).with_context(|| { + format!("Web Service {} document root could not be opened safely", self.id) + })?); + let htaccess = htaccess::compile(&root, &self.htaccess)?; + if let Some(compiled) = &htaccess { + ensure!( + self.php.enabled || !htaccess::adds_php_extensions(Some(compiled)), + ".htaccess AddHandler for PHP requires PHP-FPM to be enabled on the Web Service" + ); + tracing::debug!(service = %self.id, files = compiled.files_loaded, ".htaccess compatibility compiled"); + for warning in &compiled.warnings { + tracing::warn!(service = %self.id, warning = %warning, ".htaccess compatibility warning"); + } + } + let response_headers = self + .response_headers + .iter() + .map(|(name, value)| { + Ok::<_, anyhow::Error>(( + HeaderName::from_str(name)?, + HeaderValue::from_str(value)?, + )) + }) + .collect::, anyhow::Error>>()?; + let cache_rules = self + .cache_rules + .iter() + .map(|rule| { + let matcher = if let Some(extension) = rule.matcher.strip_prefix("ext:") { + CompiledCacheMatcher::Extension(extension.to_owned()) + } else if let Some(prefix) = rule.matcher.strip_prefix("mime:") { + CompiledCacheMatcher::MimePrefix(prefix.to_owned()) + } else { + anyhow::bail!("invalid compiled Web Service cache matcher") + }; + Ok(CompiledCacheRule { + matcher, + cache_control: HeaderValue::from_str(&rule.cache_control)?, + }) + }) + .collect::>>()?; + let rewrites = self + .rewrites + .iter() + .map(|rule| { + Ok(CompiledRewriteRule { + pattern: Regex::new(&rule.pattern)?, + replacement: rule.replacement.clone(), + status: rule.status.and_then(|value| StatusCode::from_u16(value).ok()), + }) + }) + .collect::>>()?; + let mut script_extensions: HashSet = self.php.extensions.iter().cloned().collect(); + script_extensions.extend(["php", "phtml", "phar", "php3", "php4", "php5", "php7", "php8"].into_iter().map(str::to_owned)); + let php = if self.php.enabled { + Some(CompiledPhp { + extensions: self.php.extensions.iter().cloned().collect(), + endpoint: FastCgiEndpoint::parse(&self.php.endpoint)?, + connect_timeout: Duration::from_millis(self.php.connect_timeout_ms), + request_timeout: Duration::from_millis(self.php.request_timeout_ms), + permits: Arc::new(Semaphore::new(self.php.max_concurrent_requests)), + pass_authorization: self.php.pass_authorization, + max_request_body_bytes: self.php.max_request_body_bytes, + max_response_header_bytes: self.php.max_response_header_bytes, + stderr_log_bytes: self.php.stderr_log_bytes, + custom_params: self.php.custom_params.clone(), + php_values: self.php.php_values.clone(), + }) + } else { + None + }; + Ok(CompiledService { + id: self.id.clone(), + name: self.name.clone(), + root, + index: self.index.clone(), + directory_listing: self.directory_listing, + mime_types: self + .mime_types + .iter() + .map(|(extension, value)| { + Ok((extension.clone(), HeaderValue::from_str(value)?)) + }) + .collect::, http::header::InvalidHeaderValue>>()?, + default_mime_type: HeaderValue::from_str(&self.default_mime_type)?, + cache_control: self + .cache_control + .as_deref() + .map(HeaderValue::from_str) + .transpose()?, + cache_rules, + response_headers, + try_files: self.try_files.clone(), + rewrites, + precompressed: self.precompressed.clone(), + download_extensions: self.download_extensions.iter().cloned().collect(), + symlink_policy: self.symlink_policy, + script_extensions, + php, + htaccess, + hardening: self.hardening.clone(), + #[cfg(unix)] + root_handle, + }) + } +} + +#[derive(Debug, Clone)] +pub(super) enum CompiledCacheMatcher { + Extension(String), + MimePrefix(String), +} + +#[derive(Debug, Clone)] +pub(super) struct CompiledCacheRule { + pub(super) matcher: CompiledCacheMatcher, + pub(super) cache_control: HeaderValue, +} + +#[derive(Debug, Clone)] +pub(super) struct CompiledRewriteRule { + pub(super) pattern: Regex, + pub(super) replacement: String, + pub(super) status: Option, +} + +#[derive(Debug, Clone)] +pub(super) enum FastCgiEndpoint { + #[cfg(unix)] + Unix(PathBuf), + Tcp(std::net::SocketAddr), +} diff --git a/src/web_server/config/model.rs b/src/web_server/config/model.rs new file mode 100644 index 0000000..c4a8f80 --- /dev/null +++ b/src/web_server/config/model.rs @@ -0,0 +1,378 @@ +use super::htaccess; +use super::htaccess::Config as HtaccessConfig; +#[cfg(unix)] +use super::filesystem::open_root_directory; +use anyhow::{ensure, Context, Result}; +use http::{HeaderName, HeaderValue, StatusCode}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, HashSet}, + fs::File, + path::PathBuf, + str::FromStr, + sync::Arc, + time::Duration, +}; +use tokio::sync::Semaphore; + +const MAX_INDEX_FILES: usize = 16; +const MAX_INDEX_NAME_BYTES: usize = 128; +const MAX_SERVICE_NAME_BYTES: usize = 96; +const MAX_MIME_TYPES: usize = 64; +const MAX_MIME_EXTENSION_BYTES: usize = 32; +const MAX_MIME_VALUE_BYTES: usize = 256; +const MAX_CACHE_CONTROL_BYTES: usize = 1024; +const MAX_TRY_FILES: usize = 16; +const MAX_TRY_FILE_BYTES: usize = 512; +const MAX_REWRITE_RULES: usize = 32; +const MAX_REWRITE_PATTERN_BYTES: usize = 512; +const MAX_REWRITE_REPLACEMENT_BYTES: usize = 1024; +const MAX_CACHE_RULES: usize = 64; +const MAX_RESPONSE_HEADERS: usize = 32; +const MAX_HEADER_NAME_BYTES: usize = 128; +pub(super) const MAX_HEADER_VALUE_BYTES: usize = 8192; +const MAX_DOWNLOAD_EXTENSIONS: usize = 64; +const MAX_PHP_EXTENSIONS: usize = 16; +const MAX_PHP_CUSTOM_PARAMS: usize = 64; +const MAX_PHP_VALUES: usize = 64; +const MAX_FASTCGI_PARAM_NAME_BYTES: usize = 64; +const MAX_FASTCGI_PARAM_VALUE_BYTES: usize = 8192; +const DEFAULT_FASTCGI_HEADER_BYTES: usize = 64 * 1024; +const DEFAULT_FASTCGI_STDERR_BYTES: usize = 4096; + +fn default_index() -> Vec { + vec!["index.html".into(), "index.htm".into()] +} + +fn default_true() -> bool { + true +} + +fn default_php_extensions() -> Vec { + vec!["php".into()] +} + +fn default_php_connect_timeout_ms() -> u64 { + 1_000 +} + +fn default_php_request_timeout_ms() -> u64 { + 30_000 +} + +fn default_php_max_concurrent_requests() -> usize { + 64 +} + +fn default_php_pass_authorization() -> bool { + true +} + +fn default_php_max_response_header_bytes() -> usize { + DEFAULT_FASTCGI_HEADER_BYTES +} + +fn default_php_stderr_log_bytes() -> usize { + DEFAULT_FASTCGI_STDERR_BYTES +} + +fn valid_extension(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_MIME_EXTENSION_BYTES + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + +fn normalize_extensions(values: Vec, label: &str) -> Result> { + let mut seen = HashSet::new(); + let mut normalized = Vec::with_capacity(values.len()); + for value in values { + let value = value.trim().trim_start_matches('.').to_ascii_lowercase(); + ensure!(valid_extension(&value), "{label} is invalid"); + ensure!(seen.insert(value.clone()), "duplicate {label}"); + normalized.push(value); + } + Ok(normalized) +} + +fn reserved_fastcgi_param(name: &str) -> bool { + name.starts_with("HTTP_") + || matches!( + name, + "REQUEST_METHOD" + | "SCRIPT_FILENAME" + | "SCRIPT_NAME" + | "QUERY_STRING" + | "REQUEST_URI" + | "DOCUMENT_ROOT" + | "SERVER_PROTOCOL" + | "SERVER_NAME" + | "SERVER_PORT" + | "REMOTE_ADDR" + | "REMOTE_PORT" + | "HTTPS" + | "GATEWAY_INTERFACE" + | "SERVER_SOFTWARE" + | "REDIRECT_STATUS" + | "CONTENT_TYPE" + | "CONTENT_LENGTH" + | "PATH_INFO" + | "PATH_TRANSLATED" + | "PHP_VALUE" + | "PHP_ADMIN_VALUE" + ) +} + +fn normalize_fastcgi_params(values: BTreeMap) -> Result> { + ensure!( + values.len() <= MAX_PHP_CUSTOM_PARAMS, + "Web Service PHP allows at most 64 custom FastCGI parameters" + ); + let mut normalized = BTreeMap::new(); + for (name, value) in values { + let name = name.trim().to_ascii_uppercase(); + let value = value.trim().to_owned(); + ensure!( + !name.is_empty() + && name.len() <= MAX_FASTCGI_PARAM_NAME_BYTES + && name.bytes().enumerate().all(|(index, byte)| { + byte.is_ascii_uppercase() + || byte.is_ascii_digit() && index > 0 + || byte == b'_' && index > 0 + }), + "Web Service PHP custom FastCGI parameter name is invalid" + ); + ensure!( + !reserved_fastcgi_param(&name), + "Web Service PHP custom FastCGI parameter uses a reserved name" + ); + ensure!( + !value.is_empty() + && value.len() <= MAX_FASTCGI_PARAM_VALUE_BYTES + && !value.chars().any(char::is_control), + "Web Service PHP custom FastCGI parameter value is invalid" + ); + ensure!( + normalized.insert(name, value).is_none(), + "duplicate Web Service PHP custom FastCGI parameter" + ); + } + Ok(normalized) +} + +fn normalize_php_values(values: BTreeMap) -> Result> { + ensure!( + values.len() <= MAX_PHP_VALUES, + "Web Service PHP allows at most 64 PHP INI overrides" + ); + let mut normalized = BTreeMap::new(); + for (name, value) in values { + let name = name.trim().to_ascii_lowercase(); + let value = value.trim().to_owned(); + ensure!( + !name.is_empty() + && name.len() <= MAX_FASTCGI_PARAM_NAME_BYTES + && name.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-') + }), + "Web Service PHP INI override name is invalid" + ); + ensure!( + !value.is_empty() + && value.len() <= MAX_FASTCGI_PARAM_VALUE_BYTES + && !value.chars().any(char::is_control), + "Web Service PHP INI override value is invalid" + ); + ensure!( + normalized.insert(name, value).is_none(), + "duplicate Web Service PHP INI override" + ); + } + Ok(normalized) +} + +fn protected_service_header(name: &HeaderName) -> bool { + matches!( + name.as_str(), + "connection" + | "content-length" + | "content-encoding" + | "content-range" + | "transfer-encoding" + | "upgrade" + | "keep-alive" + | "proxy-connection" + | "vary" + ) +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum SymlinkPolicy { + #[default] + Deny, + InsideRoot, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default, deny_unknown_fields)] +pub struct RewriteRule { + pub pattern: String, + pub replacement: String, + /// `None` means an internal rewrite. Redirects are limited to 301/302/307/308. + pub status: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default, deny_unknown_fields)] +pub struct CacheRule { + /// `ext:` or `mime:`. + pub matcher: String, + pub cache_control: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Precompressed { + pub enabled: bool, + #[serde(default = "default_true")] + pub brotli: bool, + #[serde(default = "default_true")] + pub gzip: bool, +} + +impl Default for Precompressed { + fn default() -> Self { + Self { + enabled: false, + brotli: true, + gzip: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Php { + pub enabled: bool, + #[serde(default = "default_php_extensions")] + pub extensions: Vec, + /// `unix:/run/php/php-fpm.sock` or `tcp:127.0.0.1:9000`. + pub endpoint: String, + #[serde(default = "default_php_connect_timeout_ms")] + pub connect_timeout_ms: u64, + #[serde(default = "default_php_request_timeout_ms")] + pub request_timeout_ms: u64, + #[serde(default = "default_php_max_concurrent_requests")] + pub max_concurrent_requests: usize, + /// Forward the inbound Authorization header as HTTP_AUTHORIZATION. + #[serde(default = "default_php_pass_authorization")] + pub pass_authorization: bool, + /// Optional RustProxy-side request-body limit for PHP. 0 keeps the body unlimited here. + pub max_request_body_bytes: u64, + /// Maximum bytes accepted before the CGI response-header terminator. + #[serde(default = "default_php_max_response_header_bytes")] + pub max_response_header_bytes: usize, + /// Maximum PHP-FPM stderr bytes retained for one request diagnostic log entry. + #[serde(default = "default_php_stderr_log_bytes")] + pub stderr_log_bytes: usize, + /// Trusted operator-defined static FastCGI parameters. Core CGI/HTTP/PHP_* names are reserved. + pub custom_params: BTreeMap, + /// Trusted operator-defined PHP INI overrides encoded through a single PHP_VALUE parameter. + pub php_values: BTreeMap, +} + +impl Default for Php { + fn default() -> Self { + Self { + enabled: false, + extensions: default_php_extensions(), + endpoint: "unix:/run/php/php-fpm.sock".into(), + connect_timeout_ms: default_php_connect_timeout_ms(), + request_timeout_ms: default_php_request_timeout_ms(), + max_concurrent_requests: default_php_max_concurrent_requests(), + pass_authorization: default_php_pass_authorization(), + max_request_body_bytes: 0, + max_response_header_bytes: default_php_max_response_header_bytes(), + stderr_log_bytes: default_php_stderr_log_bytes(), + custom_params: BTreeMap::new(), + php_values: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default, deny_unknown_fields)] +pub struct Hardening { + /// Reject high-confidence exploit probes and sensitive control/backup paths. + pub block_common_attacks: bool, + /// Remove implementation/fingerprint response headers from Web Server/PHP responses. + pub strip_unnecessary_headers: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Service { + /// Stable configuration identifier referenced by vhosts/locations. + pub id: String, + /// Human-readable service name shown in the administration UI. + pub name: String, + pub document_root: PathBuf, + pub index: Vec, + pub directory_listing: bool, + /// Optional per-extension MIME overrides. Keys are extensions without a leading dot. + pub mime_types: BTreeMap, + /// Fallback MIME type used when neither an override nor a built-in mapping exists. + pub default_mime_type: String, + /// Browser/proxy cache policy emitted for static file responses. Location/vhost + /// response-header rules run later and can intentionally override this value. + pub cache_control: Option, + /// More specific cache policy rules evaluated before `cache_control`. + pub cache_rules: Vec, + /// Fixed response headers emitted by this Web Service. Vhost/location response + /// headers still run later and remain authoritative. + pub response_headers: BTreeMap, + /// Nginx-style file candidates, e.g. `$uri`, `$uri/`, `/index.html`. + pub try_files: Vec, + /// Regex rewrite/redirect rules evaluated before `try_files`. + pub rewrites: Vec, + /// Serve pre-generated `.br`/`.gz` sidecars when accepted by the client. + pub precompressed: Precompressed, + /// Extensions that should be downloaded as attachments. + pub download_extensions: Vec, + /// Symlink handling. The conservative default is `deny`. + pub symlink_policy: SymlinkPolicy, + /// Optional FastCGI/PHP-FPM execution for configured script extensions. + pub php: Php, + /// Optional bounded Apache .htaccess compatibility layer compiled at Web Service apply time. + pub htaccess: HtaccessConfig, + /// Optional Web Service hardening. This is intentionally narrower than a generic WAF. + pub hardening: Hardening, +} + +impl Default for Service { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + document_root: PathBuf::new(), + index: default_index(), + directory_listing: false, + mime_types: BTreeMap::new(), + default_mime_type: "application/octet-stream".into(), + cache_control: None, + cache_rules: Vec::new(), + response_headers: BTreeMap::new(), + try_files: Vec::new(), + rewrites: Vec::new(), + precompressed: Precompressed::default(), + download_extensions: Vec::new(), + symlink_policy: SymlinkPolicy::Deny, + php: Php::default(), + htaccess: HtaccessConfig::default(), + hardening: Hardening::default(), + } + } +} diff --git a/src/web_server/config/validation.rs b/src/web_server/config/validation.rs new file mode 100644 index 0000000..07d2f56 --- /dev/null +++ b/src/web_server/config/validation.rs @@ -0,0 +1,64 @@ +impl FastCgiEndpoint { + pub(super) fn parse(value: &str) -> Result { + if let Some(path) = value.strip_prefix("unix:") { + ensure!(!path.trim().is_empty(), "PHP-FPM unix socket path is empty"); + #[cfg(unix)] + { + return Ok(Self::Unix(PathBuf::from(path))); + } + #[cfg(not(unix))] + anyhow::bail!("PHP-FPM unix sockets require a Unix platform"); + } + if let Some(address) = value.strip_prefix("tcp:") { + let address = address + .parse::() + .context("PHP-FPM TCP endpoint must be an IP socket address")?; + ensure!( + address.ip().is_loopback(), + "PHP-FPM TCP endpoint must use a loopback address" + ); + return Ok(Self::Tcp(address)); + } + anyhow::bail!("PHP-FPM endpoint must start with unix: or tcp:") + } +} + +#[derive(Debug, Clone)] +pub(super) struct CompiledPhp { + pub(super) extensions: HashSet, + pub(super) endpoint: FastCgiEndpoint, + pub(super) connect_timeout: Duration, + pub(super) request_timeout: Duration, + pub(super) permits: Arc, + pub(super) pass_authorization: bool, + pub(super) max_request_body_bytes: u64, + pub(super) max_response_header_bytes: usize, + pub(super) stderr_log_bytes: usize, + pub(super) custom_params: BTreeMap, + pub(super) php_values: BTreeMap, +} + +#[derive(Debug, Clone)] +pub struct CompiledService { + pub id: String, + pub name: String, + pub(super) root: PathBuf, + pub(super) index: Vec, + pub(super) directory_listing: bool, + pub(super) mime_types: BTreeMap, + pub(super) default_mime_type: HeaderValue, + pub(super) cache_control: Option, + pub(super) cache_rules: Vec, + pub(super) response_headers: Vec<(HeaderName, HeaderValue)>, + pub(super) try_files: Vec, + pub(super) rewrites: Vec, + pub(super) precompressed: Precompressed, + pub(super) download_extensions: HashSet, + pub(super) symlink_policy: SymlinkPolicy, + pub(super) script_extensions: HashSet, + pub(super) php: Option, + pub(super) htaccess: Option, + pub(super) hardening: Hardening, + #[cfg(unix)] + pub(super) root_handle: Arc, +} diff --git a/src/web_server/fastcgi.rs b/src/web_server/fastcgi.rs new file mode 100644 index 0000000..ad5aa2e --- /dev/null +++ b/src/web_server/fastcgi.rs @@ -0,0 +1,2 @@ +include!("fastcgi/protocol.rs"); +include!("fastcgi/response.rs"); diff --git a/src/web_server/fastcgi/protocol.rs b/src/web_server/fastcgi/protocol.rs new file mode 100644 index 0000000..48f6e76 --- /dev/null +++ b/src/web_server/fastcgi/protocol.rs @@ -0,0 +1,364 @@ +use super::{ + config::{CompiledPhp, FastCgiEndpoint, MAX_HEADER_VALUE_BYTES}, + filesystem::path_components, + htaccess, + response::{builtin_error_response, status_response}, + request::server_protocol, + CompiledService, RequestContext, +}; +use anyhow::{Context, ensure, Result}; +use axum::{body::Body, response::Response}; +use bytes::Bytes; +use futures_util::stream; +use http::{ + header::{ALLOW, CONTENT_LENGTH, CONTENT_TYPE}, + HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri, +}; +use http_body_util::BodyExt; +use std::{collections::BTreeMap, io, path::Path, str::FromStr}; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, + sync::{mpsc, OwnedSemaphorePermit}, + time::Instant, +}; + +const FCGI_VERSION_1: u8 = 1; +const FCGI_BEGIN_REQUEST: u8 = 1; +pub(super) const FCGI_END_REQUEST: u8 = 3; +const FCGI_PARAMS: u8 = 4; +pub(super) const FCGI_STDIN: u8 = 5; +pub(super) const FCGI_STDOUT: u8 = 6; +const FCGI_STDERR: u8 = 7; +const FCGI_RESPONDER: u16 = 1; +const FCGI_REQUEST_ID: u16 = 1; + +pub(super) trait FastCgiIo: AsyncRead + AsyncWrite + Unpin + Send {} +impl FastCgiIo for T where T: AsyncRead + AsyncWrite + Unpin + Send {} + +async fn connect_fastcgi(endpoint: &FastCgiEndpoint) -> io::Result> { + match endpoint { + #[cfg(unix)] + FastCgiEndpoint::Unix(path) => { + Ok(Box::new(tokio::net::UnixStream::connect(path).await?)) + } + FastCgiEndpoint::Tcp(address) => { + Ok(Box::new(tokio::net::TcpStream::connect(address).await?)) + } + } +} + +pub(super) async fn write_fcgi_record( + io: &mut dyn FastCgiIo, + record_type: u8, + content: &[u8], +) -> io::Result<()> { + if content.len() > u16::MAX as usize { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "FastCGI record is too large", + )); + } + let padding = (8 - (content.len() % 8)) % 8; + let mut header = [0u8; 8]; + header[0] = FCGI_VERSION_1; + header[1] = record_type; + header[2..4].copy_from_slice(&FCGI_REQUEST_ID.to_be_bytes()); + header[4..6].copy_from_slice(&(content.len() as u16).to_be_bytes()); + header[6] = padding as u8; + io.write_all(&header).await?; + io.write_all(content).await?; + if padding != 0 { + io.write_all(&[0u8; 8][..padding]).await?; + } + Ok(()) +} + +fn encode_fcgi_length(target: &mut Vec, length: usize) -> io::Result<()> { + if length < 128 { + target.push(length as u8); + } else if length <= 0x7fff_ffff { + target.extend_from_slice(&((length as u32) | 0x8000_0000).to_be_bytes()); + } else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "FastCGI parameter is too large", + )); + } + Ok(()) +} + +fn encode_fcgi_param(target: &mut Vec, name: &str, value: &str) -> io::Result<()> { + encode_fcgi_length(target, name.len())?; + encode_fcgi_length(target, value.len())?; + target.extend_from_slice(name.as_bytes()); + target.extend_from_slice(value.as_bytes()); + Ok(()) +} + + +fn fastcgi_header_param(name: &HeaderName) -> Option { + let lower = name.as_str(); + if matches!( + lower, + "content-type" + | "content-length" + | "connection" + | "transfer-encoding" + | "upgrade" + | "keep-alive" + | "proxy-connection" + | "vary" + | "te" + | "trailer" + ) { + return None; + } + Some(format!( + "HTTP_{}", + lower.replace('-', "_").to_ascii_uppercase() + )) +} + +pub(super) fn fastcgi_params( + service: &CompiledService, + php: &CompiledPhp, + method: &Method, + headers: &HeaderMap, + original_uri: &Uri, + script_path: &str, + query: Option<&str>, + context: &RequestContext, +) -> Result> { + let components = path_components(script_path)?; + let mut script_filename = service.root.clone(); + for component in &components { + script_filename.push(component); + } + let document_root = service.root.to_string_lossy().to_string(); + let script_filename = script_filename.to_string_lossy().to_string(); + let request_uri = original_uri + .path_and_query() + .map(|value| value.as_str()) + .unwrap_or(original_uri.path()); + let mut params = BTreeMap::::new(); + params.insert("REQUEST_METHOD".into(), method.as_str().into()); + params.insert("SCRIPT_FILENAME".into(), script_filename); + params.insert("SCRIPT_NAME".into(), script_path.into()); + params.insert("QUERY_STRING".into(), query.unwrap_or("").into()); + params.insert("REQUEST_URI".into(), request_uri.into()); + params.insert("DOCUMENT_ROOT".into(), document_root); + params.insert("SERVER_PROTOCOL".into(), server_protocol(context.version).into()); + params.insert("SERVER_NAME".into(), context.server_name.clone()); + params.insert("SERVER_PORT".into(), context.server_port.to_string()); + params.insert("REMOTE_ADDR".into(), context.client_ip.to_string()); + if let Some(port) = context.client_port { + params.insert("REMOTE_PORT".into(), port.to_string()); + } + params.insert("HTTPS".into(), if context.tls { "on" } else { "off" }.into()); + params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into()); + params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.25.1".into()); + params.insert("REDIRECT_STATUS".into(), "200".into()); + if let Some(value) = headers.get(CONTENT_TYPE).and_then(|value| value.to_str().ok()) { + params.insert("CONTENT_TYPE".into(), value.into()); + } + if let Some(value) = headers.get(CONTENT_LENGTH).and_then(|value| value.to_str().ok()) { + params.insert("CONTENT_LENGTH".into(), value.into()); + } + for (name, value) in headers { + // HTTP_PROXY is a well-known CGI/FastCGI injection vector (httpoxy). + // This must never depend on the optional Web Service hardening switch. + if name.as_str() == "proxy" + || (!php.pass_authorization && name.as_str() == "authorization") + { + continue; + } + let Some(param) = fastcgi_header_param(name) else { continue }; + let Ok(value) = value.to_str() else { continue }; + if value.len() > MAX_HEADER_VALUE_BYTES { + continue; + } + params + .entry(param) + .and_modify(|existing| { + if existing.len() + value.len() + 2 <= MAX_HEADER_VALUE_BYTES { + existing.push_str(", "); + existing.push_str(value); + } + }) + .or_insert_with(|| value.to_owned()); + } + // Trusted operator-defined parameters cannot override request-derived/core CGI names. + for (name, value) in &php.custom_params { + params.insert(name.clone(), value.clone()); + } + if !php.php_values.is_empty() { + let mut value = String::new(); + for (name, item) in &php.php_values { + if !value.is_empty() { + value.push('\n'); + } + value.push_str(name); + value.push('='); + value.push_str(item); + } + params.insert("PHP_VALUE".into(), value); + } + // Request headers cannot synthesize PHP_VALUE/PHP_ADMIN_VALUE because only HTTP_* + // names are derived above. PHP_VALUE comes exclusively from validated operator config. + let mut encoded = Vec::with_capacity(4096); + for (name, value) in params { + encode_fcgi_param(&mut encoded, &name, &value)?; + ensure!( + encoded.len() <= 512 * 1024, + "FastCGI parameter block exceeds 512 KiB" + ); + } + Ok(encoded) +} + +pub(super) async fn read_fcgi_record( + io: &mut dyn FastCgiIo, + deadline: Instant, +) -> io::Result<(u8, Vec)> { + let mut header = [0u8; 8]; + tokio::time::timeout_at(deadline, io.read_exact(&mut header)) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "FastCGI response timeout"))??; + if header[0] != FCGI_VERSION_1 || u16::from_be_bytes([header[2], header[3]]) != FCGI_REQUEST_ID { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid FastCGI record header", + )); + } + let length = u16::from_be_bytes([header[4], header[5]]) as usize; + let padding = header[6] as usize; + let mut content = vec![0u8; length]; + if length != 0 { + tokio::time::timeout_at(deadline, io.read_exact(&mut content)) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "FastCGI response timeout"))??; + } + if padding != 0 { + let mut discard = [0u8; 255]; + tokio::time::timeout_at(deadline, io.read_exact(&mut discard[..padding])) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "FastCGI response timeout"))??; + } + Ok((header[1], content)) +} + +fn find_cgi_header_end(value: &[u8]) -> Option<(usize, usize)> { + value + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| (position, 4)) + .or_else(|| { + value + .windows(2) + .position(|window| window == b"\n\n") + .map(|position| (position, 2)) + }) +} + +pub(super) fn parse_cgi_headers(value: &[u8]) -> Result<(StatusCode, HeaderMap)> { + let text = std::str::from_utf8(value).context("PHP-FPM returned non-UTF-8 CGI headers")?; + let mut status = StatusCode::OK; + let mut headers = HeaderMap::new(); + for (index, line) in text.lines().enumerate() { + ensure!(index < 128, "PHP-FPM returned too many CGI headers"); + let line = line.trim_end_matches('\r'); + if line.is_empty() { + continue; + } + let (name, value) = line + .split_once(':') + .context("PHP-FPM returned a malformed CGI header")?; + if name.eq_ignore_ascii_case("status") { + let code = value + .trim() + .split_whitespace() + .next() + .context("PHP-FPM returned an empty Status header")? + .parse::()?; + ensure!( + (200..=599).contains(&code), + "PHP-FPM returned an unsupported informational/invalid status" + ); + status = StatusCode::from_u16(code)?; + continue; + } + let name = HeaderName::from_str(name.trim())?; + if matches!( + name.as_str(), + "connection" + | "content-length" + | "transfer-encoding" + | "upgrade" + | "keep-alive" + | "proxy-connection" + | "te" + | "trailer" + | "proxy-authenticate" + | "proxy-authorization" + ) { + continue; + } + let value = HeaderValue::from_str(value.trim())?; + headers.append(name, value); + } + Ok((status, headers)) +} + +async fn drain_fastcgi_body( + mut io: Box, + deadline: Instant, + tx: mpsc::Sender>, + initial: Vec, + send_body: bool, + stderr_log_bytes: usize, + _permit: OwnedSemaphorePermit, +) { + if send_body && !initial.is_empty() && tx.send(Ok(Bytes::from(initial))).await.is_err() { + return; + } + let mut stderr = Vec::new(); + loop { + match read_fcgi_record(io.as_mut(), deadline).await { + Ok((FCGI_STDOUT, content)) => { + if send_body && !content.is_empty() && tx.send(Ok(Bytes::from(content))).await.is_err() { + return; + } + } + Ok((FCGI_STDERR, content)) => { + let room = stderr_log_bytes.saturating_sub(stderr.len()); + stderr.extend_from_slice(&content[..content.len().min(room)]); + } + Ok((FCGI_END_REQUEST, _)) => break, + Ok(_) => {} + Err(error) => { + if send_body { + let _ = tx.send(Err(error)).await; + } + return; + } + } + } + if !stderr.is_empty() { + tracing::warn!( + stderr = %String::from_utf8_lossy(&stderr), + "PHP-FPM FastCGI stderr" + ); + } +} + +#[derive(Debug)] +enum FastCgiRequestWriteError { + Io(io::Error), + BodyTooLarge, +} + +impl From for FastCgiRequestWriteError { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} diff --git a/src/web_server/fastcgi/response.rs b/src/web_server/fastcgi/response.rs new file mode 100644 index 0000000..b054e3a --- /dev/null +++ b/src/web_server/fastcgi/response.rs @@ -0,0 +1,198 @@ +pub(super) async fn php_response( + service: &CompiledService, + php: &CompiledPhp, + method: Method, + headers: HeaderMap, + original_uri: Uri, + script_path: String, + query: Option, + body: Body, + context: RequestContext, +) -> Response { + if php.max_request_body_bytes > 0 { + if let Some(length) = headers + .get(CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + { + if length > php.max_request_body_bytes { + return builtin_error_response(StatusCode::PAYLOAD_TOO_LARGE, &method); + } + } + } + let permit = match php.permits.clone().try_acquire_owned() { + Ok(permit) => permit, + Err(_) => { + let mut response = builtin_error_response(StatusCode::SERVICE_UNAVAILABLE, &method); + response + .headers_mut() + .insert("retry-after", HeaderValue::from_static("1")); + return response; + } + }; + let mut io = match tokio::time::timeout(php.connect_timeout, connect_fastcgi(&php.endpoint)).await { + Ok(Ok(io)) => io, + Ok(Err(error)) => { + tracing::warn!(%error, service = %service.id, "PHP-FPM connection failed"); + return builtin_error_response(StatusCode::BAD_GATEWAY, &method); + } + Err(_) => return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method), + }; + let params = match fastcgi_params( + service, + php, + &method, + &headers, + &original_uri, + &script_path, + query.as_deref(), + &context, + ) { + Ok(params) => params, + Err(error) => { + tracing::warn!(%error, service = %service.id, "PHP-FPM parameter preparation failed"); + return builtin_error_response(StatusCode::BAD_GATEWAY, &method); + } + }; + let deadline = Instant::now() + php.request_timeout; + let exchange = async { + let mut begin = [0u8; 8]; + begin[..2].copy_from_slice(&FCGI_RESPONDER.to_be_bytes()); + write_fcgi_record(io.as_mut(), FCGI_BEGIN_REQUEST, &begin).await?; + for chunk in params.chunks(u16::MAX as usize) { + write_fcgi_record(io.as_mut(), FCGI_PARAMS, chunk).await?; + } + write_fcgi_record(io.as_mut(), FCGI_PARAMS, &[]).await?; + + let mut body = body; + let mut body_bytes = 0u64; + while let Some(frame) = body.frame().await { + let frame = frame + .map_err(|error| FastCgiRequestWriteError::Io(io::Error::other(error.to_string())))?; + if let Ok(data) = frame.into_data() { + body_bytes = body_bytes.saturating_add(data.len() as u64); + if php.max_request_body_bytes > 0 && body_bytes > php.max_request_body_bytes { + return Err(FastCgiRequestWriteError::BodyTooLarge); + } + for chunk in data.chunks(u16::MAX as usize) { + write_fcgi_record(io.as_mut(), FCGI_STDIN, chunk).await?; + } + } + } + write_fcgi_record(io.as_mut(), FCGI_STDIN, &[]).await?; + io.flush().await?; + Ok::<(), FastCgiRequestWriteError>(()) + }; + match tokio::time::timeout_at(deadline, exchange).await { + Ok(Ok(())) => {} + Ok(Err(FastCgiRequestWriteError::BodyTooLarge)) => { + return builtin_error_response(StatusCode::PAYLOAD_TOO_LARGE, &method); + } + Ok(Err(FastCgiRequestWriteError::Io(error))) => { + tracing::warn!(%error, service = %service.id, "PHP-FPM request write failed"); + return builtin_error_response(StatusCode::BAD_GATEWAY, &method); + } + Err(_) => return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method), + } + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let (status, response_headers, initial_body) = loop { + let record = read_fcgi_record(io.as_mut(), deadline).await; + let (record_type, content) = match record { + Ok(value) => value, + Err(error) if error.kind() == io::ErrorKind::TimedOut => { + return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method) + } + Err(error) => { + tracing::warn!(%error, service = %service.id, "PHP-FPM response read failed"); + return builtin_error_response(StatusCode::BAD_GATEWAY, &method); + } + }; + match record_type { + FCGI_STDOUT => { + stdout.extend_from_slice(&content); + if stdout.len() > php.max_response_header_bytes { + return builtin_error_response(StatusCode::BAD_GATEWAY, &method); + } + if let Some((position, separator_len)) = find_cgi_header_end(&stdout) { + let body = stdout[position + separator_len..].to_vec(); + let parsed = parse_cgi_headers(&stdout[..position]); + match parsed { + Ok((status, headers)) => break (status, headers, body), + Err(error) => { + tracing::warn!(%error, service = %service.id, "PHP-FPM CGI headers rejected"); + return builtin_error_response(StatusCode::BAD_GATEWAY, &method); + } + } + } + } + FCGI_STDERR => { + let room = php.stderr_log_bytes.saturating_sub(stderr.len()); + stderr.extend_from_slice(&content[..content.len().min(room)]); + } + FCGI_END_REQUEST => { + if !stderr.is_empty() { + tracing::warn!(stderr = %String::from_utf8_lossy(&stderr), service = %service.id, "PHP-FPM FastCGI stderr"); + } + return builtin_error_response(StatusCode::BAD_GATEWAY, &method); + } + _ => {} + } + }; + if !stderr.is_empty() { + tracing::warn!(stderr = %String::from_utf8_lossy(&stderr), service = %service.id, "PHP-FPM FastCGI stderr"); + } + let send_body = method != Method::HEAD + && status != StatusCode::NO_CONTENT + && status != StatusCode::NOT_MODIFIED + && !status.is_informational(); + let (tx, rx) = mpsc::channel::>(8); + tokio::spawn(drain_fastcgi_body( + io, + deadline, + tx, + initial_body, + send_body, + php.stderr_log_bytes, + permit, + )); + let mut response = Response::new(if send_body { + Body::from_stream(stream::unfold(rx, |mut rx| async move { + rx.recv().await.map(|item| (item, rx)) + })) + } else { + Body::empty() + }); + *response.status_mut() = status; + *response.headers_mut() = response_headers; + response +} + +pub(super) fn method_not_allowed_response() -> Response { + let mut response = status_response( + StatusCode::METHOD_NOT_ALLOWED, + "RustProxy: Web Server method is not allowed for a static resource\n", + ); + response + .headers_mut() + .insert(ALLOW, HeaderValue::from_static("GET, HEAD")); + response +} + +pub(super) fn php_for_path<'a>(service: &'a CompiledService, path: &Path) -> Option<&'a CompiledPhp> { + let php = service.php.as_ref()?; + let extension = path + .extension() + .and_then(|value| value.to_str())? + .to_ascii_lowercase(); + (php.extensions.contains(&extension) || htaccess::php_extension(service.htaccess.as_ref(), path)).then_some(php) +} + +pub(super) fn protected_script_without_handler(service: &CompiledService, path: &Path) -> bool { + if service.php.is_some() { return false; } + path.extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .is_some_and(|extension| service.script_extensions.contains(&extension)) +} diff --git a/src/web_server/filesystem.rs b/src/web_server/filesystem.rs new file mode 100644 index 0000000..2c65e4b --- /dev/null +++ b/src/web_server/filesystem.rs @@ -0,0 +1,268 @@ +use super::{CompiledService, SymlinkPolicy}; +use std::{fs::File, io, path::Path}; +#[cfg(unix)] +use std::ffi::CString; + +#[derive(Debug)] +pub(super) struct Opened { + pub(super) file: File, + pub(super) metadata: std::fs::Metadata, +} + +#[derive(Debug, Clone)] +pub(super) struct DirectoryEntry { + pub(super) name: String, + pub(super) directory: bool, +} + +#[cfg(unix)] +fn cstring_component(value: &str) -> io::Result { + CString::new(value.as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL")) +} + +#[cfg(unix)] +pub(super) fn open_root_directory(path: &Path) -> io::Result { + use std::os::unix::{ffi::OsStrExt, io::FromRawFd}; + let path = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?; + let fd = unsafe { + libc::open( + path.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ) + }; + if fd < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(unsafe { File::from_raw_fd(fd) }) + } +} + +pub(super) fn path_components(path: &str) -> io::Result> { + if !path.starts_with('/') { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "path is not absolute")); + } + let mut result = Vec::new(); + for part in path.split('/').filter(|part| !part.is_empty()) { + if part == "." || part == ".." || part.contains('\\') || part.contains('\0') { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "ambiguous path")); + } + result.push(part.to_owned()); + } + Ok(result) +} + +#[cfg(unix)] +pub(super) fn secure_open_sync(service: &CompiledService, components: &[String]) -> io::Result { + use std::os::unix::io::{AsRawFd, FromRawFd}; + + if components.is_empty() { + // `File::try_clone()` uses dup(2), which shares the directory offset with + // the persistent root descriptor. Passing such a clone to readdir(3) + // would leave the next root listing positioned at EOF. openat(".") + // creates a fresh open file description and therefore a fresh offset for + // every request while retaining the same O_NOFOLLOW confinement. + let dot = cstring_component(".")?; + let fd = unsafe { + libc::openat( + service.root_handle.as_raw_fd(), + dot.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ) + }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + let current = unsafe { File::from_raw_fd(fd) }; + let metadata = current.metadata()?; + return Ok(Opened { file: current, metadata }); + } + let mut current = service.root_handle.try_clone()?; + for (index, component) in components.iter().enumerate() { + let name = cstring_component(component)?; + let intermediate = index + 1 != components.len(); + // The default remains O_NOFOLLOW on every component. `inside_root` is an + // explicit Linux-only opt-in: the opened descriptor is checked through + // /proc/self/fd after each resolution step before it can be used further. + let mut flags = libc::O_RDONLY | libc::O_CLOEXEC; + if service.symlink_policy == SymlinkPolicy::Deny { + flags |= libc::O_NOFOLLOW; + } + if intermediate { + flags |= libc::O_DIRECTORY; + } else { + // Avoid blocking on special files. Only regular files/directories are served. + flags |= libc::O_NONBLOCK; + } + let fd = unsafe { libc::openat(current.as_raw_fd(), name.as_ptr(), flags) }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + if service.symlink_policy == SymlinkPolicy::InsideRoot { + #[cfg(target_os = "linux")] + verify_linux_fd_beneath_root(service, fd)?; + #[cfg(not(target_os = "linux"))] + { + unsafe { libc::close(fd) }; + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "inside_root symlink policy requires Linux", + )); + } + } + current = unsafe { File::from_raw_fd(fd) }; + } + let metadata = current.metadata()?; + if !metadata.is_file() && !metadata.is_dir() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Web Server serves only regular files and directories", + )); + } + Ok(Opened { file: current, metadata }) +} + +#[cfg(target_os = "linux")] +fn verify_linux_fd_beneath_root(service: &CompiledService, fd: libc::c_int) -> io::Result<()> { + let target = match std::fs::read_link(format!("/proc/self/fd/{fd}")) { + Ok(target) => target, + Err(error) => { + unsafe { libc::close(fd) }; + return Err(error); + } + }; + if target.starts_with(&service.root) { + Ok(()) + } else { + unsafe { libc::close(fd) }; + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "symlink target escaped document root", + )) + } +} + +#[cfg(not(unix))] +pub(super) fn secure_open_sync(service: &CompiledService, components: &[String]) -> io::Result { + let mut candidate = service.root.clone(); + for component in components { + candidate.push(component); + } + let canonical = std::fs::canonicalize(&candidate)?; + if !canonical.starts_with(&service.root) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "path escaped document root", + )); + } + let file = File::open(&canonical)?; + let metadata = file.metadata()?; + if !metadata.is_file() && !metadata.is_dir() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Web Server serves only regular files and directories", + )); + } + Ok(Opened { file, metadata }) +} + +#[cfg(unix)] +pub(super) fn list_directory_sync( + service: &CompiledService, + components: &[String], +) -> io::Result> { + use std::ffi::CStr; + use std::os::unix::io::IntoRawFd; + + let opened = secure_open_sync(service, components)?; + if !opened.metadata.is_dir() { + return Err(io::Error::new(io::ErrorKind::NotADirectory, "not a directory")); + } + let raw = opened.file.try_clone()?.into_raw_fd(); + let dir = unsafe { libc::fdopendir(raw) }; + if dir.is_null() { + let error = io::Error::last_os_error(); + unsafe { libc::close(raw) }; + return Err(error); + } + let mut entries = Vec::new(); + loop { + let entry = unsafe { libc::readdir(dir) }; + if entry.is_null() { + break; + } + let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes(); + if name == b"." || name == b".." { + continue; + } + let Ok(name) = std::str::from_utf8(name) else { + // Routing intentionally accepts only valid UTF-8, so do not emit an + // unusable or ambiguously decoded link for a non-UTF-8 file name. + continue; + }; + let mut child = components.to_vec(); + child.push(name.to_owned()); + // Re-open through the root fd using the configured symlink policy. The + // default omits every symlink; Linux inside_root may include a target only + // after the opened descriptor has been proven to remain below the root. + let Ok(child_opened) = secure_open_sync(service, &child) else { + continue; + }; + entries.push(DirectoryEntry { + name: name.to_owned(), + directory: child_opened.metadata.is_dir(), + }); + } + unsafe { libc::closedir(dir) }; + entries.sort_by(|a, b| { + b.directory + .cmp(&a.directory) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + .then_with(|| a.name.cmp(&b.name)) + }); + Ok(entries) +} + +#[cfg(not(unix))] +pub(super) fn list_directory_sync( + service: &CompiledService, + components: &[String], +) -> io::Result> { + let opened = secure_open_sync(service, components)?; + if !opened.metadata.is_dir() { + return Err(io::Error::new(io::ErrorKind::NotADirectory, "not a directory")); + } + let mut path = service.root.clone(); + for component in components { + path.push(component); + } + let canonical = std::fs::canonicalize(path)?; + if !canonical.starts_with(&service.root) { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, "path escaped document root")); + } + let mut entries = Vec::new(); + for entry in std::fs::read_dir(canonical)? { + let entry = entry?; + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + let mut child = components.to_vec(); + child.push(name.clone()); + let Ok(child_opened) = secure_open_sync(service, &child) else { + continue; + }; + entries.push(DirectoryEntry { + name, + directory: child_opened.metadata.is_dir(), + }); + } + entries.sort_by(|a, b| { + b.directory + .cmp(&a.directory) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + .then_with(|| a.name.cmp(&b.name)) + }); + Ok(entries) +} + diff --git a/src/web_server/htaccess.rs b/src/web_server/htaccess.rs index 8590383..a35888b 100644 --- a/src/web_server/htaccess.rs +++ b/src/web_server/htaccess.rs @@ -1,1428 +1,3 @@ -use super::{open_components, path_components, CompiledService, RequestContext}; -use anyhow::{bail, ensure, Context, Result}; -use axum::{body::Body, response::Response}; -use http::{ - header::{CACHE_CONTROL, EXPIRES, LOCATION}, - HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri, -}; -use regex::Regex; -use serde::{Deserialize, Serialize}; -use std::{ - collections::{BTreeMap, HashSet}, - fs, - io::Read, - path::{Path, PathBuf}, - str::FromStr, - time::{Duration, SystemTime}, -}; - -const MAX_HTACCESS_FILES: usize = 128; -const MAX_HTACCESS_DEPTH: usize = 16; -const MAX_HTACCESS_FILE_BYTES: u64 = 128 * 1024; -const MAX_HTACCESS_TOTAL_BYTES: u64 = 1024 * 1024; -const MAX_HTACCESS_RULES: usize = 256; -const MAX_HTACCESS_CONDITIONS: usize = 512; -const MAX_HTACCESS_HEADERS: usize = 64; -const MAX_HTACCESS_MIME_TYPES: usize = 128; - -fn default_filename() -> String { - ".htaccess".into() -} - -fn default_scan_subdirectories() -> bool { - true -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct Config { - pub enabled: bool, - #[serde(default = "default_filename")] - pub filename: String, - #[serde(default = "default_scan_subdirectories")] - pub scan_subdirectories: bool, - /// Unsupported directives are warnings by default. Strict mode rejects them. - pub strict: bool, -} - -impl Default for Config { - fn default() -> Self { - Self { - enabled: false, - filename: default_filename(), - scan_subdirectories: true, - strict: false, - } - } -} - -pub fn validate(config: &mut Config) -> Result<()> { - config.filename = config.filename.trim().to_owned(); - ensure!( - !config.filename.is_empty() - && config.filename.len() <= 64 - && !config.filename.chars().any(char::is_control) - && !config.filename.contains('/') - && !config.filename.contains('\\') - && config.filename != "." - && config.filename != "..", - ".htaccess compatibility filename must be a plain 1..64 byte file name" - ); - Ok(()) -} - -#[derive(Debug, Clone)] -pub struct Compiled { - pub filename: String, - scopes: Vec, - pub files_loaded: usize, - pub warnings: Vec, -} - -#[derive(Debug, Clone, Default)] -struct Scope { - components: Vec, - rewrites: Vec, - directory_index: Option>, - directory_listing: Option, - mime_types: BTreeMap, - php_extensions: HashSet, - headers: Vec, - expires_active: bool, - expires_default: Option, - expires_by_type: Vec<(String, Duration)>, - deny_all: Option, -} - -#[derive(Debug, Clone)] -struct RewriteRule { - pattern: Regex, - replacement: String, - redirect: Option, - forbidden: bool, - last: bool, - end: bool, - qsa: bool, - conditions: Vec, -} - -#[derive(Debug, Clone)] -struct RewriteCondition { - input: String, - matcher: ConditionMatcher, - or_next: bool, -} - -#[derive(Debug, Clone)] -enum ConditionMatcher { - Regex { - regex: Regex, - negate: bool, - }, - Exact { - value: String, - negate: bool, - insensitive: bool, - }, - File { - kind: FileTest, - negate: bool, - }, -} - -#[derive(Debug, Clone, Copy)] -enum FileTest { - File, - Directory, - NonEmptyFile, -} - -#[derive(Debug, Clone)] -enum HeaderOp { - Set(HeaderName, HeaderValue), - Add(HeaderName, HeaderValue), - Unset(HeaderName), -} - -#[derive(Debug)] -pub enum RewriteOutcome { - Internal { path: String, query: Option }, - Redirect(Response), - Forbidden, -} - -#[derive(Default)] -struct ParseState { - rewrite_engine: bool, - pending_conditions: Vec, - skip_next_rule: bool, - in_files_block: usize, - unsupported: Vec, -} - -fn tokens(value: &str) -> Result> { - let mut out = Vec::new(); - let mut current = String::new(); - let mut quote = None; - let mut escaped = false; - for ch in value.chars() { - if escaped { - current.push(ch); - escaped = false; - continue; - } - if ch == '\\' { - escaped = true; - current.push(ch); - continue; - } - if let Some(wanted) = quote { - if ch == wanted { - quote = None; - } else { - current.push(ch); - } - continue; - } - if ch == '"' || ch == '\'' { - quote = Some(ch); - } else if ch.is_whitespace() { - if !current.is_empty() { - out.push(std::mem::take(&mut current)); - } - } else { - current.push(ch); - } - } - ensure!(quote.is_none(), "unterminated quote in .htaccess"); - if !current.is_empty() { - out.push(current); - } - Ok(out) -} - -fn flags(value: Option<&str>) -> Vec { - value - .and_then(|value| value.strip_prefix('[').and_then(|v| v.strip_suffix(']'))) - .map(|value| { - value - .split(',') - .map(|v| v.trim().to_owned()) - .filter(|v| !v.is_empty()) - .collect() - }) - .unwrap_or_default() -} - -fn ci_regex(pattern: &str, insensitive: bool) -> Result { - if insensitive { - Regex::new(&format!("(?i:{pattern})")).context("invalid case-insensitive .htaccess regex") - } else { - Regex::new(pattern).context("invalid .htaccess regex") - } -} - -fn supported_response_header(name: &HeaderName) -> bool { - !matches!( - name.as_str(), - "connection" - | "content-length" - | "content-encoding" - | "content-range" - | "transfer-encoding" - | "upgrade" - | "keep-alive" - | "proxy-connection" - | "vary" - ) -} - -fn parse_duration(value: &str) -> Result { - let value = value.trim().trim_matches('"').trim(); - if let Some(seconds) = value.strip_prefix('A').or_else(|| value.strip_prefix('a')) { - let seconds: u64 = seconds.parse().context("invalid Expires duration")?; - return Ok(Duration::from_secs(seconds)); - } - let parts = value.split_whitespace().collect::>(); - ensure!( - parts.len() >= 4, - "Expires value must look like 'access plus 1 day' or A3600" - ); - ensure!( - parts[0].eq_ignore_ascii_case("access") && parts[1].eq_ignore_ascii_case("plus"), - "only access-based Expires values are supported" - ); - let count: u64 = parts[2] - .parse() - .context("invalid Expires duration number")?; - let unit = parts[3].to_ascii_lowercase(); - let seconds = match unit.trim_end_matches('s') { - "second" => count, - "minute" => count.saturating_mul(60), - "hour" => count.saturating_mul(3600), - "day" => count.saturating_mul(86400), - "week" => count.saturating_mul(7 * 86400), - "month" => count.saturating_mul(30 * 86400), - "year" => count.saturating_mul(365 * 86400), - _ => bail!("unsupported Expires duration unit"), - }; - Ok(Duration::from_secs(seconds)) -} - -fn parse_condition(parts: &[String]) -> Result { - ensure!( - parts.len() >= 3, - "RewriteCond needs a test string and pattern" - ); - let condition_flags = flags(parts.get(3).map(String::as_str)); - let insensitive = condition_flags - .iter() - .any(|flag| flag.eq_ignore_ascii_case("NC")); - let or_next = condition_flags - .iter() - .any(|flag| flag.eq_ignore_ascii_case("OR")); - for flag in &condition_flags { - ensure!( - flag.eq_ignore_ascii_case("NC") || flag.eq_ignore_ascii_case("OR"), - "unsupported RewriteCond flag [{flag}]" - ); - } - let raw = parts[2].as_str(); - let matcher = if let Some(value) = raw.strip_prefix("!=") { - ConditionMatcher::Exact { - value: value.to_owned(), - negate: true, - insensitive, - } - } else if let Some(value) = raw.strip_prefix('=') { - ConditionMatcher::Exact { - value: value.to_owned(), - negate: false, - insensitive, - } - } else { - let (negate, raw) = raw.strip_prefix('!').map_or((false, raw), |v| (true, v)); - match raw { - "-f" => ConditionMatcher::File { - kind: FileTest::File, - negate, - }, - "-d" => ConditionMatcher::File { - kind: FileTest::Directory, - negate, - }, - "-s" => ConditionMatcher::File { - kind: FileTest::NonEmptyFile, - negate, - }, - _ => ConditionMatcher::Regex { - regex: ci_regex(raw, insensitive)?, - negate, - }, - } - }; - Ok(RewriteCondition { - input: parts[1].clone(), - matcher, - or_next, - }) -} - -fn parse_rewrite_rule( - parts: &[String], - conditions: Vec, - state: &mut ParseState, -) -> Result { - ensure!( - parts.len() >= 3, - "RewriteRule needs a pattern and substitution" - ); - let rule_flags = flags(parts.get(3).map(String::as_str)); - let mut redirect = None; - let mut forbidden = false; - let mut last = false; - let mut end = false; - let mut qsa = false; - let mut insensitive = false; - for flag in rule_flags { - let upper = flag.to_ascii_uppercase(); - match upper.as_str() { - "L" => last = true, - "END" => { end = true; last = true; } - "QSA" => qsa = true, - "NC" => insensitive = true, - "F" => { forbidden = true; last = true; } - "R" => { redirect = Some(StatusCode::FOUND); last = true; } - "PT" | "NE" => {} - _ if upper.starts_with("R=") => { - let code: u16 = upper[2..].parse().context("invalid RewriteRule redirect status")?; - ensure!(matches!(code, 301 | 302 | 307 | 308), "RewriteRule redirect status must be 301, 302, 307 or 308"); - redirect = Some(StatusCode::from_u16(code)?); - last = true; - } - _ if upper.starts_with("E=") => state.unsupported.push(format!("RewriteRule environment flag [{flag}] is accepted as a no-op; request HTTP headers are already forwarded to PHP-FPM")), - _ => bail!("unsupported RewriteRule flag [{flag}]"), - } - } - Ok(RewriteRule { - pattern: ci_regex(&parts[1], insensitive)?, - replacement: parts[2].clone(), - redirect, - forbidden, - last, - end, - qsa, - conditions, - }) -} - -fn normalize_extension(value: &str) -> Result { - let value = value.trim().trim_start_matches('.').to_ascii_lowercase(); - ensure!( - !value.is_empty() - && value.len() <= 32 - && value - .bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')), - "invalid extension in .htaccess" - ); - Ok(value) -} - -fn unsupported( - state: &mut ParseState, - config: &Config, - file: &Path, - line: usize, - message: String, -) -> Result<()> { - if config.strict { - bail!("{}:{line}: {message}", file.display()); - } - state - .unsupported - .push(format!("{}:{line}: {message}", file.display())); - Ok(()) -} - -fn read_htaccess_nofollow(path: &Path) -> Result { - #[cfg(unix)] - let mut file = { - use std::os::unix::fs::OpenOptionsExt; - fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) - .open(path) - .with_context(|| format!("cannot safely open {}", path.display()))? - }; - #[cfg(not(unix))] - let mut file = { - let metadata = fs::symlink_metadata(path)?; - ensure!( - !metadata.file_type().is_symlink(), - "{} must not be a symlink", - path.display() - ); - fs::File::open(path)? - }; - let metadata = file.metadata()?; - ensure!( - metadata.is_file(), - "{} is not a regular file", - path.display() - ); - ensure!( - metadata.len() <= MAX_HTACCESS_FILE_BYTES, - "{} exceeds the 128 KiB .htaccess limit", - path.display() - ); - let mut content = String::new(); - (&mut file) - .take(MAX_HTACCESS_FILE_BYTES + 1) - .read_to_string(&mut content) - .with_context(|| format!("{} is not valid UTF-8", path.display()))?; - ensure!( - content.len() as u64 <= MAX_HTACCESS_FILE_BYTES, - "{} exceeds the 128 KiB .htaccess limit", - path.display() - ); - Ok(content) -} - -fn parse_file( - path: &Path, - components: Vec, - config: &Config, -) -> Result<(Scope, Vec)> { - let content = read_htaccess_nofollow(path)?; - let mut scope = Scope { - components, - ..Scope::default() - }; - let mut state = ParseState { - rewrite_engine: false, - ..ParseState::default() - }; - let mut rule_count = 0usize; - let mut condition_count = 0usize; - - for (line_no, raw) in content.lines().enumerate() { - let line_no = line_no + 1; - let line = raw.trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let lower_line = line.to_ascii_lowercase(); - if lower_line.starts_with("" { - continue; - } - if lower_line.starts_with("") || line.eq_ignore_ascii_case("") { - state.in_files_block = state.in_files_block.saturating_sub(1); - continue; - } - if state.in_files_block != 0 { - // RustProxy always blocks .htaccess/.htpasswd directly. More general Files/FilesMatch - // access semantics remain intentionally outside the compatibility subset. - continue; - } - let parts = tokens(line).with_context(|| format!("{}:{line_no}", path.display()))?; - if parts.is_empty() { - continue; - } - let directive = parts[0].to_ascii_lowercase(); - match directive.as_str() { - "rewriteengine" => { - ensure!( - parts.len() == 2, - "{}:{line_no}: RewriteEngine expects On or Off", - path.display() - ); - state.rewrite_engine = if parts[1].eq_ignore_ascii_case("on") { - true - } else if parts[1].eq_ignore_ascii_case("off") { - false - } else { - bail!( - "{}:{line_no}: RewriteEngine expects On or Off", - path.display() - ) - }; - if !state.rewrite_engine { - state.pending_conditions.clear(); - state.skip_next_rule = false; - } - } - "rewritebase" => { - // Per-directory substitutions are already resolved relative to the containing scope. - } - "rewritecond" if state.rewrite_engine => { - condition_count += 1; - ensure!( - condition_count <= MAX_HTACCESS_CONDITIONS, - "too many RewriteCond entries across one .htaccess file" - ); - match parse_condition(&parts) { - Ok(condition) => state.pending_conditions.push(condition), - Err(error) => { - unsupported(&mut state, config, path, line_no, error.to_string())?; - state.skip_next_rule = true; - } - } - } - "rewriterule" if state.rewrite_engine => { - rule_count += 1; - ensure!( - rule_count <= MAX_HTACCESS_RULES, - "too many RewriteRule entries across one .htaccess file" - ); - let conditions = std::mem::take(&mut state.pending_conditions); - if state.skip_next_rule { - state.skip_next_rule = false; - state.unsupported.push(format!("{}:{line_no}: RewriteRule skipped because a preceding RewriteCond was unsupported", path.display())); - continue; - } - match parse_rewrite_rule(&parts, conditions, &mut state) { - Ok(rule) => scope.rewrites.push(rule), - Err(error) => { - unsupported(&mut state, config, path, line_no, error.to_string())? - } - } - } - "directoryindex" => { - ensure!( - parts.len() >= 2, - "{}:{line_no}: DirectoryIndex needs at least one file", - path.display() - ); - let mut index = Vec::new(); - for value in &parts[1..] { - if value.eq_ignore_ascii_case("disabled") { - index.clear(); - break; - } - ensure!( - !value.contains('/') - && !value.contains('\\') - && value != "." - && value != "..", - "{}:{line_no}: DirectoryIndex entries must be plain file names", - path.display() - ); - index.push(value.clone()); - } - ensure!( - index.len() <= 16, - "{}:{line_no}: DirectoryIndex allows at most 16 files", - path.display() - ); - scope.directory_index = Some(index); - } - "options" => { - for option in &parts[1..] { - if option.eq_ignore_ascii_case("+Indexes") - || option.eq_ignore_ascii_case("Indexes") - { - scope.directory_listing = Some(true); - } else if option.eq_ignore_ascii_case("-Indexes") { - scope.directory_listing = Some(false); - } else if option.eq_ignore_ascii_case("-MultiViews") { /* RustProxy has no content negotiation; already effectively off. */ - } else { - unsupported( - &mut state, - config, - path, - line_no, - format!("unsupported Options token {option}"), - )?; - } - } - } - "addtype" => { - ensure!( - parts.len() >= 3, - "{}:{line_no}: AddType expects media/type and extension(s)", - path.display() - ); - let mime = - HeaderValue::from_str(&parts[1]).context("invalid AddType media type")?; - for extension in &parts[2..] { - ensure!( - scope.mime_types.len() < MAX_HTACCESS_MIME_TYPES, - "too many AddType extensions in .htaccess" - ); - scope - .mime_types - .insert(normalize_extension(extension)?, mime.clone()); - } - } - "addhandler" => { - ensure!( - parts.len() >= 3, - "{}:{line_no}: AddHandler expects handler and extension(s)", - path.display() - ); - if parts[1].to_ascii_lowercase().contains("php") { - for extension in &parts[2..] { - scope.php_extensions.insert(normalize_extension(extension)?); - } - } else { - bail!("{}:{line_no}: non-PHP AddHandler is not supported and cannot be ignored safely", path.display()); - } - } - "header" => { - ensure!( - parts.len() >= 3, - "{}:{line_no}: Header expects an action and name", - path.display() - ); - ensure!( - scope.headers.len() < MAX_HTACCESS_HEADERS, - "too many Header directives in .htaccess" - ); - let mut action_index = 1usize; - if parts[action_index].eq_ignore_ascii_case("always") - || parts[action_index].eq_ignore_ascii_case("onsuccess") - { - action_index += 1; - } - ensure!( - parts.len() > action_index + 1, - "{}:{line_no}: Header expects an action and name", - path.display() - ); - let action = parts[action_index].to_ascii_lowercase(); - let name = HeaderName::from_str(&parts[action_index + 1]) - .context("invalid Header name")?; - ensure!( - supported_response_header(&name), - "protected response header cannot be set from .htaccess" - ); - match action.as_str() { - "unset" => scope.headers.push(HeaderOp::Unset(name)), - "set" | "add" => { - ensure!( - parts.len() > action_index + 2, - "{}:{line_no}: Header {action} needs a value", - path.display() - ); - let value = HeaderValue::from_str(&parts[action_index + 2..].join(" ")) - .context("invalid Header value")?; - if action == "set" { - scope.headers.push(HeaderOp::Set(name, value)); - } else { - scope.headers.push(HeaderOp::Add(name, value)); - } - } - _ => unsupported( - &mut state, - config, - path, - line_no, - format!("unsupported Header action {action}"), - )?, - } - } - "expiresactive" => { - ensure!( - parts.len() == 2, - "{}:{line_no}: ExpiresActive expects On or Off", - path.display() - ); - scope.expires_active = parts[1].eq_ignore_ascii_case("on"); - } - "expiresdefault" => { - ensure!( - parts.len() >= 2, - "{}:{line_no}: ExpiresDefault needs a duration", - path.display() - ); - scope.expires_default = Some(parse_duration(&parts[1..].join(" "))?); - } - "expiresbytype" => { - ensure!( - parts.len() >= 3, - "{}:{line_no}: ExpiresByType needs media type and duration", - path.display() - ); - scope.expires_by_type.push(( - parts[1].to_ascii_lowercase(), - parse_duration(&parts[2..].join(" "))?, - )); - } - "require" => { - if parts.len() == 3 - && parts[1].eq_ignore_ascii_case("all") - && parts[2].eq_ignore_ascii_case("denied") - { - scope.deny_all = Some(true); - } else if parts.len() == 3 - && parts[1].eq_ignore_ascii_case("all") - && parts[2].eq_ignore_ascii_case("granted") - { - scope.deny_all = Some(false); - } else { - bail!("{}:{line_no}: only 'Require all granted/denied' is supported; refusing to ignore an access-control directive", path.display()); - } - } - "sethandler" | "removehandler" | "authtype" | "authname" | "authuserfile" - | "authgroupfile" | "order" | "allow" | "deny" => { - bail!("{}:{line_no}: security/handler directive {} is not supported and cannot be ignored safely", path.display(), parts[0]); - } - "errordocument" | "setenvif" | "redirect" | "redirectmatch" | "fallbackresource" => { - unsupported( - &mut state, - config, - path, - line_no, - format!( - "directive {} is not in the RustProxy compatibility subset", - parts[0] - ), - )?; - } - _ => unsupported( - &mut state, - config, - path, - line_no, - format!("unsupported directive {}", parts[0]), - )?, - } - } - ensure!( - state.pending_conditions.is_empty(), - "{}: RewriteCond without following RewriteRule", - path.display() - ); - Ok((scope, state.unsupported)) -} - -fn discover(root: &Path, filename: &str, recursive: bool) -> Result)>> { - let mut result = Vec::new(); - let mut stack = vec![(root.to_path_buf(), Vec::::new(), 0usize)]; - while let Some((directory, components, depth)) = stack.pop() { - let directory = fs::canonicalize(&directory).with_context(|| { - format!( - "cannot canonicalize {} during .htaccess scan", - directory.display() - ) - })?; - ensure!( - directory.starts_with(root), - ".htaccess scan escaped the Web Service document root" - ); - let candidate = directory.join(filename); - match fs::symlink_metadata(&candidate) { - Ok(metadata) => { - ensure!( - !metadata.file_type().is_symlink(), - "{} must not be a symlink", - candidate.display() - ); - if metadata.is_file() { - result.push((candidate, components.clone())); - ensure!( - result.len() <= MAX_HTACCESS_FILES, - "Web Service .htaccess scan exceeds 128 files" - ); - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(error) - .with_context(|| format!("cannot inspect {}", candidate.display())) - } - } - if !recursive || depth >= MAX_HTACCESS_DEPTH { - continue; - } - for entry in fs::read_dir(&directory) - .with_context(|| format!("cannot scan {} for .htaccess", directory.display()))? - { - let entry = entry?; - let file_type = entry.file_type()?; - if !file_type.is_dir() || file_type.is_symlink() { - continue; - } - let Some(name) = entry.file_name().to_str().map(str::to_owned) else { - continue; - }; - if name == ".git" || name == "node_modules" { - continue; - } - let mut child = components.clone(); - child.push(name); - stack.push((entry.path(), child, depth + 1)); - } - } - result.sort_by(|a, b| a.1.len().cmp(&b.1.len()).then_with(|| a.1.cmp(&b.1))); - Ok(result) -} - -pub fn compile(root: &Path, config: &Config) -> Result> { - if !config.enabled { - return Ok(None); - } - let files = discover(root, &config.filename, config.scan_subdirectories)?; - let mut scopes = Vec::new(); - let mut warnings = Vec::new(); - let mut total = 0u64; - for (file, components) in files { - total = total.saturating_add(fs::metadata(&file)?.len()); - ensure!( - total <= MAX_HTACCESS_TOTAL_BYTES, - "Web Service .htaccess files exceed 1 MiB total" - ); - let (scope, mut file_warnings) = parse_file(&file, components, config)?; - scopes.push(scope); - warnings.append(&mut file_warnings); - } - Ok(Some(Compiled { - filename: config.filename.clone(), - files_loaded: scopes.len(), - scopes, - warnings, - })) -} - -pub fn adds_php_extensions(compiled: Option<&Compiled>) -> bool { - compiled.is_some_and(|compiled| { - compiled - .scopes - .iter() - .any(|scope| !scope.php_extensions.is_empty()) - }) -} - -pub fn protected_component(compiled: Option<&Compiled>, value: &str) -> bool { - let lower = value.to_ascii_lowercase(); - if lower == ".htaccess" || lower == ".htpasswd" || lower.starts_with(".ht") { - return true; - } - compiled.is_some_and(|compiled| value.eq_ignore_ascii_case(&compiled.filename)) -} - -fn scope_matches(scope: &Scope, components: &[String]) -> bool { - components.len() >= scope.components.len() - && components[..scope.components.len()] == scope.components[..] -} - -fn matching_scopes<'a>(compiled: &'a Compiled, path: &str) -> Vec<&'a Scope> { - let Ok(components) = path_components(path) else { - return Vec::new(); - }; - compiled - .scopes - .iter() - .filter(|scope| scope_matches(scope, &components)) - .collect() -} - -pub fn denied(compiled: Option<&Compiled>, path: &str) -> bool { - let Some(compiled) = compiled else { - return false; - }; - matching_scopes(compiled, path) - .into_iter() - .filter_map(|scope| scope.deny_all) - .last() - .unwrap_or(false) -} - -pub fn index_files<'a>( - compiled: Option<&'a Compiled>, - directory_components: &[String], - fallback: &'a [String], -) -> &'a [String] { - let Some(compiled) = compiled else { - return fallback; - }; - compiled - .scopes - .iter() - .filter(|scope| scope_matches(scope, directory_components)) - .filter_map(|scope| scope.directory_index.as_deref()) - .last() - .unwrap_or(fallback) -} - -pub fn directory_listing( - compiled: Option<&Compiled>, - directory_components: &[String], - fallback: bool, -) -> bool { - let Some(compiled) = compiled else { - return fallback; - }; - compiled - .scopes - .iter() - .filter(|scope| scope_matches(scope, directory_components)) - .filter_map(|scope| scope.directory_listing) - .last() - .unwrap_or(fallback) -} - -pub fn mime_type(compiled: Option<&Compiled>, path: &Path) -> Option { - let compiled = compiled?; - let extension = path.extension()?.to_str()?.to_ascii_lowercase(); - let path_text = path.to_string_lossy(); - matching_scopes(compiled, &path_text) - .into_iter() - .filter_map(|scope| scope.mime_types.get(&extension).cloned()) - .last() -} - -pub fn php_extension(compiled: Option<&Compiled>, path: &Path) -> bool { - let Some(compiled) = compiled else { - return false; - }; - let Some(extension) = path - .extension() - .and_then(|v| v.to_str()) - .map(|v| v.to_ascii_lowercase()) - else { - return false; - }; - let path_text = path.to_string_lossy(); - matching_scopes(compiled, &path_text) - .into_iter() - .any(|scope| scope.php_extensions.contains(&extension)) -} - -pub fn apply_response(compiled: Option<&Compiled>, response: &mut Response, path: &str) { - let Some(compiled) = compiled else { - return; - }; - let scopes = matching_scopes(compiled, path); - for scope in &scopes { - for op in &scope.headers { - match op { - HeaderOp::Set(name, value) => { - response.headers_mut().insert(name.clone(), value.clone()); - } - HeaderOp::Add(name, value) => { - response.headers_mut().append(name.clone(), value.clone()); - } - HeaderOp::Unset(name) => { - response.headers_mut().remove(name); - } - } - } - } - let Some(content_type) = response - .headers() - .get(http::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .map(|v| v.to_ascii_lowercase()) - else { - return; - }; - let mut expiry = None; - for scope in scopes { - if !scope.expires_active { - continue; - } - let specific = scope - .expires_by_type - .iter() - .find(|(mime, _)| content_type.starts_with(mime)) - .map(|(_, duration)| *duration); - expiry = specific.or(scope.expires_default).or(expiry); - } - if let Some(duration) = expiry { - let seconds = duration.as_secs(); - if let Ok(value) = HeaderValue::from_str(&format!("public, max-age={seconds}")) { - response.headers_mut().insert(CACHE_CONTROL, value); - } - if let Ok(value) = - HeaderValue::from_str(&httpdate::fmt_http_date(SystemTime::now() + duration)) - { - response.headers_mut().insert(EXPIRES, value); - } - } -} - -fn scope_relative_path(scope: &Scope, path: &str) -> String { - let prefix = if scope.components.is_empty() { - "/".to_owned() - } else { - format!("/{}/", scope.components.join("/")) - }; - if scope.components.is_empty() { - return path.trim_start_matches('/').to_owned(); - } - if path == prefix.trim_end_matches('/') { - return String::new(); - } - path.strip_prefix(&prefix) - .unwrap_or(path.trim_start_matches('/')) - .to_owned() -} - -fn expand_condition_input( - input: &str, - service: &CompiledService, - method: &Method, - headers: &HeaderMap, - path: &str, - query: Option<&str>, - uri: &Uri, - context: &RequestContext, - captures: &[String], -) -> String { - let mut value = input.to_owned(); - let request_filename = service - .root - .join(path.trim_start_matches('/')) - .to_string_lossy() - .to_string(); - let pairs = [ - ("%{REQUEST_FILENAME}", request_filename), - ("%{REQUEST_URI}", path.to_owned()), - ("%{REQUEST_METHOD}", method.as_str().to_owned()), - ("%{QUERY_STRING}", query.unwrap_or("").to_owned()), - ( - "%{HTTP_HOST}", - headers - .get(http::header::HOST) - .and_then(|v| v.to_str().ok()) - .unwrap_or(&context.server_name) - .to_owned(), - ), - ( - "%{HTTPS}", - if context.tls { "on" } else { "off" }.to_owned(), - ), - ( - "%{REQUEST_SCHEME}", - if context.tls { "https" } else { "http" }.to_owned(), - ), - ("%{SERVER_NAME}", context.server_name.clone()), - ("%{SERVER_PORT}", context.server_port.to_string()), - ("%{REMOTE_ADDR}", context.client_ip.to_string()), - ( - "%{HTTP_USER_AGENT}", - headers - .get(http::header::USER_AGENT) - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_owned(), - ), - ( - "%{HTTP_REFERER}", - headers - .get(http::header::REFERER) - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_owned(), - ), - ( - "%{HTTP_COOKIE}", - headers - .get(http::header::COOKIE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_owned(), - ), - ( - "%{DOCUMENT_ROOT}", - service.root.to_string_lossy().to_string(), - ), - ( - "%{THE_REQUEST}", - format!( - "{} {} {}", - method, - uri.path_and_query().map(|v| v.as_str()).unwrap_or(path), - super::server_protocol(context.version) - ), - ), - ]; - for (needle, replacement) in pairs { - value = value.replace(needle, &replacement); - } - let mut offset = 0usize; - while let Some(start) = value[offset..].find("%{HTTP:") { - let start = offset + start; - let Some(end_rel) = value[start..].find('}') else { - break; - }; - let end = start + end_rel; - let name = &value[start + 7..end]; - let replacement = HeaderName::from_str(name) - .ok() - .and_then(|name| headers.get(&name)) - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_owned(); - value.replace_range(start..=end, &replacement); - offset = start + replacement.len(); - } - for index in 1..=9 { - value = value.replace( - &format!("%{index}"), - captures.get(index).map(String::as_str).unwrap_or(""), - ); - } - value -} - -async fn file_test(service: &CompiledService, value: &str, kind: FileTest) -> bool { - let path = PathBuf::from(value); - let relative = if path.is_absolute() { - match path.strip_prefix(&service.root) { - Ok(value) => value, - Err(_) => return false, - } - } else { - path.as_path() - }; - let mut components = Vec::new(); - for part in relative.components() { - match part { - std::path::Component::Normal(value) => { - let Some(value) = value.to_str() else { - return false; - }; - components.push(value.to_owned()); - } - std::path::Component::CurDir => {} - _ => return false, - } - } - match open_components(service, components).await { - Ok(opened) => match kind { - FileTest::File => opened.metadata.is_file(), - FileTest::Directory => opened.metadata.is_dir(), - FileTest::NonEmptyFile => opened.metadata.is_file() && opened.metadata.len() > 0, - }, - Err(_) => false, - } -} - -async fn condition_matches( - condition: &RewriteCondition, - service: &CompiledService, - method: &Method, - headers: &HeaderMap, - path: &str, - query: Option<&str>, - uri: &Uri, - context: &RequestContext, - prior_captures: &[String], -) -> (bool, Vec) { - let value = expand_condition_input( - &condition.input, - service, - method, - headers, - path, - query, - uri, - context, - prior_captures, - ); - match &condition.matcher { - ConditionMatcher::File { kind, negate } => { - let matched = file_test(service, &value, *kind).await; - (if *negate { !matched } else { matched }, Vec::new()) - } - ConditionMatcher::Exact { - value: wanted, - negate, - insensitive, - } => { - let equal = if *insensitive { - value.eq_ignore_ascii_case(wanted) - } else { - value.as_str() == wanted.as_str() - }; - (if *negate { !equal } else { equal }, Vec::new()) - } - ConditionMatcher::Regex { regex, negate } => { - if let Some(captures) = regex.captures(&value) { - let values = captures - .iter() - .map(|value| value.map(|v| v.as_str().to_owned()).unwrap_or_default()) - .collect::>(); - ( - if *negate { false } else { true }, - if *negate { Vec::new() } else { values }, - ) - } else { - (if *negate { true } else { false }, Vec::new()) - } - } - } -} - -async fn conditions_match( - rule: &RewriteRule, - service: &CompiledService, - method: &Method, - headers: &HeaderMap, - path: &str, - query: Option<&str>, - uri: &Uri, - context: &RequestContext, -) -> (bool, Vec) { - let mut i = 0usize; - let mut captures = Vec::new(); - while i < rule.conditions.len() { - let mut group_ok = false; - loop { - let condition = &rule.conditions[i]; - let (matched, current) = condition_matches( - condition, service, method, headers, path, query, uri, context, &captures, - ) - .await; - if matched { - group_ok = true; - if !current.is_empty() { - captures = current; - } - } - let chained = condition.or_next; - i += 1; - if !chained || i >= rule.conditions.len() { - break; - } - } - if !group_ok { - return (false, Vec::new()); - } - } - (true, captures) -} - -fn replacement_with_captures( - rule: &RewriteRule, - captures: ®ex::Captures<'_>, - condition_captures: &[String], -) -> String { - let mut replacement = String::new(); - captures.expand(&rule.replacement, &mut replacement); - for index in 1..=9 { - replacement = replacement.replace( - &format!("%{index}"), - condition_captures - .get(index) - .map(String::as_str) - .unwrap_or(""), - ); - } - replacement -} - -fn scoped_target(scope: &Scope, replacement: &str) -> String { - if replacement.starts_with('/') { - return replacement.to_owned(); - } - if scope.components.is_empty() { - format!("/{replacement}") - } else if replacement.is_empty() { - format!("/{}/", scope.components.join("/")) - } else { - format!("/{}/{}", scope.components.join("/"), replacement) - } -} - -fn redirect_response(status: StatusCode, location: &str) -> Result { - let mut response = Response::new(Body::empty()); - *response.status_mut() = status; - response.headers_mut().insert( - LOCATION, - HeaderValue::from_str(location).context("invalid .htaccess redirect location")?, - ); - Ok(response) -} - -pub async fn apply_rewrites( - compiled: Option<&Compiled>, - service: &CompiledService, - method: &Method, - headers: &HeaderMap, - uri: &Uri, - context: &RequestContext, - initial_path: &str, - initial_query: Option<&str>, -) -> Result { - let Some(compiled) = compiled else { - return Ok(RewriteOutcome::Internal { - path: initial_path.to_owned(), - query: initial_query.map(str::to_owned), - }); - }; - let mut path = initial_path.to_owned(); - let mut query = initial_query.map(str::to_owned); - let mut exhausted = false; - for pass in 0..8 { - let scopes = matching_scopes(compiled, &path); - let mut changed = false; - let mut end = false; - 'scope: for scope in scopes { - for rule in &scope.rewrites { - let relative = scope_relative_path(scope, &path); - if !rule.pattern.is_match(&relative) { - continue; - } - let (conditions_ok, condition_captures) = conditions_match( - rule, - service, - method, - headers, - &path, - query.as_deref(), - uri, - context, - ) - .await; - if !conditions_ok { - continue; - } - let Some(captures) = rule.pattern.captures(&relative) else { - continue; - }; - if rule.forbidden { - return Ok(RewriteOutcome::Forbidden); - } - let replacement = replacement_with_captures(rule, &captures, &condition_captures); - if replacement != "-" { - let (replacement_path, replacement_query) = - if let Some((target, query_part)) = replacement.split_once('?') { - (target, Some(query_part.to_owned())) - } else { - (replacement.as_str(), None) - }; - let target = if rule.redirect.is_some() - && (replacement_path.starts_with("http://") - || replacement_path.starts_with("https://")) - { - replacement_path.to_owned() - } else { - scoped_target(scope, replacement_path) - }; - let next_query = if rule.qsa { - match (replacement_query, query.clone()) { - (Some(new), Some(old)) if !new.is_empty() && !old.is_empty() => { - Some(format!("{new}&{old}")) - } - (Some(new), _) => Some(new), - (None, old) => old, - } - } else { - replacement_query.or_else(|| query.clone()) - }; - if let Some(status) = rule.redirect { - let mut location = target; - if let Some(query) = next_query.filter(|q| !q.is_empty()) { - location.push('?'); - location.push_str(&query); - } - return Ok(RewriteOutcome::Redirect(redirect_response( - status, &location, - )?)); - } - ensure!( - target.starts_with('/'), - ".htaccess internal rewrite must stay on an absolute local path" - ); - if target != path || next_query != query { - changed = true; - path = target; - query = next_query; - } - } - if rule.end { - end = true; - break 'scope; - } - if rule.last { - break 'scope; - } - } - } - if end || !changed { - break; - } - if pass == 7 { - exhausted = true; - } - } - ensure!( - !exhausted, - ".htaccess rewrite exceeded the 8-pass safety limit" - ); - Ok(RewriteOutcome::Internal { path, query }) -} +include!("htaccess/model.rs"); +include!("htaccess/parse.rs"); +include!("htaccess/rewrite.rs"); diff --git a/src/web_server/htaccess/model.rs b/src/web_server/htaccess/model.rs new file mode 100644 index 0000000..4ca0ef1 --- /dev/null +++ b/src/web_server/htaccess/model.rs @@ -0,0 +1,359 @@ +use super::{ + config::CompiledService, + filesystem::path_components, + request::RequestContext, + routing::open_components, +}; +use anyhow::{bail, ensure, Context, Result}; +use axum::{body::Body, response::Response}; +use http::{ + header::{CACHE_CONTROL, EXPIRES, LOCATION}, + HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri, +}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, HashSet}, + fs, + io::Read, + path::{Path, PathBuf}, + str::FromStr, + time::{Duration, SystemTime}, +}; + +const MAX_HTACCESS_FILES: usize = 128; +const MAX_HTACCESS_DEPTH: usize = 16; +const MAX_HTACCESS_FILE_BYTES: u64 = 128 * 1024; +const MAX_HTACCESS_TOTAL_BYTES: u64 = 1024 * 1024; +const MAX_HTACCESS_RULES: usize = 256; +const MAX_HTACCESS_CONDITIONS: usize = 512; +const MAX_HTACCESS_HEADERS: usize = 64; +const MAX_HTACCESS_MIME_TYPES: usize = 128; + +fn default_filename() -> String { + ".htaccess".into() +} + +fn default_scan_subdirectories() -> bool { + true +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + pub enabled: bool, + #[serde(default = "default_filename")] + pub filename: String, + #[serde(default = "default_scan_subdirectories")] + pub scan_subdirectories: bool, + /// Unsupported directives are warnings by default. Strict mode rejects them. + pub strict: bool, +} + +impl Default for Config { + fn default() -> Self { + Self { + enabled: false, + filename: default_filename(), + scan_subdirectories: true, + strict: false, + } + } +} + +pub fn validate(config: &mut Config) -> Result<()> { + config.filename = config.filename.trim().to_owned(); + ensure!( + !config.filename.is_empty() + && config.filename.len() <= 64 + && !config.filename.chars().any(char::is_control) + && !config.filename.contains('/') + && !config.filename.contains('\\') + && config.filename != "." + && config.filename != "..", + ".htaccess compatibility filename must be a plain 1..64 byte file name" + ); + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct Compiled { + pub filename: String, + scopes: Vec, + pub files_loaded: usize, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Default)] +struct Scope { + components: Vec, + rewrites: Vec, + directory_index: Option>, + directory_listing: Option, + mime_types: BTreeMap, + php_extensions: HashSet, + headers: Vec, + expires_active: bool, + expires_default: Option, + expires_by_type: Vec<(String, Duration)>, + deny_all: Option, +} + +#[derive(Debug, Clone)] +struct RewriteRule { + pattern: Regex, + replacement: String, + redirect: Option, + forbidden: bool, + last: bool, + end: bool, + qsa: bool, + conditions: Vec, +} + +#[derive(Debug, Clone)] +struct RewriteCondition { + input: String, + matcher: ConditionMatcher, + or_next: bool, +} + +#[derive(Debug, Clone)] +enum ConditionMatcher { + Regex { regex: Regex, negate: bool }, + Exact { value: String, negate: bool, insensitive: bool }, + File { kind: FileTest, negate: bool }, +} + +#[derive(Debug, Clone, Copy)] +enum FileTest { + File, + Directory, + NonEmptyFile, +} + +#[derive(Debug, Clone)] +enum HeaderOp { + Set(HeaderName, HeaderValue), + Add(HeaderName, HeaderValue), + Unset(HeaderName), +} + +#[derive(Debug)] +pub enum RewriteOutcome { + Internal { path: String, query: Option }, + Redirect(Response), + Forbidden, +} + +#[derive(Default)] +struct ParseState { + rewrite_engine: bool, + pending_conditions: Vec, + skip_next_rule: bool, + in_files_block: usize, + unsupported: Vec, +} + +fn tokens(value: &str) -> Result> { + let mut out = Vec::new(); + let mut current = String::new(); + let mut quote = None; + let mut escaped = false; + for ch in value.chars() { + if escaped { + current.push(ch); + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + current.push(ch); + continue; + } + if let Some(wanted) = quote { + if ch == wanted { + quote = None; + } else { + current.push(ch); + } + continue; + } + if ch == '"' || ch == '\'' { + quote = Some(ch); + } else if ch.is_whitespace() { + if !current.is_empty() { + out.push(std::mem::take(&mut current)); + } + } else { + current.push(ch); + } + } + ensure!(quote.is_none(), "unterminated quote in .htaccess"); + if !current.is_empty() { + out.push(current); + } + Ok(out) +} + +fn flags(value: Option<&str>) -> Vec { + value + .and_then(|value| value.strip_prefix('[').and_then(|v| v.strip_suffix(']'))) + .map(|value| value.split(',').map(|v| v.trim().to_owned()).filter(|v| !v.is_empty()).collect()) + .unwrap_or_default() +} + +fn ci_regex(pattern: &str, insensitive: bool) -> Result { + if insensitive { + Regex::new(&format!("(?i:{pattern})")).context("invalid case-insensitive .htaccess regex") + } else { + Regex::new(pattern).context("invalid .htaccess regex") + } +} + +fn supported_response_header(name: &HeaderName) -> bool { + !matches!( + name.as_str(), + "connection" + | "content-length" + | "content-encoding" + | "content-range" + | "transfer-encoding" + | "upgrade" + | "keep-alive" + | "proxy-connection" + | "vary" + ) +} + +fn parse_duration(value: &str) -> Result { + let value = value.trim().trim_matches('"').trim(); + if let Some(seconds) = value.strip_prefix('A').or_else(|| value.strip_prefix('a')) { + let seconds: u64 = seconds.parse().context("invalid Expires duration")?; + return Ok(Duration::from_secs(seconds)); + } + let parts = value.split_whitespace().collect::>(); + ensure!(parts.len() >= 4, "Expires value must look like 'access plus 1 day' or A3600"); + ensure!(parts[0].eq_ignore_ascii_case("access") && parts[1].eq_ignore_ascii_case("plus"), "only access-based Expires values are supported"); + let count: u64 = parts[2].parse().context("invalid Expires duration number")?; + let unit = parts[3].to_ascii_lowercase(); + let seconds = match unit.trim_end_matches('s') { + "second" => count, + "minute" => count.saturating_mul(60), + "hour" => count.saturating_mul(3600), + "day" => count.saturating_mul(86400), + "week" => count.saturating_mul(7 * 86400), + "month" => count.saturating_mul(30 * 86400), + "year" => count.saturating_mul(365 * 86400), + _ => bail!("unsupported Expires duration unit"), + }; + Ok(Duration::from_secs(seconds)) +} + +fn parse_condition(parts: &[String]) -> Result { + ensure!(parts.len() >= 3, "RewriteCond needs a test string and pattern"); + let condition_flags = flags(parts.get(3).map(String::as_str)); + let insensitive = condition_flags.iter().any(|flag| flag.eq_ignore_ascii_case("NC")); + let or_next = condition_flags.iter().any(|flag| flag.eq_ignore_ascii_case("OR")); + for flag in &condition_flags { + ensure!(flag.eq_ignore_ascii_case("NC") || flag.eq_ignore_ascii_case("OR"), "unsupported RewriteCond flag [{flag}]"); + } + let raw = parts[2].as_str(); + let matcher = if let Some(value) = raw.strip_prefix("!=") { + ConditionMatcher::Exact { value: value.to_owned(), negate: true, insensitive } + } else if let Some(value) = raw.strip_prefix('=') { + ConditionMatcher::Exact { value: value.to_owned(), negate: false, insensitive } + } else { + let (negate, raw) = raw.strip_prefix('!').map_or((false, raw), |v| (true, v)); + match raw { + "-f" => ConditionMatcher::File { kind: FileTest::File, negate }, + "-d" => ConditionMatcher::File { kind: FileTest::Directory, negate }, + "-s" => ConditionMatcher::File { kind: FileTest::NonEmptyFile, negate }, + _ => ConditionMatcher::Regex { regex: ci_regex(raw, insensitive)?, negate }, + } + }; + Ok(RewriteCondition { input: parts[1].clone(), matcher, or_next }) +} + +fn parse_rewrite_rule(parts: &[String], conditions: Vec, state: &mut ParseState) -> Result { + ensure!(parts.len() >= 3, "RewriteRule needs a pattern and substitution"); + let rule_flags = flags(parts.get(3).map(String::as_str)); + let mut redirect = None; + let mut forbidden = false; + let mut last = false; + let mut end = false; + let mut qsa = false; + let mut insensitive = false; + for flag in rule_flags { + let upper = flag.to_ascii_uppercase(); + match upper.as_str() { + "L" => last = true, + "END" => { end = true; last = true; } + "QSA" => qsa = true, + "NC" => insensitive = true, + "F" => { forbidden = true; last = true; } + "R" => { redirect = Some(StatusCode::FOUND); last = true; } + "PT" | "NE" => {} + _ if upper.starts_with("R=") => { + let code: u16 = upper[2..].parse().context("invalid RewriteRule redirect status")?; + ensure!(matches!(code, 301 | 302 | 307 | 308), "RewriteRule redirect status must be 301, 302, 307 or 308"); + redirect = Some(StatusCode::from_u16(code)?); + last = true; + } + _ if upper.starts_with("E=") => state.unsupported.push(format!("RewriteRule environment flag [{flag}] is accepted as a no-op; request HTTP headers are already forwarded to PHP-FPM")), + _ => bail!("unsupported RewriteRule flag [{flag}]"), + } + } + Ok(RewriteRule { + pattern: ci_regex(&parts[1], insensitive)?, + replacement: parts[2].clone(), + redirect, + forbidden, + last, + end, + qsa, + conditions, + }) +} + +fn normalize_extension(value: &str) -> Result { + let value = value.trim().trim_start_matches('.').to_ascii_lowercase(); + ensure!(!value.is_empty() && value.len() <= 32 && value.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')), "invalid extension in .htaccess"); + Ok(value) +} + +fn unsupported(state: &mut ParseState, config: &Config, file: &Path, line: usize, message: String) -> Result<()> { + if config.strict { + bail!("{}:{line}: {message}", file.display()); + } + state.unsupported.push(format!("{}:{line}: {message}", file.display())); + Ok(()) +} + +fn read_htaccess_nofollow(path: &Path) -> Result { + #[cfg(unix)] + let mut file = { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) + .open(path) + .with_context(|| format!("cannot safely open {}", path.display()))? + }; + #[cfg(not(unix))] + let mut file = { + let metadata = fs::symlink_metadata(path)?; + ensure!(!metadata.file_type().is_symlink(), "{} must not be a symlink", path.display()); + fs::File::open(path)? + }; + let metadata = file.metadata()?; + ensure!(metadata.is_file(), "{} is not a regular file", path.display()); + ensure!(metadata.len() <= MAX_HTACCESS_FILE_BYTES, "{} exceeds the 128 KiB .htaccess limit", path.display()); + let mut content = String::new(); + (&mut file) + .take(MAX_HTACCESS_FILE_BYTES + 1) + .read_to_string(&mut content) + .with_context(|| format!("{} is not valid UTF-8", path.display()))?; + ensure!(content.len() as u64 <= MAX_HTACCESS_FILE_BYTES, "{} exceeds the 128 KiB .htaccess limit", path.display()); + Ok(content) +} diff --git a/src/web_server/htaccess/parse.rs b/src/web_server/htaccess/parse.rs new file mode 100644 index 0000000..22662f8 --- /dev/null +++ b/src/web_server/htaccess/parse.rs @@ -0,0 +1,395 @@ +fn parse_file(path: &Path, components: Vec, config: &Config) -> Result<(Scope, Vec)> { + let content = read_htaccess_nofollow(path)?; + let mut scope = Scope { components, ..Scope::default() }; + let mut state = ParseState { rewrite_engine: false, ..ParseState::default() }; + let mut rule_count = 0usize; + let mut condition_count = 0usize; + + for (line_no, raw) in content.lines().enumerate() { + let line_no = line_no + 1; + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { continue; } + let lower_line = line.to_ascii_lowercase(); + if lower_line.starts_with("" { continue; } + if lower_line.starts_with("") || line.eq_ignore_ascii_case("") { + state.in_files_block = state.in_files_block.saturating_sub(1); + continue; + } + if state.in_files_block != 0 { + // RustProxy always blocks .htaccess/.htpasswd directly. More general Files/FilesMatch + // access semantics remain intentionally outside the compatibility subset. + continue; + } + let parts = tokens(line).with_context(|| format!("{}:{line_no}", path.display()))?; + if parts.is_empty() { continue; } + let directive = parts[0].to_ascii_lowercase(); + match directive.as_str() { + "rewriteengine" => { + ensure!(parts.len() == 2, "{}:{line_no}: RewriteEngine expects On or Off", path.display()); + state.rewrite_engine = if parts[1].eq_ignore_ascii_case("on") { true } else if parts[1].eq_ignore_ascii_case("off") { false } else { bail!("{}:{line_no}: RewriteEngine expects On or Off", path.display()) }; + if !state.rewrite_engine { state.pending_conditions.clear(); state.skip_next_rule = false; } + } + "rewritebase" => { + // Per-directory substitutions are already resolved relative to the containing scope. + } + "rewritecond" if state.rewrite_engine => { + condition_count += 1; + ensure!(condition_count <= MAX_HTACCESS_CONDITIONS, "too many RewriteCond entries across one .htaccess file"); + match parse_condition(&parts) { + Ok(condition) => state.pending_conditions.push(condition), + Err(error) => { + unsupported(&mut state, config, path, line_no, error.to_string())?; + state.skip_next_rule = true; + } + } + } + "rewriterule" if state.rewrite_engine => { + rule_count += 1; + ensure!(rule_count <= MAX_HTACCESS_RULES, "too many RewriteRule entries across one .htaccess file"); + let conditions = std::mem::take(&mut state.pending_conditions); + if state.skip_next_rule { + state.skip_next_rule = false; + state.unsupported.push(format!("{}:{line_no}: RewriteRule skipped because a preceding RewriteCond was unsupported", path.display())); + continue; + } + match parse_rewrite_rule(&parts, conditions, &mut state) { + Ok(rule) => scope.rewrites.push(rule), + Err(error) => unsupported(&mut state, config, path, line_no, error.to_string())?, + } + } + "directoryindex" => { + ensure!(parts.len() >= 2, "{}:{line_no}: DirectoryIndex needs at least one file", path.display()); + let mut index = Vec::new(); + for value in &parts[1..] { + if value.eq_ignore_ascii_case("disabled") { index.clear(); break; } + ensure!(!value.contains('/') && !value.contains('\\') && value != "." && value != "..", "{}:{line_no}: DirectoryIndex entries must be plain file names", path.display()); + index.push(value.clone()); + } + ensure!(index.len() <= 16, "{}:{line_no}: DirectoryIndex allows at most 16 files", path.display()); + scope.directory_index = Some(index); + } + "options" => { + for option in &parts[1..] { + if option.eq_ignore_ascii_case("+Indexes") || option.eq_ignore_ascii_case("Indexes") { scope.directory_listing = Some(true); } + else if option.eq_ignore_ascii_case("-Indexes") { scope.directory_listing = Some(false); } + else if option.eq_ignore_ascii_case("-MultiViews") { /* RustProxy has no content negotiation; already effectively off. */ } + else { unsupported(&mut state, config, path, line_no, format!("unsupported Options token {option}"))?; } + } + } + "addtype" => { + ensure!(parts.len() >= 3, "{}:{line_no}: AddType expects media/type and extension(s)", path.display()); + let mime = HeaderValue::from_str(&parts[1]).context("invalid AddType media type")?; + for extension in &parts[2..] { + ensure!(scope.mime_types.len() < MAX_HTACCESS_MIME_TYPES, "too many AddType extensions in .htaccess"); + scope.mime_types.insert(normalize_extension(extension)?, mime.clone()); + } + } + "addhandler" => { + ensure!(parts.len() >= 3, "{}:{line_no}: AddHandler expects handler and extension(s)", path.display()); + if parts[1].to_ascii_lowercase().contains("php") { + for extension in &parts[2..] { scope.php_extensions.insert(normalize_extension(extension)?); } + } else { + bail!("{}:{line_no}: non-PHP AddHandler is not supported and cannot be ignored safely", path.display()); + } + } + "header" => { + ensure!(parts.len() >= 3, "{}:{line_no}: Header expects an action and name", path.display()); + ensure!(scope.headers.len() < MAX_HTACCESS_HEADERS, "too many Header directives in .htaccess"); + let mut action_index = 1usize; + if parts[action_index].eq_ignore_ascii_case("always") || parts[action_index].eq_ignore_ascii_case("onsuccess") { action_index += 1; } + ensure!(parts.len() > action_index + 1, "{}:{line_no}: Header expects an action and name", path.display()); + let action = parts[action_index].to_ascii_lowercase(); + let name = HeaderName::from_str(&parts[action_index + 1]).context("invalid Header name")?; + ensure!(supported_response_header(&name), "protected response header cannot be set from .htaccess"); + match action.as_str() { + "unset" => scope.headers.push(HeaderOp::Unset(name)), + "set" | "add" => { + ensure!(parts.len() > action_index + 2, "{}:{line_no}: Header {action} needs a value", path.display()); + let value = HeaderValue::from_str(&parts[action_index + 2..].join(" ")).context("invalid Header value")?; + if action == "set" { scope.headers.push(HeaderOp::Set(name, value)); } else { scope.headers.push(HeaderOp::Add(name, value)); } + } + _ => unsupported(&mut state, config, path, line_no, format!("unsupported Header action {action}"))?, + } + } + "expiresactive" => { + ensure!(parts.len() == 2, "{}:{line_no}: ExpiresActive expects On or Off", path.display()); + scope.expires_active = parts[1].eq_ignore_ascii_case("on"); + } + "expiresdefault" => { + ensure!(parts.len() >= 2, "{}:{line_no}: ExpiresDefault needs a duration", path.display()); + scope.expires_default = Some(parse_duration(&parts[1..].join(" "))?); + } + "expiresbytype" => { + ensure!(parts.len() >= 3, "{}:{line_no}: ExpiresByType needs media type and duration", path.display()); + scope.expires_by_type.push((parts[1].to_ascii_lowercase(), parse_duration(&parts[2..].join(" "))?)); + } + "require" => { + if parts.len() == 3 && parts[1].eq_ignore_ascii_case("all") && parts[2].eq_ignore_ascii_case("denied") { scope.deny_all = Some(true); } + else if parts.len() == 3 && parts[1].eq_ignore_ascii_case("all") && parts[2].eq_ignore_ascii_case("granted") { scope.deny_all = Some(false); } + else { bail!("{}:{line_no}: only 'Require all granted/denied' is supported; refusing to ignore an access-control directive", path.display()); } + } + "sethandler" | "removehandler" | "authtype" | "authname" | "authuserfile" | "authgroupfile" | "order" | "allow" | "deny" => { + bail!("{}:{line_no}: security/handler directive {} is not supported and cannot be ignored safely", path.display(), parts[0]); + } + "errordocument" | "setenvif" | "redirect" | "redirectmatch" | "fallbackresource" => { + unsupported(&mut state, config, path, line_no, format!("directive {} is not in the RustProxy compatibility subset", parts[0]))?; + } + _ => unsupported(&mut state, config, path, line_no, format!("unsupported directive {}", parts[0]))?, + } + } + ensure!(state.pending_conditions.is_empty(), "{}: RewriteCond without following RewriteRule", path.display()); + Ok((scope, state.unsupported)) +} + +fn discover(root: &Path, filename: &str, recursive: bool) -> Result)>> { + let mut result = Vec::new(); + let mut stack = vec![(root.to_path_buf(), Vec::::new(), 0usize)]; + while let Some((directory, components, depth)) = stack.pop() { + let directory = fs::canonicalize(&directory) + .with_context(|| format!("cannot canonicalize {} during .htaccess scan", directory.display()))?; + ensure!(directory.starts_with(root), ".htaccess scan escaped the Web Service document root"); + let candidate = directory.join(filename); + match fs::symlink_metadata(&candidate) { + Ok(metadata) => { + ensure!(!metadata.file_type().is_symlink(), "{} must not be a symlink", candidate.display()); + if metadata.is_file() { + result.push((candidate, components.clone())); + ensure!(result.len() <= MAX_HTACCESS_FILES, "Web Service .htaccess scan exceeds 128 files"); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).with_context(|| format!("cannot inspect {}", candidate.display())), + } + if !recursive || depth >= MAX_HTACCESS_DEPTH { continue; } + for entry in fs::read_dir(&directory).with_context(|| format!("cannot scan {} for .htaccess", directory.display()))? { + let entry = entry?; + let file_type = entry.file_type()?; + if !file_type.is_dir() || file_type.is_symlink() { continue; } + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { continue; }; + if name == ".git" || name == "node_modules" { continue; } + let mut child = components.clone(); + child.push(name); + stack.push((entry.path(), child, depth + 1)); + } + } + result.sort_by(|a, b| a.1.len().cmp(&b.1.len()).then_with(|| a.1.cmp(&b.1))); + Ok(result) +} + +pub fn compile(root: &Path, config: &Config) -> Result> { + if !config.enabled { return Ok(None); } + let files = discover(root, &config.filename, config.scan_subdirectories)?; + let mut scopes = Vec::new(); + let mut warnings = Vec::new(); + let mut total = 0u64; + for (file, components) in files { + total = total.saturating_add(fs::metadata(&file)?.len()); + ensure!(total <= MAX_HTACCESS_TOTAL_BYTES, "Web Service .htaccess files exceed 1 MiB total"); + let (scope, mut file_warnings) = parse_file(&file, components, config)?; + scopes.push(scope); + warnings.append(&mut file_warnings); + } + Ok(Some(Compiled { filename: config.filename.clone(), files_loaded: scopes.len(), scopes, warnings })) +} + +pub fn adds_php_extensions(compiled: Option<&Compiled>) -> bool { + compiled.is_some_and(|compiled| compiled.scopes.iter().any(|scope| !scope.php_extensions.is_empty())) +} + +pub fn protected_component(compiled: Option<&Compiled>, value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + if lower == ".htaccess" || lower == ".htpasswd" || lower.starts_with(".ht") { return true; } + compiled.is_some_and(|compiled| value.eq_ignore_ascii_case(&compiled.filename)) +} + +fn scope_matches(scope: &Scope, components: &[String]) -> bool { + components.len() >= scope.components.len() && components[..scope.components.len()] == scope.components[..] +} + +fn matching_scopes<'a>(compiled: &'a Compiled, path: &str) -> Vec<&'a Scope> { + let Ok(components) = path_components(path) else { return Vec::new(); }; + compiled.scopes.iter().filter(|scope| scope_matches(scope, &components)).collect() +} + +pub fn denied(compiled: Option<&Compiled>, path: &str) -> bool { + let Some(compiled) = compiled else { return false; }; + matching_scopes(compiled, path).into_iter().filter_map(|scope| scope.deny_all).last().unwrap_or(false) +} + +pub fn index_files<'a>(compiled: Option<&'a Compiled>, directory_components: &[String], fallback: &'a [String]) -> &'a [String] { + let Some(compiled) = compiled else { return fallback; }; + compiled.scopes.iter().filter(|scope| scope_matches(scope, directory_components)).filter_map(|scope| scope.directory_index.as_deref()).last().unwrap_or(fallback) +} + +pub fn directory_listing(compiled: Option<&Compiled>, directory_components: &[String], fallback: bool) -> bool { + let Some(compiled) = compiled else { return fallback; }; + compiled.scopes.iter().filter(|scope| scope_matches(scope, directory_components)).filter_map(|scope| scope.directory_listing).last().unwrap_or(fallback) +} + +pub fn mime_type(compiled: Option<&Compiled>, path: &Path) -> Option { + let compiled = compiled?; + let extension = path.extension()?.to_str()?.to_ascii_lowercase(); + let path_text = path.to_string_lossy(); + matching_scopes(compiled, &path_text).into_iter().filter_map(|scope| scope.mime_types.get(&extension).cloned()).last() +} + +pub fn php_extension(compiled: Option<&Compiled>, path: &Path) -> bool { + let Some(compiled) = compiled else { return false; }; + let Some(extension) = path.extension().and_then(|v| v.to_str()).map(|v| v.to_ascii_lowercase()) else { return false; }; + let path_text = path.to_string_lossy(); + matching_scopes(compiled, &path_text).into_iter().any(|scope| scope.php_extensions.contains(&extension)) +} + +pub fn apply_response(compiled: Option<&Compiled>, response: &mut Response, path: &str) { + let Some(compiled) = compiled else { return; }; + let scopes = matching_scopes(compiled, path); + for scope in &scopes { + for op in &scope.headers { + match op { + HeaderOp::Set(name, value) => { response.headers_mut().insert(name.clone(), value.clone()); } + HeaderOp::Add(name, value) => { response.headers_mut().append(name.clone(), value.clone()); } + HeaderOp::Unset(name) => { response.headers_mut().remove(name); } + } + } + } + let Some(content_type) = response.headers().get(http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).map(|v| v.to_ascii_lowercase()) else { return; }; + let mut expiry = None; + for scope in scopes { + if !scope.expires_active { continue; } + let specific = scope.expires_by_type.iter().find(|(mime, _)| content_type.starts_with(mime)).map(|(_, duration)| *duration); + expiry = specific.or(scope.expires_default).or(expiry); + } + if let Some(duration) = expiry { + let seconds = duration.as_secs(); + if let Ok(value) = HeaderValue::from_str(&format!("public, max-age={seconds}")) { response.headers_mut().insert(CACHE_CONTROL, value); } + if let Ok(value) = HeaderValue::from_str(&httpdate::fmt_http_date(SystemTime::now() + duration)) { response.headers_mut().insert(EXPIRES, value); } + } +} + +fn scope_relative_path(scope: &Scope, path: &str) -> String { + let prefix = if scope.components.is_empty() { "/".to_owned() } else { format!("/{}/", scope.components.join("/")) }; + if scope.components.is_empty() { return path.trim_start_matches('/').to_owned(); } + if path == prefix.trim_end_matches('/') { return String::new(); } + path.strip_prefix(&prefix).unwrap_or(path.trim_start_matches('/')).to_owned() +} + +fn expand_condition_input(input: &str, service: &CompiledService, method: &Method, headers: &HeaderMap, path: &str, query: Option<&str>, uri: &Uri, context: &RequestContext, captures: &[String]) -> String { + let mut value = input.to_owned(); + let request_filename = service.root.join(path.trim_start_matches('/')).to_string_lossy().to_string(); + let pairs = [ + ("%{REQUEST_FILENAME}", request_filename), + ("%{REQUEST_URI}", path.to_owned()), + ("%{REQUEST_METHOD}", method.as_str().to_owned()), + ("%{QUERY_STRING}", query.unwrap_or("").to_owned()), + ("%{HTTP_HOST}", headers.get(http::header::HOST).and_then(|v| v.to_str().ok()).unwrap_or(&context.server_name).to_owned()), + ("%{HTTPS}", if context.tls { "on" } else { "off" }.to_owned()), + ("%{REQUEST_SCHEME}", if context.tls { "https" } else { "http" }.to_owned()), + ("%{SERVER_NAME}", context.server_name.clone()), + ("%{SERVER_PORT}", context.server_port.to_string()), + ("%{REMOTE_ADDR}", context.client_ip.to_string()), + ("%{HTTP_USER_AGENT}", headers.get(http::header::USER_AGENT).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned()), + ("%{HTTP_REFERER}", headers.get(http::header::REFERER).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned()), + ("%{HTTP_COOKIE}", headers.get(http::header::COOKIE).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned()), + ("%{DOCUMENT_ROOT}", service.root.to_string_lossy().to_string()), + ("%{THE_REQUEST}", format!("{} {} {}", method, uri.path_and_query().map(|v| v.as_str()).unwrap_or(path), super::request::server_protocol(context.version))), + ]; + for (needle, replacement) in pairs { value = value.replace(needle, &replacement); } + let mut offset = 0usize; + while let Some(start) = value[offset..].find("%{HTTP:") { + let start = offset + start; + let Some(end_rel) = value[start..].find('}') else { break; }; + let end = start + end_rel; + let name = &value[start + 7..end]; + let replacement = HeaderName::from_str(name).ok().and_then(|name| headers.get(&name)).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned(); + value.replace_range(start..=end, &replacement); + offset = start + replacement.len(); + } + for index in 1..=9 { value = value.replace(&format!("%{index}"), captures.get(index).map(String::as_str).unwrap_or("")); } + value +} + +async fn file_test(service: &CompiledService, value: &str, kind: FileTest) -> bool { + let path = PathBuf::from(value); + let relative = if path.is_absolute() { + match path.strip_prefix(&service.root) { Ok(value) => value, Err(_) => return false } + } else { path.as_path() }; + let mut components = Vec::new(); + for part in relative.components() { + match part { + std::path::Component::Normal(value) => { let Some(value) = value.to_str() else { return false; }; components.push(value.to_owned()); } + std::path::Component::CurDir => {} + _ => return false, + } + } + match open_components(service, components).await { + Ok(opened) => match kind { FileTest::File => opened.metadata.is_file(), FileTest::Directory => opened.metadata.is_dir(), FileTest::NonEmptyFile => opened.metadata.is_file() && opened.metadata.len() > 0 }, + Err(_) => false, + } +} + +async fn condition_matches(condition: &RewriteCondition, service: &CompiledService, method: &Method, headers: &HeaderMap, path: &str, query: Option<&str>, uri: &Uri, context: &RequestContext, prior_captures: &[String]) -> (bool, Vec) { + let value = expand_condition_input(&condition.input, service, method, headers, path, query, uri, context, prior_captures); + match &condition.matcher { + ConditionMatcher::File { kind, negate } => { + let matched = file_test(service, &value, *kind).await; + (if *negate { !matched } else { matched }, Vec::new()) + } + ConditionMatcher::Exact { value: wanted, negate, insensitive } => { + let equal = if *insensitive { value.eq_ignore_ascii_case(wanted) } else { value.as_str() == wanted.as_str() }; + (if *negate { !equal } else { equal }, Vec::new()) + } + ConditionMatcher::Regex { regex, negate } => { + if let Some(captures) = regex.captures(&value) { + let values = captures.iter().map(|value| value.map(|v| v.as_str().to_owned()).unwrap_or_default()).collect::>(); + (if *negate { false } else { true }, if *negate { Vec::new() } else { values }) + } else { (if *negate { true } else { false }, Vec::new()) } + } + } +} + +async fn conditions_match(rule: &RewriteRule, service: &CompiledService, method: &Method, headers: &HeaderMap, path: &str, query: Option<&str>, uri: &Uri, context: &RequestContext) -> (bool, Vec) { + let mut i = 0usize; + let mut captures = Vec::new(); + while i < rule.conditions.len() { + let mut group_ok = false; + loop { + let condition = &rule.conditions[i]; + let (matched, current) = condition_matches(condition, service, method, headers, path, query, uri, context, &captures).await; + if matched { + group_ok = true; + if !current.is_empty() { captures = current; } + } + let chained = condition.or_next; + i += 1; + if !chained || i >= rule.conditions.len() { break; } + } + if !group_ok { return (false, Vec::new()); } + } + (true, captures) +} + +fn replacement_with_captures(rule: &RewriteRule, captures: ®ex::Captures<'_>, condition_captures: &[String]) -> String { + let mut replacement = String::new(); + captures.expand(&rule.replacement, &mut replacement); + for index in 1..=9 { replacement = replacement.replace(&format!("%{index}"), condition_captures.get(index).map(String::as_str).unwrap_or("")); } + replacement +} + +fn scoped_target(scope: &Scope, replacement: &str) -> String { + if replacement.starts_with('/') { return replacement.to_owned(); } + if scope.components.is_empty() { format!("/{replacement}") } else if replacement.is_empty() { format!("/{}/", scope.components.join("/")) } else { format!("/{}/{}", scope.components.join("/"), replacement) } +} + +fn redirect_response(status: StatusCode, location: &str) -> Result { + let mut response = Response::new(Body::empty()); + *response.status_mut() = status; + response.headers_mut().insert(LOCATION, HeaderValue::from_str(location).context("invalid .htaccess redirect location")?); + Ok(response) +} diff --git a/src/web_server/htaccess/rewrite.rs b/src/web_server/htaccess/rewrite.rs new file mode 100644 index 0000000..48f73e8 --- /dev/null +++ b/src/web_server/htaccess/rewrite.rs @@ -0,0 +1,46 @@ +pub async fn apply_rewrites(compiled: Option<&Compiled>, service: &CompiledService, method: &Method, headers: &HeaderMap, uri: &Uri, context: &RequestContext, initial_path: &str, initial_query: Option<&str>) -> Result { + let Some(compiled) = compiled else { return Ok(RewriteOutcome::Internal { path: initial_path.to_owned(), query: initial_query.map(str::to_owned) }); }; + let mut path = initial_path.to_owned(); + let mut query = initial_query.map(str::to_owned); + let mut exhausted = false; + for pass in 0..8 { + let scopes = matching_scopes(compiled, &path); + let mut changed = false; + let mut end = false; + 'scope: for scope in scopes { + for rule in &scope.rewrites { + let relative = scope_relative_path(scope, &path); + if !rule.pattern.is_match(&relative) { continue; } + let (conditions_ok, condition_captures) = conditions_match(rule, service, method, headers, &path, query.as_deref(), uri, context).await; + if !conditions_ok { continue; } + let Some(captures) = rule.pattern.captures(&relative) else { continue; }; + if rule.forbidden { return Ok(RewriteOutcome::Forbidden); } + let replacement = replacement_with_captures(rule, &captures, &condition_captures); + if replacement != "-" { + let (replacement_path, replacement_query) = if let Some((target, query_part)) = replacement.split_once('?') { (target, Some(query_part.to_owned())) } else { (replacement.as_str(), None) }; + let target = if rule.redirect.is_some() && (replacement_path.starts_with("http://") || replacement_path.starts_with("https://")) { replacement_path.to_owned() } else { scoped_target(scope, replacement_path) }; + let next_query = if rule.qsa { + match (replacement_query, query.clone()) { + (Some(new), Some(old)) if !new.is_empty() && !old.is_empty() => Some(format!("{new}&{old}")), + (Some(new), _) => Some(new), + (None, old) => old, + } + } else { replacement_query.or_else(|| query.clone()) }; + if let Some(status) = rule.redirect { + let mut location = target; + if let Some(query) = next_query.filter(|q| !q.is_empty()) { location.push('?'); location.push_str(&query); } + return Ok(RewriteOutcome::Redirect(redirect_response(status, &location)?)); + } + ensure!(target.starts_with('/'), ".htaccess internal rewrite must stay on an absolute local path"); + if target != path || next_query != query { changed = true; path = target; query = next_query; } + } + if rule.end { end = true; break 'scope; } + if rule.last { break 'scope; } + } + } + if end || !changed { break; } + if pass == 7 { exhausted = true; } + } + ensure!(!exhausted, ".htaccess rewrite exceeded the 8-pass safety limit"); + Ok(RewriteOutcome::Internal { path, query }) +} diff --git a/src/web_server/mod.rs b/src/web_server/mod.rs new file mode 100644 index 0000000..15a1407 --- /dev/null +++ b/src/web_server/mod.rs @@ -0,0 +1,25 @@ +//! Static Web Server source integrated with the normal RustProxy vhost/location pipeline. +//! Web services are reusable configuration resources. Vhosts and locations only reference them. +//! +//! Keep this module as the stable public facade. Configuration, filesystem confinement, +//! HTTP responses, routing, FastCGI/PHP-FPM and request dispatch live in dedicated files. + +mod config; +mod fastcgi; +mod filesystem; +mod htaccess; +mod request; +mod response; +mod routing; +mod security; +mod serve; + +pub use config::{ + CacheRule, CompiledService, Hardening, Php, Precompressed, RewriteRule, Service, SymlinkPolicy, +}; +pub use htaccess::Config as HtaccessConfig; +pub use request::RequestContext; +pub use serve::serve; + +#[cfg(test)] +mod tests; diff --git a/src/web_server/request.rs b/src/web_server/request.rs new file mode 100644 index 0000000..fd443f1 --- /dev/null +++ b/src/web_server/request.rs @@ -0,0 +1,23 @@ +use http::Version; +use std::net::IpAddr; + +pub struct RequestContext { + pub client_ip: IpAddr, + pub client_port: Option, + pub server_name: String, + pub server_port: u16, + pub tls: bool, + pub version: Version, +} + + +pub(super) fn server_protocol(version: http::Version) -> &'static str { + match version { + http::Version::HTTP_09 => "HTTP/0.9", + http::Version::HTTP_10 => "HTTP/1.0", + http::Version::HTTP_11 => "HTTP/1.1", + http::Version::HTTP_2 => "HTTP/2.0", + http::Version::HTTP_3 => "HTTP/3.0", + _ => "HTTP/1.1", + } +} diff --git a/src/web_server/response.rs b/src/web_server/response.rs new file mode 100644 index 0000000..741a2b1 --- /dev/null +++ b/src/web_server/response.rs @@ -0,0 +1,2 @@ +include!("response/headers.rs"); +include!("response/file.rs"); diff --git a/src/web_server/response/file.rs b/src/web_server/response/file.rs new file mode 100644 index 0000000..29f137e --- /dev/null +++ b/src/web_server/response/file.rs @@ -0,0 +1,206 @@ +pub(super) async fn file_response( + service: &CompiledService, + opened: Opened, + path: &Path, + method: &Method, + headers: &HeaderMap, + content_encoding: Option<&'static str>, +) -> Response { + let length = opened.metadata.len(); + let (etag, modified) = metadata_validators(&opened.metadata); + let mime = content_type(service, path); + if not_modified(headers, etag.as_deref(), modified) { + let mut response = Response::new(Body::empty()); + *response.status_mut() = StatusCode::NOT_MODIFIED; + add_validators(&mut response, etag.as_deref(), modified); + add_cache_control(&mut response, service, path, &mime); + add_download_header(&mut response, service, path); + if let Some(encoding) = content_encoding { + response + .headers_mut() + .insert(CONTENT_ENCODING, HeaderValue::from_static(encoding)); + response + .headers_mut() + .insert(VARY, HeaderValue::from_static("Accept-Encoding")); + } + response + .headers_mut() + .insert(ACCEPT_RANGES, HeaderValue::from_static("bytes")); + return response; + } + + let range_header = headers.get(RANGE).and_then(|value| value.to_str().ok()); + let range = if range_header.is_some() && if_range_allows(headers, etag.as_deref(), modified) { + match range_header.and_then(|value| parse_range(value, length)) { + Some(range) => Some(range), + None => { + let mut response = Response::new(Body::empty()); + *response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE; + response.headers_mut().insert( + CONTENT_RANGE, + HeaderValue::from_str(&format!("bytes */{length}")).unwrap(), + ); + response + .headers_mut() + .insert(ACCEPT_RANGES, HeaderValue::from_static("bytes")); + response + .headers_mut() + .insert(CONTENT_LENGTH, HeaderValue::from_static("0")); + add_validators(&mut response, etag.as_deref(), modified); + add_cache_control(&mut response, service, path, &mime); + add_download_header(&mut response, service, path); + if let Some(encoding) = content_encoding { + response + .headers_mut() + .insert(CONTENT_ENCODING, HeaderValue::from_static(encoding)); + response + .headers_mut() + .insert(VARY, HeaderValue::from_static("Accept-Encoding")); + } + return response; + } + } + } else { + None + }; + + let (status, start, body_length) = match range { + Some(range) => ( + StatusCode::PARTIAL_CONTENT, + range.start, + range.end - range.start + 1, + ), + None => (StatusCode::OK, 0, length), + }; + let mut response = Response::new(Body::empty()); + *response.status_mut() = status; + response.headers_mut().insert(CONTENT_TYPE, mime.clone()); + response.headers_mut().insert( + CONTENT_LENGTH, + HeaderValue::from_str(&body_length.to_string()).unwrap(), + ); + response + .headers_mut() + .insert(ACCEPT_RANGES, HeaderValue::from_static("bytes")); + if let Some(range) = range { + response.headers_mut().insert( + CONTENT_RANGE, + HeaderValue::from_str(&format!("bytes {}-{}/{length}", range.start, range.end)).unwrap(), + ); + } + add_validators(&mut response, etag.as_deref(), modified); + add_cache_control(&mut response, service, path, &mime); + add_download_header(&mut response, service, path); + if let Some(encoding) = content_encoding { + response + .headers_mut() + .insert(CONTENT_ENCODING, HeaderValue::from_static(encoding)); + response + .headers_mut() + .insert(VARY, HeaderValue::from_static("Accept-Encoding")); + } + if method == Method::HEAD || body_length == 0 { + return response; + } + + let mut file = tokio::fs::File::from_std(opened.file); + if start != 0 { + if file.seek(std::io::SeekFrom::Start(start)).await.is_err() { + return status_response( + StatusCode::INTERNAL_SERVER_ERROR, + "RustProxy: Web Server seek failed\n", + ); + } + } + let stream = ReaderStream::new(file.take(body_length)); + *response.body_mut() = Body::from_stream(stream); + response +} + +fn percent_encode_segment(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for byte in value.as_bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + out.push(*byte as char); + } else { + use std::fmt::Write; + let _ = write!(&mut out, "%{byte:02X}"); + } + } + out +} + +fn html_escape(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for ch in value.chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(ch), + } + } + out +} + +pub(super) fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec { + let title = html_escape(path); + let mut html = String::with_capacity(4096 + entries.len() * 180); + html.push_str(r#"Index of "#); + html.push_str(&title); + html.push_str(r#" · RustProxy
                    RustProxyWEB SERVER
                    "#); + html.push_str(&title); + html.push_str(r#"
                    DIRECTORY LISTING

                    Index of "#); + html.push_str(&title); + html.push_str("

                    "); + html.push_str(&format!("{} entries", entries.len())); + html.push_str("
                    "); + if path != "/" { + html.push_str(r#"UP../Parent"#); + } + for entry in entries { + let label = html_escape(&entry.name); + let mut href = percent_encode_segment(&entry.name); + if entry.directory { + href.push('/'); + } + let href = html_escape(&href); + let slash = if entry.directory { "/" } else { "" }; + let class = if entry.directory { "entry directory" } else { "entry file" }; + let kind = if entry.directory { "Directory" } else { "File" }; + let type_label = if entry.directory { "DIR" } else { "FILE" }; + html.push_str(&format!( + "{type_label}{label}{slash}{kind}" + )); + } + html.push_str(r#"
                    RustProxy 0.25.1Web Server · Directory listing
                    "#); + html.into_bytes() +} + +pub(super) fn directory_redirect(uri: &Uri, method: &Method) -> Response { + let mut target = uri.path().to_owned(); + if !target.ends_with('/') { + target.push('/'); + } + if let Some(query) = uri.query() { + target.push('?'); + target.push_str(query); + } + let mut response = Response::new(Body::empty()); + *response.status_mut() = if *method == Method::GET || *method == Method::HEAD { + StatusCode::MOVED_PERMANENTLY + } else { + StatusCode::PERMANENT_REDIRECT + }; + if let Ok(value) = HeaderValue::from_str(&target) { + response.headers_mut().insert(LOCATION, value); + } + response +} diff --git a/src/web_server/response/headers.rs b/src/web_server/response/headers.rs new file mode 100644 index 0000000..e71789f --- /dev/null +++ b/src/web_server/response/headers.rs @@ -0,0 +1,369 @@ +use super::{ + config::CompiledCacheMatcher, + filesystem::{DirectoryEntry, Opened}, + htaccess, + CompiledService, +}; +use axum::{body::Body, response::Response}; +use http::{ + header::{ + ACCEPT_RANGES, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, + CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, + IF_RANGE, LAST_MODIFIED, LOCATION, RANGE, VARY, + }, + HeaderMap, HeaderValue, Method, StatusCode, Uri, +}; +use std::{ + io, + path::Path, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio::io::{AsyncReadExt, AsyncSeekExt}; +use tokio_util::io::ReaderStream; + +pub(super) fn status_response(status: StatusCode, message: &'static str) -> Response { + let mut response = Response::new(Body::from(message)); + *response.status_mut() = status; + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response +} + +pub(super) fn builtin_error_response(status: StatusCode, method: &Method) -> Response { + let html = crate::pages::builtin_gateway_error(status) + .unwrap_or("

                    Error

                    "); + let mut response = Response::new(if *method == Method::HEAD { + Body::empty() + } else { + Body::from(html) + }); + *response.status_mut() = status; + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + if let Ok(length) = HeaderValue::from_str(&html.len().to_string()) { + response.headers_mut().insert(CONTENT_LENGTH, length); + } + response + .headers_mut() + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + response.headers_mut().insert( + "x-content-type-options", + HeaderValue::from_static("nosniff"), + ); + response +} + +pub(super) fn io_response(error: &io::Error) -> Response { + #[cfg(unix)] + if matches!(error.raw_os_error(), Some(libc::ELOOP)) { + return status_response(StatusCode::FORBIDDEN, "RustProxy: Web Server path is not allowed\n"); + } + match error.kind() { + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory => { + status_response(StatusCode::NOT_FOUND, "RustProxy: Web Server resource not found\n") + } + io::ErrorKind::PermissionDenied | io::ErrorKind::InvalidInput => { + status_response(StatusCode::FORBIDDEN, "RustProxy: Web Server path is not allowed\n") + } + _ => status_response( + StatusCode::INTERNAL_SERVER_ERROR, + "RustProxy: Web Server filesystem error\n", + ), + } +} + +fn builtin_content_type(path: &Path) -> Option<&'static str> { + let ext = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + Some(match ext.as_str() { + "html" | "htm" => "text/html; charset=utf-8", + "css" => "text/css; charset=utf-8", + "js" | "mjs" | "cjs" => "text/javascript; charset=utf-8", + "txt" | "log" | "md" => "text/plain; charset=utf-8", + "csv" => "text/csv; charset=utf-8", + "tsv" => "text/tab-separated-values; charset=utf-8", + "json" | "map" => "application/json; charset=utf-8", + "yaml" | "yml" => "application/yaml; charset=utf-8", + "xml" => "application/xml; charset=utf-8", + "rss" => "application/rss+xml; charset=utf-8", + "atom" => "application/atom+xml; charset=utf-8", + "ics" => "text/calendar; charset=utf-8", + "vcf" => "text/vcard; charset=utf-8", + "pdf" => "application/pdf", + "zip" => "application/zip", + "7z" => "application/x-7z-compressed", + "rar" => "application/vnd.rar", + "gz" => "application/gzip", + "bz2" => "application/x-bzip2", + "xz" => "application/x-xz", + "tar" => "application/x-tar", + "wasm" => "application/wasm", + "webmanifest" => "application/manifest+json; charset=utf-8", + "svg" => "image/svg+xml", + "png" => "image/png", + "apng" => "image/apng", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "avif" => "image/avif", + "heic" => "image/heic", + "heif" => "image/heif", + "jxl" => "image/jxl", + "ico" => "image/x-icon", + "bmp" => "image/bmp", + "mp3" => "audio/mpeg", + "m4a" => "audio/mp4", + "ogg" | "oga" => "audio/ogg", + "wav" => "audio/wav", + "flac" => "audio/flac", + "mp4" | "m4v" => "video/mp4", + "mov" => "video/quicktime", + "mpeg" | "mpg" => "video/mpeg", + "avi" => "video/x-msvideo", + "webm" => "video/webm", + "ogv" => "video/ogg", + "woff" => "font/woff", + "woff2" => "font/woff2", + "ttf" => "font/ttf", + "otf" => "font/otf", + "eot" => "application/vnd.ms-fontobject", + "doc" => "application/msword", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls" => "application/vnd.ms-excel", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt" => "application/vnd.ms-powerpoint", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + _ => return None, + }) +} + +fn content_type(service: &CompiledService, path: &Path) -> HeaderValue { + if let Some(value) = htaccess::mime_type(service.htaccess.as_ref(), path) { + return value; + } + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if let Some(value) = service.mime_types.get(&extension) { + return value.clone(); + } + if let Some(value) = builtin_content_type(path) { + return HeaderValue::from_static(value); + } + service.default_mime_type.clone() +} + +fn cache_control_for( + service: &CompiledService, + path: &Path, + content_type: &HeaderValue, +) -> Option { + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + let mime = content_type + .to_str() + .unwrap_or("") + .to_ascii_lowercase(); + for rule in &service.cache_rules { + let matches = match &rule.matcher { + CompiledCacheMatcher::Extension(value) => extension == *value, + CompiledCacheMatcher::MimePrefix(value) => mime.starts_with(value), + }; + if matches { + return Some(rule.cache_control.clone()); + } + } + service.cache_control.clone() +} + +fn add_cache_control( + response: &mut Response, + service: &CompiledService, + path: &Path, + content_type: &HeaderValue, +) { + if let Some(value) = cache_control_for(service, path, content_type) { + response.headers_mut().insert(CACHE_CONTROL, value); + } +} + +pub(super) fn add_service_headers(response: &mut Response, service: &CompiledService) { + for (name, value) in &service.response_headers { + response.headers_mut().insert(name.clone(), value.clone()); + } +} + +fn download_extension(service: &CompiledService, path: &Path) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .is_some_and(|value| service.download_extensions.contains(&value)) +} + +fn add_download_header(response: &mut Response, service: &CompiledService, path: &Path) { + if !download_extension(service, path) { + return; + } + let filename = path.file_name().and_then(|value| value.to_str()).unwrap_or("download"); + let value = format!("attachment; filename*=UTF-8''{}", percent_encode_segment(filename)); + if let Ok(value) = HeaderValue::from_str(&value) { + response.headers_mut().insert(CONTENT_DISPOSITION, value); + } +} + +pub(super) fn accepts_content_encoding(headers: &HeaderMap, wanted: &str) -> bool { + let Some(value) = headers + .get(http::header::ACCEPT_ENCODING) + .and_then(|value| value.to_str().ok()) + else { + return false; + }; + let mut wildcard = None; + for item in value.split(',') { + let mut parts = item.trim().split(';'); + let encoding = parts.next().unwrap_or("").trim(); + let mut q = 1.0f32; + for parameter in parts { + if let Some(value) = parameter.trim().strip_prefix("q=") { + q = value.parse::().unwrap_or(0.0); + } + } + if encoding.eq_ignore_ascii_case(wanted) { + return q > 0.0; + } + if encoding == "*" { + wildcard = Some(q > 0.0); + } + } + wildcard.unwrap_or(false) +} + +fn metadata_validators(metadata: &std::fs::Metadata) -> (Option, Option) { + let modified = metadata.modified().ok(); + let etag = modified.and_then(|modified| { + modified.duration_since(UNIX_EPOCH).ok().map(|stamp| { + format!( + "\"{:x}-{:x}-{:x}\"", + metadata.len(), + stamp.as_secs(), + stamp.subsec_nanos() + ) + }) + }); + (etag, modified) +} + +fn weak_etag_value(value: &str) -> &str { + value.trim().strip_prefix("W/").unwrap_or(value.trim()) +} + +fn if_none_match_matches(value: &HeaderValue, etag: &str) -> bool { + value.to_str().ok().is_some_and(|value| { + value.split(',').any(|candidate| { + let candidate = candidate.trim(); + candidate == "*" || weak_etag_value(candidate) == weak_etag_value(etag) + }) + }) +} + +fn second_precision(value: SystemTime) -> Option { + value + .duration_since(UNIX_EPOCH) + .ok() + .map(|duration| UNIX_EPOCH + Duration::from_secs(duration.as_secs())) +} + +fn not_modified(headers: &HeaderMap, etag: Option<&str>, modified: Option) -> bool { + if let (Some(etag), Some(value)) = (etag, headers.get(IF_NONE_MATCH)) { + return if_none_match_matches(value, etag); + } + if headers.contains_key(IF_NONE_MATCH) { + return false; + } + let Some(modified) = modified.and_then(second_precision) else { + return false; + }; + headers + .get(IF_MODIFIED_SINCE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| httpdate::parse_http_date(value).ok()) + .is_some_and(|since| modified <= since) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ByteRange { + start: u64, + end: u64, +} + +fn parse_range(value: &str, length: u64) -> Option { + let value = value.strip_prefix("bytes=")?.trim(); + if value.contains(',') || value.is_empty() || length == 0 { + return None; + } + let (start, end) = value.split_once('-')?; + if start.is_empty() { + let suffix: u64 = end.parse().ok()?; + if suffix == 0 { + return None; + } + let size = suffix.min(length); + return Some(ByteRange { + start: length - size, + end: length - 1, + }); + } + let start: u64 = start.parse().ok()?; + if start >= length { + return None; + } + let end = if end.is_empty() { + length - 1 + } else { + end.parse::().ok()?.min(length - 1) + }; + (end >= start).then_some(ByteRange { start, end }) +} + +fn if_range_allows(headers: &HeaderMap, etag: Option<&str>, modified: Option) -> bool { + let Some(value) = headers.get(IF_RANGE).and_then(|value| value.to_str().ok()) else { + return true; + }; + let value = value.trim(); + if value.starts_with('"') || value.starts_with("W/") { + return etag.is_some_and(|etag| !value.starts_with("W/") && value == etag); + } + let Some(modified) = modified.and_then(second_precision) else { + return false; + }; + httpdate::parse_http_date(value) + .ok() + .is_some_and(|date| modified <= date) +} + +fn add_validators( + response: &mut Response, + etag: Option<&str>, + modified: Option, +) { + if let Some(etag) = etag.and_then(|value| HeaderValue::from_str(value).ok()) { + response.headers_mut().insert(ETAG, etag); + } + if let Some(modified) = modified { + if let Ok(value) = HeaderValue::from_str(&httpdate::fmt_http_date(modified)) { + response.headers_mut().insert(LAST_MODIFIED, value); + } + } +} diff --git a/src/web_server/routing.rs b/src/web_server/routing.rs new file mode 100644 index 0000000..74f2118 --- /dev/null +++ b/src/web_server/routing.rs @@ -0,0 +1,148 @@ +use super::{ + filesystem::{path_components, secure_open_sync, Opened}, + response::accepts_content_encoding, + CompiledService, +}; +use anyhow::{ensure, Result}; +use axum::{body::Body, response::Response}; +use http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode}; +use std::io; + +pub(super) async fn open_components(service: &CompiledService, components: Vec) -> io::Result { + let service = service.clone(); + tokio::task::spawn_blocking(move || secure_open_sync(&service, &components)) + .await + .map_err(|_| io::Error::other("Web Server filesystem task failed"))? +} + +fn target_path_and_query( + raw: &str, + uri_path: &str, + query: Option<&str>, +) -> Result<(String, Option)> { + let query_string = query.unwrap_or(""); + let expanded = raw + .replace("$uri", uri_path) + .replace("$query_string", query_string); + let (path, query) = match expanded.split_once('?') { + Some((path, query)) => (path.to_owned(), Some(query.to_owned())), + None => (expanded, query.map(str::to_owned)), + }; + ensure!(path.starts_with('/'), "Web Server internal target must start with /"); + crate::acl::validate_decoded_routing_path(&path) + .map_err(|_| anyhow::anyhow!("invalid Web Server internal target"))?; + Ok((path, query)) +} + +pub(super) enum RewriteOutcome { + Internal { path: String, query: Option }, + Redirect(Response), +} + +pub(super) fn apply_rewrites( + service: &CompiledService, + path: &str, + query: Option<&str>, +) -> Result { + let mut path = path.to_owned(); + let mut query = query.map(str::to_owned); + for rule in &service.rewrites { + if !rule.pattern.is_match(&path) { + continue; + } + let replacement = rule + .pattern + .replace(&path, rule.replacement.as_str()) + .to_string(); + let (next_path, next_query) = target_path_and_query( + &replacement, + &path, + query.as_deref(), + )?; + if let Some(status) = rule.status { + let mut target = next_path; + if let Some(query) = next_query.filter(|value| !value.is_empty()) { + target.push('?'); + target.push_str(&query); + } + let mut response = Response::new(Body::empty()); + *response.status_mut() = status; + response + .headers_mut() + .insert(LOCATION, HeaderValue::from_str(&target)?); + return Ok(RewriteOutcome::Redirect(response)); + } + path = next_path; + query = next_query; + } + Ok(RewriteOutcome::Internal { path, query }) +} + +pub(super) enum TryFilesOutcome { + Path { path: String, query: Option }, + Status(StatusCode), +} + +pub(super) async fn resolve_try_files( + service: &CompiledService, + path: &str, + query: Option<&str>, +) -> Result { + if service.try_files.is_empty() { + return Ok(TryFilesOutcome::Path { + path: path.to_owned(), + query: query.map(str::to_owned), + }); + } + for (index, raw) in service.try_files.iter().enumerate() { + if let Some(status) = raw.strip_prefix('=') { + let status = StatusCode::from_u16(status.parse::()?)?; + return Ok(TryFilesOutcome::Status(status)); + } + let (candidate, candidate_query) = target_path_and_query(raw, path, query)?; + let last = index + 1 == service.try_files.len(); + if last { + return Ok(TryFilesOutcome::Path { + path: candidate, + query: candidate_query, + }); + } + let components = path_components(&candidate)?; + if open_components(service, components).await.is_ok() { + return Ok(TryFilesOutcome::Path { + path: candidate, + query: candidate_query, + }); + } + } + Ok(TryFilesOutcome::Status(StatusCode::NOT_FOUND)) +} + +pub(super) async fn select_precompressed( + service: &CompiledService, + components: &[String], + headers: &HeaderMap, +) -> Option<(Opened, &'static str)> { + if !service.precompressed.enabled || components.is_empty() { + return None; + } + let candidates = [ + (service.precompressed.brotli, "br", ".br"), + (service.precompressed.gzip, "gzip", ".gz"), + ]; + for (enabled, encoding, suffix) in candidates { + if !enabled || !accepts_content_encoding(headers, encoding) { + continue; + } + let mut sidecar = components.to_vec(); + let Some(last) = sidecar.last_mut() else { continue }; + last.push_str(suffix); + if let Ok(opened) = open_components(service, sidecar).await { + if opened.metadata.is_file() { + return Some((opened, encoding)); + } + } + } + None +} + diff --git a/src/web_server/security.rs b/src/web_server/security.rs new file mode 100644 index 0000000..160f2c1 --- /dev/null +++ b/src/web_server/security.rs @@ -0,0 +1,76 @@ +use super::{filesystem::path_components, CompiledService}; +use axum::response::Response; +use http::Method; + +pub(super) fn hardening_denied_path(service: &CompiledService, path: &str) -> bool { + if !service.hardening.block_common_attacks { + return false; + } + let lower = path.to_ascii_lowercase(); + if [ + "/vendor/phpunit/", + "/_ignition/execute-solution", + "/server-status", + "/server-info", + "/cgi-bin/", + ] + .iter() + .any(|needle| lower.contains(*needle)) + { + return true; + } + let sensitive_names = [ + "wp-config.php", + "wp-config-sample.php", + "configuration.php", + "settings.php", + "services.yml", + "php.ini", + "web.config", + "composer.json", + "composer.lock", + "auth.json", + "id_rsa", + "id_dsa", + "id_ed25519", + ]; + let backup_suffixes = [ + ".bak", ".backup", ".old", ".orig", ".save", ".swp", ".swo", ".sql", + ".sqlite", ".sqlite3", ".db", + ]; + path_components(path).ok().is_some_and(|components| { + components.iter().any(|component| { + let component = component.to_ascii_lowercase(); + (component.starts_with('.') && component != ".well-known") + || component == ".env" + || component.starts_with(".env.") + || sensitive_names.contains(&component.as_str()) + || component.ends_with('~') + || backup_suffixes.iter().any(|suffix| component.ends_with(*suffix)) + }) + }) +} + +pub(super) fn hardening_denied_method(service: &CompiledService, method: &Method) -> bool { + service.hardening.block_common_attacks + && (method == Method::CONNECT || method == Method::TRACE || method.as_str().eq_ignore_ascii_case("TRACK")) +} + +pub(super) fn strip_unnecessary_headers(response: &mut Response, service: &CompiledService) { + if !service.hardening.strip_unnecessary_headers { + return; + } + for name in [ + "server", + "x-powered-by", + "x-aspnet-version", + "x-aspnetmvc-version", + "x-runtime", + "x-generator", + "x-drupal-cache", + "x-drupal-dynamic-cache", + ] { + response.headers_mut().remove(name); + } +} + diff --git a/src/web_server/serve.rs b/src/web_server/serve.rs new file mode 100644 index 0000000..07ecf1a --- /dev/null +++ b/src/web_server/serve.rs @@ -0,0 +1,297 @@ +use super::{ + fastcgi::{method_not_allowed_response, php_for_path, php_response, protected_script_without_handler}, + filesystem::{list_directory_sync, path_components}, + htaccess, + response::{ + add_service_headers, builtin_error_response, directory_listing, directory_redirect, + file_response, io_response, status_response, + }, + routing::{ + apply_rewrites, open_components, resolve_try_files, select_precompressed, RewriteOutcome, + TryFilesOutcome, + }, + security::{hardening_denied_method, hardening_denied_path, strip_unnecessary_headers}, + CompiledService, RequestContext, +}; +use axum::{body::Body, response::Response}; +use http::{ + header::{CACHE_CONTROL, CONTENT_LENGTH, CONTENT_TYPE}, + HeaderMap, HeaderValue, Method, StatusCode, Uri, +}; +use std::{io, path::PathBuf}; + +async fn serve_inner( + service: &CompiledService, + method: Method, + headers: HeaderMap, + uri: Uri, + path: &str, + body: Body, + context: RequestContext, +) -> Response { + if crate::acl::validate_decoded_routing_path(path).is_err() { + return status_response(StatusCode::BAD_REQUEST, "RustProxy: invalid Web Server path\n"); + } + if hardening_denied_method(service, &method) || hardening_denied_path(service, path) { + return builtin_error_response(StatusCode::FORBIDDEN, &method); + } + if method != Method::GET && method != Method::HEAD && service.php.is_none() { + return method_not_allowed_response(); + } + + if path_components(path).ok().is_some_and(|components| components.iter().any(|component| htaccess::protected_component(service.htaccess.as_ref(), component))) + || htaccess::denied(service.htaccess.as_ref(), path) + { + return builtin_error_response(StatusCode::FORBIDDEN, &method); + } + + let (rewritten_path, rewritten_query) = match apply_rewrites(service, path, uri.query()) { + Ok(RewriteOutcome::Redirect(response)) => return response, + Ok(RewriteOutcome::Internal { path, query }) => (path, query), + Err(_) => { + return status_response( + StatusCode::BAD_REQUEST, + "RustProxy: invalid Web Server rewrite target\n", + ) + } + }; + let (rewritten_path, rewritten_query) = match htaccess::apply_rewrites( + service.htaccess.as_ref(), + service, + &method, + &headers, + &uri, + &context, + &rewritten_path, + rewritten_query.as_deref(), + ).await { + Ok(htaccess::RewriteOutcome::Redirect(response)) => return response, + Ok(htaccess::RewriteOutcome::Forbidden) => return builtin_error_response(StatusCode::FORBIDDEN, &method), + Ok(htaccess::RewriteOutcome::Internal { path, query }) => (path, query), + Err(_) => return status_response(StatusCode::BAD_REQUEST, "RustProxy: invalid .htaccess rewrite target\n"), + }; + if crate::acl::validate_decoded_routing_path(&rewritten_path).is_err() + || hardening_denied_path(service, &rewritten_path) + || path_components(&rewritten_path).ok().is_some_and(|components| components.iter().any(|component| htaccess::protected_component(service.htaccess.as_ref(), component))) + || htaccess::denied(service.htaccess.as_ref(), &rewritten_path) + { + return builtin_error_response(StatusCode::FORBIDDEN, &method); + } + let (effective_path, effective_query) = match resolve_try_files( + service, + &rewritten_path, + rewritten_query.as_deref(), + ) + .await + { + Ok(TryFilesOutcome::Path { path, query }) => (path, query), + Ok(TryFilesOutcome::Status(status)) => return builtin_error_response(status, &method), + Err(_) => { + return status_response( + StatusCode::BAD_REQUEST, + "RustProxy: invalid Web Server try_files target\n", + ) + } + }; + + let components = match path_components(&effective_path) { + Ok(components) => components, + Err(error) => return io_response(&error), + }; + if hardening_denied_path(service, &effective_path) + || components.iter().any(|component| htaccess::protected_component(service.htaccess.as_ref(), component)) + || htaccess::denied(service.htaccess.as_ref(), &effective_path) + { + return builtin_error_response(StatusCode::FORBIDDEN, &method); + } + let opened = match open_components(service, components.clone()).await { + Ok(opened) => opened, + Err(error) => return io_response(&error), + }; + let mut request_body = Some(body); + + if opened.metadata.is_file() { + let public_path = PathBuf::from(&effective_path); + if let Some(php) = php_for_path(service, &public_path) { + return php_response( + service, + php, + method, + headers, + uri, + effective_path, + effective_query, + request_body.take().unwrap_or_else(Body::empty), + context, + ) + .await; + } + if protected_script_without_handler(service, &public_path) { + return builtin_error_response(StatusCode::FORBIDDEN, &method); + } + if method != Method::GET && method != Method::HEAD { + return method_not_allowed_response(); + } + if let Some((compressed, encoding)) = + select_precompressed(service, &components, &headers).await + { + return file_response( + service, + compressed, + &public_path, + &method, + &headers, + Some(encoding), + ) + .await; + } + return file_response(service, opened, &public_path, &method, &headers, None).await; + } + + if !uri.path().ends_with('/') { + return directory_redirect(&uri, &method); + } + + for index in htaccess::index_files(service.htaccess.as_ref(), &components, &service.index) { + let mut candidate = components.clone(); + candidate.push(index.clone()); + match open_components(service, candidate.clone()).await { + Ok(index_opened) if index_opened.metadata.is_file() => { + let mut script_path = effective_path.trim_end_matches('/').to_owned(); + script_path.push('/'); + script_path.push_str(index); + if hardening_denied_path(service, &script_path) { + return builtin_error_response(StatusCode::FORBIDDEN, &method); + } + let public_path = PathBuf::from(&script_path); + if let Some(php) = php_for_path(service, &public_path) { + return php_response( + service, + php, + method, + headers, + uri, + script_path, + effective_query, + request_body.take().unwrap_or_else(Body::empty), + context, + ) + .await; + } + if protected_script_without_handler(service, &public_path) { + return builtin_error_response(StatusCode::FORBIDDEN, &method); + } + if method != Method::GET && method != Method::HEAD { + return method_not_allowed_response(); + } + if let Some((compressed, encoding)) = + select_precompressed(service, &candidate, &headers).await + { + return file_response( + service, + compressed, + &public_path, + &method, + &headers, + Some(encoding), + ) + .await; + } + return file_response( + service, + index_opened, + &public_path, + &method, + &headers, + None, + ) + .await; + } + Ok(_) => continue, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound | io::ErrorKind::NotADirectory + ) => + { + continue + } + Err(error) => return io_response(&error), + } + } + + if method != Method::GET && method != Method::HEAD { + return method_not_allowed_response(); + } + if !htaccess::directory_listing(service.htaccess.as_ref(), &components, service.directory_listing) { + return builtin_error_response(StatusCode::FORBIDDEN, &method); + } + let service_for_list = service.clone(); + let components_for_list = components.clone(); + let entries = match tokio::task::spawn_blocking(move || { + list_directory_sync(&service_for_list, &components_for_list) + }) + .await + { + Ok(Ok(entries)) => entries, + Ok(Err(error)) => return io_response(&error), + Err(_) => { + return status_response( + StatusCode::INTERNAL_SERVER_ERROR, + "RustProxy: Web Server directory task failed\n", + ) + } + }; + let entries: Vec<_> = entries + .into_iter() + .filter(|entry| { + if htaccess::protected_component(service.htaccess.as_ref(), &entry.name) { + return false; + } + let child_path = if effective_path.ends_with('/') { + format!("{effective_path}{}", entry.name) + } else { + format!("{effective_path}/{}", entry.name) + }; + !hardening_denied_path(service, &child_path) + }) + .collect(); + let listing = directory_listing(&effective_path, &entries); + let mut response = Response::new(if method == Method::HEAD { + Body::empty() + } else { + Body::from(listing.clone()) + }); + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + response.headers_mut().insert( + CONTENT_LENGTH, + HeaderValue::from_str(&listing.len().to_string()).unwrap(), + ); + response + .headers_mut() + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +/// Serve a decoded, validated routing path from a compiled Web Service. +/// The caller is responsible for normal vhost/location ACL/auth/routing selection; +/// this function performs filesystem handling plus optional Web Service routing, +/// precompressed delivery and bounded FastCGI/PHP-FPM execution. +pub async fn serve( + service: &CompiledService, + method: Method, + headers: HeaderMap, + uri: Uri, + path: &str, + body: Body, + context: RequestContext, +) -> Response { + let mut response = serve_inner(service, method, headers, uri, path, body, context).await; + add_service_headers(&mut response, service); + htaccess::apply_response(service.htaccess.as_ref(), &mut response, path); + strip_unnecessary_headers(&mut response, service); + response +} diff --git a/src/web_server/tests.rs b/src/web_server/tests.rs new file mode 100644 index 0000000..29efe05 --- /dev/null +++ b/src/web_server/tests.rs @@ -0,0 +1,3 @@ +include!("tests/part_01.rs"); +include!("tests/part_02.rs"); +include!("tests/part_03.rs"); diff --git a/src/web_server/tests/part_01.rs b/src/web_server/tests/part_01.rs new file mode 100644 index 0000000..5035748 --- /dev/null +++ b/src/web_server/tests/part_01.rs @@ -0,0 +1,413 @@ +use super::*; +use super::fastcgi::{ + fastcgi_params, parse_cgi_headers, read_fcgi_record, write_fcgi_record, FCGI_END_REQUEST, + FCGI_STDIN, FCGI_STDOUT, +}; +use axum::{body::Body, response::Response}; +use http::{ + header::{ + ACCEPT_RANGES, ALLOW, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LENGTH, + CONTENT_RANGE, CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, IF_RANGE, + LAST_MODIFIED, LOCATION, RANGE, VARY, + }, + HeaderMap, HeaderValue, Method, StatusCode, Uri, Version, +}; +use http_body_util::BodyExt; +use std::{collections::BTreeMap, path::Path, time::Duration}; +use tempfile::TempDir; +use tokio::{io::AsyncWriteExt, time::Instant}; + +fn service(root: &Path, listing: bool) -> CompiledService { + Service { + id: "site".into(), + name: "Site".into(), + document_root: root.to_path_buf(), + index: vec!["index.html".into(), "index.htm".into()], + directory_listing: listing, + ..Default::default() + } + .compile() + .unwrap() +} + +fn request(method: Method, uri: &str) -> http::Request<()> { + http::Request::builder() + .method(method) + .uri(uri) + .body(()) + .unwrap() +} + +async fn serve_request( + service: &CompiledService, + request: &http::Request<()>, + path: &str, +) -> Response { + serve_body_request(service, request, path, Body::empty()).await +} + +async fn serve_body_request( + service: &CompiledService, + request: &http::Request<()>, + path: &str, + body: Body, +) -> Response { + serve( + service, + request.method().clone(), + request.headers().clone(), + request.uri().clone(), + path, + body, + RequestContext { + client_ip: "127.0.0.1".parse().unwrap(), + client_port: Some(12345), + server_name: "example.test".into(), + server_port: 80, + tls: false, + version: Version::HTTP_11, + }, + ) + .await +} + +#[tokio::test] +async fn get_head_index_and_directory_redirect_work() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir(tmp.path().join("dir")).unwrap(); + std::fs::write(tmp.path().join("index.html"), b"root").unwrap(); + std::fs::write(tmp.path().join("dir/index.html"), b"nested").unwrap(); + let service = service(tmp.path(), false); + + let response = serve_request(&service, &request(Method::GET, "/"), "/").await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); + assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "root"); + + let response = serve_request(&service, &request(Method::HEAD, "/"), "/").await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CONTENT_LENGTH], "4"); + assert!(response.into_body().collect().await.unwrap().to_bytes().is_empty()); + + let response = serve_request(&service, &request(Method::GET, "/dir?x=1"), "/dir").await; + assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY); + assert_eq!(response.headers()[LOCATION], "/dir/?x=1"); +} + +#[tokio::test] +async fn missing_files_and_disabled_directory_listing_return_normal_errors() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir(tmp.path().join("empty")).unwrap(); + let service = service(tmp.path(), false); + + let missing = serve_request(&service, &request(Method::GET, "/missing.txt"), "/missing.txt").await; + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + + let directory = serve_request(&service, &request(Method::GET, "/empty/"), "/empty/").await; + assert_eq!(directory.status(), StatusCode::FORBIDDEN); + assert_eq!(directory.headers()[CONTENT_TYPE], "text/html; charset=utf-8"); + let directory_body = directory.into_body().collect().await.unwrap().to_bytes(); + let directory_body = std::str::from_utf8(&directory_body).unwrap(); + assert!(directory_body.contains("403")); + assert!(!directory_body.contains("directory listing is disabled")); + + let directory_head = serve_request(&service, &request(Method::HEAD, "/empty/"), "/empty/").await; + assert_eq!(directory_head.status(), StatusCode::FORBIDDEN); + assert!(directory_head.into_body().collect().await.unwrap().to_bytes().is_empty()); + + let method = serve_request(&service, &request(Method::POST, "/missing.txt"), "/missing.txt").await; + assert_eq!(method.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(method.headers()[ALLOW], "GET, HEAD"); +} + +#[tokio::test] +async fn last_modified_condition_returns_304() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("file.txt"), b"validator").unwrap(); + let service = service(tmp.path(), false); + let first = serve_request(&service, &request(Method::GET, "/file.txt"), "/file.txt").await; + let last_modified = first.headers()[LAST_MODIFIED].clone(); + assert_eq!(first.headers()[CONTENT_TYPE], "text/plain; charset=utf-8"); + assert_eq!(first.headers()[CONTENT_LENGTH], "9"); + + let mut conditional = request(Method::GET, "/file.txt"); + conditional + .headers_mut() + .insert(IF_MODIFIED_SINCE, last_modified); + let response = serve_request(&service, &conditional, "/file.txt").await; + assert_eq!(response.status(), StatusCode::NOT_MODIFIED); + assert!(response.headers().contains_key(LAST_MODIFIED)); +} + +#[tokio::test] +async fn validators_and_single_ranges_work() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap(); + let service = service(tmp.path(), false); + let first = serve_request(&service, &request(Method::GET, "/file.txt"), "/file.txt").await; + let etag = first.headers()[ETAG].clone(); + assert_eq!(first.headers()[ACCEPT_RANGES], "bytes"); + + let mut conditional = request(Method::GET, "/file.txt"); + conditional.headers_mut().insert(IF_NONE_MATCH, etag); + assert_eq!( + serve_request(&service, &conditional, "/file.txt").await.status(), + StatusCode::NOT_MODIFIED + ); + + let mut ranged = request(Method::GET, "/file.txt"); + ranged.headers_mut().insert(RANGE, "bytes=2-5".parse().unwrap()); + let response = serve_request(&service, &ranged, "/file.txt").await; + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response.headers()[CONTENT_RANGE], "bytes 2-5/10"); + assert_eq!(response.headers()[CONTENT_LENGTH], "4"); + assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "2345"); + + let mut invalid = request(Method::GET, "/file.txt"); + invalid.headers_mut().insert(RANGE, "bytes=99-100".parse().unwrap()); + let response = serve_request(&service, &invalid, "/file.txt").await; + assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); + assert_eq!(response.headers()[CONTENT_RANGE], "bytes */10"); + + let mut if_range_match = request(Method::GET, "/file.txt"); + if_range_match.headers_mut().insert(RANGE, "bytes=0-1".parse().unwrap()); + if_range_match.headers_mut().insert(IF_RANGE, first.headers()[ETAG].clone()); + assert_eq!(serve_request(&service, &if_range_match, "/file.txt").await.status(), StatusCode::PARTIAL_CONTENT); + + let mut if_range_miss = request(Method::GET, "/file.txt"); + if_range_miss.headers_mut().insert(RANGE, "bytes=0-1".parse().unwrap()); + if_range_miss.headers_mut().insert(IF_RANGE, "\"different\"".parse().unwrap()); + let response = serve_request(&service, &if_range_miss, "/file.txt").await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CONTENT_LENGTH], "10"); +} + +#[tokio::test] +async fn listing_escapes_html_and_encodes_urls() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("a & b.txt"), b"x").unwrap(); + let service = service(tmp.path(), true); + let response = serve_request(&service, &request(Method::GET, "/"), "/").await; + let body = response.into_body().collect().await.unwrap().to_bytes(); + let body = std::str::from_utf8(&body).unwrap(); + assert!(body.contains("a & b.txt")); + assert!(body.contains("a%20%26%20b.txt")); + assert!(!body.contains(tmp.path().to_string_lossy().as_ref())); +} + +#[tokio::test] +async fn root_directory_listing_is_repeatable_across_refreshes() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("first.txt"), b"one").unwrap(); + std::fs::write(tmp.path().join("second.txt"), b"two").unwrap(); + let mut configured = Service { + id: "repeat-listing".into(), + name: "Repeat listing".into(), + document_root: tmp.path().to_path_buf(), + index: Vec::new(), + directory_listing: true, + ..Default::default() + }; + configured.validate().unwrap(); + let service = configured.compile().unwrap(); + + for _ in 0..2 { + let response = serve_request(&service, &request(Method::GET, "/"), "/").await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CACHE_CONTROL], "no-store"); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let body = std::str::from_utf8(&body).unwrap(); + assert!(body.contains("first.txt")); + assert!(body.contains("second.txt")); + } +} + +#[tokio::test] +async fn index_precedes_listing_and_missing_index_falls_back_to_listing_per_directory() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir(tmp.path().join("with-index")).unwrap(); + std::fs::create_dir(tmp.path().join("without-index")).unwrap(); + std::fs::write(tmp.path().join("with-index/index.html"), b"INDEX-WINS").unwrap(); + std::fs::write(tmp.path().join("with-index/other.txt"), b"hidden by index").unwrap(); + std::fs::write(tmp.path().join("without-index/listed.txt"), b"listed").unwrap(); + let service = service(tmp.path(), true); + + let indexed = serve_request( + &service, + &request(Method::GET, "/with-index/"), + "/with-index/", + ) + .await; + assert_eq!(indexed.status(), StatusCode::OK); + assert_eq!( + indexed.into_body().collect().await.unwrap().to_bytes(), + "INDEX-WINS" + ); + + let listed = serve_request( + &service, + &request(Method::GET, "/without-index/"), + "/without-index/", + ) + .await; + assert_eq!(listed.status(), StatusCode::OK); + let body = listed.into_body().collect().await.unwrap().to_bytes(); + let body = std::str::from_utf8(&body).unwrap(); + assert!(body.contains("listed.txt")); + assert!(body.contains("DIRECTORY LISTING")); +} + +#[tokio::test] +async fn mime_overrides_default_mime_and_cache_control_are_applied_to_files() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("asset.foo"), b"foo").unwrap(); + std::fs::write(tmp.path().join("asset.unknown"), b"unknown").unwrap(); + let mut configured = Service { + id: "mime".into(), + name: "MIME".into(), + document_root: tmp.path().to_path_buf(), + mime_types: BTreeMap::from([(".FOO".into(), "application/x-foo".into())]), + default_mime_type: "application/x-fallback".into(), + cache_control: Some("public, max-age=3600".into()), + ..Default::default() + }; + configured.validate().unwrap(); + assert!(configured.mime_types.contains_key("foo")); + let service = configured.compile().unwrap(); + + let custom = serve_request(&service, &request(Method::GET, "/asset.foo"), "/asset.foo").await; + assert_eq!(custom.headers()[CONTENT_TYPE], "application/x-foo"); + assert_eq!(custom.headers()[CACHE_CONTROL], "public, max-age=3600"); + + let fallback = serve_request( + &service, + &request(Method::GET, "/asset.unknown"), + "/asset.unknown", + ) + .await; + assert_eq!(fallback.headers()[CONTENT_TYPE], "application/x-fallback"); + assert_eq!(fallback.headers()[CACHE_CONTROL], "public, max-age=3600"); +} + +#[tokio::test] +async fn try_files_rewrites_cache_rules_headers_and_downloads_work() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.html"), b"SPA").unwrap(); + std::fs::write(tmp.path().join("style.css"), b"css").unwrap(); + std::fs::write(tmp.path().join("archive.zip"), b"zip").unwrap(); + let mut configured = Service { + id: "routing".into(), + name: "Routing".into(), + document_root: tmp.path().to_path_buf(), + try_files: vec!["$uri".into(), "/index.html?$query_string".into()], + rewrites: vec![ + RewriteRule { + pattern: "^/internal$".into(), + replacement: "/style.css".into(), + status: None, + }, + RewriteRule { + pattern: "^/old/(.*)$".into(), + replacement: "/new/$1".into(), + status: Some(301), + }, + ], + cache_control: Some("no-cache".into()), + cache_rules: vec![CacheRule { + matcher: "ext:css".into(), + cache_control: "public, max-age=3600".into(), + }], + response_headers: BTreeMap::from([("x-static-service".into(), "yes".into())]), + download_extensions: vec!["zip".into()], + ..Default::default() + }; + configured.validate().unwrap(); + let service = configured.compile().unwrap(); + + let spa = serve_request( + &service, + &request(Method::GET, "/missing?x=1"), + "/missing", + ) + .await; + assert_eq!(spa.status(), StatusCode::OK); + assert_eq!(spa.headers()[CACHE_CONTROL], "no-cache"); + assert_eq!(spa.headers()["x-static-service"], "yes"); + assert_eq!(spa.into_body().collect().await.unwrap().to_bytes(), "SPA"); + + let internal = serve_request( + &service, + &request(Method::GET, "/internal"), + "/internal", + ) + .await; + assert_eq!(internal.status(), StatusCode::OK); + assert_eq!(internal.headers()[CONTENT_TYPE], "text/css; charset=utf-8"); + assert_eq!(internal.headers()[CACHE_CONTROL], "public, max-age=3600"); + + let redirect = serve_request( + &service, + &request(Method::GET, "/old/path"), + "/old/path", + ) + .await; + assert_eq!(redirect.status(), StatusCode::MOVED_PERMANENTLY); + assert_eq!(redirect.headers()[LOCATION], "/new/path"); + + let download = serve_request( + &service, + &request(Method::GET, "/archive.zip"), + "/archive.zip", + ) + .await; + assert!(download.headers()[CONTENT_DISPOSITION] + .to_str() + .unwrap() + .starts_with("attachment;")); +} + +#[tokio::test] +async fn precompressed_sidecars_keep_mime_validators_and_range_semantics() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("app.js"), b"ORIGINAL").unwrap(); + std::fs::write(tmp.path().join("app.js.br"), b"BROTLI").unwrap(); + let mut configured = Service { + id: "compressed".into(), + name: "Compressed".into(), + document_root: tmp.path().to_path_buf(), + precompressed: Precompressed { + enabled: true, + brotli: true, + gzip: false, + }, + ..Default::default() + }; + configured.validate().unwrap(); + let service = configured.compile().unwrap(); + + let mut accepted = request(Method::GET, "/app.js"); + accepted + .headers_mut() + .insert(http::header::ACCEPT_ENCODING, "br".parse().unwrap()); + let response = serve_request(&service, &accepted, "/app.js").await; + assert_eq!(response.headers()[CONTENT_ENCODING], "br"); + assert_eq!(response.headers()[CONTENT_TYPE], "text/javascript; charset=utf-8"); + assert_eq!(response.headers()[VARY], "Accept-Encoding"); + assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "BROTLI"); + + let mut ranged = request(Method::GET, "/app.js"); + ranged + .headers_mut() + .insert(http::header::ACCEPT_ENCODING, "br".parse().unwrap()); + ranged + .headers_mut() + .insert(RANGE, "bytes=0-2".parse().unwrap()); + let response = serve_request(&service, &ranged, "/app.js").await; + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(response.headers()[CONTENT_RANGE], "bytes 0-2/6"); + assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "BRO"); + + let plain = serve_request(&service, &request(Method::GET, "/app.js"), "/app.js").await; + assert!(!plain.headers().contains_key(CONTENT_ENCODING)); + assert_eq!(plain.into_body().collect().await.unwrap().to_bytes(), "ORIGINAL"); +} diff --git a/src/web_server/tests/part_02.rs b/src/web_server/tests/part_02.rs new file mode 100644 index 0000000..6d790e2 --- /dev/null +++ b/src/web_server/tests/part_02.rs @@ -0,0 +1,394 @@ +async fn fastcgi_fixture(response_body: &'static [u8]) -> std::net::SocketAddr { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let (kind, content) = read_fcgi_record(&mut stream, deadline).await.unwrap(); + if kind == FCGI_STDIN && content.is_empty() { + break; + } + } + let mut stdout = b"Status: 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nX-PHP-FPM: fixture\r\n\r\n".to_vec(); + stdout.extend_from_slice(response_body); + write_fcgi_record(&mut stream, FCGI_STDOUT, &stdout).await.unwrap(); + write_fcgi_record(&mut stream, FCGI_STDOUT, &[]).await.unwrap(); + write_fcgi_record(&mut stream, FCGI_END_REQUEST, &[0u8; 8]).await.unwrap(); + stream.flush().await.unwrap(); + }); + address +} + +#[tokio::test] +async fn php_fpm_executes_index_and_try_files_front_controller_without_source_disclosure() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.php"), b"0.25.1
                    ")); +} + +#[tokio::test] +async fn traversal_and_symlink_escape_are_not_served() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("ok.txt"), b"ok").unwrap(); + let service = service(tmp.path(), false); + assert_eq!( + serve_request(&service, &request(Method::GET, "/../secret"), "/../secret") + .await + .status(), + StatusCode::BAD_REQUEST + ); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + let outside = TempDir::new().unwrap(); + std::fs::write(outside.path().join("secret.txt"), b"secret").unwrap(); + symlink(outside.path().join("secret.txt"), tmp.path().join("escape.txt")).unwrap(); + let response = serve_request( + &service, + &request(Method::GET, "/escape.txt"), + "/escape.txt", + ) + .await; + assert_ne!(response.status(), StatusCode::OK); + } +} +#[tokio::test] +async fn htaccess_wordpress_style_front_controller_and_protected_files_work() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.html"), b"front-controller").unwrap(); + std::fs::write(tmp.path().join("existing.txt"), b"existing").unwrap(); + std::fs::write( + tmp.path().join(".htaccess"), + br#" +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule . /index.html [L] + +"#, + ) + .unwrap(); + let mut configured = Service { + id: "htaccess-rewrite".into(), + name: "htaccess rewrite".into(), + document_root: tmp.path().to_path_buf(), + htaccess: htaccess::Config { enabled: true, ..Default::default() }, + ..Default::default() + }; + configured.validate().unwrap(); + let service = configured.compile().unwrap(); + + let rewritten = serve_request(&service, &request(Method::GET, "/missing"), "/missing").await; + assert_eq!(rewritten.status(), StatusCode::OK); + assert_eq!(rewritten.into_body().collect().await.unwrap().to_bytes(), "front-controller"); + + let existing = serve_request(&service, &request(Method::GET, "/existing.txt"), "/existing.txt").await; + assert_eq!(existing.status(), StatusCode::OK); + assert_eq!(existing.into_body().collect().await.unwrap().to_bytes(), "existing"); + + let protected = serve_request(&service, &request(Method::GET, "/.htaccess"), "/.htaccess").await; + assert_eq!(protected.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn htaccess_directory_index_mime_headers_expires_and_nested_scope_work() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir(tmp.path().join("docs")).unwrap(); + std::fs::write(tmp.path().join("docs/home.foo"), b"nested").unwrap(); + std::fs::write( + tmp.path().join("docs/.htaccess"), + br#"DirectoryIndex home.foo +Options -Indexes +AddType text/x-foo .foo +Header always set X-Htaccess yes +ExpiresActive On +ExpiresByType text/x-foo "access plus 1 hour" +"#, + ) + .unwrap(); + let mut configured = Service { + id: "htaccess-nested".into(), + name: "htaccess nested".into(), + document_root: tmp.path().to_path_buf(), + index: vec!["index.html".into()], + directory_listing: true, + htaccess: htaccess::Config { enabled: true, ..Default::default() }, + ..Default::default() + }; + configured.validate().unwrap(); + let service = configured.compile().unwrap(); + let response = serve_request(&service, &request(Method::GET, "/docs/"), "/docs/").await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CONTENT_TYPE], "text/x-foo"); + assert_eq!(response.headers()["x-htaccess"], "yes"); + assert_eq!(response.headers()[CACHE_CONTROL], "public, max-age=3600"); + assert!(response.headers().contains_key(http::header::EXPIRES)); + assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "nested"); +} + +#[tokio::test] +async fn php_source_extensions_are_not_served_without_php_fpm() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.php"), b" +SetHandler application/x-httpd-php + +"#, + ) + .unwrap(); + let mut configured = Service { + id: "htaccess-filesmatch".into(), + name: "htaccess filesmatch".into(), + document_root: tmp.path().to_path_buf(), + htaccess: htaccess::Config { enabled: true, strict: false, ..Default::default() }, + ..Default::default() + }; + configured.validate().unwrap(); + let error = configured.compile().unwrap_err().to_string(); + assert!(error.contains("Files/FilesMatch")); +} + +#[test] +fn htaccess_strict_mode_rejects_unsupported_directives() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.html"), b"ok").unwrap(); + std::fs::write(tmp.path().join(".htaccess"), b"ErrorDocument 404 /404.html\n").unwrap(); + let mut configured = Service { + id: "htaccess-strict".into(), + name: "htaccess strict".into(), + document_root: tmp.path().to_path_buf(), + htaccess: htaccess::Config { enabled: true, strict: true, ..Default::default() }, + ..Default::default() + }; + configured.validate().unwrap(); + assert!(configured.compile().unwrap_err().to_string().contains("ErrorDocument")); +} + +#[cfg(unix)] +#[test] +fn htaccess_configuration_file_cannot_be_a_symlink() { + use std::os::unix::fs::symlink; + let tmp = TempDir::new().unwrap(); + let outside = TempDir::new().unwrap(); + std::fs::write(outside.path().join("outside.conf"), b"RewriteEngine On\n").unwrap(); + symlink(outside.path().join("outside.conf"), tmp.path().join(".htaccess")).unwrap(); + let mut configured = Service { + id: "htaccess-symlink".into(), + name: "htaccess symlink".into(), + document_root: tmp.path().to_path_buf(), + htaccess: htaccess::Config { enabled: true, ..Default::default() }, + ..Default::default() + }; + configured.validate().unwrap(); + let error = configured.compile().unwrap_err().to_string(); + assert!(error.contains("must not be a symlink")); +} diff --git a/src/web_server/tests/part_03.rs b/src/web_server/tests/part_03.rs new file mode 100644 index 0000000..b91195a --- /dev/null +++ b/src/web_server/tests/part_03.rs @@ -0,0 +1,211 @@ +#[test] +fn fastcgi_never_forwards_proxy_header_as_http_proxy() { + let tmp = TempDir::new().unwrap(); + let mut configured = Service { + id: "fastcgi-params".into(), + name: "FastCGI params".into(), + document_root: tmp.path().to_path_buf(), + php: Php { enabled: true, endpoint: "tcp:127.0.0.1:9000".into(), ..Default::default() }, + ..Default::default() + }; + configured.validate().unwrap(); + let service = configured.compile().unwrap(); + let php = service.php.as_ref().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("proxy", HeaderValue::from_static("http://attacker.invalid:8080")); + headers.insert("user-agent", HeaderValue::from_static("security-test")); + let uri: Uri = "/index.php?q=1".parse().unwrap(); + let encoded = fastcgi_params( + &service, + php, + &Method::GET, + &headers, + &uri, + "/index.php", + Some("q=1"), + &RequestContext { + client_ip: "127.0.0.1".parse().unwrap(), + client_port: Some(12345), + server_name: "example.test".into(), + server_port: 80, + tls: false, + version: Version::HTTP_11, + }, + ) + .unwrap(); + assert!(!encoded.windows(b"HTTP_PROXY".len()).any(|window| window == b"HTTP_PROXY")); + assert!(encoded.windows(b"HTTP_USER_AGENT".len()).any(|window| window == b"HTTP_USER_AGENT")); +} + +#[test] +fn php_custom_fastcgi_params_and_php_values_are_validated() { + let tmp = TempDir::new().unwrap(); + let mut configured = Service { + id: "php-custom".into(), + name: "PHP custom".into(), + document_root: tmp.path().to_path_buf(), + php: Php { + enabled: true, + endpoint: "tcp:127.0.0.1:9000".into(), + pass_authorization: false, + custom_params: BTreeMap::from([("app_env".into(), "production".into())]), + php_values: BTreeMap::from([("memory_limit".into(), "256M".into())]), + ..Default::default() + }, + ..Default::default() + }; + configured.validate().unwrap(); + assert!(configured.php.custom_params.contains_key("APP_ENV")); + let service = configured.compile().unwrap(); + let php = service.php.as_ref().unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("authorization", HeaderValue::from_static("Bearer secret")); + let uri: Uri = "/index.php".parse().unwrap(); + let encoded = fastcgi_params( + &service, + php, + &Method::GET, + &headers, + &uri, + "/index.php", + None, + &RequestContext { + client_ip: "127.0.0.1".parse().unwrap(), + client_port: None, + server_name: "example.test".into(), + server_port: 443, + tls: true, + version: Version::HTTP_2, + }, + ) + .unwrap(); + assert!(encoded.windows(b"APP_ENV".len()).any(|window| window == b"APP_ENV")); + assert!(encoded.windows(b"production".len()).any(|window| window == b"production")); + assert!(encoded.windows(b"PHP_VALUE".len()).any(|window| window == b"PHP_VALUE")); + assert!(encoded.windows(b"memory_limit=256M".len()).any(|window| window == b"memory_limit=256M")); + assert!(!encoded.windows(b"HTTP_AUTHORIZATION".len()).any(|window| window == b"HTTP_AUTHORIZATION")); +} + +#[test] +fn php_custom_fastcgi_params_cannot_override_core_or_php_admin_values() { + let tmp = TempDir::new().unwrap(); + for name in ["SCRIPT_FILENAME", "HTTP_HOST", "PHP_VALUE", "PHP_ADMIN_VALUE"] { + let mut configured = Service { + id: "php-reserved".into(), + name: "PHP reserved".into(), + document_root: tmp.path().to_path_buf(), + php: Php { + custom_params: BTreeMap::from([(name.into(), "bad".into())]), + ..Default::default() + }, + ..Default::default() + }; + assert!(configured.validate().unwrap_err().to_string().contains("reserved name")); + } +} + +#[test] +fn php_custom_fastcgi_data_has_a_combined_bound() { + let tmp = TempDir::new().unwrap(); + let custom_params = (0..17) + .map(|index| (format!("APP_{index}"), "x".repeat(8192))) + .collect(); + let mut configured = Service { + id: "php-custom-bound".into(), + name: "PHP custom bound".into(), + document_root: tmp.path().to_path_buf(), + php: Php { custom_params, ..Default::default() }, + ..Default::default() + }; + assert!(configured + .validate() + .unwrap_err() + .to_string() + .contains("at most 128 KiB")); +} + +#[test] +fn fastcgi_response_cannot_control_downstream_framing_or_hop_headers() { + let (_, headers) = parse_cgi_headers( + b"Content-Type: text/plain\r\nContent-Length: 999999\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\nTE: trailers\r\nTrailer: X-Test\r\nProxy-Authenticate: Basic realm=x\r\nProxy-Authorization: secret\r\nX-App: ok\r\n", + ) + .unwrap(); + assert_eq!(headers.get("x-app").unwrap(), "ok"); + for name in [ + "content-length", + "transfer-encoding", + "connection", + "te", + "trailer", + "proxy-authenticate", + "proxy-authorization", + ] { + assert!(!headers.contains_key(name), "unexpected FastCGI framing header: {name}"); + } +} + +#[test] +fn fastcgi_response_rejects_informational_status_codes() { + let error = parse_cgi_headers(b"Status: 101 Switching Protocols\r\nContent-Type: text/plain\r\n") + .unwrap_err() + .to_string(); + assert!(error.contains("unsupported informational/invalid status")); +} + +#[tokio::test] +async fn hardening_blocks_common_probe_paths_and_trace() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.html"), b"ok").unwrap(); + std::fs::write(tmp.path().join(".env"), b"secret").unwrap(); + std::fs::write(tmp.path().join("backup.sql"), b"secret").unwrap(); + std::fs::write(tmp.path().join("public.txt"), b"public").unwrap(); + let mut configured = Service { + id: "hardened".into(), + name: "hardened".into(), + document_root: tmp.path().to_path_buf(), + index: vec![], + directory_listing: true, + hardening: Hardening { block_common_attacks: true, strip_unnecessary_headers: false }, + ..Default::default() + }; + configured.validate().unwrap(); + let service = configured.compile().unwrap(); + for path in ["/.env", "/wp-config.php", "/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php", "/backup.sql"] { + let response = serve_request(&service, &request(Method::GET, path), path).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{path}"); + } + let trace = serve_request(&service, &request(Method::TRACE, "/"), "/").await; + assert_eq!(trace.status(), StatusCode::FORBIDDEN); + let listing = serve_request(&service, &request(Method::GET, "/"), "/").await; + let listing = String::from_utf8_lossy(&listing.into_body().collect().await.unwrap().to_bytes()).to_string(); + assert!(listing.contains("public.txt")); + assert!(!listing.contains(".env")); + assert!(!listing.contains("backup.sql")); + let well_known = serve_request(&service, &request(Method::GET, "/.well-known/missing"), "/.well-known/missing").await; + assert_eq!(well_known.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn hardening_strips_fingerprint_headers_after_source_rules() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("index.html"), b"ok").unwrap(); + let mut configured = Service { + id: "headers".into(), + name: "headers".into(), + document_root: tmp.path().to_path_buf(), + response_headers: BTreeMap::from([ + ("server".into(), "Example/1.0".into()), + ("x-powered-by".into(), "PHP/8.4".into()), + ("x-custom".into(), "kept".into()), + ]), + hardening: Hardening { block_common_attacks: false, strip_unnecessary_headers: true }, + ..Default::default() + }; + configured.validate().unwrap(); + let service = configured.compile().unwrap(); + let response = serve_request(&service, &request(Method::GET, "/index.html"), "/index.html").await; + assert_eq!(response.status(), StatusCode::OK); + assert!(!response.headers().contains_key("server")); + assert!(!response.headers().contains_key("x-powered-by")); + assert_eq!(response.headers()["x-custom"], "kept"); +} diff --git a/tests/http3.rs b/tests/http3.rs index c5990bc..1592b16 100644 --- a/tests/http3.rs +++ b/tests/http3.rs @@ -14,7 +14,7 @@ fn rfc9220_extended_connect_is_recognized() { .unwrap(); request .extensions_mut() - .insert("websocket".parse::().unwrap()); + .insert("websocket".parse::().unwrap_or_else(|_| panic!("static websocket H3 protocol token must parse"))); assert_eq!(h3_protocol(&request), Some("websocket")); let handshake = validate_h3(&request).expect("valid RFC 9220 bootstrap");