This commit is contained in:
Mateusz Gruszczyński committed 2026-10-08 15:59:37 +02:00
1 parent 65e082b9d1
commit 76cb4acef0
72 files changed
+1715 -290

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes.
The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for
compatibility. Current source version: 0.28.2.
compatibility. Current source version: 0.29.0.
## Delivery
+12
View File
@@ -4,6 +4,18 @@
> test-only changes and packaging implementation notes were moved to
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
## 0.29.0
- Group vhost hostnames and WWW redirect controls in Frontends. Each exact domain shows both redirect directions as selectable source → destination cards, even while redirects are off. Implicit `www.` targets are supported without adding an extra hostname. Other aliases continue serving the same application without cross-domain redirects; wildcard targets are excluded.
- Add one-click per-vhost canonical www/non-www redirection. The first exact DNS hostname is canonical and its companion hostname redirects permanently while retaining the request target; Force SSL and canonicalization collapse into one redirect where possible.
- Add a dedicated Custom location Redirect action with configurable permanent/temporary status, absolute HTTP(S) target, optional capture rewrite regex and query preservation.
- Default HTTP health-check `Host` to the first exact vhost DNS hostname when no explicit health-check Host override is configured.
- Add an opt-in per-vhost forced-cache target list for narrowly scoped URLs that must be cacheable despite request query/Cookie/Authorization/client-cache directives. Response `Set-Cookie`, `private`, `no-store` and other response-side safety exclusions still block storage, and credential/cookie values remain representation variants.
- Read bounded logrotate `.gz` access/error/per-vhost log rotations from Stored Logs without accepting arbitrary filesystem paths or unbounded decompression.
- Cover static maintenance HTML returned with an explicit status such as `503 Service Unavailable`.
- Preserve valid nested-URL query strings end to end and retain rejection of literal/encoded backslashes and other ambiguous path encodings.
- Keep SQLite schema 9, saved-configuration compatibility and Cold Cache format 3 unchanged.
## 0.28.2
- Harden external certificate-file loading for Certbot/system-managed PEMs without requiring an exact chmod. POSIX ACL read access and group-readable private keys are supported; private keys still reject world-read and all external certificate/key files reject group/world-write.
Generated
+1 -1
View File
@@ -1688,7 +1688,7 @@ dependencies = [
[[package]]
name = "rust-proxy"
version = "0.28.2"
version = "0.29.0"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rust-proxy"
version = "0.28.2"
version = "0.29.0"
edition = "2021"
rust-version = "1.88"
description = "RustProxy - independent reverse proxy with local administration"
+11 -11
View File
@@ -1,9 +1,9 @@
# RustProxy 0.28.2
# RustProxy 0.29.0
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.28.2 release source.**
**0.29.0 release source.**
## Layer-4 streams
@@ -11,17 +11,17 @@ 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.28.2
## What changed in 0.29.0
- Harden external certificate-file loading without requiring one exact `chmod`: POSIX ACL based read access is supported, group read is allowed for external private keys, and harmless execute-mask bits no longer cause rejection. Private keys still reject world-read and group/world-write; all external certificate/key files reject group/world-write.
- Validate both the configured path and the resolved Certbot `live/ -> archive/` target through a trusted parent chain. Parent directories must be owned by root or the RustProxy service user and cannot be group/world-writable, except root-owned sticky ancestors such as `/tmp`.
- Make Certificate API credentials least-privilege. New tokens are reload-only by default, require an explicit allowlist of 1-32 certificate IDs, cannot call the global reload endpoint, and may receive deploy permission only when explicitly enabled. Existing credentials migrate to reload-only broad access and should be rotated to explicit IDs.
- Add schema 9 for Certificate API grants while preserving the existing proxy configuration document and COLD cache format 3.
- Add explicit read-only systemd paths for `/etc/letsencrypt` and `/etc/ssl`; Unix/ACL permissions still decide which files the `rustproxy` user can actually read.
- Expand English/Polish certificate help with concrete POSIX ACL examples, including `setfacl -m u:rustproxy:--x /etc/ssl/`, and clarify the external-file security requirements.
- Retain the 0.28.1 external-file/Certbot hot-reload workflow and all 0.28.0 GeoIP, listener protocol-badge, cache Via/invalidation, Security-editor and ACME runtime-reporting improvements.
- Add a per-vhost **www/non-www redirect** switch. The Frontends tab groups hostname chips, www controls and direction cards. For each exact hostname, the operator can select either www to bare or bare to www, even if the target variant was not explicitly added to the vhost; the runtime owns both names. Options stay visible while disabled for preview. Redirect `308` keeps path/query, Force SSL collapses into one hop, and other hostnames such as `suba.local` and `subb.local` keep serving the same application without cross-domain redirects.
- Add a first-class **Redirect** action to Custom locations with `301/302/303/307/308`, an absolute HTTP(S) target, optional capture rewrite regex (`$1`, `${name}`) and optional query preservation. This covers Nginx-style `location` + `rewrite ... permanent` workflows without opening a backend connection.
- Make HTTP backend health checks automatically send the first exact vhost DNS hostname as `Host` when the health-check Host field is empty. An explicitly configured health-check Host remains the override.
- Add an explicitly dangerous, **per-vhost-only forced-cache URL list**. Matching GET/HEAD targets can bypass request-side query/Cookie/Authorization/client-no-cache exclusions, while response-side privacy guards such as `Set-Cookie`, `private` and `no-store` remain mandatory; Cookie and Authorization are included in variant identity when this mode is used.
- Let Stored Logs discover and read logrotate-style `.gz` rotations with strict compressed/decompressed byte limits and the same bounded tail/search behavior as active logs.
- Retain the existing local/static page status override and cover the common maintenance-page case where HTML is served with HTTP `503 Service Unavailable`.
- Add regression coverage for nested URL query parameters such as `/convert?url=https%3A//...&ip=...`. Literal/encoded backslashes remain rejected as ambiguous routing input; quoted curl URLs must not contain shell backslashes before `?`, `=` or `&`.
0.28.2 upgrades SQLite schema 8 to schema 9 with an additive Certificate API grants table. Saved proxy configuration remains compatible with 0.28.1. Historical details are in [CHANGELOG.md](CHANGELOG.md).
0.29.0 keeps SQLite schema 9 and Cold Cache format 3. Existing 0.28.2 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
+13
View File
@@ -1,5 +1,18 @@
# RustProxy roadmap
## 0.29.0 - Redirect, health-host, forced-cache and rotated-log controls
Current direction: 0.28.2 -> 0.29.0
- [x] Add one-checkbox canonical www/non-www redirects per vhost.
- [x] Add first-class Custom location redirects with capture-based rewrite targets and query preservation.
- [x] Default HTTP health-check Host to the first exact vhost hostname while keeping the manual field as an override.
- [x] Add explicit per-vhost forced-cache targets with response privacy protections retained.
- [x] Read bounded logrotate gzip rotations in Stored Logs.
- [x] Verify local/static HTML pages can intentionally return statuses such as 503.
- [x] Preserve valid nested URL query parameters while retaining ambiguous-backslash rejection.
- [x] Keep SQLite schema 9 and Cold Cache format 3 unchanged.
## 0.28.2 - External certificate hardening and least-privilege reload API
Current direction: 0.28.1 -> 0.28.2
+74 -68
View File
@@ -1,18 +1,18 @@
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
50ce81b53fc7fb7c33c5a1e3dd47c2e9de851ebdec103dbeb93e6c83f4bca3c2 AGENTS.md
35c34cfb19cab211e02cf473911c412fac0637ab0f9a62334704b5f598e3f80f CHANGELOG.md
5fec7c9f4b7c16088b05bdaf5ecd439e4521b75df07c4ee6ee8fdb50a4b78420 Cargo.lock
eda38ce1f741b31b66b4d3d7f1b197bcb4710b933863cfe925a8a71068e632c8 Cargo.toml
ee028ed38129f0e76a2b818066428ad74a3d3f1ee61b0b25ea36e77b65f4f0a8 AGENTS.md
36736e8d99e58e9bbc43f84d81a15df1a2794d483e11f218c21d9429f135e7c3 CHANGELOG.md
1116b5deea3ec4a2a46692b3a4eb2a5bfc31081dd1bdab96b6244c6a6e22c933 Cargo.lock
2d9dc00622ab42c7fb29f324528a5b190e906a37c5fe8d0fdd142fc35589972c Cargo.toml
75b008f237b2226c62a7721b7901018ce83bfc2726f047a83a17c66fcb799f3b Dockerfile
3981a80f8516471defe68d7d76972b949278704ee5586982232f779ad4c7728e README.md
5f5956476616f774c0488e7db20883619bd4844e2e5009c5801e96c7b4bbbbf0 ROADMAP.md
02954764624debfb8f630bd14060194469ffe84a571c4b2107ef09144c8c6829 README.md
ac958a06396e1b8af0c46e903e1eb23f38706fa694aac380654abeed9299dbad ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md
4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service
6e4601243bdbb6917494a21319ef9240150ea51b86f5bbeee0e8283f54a08470 dev.sh
5f950935ca5098a1b6ba500cee86c8f06b8827f7cb30b9294548efd53a92a031 dev.sh
5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md
7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go
5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example
@@ -21,25 +21,25 @@ bf687a851374d8f1fa59d050f3f76532f7c12f16a6daa7d3c36102c2d9a8ec9c docker-agent/d
9d77c90767fa929ee8338734d72ce83284d3d965673270f62cb2b1613d587a33 docker-agent/install.sh
b68234472bec5cedd67c7cfe1de7d8806a965dfd4101f944aa6320132f18c14e docker-agent/main.go
3d4051a1f805d145df3877476e4b87773e74be8e123045065955a5133afdc68c docker-agent/main_test.go
dccc9fb4ae1f8358fb4b91fe3746afaa9a58288fc4ff9fd7ae7d2083d360672c docker-agent/model.go
dc716943644f98db80213efdb3409d0c8c0a180298eefc7de924777606a09ef8 docker-agent/model.go
e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/network.go
634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
875faa3969df9409d79f050a84b4183496f557edda3d34aaec19fe556cf8c471 docs/ACME_PROVIDERS.md
ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md
67a1e0cbe5d1139251852401d876273ec7ea8cd1c595ecd63af097921b0d21b3 docs/API.md
598a93c724e6eed16083e304af3e082c804e227cc9175288e582db93805dc869 docs/API_ACCESS.md
464d514c54f9dd1efd7c1b7a0de9cf1dc91ff26bebb79432f177ca686e0a0113 docs/API_ACCESS.md
4c5288c9ed427cfb919bd023ed9a321dcf6d64bc4cad3e1768393356c5d835ba docs/ARCHITECTURE.md
3f18d48514ad003ac24ad07ea47271a5719dd8bc1f2329f0c3fbae774e086426 docs/CACHE.md
57471965bbbb8bebfe675a36ac9e7b8bf8fb56d7d60537c8724fd5d42a0ffe94 docs/CACHE.md
6266163e17d2921468abdae92c1620266af8351321530e2c9b7dca6f3dd89c88 docs/CACHE_DIAGNOSTICS.md
c6003dcbc0b95e2cfa9523b7017186cc8b4826248b588af77111ac5eb15174f9 docs/CHANGELOG_TECHNICAL_ARCHIVE.md
6476f0c99eef0bff0b17b5891306ed4073f3de242f1c147a8fa1838a06a0b2bd docs/CONTAINER.md
9538a52d508d8d7053c1387c42eef60e6d5dc1879c300b6fe951b3374c16884c docs/CUSTOM_LOCATIONS.md
3bf0f1d06fa0a13f6733aa6c3f1b7d269d7a95e81138c5db6df6fb49f5beb13e docs/CONTAINER.md
6794d1ca3e06a5c04c3e514f4d23f7de917f9f5429ff659fc90dbbae68abccc6 docs/CUSTOM_LOCATIONS.md
16d8f59908c66e595d6003495cbf5c068c68b10aa9495eb8612c38b2de0e4a7c docs/DOCKER_DISCOVERY.md
9ea8db4d034ecb573976dd323da29f044538525c230df9f43a47be1ac867b24c docs/ERROR_LOGGING.md
8182d586687b1c4900f17c857e6c5a1b7cf252d86dc243361c0b59241aa68261 docs/FILE_LOGS.md
c23b9e7d6e4df4134aa39a697d67ec22948133ecce1d4a36774d2a2a344aae75 docs/FILE_LOGS.md
8dfc89a8b3512103b87864bd7c24a3dc3de4465edf8f9874e6bed1f4634dd930 docs/HTTP3.md
f2689412e7a5f84ce29ee496d4adac01eccc022afe464747063fefa6e18ecf08 docs/INSTALL.md
ed17ee90719ff6211ce8974a00c39d1c299f7d58b538dbd4f2024b3ab47d05f2 docs/INSTALL.md
3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md
4bb56add544f15d54695cf797cd16a51c09024360cdeef803a88988f4a54b3a5 docs/LIVE_LOGS.md
7c35c4cdbe7bbb81d94f3bf6aeb22b8a866af3e7f0d62bbedc299e4d6f264e0d docs/MANAGEMENT.md
@@ -50,7 +50,7 @@ e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND
89a1af6c9fe10c572b211f9632ce256166e67990b5dacacf2ae07265d99a5172 docs/NPM_MIGRATION.md
712cb1cbf84515384b081fd2929e86e40af7c10f65bc69919b78b7b3e2fd12b5 docs/PHP_FPM.md
572c70fbd2f61fecd80a8ff9fa5de4a94c4f158de5e90da8e920f99b051241fa docs/PROMETHEUS.md
a4ec600ee09eef7fa02d6d894cec6cfb7146da4253b40ad2b65968efa8cc2c06 docs/README.md
c2a1f118c07c59fb25f0f0e13015fcb9b65bd04ae1570df06ec06ba591a73f4c docs/README.md
6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md
0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md
d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.md
@@ -60,7 +60,7 @@ a2bb837cdd3559d58158880c1f832badf64035376a3623050bb4e40c2bca104c docs/ROADMAP_H
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
d5c5c59f6e63b8d83dad5d159657c4d4039dfeb4cc1d8af210169b576adbf7e2 docs/SETTINGS.md
3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md
e7471f3641e2ac84cd02f61effcb8ba8b91f45d941fcb99c3a038f14c90938a0 docs/TEST_REPORT.md
d03f3ec73a8a58d830712f3adaf9de61f11b93f3aa735f6584b07e724c0e0440 docs/TEST_REPORT.md
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
8ee6187a5460ca45643b7fd789e27bffd264449e3a92ff70e9359edee011bbbb docs/TRANSFORM.md
660687fc961f71aae2bdf71595aa47a336fa782f4e78ceee8c1d1e63e46393ee docs/TRANSPORT.md
@@ -102,7 +102,8 @@ efd5935852d4cfd09758bf67c70e437490cfce2baade46c8733201e506dbcc42 docs/V0.27.0_I
713518c1a09969703d7b09b83380d5e659f5d1587ef23585abfa1fa87c033ae5 docs/V0.28.0_IMPLEMENTATION.md
8e1b45415a2bffcf26fe113bbc25627daf5d8758eb9e44c38abb060a4a402a46 docs/V0.28.1_IMPLEMENTATION.md
097a4ff47634e5b09180012135b49a8ed16193fadc36113cad0c8dffb9e47cca docs/V0.28.2_IMPLEMENTATION.md
bc2ac0117528ed4616c68cf7fe05cb1d9bbd36ecaa8573828cb3b3136923f265 docs/VHOSTS.md
bfe9b83d2292bae1e579c1841a396173258496063dbd0f3953780dedeebc2c40 docs/V0.29.0_IMPLEMENTATION.md
7fe75e824160570c7ebd3b04e92d3493d74bfda64732c1dacfff59d90ed74398 docs/VHOSTS.md
09b60fb7b40db4d0139fa2a9e69a2aa9f28d4e61b8643521cfc7e936d7df8774 docs/VHOST_ACCESS.md
4026a4adeee70f98a96cc7aa8d8be16cda42ffb2e5890d38f1ba638134ab0675 docs/WEB_SERVER.md
c336f998027330c58d5afbbbb3a3a682fd44ae3997643882fe342633822f8bb3 docs/screenshots/README.md
@@ -116,8 +117,8 @@ b26833cb4251f9390a44192ecc827ff7f052fd19016a5ccf7498c94503ab4ce3 examples/mock-
5a8f3a8a054abc89c6e78896b9fe2f903212199a24dc3d2d2bcbd0696a31bcde examples/transport.json
d242de9614fbb52933b4accd32f4715ca94107abe4a3b1d7e088362b07a6ba60 examples/vhosts-balanced.json
af2bdb1343a8141069e94ccd2c7f92574ad848437e2f9fdb5c7576a9a18ef4a5 examples/vhosts-private.json
c710b7658d131be4b8a5c90a31d149f442c1c511464fddc5522e38f15f20f687 grafana/README.md
b1312b071a840fa4e7d9538eff7fadbe197e564811dc6fb4608a2ee34bd197f3 grafana/rustproxy-overview.json
84a24c85d2986f4de509e3e816880b051e4eece00a0efa47945102a0c010fb52 grafana/README.md
f15db15a269a1c4ec9c7ff758f418a402475e4712f821599cd20e85ade2f42a8 grafana/rustproxy-overview.json
a51d911526d2631282a358be4152ef1ad6ed4034e34f961a5290b1b90fffaf32 install-debian13.sh
909e9ab94617ace74a425f15084bf85f3086cc52ba5b28f2b441ff800abc6ef9 install.sh
42f9f1d18c021fc7734b5bd020844bf678725a0e539e2fcbe05ec676f6239eae integrations/README.md
@@ -130,9 +131,9 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md
37dd685d47305be1450cb3ecc47f5e614c745cc09e30cd711e029c145e308e52 langs/en_US.json
a443d4cf2a37447ccf7553f8a3e14631bcd7997474c198c667617318dba38489 langs/pl_pl.json
8791b72d868d7c1a54ba6ba06927899a5bebba3dd57ed29bf4a735d1bf2303ac make_zip.py
636ba7cf04e94629f04c65f30abb3e3eef4af4557d80f4edf4efad9377308589 langs/en_US.json
000f6706dcf22d74b26f4f0aa7f04cab072fed86909a4f7754ee3b767c60fd7b langs/pl_pl.json
1eaeed54289f6c2fc659714c258fbe2848682962ea4b1f2802a491fea0ef23ce make_zip.py
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
5c62da30fa9db594a4d656c77ae676d3c388072b6a85f91170ec7d911d7cde43 migrations/0004_admin_preferences.sql
@@ -149,11 +150,11 @@ ee50f38c037765fab742e888d4b9fc4d874c31c785c6064d4eca3d2c4d24d944 nagios/check_r
6e036f736a2b40feb78eec17bf34871771524190f283591d1c89e48e6d2271e6 nagios/check_rustproxy_queues
7458b2d74495e6fb1c1edeea4d5a793705476431dadb60627187111f91441e13 rust-toolchain.toml
2145ad17ae7a823756371ceab6bdbeb332211df93193ffb4e486f7a80c4f29ae scripts/README.md
0c404444b802c0c7e71307c05b5348ae23bf6b00d7b824eabf0e240e2bb579d3 scripts/build/swagger_assets.py
466d313c77b76e4756cab175f019e4b91c4685766da26c8fe698534bed2c93db scripts/build/swagger_assets.py
187ee92c8029cdd59e89b4447cf4d170e856017a2091ceb85ae7c22dd1a79c72 scripts/deploy/deploy.py
533af06a16e99be60db5cd198afa0fb55a71e573b6fe1f2a214d7999d7603000 scripts/deploy/deploy_core.py
dc2cbeecb183308cb7ade81e2f88583fdd12df8d5f85f373424150edffb428d0 scripts/deploy/deploy_core.py
2eec7ff7c689416135984e38a90c177a8b06bdf176da69294cce25e5571847d5 scripts/deploy/deploy_runtime.py
005565787a15a28b2ae622af4496ac5f49b119bbe1d22a65693195d0ba86d1d3 scripts/deploy/upgrade.py
83d11d9b21358c698089f0a8ca3404c6b9b5bf1988040675ac5e90edfc18ed2f scripts/deploy/upgrade.py
2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py
a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py
31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py
@@ -172,13 +173,14 @@ c8941a929dfef9e8875adf534394ca01a1532e567d5099fefe3657826886ee25 src/acme/runti
bc9eb32f0d2785bac122d5eff44742cd24306c503daa065564383d46416b2211 src/admin/access.rs
019f4aa33e3596bc9c11ef8cf5703bcfffe46c15288c0eba83c8dd9e38055fb2 src/admin/certificates.rs
1de4c7c116eeab2452ccccbaba3f2b7b63c6eb1c1e146ab870d19c5a5ca16f80 src/admin/documentation.rs
af141b6eafa1729af1a5e8c8540bf4ee2aeb94f7443d5855f84ef2163fd41128 src/admin/documentation/documents.rs
2518cf03b99c2ffb784cb3a68afbeb40565326743c8df91560046ac5a831e13c src/admin/documentation/documents.rs
67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs
002922c91aa07cbea17cb0ee11127bb6e7e015de108ee032761b34507dcfa06d src/admin/file_log_search.rs
d3210e037481e1f95e840550428e088ffd83ec9a42f1810e4c976298fb555a9a src/admin/file_logs.rs
e9f72e65c9ceb255248f460cd7a003fff33e4b258160e248353c3e1fb4e952fb src/admin/file_logs.rs
1596561bb57976268b02e36e8ace426091045ee9d3f8832ce775eccdbc30bc81 src/admin/file_logs_io.rs
de331ab75c31df65a4f0f1607dbfb26972799bff5d9f232d8e914c38225cffc7 src/admin/logging_api.rs
58802528128f53e493f7339063433a747b605007a8842b5efc670132d897d6f7 src/admin/mod.rs
8c1e6163414bafd7c7106008886a952f005a2ed0e50928ca83545cce647528e1 src/admin/openapi.json
4f11cd49f01f00a30918fc759d2a7bfe96ed0f69c9c5017652964a559a90d24a src/admin/openapi.json
6c2b9a0ecb2f4172f110a363c22a6be32895dbcff9605fe7aa94efe91e0a536a src/admin/operations.rs
5287fff6b3ad436ac695e9b66feb1b0b5705b744a9f00cec7ea6a3e951e5eebf src/admin/preferences.rs
b4fa393b780b2676b5d72e0f90cd7290292f424b8062d4b5983e722d6ca72c32 src/admin/public.rs
@@ -192,7 +194,7 @@ cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail
34cf08cafb63cf7a8a6336e5c81fc99abfce8669662e387a3702c3a532be8056 src/admin/tests/part_03.rs
46c6d04fb6a125ebb00f7295d3ad3c16071477fc40f2b377455ed7a6eb1370d2 src/admin/tests/part_04.rs
5f0062abc42a3b81c79088dc0824ab4c81a22c6ea765e77d2527d07d13bffd58 src/admin/tests/part_05.rs
f869e4da5de149fc04ce1aab658befeaf3208c862f149ca976235d15832c3cdb src/admin/tests/part_06.rs
311c4ad16b3ad5b295a339241afd4ca8ea0d3c7f54f609c27518b4de1b4a4b71 src/admin/tests/part_06.rs
5fa006413fd93529ac9aaa4ffb28bf6b0b88d0a55a0bf0c49576db4a06fe9fc3 src/admin/tests/part_07.rs
2ae5bc857dd62882f16d1cfcee9691c9ba8afc149f4c0435addd48b564487517 src/admin/web/acme.js
446011c2c7afddf7734ceb268c75a3b4146dbe8e915b11986495e956b6c62c69 src/admin/web/app/config-history.js
@@ -216,7 +218,7 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
af6e7a2e890df2fb7385a374bcb3fba52fb5a53dbe18adc1fb7865660fcba842 src/admin/web/file-logs.js
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
964b78bb365d9c2965031860f8d268c20593966b22670e58ca6b96673c4efae4 src/admin/web/index.html
4ab416af339213f4774e358855aeaf890fa6a9ae33190bc44cc668fe53d25e87 src/admin/web/index.html
24db88842707f764064657712d3660c6ac129fbfb47d188c0903925cf8244ce5 src/admin/web/integrations.js
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
af0fbd94d9b88ce1cce28e39dfd2c35de031adcba412dd4a9a17d9b416c256dd src/admin/web/login.html
@@ -225,12 +227,12 @@ e169d77359785ba604ce1a728420571fdc9b491fc1c0088642399466fbed8c1b src/admin/web/
90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js
7fc4c3661524f11448bc43ebe8f494ed44b8b0a414e63ce5bb8717f314c33808 src/admin/web/settings.js
bb67e77af5514478485cfb69f339d63d7eb0cbadd093fa054814a416260ccc1d src/admin/web/streams.js
ff777b36069747dc30c10fdaf16f610a866879f6acf8af6ef67fb96c1d9b00c2 src/admin/web/style.css
82b3d9b270c0adfe7a44925c8dd1a93b39f2936f40f4b260d36dec0689701595 src/admin/web/style.css
a8a73a4fa849ae49391fcd6143e900c515c8b577f71b55cbae9701fa9f5bbe47 src/admin/web/traffic-panel.js
e686d68fd6a368110a85dc4c5dc64e8093950d20d63e9aa44790c03dea6fc193 src/admin/web/transform-panel.js
afed64f55bc0f377ffac68b630b8e3a96dcf2f735a42ab658d74d9ce15cef265 src/admin/web/vhosts/core.js
4cf331c897574f7cba035e0af12f3fc5e2c8a6efae790f8ca3be77d29e4fa593 src/admin/web/vhosts/editor.js
b17f6a06681102c507aa7bdfb48ca5a713a687de3a8ac2babf148e69943ce033 src/admin/web/vhosts/render.js
c6f78facd9e34266b8b651c45bca46db2cf2fc7a1a76a16f31fcce787f60aa9b src/admin/web/vhosts/core.js
47f4f8faa64df6d515f553365aab86df4278eeeb2206fdb383f9a38fc46e31c3 src/admin/web/vhosts/editor.js
a155dd33eb802d4f2906f581b8f0d4b40260cb917766c31d4e887cdf51f2e940 src/admin/web/vhosts/render.js
319b1f0b8b9591d54da7a9be86633b33520a259ba96e7eb32fa2a28817e768da src/admin/web/web-server.js
98c00d99822f9be54fc58afb6d4b577901712effd2c5eddce014312681864625 src/auth/files.rs
b2f948bcc3dbd24952c7b1b02d41d06757f49f0c38a78caf51f8f8a687d8480d src/auth/files/tests.rs
@@ -275,20 +277,20 @@ f3e026a3c0e953dca3e5c9def43d864a7706afcd7cbeeaf0f21c059a335b30cc src/cache/look
58ede9780b70ac46fe8b35b6a85f02f56f9a2c75b045e3435062cceb3921f3aa src/cache/model.rs
ba94226addfd6cdab67d666dc15b8412c4d117111e44639e503e89a95175d0a2 src/cache/model/cache.rs
0ec4f6d0adea1ed80a3ca17c68c9c85502491da2904d9d690b4169ab9b025b21 src/cache/model/cold_metadata.rs
a99e79aba7f7614712386a4393ede0f3826be1f74af0cacfc1859a17636b8e6d src/cache/model/entry.rs
f9c426a17db8555ce6cb81eeb39b2321d5cedc628139ad3abb3152f59e7ce8b5 src/cache/model/entry.rs
09a4417f82984eca8c040ea28eb82b3f129c347d951d4b4fab704bb194d9b985 src/cache/model/maintenance.rs
e477f9b9fc63347ad69acf6757c54c88db26ebbf486d5f3229247bec982e836a src/cache/model/settings.rs
55dcc0ad84d6b73689ba43e759d5685c406419c7733e23e78d5b072891ab17d7 src/cache/model/views.rs
e26dce02df9c043329ca164fed215d271781bce8fb03240f528331bf3df6b9d7 src/cache/policy.rs
ee06af7a6d14bc12e73be5c1bd494426c1735d0bba2a8870a2aea4764ba4270c src/cache/policy/request.rs
c46f335cf75b91b728125596242a9682a7ef3dbe86242dde7fc038790984a6e4 src/cache/policy/response.rs
989df11e246123f760ef31689370f3500e0913fcc6b70d7ac0c2c95e2f412fb0 src/cache/policy/request.rs
d426d341a7678f8ec70406d86052c97d0f49804bbcd8402951103d61a735b9da src/cache/policy/response.rs
7e2bcfbb2b57732cdc704d9df7a9298fb80e735e37984431bc936630e41e7f8a src/cache/range.rs
efde2bd046ed073900cd3e67051f0542c802a06e8f5e38c2c20ee92604080c36 src/cache/tests.rs
13dab7b7a587cfc146826a92ab94ec9e04f7e500b7a84e678d77226829844923 src/cache/tests/cold_tier_tests.rs
b4f609ee900c556b613136bbd3fce11c242a415d277ac3d25bdcb72275b66832 src/cache/tests/cold_tier_tests/part_01.rs
f43aa6c231f2c85ee3390dddab74aab2eea2a3f874acb35f145c23866266cb52 src/cache/tests/cold_tier_tests/part_02.rs
e94cb7b767af1e7adb284d43604e1553cff1fae7cf72dccf40ae11f434a4a26c src/cache/tests/dashboard_tests.rs
6c69ef40567d34671d26231358e2db74cc26ec0113843303caa98d4dc0fbf1f9 src/cache/tests/scope_tests.rs
1db70ea5f3cc1f1543504a68498ba23cafce5f9bb48c4616a4bf70d9a536790e src/cache/tests/scope_tests.rs
578b18cac918ba51fad775eca188f645b228500e59eb1790f783a0b8d0245b7b src/cache/tests/tests.rs
c33013089bf9e75a07a5b43f1030acaba8aba4098796e393ed4bad784f4fff96 src/cache/tests/tests/part_01.rs
2f579a67e73817907525651f32c65db9f4175d55c9ea34773e988a7764a7fc8b src/cache/tests/tests/part_02.rs
@@ -302,15 +304,15 @@ a8a0ce446eb53ffdb8f8d97efa3e1344410dc3798b25f62b8817e8e160d28940 src/cache/tick
e2f58279499b7c3b47c65d3a7814bb5c7cd6506544d939875fbb0f91fa9aa59d src/compression/tests.rs
7921ae451ccd65d647165b02919bd97399399da471454a6a1a1f63e682b76fc8 src/config.rs
7ba52d3de4d77d1f3cad1b10de418953adc33901727a0791980847ae223929aa src/config/backend.rs
f6e9452a960f1d1c825ef6459125eda5ec9d16d5c461b5c6d21baae8e7d95d6c src/config/compile.rs
7572ef3ad2125806ea5fd9a7c31f3e7da860d8cfe167fcf7cd6fe4b345b70199 src/config/model.rs
2ee73f5f68b3fdaaef787319b8871ab3817a2052779fd0575965edee1d468fba src/config/routing.rs
9662674d17740df4999f28bec42a2fbd3453a93e591c88f0796edc396bb23a18 src/config/snapshot.rs
ae30522f84db31cd77e7324cca1f88212a16e567eefcba45ffb655918a921d30 src/config/compile.rs
63e636b2693950a48f7b559f9e7afdcdbe43644753cc259cc2ae219f766383c6 src/config/model.rs
c8bbb5d6356911accceca128d2cd14cf5ed8cc6cb1a789e17d8187074997cb69 src/config/routing.rs
8ffac4b67fffa8a6d0b96b2f0aa171dc71d5b8b525a9da0f4b51c5b26af5d1f2 src/config/snapshot.rs
7d94d1bd4eb698e0588b822d93e56e3dc5594c46edac4aa9713f148893ebe6d5 src/config/tests.rs
7592bdeaad9950a720e2d841fcb1a4edb2a68af5333a6163e672d0392616103c src/config/tests/part_01.rs
fb0d4d91ae95fe551b06b9195f9e6d41bc40fb6a3a7c547b170fcfeb9abe213c src/config/tests/part_02.rs
a5bbae014b7b5c620f327c337c54951f56713ade149e3a5f558bcc2b73a9e50b src/db.rs
7d85028d6470c2ceb2bac9861a4fe0c4793dbbd886434bfd5d289011777010b5 src/db/certificate_api.rs
fb6e42d969b207e3e204d5892303740cf6f780be5a195cbee1f894b703994b60 src/config/tests/part_02.rs
c6db84438878accb8a0c96be13fa01a83da824af39c929dc3f6df498f393b7e0 src/db.rs
7fadb86f6a61dd0127dd68c115be36865f0b2af535510f4c8f9aff6c9b8cdbfe src/db/certificate_api.rs
2ff744223934685439374572b83fd639bb3eabdbac168bea4a6501a6e9ce0ee0 src/db/history.rs
c05a02618d476e4670b4f9930555f199cac776cdf09edb8fcc1a675695b8f99f src/db/tests.rs
19ab6c3ae37009c544291cb0b65c1bb9c721e168f95c1ed3fa88155aca7463ad src/demo.rs
@@ -343,11 +345,11 @@ a16613114273c54b6d2a16a94fa8748550e91468585469642622bd2c86087baa src/logging/fo
17425ebbca6bbbb0dcaeb65ebc44b1e51488a4092b7b6add556e764c6d486d6a src/logging/tests.rs
3836ed14b6710e183f048ee4d7807a35366c27a27338057fe4a2811129b63302 src/main.rs
2f6b5a9517453541bf43580a9d963e64e36338575ee9ad295ca3718803a59ed6 src/main/admin_runtime.rs
2f7c87361711b80b1f1740a91e80af44122c7015887b79e9b0db9684065c8c4d src/main/bootstrap.rs
3b6a523d5b4e46576d97ae5816ed7b669ed97463afb90d326aa5a9ffb375353c src/main/bootstrap.rs
5a98d0e249338e73aa57ae2e352dc16db2f79c2d042c1544ce1c2022da76a31c src/main/proxy_runtime.rs
4dd7c1084a6214c0f2513ebf86fe8ef144470695557772bfe25979edd64491ab src/main/shutdown.rs
0a6578a0f4023b9f8d73043bf942dc5e096ef9afb8c0d2f131c14f65d08ac01d src/main/shutdown/tests.rs
4837a31562dd411b998fe8397459ac72aa4777ae61ce6484365c552b8b553cb8 src/main/tasks.rs
1b127f28d53c082614dec1163b6b6ce24bbff0db6cbef9c9873fc44c45443566 src/main/tasks.rs
49d7e104585c8791ac9a203dddd95802784b68c0ba65d6f90f3d57154b4c7871 src/metrics.rs
201ab702722d6d21585fe8bb49939150d61bfac7ed8dc198b98c2cc6098ffb5f src/metrics/global.rs
49b11df65b4fb0b5b727f9f867436584680b45e3f4fbd53f74bc0cab8b9a88ec src/metrics/vhost.rs
@@ -355,7 +357,7 @@ a54b5b34ea65f29723b89b81bf7968a238e8247b9c9760f3430d3504cb1017c9 src/metrics/vh
a4702c95c7e5077c2ccff86e2e3216e1da6ff9ee4383ae199a0aff2f78db325f src/not_found.rs
668840e7bd70590e2e13f59b6d5b9e43ca7c877f3d07c71907baea91b5bba6cb src/not_found/tests.rs
6aab4f97d72ec6352c940bc89e911b8e50c580821da8202744e9832621458784 src/pages.rs
73a0770726eb90f89fe1a65e0e146c39209120e70d5817fe2e8e63f3c3521c40 src/pages/tests.rs
49c17c5084b3d2f52a7d3473e55311dc4daafb1a47805f8821a5a4090dd477af src/pages/tests.rs
4198844bc70c59ca3f8da812928391e2ca3d4eebc84bde432a5724c0c63d414b src/prometheus.rs
de67f5ef52289506ee3b4b4ceb72923eb16b8f53ae55373c6c9e7a01900dadcd src/prometheus/core.rs
5da9771bbd9843febe80cc9a722a9ada9d20cfb8d22addde7fef2c4edb66f327 src/prometheus/docker_misc.rs
@@ -368,17 +370,18 @@ b5856fd38a957b2ca9c7e3606d84d13d5cfcf3a45b487b15ae1d2dc248cbda57 src/proxy/body
f0cb26ed34c35bacc74b914457acac8a611390de14cf46e2e6d3bcc848e914e8 src/proxy/cache_helpers.rs
cfb0910af8cd868bde3f6c9992319e46d5328d6d8f69c50701df0ea12bef2108 src/proxy/forward/cache_lookup.rs
212de22f407dd96800afcf65fd1fc7c40fede1b88f80d04850eb8d1227b08a5b src/proxy/forward/response.rs
0a7841a355f4740c5d9182cfa3ede1b0e6195b66f0518f976810513612ca47dd src/proxy/forward/route_cache.rs
51c620b77004cca26edf387f9650f07d27801e093d32cc0162eaee1d0702c126 src/proxy/forward/success_finish.rs
e5b1812c75b4cca91c32e99d28622cac1dea8e347bdf1c46e43546cb341f95a4 src/proxy/forward/success_prepare.rs
59665df93430930f146d331f360189bad6ef03979a9cfe991453c5ba259b351b 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
fcddbe41ec8493dabad338c282679d1679d0eb4dfdec51abdab1f80f582ba3ac src/proxy/forward_entry.rs
b604e221d7e0d26b6eedfb5f10e2584c8bda99899fcf28ea740f4046bba3da39 src/proxy/forward_entry.rs
c438066616527a3108f2ad53e3698ae9b2a80961714394cdd0a450c92f99d992 src/proxy/headers.rs
5cb7522ceb9a9f083a15596ac4898559295e38d1138c80099577211de8a868ec src/proxy/headers/tests.rs
f6aef54204b5bb9305dd1367818c7ffd3cb273008404c9fab0317af1d13ba7a6 src/proxy/mod.rs
d75ff3e38affa49f9a8b9d6801454c1449458e5fbf07e08d51aa3f42abebafd5 src/proxy/tests/authority_tests.rs
9fbe0578c56f5d428ef07086c3c26cd40780d82d5aa21361ff58d946a395fed7 src/proxy/mod.rs
55133ceace2e5262d9c54fa41bea099a27b817d1e09c1cd0e2493fd9cee4da38 src/proxy/redirect_helpers.rs
3c6ffd4b8578af0836976707f918d73b9516ab37d97e68744d3d608fbaa30438 src/proxy/tests/authority_tests.rs
9a4c13efb218a5fe8689db5bd6b66029ca6ec270724d948651f3b18e78c80fba src/proxy/types.rs
5ad8b7821b53804f12b151b2bc39cc586401d9fa71d287d15d1e970b0160d14d src/proxy/upstream_helpers.rs
1b980d68f8b06b5d745b4556343b7990fa20660825953b9d0037c056e6991fc3 src/proxy/upstream_helpers.rs
69f775f6746b4c6d729dc10961dbef54168846f4ce9cf7fe453ab5a2b879c9b8 src/proxy/websocket.rs
016a60b2471edd3df9bbe9f92a9cebea11047178b83ff0e3b2351c12814a4939 src/proxy/websocket/handshake.rs
7e9c4d4c207badabf90630223964bb2ad641f06d4149843cab751f192b8b4adf src/proxy/websocket/tests.rs
@@ -418,8 +421,8 @@ d9123c623e2a97d09e41edaa29993b4b2c8cba39a3abdf2b48a205aa9a9c2939 src/system_sta
7467d83066bb6986d8c9103597f7a7617ac677579ed46d573c573ff6ca57bbd9 src/tinyauth.rs
0e1d87554db55e724f30c41ec4a5c80bdb8fc9c8cba8700ac509864d4abf8871 src/tinyauth/tests.rs
1084cccd9de28acd6cb0f0813de7c0953910f51726f73f4acdb2e3ada4593976 src/tls.rs
e62b55a057ab39daf540e225342e98a57a6266f1603d685c48468f2f7c37c4b8 src/tls/certificates.rs
ca471212a97bae737e441920818e2e251868dfb2e75db45dce01ff8e420bf736 src/tls/external_paths.rs
5919db3296523dd0b7a8e0cccbe44266500a6a5183a7a08cc195d7f9847886dc src/tls/certificates.rs
436575144aa352498a4696f12c2258bef3a898f3f50e6fbc6d67c3b93afa099f src/tls/external_paths.rs
3e6947dde167f82a5efc407ccf9bd4c9f5b26cc8633d1418a10284bc1a1503d4 src/tls/server_config.rs
893d1077fc0d9a08598596a1d3f4bde7019357fac27ebe13f52c11a4859a1d25 src/tls/tests.rs
e0ab65406911d4b95cb26594df65779a88d9011d5b11adb22c1a28831cda3826 src/tls/tests/acme.rs
@@ -447,16 +450,17 @@ a67e58a597a5eeb7efe605ae6e438a8f84ff6a1cedf8cd228141cb95551872a8 src/upstream.r
225e3078e6775ffaa6dc6f12bc87503a123983cbb347edf38cae4c37ff02a5db src/upstream/client.rs
4aa2420a92d33c50f7027f57ac4eccdc050bdb7e9ec8fa6e0eaa809228b71cd8 src/upstream/client_tests.rs
6f932b8e59de8a5c89d3b29b838eb2a30f6e3ddd342fec29e39af1ce1b1c7d11 src/upstream/tests.rs
e4c1ff8f53112e7cfad101a266355f7e47b82895480db431662f4cc59a1d9702 src/vhost.rs
7529fe4ccf44fc8fc3e24b591c53ff41860e3f168cb227d9bbffb17797c01f38 src/vhost/model.rs
251b5d1cf0c24d644e13f85ae473f630a76ca361dbb4b4ea971194c66dcab9f0 src/vhost.rs
2152423877010c45328e8c78305669c3b9cb86aff5abf769eb074736c0b80f9b src/vhost/model.rs
35a0aca3eee8d47d1bab27c83d4532cd572fc3e17b5f0d4a8d4515a8f85b3d96 src/vhost/redirect.rs
7127e081d884863b21c06f700b54884f90ec04efdfce835591b930f2d210faf6 src/vhost/tests.rs
ff4980f0a091da9ff1722d6b9f6662529cbd7c5e2ac68dcd22a507ce6aaf3011 src/vhost/validation.rs
be63aaf2ca2a3efe10afc863216c38b0e12a114cefba96781e50c997e98f0434 src/vhost/validation.rs
8d1eff4b24f01d137414ef5419f75dd281879228adb70409645b11b3c78c20e9 src/web_server/config.rs
35471c7ba376365d4044c4cc042f675491453d6e59211255802ce4b386172209 src/web_server/config/compile.rs
c1fef6dfb79275479c15097b211cd5f063ad7e0852a0767b0145cf4f32f751da src/web_server/config/model.rs
f1d3e427c53f8b5be5768d85e69ead746d83647fcd3b355acc98e3f9d5e217e0 src/web_server/config/validation.rs
86c744216d7132d8294a6e90ba9be5fd871646006313d1c62506d40c81db9b12 src/web_server/fastcgi.rs
7a32e7950f485cf8897c6842ff24bb67e2a1348f6af1bbc32a95df8948c76583 src/web_server/fastcgi/protocol.rs
62f0b6dfad23dbe99a06f1dc2e998946af64f2754323c306ccb9fbdf2138ee5a src/web_server/fastcgi/protocol.rs
134c4998f6a0aa27e121af1ae3355b7e335488b2ae867ac0a934c58b4eb77191 src/web_server/fastcgi/response.rs
8186363315bd998919c6c783e6f7501528f257a5c0da77c5d60f423e8670db5e src/web_server/filesystem.rs
0aafd7f37c4fe784b047646e4ce5ddcb5fe821231a41ed0a9f519529385288a8 src/web_server/htaccess.rs
@@ -466,14 +470,14 @@ f1d3e427c53f8b5be5768d85e69ead746d83647fcd3b355acc98e3f9d5e217e0 src/web_server
80f946570c14c8915fd21bfdb1a84dfb6a14c633ab6060acb828fab4c7c21daa src/web_server/mod.rs
67ab8bef567452f123c4b9b46a942b39c93f1f765c128edca7ee808ff1c5e304 src/web_server/request.rs
c5cb561b0f276e8082f904b9446fa5885f6b3c0608f25ea2ef425061e9f7f6b4 src/web_server/response.rs
6276ca74bd6d613a511a794bc5f0dfed91f16ef691a4818ee8a96e658be05a33 src/web_server/response/file.rs
5f5d0a1bcf8fe200d9883146786841c6b40b48776a8ffb8ecf397a0c3a58c0b7 src/web_server/response/file.rs
9aa68251a5f549f4ce3b1bda88d4abb9efc629caf444989381b4117c9488e37b src/web_server/response/headers.rs
2268a6aeed2f565f9791af33e1cd21c56c228c8873b031725b39a6c57184f2d1 src/web_server/routing.rs
400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs
3e0b4e92600a7d5abcec92efed532b3a01e0781cb4f2681daac6d6ae13b0a6e9 src/web_server/serve.rs
cf86c4692080723bbdf8f4105e1abfae33d7fcf020d2ad9e13ebae3943bd0cbe src/web_server/tests.rs
c62752fe2fa6b74ddc9d94d36f8d191376b1575b15d21fc434c5a5da4c932692 src/web_server/tests/part_01.rs
47d67d3033295e0172219f6a640a93d3d59a87da6c35563b438075ceff271d37 src/web_server/tests/part_02.rs
6b41487f46b9b249d141980a350e8501ce61e571ff0605bf3561e1a88201f4cf src/web_server/tests/part_02.rs
e54f44f6ca1ed2c41e620de9195d32c4f8bb6936c3881745316a07160f87b4fa src/web_server/tests/part_03.rs
f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json
1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs
@@ -482,20 +486,22 @@ eecf680f001f2110dde4eceaccf2f922859a16c946a644f8854e8de4d3771f7c tests/policies
728999110a6d8cacb62bc0399bc333fa23a66526ee23f563a3c93bf00e5fad19 tests/transport_config.rs
b04362ad082a32c9ccc4cf38c46f44d71347adecf7aba3224b3af3ea4872e3d0 tools/README.md
358c1e94dd2b4a756430e5335747f0b437345b1713670a9972c5bcf78b837ccb tools/audit/admin_audit.py
af22d8904631672566122c842eda77054b1c6cf422861fa4cdf972173ef6537d tools/checks/check_source.py
dbb2f8e117197732f3ee14d7eefcadf18dda3a1679cd6b96f9f0a0471c0be8ae tools/checks/docs_check.py
a699c0366c1b9329083602b2731fa5687839dfe265e5a60ff60a3b5796a493fb tools/checks/check_source.py
5e2382a4f4094cf0a7cbc5870c78a33dfea65682dd81625b4fc12eee000dd6c8 tools/checks/docs_check.py
4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py
cf762780c4085791c72c3c5f93054d7f2a1107403bee0616a1f4113bbcbb99f9 tools/checks/memory_contracts.py
08af3e80470758493c107c925aab6c30e755807eac1734ad664ef72a1e6043e8 tools/checks/preflight.py
40e68075e14de2987b3ddc4614b4dbe66ca2bb7c823fa52a1c710a62be7d867d tools/checks/release_smoke.py
f0cef60dfb1180fdb495cc6a617c033db92b92d279b5fd9cd3b1343b130d1e40 tools/checks/preflight.py
7187ba9799fda295698f56b1f89b2ac66c0abf3fa155f73c71726cbc14b91ad9 tools/checks/release_smoke.py
f69845bc26ec33160fc756953991cf32aa8ad6a7fb4a131caa2093db91e783ef tools/checks/rust_module_contracts.py
ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py
adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py
23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_contracts.py
2309c41e769e797c838d4f40d50bb3c6b9ad9ad0f9ed07fb93e07f95d66563f9 tools/checks/source_module_contracts.py
0c071c26fc316934bdfd2bfd19f5fed9521790d8c887760eaabda81a9be96703 tools/checks/vhost_www_ui_test.mjs
0aaa80453e25aae7ea953fadb91b3ae3eaff627977c81c9ab5a2779f9a93582f tools/checks/web_assets.py
a465784a5b40897f905c639130e4989d5764451900155d3e4820b5b4feeaa18d tools/checks/web_server_contracts.py
ba8e92ec5dde6e210fdc034a4871115acccf03288cf39b32a82ab2d4dcf390ed tools/checks/web_server_contracts.py
c245f8f832bd2c36a3fbd5e66fe98e7367c70a8db840c79e6b640f1d9d74f2ac 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
538b5f6cfa738355a729940ee3d92e7ab3dc25ca69ae330ddaa08f831775b8ba tools/dev/test_compression.py
+4 -2
View File
@@ -18,7 +18,7 @@ if [[ $# -gt 0 ]]; then shift; fi
case "$mode" in
help|-h|--help)
cat <<'HELP'
RustProxy 0.28.2
RustProxy 0.29.0
./dev.sh Build and start proxy + local panel
./dev.sh run [serve flags] Same, with extra server options
./dev.sh admin setup-token Generate/print a one-time setup capability before first login
@@ -98,6 +98,8 @@ if [[ "$mode" == "test" ]]; then
python3 tools/checks/security_dependency_contracts.py
if command -v node >/dev/null 2>&1; then
node tools/checks/web_service_ui_test.mjs
node tools/checks/www_redirect_ui_test.mjs
node tools/checks/vhost_www_ui_test.mjs
else
printf 'Node.js not found; skipping Web Service UI behavior test.\n' >&2
fi
@@ -139,7 +141,7 @@ if [[ "$mode" == "test" ]]; then
printf 'Checking all Rust targets before tests...\n' >&2
cargo check --locked --all-targets
fi
printf 'Building RustProxy 0.28.2...\n' >&2
printf 'Building RustProxy 0.29.0...\n' >&2
cargo build --locked
BIN="$CARGO_TARGET_DIR/debug/rust-proxy"
case "$mode" in
+1 -1
View File
@@ -1,6 +1,6 @@
package main
const version = "0.28.2"
const version = "0.29.0"
type config struct {
Listen string
+1 -1
View File
@@ -1,4 +1,4 @@
# Administration API access - 0.28.2
# Administration API access - 0.29.0
## Sign-in boundary
+8
View File
@@ -128,6 +128,14 @@ safety behavior. RustProxy's static-cookie optimization can ignore Cookie only f
configured static file extensions; response `Set-Cookie`, `private`, `no-store` and
`Vary: Cookie` still prevent unsafe shared reuse.
### Per-vhost forced-cache targets
A vhost can opt into an explicitly unsafe `cache.unsafe_force` list for narrowly selected public request targets. It is intentionally not available as a global cache setting. Each configured target starts with `/`; a target without `?` matches by normalized path prefix, while a target containing `?` matches the original path+query prefix.
For matching GET/HEAD requests, forced mode may ignore request-side bypasses caused by a query string, Cookie, Authorization, `Cache-Control: no-cache`/`no-store`/`max-age=0`, and `Pragma: no-cache`. Protocol-safety exclusions such as Upgrade, unsupported Range/preconditions and request bodies remain in force. Cookie and Authorization values are added to the cached representation's `Vary` identity in forced mode so different credentials/cookies cannot reuse each other's object.
Forced mode does **not** override response-side privacy policy. `Set-Cookie`, `Cache-Control: private`, `no-store`, `Vary: *`, partial/trailer/SSE/gRPC responses and the normal size/lifetime validation still prevent storage. Use this only for URLs whose origin semantics are understood; ordinary cache defaults remain safer.
## Request methods and invalidation
GET and HEAD participate in lookup. They share one representation identity. On a
+2 -2
View File
@@ -9,7 +9,7 @@ each Docker host, so the proxy container never needs Docker-socket access. See
Build locally with a normal OCI builder:
```bash
docker build -t rustproxy:0.28.2 .
docker build -t rustproxy:0.29.0 .
```
Persist `/var/lib/rustproxy`; it contains the SQLite database, certificates and the
@@ -22,7 +22,7 @@ docker volume create rustproxy-data
docker run --rm -d --name rustproxy \
-p 8080:8080 \
-v rustproxy-data:/var/lib/rustproxy \
rustproxy:0.28.2
rustproxy:0.29.0
docker exec rustproxy rust-proxy admin setup-token
```
+15 -2
View File
@@ -38,7 +38,7 @@ Encoded separators (`%2f`, `%5c`), encoded `%`, dot segments and other ambiguous
## Actions
A location can proxy to a backend, serve an existing Web Service, or return a local response.
A location can proxy to a backend, serve an existing Web Service, return a local response, or redirect the client to another absolute HTTP(S) URL.
### Proxy
@@ -61,7 +61,20 @@ The query string is preserved. Rewrites change only the selected source path; re
`Return custom response` is the equivalent of a simple Nginx `return`: choose an HTTP status from 200 through 599, a Content-Type and a response body. RustProxy does not open a backend connection for this action. `HEAD`, `204` and `304` responses do not send a body.
For a redirect, use a 3xx status and add a `Location` response header in the visual header configurator.
### Redirect
`Redirect` is a backend-less action. Choose `301`, `302`, `303`, `307` or `308`, set an absolute `http://` or `https://` target, and optionally preserve the incoming query string. RustProxy validates the target and does not allow capture expansion inside the target scheme/authority.
For capture-based path rewriting, configure a separate **Redirect rewrite regex**. Captures can be referenced as `$1` or `${name}` in the redirect target path/query. This separation mirrors an Nginx `location` selector followed by a separate `rewrite` expression. For example, the Nginx pattern:
```nginx
location ~* \.(?:tar\.gz|zip?)$ {
rewrite ^/(.*)$ https://objectstorage.example.invalid/b/repo/o/$1 permanent;
}
```
corresponds to a case-insensitive regex Custom location `\.(?:tar\.gz|zip?)$`, action **Redirect**, status `301`, target `https://objectstorage.example.invalid/b/repo/o/$1`, and redirect rewrite regex `^/(.*)$`. Query preservation is controlled independently.
## Request and response headers
+7 -3
View File
@@ -1,6 +1,6 @@
# Stored log explorer
RustProxy 0.28.2 provides an authenticated, read-only **Logs** workspace at `/logs`. The top bar has one **Logs** menu with separate **Stored logs** and **Live Logs** actions; the two data paths remain independent.
RustProxy 0.29.0 provides an authenticated, read-only **Logs** workspace at `/logs`. The top bar has one **Logs** menu with separate **Stored logs** and **Live Logs** actions; the two data paths remain independent.
## Separation from Live Logs
@@ -63,9 +63,13 @@ The dashboard no longer exposes the configured-file inventory. The Source select
The browser still never supplies a filesystem path. The server derives sources from the active configuration and reads only regular files. On Unix it opens with `O_NOFOLLOW`; per-vhost enumeration rejects a symlinked directory and accepts only direct regular `.jsonl` children.
Logrotate-style gzip rotations are discovered next to configured combined access/error files and inside the configured per-vhost directory. Only regular non-symlink files matching the active filename rotation pattern (for example `access.jsonl.1.gz`) are eligible. The browser still refers to them by server-generated source IDs rather than paths.
Gzip reading is bounded before and after decompression. A compressed source larger than 8 MiB is not decompressed for a query, decompressed output is capped at 64 MiB, and only the bounded tail is parsed/searched. These limits prevent a gzip rotation from becoming an unbounded memory/decompression workload.
The reader remains bounded:
- up to 2 MiB scanned from one source;
- up to 2 MiB of retained tail data from one source;
- up to 8 MiB across one query;
- up to 20,000 lines across a query;
- at most 32 sources in an all-source query;
@@ -75,4 +79,4 @@ The reader remains bounded:
If more than 32 sources are eligible, the newest 32 are scanned and the response is marked truncated. File reads run in a blocking worker rather than the async proxy runtime.
The explorer is diagnostic rather than a retention/indexing system. RustProxy does not add rollover, indexing or compression; use operating-system log rotation for retention.
The explorer is diagnostic rather than a retention/indexing system. RustProxy does not create rollovers or compress files itself; use operating-system log rotation for retention. Existing gzip rotations can be read as described above.
+1 -1
View File
@@ -1,4 +1,4 @@
# Installing and updating RustProxy 0.28.2
# Installing and updating RustProxy 0.29.0
The scripts prepare a Linux/systemd installation. This source archive is **not a
verified production build**: Rust/Cargo, real CA operations, live systemd startup
+2 -1
View File
@@ -15,6 +15,7 @@ This is the canonical index for the current project documentation. The authentic
- [0.25.1 release audit](V0.25.1_AUDIT.md) - historical Traffic/module-split release scope and compiler-regression guards.
- [0.25.0 specification audit](V0.25.0_AUDIT.md) - Web Server foundation point-by-point implementation status.
- [0.25.0 pre-production security audit](V0.25.0_SECURITY_PREPROD_AUDIT.md) - threat-oriented source/configuration review, fixes made in-session and mandatory runtime security gates.
- [0.29.0 implementation note](V0.29.0_IMPLEMENTATION.md) - canonical host/custom-location redirects, health Host defaults, forced cache and gzip Stored Logs.
- [0.28.2 implementation note](V0.28.2_IMPLEMENTATION.md) - external certificate ACL/path hardening and least-privilege Certificate API grants.
- [0.28.1 implementation note](V0.28.1_IMPLEMENTATION.md) - external certificate paths, Certbot symlink reloads, backup semantics and certificate-source UX.
- [0.28.0 implementation note](V0.28.0_IMPLEMENTATION.md) - manual/hot-reloaded GeoIP, listener status UX, cache Via/narrow invalidation and header-rule layout.
@@ -74,7 +75,7 @@ This is the canonical index for the current project documentation. The authentic
## Historical or version-labelled context
Version-labelled `V*.md` files are retained as historical implementation/audit records. Their version number describes the audited milestone. The current release implementation note is [V0.28.2_IMPLEMENTATION.md](V0.28.2_IMPLEMENTATION.md); [V0.28.1_IMPLEMENTATION.md](V0.28.1_IMPLEMENTATION.md) is retained as the prior external-path milestone; [V0.28.0_IMPLEMENTATION.md](V0.28.0_IMPLEMENTATION.md) is retained as the prior GeoIP/listener/cache milestone, and [V0.27.3_IMPLEMENTATION.md](V0.27.3_IMPLEMENTATION.md) is retained as the prior HTTP/3/cache-maintenance milestone, [V0.27.2_IMPLEMENTATION.md](V0.27.2_IMPLEMENTATION.md) is retained as the prior managed-ACME repair, [V0.27.1_IMPLEMENTATION.md](V0.27.1_IMPLEMENTATION.md) as the prior managed-runtime/list-sorting milestone, and older version notes remain historical evidence. The latest retained audit is [V0.25.5_AUDIT.md](V0.25.5_AUDIT.md); older audit files remain available for traceability.
Version-labelled `V*.md` files are retained as historical implementation/audit records. Their version number describes the audited milestone. The current release implementation note is [V0.29.0_IMPLEMENTATION.md](V0.29.0_IMPLEMENTATION.md); [V0.28.2_IMPLEMENTATION.md](V0.28.2_IMPLEMENTATION.md) is retained as the prior certificate-hardening milestone; [V0.28.1_IMPLEMENTATION.md](V0.28.1_IMPLEMENTATION.md) is retained as the prior external-path milestone; [V0.28.0_IMPLEMENTATION.md](V0.28.0_IMPLEMENTATION.md) is retained as the prior GeoIP/listener/cache milestone, and [V0.27.3_IMPLEMENTATION.md](V0.27.3_IMPLEMENTATION.md) is retained as the prior HTTP/3/cache-maintenance milestone, [V0.27.2_IMPLEMENTATION.md](V0.27.2_IMPLEMENTATION.md) is retained as the prior managed-ACME repair, [V0.27.1_IMPLEMENTATION.md](V0.27.1_IMPLEMENTATION.md) as the prior managed-runtime/list-sorting milestone, and older version notes remain historical evidence. The latest retained audit is [V0.25.5_AUDIT.md](V0.25.5_AUDIT.md); older audit files remain available for traceability.
Older context documents such as [INTEGRATIONS_040](INTEGRATIONS_040.md), [MANAGEMENT](MANAGEMENT.md), [SECURITY_AUDIT](SECURITY_AUDIT.md), [ACME_RFC2136](ACME_RFC2136.md) and older version audits remain available for traceability but should not override the current functional/technical documents above.
+77 -19
View File
@@ -1,29 +1,87 @@
# Verification report - RustProxy 0.28.2
# Verification report - RustProxy 0.29.0
## Scope
RustProxy 0.28.2 hardens the 0.28.1 external-certificate workflow. Absolute certificate/key paths are validated across the configured and resolved parent chains, the final file is opened with `O_NOFOLLOW`, POSIX ACL read access is supported without requiring an exact chmod value, private keys may not be world-readable, and external certificate/key files may not be group/world-writable. The packaged systemd service explicitly keeps `/etc/letsencrypt` and `/etc/ssl` read-only while normal Unix permissions/ACLs remain authoritative for access.
This report records verification of the 0.29.0 release source in the packaging environment. The release adds canonical www/non-www redirects, first-class Custom location redirects, automatic HTTP health-check Host selection, per-vhost forced-cache targets, bounded gzip log reading, and regression coverage for 503 maintenance pages and nested URL query strings. SQLite remains schema 9 and Cold Cache remains format 3.
Certificate automation is also narrowed. SQLite schema 9 adds per-Key-ID grants: new Certificate API tokens require explicit certificate IDs, are reload-only by default, cannot call the global all-certificate reload endpoint, and require an explicit deploy grant before `POST /api/certificates/deploy` is accepted. Credentials from earlier releases migrate to reload-only wildcard compatibility grants and should be rotated to explicit IDs.
## Source and release checks
The release retains the 0.28.0 GeoIP direct-source/MMDB hot reload, listener protocol badges, cache Via/narrow invalidation, Security-editor layout and ACME runtime reporting. COLD cache format 3 and saved proxy configuration format remain unchanged.
The repository release gates are executed with:
## Verification
```bash
./dev.sh test
./dev.sh check
```
| Check | Result |
|---|---|
| Source/static contracts | PASS - `tools/checks/check_source.py` |
| Documentation/index/link checks | PASS - `tools/checks/docs_check.py` |
| Browser-side JavaScript syntax/UI contracts | PASS - `./dev.sh test` pre-Cargo stages |
| SQLite schema-9 migration and release smoke | PASS - `tools/checks/release_smoke.py`, including legacy-token grant migration |
| Docker Agent Go tests | PASS - `go test ./...` |
| Shell/Python/JSON syntax checks | PASS - release/static checks plus explicit shell/JSON validation |
| `cargo fmt/check/test` | NOT VERIFIED - Rust/Cargo are not installed in this verification environment; `./dev.sh test` exits 127 only after all pre-Cargo gates pass |
| Live Certbot ACL + targeted API reload | NOT VERIFIED - requires a compiled binary and host certificate material |
| Live HTTP/2 + HTTP/3 protocol tests | NOT VERIFIED - requires a compiled binary and QUIC-capable runtime |
`./dev.sh test` runs the Rust-independent source/module, release-smoke, memory, Web Server, security, dependency, browser-UI, documentation and translation checks before attempting Cargo. The 0.29.0 source checks cover the new configuration/UI/runtime contracts as well as retained release behavior.
## Security regression coverage
Additional syntax checks used for the edited browser assets are:
The source tests include Certbot-style `live/ -> archive/` symlink refresh behavior, acceptance of ACL-style group-mask bits, rejection of group/world-writable or world-readable private-key modes, and rejection of writable non-sticky parent directories. Administration tests cover targeted certificate-ID grants, denial of global reload for Certificate API tokens, rejection of non-allowlisted certificate IDs and independent token revocation.
```bash
node --check src/admin/web/vhosts/core.js
node --check src/admin/web/vhosts/editor.js
node --check src/admin/web/vhosts/render.js
```
See [V0.28.2_IMPLEMENTATION.md](V0.28.2_IMPLEMENTATION.md) for the operator ACL example and migration behavior.
## Environment limitation
Rust/Cargo is not installed or not available on PATH in this packaging environment. Therefore Rust parsing, type checking, linking and execution of `cargo test --locked --all-targets` cannot be claimed here. `./dev.sh test` was executed and all Rust-independent gates printed PASS before the script stopped at the explicit Rust/Cargo guard with exit 127. `./dev.sh check` was also executed and stopped at the same guard with exit 127.
Observed auxiliary checks:
```text
node --check src/admin/web/vhosts/core.js PASS
node --check src/admin/web/vhosts/editor.js PASS
node --check src/admin/web/vhosts/render.js PASS
(cd docker-agent && go test ./...) PASS
```
An attempt to fetch a user-local Rust toolchain was unavailable from this environment, so no compiler result is inferred from the source/static checks.
A Rust-enabled release host must still run:
```bash
cargo fmt --all -- --check
cargo check --locked --all-targets
cargo test --locked --all-targets
```
before promoting the binary package.
## Functional regressions included in source
The Rust test source includes coverage for:
- www/non-www companion routing and canonical redirect behavior;
- default and explicitly overridden HTTP health-check Host selection;
- Custom location redirects with a separate capture regex and query preservation;
- per-vhost forced-cache target matching and retained protocol-safety exclusions;
- bounded discovery/read of rotated `.gz` Stored Logs;
- a static/maintenance HTML response with HTTP 503;
- preservation of a valid nested-URL query string in the upstream URI.
Literal/encoded backslashes remain intentionally rejected by the ambiguous-routing guard. For the reported curl case, use normal query separators inside a quoted URL rather than `\?`, `\=` or `\&`.
## Post-release source revision: multi-host vhost and canonical selector
The vhost editor now selects a canonical hostname from the configured host list, with a legacy first-host fallback. An additional Rust regression test verifies independent `suba.local` and `subb.local` aliases and a selected redirect target; invalid foreign redirect targets are rejected. Compiler/runtime tests remain unverified in this environment.
## Frontends WWW-routing UX revision
The editor groups host chips and WWW redirect controls in the same Frontends tab. The legacy select is replaced by accessible paired radio choices showing both `www -> bare` and `bare -> www` for each exact host (also while the feature is off), plus a list of non-redirected aliases and the number of configured names. A `www.` destination can be implicit: routing claims both hosts even if only the bare name was listed in the vhost. Wildcards are not offered as redirect targets. The selected target survives unrelated edits and automatically falls back to a valid hostname when removed; wildcard-only configurations disable the redirect control and show guidance. The persisted fields (`www_redirect` and `www_redirect_target`) are unchanged.
`node tools/checks/vhost_www_ui_test.mjs` runs the actual WWW-routing editor component in a DOM stand-in and covers its switch, selected-target restore, alias visibility, both redirect directions, implicit `www.` target, wildcard handling, removal fallback, and serialization. Rust source includes a complementary runtime regression for the implicit www routing pair. These checks do not substitute for executing Rust proxy runtime tests.
## WWW UX correction (source revision)
The Frontends tab now shows the WWW target choices next to its hostname editor, grouped by domain and available for preview even when the feature is disabled. Both www to bare and bare to www destinations are available. A WWW destination may be implicit (`www.subb.local` with only `subb.local` configured); the runtime claims both hostnames for the selected pair, while a second domain (`suba.local`) remains an independent alias of the same vhost and backend. A Rust regression test covers the implicit WWW direction; static checks and JS syntax tests are described above. Full Rust compiler/runtime verification still requires Cargo.
### WWW UX packaging verification
In this source revision, `node tools/checks/www_redirect_ui_test.mjs` and
`node tools/checks/vhost_www_ui_test.mjs` both pass their DOM-stand-in selection
and synchronization checks. `./dev.sh test` passed all pre-Cargo checks and
stopped at the missing Cargo guard with exit 127. Syntax checks for the edited
JavaScript and the two language JSON packs also passed. This does not establish
Rust compile/test success or end-to-end browser behavior.
+41
View File
@@ -0,0 +1,41 @@
# RustProxy 0.29.0 implementation note
RustProxy 0.29.0 adds narrowly scoped redirect, health-check, cache and stored-log controls without changing SQLite schema 9 or Cold Cache format 3. Existing 0.28.2 configuration remains compatible because all new configuration fields are defaulted.
## Canonical www/non-www redirect
A vhost can enable `www_redirect`. The canonical exact DNS name is chosen in the UI from `frontend.hosts` and persisted as optional `www_redirect_target`; it defaults to the first hostname for older configurations. RustProxy derives its direct companion (`example.com` <-> `www.example.com`), claims that host on the same listeners and returns `308 Permanent Redirect` to the canonical name while preserving the request path/query. Wildcard/IP first hosts are rejected for this option. If `force_ssl` is also enabled, cleartext companion requests redirect directly to the HTTPS canonical URL.
Certificate assignment includes both routing names, but certificate validity is not synthesized: operators must use a certificate whose SANs cover the canonical and companion names.
## Custom location redirects
Custom locations have a backend-less `redirect` action with status `301`, `302`, `303`, `307` or `308`, an absolute HTTP(S) target, optional redirect rewrite regex and optional query preservation. The rewrite regex is separate from the location match regex, so a configuration equivalent to an Nginx `location` followed by `rewrite ^/(.*)$ .../$1 permanent` can capture the complete request path independently of the location selector.
Capture expansion is allowed in the target path/query but not in the scheme/authority. This prevents client-controlled captures from turning an approved redirect rule into arbitrary-host redirection.
## Health-check Host selection
HTTP backend probes already send an HTTP/1.1 Host header. In 0.29.0 an empty `health_check.host` automatically resolves to the first exact DNS hostname of the vhost. This makes IP-address backends with name-based virtual hosting work without repeating the public hostname in the health settings. An explicitly configured health-check Host remains authoritative; if the vhost has no exact DNS hostname, the existing backend-host fallback remains.
## Per-vhost forced cache
`cache.unsafe_force` is available only in a vhost override and is disabled by default. Matching GET/HEAD request targets may bypass request-side query, Cookie, Authorization and client cache-control exclusions. Protocol-safety exclusions remain active. Cookie and Authorization are added to variant identity for forced objects.
Response policy remains authoritative: responses with `Set-Cookie`, `private`, `no-store`, `Vary: *`, partial framing and the other normal non-cacheable conditions are not stored. This option is intentionally labelled dangerous and must be enabled per vhost with explicit target prefixes; it is not a global switch.
## Rotated gzip logs
Stored Logs discovers logrotate-style `.gz` siblings of configured combined access/error logs and `.jsonl.*.gz` rotations in the configured per-vhost directory. Only regular non-symlink files derived from configured locations are eligible. Compressed input and decompressed output are bounded, and only the existing bounded tail is parsed/searched.
## Local page status and URL-query regression coverage
The existing static/maintenance page model can serve its HTML body with an explicit status such as `503 Service Unavailable`; 0.29.0 adds regression coverage for that operator workflow.
Valid nested URL query parameters are preserved by upstream URI construction. Literal or percent-encoded backslashes remain rejected by the ambiguous-path guard. A quoted curl URL must therefore use normal `?`, `=` and `&` separators without backslashes.
## Verification scope
The release source includes Rust unit/integration regressions for canonical redirects, custom redirect capture/query behavior, automatic health-check Host selection and explicit override precedence, forced-cache request policy and target matching, gzip Stored Logs, maintenance-page 503 and nested URL query preservation. Rust-independent release/source/documentation checks are also retained. See [TEST_REPORT.md](TEST_REPORT.md) for the verification actually performed in the packaging environment.
Multiple hostnames on the same vhost (for example `suba.local` and `subb.local`) receive identical proxy behavior without implicit redirects.
+7 -3
View File
@@ -59,6 +59,8 @@ listener are required so there is both an ingress to redirect and an HTTPS targe
several TLS ports are selected, port 443 is preferred; otherwise the selected TLS target
port must be unique.
`www_redirect` defaults to false. When enabled, `www_redirect_target` selects an exact DNS hostname from `frontend.hosts` as canonical (default: the first hostname for backwards compatibility). RustProxy also claims its direct companion (`example.com` <-> `www.example.com`) on the same listeners and returns `308 Permanent Redirect` from that companion to the first hostname while preserving path/query. Wildcards and IP literals cannot be the canonical hostname for this feature. The companion also participates in vhost certificate assignment, but the selected certificate must still contain both DNS names in its SANs. When `force_ssl` is also enabled, an HTTP companion request redirects directly to the HTTPS canonical URL instead of creating two redirect hops.
`forward_client_ip` defaults to true. Normal vhosts therefore receive the sanitized
`Forwarded`, `X-Forwarded-For` and `X-Real-IP` client identity generated by RustProxy.
Set it to false for applications whose own trusted-proxy/bypass logic must see the
@@ -260,7 +262,7 @@ affinity to the backend that actually answered.
|---|---|---|
| `mode` | `tcp` | `off`, `tcp`, `http` |
| `path` | `/` | Leading slash, up to 1024 bytes; no authority/fragment |
| `host` | null | Optional validated HTTP Host override |
| `host` | null | Optional validated HTTP Host override; otherwise the first exact vhost DNS hostname is used |
| `interval_ms` | 5000 | 250..300000 |
| `timeout_ms` | 2000 | 50..60000; no greater than interval |
| `slow_response_ms` | 1500 | 0 disables; otherwise no greater than timeout |
@@ -270,7 +272,7 @@ affinity to the backend that actually answered.
TCP checks verify that an approved origin address accepts a connection. HTTP checks
perform GET on the configured path, without redirects, cookies or authorization.
They use HTTP/1.1, including verified TLS when appropriate, and measure time to
headers, not the full body. For an h2c-only service, use TCP checks. Slow probes
headers, not the full body. When `health_check.host` is empty, RustProxy automatically uses the first exact DNS hostname configured on the vhost so an IP-address backend can still route the probe through its name-based virtual host. If no exact DNS hostname exists, the existing backend-host fallback remains. Set `health_check.host` only when the probe must use a different Host value. For an h2c-only service, use TCP checks. Slow probes
count as active failures. Active TCP/HTTP health is independent from `passive_health`.
`passive_health.enabled` defaults to `false`. With that default, normal user traffic
@@ -407,7 +409,7 @@ storage and before downstream compression. Origin `Cache-Control: no-transform`
wins and cannot be overridden from the vhost.
Pages offers a static/maintenance response, a fallback for unassigned paths, and
error overrides. Vhosts still retain their configured backend pool; static mode
error overrides. The static/maintenance response can set an explicit HTTP status independently of its HTML body, so an operator can serve a maintenance document as `503 Service Unavailable` instead of returning `200`. Vhosts still retain their configured backend pool; static mode
avoids forwarding but is not a backend-less static-file server. Host/path ACLs run
first. Explicit error-status overrides change presentation, not authorization.
Empty/inherit means use the global error policy; to disable one inherited error
@@ -420,3 +422,5 @@ to override inheritance. See MANAGEMENT.md for exact precedence.
New backends use the backend URL host for the upstream `Host` header by default, matching nginx `proxy_pass` behavior. Enable **Preserve client Host** only when the application intentionally routes on the public hostname. This is especially important for embedded/lighttpd-style administration panels that may close or reject requests carrying an unknown Host. Existing explicit settings are preserved during upgrades.
When an upstream request fails before an HTTP response is received, access logs include a bounded `upstream_error` field in addition to `upstream_status`.
Multiple explicit hostnames can belong to a single vhost: `suba.local` and `subb.local` share the same routing, backends and configuration. Only the www/non-www companion of the selected canonical hostname redirects; other aliases continue serving traffic directly.
+1 -1
View File
@@ -19,7 +19,7 @@ classes, cache outcomes, capture-state gauges and configured-route traffic. Raw
are intentionally absent from Prometheus labels.
Import the JSON in Grafana and select a Prometheus datasource when prompted. The
dashboard expects RustProxy 0.28.2 metric names and a scrape interval around 15s.
dashboard expects RustProxy 0.29.0 metric names and a scrape interval around 15s.
Collectors can be disabled in RustProxy settings; panels for disabled collectors
will simply show no series.
+2 -2
View File
@@ -4529,9 +4529,9 @@
},
"timepicker": {},
"timezone": "browser",
"title": "RustProxy 0.28.2 - Operations",
"title": "RustProxy 0.29.0 - Operations",
"uid": "rustproxy-0170",
"version": 5,
"weekStart": "",
"description": "RustProxy 0.28.2 operations dashboard including backend client lifecycle/retry diagnostics, bounded Traffic analytics, country and User-Agent-family visibility, and built-in Web Server traffic."
"description": "RustProxy 0.29.0 operations dashboard including backend client lifecycle/retry diagnostics, bounded Traffic analytics, country and User-Agent-family visibility, and built-in Web Server traffic."
}
+29 -3
View File
@@ -383,7 +383,7 @@
"vhRise": "Successes before recovery",
"vhStatusMin": "Minimum HTTP status",
"vhStatusMax": "Maximum HTTP status",
"vhHealthNote": "HTTP probes use HTTP/1.1 and measure time to headers, not body duration. Use TCP for h2c-only services. New backends wait for successful probes. Active health is independent from passive health.",
"vhHealthNote": "HTTP probes use HTTP/1.1 and measure time to headers, not body duration. When Host override is blank, RustProxy automatically uses the first exact DNS hostname of the vhost; if none exists it falls back to the backend host. Use TCP for h2c-only services. New backends wait for successful probes. Active health is independent from passive health.",
"vhPassiveHealth": "Passive Backend Health",
"vhPassiveEnabled": "Allow normal request failures to mark a backend unhealthy",
"vhPassiveHelp": "Off by default. Errors are still logged and counted, but normal user traffic cannot change backend health until this is explicitly enabled.",
@@ -1044,7 +1044,7 @@
"vhAclProfileNone": "No reusable profile",
"vhAclProfileHelp": "Optional reusable profile. Inline vhost ACL and country rules below are still enforced in addition to the profile.",
"vhHostAdd": "Add hostname",
"vhHostsHelp": "Press Enter or comma to add a hostname. Wildcards are supported only as a leading *. (for example *.example.com). Exact hosts take priority over wildcard matches.",
"vhHostsHelp": "Add multiple hostnames (e.g. domain.local i subdomain.local) with Enter or comma. They share this vhost and its backend/settings. Wildcards are supported only as leading *. Exact hosts take priority.",
"vhHostInvalid": "Invalid hostname or wildcard pattern.",
"vhHostRequired": "Add at least one hostname.",
"vhLifetimeChart": "Lifetime request rate",
@@ -2201,6 +2201,32 @@
"certificateSourceManagedHelp": "Enter filenames only, for example app.pem and app.key. RustProxy loads them from <data-dir>/certs (normally /var/lib/rustproxy/certs for a packaged service).",
"certificateSourceExternalHelp": "Enter absolute server paths. Certbot example: /etc/letsencrypt/live/example.com/fullchain.pem and /etc/letsencrypt/live/example.com/privkey.pem. live/ symlinks are resolved on every reload. POSIX ACL read access is supported; RustProxy does not require an exact chmod. The private key must not be world-readable or group/world-writable, and the path must stay under root- or rustproxy-owned non-writable directories.",
"certificateExternalAclExample": "ACL example (grant traversal, not directory read):\nsetfacl -m u:rustproxy:--x /etc/ssl/\nsetfacl -m u:rustproxy:--x /etc/letsencrypt/ /etc/letsencrypt/live/ /etc/letsencrypt/archive/\nsetfacl -m u:rustproxy:--x /etc/letsencrypt/live/example.com/ /etc/letsencrypt/archive/example.com/\nsetfacl -m u:rustproxy:r-- /etc/letsencrypt/archive/example.com/fullchain*.pem /etc/letsencrypt/archive/example.com/privkey*.pem\nAfter renewal, reapply read ACL to the current resolved files before API reload:\nsetfacl -m u:rustproxy:r-- \"$(readlink -f /etc/letsencrypt/live/example.com/fullchain.pem)\" \"$(readlink -f /etc/letsencrypt/live/example.com/privkey.pem)\"",
"certificateExternalFiles": "External file paths"
"certificateExternalFiles": "External file paths",
"vhWwwRedirect": "Redirect www ↔ non-www",
"vhWwwRedirectTarget": "Choose the domain and redirect direction (308 destination)",
"vhWwwRedirectHelp": "Only the selected www/non-www pair redirects (308). Other names use the same backend without cross-domain redirects. TLS must cover both variants.",
"vhCacheForceEnabled": "Unsafe forced cache for selected URLs",
"vhCacheForceTargets": "Forced cache path/query prefixes (one per line)",
"vhCacheForceHelp": "Per-vhost only. Matching GET/HEAD targets may ignore query bypass, request Cookie/Authorization and client no-cache/no-store. Cookie/Authorization remain separate cache variants. Origin Set-Cookie, private and no-store responses are still never stored.",
"vhCacheForceConfirm": "Forced cache can serve stale or user-specific content if configured incorrectly. Enable it only for explicitly reviewed URL prefixes?",
"locationRedirectAction": "Redirect to URL",
"locationRedirectShort": "redirect",
"locationRedirectStatus": "Redirect status",
"locationRedirectTarget": "Target URL template",
"locationRedirectPattern": "Optional rewrite regex for captures",
"locationRedirectQuery": "Preserve request query string",
"locationRedirectHelp": "Target must be an absolute http/https URL. Optional rewrite regex supports $1/${name} replacements in the path/query portion, for example ^/(.*)$ → https://storage.example/o/$1.",
"locationRedirect301": "301 Permanent",
"locationRedirect302": "302 Found",
"locationRedirect303": "303 See Other",
"locationRedirect307": "307 Temporary",
"locationRedirect308": "308 Permanent",
"cacheReason_cache_miss_forced": "Forced-cache target miss",
"vhWwwPanelTitle": "WWW address routing",
"vhWwwInactive": "Redirect is off. Choose a destination below and then enable the switch above. All hostnames currently serve the same vhost.",
"vhWwwOtherDomains": "Other domains (no redirect):",
"vhWwwNoHosts": "Add an exact hostname above to enable a WWW redirect. Wildcards cannot be the destination.",
"vhWwwSharedCount": "{count} names / one vhost",
"vhWwwEnable": "Enable 308 redirect"
}
}
+29 -3
View File
@@ -383,7 +383,7 @@
"vhRise": "Sukcesy przed przywróceniem",
"vhStatusMin": "Minimalny status HTTP",
"vhStatusMax": "Maksymalny status HTTP",
"vhHealthNote": "Testy HTTP używają HTTP/1.1 i mierzą czas do nagłówków, nie całej treści. Dla usług tylko h2c wybierz TCP. Nowe backendy czekają na udane testy. Aktywny health-check jest niezależny od pasywnego.",
"vhHealthNote": "Sondy HTTP używają HTTP/1.1 i mierzą czas do nagłówków, nie czas całego body. Gdy pole Host jest puste, RustProxy automatycznie używa pierwszej dokładnej domeny DNS vhosta; jeśli jej nie ma, używa hosta backendu. Dla usług tylko h2c użyj TCP. Nowe backendy czekają na udaną sondę. Active health jest niezależny od passive health.",
"vhPassiveHealth": "Pasywne zdrowie backendu",
"vhPassiveEnabled": "Pozwól, aby błędy zwykłych requestów oznaczały backend jako niedostępny",
"vhPassiveHelp": "Domyślnie wyłączone. Błędy nadal są logowane i zliczane, ale zwykły ruch użytkowników nie zmienia stanu backendu bez świadomego włączenia.",
@@ -1044,7 +1044,7 @@
"vhAclProfileNone": "Bez wspólnego profilu",
"vhAclProfileHelp": "Opcjonalny profil wielokrotnego użytku. Indywidualne ACL i reguły krajów vhosta poniżej nadal są dodatkowo egzekwowane.",
"vhHostAdd": "Dodaj hostname",
"vhHostsHelp": "Naciśnij Enter lub przecinek, aby dodać hostname. Wildcard jest dozwolony tylko jako początkowe *. (np. *.example.com). Dokładny host ma pierwszeństwo przed wildcardem.",
"vhHostsHelp": "Dodaj wiele domen (np. domena1.local i domena2.local), zatwierdzając Enterem lub przecinkiem. Wszystkie korzystają z tego samego vhosta i backendu. Wildcard tylko w postaci *.domena.pl.",
"vhHostInvalid": "Nieprawidłowy hostname lub wzorzec wildcard.",
"vhHostRequired": "Dodaj co najmniej jeden hostname.",
"vhLifetimeChart": "Przebieg ruchu od otwarcia panelu",
@@ -2201,6 +2201,32 @@
"certificateSourceManagedHelp": "Podaj tylko nazwy plików, np. app.pem i app.key. RustProxy ładuje je z <data-dir>/certs (przy instalacji pakietowej zwykle /var/lib/rustproxy/certs).",
"certificateSourceExternalHelp": "Podaj pełne ścieżki na serwerze. Przykład Certbota: /etc/letsencrypt/live/example.com/fullchain.pem oraz /etc/letsencrypt/live/example.com/privkey.pem. Symlinki live/ są rozwiązywane przy każdym reloadzie. Odczyt przez POSIX ACL jest obsługiwany i RustProxy nie wymaga konkretnego chmod. Klucz prywatny nie może być czytelny dla innych ani zapisywalny przez grupę/innych, a katalogi ścieżki muszą być własnością root lub rustproxy i nie mogą być zapisywalne przez grupę/innych.",
"certificateExternalAclExample": "Przykład ACL (prawo przejścia bez listowania katalogu):\nsetfacl -m u:rustproxy:--x /etc/ssl/\nsetfacl -m u:rustproxy:--x /etc/letsencrypt/ /etc/letsencrypt/live/ /etc/letsencrypt/archive/\nsetfacl -m u:rustproxy:--x /etc/letsencrypt/live/example.com/ /etc/letsencrypt/archive/example.com/\nsetfacl -m u:rustproxy:r-- /etc/letsencrypt/archive/example.com/fullchain*.pem /etc/letsencrypt/archive/example.com/privkey*.pem\nPo odnowieniu nadaj odczyt bieżącym plikom docelowym przed reloadem API:\nsetfacl -m u:rustproxy:r-- \"$(readlink -f /etc/letsencrypt/live/example.com/fullchain.pem)\" \"$(readlink -f /etc/letsencrypt/live/example.com/privkey.pem)\"",
"certificateExternalFiles": "Zewnętrzne ścieżki plików"
"certificateExternalFiles": "Zewnętrzne ścieżki plików",
"vhWwwRedirect": "Przekieruj www ↔ bez www",
"vhWwwRedirectTarget": "Wybierz domenę i kierunek przekierowania (308)",
"vhWwwRedirectHelp": "Tylko wybrana para www/bez www jest przekierowywana (308). Pozostałe domeny działają na tym samym backendzie bez przekierowań między nimi. Certyfikat TLS musi obejmować obie wersje.",
"vhCacheForceEnabled": "Ryzykowne wymuszenie cache dla wskazanych URL",
"vhCacheForceTargets": "Prefiksy ścieżki/query dla wymuszonego cache (po jednym w linii)",
"vhCacheForceHelp": "Tylko per-vhost. Pasujące GET/HEAD mogą ominąć bypass query, Cookie/Authorization żądania i klienckie no-cache/no-store. Cookie/Authorization nadal tworzą osobne warianty. Odpowiedzi origin z Set-Cookie, private lub no-store nadal nigdy nie są zapisywane.",
"vhCacheForceConfirm": "Błędne wymuszenie cache może podać nieaktualną lub prywatną treść. Włączyć je tylko dla jawnie sprawdzonych prefiksów URL?",
"locationRedirectAction": "Przekieruj na URL",
"locationRedirectShort": "redirect",
"locationRedirectStatus": "Kod przekierowania",
"locationRedirectTarget": "Szablon docelowego URL",
"locationRedirectPattern": "Opcjonalny regex rewrite dla capture",
"locationRedirectQuery": "Zachowaj query string żądania",
"locationRedirectHelp": "Cel musi być pełnym URL http/https. Opcjonalny regex rewrite obsługuje $1/${name} w ścieżce/query, np. ^/(.*)$ → https://storage.example/o/$1.",
"locationRedirect301": "301 Stałe",
"locationRedirect302": "302 Found",
"locationRedirect303": "303 See Other",
"locationRedirect307": "307 Tymczasowe",
"locationRedirect308": "308 Stałe",
"cacheReason_cache_miss_forced": "MISS dla wymuszonego cache",
"vhWwwPanelTitle": "Przekierowanie adresu www",
"vhWwwInactive": "Przekierowanie wyłączone. Wybierz adres docelowy poniżej i włącz przełącznik powyżej. Wszystkie domeny obsługują ten sam vhost.",
"vhWwwOtherDomains": "Pozostałe domeny (bez przekierowania):",
"vhWwwNoHosts": "Dodaj powyżej konkretną domenę. Wildcard nie może być adresem docelowym.",
"vhWwwSharedCount": "{count} nazw / jeden vhost",
"vhWwwEnable": "Włącz przekierowanie 308"
}
}
+1 -1
View File
@@ -86,7 +86,7 @@ def add_file(archive: zipfile.ZipFile, rel: Path, data: bytes, timestamp: tuple[
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('output', nargs='?', type=Path, default=ROOT.parent / 'RustProxy-0.28.2.zip')
parser.add_argument('output', nargs='?', type=Path, default=ROOT.parent / 'RustProxy-0.29.0.zip')
output = parser.parse_args().output.resolve()
paths = files()
checksum = ROOT / 'SHA256SUMS'
+1 -1
View File
@@ -85,7 +85,7 @@ def download(destination: Path) -> None:
"""TLS verification uses Python's default trust store; never executes a shell."""
started = time.monotonic()
opener = build_opener(HttpsRedirects())
request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build/0.28.2"})
request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build/0.29.0"})
with opener.open(request, timeout=30) as response, destination.open("wb") as output:
length = response.headers.get("Content-Length")
if length and int(length) > MAX_ARCHIVE:
+1 -1
View File
@@ -95,7 +95,7 @@ def digest(path: Path) -> str:
def fetch_https(url: str, max_bytes: int) -> bytes:
if urllib.parse.urlparse(url).scheme != 'https':
raise ValueError('GeoIP downloads require HTTPS.')
request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer/0.28.2'})
request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer/0.29.0'})
with urllib.request.urlopen(request, timeout=30) as response:
if urllib.parse.urlparse(response.geturl()).scheme != 'https':
raise ValueError('GeoIP download redirected away from HTTPS.')
+3 -3
View File
@@ -46,7 +46,7 @@ def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None
for name in MANAGED:
if (target / name).is_symlink():
raise ValueError(f"Managed target {name} is a symlink; refusing to replace it.")
backup = target / ".upgrade-backup" / ("before-0.28.2-" + uuid.uuid4().hex[:12])
backup = target / ".upgrade-backup" / ("before-0.29.0-" + uuid.uuid4().hex[:12])
backup.mkdir(parents=True, mode=0o700)
installed: list[str] = []
moved: list[str] = []
@@ -84,7 +84,7 @@ def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None
remove_legacy = legacy.exists()
if remove_legacy and (legacy.is_symlink() or not legacy.is_file()):
raise ValueError("Obsolete renewal observation sidecar must be a regular file.")
(backup / "upgrade.json").write_text(json.dumps({"from_directory":str(target), "to_version":"0.28.2", "managed_paths":MANAGED, "removed_obsolete_runtime_state": remove_legacy}, indent=2)+"\n", encoding="utf-8")
(backup / "upgrade.json").write_text(json.dumps({"from_directory":str(target), "to_version":"0.29.0", "managed_paths":MANAGED, "removed_obsolete_runtime_state": remove_legacy}, indent=2)+"\n", encoding="utf-8")
if remove_legacy:
legacy.unlink()
except BaseException:
@@ -115,7 +115,7 @@ def main() -> None:
backup = upgrade(Path(__file__).resolve().parents[2], args.target, stopped=args.stopped, data_dir=data_dir)
except (OSError, ValueError) as error:
parser.exit(2, f"Upgrade refused or rolled back: {error}\n")
print(f"Installed RustProxy 0.28.2. Previous managed source files: {backup}")
print(f"Installed RustProxy 0.29.0. Previous managed source files: {backup}")
print("Preserved data, .env, .git, target and other unmanaged files. No database migration was run.")
print(f"Next: cd {args.target}; ./dev.sh test; ./dev.sh")
+1
View File
@@ -409,6 +409,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"/docs/V0.25.1_AUDIT.md"
)),
),
reader_doc!("docs/V0.29.0_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.28.2_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.28.1_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.28.0_IMPLEMENTATION.md", "technical"),
+30 -56
View File
@@ -4,6 +4,8 @@ const FILE_LOG_SCAN_LINES: usize = 20_000;
const FILE_LOG_MAX_SOURCES: usize = 32;
const FILE_LOG_MAX_LIMIT: usize = 500;
const FILE_LOG_MAX_OFFSET: usize = 10_000;
const FILE_LOG_GZIP_INPUT_MAX: u64 = 8 * 1024 * 1024;
const FILE_LOG_GZIP_OUTPUT_MAX: u64 = 64 * 1024 * 1024;
#[derive(Clone)]
struct FileLogSource {
id: String,
@@ -15,6 +17,7 @@ struct FileLogSource {
exists: bool,
size: u64,
modified_ms: u64,
compressed: bool,
}
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
@@ -48,6 +51,10 @@ fn source_from_path(
let safe_regular = metadata
.as_ref()
.is_some_and(|meta| meta.file_type().is_file() && !meta.file_type().is_symlink());
let compressed = path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("gz"));
FileLogSource {
id,
label,
@@ -64,8 +71,11 @@ fn source_from_path(
.as_ref()
.filter(|_| safe_regular)
.map_or(0, file_modified_ms),
compressed,
}
}
include!("file_logs_io.rs");
fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSource> {
let mut sources = Vec::new();
if config.logging.file.per_vhost {
@@ -81,7 +91,9 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
.filter_map(|entry| {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
if !name.ends_with(".jsonl") || name.len() > 160 {
let plain = name.ends_with(".jsonl");
let rotated = name.contains(".jsonl.") && name.ends_with(".gz");
if (!plain && !rotated) || name.len() > 180 {
return None;
}
let file_type = entry.file_type().ok()?;
@@ -95,7 +107,11 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
entries.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
entries.truncate(256);
for (name, path, _) in entries {
let id = format!("vhost:{}", name.trim_end_matches(".jsonl"));
let id = if name.ends_with(".jsonl") {
format!("vhost:{}", name.trim_end_matches(".jsonl"))
} else {
format!("vhost-gz:{name}")
};
sources.push(source_from_path(
id,
name,
@@ -107,26 +123,31 @@ fn configured_file_log_sources(config: &crate::config::Config) -> Vec<FileLogSou
}
}
} else {
let access_path = std::path::PathBuf::from(&config.logging.file.path);
sources.push(source_from_path(
"access".into(),
"Access log".into(),
"access",
std::path::PathBuf::from(&config.logging.file.path),
access_path.clone(),
config.logging.file.enabled,
"JSONL",
));
sources.extend(rotated_gzip_sources(&access_path, "access", "Access log", "access", config.logging.file.enabled, "JSONL"));
}
let error_format = match config.error_log.format {
crate::logging::ErrorLogFormat::Json => "JSONL",
crate::logging::ErrorLogFormat::Raw => "RAW",
};
let error_path = std::path::PathBuf::from(&config.error_log.path);
sources.push(source_from_path(
"error".into(),
"Error log".into(),
"error",
std::path::PathBuf::from(&config.error_log.path),
error_path.clone(),
config.error_log.enabled,
match config.error_log.format {
crate::logging::ErrorLogFormat::Json => "JSONL",
crate::logging::ErrorLogFormat::Raw => "RAW",
},
error_format,
));
sources.extend(rotated_gzip_sources(&error_path, "error", "Error log", "error", config.error_log.enabled, error_format));
sources
}
fn file_log_sources_json(sources: &[FileLogSource]) -> Value {
@@ -144,6 +165,7 @@ fn file_log_sources_json(sources: &[FileLogSource]) -> Value {
"exists": source.exists,
"size": source.size,
"modified_ms": source.modified_ms,
"compressed": source.compressed,
})
})
.collect(),
@@ -171,54 +193,6 @@ async fn file_logs(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiErro
}
})))
}
fn open_file_log(path: &std::path::Path) -> std::io::Result<std::fs::File> {
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let file = options.open(path)?;
let metadata = file.metadata()?;
if !metadata.file_type().is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"configured log path is not a regular file",
));
}
Ok(file)
}
fn read_tail(
source: &FileLogSource,
budget: u64,
line_budget: usize,
) -> std::io::Result<(Vec<String>, u64, bool)> {
use std::io::{Read, Seek, SeekFrom};
let mut file = open_file_log(&source.path)?;
let length = file.metadata()?.len();
let bytes = length.min(budget).min(FILE_LOG_SCAN_BYTES_PER_SOURCE);
let start = length.saturating_sub(bytes);
file.seek(SeekFrom::Start(start))?;
let mut content = Vec::with_capacity(bytes as usize);
file.take(bytes).read_to_end(&mut content)?;
let truncated = start > 0;
if truncated {
if let Some(index) = content.iter().position(|byte| *byte == b'\n') {
content.drain(..=index);
} else {
content.clear();
}
}
let text = String::from_utf8_lossy(&content);
let mut iter = text.lines().rev();
let lines = iter
.by_ref()
.take(line_budget)
.map(str::to_owned)
.collect::<Vec<_>>();
Ok((lines, bytes, truncated || iter.next().is_some()))
}
fn copy_mapped_field(
object: &mut serde_json::Map<String, Value>,
format: &crate::logging::JsonFormat,
+106
View File
@@ -0,0 +1,106 @@
// Bounded file-log inventory helpers and gzip tail reader.
fn rotated_gzip_sources(
active: &std::path::Path,
id_prefix: &str,
label_prefix: &str,
kind: &'static str,
enabled: bool,
format: &'static str,
) -> Vec<FileLogSource> {
let Some(parent) = active.parent() else { return Vec::new(); };
let Some(base) = active.file_name().and_then(|name| name.to_str()) else { return Vec::new(); };
let safe = std::fs::symlink_metadata(parent).ok().is_some_and(|meta| meta.file_type().is_dir() && !meta.file_type().is_symlink());
if !safe { return Vec::new(); }
let prefix = format!("{base}.");
let mut entries = std::fs::read_dir(parent).ok().into_iter().flat_map(|iter| iter.filter_map(|entry| entry.ok())).filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
if name.len() > 180 || !name.starts_with(&prefix) || !name.ends_with(".gz") { return None; }
let file_type = entry.file_type().ok()?;
if !file_type.is_file() || file_type.is_symlink() { return None; }
let meta = entry.metadata().ok()?;
Some((name, entry.path(), file_modified_ms(&meta)))
}).collect::<Vec<_>>();
entries.sort_by(|a,b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
entries.truncate(64);
entries.into_iter().map(|(name,path,_)| source_from_path(
format!("{id_prefix}:gz:{name}"),
format!("{label_prefix} · {name}"),
kind, path, enabled, format
)).collect()
}
fn open_file_log(path: &std::path::Path) -> std::io::Result<std::fs::File> {
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let file = options.open(path)?;
let metadata = file.metadata()?;
if !metadata.file_type().is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"configured log path is not a regular file",
));
}
Ok(file)
}
fn read_tail(
source: &FileLogSource,
budget: u64,
line_budget: usize,
) -> std::io::Result<(Vec<String>, u64, bool)> {
use std::io::{Read, Seek, SeekFrom};
let mut file = open_file_log(&source.path)?;
let length = file.metadata()?.len();
if source.compressed {
// gzip streams are not seekable. Read a complete bounded rotation, then
// keep only the same 2 MiB decompressed tail used for plain files.
if length > budget || length > FILE_LOG_GZIP_INPUT_MAX {
return Ok((Vec::new(), length.min(budget), true));
}
let mut decoder = flate2::read::GzDecoder::new(file);
let mut decoded = Vec::new();
decoder
.by_ref()
.take(FILE_LOG_GZIP_OUTPUT_MAX + 1)
.read_to_end(&mut decoded)?;
if decoded.len() as u64 > FILE_LOG_GZIP_OUTPUT_MAX {
return Ok((Vec::new(), length, true));
}
let keep = decoded.len().min(FILE_LOG_SCAN_BYTES_PER_SOURCE as usize);
let start = decoded.len().saturating_sub(keep);
let mut content = decoded[start..].to_vec();
let truncated = start > 0;
if truncated {
if let Some(index) = content.iter().position(|byte| *byte == b'\n') {
content.drain(..=index);
} else {
content.clear();
}
}
let text = String::from_utf8_lossy(&content);
let mut iter = text.lines().rev();
let lines = iter.by_ref().take(line_budget).map(str::to_owned).collect::<Vec<_>>();
return Ok((lines, length, truncated || iter.next().is_some()));
}
let bytes = length.min(budget).min(FILE_LOG_SCAN_BYTES_PER_SOURCE);
let start = length.saturating_sub(bytes);
file.seek(SeekFrom::Start(start))?;
let mut content = Vec::with_capacity(bytes as usize);
file.take(bytes).read_to_end(&mut content)?;
let truncated = start > 0;
if truncated {
if let Some(index) = content.iter().position(|byte| *byte == b'\n') {
content.drain(..=index);
} else {
content.clear();
}
}
let text = String::from_utf8_lossy(&content);
let mut iter = text.lines().rev();
let lines = iter.by_ref().take(line_budget).map(str::to_owned).collect::<Vec<_>>();
Ok((lines, bytes, truncated || iter.next().is_some()))
}
+70 -3
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.28.2",
"version": "0.29.0",
"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": [
@@ -375,6 +375,43 @@
},
"source": {
"$ref": "#/components/schemas/Source"
},
"redirect": {
"type": "object",
"nullable": true,
"additionalProperties": false,
"properties": {
"status": {
"type": "integer",
"enum": [
301,
302,
303,
307,
308
],
"default": 302
},
"target": {
"type": "string",
"maxLength": 2048,
"description": "Absolute HTTP(S) redirect target. Capture references are allowed only in path/query."
},
"rewrite_pattern": {
"type": "string",
"maxLength": 1024,
"nullable": true,
"description": "Optional Rust regex applied to the matched request path before expanding captures in target."
},
"preserve_query": {
"type": "boolean",
"default": true
}
},
"required": [
"target"
],
"description": "Backend-less client redirect for this location."
}
},
"additionalProperties": false,
@@ -395,6 +432,15 @@
"type": "boolean",
"description": "Redirect cleartext requests for this vhost to the selected HTTPS listener using HTTP 308."
},
"www_redirect": {
"type": "boolean",
"default": false,
"description": "Claim the direct www/non-www companion of the selected exact DNS hostname and redirect it permanently to that hostname."
},
"www_redirect_target": {
"type": ["string", "null"],
"description": "An exact hostname from frontend.hosts to use as the canonical redirect destination. Null uses the first hostname for compatibility. Other hostnames continue serving the same vhost without redirects."
},
"forward_client_ip": {
"type": "boolean",
"default": true,
@@ -446,7 +492,27 @@
},
"cache": {
"type": "object",
"properties": {},
"properties": {
"unsafe_force": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"default": false
},
"targets": {
"type": "array",
"maxItems": 32,
"items": {
"type": "string",
"maxLength": 2048
}
}
},
"description": "Dangerous per-vhost-only request-side forced-cache target prefixes. Response privacy exclusions remain authoritative."
}
},
"additionalProperties": true
},
"load_balancing": {
@@ -2889,7 +2955,8 @@
},
"host": {
"type": "string",
"nullable": true
"nullable": true,
"description": "Optional HTTP Host override. When null for an HTTP vhost probe, RustProxy uses the first exact vhost DNS hostname; if none exists it falls back to the backend host."
},
"interval_ms": {
"type": "integer",
+36
View File
@@ -150,3 +150,39 @@ fn stored_log_reader_refuses_symlink_targets() {
symlink(&real, &link).unwrap();
assert!(open_file_log(&link).is_err());
}
#[test]
fn stored_log_tail_reads_rotated_gzip_and_inventory_discovers_it() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let active = dir.path().join("access.jsonl");
std::fs::write(&active, b"{\"event\":\"request\",\"time_ms\":3}\n").unwrap();
let rotated = dir.path().join("access.jsonl.1.gz");
let file = std::fs::File::create(&rotated).unwrap();
let mut encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
writeln!(encoder, r#"{{"event":"request","time_ms":1}}"#).unwrap();
writeln!(encoder, r#"{{"event":"request","time_ms":2}}"#).unwrap();
encoder.finish().unwrap();
let source = source_from_path(
"access:gz:access.jsonl.1.gz".into(),
"Access log · access.jsonl.1.gz".into(),
"access",
rotated.clone(),
true,
"JSONL",
);
assert!(source.compressed);
let (lines, bytes, truncated) = read_tail(&source, FILE_LOG_SCAN_BYTES_TOTAL, 10).unwrap();
assert_eq!(bytes, std::fs::metadata(&rotated).unwrap().len());
assert!(!truncated);
assert_eq!(lines.len(), 2);
assert!(lines[0].contains(r#""time_ms":2"#));
let mut config = Config::default();
config.logging.file.per_vhost = false;
config.logging.file.path = active.to_string_lossy().into_owned();
let sources = configured_file_log_sources(&config);
assert!(sources.iter().any(|item| item.path == rotated && item.compressed));
}
+2 -2
View File
@@ -2891,7 +2891,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.28.2</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.29.0</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
@@ -3635,7 +3635,7 @@ max_execution_time = 30</code></pre>
<article class="card about-card">
<div class="about-product">
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
<div><span class="small-label">RustProxy</span><strong>0.28.2</strong></div>
<div><span class="small-label">RustProxy</span><strong>0.29.0</strong></div>
</div>
<dl class="about-meta">
<div>
+158
View File
@@ -5235,6 +5235,164 @@ dialog .window-heading {
color: var(--red)
}
/* Vhost names and canonical WWW routing share one visual decision surface. */
.vh-www-panel {
padding: 12px;
border: 1px solid var(--line);
background: var(--panel2);
min-width: 0
}
.vh-www-panel.is-enabled {
border-color: var(--green);
box-shadow: inset 3px 0 0 var(--green)
}
.vh-www-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 18px
}
.vh-www-title {
font-size: 12px;
color: var(--text)
}
.vh-www-host-count {
padding: 3px 7px;
border: 1px solid var(--line);
color: var(--muted);
font: 10px var(--mono)
}
.vh-www-header .vh-www-toggle {
margin: 0;
min-height: 0;
display: flex;
align-items: center;
color: var(--text);
font-size: 11px
}
.vh-www-inactive,
.vh-www-help {
margin: 9px 0 0;
font-size: 11px
}
.vh-www-details {
margin-top: 12px;
border-top: 1px solid var(--line);
padding-top: 12px
}
.vh-www-option-title {
font-size: 11px;
font-weight: 600;
margin: 0 0 8px;
color: var(--text)
}
.vh-www-choices {
display: grid;
gap: 10px
}
.vh-www-domain-pair {
padding: 9px;
border: 1px solid var(--line);
background: var(--panel)
}
.vh-www-domain-label {
display: block;
font: 11px var(--mono);
color: var(--text);
margin-bottom: 8px;
overflow-wrap: anywhere
}
.vh-www-pair-directions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px
}
@media (max-width: 650px) {
.vh-www-pair-directions { grid-template-columns: minmax(0, 1fr) }
}
.vh-www-choice {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 11px;
border: 1px solid var(--line);
background: var(--field-bg);
cursor: pointer
}
.vh-www-choice:hover,
.vh-www-choice:focus-within {
border-color: var(--green)
}
.vh-www-choice.is-selected {
border-color: var(--green);
box-shadow: inset 3px 0 0 var(--green)
}
.vh-www-choice input {
margin: 3px 0 0;
accent-color: var(--green);
flex: 0 0 auto
}
.vh-www-choice-copy {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0
}
.vh-www-choice-copy strong {
font-size: 12px;
overflow-wrap: anywhere;
color: var(--text)
}
.vh-www-route {
color: var(--muted);
font: 11px var(--mono);
overflow-wrap: anywhere
}
.vh-www-no-hosts {
margin: 6px 0;
font-size: 11px
}
.vh-www-other {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 12px;
font-size: 11px;
color: var(--muted)
}
.vh-www-alias {
font: 11px var(--mono);
padding: 4px 6px;
border: 1px solid var(--line);
background: var(--panel)
}
.vhost-chart {
margin-top: 9px;
border: 1px solid var(--line);
+21 -9
View File
@@ -43,7 +43,7 @@
};
const allowDiscard = async () => { if (!dirty) return true; if (!(await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") }))) return false; dirty = false; return true; };
const defaults = () => ({
id: "", enabled: true, force_ssl: false, forward_client_ip: true, websockets: false, websocket_legacy_support: false, websocket_rfc8441: false, websocket_rfc9220: false, http2_enabled: true, http3_enabled: true, cloudflare_client_ip: false, timing_headers: false, backend_header: false, backend_timing_header: false,
id: "", enabled: true, force_ssl: false, www_redirect: false, www_redirect_target: null, forward_client_ip: true, websockets: false, websocket_legacy_support: false, websocket_rfc8441: false, websocket_rfc9220: false, http2_enabled: true, http3_enabled: true, cloudflare_client_ip: false, timing_headers: false, backend_header: false, backend_timing_header: false,
frontend: { hosts: [], listener_ids: [] },
source: { type: "reverse_proxy" },
backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }],
@@ -52,7 +52,7 @@
function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); }
function initTabs() { const tabs = $("vhost-tabs"); tabs.replaceChildren(); tabs.setAttribute("role", "tablist"); for (const [name, key] of [["details", "vhDetailsTab"], ["backends", "vhBackendPool"], ["health", "vhHealth"], ["locations", "vhLocations"], ["auth", "vhTabAuth"], ["ssl", "vhSsl"], ["cache", "vhCache"], ["compression", "vhCompression"], ["transform", "vhTransform"], ["security", "vhSecurity"], ["pages", "vhPages"], ["advanced", "advanced"]]) { const b = button(key, () => activateTab(name)); b.dataset.tab = name; b.setAttribute("role", "tab"); tabs.append(b); } activateTab("details"); }
function collectCache() { const tri = name => str(name) === "" ? null : str(name) === "on", number = (name, mul = 1) => { if (str(name) === "") return null; const value = Number(str(name)) * mul; if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; }; return { enabled: tri("vh-cache-enabled"), max_ttl_seconds: number("vh-cache-ttl"), default_ttl_seconds: number("vh-cache-default"), max_object_bytes: number("vh-cache-object", 1024), bypass_query: tri("vh-cache-query"), static_ignore_cookies: tri("vh-cache-static-cookie"), static_extensions: yes("vh-cache-static-custom") ? lines(str("vh-cache-static-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null, expose_status: tri("vh-cache-status"), expose_hits: tri("vh-cache-hits"), expose_cache_status: tri("vh-cache-rfc"), expose_resident_age: tri("vh-cache-resident-age"), bypass_prefixes: yes("vh-cache-prefix-custom") ? lines(str("vh-cache-prefixes")) : null }; }
function collectCache() { const tri = name => str(name) === "" ? null : str(name) === "on", number = (name, mul = 1) => { if (str(name) === "") return null; const value = Number(str(name)) * mul; if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; }; return { enabled: tri("vh-cache-enabled"), max_ttl_seconds: number("vh-cache-ttl"), default_ttl_seconds: number("vh-cache-default"), max_object_bytes: number("vh-cache-object", 1024), bypass_query: tri("vh-cache-query"), static_ignore_cookies: tri("vh-cache-static-cookie"), static_extensions: yes("vh-cache-static-custom") ? lines(str("vh-cache-static-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null, expose_status: tri("vh-cache-status"), expose_hits: tri("vh-cache-hits"), expose_cache_status: tri("vh-cache-rfc"), expose_resident_age: tri("vh-cache-resident-age"), bypass_prefixes: yes("vh-cache-prefix-custom") ? lines(str("vh-cache-prefixes")) : null, unsafe_force: { enabled: yes("vh-cache-force-enabled"), targets: lines(str("vh-cache-force-targets")) } }; }
function collectCompression() { const tri = name => str(name) === "" ? null : str(name) === "on"; return { enabled: tri("vh-compression-enabled"), zstd: tri("vh-compression-zstd"), br: tri("vh-compression-br"), gzip: tri("vh-compression-gzip"), deflate: tri("vh-compression-deflate") }; }
function collectTransform() {
const tri = name => str(name) === "" ? null : str(name) === "on";
@@ -312,7 +312,7 @@
function readRows() {
return [...rows.children].map(r => {
const action = str("location-action", r), rewriteMode = str("location-rewrite-mode", r);
const handled = action !== "return";
const handled = action === "proxy" || action === "web_server";
return {
id: str("location-id", r), path_prefix: str("location-path", r), match_type: str("location-match", r),
source: action === "web_server" ? { type: "web_server", web_service_id: str("location-web-service", r) } : { type: "reverse_proxy" },
@@ -324,7 +324,8 @@
method_policy: { mode: str("location-method-mode", r), methods: lines(str("location-method-list", r)).map(value => value.toUpperCase()) },
headers: r.readHeaders(),
rewrite: { mode: handled ? rewriteMode : "preserve", value: handled && rewriteMode !== "preserve" ? read("location-rewrite-value", r).value.trim() : "" },
response: action === "return" ? { status: Number(str("location-response-status", r)), content_type: str("location-response-type", r), body: read("location-response-body", r).value } : null
response: action === "return" ? { status: Number(str("location-response-status", r)), content_type: str("location-response-type", r), body: read("location-response-body", r).value } : null,
redirect: action === "redirect" ? { status: Number(str("location-redirect-status", r)), target: str("location-redirect-target", r), rewrite_pattern: nullable("location-redirect-pattern", r), preserve_query: yes("location-redirect-query", r) } : null
};
});
}
@@ -338,8 +339,8 @@
field(body, "location-id", "locationId", value.id || `location-${suffix}`, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
const match = field(body, "location-match", "locationMatchType", value.match_type || "prefix", "select", { values: [["prefix", "locationMatchPrefix"], ["prefix_priority", "locationMatchPriority"], ["exact", "locationMatchExact"], ["regex", "locationMatchRegex"], ["regex_insensitive", "locationMatchRegexI"]] });
const path = field(body, "location-path", "locationPathPattern", value.path_prefix || "/", "text", { required: true, maxLength: 1024 }); path.placeholder = "/api or ^/api/v[0-9]+/";
const initialAction = value.response ? "return" : value.source?.type === "web_server" ? "web_server" : "proxy";
const action = field(body, "location-action", "locationAction", initialAction, "select", { values: [["proxy", "locationProxyAction"], ["web_server", "locationWebServerAction"], ["return", "locationReturnAction"]] });
const initialAction = value.response ? "return" : value.redirect ? "redirect" : value.source?.type === "web_server" ? "web_server" : "proxy";
const action = field(body, "location-action", "locationAction", initialAction, "select", { values: [["proxy", "locationProxyAction"], ["web_server", "locationWebServerAction"], ["redirect", "locationRedirectAction"], ["return", "locationReturnAction"]] });
const backend = field(body, "location-backend", "backend", value.backend_id || "", "select"); memberOptions(backend, value.backend_id || "");
const webService = field(body, "location-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(webService, value.source?.web_service_id || "");
field(body, "location-enabled", "enabled", value.enabled !== false, "checkbox");
@@ -352,6 +353,12 @@
field(returnBox, "location-response-status", "locationResponseStatus", value.response?.status ?? 200, "number", { min: 200, max: 599, required: true });
field(returnBox, "location-response-type", "locationResponseType", value.response?.content_type || "text/plain; charset=utf-8", "text", { maxLength: 256, required: true });
field(returnBox, "location-response-body", "locationResponseBody", value.response?.body || "", "textarea", { rows: 5, maxLength: 65536 });
const redirectBox = el("div", undefined, "vh-wide vh-grid location-redirect-box"); handlingBody.append(redirectBox);
field(redirectBox, "location-redirect-status", "locationRedirectStatus", value.redirect?.status ?? 302, "select", { values: [["301", "locationRedirect301"], ["302", "locationRedirect302"], ["303", "locationRedirect303"], ["307", "locationRedirect307"], ["308", "locationRedirect308"]] });
field(redirectBox, "location-redirect-target", "locationRedirectTarget", value.redirect?.target || "", "url", { maxLength: 2048 });
field(redirectBox, "location-redirect-pattern", "locationRedirectPattern", value.redirect?.rewrite_pattern || "", "text", { maxLength: 1024 });
field(redirectBox, "location-redirect-query", "locationRedirectQuery", value.redirect?.preserve_query !== false, "checkbox");
note(redirectBox, "locationRedirectHelp");
note(handlingBody, "locationRewriteHelp");
const extra = el("details", undefined, "vh-wide location-section"), accessBody = el("div", undefined, "vh-grid"); extra.append(text("summary", "locationAccess"), accessBody); body.append(extra);
@@ -387,15 +394,15 @@
actions.append(dedicated, button("remove", () => { row.remove(); sync(); })); body.append(actions);
const syncUi = () => {
const isReturn = action.value === "return", isWeb = action.value === "web_server", isProxy = action.value === "proxy", isRegex = match.value === "regex" || match.value === "regex_insensitive";
const isReturn = action.value === "return", isRedirect = action.value === "redirect", isWeb = action.value === "web_server", isProxy = action.value === "proxy", isRegex = match.value === "regex" || match.value === "regex_insensitive";
backend.disabled = !isProxy; cache.disabled = !isProxy; dedicated.disabled = !isProxy;
webService.disabled = !isWeb; webService.required = isWeb;
returnBox.hidden = !isReturn; rewriteMode.disabled = isReturn; rewriteValue.disabled = isReturn || rewriteMode.value === "preserve" || rewriteMode.value === "strip_prefix";
returnBox.hidden = !isReturn; redirectBox.hidden = !isRedirect; rewriteMode.disabled = isReturn || isRedirect; rewriteValue.disabled = isReturn || isRedirect || rewriteMode.value === "preserve" || rewriteMode.value === "strip_prefix";
for (const option of rewriteMode.options) if (option.value === "regex") option.disabled = !isRegex;
if (!isRegex && rewriteMode.value === "regex") rewriteMode.value = "preserve";
path.placeholder = isRegex ? "^/api/v[0-9]+/" : "/api/";
const id = str("location-id", row) || t("locationId"), pattern = str("location-path", row) || "/";
summary.textContent = `${id} · ${t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : isWeb ? "locationWebServerShort" : "locationProxyShort")}`;
summary.textContent = `${id} · ${t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : isRedirect ? "locationRedirectShort" : isWeb ? "locationWebServerShort" : "locationProxyShort")}`;
previewStatus.hidden = yes("location-enabled", row);
previewValues.match.textContent = `${t(`locationMatch_${match.value}`)} · ${pattern}`;
@@ -404,6 +411,11 @@
previewValues.handling.textContent = `${t("locationReturnAction")}: HTTP ${status} · ${type} · ${t("locationPreviewBackendSkipped")}`;
previewValues.rewrite.textContent = "-";
previewValues.cache.textContent = t("notEnabled");
} else if (isRedirect) {
const status = str("location-redirect-status", row) || "302", target = str("location-redirect-target", row) || "-", capture = str("location-redirect-pattern", row);
previewValues.handling.textContent = `${t("locationRedirectAction")}: HTTP ${status} · ${target} · ${t("locationPreviewBackendSkipped")}`;
previewValues.rewrite.textContent = capture || "-";
previewValues.cache.textContent = t("notEnabled");
} else {
if (isWeb) {
const selected = webService.selectedOptions?.[0]?.textContent || t("webServiceSelect");
+91 -4
View File
@@ -13,7 +13,7 @@
return { async prepare() { for (const row of rows.children) await row.prepare(); }, read: () => ({ enabled: str("auth-mode") === "basic", realm: realm.value.trim(), require_tls: secure.checked, forward_credentials: forward.checked, users: [...rows.children].map(row => ({ username: str("basic-username", row), password_hash: row.userHash })) }) };
}
function hostTagEditor(parent, hosts = []) {
function hostTagEditor(parent, hosts = [], onUpdate = () => {}) {
const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(text("span", "vhHosts"));
const hidden = document.createElement("textarea"); hidden.name = "hosts"; hidden.hidden = true;
const box = el("div", undefined, "host-chip-editor"), input = document.createElement("input"); input.type = "text"; input.maxLength = 253; input.placeholder = "app.example.com / *.example.com"; input.setAttribute("aria-label", t("vhHostAdd"));
@@ -23,7 +23,7 @@
if (base.length > 253 || !/^[a-z0-9.-]+$/.test(base) || base.startsWith(".") || base.endsWith(".") || base.split(".").some(part => !part || part.length > 63 || part.startsWith("-") || part.endsWith("-"))) throw new Error(t("vhHostInvalid"));
if (wildcard && !base.includes(".")) throw new Error(t("vhHostInvalid")); return wildcard ? `*.${base}` : base;
};
const sync = () => { hidden.value = values.join("\n"); box.querySelectorAll(".host-chip").forEach(node => node.remove()); for (const value of values) { const chip = el("span", undefined, "host-chip"); chip.append(el("span", value)); const remove = el("button", "×"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${value}`); remove.addEventListener("click", () => { values.splice(values.indexOf(value), 1); sync(); changed(); }); chip.append(remove); box.insertBefore(chip, input); } };
const sync = () => { hidden.value = values.join("\n"); onUpdate([...values]); box.querySelectorAll(".host-chip").forEach(node => node.remove()); for (const value of values) { const chip = el("span", undefined, "host-chip"); chip.append(el("span", value)); const remove = el("button", "×"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${value}`); remove.addEventListener("click", () => { values.splice(values.indexOf(value), 1); sync(); changed(); }); chip.append(remove); box.insertBefore(chip, input); } };
const add = raw => { const value = normalize(raw); if (!value || values.includes(value)) return; values.push(value); sync(); changed(); };
const consume = raw => { for (const part of raw.split(/[\s,]+/).filter(Boolean)) add(part); input.value = ""; };
input.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { consume(input.value); } catch (error) { toast(error.message, true); } } });
@@ -38,7 +38,7 @@
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
let g = group("vhEditor"); field(g, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(g, "vhIdRenameHelp");
readHosts = hostTagEditor(g, value.frontend?.hosts || []);
let refreshCanonical = () => {};
const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
window.rustProxyEnableControl(enabledControl, "vhEnableHint");
const mainSource = field(g, "source-type", "sourceType", value.source?.type || "reverse_proxy", "select", { values: [["reverse_proxy", "sourceReverseProxy"], ["web_server", "sourceWebServer"]] });
@@ -52,10 +52,92 @@
const websocketLegacy = field(g, "websocket-legacy-support", "vhWebsocketLegacySupport", value.websocket_legacy_support === true, "checkbox");
note(g, "vhWebsocketLegacySupportHelp");
g = group("vhFrontends");
// Keep the list of names beside the WWW routing control. Every name in
// frontend.hosts remains an independent alias of this vhost.
readHosts = hostTagEditor(g, value.frontend?.hosts || [], hosts => refreshCanonical(hosts));
const bindingControl = window.rustProxyIntegrations.bindings(g, value.frontend?.listener_ids || [], !editing); readBindings = bindingControl.read;
note(g, "listenerDefaultPortsHint");
field(g, "force-ssl", "vhForceSsl", value.force_ssl === true, "checkbox");
note(g, "vhForceSslHelp");
const wwwPanel = el("section", undefined, "vh-wide vh-www-panel");
const wwwHeader = el("div", undefined, "vh-www-header");
const wwwTitle = text("strong", "vhWwwPanelTitle", "vh-www-title");
const wwwHostCount = el("span", "", "vh-www-host-count");
const wwwRedirect = field(wwwHeader, "www-redirect", "vhWwwEnable", value.www_redirect === true, "checkbox");
wwwRedirect.closest("label").classList.add("vh-www-toggle");
wwwHeader.prepend(wwwTitle, wwwHostCount);
const wwwInactive = text("p", "vhWwwInactive", "fine vh-www-inactive");
const wwwDetails = el("div", undefined, "vh-www-details");
wwwDetails.id = "vh-www-details";
wwwRedirect.setAttribute("aria-controls", wwwDetails.id);
const wwwLabel = text("p", "vhWwwRedirectTarget", "vh-www-option-title");
const wwwChoices = el("div", undefined, "vh-www-choices");
wwwChoices.setAttribute("role", "radiogroup");
wwwChoices.setAttribute("aria-label", t("vhWwwRedirectTarget"));
const wwwTarget = document.createElement("input");
wwwTarget.type = "hidden"; wwwTarget.name = "www-redirect-target";
const wwwNoHosts = text("p", "vhWwwNoHosts", "fine vh-www-no-hosts");
const wwwOther = el("div", undefined, "vh-www-other");
const wwwHelp = text("p", "vhWwwRedirectHelp", "fine vh-www-help");
wwwDetails.append(wwwLabel, wwwChoices, wwwNoHosts, wwwOther, wwwHelp);
wwwPanel.append(wwwHeader, wwwInactive, wwwDetails, wwwTarget);
g.append(wwwPanel);
// Persist only the selected canonical host; choosing the www variant does
// not require adding an extra hostname to the vhost manually.
let selectedCanonical = value.www_redirect_target || (value.frontend?.hosts || []).find(host => !host.startsWith("*.")) || "";
const syncCanonical = () => {
const allHosts = lines(read("hosts").value);
const exactHosts = allHosts.filter(host => !host.startsWith("*.") && !/^[\d.:]+$/.test(host));
const bases = [...new Set(exactHosts.map(host => host.startsWith("www.") ? host.slice(4) : host))]
.filter(base => !/^[\d.:]+$/.test(base) && `www.${base}`.length <= 253);
const options = bases.flatMap(base => [base, `www.${base}`]);
if (!options.includes(selectedCanonical)) selectedCanonical = exactHosts[0] || "";
wwwTarget.value = selectedCanonical;
wwwHostCount.textContent = t("vhWwwSharedCount").replace("{count}", String(allHosts.length));
wwwChoices.replaceChildren();
for (const base of bases) {
const pair = el("div", undefined, "vh-www-domain-pair");
pair.append(el("strong", base, "vh-www-domain-label"));
const directions = el("div", undefined, "vh-www-pair-directions");
for (const target of [base, `www.${base}`]) {
const companion = target === base ? `www.${base}` : base;
const choice = el("label", undefined, "vh-www-choice");
if (target === selectedCanonical) choice.classList.add("is-selected");
const radio = document.createElement("input"); radio.type = "radio"; radio.name = "www-canonical-choice";
radio.value = target; radio.checked = target === selectedCanonical;
const copy = el("span", undefined, "vh-www-choice-copy");
copy.append(el("strong", target), el("span", `${companion} \u2192 ${target}`, "vh-www-route"));
choice.append(radio, copy);
radio.addEventListener("change", () => {
if (!radio.checked) return;
selectedCanonical = target;
syncCanonical();
changed();
});
directions.append(choice);
}
pair.append(directions); wwwChoices.append(pair);
}
const companion = selectedCanonical.startsWith("www.") ? selectedCanonical.slice(4) : `www.${selectedCanonical}`;
const aliases = allHosts.filter(host => host !== selectedCanonical && host !== companion);
wwwOther.replaceChildren();
if (aliases.length) {
wwwOther.append(text("span", "vhWwwOtherDomains"));
for (const host of aliases) wwwOther.append(el("code", host, "vh-www-alias"));
}
wwwNoHosts.hidden = bases.length > 0;
wwwRedirect.disabled = !bases.length;
if (!bases.length) wwwRedirect.checked = false;
// Never hide the destination choices: the unchecked state is a preview
// that the operator can configure before enabling the rule.
wwwDetails.hidden = false;
wwwInactive.hidden = wwwRedirect.checked;
wwwRedirect.setAttribute("aria-expanded", "true");
wwwPanel.classList.toggle("is-enabled", wwwRedirect.checked);
};
refreshCanonical = syncCanonical;
wwwRedirect.addEventListener("change", () => { syncCanonical(); changed(); });
syncCanonical();
field(g, "forward-client-ip", "vhForwardClientIp", value.forward_client_ip !== false, "checkbox");
note(g, "vhForwardClientIpHelp");
field(g, "cloudflare-client-ip", "vhCloudflareClientIp", value.cloudflare_client_ip === true, "checkbox");
@@ -267,6 +349,10 @@
tri(cacheOptions, "vh-cache-status", "cacheStatusHeader", cache.expose_status); tri(cacheOptions, "vh-cache-hits", "cacheHitsHeader", cache.expose_hits); tri(cacheOptions, "vh-cache-rfc", "cacheRfcHeader", cache.expose_cache_status); tri(cacheOptions, "vh-cache-resident-age", "cacheResidentAge", cache.expose_resident_age);
field(cacheOptions, "vh-cache-prefix-custom", "vhCacheCustomPrefixes", cache.bypass_prefixes != null, "checkbox");
field(cacheOptions, "vh-cache-prefixes", "cacheBypassPrefixes", (cache.bypass_prefixes || []).join("\n"), "textarea", { rows: 2, maxLength: 32768 }); note(cacheOptions, "vhCacheHint");
const forceCache = field(cacheOptions, "vh-cache-force-enabled", "vhCacheForceEnabled", cache.unsafe_force?.enabled === true, "checkbox");
const forceTargets = field(cacheOptions, "vh-cache-force-targets", "vhCacheForceTargets", (cache.unsafe_force?.targets || []).join("\n"), "textarea", { rows: 3, maxLength: 65536 });
const syncForceCache = () => { forceTargets.disabled = !forceCache.checked; }; forceCache.addEventListener("change", async () => { if (forceCache.checked && !(await appConfirm(t("vhCacheForceConfirm"), { danger: true }))) forceCache.checked = false; syncForceCache(); }); syncForceCache();
note(cacheOptions, "vhCacheForceHelp");
if (editing) cacheOptions.append(button("vhPurgeCache", () => purge(editing)));
const syncCacheVisibility = () => {
const enabled = envelope?.config.cache?.enabled === true && cacheToggle.value !== "off";
@@ -303,6 +389,7 @@
const needsProxy = mainSourceType === "reverse_proxy" || paths.some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
if (!bindingSelection.length) throw new Error(t("vhListenerRequired"));
if (needsProxy && !members.length) throw new Error(t("vhBackendRequired"));
if (yes("www-redirect") && !str("www-redirect-target")) throw new Error(t("vhWwwNoHosts"));
if (mainSourceType === "web_server" && !str("source-web-service")) throw new Error(t("webServiceRequired"));
if (paths.some(route => !route.response && route.source?.type === "web_server" && !route.source.web_service_id)) throw new Error(t("webServiceRequired"));
if (yes("force-ssl")) {
@@ -332,7 +419,7 @@
if (JSON.stringify(selected) !== JSON.stringify(globalPolicy)) throw new Error(t("vhTlsHttp3Conflict"));
}
return {
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, force_ssl: yes("force-ssl"), forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), compression: collectCompression(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, force_ssl: yes("force-ssl"), www_redirect: yes("www-redirect"), www_redirect_target: str("www-redirect-target") || null, forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), compression: collectCompression(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") },
rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader() },
method_policy: { mode: str("method-mode"), methods: lines(str("method-list")).map(value => value.toUpperCase()) },
+4
View File
@@ -204,6 +204,10 @@
if (v.rate_limit?.enabled) policy.append(el("small", t("vhRateEnabled"), "pill"));
if (v.acl_profile_id) policy.append(el("small", `${t("vhAclProfile")}: ${v.acl_profile_id}`, "pill"));
if (v.force_ssl) policy.append(el("small", t("vhForceSsl"), "pill"));
if (v.www_redirect) {
const destination = v.www_redirect_target || (v.frontend?.hosts || []).find(host => !host.startsWith("*.")) || "";
policy.append(el("small", `WWW 308 \u2192 ${destination}`, "pill"));
}
if (v.forward_client_ip === false) policy.append(el("small", t("vhClientIpHidden"), "pill"));
if (v.websockets) {
const transports = ["H1"];
+42
View File
@@ -277,6 +277,45 @@ fn valid_relative_cache_path(value: &str) -> bool {
.all(|part| matches!(part, Component::Normal(_)))
}
/// Explicit, per-vhost-only request-side cache override. It never weakens the
/// response-side privacy guards (Set-Cookie/private/no-store/Vary rules).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct UnsafeForce {
pub enabled: bool,
pub targets: Vec<String>,
}
impl UnsafeForce {
pub fn validate(&self) -> Result<()> {
ensure!(self.targets.len() <= 32, "forced cache supports at most 32 target prefixes");
for target in &self.targets {
ensure!(
!target.is_empty()
&& target.len() <= 2048
&& target.starts_with('/')
&& !target.contains('#')
&& !target.chars().any(char::is_control),
"forced cache targets must be 1..2048 byte origin-form path/query prefixes"
);
let path = target.split('?').next().unwrap_or(target);
ensure!(crate::acl::routing_path(path)? == path, "forced cache target path must be normalized");
}
Ok(())
}
pub fn matches(&self, path: &str, target: &str) -> bool {
if !self.enabled {
return false;
}
self.targets.iter().any(|configured| {
if configured.contains('?') {
target.starts_with(configured)
} else {
crate::config::prefix_matches(configured, path)
}
})
}
}
/// Per-vhost behavior, not independent memory pools. None inherits the global
/// default; the global resident-byte/entry/object ceilings remain hard limits.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -294,9 +333,12 @@ pub struct Overrides {
pub expose_hits: Option<bool>,
pub expose_cache_status: Option<bool>,
pub expose_resident_age: Option<bool>,
/// Deliberately unavailable in the global cache configuration.
pub unsafe_force: UnsafeForce,
}
impl Overrides {
pub fn resolve(&self, global: &Settings) -> Result<Settings> {
self.unsafe_force.validate()?;
let mut result = global.clone();
// A vhost may narrow the global policy, but can never re-enable a cache
// that the administrator disabled globally.
+25
View File
@@ -92,6 +92,31 @@ fn pragma_no_cache(headers: &HeaderMap) -> bool {
.any(|token| token.trim().eq_ignore_ascii_case("no-cache"))
})
}
pub fn forced_request_policy(
method: &Method,
headers: &HeaderMap,
empty_body: bool,
) -> RequestPolicy {
if method != Method::GET && method != Method::HEAD {
return RequestPolicy::Bypass("request_method");
}
if !empty_body {
return RequestPolicy::Bypass("request_body");
}
if matches!(request_range(method, headers), RangeSelection::Unsupported) {
return RequestPolicy::Bypass("request_range_unsupported");
}
if headers.contains_key("if-range") && !headers.contains_key("range") {
return RequestPolicy::Bypass("request_precondition");
}
for key in ["if-match", "if-unmodified-since", "upgrade"] {
if headers.contains_key(key) {
return RequestPolicy::Bypass(if key == "upgrade" { "request_upgrade" } else { "request_precondition" });
}
}
RequestPolicy::Use
}
pub fn request_policy(
method: &Method,
headers: &HeaderMap,
+8
View File
@@ -3,6 +3,14 @@ impl Plan {
self.ttl.saturating_sub(self.initial_age.as_secs())
}
pub fn with_vary(mut self, name: &str) -> Self {
if !self.vary.iter().any(|value| value.eq_ignore_ascii_case(name)) {
self.vary.push(name.to_ascii_lowercase());
self.vary.sort();
}
self
}
pub fn without_vary(mut self, name: &str) -> Self {
self.vary.retain(|value| !value.eq_ignore_ascii_case(name));
self
+37
View File
@@ -97,3 +97,40 @@ mod scope_tests {
.is_err());
}
}
#[test]
fn unsafe_force_cache_is_per_vhost_and_matches_path_or_target_prefix() {
let force = UnsafeForce {
enabled: true,
targets: vec!["/convert?url=".into(), "/assets".into()],
};
force.validate().unwrap();
assert!(force.matches(
"/convert",
"/convert?url=https%3A%2F%2Fexample.test%2Flist.txt"
));
assert!(force.matches("/assets/app.js", "/assets/app.js?v=1"));
assert!(!force.matches("/api", "/api?url=x"));
assert!(UnsafeForce {
enabled: true,
targets: vec!["https://example.test/".into()],
}
.validate()
.is_err());
}
#[test]
fn forced_request_policy_keeps_protocol_safety_but_ignores_cookie_and_client_no_store() {
let mut headers = HeaderMap::new();
headers.insert("cookie", HeaderValue::from_static("session=abc"));
headers.insert("authorization", HeaderValue::from_static("Bearer token"));
headers.insert("cache-control", HeaderValue::from_static("no-store"));
assert_eq!(
forced_request_policy(&Method::GET, &headers, true),
RequestPolicy::Use
);
assert_eq!(
forced_request_policy(&Method::POST, &headers, true),
RequestPolicy::Bypass("request_method")
);
}
+44
View File
@@ -367,6 +367,50 @@ fn validate_route_rewrite(
Ok(())
}
fn validate_location_redirect(redirect: Option<&crate::vhost::LocationRedirect>) -> Result<Option<crate::config::CompiledLocationRedirect>> {
let Some(redirect) = redirect else {
return Ok(None);
};
ensure!(
matches!(redirect.status, 301 | 302 | 303 | 307 | 308),
"location redirect status must be 301, 302, 303, 307 or 308"
);
ensure!(
!redirect.target.is_empty()
&& redirect.target.len() <= 2048
&& !redirect.target.chars().any(char::is_control),
"invalid location redirect target"
);
// Replace capture references with a harmless segment so URL structure can
// be validated before requests reach the data plane.
let authority_end = redirect.target.find("://").and_then(|start| {
let rest = &redirect.target[start + 3..];
rest.char_indices().find(|(_, ch)| matches!(ch, '/' | '?' | '#')).map(|(offset, _)| start + 3 + offset).or(Some(redirect.target.len()))
}).ok_or_else(|| anyhow::anyhow!("location redirect target must be an absolute URL"))?;
ensure!(!redirect.target[..authority_end].contains('$'), "location redirect captures are not allowed in the URL authority");
let sample = regex::Regex::new(r"\$\{?[A-Za-z0-9_]+\}?")
.expect("static capture regex")
.replace_all(&redirect.target, "capture");
let parsed = url::Url::parse(&sample).context("location redirect target must be an absolute URL")?;
ensure!(matches!(parsed.scheme(), "http" | "https") && parsed.host_str().is_some(), "location redirect target must use http/https and include a host");
ensure!(parsed.username().is_empty() && parsed.password().is_none(), "location redirect target cannot contain credentials");
let rewrite_regex = match redirect.rewrite_pattern.as_deref() {
None | Some("") => None,
Some(pattern) => {
ensure!(pattern.len() <= 1024 && !pattern.chars().any(char::is_control), "invalid location redirect rewrite regex");
let mut builder = regex::RegexBuilder::new(pattern);
builder.size_limit(2 * 1024 * 1024).dfa_size_limit(2 * 1024 * 1024);
Some(builder.build().context("invalid location redirect rewrite regex")?)
}
};
Ok(Some(crate::config::CompiledLocationRedirect {
status: redirect.status,
target: redirect.target.clone(),
rewrite_regex,
preserve_query: redirect.preserve_query,
}))
}
fn validate_location_response(response: Option<&crate::vhost::LocationResponse>) -> Result<()> {
let Some(response) = response else {
return Ok(());
+12
View File
@@ -182,6 +182,14 @@ pub enum CompiledSource {
WebServer(Arc<crate::web_server::CompiledService>),
}
#[derive(Debug, Clone)]
pub struct CompiledLocationRedirect {
pub status: u16,
pub target: String,
pub rewrite_regex: Option<regex::Regex>,
pub preserve_query: bool,
}
#[derive(Debug, Clone)]
pub struct CompiledRoute {
pub backend_id: Option<String>,
@@ -195,6 +203,7 @@ pub struct CompiledRoute {
pub path_regex: Option<regex::Regex>,
pub rewrite: crate::vhost::PathRewrite,
pub response: Option<crate::vhost::LocationResponse>,
pub redirect: Option<CompiledLocationRedirect>,
pub source: CompiledSource,
pub backend: Option<Arc<CompiledBackend>>,
pub acl_profile_id: Option<String>,
@@ -213,6 +222,8 @@ pub struct CompiledFrontend {
pub websocket_rfc8441: bool,
pub websocket_rfc9220: bool,
pub force_ssl: bool,
/// Optional quick www/non-www canonical redirect (from, to).
pub www_redirect: Option<(String, String)>,
pub forward_client_ip: bool,
/// HTTPS target port used by Force SSL redirects. Port 443 wins when multiple TLS ports are selected.
pub https_redirect_port: Option<u16>,
@@ -242,6 +253,7 @@ pub struct CompiledFrontend {
pub security: crate::security::CompiledSecurity,
pub compression: crate::compression::Overrides,
pub cache: crate::cache::Settings,
pub force_cache: crate::cache::UnsafeForce,
pub transform: crate::transform::Settings,
pub pages: crate::pages::VhostPages,
pub proxy_timeouts: crate::vhost::ProxyTimeouts,
+27 -7
View File
@@ -16,6 +16,8 @@ fn compile_routing(
let mut pools = Vec::new();
let mut vhost_metrics = std::collections::BTreeMap::new();
for host in &mut config.vhosts {
let routing_hosts = host.routing_hosts()?;
let www_redirect = host.www_redirect_pair()?;
let host_bindings = host.frontend.resolved(&config.listeners)?;
let tls_ports: HashSet<u16> = host_bindings
.iter()
@@ -76,7 +78,7 @@ fn compile_routing(
"prefixed affinity cookies require TLS on every resolved frontend"
);
// Disabled vhosts also reserve their name, preventing fallback exposure.
for name in &host.frontend.hosts {
for name in &routing_hosts {
let claimed = claimed_hosts.entry(name.clone()).or_default();
for a in claimed.iter() {
for b in &host_bindings {
@@ -107,9 +109,22 @@ fn compile_routing(
let pool = if compiled_members.is_empty() {
None
} else {
let mut pool_settings = host.load_balancing.clone();
// Blank health-check Host means the first exact DNS name of the vhost.
// This is important when the backend URL is an IP shared by several vhosts.
if pool_settings.health_check.host.is_none()
&& pool_settings.health_check.mode == crate::balance::HealthMode::Http
{
pool_settings.health_check.host = host
.frontend
.hosts
.iter()
.find(|name| !name.starts_with("*.") && name.parse::<std::net::IpAddr>().is_err())
.cloned();
}
let mut pool = crate::balance::Pool::new(
host.id.clone(),
host.load_balancing.clone(),
pool_settings,
&host.backends,
compiled_members,
)?;
@@ -159,6 +174,7 @@ fn compile_routing(
websocket_rfc8441: host.websocket_rfc8441,
websocket_rfc9220: host.websocket_rfc9220,
force_ssl: host.force_ssl,
www_redirect,
forward_client_ip: host.forward_client_ip,
https_redirect_port,
http2_enabled: host.http2_enabled,
@@ -192,6 +208,7 @@ fn compile_routing(
security: host.security_headers.compile()?,
compression: host.compression.clone(),
cache: host.cache.resolve(&config.cache)?,
force_cache: host.cache.unsafe_force.clone(),
transform: host.transform.resolve(&config.transform)?,
pages: host.pages.clone(),
proxy_timeouts: host.proxy_timeouts.clone(),
@@ -229,6 +246,7 @@ fn compile_routing(
path_regex: None,
rewrite: crate::vhost::PathRewrite::default(),
response: None,
redirect: None,
source: fallback_source,
backend: fallback_backend,
acl_profile_id: None,
@@ -240,7 +258,7 @@ fn compile_routing(
headers: crate::headers::CompiledHeaders::default(),
unavailable: host.transient_unavailable,
};
for name in &host.frontend.hosts {
for name in &routing_hosts {
fallbacks
.entry(name.clone())
.or_default()
@@ -293,6 +311,7 @@ fn compile_routing(
);
validate_route_rewrite(route, path_regex.as_ref())?;
validate_location_response(route.response.as_ref())?;
let compiled_redirect = validate_location_redirect(route.redirect.as_ref())?;
let (route_profile_acl, route_profile_country_access) =
match route.acl_profile_id.as_deref() {
Some(id) => acl_profiles.get(id).cloned().ok_or_else(|| {
@@ -317,8 +336,8 @@ fn compile_routing(
}
}
if host.enabled && route.enabled {
let (source, route_pool, backend) = if route.response.is_some() {
// Local return is handled before source dispatch and needs no origin.
let (source, route_pool, backend) = if route.response.is_some() || route.redirect.is_some() {
// Local return/redirect is handled before source dispatch and needs no origin.
(CompiledSource::ReverseProxy, None, None)
} else {
match &route.source {
@@ -364,6 +383,7 @@ fn compile_routing(
path_regex,
rewrite: route.rewrite.clone(),
response: route.response.clone(),
redirect: compiled_redirect,
source,
backend,
acl_profile_id: route.acl_profile_id.clone(),
@@ -375,7 +395,7 @@ fn compile_routing(
headers,
unavailable: false,
};
for name in &host.frontend.hosts {
for name in &routing_hosts {
routes
.entry(name.clone())
.or_default()
@@ -384,7 +404,7 @@ fn compile_routing(
}
}
if host.enabled {
for name in &host.frontend.hosts {
for name in &routing_hosts {
frontends
.entry(name.clone())
.or_default()
+23
View File
@@ -261,6 +261,29 @@ where
}
impl CompiledRoute {
pub fn redirect_location(&self, path: &str, uri: &http::Uri) -> Result<Option<http::HeaderValue>> {
let Some(spec) = &self.redirect else {
return Ok(None);
};
let target = if let Some(regex) = &spec.rewrite_regex {
ensure!(regex.is_match(path), "location redirect rewrite regex did not match");
regex.replace(path, spec.target.as_str()).into_owned()
} else {
spec.target.clone()
};
let mut target = url::Url::parse(&target).context("invalid compiled location redirect target")?;
if spec.preserve_query {
if let Some(query) = uri.query() {
let merged = match target.query() {
Some(existing) if !existing.is_empty() => format!("{existing}&{query}"),
_ => query.to_owned(),
};
target.set_query(Some(&merged));
}
}
Ok(Some(http::HeaderValue::from_str(target.as_str()).context("invalid redirect Location header")?))
}
/// Return a normalized replacement path for the upstream hop. `None` means
/// preserve the client's exact origin-form path bytes.
pub fn rewritten_path(&self, path: &str) -> Result<Option<String>> {
+115
View File
@@ -205,3 +205,118 @@ fn legacy_startup_remote_administration_is_left_to_cli_risk_gate() {
assert!(c.administration.listen.is_none());
assert!(compile(c, 1, &p).is_ok());
}
#[test]
fn www_redirect_claims_companion_hostname_and_keeps_first_host_canonical() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["example.test".into()];
c.vhosts[0].www_redirect = true;
let snapshot = compile(c, 1, &policy()).unwrap();
assert!(snapshot.select("www.example.test", "/path").is_some());
let frontend = snapshot
.frontend("www.example.test", "127.0.0.1:8080".parse().unwrap(), false)
.unwrap();
assert_eq!(
frontend.www_redirect.as_ref(),
Some(&("www.example.test".into(), "example.test".into()))
);
}
#[test]
fn multiple_domains_share_vhost_and_selected_canonical_redirect() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec![
"suba.local".into(), "subb.local".into(), "site.test".into(),
];
c.vhosts[0].www_redirect = true;
c.vhosts[0].www_redirect_target = Some("site.test".into());
let snapshot = compile(c, 1, &policy()).unwrap();
for hostname in ["suba.local", "subb.local", "site.test", "www.site.test"] {
assert!(snapshot.select(hostname, "/same").is_some(), "{hostname}");
}
for hostname in ["suba.local", "subb.local", "www.site.test"] {
let frontend = snapshot.frontend(hostname, "127.0.0.1:8080".parse().unwrap(), false).unwrap();
assert_eq!(frontend.www_redirect.as_ref(),
Some(&("www.site.test".into(), "site.test".into())));
}
}
#[test]
fn implicit_www_target_is_claimed_and_other_domain_stays_independent() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["suba.local".into(), "subb.local".into()];
c.vhosts[0].www_redirect = true;
c.vhosts[0].www_redirect_target = Some("www.subb.local".into());
let snapshot = compile(c, 1, &policy()).unwrap();
for host in ["suba.local", "subb.local", "www.subb.local"] {
assert!(snapshot.select(host, "/path").is_some(), "{host}");
}
assert!(snapshot.select("www.suba.local", "/path").is_none());
let frontend = snapshot.frontend("subb.local", "127.0.0.1:8080".parse().unwrap(), false).unwrap();
assert_eq!(frontend.www_redirect.as_ref(),
Some(&(String::from("subb.local"), String::from("www.subb.local"))));
}
#[test]
fn selected_www_redirect_target_must_belong_to_vhost() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["suba.local".into()];
c.vhosts[0].www_redirect = true;
c.vhosts[0].www_redirect_target = Some("elsewhere.local".into());
assert!(compile(c, 1, &policy()).is_err());
}
#[test]
fn http_health_check_defaults_host_to_first_exact_vhost_dns_name() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["app.example.test".into()];
c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Http;
c.vhosts[0].load_balancing.health_check.host = None;
let snapshot = compile(c, 1, &policy()).unwrap();
assert_eq!(
snapshot.pools[0].settings.health_check.host.as_deref(),
Some("app.example.test")
);
}
#[test]
fn explicit_http_health_check_host_override_wins_over_vhost_hostname() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].frontend.hosts = vec!["app.example.test".into()];
c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Http;
c.vhosts[0].load_balancing.health_check.host = Some("health.example.test".into());
let snapshot = compile(c, 1, &policy()).unwrap();
assert_eq!(
snapshot.pools[0].settings.health_check.host.as_deref(),
Some("health.example.test")
);
}
#[test]
fn custom_location_redirect_supports_separate_capture_regex_and_query() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
c.vhosts[0].routes = vec![crate::vhost::PathRoute {
id: "archives".into(),
path_prefix: r"\.(?:tar\.gz|zip?)$".into(),
match_type: crate::vhost::PathMatch::RegexInsensitive,
response: None,
redirect: Some(crate::vhost::LocationRedirect {
status: 301,
target: "https://storage.example.test/o/$1".into(),
rewrite_pattern: Some(r"^/(.*)$".into()),
preserve_query: true,
}),
..Default::default()
}];
let snapshot = compile(c, 1, &policy()).unwrap();
let route = snapshot.select("localhost", "/repo/file.tar.gz").unwrap();
let uri: http::Uri = "/repo/file.tar.gz?download=1".parse().unwrap();
let location = route
.redirect_location("/repo/file.tar.gz", &uri)
.unwrap()
.unwrap();
assert_eq!(
location,
"https://storage.example.test/o/repo/file.tar.gz?download=1"
);
}
+1 -1
View File
@@ -41,7 +41,7 @@ use zeroize::Zeroizing;
#[command(
name = "rust-proxy",
version,
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.28.2"
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.29.0"
)]
struct Cli {
#[arg(
+1 -1
View File
@@ -239,7 +239,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
}
});
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
println!("RustProxy live logs 0.28.2 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
println!("RustProxy live logs 0.29.0 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
if interactive { print!("> "); io::stdout().flush()?; }
let mut filters = LiveLogFilters::default();
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();
+16
View File
@@ -41,6 +41,22 @@ mod tests {
assert!(response.extensions().get::<PageServed>().is_some());
}
#[tokio::test]
async fn static_vhost_page_can_serve_html_with_503_status() {
let page = Page {
kind: Kind::Html,
html: "<h1>Maintenance</h1>".into(),
status: Some(503),
};
page.validate().unwrap();
let response = page.serve(&Method::GET, StatusCode::OK);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
response.into_body().collect().await.unwrap().to_bytes(),
"<h1>Maintenance</h1>"
);
}
#[test]
fn builtin_gateway_pages_cover_edge_policy_and_gateway_errors() {
assert!(builtin_gateway_error(StatusCode::UNAUTHORIZED)
+40 -10
View File
@@ -6,6 +6,8 @@ if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
*exchange.route.lock().unwrap_or_else(|e| e.into_inner()) = Some(route.id.clone());
*exchange.source.lock().unwrap_or_else(|e| e.into_inner()) = if route.response.is_some() {
"local_response"
} else if route.redirect.is_some() {
"local_redirect"
} else {
match &route.source {
crate::config::CompiledSource::WebServer(_) => "web_server",
@@ -18,6 +20,17 @@ if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
if let Some(spec) = &route.response {
return local_location_response(spec, request.method());
}
if let Some(spec) = &route.redirect {
let location = match route.redirect_location(&path, request.uri()) {
Ok(Some(location)) => location,
_ => return error(StatusCode::INTERNAL_SERVER_ERROR, "RustProxy: invalid location redirect\n"),
};
let status = StatusCode::from_u16(spec.status).unwrap_or(StatusCode::FOUND);
let mut response = status.into_response();
response.headers_mut().insert(http::header::LOCATION, location);
response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store"));
return response;
}
if route.unavailable {
let mut response = error(
StatusCode::SERVICE_UNAVAILABLE,
@@ -178,10 +191,25 @@ let body_empty = if cache_enabled
} else {
request.body().is_end_stream()
};
let ignore_static_cookie = cache_settings.ignore_cookie_for_path(&path);
let public_suffix = request
.uri()
.path_and_query()
.map(|v| v.as_str())
.unwrap_or("/")
.to_owned();
let force_cache = policy_reason.is_none()
&& cache_settings.active_for(route.cache)
&& route
.frontend
.as_ref()
.is_some_and(|frontend| frontend.force_cache.matches(&path, &public_suffix));
let ignore_static_cookie = !force_cache && cache_settings.ignore_cookie_for_path(&path);
let original_policy = policy_reason
.map(crate::cache::RequestPolicy::Bypass)
.unwrap_or_else(|| {
if force_cache {
crate::cache::forced_request_policy(request.method(), request.headers(), body_empty)
} else {
crate::cache::request_policy(
request.method(),
request.headers(),
@@ -190,16 +218,11 @@ let original_policy = policy_reason
request.uri().query().is_some(),
cache_settings,
)
}
});
let only_cached =
cache_settings.active_for(route.cache) && crate::cache::only_if_cached(request.headers());
let public_suffix = request
.uri()
.path_and_query()
.map(|v| v.as_str())
.unwrap_or("/")
.to_owned();
let origin_suffix = match route.rewritten_path(&path) {
Ok(None) => public_suffix.clone(),
Ok(Some(rewritten)) => {
@@ -283,14 +306,20 @@ if websocket_legacy_support {
let range_selection = crate::cache::request_range(request.method(), request.headers());
let if_range = request.headers().get("if-range").cloned();
let range_requested = !matches!(range_selection, crate::cache::RangeSelection::None);
let post_policy = crate::cache::request_policy(
let post_policy = if force_cache {
crate::cache::forced_request_policy(request.method(), request.headers(), body_empty)
} else {
crate::cache::request_policy(
request.method(),
request.headers(),
body_empty,
&path,
request.uri().query().is_some(),
cache_settings,
);
)
};
let force_vary_cookie = force_cache && request.headers().contains_key("cookie");
let force_vary_authorization = force_cache && request.headers().contains_key("authorization");
let effective_policy = match (original_policy, post_policy) {
(crate::cache::RequestPolicy::Bypass(reason), _) => {
crate::cache::RequestPolicy::Bypass(reason)
@@ -353,7 +382,8 @@ let pending_revalidation = revalidation_reason.is_some()
let miss_recorded = false;
(
backend, backend_lease, affinity_cookie, admin_control_plane, cache_settings,
transform_settings, scope, ignore_static_cookie, only_cached, public_suffix,
transform_settings, scope, ignore_static_cookie, force_cache, force_vary_cookie,
force_vary_authorization, only_cached, public_suffix,
origin_suffix, uri, exchange, lease, stop, downstream, h2_downstream,
range_selection, if_range, range_requested, effective_reason, revalidation_reason,
cache_ticket, client_conditionals, cached_revalidation, freshness_revalidation_reason,
+1 -1
View File
@@ -117,7 +117,7 @@ let reason = effective_reason
if cache_ticket.is_some() && transformed_plan.is_none() {
"response_headers_policy"
} else if cache_ticket.is_some() {
"cache_miss"
if force_cache { "cache_miss_forced" } else { "cache_miss" }
} else {
"configuration_changed"
}
+4 -1
View File
@@ -118,7 +118,10 @@ let origin_plan = if cache_ticket.is_some()
response.headers(),
cache_settings,
upstream_started.elapsed(),
)
).map(|plan| {
let plan = if force_vary_cookie { plan.with_vary("cookie") } else { plan };
if force_vary_authorization { plan.with_vary("authorization") } else { plan }
})
} else {
None
};
+8
View File
@@ -146,6 +146,11 @@ async fn forward(
{
return response;
}
if let Some(response) = frontend.and_then(|frontend| {
canonical_host_redirect(frontend, &host, tls, local.0.port(), request.uri())
}) {
return response;
}
if !tls {
if let Some(response) =
frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri()))
@@ -378,6 +383,9 @@ async fn forward(
transform_settings,
scope,
ignore_static_cookie,
force_cache,
force_vary_cookie,
force_vary_authorization,
only_cached,
public_suffix,
origin_suffix,
+1
View File
@@ -1,6 +1,7 @@
include!("types.rs");
include!("cache_helpers.rs");
include!("upstream_helpers.rs");
include!("redirect_helpers.rs");
include!("forward_entry.rs");
#[cfg(test)]
+66
View File
@@ -0,0 +1,66 @@
// Frontend redirect helpers.
/// Build a same-host HTTPS redirect while preserving the request path and query.
fn force_ssl_location(host: &str, uri: &Uri, port: u16) -> Option<HeaderValue> {
let path = uri
.path_and_query()
.map(|value| value.as_str())
.unwrap_or("/");
let location = if port == 443 {
format!("https://{host}{path}")
} else {
format!("https://{host}:{port}{path}")
};
HeaderValue::from_str(&location).ok()
}
fn canonical_host_redirect(
frontend: &crate::config::CompiledFrontend,
host: &str,
tls: bool,
local_port: u16,
uri: &Uri,
) -> Option<Response> {
let (from, to) = frontend.www_redirect.as_ref()?;
if !host.eq_ignore_ascii_case(from) {
return None;
}
let (scheme, port) = if tls {
("https", local_port)
} else if frontend.force_ssl {
("https", frontend.https_redirect_port?)
} else {
("http", local_port)
};
let path = uri.path_and_query().map(|value| value.as_str()).unwrap_or("/");
let default_port = (scheme == "https" && port == 443) || (scheme == "http" && port == 80);
let location = if default_port {
format!("{scheme}://{to}{path}")
} else {
format!("{scheme}://{to}:{port}{path}")
};
let mut response = StatusCode::PERMANENT_REDIRECT.into_response();
response.headers_mut().insert(http::header::LOCATION, HeaderValue::from_str(&location).ok()?);
response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store"));
Some(response)
}
fn force_ssl_redirect(
frontend: &crate::config::CompiledFrontend,
host: &str,
uri: &Uri,
) -> Option<Response> {
if !frontend.force_ssl {
return None;
}
let location = force_ssl_location(host, uri, frontend.https_redirect_port?)?;
let mut response = StatusCode::PERMANENT_REDIRECT.into_response();
response
.headers_mut()
.insert(http::header::LOCATION, location);
// Do not pin a redirect in a browser after an operator later disables Force SSL.
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
Some(response)
}
+10
View File
@@ -297,3 +297,13 @@ mod authority_tests {
);
}
}
#[test]
fn upstream_uri_preserves_nested_url_query_without_rejecting_valid_separators() {
let config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap();
let backend = snapshot.select("localhost", "/").unwrap().backend.as_ref().unwrap();
let suffix = "/convert?url=https%3A//easylist.to/easylist/easylist.txt&ip=0.0.0.0";
let uri = upstream_uri(backend, suffix).unwrap();
assert_eq!(uri.path_and_query().unwrap().as_str(), suffix);
}
-34
View File
@@ -420,40 +420,6 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
))
})
}
/// Build a same-host HTTPS redirect while preserving the request path and query.
fn force_ssl_location(host: &str, uri: &Uri, port: u16) -> Option<HeaderValue> {
let path = uri
.path_and_query()
.map(|value| value.as_str())
.unwrap_or("/");
let location = if port == 443 {
format!("https://{host}{path}")
} else {
format!("https://{host}:{port}{path}")
};
HeaderValue::from_str(&location).ok()
}
fn force_ssl_redirect(
frontend: &crate::config::CompiledFrontend,
host: &str,
uri: &Uri,
) -> Option<Response> {
if !frontend.force_ssl {
return None;
}
let location = force_ssl_location(host, uri, frontend.https_redirect_port?)?;
let mut response = StatusCode::PERMANENT_REDIRECT.into_response();
response
.headers_mut()
.insert(http::header::LOCATION, location);
// Do not pin a redirect in a browser after an operator later disables Force SSL.
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
Some(response)
}
/// HTTP/2 and HTTP/3 authority is a pseudo-header, not necessarily a Host field.
/// HTTP/1 absolute-form remains forbidden: this is not a forward proxy.
fn effective_host(request: &Request, tls: bool) -> Result<HeaderValue, ()> {
+2 -1
View File
@@ -283,7 +283,8 @@ pub fn for_all_frontends(
};
for vhost in vhosts {
if let Some(id) = &vhost.certificate_id {
assign(&vhost.frontend.hosts, id, "vhost")?;
let hosts = vhost.routing_hosts()?;
assign(&hosts, id, "vhost")?;
}
}
for redirect in redirects {
+1
View File
@@ -2,6 +2,7 @@
//! A Host header is never evidence that traffic arrived on a private interface.
include!("vhost/model.rs");
include!("vhost/redirect.rs");
include!("vhost/validation.rs");
#[cfg(test)]
include!("vhost/tests.rs");
+10 -1
View File
@@ -193,7 +193,6 @@ impl Default for LocationResponse {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Source {
@@ -268,6 +267,13 @@ pub struct Vhost {
/// Redirect cleartext requests for this vhost to the selected HTTPS frontend.
#[serde(default)]
pub force_ssl: bool,
/// Redirect the www/non-www companion to a selected exact hostname.
#[serde(default)]
pub www_redirect: bool,
/// Canonical hostname for the quick www redirect. None preserves the legacy
/// first-hostname behavior. Other vhost hostnames remain independent aliases.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub www_redirect_target: Option<String>,
/// Forward the effective client IP to the application using Forwarded/X-Forwarded-For/X-Real-IP.
/// Disable only for applications that must see the RustProxy transport peer instead.
#[serde(default = "yes")]
@@ -483,4 +489,7 @@ pub struct PathRoute {
/// Nginx-style local `return`: when present no backend connection is opened.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response: Option<LocationResponse>,
/// Local HTTP redirect; mutually exclusive with `response`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub redirect: Option<LocationRedirect>,
}
+34
View File
@@ -0,0 +1,34 @@
// Custom-location redirect model.
fn redirect_status() -> u16 {
302
}
fn preserve_redirect_query() -> bool {
true
}
/// Local redirect action for a custom location. `rewrite_pattern` is optional;
/// when present it is applied to the normalized request path and supports the
/// Rust regex replacement syntax (`$1`, `${name}`, ...).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct LocationRedirect {
#[serde(default = "redirect_status")]
pub status: u16,
pub target: String,
#[serde(default)]
pub rewrite_pattern: Option<String>,
#[serde(default = "preserve_redirect_query")]
pub preserve_query: bool,
}
impl Default for LocationRedirect {
fn default() -> Self {
Self {
status: redirect_status(),
target: String::new(),
rewrite_pattern: None,
preserve_query: preserve_redirect_query(),
}
}
}
+76 -1
View File
@@ -15,6 +15,7 @@ impl Default for PathRoute {
headers: HeaderRules::default(),
rewrite: PathRewrite::default(),
response: None,
redirect: None,
}
}
}
@@ -40,6 +41,7 @@ impl PathRoute {
&& self.rewrite.mode == RewriteMode::Preserve
&& self.rewrite.value.is_empty()
&& self.response.is_none()
&& self.redirect.is_none()
}
}
impl Vhost {
@@ -71,6 +73,22 @@ impl Vhost {
*host = crate::config::normalize_host_pattern(host)?;
ensure!(hosts.insert(host.clone()), "duplicate vhost hostname");
}
if let Some(target) = &mut self.www_redirect_target {
*target = crate::config::normalize_host_pattern(target)?;
ensure!(
!target.starts_with("*.") && target.parse::<std::net::IpAddr>().is_err(),
"www redirect target must be an exact DNS hostname"
);
let partner = www_partner(target)?;
ensure!(
self.frontend.hosts.contains(target) || self.frontend.hosts.contains(&partner),
"www redirect target or its www/non-www partner must belong to this vhost"
);
}
if self.www_redirect {
let _ = self.www_redirect_pair()?;
let _ = self.routing_hosts()?;
}
if let Some(id) = &self.acl_profile_id {
ensure!(crate::config::valid_id(id), "invalid ACL profile id");
}
@@ -86,7 +104,10 @@ impl Vhost {
self.source.validate()?;
let needs_proxy = self.source.is_reverse_proxy()
|| self.routes.iter().any(|route| {
route.enabled && route.response.is_none() && route.source.is_reverse_proxy()
route.enabled
&& route.response.is_none()
&& route.redirect.is_none()
&& route.source.is_reverse_proxy()
});
ensure!(
self.backends.len() <= 32,
@@ -121,6 +142,10 @@ impl Vhost {
self.routes.retain(|route| !route.is_legacy_main_fallback());
for route in &mut self.routes {
route.source.validate()?;
ensure!(
route.response.is_none() || route.redirect.is_none(),
"location cannot define both a local response and a redirect"
);
if let Some(id) = &route.acl_profile_id {
ensure!(crate::config::valid_id(id), "invalid location ACL profile id");
}
@@ -224,6 +249,56 @@ pub fn validate_listener(listener: Listener) -> Result<()> {
);
Ok(())
}
/// Return the other side of a www/non-www redirect pair. The target and its
/// counterpart must both be routable, even if only one was configured.
fn www_partner(host: &str) -> Result<String> {
if let Some(bare) = host.strip_prefix("www.") {
ensure!(!bare.is_empty() && bare.parse::<std::net::IpAddr>().is_err(),
"www redirect requires a DNS hostname");
crate::config::normalize_host(bare)
} else {
crate::config::normalize_host(&format!("www.{host}"))
}
}
impl Vhost {
fn www_canonical_host(&self) -> Result<&str> {
let canonical = self.www_redirect_target.as_deref()
.or_else(|| self.frontend.hosts.first().map(String::as_str))
.ok_or_else(|| anyhow::anyhow!("vhost requires at least one hostname"))?;
ensure!(!canonical.starts_with("*.") && canonical.parse::<std::net::IpAddr>().is_err(),
"www redirect requires an exact DNS hostname");
let partner = www_partner(canonical)?;
ensure!(self.frontend.hosts.iter().any(|host| host == canonical || host == &partner),
"www redirect target or its www/non-www partner must belong to this vhost");
Ok(canonical)
}
/// Hostnames owned by the vhost at runtime. Both the selected canonical
/// host and its www/non-www partner are claimed, even if one is implicit.
pub fn routing_hosts(&self) -> Result<Vec<String>> {
let mut hosts = self.frontend.hosts.clone();
if !self.www_redirect {
return Ok(hosts);
}
let canonical = self.www_canonical_host()?.to_owned();
let companion = www_partner(&canonical)?;
for name in [canonical, companion] {
if !hosts.contains(&name) {
hosts.push(name);
}
}
Ok(hosts)
}
pub fn www_redirect_pair(&self) -> Result<Option<(String, String)>> {
if !self.www_redirect {
return Ok(None);
}
let canonical = self.www_canonical_host()?.to_owned();
let companion = www_partner(&canonical)?;
Ok(Some((companion, canonical)))
}
}
impl Frontend {
pub fn resolved(&self, settings: &crate::settings::Listeners) -> Result<Vec<Listener>> {
let mut result = Vec::new();
+1 -1
View File
@@ -159,7 +159,7 @@ pub(super) fn fastcgi_params(
if context.tls { "on" } else { "off" }.into(),
);
params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.28.2".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.29.0".into());
params.insert("REDIRECT_STATUS".into(), "200".into());
if let Some(value) = headers
.get(CONTENT_TYPE)
+1 -1
View File
@@ -185,7 +185,7 @@ pub(super) fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec<u
"<a class=\"{class}\" href=\"{href}\"><span class=\"type\">{type_label}</span><span class=\"name\">{label}{slash}</span><span class=\"kind\">{kind}</span></a>"
));
}
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.28.2</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.29.0</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
html.into_bytes()
}
+1 -1
View File
@@ -202,7 +202,7 @@ async fn empty_index_can_go_directly_to_directory_listing() {
let body = std::str::from_utf8(&body).unwrap();
assert!(body.contains("file.txt"));
assert!(body.contains("DIRECTORY LISTING"));
assert!(body.contains("RustProxy <b>0.28.2</b>"));
assert!(body.contains("RustProxy <b>0.29.0</b>"));
}
#[tokio::test]
+39 -10
View File
@@ -24,7 +24,7 @@ def read_docker_agent_source() -> str:
preflight(root)
manifest = tomllib.loads((root / "Cargo.toml").read_text())
assert manifest["package"]["version"] == "0.28.2"
assert manifest["package"]["version"] == "0.29.0"
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.28.2"' in lock
assert 'name = "rust-proxy"\nversion = "0.29.0"' 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.28.2",
"CHANGELOG.md": "## 0.28.2",
"ROADMAP.md": "## 0.28.2",
"AGENTS.md": "Current source version: 0.28.2.",
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.28.2",
"README.md": "# RustProxy 0.29.0",
"CHANGELOG.md": "## 0.29.0",
"ROADMAP.md": "## 0.29.0",
"AGENTS.md": "Current source version: 0.29.0.",
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.29.0",
}.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.28.2"', '"/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.0"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
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.28.2"' in openapi
assert '"RuntimeInformation"' in openapi and '"version": "0.29.0"' in openapi
# 0.24.1 compiler and hostname-cardinality regressions.
tls_0241 = rust_source(root, "src/tls.rs")
@@ -1518,6 +1518,35 @@ assert 'listener-active-item' in runtime_web_0280 and 'listener-active-feature'
assert 'cache-via-enabled' in cache_web_0280 and 'via_header:' in cache_web_0280
assert 'location-header-direction location-header-editor' in vhost_core_0280
# 0.29.0 redirect/health/cache/gzip-log contracts.
vhost_model_0290 = rust_source(root, "src/vhost/model.rs")
vhost_validation_0290 = rust_source(root, "src/vhost/validation.rs")
config_model_0290 = rust_source(root, "src/config/model.rs")
config_routing_0290 = rust_source(root, "src/config/routing.rs")
proxy_redirect_0290 = rust_source(root, "src/proxy/redirect_helpers.rs")
route_cache_0290 = rust_source(root, "src/proxy/forward/route_cache.rs")
cache_entry_0290 = rust_source(root, "src/cache/model/entry.rs")
cache_request_0290 = rust_source(root, "src/cache/policy/request.rs")
file_logs_0290 = rust_source(root, "src/admin/file_logs.rs")
file_logs_io_0290 = rust_source(root, "src/admin/file_logs_io.rs")
for marker_0290 in ['www_redirect', 'redirect: Option<LocationRedirect>']:
assert marker_0290 in vhost_model_0290, marker_0290
for marker_0290 in ['routing_hosts', 'www_redirect_pair']:
assert marker_0290 in vhost_validation_0290, marker_0290
assert 'www_redirect: Option<(String, String)>' in config_model_0290
assert 'health_check.host.is_none()' in config_routing_0290 and 'HealthMode::Http' in config_routing_0290
assert 'canonical_host_redirect' in proxy_redirect_0290 and 'PERMANENT_REDIRECT' in proxy_redirect_0290
assert 'redirect_location' in rust_source(root, "src/config/snapshot.rs") and 'local_redirect' in route_cache_0290
assert 'pub struct UnsafeForce' in cache_entry_0290 and 'unsafe_force: UnsafeForce' in cache_entry_0290
assert 'forced_request_policy' in cache_request_0290 and 'cache_miss_forced' in rust_source(root, "src/proxy/mod.rs")
assert 'compressed: bool' in file_logs_0290 and 'GzDecoder' in file_logs_io_0290
for path_0290 in ['src/vhost/redirect.rs', 'docs/V0.29.0_IMPLEMENTATION.md']:
assert (root / path_0290).is_file(), path_0290
assert 'static_vhost_page_can_serve_html_with_503_status' in rust_source(root, "src/pages.rs")
assert 'upstream_uri_preserves_nested_url_query_without_rejecting_valid_separators' in rust_source(root, "src/proxy/mod.rs")
assert '"www_redirect"' in openapi and '"unsafe_force"' in openapi
assert '"redirect"' in openapi and 'Backend-less client redirect for this location.' in openapi
# 0.28.2 external-certificate ACL/path hardening and least-privilege Certificate API.
tls_types_0282 = (root / "src/tls/types.rs").read_text()
tls_external_0282 = (root / "src/tls/external_paths.rs").read_text()
@@ -1578,5 +1607,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.28.2 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.0 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.")
+6 -5
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Rust-independent documentation/Reader consistency checks for the 0.28.2 release."""
"""Rust-independent documentation/Reader consistency checks for the 0.29.0 release."""
from pathlib import Path
import re
from rust_source import rust_source
@@ -14,6 +14,7 @@ required = [
DOCS / "README.md",
DOCS / "WEB_SERVER.md",
DOCS / "TODO.md",
DOCS / "V0.29.0_IMPLEMENTATION.md",
DOCS / "V0.28.2_IMPLEMENTATION.md",
DOCS / "V0.28.1_IMPLEMENTATION.md",
DOCS / "V0.27.3_IMPLEMENTATION.md",
@@ -62,16 +63,16 @@ 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.28.2")
assert readme.startswith("# RustProxy 0.29.0")
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.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.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.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"]:
for release in ["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"]:
assert f"## {release}" in changelog, release
assert changelog.index("## 0.28.2") < changelog.index("## 0.28.1") < changelog.index("## 0.28.0") < changelog.index("## 0.27.3") < changelog.index("## 0.27.2") < changelog.index("## 0.27.1") < changelog.index("## 0.27.0") < changelog.index("## 0.26.7") < changelog.index("## 0.26.6") < changelog.index("## 0.26.5") < changelog.index("## 0.26.4") < changelog.index("## 0.26.1") < changelog.index("## 0.26.0") < changelog.index("## 0.25.6") < changelog.index("## 0.25.5")
assert changelog.index("## 0.29.0") < changelog.index("## 0.28.2") < changelog.index("## 0.28.1") < changelog.index("## 0.28.0") < changelog.index("## 0.27.3") < changelog.index("## 0.27.2") < changelog.index("## 0.27.1") < changelog.index("## 0.27.0") < changelog.index("## 0.26.7") < changelog.index("## 0.26.6") < changelog.index("## 0.26.5") < changelog.index("## 0.26.4") < changelog.index("## 0.26.1") < changelog.index("## 0.26.0") < changelog.index("## 0.25.6") < changelog.index("## 0.25.5")
assert "CHANGELOG_TECHNICAL_ARCHIVE.md" in changelog
for developer_only in ["Clean the remaining compiler warnings", "Update package/source/UI/OpenAPI/release identity", "source regressions and update release identity"]:
assert developer_only not in changelog, f"developer-only CHANGELOG detail: {developer_only}"
+2 -2
View File
@@ -75,10 +75,10 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/proxy/websocket.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/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.28.2 source tree: missing {path}")
raise ValueError(f"Incomplete RustProxy 0.29.0 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.28.2 entry point; extract the complete package, not individual files.")
raise ValueError("src/lib.rs is not the 0.29.0 entry point; extract the complete package, not individual files.")
return messages
+5 -5
View File
@@ -135,7 +135,7 @@ def upgrade_smoke(tmp: Path) -> None:
assert backup.is_dir()
assert (backup / "src/obsolete.rs").is_file()
assert not (target / "src/obsolete.rs").exists()
assert 'version = "0.28.2"' in (target / "Cargo.toml").read_text(encoding="utf-8")
assert 'version = "0.29.0"' 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.28.2"
assert manifest["to_version"] == "0.29.0"
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.28.2"' in openapi_0241
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.29.0"' 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.28.2"', '"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.0"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
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.28.2 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.0 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__":
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env node
// Exercise the real vhost WWW-routing editor code with a small DOM stand-in.
// This is a browser-UI state test, not a Rust request-routing test.
import fs from 'node:fs';
import vm from 'node:vm';
const editor = fs.readFileSync('src/admin/web/vhosts/editor.js', 'utf8');
const first = editor.indexOf(' const wwwPanel = el("section", undefined, "vh-wide vh-www-panel");');
const last = editor.indexOf(' field(g, "forward-client-ip",', first);
if (first === -1 || last === -1) throw new Error('WWW editor component missing');
const source = editor.slice(first, last);
if (editor.indexOf('readHosts = hostTagEditor(g, value.frontend?.hosts') < editor.indexOf('g = group("vhFrontends")')) {
throw new Error('Host chips must be in the same Frontends tab as WWW routing');
}
class ClassList {
constructor() { this.names = new Set(); }
add(name) { this.names.add(name); }
toggle(name, active) { if (active) this.names.add(name); else this.names.delete(name); }
contains(name) { return this.names.has(name); }
}
class Element {
constructor(tag = 'div', text = '', css = '') {
this.tag = tag;
this.textContent = text || '';
this.classList = new ClassList();
if (css) for (const name of css.split(' ')) this.classList.add(name);
this.children = [];
this.hidden = false;
this.checked = false;
this.value = '';
this.attrs = {};
this.listeners = new Map();
}
append(...children) { for (const child of children) { child.parent = this; this.children.push(child); } }
prepend(...children) { for (const child of [...children].reverse()) { child.parent = this; this.children.unshift(child); } }
replaceChildren(...children) { this.children = []; this.append(...children); }
closest(tag) { let node = this.parent; while (node && node.tag !== tag) node = node.parent; return node; }
setAttribute(key, value) { this.attrs[key] = String(value); }
addEventListener(event, listener) { this.listeners.set(event, listener); }
dispatch(event) { this.listeners.get(event)?.(); }
}
const el = (tag, content, css) => new Element(tag, content, css);
const t = key => key === 'vhWwwSharedCount' ? '{count} names / one vhost' : key;
const text = (tag, key, css) => el(tag, t(key), css);
const g = new Element();
const hosts = new Element('textarea');
hosts.value = 'suba.local\nsubb.local\n*.local';
const read = name => { if (name !== 'hosts') throw new Error(name); return hosts; };
const lines = raw => raw.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
let changes = 0;
const changed = () => { changes++; };
const value = { www_redirect: false, www_redirect_target: 'subb.local' };
const document = { createElement: tag => new Element(tag) };
const field = (parent, name, key, initial, type) => {
const label = new Element('label');
const input = new Element('input');
input.name = name;
input.type = type;
input.checked = Boolean(initial);
label.append(input, text('span', key));
parent.append(label);
return input;
};
let refreshCanonical = () => {};
const context = { el, text, field, g, t, value, read, lines, changed, document, refreshCanonical };
const component = vm.runInNewContext(`(() => { ${source}\nreturn { wwwPanel, wwwRedirect, wwwTarget, wwwChoices, wwwDetails, wwwInactive, wwwOther, wwwNoHosts, wwwHostCount, syncCanonical }; })()`, context);
const check = (condition, message) => { if (!condition) throw new Error(message); };
const choice = (baseIndex, directionIndex) => component.wwwChoices.children[baseIndex].children[1].children[directionIndex];
const route = (baseIndex, directionIndex) => choice(baseIndex, directionIndex).children[1].children[1].textContent;
check(!component.wwwDetails.hidden && !component.wwwInactive.hidden, 'Direction preview should be visible even when redirect is disabled');
check(component.wwwTarget.value === 'subb.local', 'Saved canonical domain was lost');
check(component.wwwChoices.children.length === 2, 'Wildcard must not become a canonical pair');
check(component.wwwHostCount.textContent === '3 names / one vhost', 'Shared host count not updated');
check(route(0, 0) === 'www.suba.local → suba.local', 'Incorrect www -> non-www preview');
check(route(0, 1) === 'suba.local → www.suba.local', 'Incorrect non-www -> www preview');
check(route(1, 0) === 'www.subb.local → subb.local', 'Incorrect second domain preview');
check(choice(1, 0).classList.contains('is-selected'), 'Saved target not visibly selected');
component.wwwRedirect.checked = true;
component.wwwRedirect.dispatch('change');
check(!component.wwwDetails.hidden && component.wwwInactive.hidden, 'Switch did not reveal WWW routing choices');
check(component.wwwRedirect.attrs['aria-expanded'] === 'true', 'Toggle accessibility state not updated');
check(component.wwwOther.children.some(child => child.textContent === 'suba.local'), 'Non-canonical alias must remain available');
check(component.wwwOther.children.some(child => child.textContent === '*.local'), 'Wildcard alias must remain available');
choice(0, 1).children[0].checked = true;
choice(0, 1).children[0].dispatch('change');
check(component.wwwTarget.value === 'www.suba.local', 'WWW destination selected without a separately declared www host was lost');
check(choice(0, 1).classList.contains('is-selected'), 'Selected direction not styled');
check(changes === 2, 'Toggle and radio change did not mark editor dirty');
hosts.value = 'subb.local\nwww.subb.local\n*.local';
context.refreshCanonical();
check(component.wwwTarget.value === 'subb.local', 'Deleting selected domain did not choose a valid fallback');
check(component.wwwChoices.children.length === 1, 'Duplicate base and www domains should be one pair');
check(route(0, 1) === 'subb.local → www.subb.local', 'Wrong preview for canonical www host');
choice(0, 1).children[0].checked = true;
choice(0, 1).children[0].dispatch('change');
check(component.wwwTarget.value === 'www.subb.local', 'Explicit WWW target not serialized');
check(component.wwwOther.children.every(child => child.textContent !== 'www.subb.local'), 'Redirected companion must not appear as a non-redirected alias');
hosts.value = '*.local';
context.refreshCanonical();
check(component.wwwNoHosts.hidden === false && component.wwwTarget.value === '', 'Wildcard-only configurations need a useful empty state');
check(component.wwwRedirect.disabled && !component.wwwRedirect.checked, 'Wildcard-only configuration must disable redirect');
console.log('PASS: WWW-routing UI two-way choices, implicit www target, shared aliases, dynamic host edits, wildcard exclusion and empty state.');
+1 -1
View File
@@ -96,7 +96,7 @@ need(web,
'HeaderValue::from_static("no-store")',
"builtin_error_response(StatusCode::FORBIDDEN, &method)",
"empty_index_can_go_directly_to_directory_listing",
"RustProxy <b>0.28.2</b>",
"RustProxy <b>0.29.0</b>",
"traversal_and_symlink_escape_are_not_served",
"try_files_rewrites_cache_rules_headers_and_downloads_work",
"precompressed_sidecars_keep_mime_validators_and_range_semantics",
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import fs from "node:fs";
import vm from "node:vm";
// Exercise the actual domain-choice sync function from the vhost editor with
// DOM stand-ins. This does not replace browser or Rust runtime acceptance.
const source = fs.readFileSync("src/admin/web/vhosts/editor.js", "utf8");
const start = source.indexOf(" const syncCanonical = () => {");
const end = source.indexOf(" refreshCanonical = syncCanonical;", start);
assert.ok(start >= 0 && end > start, "WWW selection logic must remain discoverable");
const selectedLogic = source.slice(start, end);
class Element {
constructor(tag = "", body = "", className = "") {
this.tag = tag;
this.textContent = body ?? "";
this.className = className;
this.children = [];
this.listeners = {};
this.checked = false;
this.disabled = false;
this.hidden = false;
this.value = "";
this.attrs = new Map();
this.classes = new Set();
this.classList = {
add: name => this.classes.add(name),
toggle: (name, active) => active ? this.classes.add(name) : this.classes.delete(name),
};
}
append(...children) { this.children.push(...children); }
replaceChildren(...children) { this.children = [...children]; }
setAttribute(key, value) { this.attrs.set(key, String(value)); }
addEventListener(event, callback) { this.listeners[event] = callback; }
fire(event) { this.listeners[event]?.(); }
}
function editor(hosts, previous = "", checked = false) {
const dom = Object.fromEntries([
"wwwTarget", "wwwChoices", "wwwOther", "wwwNoHosts", "wwwDetails", "wwwInactive", "wwwRedirect", "wwwPanel", "wwwHostCount"
].map(id => [id, new Element()]));
dom.wwwRedirect.checked = checked;
let changes = 0;
const bindings = {
...dom,
document: { createElement: tag => new Element(tag) },
el: (tag, content, className) => new Element(tag, content, className),
text: (_tag, key) => new Element("span", key),
t: key => key === "vhWwwSharedCount" ? "{count} names / one vhost" : key,
read: key => { assert.equal(key, "hosts"); return { value: hosts.join("\n") }; },
lines: value => value.split(/[\n,]+/).filter(Boolean),
changed: () => { changes++; },
};
const code = `let selectedCanonical = ${JSON.stringify(previous)};\n${selectedLogic}\nthis.run = () => syncCanonical();\nthis.getSelected = () => selectedCanonical;`;
const runtime = vm.createContext(bindings);
vm.runInContext(code, runtime, { filename: "editor-www-sync.js" });
runtime.run();
return { runtime, dom, countChanges: () => changes };
}
const result = editor(["suba.local", "subb.local"], "www.subb.local", false);
assert.equal(result.dom.wwwDetails.hidden, false, "destination cards must be visible while redirect is disabled");
assert.equal(result.dom.wwwTarget.value, "www.subb.local", "www target must survive editor re-opening");
assert.equal(result.dom.wwwChoices.children.length, 2, "unrelated domains must remain distinct groups");
assert.equal(result.dom.wwwHostCount.textContent, "2 names / one vhost");
const aChoices = result.dom.wwwChoices.children[0].children[1].children;
assert.equal(aChoices.length, 2, "each domain must offer both www and non-www targets");
assert.equal(aChoices[0].children[0].value, "suba.local");
assert.equal(aChoices[1].children[0].value, "www.suba.local");
assert.ok(aChoices[1].children[1].children[1].textContent.includes("suba.local"), "preview must show direction");
// Select www destination for a different host, without editing hostname chips.
aChoices[1].children[0].checked = true;
aChoices[1].children[0].fire("change");
assert.equal(result.dom.wwwTarget.value, "www.suba.local");
assert.equal(result.countChanges(), 1);
assert.ok(result.dom.wwwOther.children.some(child => child.textContent === "subb.local"), "other alias remains in vhost");
result.dom.wwwRedirect.checked = true;
result.runtime.run();
assert.equal(result.dom.wwwInactive.hidden, true);
assert.ok(result.dom.wwwPanel.classes.has("is-enabled"));
const empty = editor(["*.example.test"], "", true);
assert.equal(empty.dom.wwwRedirect.disabled, true, "wildcard-only vhost cannot enable WWW canonicalization");
assert.equal(empty.dom.wwwRedirect.checked, false);
const canonical = editor(["www.example.test"], "example.test", true);
assert.equal(canonical.dom.wwwTarget.value, "example.test", "bare target may be implicit when only www host is configured");
assert.equal(canonical.dom.wwwChoices.children.length, 1);
console.log("PASS: WWW destination cards, both directions, disabled preview, live update, aliases and wildcard guard.");