This commit is contained in:
Mateusz Gruszczyński committed 2026-10-11 13:56:30 +02:00
1 parent 95c55da2ee
commit d942c1a35d
42 files changed
+1259 -103

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. 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 The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for
compatibility. Current source version: 0.31.3. compatibility. Current source version: 0.31.4.
## Delivery ## Delivery
+7
View File
@@ -3,6 +3,13 @@
> Operator-facing release history. Historical engineering notes are kept in > Operator-facing release history. Historical engineering notes are kept in
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md). > [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
## 0.31.4
- Correct the administration panel's handling of intermittent `auth.invalid_origin` with optional explicit HTTPS `administration.public_origin` when behind a trusted reverse proxy. Allow a bounded, HTTPS-first multi-hop forwarded-proto chain without trusting arbitrary forwarding headers.
- Use a single public panel URL: automatically include its hostname in the panel's effective Host allowlist. Keep `allowed_hosts` as optional **additional** DNS entries in an advanced section and deduplicate legacy repeated values on edit/save without an automatic database migration.
- Preserve strict Host, forwarded-Host, Origin, Fetch Metadata, CSRF and trusted-proxy checks; keep public proxy routes and SQLite schema unchanged.
- Add configuration and HTTP guard simulations, Rust source regression tests, English/Polish form labels and operator documentation. Rust/Cargo build and live mobile acceptance are still outstanding.
## 0.31.3 ## 0.31.3
- Prevent duplicate session requests after BFCache/tab restoration; suspend SSE/polling until session validation finishes, then resume once. Replace automatic resume reload loops with a visible, localized retry screen and preserve the startup recovery limit until initialization is complete. - Prevent duplicate session requests after BFCache/tab restoration; suspend SSE/polling until session validation finishes, then resume once. Replace automatic resume reload loops with a visible, localized retry screen and preserve the startup recovery limit until initialization is complete.
Generated
+1 -1
View File
@@ -1881,7 +1881,7 @@ dependencies = [
[[package]] [[package]]
name = "rust-proxy" name = "rust-proxy"
version = "0.31.3" version = "0.31.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arc-swap", "arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rust-proxy" name = "rust-proxy"
version = "0.31.3" version = "0.31.4"
edition = "2021" edition = "2021"
rust-version = "1.99" rust-version = "1.99"
description = "RustProxy - independent reverse proxy with local administration" description = "RustProxy - independent reverse proxy with local administration"
+14 -3
View File
@@ -1,11 +1,13 @@
# RustProxy 0.31.3 # RustProxy 0.31.4
**0.31.0 panel/cache additions:** browser-negotiated Zstd/Brotli/gzip for HTML, JS and CSS (Settings → Administration); optional HTTP BAN/PURGE restricted to explicit IP/CIDR allowlist (Settings → Cache, disabled by default); System information Refresh button beside the API badge. See [Settings](docs/SETTINGS.md) and [Cache](docs/CACHE.md). **0.31.0 panel/cache additions:** browser-negotiated Zstd/Brotli/gzip for HTML, JS and CSS (Settings → Administration); optional HTTP BAN/PURGE restricted to explicit IP/CIDR allowlist (Settings → Cache, disabled by default); System information Refresh button beside the API badge. See [Settings](docs/SETTINGS.md) and [Cache](docs/CACHE.md).
Independent Rust reverse proxy and static Web Server with a dashboard, SQLite configuration, 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. streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhosts and backend pools.
**0.31.3 mobile-session recovery patch source (Cargo verification required).** **0.31.4 administration Origin pinning and simplified one-field configuration (Cargo verification required).**
**Reissued source package:** this package supersedes the earlier 0.31.4 archive under the same version number. Update/version checks cannot distinguish the two by version alone; rebuild/redeploy explicitly and verify the package SHA-256.
## Layer-4 streams ## Layer-4 streams
@@ -13,7 +15,16 @@ 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. Named HTTP/HTTPS listener bind addresses use the same chip-style editor as vhost hostnames, including multi-address listeners.
## What changed in 0.31.3 ## What changed in 0.31.4
- Fix intermittent `auth.invalid_origin` on the **administration panel only** behind a trusted reverse proxy. The optional `administration.public_origin` pins the browser-facing HTTPS origin and allows a bounded `X-Forwarded-Proto: https, http` chain when the immediate proxy peer is trusted. Without a pin, ambiguous forwarded-proto values still return HTTP 403.
- Set **only** `administration.public_origin`, for example `https://panel.example.test`, in Settings → Administration. Its DNS hostname is automatically included in the panel Host allowlist; it does **not** need to be repeated in `allowed_hosts`.
- The advanced **Additional allowed DNS hostnames** section remains optional. Older configurations with a duplicate pinned hostname still load, and editing/saving omits that duplicate while preserving independent entries. Removing or changing the pinned URL never silently keeps the previous hostname implicitly allowed.
- Keep strict checks for foreign `Origin`, mismatched or duplicate `X-Forwarded-Host`, cross-site requests, CSRF and untrusted proxies. The narrow opt-in self-admin session-cookie path may use the derived hostname only under its existing local-listener-risk conditions. Public proxy traffic and SQLite schema 9 are unchanged.
- Restart RustProxy after modifying the setting. See [Administration HTTPS](docs/SETTINGS.md) and [0.31.4 implementation and tests](docs/IMPLEMENTATION_0.31.4.md).
- **Release gate remains open:** Rust/Cargo compilation, resolved dependency lockfile and real mobile/reverse-proxy acceptance are still required before production deployment.
## Previously in 0.31.3
- Correct mobile tab/BFCache recovery: share in-flight session validation across resume events and defer SSE/polling until verification succeeds. A failed check keeps a visible retry UI instead of reloading the page, and 401 still requires a fresh login. - Correct mobile tab/BFCache recovery: share in-flight session validation across resume events and defer SSE/polling until verification succeeds. A failed check keeps a visible retry UI instead of reloading the page, and 401 still requires a fresh login.
- Preserve the one-time initial Origin reload guard until **full** panel initialization succeeds. Authenticated dashboard navigation rejected for invalid Origin now returns a visible 403 HTML explanation rather than raw JSON; API requests retain JSON errors and the same Origin/Sec-Fetch-Site authorization policy. - Preserve the one-time initial Origin reload guard until **full** panel initialization succeeds. Authenticated dashboard navigation rejected for invalid Origin now returns a visible 403 HTML explanation rather than raw JSON; API requests retain JSON errors and the same Origin/Sec-Fetch-Site authorization policy.
+9
View File
@@ -1,5 +1,14 @@
# RustProxy roadmap # RustProxy roadmap
## 0.31.4 - Administration-only public HTTPS Origin with one-field configuration
- [x] Add explicit HTTPS `administration.public_origin` and restrict its use to trusted reverse-proxy peers for panel requests.
- [x] Derive the pinned DNS hostname for the effective Host allowlist without duplicating it in saved `allowed_hosts`.
- [x] Keep additional allowed DNS hostnames in an advanced panel field and remove redundant legacy duplicates on edit/save.
- [x] Preserve strict Origin, CSRF, trusted proxy, forwarded Host and HTTPS-first header checks and the opt-in self-admin session-cookie boundary.
- [x] Add panel settings UI, translations, API schema, Rust source regressions and Node simulations without changing public proxy forwarding.
- [ ] Resolve Cargo dependencies, compile and test Rust, and perform real mobile/reverse-proxy acceptance before production deployment.
## 0.31.3 - Mobile session recovery and Origin diagnostics ## 0.31.3 - Mobile session recovery and Origin diagnostics
- [x] Reproduce the overlapping resume-session checks, reload-loop marker reset and early SSE restart using the 0.31.2 source in a deterministic JavaScript simulation. - [x] Reproduce the overlapping resume-session checks, reload-loop marker reset and early SSE restart using the 0.31.2 source in a deterministic JavaScript simulation.
+41 -35
View File
@@ -1,18 +1,18 @@
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
2326433366f5ee39f50cc0c8fd308a1aa4bf913dd29c60d36d6ad40596e2057e AGENTS.md ce2dfe3ddaf72f4d9f47fbddde8cc3a6379c95797785bbafdbc9f505ab0c06d7 AGENTS.md
fe15afa19911918df2cb8f754d42b23e367056b2350c4421273e4ea15558e04c CHANGELOG.md 3868e2b5ac1e1e324e0647da39dd4ace965b102fdd06a26d720977cb7bcc4ba2 CHANGELOG.md
a527e1bdd3ad6217a4a0bb06f29d33b4f5718525b8de81d4170ec7233bee5155 Cargo.lock 140089a442d329eb0819f17bbb8e9215687ede43d77fb74068c1b4e79b074547 Cargo.lock
267b8d8ff64905e059ea6626a6ab48fb20f42cf5ae4af25ffc69b07535a9d42b Cargo.toml d8e3b968089f11eac6098bb8f51598a9012de6ec2f84a0ef6899ac27cdf2a134 Cargo.toml
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
1ceec1cdf7e056bf26096eedcf95f0efb192b69c0f614ba13ecf2808e534ebf3 README.md 6c1cddc4650a1bd9e31b3598baffb0d55435a2d3a5e31653c111aacd4ed96070 README.md
4c8c8a4efa169b7c92d27715d15d467c95c705bd8b439302e6bbde26e56f1dfd ROADMAP.md ea6203f84882ab99aa22b99dd26d0d7b8d7fd64639aa74230ee431b3edac25ae ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md
4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service 4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service
5147ac3233914fe580b4c4d5ae1b74af2948019f34ff6629d21a7811b27a5b84 dev.sh 7e9d5cf9a0720ac00a87a177b41c44524ed6b04e819343ea16967945b3a413ae dev.sh
5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md 5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md
7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go 7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go
5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example 5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example
@@ -27,8 +27,8 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go 5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
875faa3969df9409d79f050a84b4183496f557edda3d34aaec19fe556cf8c471 docs/ACME_PROVIDERS.md 875faa3969df9409d79f050a84b4183496f557edda3d34aaec19fe556cf8c471 docs/ACME_PROVIDERS.md
ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md
8ded74f422426606238fa9876b49277a7bc56ddd96062a836cdd6ddbf78bd335 docs/API.md a36880dc07e922a36a4c8be5fa889a03658bb0492a1e20ec00fbb130241c7719 docs/API.md
6ed12684b970fb7173fc9d1ad1da8ecce784eb38b29b7e6e631309fb25446807 docs/API_ACCESS.md b0c6fae1d538f33fb468a00307cbb745a601d9fc341c0a4f859d60c7e8d48865 docs/API_ACCESS.md
4c5288c9ed427cfb919bd023ed9a321dcf6d64bc4cad3e1768393356c5d835ba docs/ARCHITECTURE.md 4c5288c9ed427cfb919bd023ed9a321dcf6d64bc4cad3e1768393356c5d835ba docs/ARCHITECTURE.md
e9e2327684a4ae2d1ca4a0b3fb67d0ff83b5a150d38d4de4cfad2c5d3102eab6 docs/CACHE.md e9e2327684a4ae2d1ca4a0b3fb67d0ff83b5a150d38d4de4cfad2c5d3102eab6 docs/CACHE.md
9113f3abbfe81bafc4855d8680df8aa2a381ecc358cc3e1a0f78b6b756421117 docs/CACHE_DIAGNOSTICS.md 9113f3abbfe81bafc4855d8680df8aa2a381ecc358cc3e1a0f78b6b756421117 docs/CACHE_DIAGNOSTICS.md
@@ -42,6 +42,7 @@ befa50579e3475b22589f26380d85b3345b80e4fa947d49649a3b91a8dd8b8c2 docs/CUSTOM_LO
5301e321611d494fd97b04ab648df1f66a6a0d1f1f4c3475eab485e138d5605b docs/IMPLEMENTATION_0.31.0.md 5301e321611d494fd97b04ab648df1f66a6a0d1f1f4c3475eab485e138d5605b docs/IMPLEMENTATION_0.31.0.md
eb52e72325e1307d05cb04878b6d22dfedea491908c5f36110b32421765096c8 docs/IMPLEMENTATION_0.31.1.md eb52e72325e1307d05cb04878b6d22dfedea491908c5f36110b32421765096c8 docs/IMPLEMENTATION_0.31.1.md
991ff3c09ca1e2bcf73b8fa2975405b955dd640c3f1e1246e75038c043c17183 docs/IMPLEMENTATION_0.31.3.md 991ff3c09ca1e2bcf73b8fa2975405b955dd640c3f1e1246e75038c043c17183 docs/IMPLEMENTATION_0.31.3.md
db2087608a1225c56e42c416d949b777fb15b1ed8b69a1e71fc2a6af6750a1e5 docs/IMPLEMENTATION_0.31.4.md
a00ec3c1d3b28ba10677435128e031e5f18fe8c5cef2ac90eb0f6779cefeb434 docs/INSTALL.md a00ec3c1d3b28ba10677435128e031e5f18fe8c5cef2ac90eb0f6779cefeb434 docs/INSTALL.md
3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md 3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md
4bb56add544f15d54695cf797cd16a51c09024360cdeef803a88988f4a54b3a5 docs/LIVE_LOGS.md 4bb56add544f15d54695cf797cd16a51c09024360cdeef803a88988f4a54b3a5 docs/LIVE_LOGS.md
@@ -55,7 +56,7 @@ e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND
e68d5a5cf0991de9308f78afb0264e02979f7463a5f395eb7a6e9ec2bd3a3d54 docs/PHP_FPM.md e68d5a5cf0991de9308f78afb0264e02979f7463a5f395eb7a6e9ec2bd3a3d54 docs/PHP_FPM.md
bc068a176056f5ea4d3b637071e644ed58a2140494c233ef22aa66e9700e3299 docs/PHP_FPM_ISOLATION.md bc068a176056f5ea4d3b637071e644ed58a2140494c233ef22aa66e9700e3299 docs/PHP_FPM_ISOLATION.md
572c70fbd2f61fecd80a8ff9fa5de4a94c4f158de5e90da8e920f99b051241fa docs/PROMETHEUS.md 572c70fbd2f61fecd80a8ff9fa5de4a94c4f158de5e90da8e920f99b051241fa docs/PROMETHEUS.md
e87c81d2eb2e90d5b1ea9e40abb81a65ca81593eb5cc3e39717b818c9a75b287 docs/README.md 1abcf8b9abcff02cf40c0f3e5597af1e1bf3e6d48c2f7f4278a53c6ebad51f5b docs/README.md
6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md 6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md
0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md 0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md
d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.md d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.md
@@ -64,9 +65,9 @@ a2bb837cdd3559d58158880c1f832badf64035376a3623050bb4e40c2bca104c docs/ROADMAP_H
5dbdf45fddba198a28f511dbbd2f12bf7d2b88c41c3cbedbc20dfea3858779d3 docs/SECURITY.md 5dbdf45fddba198a28f511dbbd2f12bf7d2b88c41c3cbedbc20dfea3858779d3 docs/SECURITY.md
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md 620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md 007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md
2cd9dadc49a5474e4ab06d5c01fa38c27cab436578cb9c9f327a37b3406d2e13 docs/SETTINGS.md 970025a6d1a37452b8bead6c9d2b3d4743ea602251dc446d575531d76c11ae83 docs/SETTINGS.md
3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md 3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md
1de7cf885e596ccfab31a434d597c3a8d9113d206305810ea628a5ee6d96f986 docs/TEST_REPORT.md d7cd61b534a2d38511d212cd9ee906b16b28d99d4a7ba5ed6ef7c1ab3611d2b8 docs/TEST_REPORT.md
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md 751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md
660687fc961f71aae2bdf71595aa47a336fa782f4e78ceee8c1d1e63e46393ee docs/TRANSPORT.md 660687fc961f71aae2bdf71595aa47a336fa782f4e78ceee8c1d1e63e46393ee docs/TRANSPORT.md
@@ -138,8 +139,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh 96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh 3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md 71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md
19c17d16960539f9811cd551bbcbba25863711b064618069a58d48de3cc9fade langs/en_US.json 61337803fabb183629fa4dc9ff038ee7ee41956c8304de3d0122d425d0b1f53a langs/en_US.json
fcdb2db09c7a105893fc13462da4d0341a873e305bcc3fdcd0bd5e10b9a110ba langs/pl_pl.json 949dc843e348ada1b1208407daa13463a0e7e16548411376382e18635578dcbb langs/pl_pl.json
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
@@ -165,7 +166,7 @@ a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy
2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py 2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py
a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py
31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py 31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py
3d8cfbc8177ef82f28dfba3fd555955dac4a29003267f93572ea26a98fb8255e scripts/resolve_dependencies_0312.sh abf438c3cb5a9d0dcc1351a541d3d399f3e963fc793ceb1e4708beaf3f7523a9 scripts/resolve_dependencies_0312.sh
20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py 20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py
e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py
22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py 22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py
@@ -180,17 +181,17 @@ b9e57db4bd80ef37564dfc533a96897d8a494671d53acea9fb4bf28d23ba370d src/acme/provi
87f64198770ae026c03a242fa542db2590ca734b02e756f1e5c28d66236f753a src/acme/runtime_env.rs 87f64198770ae026c03a242fa542db2590ca734b02e756f1e5c28d66236f753a src/acme/runtime_env.rs
f8075a5ef9ae5651d72a33ac0f0fd5b44ad0ae30f6e2d2318412177e0ed837f5 src/acme/runtime_versions.rs f8075a5ef9ae5651d72a33ac0f0fd5b44ad0ae30f6e2d2318412177e0ed837f5 src/acme/runtime_versions.rs
e38331a1b186b49fe9d04051de52b603e1fa028def37e4e2f52e386b4d7b29ac src/acme/tests.rs e38331a1b186b49fe9d04051de52b603e1fa028def37e4e2f52e386b4d7b29ac src/acme/tests.rs
bc9eb32f0d2785bac122d5eff44742cd24306c503daa065564383d46416b2211 src/admin/access.rs b6f38f3fd75d6685bbc882ed1077a38bf495cfcf01c397f69ed9b98be68b446f src/admin/access.rs
019f4aa33e3596bc9c11ef8cf5703bcfffe46c15288c0eba83c8dd9e38055fb2 src/admin/certificates.rs 019f4aa33e3596bc9c11ef8cf5703bcfffe46c15288c0eba83c8dd9e38055fb2 src/admin/certificates.rs
1de4c7c116eeab2452ccccbaba3f2b7b63c6eb1c1e146ab870d19c5a5ca16f80 src/admin/documentation.rs 1de4c7c116eeab2452ccccbaba3f2b7b63c6eb1c1e146ab870d19c5a5ca16f80 src/admin/documentation.rs
69095640bb0dca081317c7776cd73e83fab11ddd04fe903acea8f8c1b4899210 src/admin/documentation/documents.rs efa4a357d51d9b3c575e64593b55c8a30afbab5cd96a44cc39786207228757b6 src/admin/documentation/documents.rs
67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs 67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs
002922c91aa07cbea17cb0ee11127bb6e7e015de108ee032761b34507dcfa06d src/admin/file_log_search.rs 002922c91aa07cbea17cb0ee11127bb6e7e015de108ee032761b34507dcfa06d src/admin/file_log_search.rs
2ebaf24e9d0d87383d2eea073ee2d3ac37962eeb503a0f1ee2c2de4f3f6d1b7a src/admin/file_logs.rs 2ebaf24e9d0d87383d2eea073ee2d3ac37962eeb503a0f1ee2c2de4f3f6d1b7a src/admin/file_logs.rs
d7aa68a78a4cb5a61342d92c1f3209af230d0f9ca3fea1115f67078372e6f37c src/admin/file_logs_io.rs d7aa68a78a4cb5a61342d92c1f3209af230d0f9ca3fea1115f67078372e6f37c src/admin/file_logs_io.rs
899e87a73bfe46c4078a46f9e1eb1c17b384472e8a30d2bab5486872b43fc809 src/admin/logging_api.rs 899e87a73bfe46c4078a46f9e1eb1c17b384472e8a30d2bab5486872b43fc809 src/admin/logging_api.rs
58802528128f53e493f7339063433a747b605007a8842b5efc670132d897d6f7 src/admin/mod.rs 58802528128f53e493f7339063433a747b605007a8842b5efc670132d897d6f7 src/admin/mod.rs
a857bcbdef2a673be2f01c5817ca6d75dd5627b7e3b80777c394aa2edd933cd8 src/admin/openapi.json 3028fd407ec2cbda7742746241113739136a27a5b81993acb2d6f99187086246 src/admin/openapi.json
0d1a5324e8ad32f5ab0861b722663f50f066e256bc5fd5dcced6a63f2bd399d1 src/admin/operations.rs 0d1a5324e8ad32f5ab0861b722663f50f066e256bc5fd5dcced6a63f2bd399d1 src/admin/operations.rs
2b0ec6df0ed9e1bdc5c9e4ff1758b59dd14abc150f98365ac3edcf4672908e48 src/admin/panel_assets.rs 2b0ec6df0ed9e1bdc5c9e4ff1758b59dd14abc150f98365ac3edcf4672908e48 src/admin/panel_assets.rs
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs 75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
@@ -200,12 +201,12 @@ d9e90166d2199152585a5208feedfc1dd1395dc32ddbd2a094ae4a1aee45e115 src/admin/pref
c9f952bec99dfe1d5a60998a38bd177f82f54de567230413e0c322ee561eba7c src/admin/public/tests.rs c9f952bec99dfe1d5a60998a38bd177f82f54de567230413e0c322ee561eba7c src/admin/public/tests.rs
6125419b099cf4d330767bc07424d52a2b400f273d31084c058c43a72a6427b7 src/admin/refresh_preferences.rs 6125419b099cf4d330767bc07424d52a2b400f273d31084c058c43a72a6427b7 src/admin/refresh_preferences.rs
765c14e52bdc9e542d6ff651c250c24d33b77b7e46f1b5f19acf96cc2c99bdc8 src/admin/router.rs 765c14e52bdc9e542d6ff651c250c24d33b77b7e46f1b5f19acf96cc2c99bdc8 src/admin/router.rs
daf0f7393f2350e569a5e8dda52dbf3e3bbefcb989f07f723dca540ae0fe487e src/admin/session.rs a40adfbaa908da3ea726d7aa3d45394a70a6a55b6fc0063270ec4d2a2f2026a3 src/admin/session.rs
6fa2e586feb335ea9ce2c4dc523e422e4b2aa52c823ba8f8851f9e0f37cd2fef src/admin/status.rs e7355ef15e4377cd71e47d054c607079dd8e6711f1d86824f716b8003210a046 src/admin/status.rs
e79d3dc0c4fde1ca4a69c204fb41cc5aa1db02aefdabff3435f27b5db0c6eb8d src/admin/status_live.rs e79d3dc0c4fde1ca4a69c204fb41cc5aa1db02aefdabff3435f27b5db0c6eb8d src/admin/status_live.rs
c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
3277a08915eb2e272ad4c0f3aa1ffa0c7fed4e6a1d1fb66d4bfd8a4a4ea515aa src/admin/tests.rs 684307b74155ac7833acfb5e9e8eaccc92575d49b760928c30fc22e805a42a4c src/admin/tests.rs
18aa617a0bf60a3d8d2ad7dd059a369e7549ef46683d8f912e07f8fb091c26d1 src/admin/tests/part_01.rs 18aa617a0bf60a3d8d2ad7dd059a369e7549ef46683d8f912e07f8fb091c26d1 src/admin/tests/part_01.rs
6f8c1e30babb2cc4cb7accf4bfa28338651a469b16341f6e7eaec881dd9abf2b src/admin/tests/part_02.rs 6f8c1e30babb2cc4cb7accf4bfa28338651a469b16341f6e7eaec881dd9abf2b src/admin/tests/part_02.rs
34cf08cafb63cf7a8a6336e5c81fc99abfce8669662e387a3702c3a532be8056 src/admin/tests/part_03.rs 34cf08cafb63cf7a8a6336e5c81fc99abfce8669662e387a3702c3a532be8056 src/admin/tests/part_03.rs
@@ -216,6 +217,7 @@ cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail
54ed2130e85239ffa4576417dd68fbb9349e396210b75f5da862897137d7e876 src/admin/tests/part_08.rs 54ed2130e85239ffa4576417dd68fbb9349e396210b75f5da862897137d7e876 src/admin/tests/part_08.rs
225a29e2e560e42e06e19c3d41c0196c4a42e1e84caa7c83446cab62e3ac30dc src/admin/tests/part_09.rs 225a29e2e560e42e06e19c3d41c0196c4a42e1e84caa7c83446cab62e3ac30dc src/admin/tests/part_09.rs
bffa6611f3776e97d0065b510b63f083aabf7b3429fcc5cb26b1883fdb7c47dd src/admin/tests/part_10.rs bffa6611f3776e97d0065b510b63f083aabf7b3429fcc5cb26b1883fdb7c47dd src/admin/tests/part_10.rs
578f93cc5771b5858813945e5608b15c572bd3d7fd15bae291d292e2994ece25 src/admin/tests/part_11.rs
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js 48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js
446011c2c7afddf7734ceb268c75a3b4146dbe8e915b11986495e956b6c62c69 src/admin/web/app/config-history.js 446011c2c7afddf7734ceb268c75a3b4146dbe8e915b11986495e956b6c62c69 src/admin/web/app/config-history.js
9527dcafb9c14e420cb841a6984d373ba2136d07c45350e9368dd7a88e89af22 src/admin/web/app/core.js 9527dcafb9c14e420cb841a6984d373ba2136d07c45350e9368dd7a88e89af22 src/admin/web/app/core.js
@@ -240,14 +242,14 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg 0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
af6e7a2e890df2fb7385a374bcb3fba52fb5a53dbe18adc1fb7865660fcba842 src/admin/web/file-logs.js af6e7a2e890df2fb7385a374bcb3fba52fb5a53dbe18adc1fb7865660fcba842 src/admin/web/file-logs.js
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
8490011e8164e6fab9601ff75a82eef3cc7972d1fdb8e8c4f73df5796ae72d37 src/admin/web/index.html 7994b44abf769243ff3a21d248d3eb79f26736c7730bd94978c29be08b3f07ef src/admin/web/index.html
24db88842707f764064657712d3660c6ac129fbfb47d188c0903925cf8244ce5 src/admin/web/integrations.js 24db88842707f764064657712d3660c6ac129fbfb47d188c0903925cf8244ce5 src/admin/web/integrations.js
7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json 7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json
af0fbd94d9b88ce1cce28e39dfd2c35de031adcba412dd4a9a17d9b416c256dd src/admin/web/login.html af0fbd94d9b88ce1cce28e39dfd2c35de031adcba412dd4a9a17d9b416c256dd src/admin/web/login.html
99fe9b8ba7a8a24e5bbe9ae40999484fe13b0faf5eb1d7a9193701f667e51ca5 src/admin/web/login.js 99fe9b8ba7a8a24e5bbe9ae40999484fe13b0faf5eb1d7a9193701f667e51ca5 src/admin/web/login.js
21ddae9743ca545c89f6df0db40810dd680812e58f2b2b6875ed64fd8fb97ac6 src/admin/web/management.js 21ddae9743ca545c89f6df0db40810dd680812e58f2b2b6875ed64fd8fb97ac6 src/admin/web/management.js
90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js 90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js
df6efd27b1ebe1fafeb0dc6061b69be9aa46f71451f0fcdd00e2f7ef8692d3cb src/admin/web/settings.js 8fcad0d1a161ac6f45d773d1dc3925db2d32b34bdbe57595924f84eacb4f9136 src/admin/web/settings.js
bb67e77af5514478485cfb69f339d63d7eb0cbadd093fa054814a416260ccc1d src/admin/web/streams.js bb67e77af5514478485cfb69f339d63d7eb0cbadd093fa054814a416260ccc1d src/admin/web/streams.js
c4f051f54293156803b48c04d972e9d5a290209826138ce15356d19adf569979 src/admin/web/style.css c4f051f54293156803b48c04d972e9d5a290209826138ce15356d19adf569979 src/admin/web/style.css
d01b615635c7373981b0633cc27298a34e67fe0cbe9d1384d277d46f0cafb1b4 src/admin/web/traffic-panel.js d01b615635c7373981b0633cc27298a34e67fe0cbe9d1384d277d46f0cafb1b4 src/admin/web/traffic-panel.js
@@ -396,7 +398,7 @@ e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus
59095a04707c2e4714358f4462dcf21c41952474b9b815c42bbd14e96c573846 src/prometheus/web_php.rs 59095a04707c2e4714358f4462dcf21c41952474b9b815c42bbd14e96c573846 src/prometheus/web_php.rs
b5856fd38a957b2ca9c7e3606d84d13d5cfcf3a45b487b15ae1d2dc248cbda57 src/proxy/body.rs b5856fd38a957b2ca9c7e3606d84d13d5cfcf3a45b487b15ae1d2dc248cbda57 src/proxy/body.rs
16b9d4db3b20b2ab14c47ac14fcd18d3d8ca77c3ad9a1f37866ebfa60f379862 src/proxy/body/tests.rs 16b9d4db3b20b2ab14c47ac14fcd18d3d8ca77c3ad9a1f37866ebfa60f379862 src/proxy/body/tests.rs
f0cb26ed34c35bacc74b914457acac8a611390de14cf46e2e6d3bcc848e914e8 src/proxy/cache_helpers.rs d3126b44f0a32d60421bb5afe8e83adf4a253faf7dc77f500bce8fe5d78b9d74 src/proxy/cache_helpers.rs
aa49c9928bb91e3d5bae0d340d88396a0417b9edec6192d41e1403533319a897 src/proxy/cache_http_invalidation.rs aa49c9928bb91e3d5bae0d340d88396a0417b9edec6192d41e1403533319a897 src/proxy/cache_http_invalidation.rs
771cc1a38927f96e593b144dbab66cd64a5dc0ed42d54b1615c359845b2b7223 src/proxy/error_fallback.rs 771cc1a38927f96e593b144dbab66cd64a5dc0ed42d54b1615c359845b2b7223 src/proxy/error_fallback.rs
bf2e6bbd64fb45fceed54af0bff1c1e00da22b79c64dd8f5c23d2af0dbab0b7d src/proxy/forward/cache_lookup.rs bf2e6bbd64fb45fceed54af0bff1c1e00da22b79c64dd8f5c23d2af0dbab0b7d src/proxy/forward/cache_lookup.rs
@@ -426,7 +428,7 @@ d40ad63939097d2b91339127d5835a74b618d83b7ba31db0c512f05a82107d6e src/redirect.r
811c83ae9fd0f5a13d35b5443cd37843f82b9d600bee4bb136ba9b87732a5347 src/redirect/tests.rs 811c83ae9fd0f5a13d35b5443cd37843f82b9d600bee4bb136ba9b87732a5347 src/redirect/tests.rs
d0939e1b0829c25356a0b67ecb3a178c74936dd07baa15fee24eb32544db6cad src/runtime.rs d0939e1b0829c25356a0b67ecb3a178c74936dd07baa15fee24eb32544db6cad src/runtime.rs
4e0b25c2b86eefddd76c64b39dc1c1cd6d243addc32cdea99e6c3ac84ed879a6 src/runtime/certificates.rs 4e0b25c2b86eefddd76c64b39dc1c1cd6d243addc32cdea99e6c3ac84ed879a6 src/runtime/certificates.rs
2752a1122f22cab7af9ccdd23cd0aa6d99a73f1d82614814217a3875927d7eef src/runtime/constructors.rs 8d2443df8dab1ce6f11878e991b4928046b7362c417c5703f6d1f3a977646cbe src/runtime/constructors.rs
31dc5cdea00efd63594dc6becd5a9103594696145cbc8e7b7594fb407214ad9d src/runtime/docker.rs 31dc5cdea00efd63594dc6becd5a9103594696145cbc8e7b7594fb407214ad9d src/runtime/docker.rs
e90df3fff8a25465425b099ae358d19085809e56d948ba47e01e53f8b93d5dd0 src/runtime/tests.rs e90df3fff8a25465425b099ae358d19085809e56d948ba47e01e53f8b93d5dd0 src/runtime/tests.rs
0046525317ffbd5d3b34845656b0f58d9d31f38b3d8acd71e90d94bd7cdfb8ab src/runtime/types.rs 0046525317ffbd5d3b34845656b0f58d9d31f38b3d8acd71e90d94bd7cdfb8ab src/runtime/types.rs
@@ -441,8 +443,9 @@ f2f6196632b2fdb2444189624f961edaf5bdfff57f700b0207b57e2491c1daa5 src/server/htt
71899a9b1897fa22ab9e8eb6a670054a6d382bad78714006b9b218d07ba99902 src/server/serve.rs 71899a9b1897fa22ab9e8eb6a670054a6d382bad78714006b9b218d07ba99902 src/server/serve.rs
e9e478cf45b88994eaffd115f51764b6b07852ee3c941cafdabb20584d3dabc0 src/server/serve/tests.rs e9e478cf45b88994eaffd115f51764b6b07852ee3c941cafdabb20584d3dabc0 src/server/serve/tests.rs
048f2c7a8ef698a172ba7ed844558f4118b367b2cf340748c5c28da7723d8e85 src/server/tls_runtime.rs 048f2c7a8ef698a172ba7ed844558f4118b367b2cf340748c5c28da7723d8e85 src/server/tls_runtime.rs
623e1b24c17f2149c712bc9fe38ca23c503e88891f5eef1b5ceef6cbf8cf9093 src/settings.rs 73ec453ac2850f6963f5bc33401cb8c92aa26d5123f15d68e6633860d91f265e src/settings.rs
3b3893d899b8e82969588f30c1e0aeff250449e4c94627ff5ad94d29f227ceca src/settings/tests.rs 240ecdd70e8662ff3b84c21830dc0cbbbacadd755d56146e54214bc2887f068c src/settings/public_origin.rs
1f1121c3562c8f8f3fdee2113be2afffb8849be2d303e4c4409f87478b6e3307 src/settings/tests.rs
566daf9c0e1a30da2dc18e51090541a85ced3320a0fc00ed1d1de3e2b4da5072 src/stream.rs 566daf9c0e1a30da2dc18e51090541a85ced3320a0fc00ed1d1de3e2b4da5072 src/stream.rs
46d9f63ce13a634e00f38d36ca3bdb217f2e37f04d75a4ef118e7dac9896a489 src/stream/model.rs 46d9f63ce13a634e00f38d36ca3bdb217f2e37f04d75a4ef118e7dac9896a489 src/stream/model.rs
9c59bdb856106c0a74d1f8c156b0dd7289a170322ea556bad19bb8de3bf13d69 src/stream/runtime.rs 9c59bdb856106c0a74d1f8c156b0dd7289a170322ea556bad19bb8de3bf13d69 src/stream/runtime.rs
@@ -522,28 +525,31 @@ eecf680f001f2110dde4eceaccf2f922859a16c946a644f8854e8de4d3771f7c tests/policies
728999110a6d8cacb62bc0399bc333fa23a66526ee23f563a3c93bf00e5fad19 tests/transport_config.rs 728999110a6d8cacb62bc0399bc333fa23a66526ee23f563a3c93bf00e5fad19 tests/transport_config.rs
b04362ad082a32c9ccc4cf38c46f44d71347adecf7aba3224b3af3ea4872e3d0 tools/README.md b04362ad082a32c9ccc4cf38c46f44d71347adecf7aba3224b3af3ea4872e3d0 tools/README.md
358c1e94dd2b4a756430e5335747f0b437345b1713670a9972c5bcf78b837ccb tools/audit/admin_audit.py 358c1e94dd2b4a756430e5335747f0b437345b1713670a9972c5bcf78b837ccb tools/audit/admin_audit.py
1a7ff9b28ade57b99d054c703496b51949dd1a8e7ed67bc5630f94a7df258cb5 tools/checks/admin_panel_config_0314_ui_test.mjs
e5c58fc9b7685ec911bfa86f6ca4f0cc97c19a90c0cff5b60ea9887f1afe9ced tools/checks/admin_public_origin_0314_config_sim.mjs
0060f8217640403c6af2d5091fdc9087d0fb2fc30cd545a3c58277a396d8121c tools/checks/admin_public_origin_0314_sim.mjs
fd082812411cff7afb71b2289a6f1a469b8d3c8a7cfdcdd9e567a2a4baf81be4 tools/checks/admin_surface_audit_0310.py fd082812411cff7afb71b2289a6f1a469b8d3c8a7cfdcdd9e567a2a4baf81be4 tools/checks/admin_surface_audit_0310.py
412255d383fcc8306c6086784fb416d9814e3ec790e440a0927431ca615f3411 tools/checks/backend_cache_0297_ui_test.mjs 412255d383fcc8306c6086784fb416d9814e3ec790e440a0927431ca615f3411 tools/checks/backend_cache_0297_ui_test.mjs
2a493dbce2991a23d807c01952f58608a364f541560836b4cb385e75e108e9e7 tools/checks/backend_health_0300_ui_test.mjs 2a493dbce2991a23d807c01952f58608a364f541560836b4cb385e75e108e9e7 tools/checks/backend_health_0300_ui_test.mjs
bebe46609381f2050d90c809e9b5ac7b564d5b26f733a926f20e5363a4140d34 tools/checks/check_source.py 54c73f3c0a63d0345bbe14a538503a9c63b6a9ff1925e7e51acbce38abd370e9 tools/checks/check_source.py
0b583ee59dcccc35b15ef4e211d7e6546a8aaa7b0286ceab03c0b0dc084c1e07 tools/checks/dashboard_boot_0311_ui_test.mjs 0b583ee59dcccc35b15ef4e211d7e6546a8aaa7b0286ceab03c0b0dc084c1e07 tools/checks/dashboard_boot_0311_ui_test.mjs
259d4b0985c82b631e151a07e8ef3b4c760019cb99bb2af458ce577497f7df58 tools/checks/dashboard_startup_0310_ui_test.mjs 259d4b0985c82b631e151a07e8ef3b4c760019cb99bb2af458ce577497f7df58 tools/checks/dashboard_startup_0310_ui_test.mjs
6adc94ee148508a2e46f073eb6f3b89c4057983dea12c976ae7332fb8423ce41 tools/checks/dependency_upgrade_0312.py 76884f3c44f18c36eff3a1968aa173d2d782fa8d31ed7cc63e4ff41853488672 tools/checks/dependency_upgrade_0312.py
dd7b7512f5a988c1fe6fdbdeb78c3b0d33a23b57335eb4f881296cb252d8692c tools/checks/docs_check.py 9add103198795111ccf1b3197cdd2586f54f606d152ff92be2ae09beb628d5aa tools/checks/docs_check.py
e33a9cc51d58bc92a637c0e6a1c88cbd24ba4605be55748212e296c3c35a4060 tools/checks/high_traffic_memory_model.py e33a9cc51d58bc92a637c0e6a1c88cbd24ba4605be55748212e296c3c35a4060 tools/checks/high_traffic_memory_model.py
4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py 4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py
f8f87ba891338b565249d02c3cf5f02cec44e606b4205de99fcbea1eb2c24d57 tools/checks/memory_contracts.py f8f87ba891338b565249d02c3cf5f02cec44e606b4205de99fcbea1eb2c24d57 tools/checks/memory_contracts.py
af98579ee942e774d88775c261ac351847c28bb4a5396dc67e458bd6b1ff7963 tools/checks/origin_resume_0313_ui_test.mjs af98579ee942e774d88775c261ac351847c28bb4a5396dc67e458bd6b1ff7963 tools/checks/origin_resume_0313_ui_test.mjs
5b7901747d879e12a39ed82b6efc513bcdddd15d508b5828210d2c83398fb6a4 tools/checks/panel_cache_http_0310_ui_test.mjs b432ea24933ba28ae819d900892f7ada268560e2816234820394084d6296b595 tools/checks/panel_cache_http_0310_ui_test.mjs
7770cc0b1b70255003d19b63210cc3e21ca9c8804cb56c2fe01eaf2e04ebb0fa tools/checks/preflight.py 300c114c05f66be214bc2e06187fc4d0063e3dc3cd55f44c43c2e43cbd3106f2 tools/checks/preflight.py
021a97bd6763b59f3f7eca15f51efdf88d0c863f1f81a2e42ba80a9a09ca3fec tools/checks/refresh_0310_ui_test.mjs 021a97bd6763b59f3f7eca15f51efdf88d0c863f1f81a2e42ba80a9a09ca3fec tools/checks/refresh_0310_ui_test.mjs
951d184c67cf3b006f89dc6588f460797fcad9c15460f6638abd8316f8777736 tools/checks/release_smoke.py e24a81c06deceab7f4d3c3bf596712d3eeb70b884c731820637461fbc93aa975 tools/checks/release_smoke.py
f69845bc26ec33160fc756953991cf32aa8ad6a7fb4a131caa2093db91e783ef tools/checks/rust_module_contracts.py f69845bc26ec33160fc756953991cf32aa8ad6a7fb4a131caa2093db91e783ef tools/checks/rust_module_contracts.py
ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py
adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py
23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_contracts.py 23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_contracts.py
0d211440ae77f16187a5d833156911c7793ed27ac1d77e79de362e47c1ab1abd tools/checks/source_module_contracts.py 0d211440ae77f16187a5d833156911c7793ed27ac1d77e79de362e47c1ab1abd tools/checks/source_module_contracts.py
51afd5f31eeea8f1658d239dd34637d38a0a7eb1fb9f58a212040505d3b39b0c tools/checks/system_info_0301_ui_test.mjs b87c6c5b5d5335e95b3afc00f7c9035ddccc5ca1394a4425abcf898092da053a tools/checks/system_info_0301_ui_test.mjs
543a488951ea6ec8fe06e04f168ccc4d4f2af3cfc281c9a728ab7bd87377fa98 tools/checks/system_info_no_subprocess_contracts.py 543a488951ea6ec8fe06e04f168ccc4d4f2af3cfc281c9a728ab7bd87377fa98 tools/checks/system_info_no_subprocess_contracts.py
614ed0c775e32ec5bfb4ee75aaa90206a10da0f43659747a7ec76fd93e33767a tools/checks/version_contracts.py 614ed0c775e32ec5bfb4ee75aaa90206a10da0f43659747a7ec76fd93e33767a tools/checks/version_contracts.py
161637cb5e2e613e3c71cf8489cc936fa84913a2a02649463f8893a28a674242 tools/checks/vhost_origin_errors_0297_ui_test.mjs 161637cb5e2e613e3c71cf8489cc936fa84913a2a02649463f8893a28a674242 tools/checks/vhost_origin_errors_0297_ui_test.mjs
@@ -552,7 +558,7 @@ adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/s
0398892be30fe416a00578915c588f360fd93244e3af77ece834e560e547d1c8 tools/checks/web_server_contracts.py 0398892be30fe416a00578915c588f360fd93244e3af77ece834e560e547d1c8 tools/checks/web_server_contracts.py
35f76060e585a96c623ef83bd5f771969f5be16a0e1ae626a3092d7458b84e84 tools/checks/web_service_ui_test.mjs 35f76060e585a96c623ef83bd5f771969f5be16a0e1ae626a3092d7458b84e84 tools/checks/web_service_ui_test.mjs
adaa1ed9e5958739df40190055c780c80d0a8e270ec8dc105ed3fa2dbb8fb74f tools/checks/www_redirect_ui_test.mjs adaa1ed9e5958739df40190055c780c80d0a8e270ec8dc105ed3fa2dbb8fb74f tools/checks/www_redirect_ui_test.mjs
cfa2dee224a11bf3948dbe528f5877a76b830b7efb01cd785007213cc245493d tools/dev/memory_load.py ddee9083a349b2a56b9ebe5742b5c91b668f31143762bac7d939068fbd2ee7a6 tools/dev/memory_load.py
d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a tools/dev/mock_auth.py
2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 tools/dev/mock_server.py 2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 tools/dev/mock_server.py
538b5f6cfa738355a729940ee3d92e7ab3dc25ca69ae330ddaa08f831775b8ba tools/dev/test_compression.py 538b5f6cfa738355a729940ee3d92e7ab3dc25ca69ae330ddaa08f831775b8ba tools/dev/test_compression.py
+3
View File
@@ -111,6 +111,9 @@ if [[ "$mode" == "test" ]]; then
node tools/checks/dashboard_startup_0310_ui_test.mjs node tools/checks/dashboard_startup_0310_ui_test.mjs
node tools/checks/dashboard_boot_0311_ui_test.mjs node tools/checks/dashboard_boot_0311_ui_test.mjs
node tools/checks/origin_resume_0313_ui_test.mjs node tools/checks/origin_resume_0313_ui_test.mjs
node tools/checks/admin_public_origin_0314_sim.mjs
node tools/checks/admin_public_origin_0314_config_sim.mjs
node tools/checks/admin_panel_config_0314_ui_test.mjs
node tools/checks/panel_cache_http_0310_ui_test.mjs node tools/checks/panel_cache_http_0310_ui_test.mjs
node tools/checks/vhost_origin_errors_0297_ui_test.mjs node tools/checks/vhost_origin_errors_0297_ui_test.mjs
node tools/checks/www_redirect_ui_test.mjs node tools/checks/www_redirect_ui_test.mjs
+3 -3
View File
@@ -65,9 +65,9 @@ The `rustproxy_session` cookie is HttpOnly, SameSite=Strict and Path=/ with the
administration adds the Secure attribute; behind a trusted HTTPS-terminating reverse proxy administration adds the Secure attribute; behind a trusted HTTPS-terminating reverse proxy
the external forwarded scheme controls that flag. A protected SSH/VPN tunnel remains valid, the external forwarded scheme controls that flag. A protected SSH/VPN tunnel remains valid,
while direct remote administration should use HTTPS plus firewall/private-network policy. while direct remote administration should use HTTPS plus firewall/private-network policy.
Direct IP/localhost Host values must use the actual administration port. A DNS name explicitly Direct IP/localhost Host values must use the actual administration port. A DNS name explicitly listed in `administration.allowed_hosts`, or automatically
listed in `administration.allowed_hosts` represents the browser-facing authority and may use derived from the validated `administration.public_origin`, represents the
the trusted reverse proxy's external port. Foreign Origin/cross-site requests are rejected. browser-facing authority and may use the trusted reverse proxy's external port. Foreign Origin/cross-site requests are rejected.
There is no permissive CORS mode. There is no permissive CORS mode.
Auth request bodies are limited to 4096 bytes. Auth request bodies are limited to 4096 bytes.
+5 -4
View File
@@ -9,16 +9,17 @@ new bind address and allow explicit DNS hostnames. A persisted non-loopback bind
accepted when HTTPS is enabled. The legacy startup flags remain only for deliberately accepted when HTTPS is enabled. The legacy startup flags remain only for deliberately
plain remote HTTP. Public vhost `/docs` paths are not captured by the admin listener. plain remote HTTP. Public vhost `/docs` paths are not captured by the admin listener.
When the dashboard is published through a trusted reverse proxy, an explicit When the dashboard is published through a trusted reverse proxy, the hostname of
`administration.allowed_hosts` DNS entry is matched against the external authority rather the optional `administration.public_origin` is automatically allowed for panel
than the private admin socket port. `X-Forwarded-Host` is trusted only from loopback or a Host validation. Any extra `administration.allowed_hosts` DNS entry is also
matched against the external authority rather than the private admin socket port. `X-Forwarded-Host` is trusted only from loopback or a
configured trusted proxy; the external `X-Forwarded-Proto` additionally requires the configured trusted proxy; the external `X-Forwarded-Proto` additionally requires the
forwarded-proto trust setting except for loopback. Origin/CSRF checks and Secure cookies use forwarded-proto trust setting except for loopback. Origin/CSRF checks and Secure cookies use
that external origin. Direct IP/localhost access still requires the real administration port. that external origin. Direct IP/localhost access still requires the real administration port.
### Publishing the panel through RustProxy itself ### Publishing the panel through RustProxy itself
0.16.0 supports the explicitly opted-in same-process topology where a public HTTPS vhost proxies to the local administration listener. The reserved `rustproxy_session` cookie is passed through only when all of these conditions are true: the browser-facing request is TLS, its DNS hostname is explicitly present in `administration.allowed_hosts`, the selected backend directly addresses the configured administration socket by IP, and that backend has `accept_local_listener_risk=true`. The control-plane route is forced out of shared cache. 0.16.0 supports the explicitly opted-in same-process topology where a public HTTPS vhost proxies to the local administration listener. The reserved `rustproxy_session` cookie is passed through only when all of these conditions are true: the browser-facing request is TLS, its DNS hostname either matches the pinned `administration.public_origin` or is explicitly listed in `administration.allowed_hosts`, the selected backend directly addresses the configured administration socket by IP, and that backend has `accept_local_listener_risk=true`. The control-plane route is forced out of shared cache.
This is a narrow exception to the normal isolation rule. For every ordinary backend RustProxy still removes `rustproxy_session` before forwarding requests and strips an upstream attempt to set that cookie in responses. DNS backend names are not accepted for the special self-admin cookie path. This prevents a normal application backend from reading or replacing the administration session. This is a narrow exception to the normal isolation rule. For every ordinary backend RustProxy still removes `rustproxy_session` before forwarding requests and strips an upstream attempt to set that cookie in responses. DNS backend names are not accepted for the special self-admin cookie path. This prevents a normal application backend from reading or replacing the administration session.
+151
View File
@@ -0,0 +1,151 @@
# RustProxy 0.31.4 - Administration-panel Origin pinning and simplified HTTPS settings
## Scope
Only the **administration panel/control-plane authentication** and its settings,
OpenAPI, translations and regression tests change. No public vhost/proxy routing,
backend forwarding, stream, session-store or database schema behavior is altered.
Credential-based certificate API automation remains on its existing validation
path. The 0.31.3 BFCache, manual retry, SSE/polling and startup error protection
remains unchanged.
## Evidence and failure mode
0.31.3's `external_admin_scheme` rejects a trusted
`X-Forwarded-Proto: https, http` value with `auth.invalid_origin` and reason
`external_scheme` before checking a session, even for `GET /api/auth/session`
without an `Origin` header. This may appear intermittently if a multi-hop
reverse proxy sometimes appends rather than replaces the header. The previous
Node.js HTTP reconstruction reproduced this with 3/12 intentionally malformed
GETs; it does **not** prove the real user's reverse proxy behaves that way.
## Patch design
`administration.public_origin` is an **optional, explicit HTTPS origin**:
- Requires one canonical DNS hostname; that hostname is derived for the effective
panel Host allowlist without repeating it in `administration.allowed_hosts`.
The setting forbids credentials, userinfo, path, query, fragment, HTTP and ambiguous input.
- Is active only after restart and only when the immediate TCP peer is loopback
or is already in the configured trusted-proxy list. It does not implicitly
trust another proxy or the browser's `Host`/forwarding headers.
- Keeps checking the actual request `Host` against the administration listener
policy. If a forwarded Host exists, it must be a **single** authority and
match the pinned host and effective port. Malformed/userinfo/duplicate values
are rejected.
- For the pinned panel, accepts a bounded (maximum eight hops) protocol chain
only if the *first* hop is literally `https` and every hop is `http` or
`https`. This permits `https, http` but not `http`, `http, https`, `ftp`,
empty values, or arbitrarily long chains. Missing XFP is allowed only
because the explicit administrator pin defines the external HTTPS identity.
- Uses the configured origin for the browser `Origin` comparison, correct
`Secure` session cookie and the existing CSRF checks. Existing
`Sec-Fetch-Site: cross-site` blocking is unchanged.
- Does **not** alter the validation path when the pin is absent, the peer is
untrusted, or certificate-automation credentials are present.
On every rejection, the existing bounded, credential-redacted diagnostics
remain available. New `pinned_origin_mismatch` identifies a disagreement with
the explicit pin. Do not log cookies, CSRF tokens or Authorization headers.
## Simplified hostname configuration (included in this 0.31.4 reissue)
### Operator change
For the panel behind a trusted reverse proxy, **Settings -> Administration**
now needs only the **Public panel HTTPS origin** such as
`https://panel.example.test`. The DNS hostname comes from this validated
administrator setting; it need not (and should not) be repeated in
`administration.allowed_hosts`. Additional DNS hostnames remain in an optional
**Advanced** section. Only those extra names are stored in the allowlist.
Backend validation also canonicalizes any duplicate hostname in memory.
The settings page suppresses this value when loading an older configuration and omits it on the next save.
Other explicitly allowed hosts remain. Removing/changing the pin does not automatically retain the old
hostname as an extra allowlist entry. For legacy duplicated values, this
normalization occurs **only when the administrator saves**; there is no
silent SQLite mutation on startup.
### Configuration example
```json
{
"administration": {
"public_origin": "https://panel.example.test",
"allowed_hosts": []
}
}
```
This field still requires an HTTPS URL with one DNS hostname and an optional
port; userinfo, fragment, path, HTTP, IP literals and localhost remain invalid.
Changing either access-control field needs a RustProxy restart.
### Implementation and security
- `Administration::validate` normalizes the public URL independently of the
optional additional DNS list. `Administration::include_public_origin_host`
generates an **effective runtime/desired** Host allowlist once on startup /
status read, without adding to the persisted configuration or accepting a
wildcard. The originally saved allowlist is unchanged.
- `admin_host_allowed` and `configured_admin_public_authority` continue to
check the actual Host and the trusted last-hop proxy. Forwarded Host remains
single and identical to the pin. Only a bounded, first-hop HTTPS
X-Forwarded-Proto chain can be used with the explicit pin; no arbitrary
Origin or `Sec-Fetch-Site: cross-site` or missing CSRF is permitted.
- The explicitly opted-in self-admin vhost case may pass the reserved session
cookie when the incoming HTTPS host equals the pin; it still requires a
backend targeting the administration listener directly by IP and the prior
`accept_local_listener_risk` opt-in. No other proxy traffic changes.
- No new configuration key or SQLite migration. Older `allowed_hosts` lists
remain accepted, preserving backwards compatibility. Existing 0.31.3
mobile/BFCache recovery is untouched.
### Tests and acceptance gate
New Rust source tests also cover pin-only saved configuration, derived runtime
allowlist, additional legacy entries, changes/removal, invalid URL input,
same-origin session and `Secure` cookie, foreign Origin, untrusted peers,
forwarded-host spoofing and CSRF. Node.js runs a focused real settings-helper
test and a local HTTP simulation of the guard; these **do not** execute the
Rust server. Exact results are recorded in [TEST_REPORT](TEST_REPORT.md).
Production acceptance remains OPEN until Rust 1.99+ builds/tests pass with a
resolved Cargo.lock; a trusted reverse proxy and real mobile Chrome/Safari
are checked for repeated BFCache resumes, session expiry and negative
Origin/Host/CSRF cases. Never weaken the trusted-proxy list or disable Origin
validation to avoid `auth.invalid_origin`.
## Test coverage
Rust source tests in `src/settings/tests.rs` and `src/admin/tests/part_11.rs`
cover config parsing/normalization, persisted legacy defaults, restart gating,
trusted/foreign hosts, unknown/duplicate headers, cross-site metadata, HTTP
first hops, genuine same-origin session GET with multi-hop metadata, HTTPS
`Secure` session cookie, CSRF enforcement and opt-out behavior.
`tools/checks/admin_public_origin_0314_sim.mjs` and
`tools/checks/admin_public_origin_0314_config_sim.mjs` perform additional HTTP
guard *simulations*, not compiled RustProxy execution.
`tools/checks/admin_panel_config_0314_ui_test.mjs` checks the settings UI and form behavior. Record its result in
`docs/TEST_REPORT.md` only after it is run.
## Required production acceptance
1. `bash scripts/resolve_dependencies_0312.sh` on a Rust 1.99+ builder with
network/local audited dependencies; inspect regenerated lockfile.
2. `./dev.sh check && ./dev.sh test && ./dev.sh release` and Go tests.
3. Set an exact HTTPS panel origin (no duplicate DNS allowlist entry), restart, and verify
with a trusted real edge proxy. Keep RustProxy administration listening on
an isolated/private socket. Verify the first trusted hop sanitizes headers.
4. On iOS Safari and Android Chrome, perform 20+ visibility/BFCache resumes,
full refreshes, sessions renewed/expired, and a deliberate temporary 403.
Check HTTP 200 for valid `/api/auth/session` and `Secure` cookie; no reload
loops, blank screens, request floods, or leaked SSE/polling tasks.
5. Inject cross-origin, duplicate/foreign forwarded Host, downgraded protocol,
missing CSRF and untrusted-source requests; they must still fail. Confirm
certificate API automation and public vhosts behave identically to 0.31.3.
The original production cause cannot be declared fixed without representative
mobile/reverse-proxy request logs. This artifact is not compiler-verified unless
the release-host gate above passes.
+2 -1
View File
@@ -31,6 +31,7 @@ This is the canonical index for the current project documentation. The authentic
- [0.26.3 implementation note](V0.26.3_IMPLEMENTATION.md) - global interface scaling, chart themes, square draft dock and Settings navigation polish. - [0.26.3 implementation note](V0.26.3_IMPLEMENTATION.md) - global interface scaling, chart themes, square draft dock and Settings navigation polish.
- [0.26.2 implementation note](V0.26.2_IMPLEMENTATION.md) - unified Logs navigation, field-query search, dynamic JSON columns and RAW parsing. - [0.26.2 implementation note](V0.26.2_IMPLEMENTATION.md) - unified Logs navigation, field-query search, dynamic JSON columns and RAW parsing.
- [0.26.1 implementation note](V0.26.1_IMPLEMENTATION.md) - historical vhost-aware protocol logging and Stored Logs UX milestone. - [0.26.1 implementation note](V0.26.1_IMPLEMENTATION.md) - historical vhost-aware protocol logging and Stored Logs UX milestone.
- [0.31.4 administration public Origin and settings](IMPLEMENTATION_0.31.4.md) - opt-in HTTPS origin, one-field DNS allowlist, additional hosts in advanced settings, legacy deduplication, CSRF and remaining deployment checks.
- [0.31.3 mobile session / invalid-Origin recovery](IMPLEMENTATION_0.31.3.md) - deterministic BFCache and visibility session coordination, 403 diagnostics, recovery and remaining acceptance. - [0.31.3 mobile session / invalid-Origin recovery](IMPLEMENTATION_0.31.3.md) - deterministic BFCache and visibility session coordination, 403 diagnostics, recovery and remaining acceptance.
- [0.31.1 mobile panel bootstrap](IMPLEMENTATION_0.31.1.md) - panel script readiness, versioned assets, failure recovery and acceptance tests. - [0.31.1 mobile panel bootstrap](IMPLEMENTATION_0.31.1.md) - panel script readiness, versioned assets, failure recovery and acceptance tests.
- [0.31.0 security audit](SECURITY_AUDIT_0.31.0.md) - current API/panel/BAN/PURGE attack-surface review, hardening and remaining runtime verification. - [0.31.0 security audit](SECURITY_AUDIT_0.31.0.md) - current API/panel/BAN/PURGE attack-surface review, hardening and remaining runtime verification.
@@ -82,7 +83,7 @@ This is the canonical index for the current project documentation. The authentic
## Historical or version-labelled context ## 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 [IMPLEMENTATION_0.31.3.md](IMPLEMENTATION_0.31.3.md); [IMPLEMENTATION_0.31.1.md](IMPLEMENTATION_0.31.1.md) is retained as the prior mobile milestone; [IMPLEMENTATION_0.31.0.md](IMPLEMENTATION_0.31.0.md) is retained as the previous milestone; [V0.29.1_IMPLEMENTATION.md](V0.29.1_IMPLEMENTATION.md) is retained as historical context, and [V0.29.0_IMPLEMENTATION.md](V0.29.0_IMPLEMENTATION.md) is retained as the prior redirect/health/cache/log milestone; [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. 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 [IMPLEMENTATION_0.31.4.md](IMPLEMENTATION_0.31.4.md); [IMPLEMENTATION_0.31.3.md](IMPLEMENTATION_0.31.3.md) is retained as the previous mobile recovery milestone; [IMPLEMENTATION_0.31.1.md](IMPLEMENTATION_0.31.1.md) is retained as the prior mobile milestone; [IMPLEMENTATION_0.31.0.md](IMPLEMENTATION_0.31.0.md) is retained as the previous milestone; [V0.29.1_IMPLEMENTATION.md](V0.29.1_IMPLEMENTATION.md) is retained as historical context, and [V0.29.0_IMPLEMENTATION.md](V0.29.0_IMPLEMENTATION.md) is retained as the prior redirect/health/cache/log milestone; [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. 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.
+66
View File
@@ -114,12 +114,78 @@ accepted only when `administration.tls` is true. Plain remote HTTP remains avail
"tls": true, "tls": true,
"certificate_id": "admin-example", "certificate_id": "admin-example",
"allowed_hosts": ["admin.example.com"], "allowed_hosts": ["admin.example.com"],
"public_origin": null,
"session_days": 30, "session_days": 30,
"panel_compression": {"zstd": true, "br": true, "gzip": true} "panel_compression": {"zstd": true, "br": true, "gzip": true}
} }
} }
``` ```
### Fixed public origin for a reverse-proxied panel (0.31.4)
For a **panel** reached through a trusted reverse proxy, set the optional
`administration.public_origin` in **Settings → Administration** to the exact
browser-facing HTTPS origin (for example `https://panel.example.test`).
**Do not duplicate that hostname in `administration.allowed_hosts`.** Its DNS
hostname is implicitly included in the runtime panel Host allowlist. **Restart
RustProxy**; this is a restart-bound access-control setting. Omit it (`null`) for
direct administration or when forwarded headers are canonical. This setting
is not a wildcard, does not accept HTTP or a URL path, and requires a DNS
hostname rather than an IP address. SQLite schema 9 remains unchanged.
The advanced **Additional allowed DNS hostnames** field controls independent,
extra hostnames only. Older configurations containing both values are
accepted; on editing/saving the redundant duplicate is omitted from the
persisted list. If `public_origin` changes or is cleared, its old hostname is
no longer implicitly allowed (unless explicitly retained as an additional host).
Example for the default **private loopback** administration listener behind an
HTTPS-terminating proxy:
```json
{
"administration": {
"allowed_hosts": [],
"public_origin": "https://panel.example.test"
}
}
```
The last hop must connect from loopback or an existing explicitly trusted proxy
CIDR. If it is not loopback, configure **only the actual proxy peer CIDR** under
Forwarding → Trusted proxies; be aware the general trusted-proxy setting also
applies to public traffic and must never include untrusted clients. The
administration listener should not be reachable directly from external clients.
Do **not** grant trust to public networks to work around Origin failures.
The pinned URL, not an element of `X-Forwarded-Proto`, defines the panel
scheme/authority. A trusted, bounded proxy chain `https, http` (HTTPS on the
browser side, HTTP on an internal hop) is permitted in this explicit mode.
An `http` first hop, malformed/too-long protocol chain, duplicate/conflicting
`X-Forwarded-Host`, different `Origin`, `Sec-Fetch-Site: cross-site`, and missing
CSRF protection for state-changing requests still result in HTTP 403. The
public proxy/vhost data plane is **not changed**, except that an already opted-in
self-admin vhost can recognize the pinned hostname for its reserved panel session
cookie; other upstreams continue to strip it. Credential-bearing certificate
automation retains its original TLS/forwarding checks.
Prefer fixing the proxy's forwarding headers rather than configuring the pin
when practical. For a TLS-only public entry, the closest trusted hop should
**replace**, not append, forwarded headers using known values, for example:
```nginx
proxy_set_header Host panel.example.test;
proxy_set_header X-Forwarded-Host panel.example.test;
proxy_set_header X-Forwarded-Proto https;
```
Only use that `https` setting on an entry that actually verifies external TLS;
never trust an arbitrary inbound `X-Forwarded-Proto`. If multiple intermediate
proxy hops must append protocol values, the pinned mode provides an explicit
server-side comparison target without accepting a client-supplied origin as
authoritative. Confirm deployment using a real mobile browser and the safe
`administration origin rejected` logs before production rollout.
**Administration → Panel content compression** (next to the admin listener/TLS settings) **Administration → Panel content compression** (next to the admin listener/TLS settings)
controls Zstd, Brotli and gzip for the authenticated HTML document, JavaScript, controls Zstd, Brotli and gzip for the authenticated HTML document, JavaScript,
CSS and language bundles. Each browser request is negotiated using CSS and language bundles. Each browser request is negotiated using
+27 -1
View File
@@ -1,4 +1,30 @@
# Verification report - RustProxy 0.31.3 # Verification report - RustProxy 0.31.4
## 0.31.4 reissue - origin protection and one-field administration public URL (2026-10-11)
- **Reissue verification (2026-10-11):** rebased the complete previously prepared simplified-config source onto release identifier `0.31.4` in the manifest, existing resolved-lock snapshot package entry, OpenAPI, source/version checks, documentation and deterministic ZIP metadata. Merged the panel Origin and one-field config release notes; no backend validation logic changes were introduced during this renumbering.
- **PASS — executed for this reissue:** `version_contracts.py`, `docs_check.py`, `langs_check.py`, `check_source.py`, `release_smoke.py`, `security_contracts.py`, `dependency_upgrade_0312.py`, `rust_module_contracts.py`, `system_info_no_subprocess_contracts.py`, `source_module_contracts.py`, `memory_contracts.py`, `web_server_contracts.py`, `admin_surface_audit_0310.py`, `security_dependency_contracts.py`; 15 standalone Node frontend/simulation scripts (including the updated original 53-case and expanded 61-case administration simulations and 9 UI scenarios); `go test ./...` for `docker-agent`; `bash -n dev.sh` and OpenAPI JSON parsing.
- **Not executed for this reissue:** full Rust compile/test/release (`cargo` and `rustc` absent), real mobile Safari/Chrome or live reverse-proxy verification. Passing Node simulations is **not** proof of compiled Rust behavior. The inherited Cargo.lock requires verification and/or re-resolution on the declared Rust 1.99+ toolchain.
- **Scope:** panel administration settings/config and narrow self-admin cookie opt-in only. `allowed_hosts` stores only additional DNS hostnames; the HTTPS pin contributes its hostname to the active/desired Host allowlist without persisting a duplicate.
- **Compatibility:** older configurations with a duplicate hostname remain valid; validation canonicalizes the in-memory config and the next save omits the duplicate without an automatic SQLite migration; changing/removing the pin does not silently retain a stale implicit hostname.
- **Security:** Host, single forwarded Host, trusted reverse proxy, first-hop HTTPS, CSRF and `Origin`/cross-site guard rules are unchanged. No SQLite schema migration, TLS listener or other vhost route changes.
- **PASS — source/release gates:** `version_contracts.py`, `rust_module_contracts.py`, `check_source.py`, `release_smoke.py`, `docs_check.py`, `langs_check.py`, `security_contracts.py`, `admin_surface_audit_0310.py`, `security_dependency_contracts.py`, and `dependency_upgrade_0312.py`; SQLite schema-9/upgrade smoke and the source/HTML/Reader/translation checks also passed.
- **PASS — focused simulated regressions:** `admin_public_origin_0314_config_sim.mjs`: **61/61 local Node HTTP guard-simulation assertions**, comprising 37 cases and 24 repeated session GETs. Invalid forwarded-proto on 3/12 unpinned GETs produced 403; with a pinned HTTPS public URL and **no duplicate DNS allowlist entry**, the same sequence produced 0/12. This simulation is not compiled RustProxy and cannot prove actual mobile/proxy headers.
- **PASS — real frontend helper/UI checks:** `admin_panel_config_0314_ui_test.mjs`: **9/9 settings-normalization scenarios** plus form/HTML/locale/OpenAPI consistency; `panel_cache_http_0310_ui_test.mjs` also executes the updated real administration form callback with a duplicated legacy hostname and verifies it is omitted on save.
- **PASS — other standalone checks:** all 14 bundled Node UI/simulation scripts, memory model (300,000 simulated PURGEs), 2,000 HTTP requests to a **Python mock peer** (not RustProxy), and `cd docker-agent && go test ./...`.
- **Harness and compiler limitations:** an initial `./dev.sh test` run exposed a legacy UI-test harness that lacked the new form helper; the harness was corrected and passed separately. A subsequent monolithic run exceeded the execution time limit after the memory/load-model stage, so no complete `./dev.sh test` success is claimed. `./dev.sh check` exited **127** because `cargo`/`rustc` are not installed. New Rust tests in `src/settings/tests.rs` and `src/admin/tests/part_11.rs` were **not compiled/executed**. The inherited Cargo.lock still needs resolution on Rust 1.99+; live mobile and real reverse-proxy acceptance remain OPEN.
### Earlier 0.31.4 validation baseline (2026-10-11)
- **User scope:** admin panel only; public vhosts, proxy forwarding, streams and database migrations are unchanged. The 0.31.3 BFCache/session resume and visible retry protections are retained.
- **Source correction:** optional `administration.public_origin` pins a browser-facing HTTPS panel origin to a configured DNS allowlist; it takes effect after restart and only with a trusted last-hop proxy. It accepts bounded `X-Forwarded-Proto: https, http` without relying on an ambiguous forwarded-proto element for the expected origin. Without the pin, 0.31.3's strict handling remains.
- **Security:** actual Host is validated, forwarded Host must be single/matching, the protocol's first hop must be HTTPS, and Origin, cross-site and CSRF checks remain. Untrusted peers and credential-bearing certificate automation retain their previous scheme validation. The earlier build required explicit `allowed_hosts`; the reissue now derives the pinned hostname without wildcard trust.
- **Node HTTP guard simulation (not compiled RustProxy): PASS, 53/53 assertions**, including 29 individual scenarios and 24 repeated session GETs: without the pin and with intentionally malformed XFP every fourth request, **3/12 GETs returned 403**; with an explicit pin and otherwise identical simulated headers, **0/12 returned 403**. `tools/checks/admin_public_origin_0314_sim.mjs` checks relevant code anchors and operates a local Node HTTP test peer. This does not confirm production headers or backend runtime behavior.
- **PASS (2026-10-11):** `./dev.sh test` passed every available Rust-independent stage: source-module boundaries, version/contracts, SQLite schema-9 migrations and upgrade, security source/dependency checks, 300,000 PURGE-model iterations, 2,000 real HTTP requests to a **Python test peer**, all bundled Node/browser UI regressions (including BFCache/resume), 0.31.4 HTTP simulation, documentation and both language packs. Exit **127** at the missing Rust/Cargo toolchain guard only. The simulated peer is **not** a running RustProxy binary. `./dev.sh check` likewise exits 127 due to missing Cargo.
- **PASS:** `(cd docker-agent && go test ./...)`, `node --check tools/checks/admin_public_origin_0314_sim.mjs`; source-package size/integrity checks are performed separately.
- **Rust regression tests added, NOT EXECUTED:** `src/settings/tests.rs` and `src/admin/tests/part_11.rs` cover pin validation, restart, strict opt-out, session Secure cookie, CSRF, foreign Origin, cross-site, host spoofing and untrusted peers.
- **Remaining release gate OPEN:** the sandbox has no Rust/Cargo/rustfmt and the inherited lockfile remains unresolved against 0.31.2+ dependencies. Before production use: resolve on Rust 1.99+, format, clippy, test/build, then capture actual mobile Safari/Chrome and reverse-proxy traffic and run the acceptance matrix in [IMPLEMENTATION_0.31.4.md](IMPLEMENTATION_0.31.4.md). No claim is made that the original field `auth.invalid_origin` was directly observed or eliminated.
## 0.31.3 - invalid-Origin resume and mobile panel recovery (2026-10-11) ## 0.31.3 - invalid-Origin resume and mobile panel recovery (2026-10-11)
+6 -2
View File
@@ -893,7 +893,7 @@
"administrationCertificate": "Certificate", "administrationCertificate": "Certificate",
"administrationGeneratedCertificate": "Application-generated default certificate", "administrationGeneratedCertificate": "Application-generated default certificate",
"administrationHelp": "HTTPS allows the administration listener to bind beyond loopback. Plain HTTP on the HTTPS port is redirected automatically. Listener and certificate changes take effect after restart.", "administrationHelp": "HTTPS allows the administration listener to bind beyond loopback. Plain HTTP on the HTTPS port is redirected automatically. Listener and certificate changes take effect after restart.",
"administrationHosts": "Allowed DNS hostnames (optional strict allowlist)", "administrationHosts": "Additional allowed DNS hostnames",
"administrationHttps": "Enable HTTPS for administration", "administrationHttps": "Enable HTTPS for administration",
"administrationListen": "Listen address", "administrationListen": "Listen address",
"cacheOverhead": "Overhead", "cacheOverhead": "Overhead",
@@ -2332,6 +2332,10 @@
"cacheHttpInvalidationCurlNote": "PURGE removes an exact URL; BAN invalidates a path prefix for 1 hour. No admin cookie is needed, but your IP must be allowed. A reverse proxy must forward these methods and trusted client IP information.", "cacheHttpInvalidationCurlNote": "PURGE removes an exact URL; BAN invalidates a path prefix for 1 hour. No admin cookie is needed, but your IP must be allowed. A reverse proxy must forward these methods and trusted client IP information.",
"cacheHttpInvalidationTitle": "HTTP BAN / PURGE allowlist", "cacheHttpInvalidationTitle": "HTTP BAN / PURGE allowlist",
"cacheHttpInvalidationAddresses": "Permitted client IP addresses / CIDRs (one per line)", "cacheHttpInvalidationAddresses": "Permitted client IP addresses / CIDRs (one per line)",
"cacheHttpInvalidationHelp": "Empty = disabled. PURGE /path?query removes exact matches; BAN /path prevents use of cached URLs with that prefix (default TTL 1 hour, optional X-RustProxy-Ban-Ttl). BAN / requires X-RustProxy-Confirm-Wide: 1. Only the matched Host and vhost are affected. Behind a reverse proxy, configure trusted proxies and sanitize forwarding headers." "cacheHttpInvalidationHelp": "Empty = disabled. PURGE /path?query removes exact matches; BAN /path prevents use of cached URLs with that prefix (default TTL 1 hour, optional X-RustProxy-Ban-Ttl). BAN / requires X-RustProxy-Confirm-Wide: 1. Only the matched Host and vhost are affected. Behind a reverse proxy, configure trusted proxies and sanitize forwarding headers.",
"administrationPublicOrigin": "Public panel HTTPS origin (optional)",
"administrationHostsAdvanced": "Advanced: additional allowed DNS hostnames",
"administrationHostsHelp": "Enter only additional hostnames. The public panel URL is included automatically. Without that URL, this remains an independent strict allowlist.",
"administrationPublicOriginHelp": "Behind a trusted reverse proxy, enter the external HTTPS panel URL once. Its hostname is automatically allowed. Restart required; public proxy routing is unchanged."
} }
} }
+6 -2
View File
@@ -893,7 +893,7 @@
"administrationCertificate": "Certyfikat", "administrationCertificate": "Certyfikat",
"administrationGeneratedCertificate": "Domyślny certyfikat wygenerowany przez aplikację", "administrationGeneratedCertificate": "Domyślny certyfikat wygenerowany przez aplikację",
"administrationHelp": "HTTPS pozwala wystawić panel administracyjny poza loopback. Zwykły HTTP na porcie HTTPS jest automatycznie przekierowywany. Zmiany listenera i certyfikatu działają po restarcie.", "administrationHelp": "HTTPS pozwala wystawić panel administracyjny poza loopback. Zwykły HTTP na porcie HTTPS jest automatycznie przekierowywany. Zmiany listenera i certyfikatu działają po restarcie.",
"administrationHosts": "Dozwolone hosty DNS (opcjonalna ścisła lista)", "administrationHosts": "Dodatkowe dozwolone hosty DNS",
"administrationHttps": "Włącz HTTPS dla panelu administracyjnego", "administrationHttps": "Włącz HTTPS dla panelu administracyjnego",
"administrationListen": "Adres nasłuchu", "administrationListen": "Adres nasłuchu",
"cacheOverhead": "Narzut", "cacheOverhead": "Narzut",
@@ -2332,6 +2332,10 @@
"cacheHttpInvalidationCurlNote": "PURGE usuwa dokładny URL; BAN unieważnia prefiks ścieżki na 1 godzinę. Nie potrzeba ciasteczka administratora, lecz IP musi być na liście. Reverse proxy musi przekazywać te metody oraz poprawnie ustalone IP klienta.", "cacheHttpInvalidationCurlNote": "PURGE usuwa dokładny URL; BAN unieważnia prefiks ścieżki na 1 godzinę. Nie potrzeba ciasteczka administratora, lecz IP musi być na liście. Reverse proxy musi przekazywać te metody oraz poprawnie ustalone IP klienta.",
"cacheHttpInvalidationTitle": "Lista dozwolonych adresów HTTP BAN / PURGE", "cacheHttpInvalidationTitle": "Lista dozwolonych adresów HTTP BAN / PURGE",
"cacheHttpInvalidationAddresses": "Dozwolone adresy IP klientów / CIDR (jeden na wiersz)", "cacheHttpInvalidationAddresses": "Dozwolone adresy IP klientów / CIDR (jeden na wiersz)",
"cacheHttpInvalidationHelp": "Pusta lista = wyłączone. PURGE /ścieżka?zapytanie usuwa dokładne trafienia; BAN /ścieżka blokuje buforowane adresy URL z tym prefiksem (domyślnie 1 godzina, opcjonalnie X-RustProxy-Ban-Ttl). BAN / wymaga X-RustProxy-Confirm-Wide: 1. Operacje dotyczą wyłącznie dopasowanego hosta i vhosta. Za reverse proxy skonfiguruj zaufane proxy i usuwanie fałszywych nagłówków przekazywania." "cacheHttpInvalidationHelp": "Pusta lista = wyłączone. PURGE /ścieżka?zapytanie usuwa dokładne trafienia; BAN /ścieżka blokuje buforowane adresy URL z tym prefiksem (domyślnie 1 godzina, opcjonalnie X-RustProxy-Ban-Ttl). BAN / wymaga X-RustProxy-Confirm-Wide: 1. Operacje dotyczą wyłącznie dopasowanego hosta i vhosta. Za reverse proxy skonfiguruj zaufane proxy i usuwanie fałszywych nagłówków przekazywania.",
"administrationPublicOrigin": "Publiczny adres HTTPS panelu (opcjonalnie)",
"administrationHostsAdvanced": "Zaawansowane: dodatkowe dozwolone hosty DNS",
"administrationHostsHelp": "Wpisz wyłącznie dodatkowe domeny. Domena z publicznego adresu panelu jest dozwolona automatycznie. Bez tego adresu lista pozostaje niezależnym ścisłym ograniczeniem.",
"administrationPublicOriginHelp": "Za zaufanym reverse proxy wpisz tylko publiczny adres HTTPS panelu. Jego domena zostanie dozwolona automatycznie. Wymagany restart; ruch proxy bez zmian."
} }
} }
+2 -2
View File
@@ -8,8 +8,8 @@ if ! command -v cargo >/dev/null 2>&1 || ! command -v rustc >/dev/null 2>&1; the
exit 127 exit 127
fi fi
python3 tools/checks/preflight.py python3 tools/checks/preflight.py
if [[ "$(python3 scripts/version.py)" != '0.31.3' ]]; then if [[ "$(python3 scripts/version.py)" != '0.31.4' ]]; then
printf 'Expected RustProxy 0.31.3 sources.\n' >&2 printf 'Expected RustProxy 0.31.4 sources.\n' >&2
exit 1 exit 1
fi fi
printf 'Toolchain: '; rustc --version printf 'Toolchain: '; rustc --version
+66
View File
@@ -229,6 +229,72 @@ fn external_admin_authority(
} }
} }
// In pinned-origin mode accept only bounded, consistent proxy-hop metadata:
// the first hop must report browser HTTPS, later internal hops may use HTTP.
// We still do NOT pick a trusted scheme from these values; the explicit pin
// defines the panel origin. A clear public HTTP or unknown value fails closed.
fn pinned_admin_proto_compatible(headers: &HeaderMap) -> bool {
let mut hops = 0;
for value in headers.get_all("x-forwarded-proto").iter() {
let Ok(value) = value.to_str() else { return false; };
for part in value.split(',') {
hops += 1;
if hops > 8 { return false; }
let scheme = part.trim();
if (hops == 1 && scheme != "https")
|| !matches!(scheme, "http" | "https")
{
return false;
}
}
}
true
}
/// A configured panel URL is authoritative only for an explicitly trusted
/// direct reverse-proxy peer. In this mode X-Forwarded-Proto is deliberately
/// ignored: conflicting hop values such as "https, http" are not evidence of
/// the browser scheme. The public URL comes from administrator configuration,
/// not from a user-controlled header.
fn configured_admin_public_authority(
state: &App,
headers: &HeaderMap,
peer: Option<PeerAddr>,
raw_host: &str,
transport: Transport,
) -> Result<Option<String>, ()> {
let Some(origin) = state.active_administration.public_origin.as_deref() else {
return Ok(None);
};
if !trusted_admin_proxy(state, peer) {
// Never apply the pin to arbitrary direct/untrusted connections.
return Ok(None);
}
if !admin_host_allowed(
raw_host,
state.policy.admin_addr,
transport.tls,
&state.active_administration.allowed_hosts,
) {
return Err(());
}
let authority = origin.strip_prefix("https://").ok_or(())?;
if !pinned_admin_proto_compatible(headers) {
return Err(());
}
if headers.contains_key("x-forwarded-host") {
let forwarded = single_header(headers, "x-forwarded-host").ok_or(())?;
if forwarded.contains('@') {
return Err(());
}
let candidate = forwarded.parse::<http::uri::Authority>().map_err(|_| ())?;
if !authority_matches_origin(&candidate, authority, "https") {
return Err(());
}
}
Ok(Some(authority.to_owned()))
}
fn external_admin_scheme( fn external_admin_scheme(
state: &App, state: &App,
headers: &HeaderMap, headers: &HeaderMap,
+1
View File
@@ -244,6 +244,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"guides", "guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/STREAMS.md")), include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/STREAMS.md")),
), ),
reader_doc!("docs/IMPLEMENTATION_0.31.4.md", "technical"),
reader_doc!("docs/IMPLEMENTATION_0.31.3.md", "technical"), reader_doc!("docs/IMPLEMENTATION_0.31.3.md", "technical"),
reader_doc!("docs/IMPLEMENTATION_0.31.1.md", "technical"), reader_doc!("docs/IMPLEMENTATION_0.31.1.md", "technical"),
reader_doc!("docs/IMPLEMENTATION_0.31.0.md", "technical"), reader_doc!("docs/IMPLEMENTATION_0.31.0.md", "technical"),
+8 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3", "openapi": "3.0.3",
"info": { "info": {
"title": "RustProxy Administration API", "title": "RustProxy Administration API",
"version": "0.31.3", "version": "0.31.4",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust." "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": [ "servers": [
@@ -764,7 +764,13 @@
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "type": "string"
} },
"description": "Optional additional panel DNS hostnames. The hostname of public_origin is implicitly allowed and must not be duplicated."
},
"public_origin": {
"type": "string",
"nullable": true,
"description": "Optional administrator-pinned browser HTTPS origin behind a trusted reverse proxy. Its DNS hostname is automatically allowed without adding it to allowed_hosts. Restart required. Does not affect public proxy traffic."
}, },
"session_days": { "session_days": {
"type": "integer", "type": "integer",
+24 -2
View File
@@ -117,17 +117,39 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
let headers = request.headers(); let headers = request.headers();
let trusted_host = trusted_admin_proxy(&state, peer); let trusted_host = trusted_admin_proxy(&state, peer);
let trusted_proto = trusted_admin_forwarded_proto(&state, peer); let trusted_proto = trusted_admin_forwarded_proto(&state, peer);
// Pinned browser origin is panel-only. Certificate automation using API
// credentials keeps the original forwarding/TLS authorization semantics.
let automation_credentials = headers.contains_key(CERTIFICATE_API_KEY_HEADER)
|| (headers.contains_key(AUTHORIZATION)
&& certificate_api_operation(request.method(), request.uri().path()).is_some());
let pinned_authority_result = if automation_credentials {
Ok(None)
} else {
configured_admin_public_authority(&state, headers, peer, &raw_host, transport)
};
let pinned_authority = match pinned_authority_result {
Ok(value) => value,
Err(()) => return invalid_admin_origin(&request, "pinned_origin_mismatch", Some("https"),
state.active_administration.public_origin.as_deref().and_then(|value| value.strip_prefix("https://")),
trusted_host, trusted_proto),
};
let (scheme, external_authority) = if let Some(authority) = pinned_authority {
// HTTPS is a trusted, administrator-pinned identity here. Do not select
// any list element of an ambiguous forwarded-proto chain.
("https", authority)
} else {
let scheme = match external_admin_scheme(&state, headers, peer, transport) { let scheme = match external_admin_scheme(&state, headers, peer, transport) {
Ok(value) => value, Ok(value) => value,
Err(()) => { Err(()) => {
return invalid_admin_origin(&request, "external_scheme", None, None, trusted_host, trusted_proto); return invalid_admin_origin(&request, "external_scheme", None, None, trusted_host, trusted_proto);
} }
}; };
let external_authority = let authority = match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") {
match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") {
Ok(value) => value, Ok(value) => value,
Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")), Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")),
}; };
(scheme, authority)
};
if headers.get_all(ORIGIN).iter().count() > 1 if headers.get_all(ORIGIN).iter().count() > 1
|| headers.get(ORIGIN).is_some_and(|h| { || headers.get(ORIGIN).is_some_and(|h| {
h.to_str() h.to_str()
+3 -2
View File
@@ -123,10 +123,11 @@ fn status_payload(state: &App) -> Value {
desired_admin desired_admin
.allowed_hosts .allowed_hosts
.extend(certificate.hosts.clone()); .extend(certificate.hosts.clone());
}
}
desired_admin.include_public_origin_host();
desired_admin.allowed_hosts.sort(); desired_admin.allowed_hosts.sort();
desired_admin.allowed_hosts.dedup(); desired_admin.allowed_hosts.dedup();
}
}
let desired_admin_certificate_files = desired_admin let desired_admin_certificate_files = desired_admin
.certificate_id .certificate_id
.as_deref() .as_deref()
+1
View File
@@ -9,4 +9,5 @@ mod tests {
include!("tests/part_08.rs"); include!("tests/part_08.rs");
include!("tests/part_09.rs"); include!("tests/part_09.rs");
include!("tests/part_10.rs"); include!("tests/part_10.rs");
include!("tests/part_11.rs");
} }
+142
View File
@@ -0,0 +1,142 @@
#[test]
fn pinned_admin_proxy_proto_is_bounded_and_requires_external_https() {
let mut headers = HeaderMap::new();
assert!(pinned_admin_proto_compatible(&headers));
headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
assert!(pinned_admin_proto_compatible(&headers));
headers.insert("x-forwarded-proto", HeaderValue::from_static("https, http"));
assert!(pinned_admin_proto_compatible(&headers));
headers.append("x-forwarded-proto", HeaderValue::from_static("http"));
assert!(pinned_admin_proto_compatible(&headers));
for bad in ["http", "http, https", "https, ftp", "https,", ",https", "HTTPS", "https, http, http, http, http, http, http, http, http"] {
headers.remove("x-forwarded-proto");
headers.insert("x-forwarded-proto", HeaderValue::from_str(bad).unwrap());
assert!(!pinned_admin_proto_compatible(&headers), "must reject {bad}");
}
}
#[tokio::test]
async fn pinned_panel_origin_survives_multi_hop_proxy_without_relaxing_csrf() {
let mut config = Config::default();
config.administration.public_origin = Some("https://panel.example.test".into());
let (router, store, _dir, secret, csrf) = fixture_with_config(config).await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
// Without an explicit origin pin, 0.31.3 rejected this session GET as
// external_scheme although the browser address remained correct.
let result = router.clone().oneshot(request("/api/auth/session")
.header("cookie", &cookie)
.header("x-forwarded-host", "panel.example.test")
.header("x-forwarded-proto", "https, http")
.header("sec-fetch-site", "same-origin")
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(result.status(), StatusCode::OK);
let session_cookie = result.headers().get_all(SET_COOKIE).iter()
.filter_map(|v| v.to_str().ok()).find(|v| v.starts_with(auth::COOKIE_NAME)).unwrap();
assert!(session_cookie.contains("; Secure"));
// The incoming admin Host may itself be the pinned public DNS name.
// There is no separate allowed_hosts entry in the saved configuration.
let pinned_host = Request::builder().uri("/api/auth/session")
.header("host", "panel.example.test")
.header("cookie", &cookie)
.header("x-forwarded-host", "panel.example.test")
.header("x-forwarded-proto", "https, http")
.extension(PeerAddr("127.0.0.1:45003".parse().unwrap()))
.body(Body::empty()).unwrap();
assert_eq!(router.clone().oneshot(pinned_host).await.unwrap().status(), StatusCode::OK);
// Explicit Origin, CSRF token and app request marker remain mandatory
// for a state-changing request even when XFP contains multiple hops.
let unauthorized = router.clone().oneshot(request("/api/auth/logout")
.method("POST").header("cookie", &cookie)
.header("origin", "https://panel.example.test")
.header("x-forwarded-host", "panel.example.test")
.header("x-forwarded-proto", "https, http")
.extension(PeerAddr("127.0.0.1:45001".parse().unwrap()))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(unauthorized.status(), StatusCode::FORBIDDEN);
let authorized = router.oneshot(request("/api/auth/logout")
.method("POST").header("cookie", &cookie)
.header("origin", "https://panel.example.test")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", csrf)
.header("x-forwarded-host", "panel.example.test")
.header("x-forwarded-proto", "https, http")
.extension(PeerAddr("127.0.0.1:45002".parse().unwrap()))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(authorized.status(), StatusCode::OK);
store.close().await;
}
#[tokio::test]
async fn pinned_panel_origin_rejects_untrusted_proxy_host_and_origin() {
let mut config = Config::default();
config.administration.public_origin = Some("https://panel.example.test".into());
let (router, store, _dir, secret, _) = fixture_with_config(config).await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
for (xfh, xfp, origin, fetch_site) in [
("evil.example.test", "https, http", "https://panel.example.test", "same-origin"),
("other@panel.example.test", "https, http", "https://panel.example.test", "same-origin"),
("panel.example.test", "http", "https://panel.example.test", "same-origin"),
("panel.example.test", "http, https", "https://panel.example.test", "same-origin"),
("panel.example.test", "https, http", "https://evil.example.test", "same-origin"),
("panel.example.test", "https, http", "null", "same-origin"),
("panel.example.test", "https, http", "https://panel.example.test", "cross-site"),
] {
let result = router.clone().oneshot(request("/api/auth/session")
.header("cookie", &cookie)
.header("x-forwarded-host", xfh)
.header("x-forwarded-proto", xfp)
.header("origin", origin)
.header("sec-fetch-site", fetch_site)
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(result.status(), StatusCode::FORBIDDEN,
"must reject forwarded-host={xfh}, proto={xfp}, origin={origin}, fetch={fetch_site}");
}
// Credential-bearing certificate automation does not inherit the
// browser-origin override, even through the same trusted admin socket.
let automation = router.clone().oneshot(request("/api/certificates/deploy")
.method("POST")
.header(CERTIFICATE_API_KEY_HEADER, "svc-key")
.header("x-forwarded-host", "panel.example.test")
.header("x-forwarded-proto", "https, http")
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(automation.status(), StatusCode::FORBIDDEN);
let untrusted = router.clone().oneshot(request("/api/auth/session")
.header("cookie", &cookie)
.header("x-forwarded-host", "panel.example.test")
.header("x-forwarded-proto", "https, http")
.header("origin", "https://panel.example.test")
.extension(PeerAddr("192.0.2.42:44000".parse().unwrap()))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(untrusted.status(), StatusCode::FORBIDDEN);
let mut duplicated = request("/api/auth/session")
.header("cookie", &cookie)
.header("x-forwarded-host", "panel.example.test")
.header("x-forwarded-proto", "https, http")
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
.body(Body::empty()).unwrap();
duplicated.headers_mut().append("x-forwarded-host", "panel.example.test".parse().unwrap());
assert_eq!(router.clone().oneshot(duplicated).await.unwrap().status(), StatusCode::FORBIDDEN);
store.close().await;
}
#[tokio::test]
async fn without_public_origin_ambiguous_forwarded_proto_stays_forbidden() {
let mut config = Config::default();
config.administration.allowed_hosts = vec!["panel.example.test".into()];
let (router, store, _dir, secret, _) = fixture_with_config(config).await;
let result = router.oneshot(request("/api/auth/session")
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
.header("x-forwarded-host", "panel.example.test")
.header("x-forwarded-proto", "https, http")
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(result.status(), StatusCode::FORBIDDEN);
store.close().await;
}
+10 -2
View File
@@ -2734,9 +2734,17 @@
data-i18n="administrationHttps">Enable HTTPS for administration</span></label> data-i18n="administrationHttps">Enable HTTPS for administration</span></label>
<label><span data-i18n="administrationCertificate">Certificate</span><select <label><span data-i18n="administrationCertificate">Certificate</span><select
id="admin-certificate"></select></label> id="admin-certificate"></select></label>
<label><span data-i18n="administrationHosts">Allowed DNS hostnames</span><textarea <label><span data-i18n="administrationPublicOrigin">Public panel HTTPS origin (optional)</span><input
id="admin-public-origin" maxlength="255" placeholder="https://panel.example.com"
type="url" spellcheck="false" autocomplete="off" /></label>
<p class="fine" data-i18n="administrationPublicOriginHelp">When behind a trusted reverse proxy, enter the panel's external HTTPS address once. Its DNS name is automatically allowed. A restart is required.</p>
<details id="admin-extra-hosts-details">
<summary data-i18n="administrationHostsAdvanced">Advanced: additional allowed DNS hostnames</summary>
<label><span data-i18n="administrationHosts">Additional allowed DNS hostnames</span><textarea
id="admin-hosts" rows="4" spellcheck="false" id="admin-hosts" rows="4" spellcheck="false"
placeholder="admin.example.com"></textarea></label> placeholder="other-admin.example.com"></textarea></label>
<p class="fine" data-i18n="administrationHostsHelp">Only extra hostnames belong here. The hostname from the public panel URL is automatically included; without a public URL this remains an independent strict allowlist.</p>
</details>
<label><span data-i18n="administrationSessionDays">Administrator session lifetime <label><span data-i18n="administrationSessionDays">Administrator session lifetime
(days)</span><input id="admin-session-days" min="7" max="30" step="1" (days)</span><input id="admin-session-days" min="7" max="30" step="1"
type="number" required="" /></label> type="number" required="" /></label>
+18 -2
View File
@@ -8,6 +8,19 @@
? "logging" ? "logging"
: (typeof location !== "undefined" && location.pathname === "/settings" ? new URLSearchParams(location.search).get("tab") : null); : (typeof location !== "undefined" && location.pathname === "/settings" ? new URLSearchParams(location.search).get("tab") : null);
const tabAliases = { http2: "connections" }; const tabAliases = { http2: "connections" };
// Keep the public panel hostname implicit. Legacy configurations may have
// written it into allowed_hosts in 0.31.4; do not show/save it twice.
function pinnedAdminHostname(value) {
try {
const url = new URL(value);
return url.protocol === "https:" ? url.hostname.toLowerCase().replace(/\.$/, "") : "";
} catch { return ""; }
}
function additionalAdminHosts(hosts, origin) {
const pinned = pinnedAdminHostname(origin);
return hosts.map(host => host.trim()).filter(host =>
host && (!pinned || host.replace(/\.$/, "").toLowerCase() !== pinned));
}
const normalizedRequestedTab = tabAliases[requestedTab] || requestedTab; const normalizedRequestedTab = tabAliases[requestedTab] || requestedTab;
let active = buttons.some(button => button.dataset.settingsTab === normalizedRequestedTab) ? normalizedRequestedTab : defaultTab; let active = buttons.some(button => button.dataset.settingsTab === normalizedRequestedTab) ? normalizedRequestedTab : defaultTab;
function applySettingsSearch() { function applySettingsSearch() {
@@ -212,7 +225,10 @@ curl --fail --show-error --request POST \
$("admin-tls").checked = !!admin.tls; $("admin-tls").checked = !!admin.tls;
const current = admin.certificate_id || "", generated = document.createElement("option"); generated.value = ""; generated.textContent = t("administrationGeneratedCertificate"); const current = admin.certificate_id || "", generated = document.createElement("option"); generated.value = ""; generated.textContent = t("administrationGeneratedCertificate");
$("admin-certificate").replaceChildren(generated, ...(envelope.config.tls?.certificates || []).map(cert => { const o = document.createElement("option"); o.value = cert.id; o.textContent = cert.id; return o; })); $("admin-certificate").value = current; $("admin-certificate").replaceChildren(generated, ...(envelope.config.tls?.certificates || []).map(cert => { const o = document.createElement("option"); o.value = cert.id; o.textContent = cert.id; return o; })); $("admin-certificate").value = current;
$("admin-hosts").value = (admin.allowed_hosts || []).join("\n"); const extraHosts = additionalAdminHosts(admin.allowed_hosts || [], admin.public_origin);
$("admin-hosts").value = extraHosts.join("\n");
$("admin-extra-hosts-details").open = extraHosts.length > 0;
$("admin-public-origin").value = admin.public_origin || "";
$("admin-session-days").value = Number(admin.session_days || 30); $("admin-session-days").value = Number(admin.session_days || 30);
for (const codec of ["zstd", "br", "gzip"]) $(`admin-compression-${codec}`).checked = admin.panel_compression?.[codec] !== false; for (const codec of ["zstd", "br", "gzip"]) $(`admin-compression-${codec}`).checked = admin.panel_compression?.[codec] !== false;
} }
@@ -300,7 +316,7 @@ curl --fail --show-error --request POST \
}, "prometheus"); }, "prometheus");
bindSettingsForm("administration-form", next => { bindSettingsForm("administration-form", next => {
const locked = !!latestStatus?.administration?.listen_locked; const locked = !!latestStatus?.administration?.listen_locked;
next.administration = { listen: locked ? null : $("admin-listen").value.trim(), tls: $("admin-tls").checked, certificate_id: $("admin-tls").checked ? ($("admin-certificate").value || null) : null, allowed_hosts: $("admin-hosts").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean), session_days: Number($("admin-session-days").value), panel_compression: Object.fromEntries(["zstd", "br", "gzip"].map(codec => [codec, $(`admin-compression-${codec}`).checked])) }; next.administration = { listen: locked ? null : $("admin-listen").value.trim(), tls: $("admin-tls").checked, certificate_id: $("admin-tls").checked ? ($("admin-certificate").value || null) : null, allowed_hosts: additionalAdminHosts($("admin-hosts").value.split(/[\n,]+/), $("admin-public-origin").value.trim()), public_origin: $("admin-public-origin").value.trim() || null, session_days: Number($("admin-session-days").value), panel_compression: Object.fromEntries(["zstd", "br", "gzip"].map(codec => [codec, $(`admin-compression-${codec}`).checked])) };
}, "administration"); }, "administration");
bindSettingsForm("tls-policy-form", next => { bindSettingsForm("tls-policy-form", next => {
if (!$("tls-policy-12").checked && !$("tls-policy-13").checked) throw fieldError($("tls-policy-12"), t("tlsPolicyVersionRequired")); if (!$("tls-policy-12").checked && !$("tls-policy-13").checked) throw fieldError($("tls-policy-12"), t("tlsPolicyVersionRequired"));
+4 -5
View File
@@ -181,8 +181,9 @@ fn response_context(
} }
/// Reserved administration-session cookies may cross the proxy boundary only /// Reserved administration-session cookies may cross the proxy boundary only
/// for the explicit self-admin deployment: HTTPS, an explicitly allowed public /// for the explicit self-admin deployment: HTTPS, a specifically allowed
/// administration hostname, a backend that directly targets the configured /// administration hostname (extra DNS list or pinned public HTTPS origin),
/// a backend that directly targets the configured
/// administration listener, and the existing local-listener risk opt-in. /// administration listener, and the existing local-listener risk opt-in.
fn admin_session_passthrough( fn admin_session_passthrough(
snapshot: &crate::config::Snapshot, snapshot: &crate::config::Snapshot,
@@ -196,9 +197,7 @@ fn admin_session_passthrough(
.envelope .envelope
.config .config
.administration .administration
.allowed_hosts .explicitly_allowed_panel_host(host)
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(host))
} }
fn apply_response_rules( fn apply_response_rules(
+3 -2
View File
@@ -145,10 +145,11 @@ impl App {
active_administration active_administration
.allowed_hosts .allowed_hosts
.extend(certificate.hosts.clone()); .extend(certificate.hosts.clone());
}
}
active_administration.include_public_origin_host();
active_administration.allowed_hosts.sort(); active_administration.allowed_hosts.sort();
active_administration.allowed_hosts.dedup(); active_administration.allowed_hosts.dedup();
}
}
let logs = Logs::start(log_settings, shutdown.clone()); let logs = Logs::start(log_settings, shutdown.clone());
let traffic = crate::traffic::Analytics::start(shutdown.clone()); let traffic = crate::traffic::Analytics::start(shutdown.clone());
let app = Arc::new(Self { let app = Arc::new(Self {
+18 -2
View File
@@ -1,5 +1,5 @@
//! Persisted listener intent and response controls. Listener changes require restart. //! Persisted listener intent and response controls. Listener changes require restart.
use anyhow::{ensure, Result}; use anyhow::{ensure, Context as _, Result};
use http::{HeaderMap, HeaderValue}; use http::{HeaderMap, HeaderValue};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{net::SocketAddr, time::Duration}; use std::{net::SocketAddr, time::Duration};
@@ -195,8 +195,13 @@ pub struct Administration {
pub tls: bool, pub tls: bool,
/// None uses the application-managed default certificate pair. /// None uses the application-managed default certificate pair.
pub certificate_id: Option<String>, pub certificate_id: Option<String>,
/// Optional strict DNS Host/Origin allowlist. Empty HTTPS lists accept valid DNS names. /// Optional additional DNS Host allowlist. The hostname of public_origin is
/// included automatically at runtime, never duplicated in persisted config.
/// With no public_origin, an empty HTTPS list retains the legacy DNS policy.
pub allowed_hosts: Vec<String>, pub allowed_hosts: Vec<String>,
/// Optional fixed browser-facing HTTPS origin for the panel behind a trusted
/// reverse proxy. It is NEVER inferred from forwarded headers.
pub public_origin: Option<String>,
/// Absolute browser-session lifetime. Existing sessions are bounded by the current value. /// Absolute browser-session lifetime. Existing sessions are bounded by the current value.
pub session_days: u16, pub session_days: u16,
pub panel_compression: PanelCompression, pub panel_compression: PanelCompression,
@@ -208,6 +213,7 @@ impl Default for Administration {
tls: false, tls: false,
certificate_id: None, certificate_id: None,
allowed_hosts: Vec::new(), allowed_hosts: Vec::new(),
public_origin: None,
session_days: 30, session_days: 30,
panel_compression: PanelCompression::default(), panel_compression: PanelCompression::default(),
} }
@@ -225,6 +231,7 @@ impl Administration {
|| self.tls != active.tls || self.tls != active.tls
|| self.certificate_id != active.certificate_id || self.certificate_id != active.certificate_id
|| self.allowed_hosts != active.allowed_hosts || self.allowed_hosts != active.allowed_hosts
|| self.public_origin != active.public_origin
} }
pub fn validate(&mut self, tls: &crate::tls::Settings) -> Result<()> { pub fn validate(&mut self, tls: &crate::tls::Settings) -> Result<()> {
if let Some(addr) = self.listen { if let Some(addr) = self.listen {
@@ -263,6 +270,14 @@ impl Administration {
"duplicate administration hostname" "duplicate administration hostname"
); );
} }
if let Some(public_origin) = self.public_origin.as_mut() {
*public_origin = normalize_public_admin_origin(public_origin)?;
}
// Accept old 0.31.4 saved configs, but never retain a second explicit
// copy of the pinned hostname in a validated or newly saved config.
if let Some(host) = self.public_origin_host().map(str::to_owned) {
self.allowed_hosts.retain(|allowed| !allowed.eq_ignore_ascii_case(&host));
}
Ok(()) Ok(())
} }
} }
@@ -467,5 +482,6 @@ impl ServerHeader {
} }
} }
} }
include!("settings/public_origin.rs");
#[cfg(test)] #[cfg(test)]
include!("settings/tests.rs"); include!("settings/tests.rs");
+58
View File
@@ -0,0 +1,58 @@
// Normalize the browser-facing HTTPS origin independently of the optional
// additional DNS allowlist. It is never inferred from forwarding headers.
fn normalize_public_admin_origin(public_origin: &str) -> Result<String> {
ensure!(
public_origin.len() <= 255 && !public_origin.chars().any(|c| matches!(c, '@' | '#' | '?' | ',' | '\\')),
"administration public_origin must be a single HTTPS origin without userinfo, query or fragment"
);
let uri = public_origin.parse::<http::Uri>()
.context("invalid administration public_origin")?;
ensure!(
uri.scheme_str() == Some("https")
&& uri.query().is_none()
&& matches!(uri.path(), "" | "/"),
"administration public_origin must be HTTPS without a path or query"
);
let authority = uri.authority()
.context("administration public_origin requires an authority")?;
// This setting is for a named HTTPS origin. Use the existing direct
// listener/Host policy for IP addresses and localhost.
let host = crate::config::normalize_host(authority.host())?;
ensure!(
host.parse::<std::net::IpAddr>().is_err() && host != "localhost",
"administration public_origin must name a DNS hostname"
);
let port = if authority.as_str().contains(':') {
let port = authority.port_u16()
.context("invalid administration public_origin port")?;
ensure!(port > 0, "administration public_origin port must be nonzero");
format!(":{port}")
} else {
String::new()
};
Ok(format!("https://{host}{port}"))
}
impl Administration {
/// The origin is validated and normalized before it is stored, so this
/// returns only its DNS hostname (never a forwarded or client-supplied host).
pub fn public_origin_host(&self) -> Option<&str> {
self.public_origin.as_deref()
.and_then(|url| url.strip_prefix("https://"))
.and_then(|authority| authority.split(':').next())
}
/// Used only by the explicitly opted-in panel session-cookie passthrough.
pub fn explicitly_allowed_panel_host(&self, host: &str) -> bool {
self.allowed_hosts.iter().any(|allowed| allowed.eq_ignore_ascii_case(host))
|| self.public_origin_host().is_some_and(|name| name.eq_ignore_ascii_case(host))
}
/// Build the access-control allowlist for the active/desired panel state.
/// Never mutate the saved configuration or accumulate stale hosts on edits.
pub fn include_public_origin_host(&mut self) {
if let Some(host) = self.public_origin_host().map(str::to_owned) {
if !self.allowed_hosts.iter().any(|allowed| allowed.eq_ignore_ascii_case(&host)) {
self.allowed_hosts.push(host);
}
}
}
}
+61
View File
@@ -256,6 +256,67 @@ mod tests {
admin.allowed_hosts = vec!["panel.example.com".into(), "PANEL.EXAMPLE.COM.".into()]; admin.allowed_hosts = vec!["panel.example.com".into(), "PANEL.EXAMPLE.COM.".into()];
assert!(admin.validate(&crate::tls::Settings::default()).is_err()); assert!(admin.validate(&crate::tls::Settings::default()).is_err());
} }
#[test]
fn administration_public_origin_is_https_explicit_and_restart_bound() {
let tls = crate::tls::Settings::default();
let mut admin = Administration {
public_origin: Some("https://Panel.Example.TEST:8443/".into()),
..Administration::default()
};
admin.validate(&tls).unwrap();
assert_eq!(admin.public_origin.as_deref(), Some("https://panel.example.test:8443"));
assert_eq!(admin.public_origin_host(), Some("panel.example.test"));
assert!(admin.allowed_hosts.is_empty(), "pin must not be stored twice");
assert!(admin.explicitly_allowed_panel_host("PANEL.EXAMPLE.TEST"));
assert!(!admin.explicitly_allowed_panel_host("other.example.test"));
let mut runtime = admin.clone();
runtime.include_public_origin_host();
runtime.include_public_origin_host();
assert_eq!(runtime.allowed_hosts, vec!["panel.example.test".to_owned()]);
assert!(admin.allowed_hosts.is_empty(), "saved config remains unchanged");
let active = Administration { public_origin: None, ..admin.clone() };
assert!(admin.restart_differs(&active));
let mut legacy: Administration = serde_json::from_str(r#"{"allowed_hosts":["panel.example.test"]}"#).unwrap();
legacy.validate(&tls).unwrap();
assert_eq!(legacy.public_origin, None);
assert!(legacy.explicitly_allowed_panel_host("panel.example.test"));
let mut old_pin: Administration = serde_json::from_str(
r#"{"public_origin":"https://panel.example.test","allowed_hosts":["PANEL.Example.TEST.","other.example.test"]}"#
).unwrap();
old_pin.validate(&tls).unwrap();
assert_eq!(old_pin.allowed_hosts, vec!["other.example.test".to_owned()]);
assert!(old_pin.explicitly_allowed_panel_host("panel.example.test"));
assert!(old_pin.explicitly_allowed_panel_host("other.example.test"));
// Additional hosts are independent: editing/removing the pinned origin
// must not leave its former hostname in the saved strict list.
let mut changed = admin.clone();
changed.public_origin = Some("https://other.example.test".into());
changed.validate(&tls).unwrap();
assert_eq!(changed.public_origin_host(), Some("other.example.test"));
assert!(!changed.explicitly_allowed_panel_host("panel.example.test"));
let mut opted_out = changed;
opted_out.public_origin = None;
opted_out.validate(&tls).unwrap();
assert!(opted_out.allowed_hosts.is_empty());
for invalid in [
"http://panel.example.test",
"https://*.example.test",
"https://localhost",
"https://127.0.0.1",
"https://panel.example.test/a",
"https://panel.example.test/?x=1",
"https://admin@panel.example.test",
"https://panel.example.test#frag",
"https://panel.example.test:0",
"https://panel.example.test:99999",
"https://panel.example.test,https://evil.example.test",
] {
let mut candidate = admin.clone();
candidate.public_origin = Some(invalid.into());
assert!(candidate.validate(&tls).is_err(), "must reject {invalid}");
}
}
#[test] #[test]
fn administration_hostname_count_is_not_artificially_capped() { fn administration_hostname_count_is_not_artificially_capped() {
let mut admin = Administration { let mut admin = Administration {
@@ -0,0 +1,52 @@
// Test the real admin settings normalization helpers and visible form wiring;
// source-level UI test only, not a Rust or browser integration test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const js = fs.readFileSync('src/admin/web/settings.js','utf8');
const html = fs.readFileSync('src/admin/web/index.html','utf8');
const en = JSON.parse(fs.readFileSync('langs/en_US.json','utf8'));
const pl = JSON.parse(fs.readFileSync('langs/pl_pl.json','utf8'));
const api = JSON.parse(fs.readFileSync('src/admin/openapi.json','utf8'));
const begin = js.indexOf(' function pinnedAdminHostname(value) {');
const end = js.indexOf(' const normalizedRequestedTab', begin);
assert(begin >= 0 && end > begin, 'real admin form normalization helpers must exist');
const sandbox = vm.createContext({URL});
vm.runInContext(js.slice(begin,end),sandbox);
const normalize = (hosts, pin) => Array.from(vm.runInContext(
`additionalAdminHosts(${JSON.stringify(hosts)},${JSON.stringify(pin)})`,sandbox));
const cases = [
{name:'empty form',hosts:[],pin:null,expected:[]},
{name:'simple single origin',hosts:[],pin:'https://panel.example.test',expected:[]},
{name:'0.31.4 repeated host',hosts:['panel.example.test'],pin:'https://panel.example.test',expected:[]},
{name:'case and trailing dot',hosts:['PANEL.Example.Test.',' other.example.test '],pin:'https://panel.example.test:8443',expected:['other.example.test']},
{name:'multiple additional names',hosts:['foo.example.test','panel.example.test','bar.example.test'],pin:'https://panel.example.test',expected:['foo.example.test','bar.example.test']},
{name:'pin changed',hosts:['other.example.test'],pin:'https://new.example.test',expected:['other.example.test']},
{name:'no pin means independent allowlist',hosts:['panel.example.test'],pin:'',expected:['panel.example.test']},
{name:'invalid URL delegates to server validation',hosts:['other.example.test'],pin:'not a url',expected:['other.example.test']},
{name:'blank host lines removed',hosts:['',' panel.example.test ',' '],pin:null,expected:['panel.example.test']},
];
for (const item of cases) {
assert.deepEqual(normalize(item.hosts,item.pin),item.expected,item.name);
}
const admin = html.split('id="settings-page-administration"')[1].split('id="settings-page-system-info"')[0];
for (const id of ['admin-public-origin','admin-extra-hosts-details','admin-hosts'])
assert(admin.includes(`id="${id}"`),`missing ${id}`);
assert(admin.indexOf('id="admin-public-origin"') < admin.indexOf('id="admin-extra-hosts-details"'));
assert(admin.includes('<details id="admin-extra-hosts-details">'));
assert(js.includes('$("admin-extra-hosts-details").open = extraHosts.length > 0'));
assert(js.includes('additionalAdminHosts(admin.allowed_hosts || [], admin.public_origin)'));
assert(js.includes('allowed_hosts: additionalAdminHosts($("admin-hosts").value.split('));
assert(!js.includes('allowed_hosts: $("admin-hosts").value.split('));
for (const key of ['administrationHosts','administrationHostsAdvanced','administrationHostsHelp','administrationPublicOriginHelp']) {
assert.equal(typeof en.translations[key],'string'); assert.equal(typeof pl.translations[key],'string');
assert(en.translations[key].length > 15 && pl.translations[key].length > 15,key);
}
// Config contract remains compatible; the pin allows a host implicitly.
const spec = api.components?.schemas || api.definitions;
const specText=JSON.stringify(spec);
assert(specText.includes('implicitly allowed'));
assert(specText.includes('automatically allowed'));
console.log(`PASS: ${cases.length} real settings-helper scenarios, HTML/wiring, locale parity and OpenAPI descriptions`);
@@ -0,0 +1,210 @@
// Reconstruct ONLY the RustProxy 0.31.4 administration Origin guard decision.
// Real-source anchors guard against accidental model/source drift. This is NOT
// a Rust server, not a browser, and not an HTTP/2 or TLS integration test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import http from 'node:http';
import {fileURLToPath} from 'node:url';
import path from 'node:path';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = p => fs.readFileSync(path.join(root, p), 'utf8');
const source = {
settings: read('src/settings.rs') + read('src/settings/public_origin.rs'), access: read('src/admin/access.rs'),
session: read('src/admin/session.rs'), forwarding: read('src/forwarding.rs'),
};
for (const [part, tokens] of Object.entries({
settings: ['pub public_origin: Option<String>', 'fn normalize_public_admin_origin(public_origin: &str)', 'fn include_public_origin_host(&mut self)', 'fn explicitly_allowed_panel_host(&self, host: &str)', 'self.public_origin != active.public_origin'],
access: ['fn configured_admin_public_authority(', 'fn pinned_admin_proto_compatible(', 'hops > 8', 'scheme != "https"', 'trusted_admin_proxy(state, peer)'],
session: ['configured_admin_public_authority(&state, headers, peer, &raw_host, transport)', 'automation_credentials', 'pinned_origin_mismatch', '"origin_mismatch"', '"cross_site_fetch"'],
forwarding: ['self.trust_proto && self.trusted(ip)'],
})) for (const token of tokens) assert(source[part].includes(token), `Source contract missing: ${part}/${token}`);
const defaults = {host:'127.0.0.1:9090', 'x-forwarded-host':'panel.example.test',
'x-forwarded-proto':'https', 'sec-fetch-site':'same-origin'};
const context = {listenIp:'127.0.0.1', listenPort:9090, tls:false,
peerTrusted:true, trustProto:true, publicOrigin:null, allowedHosts:['panel.example.test'],
automation:false};
const normal = x => x.toLowerCase();
function authority(value) {
if (typeof value !== 'string' || !value || value.includes('@') || value.includes(',') || value.includes('/')) return null;
try {
const parsed = new URL(`https://${value}/`);
if (parsed.username || parsed.password || parsed.hash || parsed.search || parsed.pathname !== '/') return null;
const host = parsed.hostname.toLowerCase();
return {host, port:parsed.port ? Number(parsed.port) : null};
} catch {return null;}
}
function sameAuthority(a,b,scheme) {
const left=authority(a), right=authority(b);
if (!left || !right) return false;
const defaultPort = scheme === 'https' ? 443 : 80;
return left.host===right.host && (left.port ?? defaultPort)===(right.port ?? defaultPort);
}
function hostAllowed(value, tls, cfg) {
const actual=authority(value);
if (!actual) return false;
const port=actual.port ?? (tls ? 443 : 80);
// Effective runtime/desired allowlist also includes the public origin host;
// only extra names are persisted in allowed_hosts.
const pinnedHost=cfg.publicOrigin ? new URL(cfg.publicOrigin).hostname.toLowerCase() : null;
return (actual.host===cfg.listenIp && port===cfg.listenPort)
|| cfg.allowedHosts.includes(actual.host)
|| actual.host===pinnedHost;
}
const map = raw => {
const headers=new Map();
for (let i=0;i<raw.length;i+=2) {
const name=normal(raw[i]);
headers.set(name,[...(headers.get(name)||[]),raw[i+1]]);
}
return headers;
};
const all = (headers,name) => headers.get(name)||[];
const single = (headers,name) => all(headers,name).length===1 ? all(headers,name)[0] : null;
function protoCompatible(headers) {
let hops=0;
for (const value of all(headers,'x-forwarded-proto')) {
for (const part of value.split(',')) {
hops++;
const scheme=part.trim();
if (hops>8 || (hops===1 && scheme!=='https') || !['http','https'].includes(scheme)) return false;
}
}
return true;
}
function originMatches(value,scheme,external) {
if (typeof value!=='string' || value.includes('#') || value.includes('@')) return false;
try {
const uri=new URL(value);
if (uri.protocol!==`${scheme}:` || uri.pathname!=='/' || uri.search || uri.hash || uri.username || uri.password) return false;
return sameAuthority(uri.host, external, scheme);
} catch {return false;}
}
function guard(headers,cfg) {
const rawHost=single(headers,'host');
if (!rawHost) return {status:403,reason:'invalid_host'};
let scheme=cfg.tls ? 'https' : 'http', external=rawHost;
// 0.31.4 public pin only behind a trusted last hop and only for panel traffic.
if (cfg.publicOrigin && cfg.peerTrusted && !cfg.automation) {
let pinned;
try { pinned=new URL(cfg.publicOrigin); } catch {return {status:403,reason:'pinned_origin_mismatch'};}
if (pinned.protocol!=='https:' || !hostAllowed(rawHost,cfg.tls,cfg) || !protoCompatible(headers))
return {status:403,reason:'pinned_origin_mismatch'};
const xfh=all(headers,'x-forwarded-host');
if (xfh.length>1 || xfh.length===1 && !sameAuthority(xfh[0],pinned.host,'https'))
return {status:403,reason:'pinned_origin_mismatch'};
scheme='https'; external=pinned.host;
} else {
if (!cfg.tls && cfg.peerTrusted && cfg.trustProto && all(headers,'x-forwarded-proto').length) {
const proto=single(headers,'x-forwarded-proto');
if (proto!=='https' && proto!=='http') return {status:403,reason:'external_scheme'};
scheme=proto;
}
if (cfg.peerTrusted && all(headers,'x-forwarded-host').length)
external=single(headers,'x-forwarded-host');
if (!hostAllowed(external,scheme==='https',cfg))
return {status:403,reason:'invalid_host'};
}
const originValues=all(headers,'origin');
if (originValues.length>1 || originValues.length===1 && !originMatches(originValues[0],scheme,external))
return {status:403,reason:'origin_mismatch'};
if (all(headers,'sec-fetch-site').includes('cross-site'))
return {status:403,reason:'cross_site_fetch'};
return {status:200,reason:'pass',scheme};
}
const cases=[];
const add=(name,patch,opts,expected)=>cases.push({name,patch,opts,expected});
const PIN={publicOrigin:'https://panel.example.test',allowedHosts:[]};
add('legacy normal same-origin GET', {},{},'pass');
add('no pin and no DNS allowlist rejects proxied host', {},{allowedHosts:[]},'invalid_host');
add('legacy malformed multi-hop chain', {'x-forwarded-proto':'https, http'},{},'external_scheme');
add('legacy duplicated XFP lines', {'x-forwarded-proto':['https','http']},{},'external_scheme');
add('legacy explicit DNS host without pin', {'host':'other.example.test','x-forwarded-host':'other.example.test'},
{allowedHosts:['other.example.test']}, 'pass');
add('legacy extra host without pin still denies unknown', {'host':'evil.example.test','x-forwarded-host':'evil.example.test'},
{allowedHosts:['other.example.test']}, 'invalid_host');
add('pin: normal GET, persisted allowed_hosts empty', {},PIN,'pass');
add('pin: public Host implicitly allowed', {'host':'panel.example.test'},PIN,'pass');
add('pin: unknown Host still rejected', {'host':'unknown.example.test'},PIN,'pinned_origin_mismatch');
add('pin: old 0.31.4 explicit hostname remains compatible', {}, {...PIN,allowedHosts:['panel.example.test']},'pass');
add('pin: additional DNS host accepted only with matching forwarded public Host',
{'host':'other.example.test'}, {...PIN,allowedHosts:['other.example.test']},'pass');
add('pin: changing public origin does not allow previous hostname',
{'host':'panel.example.test','x-forwarded-host':'new.example.test'},
{...PIN,publicOrigin:'https://new.example.test'},'pinned_origin_mismatch');
add('pin: GET multi-hop https then http',{'x-forwarded-proto':'https, http'},PIN,'pass');
add('pin: duplicate XFP lines',{'x-forwarded-proto':['https','http']},PIN,'pass');
add('pin: 8-hop bounded chain',{'x-forwarded-proto':'https, http, http, http, http, http, http, http'},PIN,'pass');
add('pin: 9-hop rejected',{'x-forwarded-proto':'https, http, http, http, http, http, http, http, http'},PIN,'pinned_origin_mismatch');
add('pin: no XFP; public origin configured',{'x-forwarded-proto':null},PIN,'pass');
add('pin: no XFH; internal Host still validated',{'x-forwarded-host':null,'origin':'https://panel.example.test'},PIN,'pass');
add('pin: wrong XFH',{'x-forwarded-host':'attacker.example.test'},PIN,'pinned_origin_mismatch');
add('pin: different allowed Host but wrong configured URL',{'x-forwarded-host':'panel.example.test:8443'},PIN,'pinned_origin_mismatch');
add('pin: XFH userinfo rejected',{'x-forwarded-host':'attacker@panel.example.test'},PIN,'pinned_origin_mismatch');
add('pin: duplicate XFH lines',{'x-forwarded-host':['panel.example.test','panel.example.test']},PIN,'pinned_origin_mismatch');
add('pin: first hop downgraded HTTP',{'x-forwarded-proto':'http, https'},PIN,'pinned_origin_mismatch');
add('pin: HTTP only',{'x-forwarded-proto':'http'},PIN,'pinned_origin_mismatch');
add('pin: unrecognized protocol',{'x-forwarded-proto':'ftp, http'},PIN,'pinned_origin_mismatch');
add('pin: empty protocol element',{'x-forwarded-proto':'https, '},PIN,'pinned_origin_mismatch');
add('pin: invalid XFP capitalization',{'x-forwarded-proto':'HTTPS'},PIN,'pinned_origin_mismatch');
add('pin: correct explicit browser Origin',{'origin':'https://panel.example.test','x-forwarded-proto':'https, http'},PIN,'pass');
add('pin: correct explicit Origin with :443',{'origin':'https://panel.example.test:443'},PIN,'pass');
add('pin: attacker Origin rejected',{'origin':'https://attacker.example.test'},PIN,'origin_mismatch');
add('pin: Origin null rejected',{'origin':'null'},PIN,'origin_mismatch');
add('pin: conflicting Origin port rejected',{'origin':'https://panel.example.test:8443'},PIN,'origin_mismatch');
add('pin: cross-site API request rejected',{'sec-fetch-site':'cross-site'},PIN,'cross_site_fetch');
add('pin: untrusted peer must not apply pin',{'x-forwarded-proto':'https, http','origin':'https://panel.example.test'}, {...PIN,peerTrusted:false},'origin_mismatch');
add('pin: credential automation remains strict',{'x-forwarded-proto':'https, http'}, {...PIN,automation:true},'external_scheme');
add('pin: Host cannot spoof unallowlisted identity',{'host':'attacker.example.test'},PIN,'pinned_origin_mismatch');
add('pin: correct alt port 8443',{'x-forwarded-host':'panel.example.test:8443','origin':'https://panel.example.test:8443'}, {...PIN, publicOrigin:'https://panel.example.test:8443'},'pass');
function encodedHeaders(patch) {
const merged={...defaults,...patch};
const result={};
for (const [key,value] of Object.entries(merged)) if (value!==null) result[key]=value;
return result;
}
const pending=[];
const server=http.createServer((req,res)=>{
const item=pending.shift();
if (!item) return res.writeHead(500).end('no scenario');
const result=guard(map(req.rawHeaders),{...context,...item.opts});
res.writeHead(result.status, {'content-type':'application/json'}).end(JSON.stringify(result));
});
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
async function request(item) {
pending.push(item);
return await new Promise((resolve,reject)=>{
const req=http.request({hostname:'127.0.0.1',port:server.address().port,path:'/api/auth/session',
agent:false,headers:encodedHeaders(item.patch)},res=>{
let data='';res.setEncoding('utf8');res.on('data',part=>data+=part);
res.on('end',()=>resolve(JSON.parse(data)));
});
req.on('error',reject);req.end();
});
}
try {
for (const item of cases) {
const result=await request(item);
assert.equal(result.reason,item.expected,item.name);
assert.equal(result.status,item.expected==='pass' ? 200:403,item.name);
}
const repeats=[];
for (const configured of [false,true]) {
for (let attempt=1;attempt<=12;attempt++) {
const malformed=attempt%4===0;
const result=await request({patch:{'x-forwarded-proto':malformed?'https, http':'https'},
opts:configured? PIN : {}});
assert.equal(result.status,configured?200:(malformed?403:200));
repeats.push({pin:configured,attempt,forwardedProto:malformed?'https, http':'https',status:result.status,reason:result.reason});
}
}
const summary={cases:cases.length,repeated:repeats.length,unconfigured403:repeats.filter(x=>!x.pin&&x.status===403).length,
pinned403:repeats.filter(x=>x.pin&&x.status===403).length};
const output={release:'0.31.4',classification:'Node.js HTTP simulation of admin guard, not compiled RustProxy',
assertions:cases.length+repeats.length,scenarios:cases.map(({name,expected})=>({name,expected})),repeats,summary};
if (process.argv[2]) fs.writeFileSync(process.argv[2],JSON.stringify(output,null,2)+'\n');
console.log(`PASS: ${output.assertions} HTTP-simulation assertions: ${summary.cases} cases + ${summary.repeated} resumes; `+
`403 with no pin ${summary.unconfigured403}/12, 403 with pin ${summary.pinned403}/12`);
} finally {server.close();}
@@ -0,0 +1,196 @@
// Reconstruct ONLY the RustProxy 0.31.4 administration Origin guard decision.
// Real-source anchors guard against accidental model/source drift. This is NOT
// a Rust server, not a browser, and not an HTTP/2 or TLS integration test.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import http from 'node:http';
import {fileURLToPath} from 'node:url';
import path from 'node:path';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = p => fs.readFileSync(path.join(root, p), 'utf8');
const source = {
settings: read('src/settings.rs') + read('src/settings/public_origin.rs'), access: read('src/admin/access.rs'),
session: read('src/admin/session.rs'), forwarding: read('src/forwarding.rs'),
};
for (const [part, tokens] of Object.entries({
settings: ['pub public_origin: Option<String>', 'fn include_public_origin_host(&mut self)', 'self.public_origin != active.public_origin'],
access: ['fn configured_admin_public_authority(', 'fn pinned_admin_proto_compatible(', 'hops > 8', 'scheme != "https"', 'trusted_admin_proxy(state, peer)'],
session: ['configured_admin_public_authority(&state, headers, peer, &raw_host, transport)', 'automation_credentials', 'pinned_origin_mismatch', '"origin_mismatch"', '"cross_site_fetch"'],
forwarding: ['self.trust_proto && self.trusted(ip)'],
})) for (const token of tokens) assert(source[part].includes(token), `Source contract missing: ${part}/${token}`);
const defaults = {host:'127.0.0.1:9090', 'x-forwarded-host':'panel.example.test',
'x-forwarded-proto':'https', 'sec-fetch-site':'same-origin'};
const context = {listenIp:'127.0.0.1', listenPort:9090, tls:false,
peerTrusted:true, trustProto:true, publicOrigin:null, allowedHosts:['panel.example.test'],
automation:false};
const normal = x => x.toLowerCase();
function authority(value) {
if (typeof value !== 'string' || !value || value.includes('@') || value.includes(',') || value.includes('/')) return null;
try {
const parsed = new URL(`https://${value}/`);
if (parsed.username || parsed.password || parsed.hash || parsed.search || parsed.pathname !== '/') return null;
const host = parsed.hostname.toLowerCase();
return {host, port:parsed.port ? Number(parsed.port) : null};
} catch {return null;}
}
function sameAuthority(a,b,scheme) {
const left=authority(a), right=authority(b);
if (!left || !right) return false;
const defaultPort = scheme === 'https' ? 443 : 80;
return left.host===right.host && (left.port ?? defaultPort)===(right.port ?? defaultPort);
}
function hostAllowed(value, tls, cfg) {
const actual=authority(value);
if (!actual) return false;
const port=actual.port ?? (tls ? 443 : 80);
const pinnedHost=cfg.publicOrigin ? new URL(cfg.publicOrigin).hostname.toLowerCase() : null;
// The validated public panel hostname is derived at runtime.
return (actual.host===cfg.listenIp && port===cfg.listenPort)
|| cfg.allowedHosts.includes(actual.host)
|| actual.host===pinnedHost;
}
const map = raw => {
const headers=new Map();
for (let i=0;i<raw.length;i+=2) {
const name=normal(raw[i]);
headers.set(name,[...(headers.get(name)||[]),raw[i+1]]);
}
return headers;
};
const all = (headers,name) => headers.get(name)||[];
const single = (headers,name) => all(headers,name).length===1 ? all(headers,name)[0] : null;
function protoCompatible(headers) {
let hops=0;
for (const value of all(headers,'x-forwarded-proto')) {
for (const part of value.split(',')) {
hops++;
const scheme=part.trim();
if (hops>8 || (hops===1 && scheme!=='https') || !['http','https'].includes(scheme)) return false;
}
}
return true;
}
function originMatches(value,scheme,external) {
if (typeof value!=='string' || value.includes('#') || value.includes('@')) return false;
try {
const uri=new URL(value);
if (uri.protocol!==`${scheme}:` || uri.pathname!=='/' || uri.search || uri.hash || uri.username || uri.password) return false;
return sameAuthority(uri.host, external, scheme);
} catch {return false;}
}
function guard(headers,cfg) {
const rawHost=single(headers,'host');
if (!rawHost) return {status:403,reason:'invalid_host'};
let scheme=cfg.tls ? 'https' : 'http', external=rawHost;
// 0.31.4 public pin only behind a trusted last hop and only for panel traffic.
if (cfg.publicOrigin && cfg.peerTrusted && !cfg.automation) {
let pinned;
try { pinned=new URL(cfg.publicOrigin); } catch {return {status:403,reason:'pinned_origin_mismatch'};}
if (pinned.protocol!=='https:' || !hostAllowed(rawHost,cfg.tls,cfg) || !protoCompatible(headers))
return {status:403,reason:'pinned_origin_mismatch'};
const xfh=all(headers,'x-forwarded-host');
if (xfh.length>1 || xfh.length===1 && !sameAuthority(xfh[0],pinned.host,'https'))
return {status:403,reason:'pinned_origin_mismatch'};
scheme='https'; external=pinned.host;
} else {
if (!cfg.tls && cfg.peerTrusted && cfg.trustProto && all(headers,'x-forwarded-proto').length) {
const proto=single(headers,'x-forwarded-proto');
if (proto!=='https' && proto!=='http') return {status:403,reason:'external_scheme'};
scheme=proto;
}
if (cfg.peerTrusted && all(headers,'x-forwarded-host').length)
external=single(headers,'x-forwarded-host');
if (!hostAllowed(external,scheme==='https',cfg))
return {status:403,reason:'invalid_host'};
}
const originValues=all(headers,'origin');
if (originValues.length>1 || originValues.length===1 && !originMatches(originValues[0],scheme,external))
return {status:403,reason:'origin_mismatch'};
if (all(headers,'sec-fetch-site').includes('cross-site'))
return {status:403,reason:'cross_site_fetch'};
return {status:200,reason:'pass',scheme};
}
const cases=[];
const add=(name,patch,opts,expected)=>cases.push({name,patch,opts,expected});
const PIN={publicOrigin:'https://panel.example.test',allowedHosts:[]};
add('legacy normal same-origin GET', {},{},'pass');
add('legacy malformed multi-hop chain', {'x-forwarded-proto':'https, http'},{},'external_scheme');
add('legacy duplicated XFP lines', {'x-forwarded-proto':['https','http']},{},'external_scheme');
add('pin: normal GET', {},PIN,'pass');
add('pin: GET multi-hop https then http',{'x-forwarded-proto':'https, http'},PIN,'pass');
add('pin: duplicate XFP lines',{'x-forwarded-proto':['https','http']},PIN,'pass');
add('pin: 8-hop bounded chain',{'x-forwarded-proto':'https, http, http, http, http, http, http, http'},PIN,'pass');
add('pin: 9-hop rejected',{'x-forwarded-proto':'https, http, http, http, http, http, http, http, http'},PIN,'pinned_origin_mismatch');
add('pin: no XFP; public origin configured',{'x-forwarded-proto':null},PIN,'pass');
add('pin: no XFH; internal Host still validated',{'x-forwarded-host':null,'origin':'https://panel.example.test'},PIN,'pass');
add('pin: wrong XFH',{'x-forwarded-host':'attacker.example.test'},PIN,'pinned_origin_mismatch');
add('pin: different allowed Host but wrong configured URL',{'x-forwarded-host':'panel.example.test:8443'},PIN,'pinned_origin_mismatch');
add('pin: XFH userinfo rejected',{'x-forwarded-host':'attacker@panel.example.test'},PIN,'pinned_origin_mismatch');
add('pin: duplicate XFH lines',{'x-forwarded-host':['panel.example.test','panel.example.test']},PIN,'pinned_origin_mismatch');
add('pin: first hop downgraded HTTP',{'x-forwarded-proto':'http, https'},PIN,'pinned_origin_mismatch');
add('pin: HTTP only',{'x-forwarded-proto':'http'},PIN,'pinned_origin_mismatch');
add('pin: unrecognized protocol',{'x-forwarded-proto':'ftp, http'},PIN,'pinned_origin_mismatch');
add('pin: empty protocol element',{'x-forwarded-proto':'https, '},PIN,'pinned_origin_mismatch');
add('pin: invalid XFP capitalization',{'x-forwarded-proto':'HTTPS'},PIN,'pinned_origin_mismatch');
add('pin: correct explicit browser Origin',{'origin':'https://panel.example.test','x-forwarded-proto':'https, http'},PIN,'pass');
add('pin: correct explicit Origin with :443',{'origin':'https://panel.example.test:443'},PIN,'pass');
add('pin: attacker Origin rejected',{'origin':'https://attacker.example.test'},PIN,'origin_mismatch');
add('pin: Origin null rejected',{'origin':'null'},PIN,'origin_mismatch');
add('pin: conflicting Origin port rejected',{'origin':'https://panel.example.test:8443'},PIN,'origin_mismatch');
add('pin: cross-site API request rejected',{'sec-fetch-site':'cross-site'},PIN,'cross_site_fetch');
add('pin: untrusted peer must not apply pin',{'x-forwarded-proto':'https, http','origin':'https://panel.example.test'}, {...PIN,peerTrusted:false},'origin_mismatch');
add('pin: credential automation remains strict',{'x-forwarded-proto':'https, http'}, {...PIN,automation:true},'external_scheme');
add('pin: Host cannot spoof unallowlisted identity',{'host':'attacker.example.test'},PIN,'pinned_origin_mismatch');
add('pin: correct alt port 8443',{'x-forwarded-host':'panel.example.test:8443','origin':'https://panel.example.test:8443'}, {...PIN, publicOrigin:'https://panel.example.test:8443'},'pass');
function encodedHeaders(patch) {
const merged={...defaults,...patch};
const result={};
for (const [key,value] of Object.entries(merged)) if (value!==null) result[key]=value;
return result;
}
const pending=[];
const server=http.createServer((req,res)=>{
const item=pending.shift();
if (!item) return res.writeHead(500).end('no scenario');
const result=guard(map(req.rawHeaders),{...context,...item.opts});
res.writeHead(result.status, {'content-type':'application/json'}).end(JSON.stringify(result));
});
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
async function request(item) {
pending.push(item);
return await new Promise((resolve,reject)=>{
const req=http.request({hostname:'127.0.0.1',port:server.address().port,path:'/api/auth/session',
agent:false,headers:encodedHeaders(item.patch)},res=>{
let data='';res.setEncoding('utf8');res.on('data',part=>data+=part);
res.on('end',()=>resolve(JSON.parse(data)));
});
req.on('error',reject);req.end();
});
}
try {
for (const item of cases) {
const result=await request(item);
assert.equal(result.reason,item.expected,item.name);
assert.equal(result.status,item.expected==='pass' ? 200:403,item.name);
}
const repeats=[];
for (const configured of [false,true]) {
for (let attempt=1;attempt<=12;attempt++) {
const malformed=attempt%4===0;
const result=await request({patch:{'x-forwarded-proto':malformed?'https, http':'https'},
opts:configured? PIN : {}});
assert.equal(result.status,configured?200:(malformed?403:200));
repeats.push({pin:configured,attempt,forwardedProto:malformed?'https, http':'https',status:result.status,reason:result.reason});
}
}
const summary={cases:cases.length,repeated:repeats.length,unconfigured403:repeats.filter(x=>!x.pin&&x.status===403).length,
pinned403:repeats.filter(x=>x.pin&&x.status===403).length};
const output={release:'0.31.4',classification:'Node.js HTTP simulation of admin guard, not compiled RustProxy',
assertions:cases.length+repeats.length,scenarios:cases.map(({name,expected})=>({name,expected})),repeats,summary};
if (process.argv[2]) fs.writeFileSync(process.argv[2],JSON.stringify(output,null,2)+'\n');
console.log(`PASS: ${output.assertions} HTTP-simulation assertions: ${summary.cases} cases + ${summary.repeated} resumes; `+
`403 with no pin ${summary.unconfigured403}/12, 403 with pin ${summary.pinned403}/12`);
} finally {server.close();}
+10 -10
View File
@@ -24,7 +24,7 @@ def read_docker_agent_source() -> str:
preflight(root) preflight(root)
manifest = tomllib.loads((root / "Cargo.toml").read_text()) manifest = tomllib.loads((root / "Cargo.toml").read_text())
assert manifest["package"]["version"] == "0.31.3" assert manifest["package"]["version"] == "0.31.4"
assert manifest["dependencies"]["quinn"]["version"] == "0.11.12" assert manifest["dependencies"]["quinn"]["version"] == "0.11.12"
h3dep = manifest["dependencies"]["h3"] h3dep = manifest["dependencies"]["h3"]
assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.20" and "quinn" in h3dep["features"] assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.20" and "quinn" in h3dep["features"]
@@ -32,14 +32,14 @@ assert "h3-quinn" not in manifest["dependencies"]
lock_path = root / "Cargo.lock" lock_path = root / "Cargo.lock"
if lock_path.exists(): if lock_path.exists():
lock = lock_path.read_text() lock = lock_path.read_text()
assert 'name = "rust-proxy"\nversion = "0.31.3"' in lock assert 'name = "rust-proxy"\nversion = "0.31.4"' in lock
assert 'name = "systemstat"' in lock and 'name = "metrics-process"' in lock and 'name = "sysinfo"' in lock, "Cargo.lock must be resolver-generated after adding cross-platform statistics libraries" 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 { for release_doc, marker in {
"README.md": "# RustProxy 0.31.3", "README.md": "# RustProxy 0.31.4",
"CHANGELOG.md": "## 0.31.3", "CHANGELOG.md": "## 0.31.4",
"ROADMAP.md": "## 0.31.3", "ROADMAP.md": "## 0.31.4",
"AGENTS.md": "Current source version: 0.31.3.", "AGENTS.md": "Current source version: 0.31.4.",
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.31.3", "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.31.4",
}.items(): }.items():
assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}" 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" 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 '"force_ssl"' in openapi and '"forward_client_ip"' in openapi
assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi
assert '"StreamMetrics"' in openapi and '"streams"' in openapi assert '"StreamMetrics"' in openapi and '"streams"' in openapi
for marker in ['"version": "0.31.3"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']: for marker in ['"version": "0.31.4"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
assert marker in openapi, marker 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"']: 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 assert marker in openapi, marker
@@ -1394,7 +1394,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 '"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 '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 'location.pathname === "/logging"' in settings_js_0241
assert '"RuntimeInformation"' in openapi and '"version": "0.31.3"' in openapi assert '"RuntimeInformation"' in openapi and '"version": "0.31.4"' in openapi
# 0.24.1 compiler and hostname-cardinality regressions. # 0.24.1 compiler and hostname-cardinality regressions.
tls_0241 = rust_source(root, "src/tls.rs") tls_0241 = rust_source(root, "src/tls.rs")
@@ -1633,5 +1633,5 @@ for marker_0282 in [
assert 'Zewnętrzne ścieżki plików' in (root / 'langs/pl_pl.json').read_text() 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() assert 'setfacl -m u:rustproxy:--x /etc/ssl/' in (root / 'docs/V0.28.2_IMPLEMENTATION.md').read_text()
print("PASS: 0.31.3 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v9 migrations.") print("PASS: 0.31.4 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v9 migrations.")
print("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.") print("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.")
+3 -3
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Check the 0.31.3 dependency/source migration; optionally require a resolved lock. """Check the 0.31.4 dependency/source migration; optionally require a resolved lock.
This is not a substitute for rustc, cargo metadata or runtime tests. This is not a substitute for rustc, cargo metadata or runtime tests.
""" """
@@ -14,7 +14,7 @@ parser = argparse.ArgumentParser()
parser.add_argument('--require-lock', action='store_true', help='Require the post-upgrade Cargo.lock produced by cargo update') parser.add_argument('--require-lock', action='store_true', help='Require the post-upgrade Cargo.lock produced by cargo update')
args = parser.parse_args() args = parser.parse_args()
manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text()) manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text())
assert manifest['package']['version'] == '0.31.3' assert manifest['package']['version'] == '0.31.4'
assert manifest['package']['rust-version'] == '1.99' assert manifest['package']['rust-version'] == '1.99'
deps = manifest['dependencies'] deps = manifest['dependencies']
expected_requirements = { expected_requirements = {
@@ -78,4 +78,4 @@ if missing:
print(f'PENDING: lockfile still requires cargo update ({details})') print(f'PENDING: lockfile still requires cargo update ({details})')
else: else:
print('PASS: pinned key packages found in post-upgrade lock; cargo --locked verification is still required') print('PASS: pinned key packages found in post-upgrade lock; cargo --locked verification is still required')
print('PASS: 0.31.3 source dependency APIs, manifest, TLS features, toolchain images and Swagger pin (static only)') print('PASS: 0.31.4 source dependency APIs, manifest, TLS features, toolchain images and Swagger pin (static only)')
+4 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Rust-independent documentation/Reader consistency checks for the 0.31.3 release.""" """Rust-independent documentation/Reader consistency checks for the 0.31.4 release."""
from pathlib import Path from pathlib import Path
import re import re
from rust_source import rust_source from rust_source import rust_source
@@ -29,6 +29,8 @@ required = [
DOCS / "V0.25.2_AUDIT.md", DOCS / "V0.25.2_AUDIT.md",
DOCS / "V0.25.1_AUDIT.md", DOCS / "V0.25.1_AUDIT.md",
DOCS / "V0.25.0_AUDIT.md", DOCS / "V0.25.0_AUDIT.md",
DOCS / "IMPLEMENTATION_0.31.4.md",
DOCS / "IMPLEMENTATION_0.31.3.md",
DOCS / "IMPLEMENTATION_0.31.1.md", DOCS / "IMPLEMENTATION_0.31.1.md",
DOCS / "TEST_REPORT.md", DOCS / "TEST_REPORT.md",
DOCS / "CHANGELOG_TECHNICAL_ARCHIVE.md", DOCS / "CHANGELOG_TECHNICAL_ARCHIVE.md",
@@ -65,7 +67,7 @@ assert '"/docs/content.json"' in admin
readme = (ROOT / "README.md").read_text(encoding="utf-8") readme = (ROOT / "README.md").read_text(encoding="utf-8")
roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8") roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8")
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
assert readme.startswith("# RustProxy 0.31.3") assert readme.startswith("# RustProxy 0.31.4")
assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme 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 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 "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme
+11 -2
View File
@@ -77,13 +77,22 @@ const bindSettingsForm = (id, handler) => handlers.set(id, handler);
const adminStart = administration.indexOf('bindSettingsForm("administration-form", next => {'); const adminStart = administration.indexOf('bindSettingsForm("administration-form", next => {');
const adminEnd = administration.indexOf('bindSettingsForm("tls-policy-form",', adminStart); const adminEnd = administration.indexOf('bindSettingsForm("tls-policy-form",', adminStart);
assert(adminStart > 0 && adminEnd > adminStart); assert(adminStart > 0 && adminEnd > adminStart);
runInNewContext(administration.slice(adminStart, adminEnd), { // Use the shipped helper in the extracted callback environment as well.
$: field, bindSettingsForm, latestStatus: { administration: { listen_locked: false } }, const hostHelperStart = administration.indexOf(' function pinnedAdminHostname(value) {');
const hostHelperEnd = administration.indexOf(' const normalizedRequestedTab', hostHelperStart);
assert(hostHelperStart > 0 && hostHelperEnd > hostHelperStart);
runInNewContext(administration.slice(hostHelperStart, hostHelperEnd) + administration.slice(adminStart, adminEnd), {
$: field, bindSettingsForm, latestStatus: { administration: { listen_locked: false } }, URL,
}); });
const adminNext = {}; const adminNext = {};
handlers.get('administration-form')(adminNext); handlers.get('administration-form')(adminNext);
assert.deepEqual(JSON.parse(JSON.stringify(adminNext.administration.panel_compression)), assert.deepEqual(JSON.parse(JSON.stringify(adminNext.administration.panel_compression)),
{ zstd: true, br: true, gzip: true }); { zstd: true, br: true, gzip: true });
field('admin-public-origin').value = 'https://panel.example.test';
field('admin-hosts').value = 'panel.example.test\nother.example.test';
const pinnedNext = {};
handlers.get('administration-form')(pinnedNext);
assert.deepEqual(JSON.parse(JSON.stringify(pinnedNext.administration.allowed_hosts)), ['other.example.test']);
field('admin-compression-br').checked = false; field('admin-compression-br').checked = false;
field('admin-compression-zstd').checked = false; field('admin-compression-zstd').checked = false;
const offNext = {}; const offNext = {};
+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)}") raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/health.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/session-resume.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"]: for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/health.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/session-resume.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(): if not (root / path).is_file():
raise ValueError(f"Incomplete RustProxy 0.31.3 source tree: missing {path}") raise ValueError(f"Incomplete RustProxy 0.31.4 source tree: missing {path}")
text = (root / "src/lib.rs").read_text(encoding="utf-8") text = (root / "src/lib.rs").read_text(encoding="utf-8")
if '#[path = "auth/mod.rs"]' not in text: if '#[path = "auth/mod.rs"]' not in text:
raise ValueError("src/lib.rs is not the 0.31.3 entry point; extract the complete package, not individual files.") raise ValueError("src/lib.rs is not the 0.31.4 entry point; extract the complete package, not individual files.")
return messages return messages
+5 -5
View File
@@ -135,7 +135,7 @@ def upgrade_smoke(tmp: Path) -> None:
assert backup.is_dir() assert backup.is_dir()
assert (backup / "src/obsolete.rs").is_file() assert (backup / "src/obsolete.rs").is_file()
assert not (target / "src/obsolete.rs").exists() assert not (target / "src/obsolete.rs").exists()
assert 'version = "0.31.3"' in (target / "Cargo.toml").read_text(encoding="utf-8") assert 'version = "0.31.4"' in (target / "Cargo.toml").read_text(encoding="utf-8")
assert (target / "data/keep.txt").read_text(encoding="utf-8") == "preserve\n" 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 / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n"
assert (target / "langs/site.json").is_file() 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/certificate.go").is_file()
assert (target / "docker-agent/server.go").is_file() assert (target / "docker-agent/server.go").is_file()
manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8")) manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8"))
assert manifest["to_version"] == "0.31.3" assert manifest["to_version"] == "0.31.4"
assert manifest["removed_obsolete_runtime_state"] is True assert manifest["removed_obsolete_runtime_state"] is True
assert not legacy.exists() assert not legacy.exists()
assert not (backup / "legacy-data").exists() assert not (backup / "legacy-data").exists()
@@ -617,7 +617,7 @@ def source_contract_smoke() -> None:
assert marker in admin, marker assert marker in admin, marker
anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0] 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 '"/api/status"' not in anonymous_block
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.31.3"' in openapi_0241 assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.31.4"' in openapi_0241
assert 'id="panel-logging"' not in html 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"']: for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']:
assert marker in html, marker assert marker in html, marker
@@ -1108,7 +1108,7 @@ def source_contract_smoke() -> None:
assert '.managed-update' in main and "flush_hot_to_cold_for_update" in main 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") openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8")
assert '"/api/config/history/restore"' in openapi assert '"/api/config/history/restore"' in openapi
for marker in ['"version": "0.31.3"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']: for marker in ['"version": "0.31.4"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
assert marker in openapi, marker assert marker in openapi, marker
assert '"software_update"' in openapi and '"update_available"' in openapi assert '"software_update"' in openapi and '"update_available"' in openapi
for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']: for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']:
@@ -1313,7 +1313,7 @@ def main() -> None:
source_contract_smoke() source_contract_smoke()
container_contract_smoke() container_contract_smoke()
grafana_contract_smoke() grafana_contract_smoke()
print("PASS: 0.31.3 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.") print("PASS: 0.31.4 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
if __name__ == "__main__": if __name__ == "__main__":
+3 -3
View File
@@ -43,7 +43,7 @@ const code = js.slice(js.indexOf(' const runtime = latestStatus?.runtime || {
const elements = new Map(); const elements = new Map();
const dollar = id => { if (!elements.has(id)) elements.set(id, { textContent: '' }); return elements.get(id); }; const dollar = id => { if (!elements.has(id)) elements.set(id, { textContent: '' }); return elements.get(id); };
const status = { const status = {
version: '0.31.3', uptime_seconds: 4201, version: '0.31.4', uptime_seconds: 4201,
runtime: { runtime: {
platform: 'Linux', operating_system: 'Debian GNU/Linux 13', kernel_version: '6.12.0', architecture: 'x86_64', platform: 'Linux', operating_system: 'Debian GNU/Linux 13', kernel_version: '6.12.0', architecture: 'x86_64',
statistics_source: 'systemstat+sysinfo', data_directory: '/var/lib/rustproxy', database_path: '/var/lib/rustproxy/config.sqlite3', statistics_source: 'systemstat+sysinfo', data_directory: '/var/lib/rustproxy', database_path: '/var/lib/rustproxy/config.sqlite3',
@@ -63,7 +63,7 @@ const context = {
vm.runInNewContext(code, context); vm.runInNewContext(code, context);
assert.equal(dollar('runtime-operating-system').textContent, 'Debian GNU/Linux 13'); assert.equal(dollar('runtime-operating-system').textContent, 'Debian GNU/Linux 13');
assert.equal(dollar('runtime-kernel-version').textContent, '6.12.0'); assert.equal(dollar('runtime-kernel-version').textContent, '6.12.0');
assert.equal(dollar('runtime-version').textContent, '0.31.3'); assert.equal(dollar('runtime-version').textContent, '0.31.4');
assert.equal(dollar('runtime-logical-cpus').textContent, '8'); assert.equal(dollar('runtime-logical-cpus').textContent, '8');
assert.equal(dollar('runtime-cpu-usage').textContent, '17.2%'); assert.equal(dollar('runtime-cpu-usage').textContent, '17.2%');
assert.equal(dollar('runtime-python').textContent, 'Available · 3.13.5'); assert.equal(dollar('runtime-python').textContent, 'Available · 3.13.5');
@@ -124,4 +124,4 @@ for (const key of ['python_version','pip_version','certbot_version']) {
assert.ok(runtimeSchema.properties.certificate_runtime.properties[key]); assert.ok(runtimeSchema.properties.certificate_runtime.properties[key]);
assert.equal(runtimeSchema.properties.certificate_runtime.properties[key].nullable, true); assert.equal(runtimeSchema.properties.certificate_runtime.properties[key].nullable, true);
} }
console.log('PASS: 0.31.3 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.'); console.log('PASS: 0.31.4 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.');
+1 -1
View File
@@ -54,7 +54,7 @@ def run_load(
port = parsed.port or (443 if parsed.scheme == "https" else 80) port = parsed.port or (443 if parsed.scheme == "https" else 80)
tls = ssl._create_unverified_context() if insecure else ssl.create_default_context() tls = ssl._create_unverified_context() if insecure else ssl.create_default_context()
connection_type = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection connection_type = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
headers = {"user-agent": "RustProxy-Memory-Stress/0.31.3", "accept": "*/*"} headers = {"user-agent": "RustProxy-Memory-Stress/0.31.4", "accept": "*/*"}
if host_header: if host_header:
headers["host"] = host_header headers["host"] = host_header