v0.31.3
This commit is contained in:
1 parent
0facfe6f5c
commit
95c55da2ee
39 files changed
+715
-151
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes.
|
||||
The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for
|
||||
compatibility. Current source version: 0.31.2.
|
||||
compatibility. Current source version: 0.31.3.
|
||||
|
||||
## Delivery
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+1
-1
@@ -1881,7 +1881,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rust-proxy"
|
||||
version = "0.31.2"
|
||||
version = "0.31.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+46
-43
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
+2
-1
@@ -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.
|
||||
|
||||
|
||||
+13
-1
@@ -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)
|
||||
|
||||
|
||||
Regular → Executable
File mode changed.
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
(
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -35,6 +35,7 @@ fn admin_app_javascript() -> &'static str {
|
||||
static ASSET: std::sync::LazyLock<String> = 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"),
|
||||
|
||||
+112
-3
@@ -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::<http::uri::Authority>().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::<http::Uri>() 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("<!doctype html><html lang=\"en\"><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>RustProxy - Access blocked</title><h1>RustProxy - Could not open the panel</h1><p>The request origin was rejected. Check the proxy/URL and try again.</p><p><a href=\"\">Reload panel</a></p></html>"),
|
||||
).into_response());
|
||||
}
|
||||
secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin"))
|
||||
}
|
||||
|
||||
async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next) -> Response {
|
||||
let transport = request
|
||||
.extensions()
|
||||
@@ -10,9 +115,13 @@ async fn guard(State(state): State<Arc<App>>, 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<Arc<App>>, 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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -23,7 +23,8 @@
|
||||
<div class="app-startup-recovery">
|
||||
<p data-i18n="startupSlow">The panel is taking longer than expected. Check your connection and try again.</p>
|
||||
<p id="app-startup-detail" class="app-startup-detail" hidden></p>
|
||||
<a class="outline" href="" data-i18n="startupRetry">Reload panel</a>
|
||||
<a class="outline" id="app-startup-reload" href="" data-i18n="startupRetry">Reload panel</a>
|
||||
<button class="outline" type="button" id="app-session-retry" data-i18n="sessionRetry" hidden>Retry connection</button>
|
||||
</div>
|
||||
<noscript>JavaScript is required to use the administration panel.</noscript>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
@@ -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, /<html class="app-loading"/);
|
||||
assert.match(html, /id="app-startup"[^>]*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, /<noscript>/);
|
||||
assert.doesNotMatch(css, /html\.app-loading\s+body\s*\{\s*visibility:\s*hidden/);
|
||||
assert.match(css, /html\.app-loading \.shell/);
|
||||
@@ -37,13 +38,13 @@ assert.match(css, /\.page-intro-title-row \.overview-refresh/);
|
||||
|
||||
// Exercise the real activate() and failure handler against a small DOM fake.
|
||||
const activateSource = core.slice(core.indexOf('async function activate(info) {'), core.indexOf('async function loadAudit()'));
|
||||
const failureSource = core.slice(core.indexOf('function showStartupFailure(error)'));
|
||||
const failureSource = resume.slice(resume.indexOf('function displayStartupError(error, sessionResume = false)'));
|
||||
assert(activateSource.startsWith('async function activate(info)'));
|
||||
assert(failureSource.startsWith('function showStartupFailure(error)'));
|
||||
assert(failureSource.startsWith('function displayStartupError(error, sessionResume = false)'));
|
||||
|
||||
const nodes = new Map();
|
||||
const node = key => {
|
||||
if (!nodes.has(key)) nodes.set(key, { hidden: true, value: 'x', textContent: '' });
|
||||
if (!nodes.has(key)) nodes.set(key, { hidden: true, value: 'x', textContent: '', addEventListener() {} });
|
||||
return nodes.get(key);
|
||||
};
|
||||
const classes = new Set(['app-loading']);
|
||||
@@ -55,6 +56,8 @@ const context = {
|
||||
document: { documentElement: { classList: {
|
||||
add(...keys) { for (const key of keys) classes.add(key); },
|
||||
remove(...keys) { for (const key of keys) classes.delete(key); },
|
||||
toggle(key, force) { if (force) classes.add(key); else classes.delete(key); },
|
||||
contains(key) { return classes.has(key); },
|
||||
} } },
|
||||
pendingAuthDestination: null,
|
||||
authMessage() {},
|
||||
@@ -65,7 +68,7 @@ const context = {
|
||||
loadConfig: () => { configCount++; return configPromise; },
|
||||
refresh: async () => { statusCount++; },
|
||||
toast() { throw new Error('Unexpected toast'); },
|
||||
window: {}, languagePacks: new Map(), t: key => key,
|
||||
window: { rustProxyRefresh: { stop() {} } }, languagePacks: new Map(), t: key => key,
|
||||
};
|
||||
runInNewContext(`${activateSource}\n${failureSource}`, context);
|
||||
const activating = context.activate({ username: 'admin', csrf_token: 'CSRF' });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the 0.31.2 dependency/source migration; optionally require a resolved lock.
|
||||
"""Check the 0.31.3 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.2'
|
||||
assert manifest['package']['version'] == '0.31.3'
|
||||
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.2 source dependency APIs, manifest, TLS features, toolchain images and Swagger pin (static only)')
|
||||
print('PASS: 0.31.3 source dependency APIs, manifest, TLS features, toolchain images and Swagger pin (static only)')
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rust-independent documentation/Reader consistency checks for the 0.31.2 release."""
|
||||
"""Rust-independent documentation/Reader consistency checks for the 0.31.3 release."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
from rust_source import rust_source
|
||||
@@ -65,7 +65,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.2")
|
||||
assert readme.startswith("# RustProxy 0.31.3")
|
||||
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
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env node
|
||||
// Real-source simulation of mobile tab restore, BFCache, Origin 403 and retry.
|
||||
// This does not replace a real Safari/Chrome session or a compiled Rust backend.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const read = path => readFileSync(resolve(root, path), 'utf8');
|
||||
const core = read('src/admin/web/app/core.js');
|
||||
const refresh = read('src/admin/web/app/refresh.js');
|
||||
const resume = read('src/admin/web/app/session-resume.js');
|
||||
const runtime = read('src/admin/web/app/runtime.js');
|
||||
const html = read('src/admin/web/index.html');
|
||||
const server = read('src/admin/session.rs');
|
||||
assert.match(html, /id="app-session-retry"[^>]*data-i18n="sessionRetry"/);
|
||||
assert.match(server, /origin_mismatch/);
|
||||
assert.match(server, /cross_site_fetch/);
|
||||
assert.match(server, /administration origin rejected/);
|
||||
|
||||
function sourceBetween(source, start, end) {
|
||||
const a = source.indexOf(start), b = source.indexOf(end, a);
|
||||
assert(a >= 0 && b > a, `Missing source slice: ${start}`);
|
||||
return source.slice(a, b);
|
||||
}
|
||||
const actualApi = sourceBetween(core, 'function apiArea(path)', 'function setPanel(name');
|
||||
const actualLive = sourceBetween(core, 'function renderLiveStatus(value)', 'async function refreshCertificateInventory()');
|
||||
const actualResume = resume;
|
||||
const recovery = sourceBetween(core, 'const ORIGIN_RECOVERY_KEY', 'const PANEL_PATHS');
|
||||
const startup = sourceBetween(runtime, 'function verifyAdminModules()', 'function certificateUsesExternalFiles');
|
||||
|
||||
function fixture() {
|
||||
const events = new Map(), windows = new Map(), pending = [], streams = [], nodes = new Map(), timers = new Map();
|
||||
const classes = new Set(), originMarker = new Map();
|
||||
let timerId = 0, reloads = 0, lockCount = 0, renderSessionCalls = 0;
|
||||
const node = id => {
|
||||
if (!nodes.has(id)) {
|
||||
const handlers = new Map();
|
||||
nodes.set(id, { hidden: id !== 'workspace', disabled: false, textContent: '',
|
||||
addEventListener(type, fn) { handlers.set(type, fn); },
|
||||
click() { handlers.get('click')?.(); },
|
||||
});
|
||||
}
|
||||
return nodes.get(id);
|
||||
};
|
||||
class FakeEventSource {
|
||||
constructor(url) { this.url = url; this.closed = false; streams.push(this); }
|
||||
addEventListener() {}
|
||||
close() { this.closed = true; }
|
||||
}
|
||||
const classList = {
|
||||
contains(k) { return classes.has(k); },
|
||||
add(...keys) { keys.forEach(k => classes.add(k)); },
|
||||
remove(...keys) { keys.forEach(k => classes.delete(k)); },
|
||||
toggle(k, forced) { if (forced) classes.add(k); else classes.delete(k); },
|
||||
};
|
||||
const document = {
|
||||
hidden: false, body: {}, documentElement: { classList },
|
||||
addEventListener(name, handler) { assert(!events.has(name)); events.set(name, handler); },
|
||||
removeEventListener(name, handler) { assert.equal(events.get(name), handler); events.delete(name); },
|
||||
};
|
||||
const window = {
|
||||
rustProxySettings: { current: () => 'listeners', render() {} },
|
||||
addEventListener(name, handler) { assert(!windows.has(name)); windows.set(name, handler); },
|
||||
removeEventListener(name, handler) { assert.equal(windows.get(name), handler); windows.delete(name); },
|
||||
};
|
||||
const ctx = {
|
||||
document, window, $: node, AbortController, EventSource: FakeEventSource,
|
||||
MutationObserver: class { observe() {} },
|
||||
location: { origin: 'https://panel.example', pathname: '/overview', search: '',
|
||||
reload() { reloads++; }, replace() { throw new Error('Unexpected replace'); } },
|
||||
sessionStorage: { getItem: key => originMarker.get(key), setItem: (key, value) => originMarker.set(key, value), removeItem: key => originMarker.delete(key) },
|
||||
performance: { now: () => 1 },
|
||||
fetch: (path, options) => new Promise((resolve, reject) => pending.push({ path, options, resolve, reject })),
|
||||
setTimeout(fn, ms) { const id = ++timerId; timers.set(id, { fn, ms }); return id; },
|
||||
clearTimeout(id) { timers.delete(id); },
|
||||
console: { warn() {} },
|
||||
csrf: 'old', authenticated: true, authEpoch: 1, sessionInfo: null, sessionCheckPromise: null,
|
||||
languagePacks: new Map([['en_US', {}]]), latestStatus: null,
|
||||
currentPanel: 'overview', liveEvents: null, liveInterval: null, liveConnected: false, liveFallback: false,
|
||||
t: key => key === 'sessionValidationFailed' ? 'Could not verify your session' : key,
|
||||
renderSession() { renderSessionCalls++; },
|
||||
renderFooter() {}, renderStatus() {}, renderLogs() {}, renderDiagnostics() {},
|
||||
toast() { throw new Error('Resume errors must not be transient toasts'); },
|
||||
loadTunnels: async () => {},
|
||||
lock() { lockCount++; ctx.authEpoch++; ctx.authenticated = false; ctx.window.rustProxyRefresh.stop(); },
|
||||
};
|
||||
runInNewContext(`${recovery}\n${actualApi}\n${actualLive}\n${actualResume}\n${refresh}`, ctx,
|
||||
{ filename: 'actual-rustproxy-admin-source.js' });
|
||||
const tick = async () => { for (let i = 0; i < 25; i++) await Promise.resolve(); };
|
||||
const answer = (status, value) => {
|
||||
const request = pending.shift();
|
||||
assert(request && request.path === '/api/auth/session', `Unexpected request: ${request?.path}`);
|
||||
request.resolve({ ok: status >= 200 && status < 300, status, text: async () => JSON.stringify(value) });
|
||||
return request;
|
||||
};
|
||||
const visibility = hidden => { document.hidden = hidden; events.get('visibilitychange')?.(); };
|
||||
const pagehide = () => windows.get('pagehide')?.({});
|
||||
const pageshow = persisted => windows.get('pageshow')?.({ persisted });
|
||||
const activeStreams = () => streams.filter(stream => !stream.closed);
|
||||
return { ctx, events, windows, pending, streams, timers, originMarker, nodes, classes, tick, answer,
|
||||
visibility, pagehide, pageshow, activeStreams, node, get reloads() { return reloads; },
|
||||
get lockCount() { return lockCount; }, get renderSessionCalls() { return renderSessionCalls; } };
|
||||
}
|
||||
|
||||
const normal = fixture();
|
||||
normal.ctx.window.rustProxyRefresh.start();
|
||||
assert.equal(normal.activeStreams().length, 1, 'authenticated shell starts one SSE stream');
|
||||
normal.visibility(true);
|
||||
assert.equal(normal.activeStreams().length, 0, 'hidden document pauses SSE');
|
||||
normal.visibility(false);
|
||||
normal.pageshow(true);
|
||||
assert.equal(normal.pending.length, 1, 'pageshow and visibilitychange share one session GET');
|
||||
assert.equal(normal.activeStreams().length, 0, 'SSE cannot resume before session verification');
|
||||
assert.equal(normal.ctx.window.rustProxyRefresh.diagnostics().filter(task => task.scheduled).length, 0);
|
||||
normal.answer(403, { error: 'auth.invalid_origin' }); await normal.tick();
|
||||
assert.equal(normal.reloads, 0, '403 during resume must not reload');
|
||||
assert.equal(normal.originMarker.size, 0, 'resume must not update startup reload limit');
|
||||
assert(normal.classes.has('app-loading') && normal.classes.has('app-startup-failed'));
|
||||
assert(normal.classes.has('app-session-failed'), 'show an actionable resume failure');
|
||||
assert.equal(normal.node('workspace').hidden, true);
|
||||
assert.equal(normal.node('app-session-retry').hidden, false);
|
||||
assert.equal(normal.node('app-startup-reload').hidden, true);
|
||||
assert.equal(normal.node('app-startup-title').textContent, 'Could not verify your session');
|
||||
assert.equal(normal.activeStreams().length, 0);
|
||||
assert.equal(normal.ctx.window.rustProxyRefresh.diagnostics().filter(task => task.scheduled).length, 0);
|
||||
normal.pageshow(true);
|
||||
assert.equal(normal.pending.length, 0, 'no automatic error-loop retries on duplicate resume events');
|
||||
normal.node('app-session-retry').click();
|
||||
assert.equal(normal.pending.length, 1, 'user may explicitly retry without a reload');
|
||||
assert.equal(normal.node('app-session-retry').disabled, true);
|
||||
normal.answer(200, { username: 'admin', csrf_token: 'fresh' }); await normal.tick();
|
||||
assert.equal(normal.ctx.csrf, 'fresh');
|
||||
assert.equal(normal.node('workspace').hidden, false);
|
||||
assert.equal(normal.classes.has('app-loading'), false);
|
||||
assert.equal(normal.node('app-session-retry').hidden, true);
|
||||
assert.equal(normal.node('app-session-retry').disabled, false);
|
||||
assert.equal(normal.activeStreams().length, 1, 'SSE resumes only after verified session');
|
||||
assert.equal(normal.reloads, 0);
|
||||
|
||||
// A real 401 follows the existing lock/redirect flow, never displaying stale data.
|
||||
normal.visibility(true);
|
||||
normal.visibility(false);
|
||||
assert.equal(normal.pending.length, 1);
|
||||
normal.answer(401, { error: 'auth.required' }); await normal.tick();
|
||||
assert.equal(normal.lockCount, 1);
|
||||
assert.equal(normal.activeStreams().length, 0);
|
||||
assert.equal(normal.classes.has('app-session-failed'), false);
|
||||
|
||||
const restored = fixture();
|
||||
restored.ctx.window.rustProxyRefresh.start();
|
||||
restored.pagehide();
|
||||
assert.equal(restored.activeStreams().length, 0);
|
||||
restored.pageshow(true);
|
||||
restored.visibility(false);
|
||||
assert.equal(restored.pending.length, 1, 'BFCache pagehide/pageshow + visibility triggers one session check');
|
||||
restored.answer(200, { username: 'admin', csrf_token: 'bfcache' }); await restored.tick();
|
||||
assert.equal(restored.activeStreams().length, 1);
|
||||
// Ten tab cycles must not add listeners or duplicate SSE sessions.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
restored.visibility(true); restored.visibility(false); restored.pageshow(true);
|
||||
assert.equal(restored.pending.length, 1);
|
||||
assert.equal(restored.activeStreams().length, 0);
|
||||
restored.answer(200, { username: 'admin', csrf_token: `cycle-${i}` }); await restored.tick();
|
||||
assert.equal(restored.activeStreams().length, 1);
|
||||
assert.equal(restored.windows.size, 2);
|
||||
assert.equal(restored.events.size, 1);
|
||||
}
|
||||
// A response from a prior auth epoch may not restore the dashboard.
|
||||
restored.visibility(true); restored.visibility(false);
|
||||
assert.equal(restored.pending.length, 1);
|
||||
restored.ctx.authEpoch++;
|
||||
restored.ctx.authenticated = false;
|
||||
restored.ctx.window.rustProxyRefresh.stop();
|
||||
restored.answer(200, { username: 'stale', csrf_token: 'stale' }); await restored.tick();
|
||||
assert.equal(restored.activeStreams().length, 0);
|
||||
assert.equal(restored.ctx.csrf, 'cycle-9');
|
||||
assert.equal(restored.windows.size, 0);
|
||||
assert.equal(restored.events.size, 0);
|
||||
|
||||
// Startup reload-marker is released only after the complete panel activation,
|
||||
// never merely when /api/auth/session happened to return successfully.
|
||||
async function checkStartup(activationResult, failLanguage) {
|
||||
let bootstrap, clears = 0, failures = 0;
|
||||
const modules = Object.fromEntries([
|
||||
['rustProxyIntegrations', 'bindings'], ['rustProxyVhosts', 'render'], ['rustProxySettings', 'render'],
|
||||
['rustProxyCachePanel', 'refresh'], ['rustProxyTrafficPanel', 'refresh'],
|
||||
['rustProxyPreferences', 'load'], ['rustProxyRefresh', 'start'],
|
||||
].map(([name, method]) => [name, { [method]() {} }]));
|
||||
const ctx = {
|
||||
window: modules, document: { readyState: 'loading', addEventListener(_name, cb) { bootstrap = cb; } },
|
||||
api: async () => ({ csrf_token: 'valid' }), loadLanguages: async () => { if (failLanguage) throw new Error('Language assets missing'); },
|
||||
translate() {}, setupRequired: false, syncAuthForm() {}, activate: async () => activationResult,
|
||||
clearInvalidOriginRecovery() { clears++; }, recoverInvalidOrigin: () => false,
|
||||
showStartupFailure() { failures++; },
|
||||
location: { pathname: '/overview', search: '', replace() { throw new Error('unexpected redirect'); } },
|
||||
};
|
||||
runInNewContext(startup, ctx);
|
||||
bootstrap(); for (let i = 0; i < 12; i++) await Promise.resolve();
|
||||
return { clears, failures };
|
||||
}
|
||||
assert.deepEqual(await checkStartup(true, false), { clears: 1, failures: 0 });
|
||||
assert.deepEqual(await checkStartup(false, false), { clears: 0, failures: 0 });
|
||||
assert.deepEqual(await checkStartup(true, true), { clears: 0, failures: 1 });
|
||||
console.log('PASS: 0.31.3 real-source resume race, 403 retry, BFCache, 401 lock, SSE pause, stale epochs, startup recovery limit and repeated cycles');
|
||||
@@ -73,12 +73,12 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
|
||||
flat = module.parent.with_suffix(".rs")
|
||||
if flat.is_file():
|
||||
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/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "docs/TODO.md", "docs/V0.25.5_AUDIT.md", "docs/V0.25.4_AUDIT.md", "docs/V0.25.3_AUDIT.md", "docs/V0.25.2_AUDIT.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "tools/checks/docs_check.py", "tools/checks/web_server_contracts.py", "tools/checks/rust_source.py", "tools/checks/rust_module_contracts.py", "tools/checks/source_module_contracts.py", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "grafana/rustproxy-overview.json"]:
|
||||
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/vhost/error_fallback.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/session-resume.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "docs/TODO.md", "docs/V0.25.5_AUDIT.md", "docs/V0.25.4_AUDIT.md", "docs/V0.25.3_AUDIT.md", "docs/V0.25.2_AUDIT.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "tools/checks/docs_check.py", "tools/checks/web_server_contracts.py", "tools/checks/rust_source.py", "tools/checks/rust_module_contracts.py", "tools/checks/source_module_contracts.py", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "grafana/rustproxy-overview.json"]:
|
||||
if not (root / path).is_file():
|
||||
raise ValueError(f"Incomplete RustProxy 0.31.2 source tree: missing {path}")
|
||||
raise ValueError(f"Incomplete RustProxy 0.31.3 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.2 entry point; extract the complete package, not individual files.")
|
||||
raise ValueError("src/lib.rs is not the 0.31.3 entry point; extract the complete package, not individual files.")
|
||||
return messages
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ assert.match(bridge, /function startLive\(/);
|
||||
|
||||
function fixture({ withoutSse = false } = {}) {
|
||||
let time = 0, nextId = 1, renderCount = 0, logCount = 0, diagCount = 0;
|
||||
const timers = new Map(), listeners = new Map(), streams = [], requests = [], rendered = [], failures = [];
|
||||
const timers = new Map(), listeners = new Map(), windowListeners = new Map(), streams = [], requests = [], rendered = [], failures = [];
|
||||
let response = async () => ({});
|
||||
class FakeEventSource {
|
||||
constructor(url) { this.url = url; this.closed = false; this.handlers = new Map(); streams.push(this); }
|
||||
@@ -37,7 +37,11 @@ function fixture({ withoutSse = false } = {}) {
|
||||
console: { warn(...args) { failures.push(args); } },
|
||||
authenticated: true, authEpoch: 1, currentPanel: "overview", liveConnected: false, liveFallback: false,
|
||||
liveEvents: null, liveInterval: null, latestStatus: null,
|
||||
window: { rustProxySettings: { current: () => "listeners", render: () => undefined } },
|
||||
window: {
|
||||
rustProxySettings: { current: () => "listeners", render: () => undefined },
|
||||
addEventListener(type, callback) { assert(!windowListeners.has(type)); windowListeners.set(type, callback); },
|
||||
removeEventListener(type, callback) { assert.equal(windowListeners.get(type), callback); windowListeners.delete(type); },
|
||||
},
|
||||
$: name => nodes[name] || null,
|
||||
t: name => name,
|
||||
api: (url, args = {}) => { requests.push({ url, args, time }); return response(url, args); },
|
||||
@@ -45,7 +49,11 @@ function fixture({ withoutSse = false } = {}) {
|
||||
renderLogs() { logCount++; },
|
||||
renderDiagnostics() { diagCount++; },
|
||||
renderLiveStatus() { throw new Error("bridge not loaded"); },
|
||||
revalidateSession: async () => undefined,
|
||||
revalidateSession: async () => {
|
||||
ctx.window.rustProxyRefresh.suspendForSessionValidation();
|
||||
await Promise.resolve();
|
||||
ctx.window.rustProxyRefresh.completeSessionValidation(true);
|
||||
},
|
||||
loadTunnels: async () => undefined
|
||||
};
|
||||
runInNewContext(bridge + "\n" + scheduler, ctx, { filename: "coordinator-browser-bundle.js" });
|
||||
@@ -76,7 +84,7 @@ function fixture({ withoutSse = false } = {}) {
|
||||
}
|
||||
}
|
||||
return {
|
||||
ctx, refresh, requests, rendered, streams, listeners, timers, failures, advance, switchPanel, count,
|
||||
ctx, refresh, requests, rendered, streams, listeners, windowListeners, timers, failures, advance, switchPanel, count,
|
||||
registerViews, changeVisibility,
|
||||
setResponse(fn) { response = fn; },
|
||||
get renderCount() { return renderCount; },
|
||||
@@ -152,7 +160,7 @@ assert(h.streams.at(-1).closed, "SSE closes when hidden");
|
||||
assert.equal(h.refresh.diagnostics().filter(row => row.scheduled).length, 0);
|
||||
await h.advance(120_000);
|
||||
assert.equal(h.count("/api/traffic"), beforeHidden);
|
||||
h.changeVisibility(false); await h.advance();
|
||||
h.changeVisibility(false); await h.advance(); await h.advance();
|
||||
assert.equal(h.count("/api/traffic"), beforeHidden + 1);
|
||||
assert.equal(h.streams.filter(stream => !stream.closed).length, 1);
|
||||
|
||||
@@ -206,6 +214,7 @@ assert.throws(() => h.refresh.register("traffic", { interval: () => 5, run: asyn
|
||||
// Logout clears timers, active SSE streams, observer and pending fetches.
|
||||
h.refresh.stop();
|
||||
assert.equal(h.listeners.has("visibilitychange"), false);
|
||||
assert.equal(h.windowListeners.size, 0, "pagehide/pageshow listeners are removed on logout");
|
||||
assert.equal(h.streams.filter(stream => !stream.closed).length, 0);
|
||||
assert.equal(h.refresh.diagnostics().filter(row => row.scheduled).length, 0);
|
||||
const afterLogout = h.requests.length;
|
||||
|
||||
@@ -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.2"' in (target / "Cargo.toml").read_text(encoding="utf-8")
|
||||
assert 'version = "0.31.3"' 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.2"
|
||||
assert manifest["to_version"] == "0.31.3"
|
||||
assert manifest["removed_obsolete_runtime_state"] is True
|
||||
assert not legacy.exists()
|
||||
assert not (backup / "legacy-data").exists()
|
||||
@@ -463,7 +463,7 @@ def ui_contract_smoke() -> None:
|
||||
assert 'viewBox: "0 0 300 60"' in app and 'M0 15H300 M0 30H300 M0 45H300' in app
|
||||
assert '.network-interface-row {' in css and 'background: transparent' in css
|
||||
assert "hostTagEditor" in vhosts and "vhost-chart-line" in vhosts and "vhLifetimeChart" in vhosts
|
||||
for marker in ["refreshCertificateInventory", "certificateDetails", "certificate-domain-pills", "certificateCoverageUnknown", "async function revalidateSession", 'Object.values(PANEL_PATHS), "/configuration"', "recoverInvalidOrigin", "auth.invalid_origin", "ORIGIN_RECOVERY_KEY"]:
|
||||
for marker in ["refreshCertificateInventory", "certificateDetails", "certificate-domain-pills", "certificateCoverageUnknown", "function revalidateSession", 'Object.values(PANEL_PATHS), "/configuration"', "recoverInvalidOrigin", "auth.invalid_origin", "ORIGIN_RECOVERY_KEY"]:
|
||||
assert marker in app, marker
|
||||
for marker in ['data-certificate-tab="installed"', 'data-certificate-tab="renewal"', 'data-certificate-tab="generate"', 'id="certificate-page-installed"', 'id="certificate-page-renewal"', 'id="certificate-page-generate"']:
|
||||
assert marker in html, marker
|
||||
@@ -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.2"' in openapi_0241
|
||||
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.31.3"' 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.2"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
|
||||
for marker in ['"version": "0.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"']:
|
||||
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.2 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
|
||||
print("PASS: 0.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.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -35,7 +35,7 @@ for directory in ("scripts/runtime", "scripts/build", "scripts/deploy", "scripts
|
||||
for old in (ROOT / "src/admin/web/app.js", ROOT / "src/admin/web/vhosts.js"):
|
||||
assert not old.exists(), f"oversized browser monolith returned: {old.relative_to(ROOT)}"
|
||||
for required in (
|
||||
"src/admin/web/app/core.js", "src/admin/web/app/preferences.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js",
|
||||
"src/admin/web/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/vhosts/core.js", "src/admin/web/vhosts/health.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js",
|
||||
"docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go",
|
||||
"docker-agent/certificate.go", "docker-agent/server.go",
|
||||
|
||||
@@ -13,7 +13,7 @@ const has = (haystack, needle) => assert.ok(haystack.includes(needle), `Missing
|
||||
|
||||
// The two mini charts share the exact H1/H2/H3 palette as their SVG lines.
|
||||
for (const version of ['h1', 'h2', 'h3']) {
|
||||
has(css, `.stat-chart .stat-chart-legend .legend-${version} i { background: var(--traffic-${version}); }`);
|
||||
assert.match(css, new RegExp(`\\.stat-chart\\s+\\.stat-chart-legend\\s+\\.legend-${version}\\s+i\\s*\\{\\s*background:\\s*var\\(--traffic-${version}\\);`));
|
||||
assert.match(css, new RegExp(`\\.proto-${version}-line\\s*\\{[^}]*stroke:\\s*var\\(--traffic-${version}\\);`));
|
||||
has(html, `id="http-protocol-${version}"`);
|
||||
has(html, `id="ws-protocol-${version}"`);
|
||||
@@ -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.2', uptime_seconds: 4201,
|
||||
version: '0.31.3', 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.2');
|
||||
assert.equal(dollar('runtime-version').textContent, '0.31.3');
|
||||
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.2 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.');
|
||||
console.log('PASS: 0.31.3 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.');
|
||||
@@ -75,7 +75,7 @@ check(prepared.includes('response = intercept_route_origin_error(response, route
|
||||
const marker = fs.readFileSync('src/proxy/error_fallback.rs','utf8');
|
||||
check(marker.includes('response.extensions_mut().insert(crate::pages::OriginProvidedResponse)'), 'Origin responses must be marked for the outer observer');
|
||||
const observer = fs.readFileSync('src/proxy/upstream_helpers.rs','utf8');
|
||||
check(observer.includes('response.extensions().get::<crate::pages::OriginProvidedResponse>().is_none()'), 'Outer response observer must not unconditionally replace origin 404/5xx');
|
||||
check(/fn should_replace_error_page\(/.test(observer) && /get::<crate::pages::OriginProvidedResponse>\(\)\s*\.is_none\(\)/.test(observer) && /if should_replace_error_page\(&response, minimal_welcome\)/.test(observer), 'Outer response observer must not unconditionally replace origin 404/5xx');
|
||||
const pages = fs.readFileSync('src/pages.rs','utf8');
|
||||
check(pages.includes('body_only.status = None;'), 'Application error interception must preserve the upstream HTTP code even when page overrides status');
|
||||
const interceptPosition = prepared.indexOf('response = intercept_route_origin_error(response, route, &snapshot);');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from pathlib import Path
|
||||
|
||||
APP_PARTS = ("core.js", "refresh.js", "preferences.js", "config-history.js", "runtime.js", "settings.js")
|
||||
APP_PARTS = ("core.js", "session-resume.js", "refresh.js", "preferences.js", "config-history.js", "runtime.js", "settings.js")
|
||||
PANEL_PARTS = (
|
||||
"management.js", "drafts.js", "integrations.js", "vhosts.js",
|
||||
"web-server-profiles.js", "web-server.js", "docker.js", "streams.js",
|
||||
|
||||
@@ -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.2", "accept": "*/*"}
|
||||
headers = {"user-agent": "RustProxy-Memory-Stress/0.31.3", "accept": "*/*"}
|
||||
if host_header:
|
||||
headers["host"] = host_header
|
||||
|
||||
|
||||
Reference in new issue
Block a user