diff --git a/AGENTS.md b/AGENTS.md index 0912720..60b4b66 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.29.6. +compatibility. Current source version: 0.29.7. ## Delivery diff --git a/CHANGELOG.md b/CHANGELOG.md index e75bffe..e1616fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ > test-only changes and packaging implementation notes were moved to > [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md). +## 0.29.7 + +- Introduce per-backend `health_check_enabled` (default true) and optional per-backend `health_check` override (default null): both active probes and passive health ejection can be switched off without disabling a path-pinned backend. Add UI, status and validation. +- Add cache diagnostic policy `expose_only_hits` (global default false; vhost null inherits) with global and vhost controls, keeping Via separate and cache hits visible while MISS/BYPASS remain unadvertised when opted in. +- Warn when active Web Services use one PHP-FPM socket with different dynamic PHP_VALUE/PHP_ADMIN_VALUE directives. Provide instructions for dedicated PHP-FPM pools; RustProxy cannot reset worker-scoped INI state inside a reused shared FPM worker. +- Preserve schema 9 and Cold format 3 and existing settings defaults. Update Rust regressions, browser UI tests, translations, OpenAPI, packaging and documentation; Rust compilation/live production tests are still pending. + +- Extend PHP-FPM help in both Web Service settings and reusable PHP profiles with concrete parameter/value syntax, limits, privilege distinctions, WordPress/Roundcube examples and dedicated-pool guidance. Align PHP/PATH_INFO switches and attach consistent info tooltips. +- Add opt-in vhost `pages.intercept_statuses` so selected application-origin HTTP error bodies are replaced by vhost/global/built-in RustProxy error pages, with preserved application status, no-store, and higher-priority named internal fallbacks. Keep old configs disabled and add regression coverage, localization and OpenAPI documentation. +- Move the shared PHP profile syntax/safety help button below the profile list and Add profile action, separated by a horizontal rule. Remove an unnecessary mutable Web Server response binding. Ensure the outer observer never silently replaces reverse-proxy or Web Server/PHP origin errors with global pages when interception is disabled; origin codes remain unchanged even when the replacement page has its own status override. + ## 0.29.6 - Fix PHP-FPM response truncation with gzip, Brotli, Zstd and deflate in native Web Server mode: fuse the FastCGI response stream so compression can safely poll after EOF without a Tokio worker panic. diff --git a/Cargo.lock b/Cargo.lock index c669d5f..dd3477d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1688,7 +1688,7 @@ dependencies = [ [[package]] name = "rust-proxy" -version = "0.29.6" +version = "0.29.7" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 7d4b63e..7ed6606 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-proxy" -version = "0.29.6" +version = "0.29.7" edition = "2021" rust-version = "1.88" description = "RustProxy - independent reverse proxy with local administration" diff --git a/README.md b/README.md index 3c57eec..c409c85 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# RustProxy 0.29.6 +# RustProxy 0.29.7 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.29.6 release source.** +**0.29.7 release source.** ## Layer-4 streams @@ -11,7 +11,18 @@ 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.29.6 +## What changed in 0.29.7 + +- Configure active/passive monitoring per backend independently from backend enablement and balanced-pool eligibility. Optional per-member active TCP/HTTP health-check overrides inherit the vhost's HTTP Host when omitted. An unmonitored internal 404 backend remains routable but is no longer probed or passively ejected. +- Add global and per-vhost **HIT-only cache diagnostics**. Suppress X-Cache, X-Cache-Hits, X-Cache-Reason and Cache-Status on MISS/BYPASS, while retaining diagnostic headers on actual HIT. The separately configured Via header still applies; internal metrics are unaffected. +- Detect and warn about Web Services sharing the same PHP-FPM endpoint with different PHP_VALUE or PHP_ADMIN_VALUE settings. Shared PHP-FPM worker processes can retain dynamic ini settings between requests (including open_basedir), so WordPress/Roundcube must use distinct PHP-FPM pools/sockets when their policy differs. See [PHP-FPM isolation](docs/PHP_FPM_ISOLATION.md). +- Add configuration/Rust source regressions for per-backend monitor overrides, HIT-only cache policy and cache revision stability. Existing persisted settings load with their prior defaults; SQLite schema 9 and Cold Cache format 3 remain unchanged. +- Cargo and live PHP-FPM tests remain required before production deployment. + +- Expand per-service and shared-profile PHP-FPM editor help: field-by-field examples, FastCGI `NAME = value`, `PHP_VALUE` and `PHP_ADMIN_VALUE` syntax, and dedicated WordPress/Roundcube pool guidance. Fix checkboxes/labels and add explanatory info tooltips for PHP execution, PATH_INFO and Authorization forwarding. See [PHP-FPM settings](docs/PHP_FPM.md). +- Add opt-in **vhost origin-error interception** (Vhosts → Pages) with selected 400/404/5xx statuses. RustProxy replaces application HTML with a configured vhost/global page or a built-in page; the application's HTTP error status is always preserved, final internal fallbacks have precedence, and intercepted responses are `no-store`. Origin responses remain unchanged when interception is disabled, even if a global RustProxy error page exists. + +## Previously in 0.29.6 - Fix native Web Server/PHP-FPM response compression across HTTP/1.1, HTTP/2 and HTTP/3 by fusing the FastCGI response stream; prevent a `futures-util::stream::unfold` panic after EOF. - When **Error file** is enabled, write warnings, errors, PHP-FPM stderr and worker panic diagnostics to the error file instead of duplicating them in systemd. When disabled, use the console. If the file cannot be written, fall back to console diagnostics. @@ -46,7 +57,7 @@ Named HTTP/HTTPS listener bind addresses use the same chip-style editor as vhost - Add streaming **multipart byte-range** responses for requests such as `Range: bytes=0-99,200-299`, with a maximum of 16 ranges and identity representation for multipart responses. - Add opt-in PHP-FPM **PATH_INFO** splitting. With `path_info: true`, `/index.php/foo/bar` securely resolves the existing `index.php` script, keeps `SCRIPT_FILENAME` on that file and sends `/foo/bar` as `PATH_INFO`. -0.29.6 keeps SQLite schema 9 and Cold Cache format 3. Existing 0.29.0 configuration remains compatible; no migration or new configuration field is needed. Historical details are in [CHANGELOG.md](CHANGELOG.md). +0.29.7 keeps SQLite schema 9 and Cold Cache format 3. Existing configurations remain loadable; new optional health and diagnostic fields receive backward-compatible defaults. Historical details are in [CHANGELOG.md](CHANGELOG.md). ### Historical 0.26.7 release notes diff --git a/ROADMAP.md b/ROADMAP.md index 6a22b98..cf5cf37 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,13 @@ # RustProxy roadmap +## 0.29.7 - Backend health scoping, PHP-FPM isolation and HIT-only cache diagnostics + +- [x] Support per-backend active/passive health monitoring disablement and independent health-check settings. +- [x] Expose optional global and vhost-level cache HIT-only diagnostic headers. +- [x] Identify unsafe PHP-FPM shared-socket directive differences and document dedicated pool migration for Roundcube/WordPress. +- [x] Preserve existing settings and schema 9/Cold format 3. +- [ ] Compile the Rust code, execute runtime tests, and verify WordPress/Roundcube against separate live PHP-FPM pools before production deployment. + ## 0.29.6 - FastCGI compression EOF and diagnostic routing - [x] Fuse FastCGI response streams so compressors may safely probe the end of a PHP-FPM response multiple times. diff --git a/SHA256SUMS b/SHA256SUMS index 2979744..661d17a 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,18 +1,18 @@ 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore -3b2eca0de71bfccea5a1053db1169555e66e2fc14fcef662cb9b04014d6db925 AGENTS.md -58eb5a78cefc162dd12d5a5adf64b778fd73fff815d9dc8fb75898e15fb9411c CHANGELOG.md -b42a46c774a5202a41872a035b6a19fd784111dc7d5a9ac66799b0decdca4ca6 Cargo.lock -2d3de7fedcf5aa9c1c4e45092c8c7b958d60c25853c5a351570ecc298aad60ac Cargo.toml +ed172139a0cd60d99e85aee2fc7e475491c4ab90d799afce2aeaaed1aa6d2bfe AGENTS.md +ce1a2e838ebbbd866104beb8dba475bbb431d657be5bddab132502dcd259e3df CHANGELOG.md +0ae3665f323feb3b14eba4f642c01e997fe277aaa50c5c0bcd6e3edec9c57bcc Cargo.lock +edb7ec2554d3247f04722b166a0cf268fa8a185325dffa3a9a2e607e965362eb Cargo.toml 75b008f237b2226c62a7721b7901018ce83bfc2726f047a83a17c66fcb799f3b Dockerfile -a66dd9dfbd7f0e382491e1807fec564d2ac6125684d4c24540affce88b89bcbf README.md -299fb1eb8e0471a6b0da994290414505766079efbb28a12bcf9c77b5c6cdecaf ROADMAP.md +bcd955e12490a269efa26ecec3c00705f98a0cc7a4d73a9c7506a84139093187 README.md +d44341887a6faa45886db572fe43922113abb1f2a223f7f6c71025cd9374ea71 ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md 4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service -1d63a1e957413e7c47bd51fcf9a75b89c4f7c92aff96c606f5111d4c1a766870 dev.sh +ff46619bd0a8ecf5e4afbe7179385d6d2064a41d36d28d2b4d80a9a006be6074 dev.sh 5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md 7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go 5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example @@ -31,10 +31,10 @@ ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2 f054206a09bed81018708608cdeecbfd09018ee7cec9d3abc038f8a37d47b815 docs/API_ACCESS.md 4c5288c9ed427cfb919bd023ed9a321dcf6d64bc4cad3e1768393356c5d835ba docs/ARCHITECTURE.md 598298303036912c19e0a4be1431acc7b7d3df7333f3e756e8268ac0e8c16921 docs/CACHE.md -6266163e17d2921468abdae92c1620266af8351321530e2c9b7dca6f3dd89c88 docs/CACHE_DIAGNOSTICS.md -c6003dcbc0b95e2cfa9523b7017186cc8b4826248b588af77111ac5eb15174f9 docs/CHANGELOG_TECHNICAL_ARCHIVE.md +9113f3abbfe81bafc4855d8680df8aa2a381ecc358cc3e1a0f78b6b756421117 docs/CACHE_DIAGNOSTICS.md +d069a1ffcc2c791ca0ea0a26b2533b694db1ff48f416e6e7a5525236da3f8cbe docs/CHANGELOG_TECHNICAL_ARCHIVE.md c8e69e492179ce39958cc63615cf46c4ac08cfdb7f3483aaab66868a3abcd687 docs/CONTAINER.md -7a88824e1d84383ebf69fbef992d6d6c9975902a9170502eac800bf38fd97712 docs/CUSTOM_LOCATIONS.md +befa50579e3475b22589f26380d85b3345b80e4fa947d49649a3b91a8dd8b8c2 docs/CUSTOM_LOCATIONS.md 16d8f59908c66e595d6003495cbf5c068c68b10aa9495eb8612c38b2de0e4a7c docs/DOCKER_DISCOVERY.md 5324b215075214a5bb9b388bc82d6491eacfc507df95856d1aecedd184e25b80 docs/ERROR_LOGGING.md 7dacf2129f2575823d26104ce12fd48a951906c8b2cfd40a7981b8e14813b154 docs/FILE_LOGS.md @@ -48,9 +48,10 @@ fea1ff4055d0930edeffb0c8501a6ba1ac55ec7a7064dcd0e300a43915e8432e docs/MIGRATION 1f85fac0e06e432ef6f744f0b5f9c1b7a27a1e9e1ad30dc1a218eab96b1caffe docs/MOCK_SERVER.md e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND_HOSTS.md 89a1af6c9fe10c572b211f9632ce256166e67990b5dacacf2ae07265d99a5172 docs/NPM_MIGRATION.md -98ed766b6c6778114959a1f17048188b63f218501bbef326067017a91e114a66 docs/PHP_FPM.md +e68d5a5cf0991de9308f78afb0264e02979f7463a5f395eb7a6e9ec2bd3a3d54 docs/PHP_FPM.md +bc068a176056f5ea4d3b637071e644ed58a2140494c233ef22aa66e9700e3299 docs/PHP_FPM_ISOLATION.md 572c70fbd2f61fecd80a8ff9fa5de4a94c4f158de5e90da8e920f99b051241fa docs/PROMETHEUS.md -7046079140db7c152dc9caa536259d3c175c8893bda3a23d6ca4e4acb8d0b4c0 docs/README.md +926fb145ba58b6c9e7af6474676da22c9e6231dd86c41703f343e6457c34bbc6 docs/README.md 6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md 0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.md @@ -60,7 +61,7 @@ a2bb837cdd3559d58158880c1f832badf64035376a3623050bb4e40c2bca104c docs/ROADMAP_H 620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md d5c5c59f6e63b8d83dad5d159657c4d4039dfeb4cc1d8af210169b576adbf7e2 docs/SETTINGS.md 3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md -45c5af7894f1e3da1d4f49e0fbdb3657baa49aefe5ecfc004f14c5fc1051997e docs/TEST_REPORT.md +91d505c03649a62644a08b4458ce2e65597d2c491a45659f52a29c69e62e1166 docs/TEST_REPORT.md cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md 751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md 660687fc961f71aae2bdf71595aa47a336fa782f4e78ceee8c1d1e63e46393ee docs/TRANSPORT.md @@ -104,9 +105,9 @@ efd5935852d4cfd09758bf67c70e437490cfce2baade46c8733201e506dbcc42 docs/V0.27.0_I 097a4ff47634e5b09180012135b49a8ed16193fadc36113cad0c8dffb9e47cca docs/V0.28.2_IMPLEMENTATION.md bfe9b83d2292bae1e579c1841a396173258496063dbd0f3953780dedeebc2c40 docs/V0.29.0_IMPLEMENTATION.md 1bf34805a56508bb7e60745be8e872ebdc458f03bd0f56941af9c18d94ad87ce docs/V0.29.1_IMPLEMENTATION.md -c7c6c237f77b8fbc9601d2fbe30216d51394a392ef1705af74d5ca79930e9d61 docs/VHOSTS.md +a236de3c92bdd0248c12b0224bf29ddafe6baf88b4f7f3f13f179fdad339b371 docs/VHOSTS.md 09b60fb7b40db4d0139fa2a9e69a2aa9f28d4e61b8643521cfc7e936d7df8774 docs/VHOST_ACCESS.md -7e194221c9d3ac36346caa1b09641c2614179d9efd41f2821091a4c217f07018 docs/WEB_SERVER.md +04a42e4629fdfb7224173dcf57cd0992cba2edd109204bbf503a255886605542 docs/WEB_SERVER.md c336f998027330c58d5afbbbb3a3a682fd44ae3997643882fe342633822f8bb3 docs/screenshots/README.md 5502e8ce44276213803b66c58e3a4d804a1354f5ed5d83f47ee805e8c44ab088 examples/acl-headers.json 2431dd88a2d9ad2cf3cd4b8fcdfa602563ab1013c612d8917fddbe7febfb2dc3 examples/cache-dashboard.json @@ -132,8 +133,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r 96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh 3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh 71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md -21650f4e9acf69d3c064068e3f4565bf99371b707c8969b6383ba57d32e7e9ab langs/en_US.json -e256c7816a9b2d8e5cd5a9b3c1cb3fc049ef9a052f7b42407a61a59725074063 langs/pl_pl.json +3734133583c50573492e93cddc74e5c374330f04dfaa2488597b509ac4bc5bfa langs/en_US.json +be73dbfc30ba30b31cc97debb6b35e8fd354756f1227311ed6c99662c2d5a7e7 langs/pl_pl.json a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql @@ -175,14 +176,14 @@ c8941a929dfef9e8875adf534394ca01a1532e567d5099fefe3657826886ee25 src/acme/runti bc9eb32f0d2785bac122d5eff44742cd24306c503daa065564383d46416b2211 src/admin/access.rs 019f4aa33e3596bc9c11ef8cf5703bcfffe46c15288c0eba83c8dd9e38055fb2 src/admin/certificates.rs 1de4c7c116eeab2452ccccbaba3f2b7b63c6eb1c1e146ab870d19c5a5ca16f80 src/admin/documentation.rs -2270b77cc006de95b3d616aae07d4cb0b45abf3f9301abea5b5dad8ab05e4c56 src/admin/documentation/documents.rs +db77101b3c48292538f41694f087c498933d22a14e303445540187c92fcada0c src/admin/documentation/documents.rs 67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs 002922c91aa07cbea17cb0ee11127bb6e7e015de108ee032761b34507dcfa06d src/admin/file_log_search.rs e9f72e65c9ceb255248f460cd7a003fff33e4b258160e248353c3e1fb4e952fb src/admin/file_logs.rs 1596561bb57976268b02e36e8ace426091045ee9d3f8832ce775eccdbc30bc81 src/admin/file_logs_io.rs b3e2c11dbc11bb8930117afdac64779111acc2ca7b2857f6e359a5108fc9ced8 src/admin/logging_api.rs 58802528128f53e493f7339063433a747b605007a8842b5efc670132d897d6f7 src/admin/mod.rs -e8590a28bf492162ac8d64a26b6fec44bba2efcb258ab608a04da135136b7af9 src/admin/openapi.json +ddaa17ded3686ea0e0947b3968787bdbb161b32da586643485edae948667d03e src/admin/openapi.json 6c2b9a0ecb2f4172f110a363c22a6be32895dbcff9605fe7aa94efe91e0a536a src/admin/operations.rs 5287fff6b3ad436ac695e9b66feb1b0b5705b744a9f00cec7ea6a3e951e5eebf src/admin/preferences.rs 31bd90ee5f5ec2984920225ff6b57e07db3e0758092fb55d7c230e3590c1b015 src/admin/public.rs @@ -203,7 +204,7 @@ cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail ed063b8a414cf3b5e539c900e8722aa82d84c561a5cd54d875650793a25529e2 src/admin/web/app/core.js cb5c4c2dd4964795b61393b05537cc1528c72d7c1390953f4d119a5e55c65c7c src/admin/web/app/preferences.js de17987bda89dfab54b6751007474ff9e7cc8da35f7e9c46a2eff922a50fb085 src/admin/web/app/runtime.js -3b63cc15068d287fe873956b38306abaae8c88a2f288ecd98159c5c47acb98e1 src/admin/web/app/settings.js +887e34c7e4b3e197e54151f4bb48932e4f7b9f8e64e3d3e8c9c7ee0835fbe4b0 src/admin/web/app/settings.js 74777adaba5a448f3a9abf3a46d91efc6eb81ff6f3b5441e5b99a9e1d6c09af0 src/admin/web/cache-panel.js 50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html 720e201a29d4b3d2e487aa9b857ddcfe785f6f26190827ae5c265ef8c308a86a src/admin/web/docker.js @@ -220,23 +221,23 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/ 0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg af6e7a2e890df2fb7385a374bcb3fba52fb5a53dbe18adc1fb7865660fcba842 src/admin/web/file-logs.js b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js -d22291b13e371f2265273762bbba392e543f68a2799571f769abf306070a9058 src/admin/web/index.html +62fe27711ced37f0c4908c84b42ab29336ac2af207fecf5c7eb5557c30ce2868 src/admin/web/index.html 24db88842707f764064657712d3660c6ac129fbfb47d188c0903925cf8244ce5 src/admin/web/integrations.js 7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json af0fbd94d9b88ce1cce28e39dfd2c35de031adcba412dd4a9a17d9b416c256dd src/admin/web/login.html 99fe9b8ba7a8a24e5bbe9ae40999484fe13b0faf5eb1d7a9193701f667e51ca5 src/admin/web/login.js -e169d77359785ba604ce1a728420571fdc9b491fc1c0088642399466fbed8c1b src/admin/web/management.js +810e7e5bf1af302b51d90771c3ec3c20227eca392f8c7d91d027e11a999bc236 src/admin/web/management.js 90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js 7fc4c3661524f11448bc43ebe8f494ed44b8b0a414e63ce5bb8717f314c33808 src/admin/web/settings.js bb67e77af5514478485cfb69f339d63d7eb0cbadd093fa054814a416260ccc1d src/admin/web/streams.js -82b3d9b270c0adfe7a44925c8dd1a93b39f2936f40f4b260d36dec0689701595 src/admin/web/style.css +0798eb842c2413acf20001cbe750452c7697a3c8376630af45cf7d2b19bf6175 src/admin/web/style.css a8a73a4fa849ae49391fcd6143e900c515c8b577f71b55cbae9701fa9f5bbe47 src/admin/web/traffic-panel.js e686d68fd6a368110a85dc4c5dc64e8093950d20d63e9aa44790c03dea6fc193 src/admin/web/transform-panel.js -7ee39a069155dc2650d688f14f8ea51e1a7bdad95ab4cc62769b4565c52fd37b src/admin/web/vhosts/core.js -38d1f4bee4eae192fc977747241b76ed164bfae174d0377744d0a2ef06f85306 src/admin/web/vhosts/editor.js -a155dd33eb802d4f2906f581b8f0d4b40260cb917766c31d4e887cdf51f2e940 src/admin/web/vhosts/render.js -c7eacc7e2cfe06b815524f99358233e9c41062ab1a1a2ff5f68e0c1723c8ad0f src/admin/web/web-server-profiles.js -5ff1f6421d493ef74fa3875ddbd78a5fd31a51a1e62b4dca5ee7bca022872e0b src/admin/web/web-server.js +71ebb6c4e963a62b0ee7564ab82eace665aec7016bcb8bd656225d53f6744143 src/admin/web/vhosts/core.js +6f4ec05a59bbc5cce890394247cd56ba5da38a250849952e68e001745e337bb7 src/admin/web/vhosts/editor.js +d75e2de243b52ede09977675f585e29abf5a39e0707c323b28290667fded265e src/admin/web/vhosts/render.js +91e3417fba7e8f59023cd64813befccc8341741cd4aa53dfa1ec68a03c7f4632 src/admin/web/web-server-profiles.js +de3326d4fae254eb0b8c190f02e762f4094dcba86bfdcb24c5fb472676ebcc4b src/admin/web/web-server.js 98c00d99822f9be54fc58afb6d4b577901712effd2c5eddce014312681864625 src/auth/files.rs b2f948bcc3dbd24952c7b1b02d41d06757f49f0c38a78caf51f8f8a687d8480d src/auth/files/tests.rs 97b5a060f24a102bbaa2f406426e985236c0d719b8038db210c25b34fcd8e923 src/auth/mod.rs @@ -250,13 +251,13 @@ a00571b409d305af86ab2a57a25434b5ab58706772df440d017fc1fec097f7b4 src/backup/tes b2ce83a48736c082e6b820e4e363cca7a5018f3ae2a1ce5adfd6af1a03d62598 src/backup/validation.rs 426a946b2c9e244f09f6cbc5f9664b8ad0737529a9bb5a3d549d27196a1f8043 src/balance.rs 3d4b3986f54bed99212063b6d135d40a202d556a3e4d253e24c74be4cb9e8ebc src/balance/health.rs -dc9335e018d61cddfe3b4c9fa480913f4444c83e4fb1384e3b046cc40940a2c3 src/balance/lease_runtime.rs -12b2a755b18fa4bdd00d1ddd05ae5ef0aabcaf2158e298ee28d6171691cef449 src/balance/model.rs -f96f359d24fab77f37889a6f802c1e77d5278a826bbf6fb6f822b262fe41c82d src/balance/pool_core.rs +a1e5d43f5417d71f3974812cb539c0932f206e2290090917f067494f3587fc72 src/balance/lease_runtime.rs +a57f352aad3b5a9a6debb59625864808f0a0ff7b11db83b47d6825a5932ca16b src/balance/model.rs +7628aa331c9dbb02650392afeb65838cc23e32939c002d5d0488a8f3b8c11bd4 src/balance/pool_core.rs 477132dd381e23f1be197c32ff58a0925f41d3e6aa63fe77c10973cefb2b76f6 src/balance/pool_selection.rs -d3ee9cc9869fd0934d1f00fd86943979bac9251630ccf7a2b07e684c7955967e src/balance/pool_view.rs +519cb798040ac9a65d9ebe43ae11ef729bd78c5c4a5c756490458add4b6fdc24 src/balance/pool_view.rs 7d94d1bd4eb698e0588b822d93e56e3dc5594c46edac4aa9713f148893ebe6d5 src/balance/tests.rs -2cf358291fe824c9809e43c6085a3388a294c6bf313a6e82a3733c0d31b9edc9 src/balance/tests/part_01.rs +81141aedaec495183b88a4f6362a71f56ee14cbfa83d71fce51d8abed8df3f5d src/balance/tests/part_01.rs 490b38a8b1472dbd9fcb823ec0ed0e92498fb9528b982bcee340c8579e89ae36 src/balance/tests/part_02.rs ccb3e1988d6ae5825450e894eea896d53ae9fac150d0175ea5e742413e55105a src/basic_auth.rs 9a8858b7ff461a4d961ba701ca3ed6eaec15c3f7f05eef8e0e30edc63617605a src/basic_auth/tests.rs @@ -280,9 +281,9 @@ f3e026a3c0e953dca3e5c9def43d864a7706afcd7cbeeaf0f21c059a335b30cc src/cache/look 58ede9780b70ac46fe8b35b6a85f02f56f9a2c75b045e3435062cceb3921f3aa src/cache/model.rs ba94226addfd6cdab67d666dc15b8412c4d117111e44639e503e89a95175d0a2 src/cache/model/cache.rs 0ec4f6d0adea1ed80a3ca17c68c9c85502491da2904d9d690b4169ab9b025b21 src/cache/model/cold_metadata.rs -f9c426a17db8555ce6cb81eeb39b2321d5cedc628139ad3abb3152f59e7ce8b5 src/cache/model/entry.rs +753d0a316169847f905ecdee0ab6f445e43a3c247d891a08936e19e992f9e3e0 src/cache/model/entry.rs 09a4417f82984eca8c040ea28eb82b3f129c347d951d4b4fab704bb194d9b985 src/cache/model/maintenance.rs -e477f9b9fc63347ad69acf6757c54c88db26ebbf486d5f3229247bec982e836a src/cache/model/settings.rs +e655c9e23069ceb553f6fcc7aa3824b08e2e5ceda5c39cd800dc79507856a94d src/cache/model/settings.rs 55dcc0ad84d6b73689ba43e759d5685c406419c7733e23e78d5b072891ab17d7 src/cache/model/views.rs e26dce02df9c043329ca164fed215d271781bce8fb03240f528331bf3df6b9d7 src/cache/policy.rs 989df11e246123f760ef31689370f3500e0913fcc6b70d7ac0c2c95e2f412fb0 src/cache/policy/request.rs @@ -292,7 +293,7 @@ efde2bd046ed073900cd3e67051f0542c802a06e8f5e38c2c20ee92604080c36 src/cache/test 13dab7b7a587cfc146826a92ab94ec9e04f7e500b7a84e678d77226829844923 src/cache/tests/cold_tier_tests.rs b4f609ee900c556b613136bbd3fce11c242a415d277ac3d25bdcb72275b66832 src/cache/tests/cold_tier_tests/part_01.rs f43aa6c231f2c85ee3390dddab74aab2eea2a3f874acb35f145c23866266cb52 src/cache/tests/cold_tier_tests/part_02.rs -e94cb7b767af1e7adb284d43604e1553cff1fae7cf72dccf40ae11f434a4a26c src/cache/tests/dashboard_tests.rs +22cb9bf20a0d25bee30c1e2213dfe8278ad7d0d5d294275d47d4a6ff9ac646ab src/cache/tests/dashboard_tests.rs 1db70ea5f3cc1f1543504a68498ba23cafce5f9bb48c4616a4bf70d9a536790e src/cache/tests/scope_tests.rs 578b18cac918ba51fad775eca188f645b228500e59eb1790f783a0b8d0245b7b src/cache/tests/tests.rs c33013089bf9e75a07a5b43f1030acaba8aba4098796e393ed4bad784f4fff96 src/cache/tests/tests/part_01.rs @@ -307,14 +308,14 @@ a8a0ce446eb53ffdb8f8d97efa3e1344410dc3798b25f62b8817e8e160d28940 src/cache/tick e2f58279499b7c3b47c65d3a7814bb5c7cd6506544d939875fbb0f91fa9aa59d src/compression/tests.rs 7bc8285cb2012506fd1a38a71dffa561f885f4ab65a9b2a7c8344319e3cd1cf6 src/config.rs 7ba52d3de4d77d1f3cad1b10de418953adc33901727a0791980847ae223929aa src/config/backend.rs -33c3598365ecd5308bf51ac39a62557b137ea83d36cd1e46be25d2f10e119fec src/config/compile.rs +514db7c259e40616b68ffb209ec1272b85bdd897f2766e601abc54dc4d5f4c31 src/config/compile.rs 490d05d84b2fe349ddde54eae44880993fab70811652484dd9ba38b5069d603b src/config/internal_routing.rs f02def401d5c0a6d619f0e6f88bbccd1db94a32bb004dc88f1581b773c7d6fc9 src/config/model.rs -1bf510a70805e6269ff39c4029016cbce7985535e52da1b915f6f971a12c2e63 src/config/routing.rs +335639a82f3449be1c41963b16cf5401f9bae0344b829f86170f911266c62864 src/config/routing.rs 818545ad36af3ae8d56976f318349dd1f5264ef5665fbf94681a81b79003f8cb src/config/snapshot.rs b9f56cc156deaf1a59daf4da96233bba12a31a4fde3f6fe6cc08909fb6c1823e src/config/tests.rs f712db55af9ac463bc905558d29d068a58ac75269075055724ad187d7ea06fa9 src/config/tests/part_01.rs -fb6e42d969b207e3e204d5892303740cf6f780be5a195cbee1f894b703994b60 src/config/tests/part_02.rs +bb754925f525374eb968a6c326672f6d565f7346a055b4cd8e7f0d546666648d src/config/tests/part_02.rs bc5816805b6e70244c339b4e680b447b3d00d545b59923e242629dc3805907e0 src/config/tests/part_03.rs c6db84438878accb8a0c96be13fa01a83da824af39c929dc3f6df498f393b7e0 src/db.rs 7fadb86f6a61dd0127dd68c115be36865f0b2af535510f4c8f9aff6c9b8cdbfe src/db/certificate_api.rs @@ -361,8 +362,8 @@ e5c2e9189777725f4afa1c3c052e4f75047ec3f2f486630d990f5d3c194bcc96 src/main/tasks a54b5b34ea65f29723b89b81bf7968a238e8247b9c9760f3430d3504cb1017c9 src/metrics/vhost/tests.rs a4702c95c7e5077c2ccff86e2e3216e1da6ff9ee4383ae199a0aff2f78db325f src/not_found.rs 668840e7bd70590e2e13f59b6d5b9e43ca7c877f3d07c71907baea91b5bba6cb src/not_found/tests.rs -6aab4f97d72ec6352c940bc89e911b8e50c580821da8202744e9832621458784 src/pages.rs -49c17c5084b3d2f52a7d3473e55311dc4daafb1a47805f8821a5a4090dd477af src/pages/tests.rs +202b4b5fe837f6a3f53f350d4c6368ef70ef7070b4094011d64b481717ecea3c src/pages.rs +e70bb6aa6794d30e561ae19d7e2fce34d9adbcc7db05bbdf6f3cf8751e3d2509 src/pages/tests.rs 0dba7b6a33866cc7bbe05509b01961e545892645d1dad4eb88ac8a4bec65e3bf src/prometheus.rs cc6fb14351b009a16d07ed0f412c2bcc68cd7e551421aea03d8d97ceec9bee40 src/prometheus/core.rs 5ed3d03f9b805413544b932d0c266b0e32b830c783d01900af7988bca6bc48a3 src/prometheus/docker_misc.rs @@ -374,22 +375,22 @@ e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus b5856fd38a957b2ca9c7e3606d84d13d5cfcf3a45b487b15ae1d2dc248cbda57 src/proxy/body.rs 16b9d4db3b20b2ab14c47ac14fcd18d3d8ca77c3ad9a1f37866ebfa60f379862 src/proxy/body/tests.rs f0cb26ed34c35bacc74b914457acac8a611390de14cf46e2e6d3bcc848e914e8 src/proxy/cache_helpers.rs -af6885dddecef81c3b12a61e7e0bd9d77123321c5cec0e15d0e77f29e7868fed src/proxy/error_fallback.rs -cfb0910af8cd868bde3f6c9992319e46d5328d6d8f69c50701df0ea12bef2108 src/proxy/forward/cache_lookup.rs -e5eec11349cda99d08b27f730202a57457fb2f48f856537b07dbda669168b5bd src/proxy/forward/response.rs +771cc1a38927f96e593b144dbab66cd64a5dc0ed42d54b1615c359845b2b7223 src/proxy/error_fallback.rs +bf2e6bbd64fb45fceed54af0bff1c1e00da22b79c64dd8f5c23d2af0dbab0b7d src/proxy/forward/cache_lookup.rs +526592b613fbe20f532f11be5a3236acaeb8cad400f70e1d28cc1c2dfc126c52 src/proxy/forward/response.rs d3b9aa0c0055fa9a29f180ca8b10c890c0b91127ee603e5d89cce4c99d5a6bd4 src/proxy/forward/route_cache.rs -f239c1d5ea7a6efa83f59eb12cbeec310bef00f1360f41b5c64783af30e8469a src/proxy/forward/success_finish.rs -e58c03f95b4ee8150665b20cbd76c3c6e21a1096b92b42dedc52c764123176cb src/proxy/forward/success_prepare.rs +cea9e47ecb31b0e4c0bbe0eb83e0e53b5e7bdcb9930107b67a8c34eef01a3f49 src/proxy/forward/success_finish.rs +902cbf8dc3c1ec8d25411bc7fe0400d301f3a7cc885afada594e4eaee3bedd08 src/proxy/forward/success_prepare.rs 23b6bd26f6175b52c13d2571318fddd12550ba34ee77b87b5ac0e4811f8e2bee src/proxy/forward/upstream.rs -249ac0985d99f4d73a9c9082a9abeaa47cb23aa5cad0e047f99f8e8cb6a295be src/proxy/forward/web_source.rs +8961d8d649d8f4708f443f1b8b8a73c8b5f42811d2cad8cc6f596833511933f4 src/proxy/forward/web_source.rs c39090511e8057d43f2620175958c3dbd9b686036306a0060bb64eaab4bf6c93 src/proxy/forward_entry.rs c438066616527a3108f2ad53e3698ae9b2a80961714394cdd0a450c92f99d992 src/proxy/headers.rs 5cb7522ceb9a9f083a15596ac4898559295e38d1138c80099577211de8a868ec src/proxy/headers/tests.rs f202c0fe0bc4e786a906d7337d368cd81bcac15ffdbbfd35d5b0d49126d9895a src/proxy/mod.rs 55133ceace2e5262d9c54fa41bea099a27b817d1e09c1cd0e2493fd9cee4da38 src/proxy/redirect_helpers.rs -c3cbffe01436901190aaf88ee280279c85bdf00b560c02a04e20a267844cf39d src/proxy/tests/authority_tests.rs +cc682f0d5754e0b806a4eb5950e4cc00611aca5ce4668414fddef1d692489805 src/proxy/tests/authority_tests.rs e80c7fe44abb7607c64ea02d56542753dc25290b3bcf4c4d45b75f2db6ca90d0 src/proxy/types.rs -6fb90a995dd6b81f681cd283d71f3dbea09b8dcb4a4b17a433e0213d6a55e245 src/proxy/upstream_helpers.rs +26afb4dcda99a54abdd85f7950ecac6d608eca78067323f695a43382d21fa2fb src/proxy/upstream_helpers.rs 69f775f6746b4c6d729dc10961dbef54168846f4ce9cf7fe453ab5a2b879c9b8 src/proxy/websocket.rs 016a60b2471edd3df9bbe9f92a9cebea11047178b83ff0e3b2351c12814a4939 src/proxy/websocket/handshake.rs 7e9c4d4c207badabf90630223964bb2ad641f06d4149843cab751f192b8b4adf src/proxy/websocket/tests.rs @@ -403,8 +404,8 @@ d0939e1b0829c25356a0b67ecb3a178c74936dd07baa15fee24eb32544db6cad src/runtime.rs 4e0b25c2b86eefddd76c64b39dc1c1cd6d243addc32cdea99e6c3ac84ed879a6 src/runtime/certificates.rs ad79a64f0fb31881aa93a2305fa3084d7e02e15d464c71dbba38123bd6a81fc5 src/runtime/constructors.rs 31dc5cdea00efd63594dc6becd5a9103594696145cbc8e7b7594fb407214ad9d src/runtime/docker.rs -b3f2456ce8be5ae54399c3cefbab4f99a518631ae57eac0dcc21907c6eb97617 src/runtime/tests.rs -571fd71115e4c9c3b31baf85978eec46fb87459cc1b6ad1976203142ce821970 src/runtime/types.rs +467e07b21075fe513786aa465dc2d2307f0ad76198d0166f03e6bc5b4a19868e src/runtime/tests.rs +9671e128bfd35179cbadf4230e23c4868f13ac44957b98644301799b8acdecee src/runtime/types.rs 88bc546fdda20d3f1f8f5e7d7872ede1fa334b8bb5102c729bd72f56e3fb94b9 src/runtime/updates.rs b4c17b304166592e63c81884076589fbaf725c3f57043f8924c46167b061ac5b src/secret_store.rs 6dca8da072a3bd610704a391bb6e25c9042971f4ebc60edb47665ce1d74cbc58 src/secret_store/tests.rs @@ -460,10 +461,10 @@ a67e58a597a5eeb7efe605ae6e438a8f84ff6a1cedf8cd228141cb95551872a8 src/upstream.r 6f932b8e59de8a5c89d3b29b838eb2a30f6e3ddd342fec29e39af1ce1b1c7d11 src/upstream/tests.rs 3aa1b07cd26d4a5b9e5e94d1d6a712ef1397d827e7f1e8c01f7195b98d2c5f67 src/vhost.rs 6678192de56bc10c5cf82bdff8da14eece7685f409592450c07478333aa3b99c src/vhost/error_fallback.rs -2d3a9d0321edc910d38afe6349d17d81bc090d36964486c8b87b64037311a83e src/vhost/model.rs +bbf2f687b435e7c5d426bb2ad61b73ae26fc3b59be377c73277153ea97a4a7ba src/vhost/model.rs 35a0aca3eee8d47d1bab27c83d4532cd572fc3e17b5f0d4a8d4515a8f85b3d96 src/vhost/redirect.rs 9b16b5307e140db97363f56c9fac8319e56df846bb1c919d3bc06091ab6ce69c src/vhost/tests.rs -b61a40bb0ead18b3e348f03a258bab6a68d5c8f46968430f692458aa0cfd5bce src/vhost/validation.rs +0e7b7ff1032a3a569acae884e9e2061ba936b22b7ac20f111a8633ca4597c25f src/vhost/validation.rs 8d1eff4b24f01d137414ef5419f75dd281879228adb70409645b11b3c78c20e9 src/web_server/config.rs 1fae9d57dfee80edcc3073438353254db5b6afdae1d9c1d6173cc6de5f66b2e6 src/web_server/config/compile.rs 7853f6ec127a91163a4418ca45b93a92f424d5c618f27b29f9d526598a26f7ef src/web_server/config/model.rs @@ -497,22 +498,24 @@ eecf680f001f2110dde4eceaccf2f922859a16c946a644f8854e8de4d3771f7c tests/policies 728999110a6d8cacb62bc0399bc333fa23a66526ee23f563a3c93bf00e5fad19 tests/transport_config.rs b04362ad082a32c9ccc4cf38c46f44d71347adecf7aba3224b3af3ea4872e3d0 tools/README.md 358c1e94dd2b4a756430e5335747f0b437345b1713670a9972c5bcf78b837ccb tools/audit/admin_audit.py -b6cf2246b4075c8a030cdd5fc4c98557afe9b42562948a064dc6ae9ebeb31854 tools/checks/check_source.py -e376c6324b7327943ad8b9715c497d05c8b0e3f83d6428f0456edfe07df05f15 tools/checks/docs_check.py +e5e352a10f0bdc748f583ee8036c7be1055ecf08228920774b171d248b9342d7 tools/checks/backend_cache_0297_ui_test.mjs +591a69193c303d401d2fe85c828748ca64e1ee2357d56b1924f8ef03dfdbc367 tools/checks/check_source.py +01af9e83643e431bb977de7d85fd257b425f029b19a31de5826122b8536b6120 tools/checks/docs_check.py 4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py cf762780c4085791c72c3c5f93054d7f2a1107403bee0616a1f4113bbcbb99f9 tools/checks/memory_contracts.py -c249798b65079d4579ff8e7b150b51409237482668eebe156f01e2425bed799a tools/checks/preflight.py -29579531358072b98e21979a5f65eeed096707c17668c03b77ccbf76183cec02 tools/checks/release_smoke.py +23552af0cd0e3fd1312d379af4b524be95ee79aa78528ad506ae9b23ab21ad26 tools/checks/preflight.py +bb590f1a6d6111e5be36dc4b4b272594c9620a636a76174f27adff52962609bd tools/checks/release_smoke.py f69845bc26ec33160fc756953991cf32aa8ad6a7fb4a131caa2093db91e783ef tools/checks/rust_module_contracts.py ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py 23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_contracts.py 2309c41e769e797c838d4f40d50bb3c6b9ad9ad0f9ed07fb93e07f95d66563f9 tools/checks/source_module_contracts.py 987b4c593b51a7c340d8f01f9a2eea1387043cdd997eeb932c8341bbb8c0bd64 tools/checks/version_contracts.py +2020e171be1a044f7ca2ab83af7604a014c36fe4ff04791d0620ccbf1bf8e000 tools/checks/vhost_origin_errors_0297_ui_test.mjs 0c071c26fc316934bdfd2bfd19f5fed9521790d8c887760eaabda81a9be96703 tools/checks/vhost_www_ui_test.mjs 0aaa80453e25aae7ea953fadb91b3ae3eaff627977c81c9ab5a2779f9a93582f tools/checks/web_assets.py 0398892be30fe416a00578915c588f360fd93244e3af77ece834e560e547d1c8 tools/checks/web_server_contracts.py -d5e786084178a9eac9c69b818c2d48945e797b7064a68214e918a5b842b8e40e tools/checks/web_service_ui_test.mjs +35f76060e585a96c623ef83bd5f771969f5be16a0e1ae626a3092d7458b84e84 tools/checks/web_service_ui_test.mjs adaa1ed9e5958739df40190055c780c80d0a8e270ec8dc105ed3fa2dbb8fb74f tools/checks/www_redirect_ui_test.mjs d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py 2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 tools/dev/mock_server.py diff --git a/dev.sh b/dev.sh index 73ea5af..b12c110 100755 --- a/dev.sh +++ b/dev.sh @@ -100,6 +100,8 @@ if [[ "$mode" == "test" ]]; then python3 tools/checks/security_dependency_contracts.py if command -v node >/dev/null 2>&1; then node tools/checks/web_service_ui_test.mjs + node tools/checks/backend_cache_0297_ui_test.mjs + node tools/checks/vhost_origin_errors_0297_ui_test.mjs node tools/checks/www_redirect_ui_test.mjs node tools/checks/vhost_www_ui_test.mjs else diff --git a/docs/CACHE_DIAGNOSTICS.md b/docs/CACHE_DIAGNOSTICS.md index 2e914fc..b251329 100644 --- a/docs/CACHE_DIAGNOSTICS.md +++ b/docs/CACHE_DIAGNOSTICS.md @@ -3,6 +3,24 @@ This describes implemented source, not a verified Rust runtime. See [TEST_REPORT.md](TEST_REPORT.md). +## 0.29.7: show diagnostic headers only on cache HIT + +Set `cache.expose_only_hits: true` globally (Settings > Cache) to emit +`X-Cache`, `X-Cache-Hits`, `X-Cache-Reason`, `Cache-Status` and +`X-Cache-Resident-Age` only when the response is actually served from +RustProxy cache. MISS and BYPASS continue to be recorded in internal metrics +and access logs but these diagnostic headers are absent from those responses. +Cached stale/revalidated/range/conditional responses use the HIT outcome and +continue to show cache diagnostics. Individual vhosts can override the global +flag with `vhosts[].cache.expose_only_hits: true | false | null` (`null` +inherits). Changing the flag alone does not purge cached objects. + +The independent `Via` proxy-chain header can still appear on MISS/BYPASS when +`cache.via_header` is configured. Set it to `null` to turn off that header. +Existing `expose_status`, `expose_hits`, `expose_cache_status` and +`expose_resident_age` switches still select which individual HIT headers are +emitted. + ## 0.19.1 cache identity safety The frontend scheme is part of the primary cache identity. `http://host/path` and diff --git a/docs/CHANGELOG_TECHNICAL_ARCHIVE.md b/docs/CHANGELOG_TECHNICAL_ARCHIVE.md index 693f58c..bc37d7b 100644 --- a/docs/CHANGELOG_TECHNICAL_ARCHIVE.md +++ b/docs/CHANGELOG_TECHNICAL_ARCHIVE.md @@ -7,6 +7,12 @@ # Changelog +## 0.29.7 same-version `E0308` source correction + +- Defer Reverse Proxy error interception from the upstream `Response` to the adapted `Response` in `success_prepare.rs`. This preserves the H2 WebSocket tunnel's `Incoming` body type and ensures replacement still precedes response transformation and cache admission. Reapply response rules and enforce `no-store` for replaced application errors. +- Change health supervisor member collection to own `Arc` and spawn probes with `Arc::clone(member)`, meeting Tokio's owned `'static` task requirement. +- Extend the application-error UI/source regression check with the body-adaptation ordering, no-store and Arc ownership assertions. Offline checks passed; Rust compilation is not available in the packaging environment. + ## 0.26.7 ### Backend client lifecycle and cache diagnostics diff --git a/docs/CUSTOM_LOCATIONS.md b/docs/CUSTOM_LOCATIONS.md index 772e1a0..15d3046 100644 --- a/docs/CUSTOM_LOCATIONS.md +++ b/docs/CUSTOM_LOCATIONS.md @@ -116,6 +116,17 @@ RustProxy 0.29.1 can mark a Custom location as `internal`. An internal location This is an internal request handoff, not an HTTP redirect. The original method, URI/query and request context are retained. The fallback target re-enters normal access/header/cache/compression/response processing. Fallback targets must be enabled internal locations in the same vhost; validation rejects cycles and runtime dispatch is depth-limited. See [0.29.1 implementation note](V0.29.1_IMPLEMENTATION.md). +### Replace origin error bodies without an internal location (0.29.7) + +The independent, optional **Vhost → Pages → Intercept application error responses** +setting (`pages.intercept_statuses`, for example `[404, 503]`) replaces a +backend or Web Server/PHP origin's error *body* with the configured vhost +error page, then global error page, or a built-in page. It retains the HTTP +status and sets `Cache-Control: no-store`. Unlike `error_fallback`, it does +**not** forward the request to a different backend. The existing internal +`error_fallback` action has priority and its final response remains intact. +See [Web Server – application HTTP error interception](WEB_SERVER.md#intercept-application-http-errors-0297). + ## What is intentionally not an Nginx parser Custom locations do **not** execute arbitrary Nginx directives or configuration snippets. RustProxy does not currently implement location-level `try_files`, per-location filesystem aliases/roots, embedded scripting, arbitrary `if` expressions or arbitrary module directives. Static roots are defined only by reusable Web Services. Use the explicit UI controls and the documented JSON schema instead; unknown fields are rejected on save. diff --git a/docs/PHP_FPM.md b/docs/PHP_FPM.md index 547ee40..1a382ce 100644 --- a/docs/PHP_FPM.md +++ b/docs/PHP_FPM.md @@ -1,5 +1,10 @@ # PHP-FPM / FastCGI for the Web Server +**Separate pools for distinct Web Services:** Reusing the same PHP-FPM socket +with different `PHP_ADMIN_VALUE` maps can leak `open_basedir` restrictions into +a different virtual host. See the 0.29.7 guide +[PHP_FPM_ISOLATION.md](PHP_FPM_ISOLATION.md) for remediation and tests. + RustProxy 0.25 can execute configured PHP extensions through a long-running PHP-FPM pool while preserving the reusable Web Service architecture. ## Architecture @@ -44,6 +49,90 @@ Configuration example: Unspecified PHP fields inherit the safe serde defaults. A service without `php_profile_id` uses its own `php` object as in earlier versions. +## Where to configure each PHP setting (0.29.7) + +Both editors expose the **same fields and validation**: + +1. **Web Server → Web Service → PHP-FPM → Local configuration:** set values on one service (`web_services[].php`). Select **Local configuration** in the profile selector first. +2. **Web Server → PHP-FPM profiles → Add/Edit profile:** set shared values on a profile (`php_fpm_profiles[].php`), then attach it in a Web Service's PHP-FPM tab (`php_profile_id`). Editing the profile changes every attached service. Per-service `php` values remain saved for later detach but do **not** override an attached profile. +3. **Actual PHP-FPM pool config** (`php-fpm.d/*.conf` or distribution equivalent): the pool must exist and listen on the configured socket. Isolate restrictive `open_basedir`/`php_admin_value` settings in *separate pools and sockets*, not by alternating request-time `PHP_ADMIN_VALUE` on shared workers. RustProxy does not launch PHP-FPM. + +| UI field (JSON property) | What to enter / sample | Notes | +| --- | --- | --- | +| Enable PHP-FPM / FastCGI (`enabled`) | Checked for PHP apps | Off means PHP is not executed. Never serve PHP source. | +| PHP-FPM socket (`endpoint`) | `unix:/run/php/php_wordpress.sock`, `tcp:127.0.0.1:9000`, `tcp:[::1]:9000` | Only local Unix sockets or TCP loopback **IP**, not hostnames. Socket ownership and permissions must allow RustProxy access. | +| PHP extensions (`extensions`) | `php, phtml` | Comma-separated, no leading dots; at most 16. Only listed extensions are executable. | +| Enable PHP PATH_INFO splitting (`path_info`) | Checked for Roundcube `/static.php/skins/...` | Splits an existing PHP script path and its trailing URL; off for ordinary WordPress unless needed. | +| Forward Authorization (`pass_authorization`) | Checked when API / Basic / Bearer auth is used | Sends request `Authorization` to `HTTP_AUTHORIZATION` in FastCGI. | +| Connection timeout (`connect_timeout_ms`) | `1000` = 1 second | Integer 50–60000 milliseconds. | +| Request timeout (`request_timeout_ms`) | `30000` = 30 seconds | Integer 100–3600000 milliseconds for the full FastCGI request. | +| Max simultaneous PHP requests (`max_concurrent_requests`) | `64` | Integer 1–4096 per Web Service; excess is rejected with HTTP 503. | +| Request-body limit (`max_request_body_bytes`) | `67108864` = 64 MiB; `0` = no additional limit | Integer 0–8589934592 **bytes**, independent of PHP `post_max_size`. | +| Response-header limit (`max_response_header_bytes`) | `65536` = 64 KiB | Integer 4096–1048576 bytes. | +| PHP-FPM STDERR log limit (`stderr_log_bytes`) | `4096`; `0` = disabled | Integer 0–65536 bytes collected per request. | +| Custom FastCGI parameters (`custom_params`) | `APP_ENV = production` | One static `NAME = value` per line. Names A-Z/0-9/underscore, beginning with a letter. No core CGI, HTTP_* or PHP control params. | +| PHP INI overrides (`php_values` / `PHP_VALUE`) | `memory_limit = 256M` | One php.ini `directive = value` per line. PHP decides which directives can change at this scope. | +| Administrator PHP INI overrides (`php_admin_values` / `PHP_ADMIN_VALUE`) | `open_basedir = /var/www/webmail/:/tmp/` | Trusted operator only; for security isolation prefer dedicated PHP-FPM pool configuration. Do not define the same directive in both INI maps. | + +**Syntax in the three multiline fields is not JSON:** use `key = value`, a separate line per entry; **do not** wrap it in `PHP_VALUE=...` or `php_admin_value[...]` inside the RustProxy editor. Those latter directives belong to the *native PHP-FPM pool configuration*, not the RustProxy FastCGI request fields. Empty values and control characters are rejected. Up to 64 entries per map, maximum 8192 bytes per value and 128 KiB combined. + +### Ready-to-use WordPress and Roundcube examples + +**WordPress — Web Service `/var/www/linuxiarz.pl/`, dedicated FPM socket:** + +```text +PHP enabled: checked +PHP PATH_INFO: unchecked +Endpoint: unix:/run/php/php_wordpress.sock +PHP extensions: php +PHP_VALUE: + memory_limit = 256M + upload_max_filesize = 64M + post_max_size = 64M + max_execution_time = 60 +PHP_ADMIN_VALUE: leave empty (or set security restrictions in WordPress's own FPM pool) +``` + +Use `index.php` in **Index files**, plus `try_files` lines `$uri`, `$uri/`, `/index.php?$query_string` in **Routing**. The WordPress pool must be allowed to read its `wp-config.php`, `wp-includes` and `wp-content` directories. + +**Roundcube — Web Service `/var/www/webmail/public_html`, separate FPM socket:** + +```text +PHP enabled: checked +PHP PATH_INFO: checked +Endpoint: unix:/run/php/php_roundcube.sock +PHP extensions: php +PHP_VALUE: + memory_limit = 256M + upload_max_filesize = 64M + post_max_size = 64M +PHP_ADMIN_VALUE: prefer the native Roundcube PHP-FPM pool instead; e.g.: + open_basedir = /var/www/webmail/:/usr/lib/php/:/tmp/:/etc/enigma +``` + +Add additional permitted external paths only if Roundcube's actual dependencies need them. Roundcube assets such as `/static.php/skins/elastic/...` require PATH_INFO splitting. Restart/recycle workers after separating pools; using a different RustProxy profile but the **same socket** does not isolate the worker state. See [pool isolation](PHP_FPM_ISOLATION.md). + +### FastCGI vs php.ini examples + +```text +# Custom FastCGI parameters — application-specific static environment: +APP_ENV = production +APP_DEBUG = 0 + +# PHP_VALUE — regular PHP-level options: +memory_limit = 256M +max_execution_time = 60 +error_reporting = E_ALL +log_errors = On +display_errors = Off +date.timezone = Europe/Warsaw + +# PHP_ADMIN_VALUE — privileged operator-level options: +open_basedir = /var/www/webmail/:/usr/lib/php/:/tmp/ +``` + +The `#` comments above are explanations separating fields; **do not paste them** into the corresponding form inputs. Boolean INI directives commonly accept `On`/`Off`, size directives can accept `K`/`M`/`G` and timeouts use seconds. The actual PHP/FPM version determines supported directives and permitted scopes. + ## Runtime model RustProxy implements the FastCGI protocol directly. It does not spawn `php`, `php-cgi` or shell processes for individual requests. diff --git a/docs/PHP_FPM_ISOLATION.md b/docs/PHP_FPM_ISOLATION.md new file mode 100644 index 0000000..331a9ee --- /dev/null +++ b/docs/PHP_FPM_ISOLATION.md @@ -0,0 +1,31 @@ +# Dedicated PHP-FPM pools for distinct Web Services + +PHP-FPM may retain request-supplied `PHP_VALUE` and `PHP_ADMIN_VALUE` directives in long-lived workers. When two applications use one PHP-FPM socket but different INI overrides, a setting such as `open_basedir` can be inherited by the next request, even when RustProxy routes the request to the correct Web Service. **RustProxy cannot provide process-level isolation inside a shared external PHP-FPM worker.** + +## Affected operator configuration (0.29.6) + +- WordPress Web Service `blog-linuxiarz-pl` uses document root `/var/www/linuxiarz.pl/` and endpoint `unix:/run/php/php_rustproxy.sock`, without an `open_basedir` override. +- Roundcube Web Service `webmail-linuxiarz-pl` uses document root `/var/www/webmail/public_html` and the **same** endpoint. Its FastCGI `PHP_ADMIN_VALUE` includes `open_basedir=/var/www/webmail/:/usr/lib/php/:/tmp/:/etc/enigma:...`. +- WordPress PHP-FPM stderr confirms the Roundcube `open_basedir` at the WordPress file check. As a result, `wp-config.php` is not visible and WordPress redirects to `/wp-admin/setup-config.php` before triggering fatal errors. This is **not** a cache MISS defect. + +## Required operator remediation + +1. Back up the current FPM configuration. Provision separate, access-restricted PHP-FPM pools under the PHP-FPM service manager used by your system (NixOS configuration when applicable). Use distinct Unix sockets, for example `/run/php/php_wordpress.sock` and `/run/php/php_roundcube.sock`. Ensure RustProxy's runtime user may connect to both. Do **not** expose raw FastCGI publicly. +2. Configure pool-level `php_admin_value[open_basedir]` for **Roundcube only**, with exactly the filesystem paths it requires, or keep its validated override in RustProxy on a dedicated pool. Keep WordPress in its own pool with appropriate policy. Pool-level directives are preferable to request-supplied administrator directives for security-sensitive settings. +3. In **Web Server > Web Services** set `blog-linuxiarz-pl > PHP-FPM endpoint` to `unix:/run/php/php_wordpress.sock` and `webmail-linuxiarz-pl` to `unix:/run/php/php_roundcube.sock`. Shared PHP-FPM profiles must also refer to the corresponding correct endpoint. +4. Apply PHP-FPM configuration and **restart** the affected PHP-FPM service/pools to retire workers with leaked directives. Save the RustProxy Web Service changes. Check that both web apps respond and that private files remain blocked. +5. Repeat requests in alternating order from both vhosts and inspect PHP-FPM warnings. Suggested tests: `curl -I https://blog.linuxiarz.pl/`, `curl -I https://blog.linuxiarz.pl/wp-login.php`, `curl -I https://webmail.linuxiarz.pl/`. The WordPress home page must no longer redirect to `/wp-admin/setup-config.php` due to `open_basedir`. + +If WordPress still redirects to setup-config.php with a **dedicated** pool, check whether `wp-config.php` really exists and is readable by the pool user, the configured pool's own php_admin_value restrictions, and database credentials. Do not fix the issue by using unrestricted `open_basedir=/` globally or by disabling protections on Roundcube. + +## New 0.29.7 protection + +During configuration compilation, RustProxy emits a warning identifying pairs of Web Services sharing an endpoint with unequal `PHP_VALUE`/`PHP_ADMIN_VALUE` maps. For backward compatibility, the existing configuration remains loadable; the warning **does not** automatically rewrite operator FPM configuration or claim that sharing becomes safe. + +## Per-backend health + +For an internal 404 response origin, leave `pool_eligible: false`, set `health_check_enabled: false`, and continue routing a named `internal` location to its `backend_id`. If a backend does require separate probes, use an optional per-member `health_check` object (same shape as vhost `load_balancing.health_check`). + +## HIT-only cache diagnostic headers + +Enable global `cache.expose_only_hits: true`, optionally override each vhost at `vhosts[].cache.expose_only_hits`. HIT-only mode filters RustProxy diagnostic cache headers for MISS/BYPASS without altering internal hit/miss metrics or upstream cache eligibility. `Via` is a separate proxy-header option and can be disabled using `cache.via_header: null`. diff --git a/docs/README.md b/docs/README.md index fef851f..da8a04f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,6 +37,7 @@ This is the canonical index for the current project documentation. The authentic - [WEB_SERVER](WEB_SERVER.md) - reusable Web Services, static-file behavior, ranges/validators and document-root security. - [PHP_FPM](PHP_FPM.md) - implemented FastCGI/PHP-FPM integration, security model and runtime requirements. +- [PHP_FPM_ISOLATION](PHP_FPM_ISOLATION.md) - separate PHP-FPM pools to prevent cross-vhost INI settings leakage. - [VHOSTS](VHOSTS.md) - vhosts and backend/source behavior. - [CUSTOM_LOCATIONS](CUSTOM_LOCATIONS.md) - location matching, rewrites and local responses. - [VHOST_ACCESS](VHOST_ACCESS.md) - access rules applied to vhosts and locations. diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index fbdd705..85ed5ec 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -1,4 +1,39 @@ -# Verification report - RustProxy 0.29.6 +# Verification report - RustProxy 0.29.7 + +## Same-version 0.29.7 PHP profile UI, Rust warning and error interception review (2026-10-09) + +- Move the shared PHP-FPM **Syntax and safety** help button to the bottom of the profile-management dialog, below **Add profile**, with a visible horizontal separator. Keep the service-local help button where it was. The simulated Web Service UI test asserts this placement and that the help modal still opens. +- Remove the unused `mut` in the `web_server::serve` binding (`src/proxy/forward/web_source.rs`). +- Correct application-error interception semantics in both Reverse Proxy and Web Server/PHP-FPM: responses supplied by a backend/service (including cache hits) are explicitly marked and are not overwritten by global/vhost error pages when `pages.intercept_statuses` is disabled. RustProxy-generated routing/upstream failures remain eligible for the configured local error page. Interception is performed before cache admission; selected statuses receive `no-store` even after response-header rules and always retain the application's HTTP status, ignoring a standalone error page's status override. An internal named-location fallback remains terminal and has precedence. +- Rust regression tests cover the opt-in behavior, the marker's effect in the response observer, precedence and preservation of status with a differently configured page status. Source/UI contracts verify both forwarding paths and correct ordering around HTTP/2 WebSocket body adaptation; these are **not** substitutes for live protocol tests. +- `./dev.sh test`: all Rust-independent source, JS, SQL/upgrade, security, documentation and translation checks passed; then exited **127** due to unavailable `cargo`/`rustc`. Rust compilation, unit tests and runtime HTTP/FastCGI/WebSocket acceptance have **not** been performed here. No configuration schema change or version bump. + +## 0.29.7 same-version compile error corrections (2026-10-09) + +- Correct the three operator-reported `E0308` mismatches. Reverse Proxy origin interception now receives an `axum::body::Body` response only **after** adapting the upstream `Response`, preserving the original `Incoming` body expected by the HTTP/2 WebSocket tunnel wrapper. The operation remains before transformation and cache admission. +- Reapply configured response-header rules to an intercepted page, then restore `Cache-Control: no-store` so the admin page cannot accidentally enter shared cache through a custom header policy. Internal named error fallbacks retain precedence. +- Build owned `Arc` candidates for the active health-check supervisor and clone the `Arc` into each `JoinSet::spawn` task, rather than attempting to spawn a future carrying `&Arc`. +- Static/UI/source/SQL/upgrade/security/documentation checks passed via `./dev.sh test` up to the Rust guard (exit 127). `./dev.sh check` exited 127 before compilation due to missing Rust/Cargo. **These are source-level corrections, not a verified Rust build.** Operator should run `./dev.sh check` and `./dev.sh test` on a Cargo-equipped host before deployment. + +## 0.29.7 in-version PHP UX and origin error interception (2026-10-09) + +- Both Web Service local PHP fields and shared PHP-FPM profiles now expose per-field examples, units, bounded syntax, PATH_INFO/Authorization/PHP enablement info tooltips and a common detailed help dialog; all localized through language packs. Configuration semantics remain unchanged. +- New optional `vhosts[].pages.intercept_statuses`: supported unique HTTP error codes are validated; selected application-origin responses are replaced by vhost/global/built-in RustProxy pages before admission to cache, preserve the HTTP status by default, and carry `Cache-Control: no-store`. The existing internal error fallback runs first. Empty/default list leaves existing origin error responses intact. +- Added Rust source-level regression tests for default preservation, replacement, headers, precedence and malformed codes plus JS form behavior checks. `./dev.sh test` ran all pre-Cargo contracts (including Web Service PHP profile editing, origin-interception roundtrip, offline migration and security checks) successfully but exited **127** at the missing `cargo` guard. `./dev.sh check` also exited **127** for that reason. Rust compilation, Rust unit execution, and live Web Server/FastCGI/backend routing remain **unverified**. + +## 0.29.7 backend/cache/FPM policy review (2026-10-09) + +- Backend monitoring now has an explicit per-member switch and optional policy; tests cover backwards-compatible defaults, serialization, inherited Host and validation. Membership in the balanced pool remains a separate setting. +- `cache.expose_only_hits` defaults to false globally and may be overridden at a vhost. Tests cover HIT/MISS/BYPASS suppression, origin diagnostic removal, independently configured Via and cache identity stability. Actual wire behavior requires Cargo/runtime tests. +- **PHP limitation:** user-supplied WordPress and Roundcube services both target `unix:/run/php/php_rustproxy.sock`. Only Roundcube passes a restrictive FastCGI `PHP_ADMIN_VALUE: open_basedir`; PHP-FPM may retain this value across worker reuse, causing WordPress to fail. The 302 to setup-config.php is a consequence of missing access to wp-config.php. Added conflict warning, inline guidance and pool-isolation instructions; code changes cannot alter an already-shared PHP-FPM worker's isolation. The operator must provision and select a dedicated Roundcube FPM pool and restart/recycle affected old workers. +- Source changes keep SQLite schema 9/Cold Cache format 3. Compiler-independent tests and verification are reported below; Rust compilation/live HTTP/2/H3/PHP integration is not asserted in this environment. + +### 0.29.7 verification summary + +- `./dev.sh test`: all Rust-independent contracts, SQLite migration/backup/source-upgrade checks, JS vhost/Web Server regressions, source security/dependency audits, docs and translation checks passed. It exits with code 127 at the Rust/Cargo availability guard, because `rustc` and `cargo` are not installed. This is **not** a successful Rust test-suite run. +- `node tools/checks/backend_cache_0297_ui_test.mjs`: passed; simulates the actual backend form toggles and verifies independent monitoring/pool-eligibility state, per-member health override and cache controls. +- Rust-only regression tests were added but could not execute: effective active/passive member policies and validation, HTTP Host defaults, HIT-only cache headers, per-vhost policy resolution, and cache compatibility identity. +- Operator acceptance remains open: Rust compilation, real PHP-FPM worker isolation on separate sockets, alternating WordPress/Roundcube requests, live 404 fallback, and actual wire-level cache HIT/MISS/BYPASS checks on HTTP/1.1, H2 and H3. ## 0.29.6 FastCGI EOF and error-log routing (2026-10-09) @@ -112,9 +147,9 @@ This source package is not certified for production deployment. Installer/update now installs /etc/logrotate.d/rustproxy using the recorded service user/group. Updates repair /etc/profile.d/rustproxy-path.sh rather than warning. logrotate uses copytruncate because RustProxy holds open log descriptors; some log records may be lost during truncate. Rust build and live Debian/systemd acceptance remain unverified in this environment. -## Version identity (0.29.6 source release) +## Version identity (0.29.7 source release) -`Cargo.toml` package version is the canonical release identity (now 0.29.6). Rust uses `CARGO_PKG_VERSION` at build time; `build.rs` substitutes the admin HTML placeholder. Packaging, upgrade and dev scripts read the manifest with `scripts/version.py`. Run `python3 tools/checks/version_contracts.py` to check that executable paths do not hardcode a version. Historical documentation and external dashboards may retain manually managed versions. Rust compilation/runtime verification is still pending. +`Cargo.toml` package version is the canonical release identity (now 0.29.7). Rust uses `CARGO_PKG_VERSION` at build time; `build.rs` substitutes the admin HTML placeholder. Packaging, upgrade and dev scripts read the manifest with `scripts/version.py`. Run `python3 tools/checks/version_contracts.py` to check that executable paths do not hardcode a version. Historical documentation and external dashboards may retain manually managed versions. Rust compilation/runtime verification is still pending. ## Shared PHP-FPM profiles and Web Server cache/transform/Host patch (0.29.4) diff --git a/docs/VHOSTS.md b/docs/VHOSTS.md index bb624b3..d2394ae 100644 --- a/docs/VHOSTS.md +++ b/docs/VHOSTS.md @@ -44,6 +44,23 @@ The main vhost `source` may define `error_fallback`, and each Custom location ma Configured upstream/Web Server failures are intercepted before final response/cache publication. The original request method, URI/query and context are internally dispatched to the target, which then follows normal vhost/location processing. Validation rejects fallback cycles and runtime dispatch is depth-limited. See [V0.29.1_IMPLEMENTATION.md](V0.29.1_IMPLEMENTATION.md) for body replay bounds and an Nginx-style static -> app -> redirector example. +## Per-backend health monitoring (0.29.7) + +Each backend has an independent `health_check_enabled` switch (default `true`). +Setting it to `false` excludes that member from both active health probes and +passive health ejection, **without** disabling the member for a named location. +This is useful for an internal 404 handler configured with `pool_eligible: false` +and a location pinned through `backend_id`: the 404 handler never receives +ordinary balanced traffic and does not need health monitoring. + +A backend may also define `health_check` with the same fields as +`load_balancing.health_check` to use its own TCP/HTTP/off probe policy, timing, +path, Host and status ranges. Omitting it inherits the vhost policy. A custom +HTTP health check without `host` inherits the vhost Host default even when the +vhost's main health check is TCP. When monitoring is disabled, the backend is +assumed available; connection errors during real requests are still counted and +logged, so monitoring off is not a guarantee of backend reachability. + ## Frontend selection and private IP isolation `frontend.hosts` is a normalized hostname-pattern list. Exact hostnames and a single diff --git a/docs/WEB_SERVER.md b/docs/WEB_SERVER.md index e70cb26..702f9c4 100644 --- a/docs/WEB_SERVER.md +++ b/docs/WEB_SERVER.md @@ -35,6 +35,44 @@ The Web Service editor is split into stable configuration tabs so later features After the Web Service is saved, attach it under **Vhosts** by selecting `Web Server` and the service ID for the main source or a custom location. +### Intercept application HTTP errors (0.29.7) + +Open **Vhosts → Edit vhost → Pages** (not the Web Service PHP tab), enable +**Intercept application error responses**, and enter statuses such as `404, 500, 503`. +This opt-in vhost policy applies to response bodies returned by both **Reverse +Proxy origins** and **Web Server/PHP-FPM origins**. It is configured in +`vhosts[].pages.intercept_statuses`; an omitted/empty list disables it and +preserves the application error HTML. Supported codes: 400, 401, 403, 404, +408, 413, 429, 500, 502, 503, 504. + +For each opted-in origin status, RustProxy replaces the *body* using the +vhost's configured error page, then the global error page, then its built-in +error page. The HTTP response status always remains the application's status, +even if the selected page has a custom `status` for standalone error handling. +`Cache-Control: no-store` is set and origin response cookies, compression +metadata and application error HTML are not forwarded. Relevant protocol +headers such as `WWW-Authenticate`, `Allow` and `Retry-After` are retained. +Local RustProxy security/routing failures are not treated as application +responses. If `error_fallback` sends an error to a named internal location, +that response takes precedence and is not overwritten by this option. + +```json +{ + "id": "my-vhost", + "pages": { + "intercept_statuses": [404, 500, 503], + "errors": { + "404": {"kind": "html", "status": null, "html": "

