diff --git a/AGENTS.md b/AGENTS.md index 1ef7325..5a90d91 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.2. +compatibility. Current source version: 0.31.3. ## Delivery diff --git a/CHANGELOG.md b/CHANGELOG.md index d9c3611..4a6e600 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ > Operator-facing release history. Historical engineering notes are kept in > [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md). +## 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. +- Return a readable 403 document for invalid-Origin dashboard navigations; continue rejecting cross-origin API requests and cross-site fetches. Add redacted, bounded server diagnostics for rejected Origin checks and security regressions. +- Keep proxy routing, cookies/CSRF, local administration policy, SQLite schema 9 and user configuration unchanged. Rust/Cargo and real mobile reverse-proxy acceptance remain required. + ## 0.31.2 - Refresh core runtime, security, TLS certificate generation, database, Swagger UI and monitoring libraries for current stable versions. Keep persisted data, cache format, configuration, panel behavior, HTTP/3 and proxy routing compatible. diff --git a/Cargo.lock b/Cargo.lock index 00107da..89714f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1881,7 +1881,7 @@ dependencies = [ [[package]] name = "rust-proxy" -version = "0.31.2" +version = "0.31.3" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 5ccdd64..903cef3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-proxy" -version = "0.31.2" +version = "0.31.3" edition = "2021" rust-version = "1.99" description = "RustProxy - independent reverse proxy with local administration" diff --git a/README.md b/README.md index eec1213..eedbfa5 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# RustProxy 0.31.2 +# RustProxy 0.31.3 **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.2 dependency-refresh candidate source (Cargo verification required).** +**0.31.3 mobile-session recovery patch source (Cargo verification required).** ## Layer-4 streams @@ -13,7 +13,15 @@ Raw TCP/UDP forwarding has its own **Streams** page in the primary navigation, a Named HTTP/HTTPS listener bind addresses use the same chip-style editor as vhost hostnames, including multi-address listeners. -## What changed in 0.31.2 +## What changed 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. +- Log bounded, credential-redacted Origin mismatch diagnostics (HTTP method, path, public host/scheme, fetch site and trusted forwarding metadata). No cookies, authorization headers, query strings or session tokens are logged. +- New deterministic UI tests cover simultaneous visibility/BFCache events, session errors, manual recovery, stale session epochs and repeated transitions. No configuration or database migration; SQLite schema 9 and proxy traffic remain unchanged. +- **Release gate remains open:** Rust/Cargo were unavailable in the validation environment, and the inherited 0.31.2 Cargo.lock still requires a real `cargo update`/build on Rust 1.99+. Reproduce mobile behavior in Safari/Chrome through the actual reverse proxy before deploying to production. + +## Previously in 0.31.2 - Refresh Rust dependencies to current stable API families, including SQLx 0.9, Rand 0.10, Argon2 0.6, rcgen 0.14, Tower HTTP 0.7, Brotli 9 and Sysinfo 0.39. Retain SQLite schema 9, serialized settings, TLS/QUIC behavior and administration contracts. - Raise the minimum compiler version to Rust 1.99 for current dependency requirements, including Docker and RouterOS build images. diff --git a/ROADMAP.md b/ROADMAP.md index 73bf805..5e1072d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,12 @@ # RustProxy roadmap +## 0.31.3 - Mobile session recovery and Origin diagnostics + +- [x] Reproduce the overlapping resume-session checks, reload-loop marker reset and early SSE restart using the 0.31.2 source in a deterministic JavaScript simulation. +- [x] Coalesce page visibility/BFCache session verification, defer background requests until revalidated and offer a visible retry on failure without weakening Origin enforcement. +- [x] Display a safe 403 HTML message for invalid-Origin dashboard navigation and provide credential-redacted rejection diagnostics. +- [ ] Pass Rust/Cargo compile/clippy/test gates and live mobile Safari/Chrome reverse-proxy verification; diagnose the original 403 trigger using the logs. + ## 0.31.2 - Dependency updates - [x] Prepare stable dependency-family and code API migrations, preserving existing wire/configuration/SQLite formats. diff --git a/SHA256SUMS b/SHA256SUMS index 5662d82..4c5fb57 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,18 +1,18 @@ 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore -047139f3c7c40778001602e77ef69302e3bef8f495d48d780953c97c401fa2d4 AGENTS.md -9b42590541917e8d92fa26a5831b9dd43503f179337f8ed6c3a4d7d1de84d3b7 CHANGELOG.md -50238f33805987e7301f9ed06a8fd51b74b0e01330dbbae42c3a2fd8bd0d8e86 Cargo.lock -c6cc53538b89420b82526bc766d73a5fc93a82017f8fdb4bf767335dd613c90a Cargo.toml +2326433366f5ee39f50cc0c8fd308a1aa4bf913dd29c60d36d6ad40596e2057e AGENTS.md +fe15afa19911918df2cb8f754d42b23e367056b2350c4421273e4ea15558e04c CHANGELOG.md +a527e1bdd3ad6217a4a0bb06f29d33b4f5718525b8de81d4170ec7233bee5155 Cargo.lock +267b8d8ff64905e059ea6626a6ab48fb20f42cf5ae4af25ffc69b07535a9d42b Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile -f3ad7c8b4b450bcb32a7c68f90e868726154b3f1ea88c7d9ba5173850334d24e README.md -e2187e12e611638f85bae7808947b46a5de3c12db234ec217767ac8aefd1919a ROADMAP.md +1ceec1cdf7e056bf26096eedcf95f0efb192b69c0f614ba13ecf2808e534ebf3 README.md +4c8c8a4efa169b7c92d27715d15d467c95c705bd8b439302e6bbde26e56f1dfd ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md 4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service -31d018c9ef676d397bafa66c5099a57bc41ee56bbb272df59bc521574d920c82 dev.sh +5147ac3233914fe580b4c4d5ae1b74af2948019f34ff6629d21a7811b27a5b84 dev.sh 5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md 7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go 5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example @@ -41,6 +41,7 @@ befa50579e3475b22589f26380d85b3345b80e4fa947d49649a3b91a8dd8b8c2 docs/CUSTOM_LO 8dfc89a8b3512103b87864bd7c24a3dc3de4465edf8f9874e6bed1f4634dd930 docs/HTTP3.md 5301e321611d494fd97b04ab648df1f66a6a0d1f1f4c3475eab485e138d5605b docs/IMPLEMENTATION_0.31.0.md eb52e72325e1307d05cb04878b6d22dfedea491908c5f36110b32421765096c8 docs/IMPLEMENTATION_0.31.1.md +991ff3c09ca1e2bcf73b8fa2975405b955dd640c3f1e1246e75038c043c17183 docs/IMPLEMENTATION_0.31.3.md a00ec3c1d3b28ba10677435128e031e5f18fe8c5cef2ac90eb0f6779cefeb434 docs/INSTALL.md 3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md 4bb56add544f15d54695cf797cd16a51c09024360cdeef803a88988f4a54b3a5 docs/LIVE_LOGS.md @@ -54,7 +55,7 @@ e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND e68d5a5cf0991de9308f78afb0264e02979f7463a5f395eb7a6e9ec2bd3a3d54 docs/PHP_FPM.md bc068a176056f5ea4d3b637071e644ed58a2140494c233ef22aa66e9700e3299 docs/PHP_FPM_ISOLATION.md 572c70fbd2f61fecd80a8ff9fa5de4a94c4f158de5e90da8e920f99b051241fa docs/PROMETHEUS.md -7f3b78e71f91bc985dc5e40509264292ddd51a9a9b54f38d160fac4aa2021b6c docs/README.md +e87c81d2eb2e90d5b1ea9e40abb81a65ca81593eb5cc3e39717b818c9a75b287 docs/README.md 6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md 0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md d85c5567fc6a7714b96ad7cfe4ee9ff2085f84c4f31534a6484340d03a73e0f7 docs/RENEWAL.md @@ -65,7 +66,7 @@ a2bb837cdd3559d58158880c1f832badf64035376a3623050bb4e40c2bca104c docs/ROADMAP_H 007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md 2cd9dadc49a5474e4ab06d5c01fa38c27cab436578cb9c9f327a37b3406d2e13 docs/SETTINGS.md 3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md -8ce2502e64a159a23eadebafdc9f0d65d384e8099eb63368748ee7e0269e299d docs/TEST_REPORT.md +1de7cf885e596ccfab31a434d597c3a8d9113d206305810ea628a5ee6d96f986 docs/TEST_REPORT.md cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md 751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md 660687fc961f71aae2bdf71595aa47a336fa782f4e78ceee8c1d1e63e46393ee docs/TRANSPORT.md @@ -125,7 +126,7 @@ d242de9614fbb52933b4accd32f4715ca94107abe4a3b1d7e088362b07a6ba60 examples/vhost af2bdb1343a8141069e94ccd2c7f92574ad848437e2f9fdb5c7576a9a18ef4a5 examples/vhosts-private.json 4068b79ddd898af43b85617d8de9e9f48cfee09a4fb9ed26bf5b692571a30196 grafana/README.md 0d97fbf31f60563bb41f2ea8d798bccebc3d4c57f10bad8b7faa17296722e36b grafana/rustproxy-overview.json -fde541cd76bc3fa2b6765822bf04baf9f9ea3e4b6e185e76ff467270ddbd945e install-debian13.sh +a05cbbf101b288bf9cf83fedfc5017925a8b360a78f313d31c1a70f15dc1dd22 install-debian13.sh 909e9ab94617ace74a425f15084bf85f3086cc52ba5b28f2b441ff800abc6ef9 install.sh 42f9f1d18c021fc7734b5bd020844bf678725a0e539e2fcbe05ec676f6239eae integrations/README.md 2d4c859aae5ba357a6d30024c8487ae8f59f01b192711f13f9c2f8fee0f48b50 integrations/opnsense-acme/README.md @@ -137,8 +138,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 -70c85a656bac197b7dcf7048188ec2d3320c3da39908f79477a6484b968f6901 langs/en_US.json -0352d8d3c65ba5f60870a2fd3e945a79333e69a94d3349799ef285b22c3a9476 langs/pl_pl.json +19c17d16960539f9811cd551bbcbba25863711b064618069a58d48de3cc9fade langs/en_US.json +fcdb2db09c7a105893fc13462da4d0341a873e305bcc3fdcd0bd5e10b9a110ba langs/pl_pl.json a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql 20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql @@ -164,7 +165,7 @@ a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy 2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py 31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py -308ceeca51ddfb43db8be8286d5286aff1a44979f84af87d9346fb4176499c0c scripts/resolve_dependencies_0312.sh +3d8cfbc8177ef82f28dfba3fd555955dac4a29003267f93572ea26a98fb8255e scripts/resolve_dependencies_0312.sh 20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py 22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py @@ -182,14 +183,14 @@ e38331a1b186b49fe9d04051de52b603e1fa028def37e4e2f52e386b4d7b29ac src/acme/tests bc9eb32f0d2785bac122d5eff44742cd24306c503daa065564383d46416b2211 src/admin/access.rs 019f4aa33e3596bc9c11ef8cf5703bcfffe46c15288c0eba83c8dd9e38055fb2 src/admin/certificates.rs 1de4c7c116eeab2452ccccbaba3f2b7b63c6eb1c1e146ab870d19c5a5ca16f80 src/admin/documentation.rs -09498a66fefa1db0500d42b73833be67690af07ea33edb4ba57b939e8220322a src/admin/documentation/documents.rs +69095640bb0dca081317c7776cd73e83fab11ddd04fe903acea8f8c1b4899210 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 -8c58aec76463fc512ee87a8cceff7b905488c6f70185c055c496d235badea6d7 src/admin/openapi.json +a857bcbdef2a673be2f01c5817ca6d75dd5627b7e3b80777c394aa2edd933cd8 src/admin/openapi.json 0d1a5324e8ad32f5ab0861b722663f50f066e256bc5fd5dcced6a63f2bd399d1 src/admin/operations.rs 2b0ec6df0ed9e1bdc5c9e4ff1758b59dd14abc150f98365ac3edcf4672908e48 src/admin/panel_assets.rs 75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs @@ -198,14 +199,14 @@ d9e90166d2199152585a5208feedfc1dd1395dc32ddbd2a094ae4a1aee45e115 src/admin/pref 9baf3c0ec4c543bda51605930ac57d6425c5e03e06d29ff755a7ce3ff611aeaf src/admin/public.rs c9f952bec99dfe1d5a60998a38bd177f82f54de567230413e0c322ee561eba7c src/admin/public/tests.rs 6125419b099cf4d330767bc07424d52a2b400f273d31084c058c43a72a6427b7 src/admin/refresh_preferences.rs -f99655cf19b04b1eb7fb49d12bfd2ba1ef2c095b81e80ef46cfddfacefc91936 src/admin/router.rs -8708427263cd4caf8454b9115e89acb09172483a090c6b0bd43811dd9e52c7f6 src/admin/session.rs +765c14e52bdc9e542d6ff651c250c24d33b77b7e46f1b5f19acf96cc2c99bdc8 src/admin/router.rs +daf0f7393f2350e569a5e8dda52dbf3e3bbefcb989f07f723dca540ae0fe487e src/admin/session.rs 6fa2e586feb335ea9ce2c4dc523e422e4b2aa52c823ba8f8851f9e0f37cd2fef 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 -5df98246c46135a176b4b8b705c92fd328719e82eb7342f088256abb976d5ae7 src/admin/tests/part_01.rs +18aa617a0bf60a3d8d2ad7dd059a369e7549ef46683d8f912e07f8fb091c26d1 src/admin/tests/part_01.rs 6f8c1e30babb2cc4cb7accf4bfa28338651a469b16341f6e7eaec881dd9abf2b src/admin/tests/part_02.rs 34cf08cafb63cf7a8a6336e5c81fc99abfce8669662e387a3702c3a532be8056 src/admin/tests/part_03.rs 2137a53991c9337988545c4472b4ffcb9a111cac9c460ebbec7e51c0be8f5630 src/admin/tests/part_04.rs @@ -217,10 +218,11 @@ cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail bffa6611f3776e97d0065b510b63f083aabf7b3429fcc5cb26b1883fdb7c47dd src/admin/tests/part_10.rs 48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js 446011c2c7afddf7734ceb268c75a3b4146dbe8e915b11986495e956b6c62c69 src/admin/web/app/config-history.js -7ec3f1b199d3e0134c1097231ded61a23fd152cdb702c008bac8f496a47b52f0 src/admin/web/app/core.js +9527dcafb9c14e420cb841a6984d373ba2136d07c45350e9368dd7a88e89af22 src/admin/web/app/core.js 23721bfbac850c4f77b116cc32bb9d2db6361ac4dd7e310a8c8070ab906997f6 src/admin/web/app/preferences.js -344faee969e9d3b8c2f59a443d8ab1090c556ceff325a109e745663baa5e644e src/admin/web/app/refresh.js -397421b1fb680f5de1aa722e82f8a882877135b4a699e9fde0a9088c875341b3 src/admin/web/app/runtime.js +729ed152eb8471c67fb0e55e44fe0669c2708802ce83228df9cb4e53c14cee02 src/admin/web/app/refresh.js +984d092961bfaaa19f982e54c7d4be474e8417b208d814ac88bda63f1ab19f4c src/admin/web/app/runtime.js +db56370db60e514a2241d231cd949bac2c7aa2f413a2c14035a877029a8d864f src/admin/web/app/session-resume.js 86ac42bd4a6a84bb137d5a3c3dc3d1f4e404a543447845e6ea6e6e8210e268ce src/admin/web/app/settings.js 4fec1344be3cd540d1e6d0b87fb0aa94e1bef2968c4f86366a62c56d4085f452 src/admin/web/cache-panel.js 50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html @@ -238,16 +240,16 @@ e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/ 0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg af6e7a2e890df2fb7385a374bcb3fba52fb5a53dbe18adc1fb7865660fcba842 src/admin/web/file-logs.js b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js -6544bbebd94208551d5d5fecbfc9c6383dd01fb0a3680867ec9e6bbf05a9a29d src/admin/web/index.html +8490011e8164e6fab9601ff75a82eef3cc7972d1fdb8e8c4f73df5796ae72d37 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 -810e7e5bf1af302b51d90771c3ec3c20227eca392f8c7d91d027e11a999bc236 src/admin/web/management.js +21ddae9743ca545c89f6df0db40810dd680812e58f2b2b6875ed64fd8fb97ac6 src/admin/web/management.js 90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js -53cdc1d9d136bbea44a2cf9f06193e90392beb3544c687fbec0fff896565a3c2 src/admin/web/settings.js +df6efd27b1ebe1fafeb0dc6061b69be9aa46f71451f0fcdd00e2f7ef8692d3cb src/admin/web/settings.js bb67e77af5514478485cfb69f339d63d7eb0cbadd093fa054814a416260ccc1d src/admin/web/streams.js -727ad8dceae6f44ed2ebdb998fe29169bd61406700980b04390c0713c8d8b86c src/admin/web/style.css +c4f051f54293156803b48c04d972e9d5a290209826138ce15356d19adf569979 src/admin/web/style.css d01b615635c7373981b0633cc27298a34e67fe0cbe9d1384d277d46f0cafb1b4 src/admin/web/traffic-panel.js a6e7bbff4fd2d50ecf759ed28161a672d7b483af2b35c9f912746a7a06a9f476 src/admin/web/transform-panel.js 57a2b975716bdab32c33ec1bf9427b25998550e81ce191440ee66ffb7e2a249e src/admin/web/vhosts/core.js @@ -256,9 +258,9 @@ a6e7bbff4fd2d50ecf759ed28161a672d7b483af2b35c9f912746a7a06a9f476 src/admin/web/ f2689283cfe43d6aae911b0df778a69335089be230fbfc6de5dcce1801c38427 src/admin/web/vhosts/render.js 91e3417fba7e8f59023cd64813befccc8341741cd4aa53dfa1ec68a03c7f4632 src/admin/web/web-server-profiles.js de3326d4fae254eb0b8c190f02e762f4094dcba86bfdcb24c5fb472676ebcc4b src/admin/web/web-server.js -76b0d92e4360e06ae97f87c0f6ddde4962c44efdeec9727e5b9a720598302891 src/auth/files.rs +53237ae8b08d261ce21e0aa6fd225bf4f3bb17a37a80529e2a4039d45b13f7c5 src/auth/files.rs b2f948bcc3dbd24952c7b1b02d41d06757f49f0c38a78caf51f8f8a687d8480d src/auth/files/tests.rs -105cb934956457a68adc14f2c64569d9d1d40b8da0a7908a9a59fc2153a939c9 src/auth/mod.rs +775178ee5781e64574bdd7c1fb7d64e7e283977fbd9c98c99896532a7f1df612 src/auth/mod.rs c96a87e82ff0c1c9ce3d0cd5135903bb53af78d1c4fb6ebfe5fea503bf5644ea src/auth/store.rs 745226cabb007819370fc6aa680995a03a8a13fa057dacfab0fcc21d0c43bbe4 src/auth/tests.rs 09c3a622a7d2169e49e3369ea5f0f816eab2f717eaf1675c625d518c8f34d367 src/backup.rs @@ -270,8 +272,8 @@ f5afae218f3a6f91bc2f1f5a90094630ce5b7ca574de61807b5a317b13937ec2 src/backup/val 426a946b2c9e244f09f6cbc5f9664b8ad0737529a9bb5a3d549d27196a1f8043 src/balance.rs 3d4b3986f54bed99212063b6d135d40a202d556a3e4d253e24c74be4cb9e8ebc src/balance/health.rs a1e5d43f5417d71f3974812cb539c0932f206e2290090917f067494f3587fc72 src/balance/lease_runtime.rs -46d739f14b510b35aaccf18fb9f21593c92d8b8d3ea47828271574df5ed008e5 src/balance/model.rs -2a6b20ffc2b326cb2380beb0805b52181306d3b9cc57330d67930000535c03f9 src/balance/pool_core.rs +4d3aa415af2fbb42a4e19f2ca10be8b1d1ad42f366a013cd9c33806abc1fbea2 src/balance/model.rs +41c3c020a60c372dbf965bd277c330dd9cf2ab079f6608b472adf72a656f4cb3 src/balance/pool_core.rs 477132dd381e23f1be197c32ff58a0925f41d3e6aa63fe77c10973cefb2b76f6 src/balance/pool_selection.rs 519cb798040ac9a65d9ebe43ae11ef729bd78c5c4a5c756490458add4b6fdc24 src/balance/pool_view.rs 7d94d1bd4eb698e0588b822d93e56e3dc5594c46edac4aa9713f148893ebe6d5 src/balance/tests.rs @@ -412,7 +414,7 @@ c438066616527a3108f2ad53e3698ae9b2a80961714394cdd0a450c92f99d992 src/proxy/head cc682f0d5754e0b806a4eb5950e4cc00611aca5ce4668414fddef1d692489805 src/proxy/tests/authority_tests.rs d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/tests/http_invalidation_tests.rs e80c7fe44abb7607c64ea02d56542753dc25290b3bcf4c4d45b75f2db6ca90d0 src/proxy/types.rs -55c44a5d0c6588beea67439d62e3c9f57041e4d07cb1f8cf293efd856ceb8eff src/proxy/upstream_helpers.rs +9999f717492755d62fc05cd5c1aff802ff0da901fe3e6b928bd8a000833c8799 src/proxy/upstream_helpers.rs 69f775f6746b4c6d729dc10961dbef54168846f4ce9cf7fe453ab5a2b879c9b8 src/proxy/websocket.rs d866e7e72dee6ff54c2418c34e9899cbcd7e4e027d9cbe2b625044dc9062e4ee src/proxy/websocket/handshake.rs 7e9c4d4c207badabf90630223964bb2ad641f06d4149843cab751f192b8b4adf src/proxy/websocket/tests.rs @@ -429,7 +431,7 @@ d0939e1b0829c25356a0b67ecb3a178c74936dd07baa15fee24eb32544db6cad src/runtime.rs e90df3fff8a25465425b099ae358d19085809e56d948ba47e01e53f8b93d5dd0 src/runtime/tests.rs 0046525317ffbd5d3b34845656b0f58d9d31f38b3d8acd71e90d94bd7cdfb8ab src/runtime/types.rs 88bc546fdda20d3f1f8f5e7d7872ede1fa334b8bb5102c729bd72f56e3fb94b9 src/runtime/updates.rs -b5062082d7fc7c9b08533283e1f0a841f85a42917b6ebf135760b04023884d9a src/secret_store.rs +208a378b14ca80e1412ffa19ef1b140594182971e2e797ee0bb95bcdb8404da7 src/secret_store.rs 6dca8da072a3bd610704a391bb6e25c9042971f4ebc60edb47665ce1d74cbc58 src/secret_store/tests.rs 6fa1d8c352bce7a16ff0208bc4cdbe80404715df0aff4ed26910a5df57cbbcd4 src/security.rs 0e5fc2aff856bf6fd959cba12b5ddd1e3b3cbeb0fbaadde906c811cdfafdaae7 src/security/tests.rs @@ -523,33 +525,34 @@ b04362ad082a32c9ccc4cf38c46f44d71347adecf7aba3224b3af3ea4872e3d0 tools/README.m 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 -4bb77602254ff8ed1673b6d31518c77ae40c9b9c44e85ac436b93a300115ca18 tools/checks/check_source.py +bebe46609381f2050d90c809e9b5ac7b564d5b26f733a926f20e5363a4140d34 tools/checks/check_source.py 0b583ee59dcccc35b15ef4e211d7e6546a8aaa7b0286ceab03c0b0dc084c1e07 tools/checks/dashboard_boot_0311_ui_test.mjs -5f798699502c7a18e7b45aa84bdcf7ad0ccdb77f11cf2003527c97f0391c7abe tools/checks/dashboard_startup_0310_ui_test.mjs -3a5703cd3e0e511e0ea8da0c7802ccdc5cf53289f9b9169b9cf96ea5209b4a57 tools/checks/dependency_upgrade_0312.py -5d4fa0b66ae05241236551c07c900ad0c437bd10e34406b17a8a002188caebc3 tools/checks/docs_check.py +259d4b0985c82b631e151a07e8ef3b4c760019cb99bb2af458ce577497f7df58 tools/checks/dashboard_startup_0310_ui_test.mjs +6adc94ee148508a2e46f073eb6f3b89c4057983dea12c976ae7332fb8423ce41 tools/checks/dependency_upgrade_0312.py +dd7b7512f5a988c1fe6fdbdeb78c3b0d33a23b57335eb4f881296cb252d8692c 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 -8c5c64b681de7f72f01b414f2c77d1f9a44c13b47972d8252a0d392fd71c6ab4 tools/checks/preflight.py -3485681d369b5f486dfc2d14660f19b4098df670bafd9a296cc9dabe68003a51 tools/checks/refresh_0310_ui_test.mjs -db468bc0ad68704e70a15d7dda894d1913db1e2ce94e04a84b20c7dadfaa80a6 tools/checks/release_smoke.py +7770cc0b1b70255003d19b63210cc3e21ca9c8804cb56c2fe01eaf2e04ebb0fa tools/checks/preflight.py +021a97bd6763b59f3f7eca15f51efdf88d0c863f1f81a2e42ba80a9a09ca3fec tools/checks/refresh_0310_ui_test.mjs +951d184c67cf3b006f89dc6588f460797fcad9c15460f6638abd8316f8777736 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 -5c1d428cbda5f08b8d6847732672e45e9da96b545337334e473986e119197de2 tools/checks/source_module_contracts.py -49c1fe96340d97c326943826dd8b3331b884c19f4a9631beaa820f01a12108e0 tools/checks/system_info_0301_ui_test.mjs +0d211440ae77f16187a5d833156911c7793ed27ac1d77e79de362e47c1ab1abd tools/checks/source_module_contracts.py +51afd5f31eeea8f1658d239dd34637d38a0a7eb1fb9f58a212040505d3b39b0c tools/checks/system_info_0301_ui_test.mjs 543a488951ea6ec8fe06e04f168ccc4d4f2af3cfc281c9a728ab7bd87377fa98 tools/checks/system_info_no_subprocess_contracts.py 614ed0c775e32ec5bfb4ee75aaa90206a10da0f43659747a7ec76fd93e33767a tools/checks/version_contracts.py -2020e171be1a044f7ca2ab83af7604a014c36fe4ff04791d0620ccbf1bf8e000 tools/checks/vhost_origin_errors_0297_ui_test.mjs +161637cb5e2e613e3c71cf8489cc936fa84913a2a02649463f8893a28a674242 tools/checks/vhost_origin_errors_0297_ui_test.mjs 0c071c26fc316934bdfd2bfd19f5fed9521790d8c887760eaabda81a9be96703 tools/checks/vhost_www_ui_test.mjs -597d6d2da052390e285f18a257d10e6ab8dbd7add666457656cd803416b7975d tools/checks/web_assets.py +08f75c17084352df8d4dd0d1e712486d6d600739451735976de2234903538bc5 tools/checks/web_assets.py 0398892be30fe416a00578915c588f360fd93244e3af77ece834e560e547d1c8 tools/checks/web_server_contracts.py 35f76060e585a96c623ef83bd5f771969f5be16a0e1ae626a3092d7458b84e84 tools/checks/web_service_ui_test.mjs adaa1ed9e5958739df40190055c780c80d0a8e270ec8dc105ed3fa2dbb8fb74f tools/checks/www_redirect_ui_test.mjs -05382366fd801a17f8d7c1166fd4e64b8aa70e465cb69121519b239ebf8ed55f tools/dev/memory_load.py +cfa2dee224a11bf3948dbe528f5877a76b830b7efb01cd785007213cc245493d 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 a0741a1..cd41981 100755 --- a/dev.sh +++ b/dev.sh @@ -110,6 +110,7 @@ if [[ "$mode" == "test" ]]; then node tools/checks/refresh_0310_ui_test.mjs node tools/checks/dashboard_startup_0310_ui_test.mjs node tools/checks/dashboard_boot_0311_ui_test.mjs + node tools/checks/origin_resume_0313_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/IMPLEMENTATION_0.31.3.md b/docs/IMPLEMENTATION_0.31.3.md new file mode 100644 index 0000000..a14fbd0 --- /dev/null +++ b/docs/IMPLEMENTATION_0.31.3.md @@ -0,0 +1,43 @@ +# RustProxy 0.31.3 - Mobile admin session recovery + +## Scope and evidence + +The supplied 0.31.2 source was inspected and executed with a deterministic Node.js VM harness. The original functions permitted two simultaneous `/api/auth/session` requests from `pageshow` and `visibilitychange`. In the reproduced race, one `auth.invalid_origin` response called `location.reload()`, another successful response cleared the reload-protection marker, and a subsequent 403 triggered another reload. The event ordering and client-side failure were reproduced; the original server-side rejection on the reported mobile device remains unexplained because no captured mobile HTTP exchange was provided. + +The dashboard's prior visibility handler also restarted the SSE channel and refresh timers before it checked the session. A full dashboard navigation rejected at the server could return raw JSON as the document, rather than an actionable page. + +## Changes + +- `src/admin/web/app/session-resume.js`: shared in-flight session validation; one visible retry path after 403/network errors, no `location.reload()` on resume; successful checks update CSRF/session display and release polling only if the auth epoch is unchanged. An HTTP 401 keeps the existing login/lock flow. +- `src/admin/web/app/refresh.js`: `visibilitychange`, `pagehide`, and BFCache `pageshow` share a resume gate. Pause/abort polling and close SSE before validating; allow background tasks only after success. Repeated callbacks during the same visible period do not initiate another check. +- `src/admin/web/app/runtime.js` and `core.js`: initial-startup reload marker is cleared only after successful full activation, including preferences/configuration. Startup errors stop the refresh coordinator and display a visible failure. Session failures show a localized manual retry button. +- `src/admin/session.rs`: unchanged Origin matching and `Sec-Fetch-Site` enforcement; 403 from invalid Origin now has bounded structured diagnostics showing method, path (without query), sanitized Origin, Host, expected external scheme/authority, fetch site, trust status, and safe forwarding metadata. No Cookie, Authorization, CSRF token, secret, query parameter or untrusted forwarded Host value is logged. Browser document navigation requesting HTML receives a standalone 403 error/retry page; API responses remain JSON. +- Localization (`en_US`, `pl_pl`), versioned panel assets, developer checks and release identity were updated. No schema/config/proxy data-plane or public route change. + +## Test matrix + +| Scenario | Source simulation | Live acceptance still needed | +| --- | --- | --- | +| Visibility + BFCache in one event sequence | One `/api/auth/session` check | iOS Safari / Android Chrome | +| 403 during resume | No automatic reload; error screen; paused SSE and tasks | Capture browser headers and proxy forwarding | +| Manual retry after temporary rejection | Single recheck; restored shell/SSE on success | Network interruption/recovery | +| HTTP 401 | Existing lock/login behavior | Expired/revoked real session | +| Pagehide/restore, 10 repeated cycles | One check per return; no leaked event handlers or parallel SSE | Real BFCache lifecycle | +| Stale session response after logout | Ignored without restoring session state | Concurrent logout/restore | +| Startup failure | Visible error, recovery marker retained | Missing JS or language/config response | +| Foreign Origin / cross-site | Rust regression test added, exact checks retained | Real server test with Rust/Cargo | +| Document navigation blocked by Origin | Rust regression test added, 403 HTML response | Mobile top-level navigation | + +The deterministic browser suite uses actual application code and a simulated API. It is *not* a real browser, reverse proxy, or compiled Rust backend test. `Cargo.lock` comes from the prior 0.31.2 candidate, remains unresolved against the current dependency constraints and must be generated by a real Rust 1.99+ Cargo resolution. Do not deploy on the basis of static/source-only testing. + +## Release-host validation + +```bash +bash scripts/resolve_dependencies_0312.sh +./dev.sh check +./dev.sh test +./dev.sh release +(cd docker-agent && go test ./...) +``` + +Then verify on a real administrator endpoint with correct trusted reverse-proxy forwarding. Compare the external scheme/authority with `Origin`, `Host`, `Sec-Fetch-Site` and **only trusted** `X-Forwarded-Host` / `X-Forwarded-Proto` in the diagnostic log. Test at least 20 return-to-tab cycles on actual mobile browsers, including BFCache, session expiration, temporary 403 responses and recovery. Confirm that disallowed origins still return 403 and that polling/SSE do not grow across cycles. Do not turn off Origin/CSRF or loosen forwarded-header trust to hide the symptom. diff --git a/docs/README.md b/docs/README.md index 5edab32..33f2415 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.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. - [0.31.0 implementation report](IMPLEMENTATION_0.31.0.md) - consolidated changes, exact file inventory, validation results and outstanding Rust runtime acceptance. @@ -81,7 +82,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.1.md](IMPLEMENTATION_0.31.1.md); [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.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. 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/TEST_REPORT.md b/docs/TEST_REPORT.md index 0b91192..fe835d2 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -1,4 +1,16 @@ -# Verification report - RustProxy 0.31.2 +# Verification report - RustProxy 0.31.3 + +## 0.31.3 - invalid-Origin resume and mobile panel recovery (2026-10-11) + +- **Source:** the uploaded RustProxy 0.31.2 ZIP plus the user's case description. No remote repository/history or real mobile network capture was provided. +- **Reproduced in the original source:** two overlapping session validations; a 403 during return-to-tab caused `location.reload()`; a concurrent successful validation cleared the recovery marker; another 403 could reload again. Separately, refresh tasks and SSE restarted before a resumed session was validated. +- **Corrections:** single-flight session verification and coalesced visibility/BFCache events; suspend polling and SSE until verified; visible localized retry on 403/network errors; unchanged login handling on 401; clear the startup recovery marker only after full initialization; stop refresh on startup failure. Invalid-Origin top-level HTML navigation returns an explicit 403 error page rather than raw JSON. Server Origin/CSRF/cross-site enforcement is unchanged; structured diagnostic logging redacts credentials and untrusted forwarding data. +- **Regression coverage:** `tools/checks/origin_resume_0313_ui_test.mjs` exercises actual JavaScript source against deterministic session API outcomes, repeated tab cycles, stale auth epoch, SSE suspension/resume, retry, 401 and startup marker. Rust unit/integration tests were added for blocked foreign origins, safe diagnostic values and readable 403 document handling, but cannot run here without Cargo. +- **PASS (2026-10-11):** `tools/checks/origin_resume_0313_ui_test.mjs` and all 11 other bundled Node UI tests; actual assembled dashboard bundle parsed by Node; original-source Node reproduction recorded the duplicate request/reload race. +- **PASS (2026-10-11):** source/module/version/security/dependency contracts, SQLite schema-9 migration and upgrade smoke (`release_smoke.py`), documentation/Reader (`docs_check.py`), language packs (`langs_check.py`), shell syntax and Go Docker Agent `go test ./...`. Memory workload simulated 300,000 PURGEs; 2,000 HTTP requests targeted a **Python test peer**, not the RustProxy executable. +- **NOT VERIFIED:** `./dev.sh check` exits 127 because `cargo`/`rustc` are absent. `./dev.sh test` passed all Rust-independent stages through docs/locales and reached the same missing-Cargo gate; it did not run Rust unit tests. No compiled RustProxy, iOS/Android browser, trusted reverse-proxy trace or production rollout was exercised. +- **Verification limit:** the new Rust tests are written but unexecuted; the lockfile is an inherited dependency snapshot, not a resolved 0.31.3 graph. The code has not passed a compiler-enabled release gate. +- **Release gate remains OPEN:** the local environment has no rustc/cargo/rustfmt. The inherited lockfile is unresolved for 0.31.2/0.31.3 dependency families. Run Cargo resolution, fmt, clippy, Rust tests and real admin/reverse-proxy/mobile acceptance on a Rust 1.99+ host before production release. ## 0.31.2 — stable library upgrade candidate (2026-10-10) diff --git a/install-debian13.sh b/install-debian13.sh old mode 100644 new mode 100755 diff --git a/langs/en_US.json b/langs/en_US.json index fe639d7..7e7d6b5 100644 --- a/langs/en_US.json +++ b/langs/en_US.json @@ -2323,6 +2323,8 @@ "startupSlow": "The panel is taking longer than expected. Check your connection and try again.", "startupRetry": "Reload panel", "startupFailed": "Could not load RustProxy", + "sessionValidationFailed": "Could not verify your session", + "sessionRetry": "Retry connection", "panelCompressionTitle": "Panel content compression", "panelCompressionHelp": "Compressed HTML, scripts, styles and language files. Browser negotiation: Zstd → Brotli → gzip at equal quality. Disable all for identity. Takes effect immediately; a reverse proxy may change Accept-Encoding or re-encode responses.", "cacheHttpInvalidationCurlLabel": "How to send BAN and PURGE with curl", diff --git a/langs/pl_pl.json b/langs/pl_pl.json index 6947326..042e341 100644 --- a/langs/pl_pl.json +++ b/langs/pl_pl.json @@ -2323,6 +2323,8 @@ "startupSlow": "Wczytywanie trwa dłużej niż zwykle. Sprawdź połączenie i spróbuj ponownie.", "startupRetry": "Wczytaj panel ponownie", "startupFailed": "Nie udało się uruchomić RustProxy", + "sessionValidationFailed": "Nie udało się sprawdzić sesji", + "sessionRetry": "Ponów połączenie", "panelCompressionTitle": "Kompresja zasobów panelu", "panelCompressionHelp": "Obejmuje HTML, skrypty, style i języki. Negocjacja z przeglądarką: Zstd → Brotli → gzip przy równej jakości. Odznacz wszystkie, by wyłączyć. Działa od razu; reverse proxy może zmieniać Accept-Encoding lub kodowanie odpowiedzi.", "cacheHttpInvalidationCurlLabel": "Jak wywołać BAN i PURGE poleceniem curl", diff --git a/scripts/resolve_dependencies_0312.sh b/scripts/resolve_dependencies_0312.sh index 17c2e2c..7b1a817 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.2' ]]; then - printf 'Expected RustProxy 0.31.2 sources.\n' >&2 +if [[ "$(python3 scripts/version.py)" != '0.31.3' ]]; then + printf 'Expected RustProxy 0.31.3 sources.\n' >&2 exit 1 fi printf 'Toolchain: '; rustc --version diff --git a/src/admin/documentation/documents.rs b/src/admin/documentation/documents.rs index 25a1216..0f32321 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.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 a78cad2..897870c 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.2", + "version": "0.31.3", "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": [ diff --git a/src/admin/router.rs b/src/admin/router.rs index 64ed824..6b734c5 100644 --- a/src/admin/router.rs +++ b/src/admin/router.rs @@ -35,6 +35,7 @@ fn admin_app_javascript() -> &'static str { static ASSET: std::sync::LazyLock = std::sync::LazyLock::new(|| { [ include_str!("web/app/core.js"), + include_str!("web/app/session-resume.js"), include_str!("web/app/refresh.js"), include_str!("web/app/preferences.js"), include_str!("web/app/config-history.js"), diff --git a/src/admin/session.rs b/src/admin/session.rs index dd55d68..7dc662f 100644 --- a/src/admin/session.rs +++ b/src/admin/session.rs @@ -1,3 +1,108 @@ +// Only log browser origin metadata: never cookies, CSRF tokens, Authorization, +// query strings, or untrusted forwarding data. Malformed origins are redacted. +fn diagnostic_admin_authority(value: &str) -> &str { + if value.len() <= 255 && !value.contains('@') + && value.parse::().is_ok() + { + value + } else { + "(invalid)" + } +} + +fn diagnostic_admin_origin(headers: &HeaderMap) -> &str { + if !headers.contains_key(ORIGIN) { + return "(absent)"; + } + let Some(value) = single_header(headers, ORIGIN.as_str()) else { + return "(invalid)"; + }; + if value == "null" { + return "null"; + } + if value.len() > 255 || value.contains('@') || value.contains('#') { + return "(invalid)"; + } + let Ok(uri) = value.parse::() else { + return "(invalid)"; + }; + if !matches!(uri.scheme_str(), Some("http" | "https")) + || uri.query().is_some() + || !matches!(uri.path(), "" | "/") + || uri.authority().is_none() + { + return "(invalid)"; + } + value +} + +fn diagnostic_forwarded_header<'a>(headers: &'a HeaderMap, name: &str, trusted: bool) -> &'a str { + if !headers.contains_key(name) { + return "(absent)"; + } + if !trusted { + return "(untrusted)"; + } + let Some(value) = single_header(headers, name) else { + return "(invalid)"; + }; + if name == "x-forwarded-proto" { + if matches!(value, "http" | "https") { value } else { "(invalid)" } + } else { + diagnostic_admin_authority(value) + } +} + +fn invalid_admin_origin( + request: &Request, + reason: &'static str, + scheme: Option<&str>, + authority: Option<&str>, + trusted_host: bool, + trusted_proto: bool, +) -> Response { + let headers = request.headers(); + let path = request.uri().path(); + let path = if path.len() <= 160 { path } else { "(long_path)" }; + let browser_host = single_header(headers, HOST.as_str()) + .map(diagnostic_admin_authority) + .unwrap_or("(absent_or_duplicate)"); + let browser_fetch_site = match single_header(headers, "sec-fetch-site") { + None => "(absent)", + Some("same-origin" | "same-site" | "cross-site" | "none") => + single_header(headers, "sec-fetch-site").unwrap_or("(invalid)"), + _ => "(invalid)", + }; + tracing::warn!( + reason, + method = %request.method(), + path, + browser_origin = diagnostic_admin_origin(headers), + browser_host, + expected_scheme = scheme.unwrap_or("(unknown)"), + expected_authority = authority.map(diagnostic_admin_authority).unwrap_or("(unknown)"), + browser_fetch_site, + trusted_forwarded_host = trusted_host, + trusted_forwarded_proto = trusted_proto, + forwarded_host = diagnostic_forwarded_header(headers, "x-forwarded-host", trusted_host), + forwarded_proto = diagnostic_forwarded_header(headers, "x-forwarded-proto", trusted_proto), + "administration origin rejected" + ); + // When a browser navigates to the dashboard document, JSON here would + // replace the entire page with a raw API error. Keep the 403 (no shell or + // protected data) but present a standalone recovery message instead. + let wants_html = request.headers().get(http::header::ACCEPT) + .and_then(|header| header.to_str().ok()) + .is_some_and(|value| value.split(',').any(|entry| entry.trim_start().starts_with("text/html"))); + if request.method() == Method::GET && panel_document_path(request.uri().path()) && wants_html { + return secure(( + StatusCode::FORBIDDEN, + axum::response::Html("RustProxy - Access blocked

