From d942c1a35d8c7558143d61dcd793035754827e37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sun, 11 Oct 2026 13:56:30 +0200 Subject: [PATCH] v0.31.4 --- AGENTS.md | 2 +- CHANGELOG.md | 7 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 17 +- ROADMAP.md | 9 + SHA256SUMS | 76 ++++--- dev.sh | 3 + docs/API.md | 6 +- docs/API_ACCESS.md | 9 +- docs/IMPLEMENTATION_0.31.4.md | 151 +++++++++++++ docs/README.md | 3 +- docs/SETTINGS.md | 66 ++++++ docs/TEST_REPORT.md | 28 ++- langs/en_US.json | 8 +- langs/pl_pl.json | 8 +- scripts/resolve_dependencies_0312.sh | 4 +- src/admin/access.rs | 66 ++++++ src/admin/documentation/documents.rs | 1 + src/admin/openapi.json | 10 +- src/admin/session.rs | 36 ++- src/admin/status.rs | 5 +- src/admin/tests.rs | 1 + src/admin/tests/part_11.rs | 142 ++++++++++++ src/admin/web/index.html | 14 +- src/admin/web/settings.js | 20 +- src/proxy/cache_helpers.rs | 9 +- src/runtime/constructors.rs | 5 +- src/settings.rs | 20 +- src/settings/public_origin.rs | 58 +++++ src/settings/tests.rs | 61 +++++ .../admin_panel_config_0314_ui_test.mjs | 52 +++++ .../admin_public_origin_0314_config_sim.mjs | 210 ++++++++++++++++++ tools/checks/admin_public_origin_0314_sim.mjs | 196 ++++++++++++++++ tools/checks/check_source.py | 20 +- tools/checks/dependency_upgrade_0312.py | 6 +- tools/checks/docs_check.py | 6 +- .../checks/panel_cache_http_0310_ui_test.mjs | 13 +- tools/checks/preflight.py | 4 +- tools/checks/release_smoke.py | 10 +- tools/checks/system_info_0301_ui_test.mjs | 6 +- tools/dev/memory_load.py | 2 +- 42 files changed, 1265 insertions(+), 109 deletions(-) create mode 100644 docs/IMPLEMENTATION_0.31.4.md create mode 100644 src/admin/tests/part_11.rs create mode 100644 src/settings/public_origin.rs create mode 100644 tools/checks/admin_panel_config_0314_ui_test.mjs create mode 100644 tools/checks/admin_public_origin_0314_config_sim.mjs create mode 100644 tools/checks/admin_public_origin_0314_sim.mjs diff --git a/AGENTS.md b/AGENTS.md index 5a90d91..1db637b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes. The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for -compatibility. Current source version: 0.31.3. +compatibility. Current source version: 0.31.4. ## Delivery diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a6e600..0cdebbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ > Operator-facing release history. Historical engineering notes are kept in > [`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 - 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. diff --git a/Cargo.lock b/Cargo.lock index 89714f2..f2fc5f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1881,7 +1881,7 @@ dependencies = [ [[package]] name = "rust-proxy" -version = "0.31.3" +version = "0.31.4" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 903cef3..f15717d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-proxy" -version = "0.31.3" +version = "0.31.4" edition = "2021" rust-version = "1.99" description = "RustProxy - independent reverse proxy with local administration" diff --git a/README.md b/README.md index eedbfa5..86af2b4 100644 --- a/README.md +++ b/README.md @@ -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). Independent Rust reverse proxy and static Web Server with a dashboard, SQLite configuration, streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhosts and backend pools. -**0.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 @@ -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. -## 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. - 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. diff --git a/ROADMAP.md b/ROADMAP.md index 5e1072d..ac4c9dc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,14 @@ # 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 - [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. diff --git a/SHA256SUMS b/SHA256SUMS index 4c5fb57..7e99d5b 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,18 +1,18 @@ 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore -2326433366f5ee39f50cc0c8fd308a1aa4bf913dd29c60d36d6ad40596e2057e AGENTS.md -fe15afa19911918df2cb8f754d42b23e367056b2350c4421273e4ea15558e04c CHANGELOG.md -a527e1bdd3ad6217a4a0bb06f29d33b4f5718525b8de81d4170ec7233bee5155 Cargo.lock -267b8d8ff64905e059ea6626a6ab48fb20f42cf5ae4af25ffc69b07535a9d42b Cargo.toml +ce2dfe3ddaf72f4d9f47fbddde8cc3a6379c95797785bbafdbc9f505ab0c06d7 AGENTS.md +3868e2b5ac1e1e324e0647da39dd4ace965b102fdd06a26d720977cb7bcc4ba2 CHANGELOG.md +140089a442d329eb0819f17bbb8e9215687ede43d77fb74068c1b4e79b074547 Cargo.lock +d8e3b968089f11eac6098bb8f51598a9012de6ec2f84a0ef6899ac27cdf2a134 Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile -1ceec1cdf7e056bf26096eedcf95f0efb192b69c0f614ba13ecf2808e534ebf3 README.md -4c8c8a4efa169b7c92d27715d15d467c95c705bd8b439302e6bbde26e56f1dfd ROADMAP.md +6c1cddc4650a1bd9e31b3598baffb0d55435a2d3a5e31653c111aacd4ed96070 README.md +ea6203f84882ab99aa22b99dd26d0d7b8d7fd64639aa74230ee431b3edac25ae ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md 4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service -5147ac3233914fe580b4c4d5ae1b74af2948019f34ff6629d21a7811b27a5b84 dev.sh +7e9d5cf9a0720ac00a87a177b41c44524ed6b04e819343ea16967945b3a413ae dev.sh 5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md 7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go 5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example @@ -27,8 +27,8 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n 5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go 875faa3969df9409d79f050a84b4183496f557edda3d34aaec19fe556cf8c471 docs/ACME_PROVIDERS.md ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md -8ded74f422426606238fa9876b49277a7bc56ddd96062a836cdd6ddbf78bd335 docs/API.md -6ed12684b970fb7173fc9d1ad1da8ecce784eb38b29b7e6e631309fb25446807 docs/API_ACCESS.md +a36880dc07e922a36a4c8be5fa889a03658bb0492a1e20ec00fbb130241c7719 docs/API.md +b0c6fae1d538f33fb468a00307cbb745a601d9fc341c0a4f859d60c7e8d48865 docs/API_ACCESS.md 4c5288c9ed427cfb919bd023ed9a321dcf6d64bc4cad3e1768393356c5d835ba docs/ARCHITECTURE.md e9e2327684a4ae2d1ca4a0b3fb67d0ff83b5a150d38d4de4cfad2c5d3102eab6 docs/CACHE.md 9113f3abbfe81bafc4855d8680df8aa2a381ecc358cc3e1a0f78b6b756421117 docs/CACHE_DIAGNOSTICS.md @@ -42,6 +42,7 @@ befa50579e3475b22589f26380d85b3345b80e4fa947d49649a3b91a8dd8b8c2 docs/CUSTOM_LO 5301e321611d494fd97b04ab648df1f66a6a0d1f1f4c3475eab485e138d5605b docs/IMPLEMENTATION_0.31.0.md eb52e72325e1307d05cb04878b6d22dfedea491908c5f36110b32421765096c8 docs/IMPLEMENTATION_0.31.1.md 991ff3c09ca1e2bcf73b8fa2975405b955dd640c3f1e1246e75038c043c17183 docs/IMPLEMENTATION_0.31.3.md +db2087608a1225c56e42c416d949b777fb15b1ed8b69a1e71fc2a6af6750a1e5 docs/IMPLEMENTATION_0.31.4.md a00ec3c1d3b28ba10677435128e031e5f18fe8c5cef2ac90eb0f6779cefeb434 docs/INSTALL.md 3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md 4bb56add544f15d54695cf797cd16a51c09024360cdeef803a88988f4a54b3a5 docs/LIVE_LOGS.md @@ -55,7 +56,7 @@ e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND e68d5a5cf0991de9308f78afb0264e02979f7463a5f395eb7a6e9ec2bd3a3d54 docs/PHP_FPM.md bc068a176056f5ea4d3b637071e644ed58a2140494c233ef22aa66e9700e3299 docs/PHP_FPM_ISOLATION.md 572c70fbd2f61fecd80a8ff9fa5de4a94c4f158de5e90da8e920f99b051241fa docs/PROMETHEUS.md -e87c81d2eb2e90d5b1ea9e40abb81a65ca81593eb5cc3e39717b818c9a75b287 docs/README.md +1abcf8b9abcff02cf40c0f3e5597af1e1bf3e6d48c2f7f4278a53c6ebad51f5b docs/README.md 6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md 0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.md @@ -64,9 +65,9 @@ a2bb837cdd3559d58158880c1f832badf64035376a3623050bb4e40c2bca104c docs/ROADMAP_H 5dbdf45fddba198a28f511dbbd2f12bf7d2b88c41c3cbedbc20dfea3858779d3 docs/SECURITY.md 620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md 007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md -2cd9dadc49a5474e4ab06d5c01fa38c27cab436578cb9c9f327a37b3406d2e13 docs/SETTINGS.md +970025a6d1a37452b8bead6c9d2b3d4743ea602251dc446d575531d76c11ae83 docs/SETTINGS.md 3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md -1de7cf885e596ccfab31a434d597c3a8d9113d206305810ea628a5ee6d96f986 docs/TEST_REPORT.md +d7cd61b534a2d38511d212cd9ee906b16b28d99d4a7ba5ed6ef7c1ab3611d2b8 docs/TEST_REPORT.md cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md 751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md 660687fc961f71aae2bdf71595aa47a336fa782f4e78ceee8c1d1e63e46393ee docs/TRANSPORT.md @@ -138,8 +139,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r 96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh 3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh 71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md -19c17d16960539f9811cd551bbcbba25863711b064618069a58d48de3cc9fade langs/en_US.json -fcdb2db09c7a105893fc13462da4d0341a873e305bcc3fdcd0bd5e10b9a110ba langs/pl_pl.json +61337803fabb183629fa4dc9ff038ee7ee41956c8304de3d0122d425d0b1f53a langs/en_US.json +949dc843e348ada1b1208407daa13463a0e7e16548411376382e18635578dcbb langs/pl_pl.json a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql @@ -165,7 +166,7 @@ a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy 2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.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 e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py 22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py @@ -180,17 +181,17 @@ b9e57db4bd80ef37564dfc533a96897d8a494671d53acea9fb4bf28d23ba370d src/acme/provi 87f64198770ae026c03a242fa542db2590ca734b02e756f1e5c28d66236f753a src/acme/runtime_env.rs f8075a5ef9ae5651d72a33ac0f0fd5b44ad0ae30f6e2d2318412177e0ed837f5 src/acme/runtime_versions.rs e38331a1b186b49fe9d04051de52b603e1fa028def37e4e2f52e386b4d7b29ac src/acme/tests.rs -bc9eb32f0d2785bac122d5eff44742cd24306c503daa065564383d46416b2211 src/admin/access.rs +b6f38f3fd75d6685bbc882ed1077a38bf495cfcf01c397f69ed9b98be68b446f src/admin/access.rs 019f4aa33e3596bc9c11ef8cf5703bcfffe46c15288c0eba83c8dd9e38055fb2 src/admin/certificates.rs 1de4c7c116eeab2452ccccbaba3f2b7b63c6eb1c1e146ab870d19c5a5ca16f80 src/admin/documentation.rs -69095640bb0dca081317c7776cd73e83fab11ddd04fe903acea8f8c1b4899210 src/admin/documentation/documents.rs +efa4a357d51d9b3c575e64593b55c8a30afbab5cd96a44cc39786207228757b6 src/admin/documentation/documents.rs 67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs 002922c91aa07cbea17cb0ee11127bb6e7e015de108ee032761b34507dcfa06d src/admin/file_log_search.rs 2ebaf24e9d0d87383d2eea073ee2d3ac37962eeb503a0f1ee2c2de4f3f6d1b7a src/admin/file_logs.rs d7aa68a78a4cb5a61342d92c1f3209af230d0f9ca3fea1115f67078372e6f37c src/admin/file_logs_io.rs 899e87a73bfe46c4078a46f9e1eb1c17b384472e8a30d2bab5486872b43fc809 src/admin/logging_api.rs 58802528128f53e493f7339063433a747b605007a8842b5efc670132d897d6f7 src/admin/mod.rs -a857bcbdef2a673be2f01c5817ca6d75dd5627b7e3b80777c394aa2edd933cd8 src/admin/openapi.json +3028fd407ec2cbda7742746241113739136a27a5b81993acb2d6f99187086246 src/admin/openapi.json 0d1a5324e8ad32f5ab0861b722663f50f066e256bc5fd5dcced6a63f2bd399d1 src/admin/operations.rs 2b0ec6df0ed9e1bdc5c9e4ff1758b59dd14abc150f98365ac3edcf4672908e48 src/admin/panel_assets.rs 75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs @@ -200,12 +201,12 @@ d9e90166d2199152585a5208feedfc1dd1395dc32ddbd2a094ae4a1aee45e115 src/admin/pref c9f952bec99dfe1d5a60998a38bd177f82f54de567230413e0c322ee561eba7c src/admin/public/tests.rs 6125419b099cf4d330767bc07424d52a2b400f273d31084c058c43a72a6427b7 src/admin/refresh_preferences.rs 765c14e52bdc9e542d6ff651c250c24d33b77b7e46f1b5f19acf96cc2c99bdc8 src/admin/router.rs -daf0f7393f2350e569a5e8dda52dbf3e3bbefcb989f07f723dca540ae0fe487e src/admin/session.rs -6fa2e586feb335ea9ce2c4dc523e422e4b2aa52c823ba8f8851f9e0f37cd2fef src/admin/status.rs +a40adfbaa908da3ea726d7aa3d45394a70a6a55b6fc0063270ec4d2a2f2026a3 src/admin/session.rs +e7355ef15e4377cd71e47d054c607079dd8e6711f1d86824f716b8003210a046 src/admin/status.rs e79d3dc0c4fde1ca4a69c204fb41cc5aa1db02aefdabff3435f27b5db0c6eb8d src/admin/status_live.rs c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs -3277a08915eb2e272ad4c0f3aa1ffa0c7fed4e6a1d1fb66d4bfd8a4a4ea515aa src/admin/tests.rs +684307b74155ac7833acfb5e9e8eaccc92575d49b760928c30fc22e805a42a4c src/admin/tests.rs 18aa617a0bf60a3d8d2ad7dd059a369e7549ef46683d8f912e07f8fb091c26d1 src/admin/tests/part_01.rs 6f8c1e30babb2cc4cb7accf4bfa28338651a469b16341f6e7eaec881dd9abf2b src/admin/tests/part_02.rs 34cf08cafb63cf7a8a6336e5c81fc99abfce8669662e387a3702c3a532be8056 src/admin/tests/part_03.rs @@ -216,6 +217,7 @@ cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail 54ed2130e85239ffa4576417dd68fbb9349e396210b75f5da862897137d7e876 src/admin/tests/part_08.rs 225a29e2e560e42e06e19c3d41c0196c4a42e1e84caa7c83446cab62e3ac30dc src/admin/tests/part_09.rs bffa6611f3776e97d0065b510b63f083aabf7b3429fcc5cb26b1883fdb7c47dd src/admin/tests/part_10.rs +578f93cc5771b5858813945e5608b15c572bd3d7fd15bae291d292e2994ece25 src/admin/tests/part_11.rs 48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js 446011c2c7afddf7734ceb268c75a3b4146dbe8e915b11986495e956b6c62c69 src/admin/web/app/config-history.js 9527dcafb9c14e420cb841a6984d373ba2136d07c45350e9368dd7a88e89af22 src/admin/web/app/core.js @@ -240,14 +242,14 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/ 0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg af6e7a2e890df2fb7385a374bcb3fba52fb5a53dbe18adc1fb7865660fcba842 src/admin/web/file-logs.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 7a12f020fc587205694978ab41b660e9a80487d413fbf6e34fc525eabc58308f src/admin/web/login-keys.json af0fbd94d9b88ce1cce28e39dfd2c35de031adcba412dd4a9a17d9b416c256dd src/admin/web/login.html 99fe9b8ba7a8a24e5bbe9ae40999484fe13b0faf5eb1d7a9193701f667e51ca5 src/admin/web/login.js 21ddae9743ca545c89f6df0db40810dd680812e58f2b2b6875ed64fd8fb97ac6 src/admin/web/management.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 c4f051f54293156803b48c04d972e9d5a290209826138ce15356d19adf569979 src/admin/web/style.css d01b615635c7373981b0633cc27298a34e67fe0cbe9d1384d277d46f0cafb1b4 src/admin/web/traffic-panel.js @@ -396,7 +398,7 @@ e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus 59095a04707c2e4714358f4462dcf21c41952474b9b815c42bbd14e96c573846 src/prometheus/web_php.rs b5856fd38a957b2ca9c7e3606d84d13d5cfcf3a45b487b15ae1d2dc248cbda57 src/proxy/body.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 771cc1a38927f96e593b144dbab66cd64a5dc0ed42d54b1615c359845b2b7223 src/proxy/error_fallback.rs bf2e6bbd64fb45fceed54af0bff1c1e00da22b79c64dd8f5c23d2af0dbab0b7d src/proxy/forward/cache_lookup.rs @@ -426,7 +428,7 @@ d40ad63939097d2b91339127d5835a74b618d83b7ba31db0c512f05a82107d6e src/redirect.r 811c83ae9fd0f5a13d35b5443cd37843f82b9d600bee4bb136ba9b87732a5347 src/redirect/tests.rs d0939e1b0829c25356a0b67ecb3a178c74936dd07baa15fee24eb32544db6cad src/runtime.rs 4e0b25c2b86eefddd76c64b39dc1c1cd6d243addc32cdea99e6c3ac84ed879a6 src/runtime/certificates.rs -2752a1122f22cab7af9ccdd23cd0aa6d99a73f1d82614814217a3875927d7eef src/runtime/constructors.rs +8d2443df8dab1ce6f11878e991b4928046b7362c417c5703f6d1f3a977646cbe src/runtime/constructors.rs 31dc5cdea00efd63594dc6becd5a9103594696145cbc8e7b7594fb407214ad9d src/runtime/docker.rs e90df3fff8a25465425b099ae358d19085809e56d948ba47e01e53f8b93d5dd0 src/runtime/tests.rs 0046525317ffbd5d3b34845656b0f58d9d31f38b3d8acd71e90d94bd7cdfb8ab src/runtime/types.rs @@ -441,8 +443,9 @@ f2f6196632b2fdb2444189624f961edaf5bdfff57f700b0207b57e2491c1daa5 src/server/htt 71899a9b1897fa22ab9e8eb6a670054a6d382bad78714006b9b218d07ba99902 src/server/serve.rs e9e478cf45b88994eaffd115f51764b6b07852ee3c941cafdabb20584d3dabc0 src/server/serve/tests.rs 048f2c7a8ef698a172ba7ed844558f4118b367b2cf340748c5c28da7723d8e85 src/server/tls_runtime.rs -623e1b24c17f2149c712bc9fe38ca23c503e88891f5eef1b5ceef6cbf8cf9093 src/settings.rs -3b3893d899b8e82969588f30c1e0aeff250449e4c94627ff5ad94d29f227ceca src/settings/tests.rs +73ec453ac2850f6963f5bc33401cb8c92aa26d5123f15d68e6633860d91f265e src/settings.rs +240ecdd70e8662ff3b84c21830dc0cbbbacadd755d56146e54214bc2887f068c src/settings/public_origin.rs +1f1121c3562c8f8f3fdee2113be2afffb8849be2d303e4c4409f87478b6e3307 src/settings/tests.rs 566daf9c0e1a30da2dc18e51090541a85ced3320a0fc00ed1d1de3e2b4da5072 src/stream.rs 46d9f63ce13a634e00f38d36ca3bdb217f2e37f04d75a4ef118e7dac9896a489 src/stream/model.rs 9c59bdb856106c0a74d1f8c156b0dd7289a170322ea556bad19bb8de3bf13d69 src/stream/runtime.rs @@ -522,28 +525,31 @@ eecf680f001f2110dde4eceaccf2f922859a16c946a644f8854e8de4d3771f7c tests/policies 728999110a6d8cacb62bc0399bc333fa23a66526ee23f563a3c93bf00e5fad19 tests/transport_config.rs b04362ad082a32c9ccc4cf38c46f44d71347adecf7aba3224b3af3ea4872e3d0 tools/README.md 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 412255d383fcc8306c6086784fb416d9814e3ec790e440a0927431ca615f3411 tools/checks/backend_cache_0297_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 259d4b0985c82b631e151a07e8ef3b4c760019cb99bb2af458ce577497f7df58 tools/checks/dashboard_startup_0310_ui_test.mjs -6adc94ee148508a2e46f073eb6f3b89c4057983dea12c976ae7332fb8423ce41 tools/checks/dependency_upgrade_0312.py -dd7b7512f5a988c1fe6fdbdeb78c3b0d33a23b57335eb4f881296cb252d8692c tools/checks/docs_check.py +76884f3c44f18c36eff3a1968aa173d2d782fa8d31ed7cc63e4ff41853488672 tools/checks/dependency_upgrade_0312.py +9add103198795111ccf1b3197cdd2586f54f606d152ff92be2ae09beb628d5aa tools/checks/docs_check.py e33a9cc51d58bc92a637c0e6a1c88cbd24ba4605be55748212e296c3c35a4060 tools/checks/high_traffic_memory_model.py 4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py f8f87ba891338b565249d02c3cf5f02cec44e606b4205de99fcbea1eb2c24d57 tools/checks/memory_contracts.py af98579ee942e774d88775c261ac351847c28bb4a5396dc67e458bd6b1ff7963 tools/checks/origin_resume_0313_ui_test.mjs -5b7901747d879e12a39ed82b6efc513bcdddd15d508b5828210d2c83398fb6a4 tools/checks/panel_cache_http_0310_ui_test.mjs -7770cc0b1b70255003d19b63210cc3e21ca9c8804cb56c2fe01eaf2e04ebb0fa tools/checks/preflight.py +b432ea24933ba28ae819d900892f7ada268560e2816234820394084d6296b595 tools/checks/panel_cache_http_0310_ui_test.mjs +300c114c05f66be214bc2e06187fc4d0063e3dc3cd55f44c43c2e43cbd3106f2 tools/checks/preflight.py 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 ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py 23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_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 614ed0c775e32ec5bfb4ee75aaa90206a10da0f43659747a7ec76fd93e33767a tools/checks/version_contracts.py 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 35f76060e585a96c623ef83bd5f771969f5be16a0e1ae626a3092d7458b84e84 tools/checks/web_service_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 2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 tools/dev/mock_server.py 538b5f6cfa738355a729940ee3d92e7ab3dc25ca69ae330ddaa08f831775b8ba tools/dev/test_compression.py diff --git a/dev.sh b/dev.sh index cd41981..c3507ba 100755 --- a/dev.sh +++ b/dev.sh @@ -111,6 +111,9 @@ if [[ "$mode" == "test" ]]; then node tools/checks/dashboard_startup_0310_ui_test.mjs node tools/checks/dashboard_boot_0311_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/vhost_origin_errors_0297_ui_test.mjs node tools/checks/www_redirect_ui_test.mjs diff --git a/docs/API.md b/docs/API.md index 7dcc460..0475944 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 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. -Direct IP/localhost Host values must use the actual administration port. A DNS name explicitly -listed in `administration.allowed_hosts` represents the browser-facing authority and may use -the trusted reverse proxy's external port. Foreign Origin/cross-site requests are rejected. +Direct IP/localhost Host values must use the actual administration port. A DNS name explicitly listed in `administration.allowed_hosts`, or automatically +derived from the validated `administration.public_origin`, represents the +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. Auth request bodies are limited to 4096 bytes. diff --git a/docs/API_ACCESS.md b/docs/API_ACCESS.md index c5e0758..f1d4815 100644 --- a/docs/API_ACCESS.md +++ b/docs/API_ACCESS.md @@ -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 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 -`administration.allowed_hosts` DNS entry is matched against the external authority rather -than the private admin socket port. `X-Forwarded-Host` is trusted only from loopback or a +When the dashboard is published through a trusted reverse proxy, the hostname of +the optional `administration.public_origin` is automatically allowed for panel +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 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. ### 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. diff --git a/docs/IMPLEMENTATION_0.31.4.md b/docs/IMPLEMENTATION_0.31.4.md new file mode 100644 index 0000000..15a92da --- /dev/null +++ b/docs/IMPLEMENTATION_0.31.4.md @@ -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. diff --git a/docs/README.md b/docs/README.md index 33f2415..ae69864 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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.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.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.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. @@ -82,7 +83,7 @@ This is the canonical index for the current project documentation. The authentic ## Historical or version-labelled context -Version-labelled `V*.md` files are retained as historical implementation/audit records. Their version number describes the audited milestone. The current release implementation note is [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. diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md index 2f64eea..b8d5ba5 100644 --- a/docs/SETTINGS.md +++ b/docs/SETTINGS.md @@ -114,12 +114,78 @@ accepted only when `administration.tls` is true. Plain remote HTTP remains avail "tls": true, "certificate_id": "admin-example", "allowed_hosts": ["admin.example.com"], + "public_origin": null, "session_days": 30, "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) controls Zstd, Brotli and gzip for the authenticated HTML document, JavaScript, CSS and language bundles. Each browser request is negotiated using diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index fe835d2..2b56bf7 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -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) diff --git a/langs/en_US.json b/langs/en_US.json index 7e7d6b5..463718c 100644 --- a/langs/en_US.json +++ b/langs/en_US.json @@ -893,7 +893,7 @@ "administrationCertificate": "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.", - "administrationHosts": "Allowed DNS hostnames (optional strict allowlist)", + "administrationHosts": "Additional allowed DNS hostnames", "administrationHttps": "Enable HTTPS for administration", "administrationListen": "Listen address", "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.", "cacheHttpInvalidationTitle": "HTTP BAN / PURGE allowlist", "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." } } diff --git a/langs/pl_pl.json b/langs/pl_pl.json index 042e341..513fd52 100644 --- a/langs/pl_pl.json +++ b/langs/pl_pl.json @@ -893,7 +893,7 @@ "administrationCertificate": "Certyfikat", "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.", - "administrationHosts": "Dozwolone hosty DNS (opcjonalna ścisła lista)", + "administrationHosts": "Dodatkowe dozwolone hosty DNS", "administrationHttps": "Włącz HTTPS dla panelu administracyjnego", "administrationListen": "Adres nasłuchu", "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.", "cacheHttpInvalidationTitle": "Lista dozwolonych adresów HTTP BAN / PURGE", "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." } } diff --git a/scripts/resolve_dependencies_0312.sh b/scripts/resolve_dependencies_0312.sh index 7b1a817..e3cc9ca 100755 --- a/scripts/resolve_dependencies_0312.sh +++ b/scripts/resolve_dependencies_0312.sh @@ -8,8 +8,8 @@ if ! command -v cargo >/dev/null 2>&1 || ! command -v rustc >/dev/null 2>&1; the exit 127 fi python3 tools/checks/preflight.py -if [[ "$(python3 scripts/version.py)" != '0.31.3' ]]; then - printf 'Expected RustProxy 0.31.3 sources.\n' >&2 +if [[ "$(python3 scripts/version.py)" != '0.31.4' ]]; then + printf 'Expected RustProxy 0.31.4 sources.\n' >&2 exit 1 fi printf 'Toolchain: '; rustc --version diff --git a/src/admin/access.rs b/src/admin/access.rs index 747b4f2..2310981 100644 --- a/src/admin/access.rs +++ b/src/admin/access.rs @@ -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, + raw_host: &str, + transport: Transport, +) -> Result, ()> { + 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::().map_err(|_| ())?; + if !authority_matches_origin(&candidate, authority, "https") { + return Err(()); + } + } + Ok(Some(authority.to_owned())) +} + fn external_admin_scheme( state: &App, headers: &HeaderMap, diff --git a/src/admin/documentation/documents.rs b/src/admin/documentation/documents.rs index 0f32321..127c7f0 100644 --- a/src/admin/documentation/documents.rs +++ b/src/admin/documentation/documents.rs @@ -244,6 +244,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[ "guides", 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.1.md", "technical"), reader_doc!("docs/IMPLEMENTATION_0.31.0.md", "technical"), diff --git a/src/admin/openapi.json b/src/admin/openapi.json index 897870c..c6b3a0f 100644 --- a/src/admin/openapi.json +++ b/src/admin/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "RustProxy Administration API", - "version": "0.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." }, "servers": [ @@ -764,7 +764,13 @@ "type": "array", "items": { "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": { "type": "integer", diff --git a/src/admin/session.rs b/src/admin/session.rs index 7dc662f..4782d89 100644 --- a/src/admin/session.rs +++ b/src/admin/session.rs @@ -117,17 +117,39 @@ async fn guard(State(state): State>, mut request: Request, next: Next) let headers = request.headers(); let trusted_host = trusted_admin_proxy(&state, peer); let trusted_proto = trusted_admin_forwarded_proto(&state, peer); - let scheme = match external_admin_scheme(&state, headers, peer, transport) { - Ok(value) => value, - Err(()) => { - return invalid_admin_origin(&request, "external_scheme", None, None, trusted_host, trusted_proto); - } + // 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 external_authority = - match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") { + 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) { + Ok(value) => value, + Err(()) => { + return invalid_admin_origin(&request, "external_scheme", None, None, trusted_host, trusted_proto); + } + }; + let authority = match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") { Ok(value) => value, Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")), }; + (scheme, authority) + }; if headers.get_all(ORIGIN).iter().count() > 1 || headers.get(ORIGIN).is_some_and(|h| { h.to_str() diff --git a/src/admin/status.rs b/src/admin/status.rs index 97d3516..f0df91c 100644 --- a/src/admin/status.rs +++ b/src/admin/status.rs @@ -123,10 +123,11 @@ fn status_payload(state: &App) -> Value { desired_admin .allowed_hosts .extend(certificate.hosts.clone()); - desired_admin.allowed_hosts.sort(); - desired_admin.allowed_hosts.dedup(); } } + desired_admin.include_public_origin_host(); + desired_admin.allowed_hosts.sort(); + desired_admin.allowed_hosts.dedup(); let desired_admin_certificate_files = desired_admin .certificate_id .as_deref() diff --git a/src/admin/tests.rs b/src/admin/tests.rs index 93a3ca6..39f26fa 100644 --- a/src/admin/tests.rs +++ b/src/admin/tests.rs @@ -9,4 +9,5 @@ mod tests { include!("tests/part_08.rs"); include!("tests/part_09.rs"); include!("tests/part_10.rs"); + include!("tests/part_11.rs"); } diff --git a/src/admin/tests/part_11.rs b/src/admin/tests/part_11.rs new file mode 100644 index 0000000..03e5d6c --- /dev/null +++ b/src/admin/tests/part_11.rs @@ -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; + } diff --git a/src/admin/web/index.html b/src/admin/web/index.html index 775b58e..2fb3297 100644 --- a/src/admin/web/index.html +++ b/src/admin/web/index.html @@ -2734,9 +2734,17 @@ data-i18n="administrationHttps">Enable HTTPS for administration - + +

