This commit is contained in:
Mateusz Gruszczyński committed 2026-10-09 13:11:33 +02:00
1 parent f87eec7287
commit dd8c195600
38 files changed
+5491 -4682

No files matched your search

+1 -1
View File
@@ -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.3.
compatibility. Current source version: 0.29.4.
## Delivery
+9
View File
@@ -4,6 +4,15 @@
> test-only changes and packaging implementation notes were moved to
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
## 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.
- Add named, shared PHP-FPM configuration profiles that Web Services can attach or detach without modifying their local PHP settings, with a profile-management modal and revisioned API support.
- Apply the vhost cache and transformation pipeline to Web Server local-file/PHP routes, with fresh-hit reuse, vhost response rules, response-side privacy protection and bounded GET/HEAD request-body probing.
- Supply the validated HTTP authority in FastCGI `HTTP_HOST`, including authority-only HTTP/2 and HTTP/3 requests used by WordPress.
- Add Rust regression tests and browser-level UI tests. Rust compilation and live integration remain pending in this environment.
- Retain SQLite schema 9, Cold Cache format 3 and saved-configuration compatibility with 0.29.3.
## 0.29.3
- Fix internal error fallback on empty GET/HEAD requests over HTTP/2 and HTTP/3 with unknown body length; safely probe truly empty bodies without replaying nonempty or unbounded payloads.
Generated
+1 -1
View File
@@ -1688,7 +1688,7 @@ dependencies = [
[[package]]
name = "rust-proxy"
version = "0.29.3"
version = "0.29.4"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rust-proxy"
version = "0.29.3"
version = "0.29.4"
edition = "2021"
rust-version = "1.88"
description = "RustProxy - independent reverse proxy with local administration"
+12 -4
View File
@@ -1,9 +1,9 @@
# RustProxy 0.29.3
# RustProxy 0.29.4
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.3 release source.**
**0.29.4 release source.**
## Layer-4 streams
@@ -11,7 +11,15 @@ 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.3
## What changed 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.
- Run vhost RustProxy cache and enabled response transformations for Web Server filesystem/PHP sources with normal response privacy protections.
- Preserve the original request Host in FastCGI `HTTP_HOST` for WordPress, including HTTP/2/HTTP/3 authority-only requests.
- Added regression tests; the Rust compiler/runtime gate is still required before production deployment.
## Previously in 0.29.3
- Fix internal error fallback for empty GET/HEAD requests over HTTP/2 or HTTP/3 with unknown body-length hints by safely checking if the body stream is empty; nonempty and unbounded bodies remain protected from unsafe replay.
- Preserve the final status, headers and HTML returned by the internal fallback location, including its own 404 page, instead of replacing it with RustProxy's global error page. Warn when a named internal target cannot be found.
@@ -25,7 +33,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.3 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.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).
### Historical 0.26.7 release notes
+11
View File
@@ -1,5 +1,16 @@
# RustProxy roadmap
## 0.29.4 - Shared PHP-FPM profiles and local Web Server processing
Current direction: 0.29.3 -> 0.29.4
- [x] Add reusable PHP-FPM profiles attachable to individual Web Services, keeping their local settings on detach.
- [x] Apply RustProxy cache and response transformations for Web Server files and PHP-FPM responses.
- [x] Supply FastCGI `HTTP_HOST` from request authority for HTTP/2 and HTTP/3 WordPress requests.
- [x] Add source/UI regression coverage for new configuration and response behavior.
- [ ] Run the Rust compiler/test suite and verify PHP-FPM, cache and transformations end-to-end before production use.
- [x] Keep SQLite schema 9 and Cold Cache format 3 unchanged.
## 0.29.3 - HTTP/2 and HTTP/3 internal fallback stabilization
Current direction: 0.29.2 -> 0.29.3
+37 -34
View File
@@ -1,13 +1,13 @@
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
8e809683178496518f708eaa180d9f29023426d73657a908d3dd3102bac1de4d AGENTS.md
14fa3bd7730d7d353c3964ece8a5435d756664b0695260528ffc3704b378c8a0 CHANGELOG.md
286285f4ec28ce638041b81013b8c8c5fe9150471ee5b7b18aeede3136c7d2ee Cargo.lock
e5647cd8a0998d3fff7e8a9ef3753dac28edc8280c377d516ad9c33e617a9a00 Cargo.toml
2b708f425f80f11e4a3e92b16b396eb6da685bcd79730839e23ddc1002871cac AGENTS.md
4cb02e7c01b2920ab4b62919094b891a775a0af233c22d448d0879b2ddda0ca3 CHANGELOG.md
6d43d2521eee7d8a0a7317c585b7fae474ecdf9c092d02d045d3891cf3ae5764 Cargo.lock
db8c2ffcea5348298a0af085e589730b42efa7e1bc57fa3d748bf6b36aec1040 Cargo.toml
75b008f237b2226c62a7721b7901018ce83bfc2726f047a83a17c66fcb799f3b Dockerfile
9c768f1884bb19cda9c7d2f66242e1f4424c78e98e2aa4323eb6acfa74cbb3d0 README.md
13bde8e3830c2611ac4f2aac2dbcd6d39318196b56a6d70023b18dda9daa7fb5 ROADMAP.md
6598d33c7a56362c71011c71ac1c05d34241996f9cff97e4d43593e7dda735c2 README.md
1369ae49b2bfc7c205323df713be9272c6971523e7f615ef6e86c1cfe071884c ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md
@@ -30,7 +30,7 @@ ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2
67a1e0cbe5d1139251852401d876273ec7ea8cd1c595ecd63af097921b0d21b3 docs/API.md
f054206a09bed81018708608cdeecbfd09018ee7cec9d3abc038f8a37d47b815 docs/API_ACCESS.md
4c5288c9ed427cfb919bd023ed9a321dcf6d64bc4cad3e1768393356c5d835ba docs/ARCHITECTURE.md
57471965bbbb8bebfe675a36ac9e7b8bf8fb56d7d60537c8724fd5d42a0ffe94 docs/CACHE.md
598298303036912c19e0a4be1431acc7b7d3df7333f3e756e8268ac0e8c16921 docs/CACHE.md
6266163e17d2921468abdae92c1620266af8351321530e2c9b7dca6f3dd89c88 docs/CACHE_DIAGNOSTICS.md
c6003dcbc0b95e2cfa9523b7017186cc8b4826248b588af77111ac5eb15174f9 docs/CHANGELOG_TECHNICAL_ARCHIVE.md
c8e69e492179ce39958cc63615cf46c4ac08cfdb7f3483aaab66868a3abcd687 docs/CONTAINER.md
@@ -48,7 +48,7 @@ fea1ff4055d0930edeffb0c8501a6ba1ac55ec7a7064dcd0e300a43915e8432e docs/MIGRATION
1f85fac0e06e432ef6f744f0b5f9c1b7a27a1e9e1ad30dc1a218eab96b1caffe docs/MOCK_SERVER.md
e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND_HOSTS.md
89a1af6c9fe10c572b211f9632ce256166e67990b5dacacf2ae07265d99a5172 docs/NPM_MIGRATION.md
c6be860cad76833f7bda35f371e335a3304893a50b839953329fed3fb96a8af8 docs/PHP_FPM.md
d223dff88cc734993d93b67d33545c8d700b37ac1981de111a4d6899a6f7a469 docs/PHP_FPM.md
572c70fbd2f61fecd80a8ff9fa5de4a94c4f158de5e90da8e920f99b051241fa docs/PROMETHEUS.md
7046079140db7c152dc9caa536259d3c175c8893bda3a23d6ca4e4acb8d0b4c0 docs/README.md
6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md
@@ -60,9 +60,9 @@ a2bb837cdd3559d58158880c1f832badf64035376a3623050bb4e40c2bca104c docs/ROADMAP_H
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
d5c5c59f6e63b8d83dad5d159657c4d4039dfeb4cc1d8af210169b576adbf7e2 docs/SETTINGS.md
3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md
24fbc7f7a4db65b14437ebaa02cb48387039ddde5f8f826e5ff116b10162235e docs/TEST_REPORT.md
f03b4c365612cee787393bdd195ad9723251febe533568f59b0cb2da031e2c17 docs/TEST_REPORT.md
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
8ee6187a5460ca45643b7fd789e27bffd264449e3a92ff70e9359edee011bbbb docs/TRANSFORM.md
751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md
660687fc961f71aae2bdf71595aa47a336fa782f4e78ceee8c1d1e63e46393ee docs/TRANSPORT.md
96a6816915d526f00e261846131ee3f942ef332af996cf0f73c79ec1ba9f9c7a docs/V0.21.0_AUDIT.md
0c1c3ba6e8724d85ec54e254e70bbf05e063db025280ef4af866b774fb43f76b docs/V0.22.0_AUDIT.md
@@ -106,7 +106,7 @@ bfe9b83d2292bae1e579c1841a396173258496063dbd0f3953780dedeebc2c40 docs/V0.29.0_I
1bf34805a56508bb7e60745be8e872ebdc458f03bd0f56941af9c18d94ad87ce docs/V0.29.1_IMPLEMENTATION.md
c7c6c237f77b8fbc9601d2fbe30216d51394a392ef1705af74d5ca79930e9d61 docs/VHOSTS.md
09b60fb7b40db4d0139fa2a9e69a2aa9f28d4e61b8643521cfc7e936d7df8774 docs/VHOST_ACCESS.md
10bfd138ae4f9892f51c3ddcb72762ec57f6f8d12a1c371feff18abed4d23cb5 docs/WEB_SERVER.md
7e194221c9d3ac36346caa1b09641c2614179d9efd41f2821091a4c217f07018 docs/WEB_SERVER.md
c336f998027330c58d5afbbbb3a3a682fd44ae3997643882fe342633822f8bb3 docs/screenshots/README.md
5502e8ce44276213803b66c58e3a4d804a1354f5ed5d83f47ee805e8c44ab088 examples/acl-headers.json
2431dd88a2d9ad2cf3cd4b8fcdfa602563ab1013c612d8917fddbe7febfb2dc3 examples/cache-dashboard.json
@@ -132,8 +132,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
0fc9ecf808a296768c41eb4b44ca90446e24b5728492b2fe4efc9adc3382a7ca langs/en_US.json
3e8c87e778d696b9062f70a941160a9b60e348ee75ced5bf353e91daa16051aa langs/pl_pl.json
3bd93bb75fc7ee7e5a67143af4fd388050dd7aa6037b6fa342f766b459100e2d langs/en_US.json
0f43e65e9787106a7465e98d639ec63a164de47fcbe12059b9590e633f857b42 langs/pl_pl.json
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
@@ -180,13 +180,13 @@ bc9eb32f0d2785bac122d5eff44742cd24306c503daa065564383d46416b2211 src/admin/acce
002922c91aa07cbea17cb0ee11127bb6e7e015de108ee032761b34507dcfa06d src/admin/file_log_search.rs
e9f72e65c9ceb255248f460cd7a003fff33e4b258160e248353c3e1fb4e952fb src/admin/file_logs.rs
1596561bb57976268b02e36e8ace426091045ee9d3f8832ce775eccdbc30bc81 src/admin/file_logs_io.rs
de331ab75c31df65a4f0f1607dbfb26972799bff5d9f232d8e914c38225cffc7 src/admin/logging_api.rs
b3e2c11dbc11bb8930117afdac64779111acc2ca7b2857f6e359a5108fc9ced8 src/admin/logging_api.rs
58802528128f53e493f7339063433a747b605007a8842b5efc670132d897d6f7 src/admin/mod.rs
325ea183ae4ab6f5cf228d7027db31a2f7f08190acc709fbcc0af3f1a5de2752 src/admin/openapi.json
8f2a19e8cb4791fb525e36907099ec010b3034dc7e65fb1e086b6804b410b7e6 src/admin/openapi.json
6c2b9a0ecb2f4172f110a363c22a6be32895dbcff9605fe7aa94efe91e0a536a src/admin/operations.rs
5287fff6b3ad436ac695e9b66feb1b0b5705b744a9f00cec7ea6a3e951e5eebf src/admin/preferences.rs
31bd90ee5f5ec2984920225ff6b57e07db3e0758092fb55d7c230e3590c1b015 src/admin/public.rs
d73ea424a2795b6f44248abce459da770be179369a59345cb8f7514040b21071 src/admin/router.rs
fa6982ae8bbff1b7373634263493ee12b20b49d27b4aecd6268eb6502b7b8ee5 src/admin/router.rs
8708427263cd4caf8454b9115e89acb09172483a090c6b0bd43811dd9e52c7f6 src/admin/session.rs
bebf42cd1808a99a0e669d483951a441ffa22c8d5a0a289c64491adf13c8841a src/admin/status.rs
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
@@ -220,7 +220,7 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
af6e7a2e890df2fb7385a374bcb3fba52fb5a53dbe18adc1fb7865660fcba842 src/admin/web/file-logs.js
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
4a30f473b3e764d7703be41636396aef47522ada31caa054d364cd387eec7d8c src/admin/web/index.html
66ad32f9259c8e500c44e4508a6fad5ad6e01c957dc044da78ef8f2fdca551cf src/admin/web/index.html
24db88842707f764064657712d3660c6ac129fbfb47d188c0903925cf8244ce5 src/admin/web/integrations.js
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
af0fbd94d9b88ce1cce28e39dfd2c35de031adcba412dd4a9a17d9b416c256dd src/admin/web/login.html
@@ -235,7 +235,8 @@ e686d68fd6a368110a85dc4c5dc64e8093950d20d63e9aa44790c03dea6fc193 src/admin/web/
7ee39a069155dc2650d688f14f8ea51e1a7bdad95ab4cc62769b4565c52fd37b src/admin/web/vhosts/core.js
38d1f4bee4eae192fc977747241b76ed164bfae174d0377744d0a2ef06f85306 src/admin/web/vhosts/editor.js
a155dd33eb802d4f2906f581b8f0d4b40260cb917766c31d4e887cdf51f2e940 src/admin/web/vhosts/render.js
7fa7d243714236f145b9df486fbb96712a667e7296837cc9c6c6b9ade2f5d0de src/admin/web/web-server.js
c7eacc7e2cfe06b815524f99358233e9c41062ab1a1a2ff5f68e0c1723c8ad0f src/admin/web/web-server-profiles.js
5ff1f6421d493ef74fa3875ddbd78a5fd31a51a1e62b4dca5ee7bca022872e0b src/admin/web/web-server.js
98c00d99822f9be54fc58afb6d4b577901712effd2c5eddce014312681864625 src/auth/files.rs
b2f948bcc3dbd24952c7b1b02d41d06757f49f0c38a78caf51f8f8a687d8480d src/auth/files/tests.rs
97b5a060f24a102bbaa2f406426e985236c0d719b8038db210c25b34fcd8e923 src/auth/mod.rs
@@ -306,14 +307,15 @@ a8a0ce446eb53ffdb8f8d97efa3e1344410dc3798b25f62b8817e8e160d28940 src/cache/tick
e2f58279499b7c3b47c65d3a7814bb5c7cd6506544d939875fbb0f91fa9aa59d src/compression/tests.rs
7bc8285cb2012506fd1a38a71dffa561f885f4ab65a9b2a7c8344319e3cd1cf6 src/config.rs
7ba52d3de4d77d1f3cad1b10de418953adc33901727a0791980847ae223929aa src/config/backend.rs
dd1632face27e53c6aa19f9be338df907700aae1d506667556cf79e4b86fdfbd src/config/compile.rs
33c3598365ecd5308bf51ac39a62557b137ea83d36cd1e46be25d2f10e119fec src/config/compile.rs
490d05d84b2fe349ddde54eae44880993fab70811652484dd9ba38b5069d603b src/config/internal_routing.rs
875c107eb8c3b08e5ac4216e8600580397753fe599c4b665b40fa33022b8e169 src/config/model.rs
f02def401d5c0a6d619f0e6f88bbccd1db94a32bb004dc88f1581b773c7d6fc9 src/config/model.rs
1bf510a70805e6269ff39c4029016cbce7985535e52da1b915f6f971a12c2e63 src/config/routing.rs
818545ad36af3ae8d56976f318349dd1f5264ef5665fbf94681a81b79003f8cb src/config/snapshot.rs
7d94d1bd4eb698e0588b822d93e56e3dc5594c46edac4aa9713f148893ebe6d5 src/config/tests.rs
7592bdeaad9950a720e2d841fcb1a4edb2a68af5333a6163e672d0392616103c src/config/tests/part_01.rs
b9f56cc156deaf1a59daf4da96233bba12a31a4fde3f6fe6cc08909fb6c1823e src/config/tests.rs
f712db55af9ac463bc905558d29d068a58ac75269075055724ad187d7ea06fa9 src/config/tests/part_01.rs
fb6e42d969b207e3e204d5892303740cf6f780be5a195cbee1f894b703994b60 src/config/tests/part_02.rs
bc5816805b6e70244c339b4e680b447b3d00d545b59923e242629dc3805907e0 src/config/tests/part_03.rs
c6db84438878accb8a0c96be13fa01a83da824af39c929dc3f6df498f393b7e0 src/db.rs
7fadb86f6a61dd0127dd68c115be36865f0b2af535510f4c8f9aff6c9b8cdbfe src/db/certificate_api.rs
2ff744223934685439374572b83fd639bb3eabdbac168bea4a6501a6e9ce0ee0 src/db/history.rs
@@ -375,10 +377,11 @@ f0cb26ed34c35bacc74b914457acac8a611390de14cf46e2e6d3bcc848e914e8 src/proxy/cach
af6885dddecef81c3b12a61e7e0bd9d77123321c5cec0e15d0e77f29e7868fed src/proxy/error_fallback.rs
cfb0910af8cd868bde3f6c9992319e46d5328d6d8f69c50701df0ea12bef2108 src/proxy/forward/cache_lookup.rs
e5eec11349cda99d08b27f730202a57457fb2f48f856537b07dbda669168b5bd src/proxy/forward/response.rs
8ad9a8dee80a7c16a60f1dd0126790daf3cde1a13e28a2595a0ef64f3fd63792 src/proxy/forward/route_cache.rs
d3b9aa0c0055fa9a29f180ca8b10c890c0b91127ee603e5d89cce4c99d5a6bd4 src/proxy/forward/route_cache.rs
f239c1d5ea7a6efa83f59eb12cbeec310bef00f1360f41b5c64783af30e8469a src/proxy/forward/success_finish.rs
e58c03f95b4ee8150665b20cbd76c3c6e21a1096b92b42dedc52c764123176cb src/proxy/forward/success_prepare.rs
23b6bd26f6175b52c13d2571318fddd12550ba34ee77b87b5ac0e4811f8e2bee src/proxy/forward/upstream.rs
249ac0985d99f4d73a9c9082a9abeaa47cb23aa5cad0e047f99f8e8cb6a295be src/proxy/forward/web_source.rs
c39090511e8057d43f2620175958c3dbd9b686036306a0060bb64eaab4bf6c93 src/proxy/forward_entry.rs
c438066616527a3108f2ad53e3698ae9b2a80961714394cdd0a450c92f99d992 src/proxy/headers.rs
5cb7522ceb9a9f083a15596ac4898559295e38d1138c80099577211de8a868ec src/proxy/headers/tests.rs
@@ -462,11 +465,11 @@ a67e58a597a5eeb7efe605ae6e438a8f84ff6a1cedf8cd228141cb95551872a8 src/upstream.r
9b16b5307e140db97363f56c9fac8319e56df846bb1c919d3bc06091ab6ce69c src/vhost/tests.rs
b61a40bb0ead18b3e348f03a258bab6a68d5c8f46968430f692458aa0cfd5bce src/vhost/validation.rs
8d1eff4b24f01d137414ef5419f75dd281879228adb70409645b11b3c78c20e9 src/web_server/config.rs
f1c9028fe40ee54e797dd2747c95ecffca87a77eb9de2e1cfde930b8bc8ad9c4 src/web_server/config/compile.rs
53def5e7ddea307d6a8e8969af532a225636fa56a981ca4de3ec6fc41e59e5e4 src/web_server/config/model.rs
1fae9d57dfee80edcc3073438353254db5b6afdae1d9c1d6173cc6de5f66b2e6 src/web_server/config/compile.rs
7853f6ec127a91163a4418ca45b93a92f424d5c618f27b29f9d526598a26f7ef src/web_server/config/model.rs
42a613234af7015a0b14f125bb88be1dcc30a47e9064b12e3fbb5b71ce6f7caf src/web_server/config/validation.rs
86c744216d7132d8294a6e90ba9be5fd871646006313d1c62506d40c81db9b12 src/web_server/fastcgi.rs
4b2d1b59283d8ac841b3ea013c1e9d94af6f1b939735081750c019c2ecb1dbb8 src/web_server/fastcgi/protocol.rs
b4b8e86a0ae415310c740eb2575cb16fca07cf7f74b5dc780e846707a01b9f16 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
@@ -474,7 +477,7 @@ f1c9028fe40ee54e797dd2747c95ecffca87a77eb9de2e1cfde930b8bc8ad9c4 src/web_server
76de1d11df75f4a64556b2821fd0a1774d26c9f0ca0e5f2b5fcfad0722d43d47 src/web_server/htaccess/parse.rs
66260d159986d41eab0dfde2cb9f0713fc1cc209c2fb47e9023bd24fd37919e8 src/web_server/htaccess/rewrite.rs
ddc329309a3c75c095aede58495b7f698929237e1d08467921a971577ca8405d src/web_server/metrics.rs
d855dbc3a379a43a41e59998d9e93d069c09d562502630436447d692cce3100a src/web_server/mod.rs
93988a6e2f6c8a84ee03ddee8c474d516e6be6404ad14d4f23a35bdb57a9cf67 src/web_server/mod.rs
67ab8bef567452f123c4b9b46a942b39c93f1f765c128edca7ee808ff1c5e304 src/web_server/request.rs
c5cb561b0f276e8082f904b9446fa5885f6b3c0608f25ea2ef425061e9f7f6b4 src/web_server/response.rs
87a0dd815d92688bbc173ca1b2a28487c4dc580cf04318273ac1d460791f6788 src/web_server/response/file.rs
@@ -485,7 +488,7 @@ c5cb561b0f276e8082f904b9446fa5885f6b3c0608f25ea2ef425061e9f7f6b4 src/web_server
cf86c4692080723bbdf8f4105e1abfae33d7fcf020d2ad9e13ebae3943bd0cbe src/web_server/tests.rs
f5254272e3588e89e8417932244c136cedd769a5286342332be57800579d8390 src/web_server/tests/part_01.rs
874c228e03d11b33b5db7e58279569b35820d813ad6a9f45ee73b630f7bdad03 src/web_server/tests/part_02.rs
8e52125bdde56484a9bf3880392de8e10bbab05e2ed0faae6056410296761d64 src/web_server/tests/part_03.rs
8f9a0e2b0a69a1f2b8cf7505a654606150da4a53bc8c09d1d6c4b8751185b134 src/web_server/tests/part_03.rs
f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json
1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs
347a4ff9e028a1e999ad129590c355b5734cf928dd81e5099aea0c8aca8d5908 tests/http3.rs
@@ -493,12 +496,12 @@ eecf680f001f2110dde4eceaccf2f922859a16c946a644f8854e8de4d3771f7c tests/policies
728999110a6d8cacb62bc0399bc333fa23a66526ee23f563a3c93bf00e5fad19 tests/transport_config.rs
b04362ad082a32c9ccc4cf38c46f44d71347adecf7aba3224b3af3ea4872e3d0 tools/README.md
358c1e94dd2b4a756430e5335747f0b437345b1713670a9972c5bcf78b837ccb tools/audit/admin_audit.py
ecb7aee67967502c809f497478b5eb8c2ad8faf702dc5149f5829313409dc42d tools/checks/check_source.py
aa6b1685f19ff7ba39c48c3c53602bce5c04710d59dbd69949fcb7e66de0bf4a tools/checks/docs_check.py
ff891e5fc5ed591d2dd7483492ea0fad292d587c60e790a434b66390c3466a5a tools/checks/check_source.py
291a73c5c51862d4dcdba5af94bf146c2f1ed273eac015f0b8b7f634fc2cfa30 tools/checks/docs_check.py
4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py
cf762780c4085791c72c3c5f93054d7f2a1107403bee0616a1f4113bbcbb99f9 tools/checks/memory_contracts.py
a7004a3ba3c954124497d171037008ddad168e14afb0dfd46d0742d017925c82 tools/checks/preflight.py
14fe411c10eadb7ce9f7385d3e62a442302c14ca9d45c956cbca1cc6a1ac9788 tools/checks/release_smoke.py
b476cd1ae0047fb110bb6b3bf20a39f1926b45f8572b7edc6b2a313e59e779a9 tools/checks/preflight.py
b09fd21ca90f92a31cf00565fbe81e71bef01e46aeec932796b55c6cc63b74f4 tools/checks/release_smoke.py
f69845bc26ec33160fc756953991cf32aa8ad6a7fb4a131caa2093db91e783ef tools/checks/rust_module_contracts.py
ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py
adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py
@@ -507,8 +510,8 @@ adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/s
987b4c593b51a7c340d8f01f9a2eea1387043cdd997eeb932c8341bbb8c0bd64 tools/checks/version_contracts.py
0c071c26fc316934bdfd2bfd19f5fed9521790d8c887760eaabda81a9be96703 tools/checks/vhost_www_ui_test.mjs
0aaa80453e25aae7ea953fadb91b3ae3eaff627977c81c9ab5a2779f9a93582f tools/checks/web_assets.py
3c6fbb7fb96007d759e4bfef1424104f7ee14e22439c69ad955ed17fcfc53484 tools/checks/web_server_contracts.py
e99cade378ef648d3f20cc9abed4da83d05c3b7322a69018467e8af37129c541 tools/checks/web_service_ui_test.mjs
14a4f93dfc0704163334598332fe4549d4067937fc7f6f8bd86c2790b6857df6 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
2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 tools/dev/mock_server.py
+16
View File
@@ -20,6 +20,22 @@ refresh while keeping directive-controlled stale delivery through RFC 5861 and r
validator-based handling of response `no-cache` instead of reproducing Varnish hit-for-miss
behavior exactly.
## Web Server origins (0.29.4)
A Web Service selected as the vhost source now uses the normal RustProxy shared
object-cache pipeline when both global and vhost cache policy permit it.
`Cache-Control: public, max-age=...` (or another eligible freshness policy)
can produce `MISS` followed by `HIT` instead of a browser-only cache header.
Vhost response rules run before cache fill, and transformed output is what is
stored. Private/`Set-Cookie`/`no-store` origin responses remain ineligible,
even if a later response-header rule replaces their visible cache headers.
For Web Server sources, stale entries are refreshed by a new local-source
request rather than the remote-backend revalidation worker. As with reverse
proxy origins, range requests and conditional requests can reuse fresh stored
representations. Do not rely on stale-if-error remote-backend refresh behavior
for local Web Server routes.
## Settings
| Field | Default | Bounds / meaning |
+38 -2
View File
@@ -13,7 +13,36 @@ Request
-> existing error/header/logging/metrics/transport pipeline
```
Vhosts and locations still reference only a Web Service. PHP settings remain part of that reusable service.
Vhosts and locations still reference only a Web Service. Each Web Service may
use local PHP settings or reference a shared `php_fpm_profiles` entry from the
normal revisioned configuration; its local settings are retained on detach.
### Shared PHP-FPM profiles (0.29.4)
In **Web Server → Edit Web Service → PHP-FPM**, select **Local configuration**
or a named profile. **Manage PHP-FPM profiles** opens a modal with an expandable
editor for every profile and an **Add profile** action. A profile contains the
same PHP execution, FastCGI and PHP_VALUE/PHP_ADMIN_VALUE settings as local
configuration. Changes to a shared profile affect all attached services after
validation and apply. In-use profiles cannot be deleted.
Configuration example:
```json
{
"php_fpm_profiles": [{
"id": "php82", "name": "PHP 8.2 FPM",
"php": {"enabled": true, "endpoint": "unix:/run/php/php8.2-fpm.sock"}
}],
"web_services": [{
"id": "site", "name": "Site", "document_root": "/var/www/site",
"php_profile_id": "php82"
}]
}
```
Unspecified PHP fields inherit the safe serde defaults. A service without
`php_profile_id` uses its own `php` object as in earlier versions.
## Runtime model
@@ -173,7 +202,14 @@ PHP stderr is bounded by `stderr_log_bytes` and written to the RustProxy diagnos
PHP output does not use static file validators or the static-file OS representation path. Application response headers and the normal vhost/location response-header pipeline control client/intermediary caching.
The Web Service static `cache_rules` apply to local static file responses, not to PHP stdout. RustProxy does not silently place PHP responses into a new Web Server object cache.
The Web Service static `cache_rules` apply to local static file responses, not
to PHP stdout. When global/vhost caching is enabled, explicitly cacheable
PHP-FPM responses may enter the shared RustProxy cache; responses with
`Set-Cookie`, `Cache-Control: private` or `no-store` cannot be stored. In
particular, do not enable forced caching of authenticated WordPress pages
without auditing the origin response policy. HTTP/2 and HTTP/3 `:authority`
requests populate the `HTTP_HOST` FastCGI parameter, retaining the original
host/port from the incoming request when provided.
## PHP installation
+56 -3
View File
@@ -1,4 +1,50 @@
# Verification report - RustProxy 0.29.3
# Verification report - RustProxy 0.29.4
## 0.29.4 PHP-FPM profile toolbar shortcut (2026-10-09)
The Web Server list toolbar now contains a **PHP-FPM profiles** button
immediately before **New Web Service**. Both this shortcut and the PHP tab's
existing profile button open the same shared profile-management dialog;
no API or configuration schema changes were required. English/Polish button
translations and a UI regression covering placement, both entry paths and
closing the dialog were added.
`node --check` on the changed Web Server script and its profile helper,
`node tools/checks/web_service_ui_test.mjs`, and the focused version,
language, Web Server architecture and documentation checks passed.
`./dev.sh test` was attempted but exceeded the execution time limit during
the Rust-independent verification phase, so it did not complete in this run.
Rust compilation and runtime tests remain unverified without Cargo.
## 0.29.4 version bump (2026-10-09)
The PHP-FPM profiles, Web Server cache/transforms and `HTTP_HOST` fixes have
been assigned to a new 0.29.4 release. The source version, root Cargo.lock
package entry, OpenAPI metadata, release checks and documentation match 0.29.4.
The 0.29.3 internal-fallback changes remain included. This is a source release;
no compiled artifacts are included.
Release verification: `python3 tools/checks/version_contracts.py`,
`python3 tools/checks/check_source.py`, `python3 tools/checks/docs_check.py`,
`python3 tools/checks/release_smoke.py` and the Node Web Service profile
UI regression checks passed. `./dev.sh test` completed its Rust-independent
source, migration/upgrade, security, browser and documentation checks, then
exited 127 because Rust/Cargo is not installed. Compilation, Rust unit tests
and live HTTP/FastCGI/cache/transform verification remain unperformed.
## 0.29.4 feature verification (2026-10-09)
The operator-requested follow-up adds reusable PHP-FPM profiles, fixes the
Web Server cache/transform bypass and supplies `HTTP_HOST` for authority-only
HTTP/2 and HTTP/3 FastCGI requests. Changes include Config serialization,
revisioned administration section and OpenAPI, Web Server browser modal,
route cache/transform pipeline and FastCGI parameter encoding. New Rust unit
tests cover profile resolution, detach, validation and HTTP_HOST; the Node UI
test exercises opening/editing a profile and attaching it to a service.
Rust compilation, proxy/PHP-FPM integration tests and performance acceptance
remain unverified without Rust/Cargo. Static contracts and browser tests are
run separately and do not substitute for production acceptance.
## Changes
@@ -48,6 +94,13 @@ 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.3 source release)
## Version identity (0.29.4 source release)
`Cargo.toml` package version is the canonical release identity (now 0.29.3). 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.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.
## Shared PHP-FPM profiles and Web Server cache/transform/Host patch (0.29.4)
- Added a revisioned `php_fpm_profiles` collection with per-Web-Service `php_profile_id`, preserving the local PHP configuration when a shared profile is attached/detached. Added profile editor modal, schema, Polish/English messages and browser regression test.
- Web Server vhost/location responses now pass the configured RustProxy cache, vhost response rules and transforms. Cache skips ineligible/private/`Set-Cookie` responses and handles cache hits, conditional requests and range requests. Filesystem/PHP requests are refreshed synchronously rather than using the reverse-proxy background revalidator.
- FastCGI always supplies `HTTP_HOST` when HTTP/2 or HTTP/3 omitted the ordinary `Host` header; existing explicit Host with port is retained. Added Rust regression tests for both paths.
- `bash ./dev.sh test`: all Rust-independent release, source, security, browser UI, documentation, migration and dependency checks passed; stopped at absent Rust/Cargo (exit 127). `bash ./dev.sh check` also stopped at absent Cargo (exit 127). Rust compilation, new Rust tests and live PHP-FPM/HTTP2/HTTP3 cache/transform verification remain unverified in this environment.
+10
View File
@@ -5,6 +5,16 @@ storage. A transformed identity representation is cached once and downstream com
is selected later for each client. Per-vhost and Docker-discovery transform opt-in remain
disabled by default even when the global engine is available.
## Web Server origins (0.29.4)
Static-file and PHP-FPM responses selected through Web Server vhost sources
use the same optional vhost transform stage as reverse-proxy backends.
The normal rules still apply: supported MIME/extension and response encoding,
known bounded body size, memory/concurrency limits, and `no-transform`
exclusions. Successful transformed representations are eligible for shared
cache storage under the original/derived cache privacy policy. Downstream
compression is still applied separately.
## Administration dashboard
The main **Transform** workspace reads `GET /api/transform` and exposes in-memory runtime
+10 -2
View File
@@ -30,7 +30,7 @@ The Web Service editor is split into stable configuration tabs so later features
- **MIME** - per-extension MIME overrides and fallback MIME type.
- **Cache & headers** - default `Cache-Control`, ordered cache rules and fixed response headers.
- **Delivery** - precompressed `.br`/`.gz` sidecars, forced-download extensions and Range/streaming information.
- **PHP-FPM** - Unix/loopback-TCP endpoint, executable extensions, connect/request timeout, bounded concurrency, Authorization forwarding, request/header/stderr limits, custom FastCGI parameters and controlled `PHP_VALUE` overrides.
- **PHP-FPM** - attach a shared PHP-FPM profile through the selector, open the profile-management modal to expand/edit/create profiles, or choose local configuration for all FastCGI tuning options.
- **Security** - symlink policy, request/response hardening summary and filesystem-confinement notes.
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.
@@ -191,7 +191,15 @@ The built-in table covers common HTML/CSS/JS/data formats, archives, images, aud
## Cache policy and Web Service headers
RustProxy still does **not** add a second object cache for local files. Static content uses filesystem/OS page cache, validators and normal HTTP client/intermediary caching.
For Web Server sources, filesystem/OS page cache still accelerates file reads, and
static files retain validators and normal browser cache headers. Additionally,
when global and vhost RustProxy caching are enabled, eligible Web Server
responses pass through the same shared HOT/COLD RustProxy object cache as
reverse-proxy origins. Cache policy requires an eligible GET/HEAD, public
freshness and response-side safety; `Set-Cookie`, `private`, `no-store` and
other ineligible responses are not stored. Vhost response-header rules and
transformations now run for Web Server responses before cache fill. PHP-FPM
responses are protected by these same rules.
A Web Service can define:
+2256 -2239
View File
File diff suppressed because it is too large. Load diff
+2256 -2239
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -89,6 +89,10 @@ async fn update_section(
config.web_services =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?
}
"php-fpm-profiles" => {
config.php_fpm_profiles =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?
}
"redirects" => {
config.redirects =
serde_json::from_value(value).map_err(|e| invalid_section(&section, e))?
+39 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.29.3",
"version": "0.29.4",
"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": [
@@ -848,6 +848,14 @@
"items": {
"$ref": "#/components/schemas/WebService"
}
},
"php_fpm_profiles": {
"type": "array",
"maxItems": 128,
"items": {
"$ref": "#/components/schemas/PhpFpmProfile"
},
"description": "Named PHP-FPM configurations reusable by Web Services."
}
},
"additionalProperties": true
@@ -5185,6 +5193,12 @@
},
"compression": {
"$ref": "#/components/schemas/CompressionOverrides"
},
"php_profile_id": {
"type": "string",
"maxLength": 64,
"description": "Optional shared PHP-FPM profile ID; when absent the local php configuration applies.",
"nullable": true
}
},
"required": [
@@ -5649,6 +5663,30 @@
"target"
],
"description": "Nginx-style internal error fallback. The original request is internally dispatched without a client redirect."
},
"PhpFpmProfile": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"name"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"description": "ASCII letters, numbers, - and _."
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 96
},
"php": {
"$ref": "#/components/schemas/WebService/properties/php"
}
}
}
}
},
+4
View File
@@ -230,6 +230,10 @@ pub fn router(state: Arc<App>) -> Router {
)
}),
)
.route("/assets/web-server-profiles.js", get(|| async {
([ ("content-type", "text/javascript; charset=utf-8") ],
include_str!("web/web-server-profiles.js"))
}))
.route(
"/assets/web-server.js",
get(|| async {
+20 -2
View File
@@ -13,6 +13,7 @@
<script defer="" src="/assets/drafts.js"></script>
<script defer="" src="/assets/integrations.js"></script>
<script defer="" src="/assets/vhosts.js"></script>
<script defer="" src="/assets/web-server-profiles.js"></script>
<script defer="" src="/assets/web-server.js"></script>
<script defer="" src="/assets/docker.js"></script>
<script defer="" src="/assets/streams.js"></script>
@@ -684,6 +685,8 @@
<input autocomplete="off" data-i18n-aria="webServiceSearch"
data-i18n-placeholder="webServiceSearchPlaceholder" id="web-service-search"
type="search" />
<button class="outline" data-i18n="phpProfileToolbar" id="web-service-php-profiles-open"
type="button">PHP-FPM profiles</button>
<button class="outline" data-i18n="webServiceNew" id="web-service-new"
type="button">New Web Service</button>
</div>
@@ -3271,8 +3274,7 @@
placeholder="X-Content-Type-Options = nosniff"></textarea><small class="fine"
data-i18n="webServiceResponseHeadersHelp">Fixed headers for this service. Vhost/location
response-header rules are applied later and can override them.</small></label>
<p class="fine vh-wide" data-i18n="webServiceCacheHelp">Local files use filesystem/OS page cache;
this controls browser/proxy caching and does not create a second RustProxy object cache.</p>
<p class="fine vh-wide" data-i18n="webServiceCacheHelp">With vhost cache enabled, eligible Web Server responses also use the RustProxy object cache. Private, cookie-bearing and no-store responses are never stored.</p>
</div>
<div class="vh-grid web-service-page" data-web-service-page="delivery" hidden>
<label><span data-i18n="webServiceCompression">Dynamic compression</span><select
@@ -3323,6 +3325,11 @@
keep validators, single-range requests, 206/416 and streaming delivery.</p>
</div>
<div class="vh-grid web-service-page" data-web-service-page="php" hidden>
<label class="vh-wide"><span data-i18n="phpProfileSelect">PHP-FPM configuration source</span>
<select id="web-service-php-profile"><option value="" data-i18n="phpProfileLocal">Local configuration</option></select>
<small class="fine" data-i18n="phpProfileHelp">Select a shared profile or edit PHP-FPM locally. Profile changes apply to all attached Web Services.</small>
</label>
<div class="vh-wide"><button type="button" class="outline" id="web-service-php-profiles" data-i18n="phpProfileManage">Manage PHP-FPM profiles</button></div>
<label class="check"><input id="web-service-php-enabled" type="checkbox" /><span
data-i18n="webServicePhpEnabled">Enable PHP-FPM / FastCGI</span></label>
<label class="vh-wide"><span data-i18n="webServicePhpEndpoint">PHP-FPM endpoint</span><input
@@ -3394,6 +3401,17 @@
</div>
</form>
</dialog>
<dialog aria-labelledby="php-profiles-title" class="vhost-dialog location-help-dialog" id="php-fpm-profiles-dialog">
<div class="window-heading">
<h2 id="php-profiles-title" data-i18n="phpProfileTitle">Shared PHP-FPM profiles</h2>
<button type="button" class="outline" id="php-fpm-profiles-close" data-i18n="close">Close</button>
</div>
<div class="card location-help-content">
<p class="fine" data-i18n="phpProfileDescription">Expand a profile to edit it. Changes are saved immediately and shared by every attached Web Service.</p>
<div id="php-fpm-profiles-list"></div>
<button type="button" class="outline" id="php-fpm-profile-add" data-i18n="phpProfileAdd">Add profile</button>
</div>
</dialog>
<dialog aria-labelledby="htaccess-help-title" class="vhost-dialog location-help-dialog" id="htaccess-help-dialog">
<div class="window-heading">
<h2 data-i18n="webServiceHtaccessDocsTitle" id="htaccess-help-title">.htaccess compatibility</h2>
+112
View File
@@ -0,0 +1,112 @@
"use strict";
// Shared profile editor is independent of the Web Service editor. Values are
// never rendered as HTML and saves use the revisioned configuration API.
window.rustProxyPhpProfiles = ({ defaults, mapLines, lines, parseFastCgiParams, parsePhpValues, validatePhpCustomData }) => {
function refreshProfileSelect(chosen = $("web-service-php-profile").value) {
const select = $("web-service-php-profile");
const local = el("option", t("phpProfileLocal")); local.value = "";
const profiles = (envelope?.config.php_fpm_profiles || []).map(profile => {
const option = el("option", profile.name || profile.id); option.value = profile.id; return option;
});
select.replaceChildren(local, ...profiles);
select.value = chosen;
}
// Use exactly the same validators and FastCGI field semantics for local and
// shared configuration. None of the profile values are interpolated as HTML.
const profileFields = [
["enabled", "webServicePhpEnabled", "checkbox"],
["endpoint", "webServicePhpEndpoint", "text"],
["extensions", "webServicePhpExtensions", "text"],
["path_info", "webServicePhpPathInfo", "checkbox"],
["pass_authorization", "webServicePhpPassAuthorization", "checkbox"],
["connect_timeout_ms", "webServicePhpConnectTimeout", "number", 50, 60000],
["request_timeout_ms", "webServicePhpRequestTimeout", "number", 100, 3600000],
["max_concurrent_requests", "webServicePhpConcurrency", "number", 1, 4096],
["max_request_body_bytes", "webServicePhpBodyLimit", "number", 0, 8589934592],
["max_response_header_bytes", "webServicePhpHeaderLimit", "number", 4096, 1048576],
["stderr_log_bytes", "webServicePhpStderrLimit", "number", 0, 65536],
["custom_params", "webServicePhpCustomParams", "textarea"],
["php_values", "webServicePhpValues", "textarea"],
["php_admin_values", "phpProfileAdminValues", "textarea"],
];
function profileEditor(profile, creating = false) {
const details = document.createElement("details"); details.className = "card";
if (creating) details.open = true;
const summary = el("summary", profile.name || profile.id || t("phpProfileNew"));
const editor = document.createElement("form"); editor.className = "vh-grid"; editor.noValidate = true;
const namedInput = (key, value, readonly = false) => {
const wrapper = el("label");
wrapper.append(el("span", t(key)));
const input = document.createElement("input"); input.value = value || ""; input.maxLength = key === "phpProfileId" ? 64 : 96;
input.required = true; input.readOnly = readonly; wrapper.append(input); editor.append(wrapper); return input;
};
const idInput = namedInput("phpProfileId", profile.id, !creating);
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 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]); }
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;
}
const actions = el("div", undefined, "form-actions vh-wide");
const save = el("button", t("apply"), "primary"); save.type = "submit";
actions.append(save);
if (!creating) {
const remove = el("button", t("remove"), "quiet"); remove.type = "button";
remove.addEventListener("click", async () => {
const used = (envelope?.config.web_services || []).filter(service => service.php_profile_id === profile.id).map(service => service.id);
if (used.length) return toast(`${t("phpProfileInUse")}: ${used.join(", ")}`, true);
if (!(await appConfirm(t("phpProfileDelete"), { danger: true }))) return;
const next = (envelope.config.php_fpm_profiles || []).filter(item => item.id !== profile.id);
if (await saveSection("php-fpm-profiles", next)) { refreshProfileSelect(); renderProfiles(); }
});
actions.append(remove);
}
editor.append(actions);
editor.addEventListener("submit", async event => {
event.preventDefault();
try {
const id = idInput.value.trim(), name = nameInput.value.trim();
if (!/^[A-Za-z0-9_-]{1,64}$/.test(id) || !name || name.length > 96) throw new Error(t("phpProfileInvalid"));
for (const input of Object.values(controls)) if (!input.checkValidity()) throw new Error(t("phpProfileInvalid"));
const php = Object.fromEntries(profileFields.map(([key, , kind]) => [key,
kind === "checkbox" ? controls[key].checked : kind === "number" ? Number(controls[key].value) :
key === "extensions" ? lines(controls[key].value).map(item => item.replace(/^\./, "").toLowerCase()) :
key === "custom_params" ? parseFastCgiParams(controls[key].value) :
key === "php_values" || key === "php_admin_values" ? parsePhpValues(controls[key].value) : controls[key].value.trim()
]));
validatePhpCustomData(php.custom_params, php.php_values, php.php_admin_values);
const next = structuredClone(envelope.config.php_fpm_profiles || []);
if (next.some(item => item.id === id && (creating || item.id !== profile.id))) throw new Error(t("phpProfileDuplicate"));
const position = next.findIndex(item => item.id === profile.id);
if (!creating && position >= 0) next[position] = { id, name, php }; else next.push({ id, name, php });
if (await saveSection("php-fpm-profiles", next)) { refreshProfileSelect(); renderProfiles(); }
} catch (error) { toast(error.message, true); }
});
details.append(summary, editor);
return details;
}
function renderProfiles(addNew = false) {
const profiles = envelope?.config.php_fpm_profiles || [];
const list = profiles.map(item => profileEditor(item));
if (addNew) list.unshift(profileEditor({ id: "", name: "", php: defaults().php }, true));
$("php-fpm-profiles-list").replaceChildren(...list);
}
function openProfiles() {
renderProfiles();
$("php-fpm-profiles-dialog").showModal();
}
return { renderProfiles, openProfiles, refreshProfileSelect };
};
+18 -4
View File
@@ -153,15 +153,22 @@
activateTab("general");
}
const { renderProfiles, openProfiles, refreshProfileSelect } = window.rustProxyPhpProfiles({
defaults, mapLines, lines, parseFastCgiParams, parsePhpValues, validatePhpCustomData
});
function syncPhp() {
const attached = !!$("web-service-php-profile").value;
const enabled = $("web-service-php-enabled").checked;
$("web-service-php-enabled").disabled = attached;
for (const id of [
"web-service-php-endpoint", "web-service-php-extensions", "web-service-php-connect-timeout",
"web-service-php-request-timeout", "web-service-php-concurrency", "web-service-php-pass-authorization",
"web-service-php-path-info",
"web-service-php-body-limit", "web-service-php-header-limit", "web-service-php-stderr-limit",
"web-service-php-custom-params", "web-service-php-values", "web-service-php-admin-values"
]) $(id).disabled = !enabled;
]) $(id).disabled = !enabled || attached;
}
function syncCompression() {
@@ -196,7 +203,7 @@
id: "", name: "", document_root: "", index: ["index.html", "index.htm"], directory_listing: false,
mime_types: {}, default_mime_type: "application/octet-stream", cache_control: null, cache_rules: [], response_headers: {},
try_files: [], rewrites: [], compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, precompressed: { enabled: false, brotli: true, gzip: true }, download_extensions: [],
symlink_policy: "deny", php: {
symlink_policy: "deny", php_profile_id: null, php: {
enabled: false, path_info: false, extensions: ["php"], endpoint: "unix:/run/php/php-fpm.sock", connect_timeout_ms: 1000,
request_timeout_ms: 30000, max_concurrent_requests: 64, pass_authorization: true, max_request_body_bytes: 0,
max_response_header_bytes: 65536, stderr_log_bytes: 4096, custom_params: {}, php_values: {}, php_admin_values: {}
@@ -243,6 +250,7 @@
$("web-service-precompressed-br").checked = item.precompressed?.brotli !== false;
$("web-service-precompressed-gzip").checked = item.precompressed?.gzip !== false;
$("web-service-download-extensions").value = (item.download_extensions || []).join("\n");
refreshProfileSelect(item.php_profile_id || "");
$("web-service-php-enabled").checked = item.php?.enabled === true;
$("web-service-php-path-info").checked = item.php?.path_info === true;
$("web-service-php-endpoint").value = item.php?.endpoint || "unix:/run/php/php-fpm.sock";
@@ -373,7 +381,7 @@
["webServiceDirectoryListing", t(service.directory_listing ? "enabled" : "notEnabled")],
["webServiceTryFiles", (service.try_files || []).length ? String(service.try_files.length) : "-"],
["webServiceHtaccessEnabled", t(service.htaccess?.enabled ? "enabled" : "notEnabled")],
["webServicePhpEnabled", t(service.php?.enabled ? "enabled" : "notEnabled")],
["webServicePhpEnabled", service.php_profile_id ? `${service.php_profile_id} (${t("phpProfileShared")})` : t(service.php?.enabled ? "enabled" : "notEnabled")],
["webServiceCompression", service.compression?.enabled === true ? t("enabled") : service.compression?.enabled === false ? t("notEnabled") : t("inherit")],
["webServicePrecompressed", t(service.precompressed?.enabled ? "enabled" : "notEnabled")],
["webServiceHardeningTitle", t((service.hardening?.block_common_attacks || service.hardening?.strip_unnecessary_headers) ? "enabled" : "notEnabled")],
@@ -428,6 +436,7 @@
block_common_attacks: $("web-service-hardening-attacks").checked,
strip_unnecessary_headers: $("web-service-hardening-headers").checked
},
php_profile_id: $("web-service-php-profile").value || null,
php: {
enabled: phpEnabled,
path_info: $("web-service-php-path-info").checked,
@@ -446,7 +455,7 @@
}
};
validatePhpCustomData(item.php.custom_params, item.php.php_values, item.php.php_admin_values);
if (phpEnabled && item.symlink_policy !== "deny") throw new Error(t("webServicePhpSymlinkConflict"));
if ((phpEnabled || item.php_profile_id && (envelope.config.php_fpm_profiles || []).find(profile => profile.id === item.php_profile_id)?.php?.enabled) && item.symlink_policy !== "deny") throw new Error(t("webServicePhpSymlinkConflict"));
const next = structuredClone(envelope.config.web_services || []);
const duplicate = next.find(service => service.id === item.id && service.id !== editing);
if (duplicate) { toast(t("webServiceDuplicate"), true); return; }
@@ -462,6 +471,11 @@
form.addEventListener("input", () => { dirty = true; });
form.addEventListener("change", () => { dirty = true; });
$("web-service-php-enabled").addEventListener("change", syncPhp);
$("web-service-php-profile").addEventListener("change", syncPhp);
$("web-service-php-profiles").addEventListener("click", openProfiles);
$("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));
$("web-service-compression").addEventListener("change", syncCompression);
$("web-service-precompressed").addEventListener("change", syncPrecompressed);
$("web-service-htaccess-enabled").addEventListener("change", syncHtaccess);
+20 -16
View File
@@ -48,24 +48,28 @@ pub fn compile_with_active(
config.compression.validate()?;
config.server_header.validate()?;
let forwarding = config.forwarding.compile()?;
ensure!(
config.web_services.len() <= 128,
"too many Web Services (max 128)"
);
ensure!(config.php_fpm_profiles.len() <= 128, "too many PHP-FPM profiles (max 128)");
let mut php_profiles = HashMap::new();
for profile in &mut config.php_fpm_profiles {
profile.validate().with_context(|| format!("PHP-FPM profile {}", profile.id))?;
ensure!(php_profiles.insert(profile.id.clone(), profile.php.clone()).is_none(),
"duplicate PHP-FPM profile id");
}
ensure!(config.web_services.len() <= 128, "too many Web Services (max 128)");
let mut web_services = HashMap::new();
for service in &mut config.web_services {
service
.validate()
.with_context(|| format!("Web Service {}", service.id))?;
let compiled = Arc::new(
service
.compile()
.with_context(|| format!("Web Service {}", service.id))?,
);
ensure!(
web_services.insert(service.id.clone(), compiled).is_none(),
"duplicate Web Service id"
);
service.validate().with_context(|| format!("Web Service {}", service.id))?;
// A profile changes effective runtime settings, not the saved standalone
// PHP configuration. Detaching a profile restores local settings exactly.
let mut effective = service.clone();
if let Some(id) = &service.php_profile_id {
effective.php = php_profiles.get(id).with_context(|| {
format!("Web Service {} references unknown PHP-FPM profile {}", service.id, id)
})?.clone();
effective.validate().with_context(|| format!("Web Service {} PHP-FPM profile {}", service.id, id))?;
}
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");
}
ensure!(config.vhosts.len() <= 64, "too many vhosts (max 64)");
let mut vhost_ids = HashSet::new();
+2
View File
@@ -67,6 +67,8 @@ pub struct Config {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub web_services: Vec<crate::web_server::Service>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub php_fpm_profiles: Vec<crate::web_server::PhpFpmProfile>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub vhosts: Vec<crate::vhost::Vhost>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub host_policies: Vec<HostPolicy>,
+1
View File
@@ -1,4 +1,5 @@
mod tests {
include!("tests/part_01.rs");
include!("tests/part_02.rs");
include!("tests/part_03.rs");
}
+1
View File
@@ -480,3 +480,4 @@ fn backend_diagnostic_flags_compile_to_frontend() {
assert!(frontend.backend_header);
assert!(frontend.backend_timing_header);
}
+73
View File
@@ -0,0 +1,73 @@
#[test]
fn shared_php_fpm_profile_is_reused_and_detach_restores_local_configuration() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'ok';").unwrap();
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.php_fpm_profiles.push(crate::web_server::PhpFpmProfile {
id: "php-default".into(),
name: "Default PHP pool".into(),
php: crate::web_server::Php {
enabled: true,
endpoint: "tcp:127.0.0.1:9000".into(),
..Default::default()
},
});
let service = crate::web_server::Service {
id: "site-one".into(),
name: "Site One".into(),
document_root: tmp.path().to_path_buf(),
php_profile_id: Some("php-default".into()),
// Standalone settings remain distinct and disabled.
php: crate::web_server::Php::default(),
..Default::default()
};
let mut second = service.clone();
second.id = "site-two".into();
second.name = "Site Two".into();
config.web_services.extend([service, second]);
config.vhosts[0].source = crate::vhost::Source::WebServer {
web_service_id: "site-one".into(),
};
config.vhosts[0].routes.clear();
config.vhosts[0].backends.clear();
let snapshot = compile(config, 1, &policy()).unwrap();
for id in ["site-one", "site-two"] {
let compiled = snapshot.web_php_services.get(id).unwrap();
assert!(compiled.php.is_some(), "{id} must use the shared FPM profile");
}
assert_eq!(snapshot.envelope.config.php_fpm_profiles.len(), 1);
assert!(!snapshot.envelope.config.web_services[0].php.enabled);
assert!(!snapshot.envelope.config.web_services[1].php.enabled);
let serialized = serde_json::to_string(&snapshot.envelope.config).unwrap();
let mut detached: Config = serde_json::from_str(&serialized).unwrap();
detached.web_services[0].php_profile_id = None;
let snapshot = compile(detached, 2, &policy()).unwrap();
assert!(snapshot.web_php_services.get("site-one").unwrap().php.is_none());
assert!(snapshot.web_php_services.get("site-two").unwrap().php.is_some());
}
#[test]
fn shared_php_fpm_profile_reference_and_duplicate_ids_are_rejected() {
let tmp = tempfile::TempDir::new().unwrap();
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.web_services.push(crate::web_server::Service {
id: "site".into(),
name: "Site".into(),
document_root: tmp.path().to_path_buf(),
php_profile_id: Some("missing".into()),
..Default::default()
});
let err = compile(config.clone(), 1, &policy()).unwrap_err().to_string();
assert!(err.contains("unknown PHP-FPM profile"), "{err}");
config.web_services[0].php_profile_id = None;
let profile = crate::web_server::PhpFpmProfile {
id: "duplicate".into(),
name: "Duplicate".into(),
php: Default::default(),
};
config.php_fpm_profiles = vec![profile.clone(), profile];
let err = compile(config, 1, &policy()).unwrap_err().to_string();
assert!(err.contains("duplicate PHP-FPM profile"), "{err}");
}
+1 -54
View File
@@ -48,60 +48,7 @@ if route.unavailable {
return response;
}
if let crate::config::CompiledSource::WebServer(service) = &route.source {
if handshake.is_some() {
return error(
StatusCode::BAD_REQUEST,
"RustProxy: Web Server source does not support WebSocket upgrades\n",
);
}
let service_path = match route.rewritten_path(&path) {
Ok(Some(path)) => path,
Ok(None) => path.clone(),
Err(_) => {
return error(
StatusCode::BAD_REQUEST,
"RustProxy: invalid Web Server rewrite path\n",
)
}
};
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
*exchange.route.lock().unwrap_or_else(|e| e.into_inner()) = Some(route.id.clone());
*exchange.cache_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) = Some(false);
*exchange.transform_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) = Some(false);
}
// Do not hold a shared reference to Request<Body> across await. Axum handlers
// must return Send futures, while Body is not Sync. Clone only static-request
// metadata needed by the filesystem source before entering the async server.
let static_method = request.method().clone();
let static_headers = request.headers().clone();
let static_uri = request.uri().clone();
let static_version = request.version();
let static_body = std::mem::replace(request.body_mut(), Body::empty());
let mut response = crate::web_server::serve(
service,
static_method,
static_headers,
static_uri,
&service_path,
static_body,
crate::web_server::RequestContext {
client_ip: context.client_ip,
client_port: Some(peer.0.port()),
server_name: host.clone(),
server_port: local.0.port(),
tls,
version: static_version,
},
)
.await;
if let Some(fallback) = execute_error_fallback(
state.clone(), peer, local, &snapshot, &host, tls, route,
response.status(), &fallback_request, &fallback_body,
).await {
return fallback;
}
response.extensions_mut().insert(StaticSourceResponse);
return response;
return include!("web_source.rs");
}
let (backend, backend_lease, affinity_cookie) = if let Some(pool) = &route.pool {
let selection = match &route.backend_id {
+219
View File
@@ -0,0 +1,219 @@
{
// Web Server is an origin just like a reverse-proxy backend. It must pass
// through the same vhost cache policy, transform stage and response rules.
if handshake.is_some() {
return error(StatusCode::BAD_REQUEST,
"RustProxy: Web Server source does not support WebSocket upgrades\n");
}
let service_path = match route.rewritten_path(&path) {
Ok(Some(value)) => value,
Ok(None) => path.clone(),
Err(_) => return error(StatusCode::BAD_REQUEST,
"RustProxy: invalid Web Server rewrite path\n"),
};
let cache_settings = route.frontend.as_ref()
.map(|frontend| &frontend.cache)
.unwrap_or(&snapshot.envelope.config.cache);
let transform_settings = route.frontend.as_ref().map(|frontend| &frontend.transform);
let scope = route.frontend.as_ref().map(|frontend| frontend.id.as_str()).unwrap_or("");
let enabled = cache_settings.active_for(route.cache)
&& frontend.is_none_or(|frontend| frontend.basic_auth.is_none() && frontend.tinyauth.is_none());
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
*exchange.cache_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) = Some(enabled);
*exchange.transform_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) =
Some(transform_settings.is_some_and(|settings| settings.enabled));
}
// The authority is already validated by effective_host. HTTP/2 and HTTP/3
// requests commonly have :authority but no Host field, which otherwise
// makes PHP-FPM omit HTTP_HOST, breaking WordPress.
request.headers_mut().insert(HOST, original_host.clone());
let request_method = request.method().clone();
let request_headers = request.headers().clone();
let request_uri = request.uri().clone();
let request_version = request.version();
let public_suffix = request_uri.path_and_query()
.map(|item| item.as_str()).unwrap_or("/").to_owned();
// H2/H3 may expose an unknown-length empty stream before its final frame.
// Reuse the bounded probe that preserves any nonempty streaming body.
let empty_body = if enabled && (request_method == Method::GET || request_method == Method::HEAD) {
match empty_for_cache(&mut request).await {
Ok(empty) => empty,
Err(_) => return error(StatusCode::BAD_REQUEST,
"RustProxy: invalid or incomplete request body\n"),
}
} else { request.body().is_end_stream() };
let force_cache = enabled && route.frontend.as_ref().is_some_and(|frontend|
frontend.force_cache.matches(&path, &public_suffix));
let policy = if enabled {
if force_cache {
crate::cache::forced_request_policy(&request_method, &request_headers, empty_body)
} else {
crate::cache::request_policy(
&request_method, &request_headers, empty_body, &path,
request_uri.query().is_some(), cache_settings)
}
} else {
crate::cache::RequestPolicy::Bypass("cache_disabled")
};
// HEAD can use an existing GET representation but may not fill a cache
// entry with its empty downstream body.
let eligible = enabled && policy.bypass_reason().is_none();
let mut key_headers = request_headers.clone();
let ignore_cookie = !force_cache && cache_settings.ignore_cookie_for_path(&path);
if ignore_cookie { key_headers.remove("cookie"); }
let cache_ticket = if eligible {
state.cache.ticket_scoped(state.cache.revision(), scope, context.scheme,
original_host.to_str().unwrap_or(""), &route.id, &public_suffix, &key_headers)
} else { None };
let range = crate::cache::request_range(&request_method, &request_headers);
let if_range = request_headers.get("if-range").cloned();
let range_requested = !matches!(range, crate::cache::RangeSelection::None);
let needs_validation = policy.revalidation_reason().is_some();
if let Some(ticket) = &cache_ticket {
if !needs_validation {
if let Some(mut hit) = state.cache.get_tiered(ticket, cache_settings).await {
// Filesystem/PHP does not have the reverse-proxy background
// revalidator. Refresh stale representations synchronously.
if hit.stale {
// Fall through and fetch from the local origin.
} else {
let conditional = crate::cache::conditional_not_modified(
&request_headers, hit.response.headers());
if !conditional {
if let crate::cache::RangeSelection::Single(requested) = range {
hit = hit.apply_range(requested, if_range.as_ref());
}
}
let mut response = if conditional {
crate::cache::make_not_modified(hit.response)
} else { hit.response };
if request_method == Method::HEAD {
*response.body_mut() = Body::empty();
}
cache_settings.annotate_reason(response.headers_mut(), "HIT", hit.hits,
hit.remaining_ttl, "cache_hit");
cache_settings.annotate_transformed(response.headers_mut(), hit.transformed);
cache_settings.annotate_resident_age(response.headers_mut(), hit.resident_age_seconds);
state.cache.record(scope, &host, "HIT", "cache_hit");
if hit.transformed { state.transform_metrics.record_cache_hit(scope); }
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
exchange.cache_status.store(1, Ordering::Relaxed);
*exchange.cache_reason.lock().unwrap_or_else(|e| e.into_inner()) = "cache_hit";
}
response.extensions_mut().insert(ResponseRulesApplied);
response.extensions_mut().insert(StaticSourceResponse);
return response;
}
}
}
}
if cache_ticket.is_some() && crate::cache::only_if_cached(&request_headers) {
return error(StatusCode::GATEWAY_TIMEOUT,
"RustProxy: only-if-cached request has no usable Web Server representation\n");
}
// Neither the Request nor the streaming Body is held across a borrowed
// 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(),
request_headers.clone(), request_uri, &service_path, static_body,
crate::web_server::RequestContext {
client_ip: context.client_ip,
client_port: Some(peer.0.port()),
server_name: host.clone(),
server_port: local.0.port(),
tls,
version: request_version,
}).await;
if let Some(fallback) = execute_error_fallback(
state.clone(), peer, local, &snapshot, &host, tls, route,
response.status(), &fallback_request, &fallback_body,
).await { return fallback; }
let origin_headers = response.headers().clone();
let original_plan = crate::cache::plan(response.status(), &origin_headers,
cache_settings, started.elapsed());
// Do not allow a later response-header rewrite to override a private,
// explicitly uncacheable PHP/CGI origin response.
let explicit_origin_policy = origin_headers.contains_key("cache-control")
|| origin_headers.contains_key("expires")
|| origin_headers.contains_key("set-cookie")
|| origin_headers.contains_key("vary");
let origin_safe = !explicit_origin_policy || original_plan.is_some();
let no_transform = crate::transform::no_transform(&origin_headers);
let skip_compression = crate::compression::no_transform(&origin_headers);
if skip_compression || range_requested {
response.extensions_mut().insert(crate::compression::Skip);
}
response.extensions_mut().insert(crate::transform::OriginValidators {
etag: origin_headers.get("etag").cloned(),
last_modified: origin_headers.get("last-modified").cloned(),
});
apply_response_rules(response.headers_mut(), &snapshot, route.frontend.as_deref(),
Some(route), false);
response.extensions_mut().insert(ResponseRulesApplied);
response.extensions_mut().insert(StaticSourceResponse);
// Cache representation must reflect vhost response rules and transforms,
// not solely the Web Service's Cache-Control header.
let origin_eligible_plan = if origin_safe
&& !(ignore_cookie && crate::cache::varies_on(response.headers(), "cookie"))
{
crate::cache::plan(response.status(), response.headers(),
cache_settings, started.elapsed())
} else { None };
if request_method == Method::GET && !range_requested {
if let Some(settings) = transform_settings.filter(|settings| settings.enabled) {
let transform_start = Instant::now();
response = match crate::transform::apply(response, &public_suffix,
settings, no_transform).await {
Ok(value) => {
state.transform_metrics.record_response(scope, &value, transform_start.elapsed());
value
}
Err(cause) => {
state.transform_metrics.record_error(scope, transform_start.elapsed());
tracing::debug!(error = %cause, "Web Server transform failed");
return error(StatusCode::BAD_GATEWAY,
"RustProxy: Web Server response could not be transformed\n");
}
};
}
}
let fill_plan = if origin_eligible_plan.is_some() {
crate::cache::plan(response.status(), response.headers(),
cache_settings, started.elapsed())
} else { None };
let reason = if !enabled { "cache_disabled" }
else if let Some(reason) = policy.bypass_reason() { reason }
else if needs_validation { "request_revalidated" }
else if cache_ticket.is_some() && fill_plan.is_none() { "response_headers_policy" }
else if cache_ticket.is_some() { "cache_miss" }
else { "cache_unavailable" };
let fillable = request_method == Method::GET && !range_requested;
let outcome = if cache_ticket.is_some() { "MISS" } else { "BYPASS" };
if let (Some(ticket), Some(origin), Some(transformed)) = (
cache_ticket, origin_eligible_plan, fill_plan,
) {
if fillable {
let canonical = response.extensions().get::<crate::transform::CanonicalIdentity>().is_some();
let mut plan = if canonical { origin.without_vary("accept-encoding") }
else { origin };
if force_cache {
if request_headers.contains_key("cookie") { plan = plan.with_vary("cookie"); }
if request_headers.contains_key("authorization") { plan = plan.with_vary("authorization"); }
}
response = state.cache.wrap_with_fill(response, ticket,
plan.restrict(transformed), cache_settings, None);
}
}
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
exchange.cache_status.store(if outcome == "MISS" { 2 } else { 3 }, Ordering::Relaxed);
*exchange.cache_reason.lock().unwrap_or_else(|e| e.into_inner()) = reason;
}
state.cache.record(scope, &host, outcome, reason);
cache_settings.annotate_reason(response.headers_mut(), outcome, 0, 0, reason);
let transformed = response.extensions().get::<crate::transform::Applied>().is_some();
cache_settings.annotate_transformed(response.headers_mut(), transformed);
response
}
+73 -50
View File
@@ -1,3 +1,72 @@
impl Php {
pub fn validate(&mut self) -> Result<()> {
ensure!(
self.extensions.len() <= MAX_PHP_EXTENSIONS,
"Web Service PHP allows at most 16 extensions"
);
self.extensions = normalize_extensions(
std::mem::take(&mut self.extensions),
"Web Service PHP extension",
)?;
self.endpoint = self.endpoint.trim().to_owned();
ensure!(
self.connect_timeout_ms >= 50 && self.connect_timeout_ms <= 60_000,
"Web Service PHP connect timeout must be 50..60000 ms"
);
ensure!(
self.request_timeout_ms >= 100 && self.request_timeout_ms <= 3_600_000,
"Web Service PHP request timeout must be 100..3600000 ms"
);
ensure!(
(1..=4096).contains(&self.max_concurrent_requests),
"Web Service PHP max concurrent requests must be 1..4096"
);
ensure!(
self.max_request_body_bytes <= 8_u64 * 1024 * 1024 * 1024,
"Web Service PHP request body limit must be 0..8589934592 bytes"
);
ensure!(
self.max_response_header_bytes >= 4096
&& self.max_response_header_bytes <= 1024 * 1024,
"Web Service PHP response header limit must be 4096..1048576 bytes"
);
ensure!(
self.stderr_log_bytes <= 64 * 1024,
"Web Service PHP stderr log limit must be 0..65536 bytes"
);
self.custom_params = normalize_fastcgi_params(std::mem::take(&mut self.custom_params))?;
self.php_values = normalize_php_values(std::mem::take(&mut self.php_values))?;
self.php_admin_values = normalize_php_values(std::mem::take(&mut self.php_admin_values))?;
ensure!(!self.php_values.keys().any(|key| self.php_admin_values.contains_key(key)), "PHP_VALUE and PHP_ADMIN_VALUE must not define the same directive");
let php_custom_bytes = self
.custom_params
.iter()
.chain(self.php_values.iter())
.chain(self.php_admin_values.iter())
.map(|(name, value)| name.len() + value.len() + 2)
.sum::<usize>();
ensure!(
php_custom_bytes <= 128 * 1024,
"Web Service PHP custom FastCGI/PHP_VALUE data must be at most 128 KiB"
);
if self.enabled {
ensure!(!self.extensions.is_empty(), "PHP-FPM needs executable extensions");
FastCgiEndpoint::parse(&self.endpoint)?;
}
Ok(())
}
}
impl PhpFpmProfile {
pub fn validate(&mut self) -> Result<()> {
ensure!(crate::config::valid_id(&self.id), "invalid PHP-FPM profile id");
self.name = self.name.trim().to_owned();
ensure!(!self.name.is_empty() && self.name.len() <= MAX_SERVICE_NAME_BYTES
&& !self.name.chars().any(char::is_control), "PHP-FPM profile name is invalid");
self.php.validate()
}
}
impl Service {
pub fn validate(&mut self) -> Result<()> {
ensure!(crate::config::valid_id(&self.id), "invalid Web Service id");
@@ -206,56 +275,10 @@ impl Service {
"Web Service download extension",
)?;
ensure!(
self.php.extensions.len() <= MAX_PHP_EXTENSIONS,
"Web Service PHP allows at most 16 extensions"
);
self.php.extensions = normalize_extensions(
std::mem::take(&mut self.php.extensions),
"Web Service PHP extension",
)?;
self.php.endpoint = self.php.endpoint.trim().to_owned();
ensure!(
self.php.connect_timeout_ms >= 50 && self.php.connect_timeout_ms <= 60_000,
"Web Service PHP connect timeout must be 50..60000 ms"
);
ensure!(
self.php.request_timeout_ms >= 100 && self.php.request_timeout_ms <= 3_600_000,
"Web Service PHP request timeout must be 100..3600000 ms"
);
ensure!(
(1..=4096).contains(&self.php.max_concurrent_requests),
"Web Service PHP max concurrent requests must be 1..4096"
);
ensure!(
self.php.max_request_body_bytes <= 8_u64 * 1024 * 1024 * 1024,
"Web Service PHP request body limit must be 0..8589934592 bytes"
);
ensure!(
self.php.max_response_header_bytes >= 4096
&& self.php.max_response_header_bytes <= 1024 * 1024,
"Web Service PHP response header limit must be 4096..1048576 bytes"
);
ensure!(
self.php.stderr_log_bytes <= 64 * 1024,
"Web Service PHP stderr log limit must be 0..65536 bytes"
);
self.php.custom_params = normalize_fastcgi_params(std::mem::take(&mut self.php.custom_params))?;
self.php.php_values = normalize_php_values(std::mem::take(&mut self.php.php_values))?;
self.php.php_admin_values = normalize_php_values(std::mem::take(&mut self.php.php_admin_values))?;
ensure!(!self.php.php_values.keys().any(|key| self.php.php_admin_values.contains_key(key)), "PHP_VALUE and PHP_ADMIN_VALUE must not define the same directive");
let php_custom_bytes = self
.php
.custom_params
.iter()
.chain(self.php.php_values.iter())
.chain(self.php.php_admin_values.iter())
.map(|(name, value)| name.len() + value.len() + 2)
.sum::<usize>();
ensure!(
php_custom_bytes <= 128 * 1024,
"Web Service PHP custom FastCGI/PHP_VALUE data must be at most 128 KiB"
);
self.php.validate()?;
if let Some(id) = &self.php_profile_id {
ensure!(crate::config::valid_id(id), "invalid PHP-FPM profile reference");
}
htaccess::validate(&mut self.htaccess)?;
if self.php.enabled {
+15
View File
@@ -309,6 +309,16 @@ impl Default for Php {
}
}
/// Reusable PHP-FPM configuration. A Web Service can reference one profile or
/// keep its local `php` settings. Profiles are part of the revisioned Config.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct PhpFpmProfile {
pub id: String,
pub name: String,
pub php: Php,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
pub struct Hardening {
@@ -352,6 +362,10 @@ pub struct Service {
pub download_extensions: Vec<String>,
/// Symlink handling. The conservative default is `deny`.
pub symlink_policy: SymlinkPolicy,
/// Optional reference to a shared FPM profile; `php` stays the standalone
/// configuration and is preserved when attaching/detaching a profile.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub php_profile_id: Option<String>,
/// Optional FastCGI/PHP-FPM execution for configured script extensions.
pub php: Php,
/// Optional bounded Apache .htaccess compatibility layer compiled at Web Service apply time.
@@ -379,6 +393,7 @@ impl Default for Service {
compression: crate::compression::Overrides::default(),
download_extensions: Vec::new(),
symlink_policy: SymlinkPolicy::Deny,
php_profile_id: None,
php: Php::default(),
htaccess: HtaccessConfig::default(),
hardening: Hardening::default(),
+3
View File
@@ -201,6 +201,9 @@ pub(super) fn fastcgi_params(
})
.or_insert_with(|| value.to_owned());
}
// HTTP/2 and HTTP/3 use :authority and may not carry a literal Host
// header. WordPress and other CGI applications require HTTP_HOST.
params.entry("HTTP_HOST".into()).or_insert_with(|| context.server_name.clone());
// Trusted operator-defined parameters cannot override request-derived/core CGI names.
for (name, value) in &php.custom_params {
params.insert(name.clone(), value.clone());
+1 -1
View File
@@ -16,7 +16,7 @@ mod security;
mod serve;
pub use config::{
CacheRule, CompiledService, Hardening, Php, Precompressed, RewriteRule, Service, SymlinkPolicy,
CacheRule, CompiledService, Hardening, Php, PhpFpmProfile, Precompressed, RewriteRule, Service, SymlinkPolicy,
};
pub use htaccess::Config as HtaccessConfig;
pub use request::RequestContext;
+74
View File
@@ -285,3 +285,77 @@ fn php_admin_values_are_operator_only_and_serialized() {
configured.php.php_admin_values.insert("unsafe".into(), "value\nother=1".into());
assert!(configured.validate().is_err());
}
#[test]
fn fastcgi_always_sets_http_host_for_http2_http3_wordpress_requests() {
let tmp = TempDir::new().unwrap();
let mut configured = Service {
id: "host-fallback".into(),
name: "Host fallback".into(),
document_root: tmp.path().to_path_buf(),
php: Php { enabled: true, endpoint: "tcp:127.0.0.1:9000".into(), ..Default::default() },
..Default::default()
};
configured.validate().unwrap();
let service = configured.compile().unwrap();
let php = service.php.as_ref().unwrap();
let uri: Uri = "/wp-admin/index.php".parse().unwrap();
for version in [Version::HTTP_2, Version::HTTP_3] {
let encoded = fastcgi_params(
&service,
php,
&Method::GET,
&HeaderMap::new(),
&uri,
"/wp-admin/index.php",
None,
None,
&RequestContext {
client_ip: "127.0.0.1".parse().unwrap(),
client_port: Some(12345),
server_name: "example.test".into(),
server_port: 443,
tls: true,
version,
},
).unwrap();
assert!(encoded.windows(b"HTTP_HOST".len()).any(|w| w == b"HTTP_HOST"));
assert!(encoded.windows(b"example.test".len()).any(|w| w == b"example.test"));
}
}
#[test]
fn fastcgi_http_host_keeps_explicit_host_and_port() {
let tmp = TempDir::new().unwrap();
let mut configured = Service {
id: "host-port".into(),
name: "Host port".into(),
document_root: tmp.path().to_path_buf(),
php: Php { enabled: true, endpoint: "tcp:127.0.0.1:9000".into(), ..Default::default() },
..Default::default()
};
configured.validate().unwrap();
let service = configured.compile().unwrap();
let mut headers = HeaderMap::new();
headers.insert(http::header::HOST, HeaderValue::from_static("example.test:8443"));
let encoded = fastcgi_params(
&service,
service.php.as_ref().unwrap(),
&Method::GET,
&headers,
&"/index.php".parse().unwrap(),
"/index.php",
None,
None,
&RequestContext {
client_ip: "127.0.0.1".parse().unwrap(),
client_port: None,
server_name: "example.test".into(),
server_port: 443,
tls: true,
version: Version::HTTP_11,
},
).unwrap();
assert!(encoded.windows(b"HTTP_HOST".len()).any(|w| w == b"HTTP_HOST"));
assert!(encoded.windows(b"example.test:8443".len()).any(|w| w == b"example.test:8443"));
}
+10 -10
View File
@@ -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.3"
assert manifest["package"]["version"] == "0.29.4"
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.3"' in lock
assert 'name = "rust-proxy"\nversion = "0.29.4"' 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.3",
"CHANGELOG.md": "## 0.29.3",
"ROADMAP.md": "## 0.29.3",
"AGENTS.md": "Current source version: 0.29.3.",
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.29.3",
"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",
}.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.3"', '"/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.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']:
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.3"' in openapi
assert '"RuntimeInformation"' in openapi and '"version": "0.29.4"' 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.3 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.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("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.")
+3 -3
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Rust-independent documentation/Reader consistency checks for the 0.29.3 release."""
"""Rust-independent documentation/Reader consistency checks for the 0.29.4 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.3")
assert readme.startswith("# RustProxy 0.29.4")
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.3" 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.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 "## 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"]:
+2 -2
View File
@@ -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.3 source tree: missing {path}")
raise ValueError(f"Incomplete RustProxy 0.29.4 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.3 entry point; extract the complete package, not individual files.")
raise ValueError("src/lib.rs is not the 0.29.4 entry point; extract the complete package, not individual files.")
return messages
+5 -5
View File
@@ -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.3"' in (target / "Cargo.toml").read_text(encoding="utf-8")
assert 'version = "0.29.4"' 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.3"
assert manifest["to_version"] == "0.29.4"
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.3"' in openapi_0241
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.29.4"' 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.3"', '"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.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"']:
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.3 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.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.")
if __name__ == "__main__":
+19 -7
View File
@@ -21,7 +21,7 @@ web = rust_tree(ROOT, "src/web_server")
proxy = rust_source(ROOT, "src/proxy/mod.rs")
admin = rust_source(ROOT, "src/admin/mod.rs")
ui = read_vhosts_js(ROOT)
web_ui = (ROOT / "src/admin/web/web-server.js").read_text()
web_ui = (ROOT / "src/admin/web/web-server.js").read_text() + "\n" + (ROOT / "src/admin/web/web-server-profiles.js").read_text()
app_ui = read_app_js(ROOT)
html = (ROOT / "src/admin/web/index.html").read_text()
settings_ui = (ROOT / "src/admin/web/settings.js").read_text()
@@ -107,12 +107,14 @@ need(web,
"secure filesystem confinement currently requires a Unix platform")
need(proxy,
"if let crate::config::CompiledSource::WebServer(service) = &route.source",
"let static_method = request.method().clone()",
"let request_method = request.method().clone()",
"crate::web_server::serve(",
"static_headers",
"let request_headers = request.headers().clone()",
"response.extensions_mut().insert(StaticSourceResponse)",
"let custom_error =",
"execute_error_fallback(",
"crate::pages::replace_error",
"state.cache.wrap_with_fill(",
"crate::transform::apply(",
"apply_response_rules(",
"finish_headers(",
"crate::compression::encode(")
@@ -123,8 +125,9 @@ web_source_pos = proxy.index("CompiledSource::WebServer(service)", basic_identit
assert basic_identity_pos < web_source_pos
assert identity_pos < web_source_pos
assert web_source_pos < proxy.index("let (backend, backend_lease, affinity_cookie)")
custom_error_pos = proxy.index("let custom_error =")
assert proxy.find("apply_response_rules(", custom_error_pos) > custom_error_pos
web_source = rust_source(ROOT, "src/proxy/forward/web_source.rs")
assert web_source.index("apply_response_rules(") > web_source.index("let origin_headers =")
assert web_source.index("crate::transform::apply(") > web_source.index("apply_response_rules(")
need(acl, "ambiguous encoded path character", '"/%2e%2e/secret"', '"/a%2fb"', '"/a%5cb"', '"/bad%"')
need(admin, '"/web-server"', '"/assets/web-server.js"', '"web-services"', '"vhost_metrics"')
need(html, 'data-panel="web-server"', 'id="panel-web-server"', 'id="web-service-dialog"',
@@ -133,7 +136,9 @@ need(html, 'data-panel="web-server"', 'id="panel-web-server"', 'id="web-service-
'data-web-service-page="php"', 'data-web-service-page="security"', 'data-web-service-page="htaccess"',
'id="web-service-htaccess-enabled"', 'id="web-service-htaccess-help"', 'id="htaccess-help-dialog"', 'id="web-service-try-files"',
'id="web-service-cache-rules"', 'id="web-service-precompressed"', 'id="web-service-compression"', 'id="web-service-compression-zstd"', 'id="web-service-compression-br"', 'id="web-service-compression-gzip"', 'id="web-service-compression-deflate"', 'id="web-service-php-enabled"',
'id="web-service-php-pass-authorization"', 'id="web-service-php-body-limit"',
'id="web-service-php-pass-authorization"', 'id="web-service-php-profile"',
'id="web-service-php-profiles"', 'id="php-fpm-profiles-dialog"',
'id="web-service-php-body-limit"',
'id="web-service-php-header-limit"', 'id="web-service-php-stderr-limit"',
'id="web-service-php-custom-params"', 'id="web-service-php-values"', 'id="php-help-dialog"',
'id="web-service-symlink-policy"', 'id="web-service-preset"', 'id="web-service-preset-apply"',
@@ -147,6 +152,10 @@ need(web_ui, 'saveSection("web-services"', "directory_listing", "web_service_id"
"syncHtaccess", "htaccess-help-dialog", "php-help-dialog", "parseFastCgiParams", "parsePhpValues", "validatePhpCustomData", "activateTab", "initTabs", "presetChanged",
'button = el("button", t(key), "quiet")', 'button.dataset.tab = name', 'button.setAttribute("role", "tab")', "vhost-detail",
'el("span", t(key), "small-label")', "if (envelope) render();")
need(config, "pub php_fpm_profiles: Vec<crate::web_server::PhpFpmProfile>", "duplicate PHP-FPM profile id", "unknown PHP-FPM profile")
need(web, "pub struct PhpFpmProfile", "pub php_profile_id: Option<String>", "fastcgi_always_sets_http_host_for_http2_http3_wordpress_requests")
need(proxy, "empty_for_cache(&mut request)", 'request.headers_mut().insert(HOST, original_host.clone())')
need(web_ui, "phpProfileLocal", 'saveSection("php-fpm-profiles"', "function profileEditor(")
need(logging, '"route"', '"cache_effective_enabled"', '"transform_effective_enabled"', 'value["cache_enabled"]', 'value["transform_enabled"]')
need(metrics, "pub struct VhostMetricsView", "ewma_duration_us", "pub fn view(&self) -> VhostMetricsView")
assert 'item.append(text("span"' not in web_ui
@@ -154,7 +163,10 @@ need(app_ui, 'function showStartupFailure(error)', '$("login").hidden = true', '
assert '$("login").hidden = false; document.documentElement.classList.remove("app-loading")' not in app_ui
need(ui, 'type: "web_server"', "web_service_id", "sourceReverseProxy", "vhBackendRequired", "backend-diagnostics-card", "status.vhost_metrics", "vhostTrafficRate")
assert "WebService" in openapi.get("components", {}).get("schemas", {})
assert "PhpFpmProfile" in openapi["components"]["schemas"]
assert "php_fpm_profiles" in openapi["components"]["schemas"]["Config"]["properties"]
web_service_schema = openapi["components"]["schemas"]["WebService"]
assert "php_profile_id" in web_service_schema["properties"]
assert web_service_schema["properties"]["index"].get("minItems", 0) == 0
assert web_service_schema["properties"]["index"]["maxItems"] == 16
assert web_service_schema["properties"]["mime_types"]["maxProperties"] == 64
+58 -1
View File
@@ -33,6 +33,7 @@ class Element {
showModal() { this.open = true; }
close() { this.open = false; }
reset() {}
checkValidity() { return true; }
}
const ids = [
@@ -48,7 +49,9 @@ const ids = [
"web-service-compression","web-service-compression-options","web-service-compression-zstd","web-service-compression-br","web-service-compression-gzip","web-service-compression-deflate","web-service-precompressed","web-service-precompressed-br","web-service-precompressed-gzip",
"web-service-preset","web-service-preset-apply","web-service-preset-summary","web-service-reset",
"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-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"
];
const elements = new Map(ids.map(id => [id, new Element(id)]));
const pages = ["general","routing","htaccess","mime","cache","delivery","php","security"].map(name => {
@@ -69,6 +72,7 @@ for (const name of ["zstd", "br", "gzip", "deflate"]) elements.get(`web-service-
const document = {
body: new Element("body"),
createElement: tag => { const node = new Element(); node.type = tag; return node; },
querySelectorAll(selector) {
if (selector === "#web-service-tabs button") return elements.get("web-service-tabs").children;
if (selector === "[data-web-service-page]") return pages;
@@ -101,6 +105,7 @@ const context = {
};
vm.createContext(context);
vm.runInContext(fs.readFileSync("src/admin/web/drafts.js", "utf8"), context, { filename: "drafts.js" });
vm.runInContext(fs.readFileSync("src/admin/web/web-server-profiles.js", "utf8"), context, { filename: "web-server-profiles.js" });
vm.runInContext(fs.readFileSync("src/admin/web/web-server.js", "utf8"), context, { filename: "web-server.js" });
// Explicitly disabling scoped dynamic compression must hide and disable codec
@@ -172,3 +177,55 @@ if (saved.php.php_values.memory_limit !== "256M") throw new Error("Saved WordPre
if (!saved.cache_rules.some(rule => rule.matcher === "ext:css" && rule.cache_control.includes("604800"))) throw new Error("Saved WordPress preset lost cache rules");
console.log("PASS: Web Service preset selection materializes and persists CMS/download settings.");
// The shared editor lists profiles, saves them under the new revisioned
// section, and Web Services can attach without losing local PHP settings.
context.envelope.config.php_fpm_profiles = [{
id: "wp-pool", name: "WordPress pool", php: {
enabled: true, path_info: true, pass_authorization: true,
extensions: ["php"], endpoint: "tcp:127.0.0.1:9000",
connect_timeout_ms: 1000, request_timeout_ms: 30000,
max_concurrent_requests: 64, max_request_body_bytes: 0,
max_response_header_bytes: 65536, stderr_log_bytes: 4096,
custom_params: {}, php_values: {}, php_admin_values: {}
}
}];
// 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 webPanel = html.split('id="panel-web-server"')[1]?.split("</section>")[0] ?? "";
const toolbar = webPanel.match(/<div class="vhost-list-tools">([\s\S]*?)<\/div>/)?.[1] ?? "";
const shortcutAt = toolbar.indexOf('id="web-service-php-profiles-open"');
const newAt = toolbar.indexOf('id="web-service-new"');
if (shortcutAt < 0 || newAt < 0 || shortcutAt > newAt) {
throw new Error("PHP-FPM profiles shortcut must be adjacent to New Web Service in the toolbar");
}
elements.get("web-service-php-profiles-open").dispatch("click");
if (!elements.get("php-fpm-profiles-dialog").open) throw new Error("Toolbar PHP-FPM profile shortcut did not open the modal");
elements.get("php-fpm-profiles-close").dispatch("click");
if (elements.get("php-fpm-profiles-dialog").open) throw new Error("PHP-FPM profile modal failed to close");
elements.get("web-service-php-profiles").dispatch("click");
if (!elements.get("php-fpm-profiles-dialog").open) throw new Error("PHP-FPM profile modal was not opened");
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];
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") {
throw new Error("Shared profile edit did not save to the profile configuration section");
}
const profileSelector = elements.get("web-service-php-profile");
const localEndpoint = elements.get("web-service-php-endpoint").value;
profileSelector.value = "wp-pool";
profileSelector.dispatch("change");
if (!elements.get("web-service-php-endpoint").disabled) throw new Error("Shared profile left local PHP controls editable");
elements.get("web-service-id").value = "wp-shared";
elements.get("web-service-name").value = "WordPress shared";
await elements.get("web-service-form").dispatch("submit");
if (savedSection?.name !== "web-services" || savedSection.value[0].php_profile_id !== "wp-pool") {
throw new Error("Web Service did not persist selected shared PHP-FPM profile");
}
if (savedSection.value[0].php.endpoint !== localEndpoint) {
throw new Error("Attaching a shared FPM profile changed local settings");
}
console.log("PASS: Toolbar and editor PHP-FPM shortcuts open the shared modal; profile edits and Web Service attachment work.");