Not found

"} + } + } +} +``` + +This is a **vhost fragment**, not a standalone valid vhost object. A page's +`status` override still applies to locally generated RustProxy error pages, +but is ignored when replacing an application's error body. See also +[Custom locations](CUSTOM_LOCATIONS.md). + ### Application presets The editor provides one-shot presets for **Static site, SPA, PHP generic, WordPress, Drupal, Laravel, Symfony, Joomla, PrestaShop and Downloads/file repository**. Applying a preset writes normal Web Service fields; it does not store a hidden CMS mode and every generated field remains editable. Service ID/name, document root and the deployment-specific PHP-FPM endpoint/tuning are preserved. Laravel/Symfony operators should point `document_root` at the application's `public/` directory. diff --git a/langs/en_US.json b/langs/en_US.json index b9dcfeb..d99e64e 100644 --- a/langs/en_US.json +++ b/langs/en_US.json @@ -1839,21 +1839,21 @@ "webServicePhpHeaderLimit": "Max FastCGI response header bytes", "webServicePhpStderrLimit": "Max PHP-FPM stderr log bytes", "webServicePhpCustomParams": "Custom FastCGI parameters", - "webServicePhpCustomParamsHelp": "One NAME = value entry per line. Static trusted values only; core CGI, HTTP_* and PHP_* control names are reserved.", + "webServicePhpCustomParamsHelp": "One NAME = value per line, e.g. APP_ENV = production. Names: A-Z, digits, underscores; no HTTP_*, PHP_VALUE, PHP_ADMIN_VALUE or core CGI parameters.", "webServicePhpCustomParamsInvalid": "Custom FastCGI parameters must use one unique NAME = value entry per line.", "webServicePhpCustomDataTooLarge": "Custom FastCGI parameters and PHP INI overrides may use at most 128 KiB combined.", "webServicePhpValues": "PHP INI overrides (PHP_VALUE)", - "webServicePhpValuesHelp": "One php.ini directive = value per line. RustProxy sends the validated set as PHP_VALUE; PHP_ADMIN_VALUE is never accepted here.", + "webServicePhpValuesHelp": "One directive = value per line, e.g. memory_limit = 256M, upload_max_filesize = 64M, post_max_size = 64M; PHP_FPM decides which can be changed.", "webServicePhpValuesInvalid": "PHP INI overrides must use one unique directive = value entry per line.", "webServicePhpDocsButton": "FastCGI syntax and safety", "webServicePhpDocsTitle": "PHP-FPM / FastCGI configuration", "webServicePhpDocsIntro": "RustProxy connects directly to an existing PHP-FPM pool. Prefer a Unix socket on the same host. TCP is accepted only on loopback and is not a public/network FastCGI transport.", "webServicePhpDocsEndpointTitle": "Endpoint syntax", - "webServicePhpDocsEndpoint": "For TCP, include the tcp: prefix, a literal loopback IP and port. Hostnames and non-loopback addresses are rejected. For Unix sockets, RustProxy must have permission to connect to the socket.", + "webServicePhpDocsEndpoint": "Use unix: plus full socket path, or tcp: plus a literal loopback IP and port. TCP hostnames/public addresses are rejected. Use distinct FPM sockets for applications with different PHP INI restrictions.", "webServicePhpDocsCustomTitle": "Custom FastCGI parameters", - "webServicePhpDocsCustom": "Use one NAME = value entry per line. Names are normalized to uppercase and may contain A-Z, 0-9 and underscore. These are static trusted operator values. RustProxy rejects core CGI names, HTTP_* names, PHP_VALUE and PHP_ADMIN_VALUE so they cannot override request routing, script selection or HTTP-derived parameters.", + "webServicePhpDocsCustom": "One NAME = value per line (NAME starts with a letter and contains A-Z, digits or underscore). Static values only. SCRIPT_FILENAME, HTTP_* and PHP_VALUE/PHP_ADMIN_VALUE are reserved.", "webServicePhpDocsValuesTitle": "PHP INI overrides", - "webServicePhpDocsValues": "These lines are combined into the FastCGI PHP_VALUE parameter. PHP-FPM/PHP still decides which directives are changeable at this scope. Newlines/control characters are rejected and PHP_ADMIN_VALUE is intentionally unavailable.", + "webServicePhpDocsValues": "These lines go into PHP_VALUE. Use the separate PHP_ADMIN_VALUE field for trusted administrative settings; neither one isolates workers sharing the same PHP-FPM pool.", "webServicePhpDocsLimitsTitle": "Limits and headers", "webServicePhpDocsLimits": "Request timeout covers the FastCGI exchange. The body limit is enforced while streaming and returns 413 when exceeded; 0 disables the RustProxy-side body limit. Response-header and stderr limits bound memory/diagnostic data. Up to 64 custom FastCGI parameters and 64 PHP INI overrides are accepted, with at most 128 KiB of combined custom data. Authorization forwarding can be disabled for applications that do not need HTTP_AUTHORIZATION.", "webServicePhpDocsSecurity": "Do not put secrets in custom parameters or PHP_VALUE unless you accept storing them in normal Web Service configuration. Use a dedicated non-root PHP-FPM pool identity and keep PHP-FPM off public networks.", @@ -2253,6 +2253,35 @@ "phpProfileDelete": "Delete this PHP-FPM profile?", "phpProfileInvalid": "Invalid PHP-FPM profile fields", "phpProfileDuplicate": "Duplicate PHP-FPM profile ID", - "phpProfileShared": "shared profile" + "phpProfileShared": "shared profile", + "backendHealthEnabled": "Monitor backend health (active and passive)", + "backendHealthOverride": "Custom health check for this backend", + "backendHealthOverridesHelp": "When unchecked, inherit the vhost health policy. Disabling monitoring keeps the backend available for explicitly pinned locations, without active probes or passive ejection. Use a custom check for an individual backend.", + "cacheOnlyHits": "Expose cache diagnostics only on HIT (hide MISS and BYPASS)", + "phpHintEnabled": "Run configured PHP file extensions through an already-running PHP-FPM pool. RustProxy does not start PHP; non-PHP files remain static.", + "phpHintPathInfo": "Split /static.php/skins/file.css into SCRIPT_FILENAME=static.php and PATH_INFO=/skins/file.css. Useful for Roundcube; usually unnecessary for WordPress. Off by default.", + "phpHintPassAuthorization": "Pass the incoming Authorization header as HTTP_AUTHORIZATION to PHP (API/Bearer/Basic authentication). Disable if not needed.", + "phpHintExtensions": "Comma-separated extensions without leading dots: php, phtml. Only these extensions run as PHP.", + "phpHintConnectTimeout": "PHP-FPM socket connection timeout in milliseconds: 1000 = 1 second. Range: 50–60000.", + "phpHintRequestTimeout": "Timeout for the complete FastCGI request in milliseconds: 30000 = 30 seconds. Range: 100–3600000.", + "phpHintConcurrency": "Maximum concurrent PHP requests per service: e.g. 64. Exhaustion returns 503. Range: 1–4096.", + "phpHintBodyLimit": "Request-body limit in bytes: 67108864 = 64 MiB. 0 means no RustProxy-side limit. PHP post_max_size is separate.", + "phpHintHeaderLimit": "Maximum FastCGI response header length in bytes: 65536 = 64 KiB. Range: 4096–1048576.", + "phpHintStderrLimit": "Maximum PHP-FPM STDERR bytes logged per request: e.g. 4096. 0 disables stderr capture (max 65536).", + "phpHintAdminValues": "Trusted administrative INI settings, e.g. open_basedir = /var/www/webmail/:/tmp/. Use a dedicated PHP-FPM pool/socket to prevent cross-vhost INI leakage.", + "phpDocsScope": "These fields and examples apply to local PHP settings in a Web Service and to a reusable PHP-FPM profile. The chosen profile supplies PHP settings to all attached services; RustProxy connects to an already-running FPM pool.", + "phpDocsSwitchesTitle": "Enable PHP and handle PATH_INFO", + "phpDocsLimitsTitle": "Execution and size limits", + "phpDocsLimitsText": "PHP extensions: php, phtml (comma-separated). Connect timeout: 1000 ms = 1 second. Full request timeout: 30000 ms = 30 seconds. Maximum concurrent requests: 64 (exhaustion returns 503). Request body limit: 67108864 bytes = 64 MiB; 0 is unlimited in RustProxy. Response headers: 65536 bytes. STDERR capture: 4096 bytes; 0 disables it. Enter whole numbers; PHP INI limits are configured separately.", + "phpDocsValuesText": "Enter one directive = value per line in PHP INI overrides (PHP_VALUE), NOT in custom FastCGI parameters. Use php.ini syntax without [section] headers or PHP tags. Units such as M/G, booleans On/Off and timeouts in seconds are interpreted by PHP. Some PHP directives cannot be changed via PHP_VALUE. Never duplicate a key in PHP_ADMIN_VALUE.", + "phpDocsAdminTitle": "PHP_ADMIN_VALUE — privileged INI overrides", + "phpDocsAdminText": "Enter these in PHP administrator INI overrides (PHP_ADMIN_VALUE). On Unix, open_basedir is a colon-separated list of application-specific allowed paths, e.g. /var/www/webmail/:/tmp/. Prefer php_admin_value[...] defined in a dedicated PHP-FPM pool for sensitive directives. Do not apply Roundcube restrictions to WordPress workers.", + "phpDocsOtherTitle": "Validation, storage and troubleshooting", + "phpDocsOtherText": "INI names may contain letters, digits, dot, underscore and hyphen (max 64 bytes). Values must be nonempty (max 8192 bytes). At most 64 custom FastCGI parameters and 64 INI entries per map; combined data at most 128 KiB. Newlines/control characters inside values are rejected. All values are saved in standard configuration; avoid secrets. A syntactically valid option can still be rejected or ignored by PHP-FPM.", + "phpDocsPoolWarning": "Important: PHP_VALUE/PHP_ADMIN_VALUE do NOT isolate vhosts sharing PHP-FPM workers. WordPress and Roundcube must use distinct pool sockets if their INI policies differ. Restart old PHP-FPM workers after separating them. PATH_INFO is not a workaround for open_basedir.", + "vhostInterceptOriginErrors": "Replace application error bodies with RustProxy error pages", + "vhostInterceptOriginStatuses": "Intercept HTTP statuses (comma-separated)", + "vhostInterceptOriginErrorsHelp": "Example: 404, 500, 502, 503, 504. For selected codes, RustProxy keeps the HTTP error status and returns the vhost/global custom error page, or a built-in one. Application content/cookies are removed; the replacement is no-store. Off by default. Internal named-location fallbacks take precedence. Applies to reverse proxy and Web Server (including PHP).", + "vhostInterceptOriginInvalid": "Select one or more unique supported HTTP codes: 400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504." } } diff --git a/langs/pl_pl.json b/langs/pl_pl.json index d2f18d6..5fc63fc 100644 --- a/langs/pl_pl.json +++ b/langs/pl_pl.json @@ -1839,21 +1839,21 @@ "webServicePhpHeaderLimit": "Maks. bajtów nagłówków odpowiedzi FastCGI", "webServicePhpStderrLimit": "Maks. bajtów stderr PHP-FPM w logu", "webServicePhpCustomParams": "Własne parametry FastCGI", - "webServicePhpCustomParamsHelp": "Jeden wpis NAZWA = wartość na linię. Tylko statyczne, zaufane wartości; podstawowe CGI, HTTP_* i kontrolne PHP_* są zarezerwowane.", + "webServicePhpCustomParamsHelp": "Jedna linia NAME = wartość, np. APP_ENV = production. Nazwy: A-Z, cyfry i podkreślenia; bez HTTP_*, PHP_VALUE, PHP_ADMIN_VALUE i podstawowych parametrów CGI.", "webServicePhpCustomParamsInvalid": "Własne parametry FastCGI muszą mieć unikalny format NAZWA = wartość, po jednym wpisie na linię.", "webServicePhpCustomDataTooLarge": "Własne parametry FastCGI i nadpisania PHP INI mogą łącznie zajmować maksymalnie 128 KiB.", "webServicePhpValues": "Nadpisania PHP INI (PHP_VALUE)", - "webServicePhpValuesHelp": "Jedna dyrektywa php.ini = wartość na linię. RustProxy wysyła zwalidowany zestaw jako PHP_VALUE; PHP_ADMIN_VALUE nie jest tutaj dozwolone.", + "webServicePhpValuesHelp": "Jedna dyrektywa = wartość w wierszu, np. memory_limit = 256M, upload_max_filesize = 64M, post_max_size = 64M. O możliwości zmiany decyduje PHP-FPM.", "webServicePhpValuesInvalid": "Nadpisania PHP INI muszą mieć unikalny format dyrektywa = wartość, po jednym wpisie na linię.", "webServicePhpDocsButton": "Składnia i bezpieczeństwo FastCGI", "webServicePhpDocsTitle": "Konfiguracja PHP-FPM / FastCGI", "webServicePhpDocsIntro": "RustProxy łączy się bezpośrednio z istniejącą pulą PHP-FPM. Na tym samym hoście preferuj socket Unix. TCP jest akceptowane wyłącznie na loopback i nie jest publicznym/sieciowym transportem FastCGI.", "webServicePhpDocsEndpointTitle": "Składnia endpointu", - "webServicePhpDocsEndpoint": "Dla TCP podaj prefiks tcp:, literalny adres IP loopback oraz port. Nazwy hostów i adresy inne niż loopback są odrzucane. Dla socketu Unix proces RustProxy musi mieć prawo połączenia z socketem.", + "webServicePhpDocsEndpoint": "Użyj unix: z pełną ścieżką gniazda albo tcp: z adresem IP loopback i portem. Nazwy DNS i publiczne adresy TCP są zabronione. Oddziel pule PHP-FPM aplikacji z różnymi ograniczeniami INI.", "webServicePhpDocsCustomTitle": "Własne parametry FastCGI", - "webServicePhpDocsCustom": "Użyj jednego wpisu NAZWA = wartość na linię. Nazwy są normalizowane do wielkich liter i mogą zawierać A-Z, 0-9 oraz podkreślenie. Są to statyczne, zaufane wartości operatora. RustProxy odrzuca podstawowe nazwy CGI, HTTP_*, PHP_VALUE i PHP_ADMIN_VALUE, więc nie można nimi nadpisać routingu requestu, wyboru skryptu ani parametrów pochodzących z HTTP.", + "webServicePhpDocsCustom": "Jedna linia NAME = wartość (nazwa zaczyna się literą i zawiera A-Z, cyfry lub podkreślenie). Wartości są statyczne. SCRIPT_FILENAME, HTTP_* i PHP_VALUE/PHP_ADMIN_VALUE są zastrzeżone.", "webServicePhpDocsValuesTitle": "Nadpisania PHP INI", - "webServicePhpDocsValues": "Te linie są łączone w parametr FastCGI PHP_VALUE. PHP-FPM/PHP nadal decyduje, które dyrektywy można zmieniać w tym zakresie. Znaki nowej linii/kontrolne są odrzucane, a PHP_ADMIN_VALUE jest celowo niedostępne.", + "webServicePhpDocsValues": "Każda linia trafia do PHP_VALUE. Zaufane ustawienia administracyjne wpisuj osobno w PHP_ADMIN_VALUE; żadne z tych pól nie izoluje procesów korzystających z tej samej puli PHP-FPM.", "webServicePhpDocsLimitsTitle": "Limity i nagłówki", "webServicePhpDocsLimits": "Timeout żądania obejmuje wymianę FastCGI. Limit body jest egzekwowany podczas streamingu i po przekroczeniu zwraca 413; 0 wyłącza limit body po stronie RustProxy. Limity nagłówków odpowiedzi i stderr ograniczają pamięć/dane diagnostyczne. Dozwolone są maks. 64 własne parametry FastCGI i 64 nadpisania PHP INI, łącznie do 128 KiB danych własnych. Przekazywanie Authorization można wyłączyć, gdy aplikacja nie potrzebuje HTTP_AUTHORIZATION.", "webServicePhpDocsSecurity": "Nie umieszczaj sekretów we własnych parametrach ani PHP_VALUE, jeśli nie akceptujesz ich przechowywania w zwykłej konfiguracji Web Service. Używaj osobnej, nieuprzywilejowanej tożsamości puli PHP-FPM i nie wystawiaj PHP-FPM do publicznej sieci.", @@ -2253,6 +2253,35 @@ "phpProfileDelete": "Usunąć profil PHP-FPM?", "phpProfileInvalid": "Nieprawidłowe pola profilu PHP-FPM", "phpProfileDuplicate": "ID profilu PHP-FPM już istnieje", - "phpProfileShared": "profil wspólny" + "phpProfileShared": "profil wspólny", + "backendHealthEnabled": "Monitoruj stan backendu (aktywnie i pasywnie)", + "backendHealthOverride": "Osobny health check dla tego backendu", + "backendHealthOverridesHelp": "Bez nadpisania backend dziedziczy ustawienia vhosta. Wylaczenie monitorowania nie blokuje lokalizacji przypisanych do backendu, ale wylacza probe aktywna i pasywne wykluczanie.", + "cacheOnlyHits": "Pokazuj naglowki diagnostyki cache tylko dla HIT (ukryj MISS i BYPASS)", + "phpHintEnabled": "Uruchamia pliki o wskazanych rozszerzeniach przez już działającą pulę PHP-FPM. RustProxy nie uruchamia PHP; inne pliki nadal są statyczne.", + "phpHintPathInfo": "Rozdziela /static.php/skins/file.css na SCRIPT_FILENAME=static.php i PATH_INFO=/skins/file.css. Potrzebne np. w Roundcube; zwykle zbędne w WordPressie. Domyślnie wyłączone.", + "phpHintPassAuthorization": "Przekazuje nagłówek Authorization do PHP jako HTTP_AUTHORIZATION (np. API/Bearer/Basic). Wyłącz, gdy aplikacja go nie używa.", + "phpHintExtensions": "Rozszerzenia oddzielone przecinkami, bez kropek: php, phtml. Tylko te rozszerzenia uruchamiają PHP.", + "phpHintConnectTimeout": "Limit łączenia z gniazdem PHP-FPM w milisekundach: 1000 = 1 sekunda. Zakres: 50–60000.", + "phpHintRequestTimeout": "Limit całego żądania FastCGI w milisekundach: 30000 = 30 sekund. Zakres: 100–3600000.", + "phpHintConcurrency": "Maksymalna liczba równoległych żądań PHP na usługę, np. 64. Przekroczenie zwraca 503. Zakres: 1–4096.", + "phpHintBodyLimit": "Limit treści żądania w bajtach: 67108864 = 64 MiB. 0 = bez limitu po stronie RustProxy. PHP post_max_size jest osobny.", + "phpHintHeaderLimit": "Maksymalny rozmiar nagłówków odpowiedzi FastCGI w bajtach: 65536 = 64 KiB. Zakres: 4096–1048576.", + "phpHintStderrLimit": "Maksymalna liczba bajtów STDERR PHP-FPM w logu na żądanie, np. 4096. 0 wyłącza przechwytywanie (maks. 65536).", + "phpHintAdminValues": "Administracyjne ustawienia INI, np. open_basedir = /var/www/webmail/:/tmp/. Aby zapobiec przeciekaniu ustawień między vhostami, stosuj osobne pule i gniazda PHP-FPM.", + "phpDocsScope": "Te same pola i przykłady obowiązują w lokalnych ustawieniach PHP usługi WWW i we współdzielonym profilu PHP-FPM. Wybrany profil dostarcza ustawienia wszystkim przypiętym usługom; RustProxy łączy się z uruchomioną pulą.", + "phpDocsSwitchesTitle": "Włączanie PHP i obsługa PATH_INFO", + "phpDocsLimitsTitle": "Limity wykonania i rozmiarów", + "phpDocsLimitsText": "Rozszerzenia PHP: php, phtml (oddzielone przecinkami). Timeout połączenia: 1000 ms = 1 s. Całe żądanie: 30000 ms = 30 s. Maksymalnie 64 równoległe żądania (po przekroczeniu 503). Limit żądania: 67108864 bajtów = 64 MiB; 0 oznacza brak limitu w RustProxy. Nagłówki odpowiedzi: 65536 bajtów. Log STDERR: 4096 bajtów; 0 wyłącza. Wprowadź liczby całkowite; limity PHP INI to osobne ustawienia.", + "phpDocsValuesText": "Wpisuj po jednej dyrektywie = wartość w wierszu w polu PHP INI overrides (PHP_VALUE), a NIE w parametrach FastCGI. Składnia php.ini bez sekcji [sekcja] i bez tagów PHP. Jednostki M/G, wartości On/Off i czasy w sekundach interpretuje PHP. Nie każdą dyrektywę wolno zmienić przez PHP_VALUE. Nie powielaj jej w PHP_ADMIN_VALUE.", + "phpDocsAdminTitle": "PHP_ADMIN_VALUE — administracyjne dyrektywy INI", + "phpDocsAdminText": "Wpisuj je w pole Administracyjne ustawienia PHP (PHP_ADMIN_VALUE). W systemie Unix open_basedir jest listą dozwolonych ścieżek oddzielonych dwukropkiem, np. /var/www/webmail/:/tmp/. Ważne ograniczenia lepiej ustawiać jako php_admin_value[...] w osobnej puli PHP-FPM. Nie stosuj ograniczeń Roundcube dla procesów WordPressa.", + "phpDocsOtherTitle": "Walidacja, przechowywanie i diagnostyka", + "phpDocsOtherText": "Nazwa dyrektywy INI może zawierać litery, cyfry, kropkę, podkreślenie i myślnik (maks. 64 bajty). Wartość nie może być pusta (maks. 8192 bajtów). Maksymalnie 64 parametry FastCGI i 64 wpisy INI w każdej mapie; łącznie maks. 128 KiB. Znaki sterujące i wielowierszowe wartości są odrzucane. Dane trafiają do zwykłej konfiguracji — nie zapisuj tam sekretów. PHP-FPM może odrzucić poprawną składniowo opcję.", + "phpDocsPoolWarning": "Ważne: PHP_VALUE/PHP_ADMIN_VALUE NIE izolują vhostów współdzielących procesy PHP-FPM. WordPress i Roundcube wymagają oddzielnych gniazd, jeśli mają różne ograniczenia INI. Po rozdzieleniu uruchom ponownie procesy PHP-FPM. PATH_INFO nie rozwiązuje problemu open_basedir.", + "vhostInterceptOriginErrors": "Zastępuj treść błędów aplikacji stronami błędów RustProxy", + "vhostInterceptOriginStatuses": "Przechwytywane kody HTTP (oddziel przecinkami)", + "vhostInterceptOriginErrorsHelp": "Przykład: 404, 500, 502, 503, 504. Dla wybranych kodów RustProxy zachowa kod błędu, ale wyświetli stronę skonfigurowaną dla vhosta/globalnie albo wbudowaną. Treść i ciasteczka aplikacji zostaną usunięte, a odpowiedź otrzyma no-store. Domyślnie wyłączone. Wewnętrzne lokalizacje błędów mają pierwszeństwo. Działa dla Reverse Proxy i Web Server (także PHP).", + "vhostInterceptOriginInvalid": "Podaj co najmniej jeden unikalny obsługiwany kod HTTP: 400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504." } } diff --git a/src/admin/documentation/documents.rs b/src/admin/documentation/documents.rs index 3cd601c..d0b1268 100644 --- a/src/admin/documentation/documents.rs +++ b/src/admin/documentation/documents.rs @@ -173,6 +173,11 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[ "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/PHP_FPM.md")), ), + ( + "docs/PHP_FPM_ISOLATION.md", + "guides", + include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/PHP_FPM_ISOLATION.md")), + ), ( "docs/PROMETHEUS.md", "guides", diff --git a/src/admin/openapi.json b/src/admin/openapi.json index 3430bd0..6fcb4ff 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.29.6", + "version": "0.29.7", "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": [ @@ -527,6 +527,30 @@ } }, "description": "Dangerous per-vhost-only request-side forced-cache target prefixes. Response privacy exclusions remain authoritative." + }, + "expose_only_hits": { + "type": "boolean", + "nullable": true, + "default": null, + "description": "If true, only emit cache diagnostic headers for actual HIT responses, never MISS/BYPASS. null inherits global expose_only_hits. Via is controlled independently." + } + }, + "additionalProperties": true + }, + "pages": { + "type": "object", + "description": "Vhost response/error pages and optional application HTTP-error interception. Interception replaces application response HTML with the configured vhost/global page, or a built-in RustProxy page, before cache admission. The application's HTTP status is always retained, regardless of standalone page status overrides. Internal location fallbacks take priority.", + "properties": { + "intercept_statuses": { + "type": "array", + "default": [], + "uniqueItems": true, + "maxItems": 11, + "description": "Off by default. For these origin response HTTP statuses, replace the application response with a RustProxy error page (no-store). Does not change RustProxy-generated routing/security errors.", + "items": { + "type": "integer", + "enum": [400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504] + } } }, "additionalProperties": true @@ -1818,6 +1842,11 @@ "maximum": 95, "default": 80, "description": "HOT usage waterline targeted by automatic idle trimming." + }, + "expose_only_hits": { + "type": "boolean", + "default": false, + "description": "Suppress RustProxy cache diagnostic headers on MISS/BYPASS. Cache HIT diagnostics remain governed by expose_* flags. The independently configured Via header is unaffected." } }, "additionalProperties": false @@ -1904,6 +1933,20 @@ "type": "boolean", "default": false, "description": "Explicitly allow this backend to resolve to a local RustProxy listener, including a listener using port 9090. This can create a proxy loop or expose the administration plane. Loopback/private targets still require allow_private=true." + }, + "health_check_enabled": { + "type": "boolean", + "default": true, + "description": "Enable both active and passive health monitoring for this backend. False still permits internal pinned-location traffic and does not change pool_eligible." + }, + "health_check": { + "allOf": [ + { + "$ref": "#/components/schemas/DockerHealthCheck" + } + ], + "nullable": true, + "description": "Optional per-backend active health policy; null inherits the vhost policy, including its default Host." } }, "additionalProperties": true, diff --git a/src/admin/web/app/settings.js b/src/admin/web/app/settings.js index 6d0a632..07973d9 100644 --- a/src/admin/web/app/settings.js +++ b/src/admin/web/app/settings.js @@ -29,7 +29,7 @@ function renderSettings() { $("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; + $("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; $("cache-only-hits").checked = !!c.expose_only_hits; const viaHeader = c.via_header === undefined ? "RustProxy" : c.via_header; $("cache-via-enabled").checked = viaHeader !== null; $("cache-via-value").value = viaHeader || "RustProxy"; syncCacheVia(); setCacheMemoryPresetSelection(["conservative", "recommended", "performance"].includes(c.memory_preset) ? c.memory_preset : null); @@ -285,7 +285,7 @@ bindSettingsForm("response-form", next => { 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, via_header: $("cache-via-enabled").checked ? $("cache-via-value").value.trim() : null }; + 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, expose_only_hits: $("cache-only-hits").checked, via_header: $("cache-via-enabled").checked ? $("cache-via-value").value.trim() : null }; }, "cache"); bindSettingsForm("transform-form", next => { next.transform = { diff --git a/src/admin/web/index.html b/src/admin/web/index.html index 797f3b2..37536ef 100644 --- a/src/admin/web/index.html +++ b/src/admin/web/index.html @@ -2277,6 +2277,7 @@ type="checkbox" />Cache-Status (RFC 9211) +
@@ -3331,47 +3332,35 @@ Select a shared profile or edit PHP-FPM locally. Profile changes apply to all attached Web Services.
- +
i

Enables execution of configured PHP extensions via the PHP-FPM socket. The pool must already be running; RustProxy does not start PHP. Other files remain static.

- - - - - - - - - + +
i

Passes the incoming Authorization header to PHP as HTTP_AUTHORIZATION. Enable for API/Bearer/Basic authentication; disable if the application does not need it.

+
i

Allows /static.php/skins/file.css to run the existing static.php script with PATH_INFO=/skins/file.css. Needed by Roundcube static.php routes; usually not required for WordPress. Off by default.

+ + + + + + + reserved. Example: APP_ENV = production; no variable interpolation. - + Examples: memory_limit = 256M, upload_max_filesize = 64M, post_max_size = 64M, max_execution_time = 30. Use PHP_ADMIN_VALUE for operator-only settings. +

RustProxy speaks FastCGI directly to a @@ -3411,6 +3400,8 @@

Expand a profile to edit it. Changes are saved immediately and shared by every attached Web Service.

+
+
@@ -3461,41 +3452,42 @@ RewriteRule . /index.php [L]

PHP-FPM / FastCGI configuration

-
-

RustProxy connects directly to an existing PHP-FPM pool. Prefer a Unix - socket on the same host. TCP is accepted only on loopback and is not a public/network FastCGI transport. -

+
+

The same fields and syntax apply to a local Web Service PHP configuration and a reusable PHP-FPM profile. A selected profile overrides local PHP fields for all attached services. RustProxy connects to an already running PHP-FPM pool.

+

Enabling PHP and PATH_INFO

+

Enables execution of configured PHP extensions via the PHP-FPM socket. The pool must already be running; RustProxy does not start PHP. Other files remain static.

+

Allows /static.php/skins/file.css to run the existing static.php script with PATH_INFO=/skins/file.css. Needed by Roundcube static.php routes; usually not required for WordPress. Off by default.

+

Passes the incoming Authorization header to PHP as HTTP_AUTHORIZATION. Enable for API/Bearer/Basic authentication; disable if the application does not need it.

Endpoint syntax

-
unix:/run/php/php-fpm.sock
+            
unix:/run/php/php_wordpress.sock
 tcp:127.0.0.1:9000
 tcp:[::1]:9000
-

For TCP, include the tcp: prefix, a literal loopback - IP and port. Hostnames and non-loopback addresses are rejected. For Unix sockets, RustProxy must have - permission to connect to the socket.

+

Use unix: for the full local socket path or tcp: plus a literal loopback IP and port. Hostnames and non-loopback TCP are rejected. The RustProxy user must be allowed to connect to the socket. Separate pools when PHP INI security settings differ.

+

Execution and size limits

+

PHP extensions: comma-separated names such as php, phtml (no leading dots). Connect timeout: 1000 ms = 1 second. Full FastCGI request timeout: 30000 ms = 30 seconds. Concurrent requests: 64 (returns 503 when exhausted). Request body: 67108864 bytes = 64 MiB; 0 means unlimited by RustProxy. Response headers: 65536 bytes. STDERR: 4096 bytes; 0 disables capture. All numeric fields require integers; PHP INI limits are separate.

Custom FastCGI parameters

APP_ENV = production
 APP_DEBUG = 0
 TENANT = main
-

Use one NAME = value entry per line. Names are normalized to - uppercase and may contain A-Z, 0-9 and underscore. These are static trusted operator values. RustProxy - rejects core CGI names, HTTP_* names, PHP_VALUE and PHP_ADMIN_VALUE so they cannot override request - routing, script selection or HTTP-derived parameters.

-

PHP INI overrides

+

Put these in Custom FastCGI parameters only. One NAME = value per line; names start with A-Z and then contain A-Z, 0-9 or underscore (max 64 characters). Values are static, up to 8192 bytes each; no placeholders are expanded. Do not use SCRIPT_FILENAME, HTTP_* or PHP_VALUE/PHP_ADMIN_VALUE here.

+

PHP_VALUE — regular php.ini overrides

memory_limit = 256M
 upload_max_filesize = 64M
 post_max_size = 64M
-max_execution_time = 30
-

These lines are combined into the FastCGI PHP_VALUE parameter. - PHP-FPM/PHP still decides which directives are changeable at this scope. Newlines/control characters are - rejected and PHP_ADMIN_VALUE is intentionally unavailable.

-

Limits and headers

-

Request timeout covers the FastCGI exchange. The body limit is - enforced while streaming and returns 413 when exceeded; 0 disables the RustProxy-side body limit. - Response-header and stderr limits bound memory/diagnostic data. Authorization forwarding can be disabled - for applications that do not need HTTP_AUTHORIZATION.

-

Do not put secrets in custom parameters or PHP_VALUE - unless you accept storing them in normal Web Service configuration. Use a dedicated non-root PHP-FPM - pool identity and keep PHP-FPM off public networks.

+max_execution_time = 30 +error_reporting = E_ALL +log_errors = On +display_errors = Off +date.timezone = Europe/Warsaw
+

Enter these in PHP INI overrides (PHP_VALUE), not in FastCGI parameters. Use one directive = value per line (php.ini notation, no [section] headers or PHP tags). Exact support depends on which directives PHP-FPM permits to change. Common sizes use M/G, booleans On/Off and times seconds. Do not duplicate a directive in PHP_ADMIN_VALUE.

+

PHP_ADMIN_VALUE — administrator-only overrides

+
open_basedir = /var/www/webmail/:/tmp/:/usr/lib/php/
+display_errors = Off
+log_errors = On
+

Put these in PHP administrator INI overrides (PHP_ADMIN_VALUE). open_basedir lists allowed directories separated by ':' on Unix. Set it to paths of THAT application; do not reuse Roundcube restrictions in a WordPress pool. For security-sensitive values, prefer php_admin_value[...] in the configuration of a dedicated PHP-FPM pool. Shared FPM workers can retain request-supplied INI settings.

+

Storage, validation and troubleshooting

+

Each INI name may contain letters, digits, '.', '_' and '-' (up to 64 bytes); a value must be nonempty (up to 8192 bytes). Max 64 custom FastCGI entries and 64 INI overrides per map, with a total 128 KiB budget. No control characters or multi-line values. Values are saved in normal configuration: never place secrets there unless that storage is acceptable. A syntax-valid directive can still be rejected/ignored by PHP itself.

+

Critical: per-service PHP_VALUE/PHP_ADMIN_VALUE do NOT isolate applications when they share one PHP-FPM worker pool. Use different Unix sockets and restart the pools after moving WordPress and Roundcube apart. PATH_INFO does not fix open_basedir isolation.

diff --git a/src/admin/web/management.js b/src/admin/web/management.js index e999931..c845a6a 100644 --- a/src/admin/web/management.js +++ b/src/admin/web/management.js @@ -30,8 +30,30 @@ return { read() { return Object.fromEntries([...rows].map(([code, editor]) => [code, editor.read()]).filter(([, page]) => page)); } }; } function vhostEditor(parent, values = {}) { - const response = pageEditor(parent, "pageStaticHost", values.response), fallback = pageEditor(parent, "pageMissingPath", values.fallback), errors = errorsEditor(parent, values.errors); - parent.append(label("p", "pagesSafetyHint", "fine")); return { read: () => ({ response: response.read(), fallback: fallback.read(), errors: errors.read() }) }; + const response = pageEditor(parent, "pageStaticHost", values.response), fallback = pageEditor(parent, "pageMissingPath", values.fallback); + const interception = el("section", undefined, "php-origin-errors"); + const toggle = el("label", undefined, "check"), enabled = document.createElement("input"); + enabled.type = "checkbox"; enabled.checked = (values.intercept_statuses || []).length > 0; + toggle.append(enabled, label("span", "vhostInterceptOriginErrors")); + const codesLabel = el("label"), codesCaption = label("span", "vhostInterceptOriginStatuses"), statuses = document.createElement("input"); + statuses.type = "text"; statuses.maxLength = 64; + statuses.value = (values.intercept_statuses?.length ? values.intercept_statuses : [404]).join(", "); + statuses.placeholder = "404, 500, 502, 503, 504"; + codesLabel.append(codesCaption, statuses); + const hint = label("p", "vhostInterceptOriginErrorsHelp", "fine"); + const sync = () => { statuses.disabled = !enabled.checked; }; + enabled.addEventListener("change", sync); sync(); + interception.append(toggle, codesLabel, hint); parent.append(interception); + const errors = errorsEditor(parent, values.errors); + parent.append(label("p", "pagesSafetyHint", "fine")); + return { read: () => { + const selectedTokens = enabled.checked ? statuses.value.split(/[\s,]+/).filter(Boolean) : []; + const selected = selectedTokens.map(Number); + if (enabled.checked && (!selected.length || selectedTokens.some(token => !/^\d{3}$/.test(token)) || selected.length > codes.length || new Set(selected).size !== selected.length || selected.some(code => !codes.includes(code)))) { + throw new Error(t("vhostInterceptOriginInvalid")); + } + return { response: response.read(), fallback: fallback.read(), errors: errors.read(), intercept_statuses: selected }; + } }; } let global = null; function render() { if (!envelope || settingsDirty.has("pages-form")) return; const values = envelope.config.pages || {}, parent = $("global-pages-fields"); parent.replaceChildren(); const ip = pageEditor(parent, "pageDirectIp", values.direct_ip), unknown = pageEditor(parent, "pageUnknownHost", values.unknown_host), errors = errorsEditor(parent, values.errors); global = { read: () => ({ direct_ip: ip.read(), unknown_host: unknown.read(), errors: errors.read() }) }; } diff --git a/src/admin/web/style.css b/src/admin/web/style.css index 9a2c8a4..093d278 100644 --- a/src/admin/web/style.css +++ b/src/admin/web/style.css @@ -9871,3 +9871,51 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline { grid-template-columns: 1fr } } + +/* 0.29.7: PHP-FPM local/profile toggle rows with accessible info popovers. */ +.php-switch-row { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + min-width: 0; + min-height: 30px; + margin: 4px 0; +} +.php-switch-row .php-switch-label { + display: inline-flex !important; + flex: 0 1 auto; + align-items: center; + gap: 9px; + min-width: 0; + margin: 0 !important; + line-height: 1.5; +} +.php-switch-row .php-switch-label input[type="checkbox"] { + position: static; + flex: 0 0 var(--ui-check-size); + margin: 0; +} +.php-switch-row .php-switch-label span { min-width: 0; } +.php-switch-row .php-field-info { flex: 0 0 auto; } +.php-switch-row .php-field-info .metric-info-popover { right: auto; left: 0; } +.php-reference pre { + overflow-x: auto; + white-space: pre; + padding: 10px; + border: 1px solid var(--line); + border-radius: 4px; + background: var(--panel2); + font: 12px/1.6 var(--mono); +} +.php-profile-help-divider { + width: 100%; + height: 0; + margin: 16px 0 12px; + border: 0; + border-top: 1px solid var(--line); +} +.php-origin-errors { grid-column: 1 / -1; display: grid; gap: 8px; padding: 10px 0; border-top: 1px solid var(--line); } +.php-origin-errors > label { max-width: 430px; } +.php-origin-errors > .check { margin: 0 !important; } +.php-origin-errors .fine { margin: 0; } diff --git a/src/admin/web/vhosts/core.js b/src/admin/web/vhosts/core.js index 1705c36..47f7ab0 100644 --- a/src/admin/web/vhosts/core.js +++ b/src/admin/web/vhosts/core.js @@ -52,7 +52,7 @@ 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"], ["compression", "vhCompression"], ["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, unsafe_force: { enabled: yes("vh-cache-force-enabled"), targets: lines(str("vh-cache-force-targets")) } }; } + 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"), expose_only_hits: tri("vh-cache-only-hits"), bypass_prefixes: yes("vh-cache-prefix-custom") ? lines(str("vh-cache-prefixes")) : null, unsafe_force: { enabled: yes("vh-cache-force-enabled"), targets: lines(str("vh-cache-force-targets")) } }; } function collectCompression() { const tri = name => str(name) === "" ? null : str(name) === "on"; return { enabled: tri("vh-compression-enabled"), zstd: tri("vh-compression-zstd"), br: tri("vh-compression-br"), gzip: tri("vh-compression-gzip"), deflate: tri("vh-compression-deflate") }; } function collectTransform() { const tri = name => str(name) === "" ? null : str(name) === "on"; @@ -204,7 +204,7 @@ 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"); + 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], ["member-health-enabled", "backendHealthEnabled", value.health_check_enabled !== false]]) 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"); @@ -222,6 +222,25 @@ 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"); + const customHealth = field(extra, "member-health-custom", "backendHealthOverride", value.health_check != null, "checkbox"); + const health = value.health_check || {}; + const healthFields = el("div", undefined, "vh-wide vh-grid vh-backend-health-fields"); extra.append(healthFields); + field(healthFields, "member-health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] }); + field(healthFields, "member-health-path", "vhHealthPath", health.path ?? "/", "text", { maxLength: 1024 }); + field(healthFields, "member-health-host", "vhHealthHost", health.host ?? "", "text", { maxLength: 253 }); + field(healthFields, "member-health-interval", "vhInterval", health.interval_ms ?? 5000, "number", { min: 250, max: 300000, required: true }); + field(healthFields, "member-health-timeout", "vhTimeout", health.timeout_ms ?? 2000, "number", { min: 50, max: 60000, required: true }); + field(healthFields, "member-health-slow", "vhSlow", health.slow_response_ms ?? 1500, "number", { min: 0, max: 60000, required: true }); + field(healthFields, "member-health-fall", "vhFall", health.fall ?? 3, "number", { min: 1, max: 20, required: true }); + field(healthFields, "member-health-rise", "vhRise", health.rise ?? 2, "number", { min: 1, max: 20, required: true }); + field(healthFields, "member-health-min", "vhStatusMin", health.expected_status_min ?? 200, "number", { min: 100, max: 599, required: true }); + field(healthFields, "member-health-max", "vhStatusMax", health.expected_status_max ?? 399, "number", { min: 100, max: 599, required: true }); + const syncHealth = () => { + healthFields.hidden = !customHealth.checked; + healthFields.querySelectorAll("input,select").forEach(input => { input.disabled = !customHealth.checked; }); + }; + customHealth.addEventListener("change", syncHealth); syncHealth(); + note(extra, "backendHealthOverridesHelp"); row.append(advanced); const actions = el("div", undefined, "vh-backend-actions"); diff --git a/src/admin/web/vhosts/editor.js b/src/admin/web/vhosts/editor.js index 5659c1c..d9df0f8 100644 --- a/src/admin/web/vhosts/editor.js +++ b/src/admin/web/vhosts/editor.js @@ -350,7 +350,7 @@ 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); + 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); tri(cacheOptions, "vh-cache-only-hits", "cacheOnlyHits", cache.expose_only_hits); 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"); const forceCache = field(cacheOptions, "vh-cache-force-enabled", "vhCacheForceEnabled", cache.unsafe_force?.enabled === true, "checkbox"); @@ -387,7 +387,21 @@ 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 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), + health_check_enabled: yes("member-health-enabled", row), + health_check: yes("member-health-custom", row) ? { + mode: str("member-health-mode", row), path: str("member-health-path", row), host: nullable("member-health-host", row), + interval_ms: num("member-health-interval", row), timeout_ms: num("member-health-timeout", row), slow_response_ms: num("member-health-slow", row), + fall: num("member-health-fall", row), rise: num("member-health-rise", row), + expected_status_min: num("member-health-min", row), expected_status_max: num("member-health-max", row) + } : null, + 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 mainFallbackTarget = str("main-error-target"), mainFallbackStatuses = lines(str("main-error-statuses")).map(value => Number(value)); diff --git a/src/admin/web/vhosts/render.js b/src/admin/web/vhosts/render.js index 925a01f..456996b 100644 --- a/src/admin/web/vhosts/render.js +++ b/src/admin/web/vhosts/render.js @@ -258,7 +258,7 @@ 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 label = vhostDisabled ? t("vhDisabled") : (member.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 || "-"} · ${formatTime(member.last_failure_at_ms)}` : "-"; const lastRecovery = Number(member.last_recovered_ms) > 0 ? formatTime(member.last_recovered_ms) : "-"; diff --git a/src/admin/web/web-server-profiles.js b/src/admin/web/web-server-profiles.js index 5229ac9..f20a07a 100644 --- a/src/admin/web/web-server-profiles.js +++ b/src/admin/web/web-server-profiles.js @@ -31,6 +31,16 @@ window.rustProxyPhpProfiles = ({ defaults, mapLines, lines, parseFastCgiParams, ["php_admin_values", "phpProfileAdminValues", "textarea"], ]; + const helpKeys = { + enabled: "phpHintEnabled", path_info: "phpHintPathInfo", endpoint: "webServicePhpDocsEndpoint", + extensions: "phpHintExtensions", pass_authorization: "phpHintPassAuthorization", + connect_timeout_ms: "phpHintConnectTimeout", request_timeout_ms: "phpHintRequestTimeout", + max_concurrent_requests: "phpHintConcurrency", max_request_body_bytes: "phpHintBodyLimit", + max_response_header_bytes: "phpHintHeaderLimit", stderr_log_bytes: "phpHintStderrLimit", + custom_params: "webServicePhpCustomParamsHelp", php_values: "phpDocsValuesText", + php_admin_values: "phpHintAdminValues" + }; + function profileEditor(profile, creating = false) { const details = document.createElement("details"); details.className = "card"; if (creating) details.open = true; @@ -46,17 +56,35 @@ window.rustProxyPhpProfiles = ({ defaults, mapLines, lines, parseFastCgiParams, const nameInput = namedInput("phpProfileName", profile.name); const controls = {}; for (const [key, label, kind, min, max] of profileFields) { - const labelEl = el("label"); if (kind === "textarea") labelEl.className = "vh-wide"; - const caption = el("span", t(label)); labelEl.append(caption); + const toggle = kind === "checkbox"; + const wrapper = toggle ? el("div", undefined, "php-switch-row vh-wide") : el("label"); + if (!toggle && kind === "textarea") wrapper.className = "vh-wide"; + const labelEl = toggle ? el("label", undefined, "check php-switch-label") : wrapper; const input = document.createElement(kind === "textarea" ? "textarea" : "input"); if (kind !== "textarea") input.type = kind; - if (kind === "checkbox") input.checked = profile.php?.[key] === true; - else if (kind === "textarea") { input.rows = 3; input.maxLength = 65536; input.value = mapLines(profile.php?.[key]); } + const caption = el("span", t(label)); + if (toggle) labelEl.append(input, caption); else labelEl.append(caption, input); + if (toggle) input.checked = profile.php?.[key] === true; + else if (kind === "textarea") { input.rows = 4; input.maxLength = 65536; input.value = mapLines(profile.php?.[key]); } else if (key === "extensions") input.value = (profile.php?.extensions || ["php"]).join(", "); else input.value = profile.php?.[key] ?? ""; if (kind === "number") { input.min = min; input.max = max; input.step = 1; input.required = true; } - if (key === "endpoint") { input.required = true; input.maxLength = 512; } - labelEl.append(input); editor.append(labelEl); controls[key] = input; + if (key === "endpoint") { input.required = true; input.maxLength = 512; input.placeholder = "unix:/run/php/php-fpm.sock"; } + if (key === "extensions") input.placeholder = "php, phtml"; + if (key === "custom_params") input.placeholder = "APP_ENV = production\nAPP_DEBUG = 0"; + if (key === "php_values") input.placeholder = "memory_limit = 256M\npost_max_size = 64M"; + if (key === "php_admin_values") input.placeholder = "open_basedir = /var/www/webmail/:/tmp/"; + if (toggle) { + const tip = el("details", undefined, "metric-info php-field-info"), summaryTip = el("summary", "i"); + summaryTip.setAttribute("aria-label", t(helpKeys[key])); + const popover = el("div", undefined, "metric-info-popover"); + popover.append(el("p", t(helpKeys[key]), "fine")); tip.append(summaryTip, popover); + wrapper.append(labelEl, tip); + } else { + const hint = el("small", t(helpKeys[key]), "fine"); + labelEl.append(hint); + } + editor.append(wrapper); controls[key] = input; } const actions = el("div", undefined, "form-actions vh-wide"); const save = el("button", t("apply"), "primary"); save.type = "submit"; diff --git a/src/admin/web/web-server.js b/src/admin/web/web-server.js index 48b6474..cd2201b 100644 --- a/src/admin/web/web-server.js +++ b/src/admin/web/web-server.js @@ -476,6 +476,7 @@ $("web-service-php-profiles-open").addEventListener("click", openProfiles); $("php-fpm-profiles-close").addEventListener("click", () => $("php-fpm-profiles-dialog").close()); $("php-fpm-profile-add").addEventListener("click", () => renderProfiles(true)); + $("php-fpm-profiles-help").addEventListener("click", () => $("php-help-dialog").showModal()); $("web-service-compression").addEventListener("change", syncCompression); $("web-service-precompressed").addEventListener("change", syncPrecompressed); $("web-service-htaccess-enabled").addEventListener("change", syncHtaccess); diff --git a/src/balance/lease_runtime.rs b/src/balance/lease_runtime.rs index a35176e..6a80d09 100644 --- a/src/balance/lease_runtime.rs +++ b/src/balance/lease_runtime.rs @@ -345,8 +345,10 @@ pub async fn health_loop(app: Weak, shutdown: CancellationT _ = interval.tick() => { let Some(app) = app.upgrade() else { break; }; let snapshot = app.config.load_full(); + // Own an Arc for each candidate. JoinSet tasks require an + // owned, 'static member rather than a reference into snapshot. let members: Vec<_> = snapshot.pools.iter().flat_map(|pool| - pool.members.iter().map(move |member| (member, &pool.settings.health_check))).collect(); + pool.members.iter().map(move |member| (Arc::clone(member), pool.settings.health_for(&member.definition)))).collect(); for (member, _) in &members { member.state.sample_traffic(); // Time-based pool recycling must not depend on receiving a @@ -361,9 +363,9 @@ pub async fn health_loop(app: Weak, shutdown: CancellationT for offset in 0..members.len() { if checks.len() >= 16 { break; } let index = (start + offset) % members.len(); - let (member, health) = members[index]; + let (member, health) = &members[index]; if member.definition.enabled && member.state.claim_probe(health) { - checks.spawn(probe(member.clone(), health.clone())); + checks.spawn(probe(Arc::clone(member), health.clone())); cursor = (index + 1) % members.len(); } } diff --git a/src/balance/model.rs b/src/balance/model.rs index 7cbb256..712c671 100644 --- a/src/balance/model.rs +++ b/src/balance/model.rs @@ -216,6 +216,25 @@ impl Default for Settings { } } impl Settings { + /// Resolve active health per member; a disabled monitor never probes. + pub fn health_for(&self, member: &PoolMember) -> HealthCheck { + let mut health = member.health_check.clone().unwrap_or_else(|| self.health_check.clone()); + if health.host.is_none() && health.mode == HealthMode::Http { + health.host = self.health_check.host.clone(); + } + if !member.health_check_enabled { + health.mode = HealthMode::Off; + } + health + } + /// Monitoring off also excludes passive ejection; failures remain logged. + pub fn passive_for(&self, member: &PoolMember) -> PassiveHealth { + let mut passive = self.passive_health.clone(); + if !member.health_check_enabled { + passive.enabled = false; + } + passive + } pub fn validate(&self) -> Result<()> { let h = &self.health_check; let k = &self.keepalive; diff --git a/src/balance/pool_core.rs b/src/balance/pool_core.rs index 9341462..1925605 100644 --- a/src/balance/pool_core.rs +++ b/src/balance/pool_core.rs @@ -21,7 +21,7 @@ impl Pool { .zip(backends) .map(|(definition, backend)| { Arc::new(Member { - state: Arc::new(BackendState::new(settings.health_check.mode)), + state: Arc::new(BackendState::new(settings.health_for(&definition).mode)), definition, backend, token: rand::thread_rng() @@ -54,13 +54,13 @@ impl Pool { 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(); + serde_json::to_vec(&( + self.settings.health_for(&member.definition), + self.settings.passive_for(&member.definition), + )).ok() == serde_json::to_vec(&( + previous.settings.health_for(&old.definition), + previous.settings.passive_for(&old.definition), + )).ok(); let same_clients = member.backend.client.reusable_with(&old.backend.client) && member .backend @@ -195,8 +195,8 @@ impl Pool { Some(Selection { lease: Arc::new(Lease { member: member.clone(), - health: self.settings.health_check.clone(), - passive_health: self.settings.passive_health.clone(), + health: self.settings.health_for(&member.definition), + passive_health: self.settings.passive_for(&member.definition), error_recorded: AtomicBool::new(false), }), cookie: None, @@ -336,8 +336,8 @@ impl Pool { Some(Selection { lease: Arc::new(Lease { member: selected.clone(), - health: self.settings.health_check.clone(), - passive_health: self.settings.passive_health.clone(), + health: self.settings.health_for(&selected.definition), + passive_health: self.settings.passive_for(&selected.definition), error_recorded: AtomicBool::new(false), }), cookie, diff --git a/src/balance/pool_view.rs b/src/balance/pool_view.rs index af51eb4..28bb785 100644 --- a/src/balance/pool_view.rs +++ b/src/balance/pool_view.rs @@ -6,13 +6,14 @@ impl Pool { .map(|m| { let h = lock(&m.state.health); let failure = lock(&m.state.last_failure); + let member_health = self.settings.health_for(&m.definition); 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 { + } else if !h.checked && member_health.mode != HealthMode::Off { "checking" } else { "down" @@ -27,6 +28,8 @@ impl Pool { 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("health_check_enabled".into(), json!(m.definition.health_check_enabled)); + item.insert("health_mode".into(), json!(member_health.mode)); item.insert( "max_concurrent_requests".into(), json!(m.definition.max_concurrent_requests), diff --git a/src/balance/tests/part_01.rs b/src/balance/tests/part_01.rs index 1f80df5..36c1dbd 100644 --- a/src/balance/tests/part_01.rs +++ b/src/balance/tests/part_01.rs @@ -52,6 +52,29 @@ fn select(pool: &Pool) -> Selection { .unwrap() } #[test] +fn per_backend_monitoring_can_be_disabled_or_overridden() { + let mut defaults: crate::vhost::PoolMember = serde_json::from_value(serde_json::json!({ + "id": "error_404", "url": "http://127.0.0.1:8282", "pool_eligible": false + })).unwrap(); + let mut settings = Settings::default(); + settings.passive_health.enabled = true; + assert!(defaults.health_check_enabled); + assert_eq!(settings.health_for(&defaults).mode, HealthMode::Tcp); + assert!(settings.passive_for(&defaults).enabled); + defaults.health_check_enabled = false; + assert_eq!(settings.health_for(&defaults).mode, HealthMode::Off); + assert!(!settings.passive_for(&defaults).enabled); + assert!(!defaults.pool_eligible); // routing intent is independent + defaults.health_check_enabled = true; + defaults.health_check = Some(HealthCheck { mode: HealthMode::Http, path: "/healthz".into(), ..Default::default() }); + let effective = settings.health_for(&defaults); + assert_eq!(effective.mode, HealthMode::Http); + assert_eq!(effective.path, "/healthz"); + assert_eq!(effective.host, settings.health_check.host); + let restored: crate::vhost::PoolMember = serde_json::from_str(&serde_json::to_string(&defaults).unwrap()).unwrap(); + assert_eq!(settings.health_for(&restored).path, "/healthz"); +} +#[test] fn default_health_check_uses_tcp() { assert_eq!(HealthMode::default(), HealthMode::Tcp); assert_eq!(HealthCheck::default().mode, HealthMode::Tcp); diff --git a/src/cache/model/entry.rs b/src/cache/model/entry.rs index 7c1dbb2..e08127b 100644 --- a/src/cache/model/entry.rs +++ b/src/cache/model/entry.rs @@ -188,6 +188,9 @@ impl Settings { value.parse::().expect("validated cache Via header"), ); } + if self.expose_only_hits && outcome != "HIT" { + return; + } if self.expose_status { headers.insert("x-cache", HeaderValue::from_static(outcome)); } @@ -222,7 +225,10 @@ impl Settings { } } pub fn annotate_transformed(&self, headers: &mut HeaderMap, transformed: bool) { - if !self.enabled || !self.expose_cache_status || !transformed { + if !self.enabled || !self.expose_cache_status || !transformed + || (self.expose_only_hits && !headers.get("cache-status") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("RustProxy; hit"))) { return; } let base = headers @@ -249,7 +255,7 @@ impl Settings { reason: &'static str, ) { self.annotate(headers, outcome, hits, ttl); - if !self.enabled { + if !self.enabled || (self.expose_only_hits && outcome != "HIT") { return; } if self.expose_status { @@ -333,6 +339,7 @@ pub struct Overrides { pub expose_hits: Option, pub expose_cache_status: Option, pub expose_resident_age: Option, + pub expose_only_hits: Option, /// Deliberately unavailable in the global cache configuration. pub unsafe_force: UnsafeForce, } @@ -380,6 +387,9 @@ impl Overrides { if let Some(value) = self.expose_resident_age { result.expose_resident_age = value; } + if let Some(value) = self.expose_only_hits { + result.expose_only_hits = value; + } result.validate()?; Ok(result) } diff --git a/src/cache/model/settings.rs b/src/cache/model/settings.rs index 889e0ad..cf7a08c 100644 --- a/src/cache/model/settings.rs +++ b/src/cache/model/settings.rs @@ -173,6 +173,8 @@ pub struct Settings { pub expose_hits: bool, pub expose_cache_status: bool, pub expose_resident_age: bool, + /// Emit cache diagnostics only for actual HIT responses, not MISS or BYPASS. + pub expose_only_hits: bool, /// Optional response Via field value added by RustProxy cache handling. /// `None` disables the header; the default is `Via: RustProxy`. pub via_header: Option, @@ -243,6 +245,7 @@ impl Default for Settings { expose_hits: false, expose_cache_status: false, expose_resident_age: false, + expose_only_hits: false, via_header: Some("RustProxy".into()), } } diff --git a/src/cache/tests/dashboard_tests.rs b/src/cache/tests/dashboard_tests.rs index 700ec5b..116adbc 100644 --- a/src/cache/tests/dashboard_tests.rs +++ b/src/cache/tests/dashboard_tests.rs @@ -127,6 +127,39 @@ mod dashboard_tests { ); } #[test] + fn hit_only_cache_diagnostics_hides_miss_bypass_and_preserves_hits() { + let global = Settings { + expose_status: true, + expose_hits: true, + expose_cache_status: true, + expose_resident_age: true, + expose_only_hits: true, + ..Settings::default() + }; + let mut headers = HeaderMap::new(); + headers.insert("x-cache", HeaderValue::from_static("untrusted-upstream")); + headers.insert("cache-status", HeaderValue::from_static("untrusted-upstream")); + global.annotate_reason(&mut headers, "MISS", 0, 0, "response_headers_policy"); + global.annotate_transformed(&mut headers, true); + for name in ["x-cache", "x-cache-hits", "cache-status", "x-cache-reason", "x-cache-resident-age"] { + assert!(!headers.contains_key(name), "{name} leaked on MISS"); + } + assert_eq!(headers["via"], "RustProxy"); // independent proxy policy + global.annotate_reason(&mut headers, "BYPASS", 0, 0, "request_cookie"); + assert!(!headers.contains_key("cache-status")); + global.annotate_reason(&mut headers, "HIT", 4, 60, "cache_hit"); + global.annotate_resident_age(&mut headers, 10); + assert_eq!(headers["x-cache"], "HIT"); + assert_eq!(headers["x-cache-hits"], "4"); + assert!(headers["cache-status"].to_str().unwrap().contains("; hit;")); + assert_eq!(headers["x-cache-resident-age"], "10"); + let mut override_on = Overrides { expose_only_hits: Some(false), ..Default::default() }; + let normal = override_on.resolve(&global).unwrap(); + assert!(!normal.expose_only_hits); + override_on.expose_only_hits = Some(true); + assert!(override_on.resolve(&Settings::default()).unwrap().expose_only_hits); + } + #[test] fn via_header_is_configurable_and_preserves_upstream_chain() { let settings = Settings::default(); let mut headers = HeaderMap::new(); diff --git a/src/config/compile.rs b/src/config/compile.rs index ebe79df..fa2a667 100644 --- a/src/config/compile.rs +++ b/src/config/compile.rs @@ -57,6 +57,10 @@ pub fn compile_with_active( } ensure!(config.web_services.len() <= 128, "too many Web Services (max 128)"); let mut web_services = HashMap::new(); + // PHP-FPM can retain FastCGI PHP_VALUE/PHP_ADMIN_VALUE directives in reused + // workers. Different directive sets on one socket can leak to the next vhost. + // Report unsafe legacy configurations but keep them loadable for migration. + let mut php_endpoint_policies: HashMap, std::collections::BTreeMap)> = HashMap::new(); for service in &mut config.web_services { service.validate().with_context(|| format!("Web Service {}", service.id))?; // A profile changes effective runtime settings, not the saved standalone @@ -68,6 +72,21 @@ pub fn compile_with_active( })?.clone(); effective.validate().with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?; } + if effective.php.enabled { + let php = &effective.php; + if let Some((owner, values, admin_values)) = php_endpoint_policies.get(&php.endpoint) { + if values != &php.php_values || admin_values != &php.php_admin_values { + tracing::warn!( + service = %effective.id, other_service = %owner, endpoint = %php.endpoint, + "PHP-FPM endpoint is shared by Web Services with different PHP_VALUE/PHP_ADMIN_VALUE settings; FPM worker settings can leak across requests. Use distinct PHP-FPM pools/sockets (especially for open_basedir)." + ); + } + } else { + php_endpoint_policies.insert(php.endpoint.clone(), ( + effective.id.clone(), php.php_values.clone(), php.php_admin_values.clone(), + )); + } + } let compiled = Arc::new(effective.compile().with_context(|| format!("Web Service {}", service.id))?); ensure!(web_services.insert(service.id.clone(), compiled).is_none(), "duplicate Web Service id"); } diff --git a/src/config/routing.rs b/src/config/routing.rs index b7e5e3e..3831b18 100644 --- a/src/config/routing.rs +++ b/src/config/routing.rs @@ -122,7 +122,10 @@ fn compile_routing( // Blank health-check Host means the first exact DNS name of the vhost. // This is important when the backend URL is an IP shared by several vhosts. if pool_settings.health_check.host.is_none() - && pool_settings.health_check.mode == crate::balance::HealthMode::Http + && (pool_settings.health_check.mode == crate::balance::HealthMode::Http + || host.backends.iter().any(|member| member.health_check.as_ref() + .is_some_and(|health| health.mode == crate::balance::HealthMode::Http + && health.host.is_none()))) { pool_settings.health_check.host = host .frontend diff --git a/src/config/tests/part_02.rs b/src/config/tests/part_02.rs index aaacebc..bafb06c 100644 --- a/src/config/tests/part_02.rs +++ b/src/config/tests/part_02.rs @@ -6,6 +6,19 @@ fn path_boundaries() { assert!(prefix_matches("/", "/anything")); } #[test] +fn invalid_per_backend_health_policy_rejected_without_global_override() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].backends[0].health_check = Some(crate::balance::HealthCheck { + mode: crate::balance::HealthMode::Http, + interval_ms: 300, + timeout_ms: 400, + ..Default::default() + }); + assert!(compile(c.clone(), 1, &policy()).is_err()); + c.vhosts[0].backends[0].health_check.as_mut().unwrap().timeout_ms = 200; + assert!(compile(c, 1, &policy()).is_ok()); +} +#[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()); @@ -279,6 +292,18 @@ fn http_health_check_defaults_host_to_first_exact_vhost_dns_name() { ); } +#[test] +fn per_member_http_health_inherits_vhost_dns_even_when_global_health_is_tcp() { + let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); + c.vhosts[0].frontend.hosts = vec!["app.example.test".into()]; + assert_eq!(c.vhosts[0].load_balancing.health_check.mode, crate::balance::HealthMode::Tcp); + c.vhosts[0].backends[0].health_check = Some(crate::balance::HealthCheck { + mode: crate::balance::HealthMode::Http, path: "/healthz".into(), ..Default::default() + }); + let snapshot = compile(c, 1, &policy()).unwrap(); + let pool = &snapshot.pools[0]; + assert_eq!(pool.settings.health_for(&pool.members[0].definition).host.as_deref(), Some("app.example.test")); +} #[test] fn explicit_http_health_check_host_override_wins_over_vhost_hostname() { let mut c = demo_config("127.0.0.1:9000".parse().unwrap()); diff --git a/src/pages.rs b/src/pages.rs index e97fcb4..89e4200 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -57,6 +57,11 @@ pub struct GlobalPages { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct VhostPages { + /// Optional status codes whose response body from an application origin is + /// replaced by the vhost/global error page (or RustProxy built-in page). + /// An empty set preserves normal application error bodies. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub intercept_statuses: Vec, /// A static/maintenance vhost response; no upstream request is made. pub response: Option, /// Only when the hostname is bound here but no path route matches. @@ -65,6 +70,11 @@ pub struct VhostPages { } #[derive(Clone)] pub struct PageServed; +/// A completed application-origin response, from a reverse-proxy backend or +/// the built-in Web Server (including PHP-FPM and cache hits). A global error +/// page must not overwrite it when origin-error interception is disabled. +#[derive(Clone, Copy)] +pub struct OriginProvidedResponse; /// Marker for the built-in no-application placeholder. The outer proxy layer /// keeps this response deliberately minimal: no cache storage and no optional /// server/diagnostic/security/compression decoration. @@ -215,6 +225,15 @@ impl GlobalPages { impl VhostPages { pub fn validate(&self) -> Result<()> { validate_errors(&self.errors)?; + ensure!( + self.intercept_statuses.len() <= ERROR_CODES.len() + && self.intercept_statuses.iter().all(|status| ERROR_CODES.contains(status)) + && { + let distinct: std::collections::HashSet<_> = self.intercept_statuses.iter().collect(); + distinct.len() == self.intercept_statuses.len() + }, + "intercepted application status codes must be unique supported HTTP error codes" + ); for page in [&self.response, &self.fallback].into_iter().flatten() { page.validate()?; } @@ -240,6 +259,36 @@ pub fn replace_error(response: &mut Response, page: &Page) { } *response = replacement; } +/// Replaces only opted-in application-origin errors, before the original +/// representation can be cached or sent. Local routing/security errors and +/// responses from internal error-fallback locations are not passed here. +/// The origin status is always preserved, independently of the selected +/// page's status override (which applies to locally generated pages only). +pub fn intercept_application_error( + mut response: Response, + vhost_pages: &VhostPages, + global_pages: &GlobalPages, +) -> Response { + let code = response.status().as_u16(); + if !vhost_pages.intercept_statuses.contains(&code) + || response.extensions().get::().is_some() + { + return response; + } + let default_page = Page { + kind: Kind::Builtin, + status: None, + html: String::new(), + }; + let selected = vhost_pages.errors.get(&code) + .or_else(|| global_pages.errors.get(&code)) + .unwrap_or(&default_page); + let mut body_only = selected.clone(); + body_only.status = None; + replace_error(&mut response, &body_only); + response +} + pub fn literal_ip_authority(value: &str) -> bool { !value.contains('@') && value diff --git a/src/pages/tests.rs b/src/pages/tests.rs index 30bf451..fa6dffe 100644 --- a/src/pages/tests.rs +++ b/src/pages/tests.rs @@ -161,4 +161,104 @@ mod tests { assert_eq!(page.serve(&Method::POST, StatusCode::OK).status(), 405); assert_eq!(page.serve(&Method::GET, StatusCode::OK).status(), 200); } + + #[tokio::test] + async fn origin_error_interception_is_opt_in_and_keeps_http_status() { + let mk_origin = || { + let mut response = Response::new(Body::from("secret application error")); + *response.status_mut() = StatusCode::NOT_FOUND; + response.headers_mut().insert("content-type", HeaderValue::from_static("text/html")); + response + }; + let defaults: VhostPages = serde_json::from_str("{}").unwrap(); + assert!(defaults.intercept_statuses.is_empty()); + let unchanged = intercept_application_error(mk_origin(), &defaults, &GlobalPages::default()); + assert_eq!(unchanged.status(), StatusCode::NOT_FOUND); + assert_eq!(unchanged.into_body().collect().await.unwrap().to_bytes(), + "secret application error"); + let configured = VhostPages { intercept_statuses: vec![404], ..VhostPages::default() }; + configured.validate().unwrap(); + let replaced = intercept_application_error(mk_origin(), &configured, &GlobalPages::default()); + assert_eq!(replaced.status(), StatusCode::NOT_FOUND); + assert_eq!(replaced.headers().get("cache-control").unwrap(), "no-store"); + assert!(replaced.extensions().get::().is_some()); + let bytes = replaced.into_body().collect().await.unwrap().to_bytes(); + assert!(String::from_utf8_lossy(&bytes).contains("404")); + assert!(!String::from_utf8_lossy(&bytes).contains("secret application error")); + } + + #[tokio::test] + async fn interception_replaces_only_the_body_even_when_page_has_a_status_override() { + let mut vhost = VhostPages { intercept_statuses: vec![404], ..VhostPages::default() }; + vhost.errors.insert(404, Page { + kind: Kind::Html, + // Overrides are valid for standalone error pages, not an origin + // response whose HTTP status must remain 404. + status: Some(503), + html: "custom 404".into(), + }); + let mut origin = Response::new(Body::from("application 404")); + *origin.status_mut() = StatusCode::NOT_FOUND; + let intercepted = intercept_application_error(origin, &vhost, &GlobalPages::default()); + assert_eq!(intercepted.status(), StatusCode::NOT_FOUND); + assert_eq!(intercepted.headers().get("cache-control").unwrap(), "no-store"); + assert_eq!(intercepted.into_body().collect().await.unwrap().to_bytes(), "custom 404"); + // Outside interception the configured status override still applies. + assert_eq!(vhost.errors.get(&404).unwrap().render(StatusCode::NOT_FOUND).status(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[tokio::test] + async fn interception_uses_vhost_then_global_page_and_preserves_challenge() { + let mut global = GlobalPages::default(); + global.errors.insert(401, Page { kind: Kind::Html, + html: "global denied".into(), status: None }); + let mut vhost = VhostPages { intercept_statuses: vec![401], ..VhostPages::default() }; + let mut response = Response::new(Body::from("application denied")); + *response.status_mut() = StatusCode::UNAUTHORIZED; + response.headers_mut().insert("www-authenticate", HeaderValue::from_static("Basic realm=\"app\"")); + response.headers_mut().insert("set-cookie", HeaderValue::from_static("secret=1")); + response.headers_mut().insert("content-encoding", HeaderValue::from_static("gzip")); + let global_response = intercept_application_error(response, &vhost, &global); + assert_eq!(global_response.headers().get("www-authenticate").unwrap(), "Basic realm=\"app\""); + assert!(!global_response.headers().contains_key("set-cookie")); + assert!(!global_response.headers().contains_key("content-encoding")); + assert_eq!(global_response.into_body().collect().await.unwrap().to_bytes(), "global denied"); + + vhost.errors.insert(401, Page { kind: Kind::Html, + html: "local denied".into(), status: None }); + let mut response = Response::new(Body::from("application denied")); + *response.status_mut() = StatusCode::UNAUTHORIZED; + let local = intercept_application_error(response, &vhost, &global); + assert_eq!(local.into_body().collect().await.unwrap().to_bytes(), "local denied"); + } + + #[tokio::test] + async fn interception_skips_nonmatching_and_already_served_responses() { + let vhost = VhostPages { intercept_statuses: vec![404], ..VhostPages::default() }; + let mut response = Response::new(Body::from("ordinary ok")); + *response.status_mut() = StatusCode::OK; + let untouched = intercept_application_error(response, &vhost, &GlobalPages::default()); + assert_eq!(untouched.into_body().collect().await.unwrap().to_bytes(), "ordinary ok"); + let served = Page { kind: Kind::Html, status: Some(404), html: "internal fallback".into() } + .render(StatusCode::NOT_FOUND); + let preserved = intercept_application_error(served, &vhost, &GlobalPages::default()); + assert_eq!(preserved.into_body().collect().await.unwrap().to_bytes(), "internal fallback"); + } + + #[test] + fn intercepted_error_code_configuration_is_validated_and_backwards_compatible() { + let mut pages: VhostPages = serde_json::from_str("{\"response\": null}").unwrap(); + assert!(pages.intercept_statuses.is_empty()); + pages.intercept_statuses = vec![404, 503]; + pages.validate().unwrap(); + let json = serde_json::to_string(&pages).unwrap(); + assert!(json.contains("\"intercept_statuses\":[404,503]")); + pages.intercept_statuses = vec![404, 404]; + assert!(pages.validate().is_err()); + pages.intercept_statuses = vec![302]; + assert!(pages.validate().is_err()); + pages.intercept_statuses.clear(); + pages.validate().unwrap(); + assert!(!serde_json::to_string(&pages).unwrap().contains("intercept_statuses")); + } } diff --git a/src/proxy/error_fallback.rs b/src/proxy/error_fallback.rs index ed40a85..a91352c 100644 --- a/src/proxy/error_fallback.rs +++ b/src/proxy/error_fallback.rs @@ -181,3 +181,29 @@ async fn execute_error_fallback( } Some(response) } + +/// Called only for a response supplied by the Web Server or an HTTP origin. +/// Routing/security failures and internal fallback responses are excluded. +fn intercept_route_origin_error( + response: Response, + route: &crate::config::CompiledRoute, + snapshot: &crate::config::Snapshot, +) -> Response { + // An internal named location is the final, administrator-selected fallback + // representation. Interception inside its nested forward_request would + // otherwise erase its 404 HTML before ErrorFallbackResponse is attached. + if route.internal { + return response; + } + let mut response = if let Some(frontend) = route.frontend.as_ref() { + crate::pages::intercept_application_error( + response, + &frontend.pages, + &snapshot.envelope.config.pages, + ) + } else { + response + }; + response.extensions_mut().insert(crate::pages::OriginProvidedResponse); + response +} diff --git a/src/proxy/forward/cache_lookup.rs b/src/proxy/forward/cache_lookup.rs index c211cb7..869b130 100644 --- a/src/proxy/forward/cache_lookup.rs +++ b/src/proxy/forward/cache_lookup.rs @@ -121,7 +121,7 @@ if let Some(ticket) = &cache_ticket { hit.resident_age_seconds, ); hit.response.extensions_mut().insert(ResponseRulesApplied); - return hit.response; + return intercept_route_origin_error(hit.response, route, &snapshot); } state.cache.record(scope, &host, "MISS", "cache_miss"); miss_recorded = true; @@ -188,7 +188,7 @@ let serve_stale = |reason: &'static str, elapsed: Duration, origin_backend: &str exchange.cache_status.store(1, Ordering::Relaxed); *exchange.cache_reason.lock().unwrap_or_else(|e| e.into_inner()) = reason; } - Some(hit.response) + Some(intercept_route_origin_error(hit.response, route, &snapshot)) }; if only_cached { let mut response = error( @@ -281,7 +281,7 @@ if handshake.is_none() .lock() .unwrap_or_else(|e| e.into_inner()) = reason; } - return hit.response; + return intercept_route_origin_error(hit.response, route, &snapshot); } collapse_rounds = collapse_rounds.saturating_add(1); if collapse_rounds >= 8 { diff --git a/src/proxy/forward/response.rs b/src/proxy/forward/response.rs index 9e1d709..fe6e12b 100644 --- a/src/proxy/forward/response.rs +++ b/src/proxy/forward/response.rs @@ -7,6 +7,10 @@ ).await { return fallback; } + // Keep the upstream body as hyper::body::Incoming until the + // HTTP/2 WebSocket and regular response paths have adapted it. + // The opt-in application error page is applied to Response + // in success_prepare.rs, before transformation/cache admission. let (origin_validation_headers, origin_plan, origin_reason, mut response) = include!("success_prepare.rs"); include!("success_finish.rs") diff --git a/src/proxy/forward/success_finish.rs b/src/proxy/forward/success_finish.rs index 66f5de8..53c758c 100644 --- a/src/proxy/forward/success_finish.rs +++ b/src/proxy/forward/success_finish.rs @@ -87,7 +87,7 @@ if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent { .unwrap_or_else(|e| e.into_inner()) = "cache_revalidated"; } state.cache.record(scope, &host, "HIT", "cache_revalidated"); - return revalidated; + return intercept_route_origin_error(revalidated, route, &snapshot); } } } diff --git a/src/proxy/forward/success_prepare.rs b/src/proxy/forward/success_prepare.rs index 2c4f1ce..6f30287 100644 --- a/src/proxy/forward/success_prepare.rs +++ b/src/proxy/forward/success_prepare.rs @@ -333,6 +333,33 @@ let mut response = if h3_tunnel { } Response::from_parts(parts, Body::new(body)) }; +// The upstream HTTP client returns Response. Adapt it +// before rendering a RustProxy error page, so HTTP/2 WebSocket tunnels can +// continue using the original Incoming body without type erasure. +// An internal named-location fallback has already won earlier in response.rs. +let intercept_origin_error = !route.internal && route.frontend.as_ref().is_some_and(|frontend| { + frontend.pages.intercept_statuses.contains(&response.status().as_u16()) +}); +// Mark every upstream response as application-supplied, even if this vhost +// does not intercept its status. The observer must not overwrite it merely +// because a global 404/5xx error page exists. +response = intercept_route_origin_error(response, route, &snapshot); +if intercept_origin_error { + // The earlier response rules modified the original origin headers; + // reapply them to the newly rendered page so the vhost's header policy + // remains effective. Do not allow a custom header rule to cache errors. + apply_response_rules( + response.headers_mut(), + &snapshot, + route.frontend.as_deref(), + Some(route), + admin_control_plane, + ); + response.headers_mut().insert( + "cache-control", + HeaderValue::from_static("no-store"), + ); +} response.extensions_mut().insert(ResponseRulesApplied); response .extensions_mut() diff --git a/src/proxy/forward/web_source.rs b/src/proxy/forward/web_source.rs index 0421645..89da405 100644 --- a/src/proxy/forward/web_source.rs +++ b/src/proxy/forward/web_source.rs @@ -102,7 +102,7 @@ } response.extensions_mut().insert(ResponseRulesApplied); response.extensions_mut().insert(StaticSourceResponse); - return response; + return intercept_route_origin_error(response, route, &snapshot); } } } @@ -115,7 +115,7 @@ // await: the filesystem/PHP source owns the body for its full lifetime. let static_body = std::mem::replace(request.body_mut(), Body::empty()); let started = Instant::now(); - let mut response = crate::web_server::serve(service, request_method.clone(), + let response = crate::web_server::serve(service, request_method.clone(), request_headers.clone(), request_uri, &service_path, static_body, crate::web_server::RequestContext { client_ip: context.client_ip, @@ -130,6 +130,7 @@ response.status(), &fallback_request, &fallback_body, ).await { return fallback; } + let mut response = intercept_route_origin_error(response, route, &snapshot); let origin_headers = response.headers().clone(); let original_plan = crate::cache::plan(response.status(), &origin_headers, cache_settings, started.elapsed()); @@ -151,6 +152,10 @@ }); apply_response_rules(response.headers_mut(), &snapshot, route.frontend.as_deref(), Some(route), false); + if response.extensions().get::().is_some() { + // A vhost header rule must not make a replaced error cacheable. + response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store")); + } response.extensions_mut().insert(ResponseRulesApplied); response.extensions_mut().insert(StaticSourceResponse); diff --git a/src/proxy/tests/authority_tests.rs b/src/proxy/tests/authority_tests.rs index 101bcd4..c4f732c 100644 --- a/src/proxy/tests/authority_tests.rs +++ b/src/proxy/tests/authority_tests.rs @@ -296,7 +296,6 @@ mod authority_tests { "12.00ms" ); } -} #[test] fn upstream_uri_preserves_nested_url_query_without_rejecting_valid_separators() { @@ -402,11 +401,64 @@ async fn fallback_terminal_404_html_takes_precedence_over_builtin_error_page() { Bytes::from_static(b"ReactPHP 404") ); - // A normal response with no fallback is still eligible for the configured - // global/vhost error-page replacement. + // A proxy-generated response is eligible for configured global/vhost + // pages even when interception of *application* errors is disabled. let response = Response::builder() .status(StatusCode::NOT_FOUND) .body(Body::empty()) .unwrap(); assert!(should_replace_error_page(&response, false)); } + +#[tokio::test] +async fn application_404_is_not_overwritten_by_global_pages_when_interception_is_off() { + use http_body_util::BodyExt; + + for source in ["reverse_proxy", "web_server_php"] { + let mut response = Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::from(format!("{source}: origin 404 body"))) + .unwrap(); + // Both paths use intercept_route_origin_error to set this marker + // once the origin response reaches Response. + response.extensions_mut().insert(crate::pages::OriginProvidedResponse); + assert!(!should_replace_error_page(&response, false), "{source}"); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(bytes.as_ref(), format!("{source}: origin 404 body").as_bytes()); + } +} + +#[tokio::test] +async fn compiled_vhost_origin_interception_controls_application_404() { + for enabled in [false, true] { + let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); + config.pages.errors.insert(404, crate::pages::Page { + kind: crate::pages::Kind::Html, + status: Some(503), + html: "RustProxy custom error page".into(), + }); + if enabled { + config.vhosts[0].pages.intercept_statuses = vec![404]; + } + let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap(); + let route = snapshot.select("localhost", "/").unwrap(); + let response = Response::builder().status(StatusCode::NOT_FOUND) + .body(Body::from("application custom 404")) + .unwrap(); + let response = intercept_route_origin_error(response, route, &snapshot); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!(response.extensions().get::().is_some()); + assert!(!should_replace_error_page(&response, false)); + assert_eq!(response.extensions().get::().is_some(), enabled); + if enabled { + assert_eq!(response.headers().get("cache-control").unwrap(), "no-store"); + } + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + if enabled { + assert_eq!(bytes, "RustProxy custom error page"); + } else { + assert_eq!(bytes, "application custom 404"); + } + } +} +} diff --git a/src/proxy/upstream_helpers.rs b/src/proxy/upstream_helpers.rs index 6ca8d79..78ffa66 100644 --- a/src/proxy/upstream_helpers.rs +++ b/src/proxy/upstream_helpers.rs @@ -14,6 +14,8 @@ fn request_id_hash(sequence: u64) -> String { fn should_replace_error_page(response: &Response, minimal_welcome: bool) -> bool { !minimal_welcome && response.extensions().get::().is_none() + && response.extensions().get::().is_none() + && response.extensions().get::().is_none() && response .extensions() .get::() diff --git a/src/runtime/tests.rs b/src/runtime/tests.rs index b0a88b2..ed6166d 100644 --- a/src/runtime/tests.rs +++ b/src/runtime/tests.rs @@ -60,6 +60,7 @@ mod cache_revision_tests { wire_only.cache.expose_hits = !wire_only.cache.expose_hits; wire_only.cache.expose_cache_status = !wire_only.cache.expose_cache_status; wire_only.cache.expose_resident_age = !wire_only.cache.expose_resident_age; + wire_only.cache.expose_only_hits = !wire_only.cache.expose_only_hits; wire_only.cache.via_header = Some("RustProxy edge".into()); assert_eq!(cache_revision(&wire_only), identity); assert!(matches!( @@ -67,6 +68,11 @@ mod cache_revision_tests { CacheInvalidation::None )); + let mut vhost_diagnostics = base.clone(); + vhost_diagnostics.vhosts[0].cache.expose_only_hits = Some(true); + assert_eq!(cache_revision(&vhost_diagnostics), identity); + assert!(matches!(cache_invalidation(&base, &vhost_diagnostics), CacheInvalidation::None)); + let mut cache = base.clone(); cache.cache.max_entries += 1; let cache_identity = cache_revision(&cache); diff --git a/src/runtime/types.rs b/src/runtime/types.rs index 376ecb5..c74f1f5 100644 --- a/src/runtime/types.rs +++ b/src/runtime/types.rs @@ -99,6 +99,7 @@ fn cache_revision(config: &config::Config) -> u64 { cache.expose_hits = false; cache.expose_cache_status = false; cache.expose_resident_age = false; + cache.expose_only_hits = false; cache.via_header = None; // Resource-only transform controls decide how much optimization work may // happen concurrently, but do not change the transformed representation. @@ -108,12 +109,18 @@ fn cache_revision(config: &config::Config) -> u64 { transform.preset = None; transform.memory_budget_bytes = 0; transform.max_inflight = 0; + // A per-vhost diagnostics override is also presentation-only and must not + // invalidate cached objects when switched from inherited to HIT-only. + let mut vhosts = config.vhosts.clone(); + for vhost in &mut vhosts { + vhost.cache.expose_only_hits = None; + } let encoded = serde_json::to_vec(&( &cache, &transform, &config.headers, &config.forwarding, - &config.vhosts, + &vhosts, )) .expect("serializable cache compatibility view"); let digest = Sha256::digest(encoded); diff --git a/src/vhost/model.rs b/src/vhost/model.rs index fcb6809..da6fee8 100644 --- a/src/vhost/model.rs +++ b/src/vhost/model.rs @@ -4,7 +4,7 @@ use crate::{ headers::HeaderRules, security::SecurityHeaders, }; -use anyhow::{ensure, Result}; +use anyhow::{ensure, Context, Result}; use serde::{Deserialize, Serialize}; use std::net::{IpAddr, SocketAddr}; fn yes() -> bool { @@ -156,7 +156,6 @@ pub struct PathRewrite { pub mode: RewriteMode, pub value: String, } - fn response_status() -> u16 { 200 } @@ -403,6 +402,10 @@ pub struct PoolMember { pub backup: bool, #[serde(default)] pub drain: bool, + #[serde(default = "yes")] + pub health_check_enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub health_check: Option, #[serde(default)] pub allow_private: bool, #[serde(default)] diff --git a/src/vhost/validation.rs b/src/vhost/validation.rs index c0f8ce4..330ff4a 100644 --- a/src/vhost/validation.rs +++ b/src/vhost/validation.rs @@ -217,6 +217,13 @@ impl Vhost { } } self.load_balancing.validate()?; + for member in &self.backends { + if let Some(custom) = &member.health_check { + let mut settings = self.load_balancing.clone(); + settings.health_check = custom.clone(); + settings.validate().with_context(|| format!("backend {} health check", member.id))?; + } + } self.proxy_timeouts.validate()?; self.rate_limit.validate()?; if let Some(policy) = &self.tls_policy { diff --git a/tools/checks/backend_cache_0297_ui_test.mjs b/tools/checks/backend_cache_0297_ui_test.mjs new file mode 100644 index 0000000..07bbe7d --- /dev/null +++ b/tools/checks/backend_cache_0297_ui_test.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +// Exercise the shipped 0.29.7 backend monitor editor with a minimal DOM double. +// This checks browser form behavior, not health probing or compiled Rust code. +import fs from 'node:fs'; +import vm from 'node:vm'; + +const root = process.cwd(); +const core = fs.readFileSync(`${root}/src/admin/web/vhosts/core.js`, 'utf8'); +const editor = fs.readFileSync(`${root}/src/admin/web/vhosts/editor.js`, 'utf8'); +const settings = fs.readFileSync(`${root}/src/admin/web/app/settings.js`, 'utf8'); +const html = fs.readFileSync(`${root}/src/admin/web/index.html`, 'utf8'); +const start = core.indexOf(' function memberRow('); +const end = core.indexOf(' function countryRateEditor(', start); +if (start < 0 || end <= start) throw new Error('Backend editor code not found'); +const check = (condition, reason) => { if (!condition) throw new Error(reason); }; + +class Element { + constructor(tag = 'div', value = '', css = '') { + this.tag = tag; + this.value = value == null ? '' : String(value); + this.checked = false; + this.hidden = false; + this.disabled = false; + this.name = ''; + this.children = []; + this.listeners = new Map(); + this.classes = new Set(css.split(/\s+/).filter(Boolean)); + this.textContent = this.value; + } + append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } } + addEventListener(type, handler) { this.listeners.set(type, handler); } + dispatch(type) { this.listeners.get(type)?.(); } + querySelectorAll() { + const found = []; + const walk = node => { for (const child of node.children) { + if (child.tag === 'input' || child.tag === 'select') found.push(child); + walk(child); + }}; + walk(this); + return found; + } +} +const el = (tag, value, css) => new Element(tag, value, css); +const text = (tag, label, css) => el(tag, label, css); +const field = (parent, name, label, value, type = 'text') => { + const wrapper = el('label'); + const input = el(type === 'select' ? 'select' : 'input', value); + input.name = name; + input.checked = !!value; + wrapper.append(input); + parent.append(wrapper); + return input; +}; +const button = (label, handler) => { const node = el('button', label); node.addEventListener('click', handler); return node; }; +const note = (parent, label) => { const node = text('span', label); parent.append(node); return node; }; +const parent = el('div'); +const value = { + id: 'error_404', url: 'http://127.0.0.1:8282', weight: 1, enabled: true, + pool_eligible: false, health_check_enabled: false, preserve_host: true, + health_check: { mode: 'http', path: '/ready', host: 'errors.example.test', + interval_ms: 10000, timeout_ms: 1000, slow_response_ms: 500, fall: 2, + rise: 1, expected_status_min: 200, expected_status_max: 399 } +}; +vm.runInNewContext(`${core.slice(start, end)}\nmemberRow(parent, value);`, { + parent, value, el, text, field, button, note, + t: key => key, toast: () => {}, changed: () => {}, + appConfirm: async () => true, adminListenerBackendCandidate: () => false +}); +const row = parent.children[0]; +const input = name => row.querySelectorAll().find(item => item.name === name); +const fields = []; +const visit = node => { if (node.classes.has('vh-backend-health-fields')) fields.push(node); for (const child of node.children) visit(child); }; +visit(row); +check(fields.length === 1, 'Custom backend health panel not created'); +check(input('member-pool').checked === false, 'Internal 404 backend accidentally joined balanced pool'); +check(input('member-enabled').checked === true, 'Backend was disabled when monitoring was disabled'); +check(input('member-health-enabled').checked === false, 'Explicit monitoring off not restored'); +check(input('member-health-custom').checked === true, 'Custom health not restored'); +check(input('member-health-mode').value === 'http', 'Member health mode lost'); +check(input('member-health-path').value === '/ready', 'Member health path lost'); +check(input('member-health-interval').value === '10000', 'Member check interval lost'); +check(fields[0].hidden === false, 'Custom health fields hidden despite override'); +input('member-health-custom').checked = false; +input('member-health-custom').dispatch('change'); +check(fields[0].hidden && input('member-health-path').disabled, 'Inherit switch did not hide/disable overrides'); +input('member-health-custom').checked = true; +input('member-health-custom').dispatch('change'); +check(!fields[0].hidden && !input('member-health-path').disabled, 'Custom fields failed to re-enable'); +input('member-health-enabled').checked = true; +check(input('member-pool').checked === false, 'Monitoring switch changed balanced-pool eligibility'); + +for (const part of [ + 'health_check_enabled: yes("member-health-enabled", row)', + 'health_check: yes("member-health-custom", row)', + 'expose_only_hits: tri("vh-cache-only-hits")', +]) check((editor + core).includes(part), `Missing saved field: ${part}`); +check(editor.includes('tri(cacheOptions, "vh-cache-only-hits", "cacheOnlyHits", cache.expose_only_hits)'), 'Vhost cache override does not roundtrip'); +check(html.includes('id="cache-only-hits"') && settings.includes('expose_only_hits: $("cache-only-hits").checked'), 'Global cache checkbox does not save'); +check(settings.includes('$("cache-only-hits").checked = !!c.expose_only_hits'), 'Global cache checkbox does not load'); +console.log('PASS: 0.29.7 backend monitor and custom policy form state, independent pool status, global/vhost cache diagnostics toggles.'); diff --git a/tools/checks/check_source.py b/tools/checks/check_source.py index c931e7b..fd28e68 100755 --- a/tools/checks/check_source.py +++ b/tools/checks/check_source.py @@ -24,7 +24,7 @@ def read_docker_agent_source() -> str: preflight(root) manifest = tomllib.loads((root / "Cargo.toml").read_text()) -assert manifest["package"]["version"] == "0.29.6" +assert manifest["package"]["version"] == "0.29.7" 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"] @@ -32,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.29.6"' in lock + assert 'name = "rust-proxy"\nversion = "0.29.7"' 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.29.6", - "CHANGELOG.md": "## 0.29.6", - "ROADMAP.md": "## 0.29.6", - "AGENTS.md": "Current source version: 0.29.6.", - "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.29.6", + "README.md": "# RustProxy 0.29.7", + "CHANGELOG.md": "## 0.29.7", + "ROADMAP.md": "## 0.29.7", + "AGENTS.md": "Current source version: 0.29.7.", + "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.29.7", }.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" @@ -285,7 +285,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.29.6"', '"/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.29.7"', '"/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 @@ -1396,7 +1396,7 @@ 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.29.6"' in openapi +assert '"RuntimeInformation"' in openapi and '"version": "0.29.7"' in openapi # 0.24.1 compiler and hostname-cardinality regressions. tls_0241 = rust_source(root, "src/tls.rs") @@ -1635,5 +1635,5 @@ for marker_0282 in [ assert 'Zewnętrzne ścieżki plików' in (root / 'langs/pl_pl.json').read_text() assert 'setfacl -m u:rustproxy:--x /etc/ssl/' in (root / 'docs/V0.28.2_IMPLEMENTATION.md').read_text() -print("PASS: 0.29.6 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/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-v9 migrations.") +print("PASS: 0.29.7 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/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-v9 migrations.") print("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.") diff --git a/tools/checks/docs_check.py b/tools/checks/docs_check.py index 2a43b75..d3ba034 100755 --- a/tools/checks/docs_check.py +++ b/tools/checks/docs_check.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Rust-independent documentation/Reader consistency checks for the 0.29.6 release.""" +"""Rust-independent documentation/Reader consistency checks for the 0.29.7 release.""" from pathlib import Path import re from rust_source import rust_source @@ -64,11 +64,11 @@ 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.29.6") +assert readme.startswith("# RustProxy 0.29.7") 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.29.6" and "## 0.29.1" in roadmap and "## 0.29.0" in roadmap and "## 0.28.2" in roadmap and "## 0.28.1" in roadmap and "## 0.27.3" in roadmap and "## 0.27.2" in roadmap and "## 0.27.1" in roadmap and "## 0.27.0" in roadmap and "## 0.26.7" in roadmap and "## 0.26.6" in roadmap and "## 0.26.5" in roadmap and "## 0.26.4" in roadmap and "## 0.26.0" in roadmap and "## 0.25.5" in roadmap and "## 0.25.4" in roadmap and "## 0.25.3" in roadmap and "## 0.25.2" in roadmap and "## 0.25.1" in roadmap and "## 0.25.0" in roadmap +assert roadmap.startswith("# RustProxy roadmap") and "## 0.29.7" and "## 0.29.1" in roadmap and "## 0.29.0" in roadmap and "## 0.28.2" in roadmap and "## 0.28.1" in roadmap and "## 0.27.3" in roadmap and "## 0.27.2" in roadmap and "## 0.27.1" in roadmap and "## 0.27.0" in roadmap and "## 0.26.7" in roadmap and "## 0.26.6" in roadmap and "## 0.26.5" in roadmap and "## 0.26.4" in roadmap and "## 0.26.0" in roadmap and "## 0.25.5" in roadmap and "## 0.25.4" in roadmap and "## 0.25.3" in roadmap and "## 0.25.2" in 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") for release in ["0.29.1", "0.29.0", "0.28.2", "0.28.1", "0.28.0", "0.27.3", "0.27.2", "0.27.1", "0.27.0", "0.26.7", "0.26.6", "0.26.5", "0.26.4", "0.26.1", "0.26.0", "0.25.6", "0.25.5", "0.25.4", "0.25.3"]: diff --git a/tools/checks/preflight.py b/tools/checks/preflight.py index 603fac7..2a91be7 100755 --- a/tools/checks/preflight.py +++ b/tools/checks/preflight.py @@ -75,10 +75,10 @@ def check(root: Path, repair_known: bool = False) -> list[str]: 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/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.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/vhost/error_fallback.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/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/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.5_AUDIT.md", "docs/V0.25.4_AUDIT.md", "docs/V0.25.3_AUDIT.md", "docs/V0.25.2_AUDIT.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "tools/checks/docs_check.py", "tools/checks/web_server_contracts.py", "tools/checks/rust_source.py", "tools/checks/rust_module_contracts.py", "tools/checks/source_module_contracts.py", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/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", "grafana/rustproxy-overview.json"]: if not (root / path).is_file(): - raise ValueError(f"Incomplete RustProxy 0.29.6 source tree: missing {path}") + raise ValueError(f"Incomplete RustProxy 0.29.7 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.29.6 entry point; extract the complete package, not individual files.") + raise ValueError("src/lib.rs is not the 0.29.7 entry point; extract the complete package, not individual files.") return messages diff --git a/tools/checks/release_smoke.py b/tools/checks/release_smoke.py index 2c19fd6..4ab7876 100755 --- a/tools/checks/release_smoke.py +++ b/tools/checks/release_smoke.py @@ -135,7 +135,7 @@ 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.29.6"' in (target / "Cargo.toml").read_text(encoding="utf-8") + assert 'version = "0.29.7"' 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() @@ -146,7 +146,7 @@ def upgrade_smoke(tmp: Path) -> None: 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.29.6" + assert manifest["to_version"] == "0.29.7" assert manifest["removed_obsolete_runtime_state"] is True assert not legacy.exists() assert not (backup / "legacy-data").exists() @@ -619,7 +619,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.29.6"' in openapi_0241 + assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.29.7"' 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 @@ -1109,7 +1109,7 @@ def source_contract_smoke() -> None: 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") assert '"/api/config/history/restore"' in openapi - for marker in ['"version": "0.29.6"', '"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.29.7"', '"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"']: @@ -1314,7 +1314,7 @@ def main() -> None: source_contract_smoke() container_contract_smoke() grafana_contract_smoke() - print("PASS: 0.29.6 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.") + print("PASS: 0.29.7 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.") if __name__ == "__main__": diff --git a/tools/checks/vhost_origin_errors_0297_ui_test.mjs b/tools/checks/vhost_origin_errors_0297_ui_test.mjs new file mode 100644 index 0000000..2be597f --- /dev/null +++ b/tools/checks/vhost_origin_errors_0297_ui_test.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +// 0.29.7 origin error-page interception form roundtrip and input validation. +// Browser DOM is simulated; this is NOT a Rust/protocol integration test. +import fs from 'node:fs'; +import vm from 'node:vm'; + +const source = fs.readFileSync('src/admin/web/management.js', 'utf8'); +const start = source.indexOf(' function vhostEditor('); +const end = source.indexOf(' let global = null;', start); +if (start === -1 || end <= start) throw new Error('Missing current vhost pages form'); +const codes = [400,401,403,404,408,413,429,500,502,503,504]; +const check = (valid, message) => { if (!valid) throw new Error(message); }; +class Element { + constructor(tag='div', text='') { + this.tag = tag; + this.textContent = text; + this.value = ''; + this.checked = false; + this.disabled = false; + this.children = []; + this.listeners = new Map(); + } + append(...nodes) { this.children.push(...nodes); } + addEventListener(name, callback) { this.listeners.set(name, callback); } + dispatch(name) { this.listeners.get(name)?.(); } +} +const el = (tag, text) => new Element(tag, text); +const label = (tag, key) => new Element(tag, key); +const pageEditor = (parent, label, initial) => { parent.append(el('div', label)); return {read:()=>initial||null}; }; +const errorsEditor = (parent, initial) => { parent.append(el('div')); return {read:()=>initial||{}}; }; +const context = { + pageEditor, errorsEditor, el, label, codes, + document: { createElement: tag => el(tag) }, + t: key => key, +}; +vm.createContext(context); +vm.runInContext(`${source.slice(start,end)}\nthis.makeEditor = vhostEditor;`,context); +const build = values => { + const parent = el('div'); + const editor = context.makeEditor(parent, values); + const section = parent.children[2]; + check(section.tag === 'section', 'Missing origin interception section'); + const input = section.children[0].children[0]; + const statuses = section.children[1].children[1]; + return {editor, input, statuses, section}; +}; +const prior = build({}); +check(!prior.input.checked && prior.statuses.disabled, 'Interception must default off'); +check(JSON.stringify(prior.editor.read().intercept_statuses) === '[]', 'Old config must remain unchanged'); +prior.input.checked = true; prior.input.dispatch('change'); +check(!prior.statuses.disabled, 'Status list must enable with checkbox'); +check(JSON.stringify(prior.editor.read().intercept_statuses) === '[404]', 'Default status must be HTTP 404'); +prior.statuses.value = '404, 500, 503'; +check(JSON.stringify(prior.editor.read().intercept_statuses) === '[404,500,503]', 'Explicit statuses lost'); +for (const invalid of ['302', '404,404', '404.0', '0x194', 'abc', '', '200', '600']) { + prior.statuses.value = invalid; + let rejected = false; + try { prior.editor.read(); } catch { rejected = true; } + check(rejected, `Invalid interception status should be rejected: ${invalid}`); +} +prior.input.checked = false; prior.input.dispatch('change'); +check(prior.statuses.disabled && prior.editor.read().intercept_statuses.length===0,'Disabling interception must clear saved statuses'); +const saved = build({intercept_statuses:[401,404],errors:{404:{kind:'html',status:null,html:'local'}}}); +check(saved.input.checked && !saved.statuses.disabled && saved.statuses.value==='401, 404','Existing interception settings not restored'); +check(saved.editor.read().errors['404'].html==='local', 'Custom error page lost'); +const rust = fs.readFileSync('src/proxy/error_fallback.rs','utf8'); +check(rust.includes('if route.internal {\n return response;'), 'Internal fallback response is not protected from interception'); +const web = fs.readFileSync('src/proxy/forward/web_source.rs','utf8'); +const proxy = fs.readFileSync('src/proxy/forward/response.rs','utf8'); +check(web.includes('let mut response = intercept_route_origin_error(response, route, &snapshot);'), 'Web Server origin interception not in response path'); +const prepared = fs.readFileSync('src/proxy/forward/success_prepare.rs','utf8'); +check(proxy.includes('include!("success_prepare.rs")'), 'Reverse Proxy response preparation missing'); +check(!proxy.includes('let response = intercept_route_origin_error(response, route, &snapshot);'), 'Do not convert Incoming body before HTTP/2 WebSocket setup'); +check(prepared.includes('response = intercept_route_origin_error(response, route, &snapshot);'), 'Origin error interception missing after body adaptation'); +const marker = fs.readFileSync('src/proxy/error_fallback.rs','utf8'); +check(marker.includes('response.extensions_mut().insert(crate::pages::OriginProvidedResponse)'), 'Origin responses must be marked for the outer observer'); +const observer = fs.readFileSync('src/proxy/upstream_helpers.rs','utf8'); +check(observer.includes('response.extensions().get::().is_none()'), 'Outer response observer must not unconditionally replace origin 404/5xx'); +const pages = fs.readFileSync('src/pages.rs','utf8'); +check(pages.includes('body_only.status = None;'), 'Application error interception must preserve the upstream HTTP code even when page overrides status'); +const interceptPosition = prepared.indexOf('response = intercept_route_origin_error(response, route, &snapshot);'); +check(interceptPosition > prepared.indexOf('Response::from_parts(parts, Body::new(body))'), 'Origin interception must operate on Response'); +check(interceptPosition < prepared.indexOf('response.extensions_mut().insert(ResponseRulesApplied)'), 'Intercepted origin response must receive final response metadata'); +check(prepared.includes('websocket::wrap_h2_response_body(body, session, exchange)'), 'HTTP/2 WebSocket tunnel body must remain compatible with Incoming'); +const reappliedRules = prepared.indexOf('apply_response_rules(', interceptPosition); +const errorNoStore = prepared.indexOf('HeaderValue::from_static("no-store")', interceptPosition); +check(reappliedRules > interceptPosition && errorNoStore > reappliedRules, 'Intercepted pages must reapply header policy and then force no-store'); +const healthLoop = fs.readFileSync('src/balance/lease_runtime.rs','utf8'); +check(healthLoop.includes('checks.spawn(probe(Arc::clone(member), health.clone()))'), 'Health probe must take an owned Arc'); +const html = fs.readFileSync('src/admin/web/index.html','utf8'); +check(html.includes('id="php-fpm-profiles-help"') && html.includes('id="php-help-dialog"'), 'Shared PHP-FPM profiles lack help modal'); +for (const key of ['phpDocsScope','phpDocsValuesText','phpDocsAdminText','phpHintPathInfo']) { + check(html.includes(`data-i18n="${key}"`), `PHP help does not explain ${key}`); +} +console.log('PASS: application error interception form default/roundtrip/validation, internal fallback protection, PHP help access.'); diff --git a/tools/checks/web_service_ui_test.mjs b/tools/checks/web_service_ui_test.mjs index 884682a..b3a03d2 100644 --- a/tools/checks/web_service_ui_test.mjs +++ b/tools/checks/web_service_ui_test.mjs @@ -51,7 +51,7 @@ const ids = [ "web-service-response-headers","web-service-rewrites","web-service-root","web-service-search", "web-service-symlink-policy","web-service-tabs","web-service-try-files", "web-service-php-profile", "web-service-php-profiles", "web-service-php-profiles-open", "php-fpm-profiles-dialog", - "php-fpm-profiles-list", "php-fpm-profiles-close", "php-fpm-profile-add" + "php-fpm-profiles-list", "php-fpm-profiles-close", "php-fpm-profile-add", "php-fpm-profiles-help" ]; const elements = new Map(ids.map(id => [id, new Element(id)])); const pages = ["general","routing","htaccess","mime","cache","delivery","php","security"].map(name => { @@ -193,6 +193,14 @@ context.envelope.config.php_fpm_profiles = [{ // Both entry points must share the existing modal; the toolbar shortcut // belongs next to New Web Service rather than inside the service editor. const html = fs.readFileSync("src/admin/web/index.html", "utf8"); +const profileDialog = html.split('id="php-fpm-profiles-dialog"')[1]?.split('')[0] ?? ''; +const listAt = profileDialog.indexOf('id="php-fpm-profiles-list"'); +const addAt = profileDialog.indexOf('id="php-fpm-profile-add"'); +const dividerAt = profileDialog.indexOf('class="php-profile-help-divider"'); +const helpAt = profileDialog.indexOf('id="php-fpm-profiles-help"'); +if (!(listAt >= 0 && listAt < addAt && addAt < dividerAt && dividerAt < helpAt)) { + throw new Error("PHP-FPM syntax/safety help must be at the bottom of the profile modal, separated by a rule"); +} const webPanel = html.split('id="panel-web-server"')[1]?.split("")[0] ?? ""; const toolbar = webPanel.match(/
([\s\S]*?)<\/div>/)?.[1] ?? ""; const shortcutAt = toolbar.indexOf('id="web-service-php-profiles-open"'); @@ -209,6 +217,26 @@ if (!elements.get("php-fpm-profiles-dialog").open) throw new Error("PHP-FPM prof const profileDetail = elements.get("php-fpm-profiles-list").children[0]; if (!profileDetail || profileDetail.children[0].textContent !== "WordPress pool") throw new Error("Shared profile list missing"); const profileForm = profileDetail.children[1]; +const phpRows = profileForm.children.filter(node => node.classList.values.has("php-switch-row vh-wide")); +if (phpRows.length !== 3) throw new Error("PHP-FPM / PATH_INFO / Authorization switches do not have separate aligned rows"); +for (const row of phpRows) { + if (row.children.length !== 2 || row.children[0].children[0].type !== "checkbox" || + row.children[1].children[0].textContent !== "i" || row.children[1].children[1].children.length < 1) { + throw new Error("Profile PHP toggle lacks its checkbox label or explanation popover"); + } +} +if (!phpRows[0].children[0].children[0].checked || !phpRows[1].children[0].children[0].checked) { + throw new Error("PHP execution and PATH_INFO saved values were not restored"); +} +phpRows[1].children[0].children[0].checked = false; +if (!phpRows[0].children[0].children[0].checked) { + throw new Error("Changing PATH_INFO also disabled PHP execution"); +} +phpRows[1].children[0].children[0].checked = true; +elements.get("php-fpm-profiles-help").dispatch("click"); +if (!elements.get("php-help-dialog").open) throw new Error("PHP profile help button did not open syntax documentation"); +elements.get("php-help-close").dispatch("click"); +if (elements.get("php-help-dialog").open) throw new Error("PHP syntax help dialog did not close"); profileForm.children[1].children[1].value = "Updated WordPress pool"; await profileForm.dispatch("submit"); if (savedSection?.name !== "php-fpm-profiles" || savedSection.value[0].name !== "Updated WordPress pool") {