v0.29.4
This commit is contained in:
1 parent
f87eec7287
commit
dd8c195600
38 files changed
+1017
-208
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -1688,7 +1688,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rust-proxy"
|
||||
version = "0.29.3"
|
||||
version = "0.29.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
+19
-2
@@ -1812,7 +1812,7 @@
|
||||
"webServiceResponseHeaders": "Response headers",
|
||||
"webServiceResponseHeadersHelp": "Fixed headers for this Web Service. Vhost/location response headers run later and can override them.",
|
||||
"webServiceResponseHeadersInvalid": "Response headers must use one unique Header-Name = value entry per line.",
|
||||
"webServiceCacheHelp": "Local files use filesystem/OS page cache. These settings control browser/proxy caching and do not create a second RustProxy object cache.",
|
||||
"webServiceCacheHelp": "Eligible Web Server responses use RustProxy object cache when vhost caching is enabled. Private and no-store responses are not cached.",
|
||||
"webServiceTryFiles": "try_files",
|
||||
"webServiceTryFilesHelp": "One candidate per line. Supports $uri, $query_string and a final =404/other error status.",
|
||||
"webServiceRewrites": "Rewrite rules",
|
||||
@@ -2235,6 +2235,23 @@
|
||||
"locationErrorFallbackTarget": "Error fallback target",
|
||||
"locationErrorFallbackStatuses": "Intercepted status codes",
|
||||
"locationErrorFallbackHelp": "Optional Nginx-style error fallback. Target an enabled internal location by ID. Supported statuses: 403, 404, 500, 502, 503, 504.",
|
||||
"webServicePhpPathInfo": "Enable PHP PATH_INFO splitting"
|
||||
"webServicePhpPathInfo": "Enable PHP PATH_INFO splitting",
|
||||
"phpProfileSelect": "PHP-FPM configuration",
|
||||
"phpProfileLocal": "Local configuration",
|
||||
"phpProfileHelp": "Select a shared PHP-FPM profile or configure this Web Service directly. Shared edits affect all attached services.",
|
||||
"phpProfileManage": "Manage PHP-FPM profiles",
|
||||
"phpProfileToolbar": "PHP-FPM profiles",
|
||||
"phpProfileTitle": "Shared PHP-FPM profiles",
|
||||
"phpProfileDescription": "Expand a profile to edit it; save applies immediately to every attached Web Service.",
|
||||
"phpProfileAdd": "Add profile",
|
||||
"phpProfileNew": "New PHP-FPM profile",
|
||||
"phpProfileId": "Profile ID",
|
||||
"phpProfileName": "Profile name",
|
||||
"phpProfileAdminValues": "PHP administrator INI overrides (PHP_ADMIN_VALUE)",
|
||||
"phpProfileInUse": "Profile is used by",
|
||||
"phpProfileDelete": "Delete this PHP-FPM profile?",
|
||||
"phpProfileInvalid": "Invalid PHP-FPM profile fields",
|
||||
"phpProfileDuplicate": "Duplicate PHP-FPM profile ID",
|
||||
"phpProfileShared": "shared profile"
|
||||
}
|
||||
}
|
||||
+19
-2
@@ -1812,7 +1812,7 @@
|
||||
"webServiceResponseHeaders": "Nagłówki odpowiedzi",
|
||||
"webServiceResponseHeadersHelp": "Stałe nagłówki tej usługi WWW. Reguły vhosta/lokalizacji są nakładane później i mogą je nadpisać.",
|
||||
"webServiceResponseHeadersInvalid": "Nagłówki odpowiedzi muszą mieć unikalny format Nazwa-Nagłówka = wartość, po jednym wpisie na linię.",
|
||||
"webServiceCacheHelp": "Lokalne pliki korzystają z filesystemu/cache stron systemu operacyjnego. Te ustawienia sterują cache przeglądarki/proxy i nie tworzą drugiego cache obiektowego RustProxy.",
|
||||
"webServiceCacheHelp": "Gdy cache vhosta jest włączony, odpowiedzi serwera WWW spełniające warunki są buforowane również w RustProxy. Odpowiedzi prywatne i no-store nie są buforowane.",
|
||||
"webServiceTryFiles": "try_files",
|
||||
"webServiceTryFilesHelp": "Jeden kandydat na linię. Obsługuje $uri, $query_string oraz końcowy status błędu typu =404.",
|
||||
"webServiceRewrites": "Reguły rewrite",
|
||||
@@ -2235,6 +2235,23 @@
|
||||
"locationErrorFallbackTarget": "Cel fallbacku błędów",
|
||||
"locationErrorFallbackStatuses": "Przechwytywane kody statusu",
|
||||
"locationErrorFallbackHelp": "Opcjonalny fallback błędów w stylu Nginx. Wskaż ID włączonej lokalizacji wewnętrznej. Obsługiwane statusy: 403, 404, 500, 502, 503, 504.",
|
||||
"webServicePhpPathInfo": "Włącz rozdzielanie PHP PATH_INFO"
|
||||
"webServicePhpPathInfo": "Włącz rozdzielanie PHP PATH_INFO",
|
||||
"phpProfileSelect": "Konfiguracja PHP-FPM",
|
||||
"phpProfileLocal": "Konfiguracja lokalna",
|
||||
"phpProfileHelp": "Wybierz wspólny profil PHP-FPM lub skonfiguruj usługę samodzielnie. Zmiana profilu dotyczy wszystkich przypiętych usług.",
|
||||
"phpProfileManage": "Zarządzaj profilami PHP-FPM",
|
||||
"phpProfileToolbar": "Profile PHP-FPM",
|
||||
"phpProfileTitle": "Wspólne profile PHP-FPM",
|
||||
"phpProfileDescription": "Rozwiń profil, aby go zmienić. Zapis od razu aktualizuje wszystkie przypięte serwery WWW.",
|
||||
"phpProfileAdd": "Dodaj profil",
|
||||
"phpProfileNew": "Nowy profil PHP-FPM",
|
||||
"phpProfileId": "ID profilu",
|
||||
"phpProfileName": "Nazwa profilu",
|
||||
"phpProfileAdminValues": "Administracyjne ustawienia PHP (PHP_ADMIN_VALUE)",
|
||||
"phpProfileInUse": "Profil jest używany przez",
|
||||
"phpProfileDelete": "Usunąć profil PHP-FPM?",
|
||||
"phpProfileInvalid": "Nieprawidłowe pola profilu PHP-FPM",
|
||||
"phpProfileDuplicate": "ID profilu PHP-FPM już istnieje",
|
||||
"phpProfileShared": "profil wspólny"
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,10 @@ async fn update_section(
|
||||
config.web_services =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"php-fpm-profiles" => {
|
||||
config.php_fpm_profiles =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"redirects" => {
|
||||
config.redirects =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
|
||||
+39
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
mod tests {
|
||||
include!("tests/part_01.rs");
|
||||
include!("tests/part_02.rs");
|
||||
include!("tests/part_03.rs");
|
||||
}
|
||||
@@ -480,3 +480,4 @@ fn backend_diagnostic_flags_compile_to_frontend() {
|
||||
assert!(frontend.backend_header);
|
||||
assert!(frontend.backend_timing_header);
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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.")
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.");
|
||||
Reference in new issue
Block a user