From 3e33982cd3f11f8febf937b7da634702d2e20f47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 9 Oct 2026 14:07:41 +0200 Subject: [PATCH] v0.29.5 --- AGENTS.md | 2 +- CHANGELOG.md | 8 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 15 +++- ROADMAP.md | 6 ++ SHA256SUMS | 37 ++++---- docs/PHP_FPM.md | 31 ++++++- docs/TEST_REPORT.md | 14 ++- src/admin/openapi.json | 2 +- src/web_server/fastcgi/protocol.rs | 2 +- src/web_server/serve.rs | 39 +++++++++ src/web_server/tests.rs | 1 + src/web_server/tests/part_01.rs | 2 +- src/web_server/tests/part_04.rs | 126 +++++++++++++++++++++++++++ tools/checks/check_source.py | 20 ++--- tools/checks/docs_check.py | 6 +- tools/checks/preflight.py | 4 +- tools/checks/release_smoke.py | 10 +-- tools/checks/web_server_contracts.py | 10 +++ 20 files changed, 287 insertions(+), 52 deletions(-) create mode 100644 src/web_server/tests/part_04.rs diff --git a/AGENTS.md b/AGENTS.md index 2658646..8c8f8b7 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.4. +compatibility. Current source version: 0.29.5. ## Delivery diff --git a/CHANGELOG.md b/CHANGELOG.md index c2bf8d6..c7b42cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ > test-only changes and packaging implementation notes were moved to > [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md). +## 0.29.5 + +- Fix Roundcube 1.7 static asset delivery: a valid `static.php/...` PHP `PATH_INFO` target executes directly, before the Web Service's `try_files` fallback can mistakenly route it to `index.php` and return HTML instead of CSS/JavaScript. +- Apply Web Service hardening and `.htaccess` restrictions to the resolved PHP script, not just the full virtual URL. +- Test the actual FastCGI params for Roundcube static assets and verify opt-in PATH_INFO behavior with the front-controller fallback preserved when disabled. +- Retain the 0.29.4 shared PHP-FPM profile manager, Web Server cache/transform fixes, SQLite schema 9 and existing saved configuration. +- Rust compilation and live Roundcube verification remain pending in this environment. + ## 0.29.4 - Expose the existing shared PHP-FPM profile manager directly beside **New Web Service** in the Web Server toolbar, without removing the shortcut in the service editor. diff --git a/Cargo.lock b/Cargo.lock index c82b02f..c5e2fae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1688,7 +1688,7 @@ dependencies = [ [[package]] name = "rust-proxy" -version = "0.29.4" +version = "0.29.5" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 7e64e05..2b17704 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-proxy" -version = "0.29.4" +version = "0.29.5" edition = "2021" rust-version = "1.88" description = "RustProxy - independent reverse proxy with local administration" diff --git a/README.md b/README.md index 3fd2a6a..ca3960b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# RustProxy 0.29.4 +# RustProxy 0.29.5 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.4 release source.** +**0.29.5 release source.** ## Layer-4 streams @@ -11,7 +11,14 @@ 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.4 +## What changed in 0.29.5 + +- Fix PHP-FPM `PATH_INFO` dispatch for existing scripts such as Roundcube 1.7 `/static.php/skins/...`: when PATH_INFO is enabled, invoke the script before `try_files` can fall back to `/index.php`. +- Keep FastCGI `SCRIPT_FILENAME`, `SCRIPT_NAME`, `PATH_INFO`, `REQUEST_URI` and the query string accurate, and check access rules on the resolved PHP script path. +- Add mock FastCGI regression tests proving that Roundcube static assets reach `static.php`; opt-out (`path_info: false`) retains the previous fallback behavior. +- No database migration or saved Web Service configuration changes. Rust compiler/runtime verification is still required before production deployment. + +## Previously in 0.29.4 - Reuse named PHP-FPM profiles across Web Services, or keep per-service PHP-FPM settings; edit profiles in the expandable Web Server modal. - Open the same PHP-FPM profile manager from the **PHP-FPM profiles** button beside **New Web Service** on the Web Server page. @@ -33,7 +40,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.4 keeps SQLite schema 9 and Cold Cache format 3. Existing 0.29.0 configuration remains compatible; new fields use safe/default-off serde defaults. Historical details are in [CHANGELOG.md](CHANGELOG.md). +0.29.5 keeps SQLite schema 9 and Cold Cache format 3. Existing 0.29.0 configuration remains compatible; new fields use safe/default-off serde defaults. Historical details are in [CHANGELOG.md](CHANGELOG.md). ### Historical 0.26.7 release notes diff --git a/ROADMAP.md b/ROADMAP.md index 0748980..47106e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,11 @@ # RustProxy roadmap +## 0.29.5 - Roundcube PHP PATH_INFO routing + +- Resolve PHP `PATH_INFO` on an existing script before `try_files` falls back to the front controller, while preserving normal URL and script security checks. +- Verify FastCGI parameters from an actual mock PHP-FPM exchange in Rust integration tests when Cargo is available. +- Retain schema 9, Cold Cache format 3 and all previous PHP-FPM profile UI and vhost-cache behavior. + ## 0.29.4 - Shared PHP-FPM profiles and local Web Server processing Current direction: 0.29.3 -> 0.29.4 diff --git a/SHA256SUMS b/SHA256SUMS index 8537486..94e453c 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,13 +1,13 @@ 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore -2b708f425f80f11e4a3e92b16b396eb6da685bcd79730839e23ddc1002871cac AGENTS.md -4cb02e7c01b2920ab4b62919094b891a775a0af233c22d448d0879b2ddda0ca3 CHANGELOG.md -6d43d2521eee7d8a0a7317c585b7fae474ecdf9c092d02d045d3891cf3ae5764 Cargo.lock -db8c2ffcea5348298a0af085e589730b42efa7e1bc57fa3d748bf6b36aec1040 Cargo.toml +03caed3d51469aba1a5ef646c9b986b79648a14f9fc58631c3e26a836eb675b9 AGENTS.md +5fc20f00dcbce7659a1366a79c9adc65644db4790997904f66a40f611dc3e6b0 CHANGELOG.md +733220d3b7f4b2c5b79c0d152f6e046cdeb34a29ce0a4e51565078e7f1562773 Cargo.lock +88b2e5d4b64288177b9ecd4319616a89984b8cc4654cc92e615c685f3c86685f Cargo.toml 75b008f237b2226c62a7721b7901018ce83bfc2726f047a83a17c66fcb799f3b Dockerfile -6598d33c7a56362c71011c71ac1c05d34241996f9cff97e4d43593e7dda735c2 README.md -1369ae49b2bfc7c205323df713be9272c6971523e7f615ef6e86c1cfe071884c ROADMAP.md +2c7a5c76f1cab0ec0c3a4ac24d97c3e94505b88d6fb177e7a81c9f63be071de3 README.md +b91d99f9b1415a12d124d1223b806591f528b3ea629cb5b97e2e9498f9963c4d ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md @@ -48,7 +48,7 @@ fea1ff4055d0930edeffb0c8501a6ba1ac55ec7a7064dcd0e300a43915e8432e docs/MIGRATION 1f85fac0e06e432ef6f744f0b5f9c1b7a27a1e9e1ad30dc1a218eab96b1caffe docs/MOCK_SERVER.md e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND_HOSTS.md 89a1af6c9fe10c572b211f9632ce256166e67990b5dacacf2ae07265d99a5172 docs/NPM_MIGRATION.md -d223dff88cc734993d93b67d33545c8d700b37ac1981de111a4d6899a6f7a469 docs/PHP_FPM.md +7d19e5617b18c95bedd307b311517460f7c6ce0a19a67fc614fa9a1f48dfcb1c docs/PHP_FPM.md 572c70fbd2f61fecd80a8ff9fa5de4a94c4f158de5e90da8e920f99b051241fa docs/PROMETHEUS.md 7046079140db7c152dc9caa536259d3c175c8893bda3a23d6ca4e4acb8d0b4c0 docs/README.md 6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md @@ -60,7 +60,7 @@ a2bb837cdd3559d58158880c1f832badf64035376a3623050bb4e40c2bca104c docs/ROADMAP_H 620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md d5c5c59f6e63b8d83dad5d159657c4d4039dfeb4cc1d8af210169b576adbf7e2 docs/SETTINGS.md 3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md -f03b4c365612cee787393bdd195ad9723251febe533568f59b0cb2da031e2c17 docs/TEST_REPORT.md +b29d1e50e75a6f9d440ffa6e4e6a3fb572a8971cad0751fa2f7b2026b641c8c3 docs/TEST_REPORT.md cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md 751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md 660687fc961f71aae2bdf71595aa47a336fa782f4e78ceee8c1d1e63e46393ee docs/TRANSPORT.md @@ -182,7 +182,7 @@ e9f72e65c9ceb255248f460cd7a003fff33e4b258160e248353c3e1fb4e952fb src/admin/file 1596561bb57976268b02e36e8ace426091045ee9d3f8832ce775eccdbc30bc81 src/admin/file_logs_io.rs b3e2c11dbc11bb8930117afdac64779111acc2ca7b2857f6e359a5108fc9ced8 src/admin/logging_api.rs 58802528128f53e493f7339063433a747b605007a8842b5efc670132d897d6f7 src/admin/mod.rs -8f2a19e8cb4791fb525e36907099ec010b3034dc7e65fb1e086b6804b410b7e6 src/admin/openapi.json +1396d24136707a81b9427290a855d9b5ec20fc1b222acd9e26b5319a52426328 src/admin/openapi.json 6c2b9a0ecb2f4172f110a363c22a6be32895dbcff9605fe7aa94efe91e0a536a src/admin/operations.rs 5287fff6b3ad436ac695e9b66feb1b0b5705b744a9f00cec7ea6a3e951e5eebf src/admin/preferences.rs 31bd90ee5f5ec2984920225ff6b57e07db3e0758092fb55d7c230e3590c1b015 src/admin/public.rs @@ -469,7 +469,7 @@ b61a40bb0ead18b3e348f03a258bab6a68d5c8f46968430f692458aa0cfd5bce src/vhost/vali 7853f6ec127a91163a4418ca45b93a92f424d5c618f27b29f9d526598a26f7ef src/web_server/config/model.rs 42a613234af7015a0b14f125bb88be1dcc30a47e9064b12e3fbb5b71ce6f7caf src/web_server/config/validation.rs 86c744216d7132d8294a6e90ba9be5fd871646006313d1c62506d40c81db9b12 src/web_server/fastcgi.rs -b4b8e86a0ae415310c740eb2575cb16fca07cf7f74b5dc780e846707a01b9f16 src/web_server/fastcgi/protocol.rs +1c0759a3b2fda727b2af7180484bb72febdb079356dbba3169844d74281acb6f src/web_server/fastcgi/protocol.rs 6a2fff148bf5a6485c2017a8b0cb3d4214cfc247197dd3c77619bf0d19331a19 src/web_server/fastcgi/response.rs 8186363315bd998919c6c783e6f7501528f257a5c0da77c5d60f423e8670db5e src/web_server/filesystem.rs 0aafd7f37c4fe784b047646e4ce5ddcb5fe821231a41ed0a9f519529385288a8 src/web_server/htaccess.rs @@ -484,11 +484,12 @@ c5cb561b0f276e8082f904b9446fa5885f6b3c0608f25ea2ef425061e9f7f6b4 src/web_server 20509f9302b79d3a7be76dda6edaf5711826d3ddf47d19ebe07cb289427ca328 src/web_server/response/headers.rs 9b58d6bb3ff1520060fc5aa86a3594f99192b1a8f8183255be1fb3ff35b4bb35 src/web_server/routing.rs 400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs -84868853bfe2d895ec4051c7c8ce9ccdc7c429fdd9718be460a81e78fb7391f1 src/web_server/serve.rs -cf86c4692080723bbdf8f4105e1abfae33d7fcf020d2ad9e13ebae3943bd0cbe src/web_server/tests.rs -f5254272e3588e89e8417932244c136cedd769a5286342332be57800579d8390 src/web_server/tests/part_01.rs +d61ebdab046b6d2f8e34bb3058c5eb3f2d0766be573810387f48dd3116dc1a6d src/web_server/serve.rs +63d71a336187779e9cbc1cc6afa065c5fa9472835a8c2cc15dbf4527279d0c90 src/web_server/tests.rs +77edb44b58d08d9818c42baaa34acdcbb19aea5ef8fb9014e6d6809d133deb07 src/web_server/tests/part_01.rs 874c228e03d11b33b5db7e58279569b35820d813ad6a9f45ee73b630f7bdad03 src/web_server/tests/part_02.rs 8f9a0e2b0a69a1f2b8cf7505a654606150da4a53bc8c09d1d6c4b8751185b134 src/web_server/tests/part_03.rs +b25b6e0082b5153272119c7faba9d6f2ccdd288ce5c066526431c56954c66c4c src/web_server/tests/part_04.rs f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json 1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs 347a4ff9e028a1e999ad129590c355b5734cf928dd81e5099aea0c8aca8d5908 tests/http3.rs @@ -496,12 +497,12 @@ eecf680f001f2110dde4eceaccf2f922859a16c946a644f8854e8de4d3771f7c tests/policies 728999110a6d8cacb62bc0399bc333fa23a66526ee23f563a3c93bf00e5fad19 tests/transport_config.rs b04362ad082a32c9ccc4cf38c46f44d71347adecf7aba3224b3af3ea4872e3d0 tools/README.md 358c1e94dd2b4a756430e5335747f0b437345b1713670a9972c5bcf78b837ccb tools/audit/admin_audit.py -ff891e5fc5ed591d2dd7483492ea0fad292d587c60e790a434b66390c3466a5a tools/checks/check_source.py -291a73c5c51862d4dcdba5af94bf146c2f1ed273eac015f0b8b7f634fc2cfa30 tools/checks/docs_check.py +74b6ab569703d0e558b9708fddc3ed0cae4cd7bc4740be536205beba5d0a20f1 tools/checks/check_source.py +0db0b6b6083df7e0e977bb4fd963826c761b83b0d68ea8f001347f02c7b0e728 tools/checks/docs_check.py 4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py cf762780c4085791c72c3c5f93054d7f2a1107403bee0616a1f4113bbcbb99f9 tools/checks/memory_contracts.py -b476cd1ae0047fb110bb6b3bf20a39f1926b45f8572b7edc6b2a313e59e779a9 tools/checks/preflight.py -b09fd21ca90f92a31cf00565fbe81e71bef01e46aeec932796b55c6cc63b74f4 tools/checks/release_smoke.py +655eedbe607fa8b65e3184f2d47cf5cabe0afbf19506fdf777b2a5f1a745529a tools/checks/preflight.py +0ad29b742f77aa8347f71e0030ab8b6d4504e0fa2380b7ecb8cf88bf1da024ff tools/checks/release_smoke.py f69845bc26ec33160fc756953991cf32aa8ad6a7fb4a131caa2093db91e783ef tools/checks/rust_module_contracts.py ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py @@ -510,7 +511,7 @@ adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/s 987b4c593b51a7c340d8f01f9a2eea1387043cdd997eeb932c8341bbb8c0bd64 tools/checks/version_contracts.py 0c071c26fc316934bdfd2bfd19f5fed9521790d8c887760eaabda81a9be96703 tools/checks/vhost_www_ui_test.mjs 0aaa80453e25aae7ea953fadb91b3ae3eaff627977c81c9ab5a2779f9a93582f tools/checks/web_assets.py -14a4f93dfc0704163334598332fe4549d4067937fc7f6f8bd86c2790b6857df6 tools/checks/web_server_contracts.py +0398892be30fe416a00578915c588f360fd93244e3af77ece834e560e547d1c8 tools/checks/web_server_contracts.py d5e786084178a9eac9c69b818c2d48945e797b7064a68214e918a5b842b8e40e tools/checks/web_service_ui_test.mjs adaa1ed9e5958739df40190055c780c80d0a8e270ec8dc105ed3fa2dbb8fb74f tools/checks/www_redirect_ui_test.mjs d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py diff --git a/docs/PHP_FPM.md b/docs/PHP_FPM.md index 7340991..63740f4 100644 --- a/docs/PHP_FPM.md +++ b/docs/PHP_FPM.md @@ -123,7 +123,7 @@ post_max_size = 64M max_execution_time = 30 ``` -RustProxy validates the lines and combines them with newline separators into the FastCGI `PHP_VALUE` parameter. PHP-FPM/PHP still decides whether a directive is changeable at this scope. `PHP_ADMIN_VALUE` is intentionally not exposed. Control characters/newlines inside names or values are rejected. +RustProxy validates the lines and combines them with newline separators into the FastCGI `PHP_VALUE` parameter. PHP-FPM/PHP still decides whether a directive is changeable at this scope. `PHP_ADMIN_VALUE` is available separately through the validated administrator INI overrides for trusted PHP-FPM endpoints. Control characters/newlines inside names or values are rejected. These custom parameters are stored as normal Web Service configuration. Do not put secrets there unless normal configuration storage is an acceptable location for them. @@ -139,6 +139,35 @@ $uri/ Existing static files/directories are used first. The final path becomes the fallback and can therefore reach `index.php`. This resolution remains inside the already-selected Web Service and does not introduce a second vhost router. +## Roundcube 1.7 static.php / PATH_INFO (0.29.5) + +Roundcube serves CSS, JavaScript and images through URLs such as +`/static.php/skins/elastic/styles/styles.min.css?s=123`. These paths are +**PHP-FPM requests to the existing** `public_html/static.php` **script**, not +static filesystem lookups. Configure the selected Web Service (or attached +shared PHP-FPM profile) with `php.enabled: true`, `php.path_info: true`, and +`php.extensions: ["php"]`. Point its root to the Roundcube `public_html` tree. + +You can keep the normal Web Service `try_files` (`$uri`, `$uri/`, +`/index.php?$query_string`). RustProxy 0.29.5 resolves a valid PHP script plus +PATH_INFO after security checks and rewrites, **before** falling back through +`try_files`. No regex rewrite or extra Web Service is required for these URLs +when the main vhost selects this Web Service. + +For `/static.php/skins/elastic/images/favicon.ico?s=123`, FastCGI receives: + +```text +SCRIPT_NAME=/static.php +SCRIPT_FILENAME=/static.php +PATH_INFO=/skins/elastic/images/favicon.ico +REQUEST_URI=/static.php/skins/elastic/images/favicon.ico?s=123 +QUERY_STRING=s=123 +``` + +PHP-FPM still needs filesystem access to `static.php` and the Roundcube +installation outside `public_html` that script reads. The PHP `PATH_INFO` +option is explicit and remains off by default for unrelated services. + ## Bounded execution Each compiled Web Service owns a bounded PHP concurrency semaphore. `max_concurrent_requests` limits simultaneous FastCGI requests. RustProxy does not create an unbounded wait queue: if no execution slot is immediately available it returns `503 Service Unavailable` with `Retry-After`. diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index df503ef..8211d36 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -1,4 +1,12 @@ -# Verification report - RustProxy 0.29.4 +# Verification report - RustProxy 0.29.5 + +## 0.29.5 Roundcube PHP PATH_INFO dispatch (2026-10-09) + +- Cause confirmed by source: `serve_inner` originally ran `try_files` before recognizing `/static.php/skins/...` as a PHP script plus `PATH_INFO`. For an existing `static.php`, the last `try_files` candidate `/index.php` intercepted the asset request and could return HTML instead of the asset. +- Fixed dispatch order following rewrites and path ACLs; execute existing PHP PATH_INFO targets before `try_files`. Access restrictions also apply to the resolved PHP script path; the other fallback path remains protected. +- Added `roundcube_static_php_path_info_runs_before_front_controller_try_files` and `without_php_path_info_try_files_keeps_legacy_front_controller_fallback` using a local test FastCGI server and decoded FastCGI params. These are source tests until run with Cargo, **not** validated against live PHP-FPM or Roundcube. +- Operator action: ensure PHP-FPM PATH_INFO splitting enabled on the chosen Web Service / shared profile; keep `try_files` for the main service. No extra special-case URL rewrite is needed for Roundcube `static.php` URLs. +- Static checks passed: `version_contracts.py`, `web_server_contracts.py`, `rust_module_contracts.py`, `docs_check.py`, `langs_check.py`, `web_service_ui_test.mjs`, and `release_smoke.py`. `./dev.sh test` exceeded the available execution limit while running broader static `check_source.py`. `cargo`/`rustc` are unavailable; the new Rust FastCGI tests were not executed and neither was a live Roundcube integration. ## 0.29.4 PHP-FPM profile toolbar shortcut (2026-10-09) @@ -94,9 +102,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.4 source release) +## Version identity (0.29.5 source release) -`Cargo.toml` package version is the canonical release identity (now 0.29.4). 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.5). 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/src/admin/openapi.json b/src/admin/openapi.json index 499a583..0aae8ee 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.4", + "version": "0.29.5", "description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust." }, "servers": [ diff --git a/src/web_server/fastcgi/protocol.rs b/src/web_server/fastcgi/protocol.rs index 5691604..ca793d4 100644 --- a/src/web_server/fastcgi/protocol.rs +++ b/src/web_server/fastcgi/protocol.rs @@ -25,7 +25,7 @@ use tokio::{ const FCGI_VERSION_1: u8 = 1; const FCGI_BEGIN_REQUEST: u8 = 1; pub(super) const FCGI_END_REQUEST: u8 = 3; -const FCGI_PARAMS: u8 = 4; +pub(super) const FCGI_PARAMS: u8 = 4; pub(super) const FCGI_STDIN: u8 = 5; pub(super) const FCGI_STDOUT: u8 = 6; const FCGI_STDERR: u8 = 7; diff --git a/src/web_server/serve.rs b/src/web_server/serve.rs index 9f2963f..9a6476a 100644 --- a/src/web_server/serve.rs +++ b/src/web_server/serve.rs @@ -106,6 +106,38 @@ async fn serve_inner( { return builtin_error_response(StatusCode::FORBIDDEN, &method); } + // An existing PHP script followed by PATH_INFO is a valid virtual PHP + // request, even though the full URL is not a filesystem path. Dispatch it + // before try_files can rewrite /static.php/skins/... to /index.php (as in + // Roundcube 1.7), while still applying the original path's rewrites and + // access controls above. + if let Ok(components) = path_components(&rewritten_path) { + if let Some((script_path, path_info)) = resolve_php_path_info(service, &components).await { + let script_components = path_components(&script_path).unwrap_or_default(); + if hardening_denied_path(service, &script_path) + || script_components.iter().any(|component| htaccess::protected_component(service.htaccess.as_ref(), component)) + || htaccess::denied(service.htaccess.as_ref(), &script_path) + { + return builtin_error_response(StatusCode::FORBIDDEN, &method); + } + if let Some(php) = php_for_path(service, &PathBuf::from(&script_path)) { + return php_response( + service, + php, + method, + headers, + uri, + script_path, + Some(path_info), + rewritten_query, + body, + context, + ) + .await; + } + } + } + let (effective_path, effective_query) = match resolve_try_files( service, &rewritten_path, @@ -140,6 +172,13 @@ async fn serve_inner( if matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory) => { if let Some((script_path, path_info)) = resolve_php_path_info(service, &components).await { + let script_components = path_components(&script_path).unwrap_or_default(); + if hardening_denied_path(service, &script_path) + || script_components.iter().any(|component| htaccess::protected_component(service.htaccess.as_ref(), component)) + || htaccess::denied(service.htaccess.as_ref(), &script_path) + { + return builtin_error_response(StatusCode::FORBIDDEN, &method); + } let public_path = PathBuf::from(&script_path); if let Some(php) = php_for_path(service, &public_path) { return php_response( diff --git a/src/web_server/tests.rs b/src/web_server/tests.rs index 29efe05..e1619b2 100644 --- a/src/web_server/tests.rs +++ b/src/web_server/tests.rs @@ -1,3 +1,4 @@ include!("tests/part_01.rs"); include!("tests/part_02.rs"); include!("tests/part_03.rs"); +include!("tests/part_04.rs"); diff --git a/src/web_server/tests/part_01.rs b/src/web_server/tests/part_01.rs index d5c060e..948a9fc 100644 --- a/src/web_server/tests/part_01.rs +++ b/src/web_server/tests/part_01.rs @@ -1,7 +1,7 @@ use super::*; use super::fastcgi::{ fastcgi_params, parse_cgi_headers, read_fcgi_record, write_fcgi_record, FCGI_END_REQUEST, - FCGI_STDIN, FCGI_STDOUT, + FCGI_PARAMS, FCGI_STDIN, FCGI_STDOUT, }; use axum::{body::Body, response::Response}; use http::{ diff --git a/src/web_server/tests/part_04.rs b/src/web_server/tests/part_04.rs new file mode 100644 index 0000000..3fb46bf --- /dev/null +++ b/src/web_server/tests/part_04.rs @@ -0,0 +1,126 @@ +// Decode the actual FastCGI PARAMS records received by a mock PHP-FPM peer. +// This tests the Web Service dispatch and parameter generation together. +fn decode_fastcgi_params(mut bytes: &[u8]) -> BTreeMap { + fn read_len(bytes: &mut &[u8]) -> usize { + let prefix = bytes[0]; + if prefix & 0x80 == 0 { + *bytes = &bytes[1..]; + prefix as usize + } else { + let value = u32::from_be_bytes(bytes[..4].try_into().unwrap()) & 0x7fff_ffff; + *bytes = &bytes[4..]; + value as usize + } + } + let mut result = BTreeMap::new(); + while !bytes.is_empty() { + let name_len = read_len(&mut bytes); + let value_len = read_len(&mut bytes); + let name = std::str::from_utf8(&bytes[..name_len]).unwrap().to_owned(); + let value = std::str::from_utf8(&bytes[name_len..name_len + value_len]).unwrap().to_owned(); + bytes = &bytes[name_len + value_len..]; + result.insert(name, value); + } + result +} + +async fn fastcgi_fixture_record_params() -> ( + std::net::SocketAddr, + tokio::sync::oneshot::Receiver>, +) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (tx, rx) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + let mut params = Vec::new(); + loop { + let (kind, content) = read_fcgi_record(&mut stream, deadline).await.unwrap(); + if kind == FCGI_PARAMS { + params.extend_from_slice(&content); + } + if kind == FCGI_STDIN && content.is_empty() { + break; + } + } + let _ = tx.send(decode_fastcgi_params(¶ms)); + write_fcgi_record(&mut stream, FCGI_STDOUT, + b"Status: 200 OK\r\nContent-Type: text/css\r\n\r\nPHP-STATIC-OK").await.unwrap(); + write_fcgi_record(&mut stream, FCGI_STDOUT, &[]).await.unwrap(); + write_fcgi_record(&mut stream, FCGI_END_REQUEST, &[0u8; 8]).await.unwrap(); + stream.flush().await.unwrap(); + }); + (address, rx) +} + +#[tokio::test] +async fn roundcube_static_php_path_info_runs_before_front_controller_try_files() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("static.php"), b" str: preflight(root) manifest = tomllib.loads((root / "Cargo.toml").read_text()) -assert manifest["package"]["version"] == "0.29.4" +assert manifest["package"]["version"] == "0.29.5" assert manifest["dependencies"]["quinn"]["version"] == "0.11.12" h3dep = manifest["dependencies"]["h3"] assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.19" and "quinn" in h3dep["features"] @@ -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.4"' in lock + assert 'name = "rust-proxy"\nversion = "0.29.5"' in lock assert 'name = "systemstat"' in lock and 'name = "metrics-process"' in lock and 'name = "sysinfo"' in lock, "Cargo.lock must be resolver-generated after adding cross-platform statistics libraries" for release_doc, marker in { - "README.md": "# RustProxy 0.29.4", - "CHANGELOG.md": "## 0.29.4", - "ROADMAP.md": "## 0.29.4", - "AGENTS.md": "Current source version: 0.29.4.", - "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.29.4", + "README.md": "# RustProxy 0.29.5", + "CHANGELOG.md": "## 0.29.5", + "ROADMAP.md": "## 0.29.5", + "AGENTS.md": "Current source version: 0.29.5.", + "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.29.5", }.items(): assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}" assert tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] == "stable" @@ -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.4"', '"/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.5"', '"/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.4"' in openapi +assert '"RuntimeInformation"' in openapi and '"version": "0.29.5"' 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.4 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.5 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 64809b9..2db685c 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.4 release.""" +"""Rust-independent documentation/Reader consistency checks for the 0.29.5 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.4") +assert readme.startswith("# RustProxy 0.29.5") 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.4" 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.5" 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 367ffc1..bb337ba 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.4 source tree: missing {path}") + raise ValueError(f"Incomplete RustProxy 0.29.5 source tree: missing {path}") text = (root / "src/lib.rs").read_text(encoding="utf-8") if '#[path = "auth/mod.rs"]' not in text: - raise ValueError("src/lib.rs is not the 0.29.4 entry point; extract the complete package, not individual files.") + raise ValueError("src/lib.rs is not the 0.29.5 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 bc76fb9..8add334 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.4"' in (target / "Cargo.toml").read_text(encoding="utf-8") + assert 'version = "0.29.5"' in (target / "Cargo.toml").read_text(encoding="utf-8") assert (target / "data/keep.txt").read_text(encoding="utf-8") == "preserve\n" assert (target / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n" assert (target / "langs/site.json").is_file() @@ -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.4" + assert manifest["to_version"] == "0.29.5" 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.4"' in openapi_0241 + assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.29.5"' in openapi_0241 assert 'id="panel-logging"' not in html for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', '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.4"', '"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.5"', '"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.4 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.5 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/web_server_contracts.py b/tools/checks/web_server_contracts.py index 1880c7b..a99c29f 100755 --- a/tools/checks/web_server_contracts.py +++ b/tools/checks/web_server_contracts.py @@ -45,6 +45,16 @@ need(vhost, "pub source: Source", "vhost requires at least one backend member for Reverse Proxy sources", "Web Server location cannot pin a reverse-proxy backend") +# PATH_INFO must be dispatched before `try_files` rewrites a valid +# Roundcube /static.php/skins/... request into the index.php controller. +serve = (web_dir / "serve.rs").read_text() +assert serve.index("if let Ok(components) = path_components(&rewritten_path)") < serve.index("match resolve_try_files(") +assert "hardening_denied_path(service, &script_path)" in serve +assert "htaccess::denied(service.htaccess.as_ref(), &script_path)" in serve +need(web, + "roundcube_static_php_path_info_runs_before_front_controller_try_files", + "without_php_path_info_try_files_keeps_legacy_front_controller_fallback") + need(web, "pub struct Service", "pub document_root: PathBuf",