When behind a trusted reverse proxy, enter the panel's external HTTPS address once. Its DNS name is automatically allowed. A restart is required.

+
+ Advanced: additional allowed DNS hostnames + +

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.

+
diff --git a/src/admin/web/settings.js b/src/admin/web/settings.js index 6890247..8799fb8 100644 --- a/src/admin/web/settings.js +++ b/src/admin/web/settings.js @@ -8,6 +8,19 @@ ? "logging" : (typeof location !== "undefined" && location.pathname === "/settings" ? new URLSearchParams(location.search).get("tab") : null); 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; let active = buttons.some(button => button.dataset.settingsTab === normalizedRequestedTab) ? normalizedRequestedTab : defaultTab; function applySettingsSearch() { @@ -212,7 +225,10 @@ curl --fail --show-error --request POST \ $("admin-tls").checked = !!admin.tls; 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-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); 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"); bindSettingsForm("administration-form", next => { 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"); bindSettingsForm("tls-policy-form", next => { if (!$("tls-policy-12").checked && !$("tls-policy-13").checked) throw fieldError($("tls-policy-12"), t("tlsPolicyVersionRequired")); diff --git a/src/proxy/cache_helpers.rs b/src/proxy/cache_helpers.rs index 45da387..ace4915 100644 --- a/src/proxy/cache_helpers.rs +++ b/src/proxy/cache_helpers.rs @@ -181,8 +181,9 @@ fn response_context( } /// Reserved administration-session cookies may cross the proxy boundary only -/// for the explicit self-admin deployment: HTTPS, an explicitly allowed public -/// administration hostname, a backend that directly targets the configured +/// for the explicit self-admin deployment: HTTPS, a specifically allowed +/// 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. fn admin_session_passthrough( snapshot: &crate::config::Snapshot, @@ -196,9 +197,7 @@ fn admin_session_passthrough( .envelope .config .administration - .allowed_hosts - .iter() - .any(|allowed| allowed.eq_ignore_ascii_case(host)) + .explicitly_allowed_panel_host(host) } fn apply_response_rules( diff --git a/src/runtime/constructors.rs b/src/runtime/constructors.rs index 94bdbb5..ff19dab 100644 --- a/src/runtime/constructors.rs +++ b/src/runtime/constructors.rs @@ -145,10 +145,11 @@ impl App { active_administration .allowed_hosts .extend(certificate.hosts.clone()); - active_administration.allowed_hosts.sort(); - active_administration.allowed_hosts.dedup(); } } + active_administration.include_public_origin_host(); + active_administration.allowed_hosts.sort(); + active_administration.allowed_hosts.dedup(); let logs = Logs::start(log_settings, shutdown.clone()); let traffic = crate::traffic::Analytics::start(shutdown.clone()); let app = Arc::new(Self { diff --git a/src/settings.rs b/src/settings.rs index ede423c..ac0936b 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -1,5 +1,5 @@ //! 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 serde::{Deserialize, Serialize}; use std::{net::SocketAddr, time::Duration}; @@ -195,8 +195,13 @@ pub struct Administration { pub tls: bool, /// None uses the application-managed default certificate pair. pub certificate_id: Option, - /// 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, + /// 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, /// Absolute browser-session lifetime. Existing sessions are bounded by the current value. pub session_days: u16, pub panel_compression: PanelCompression, @@ -208,6 +213,7 @@ impl Default for Administration { tls: false, certificate_id: None, allowed_hosts: Vec::new(), + public_origin: None, session_days: 30, panel_compression: PanelCompression::default(), } @@ -225,6 +231,7 @@ impl Administration { || self.tls != active.tls || self.certificate_id != active.certificate_id || self.allowed_hosts != active.allowed_hosts + || self.public_origin != active.public_origin } pub fn validate(&mut self, tls: &crate::tls::Settings) -> Result<()> { if let Some(addr) = self.listen { @@ -263,6 +270,14 @@ impl Administration { "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(()) } } @@ -467,5 +482,6 @@ impl ServerHeader { } } } +include!("settings/public_origin.rs"); #[cfg(test)] include!("settings/tests.rs"); diff --git a/src/settings/public_origin.rs b/src/settings/public_origin.rs new file mode 100644 index 0000000..0f6e076 --- /dev/null +++ b/src/settings/public_origin.rs @@ -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 { + 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::() + .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::().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); + } + } + } +} diff --git a/src/settings/tests.rs b/src/settings/tests.rs index 065f645..14f05c3 100644 --- a/src/settings/tests.rs +++ b/src/settings/tests.rs @@ -256,6 +256,67 @@ mod tests { admin.allowed_hosts = vec!["panel.example.com".into(), "PANEL.EXAMPLE.COM.".into()]; 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] fn administration_hostname_count_is_not_artificially_capped() { let mut admin = Administration { diff --git a/tools/checks/admin_panel_config_0314_ui_test.mjs b/tools/checks/admin_panel_config_0314_ui_test.mjs new file mode 100644 index 0000000..dfdf40c --- /dev/null +++ b/tools/checks/admin_panel_config_0314_ui_test.mjs @@ -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('
')); +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`); diff --git a/tools/checks/admin_public_origin_0314_config_sim.mjs b/tools/checks/admin_public_origin_0314_config_sim.mjs new file mode 100644 index 0000000..92800fa --- /dev/null +++ b/tools/checks/admin_public_origin_0314_config_sim.mjs @@ -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', '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 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();} diff --git a/tools/checks/admin_public_origin_0314_sim.mjs b/tools/checks/admin_public_origin_0314_sim.mjs new file mode 100644 index 0000000..a25880f --- /dev/null +++ b/tools/checks/admin_public_origin_0314_sim.mjs @@ -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', '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 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();} diff --git a/tools/checks/check_source.py b/tools/checks/check_source.py index b4263f1..a67adc7 100755 --- a/tools/checks/check_source.py +++ b/tools/checks/check_source.py @@ -24,7 +24,7 @@ def read_docker_agent_source() -> str: preflight(root) manifest = tomllib.loads((root / "Cargo.toml").read_text()) -assert manifest["package"]["version"] == "0.31.3" +assert manifest["package"]["version"] == "0.31.4" assert manifest["dependencies"]["quinn"]["version"] == "0.11.12" h3dep = manifest["dependencies"]["h3"] 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" if lock_path.exists(): 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" for release_doc, marker in { - "README.md": "# RustProxy 0.31.3", - "CHANGELOG.md": "## 0.31.3", - "ROADMAP.md": "## 0.31.3", - "AGENTS.md": "Current source version: 0.31.3.", - "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.31.3", + "README.md": "# RustProxy 0.31.4", + "CHANGELOG.md": "## 0.31.4", + "ROADMAP.md": "## 0.31.4", + "AGENTS.md": "Current source version: 0.31.4.", + "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.31.4", }.items(): assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}" assert tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] == "stable" @@ -285,7 +285,7 @@ assert 'Opt-in health decisions based on ordinary user traffic. Defaults to disa assert '"force_ssl"' in openapi and '"forward_client_ip"' in openapi assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi assert '"StreamMetrics"' in openapi and '"streams"' in openapi -for marker in ['"version": "0.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 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 @@ -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 'logging_section_persists_error_log_settings_atomically' in admin_0241 and 'error_log_is_not_a_standalone_configuration_section' in admin_0241 assert 'location.pathname === "/logging"' in settings_js_0241 -assert '"RuntimeInformation"' in openapi and '"version": "0.31.3"' in openapi +assert '"RuntimeInformation"' in openapi and '"version": "0.31.4"' in openapi # 0.24.1 compiler and hostname-cardinality regressions. 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 '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.") diff --git a/tools/checks/dependency_upgrade_0312.py b/tools/checks/dependency_upgrade_0312.py index 01c6a5e..20f773f 100755 --- a/tools/checks/dependency_upgrade_0312.py +++ b/tools/checks/dependency_upgrade_0312.py @@ -1,5 +1,5 @@ #!/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. """ @@ -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') args = parser.parse_args() 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' deps = manifest['dependencies'] expected_requirements = { @@ -78,4 +78,4 @@ if missing: print(f'PENDING: lockfile still requires cargo update ({details})') else: 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)') diff --git a/tools/checks/docs_check.py b/tools/checks/docs_check.py index 9ce465f..9dd7e2a 100755 --- a/tools/checks/docs_check.py +++ b/tools/checks/docs_check.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Rust-independent documentation/Reader consistency checks for the 0.31.3 release.""" +"""Rust-independent documentation/Reader consistency checks for the 0.31.4 release.""" from pathlib import Path import re from rust_source import rust_source @@ -29,6 +29,8 @@ required = [ DOCS / "V0.25.2_AUDIT.md", DOCS / "V0.25.1_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 / "TEST_REPORT.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") roadmap = (ROOT / "ROADMAP.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 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 diff --git a/tools/checks/panel_cache_http_0310_ui_test.mjs b/tools/checks/panel_cache_http_0310_ui_test.mjs index eacc678..27db418 100644 --- a/tools/checks/panel_cache_http_0310_ui_test.mjs +++ b/tools/checks/panel_cache_http_0310_ui_test.mjs @@ -77,13 +77,22 @@ const bindSettingsForm = (id, handler) => handlers.set(id, handler); const adminStart = administration.indexOf('bindSettingsForm("administration-form", next => {'); const adminEnd = administration.indexOf('bindSettingsForm("tls-policy-form",', adminStart); assert(adminStart > 0 && adminEnd > adminStart); -runInNewContext(administration.slice(adminStart, adminEnd), { - $: field, bindSettingsForm, latestStatus: { administration: { listen_locked: false } }, +// Use the shipped helper in the extracted callback environment as well. +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 = {}; handlers.get('administration-form')(adminNext); assert.deepEqual(JSON.parse(JSON.stringify(adminNext.administration.panel_compression)), { 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-zstd').checked = false; const offNext = {}; diff --git a/tools/checks/preflight.py b/tools/checks/preflight.py index 25f732b..61f91b2 100755 --- a/tools/checks/preflight.py +++ b/tools/checks/preflight.py @@ -75,10 +75,10 @@ def check(root: Path, repair_known: bool = False) -> list[str]: raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}") for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/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(): - 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") 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 diff --git a/tools/checks/release_smoke.py b/tools/checks/release_smoke.py index 8554abe..b312615 100755 --- a/tools/checks/release_smoke.py +++ b/tools/checks/release_smoke.py @@ -135,7 +135,7 @@ def upgrade_smoke(tmp: Path) -> None: assert backup.is_dir() assert (backup / "src/obsolete.rs").is_file() assert not (target / "src/obsolete.rs").exists() - assert 'version = "0.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 / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n" assert (target / "langs/site.json").is_file() @@ -146,7 +146,7 @@ def upgrade_smoke(tmp: Path) -> None: assert (target / "docker-agent/certificate.go").is_file() assert (target / "docker-agent/server.go").is_file() manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8")) - assert manifest["to_version"] == "0.31.3" + assert manifest["to_version"] == "0.31.4" assert manifest["removed_obsolete_runtime_state"] is True assert not legacy.exists() assert not (backup / "legacy-data").exists() @@ -617,7 +617,7 @@ def source_contract_smoke() -> None: assert marker in admin, marker anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0] assert '"/api/status"' not in anonymous_block - assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.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 for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']: 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 openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8") 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 '"software_update"' in openapi and '"update_available"' in openapi for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']: @@ -1313,7 +1313,7 @@ def main() -> None: source_contract_smoke() container_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__": diff --git a/tools/checks/system_info_0301_ui_test.mjs b/tools/checks/system_info_0301_ui_test.mjs index 88e6fd1..c687d8c 100644 --- a/tools/checks/system_info_0301_ui_test.mjs +++ b/tools/checks/system_info_0301_ui_test.mjs @@ -43,7 +43,7 @@ const code = js.slice(js.indexOf(' const runtime = latestStatus?.runtime || { const elements = new Map(); const dollar = id => { if (!elements.has(id)) elements.set(id, { textContent: '' }); return elements.get(id); }; const status = { - version: '0.31.3', uptime_seconds: 4201, + version: '0.31.4', uptime_seconds: 4201, runtime: { 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', @@ -63,7 +63,7 @@ const context = { vm.runInNewContext(code, context); 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-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-cpu-usage').textContent, '17.2%'); 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.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.'); diff --git a/tools/dev/memory_load.py b/tools/dev/memory_load.py index ca90b99..4699129 100755 --- a/tools/dev/memory_load.py +++ b/tools/dev/memory_load.py @@ -54,7 +54,7 @@ def run_load( port = parsed.port or (443 if parsed.scheme == "https" else 80) 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 - headers = {"user-agent": "RustProxy-Memory-Stress/0.31.3", "accept": "*/*"} + headers = {"user-agent": "RustProxy-Memory-Stress/0.31.4", "accept": "*/*"} if host_header: headers["host"] = host_header