RustProxy - Could not open the panel

The request origin was rejected. Check the proxy/URL and try again.

Reload panel

"), + ).into_response()); + } + secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin")) +} + async fn guard(State(state): State>, mut request: Request, next: Next) -> Response { let transport = request .extensions() @@ -10,9 +115,13 @@ async fn guard(State(state): State>, mut request: Request, next: Next) Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")), }; 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 secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin")), + Err(()) => { + return invalid_admin_origin(&request, "external_scheme", None, None, trusted_host, trusted_proto); + } }; let external_authority = match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") { @@ -26,13 +135,13 @@ async fn guard(State(state): State>, mut request: Request, next: Next) .is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority)) }) { - return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin")); + return invalid_admin_origin(&request, "origin_mismatch", Some(scheme), Some(&external_authority), trusted_host, trusted_proto); } if headers .get("sec-fetch-site") .is_some_and(|h| h == "cross-site") { - return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin")); + return invalid_admin_origin(&request, "cross_site_fetch", Some(scheme), Some(&external_authority), trusted_host, trusted_proto); } let path = request.uri().path().to_owned(); let public = anonymous_allowed(request.method(), &path); diff --git a/src/admin/tests/part_01.rs b/src/admin/tests/part_01.rs index 2fec490..0527e53 100644 --- a/src/admin/tests/part_01.rs +++ b/src/admin/tests/part_01.rs @@ -253,6 +253,69 @@ )); } + #[test] + fn admin_origin_diagnostics_redact_non_origin_values() { + let mut headers = HeaderMap::new(); + assert_eq!(diagnostic_admin_origin(&headers), "(absent)"); + headers.insert(ORIGIN, HeaderValue::from_static("https://panel.example.test")); + assert_eq!(diagnostic_admin_origin(&headers), "https://panel.example.test"); + headers.insert(ORIGIN, HeaderValue::from_static("https://user:password@panel.example.test")); + assert_eq!(diagnostic_admin_origin(&headers), "(invalid)"); + headers.insert(ORIGIN, HeaderValue::from_static("https://panel.example.test/?token=secret")); + assert_eq!(diagnostic_admin_origin(&headers), "(invalid)"); + headers.insert("x-forwarded-host", HeaderValue::from_static("other.example.test")); + assert_eq!(diagnostic_forwarded_header(&headers, "x-forwarded-host", false), "(untrusted)"); + assert_eq!(diagnostic_forwarded_header(&headers, "x-forwarded-host", true), "other.example.test"); + } + + #[tokio::test] + async fn unauthorized_browser_origins_remain_rejected_for_session_checks() { + let (router, store, _dir, secret, _) = fixture().await; + for (origin, fetch_site) in [ + (Some("https://attacker.example"), None), + (Some("null"), None), + (None, Some("cross-site")), + ] { + let mut request = Request::builder() + .uri("/api/auth/session") + .header("host", "127.0.0.1:9090") + .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)); + if let Some(origin) = origin { request = request.header(ORIGIN, origin); } + if let Some(site) = fetch_site { request = request.header("sec-fetch-site", site); } + let response = router.clone().oneshot(request.body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + // Origin on an ordinary same-origin GET may be absent, or may match. + for origin in [None, Some("http://127.0.0.1:9090")] { + let mut request = Request::builder() + .uri("/api/auth/session") + .header("host", "127.0.0.1:9090") + .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)); + if let Some(origin) = origin { request = request.header(ORIGIN, origin); } + assert_eq!(router.clone().oneshot(request.body(Body::empty()).unwrap()).await.unwrap().status(), StatusCode::OK); + } + store.close().await; + } + + #[tokio::test] + async fn invalid_origin_on_document_is_visible_but_still_forbidden() { + let (router, store, _dir, _secret, _) = fixture().await; + let response = router.oneshot( + Request::builder() + .uri("/overview") + .header("host", "127.0.0.1:9090") + .header("origin", "null") + .header("accept", "text/html,application/xhtml+xml") + .body(Body::empty()) + .unwrap(), + ).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!(response.headers().get(http::header::CONTENT_TYPE).unwrap().to_str().unwrap().starts_with("text/html")); + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); + assert!(String::from_utf8_lossy(&body).contains("Could not open the panel")); + store.close().await; + } + #[tokio::test] async fn trusted_https_reverse_proxy_accepts_external_origin_and_refreshes_secure_cookie() { let mut config = Config::default(); diff --git a/src/admin/web/app/core.js b/src/admin/web/app/core.js index 1c92df5..dccbf3d 100644 --- a/src/admin/web/app/core.js +++ b/src/admin/web/app/core.js @@ -25,7 +25,7 @@ function panelFromLocation() { if (typeof location === "undefined") return "over let envelope = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null; let samples = [], protocolSamples = { h1: [], h2: [], h3: [] }, websocketProtocolSamples = { h1: [], h2: [], h3: [] }, transformSamples = [], toastTimer, editingBackend = null, editingRoute = null; let liveEvents = null, liveConnected = false, liveFallback = false, liveInterval = null, authEpoch = 0, pendingAuthDestination = null; -let lastApiLatencyMs = null; +let lastApiLatencyMs = null, sessionCheckPromise = null; let adminAuthChannel = null; try { adminAuthChannel = new BroadcastChannel("rustproxy-admin-session"); adminAuthChannel.addEventListener("message", event => { if (event.data === "locked" && authenticated) lock(false); }); } catch { } // Language content is discovered by build.rs from langs/*.json, never compiled // into a two-language switch here. Locale metadata also drives Intl and RTL. @@ -388,7 +388,7 @@ async function activate(info) { document.documentElement.classList.remove("app-startup-failed"); rustProxyRefresh.start(); const preferences = await loadAdminPreferences(); - if (!authenticated || epoch !== authEpoch) return; + if (!authenticated || epoch !== authEpoch) return false; window.rustProxyLogs?.applyPreferences(preferences); window.rustProxyFileLogs?.applyPreferences(preferences); window.rustProxyVhosts?.applyPreferences(preferences); @@ -399,17 +399,18 @@ async function activate(info) { for (let attempt = 0; attempt < 3 && authenticated && epoch === authEpoch; attempt++) { try { await loadConfig(); break; } catch (error) { - if (!authenticated || epoch !== authEpoch || error?.status === 401) return; - if (attempt === 2) { showStartupFailure(error); return; } + if (!authenticated || epoch !== authEpoch || error?.status === 401) return false; + if (attempt === 2) { showStartupFailure(error); return false; } await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 400)); } } - if (!authenticated || epoch !== authEpoch) return; + if (!authenticated || epoch !== authEpoch) return false; // A single full snapshot seeds non-live diagnostics; subsequent updates // continue over SSE rather than polling full status on a timer. void refresh(true).catch(error => { if (authenticated && epoch === authEpoch && error?.status !== 401) toast(error.message, true); }); + return true; } async function loadAudit() { if (!authenticated) return; const epoch = authEpoch; try { const data = await api("/api/audit"); if (!authenticated || epoch !== authEpoch) return; const rows = data.entries.map(item => { const tr = el("tr"); for (const value of [formatDateTime(item.time * 1000), item.actor, item.action, item.detail]) { const td = el("td", value); td.title = value; tr.append(td); } return tr; }); $("audit-rows").replaceChildren(...rows); $("empty-audit").hidden = rows.length > 0; } catch (e) { toast(e.message, true); } } function tunnelMessage(value = "", error = false) { const node = $("tunnels-status"); if (!node) return; node.textContent = value; node.classList.toggle("error", !!error); } @@ -475,26 +476,3 @@ $("tunnels-dialog").addEventListener("close", stopTunnelRefresh); $("config-editor").addEventListener("input", () => setSettingsDirty("config-editor")); $("save-json").addEventListener("click", async event => { const editor = $("config-editor"), page = editor.closest("[data-settings-page]"); if (!envelope || busy) return; try { await withFormSubmitState(page, event.currentTarget, async () => { let config; try { config = JSON.parse(editor.value); } catch { throw fieldError(editor, t("vhJsonInvalid")); } if (await save(config)) setSettingsDirty("config-editor", false); }); } catch (e) { toast(e.message, true); } }); $("export-config").addEventListener("click", () => { const url = URL.createObjectURL(new Blob([JSON.stringify(envelope.config, null, 2) + "\n"], { type: "application/json" })); const a = el("a"); a.href = url; a.download = `RustProxy-config-r${envelope.revision}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }); -async function revalidateSession() { - const epoch = authEpoch; - try { - const info = await api("/api/auth/session"); - clearInvalidOriginRecovery(); - if (epoch !== authEpoch) return; - if (!authenticated) { await activate(info); return; } - sessionInfo = info; csrf = info.csrf_token; renderSession(); window.rustProxyRefresh?.sync(); - } catch (error) { if (recoverInvalidOrigin(error)) return; if (error.status !== 401) toast(error.message, true); } -} -window.addEventListener("pageshow", event => { if (event.persisted) void revalidateSession(); }); -function showStartupFailure(error) { - // Never expose a blank page or an incomplete workspace after startup fails. - // The authenticated shell keeps its own retry link; 401 still redirects to login. - $("login").hidden = true; - $("workspace").hidden = true; - $("logout").hidden = !authenticated; - document.documentElement.classList.add("app-loading", "app-startup-failed"); - $("app-startup-title").textContent = languagePacks.size ? t("startupFailed") : "Could not load RustProxy"; - const detail = $("app-startup-detail"); - detail.textContent = error?.message || String(error); - detail.hidden = false; -} diff --git a/src/admin/web/app/refresh.js b/src/admin/web/app/refresh.js index 2433c75..dd3ad8b 100644 --- a/src/admin/web/app/refresh.js +++ b/src/admin/web/app/refresh.js @@ -21,9 +21,10 @@ const rustProxyRefresh = (() => { const tasks = new Map(); let ready = false, options = { ...REFRESH_DEFAULTS }, panel = currentPanel; let settingsTab = "listeners", watchingVisibility = false; + let sessionReady = true, visibleResumeHandled = false; const visible = () => !document.hidden; function allowed(task) { - return ready && authenticated && visible() && options.live_enabled && + return ready && authenticated && sessionReady && visible() && options.live_enabled && task.interval() > 0 && (!task.active || task.active()); } function clear(task, abort = false) { @@ -39,7 +40,7 @@ const rustProxyRefresh = (() => { task.timer = setTimeout(() => { task.timer = null; void execute(task, false); }, delay); } function execute(task, manual) { - if (!authenticated || (!manual && !allowed(task))) return Promise.resolve(); + if (!authenticated || !sessionReady || (!manual && !allowed(task))) return Promise.resolve(); if (task.inFlight) { if (manual) task.queued = true; return task.inFlight; @@ -92,22 +93,56 @@ const rustProxyRefresh = (() => { } task.seconds = seconds; } - if (ready && authenticated && visible() && options.live_enabled) startLive(); + if (ready && authenticated && sessionReady && visible() && options.live_enabled) startLive(); else stopLive(); } - function onVisibility() { sync(); if (visible() && ready && authenticated) void revalidateSession(); } - function start() { - ready = true; panel = currentPanel; - settingsTab = window.rustProxySettings?.current?.() || "listeners"; - if (!watchingVisibility) { document.addEventListener("visibilitychange", onVisibility); watchingVisibility = true; } - sync(); - } - function stop() { - ready = false; + function suspendForSessionValidation() { + sessionReady = false; for (const task of tasks.values()) { clear(task, true); task.queued = false; task.enabled = false; } - if (watchingVisibility) { document.removeEventListener("visibilitychange", onVisibility); watchingVisibility = false; } + sync(); + } + function completeSessionValidation(success) { + sessionReady = !!success; + sync(); + } + function requestSessionResume() { + if (!ready || !authenticated || !visible() || visibleResumeHandled) return; + visibleResumeHandled = true; + void revalidateSession(); + } + function onVisibility() { + if (visible()) requestSessionResume(); + else { visibleResumeHandled = false; sync(); } + } + function onPageHide() { + visibleResumeHandled = false; + suspendForSessionValidation(); + } + function onPageShow(event) { if (event.persisted) requestSessionResume(); } + function start() { + ready = true; sessionReady = true; visibleResumeHandled = false; panel = currentPanel; + settingsTab = window.rustProxySettings?.current?.() || "listeners"; + if (!watchingVisibility) { + document.addEventListener("visibilitychange", onVisibility); + window.addEventListener("pagehide", onPageHide); + window.addEventListener("pageshow", onPageShow); + watchingVisibility = true; + } + sync(); + } + function stop() { + ready = false; sessionReady = false; visibleResumeHandled = false; + for (const task of tasks.values()) { + clear(task, true); task.queued = false; task.enabled = false; + } + if (watchingVisibility) { + document.removeEventListener("visibilitychange", onVisibility); + window.removeEventListener("pagehide", onPageHide); + window.removeEventListener("pageshow", onPageShow); + watchingVisibility = false; + } stopLive(); } function update(value) { options = normalizeRefreshPreferences(value); sync(); } @@ -151,6 +186,7 @@ const rustProxyRefresh = (() => { register("tunnels", { interval: () => 2, active: () => !!$("tunnels-dialog")?.open, run: async context => { await loadTunnels(context); } }); return { register, manual, start, stop, sync, update, setPanel, setSettingsTab, + suspendForSessionValidation, completeSessionValidation, current: () => ({ ...options }), diagnostics: () => [...tasks.values()].map(t => ({ id: t.id, enabled: t.enabled, inFlight: !!t.inFlight, scheduled: t.timer !== null })) }; })(); window.rustProxyRefresh = rustProxyRefresh; diff --git a/src/admin/web/app/runtime.js b/src/admin/web/app/runtime.js index 0227c47..ae81a87 100644 --- a/src/admin/web/app/runtime.js +++ b/src/admin/web/app/runtime.js @@ -21,12 +21,11 @@ async function startAdminPanel() { // app.js belongs to the authenticated shell and must not fall back to its // legacy embedded sign-in form when another startup step fails. const info = await api("/api/auth/session"); - clearInvalidOriginRecovery(); await loadLanguages(); setupRequired = false; translate(); syncAuthForm(); - await activate(info); + if (await activate(info)) clearInvalidOriginRecovery(); } catch (e) { if (recoverInvalidOrigin(e)) return; if (e.status === 401) { diff --git a/src/admin/web/app/session-resume.js b/src/admin/web/app/session-resume.js new file mode 100644 index 0000000..67e846b --- /dev/null +++ b/src/admin/web/app/session-resume.js @@ -0,0 +1,74 @@ +// One in-flight validation per authenticated document. Neither BFCache restore nor +// visibility resume may trigger a reload: a 403 is a recoverable, visible error. +function revalidateSession() { + if (!authenticated) return Promise.resolve(false); + if (sessionCheckPromise) return sessionCheckPromise; + const epoch = authEpoch; + window.rustProxyRefresh?.suspendForSessionValidation(); + const check = (async () => { + try { + const info = await api("/api/auth/session"); + if (!authenticated || epoch !== authEpoch) return false; + sessionInfo = info; + csrf = info.csrf_token; + renderSession(); + hideSessionValidationFailure(); + window.rustProxyRefresh?.completeSessionValidation(true); + return true; + } catch (error) { + // api() locks and navigates to the login document on a 401. + if (!authenticated || epoch !== authEpoch || error?.status === 401) return false; + window.rustProxyRefresh?.completeSessionValidation(false); + showSessionValidationFailure(error); + return false; + } + })(); + sessionCheckPromise = check; + void check.then( + () => { if (sessionCheckPromise === check) sessionCheckPromise = null; }, + () => { if (sessionCheckPromise === check) sessionCheckPromise = null; } + ); + return check; +} +function displayStartupError(error, sessionResume = false) { + $("login").hidden = true; + $("workspace").hidden = true; + $("logout").hidden = !authenticated; + document.documentElement.classList.add("app-loading", "app-startup-failed"); + document.documentElement.classList.toggle("app-session-failed", sessionResume); + $("app-startup-title").textContent = sessionResume + ? t("sessionValidationFailed") + : (languagePacks.size ? t("startupFailed") : "Could not load RustProxy"); + const detail = $("app-startup-detail"); + detail.textContent = error?.message || String(error); + detail.hidden = false; + $("app-startup-reload").hidden = sessionResume; + $("app-session-retry").hidden = !sessionResume; +} +function showSessionValidationFailure(error) { + // Keep the workspace inaccessible while its session cannot be verified. + displayStartupError(error, true); +} +function hideSessionValidationFailure() { + if (!document.documentElement.classList.contains("app-session-failed")) return; + document.documentElement.classList.remove("app-session-failed", "app-loading", "app-startup-failed"); + $("app-startup-detail").hidden = true; + $("app-startup-detail").textContent = ""; + $("app-startup-reload").hidden = false; + $("app-session-retry").hidden = true; + $("workspace").hidden = false; + $("logout").hidden = false; +} +$("app-session-retry").addEventListener("click", () => { + const button = $("app-session-retry"); + button.disabled = true; + void revalidateSession().then( + () => { button.disabled = false; }, + () => { button.disabled = false; } + ); +}); +function showStartupFailure(error) { + // All startup failure paths stop background tasks and show visible recovery. + window.rustProxyRefresh?.stop(); + displayStartupError(error); +} diff --git a/src/admin/web/index.html b/src/admin/web/index.html index 2ef9986..775b58e 100644 --- a/src/admin/web/index.html +++ b/src/admin/web/index.html @@ -23,7 +23,8 @@

The panel is taking longer than expected. Check your connection and try again.

- Reload panel + Reload panel +
diff --git a/src/admin/web/style.css b/src/admin/web/style.css index 00084bf..5b0dbd5 100644 --- a/src/admin/web/style.css +++ b/src/admin/web/style.css @@ -232,7 +232,7 @@ html:not(.app-loading) .app-startup { animation: app-startup-reveal 0s linear 12s forwards } -.app-startup-recovery .outline { +.app-startup-recovery .outline:not([hidden]) { margin-top: 10px; display: inline-flex } diff --git a/tools/checks/check_source.py b/tools/checks/check_source.py index e1eb3f2..b4263f1 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.2" +assert manifest["package"]["version"] == "0.31.3" 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.2"' in lock + assert 'name = "rust-proxy"\nversion = "0.31.3"' 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.2", - "CHANGELOG.md": "## 0.31.2", - "ROADMAP.md": "## 0.31.2", - "AGENTS.md": "Current source version: 0.31.2.", - "docs/TEST_REPORT.md": "# Verification report - RustProxy 0.31.2", + "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", }.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.2"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']: +for marker in ['"version": "0.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']: 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 @@ -542,7 +542,7 @@ for marker in ['"/api/config/{section}"', 'async fn update_section', 'section_up for route in ['/overview', '/vhosts', '/redirects', '/404-hosts', '/streams', '/acl-profiles', '/configuration', '/tls', '/cache', '/transform', '/logging', '/settings']: assert f'.route("{route}", get(public::index))' in admin, route app_js = read_app_js(root) -for marker in ['const PANEL_PATHS', 'history.pushState', 'window.addEventListener("popstate"', 'async function saveSection', 'async function revalidateSession', 'Object.values(PANEL_PATHS), "/configuration"', 'certificateCovers', 'covered_hosts', 'refreshCertificateInventory', 'certificateDetails', 'certificate-domain-pills', 'certificateIssuerName', 'certificateDaysRemaining', 'certificateRenewalStatus', 'recoverInvalidOrigin', 'auth.invalid_origin', 'ORIGIN_RECOVERY_KEY']: +for marker in ['const PANEL_PATHS', 'history.pushState', 'window.addEventListener("popstate"', 'async function saveSection', 'function revalidateSession', 'Object.values(PANEL_PATHS), "/configuration"', 'certificateCovers', 'covered_hosts', 'refreshCertificateInventory', 'certificateDetails', 'certificate-domain-pills', 'certificateIssuerName', 'certificateDaysRemaining', 'certificateRenewalStatus', 'recoverInvalidOrigin', 'auth.invalid_origin', 'ORIGIN_RECOVERY_KEY']: assert marker in app_js, marker for marker in ['.certificate-details', '.certificate-domain-pills', '.certificate-card .pill', '.certificate-operation-card', '.renewal-meta', '#panel-tls .certificate-tabs']: assert marker in css, marker @@ -862,12 +862,10 @@ for marker in [ for forbidden in ["pub struct AllowCookie", "private_or_no_transform"]: assert forbidden not in compression_source, forbidden compression_finalize_source = rust_source(root, "src/proxy/upstream_helpers.rs") -for marker in [ - "let global_compression = &snapshot.envelope.config.compression", - "frontend.compression.resolve(global_compression, &compression)", - "service.compression.resolve(global_compression, &compression)", -]: - assert marker in compression_finalize_source, marker +assert "let global_compression = &snapshot.envelope.config.compression" in compression_finalize_source +for selector in ("frontend", "service"): + # rustfmt splits member chains across lines; check the expression, not its layout. + assert re.search(rf"\b{selector}\s*\.\s*compression\s*\.\s*resolve\(global_compression, &compression\)", compression_finalize_source), selector assert "AllowCookie" not in compression_finalize_source openapi_0270 = json.loads((root / "src/admin/openapi.json").read_text()) compression_override_properties = openapi_0270["components"]["schemas"]["CompressionOverrides"]["properties"] @@ -1396,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.2"' in openapi +assert '"RuntimeInformation"' in openapi and '"version": "0.31.3"' in openapi # 0.24.1 compiler and hostname-cardinality regressions. tls_0241 = rust_source(root, "src/tls.rs") @@ -1635,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.2 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v9 migrations.") +print("PASS: 0.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("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.") diff --git a/tools/checks/dashboard_startup_0310_ui_test.mjs b/tools/checks/dashboard_startup_0310_ui_test.mjs index f76f498..3ad0038 100644 --- a/tools/checks/dashboard_startup_0310_ui_test.mjs +++ b/tools/checks/dashboard_startup_0310_ui_test.mjs @@ -12,13 +12,14 @@ const html = read('src/admin/web/index.html'); const css = read('src/admin/web/style.css'); const core = read('src/admin/web/app/core.js'); const runtime = read('src/admin/web/app/runtime.js'); +const resume = read('src/admin/web/app/session-resume.js'); assert.match(html, /]*role="status"/); assert.match(html, /id="app-startup-title"/); assert.match(html, /class="app-startup-spinner"/); assert.match(html, /class="app-startup-recovery"/); -assert.match(html, /class="outline" href="" data-i18n="startupRetry"/); +assert.match(html, /class="outline" id="app-startup-reload" href="" data-i18n="startupRetry"/); assert.match(html, /